📚 Spring Boot(Kotlin) 서버 기본 셋팅 — 시리즈 안내
- 왜 멀티 모듈 구조인가? (아키텍처 철학 & 전체 설계 편)
- API Response 포맷 설계
- 글로벌 예외 처리(GlobalExceptionHandler) ← 현재 글
- Swagger(OpenAPI) 설정
- Security(JWT) 기본 골격
- JWT TokenProvider
- Redis 설정
- Validation 설정
- Logging + MDC(traceId)
- application.yml 프로필 분리 (local/dev/prod)
- 멀티모듈 + JPA 기본 구조 정리
- 완성된 프로젝트 템플릿 git 공유
📌 글로벌 예외 처리가 왜 필요할까?
API 서버를 만들다 보면 다양한 오류가 발생합니다.
- 사용자가 잘못된 값을 보낸 경우
- 도메인 규칙을 위반한 경우
- JSON 파싱 실패
- 존재하지 않는 리소스 요청
- 서버 내부 시스템 오류
이걸 컨트롤러마다 따로 처리한다면?
❌ 응답 형식 불일치
❌ 유지보수 난이도 증가
그래서 필요한 것이 GlobalExceptionHandler입니다.
즉, “서버에서 발생하는 모든 예외를 단일 지점에서 처리하는 구조” 입니다.
📌 GlobalExceptionHandler의 핵심 책임
✅ API Response 구조 통일
✅ HTTP Status 통일
✅ 로그 및 보안 처리
✅ 디버깅 편의성 증가
즉, GlobalExceptionHandler는 API 서버의 최종 방어선(Final Gate)입니다.
📌 API 모듈과 애플리케이션 모듈의 역할 분리
예외 클래스는 API 모듈과 애플리케이션 모듈 모두에 존재할 수 있습니다.
하지만 이 프로젝트에서는 역할을 명확히 나누었습니다. 그 이유는 애플리케이션 모듈은 도메인과 비즈니스 로직을 다루는 계층이기 때문입니다.
도메인 로직에서 발생하는 오류 중에는 시스템 오류가 아니라 비즈니스 규칙 위반을 알리기 위한 예외가 존재합니다.
예: "권한이 없어서 삭제할 수 없습니다"
이런 예외는 도메인에 더 가깝기 때문에 애플리케이션 계층에서 정의하고 던지는 것이 자연스럽습니다.
- 비즈니스 규칙 판단
- 규칙 위반 상황을 예외로 정의
- 예외를 가까운 지점에서 던짐(throw)
- 예: "현재 상태에서는 취소할 수 없습니다"
반면 API 모듈은 다음 역할을 수행합니다.
- 애플리케이션이 던진 예외 처리(handle)
- 시스템 전반의 예측 불가한 오류까지 최종 처리
- 즉, “비즈니스 예외 + 시스템 예외” 모두 책임짐
- HTTP Status, JSON 응답 결정
- 로그 및 보안 처리
- 사용자에게 전달할 최종 응답 형태 생성
정리하면,
API는 “모든 예외를 받아 응답으로 변환한다”
API 모듈은 예외 처리의 최종 관문(Final Gate)
📌 GlobalExceptionHandler 코드 구조 분석
이제 실제 코드 흐름을 살펴보며 예외 처리 구조를 이해해보겠습니다.
(ApiResponse 클래스는 이전 편에서 확인하실 수 있습니다.)
1️⃣ 비즈니스 예외 처리
@ExceptionHandler
fun handleBusiness(e: BusinessException): ResponseEntity<ApiResponse> {
val body = ApiResponse.fail(
code = e.errorCode.code,
messageKey = e.customMessage ?: e.errorCode.messageKey,
detail = e.detail
)
return ResponseEntity.status(e.errorCode.status).body(body)
}
✅ 비즈니스 실패 = 정상적인 실패
✅ 로그는 WARN 수준만 남김
✅ HTTP Status는 ErrorCode에 정의된 값 사용
2️⃣ @Valid 검증 실패
@ExceptionHandler
fun handleMethodArgumentNotValid(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse> {
val fieldError = e.bindingResult.fieldErrors.firstOrNull()
val detail = fieldError?.let {
mapOf(
"field" to it.field,
"reason" to (it.defaultMessage ?: "Invalid value"),
"rejectedValue" to it.rejectedValue
)
}
val body = ApiResponse.fail(
code = ErrorCode.INVALID_INPUT.code,
messageKey = ErrorCode.INVALID_INPUT.messageKey,
detail = detail
)
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
}
✅ 어떤 필드가 왜 실패했는지 detail로 전달
✅ HTTP 400 Bad Request 반환
3️⃣ @ModelAttribute 검증 실패
@ExceptionHandler
fun handleBindException(e: BindException): ResponseEntity<ApiResponse> {
val fieldError = e.bindingResult.fieldErrors.firstOrNull()
val detail = fieldError?.let {
mapOf(
"field" to it.field,
"reason" to (it.defaultMessage ?: "Invalid value"),
"rejectedValue" to it.rejectedValue
)
}
val body = ApiResponse.fail(
code = ErrorCode.INVALID_INPUT.code,
messageKey = ErrorCode.INVALID_INPUT.messageKey,
detail = detail
)
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
}
✅ 구조는 @Valid 실패 처리와 동일
✅ HTTP 400 Bad Request
4️⃣ JSON 파싱 예외
@ExceptionHandler
fun handleJsonParse(e: HttpMessageNotReadableException):
ResponseEntity<ApiResponse> {
val body = ApiResponse.fail(
code = ErrorCode.INVALID_JSON.code,
messageKey = ErrorCode.INVALID_JSON.messageKey,
detail = mapOf(
"reason" to "Invalid JSON format"
)
)
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
}
✅ 클라이언트 JSON 문제는 400 Bad Request
✅ detail 필드로 힌트 제공
5️⃣ 404 Not Found 처리
@ExceptionHandler
fun handleNotFound(e: NoResourceFoundException): ResponseEntity<ApiResponse> {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
ApiResponse.fail(
code = ErrorCode.RESOURCE_NOT_FOUND.code,
messageKey = ErrorCode.RESOURCE_NOT_FOUND.messageKey,
detail = e.message
)
)
}
✅ HTTP 404 반환
✅ 비즈니스 로그는 기록하지 않음
6️⃣ 예측 불가 시스템 예외 처리
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ApiResponse> {
val body = ApiResponse.fail(
code = ErrorCode.INTERNAL_ERROR.code,
messageKey = ErrorCode.INTERNAL_ERROR.messageKey
)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(body)
}
✅ 예측 불가 오류는 500 반환
✅ API 모듈이 예외 처리의 최종 관문
📌 왜 BusinessException이 필요한가?
abstract class BusinessException(
val errorCode: ErrorCode,
val detail: Any? = null,
val customMessage: String? = null
) : ApplicationException(customMessage ?: errorCode.messageKey)
✅ 시스템 오류가 아니라 “상황을 설명하는 실패”
✅ 도메인 로직 근처에서 던지기 위해 존재
✅ ErrorCode 기반으로 응답 통일 가능
📌 ApplicationException의 역할
abstract class ApplicationException(
override val message: String
) : RuntimeException(message)
✅ 비즈니스 예외들을 한 계층으로 묶기 위한 기반
✅ 도메인 로직과 시스템 예외를 구분하는 기준점
📌 ErrorCode 설계 이유
enum class ErrorCode(
val code: String,
val messageKey: String,
val status: Int
)
✅ 메시지 키로 다국어 확장 가능
✅ HTTP Status를 enum에 보관해 일관성 유지
✅ 프론트/백엔드/QA 협업에 유리
✅ 결론
✅ 애플리케이션: 판단 & throw
✅ API(GlobalExceptionHandler): 처리 & 응답 변환
✅ 시스템 전체 예외의 최종 방어선
✅ 표준화 + 확장성 + 유지보수성 UP
프로젝트가 커질수록 예외 처리는 “철학”이 됩니다.
그리고 기준은 단 하나입니다.
“도메인은 도메인답게, API는 전달 계층답게.”
📌 다음 편 안내
4편 — Swagger(OpenAPI) 설정
📌 API 문서를 자동화하고 협업 효율을 높이는 방법을 다룰 예정입니다.
https://jaemoi8.tistory.com/34
Spring Boot(Kotlin) — 4편. Swagger(OpenAPI) 설정 가이드
📚 Spring Boot(Kotlin) 서버 기본 셋팅 — 시리즈 안내왜 멀티 모듈 구조인가? (아키텍처 철학 & 전체 설계 편)API Response 포맷 설계글로벌 예외 처리(GlobalExceptionHandler)Swagger(OpenAPI) 설정 ← 현재 글Securi
jaemoi8.tistory.com
'개발' 카테고리의 다른 글
| ⚠️ Spring Boot(Kotlin) — 5편. Security(JWT) 설정 (0) | 2025.11.25 |
|---|---|
| Spring Boot(Kotlin) — 4편. Swagger(OpenAPI) 설정 가이드 (0) | 2025.11.25 |
| Spring Boot(Kotlin) — 2편. API Response 포맷 설계 (0) | 2025.11.24 |
| Spring Boot(Kotlin) 서버 기본 셋팅 — 1편. 왜 멀티 모듈 구조인가? (0) | 2025.11.21 |
| DB 커넥션 풀과 Redis — 확장 가능한 서버 구조를 위한 핵심 정리 (0) | 2025.11.19 |