Spring Boot 예외 처리 전략

김소희·2025년 11월 26일

설계 방향과 목적

RESTful API를 설계할 때 예외 처리는 API 품질과 개발 편의성에 직결되는 핵심 요소다.
특히 어떤 HTTP 상태 코드를 반환할지, 어떤 정보를 클라이언트에게 노출할지에 따라 보안에도 영향을 준다.

RESTful API라면 정확한 상태 코드를 반환하는 것이 원칙이다.
404는 NOT_FOUND, 403은 FORBIDDEN, 401은 UNAUTHORIZED를 의미한다.
하지만 현실적인 서비스 운영에서는 다음과 같은 문제가 발생한다.

세밀한 HTTP 상태 코드는 공격자에게 유용한 정보를 제공한다. 404를 반환하면 리소스의 존재 여부를 확인할 수 있고, 403을 반환하면 해당 리소스가 실제로 존재한다는 사실을 알려준다. 이런 정보는 공격 대상을 선별하거나 시스템 구조를 파악하는 데 악용될 수 있다. 또한 상태 코드가 너무 다양하면 클라이언트 로직의 복잡도가 증가하고, 일관되지 않은 에러 처리가 발생할 수 있다.

따라서 이번 프로젝트에서는 다음과 같은 전략을 선택했다.

외부 응답: 최소한의 정보 (항상 400 또는 500)
내부 로그: 최대한의 정보 (ErrorCode 기반 상세 분석)

이 방식은 보안성을 높이고, 예외 구조를 단순화하며, 개발·운영 과정에서는 충분한 정보를 제공한다는 3가지를 모두 만족하는 운영 친화적인 접근이다.

예외 클래스 계층 구조

먼저 예외 처리를 효율적으로 관리하기 위해 비즈니스 예외(BaseBusinessException)와 시스템 예외(BaseSystemException)로 계층을 분리했다.

BaseBusinessException

비즈니스 로직 위반(조회 실패, 권한 없음, 검증 실패 등)을 표현한다.

package kr.or.kosa.backend.commons.exception.base;

import kr.or.kosa.backend.commons.exception.custom.ErrorCode;
import lombok.Getter;

@Getter
public abstract class BaseBusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    protected BaseBusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

BaseSystemException

DB 장애, 파일 시스템 오류, 외부 API 문제 등이 발생할 때 사용하는 시스템 레벨 예외다.

package kr.or.kosa.backend.commons.exception.base;

import kr.or.kosa.backend.commons.exception.custom.ErrorCode;
import lombok.Getter;

@Getter
public abstract class BaseSystemException extends RuntimeException {
    private final ErrorCode errorCode;

    protected BaseSystemException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

Custom 예외들

도메인별로 구체적인 예외를 만들기 위해 CustomBusinessException, CustomSystemException을 확장했다.

public class CustomBusinessException extends BaseBusinessException { ... }
public class CustomSystemException extends BaseSystemException { ... }

ErrorCode 설계

모든 예외는 ErrorCode 인터페이스를 통해 코드와 메시지를 가진다.

public interface ErrorCode {
     String getCode();
     String getMessage();
}

에러 코드는 "404_TAG_001" 같은 형태로 구성되며, prefix를 통해 논리적 분류를 표현한다.

  • 400 → 잘못된 요청(검증 실패, 비즈니스 규칙 위반)
  • 404 → 리소스 조회 실패
  • 500 → 시스템 내부 오류

중요한 점은 이 prefix가 실제 HTTP 상태 코드와는 무관하게, 내부 분석/모니터링용 코드로만 사용된다는 점이다.

도메인 예외 코드 구성

UserErrorCode

public enum UserErrorCode implements ErrorCode {
    EMAIL_DUPLICATE("USER001", "이미 사용 중인 이메일입니다."),
    NICKNAME_DUPLICATE("USER002", "이미 사용 중인 닉네임입니다."),
    USER_NOT_FOUND("USER003", "등록되지 않은 이메일입니다."),
    ...
}

FreeboardErrorCode

public enum FreeboardErrorCode implements ErrorCode {
    NOT_FOUND("FB001", "게시글을 찾을 수 없습니다."),
    NO_EDIT_PERMISSION("FB003", "게시글 수정 권한이 없습니다."),
    INSERT_ERROR("FB005", "게시글 등록 중 오류가 발생했습니다."),
    ...
}

TagErrorCode

public enum TagErrorCode implements ErrorCode {

    TAG_TOO_MANY("TAG_005", "태그는 최대 10개까지만 가능합니다."),
    TAG_DUPLICATE("TAG_006", "중복된 태그가 있습니다."),
    TAG_INVALID_CHARACTERS("TAG_007", "태그에 사용할 수 없는 특수문자가 포함되어 있습니다."),
    BOARD_TAG_NOT_FOUND("TAG_009", "게시글의 태그를 찾을 수 없습니다."),
    BOARD_TAG_DELETE_FAILED("TAG_010", "태그 삭제에 실패했습니다."),
    ...
}

즉, 태그 관련 예외가 발생하면:

