본문 바로가기
개발

⚠️ Spring Boot(Kotlin) — 3편. 글로벌 예외 처리(GlobalExceptionHandler)

by 새싹 아빠 2025. 11. 24.

📚 Spring Boot(Kotlin) 서버 기본 셋팅 — 시리즈 안내

  1. 왜 멀티 모듈 구조인가? (아키텍처 철학 & 전체 설계 편)
  2. API Response 포맷 설계
  3. 글로벌 예외 처리(GlobalExceptionHandler) ← 현재 글
  4. Swagger(OpenAPI) 설정
  5. Security(JWT) 기본 골격
  6. JWT TokenProvider
  7. Redis 설정
  8. Validation 설정
  9. Logging + MDC(traceId)
  10. application.yml 프로필 분리 (local/dev/prod)
  11. 멀티모듈 + JPA 기본 구조 정리
  12. 완성된 프로젝트 템플릿 git 공유

📌 글로벌 예외 처리가 왜 필요할까?

API 서버를 만들다 보면 다양한 오류가 발생합니다.

  • 사용자가 잘못된 값을 보낸 경우
  • 도메인 규칙을 위반한 경우
  • JSON 파싱 실패
  • 존재하지 않는 리소스 요청
  • 서버 내부 시스템 오류

이걸 컨트롤러마다 따로 처리한다면?

❌ 코드 중복
❌ 응답 형식 불일치
❌ 유지보수 난이도 증가

그래서 필요한 것이 GlobalExceptionHandler입니다.
즉, “서버에서 발생하는 모든 예외를 단일 지점에서 처리하는 구조” 입니다.

 

📌 GlobalExceptionHandler의 핵심 책임

✅ 모든 예외를 한 곳에서 처리
✅ API Response 구조 통일
✅ HTTP Status 통일
✅ 로그 및 보안 처리
✅ 디버깅 편의성 증가

즉, GlobalExceptionHandler는 API 서버의 최종 방어선(Final Gate)입니다.

 

📌 API 모듈과 애플리케이션 모듈의 역할 분리

예외 클래스는 API 모듈과 애플리케이션 모듈 모두에 존재할 수 있습니다.

하지만 이 프로젝트에서는 역할을 명확히 나누었습니다. 그 이유는 애플리케이션 모듈은 도메인과 비즈니스 로직을 다루는 계층이기 때문입니다.

 

도메인 로직에서 발생하는 오류 중에는 시스템 오류가 아니라 비즈니스 규칙 위반을 알리기 위한 예외가 존재합니다.

예: "권한이 없어서 삭제할 수 없습니다"

이런 예외는 도메인에 더 가깝기 때문에 애플리케이션 계층에서 정의하고 던지는 것이 자연스럽습니다.

✅ 애플리케이션 모듈의 역할
- 비즈니스 규칙 판단
- 규칙 위반 상황을 예외로 정의
- 예외를 가까운 지점에서 던짐(throw)
- 예: "현재 상태에서는 취소할 수 없습니다"

반면 API 모듈은 다음 역할을 수행합니다.

✅ API 모듈(GlobalExceptionHandler)의 역할
- 애플리케이션이 던진 예외 처리(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)
}
✅ @RequestBody DTO 검증 실패 처리
✅ 어떤 필드가 왜 실패했는지 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 문법 오류, 타입 오류 대응
✅ 클라이언트 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