Skip to content

Conversation

@kimsky247-coder
Copy link

안녕하세요 혜빈님! 이번 미션도 잘 부탁 드립니다

프로젝트 구조

- controller
  * HomeController.java: 메인 페이지(/) GET 요청을 처리합니다.
  * ReservationController.java: 예약 관련 API요청을 처리합니다.
  * ReservationViewController.java: 예약 관리 페이지 요청을 처리합니다.

- domain
  * Reservation.java: 예약의 핵심 데이터(ID, 이름, 날짜, 시간)를 정의하는 도메인 객체.

- dto
  * ReservationRequest.java: 예약 생성 요청 데이터를 담는 DTO.
  * ReservationResponse.java: API 응답 데이터를 담는 DTO.

- exception
  * BadRequestReservationException.java: 400 Bad Request에 해당하는 커스텀 예외.
  * NotFoundReservationException.java: 404/400 (찾을 수 없음)에 해당하는 커스텀 예외.
  * GlobalExceptionHandler.java: 모든 컨트롤러의 예외를 전역적으로 처리하는 핸들러.

- service
  * ReservationService.java: 비즈니스 로직(유효성 검사, 저장/삭제 로직)을 담당하는 서비스 계층

궁금한 점

3, 4단계를 구현하면서 Controller의 역할이 많아져 ReservationService클래스를 만들어 데이터 저장, 비즈니스 로직, 유효성 검증을 담당하도록 분리하였습니다. 역할을 분리하면서 현재 Service가 너무 많은 책임을 지고 있지는 않은지 궁금합니다. 또한, 이와 관련해서 유효성 검증 로직을 DTO로 옮겨서 처리하고, Service는 핵심 비즈니스 로직에만 집중하도록 하는 것이 더 올바른 역할 분리인지 의견을 듣고 싶습니다!

@c0mpuTurtle
Copy link

c0mpuTurtle commented Nov 14, 2025

🌱<인사>

안녕하세요 하늘님 :) 또 만나뵙게 되어 영광입니다~

제가 던지는 질문의 의도는 ~~이렇게 반영해주세요. 가 아닌
한 번 이 주제에 대해 생각해보세요.에 가까우니 편하게 하늘님의 의견을 말씀해주시면 됩니다.

그럼 이번 미션도 화이팅입니다!!
저희 잘 해봐요 ㅎㅎㅎ


❓<질문에 대한 답>

🐤(하늘) 제 코드의 Service가 너무 많은 책임을 지고 있나요?

->
현재 Service 로직이 복잡하거나 과하게 무겁다는 느낌까지는 아닙니다.

다만 입력값 검증(null 체크 등)과 에러 메시지까지 Service에서 모두 처리하고 있다 보니
비즈니스 로직, 에러메세지, 단순 입력 검증 로직이 한 곳에 섞여 있는 점은 조금 아쉬운 부분은 있어요.

이 부분을 분리해주면 Service는 예약 생성/삭제 같은 핵심 흐름에만 집중할 수 있어서 구조가 더 깔끔해질 것 같습니다 :0

참고로 에러 메시지는 DTO의 validation 메시지나 전용 enum 등으로 따로 관리하면 Service 내부가 훨씬 가벼워져요.
관련 PR도 함께 참고해보시면 이해가 더 쉬울 거예요!
#521 (comment)

🐤(하늘) 유효성 검증 로직을 Service말고 DTO로 두는 것이 더 적합할까요?

->
네, 단순 입력 검증(예: "이름은 필수값입니다.")이라면 DTO로 옮기는 쪽이 더 적합해 보여요!
특히 Bean Validation(@notblank, @NotNull 등)을 사용하면 필수값·형식 검증은 DTO에서 처리되고,
Service는 비즈니스 로직에만 집중할 수 있어서 역할이 자연스럽게 분리됩니다.

다만 **"예약을 찾을 수 없습니다"**처럼 단순 값 검증이 아닌 도메인 규칙이나 비즈니스 흐름에 따른 검증은
Service에서 처리하는 편이 더 적절합니다.
관련 pr

Comment on lines +25 to +30
@GetMapping("/reservations")
public ResponseEntity<List<ReservationResponse>> getReservations() {
List<ReservationResponse> responses = reservationService.getAllReservations();
return ResponseEntity.ok().body(responses);
}