  1. 컨트롤러에서 throw new CustomBusinessException(TagErrorCode.TAG_NOT_FOUND)
  2. GlobalExceptionHandler가 받아서 항상 400 반환
  3. 클라이언트는 400만 받음
  4. 로그/모니터링에서는 "404_TAG_001"을 보고 원인 파악 가능

GlobalExceptionHandler 구현

이제 핵심인 전역 예외 처리(GlobalExceptionHandler) 구조다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BaseBusinessException.class)
    public ResponseEntity<ApiResponse<Void>>handleBaseSystemException(
            BaseBusinessException ex
    ){
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error(ex.getErrorCode().getCode(), ex.getMessage()));
    }

    @ExceptionHandler(BaseSystemException.class)
    public ResponseEntity<ApiResponse<Void>> handleBaseSystemException(
            BaseSystemException ex
    ){
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.error(ex.getErrorCode().getCode(), ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>>handleMethodArgumentNotValidException(
            MethodArgumentNotValidException ex
    ){
        String errorMessage = ex.getBindingResult()
                .getAllErrors()
                .get(0)
                .getDefaultMessage();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error("VALIDATION_ERROR", errorMessage));
    }
}

핵심 설계 포인트

BaseBusinessException은 모두 400으로 통일

원래 404(NOT FOUND), 403(FORBIDDEN) 등을 나누어 보내는 것이 RESTful 기준으로는 맞다. 하지만 앞서 설명한 설계 방향에 따라 모든 비즈니스 예외는 외부에는 400 BAD_REQUEST로 통일하고, 내부적으로만 ErrorCode로 상세 분류하는 방식을 채택했다.

예를 들어 존재하지 않는 게시글을 조회할 때 404를 반환하면 공격자는 "이 ID는 존재하지 않는다"는 정보를 얻는다. 권한이 없는 리소스에 접근했을 때 403을 반환하면 "이 리소스는 존재하지만 접근할 수 없다"는 정보를 제공하게 된다. 이런 정보 노출을 방지하기 위해 모든 비즈니스 예외는 400으로 통일한다.

대신 ErrorCode를 통해 개발자는 정확한 문제 원인을 파악할 수 있다. "404_TAG_001"이라는 코드를 받으면 태그 조회 실패임을 알 수 있지만, HTTP 응답 자체는 400으로 일관성을 유지한다.

BaseSystemException은 500으로 통일

시스템 레벨 예외는 클라이언트가 어떻게 할 수 없는 서버 내부 문제이므로 HttpStatus.INTERNAL_SERVER_ERROR를 반환한다. 로그에는 스택 트레이스를 포함해 상세한 오류 정보를 남기지만, 클라이언트에게는 최소한의 정보만 전달한다.

로깅 전략

  • 비즈니스 예외 → error 레벨로 코드 + 메시지 기록
  • 시스템 예외 → 스택 트레이스까지 모두 기록
  • 클라이언트에게는 절대 내부 정보 노출 금지

이 설계 덕분에 운영 중 예외가 발생하더라도 외부에는 안전하고, 내부에는 분석이 가능한 구조가 완성된다.

API 응답 구조(ApiResponse) 설계

예외 처리의 마지막 핵심 요소는 API 응답 형식의 일관성이다.
HTTP 상태 코드를 400/500으로 통일하는 전략을 사용한다면,
응답 본문(ApiResponse)이 그 역할을 대신해 추가 정보를 제공해야 한다.

이를 위해 프로젝트에서는 ApiResponse 클래스를 명확하고 확장성 있게 설계했다.

package kr.or.kosa.backend.commons.response;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {

    private boolean success;   // 요청 성공 여부
    private String code;       // ErrorCode 또는 "SUCCESS"
    private String message;    // 사용자 메시지
    private T data;            // 실제 데이터
    private Object meta;       // 페이징/AI토큰/추가정보 등 확장 필드

    private ApiResponse() {}

    // 성공 응답
    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = true;
        response.code = "SUCCESS";
        response.message = "정상 처리되었습니다.";
        response.data = data;
        return response;
    }

    public static <T> ApiResponse<T> success(T data, Object meta) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = true;
        response.code = "SUCCESS";
        response.message = "정상 처리되었습니다.";
        response.data = data;
        response.meta = meta;
        return response;
    }

    public static ApiResponse<Void> success() {
        return success(null);
    }

    // 에러 응답
    public static <T> ApiResponse<T> error(String code, String message) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = false;
        response.code = code;
        response.message = message;
        return response;
    }

    public static <T> ApiResponse<T> error(String code, String message, T data) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = false;
        response.code = code;
        response.message = message;
        response.data = data;
        return response;
    }

    public static <T> ApiResponse<T> error(String code, String message, T data, Object meta) {
        ApiResponse<T> response = new ApiResponse<>();
        response.success = false;
        response.code = code;
        response.message = message;
        response.data = data;
        response.meta = meta;
        return response;
    }

    // Getters
    public boolean isSuccess() { return success; }
    public String getCode() { return code; }
    public String getMessage() { return message; }
    public T getData() { return data; }
    public Object getMeta() { return meta; }
}

