효율적인 예외 처리를 위한 커스텀 에러 코드 구축하기
프론트엔드 개발자로 일할 때, API에서 문제가 생기면 500 에러나 다른 HTTP 상태 코드만 보고는 어떤 부분이 문제가 있는지 알 수가 없어서, 항상 백엔드 개발자에게 물어봐야 하는 경우가 많았습니다. 이런 경험이 조금 불편했는데, 백엔드로 전향하면서 이런 문제를 직접 해결해 볼 기회가 생겼습니다. 그래서 커스텀 에러 코드와 메시지를 사용해 에러 추적을 명확하게 하고, 프론트엔드 개발자들도 커스텀 에러 코드를 보고 문제를 쉽게 이해하고 정확하게 대처할 수 있도록 개선 작업을 진행했습니다.
또한, 백엔드 개발자의 입장에서도 커스텀 에러 코드를 통해 문제를 쉽게 파악하고 빠르게 해결할 수 있다는 점이 아주 좋았습니다. 모든 에러 처리를 일관성 있게 만들고 재사용 가능하게 설계해서 유지보수도 더 편리해졌습니다.
커스텀 예외 만들기
전체적인 구성도 입니다.

아래에서 하나씩 알아보겠습니다.
추상클래스를 사용하여 커스텀 예외 클래스 만들기
이 클래스는 모든 커스텀 예외의 기본이 되는 추상 클래스입니다. 여기서 HTTP 상태 코드, 에러 코드, 메시지, 에러 로그 같은 공통적인 정보를 관리합니다. 이렇게 하면 예외가 발생할 때 항상 같은 형식으로 정보를 전달할 수 있어서 일관성을 유지할 수 있습니다.
@Getter
public abstract class AbstractHttpException extends RuntimeException {
private final HttpStatus status; // HTTP 상태 코드
private final String errorCode; // 에러 코드
private final String message; // 에러 메시지
private final String errorLog; // 상세 에러 로그 (선택적)
// 필수 정보만을 포함한 생성자
public AbstractHttpException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
this.message = message;
this.errorLog = null;
}
// 상세 에러 로그를 추가하는 생성자
public AbstractHttpException(HttpStatus status, String errorCode, String message, String errorLog) {
super(message);
this.status = status;
this.errorCode = errorCode;
this.message = message;
this.errorLog = errorLog;
}
}
공통 예외 인터페이스
public interface DomainException {
String getErrorCode();
String getMessage();
Class<?> getAClass();
}
현재 예외 처리는 각 도메인마다 별도로 정의하고 관리하고 있기 때문에, 여러 도메인에서 커스텀 예외를 사용하더라도 공통된 값을 출력할 수 있도록 인터페이스를 정의했습니다. DomainException
은 커스텀 예외 코드의 공통 인터페이스로, 이를 통해 예외를 정의하는 enum들이 일관된 방식으로 동작하도록 합니다. 예를 들어, CouponCodeException
과 같은 에러 정의 enum이 이 인터페이스를 구현함으로써, 필요한 에러 코드와 메시지를 공통적인 형식으로 관리할 수 있습니다.
Enum 을 이용한 쿠폰 예외 코드 정의
@Getter
public enum CouponCodeException implements DomainException {
COUPON_NOT_FOUND("CCD-001", "존재하지 않는 쿠폰입니다.", NotFoundException.class),
ALREADY_REGISTERED("CCD-002", "이미 등록된 쿠폰 코드입니다.", BadRequestException.class),
...
;
private final String errorCode;
private final String message;
private final Class<?> aClass;
CouponCodeException(String errorCode, String message, Class<?> aClass) {
this.errorCode = errorCode;
this.message = message;
this.aClass = aClass;
}
}
이 Enum은 특정 도메인과 관련된 예외 상황을 정의합니다. 각 상황에 대해 구체적인 에러 코드와 메시지를 제공합니다. 예를 들어, CCD-001
이라는 에러 코드와 함께 "존재하지 않는 쿠폰입니다." 라는 메시지를 포함한 커스텀 NotFoundException
예외가 있습니다.
이렇게 에러 코드가 어떤 예외 타입인지를 정의하는 이유는 예외를 더 명확하게 구분하고, 다양한 상황을 정확히 처리하기 위해서입니다. 예를 들어, 데이터베이스에서 특정 데이터를 찾을 수 없을 때 단순히 RuntimeException
으로 표현하면 무슨 에러인지 파악하기 어렵습니다. 대신 NotFoundException
같은 이름의 예외를 사용하면, 이 상황이 리소스를 찾을 수 없다는 것을 명확히 나타내줍니다. 그리고 이 예외에 HTTP 상태 코드 404 (NOT_FOUND) 를 지정해 주면, 클라이언트도 이 리소스가 없다는 걸 쉽게 알 수 있습니다.
이렇게 각각의 상황에 맞는 예외 클래스를 지정하면, 어떤 상황에서 어떤 상태 코드와 메시지를 클라이언트에게 전달해야 하는지를 더 쉽게 관리할 수 있습니다. 또한, 이런 방식은 예외 처리의 일관성을 유지하고 클라이언트에게 더 명확한 오류 메시지를 제공할 수 있게 해줍니다. 예를 들어, 프론트엔드 개발자가 API 요청이 실패했을 때 반환된 에러 메시지를 보고 무슨 문제인지 정확히 이해하고 그에 맞게 대응할 수 있게 됩니다.
커스텀 예외 클래스 구현
추상클래스를 통해 만든 AbstractHttpException을 상속받아 필요한 생성자를 만듭니다. AbstractHttpException
을 상속받음으로써 공통적인 예외 정보 (상태 코드, 에러 코드, 메시지 등) 를 가지고, 이를 기반으로 더 구체적인 예외를 만들 수 있도록 하고 있습니다.
예를 들어, NotFoundException
클래스는 특정 리소스를 찾지 못했을 때 발생하는 예외를 나타내며, HTTP 404 상태 코드를 갖도록 만들었습니다.
이 클래스는 두 가지 생성자를 제공합니다.
public class NotFoundException extends AbstractHttpException {
// 기본 에러코드 생성자
public NotFoundException(String errorCode, String message) {
super(HttpStatus.NOT_FOUND, errorCode, message);
}
// 상세 에러코드 생성자(민감한 정보를 포함하여 서버에서만 확인할 수 있도록 하기위해 사용)
public NotFoundException(String errorCode, String message, String errorLog) {
super(HttpStatus.NOT_FOUND, errorCode, message, errorLog);
}
}
기본 에러코드 생성자:
errorCode
와message
만을 받아들여, 발생한 예외에 대한 기본적인 정보를 초기화합니다.상세 에러코드 생성자:
errorLog
는 발생한 예외에 대한 추가적인 정보를 기록하며, 이는 서버 측에서 디버깅할 때 유용합니다. 클라이언트에게는 이 정보를 전달하지 않으며, 로그에만 남겨 놓습니다.
동적으로 예외를 생성하는 HttpExceptionCreator 클래스
특정 예외 상황에 맞는 커스텀 예외 객체를 동적으로 만들어주는 클래스입니다.
예를들어, CouponCodeException
같은 에러 정의를 전달받아서, 그에 맞는 구체적인 예외 객체를 만들어줍니다. 이렇게 하면, 비즈니스 로직에서는 단순히 어떤 예외를 발생했는지 정의만 하고, 구체적인 예외 객체를 만드는 복잡한 작업은 HttpExceptionCreator
가 대신 처리합니다.
public class HttpExceptionCreator {
// 동적 예외 생성 메서드
private static <T> T createInstance(Class<? extends T> clazz, Object... args) {
Constructor<? extends T>[] constructorArray = (Constructor<? extends T>[]) clazz.getDeclaredConstructors();
for (Constructor<? extends T> constructor : constructorArray) {
try {
return constructor.newInstance(args);
} catch (Exception ignored) {
// 생성자 호출 실패 시 다음 생성자로 시도
}
}
throw new RuntimeException("Failed to create HttpException instance");
}
// 다양한 예외 생성 메서드 오버로딩
public static AbstractHttpException create(DomainException e) {
return (AbstractHttpException) createInstance(e.getAClass(), e.getErrorCode(), e.getMessage());
}
public static AbstractHttpException create(DomainException e, String errorLog) {
return (AbstractHttpException) createInstance(e.getAClass(), e.getErrorCode(), e.getMessage(), errorLog);
}
}
HttpExceptionCreator
는 필요한 예외 객체를 만들어주는 역할을 합니다. 비즈니스 로직에서는 어떤 예외가 발생했는지만 정의하면, 이 클래스가 실제 예외 객체를 만들어줍니다.
1. 리플렉션을 사용한 예외 객체 생성
createInstance()
메서드는 리플렉션을 이용해 전달된 클래스의 인스턴스를 만듭니다. 리플렉션은 클래스의 생성자를 동적으로 호출할 수 있는 기능을 제공합니다. 이 메서드는 클래스에 있는 생성자들을 하나씩 시도하면서, 전달받은 인자들에 맞는 적절한 생성자를 찾아 예외 객체를 만들어줍니다.
2. 다양한 예외 만들기
create()
메서드는 DomainException
을 사용해 구체적인 예외 객체를 만들어줍니다. create()
메서드는 오버로딩되어 있어서, 예외를 만들 때 필요한 정보 (에러 코드, 메시지, 에러 로그 등) 를 여러 가지 조합으로 넣을 수 있습니다.
3. 예시
예를 들어, 비즈니스 로직에서 CouponCodeException.COUPON_NOT_FOUND
가 전달되면. 이 정보에는 클래스 타입 (예: NotFoundException.class
) 이 포함되어 있습니다. HttpExceptionCreator
는 이 정보를 사용해 NotFoundException
객체를 만듭니다.
이렇게 생성된 예외 객체에는 에러 코드, 메시지, 상태 코드 같은 정보가 담겨 있어서, 클라이언트에게 예외 상황을 더 명확하게 전달할 수 있게 됩니다.
필터 생성
요청을 처리하는 중에 문제가 발생할 경우 AbstractHttpException
이 발생하면, 이 예외는 catch에 걸리기 되는데 필터에서 해당 문제를 처리 할수 있도록 하기 위함 입니다.
@RequiredArgsConstructor
public class HttpExceptionHandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
}
catch (AbstractHttpException e) {
createdExceptionResponse(response, e);
}
}
private void createdExceptionResponse(HttpServletResponse response, AbstractHttpException e) {
log.error("[" + e.getErrorCode() + "] : " + e.getErrorLog());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(e.getStatus().value());
try (OutputStream os = response.getOutputStream()) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.writeValue(os, HttpExceptionResponse.builder()
.errorCode(e.getErrorCode())
.message(e.getMessage())
.build()
);
os.flush();
} catch (IOException ioException) {
throw new RuntimeException("Failed to create HttpExceptionResponse", ioException);
}
}
}
createdExceptionResponse
메서드는 예외가 발생했을 때 클라이언트에게 에러 메시지를 담은 응답을 만드는 역할을 합니다. 예외가 발생하면, 먼저 그 예외 정보를 로그로 기록합니다. 예를 들어, [ERR001]: 쿠폰을 찾을 수 없습니다.
와 같은 메시지를 로그에 남겨두면, 개발자가 나중에 어떤 문제가 발생했는지 쉽게 파악할 수 있습니다.
응답의 타입을 JSON으로 설정하고 (response.setContentType(MediaType.APPLICATION_JSON_VALUE)
), 예외 객체에 담긴 HTTP 상태 코드를 설정합니다. 예를 들어, NotFoundException
이 발생했으면 404 상태 코드를 설정합니다.
ObjectMapper
를 사용해서 예외 정보를 JSON 형태로 변환하고, 이 내용을 아래와 같이 클라이언트에게 응답으로 전달합니다.
{
"errorCode": "CCD-001",
"message": "존재하지 않는 쿠폰입니다.",
"timeStamp": "2024-06-18T15:45:23"
}
이렇게 하면 여러 곳에서 예외가 발생하더라도 모두 같은 형식의 JSON 응답을 클라이언트에게 제공하여 상황마다 다른 응답을 주는 대신, 에러 코드와 메시지를 표준화하여 주기 때문에, 클라이언트 측에서도 대응하기가 쉬워집니다.
클라이언트에게 노출할 response 생성
@Getter
@Builder
public class HttpExceptionResponse {
private final String errorCode;
private final String message;
private final String timeStamp = LocalDateTime.now().toString();
public static ResponseEntity<HttpExceptionResponse> toResponseEntity(AbstractHttpException e) {
return ResponseEntity
.status(e.getStatus())
.headers(e.getHttpHeaders())
.body(
HttpExceptionResponse.builder()
.errorCode(e.getErrorCode())
.message(e.getMessage())
.build()
);
}
}
시큐리티 부분에 필터 등록
위에서 만든 HttpExceptionHandlerFilter
필터를 등록합니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig {
...
...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ... 여러 설정들
.addFilterBefore(new HttpExceptionHandlerFilter());
return http.build();
}
}
사용 예시
public class DiscountCouponService {
...
...
public void applyDiscountCoupon(Long userCouponId) {
UserCoupon userCoupon = userCouponRepository.findById(userCouponId)
.orElseThrow(() -> HttpExceptionCreator.create(COUPON_NOT_FOUND, "UserCouponId: " + userCouponId));
...
...
...
}
}
전체적인 흐름의 순서

