Post

⚓ DATT 01 - 공통 응답, 예외 처리, Request Logging 기반 구축

개요

DATT v2 프로젝트를 새롭게 시작하면서 가장 먼저 고민한 것은 기능 구현이 아니었다.

오히려 먼저 고민한 것은:

  • 응답 구조를 어떻게 통일할 것인가?
  • 예외를 어떻게 처리할 것인가?
  • 운영 시 로그를 어떻게 추적할 것인가?

였다.

많은 개인 프로젝트들이 기능 구현 자체에만 집중한다.

하지만 실제 운영 환경에서는 기능보다 더 중요한 것이 존재한다.

예를 들면:

  • 어떤 요청이 실패했는가?
  • 왜 실패했는가?
  • 어떤 API에서 병목이 발생했는가?
  • 어떤 요청 흐름에서 장애가 발생했는가?

같은 문제들이다.

DATT는 단순 CRUD 프로젝트가 아니라:

1
"실무형 Java 백엔드 포트폴리오"

를 목표로 하고 있기 때문에, 가장 먼저 공통 응답 구조와 운영 기반을 구축하기로 했다.

이번 글에서는 다음 내용을 정리한다.

  • ApiResponse 기반 공통 응답 구조
  • GlobalExceptionHandler 기반 예외 처리
  • ErrorCode 설계 전략
  • Request Logging 구조
  • MDC 기반 traceId 적용 이유

본문

왜 공통 응답 구조가 필요한가

초기 프로젝트에서는 보통 아래처럼 Controller마다 응답 형식이 달라지는 경우가 많다.

예:

성공 응답:

1
2
3
4
{
    "id": 1,
    "name": "datt"
}

실패 응답:

1
2
3
{
    "message": "잘못된 요청입니다."
}

또 다른 API:

1
2
3
4
{
    "success": true,
    "result": {}
}

이런 구조는 시간이 지나면 점점 유지보수가 어려워진다.

특히 프론트엔드 입장에서는:

  • 어떤 필드가 항상 존재하는가?
  • 에러 응답은 어떤 구조인가?
  • Validation 에러는 어떻게 처리하는가?

를 API마다 다르게 처리해야 한다.

그래서 DATT에서는 모든 응답 구조를 통일하기로 했다.


ApiResponse 설계

최종적으로 선택한 구조는 다음과 같다.

정상 응답:

1
2
3
4
5
{
    "success": true,
    "data": {},
    "error": null
}

실패 응답:

1
2
3
4
5
6
7
8
9
{
    "success": false,
    "data": null,
    "error": {
        "code": "member.not_found",
        "message": "사용자를 찾을 수 없습니다.",
        "status": 404
    }
}

이를 위한 공통 응답 객체는 다음과 같이 설계했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public record ApiResponse<T>(
        boolean success,
        T data,
        ErrorResponse error
) {

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, null);
    }

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

    public static ApiResponse<Void> fail(ErrorResponse error) {
        return new ApiResponse<>(false, null, error);
    }
}

왜 Generic 기반으로 설계했는가

ApiResponse를 Generic으로 설계한 이유는 API마다 응답 타입이 달라지기 때문이다.

예를 들어:

회원 조회 API:

1
ApiResponse<MemberResponse>

장소 조회 API:

1
ApiResponse<PlaceResponse>

리스트 조회 API:

1
ApiResponse<List<PlaceResponse>>

처럼 사용할 수 있다.

이 방식의 장점은 다음과 같다.

  • 타입 안정성 확보
  • IDE 자동완성 지원
  • Swagger/OpenAPI 타입 추론 가능
  • 유지보수성 향상

왜 ErrorCode를 분리했는가

처음에는 단순히 message만 내려주는 구조도 고민했다.

예:

1
2
3
{
    "message": "사용자를 찾을 수 없습니다."
}

하지만 운영 환경에서는 message만으로는 부족하다.

왜냐하면:

  • 프론트 분기 처리
  • 로그 검색
  • 장애 추적
  • 에러 유형 분류

등이 어렵기 때문이다.

그래서 ErrorCode를 별도로 분리했다.


ErrorCode 설계 전략

초기에는 다음과 같은 숫자 기반 코드도 고려했다.

1
2
MEMBER_001
COMMON_400

하지만 이 방식은 점점 의미 파악이 어려워진다.

그래서 최종적으로는 semantic naming 방식을 선택했다.

예:

1
2
3
4
member.not_found
member.duplicated_email
auth.invalid_token
common.internal_server_error

이 방식의 장점은 다음과 같다.

  • 의미 파악이 직관적
  • 로그 검색이 쉬움
  • 번호 충돌 없음
  • 유지보수성 향상

실제 운영 환경에서도 semantic naming 기반 에러 코드가 점점 많아지는 추세다.


GlobalExceptionHandler 도입 이유

초기 프로젝트에서는 보통 Controller마다 try-catch를 남발하게 된다.

