프로젝트/쇼핑몰 프로젝트

[쇼핑몰 프로젝트] Spring 예외 처리: ErrorCode 기반 공통 처리로 단순화하기

Joo.v7 2025. 8. 1. 19:43

0. 개요

쇼핑몰 프로젝트를 혼자 새로 만들면서, 예외 처리 방식을 어떻게 설계할 것인가에 대해 많은 고민을 하게 되었다.


이전 팀 프로젝트에서는 예외 클래스를 도메인별로 세분화하고 상속 구조를 활용해 관리했지만, 예외 종류가 많아질수록 클래스 수가 급격히 늘어나고, 각 예외마다 @ExceptionHandler를 추가해야 하는 번거로움이 뒤따랐다.

 

결국 이러한 관리 부담과 중복 처리 문제를 해결하기 위해, ErrorCode Enum과 공통 예외 클래스를 활용한 단순화된 예외 처리 방식으로 전환하게 되었다.

 

이 글에서는 기존 방식의 한계와 그로 인한 고민 과정을 소개하고, ErrorCode 기반 예외 처리 방식으로 어떻게 개선했는지를 정리해본다.


1. 기존 방식: 예외 클래스를 세분화하여 상속 구조로 관리

이전 팀 프로젝트에서는 예외를 좀 더 구체적이고 명확하게 표현하기 위해, 공통 예외 클래스를 베이스로 만들고 이를 상속하는 방식으로 처리했다.

  1. 공통적으로 사용할 수 있는 Base Exception을 정의.
  2. 이를 바탕으로, 각 도메인(회원, 상품 등)에 맞는 구체적 예외 클래스를 따로 생성.

 

* Exception 코드

더보기

 DuplicateResourceException

  • DB 리소스 중복 에러에 사용되는 base Exception.
/**
 * DB 리소스 중복 에러에 사용하는 예외 클래스
 */
public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String message) {
        super(message);
    }
}

 

 MemberEmailAlreadyExistsException

  • DuplicateResourceException을 상속받은 도메인별(회원 이메일) 중복 예외 클래스.
public class MemberEmailAlreadyExistsException extends DuplicateResourceException {
    public MemberEmailAlreadyExistsException() {
        super("이미 사용중인 이메일 입니다");
    }
}

 

 MemberNameAlreadyExistsException

  • DuplicateResourceException을 상속받은 도메인별(회원 이름) 중복 예외 클래스.
public class MemberNameAlreadyExistsException extends DuplicateResourceException {
    public MemberNameAlreadyExistsException() {
        super("이미 사용중인 사용자 이름 입니다");
    }
}

문제점 

예외 상황이 늘어날수록 도메인마다 비슷한 역할의 예외 클래스가 계속 추가되어야 했고, 각 base exception마다 별도의 @ExceptionHandler를 작성해야 하는 등, 관리와 확장이 점점 번거로워지는 단점이 있었다.

 

* @RestControllerAdvice

더보기

 GlobalExceptionHandler

  • 전역 예외를 처리하는 역할을 한다.
package com.chokchok.chokchokapi.common.advice;