APIResponse 설계 포인트

success 필드로 성공/실패를 직관적으로 구분

HTTP 상태 코드에 의존하지 않고, 프런트에서는 다음처럼 간단하게 처리 가능하다.

if (!response.success) {
    alert(response.message);
}

code 필드로 내부 오류 원인을 명확히 전달

HTTP status는 보안을 위해 단순화했지만,
code는 ErrorCode 그대로 내려보내므로 내부적으로 어떤 문제가 발생했는지 정확히 파악할 수 있다.

예:

  • "404_TAG_001"
  • "USER003"
  • "FB002"

meta 필드로 확장성 확보

페이징, 정렬 정보, AI 모델 사용량 등
도메인마다 필요한 부가 데이터를 자유롭게 포함할 수 있다.

예시 (게시판 목록):

"meta": {
  "page": 1,
  "size": 10,
  "total": 124
}

예시 (AI 코드 분석 기능):

"meta": {
  "tokens": 5321,
  "model": "gpt-4.1",
  "elapsedMs": 1803
}

JSON 직렬화 시 null 제거

@JsonInclude(Include.NON_NULL)
→ 불필요한 null 필드를 응답에서 자동 제거하여 API를 깔끔하게 유지한다.

왜 ApiResponse가 중요한가?

HTTP 상태 코드를 보안상 단순화했다면, 에러의 의미·원인·타입·추가 정보는 모두 ApiResponse에서 전달해야 한다.

즉,

HttpStatus = 외부 노출 최소화 위한 보안막
ApiResponse = 내부 진단 및 클라이언트 처리를 위한 풍부한 정보

이렇게 역할을 분리함으로써 보안·유지보수성·일관성·확장성 모두 확보할 수 있게 된다.


service 사용 예시 코드

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FreeboardService {

    private final FreeboardMapper mapper;
    private final ObjectMapper objectMapper;
    private final TagService tagService;

    public Map<String, Object> listPage(int page, int size) {
        int offset = (page - 1) * size;

        List<Freeboard> boards = mapper.selectPage(offset, size);
        int totalCount = mapper.countAll();

        Map<String, Object> result = new HashMap<>();
        result.put("boards", boards);
        result.put("totalCount", totalCount);
        result.put("page", page);
        result.put("size", size);
        result.put("totalPages", (int) Math.ceil((double) totalCount / size));

        return result;
    }

    @Transactional
    public Freeboard detail(Long id) {
        mapper.increaseClick(id);

        Freeboard freeboard = mapper.selectById(id);
        if (freeboard == null) {
            throw new CustomBusinessException(FreeboardErrorCode.NOT_FOUND);
        }

        List<String> tags = tagService.getFreeboardTags(id);
        freeboard.setTags(tags);

        return freeboard;
    }

    @Transactional
    public Long write(FreeboardDto dto, Long userId) {

        String jsonContent;
        String plainText;
        try {
            jsonContent = dto.toJsonContent(objectMapper);
            plainText = dto.toPlainText(objectMapper);
        } catch (Exception e) {
            log.error("JSON 변환 실패", e);
            throw new CustomBusinessException(FreeboardErrorCode.JSON_PARSE_ERROR);
        }

        Freeboard freeboard = Freeboard.builder()
                .userId(userId)
                .freeboardTitle(dto.getFreeboardTitle())
                .freeboardContent(jsonContent)
                .freeboardPlainText(plainText)
                .freeboardRepresentImage(dto.getFreeboardRepresentImage())
                .freeboardDeletedYn("N")
                .build();

        int inserted = mapper.insert(freeboard);
        if (inserted == 0) {
            throw new CustomBusinessException(FreeboardErrorCode.INSERT_ERROR);
        }

        Long freeboardId = freeboard.getFreeboardId();

        if (dto.getTags() != null && !dto.getTags().isEmpty()) {
            tagService.attachTagsToFreeboard(freeboardId, dto.getTags());
        }

        return freeboardId;
    }

    @Transactional
    public void edit(Long id, FreeboardDto dto, Long userId) {

        Freeboard existing = mapper.selectById(id);
        if (existing == null) {
            throw new CustomBusinessException(FreeboardErrorCode.NOT_FOUND);
        }
        if (!existing.getUserId().equals(userId)) {
            throw new CustomBusinessException(FreeboardErrorCode.NO_EDIT_PERMISSION);
        }

        String jsonContent;
        String plainText;

        try {
            jsonContent = dto.toJsonContent(objectMapper);
            plainText = dto.toPlainText(objectMapper);
        } catch (Exception e) {
            log.error("JSON 변환 실패: freeboardId={}", id, e);
            throw new CustomBusinessException(FreeboardErrorCode.JSON_PARSE_ERROR);
        }

        Freeboard freeboard = Freeboard.builder()
                .freeboardId(id)
                .freeboardTitle(dto.getFreeboardTitle())
                .freeboardContent(jsonContent)
                .freeboardPlainText(plainText)
                .freeboardRepresentImage(dto.getFreeboardRepresentImage())
                .build();

        if (mapper.update(freeboard) == 0) {
            throw new CustomBusinessException(FreeboardErrorCode.UPDATE_ERROR);
        }

        if (dto.getTags() != null) {
            tagService.updateFreeboardTags(id, dto.getTags());
        }
    }

    @Transactional
    public void delete(Long id, Long userId) {
        Freeboard existing = mapper.selectById(id);
        if (existing == null) {
            throw new CustomBusinessException(FreeboardErrorCode.NOT_FOUND);
        }
        if (!existing.getUserId().equals(userId)) {
            throw new CustomBusinessException(FreeboardErrorCode.NO_DELETE_PERMISSION);
        }

        int result = mapper.delete(id);
        if (result == 0) {
            throw new CustomBusinessException(FreeboardErrorCode.DELETE_ERROR);
        }
    }
}

