Skip to content

Conversation

@ke-62
Copy link

@ke-62 ke-62 commented Nov 13, 2025

안녕하세요 준수님 !! 최근에 리뷰어 리뷰이로 자주 만나뵙는 것 같네요 ㅎㅎ
이번 미션은 3,4 단계 구현하는 것이였고 3단계 - 예약 추가 / 취소, 4단계 - 예외 처리 입니다!

이번 미션부터는 볼륨이 커져서 MVC 에 맞춰 코드 분리를 해 보았습니다
코드 구조에 대해 설명을 드리자면

controller/
-HomeController.java : 메인 화면 관련 컨트롤러
-ReservationController.java : 예약 관련 컨트롤러

domain/
-Reservation.java : 예약 데이터를 저장하기 위한 클래스

dto/
-ReservationRequest.java : 예약 생성 요청 데이터를 담는 DTO

exception/
-GlobalExceptionHandler.java : 전역 예외 처리를 담당하는 핸들러
-InvalidReservationException.java : 예약 관련 비즈니스 예외

입니다!

다음은 구현 과정에서 발생한 질문 사항입니다!

  1. 예약을 삭제한 후 새로운 예약을 추가하면 ID가 1, 2, 4, 5처럼 건너뛰게 됩니다. 찾아보니 삭제된 ID를 재사용하면 참조 문제가 발생할 수 있고, 삭제된 데이터의 추적 가능성 때문에 DB에서도 ID는 한 번 사용되면 다시 사용되지 않는 것이 권장된다고 합니다. 하지만 사용자 입장에서는 화면에 표시되는 번호가 1, 2, 3으로 연속적으로 보이는 것이 더 직관적일 것 같습니다. 그래서 내부 ID는 그대로 유지하되 화면에만 순번(1, 2, 3...)을 별도로 표시하는 방식을 생각하였는데, 이 방법은 내부 ID와 화면의 순번이 달라 오히려 혼동이 생길 것 같았습니다. 이 경우 일반적으로 어떻게 처리하는지 궁금합니다.
  2. 예외 처리를 exception이란 패키지로 따로 빼 두었는데, 여러 코드들을 추가로 찾아보니 예외처리를 dto안에서 하는 경우도 있더라고요. 예외 클래스를 별도 exception 패키지로 분리하는 것과 dto에 포함하는 것의 차이나 기준이 있는지 궁금합니다.

제 코드에 대한 질문이 있으시다면 편하게 질문 주시고, 이번 주차 미션도 잘 부탁드립니다!!

++
이전 주차 커밋이랑 섞여 있네요 ㅜㅜ..!!
e453ed6
여기서 부터 확인해 주시면 됩니다!

@ke-62 ke-62 changed the base branch from main to ke-62 November 13, 2025 05:01
Copy link

@gogo1414 gogo1414 left a comment

Choose a reason for hiding this comment

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

안녕하세요, 고은님😄
다시 리뷰어로 만나게 되어 반갑네요!!

코드 구조에 대한 설명 감사합니다! 덕분에 더 쉽게 이해할 수 있었어요.

