Skip to content
3 changes: 3 additions & 0 deletions reservation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ jacocoTestReport {
}

dependencies {
// === 인증 모듈 추가 ===
implementation project(':authorization-shared')
// === Event Schema Shared 모듈 ===
implementation project(':event-schema-shared')

implementation 'org.springframework.boot:spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package net.catsnap.CatsnapReservation.shared;

import org.springframework.http.HttpStatusCode;

/**
* 결과 코드 인터페이스
* <p>
* 모든 레이어에서 공통으로 사용하는 결과 코드 인터페이스입니다.
* 도메인 에러와 프레젠테이션 에러 모두 이 인터페이스를 구현합니다.
* </p>
* <p>
* 결과 코드 형식:
* <ul>
* <li>성공 코드는 "S"로 시작 (예: SC000, SM001)</li>
* <li>에러 코드는 "E"로 시작 (예: EC000, EM001)</li>
* <li>2번째 문자는 도메인 카테고리를 나타냄 - R : reservation 모듈</li>
* </ul>
* </p>
*/
public interface ResultCode {

/**
* HTTP 상태 코드를 반환합니다.
* <p>
* ResponseEntity의 상태 코드로 사용됩니다.
* </p>
*/
HttpStatusCode getHttpStatus();

/**
* 비즈니스 상태 코드를 반환합니다.
* <p>
* 응답 body에 포함되어 더 세밀한 상태를 나타냅니다.
* </p>
*/
String getCode();

/**
* 상태에 대한 설명 메시지를 반환합니다.
*/
String getMessage();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package net.catsnap.CatsnapReservation.shared.domain.error;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import net.catsnap.CatsnapReservation.shared.ResultCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;

/**
* 도메인 에러 코드
* <p>
* 비즈니스 규칙 위반 시 발생하는 에러를 정의합니다.
* 예약 중복, 불가능한 시간대, 이미 취소된 예약 등 도메인 로직 관련 에러를 담당합니다.
* </p>
*/
@Getter
@RequiredArgsConstructor
public enum DomainErrorCode implements ResultCode {

/**
* 도메인 제약 조건 위반
* <p>
* 도메인 로직 상 허용되지 않는 값이나 상태인 경우
* </p>
*/
DOMAIN_CONSTRAINT_VIOLATION(HttpStatus.BAD_REQUEST, "ED000", "해당 값이 유효하지 않습니다.");

private final HttpStatusCode httpStatus;
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method overrides ResultCode.getHttpStatus; it is advisable to add an Override annotation.

Copilot uses AI. Check for mistakes.
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package net.catsnap.CatsnapReservation.shared.domain.error;

import lombok.Getter;
import net.catsnap.CatsnapReservation.shared.ResultCode;

/**
* 도메인 예외 최상위 클래스
* <p>
* 도메인 레이어에서 비즈니스 규칙 위반 시 발생하는 모든 예외의 기본 클래스입니다.
* ResultCode를 포함하여 일관된 응답 형식을 제공합니다.
* </p>
*/
@Getter
public class DomainException extends RuntimeException {

private final ResultCode resultCode;

/**
* ResultCode를 포함한 비즈니스 예외를 생성합니다.
*
* @param resultCode 응답에 사용할 결과 코드
*/
public DomainException(ResultCode resultCode) {
super(resultCode.getMessage());
this.resultCode = resultCode;
}

/**
* ResultCode와 원인 예외를 포함한 비즈니스 예외를 생성합니다.
*
* @param resultCode 응답에 사용할 결과 코드
* @param cause 원인 예외
*/
public DomainException(ResultCode resultCode, Throwable cause) {
super(resultCode.getMessage(), cause);
this.resultCode = resultCode;
}

/**
* ResultCode와 추가 메시지를 포함한 비즈니스 예외를 생성합니다.
*
* @param resultCode 응답에 사용할 결과 코드
* @param additionalMessage 추가 메시지
*/
public DomainException(ResultCode resultCode, String additionalMessage) {
super(resultCode.getMessage() + " - " + additionalMessage);
this.resultCode = resultCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package net.catsnap.CatsnapReservation.shared.presentation;

import lombok.extern.slf4j.Slf4j;
import net.catsnap.CatsnapReservation.shared.domain.error.DomainException;
import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode;
import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException;
import net.catsnap.CatsnapReservation.shared.presentation.response.ResultResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;

/**
* 전역 예외 처리 핸들러
* <p>
* 애플리케이션에서 발생하는 예외를 일관된 응답 형식으로 변환합니다.
* </p>
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 존재하지 않는 API 엔드포인트 요청 처리
*/
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ResultResponse<Void>> handleNoHandlerFoundException(
NoHandlerFoundException e) {
log.warn("NoHandlerFoundException: {}", e.getMessage());
return ResultResponse.of(PresentationErrorCode.NOT_FOUND_API);
}

/**
* 필수 요청 파라미터 누락 처리
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ResultResponse<Void>> handleMissingServletRequestParameterException(
MissingServletRequestParameterException e) {
log.warn("MissingServletRequestParameterException: {}", e.getMessage());
return ResultResponse.of(PresentationErrorCode.MISSING_REQUEST_PARAMETER);
}

/**
* 잘못된 요청 바디 처리
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ResultResponse<Void>> handleHttpMessageNotReadableException(
HttpMessageNotReadableException e) {
log.warn("HttpMessageNotReadableException: {}", e.getMessage());
return ResultResponse.of(PresentationErrorCode.INVALID_REQUEST_BODY);
}

/**
* 지원하지 않는 HTTP 메서드 처리
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ResultResponse<Void>> handleHttpRequestMethodNotSupportedException(
HttpRequestMethodNotSupportedException e) {
log.warn("HttpRequestMethodNotSupportedException: {}", e.getMessage());
return ResultResponse.of(PresentationErrorCode.METHOD_NOT_ALLOWED);
}

/**
* 지원하지 않는 미디어 타입 처리
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ResultResponse<Void>> handleHttpMediaTypeNotSupportedException(
HttpMediaTypeNotSupportedException e) {
log.warn("HttpMediaTypeNotSupportedException: {}", e.getMessage());
return ResultResponse.of(PresentationErrorCode.UNSUPPORTED_MEDIA_TYPE);
}

/**
* @Valid 검증 실패 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ResultResponse<Void>> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
FieldError fieldError = e.getBindingResult().getFieldError();
String errorMessage =
fieldError != null ? fieldError.getDefaultMessage() : "입력값이 올바르지 않습니다.";

log.warn("MethodArgumentNotValidException: {}", errorMessage);
return ResultResponse.of(PresentationErrorCode.INVALID_REQUEST_BODY);
}
Comment on lines +79 to +91
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

검증 실패 상세를 응답에 포함하는 방안 검토

errorMessage를 계산하지만 응답에는 사용하지 않아 클라이언트가 실패 원인을 파악하기 어렵습니다. ResultResponse가 메시지/필드 오류 전달을 지원한다면 포함을 고려해 주세요.

🤖 Prompt for AI Agents
In
`@reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/GlobalExceptionHandler.java`
around lines 79 - 91, The handler
GlobalExceptionHandler.handleMethodArgumentNotValidException computes a detailed
errorMessage but returns
ResultResponse.of(PresentationErrorCode.INVALID_REQUEST_BODY) without including
it; change the return to include the message (and field name if available) via
ResultResponse's API so the client sees the failure reason — e.g. extract
fieldName = fieldError != null ? fieldError.getField() : null and pass
errorMessage (and fieldName) into ResultResponse (for example
ResultResponse.of(PresentationErrorCode.INVALID_REQUEST_BODY, errorMessage) or
ResultResponse.of(..., fieldName, errorMessage) depending on the existing
ResultResponse factory/builder).


/**
* 프레젠테이션 예외 처리 (인증/인가 실패 포함)
* <p>
* 인증 헤더가 없거나 유효하지 않은 경우, 권한이 없는 경우 등
* 프레젠테이션 레이어에서 발생하는 예외를 처리합니다.
* </p>
*/
@ExceptionHandler(PresentationException.class)
public ResponseEntity<ResultResponse<Void>> handlePresentationException(
PresentationException e) {
log.warn("PresentationException: [{}] {}", e.getResultCode().getCode(), e.getMessage());
return ResultResponse.of(e.getResultCode());
}

/**
* 도메인 예외 처리
* <p>
* DomainException에 포함된 ResultCode를 사용하여 응답을 생성합니다.
* </p>
*/
@ExceptionHandler(DomainException.class)
public ResponseEntity<ResultResponse<Void>> handleDomainException(DomainException e) {
log.warn("DomainException: [{}] {}", e.getResultCode().getCode(), e.getMessage());
return ResultResponse.of(e.getResultCode());
}

/**
* 기타 모든 예외 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ResultResponse<Void>> handleException(Exception e) {
log.error("Unexpected exception occurred", e);
return ResultResponse.of(PresentationErrorCode.INTERNAL_SERVER_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package net.catsnap.CatsnapReservation.shared.presentation.error;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import net.catsnap.CatsnapReservation.shared.ResultCode;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;

/**
* 프레젠테이션 레이어 에러 코드
* <p>
* API 요청/응답 처리 과정에서 발생하는 에러를 정의합니다.
* 잘못된 요청 형식, 존재하지 않는 엔드포인트, 인증/인가 실패 등 HTTP 레벨의 에러를 담당합니다.
* </p>
*/
@Getter
@RequiredArgsConstructor
public enum PresentationErrorCode implements ResultCode {

// ========== API 요청 관련 에러 (EA0XX) ==========

/**
* 존재하지 않는 API 엔드포인트
*/
NOT_FOUND_API(HttpStatus.NOT_FOUND, "EA000", "존재하지 않는 API입니다."),

/**
* 필수 요청 파라미터 누락
*/
MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "EA001", "필수 요청 파라미터가 누락되었습니다."),

/**
* 잘못된 요청 바디
*/
INVALID_REQUEST_BODY(HttpStatus.BAD_REQUEST, "EA002", "요청 바디가 올바르지 않습니다."),

/**
* 서버 내부 오류
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "EA003", "서버 내부 오류가 발생했습니다."),

/**
* 잘못된 요청 형식
*/
INVALID_REQUEST_FORMAT(HttpStatus.BAD_REQUEST, "EA004", "요청 형식이 올바르지 않습니다."),

/**
* 메서드 지원 안 함
*/
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "EA005", "지원하지 않는 HTTP 메서드입니다."),

/**
* 미지원 미디어 타입
*/
UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "EA006", "지원하지 않는 미디어 타입입니다."),

// ========== 인증/인가 관련 에러 (EA1XX) ==========

/**
* 인증 정보 없음 (401 Unauthorized)
* <p>
* 요청에 인증 헤더가 없거나 비어있는 경우
* </p>
*/
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "EA100", "인증 정보가 없습니다."),

/**
* 유효하지 않은 권한 정보 (401 Unauthorized)
* <p>
* 인증 헤더의 권한 값이 유효하지 않은 경우
* </p>
*/
INVALID_AUTHORITY(HttpStatus.UNAUTHORIZED, "EA101", "유효하지 않은 권한 정보입니다."),

/**
* 접근 권한 없음 (403 Forbidden)
* <p>
* 인증은 되었으나 해당 리소스에 접근할 권한이 없는 경우
* </p>
*/
FORBIDDEN(HttpStatus.FORBIDDEN, "EA102", "접근 권한이 없습니다."),

/**
* 유효하지 않은 Passport (401 Unauthorized)
* <p>
* Passport 서명 검증에 실패했거나 파싱할 수 없는 경우
* </p>
*/
INVALID_PASSPORT(HttpStatus.UNAUTHORIZED, "EA103", "유효하지 않은 Passport입니다."),

/**
* 만료된 Passport (401 Unauthorized)
* <p>
* Passport의 유효기간이 만료된 경우
* </p>
*/
EXPIRED_PASSPORT(HttpStatus.UNAUTHORIZED, "EA104", "만료된 Passport입니다.");

Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method overrides ResultCode.getMessage; it is advisable to add an Override annotation.

Suggested change
@Override
public String getMessage() {
return message;
}

Copilot uses AI. Check for mistakes.
private final HttpStatusCode httpStatus;
private final String code;
private final String message;
}
Loading