import com.chokchok.chokchokapi.common.aop.exception.ForbiddenAccessException;
import com.chokchok.chokchokapi.common.aop.exception.UnauthorizedAccessException;
import com.chokchok.chokchokapi.common.exception.base.DuplicateResourceException;
import com.chokchok.chokchokapi.common.exception.base.EntityNotFoundException;
import com.chokchok.chokchokapi.common.exception.base.InvalidEnumValueException;
import com.chokchok.chokchokapi.common.exception.dto.ErrorResponseDto;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 전역 예외를 처리하는 핸들러 클래스
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 엔티티 찾지 못할 때 예외 처리
     * @return ErrorResponseDto
     */
    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<?> handleEntityNotFoundException(EntityNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponseDto.of(HttpStatus.NOT_FOUND.value(), e.getMessage()));
    }

    /**
     * DB 리소스 중복 예외 처리
     * @return ErrorResponseDto
     */
    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<?> handleDuplicateResourceException(DuplicateResourceException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponseDto.of(HttpStatus.BAD_REQUEST.value(), e.getMessage()));
    }

    /**
     * 유효성 검사 실패 예외 처리
     * @return ErrorResponseDto
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponseDto.of(HttpStatus.BAD_REQUEST.value(), e.getMessage()));
    }

    /**
     * 올바르지 않은 Enum 값 예외 처리
     * @return ErrorResponseDto
     */
    @ExceptionHandler(InvalidEnumValueException.class)
    public ResponseEntity<?> handleInvalidEnumValueException(InvalidEnumValueException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponseDto.of(HttpStatus.BAD_REQUEST.value(), e.getMessage()));
    }

    /**
     * 인증되지 않은 사용자에 대한 예외 처리
     * @return ErrorResponseDto
     */
    @ExceptionHandler(UnauthorizedAccessException.class)
    public ResponseEntity<?> handleUnauthorizedAccessException(UnauthorizedAccessException e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponseDto.of(HttpStatus.UNAUTHORIZED.value(), e.getMessage()));
    }

    /**
     * 접근 권한이 없는 사용자에 대한 예외 처리
     * @return ErrorResponseDto
     */
    @ExceptionHandler(ForbiddenAccessException.class)
    public ResponseEntity<?> handleForbiddenAccessException(ForbiddenAccessException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ErrorResponseDto.of(HttpStatus.FORBIDDEN.value(), e.getMessage()));
    }

}

2. 개선 방안: ErrorCode 중심의 응답 포맷

기존과 마찬가지로 공통 예외 클래스(Base Exception)를 하나의 큰 틀로 두되, 세부적인 예외 상황은 ErrorCode Enum을 통해 구체적으로 표현한다.

 

이렇게 하면 핸들러는 단 하나의 예외 타입만 받지만, ErrorCode에 따라 응답을 달리할 수 있기 때문에 유연하게 대응 가능하다.

(좌) exception 구조, (우) ErrorCode 기반 예외 처리 사용 예시

 

* 공통 예외 클래스, 에러 응답 코드를 담당하는 Enum 코드

더보기

NotFoundException

  • 공통 예외 클래스 중 하나
package com.chokchok.chokchokapi.common.exception.base;

import com.chokchok.chokchokapi.common.exception.code.ErrorCode;
import lombok.Getter;

/**
 * 요청한 리소스를 찾을 수 없는 경우 발생하는 예외
 */
@Getter
public class NotFoundException extends RuntimeException {
    private final ErrorCode errorCode;

    public NotFoundException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

 

ErrorCode

  • 에러 응답 코드를 담당하는 Enum 
package com.chokchok.chokchokapi.common.exception.code;

/**
 * 에러 응답 코드를 정의하는 enum 클래스
 * 각 에러 코드에는 HTTP 상태 코드 및 고유한 코드 값이 할당
 */
public enum ErrorCode {

    // HTTP_CODE 200
    SUCCESS(2000),

    // HTTP_CODE 204
    ACCEPTED(2041),

    // HTTP_CODE 400
    // InvalidException.class
    MISSING_REQUEST_PARAMETER(4001),
    INVALID_REQUEST_PARAMETER(4002),
    INVALID_MEMBER_GENDER_VALUE(4003),
    INVALID_MEMBER_STATUS_VALUE(4004),

    INVALID_HEADER_REQUEST(4005),
    INVALID_REQUEST_TOKEN(4006),
    INVALID_ACCESS_TOKEN_REQUEST(4007),
    INVALID_REFRESH_TOKEN_REQUEST(4008),
    INVALID_LOGIN_REQUEST(4009),

    INVALID_X_MEMBER_ID_HEADER(40010),
    INVALID_X_MEMBER_ROLE_HEADER(40011),