질문 사항에 대한 답변

  1. 예약을 삭제한 후 새로운 예약을 추가하면 ID가 1, 2, 4, 5처럼 건너뛰게 됩니다. 찾아보니 삭제된 ID를 재사용하면 참조 문제가 발생할 수 있고, 삭제된 데이터의 추적 가능성 때문에 DB에서도 ID는 한 번 사용되면 다시 사용되지 않는 것이 권장된다고 합니다. 하지만 사용자 입장에서는 화면에 표시되는 번호가 1, 2, 3으로 연속적으로 보이는 것이 더 직관적일 것 같습니다. 그래서 내부 ID는 그대로 유지하되 화면에만 순번(1, 2, 3...)을 별도로 표시하는 방식을 생각하였는데, 이 방법은 내부 ID와 화면의 순번이 달라 오히려 혼동이 생길 것 같았습니다. 이 경우 일반적으로 어떻게 처리하는지 궁금합니다.
  • 말씀하신 대로 삭제된 ID를 재사용하면 참조 무결성 문제가 발생할 수 있고, 데이터 이력 관리를 위해서라도 ID를 재사용하지 않는 것이 일반적입니다. 잘 찾아보셨네요!

  • 화면에 보이는 '순번'은 DB에 저장하지 않는 것을 권장합니다. 검색이나 필터링 기능이 적용되면 데이터의 순서는 언제든 바뀔 수 있기 때문에, DB에 고정된 값을 저장하면 오히려 관리가 어려워집니다. ID는 데이터의 고유 식별자로만 남겨두고, 화면상의 순번은 클라이언트가 데이터를 받아 리스트를 렌더링할 때 인덱스를 붙여서 보여주거나, 서버에서 페이징 처리를 할 때 계산된 순번 필드를 DTO에 담아 반환하는 방식을 주로 사용합니다.

  • 관련해서 한 가지 질문을 드리고 싶어요. 현재 구현하신 코드를 보면 데이터 존재 여부를 확인 후 실제로 삭제(Hard Delete)하고 계신데요. 실무에서는 데이터 복구나 이력 추적을 위해 실제 데이터를 삭제하지 않고 deleted_at이나 is_deleted 같은 컬럼을 두어 상태만 변경하는 논리적 삭제(Soft Delete) 방식도 많이 사용합니다.

  • 고은 님은 이번 프로젝트에서 물리적 삭제 방식을 선택하셨는데, 혹시 그렇게 결정하신 특별한 이유가 있으실까요?

  1. 예외 처리를 exception이란 패키지로 따로 빼 두었는데, 여러 코드들을 추가로 찾아보니 예외처리를 dto안에서 하는 경우도 있더라고요. 예외 클래스를 별도 exception 패키지로 분리하는 것과 dto에 포함하는 것의 차이나 기준이 있는지 궁금합니다.
  • 먼저 현재 작성하신 InvalidReservationException처럼 커스텀 예외를 만들고, GlobalExceptionHandler에서 처리하는 구조는 잘 잡으셨다고 생각해요!

  • 질문하신 DTO 안에 예외 처리가 있는 경우는 아마 DTO 내부에서 데이터 유효성 검증을 수행하며 예외를 발생시키는 코드를 보신 게 아닐까 싶어요.

  • 일반적으로 Exception 클래스 자체를 DTO 파일 내부에 정의하는 것은 권장하지 않습니다. 예외 클래스는 시스템 전반에서 공통으로 사용될 수 있으므로, 지금처럼 별도의 exception 패키지로 분리하여 관리하는 것이 재사용성과 유지보수 측면에서 훨씬 유리합니다.

  • 다만, DTO 객체가 생성될 때 데이터의 무결성을 보장하기 위해 생성자나 빌더 패턴 내부에서 검증 로직을 넣고, 조건에 맞지 않으면 예외를 throw 하는 방식은 자주 사용됩니다. 즉, 예외 클래스의 정의는 별도 패키지에서 하고, 예외 발생은 필요한 곳에서 수행한다고 이해하시면 좋을 것 같네요!

답변이 되셨으면 좋겠네요! 혹시라도 추가 질문 사항이 있으면 언제든지 코멘트 해주세요!

수정 요청드릴 만한 부분은 따로 없어서 코멘트만 확인하시고 답변 부탁드릴게요!

그리고 늦게 리뷰 달아드려 죄송합니다..! 항상 너무 잘해주고 계셔서 감사해요 👍👍👍
남은 미션도 파이팅🔥🔥

