Skip to content

Commit f560f5c

Browse files
committed
feat: 전역 예외 처리 구조 추가
1 parent 3eff751 commit f560f5c

4 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.mogumogu.momogo.global.error
2+
3+
import org.springframework.http.HttpStatus
4+
import org.springframework.http.ProblemDetail
5+
import org.springframework.web.ErrorResponseException
6+
7+
sealed class ApiException protected constructor(
8+
status: HttpStatus,
9+
val errorCode: ErrorCode,
10+
detail: String,
11+
) : ErrorResponseException(
12+
status,
13+
ProblemDetail.forStatusAndDetail(status, detail).apply {
14+
title = status.reasonPhrase
15+
},
16+
null,
17+
) {
18+
19+
class BadRequest(
20+
errorCode: ErrorCode,
21+
detail: String = errorCode.message,
22+
) : ApiException(
23+
status = HttpStatus.BAD_REQUEST,
24+
errorCode = errorCode,
25+
detail = detail,
26+
)
27+
28+
class NotFound(
29+
errorCode: ErrorCode,
30+
detail: String = errorCode.message,
31+
) : ApiException(
32+
status = HttpStatus.NOT_FOUND,
33+
errorCode = errorCode,
34+
detail = detail,
35+
)
36+
37+
class Conflict(
38+
errorCode: ErrorCode,
39+
detail: String = errorCode.message,
40+
) : ApiException(
41+
status = HttpStatus.CONFLICT,
42+
errorCode = errorCode,
43+
detail = detail,
44+
)
45+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.mogumogu.momogo.global.error
2+
3+
enum class ErrorCode(
4+
val message: String,
5+
) {
6+
INVALID_REQUEST(
7+
message = "요청 값이 올바르지 않습니다.",
8+
),
9+
RESOURCE_NOT_FOUND(
10+
message = "요청한 리소스를 찾을 수 없습니다.",
11+
),
12+
INTERNAL_SERVER_ERROR(
13+
message = "서버 내부 오류가 발생했습니다.",
14+
),
15+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.mogumogu.momogo.global.error
2+
3+
import jakarta.servlet.http.HttpServletRequest
4+
import org.slf4j.LoggerFactory
5+
import org.springframework.http.HttpHeaders
6+
import org.springframework.http.HttpStatus
7+
import org.springframework.http.HttpStatusCode
8+
import org.springframework.http.ProblemDetail
9+
import org.springframework.http.ResponseEntity
10+
import org.springframework.web.bind.MethodArgumentNotValidException
11+
import org.springframework.web.bind.annotation.ExceptionHandler
12+
import org.springframework.web.bind.annotation.RestControllerAdvice
13+
import org.springframework.web.context.request.WebRequest
14+
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
15+
16+
@RestControllerAdvice
17+
class GlobalExceptionHandler : ResponseEntityExceptionHandler() {
18+
19+
private val log = LoggerFactory.getLogger(javaClass)
20+
21+
override fun handleMethodArgumentNotValid(
22+
ex: MethodArgumentNotValidException,
23+
headers: HttpHeaders,
24+
status: HttpStatusCode,
25+
request: WebRequest,
26+
): ResponseEntity<Any>? {
27+
val problemDetail = createProblemDetail(
28+
status = HttpStatus.BAD_REQUEST,
29+
detail = ErrorCode.INVALID_REQUEST.message,
30+
)
31+
32+
problemDetail.setProperty(
33+
"errors",
34+
ex.bindingResult.fieldErrors.map { fieldError ->
35+
FieldValidationError(
36+
field = fieldError.field,
37+
message = fieldError.defaultMessage ?: "올바르지 않은 값입니다.",
38+
)
39+
},
40+
)
41+
42+
return handleExceptionInternal(ex, problemDetail, headers, status, request)
43+
}
44+
45+
@ExceptionHandler(Exception::class)
46+
fun handleUnexpectedException(
47+
exception: Exception,
48+
request: HttpServletRequest,
49+
): ProblemDetail {
50+
log.error(
51+
"Unhandled exception: method={}, path={}",
52+
request.method,
53+
request.requestURI,
54+
exception,
55+
)
56+
57+
return createProblemDetail(
58+
status = HttpStatus.INTERNAL_SERVER_ERROR,
59+
detail = ErrorCode.INTERNAL_SERVER_ERROR.message,
60+
)
61+
}
62+
63+
private fun createProblemDetail(
64+
status: HttpStatus,
65+
detail: String,
66+
): ProblemDetail =
67+
ProblemDetail.forStatusAndDetail(status, detail).apply {
68+
title = status.reasonPhrase
69+
}
70+
71+
private data class FieldValidationError(
72+
val field: String,
73+
val message: String,
74+
)
75+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.mogumogu.momogo.global.error
2+
3+
import io.kotest.core.spec.style.BehaviorSpec
4+
import io.kotest.matchers.shouldBe
5+
import jakarta.validation.Valid
6+
import jakarta.validation.constraints.NotBlank
7+
import org.springframework.http.MediaType
8+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
9+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
10+
import org.springframework.test.web.servlet.setup.MockMvcBuilders
11+
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean
12+
import org.springframework.web.bind.annotation.GetMapping
13+
import org.springframework.web.bind.annotation.PostMapping
14+
import org.springframework.web.bind.annotation.RequestBody
15+
import org.springframework.web.bind.annotation.RequestMapping
16+
import org.springframework.web.bind.annotation.RestController
17+
import tools.jackson.databind.json.JsonMapper
18+
19+
class GlobalExceptionHandlerTest : BehaviorSpec({
20+
21+
val validator = LocalValidatorFactoryBean().apply { afterPropertiesSet() }
22+
val mockMvc = MockMvcBuilders
23+
.standaloneSetup(TestController())
24+
.setControllerAdvice(GlobalExceptionHandler())
25+
.setValidator(validator)
26+
.build()
27+
val objectMapper = JsonMapper.builder().build()
28+
29+
given("API 예외 종류가 다르면") {
30+
val notFound: ApiException = ApiException.NotFound(ErrorCode.RESOURCE_NOT_FOUND)
31+
val badRequest: ApiException = ApiException.BadRequest(ErrorCode.INVALID_REQUEST)
32+
33+
then("서로 다른 런타임 타입으로 구분할 수 있다") {
34+
(notFound is ApiException.NotFound) shouldBe true
35+
(notFound is ApiException.BadRequest) shouldBe false
36+
(badRequest is ApiException.BadRequest) shouldBe true
37+
(badRequest is ApiException.NotFound) shouldBe false
38+
}
39+
}
40+
41+
given("API 예외가 발생하면") {
42+
`when`("notFound 팩토리로 예외를 생성할 때") {
43+
val response = mockMvc.perform(get("/test/api-exception"))
44+
.andReturn()
45+
.response
46+
val body = objectMapper.readTree(response.contentAsString)
47+
48+
then("404 ProblemDetail로 응답한다") {
49+
response.status shouldBe 404
50+
response.contentType shouldBe MediaType.APPLICATION_PROBLEM_JSON_VALUE
51+
body["status"].intValue() shouldBe 404
52+
body["detail"].stringValue() shouldBe "요청한 리소스를 찾을 수 없습니다."
53+
body["instance"].stringValue() shouldBe "/test/api-exception"
54+
body.has("code") shouldBe false
55+
}
56+
}
57+
}
58+
59+
given("요청 DTO 검증이 실패하면") {
60+
`when`("빈 이름을 전달할 때") {
61+
val response = mockMvc.perform(
62+
post("/test/validation")
63+
.contentType(MediaType.APPLICATION_JSON)
64+
.content("""{"name":""}"""),
65+
).andReturn().response
66+
val body = objectMapper.readTree(response.contentAsString)
67+
68+
then("필드 에러를 포함한 400 ProblemDetail로 응답한다") {
69+
response.status shouldBe 400
70+
response.contentType shouldBe MediaType.APPLICATION_PROBLEM_JSON_VALUE
71+
body["errors"][0]["field"].stringValue() shouldBe "name"
72+
body["errors"][0]["message"].stringValue() shouldBe "이름은 비어 있을 수 없습니다."
73+
body["instance"].stringValue() shouldBe "/test/validation"
74+
body.has("code") shouldBe false
75+
}
76+
}
77+
}
78+
79+
given("Spring MVC 요청 파싱 예외가 발생하면") {
80+
`when`("잘못된 JSON을 전달할 때") {
81+
val response = mockMvc.perform(
82+
post("/test/validation")
83+
.contentType(MediaType.APPLICATION_JSON)
84+
.content("""{"name":}"""),
85+
).andReturn().response
86+
val body = objectMapper.readTree(response.contentAsString)
87+
88+
then("표준 필드를 포함한 400 ProblemDetail로 응답한다") {
89+
response.status shouldBe 400
90+
response.contentType shouldBe MediaType.APPLICATION_PROBLEM_JSON_VALUE
91+
body["status"].intValue() shouldBe 400
92+
body["instance"].stringValue() shouldBe "/test/validation"
93+
body.has("code") shouldBe false
94+
}
95+
}
96+
}
97+
98+
given("IllegalArgumentException이 발생하면") {
99+
`when`("예상하지 못한 내부 예외로 처리할 때") {
100+
val response = mockMvc.perform(get("/test/illegal-argument"))
101+
.andReturn()
102+
.response
103+
val body = objectMapper.readTree(response.contentAsString)
104+
105+
then("내부 메시지를 숨기고 500으로 응답한다") {
106+
response.status shouldBe 500
107+
response.contentType shouldBe MediaType.APPLICATION_PROBLEM_JSON_VALUE
108+
body["detail"].stringValue() shouldBe "서버 내부 오류가 발생했습니다."
109+
body["instance"].stringValue() shouldBe "/test/illegal-argument"
110+
body.has("code") shouldBe false
111+
}
112+
}
113+
}
114+
}) {
115+
@RestController
116+
@RequestMapping("/test")
117+
private class TestController {
118+
119+
@GetMapping("/api-exception")
120+
fun apiException(): Nothing =
121+
throw ApiException.NotFound(ErrorCode.RESOURCE_NOT_FOUND)
122+
123+
@PostMapping("/validation")
124+
fun validation(@Valid @RequestBody request: TestRequest) = request
125+
126+
@GetMapping("/illegal-argument")
127+
fun illegalArgument(): Nothing =
128+
throw IllegalArgumentException("민감한 내부 메시지")
129+
}
130+
131+
private data class TestRequest(
132+
@field:NotBlank(message = "이름은 비어 있을 수 없습니다.")
133+
val name: String,
134+
)
135+
}

0 commit comments

Comments
 (0)