컨트롤러 사용 예시 코드

package kr.or.kosa.backend.freeboard.controller;

import jakarta.validation.Valid;
import kr.or.kosa.backend.commons.response.ApiResponse;
import kr.or.kosa.backend.freeboard.domain.Freeboard;
import kr.or.kosa.backend.freeboard.dto.FreeboardCreateRequest;
import kr.or.kosa.backend.freeboard.dto.FreeboardDto; // 이 import 추가!
import kr.or.kosa.backend.freeboard.dto.FreeboardUpdateRequest;
import kr.or.kosa.backend.freeboard.service.FreeboardService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@Log4j2
@RestController
@RequestMapping("/freeboard")
@RequiredArgsConstructor
public class FreeboardController {

    private final FreeboardService freeboardService;

    @PostMapping
    public ResponseEntity<ApiResponse<Map<String, Object>>> create(
            @Valid @RequestBody FreeboardCreateRequest request,
            @RequestAttribute(value = "userId", required = false) Long userId
    ) {
        Long actualUserId = (userId != null) ? userId : 1L;
        Long freeboardId = freeboardService.write(request.toDto(), actualUserId);

        Map<String, Object> result = new HashMap<>();
        result.put("freeboardId", freeboardId);

        return ResponseEntity.ok(ApiResponse.success(result));
    }

    @GetMapping("/{freeboardId}")
    public ResponseEntity<Freeboard> get(@PathVariable Long freeboardId) {
        Freeboard freeboard = freeboardService.detail(freeboardId);
        return ResponseEntity.ok(freeboard);
    }

    @GetMapping
    public ResponseEntity<Map<String, Object>> list(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        Map<String, Object> result = freeboardService.listPage(page, size);
        return ResponseEntity.ok(result);
    }

    @PutMapping("/{freeboardId}")
    public ResponseEntity<Void> update(
            @PathVariable Long freeboardId,
            @Valid @RequestBody FreeboardUpdateRequest request,
            @RequestAttribute(value = "userId", required = false) Long userId
    ) {
        log.info("=== update 컨트롤러 시작 ===");
        log.info("freeboardId: {}", freeboardId);
        log.info("request: {}", request);
        log.info("request.getTags(): {}", request.getTags());

        Long actualUserId = (userId != null) ? userId : 1L;

        FreeboardDto dto = request.toDto();
        log.info("dto.getTags(): {}", dto.getTags());

        freeboardService.edit(freeboardId, dto, actualUserId);
        log.info("=== update 컨트롤러 완료 ===");

        return ResponseEntity.ok().build();
    }

    @DeleteMapping("/{freeboardId}")
    public ResponseEntity<Void> delete(
            @PathVariable Long freeboardId,
            @RequestAttribute(value = "userId", required = false) Long userId
    ) {
        Long actualUserId = (userId != null) ? userId : 1L;
        freeboardService.delete(freeboardId, actualUserId);
        return ResponseEntity.ok().build();
    }
}
profile
백엔드 개발자의 노트

0개의 댓글