예:

1
2
3
4
5
try {
    ...
} catch (...) {
    ...
}

하지만 이 방식은:

  • 중복 코드 증가
  • 응답 구조 불일치
  • 유지보수 난이도 증가

문제가 발생한다.

그래서 DATT에서는 GlobalExceptionHandler 기반으로 예외를 중앙 집중 처리하도록 설계했다.

핵심 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
Controller
    ↓
Service
    ↓
Exception 발생
    ↓
GlobalExceptionHandler
    ↓
ApiResponse.fail()

즉:

1
2
비즈니스 로직은 예외만 발생시키고,
응답 생성은 GlobalExceptionHandler가 담당한다.

Validation 응답을 별도로 분리한 이유

Validation 오류는 일반 예외와 성격이 다르다.

예:

1
2
3
4
{
    "field": "email",
    "message": "이메일 형식이 올바르지 않습니다."
}

처럼 필드 단위 정보가 필요하다.

그래서 ValidationErrorResponse를 별도로 분리했다.

예상 응답:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
    "success": false,
    "data": null,
    "error": {
        "code": "common.invalid_input_value",
        "message": "요청 값이 올바르지 않습니다.",
        "status": 400,
        "errors": [
            {
                "field": "email",
                "message": "이메일 형식이 올바르지 않습니다."
            }
        ]
    }
}

왜 Request Logging을 먼저 구축했는가

기능 개발보다 먼저 Request Logging을 구축한 이유는 운영 관점 때문이다.

실제 운영 환경에서는:

  • 어떤 요청이 느린가?
  • 어떤 API에서 장애가 발생했는가?
  • 어떤 요청 흐름에서 예외가 발생했는가?

를 추적할 수 있어야 한다.

그래서 DATT에서는 OncePerRequestFilter 기반으로 Request Logging을 먼저 구축했다.

예상 로그:

1
event=request_completed traceId=abc123 method=GET uri=/api/health status=200 durationMs=12

traceId를 적용한 이유

처음에는 단순 request log만 남길 생각이었다.

하지만 요청 흐름을 추적하려면:

1
"같은 요청"

이라는 것을 식별할 수 있어야 한다.

그래서 MDC 기반 traceId를 적용했다.

핵심 흐름:

1
2
3
4
5
6
7
8
9
10
11
요청 시작
    ↓
traceId 생성
    ↓
MDC 저장
    ↓
로그 출력
    ↓
요청 종료
    ↓
MDC clear

이 구조를 통해:

  • 요청 단위 로그 추적
  • 장애 분석
  • 요청 흐름 연결

이 가능해진다.


왜 아직 Structured Logging까지는 하지 않았는가

실무에서는 JSON 기반 Structured Logging도 많이 사용한다.

예:

1
2
3
4
5
{
    "event": "request_completed",
    "traceId": "abc123",
    "durationMs": 12
}

하지만 현재 DATT 단계에서는 아직:

  • Grafana
  • Loki
  • ELK
  • OpenTelemetry

같은 관측 시스템이 없다.

그래서 현재는:

1
"단순 문자열 기반 운영 로그"

수준까지만 우선 적용했다.

추후:

  • Prometheus
  • Grafana
  • Loki
  • 분산 추적

등을 추가할 예정이다.


운영 관점에서 중요했던 부분

이번 작업에서 가장 중요하게 생각한 것은:

1
"왜 이렇게 설계하는가?"

였다.

단순히:

  • 예외 처리 구현
  • 응답 구조 구현

이 아니라:

  • 운영 시 어떤 문제가 발생하는가?
  • 장애 분석은 가능한가?
  • 요청 흐름 추적은 가능한가?
  • 응답 구조는 일관적인가?

를 계속 고민하면서 설계했다.


마무리

이번 작업을 통해 DATT v2는 단순 CRUD 프로젝트가 아니라:

  • 공통 응답 구조
  • 운영 로그 기반
  • 예외 처리 전략
  • 요청 추적 구조

를 갖춘 형태로 발전하기 시작했다.

아직은 초기 단계지만:

1
2
기능 구현보다
운영 가능한 구조를 먼저 만든다

는 방향으로 프로젝트를 진행하고 있다.

앞으로는:

  • JWT 인증
  • Place 검색
  • Review
  • Redis Cache
  • 성능 측정
  • 병목 분석

등을 추가적으로 구축할 예정이다.


핵심 요약

이번 작업의 핵심은 다음과 같다.

  • ApiResponse 기반 응답 구조 통일
  • ErrorCode 기반 에러 관리
  • GlobalExceptionHandler 기반 예외 처리 중앙화
  • Request Logging 기반 운영 로그 구축
  • MDC traceId 기반 요청 추적 구조 적용

그리고 가장 중요한 것은:

1
"실무 운영 관점"

에서 구조를 고민하기 시작했다는 점이다.

This post is licensed under CC BY 4.0 by the author.