Copy link

@c0mpuTurtle c0mpuTurtle Nov 15, 2025

Choose a reason for hiding this comment

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

스크린샷 2025-11-16 오전 3 16 28 사진을 참고하여 이 부분의 Spring MVC 동작과정을 설명해볼까요?
  • 키워드 : controller로 Data 반환

Copy link
Author

Choose a reason for hiding this comment

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

(1) DispatcherServletGET /reservations 요청을 받습니다.
(2) HandlerMapping getReservations() 메서드를 찾아냅니다.
(3)→(4) HandlerAdapterControllergetReservations() 메서드를 실행시킵니다.
(5) ControllerService를 호출하여 예약 목록 데이터를 가져옵니다.
(6)→(7) ControllerResponseEntity 객체를 생성하여 HandlerAdapter를 통해 DispatcherServlet으로 반환합니다.
(8) DispatcherServlet은 이 ResponseEntity를 JSON으로 변환하여 클라이언트에게 최종 응답합니다.

Comment on lines +8 to +12
@GetMapping("/")
public String home() {
return "home";
}
}

Choose a reason for hiding this comment

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

스크린샷 2025-11-16 오전 3 21 11

사진을 참고하여 이 부분의 Spring MVC 동작과정을 설명해볼까요?

  • 키워드 : controller로 view 반환

Copy link
Author

Choose a reason for hiding this comment

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

(1) DispatcherServletGET /요청을 받습니다.
(2) HandlerMapping이 요청을 처리할 Controllerhome() 메서드를 찾아냅니다.
(3)→(4) HandlerAdapterControllerhome() 메서드를 실행시킵니다.
(5) ControllerService를 호출하지 않고, (6)으로 넘어갑니다.
(6) home() 메서드가 "home" 문자열을 반환합니다.
(7) DispatcherServletController로부터 "home"이라는 View Name을 전달받습니다.
(9) DispatcherServlet은 이 View NameViewResolver에게 보내 이름에 맞는 HTML 뷰를 찾아달라고 요청합니다.
(10) ViewResolvertemplates/home.html 파일을 찾아 렌더링하고, DispatcherServlet은 이 HTML 페이지를 클라이언트에게 최종 응답합니다.

Comment on lines +17 to +18
@RestController
public class ReservationController {

Choose a reason for hiding this comment

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

HomeController에서는 Controller 어노테이션을 붙이셨는 데 여기에는 왜 RestController를 쓰셨는 지 여쭤봐도 될까요?
둘의 차이점은 무엇일까요?

Copy link
Author

Choose a reason for hiding this comment

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

@Controller는 HTML 페이지를 반환하기 위해 사용됩니다. @RestController@Controller@ResponseBody가 합쳐진 어노테이션으로 데이터를 반환합니다. HomeControllerhome.html이라는 View를 반환해야 하므로 @Controller를 사용했습니다. 반면, ReservationController는 JSON 데이터만 제공하는 것이 목적입니다.
따라서 데이터를 반환하는 컨트롤러임을 명확히 하기 위해 @RestController를 사용했습니다!

Comment on lines 1 to 4
package roomescape.dto;

public record ReservationRequest(String name, String date, String time) {
}
Copy link

@c0mpuTurtle c0mpuTurtle Nov 15, 2025

Choose a reason for hiding this comment

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

name, date, time 중 하나라도 빠지면 Reservation은 생성되면 안 됩니다.

그럼 @NotBlank 또는@NotNull 또는 @NotEmpty을 붙여 DTO단계에서 필드검증을 해보는 건 어떨까요?
그리고 이 어노테이션들의 차이점은 뭘까요?