CouponCodeException
- enum)
1. 비즈니스 로직에서 예외 정의 및 발생 (예를 들어, 쿠폰이 존재하지 않는 경우, CouponCodeException.COUPON_NOT_FOUND
같은 에러 정의를 사용합니다. 이 정의에는 에러 코드, 메시지, 그리고 어떤 예외 클래스를 사용할지 (NotFoundException.class
) 가 포함되어 있습니다.
HttpExceptionCreator
클래스)
2. 구체적인 예외 객체 생성 (예외가 발생하면, CouponCodeException
같은 에러 정의를 사용해 구체적인 예외 객체를 만듭니다. 예를 들어, HttpExceptionCreator.create(CouponCodeException.COUPON_NOT_FOUND)
를 호출하면, NotFoundException
객체가 만들어집니다. 이 과정에서는 리플렉션이라는 기능을 사용해 NotFoundException
의 인스턴스를 만들고, 전달받은 에러 코드와 메시지를 포함시킵니다.
NotFoundException
- 구체적인 예외 클래스)
3. 예외 객체 생성 (HttpExceptionCreator
가 실제 예외 객체를 생성하게 됩니다. NotFoundException
은 AbstractHttpException
을 상속하고 있어서, 상태 코드, 에러 코드, 메시지 같은 정보가 포함됩니다. 예를 들어, NotFoundException
객체에는 404 상태 코드, 에러 코드 "CCD-001"
, 메시지 "존재하지 않는 쿠폰입니다."
가 들어 있습니다. 이를 통해 예외 상황을 구체적으로 설명할 수 있습니다.
HttpExceptionHandlerFilter
)
4. 예외 처리기에서 예외 처리 (만약 요청을 처리하는 도중에 예외가 발생하면, 필터 체인에서 HttpExceptionHandlerFilter
가 이 예외를 잡아 처리합니다. createdExceptionResponse()
메서드를 사용해 발생한 예외의 정보를 바탕으로 HTTP 응답 본문을 생성합니다. 이 응답에는 에러 코드와 메시지 같은 정보가 포함됩니다.
HttpExceptionResponse
)
5. 클라이언트로의 응답 생성 (최종적으로 클라이언트에게 응답이 전달됩니다. HttpExceptionResponse
객체는 에러 코드, 메시지, 발생 시간 같은 정보를 담아 JSON 형식으로 변환됩니다. 클라이언트는 다음과 같은 JSON 응답을 받게 됩니다:
{
"errorCode": "CCD-001",
"message": "존재하지 않는 쿠폰입니다.",
"timeStamp": "2024-06-18T15:45:23"
}