0. 들어가며
예외 처리는 애플리케이션의 안정성과 유지보수성을 결정짓는 핵심 요소다. 잘 설계된 예외 처리 구조는 디버깅을 쉽게 만들고, 클라이언트에게 일관된 에러 응답을 제공하며, 새로운 예외 상황이 생겨도 쉽게 확장할 수 있다.
이 글에서는 내가 실제 프로젝트에서 적용한 예외 처리 설계를 공유한다. 비즈니스 예외와 인프라 예외를 분리하고, ErrorCode를 인터페이스로 추상화하며, RFC 7807 표준을 활용한 응답 구조를 살펴본다.
1. 설계 원칙
1. 예외 계층 분리: 비즈니스 vs 인프라
예외를 크게 두 가지로 분류했다.
- BusinessException: 비즈니스 로직 위반으로 발생하는 예외. 잘못된 인증 코드 입력, 존재하지 않는 리소스 조회 등 클라이언트의 요청이 원인인 경우다.
- InfraException: 외부 API 호출 실패, DB 연결 오류 등 인프라 레벨에서 발생하는 예외. 클라이언트 잘못이 아닌 서버 측 문제다.
이렇게 분리한 이유는 명확하다. 두 예외는 처리 방식이 다르기 때문이다.
| 구분 | BusinessException | InfraException |
| 원인 | 클라이언트 요청 오류 | 서버/외부 시스템 오류 |
| 클라이언트 응답 | 구체적인 에러 정보 제공 | 일반적인 서버 오류 메시지 |
| 로깅 | 상황에 따라 warn 또는 error | 항상 error |
| 후속 조치 | 대부분 불필요 | 알림, 재시도 로직 등 필요 |
2. 로그 메시지와 클라이언트 메시지 분리
예외에는 두 가지 메시지가 필요하다.
- 로그 메시지: 개발자가 문제를 파악하기 위한 상세 정보
- 클라이언트 메시지: 사용자에게 보여줄 간결하고 이해하기 쉬운 메시지
예를 들어 리소스를 찾지 못한 경우, 로그에는 "존재하지 않는 리소스에 대한 요청 발생: 12345"처럼 ID를 포함한 상세 정보를 남기고, 클라이언트에게는 "요청한 리소스를 찾을 수 없습니다"라는 일반적인 메시지를 전달한다.
3. ErrorCode 인터페이스화
에러 코드를 인터페이스로 정의하면 도메인별로 enum을 분리하면서도 일관된 처리가 가능하다. 새로운 도메인이 추가되어도 해당 도메인의 ErrorCode enum만 만들면 된다.
2. 구현
추상 예외 클래스
abstract class BusinessException(
val errorCode: ErrorCode,
logMessage: String,
cause: Throwable? = null
) : RuntimeException(logMessage, cause)
abstract class InfraException(
val errorCode: ErrorCode,
logMessage: String,
cause: Throwable? = null
) : RuntimeException(logMessage, cause)
두 추상 클래스 모두 ErrorCode를 가지고 있어 일관된 에러 응답 생성이 가능하다. RuntimeException의 message로는 로그용 메시지를 전달하고, 클라이언트 응답에는 errorCode.message를 사용한다.
ErrorCode 인터페이스
interface ErrorCode {
val code: String
val message: String
val status: HttpStatus
}
code는 클라이언트가 에러를 식별하는 데 사용하는 문자열이다. 숫자 코드 대신 INVALID_VERIFICATION_CODE처럼 의미를 담은 문자열을 사용하면 클라이언트 개발자가 문서 없이도 에러의 의미를 파악할 수 있다.
도메인별 ErrorCode 구현
enum class AuthErrorCode(
override val code: String,
override val message: String,
override val status: HttpStatus
) : ErrorCode {
INVALID_VERIFICATION_CODE("INVALID_VERIFICATION_CODE", "인증코드가 일치하지 않습니다", HttpStatus.BAD_REQUEST),
VERIFICATION_CODE_EXPIRED("VERIFICATION_CODE_EXPIRED", "인증코드가 만료 되었습니다", HttpStatus.GONE),
AUTHENTICATION_FAILED("AUTHENTICATION_FAILED", "인증에 실패했습니다", HttpStatus.UNAUTHORIZED)
}
enum class CommonErrorCode(
override val code: String,
override val message: String,
override val status: HttpStatus
) : ErrorCode {
VALIDATION_FAILED("VALIDATION_FAILED", "유효성 검증에 실패했습니다", HttpStatus.BAD_REQUEST),
INTERNAL_SERVER_ERROR("INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다", HttpStatus.INTERNAL_SERVER_ERROR),
EXTERNAL_API_ERROR("EXTERNAL_API_ERROR", "외부 API 호출에 실패했습니다", HttpStatus.BAD_GATEWAY)
}
도메인이 늘어나면 UserErrorCode, OrderErrorCode 등을 추가하면 된다. 모두 ErrorCode 인터페이스를 구현하므로 GlobalExceptionHandler에서 동일한 방식으로 처리할 수 있다.
구체 예외 클래스
비즈니스 예외 예시:
class AuthenticationException(
errorCode: AuthErrorCode = AuthErrorCode.AUTHENTICATION_FAILED,
logMessage: String
) : BusinessException(
errorCode = errorCode,
logMessage = logMessage
)
class ResourceNotFoundException(
resourceId: Long,
errorCode: ErrorCode,
) : BusinessException(
errorCode = errorCode,
logMessage = "존재하지 않는 리소스에 대한 요청 발생: $resourceId"
)
인프라 예외 예시:
class ExternalApiException(
externalApi: ExternalApi,
requestUrl: String? = null,
val statusCode: Int? = null,
body: String? = null,
cause: Throwable? = null,
) : InfraException(
logMessage = "${externalApi.apiName} API 호출 실패: url: $requestUrl, status: $statusCode, body: $body",
errorCode = CommonErrorCode.EXTERNAL_API_ERROR,
cause = cause
) {
enum class ExternalApi(val apiName: String) {
COOLSMS("CoolSMS"),
AMAZON_S3("Amazon S3")
}
}
ExternalApiException은 외부 API별로 enum을 정의해 로그 메시지에 어떤 API에서 문제가 발생했는지 명확히 남긴다. URL, 상태 코드, 응답 본문까지 로그에 포함되어 디버깅이 용이하다.
여기서 핵심은 커스텀 예외 클래스를 만들거면 명분이 있어야 한다는 것이다. ErrorCode를 강제하거나, 로그 메시지를 강제하거나, 해당 예외 클래스에 특화된 필드를 받아서 처리할 수도 있다.
GlobalExceptionHandler
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(AuthenticationException::class)
fun handleAuthenticationException(e: AuthenticationException): ProblemDetail {
logger.warn(e) { e.message }
return ProblemDetail.forStatusAndDetail(e.errorCode.status, e.errorCode.message).apply {
setProperty("code", e.errorCode.code)
}
}
@ExceptionHandler(BusinessException::class)
fun handleBusinessException(e: BusinessException): ProblemDetail {
logger.error(e) { "${e.errorCode.code}: ${e.message}" }
return ProblemDetail.forStatusAndDetail(e.errorCode.status, e.errorCode.message).apply {
setProperty("code", e.errorCode.code)
}
}
@ExceptionHandler(InfraException::class)
fun handleInfraException(e: InfraException): ProblemDetail {
logger.error(e) { "${e.errorCode.code}: ${e.message}" }
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다").apply {
setProperty("code", CommonErrorCode.INTERNAL_SERVER_ERROR.code)
}
}
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ProblemDetail {
val errors = e.bindingResult.fieldErrors.map {
mapOf("field" to it.field, "message" to (it.defaultMessage ?: "유효하지 않은 값입니다"))
}
logger.warn(e) { "입력값 검증 실패: ${errors.map { it["field"] }}" }
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "입력값 검증 실패").apply {
setProperty("code", CommonErrorCode.VALIDATION_FAILED.code)
setProperty("errors", errors)
}
}
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ProblemDetail {
logger.error(e) { "Unhandled exception" }
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다").apply {
setProperty("code", CommonErrorCode.INTERNAL_SERVER_ERROR.code)
}
}
}
몇 가지 포인트를 짚어보자.
로깅 레벨 차등 적용: AuthenticationException은 warn 레벨로, 나머지 BusinessException은 error 레벨로 로깅한다. 인증 실패는 비정상적인 상황이 아니라 일상적으로 발생할 수 있는 상황이기 때문이다. 로그인 시도 실패가 모두 error로 쌓이면 정작 중요한 에러를 놓치기 쉽다.
InfraException 응답 처리: 인프라 예외는 내부 상세 정보를 클라이언트에게 노출하지 않는다. 로그에는 "CoolSMS API 호출 실패: url: ..., status: 500, body: ..."처럼 상세 정보가 남지만, 클라이언트에게는 "서버 내부 오류가 발생했습니다"라는 일반적인 메시지만 전달한다. 보안과 사용자 경험 양쪽을 고려한 설계다.
ProblemDetail 활용: Spring 6부터 지원하는 ProblemDetail은 RFC 7807 표준을 따르는 에러 응답을 쉽게 만들 수 있게 해준다. setProperty로 커스텀 필드를 추가할 수 있어 code나 errors 같은 필드를 자유롭게 확장할 수 있다.
3. 실제 사용 예시
서비스 계층에서 예외를 던지는 코드:
@Service
class VerificationService(
private val verificationCodeRepository: VerificationCodeRepository
) {
fun verify(phoneNumber: String, code: String) {
val verification = verificationCodeRepository.findByPhoneNumber(phoneNumber)
?: throw AuthenticationException(
errorCode = AuthErrorCode.INVALID_VERIFICATION_CODE,
logMessage = "인증 정보 없음: $phoneNumber"
)
if (verification.isExpired()) {
throw AuthenticationException(
errorCode = AuthErrorCode.VERIFICATION_CODE_EXPIRED,
logMessage = "만료된 인증코드: $phoneNumber, 만료시간: ${verification.expiredAt}"
)
}
if (verification.code != code) {
throw AuthenticationException(
errorCode = AuthErrorCode.INVALID_VERIFICATION_CODE,
logMessage = "인증코드 불일치: $phoneNumber"
)
}
}
}
이 코드가 AuthenticationException을 던지면 클라이언트는 다음과 같은 응답을 받는다. 여기서 핵심은 클라이언트에게는 일반적인 메시지만 전달한다는 것이다.
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "인증코드가 일치하지 않습니다",
"instance": "/api/v1/verification",
"code": "INVALID_VERIFICATION_CODE"
}
validation 에러의 경우:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "입력값 검증 실패",
"instance": "/api/v1/users",
"code": "VALIDATION_FAILED",
"errors": [
{ "field": "email", "message": "이메일 형식이 올바르지 않습니다" },
{ "field": "password", "message": "비밀번호는 8자 이상이어야 합니다" }
]
}
4. 마치며
이 구조의 장점을 정리하면 다음과 같다.
- 확장성: 새로운 도메인 예외가 필요하면 ErrorCode enum과 Exception 클래스만 추가하면 된다. GlobalExceptionHandler는 수정할 필요가 없다.
- 일관성: 모든 에러 응답이 동일한 포맷을 따르므로 클라이언트에서 에러 처리 로직을 단순화할 수 있다.
- 디버깅 용이성: 로그 메시지와 클라이언트 메시지가 분리되어 있어 상세한 디버깅 정보를 로그에 남기면서도 사용자에게는 적절한 메시지를 보여줄 수 있다.
- 보안: Exception의 상세 정보가 클라이언트에 노출되지 않아 내부 시스템 구조가 외부에 드러나지 않는다.
물론 프로젝트 상황에 따라 더 단순하거나 복잡한 구조가 필요할 수 있다. 중요한 것은 일관된 원칙을 세우고 그 원칙에 따라 예외를 처리하는 것이다.
'개발고민' 카테고리의 다른 글
| 좋은 예외 처리란 무엇인가 #2 (1) | 2026.01.07 |
|---|---|
| 좋은 예외 처리란 무엇인가 #1 (0) | 2026.01.06 |