  • 추가적인 중요 키워드 : @Valid

Copy link
Author

Choose a reason for hiding this comment

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

@NotNull은 오직 null 값만 허용하지 않습니다. 만약 값이 빈 문자열 이거나 공백만 있는 문자열 이더라도, null이 아니기 때문에 검증을 통과시킵니다.
@NotEmptynull 값을 허용하지 않는 것은 물론이고, 길이가 0인 빈 문자열도 허용하지 않습니다. 하지만 공백만 있는 문자열은 길이가 1 이상이므로 검증을 통과시킵니다.
@NotBlank는 이 셋 중에서 가장 엄격하며 문자열전용입니다. null과 빈 문자열을 허용하지 않을 뿐만 아니라, 공백 만으로 이루어진 문자열까지도 유효하지 않다고 판단하여 허용하지 않습니다.

@Valid 어노테이션은 Spring이 DTO 내부에 선언된 위의 규칙들을 검사하도록 활성화하는 역할을 합니다.

공백까지 검증하는 @NotBlank가 가장 적합하다고 생각하여

public record ReservationRequest(
        @NotBlank(message = "이름은 필수 항목 입니다.")
        String name,
        @NotBlank(message = "날짜는 필수 항목입니다.")
        String date,
        @NotBlank(message = "시간은 필수 항목입니다.")
        String time
) {
}

코드를 위와 같이 수정하였습니다

@@ -0,0 +1,36 @@
package roomescape.domain;

import com.fasterxml.jackson.annotation.JsonFormat;

Choose a reason for hiding this comment

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

Reservation 도메인에 JsonFormat import가 들어가 있는데, 현재 클래스 내부에서는 해당 어노테이션을 사용하지 않는 것 같아요.

필요없는 import는 지워볼까요?

Copy link
Author

Choose a reason for hiding this comment

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

불필요한 import가 남아있었습니다. 바로 삭제했습니다!

Comment on lines +12 to +14
LocalDate date,
@JsonFormat(pattern = "HH:mm")
LocalTime time
Copy link

@c0mpuTurtle c0mpuTurtle Nov 15, 2025

Choose a reason for hiding this comment

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

json 포맷을 잘 활용해주었어요 :)
제가 보기엔 date 필드도 포맷을 지정해주면 응답 형식이 더 일관되게 보일 것 같아요.!

Copy link
Author

Choose a reason for hiding this comment

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

date 필드는 LocalDate의 기본 형식으로도 올바르게 나온다고 생각해서 이 부분을 놓쳤습니다. 하지만 말씀해주신대로 @JsonFormat(pattern = "yyyy-MM-dd")로 포맷을 지정하는 것이 일관성 측면에서 훨씬 보기 좋을 것 같아 추가했습니다!

Comment on lines 53 to 63
private void validateReservationRequest(ReservationRequest request) {
if (request.name() == null || request.name().isEmpty()) {
throw new BadRequestReservationException("이름은 필수 항목입니다.");
}
if (request.date() == null || request.date().isEmpty()) {
throw new BadRequestReservationException("날짜는 필수 항목입니다.");
}
if (request.time() == null || request.time().isEmpty()) {
throw new BadRequestReservationException("시간은 필수 항목입니다.");
}
}

Choose a reason for hiding this comment

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

request 값에 대한 검증은 Controller + DTO 단에서 처리해도 좋을 것 같아요.
이렇게 분리하면 Service는 비즈니스 로직에만 집중할 수 있어서
전체 구조가 더 가벼워질 것 같습니다 :)

Copy link
Author

Choose a reason for hiding this comment

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

위 메서드는 @notblank로 DTO 단계에서 처리하도록 수정해 삭제했습니다!

Comment on lines 48 to 63
if (!removed) {
throw new NotFoundReservationException("예약을 찾을 수 없습니다");
}
}

private void validateReservationRequest(ReservationRequest request) {
if (request.name() == null || request.name().isEmpty()) {
throw new BadRequestReservationException("이름은 필수 항목입니다.");
}
if (request.date() == null || request.date().isEmpty()) {
throw new BadRequestReservationException("날짜는 필수 항목입니다.");
}
if (request.time() == null || request.time().isEmpty()) {
throw new BadRequestReservationException("시간은 필수 항목입니다.");
}
}

Choose a reason for hiding this comment

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

지금 보니까 에러 클래스는 따로 관리하고 있는데, 에러 메시지는 Service 내부에서 관리되고 있네요 ㅜㅠ

개인적으로는 에러와 메시지를 한 곳에서 함께 관리하는 편이 유지보수에도 더 좋고, 확장할 때도 안정적이라고 느껴졌어요.
그래서 error 관련 내용을 enum으로 통일해서 관리해보는 건 어떨까 조심스럽게 제안드립니다 😊

ex)

@Getter
@AllArgsConstructor
public enum FailMessage {