    INVALID_PRODUCT_QUANTITY(40012),
    INVALID_PRODUCT_TYPE_CODE(40013),

    INVALID_CATEGORY_PARENT(40014),

    // HTTP_CODE 401 - 인증되지 않았거나 유효한 인증 정보가 부족
    UNAUTHORIZED(4011),
    INVALID_MEMBER_SESSION(4012),


    // HTTP_CODE 403 - 접근 권한이 없음
    // AuthorizationException.class
    INSUFFICIENT_PERMISSION(4031),
    FORBIDDEN(4032),

    // HTTP_CODE 404
    // NotFoundException.class
    MEMBER_NOT_FOUND(4041),
    MEMBER_GRADE_NOT_FOUND(4042),
    MEMBER_ROLE_NOT_FOUND(4043),
    PRODUCT_NOT_FOUND(4044),
    PRODUCT_IMAGE_NOT_FOUND(4045),
    PRODUCT_INVENTORY_NOT_FOUND(4046),
    CATEGORY_NOT_FOUND(4047),
    CATEGORY_PARENT_NOT_FOUND(4048),

    // HTTP_CODE 409 - 서버가 요청을 처리할 수 없음
    // ConflictException.class
    MEMBER_ALREADY_EXISTS(4091),
    MEMBER_NAME_ALREADY_EXISTS(4092),
    MEMBER_EMAIL_ALREADY_EXISTS(4093),
    PRODUCT_INVENTORY_ALREADY_EXISTS(4094),
    PRODUCT_ALREADY_EXISTS(4095),
    CATEGORY_NAME_ALREADY_EXISTS(4096),

    // HTTP_CODE 500 - 서버 에러
    CHOKCHOK_API_FEIGN_ERROR(5000),
    AUTH_FEIGN_ERROR(5001),

    CHOKCHOK_API_SERVER_ERROR(5006),
    AUTH_API_SERVER_ERROR(5007),
    GATEWAY_SERVER_ERROR(5008);

    private final int code;

    ErrorCode(int code) {
        this.code = code;
    }

    public int getCode() {
        return code;
    }

    public static ErrorCode from(int code) {
        for (ErrorCode errorCode : values()) {
            if (errorCode.getCode() == code) {
                return errorCode;
            }
        }
        throw new IllegalArgumentException("Unknown error code: " + code);
    }

}

 

 MemberRoleQueryService

  • ErrorCode 기반 예외 처리 사용 예시
package com.chokchok.chokchokapi.member.service;

import com.chokchok.chokchokapi.common.exception.base.NotFoundException;
import com.chokchok.chokchokapi.common.exception.code.ErrorCode;
import com.chokchok.chokchokapi.member.domain.MemberRole;
import com.chokchok.chokchokapi.member.repository.MemberRoleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * 회원권한 관련 서비스를 담당하는 클래스입니다.
 */
@Slf4j
@RequiredArgsConstructor
@Service
public class MemberRoleQueryService {

    private final MemberRoleRepository memberRoleRepository;

    private final static String DEFAULT_MEMBER_ROLE_NAME = "user";

    /**
     * Default Role을 가져오는 메소드
     * @return MemberRole
     */
    @Transactional(readOnly = true)
    public MemberRole getDefaultMemberRoleEntity() {
        return memberRoleRepository.findByName(DEFAULT_MEMBER_ROLE_NAME)
                .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_ROLE_NOT_FOUND, "Default role not found"));
    }

}

3. 마무리

예외 처리를 도메인별로 세분화하는 방식은 의미 전달이 명확하다는 장점이 있지만, 예외 클래스와 핸들러가 기하급수적으로 늘어나며 관리 포인트가 너무 많아진다는 단점이 있었다.

 

이번 고민을 통해 예외 클래스를 공통화하고, ErrorCode를 기반으로 응답을 통일함으로써 예외 처리 구조를 훨씬 단순하고 일관성 있게 개선할 수 있었다.