@Controller
public class ReservationController {

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.

일반적인 Long 클래스가 아닌 AtomicLong 클래스를 사용하셨는데, 이유가 있으실까요? 궁금하네요!

Copy link
Author

Choose a reason for hiding this comment

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

거창한 이유는 없었고 3단계 미션을 할 때
image
아래 사진과 같은 힌트를 보아서 AtomicLong 클래스를 사용했습니다!!

AtomicLong 클래스를 왜 사용하라고 했을 지 궁금해서 찾아보니, 동시 접근 하는 입력에 대해 AtomicLong은 안전하게 처리하는 반면 Long 클래스은 동시성 문제를 일으킨다고 합니다.

Choose a reason for hiding this comment

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

메서드 네이밍이 직관적이라 좋네요👍👍👍

Comment on lines +30 to +32
reservations.add(new Reservation(index.incrementAndGet(), "브라운", "2023-01-01", "10:00"));
reservations.add(new Reservation(index.incrementAndGet(), "브라운", "2023-01-02", "11:00"));
reservations.add(new Reservation(index.incrementAndGet(), "브라운", "2023-01-03", "12:00"));

Choose a reason for hiding this comment

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

현재 객체를 생성할 때 사용하고 계신 방식을 위치 기반 인수 방식이라고 합니다!

이 방식은 파라미터가 적을 때는 간단하지만, 파라미터가 많아지면 각 위치가 어떤 필드를 의미하는지 파악하기 어렵고, 순서를 잘못 입력할 실수를 하기도 쉬워요.

이러한 실수를 방지하기 위해 빌더 패턴이란 것을 사용하는데, 직접 구현도 가능하고 Lombok 라이브러리를 활용하면 어노테이션으로 쉽게 사용이 가능합니다!

나중에 한번 찾아보시면 도움이 될 것 같네요 😄

Comment on lines 29 to 39
public void setName(String name) {
this.name = name;
}

public void setDate(String date) {
this.date = date;
}

public void setTime(String time) {
this.time = time;
}

Choose a reason for hiding this comment

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

setName, setDate, setTime 메서드는 사용하지 않고 계시는데, 정의를 미리 해두신 이유가 있을까요??

Copy link
Author

Choose a reason for hiding this comment

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

처음에는 예약 정보를 수정하는 상황을 고려해 setName, setDate, setTime 같은 setter를 만들었습니다.
하지만 다시 보니 예약 객체는 불변성을 유지하는 편이 더 명확하고 안전한 것 같네요ㅜㅜ.
예약 정보를 수정하는 상황이 생기면 cancelReservation(), reschedule()처럼 실제 의도를 드러내는 메서드로 처리하는 것이 더 좋을 것 같습니다..!

Choose a reason for hiding this comment

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

아직, @Transactional과 JPA 같은 ORM을 사용하는 방식 혹은 MyBatis나 JDBC처럼 SQL을 직접 매핑하는 방식 등에 대해서 배우셨을지 모르겠지만, 흔하게 사용되는 JPA 방식에서 알아뒀으면 하는 것들 몇 개를 적어봤어요!

우선, 엔티티를 업데이트하는 방식에는 크게 2가지가 있습니다.

첫 번째는 '변경 감지'를 이용하는 방식입니다. 고은님이 사용하신 것과 같이 setter 함수를 이용하는 것이 여기에 해당합니다. @Transactional 안에서 엔티티를 findById로 조회하면, 이 엔티티는 JPA의 영속성 컨텍스트에서 관리됩니다. 이 객체의 setterupdateName 같은 메서드를 호출해서 상태를 바꾸면, 트랜잭션이 끝날 때 JPA가 처음 조회했을 때의 스냅샷과 현재 상태가 다른 것을 감지하고 UPDATE 쿼리를 자동으로 날려줍니다. 이 방식은 repository.save()를 다시 호출할 필요가 없는 것이 핵심입니다.

두 번째는 '병합'을 이용하는 방식입니다. 불변 객체 특성을 고려하여 새로운 객체를 생성하여 업데이트하는 방식이 여기에 해당될 수 있습니다. 업데이트할 데이터로 새로운 엔티티 객체를 만들어서 repository.save()에 전달하면, 이 객체는 영속성 컨텍스트가 관리하지 않는 '준영속' 상태이므로 JPA가 merge 로직으로 처리합니다. merge는 DB에서 ID로 실제 데이터를 SELECT한 다음, 우리가 전달한 준영속 객체의 모든 필드 값을 그 조회된 영속 엔티티에 덮어씌우고 업데이트를 진행합니다.

사실 대부분의 경우엔 첫 번째 '변경 감지' 방식이 훨씬 안전하고 권장됩니다. merge 방식은 DTO에서 빠진 필드가 기존 DB에 있던 값까지 null로 덮어씌워 데이터가 유실될 위험이 크거든요!

Comment on lines +64 to +74
@DeleteMapping("/reservations/{id}")
@ResponseBody
public ResponseEntity<Void> deleteReservation(@PathVariable Long id) {
boolean removed = reservations.removeIf(reservation -> reservation.getId().equals(id));

if (!removed) {
throw new InvalidReservationException("존재하지 않는 예약입니다.");
}

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

Choose a reason for hiding this comment

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

Void 타입이 아닌 다른 타입이 들어가면 안 되나요? 만약 그렇다면, 그 이유도 같이 설명해주세요!

Copy link
Author

Choose a reason for hiding this comment

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

void가 아닌 다른 타입도 가능한 걸로 알고 있습니다!
하지만 제가 void를 선택한 이유는 코드 목적이 @DeleteMapping 어노테이션을 사용하여 특정 예약을 삭제하는 API이기 때문입니다.
RESTful API 원칙을 보면, 리소스를 성공적으로 삭제했을 때는 클라이언트에게 "삭제가 성공적으로 완료되었고, 반환할 데이터는 없다"고 알려주는 것이 일반적입니다. 즉 void가 아무런 데이터도 담지 않겠다는 것을 명시적으로 잘 보여준다고 생각했습니다!

Choose a reason for hiding this comment

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

친절하게 설명해주셔서 감사합니다!

Comment on lines +11 to +19
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}

@ExceptionHandler(InvalidReservationException.class)
public ResponseEntity<String> handleInvalidReservationException(InvalidReservationException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
Copy link

@gogo1414 gogo1414 Nov 16, 2025

Choose a reason for hiding this comment

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

  • 두 예외 처리 모두 badRequest() 방식으로 처리를 해주셨는데, HttpStatus의 다양한 선택 기준이 있었을텐데, 두 예외 모두 badRequest를 선택한 이유가 있으실까요?

  • 두 예외 처리 모두 동일한 로직으로 확인되는데, 커스텀 예외를 따로 했을 때의 이점이 있었나요?

Copy link
Author

Choose a reason for hiding this comment

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

  1. 이것도 badRequest()는 요구사항에서 Status Code를 400으로 응답하라는 요구사항이 있었기 때문입니다..! 테스트 코드도 400일 때 통과되도록 명시해 두어서 badRequest를 선택했습니다.
image
  1. 두 예외처리가 코드상으로는 비슷해 보이지만 예약 도메인 문제와 입력 검증 문제 이 두개를 다르게 분리하고 싶어서 커스텀 예외 처리를 했습니다..! 스터디 시간에

예외 설계는 코드 읽는 사람에게 의도를 전달하는 수단이다.

라고 배워서 커스텀 예외를 하는 것이 이름 그 자체만으로 어떤 규칙을 위반했다는 것을 명확하게 표현하는데 훨씬 이점이 있는 것 같습니다.

@ke-62
Copy link
Author

ke-62 commented Nov 17, 2025

안녕하세요 리뷰어님!
이번에도 자세한 피드백 정말 감사합니다.🙃🥹

삭제 방식은 데이터 이력 관리가 필요하지 않아 물리적 삭제를 사용했습니다.
또한 논리적 삭제는 데이터를 계속해서 데이터베이스에 남겨두기 때문에 저장 공간을 쓸대없이 많이 낭비한다는 생각이 들었습니다..!

물론, 데이터 이력 추적이나 복구의 중요성이 높은 서비스에서는 논리적 삭제를 쓰는 것이 알맞다고 생각합니다.😊

질문에 대해 자세히 답변 주셔서 이번에도 많은 도움이 되었습니다!!
남은 미션도 열심히 진행하겠습니다 🙇

@boorownie boorownie merged commit 2e06395 into next-step:ke-62 Nov 18, 2025
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.

3 participants