Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
> java 21
> Mysql
> Redis


> ⚠ `domain`계층에서는 `CustomException`를 사용하지 않는다. ⚠
> * 의존성 최소화를 위해 `domain`계층에서 발생한 오류는 `CustomException`를 사용하지 않고 `application`계층에서 처리
Copy link

Choose a reason for hiding this comment

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

domain 에서 customException 을 사용하는데 네이밍 자체도 헷갈리 수 있어서 저는 별도의 DomainException 을 하나 두는것도 괜찮을 것 같아요.
domain 계층에서 domainException , ErrorCode 를 두고, adapter 에서 domainException 을 핸들링 하는 식으로요.

Copy link

Choose a reason for hiding this comment

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

의존성 최소화에만 초점이 되어있지만, 솔직히 불필요한 코드를 추가하지 않는 관점에서 보면 domain 계층에서 아예 사용하지 않는다는 과할 수 있을 것 같습니다.


### Junit5 테스트 환경
* DB : H2
* Redis : Testcontainers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.hanghae.application.port.in.MovieScheduleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -23,8 +24,7 @@ public class MovieController {
public ResponseEntity<ApiResponse<List<MovieScheduleResponseDto>>> getMovieSchedules() {
ApiResponse<List<MovieScheduleResponseDto>> response = movieScheduleService.getMovieSchedules();

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
return ResponseEntity.status(HttpStatus.OK).body(response);
}

//영화별 상영 시간표 조회 (grouping)
Expand All @@ -37,16 +37,18 @@ public ResponseEntity<ApiResponse<List<ShowingMovieScheduleResponseDto>>> getSho

ApiResponse<List<ShowingMovieScheduleResponseDto>> response = movieScheduleService.getShowingMovieSchedules(requestDto, ip);

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
if(response.success()) {
return ResponseEntity.status(HttpStatus.OK).body(response); //조회 성공
} else {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(response); // 조회 실패
Copy link

Choose a reason for hiding this comment

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

getShowingMovieSchedules 가 실패하면 모두 TOO_MANY_REQUESTS 로 처리하는게 맞는방법인지, 이렇게 하신 의도가 있었을까요?
조회 실패와 TOO_MANY_REQUESTS 는 다른 의미일 수도 있을 것 같아요.

}
}

//redis 캐시삭제 (테스트용)
@GetMapping("/test/evict-cache")
public ResponseEntity<ApiResponse<Void>> evictCache() {
ApiResponse<Void> response = movieScheduleService.evictShowingMovieCache();

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
return ResponseEntity.status(HttpStatus.NO_CONTENT).body(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import com.hanghae.application.dto.ApiResponse;
import com.hanghae.application.dto.request.MovieReservationRequestDto;
import com.hanghae.application.enums.ErrorCode;
import com.hanghae.application.enums.HttpStatusCode;
import com.hanghae.application.port.in.MovieReservationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -23,7 +25,12 @@ public class ReservationController {
public ResponseEntity<ApiResponse<Void>> saveMovieReservation(@RequestBody MovieReservationRequestDto requestDto) {
ApiResponse<Void> response = movieReservationService.saveMovieReservation(requestDto);

//응답코드 일치시켜서 리턴
return ResponseEntity.status(response.status().getCode()).body(response);
if(response.success()) {
return ResponseEntity.status(HttpStatus.CREATED).body(response); //조회 성공
} else if(response.errorCode() == ErrorCode.RATE_LIMIT_EXCEEDED) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(response); // 조회 실패
} else {
return ResponseEntity.status(HttpStatus.CONFLICT).body(response); // 조회 실패
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.hanghae.adapter.web.exception;

import com.hanghae.application.dto.ApiResponse;
import com.hanghae.application.enums.HttpStatusCode;
import com.hanghae.application.enums.ErrorCode;
import com.hanghae.application.exception.CustomRequestException;
import com.hanghae.application.exception.CustomServerException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -15,22 +17,36 @@ public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
log.error("IllegalArgumentException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.BAD_REQUEST);
return ResponseEntity.status(HttpStatusCode.BAD_REQUEST.getCode()).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of(false, e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse);
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
log.error("HttpMessageNotReadableException occurred: {}", e.getMessage(), e); // 로그 추가
Copy link

Choose a reason for hiding this comment

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

// 로그 추가 등의 의미없는 주석은 제거해도 좋을 것 같습니다.

ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.BAD_REQUEST);
return ResponseEntity.status(HttpStatusCode.BAD_REQUEST.getCode()).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of(false, e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse);
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException e) {
log.error("RuntimeException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), HttpStatusCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.status(HttpStatusCode.INTERNAL_SERVER_ERROR.getCode()).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of(false, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(apiResponse);
}

@ExceptionHandler(CustomRequestException.class)
public ResponseEntity<ApiResponse<Void>> handleCustomRequestException(CustomRequestException e) {
log.error("CustomRequestException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), e.getErrorCode());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(apiResponse);
}

@ExceptionHandler(CustomServerException.class)
public ResponseEntity<ApiResponse<Void>> handleCustomServerException(CustomServerException e) {
log.error("CustomServerException occurred: {}", e.getMessage(), e); // 로그 추가
ApiResponse<Void> apiResponse = ApiResponse.of(e.getMessage(), e.getErrorCode());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(apiResponse);
}

/**
Expand All @@ -39,7 +55,7 @@ public ResponseEntity<ApiResponse<Void>> handleRuntimeException(RuntimeException
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception e) {
log.error("Unhandled exception occurred: {}", e.getMessage(), e);
ApiResponse<Void> apiResponse = ApiResponse.of("서버 내부에 오류가 발생했습니다.", HttpStatusCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.status(HttpStatusCode.INTERNAL_SERVER_ERROR.getCode()).body(apiResponse);
ApiResponse<Void> apiResponse = ApiResponse.of("서버 내부에 오류가 발생했습니다.", ErrorCode.UNDEFINED_ERROR);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(apiResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ void saveMovieReservationSuccess() throws Exception {
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(requestDto)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.message").value("예매가 완료 되었습니다."));
.andExpect(jsonPath("$.message").value("예매가 완료되었습니다."));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
package com.hanghae.application.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.hanghae.application.enums.ErrorCode;
import com.hanghae.application.enums.HttpStatusCode;

import java.util.List;

@JsonInclude(JsonInclude.Include.NON_NULL) //null값 JSON에서 제외
public record ApiResponse<T> (
String message,
HttpStatusCode status,
T data
boolean success, // 성공여부
String message, // 응답메시지
ErrorCode errorCode, // 오류 발생시 오류 코드 및 메시지
Copy link

Choose a reason for hiding this comment

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

errorCode 보다는 HttpStatusCode 그대로 뒀어도 괜찮았을 것 같습니다.
message 에 error 메세지가 담겨도 충분할 것 같다는 생각이 듭니다.

T data // 응답 데이터
){
public static <T> ApiResponse<T> of(String message, HttpStatusCode status) {
return new ApiResponse<>(message, status, null);
// 메시지만 응답 (데이터X, 오류코드 X)
public static <T> ApiResponse<T> of(boolean success, String message) {
return new ApiResponse<>(success, message, null, null);
}

public static <T> ApiResponse<T> of(String message, HttpStatusCode status, T data) {
// 오류시 응딥 (데이터 X)
public static <T> ApiResponse<T> of(String message, ErrorCode errorCode) {
return new ApiResponse<>(false, message, errorCode, null);
}

// 데이터 포함된 응답 (오류코드 X)
public static <T> ApiResponse<T> of(boolean success, String message, T data) {
if ((data == null || (data instanceof List<?> list && list.isEmpty())) && (message == null || message.isEmpty())) {
return new ApiResponse<>("조회된 결과가 없습니다.", HttpStatusCode.OK, null);
return new ApiResponse<>(success, "조회된 결과가 없습니다.", null, null);
}
return new ApiResponse<>(message, status, data);
return new ApiResponse<>(success, message, null, data);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.hanghae.application.enums;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
@JsonFormat(shape = JsonFormat.Shape.OBJECT) // 객체 형태로 변환 (응답받는 곳에서 코드값, 메시지 같이 보여짐)
public enum ErrorCode {
/**
* 코드 = 도메인 + 상세코드
* SYS : 공통
* MOV : 영화
* RSV : 예매
* ERR : 오류
*/
SEARCH_REQUEST_BLOCKED("SYS-001", "너무 많은 요청으로 차단되었습니다."),
RATE_LIMIT_EXCEEDED("SYS-002", "일정 시간내 재요청이 불가합니다."),
SEAT_NOT_AVAILABLE("RSV-001", "해당 좌석은 예매할 수 없습니다."),
RESERVATION_LIMIT_EXCEEDED("RSV-002", "상영시간별 최대 예매 수를 초과 하였습니다."),
SEAT_ALREADY_RESERVED("RSV-003", "이미 예매된 좌석입니다."),
SEAT_NOT_FOUND("RSV-004", "선택할 수 없는 좌석입니다."),
UNDEFINED_ERROR("ERR-999", "서버 내부에 오류가 발생했습니다.");

private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.hanghae.application.exception;

import com.hanghae.application.enums.ErrorCode;
import lombok.Getter;

/**
* 요청 오류 Exception
* 400 응답위해 Exception 구분
*/
@Getter
public class CustomRequestException extends RuntimeException {
private final String message;
private final ErrorCode errorCode;

public CustomRequestException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.message = null;
}

public CustomRequestException(String message, ErrorCode errorCode) {
super(message != null ? message : errorCode.getMessage());
this.errorCode = errorCode;
this.message = message;
}

public String getErrorMessage() {
return message != null ? message : errorCode.getMessage();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.hanghae.application.exception;

import com.hanghae.application.enums.ErrorCode;
import lombok.Getter;

/**
* 서버 내부 오류 Exception
* 500 응답위해 Exception 구분
*/
@Getter
public class CustomServerException extends RuntimeException {
private final String message;
private final ErrorCode errorCode;

public CustomServerException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.message = null;
}

public CustomServerException(String message, ErrorCode errorCode) {
super(message != null ? message : errorCode.getMessage());
this.errorCode = errorCode;
this.message = message;
}

public String getErrorMessage() {
return message != null ? message : errorCode.getMessage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import com.hanghae.application.dto.ApiResponse;
import com.hanghae.application.dto.request.MovieReservationRequestDto;
import com.hanghae.application.enums.HttpStatusCode;
import com.hanghae.application.enums.ErrorCode;
import com.hanghae.application.exception.CustomRequestException;
import com.hanghae.application.port.in.MovieReservationService;
import com.hanghae.application.port.out.message.MessagePort;
import com.hanghae.application.port.out.redis.RedisRateLimitPort;
Expand All @@ -21,6 +22,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
Expand All @@ -45,49 +47,64 @@ public ApiResponse<Void> saveMovieReservation(MovieReservationRequestDto request

//동 시간대의 영화를 5분에 1번씩 예매 제한
if (!redisRateLimitPort.canReserve(scheduleId, memberId)) {
return ApiResponse.of("동일 시간대 영화 예매는 5분 후 가능합니다.", HttpStatusCode.TOO_MANY_REQUESTS);
return ApiResponse.of("동일 시간대 영화 예매는 5분 후 가능합니다.", ErrorCode.RATE_LIMIT_EXCEEDED);
}

// 예매할 좌석 목록 가져오기
List<ScreenSeat> selectedSeats = ScreenSeat.getSelectedConnectedSeats(screenSeat, seatCount);

try {
// 여러 좌석에 대해 Redisson 함수형 분산락 획득
// 람다식(Supplier<T>)으로 받은 함수의 리턴값을 그대로 리턴
return redissonLockPort.executeWithSeatsLocks(scheduleId, selectedSeats, () -> {
// 영화 스케줄 가져오기
ScreeningSchedule screeningSchedule = screeningScheduleRepositoryPort.findById(scheduleId);
// 회원정보 조회
Member member = memberRepositoryPort.findById(memberId);
// 선택 상영관 좌석 최대값 가져오기
ScreenSeatLayout screenSeatLayout = screenSeatLayoutRepositoryPort.findBySeatRowAndScreenId(screenSeat.getSeatRow(), screeningSchedule.getScreen().getScreenId());

// 해당 회원의 예매내역 조회
int reservedTicketCount = ticketReservationRepositoryPort.countByScreeningScheduleIdAndMemberId(scheduleId, memberId);
// 여러 좌석에 대해 Redisson 함수형 분산락 획득
// 람다식(Supplier<T>)으로 받은 함수의 리턴값을 그대로 리턴
return redissonLockPort.executeWithSeatsLocks(scheduleId, selectedSeats, () -> {
// 영화 스케줄 가져오기
ScreeningSchedule screeningSchedule = screeningScheduleRepositoryPort.findById(scheduleId);
// 회원정보 조회
Member member = memberRepositoryPort.findById(memberId);
// 선택 상영관 좌석 최대값 가져오기
ScreenSeatLayout screenSeatLayout = screenSeatLayoutRepositoryPort.findBySeatRowAndScreenId(screenSeat.getSeatRow(), screeningSchedule.getScreen().getScreenId());

// 해당 회원의 예매내역 조회
int reservedTicketCount = ticketReservationRepositoryPort.countByScreeningScheduleIdAndMemberId(scheduleId, memberId);

try {
// 예매 내역 5개 초과 검증
reservationService.validateReservationSeatLimit(reservedTicketCount, seatCount);
} catch (IllegalArgumentException e) { // 도메인 계층에서 발생한 오류 발생시 별도 처리
throw new CustomRequestException(e.getMessage(), ErrorCode.RESERVATION_LIMIT_EXCEEDED);
}

// 예매하려는 좌석에 대한 예매내역 조회
int duplicateReservationCount = ticketReservationRepositoryPort.countByScheduleIdAndScreenSeats(scheduleId, selectedSeats);
// 예매하려는 좌석에 대한 예매내역 조회
int duplicateReservationCount = ticketReservationRepositoryPort.countByScheduleIdAndScreenSeats(scheduleId, selectedSeats);

try {
// 예매 좌석 중복 내역 확인하기
reservationService.validateSeatAvailability(duplicateReservationCount);
} catch (IllegalArgumentException e) { // 도메인 계층에서 발생한 오류 발생시 별도 처리
throw new CustomRequestException(e.getMessage(), ErrorCode.SEAT_ALREADY_RESERVED);
}

List<TicketReservation> ticketReservations = new ArrayList<>();
try {
// 예매 내역 생성
List<TicketReservation> ticketReservations = reservationService.createTicketReservations(screeningSchedule, member, screenSeat, screenSeatLayout, seatCount);
ticketReservations = reservationService.createTicketReservations(screeningSchedule, member, screenSeat, screenSeatLayout, seatCount);
} catch (IllegalArgumentException e) { // 도메인 계층에서 발생한 오류 발생시 별도 처리
throw new CustomRequestException(e.getMessage(), ErrorCode.SEAT_NOT_FOUND);
}

if(!ticketReservations.isEmpty()) {
// 예매 내역 저장
ticketReservationRepositoryPort.saveMovieReservations(ticketReservations);

// 예매 성공 후 Redis 제한 설정
redisRateLimitPort.setReservationLimit(scheduleId, memberId);

//완료 메시지 전송 (비동기)
messagePort.sendMessage("영화 예매가 완료 되었습니다.");
messagePort.sendMessage("영화 예매가 완료되었습니다.");
Copy link

Choose a reason for hiding this comment

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

위, 아래의 메세지 포멧이 다른데 통일하고 상수로 빼는게 좋을 것 같습니다


return ApiResponse.of("예매가 완료 되었습니다.", HttpStatusCode.CREATED);
});
} catch (IllegalStateException e) {
return ApiResponse.of("현재 좌석을 다른 사용자가 예매 처리 중입니다.", HttpStatusCode.CONFLICT);
}
return ApiResponse.of(true, "예매가 완료되었습니다.");
} else {
return ApiResponse.of(false, "예매에 실패하였습니다.");
}
});
}
}
Loading