    //400
    BAD_REQUEST(HttpStatus.BAD_REQUEST, 40000, "잘못된 요청입니다."),
    BAD_REQUEST_REQUEST_BODY_VALID(HttpStatus.BAD_REQUEST, 40001, "잘못된 요청본문입니다."),
    BAD_REQUEST_MISSING_PARAM(HttpStatus.BAD_REQUEST, 40002, "필수 파라미터가 없습니다."),
    BAD_REQUEST_METHOD_ARGUMENT_TYPE(HttpStatus.BAD_REQUEST, 40003, "메서드 인자타입이 잘못되었습니다."),
    BAD_REQUEST_NOT_READABLE(HttpStatus.BAD_REQUEST, 40004, "Json 오류 혹은 요청본문 필드 오류 입니다. ");
private final HttpStatus httpStatus;
    private final int code;
    private final String message;
}

Choose a reason for hiding this comment

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

단순한 DTO 필드 검증 메시지 정도라면 필드앞에 어노테이션을 붙여서 메세지 관리를 해도 좋겠네요 :)
다만, 반복적으로 재사용될 경우에는 추천하지 않습니다.

ex)

@NotBlank(message = "이름은 필수 값입니다.") 

Copy link
Author

Choose a reason for hiding this comment

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

Service 내부에 에러 메시지를 하드코딩한 것이 유지보수에 좋지 않다는 점에 동의합니다. 제안해주신 enum 방식을 참고해 ErrorMessage enum을 만들어 모든 에러 메시지와 상태 코드를 한 곳에서 관리하도록 리팩토링했습니다!

public enum ErrorMessage {
    INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "필수 항목이 누락되었거나 유효하지 않은 값입니다."),
    INVALID_DATE_TIME_FORMAT(HttpStatus.BAD_REQUEST, "날짜 또는 시간 형식이 올바르지 않습니다."),
    NOT_FOUND_RESERVATION(HttpStatus.BAD_REQUEST, "예약을 찾을 수 없습니다.");

    private final HttpStatus httpStatus;
    private final String message;

Comment on lines 9 to 14
public record ReservationResponse(
Long id,
String name,
LocalDate date,
@JsonFormat(pattern = "HH:mm")
LocalTime time

Choose a reason for hiding this comment

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

현재 Reservation 엔티티와 Response DTO의 필드 구성이 동일해 보이는데요,
DTO를 따로 분리하신 의도가 무엇일까요??

Copy link
Author

Choose a reason for hiding this comment

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

DTO를 분리한 이유는 ReservationReservationResponse의 역할을 분리하기 위해서 입니다. 시간을 지정된 형식으로 변환해야 하는데, 형식 변환 기능은 Reservation이 아닌 ReservationResponse가 담당해야 한다고 생각했습니다. 또한, 나중에 Reservation에 예약자 전화번호 같은 민감한 정보가 추가되더라도, ReservationResponse에는 API로 내보낼 정보인 ID, 이름, 날짜, 시간만 담기 때문에 안전할 것이라고 생각합니다.

public class ReservationService {

private final List<Reservation> reservationList = Collections.synchronizedList(new ArrayList<>());
private final AtomicLong index = new AtomicLong(0);

Choose a reason for hiding this comment

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

ID 증가를 위해 Long이나 int 대신 AtomicLong을 사용하신 이유가 궁금합니다 :)

Copy link
Author

Choose a reason for hiding this comment

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

ID가 누락되는 것을 막기 위해 사용했습니다. @Service는 서버 전체에서 한 개만 생성되어, 모든 사용자가 같이 사용합니다. 만약 여러 사용자가 동시에 예약을 요청하면, 일반 long타입의 숫자는 ID가 중복되거나 누락될 수 있습니다. AtomicLong은 동시에 여러 요청이 와도 숫자가 겹치지 않고 순서대로 1씩 안전하게 올라가는 것을 보장해 주어 사용했습니다.

@c0mpuTurtle
Copy link

c0mpuTurtle commented Nov 15, 2025

미션 진행하시느라 수고 많으셨습니다 !!
너무너무 잘해주셨어요 ❤️❤️❤️

1차 코멘트는 여기까지 마무리할게요.
확인하신 뒤 댓글 남기시고 멘션 꼭 걸어주세요 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants