Skip to content

Conversation

@tae-wooo
Copy link

@tae-wooo tae-wooo commented Nov 20, 2025

안녕하세요 혜빈님! 처음 뵙습니다 :) 잘 부탁드립니다!

저번 미션에서 계층 분리가 충분히 이뤄지지 않았다는 피드백을 받아, 관련 개념을 다시 공부하고 이번 미션에 적극 반영해보았습니다.
특히 이번 단계에서는 DB 도입과 함께 Layer 간 책임을 명확히 분리하는 데에 중점을 두고 구현했습니다.


궁금한 점

JdbcTemplate을 사용하면서, 내부에서 발생하는 대부분의 예외가 DataAccessException 계열로 래핑된다는 점을 확인했습니다.

제가 이해하기로 DataAccessException은 비즈니스 규칙 위반이 아니라,
DB 연결 실패, SQL 문법 오류, 제약 조건 위반 등 순수 기술적 문제에서 발생하는 예외라고 알고 있습니다.

이 기술적 예외를 어느 계층에서 처리하는 것이 올바른지 고민했습니다.

1) Service 계층에서 처리하는 방식

  • 예외를 가까운 지점에서 잡을 수 있다는 장점이 있지만
  • 기술적 예외가 비즈니스 로직과 혼재하여 레이어 책임이 흐려질 수 있다는 우려가 있었습니다.

2) GlobalExceptionHandler(@ControllerAdvice)에서 처리하는 방식

  • 기술적 예외를 비즈니스 의미와 분리하여 일괄 관리할 수 있고
  • Presentation Layer에서 공통적으로 처리하므로 응답 포맷을 통일하기 좋지만
  • 예외가 상위 계층까지 전파되는 비용이 존재합니다.

그래서 이번 미션에서는
DataAccessException이 도메인 의미를 갖지 않는 기술적 예외라고 판단하여
전역 예외 처리에서 공통적으로 handling하는 방식을 선택했습니다.

이러한 접근이 Layered Architecture 관점에서 적절한 방향인지,
리뷰어님의 의견을 듣고 싶습니다!

taewoo and others added 30 commits November 6, 2025 04:09
@tae-wooo tae-wooo changed the base branch from main to tae-wooo November 20, 2025 07:08
@c0mpuTurtle
Copy link

c0mpuTurtle commented Nov 21, 2025

🌱<인사>

안녕하세요 태우님 :)
리뷰하게 되어 영광입니다 ㅎㅎㅎ

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

그럼 이번 미션도 화이팅~!!!!!!


❓<질문에 대한 답>

🐤(태우) DataAccessException를 service에서 처리해야할까요? 전역예외로 처리해야할까요?

->
우선 DataAccessException은 Spring이 DB 작업 중 발생하는 모든 기술적 예외를 런타임 예외 계층으로 감싸서 던지는 추상화 예외입니다.
즉, 태우님께서 말씀하신 데로 기술적 문제에서 발생하는 예외가 맞는데요.

저 역시 태우님과 같은 의견으로, Service에서 기술적 예외를 직접 처리하는 건 레이어 간 책임이 섞일 수 있다고 생각합니다.
Service에서 이런 예외까지 처리하면 오히려 서비스 레이어가 인프라 이슈까지 떠안는 구조가 되고,
이는 레이어드 아키텍처에서 지향하는 관심사 분리와도 맞지 않다고 봅니다.

* 인프라란?
- 애플리케이션이 동작하기 위해 필요한 기반 구조

따라서 Service는 비즈니스 로직에만 집중하고, 기술적 문제는 전역 예외 처리에서 일관되게 처리하는 흐름이 더 자연스럽습니다.
이렇게 분리해두면 Service는 도메인 규칙에 더욱 집중할 수 있고, 예외 흐름도 한 곳에서 관리되어 유지보수에도 유리합니다.



@PostMapping("/reservations")
public ResponseEntity<ReservationResponse> createReservations(@Valid @RequestBody ReservationRequest request) {
Copy link

@c0mpuTurtle c0mpuTurtle Nov 21, 2025

Choose a reason for hiding this comment

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

단일 예약을 생성하는 API지만 /reservations라는 복수형 엔드포인트를 사용하신 이유가 궁금합니다.
컬렉션을 기준으로 복수형을 사용하는 컨벤션을 적용하신 건가요?
(단순 궁금)

Copy link
Author

Choose a reason for hiding this comment

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

처음에는 단일 예약 생성 API라 /reservation이 더 적절하다고 생각했습니다.
하지만 테스트 코드가 /reservations 기준으로 작성되어 있었고,
REST API 설계를 간단히 살펴보니 컬렉션 리소스에 POST 요청을 보내
새로운 항목을 추가할 때 복수형을 사용하는 패턴도 흔히 사용되는 것을 확인했습니다.
그래서 이번 구현에서는 /reservations를 선택하게 되었습니다.

Comment on lines +11 to 28
private final String date;
private final String time;

private Reservation(Long id, String name, String date, String time) {

private Reservation(Long id, String name, LocalDate date, LocalTime time) {
this.id = id;
this.name = name;
this.date = date;
this.time = time;
}

public static Reservation newReservationFromDb(Long id, String name, String date, String time) {
return new Reservation(id, name, date, time);
}

public static Reservation createReservation(Long id, String name, String stringDate, String stringTime) {
LocalDate date = LocalDate.parse(stringDate);
LocalTime time = LocalTime.parse(stringTime);

Choose a reason for hiding this comment

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

Reservation 객체 내에서 date와 time이 String 타입으로 관리되는 걸 볼 수 있는 데요.
생성 시에 LocalDate와 LocalTime형식으로 검증하고
그 전 코드와 달리 string 타입으로 저장하게된 이유를 여쭤봐도 될까요?

Copy link
Author

Choose a reason for hiding this comment

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

5단계 미션에서 DB 스키마가 VARCHAR 기반으로 정의되어 있고,
JDBC를 통해 매핑할 때 문자열 형태가 더 단순해
도메인 모델의 해당 필드 타입을 String으로 변경하게 되었습니다.
다만 타입을 String으로 변경했다고 하더라도,
객체 생성 시점에서 여전히 LocalDateLocalTime으로 파싱하여 유효성을 검증하고 있기 때문에
도메인 규칙은 유지되고 잘못된 값이 생성되는 문제는 발생하지 않는다고 판단했습니다.

Choose a reason for hiding this comment

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

DB 스키마가 VARCHAR라 String으로 가져오는 건 이해됩니다!
이번 미션은 프론트 코드도 함께 제공되고 있어서 LocalDate/LocalTime으로 바꾸라고 하기도 애매하네요 ㅎㅎ

다만 아래 부분들은 한 번 고민해보시면 좋을 것 같아요.

  • createReservation에서 LocalDate/LocalTime으로 파싱하며 유효성 검증을 한다면,
    애초에 필드도 LocalDate/LocalTime으로 두는 편이 이후 비즈니스 로직을 작성할 때 이점이 더 크지 않을까?

  • 지금은 유효성이 보장된다고 하더라도 이후 날짜·시간을 비교하거나 계산하는 비즈니스 로직이 추가되면
    String으로 남겨두는 쪽이 오히려 실수 위험이 커질 수 있지 않을까?

  • 엔티티는 도메인에 맞는 정교한 타입(LocalDate/LocalTime)을 유지하는 게 더 자연스럽지 않을까?

Comment on lines +12 to +13
@Repository
public class ReservationDao {

Choose a reason for hiding this comment

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

DAO 잘 만들어주셨습니다!

  • DAO 위에 @Repository 어노테이션을 붙인 이유는 무엇인가요?
  • DAO와 @Repository의 차이는 무엇일까요?

Copy link
Author

Choose a reason for hiding this comment

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

처음에는 다른 DAO 구현 예시들을 참고하면서 @Repository 어노테이션을 그대로 따라 사용했습니다.
그러나 리뷰에서 “왜 @repository를 사용했는가?”라는 질문을 받으면서,
사용 이유에 대해 명확히 알고 싶어 관련 내용을 학습해보았습니다.

DAO는 데이터베이스 접근 로직을 담당하는 계층이기 때문에,
스프링에서 해당 클래스가 DB와 상호작용하는 영속성 계층임을 명확히 표현하기 위해
@Repository 어노테이션을 사용했습니다.

물론 기능적으로 보면 @Repository는 내부적으로 @Component를 포함하고 있기 때문에,
@Component만 사용해도 스프링 빈으로 등록되어 DI는 가능합니다.

하지만 @Repository는 단순한 빈 등록 이상의 장점을 제공합니다.

첫 번째로, 해당 클래스가 "DB 접근을 담당하는 영속성 계층"이라는 의미를 코드 레벨에서 명확하게 드러내기 때문에
역할 분리가 명확해지고 유지보수 시 가독성이 향상됩니다.

두 번째로, @Repository가 붙은 클래스에서 발생하는 데이터 접근 예외들은
스프링이 제공하는 DataAccessException 계열의 unchecked 예외로 변환됩니다.
덕분에 상위 계층(Service, Controller)은 JDBC의 SQLException과 같은 구체적인 DB 기술 예외를 알 필요 없이,
일관된 방식으로 예외를 처리할 수 있습니다.

정리하자면,

  • @Component → 빈 등록 목적
  • @Repository → 빈 등록 + 영속성 계층 의미 명확화 + 예외 변환 기능 제공

따라서 DAO와 같은 영속성 계층에서는 @Repository를 사용하는 것이 더 적합하다고 이해했습니다.

Copy link
Author

Choose a reason for hiding this comment

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

DAO와 Repository는 모두 데이터 접근 계층에서 사용되는 개념이지만, 접근 관점과 역할에서 차이가 있습니다.

DAO(Data Access Object)는 데이터베이스 기술에 맞춰 CRUD 작업을 수행하는 객체로, SQL 기반 접근 방식에 초점을 둡니다.
즉, 데이터베이스 테이블을 중심으로 데이터를 저장하고 조회하는 방식에 가깝습니다.

반면 Repository는 도메인 객체를 저장하고 조회하는 '저장소'라는 개념에 더 가깝습니다.
DB 기술보다는 도메인 모델을 중심으로 설계되며, 마치 컬렉션처럼 엔티티를 다루는 데에 더 집중합니다.

정리하면,

  • DAO는 CRUD 구현과 데이터베이스 접근 기술에 가까운 패턴이고
  • Repository는 도메인 모델을 중심으로 데이터를 다루기 위한 더 높은 추상화 계층입니다.

예를 들어, 아래와 같은 Repository 인터페이스는 구현체가 아닌 도메인 관점의 행동에 집중하고 있습니다.
즉, 어떻게 저장할지(JDBC)는 감추고, 무엇을 저장하고 조회할지에 초점을 둡니다.

public interface UserRepository {
    User getByUserId(int id);
    void createByName(String name);
    void updateByUserId(int id, User user);
    void removeByUserId(int id);
}

Comment on lines 30 to 37
public void delete(Long id) {
int result = reservationDao.delete(id);

if (result == 0) {
throw new NotFoundReservationException("삭제할 예약이 없습니다.");
}
}
}

Choose a reason for hiding this comment

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

개인적으로는 에러와 메시지를 한 곳에서 함께 관리하는 편이 유지보수에도 더 좋고, 확장할 때도 안정적이라고 느껴졌어요.
그래서 여러곳에 있는 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;
}

Copy link
Author

Choose a reason for hiding this comment

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

말씀해주신 것처럼 예외 메시지를 enum으로 관리하면 유지보수성과 확장성이 좋아진다는 점에 공감했습니다.
그래서 기존에 여러 곳에 흩어져 있던 에러 메시지를 FailMessage enum으로 통일해 관리할 수 있도록 개선했습니다.

다만 이번 미션 테스트 코드가 모든 예외 상황에서 HTTP 상태 코드를 400으로 반환하는 방식을 요구하고 있어,
현재 단계에서는 HttpStatus를 BAD_REQUEST로 통일하고, 기술적 예외(DB 관련)만 INTERNAL_SERVER_ERROR로 구분해두었습니다.

Comment on lines 30 to 36
@PostMapping("/reservations")
public ResponseEntity<ReservationResponse> createReservations(@Valid @RequestBody ReservationRequest request) {
Reservation newReservation = Reservation.createReservation(index.getAndIncrement(), request.name(), request.date(), request.time());
reservations.add(newReservation);
Reservation newReservation = reservationService.registerReservation(request.name(), request.date(), request.time());

return ResponseEntity.created(URI.create("/reservations/" + newReservation.getId())).
body(ReservationResponse.from(newReservation));
}

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.

요청이 들어오면 먼저 DispatcherServlet이 실행되고,
HandlerMapping을 통해 해당 요청을 처리할 Controller 메서드를 찾습니다.

그 다음 HandlerAdapter가 선택된 Handler(Controller)를 실행할 수 있도록 준비하며,
이 과정에서 ArgumentResolver가 @RequestBody, @Valid 등을 해석해
요청 본문의 JSON 데이터를 ReservationRequest DTO로 변환하고 검증합니다.

Controller가 호출되면 비즈니스 로직 처리를 위해 Service를 호출하고,
Service는 DAO를 통해 DB에 데이터를 저장한 뒤 생성된 Reservation 객체를 반환합니다.

응답 단계에서는 ResponseEntity 반환 형식에 따라 HttpMessageConverter가 동작해
도메인 객체를 JSON 형태의 HTTP Response로 변환하여 클라이언트에게 전달합니다.

Comment on lines 46 to -48
@DeleteMapping("/reservations/{id}")
public ResponseEntity<Void> deleteReservations(@PathVariable Long id) {
Reservation reservation = reservations.stream()

Choose a reason for hiding this comment

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

POST에서는 @RequestBody를, DELETE에서는 @PathVariable을 사용하셨는데,
각각의 전달 방식을 선택한 기준이 무엇이었는지 공유가능할까요?

Copy link
Author

@tae-wooo tae-wooo Nov 23, 2025

Choose a reason for hiding this comment

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

아래와 같이 POST 요청에서는 새로운 예약 정보를 생성해야 하기 때문에
사용자로부터 이름, 날짜, 시간 등의 데이터를 전달받아야 합니다.

POST /reservations HTTP/1.1
Content-Type: application/json

{
  "date": "2023-08-05",
  "name": "브라운",
  "time": "15:40"
}

이처럼 생성에 필요한 데이터가 요청 본문(JSON)에 담겨 전달되므로,
@RequestBody를 사용해 해당 값을 매핑하도록 구현했습니다.

반대로 DELETE 요청은 이미 존재하는 특정 예약을 제거하는 용도이기 때문에
추가 데이터가 필요하지 않고, 삭제할 리소스를 구분할 수 있는 식별자(id)만 있으면 충분합니다.

그래서 URI 템플릿(/reservations/{id})을 사용해 리소스를 표현하고,
@PathVariable로 id 값을 바인딩해 삭제하도록 구현했습니다.

Comment on lines 46 to 51
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDBException(DataAccessException e) {
return ResponseEntity.internalServerError()
.body(new ErrorResponse("DB 처리 중 기술적 예외가 발생했습니다."));
}
}

Choose a reason for hiding this comment

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

예외 핸들러 메서드 이름에서 handler와 handle이 같이 사용되고 있는 것 같습니다.

보통 메서드는 동사로 시작하는 경우가 많아서,
handleInvalidReservationArgumentException처럼 동사+명사 형태로 맞춰주는 게 일관성에 도움이 될 거 같아요!

Copy link
Author

Choose a reason for hiding this comment

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

handle + Exception명 형태로 네이밍을 수정했습니다.

Comment on lines 21 to 36
public Long insertWhithKeyHolder(Reservation reservation) {
KeyHolder keyHolder = new GeneratedKeyHolder();
String sql = "insert into reservation (name, date, time) values (?,?,?)";
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql,
new String[]{"id"}
);
ps.setString(1, reservation.getName());
ps.setString(2, reservation.getDate());
ps.setString(3, reservation.getTime());
return ps;
}, keyHolder);

Long id = keyHolder.getKey().longValue();
return id;
}
Copy link

@c0mpuTurtle c0mpuTurtle Nov 22, 2025

Choose a reason for hiding this comment

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

이 부분에서 KeyHolder가 어떤 방식으로 동작하는지 설명해주실 수 있을까요?
KeyHolder가 어떤 흐름으로 생성된 키를 받아오는지를 중점적으로 설명해주시면 좋을 거 같아요 ㅎㅎ

Choose a reason for hiding this comment

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

하지만, spring에서 더 간략한 방식을 제공하는 것을 알고 계셨나요?
simplejdbcinsert에 대해서도 한 번 학습해보세요 :)

https://00h0.tistory.com/85

Copy link
Author

Choose a reason for hiding this comment

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

KeyHolder는 INSERT 실행 시 DB에서 자동으로 생성되는 키(AUTO_INCREMENT 값)를
JDBC가 받아 저장할 수 있도록 도와주는 객체입니다.

현재 스키마에서 id BIGINT NOT NULL AUTO_INCREMENT로 선언되어 있기 때문에
DB는 새로운 예약이 저장될 때마다 id를 자동으로 생성합니다.

jdbcTemplate.update()가 실행되면 내부적으로 JDBC의 getGeneratedKeys()가 호출되고,
DB에서 생성된 id를 JDBC 드라이버가 읽어와 KeyHolder에 저장합니다.
이후 keyHolder.getKey()를 통해 해당 id를 사용할 수 있습니다.

Comment on lines 23 to 32
String sql = "insert into reservation (name, date, time) values (?,?,?)";
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql,
new String[]{"id"}
);
ps.setString(1, reservation.getName());
ps.setString(2, reservation.getDate());
ps.setString(3, reservation.getTime());
return ps;
}, keyHolder);

Choose a reason for hiding this comment

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

현재 INSERT 구문에서 ?,?,? 형태의 순서 기반 파라미터 바인딩을 사용하고 있는데,
이 방식은 컬럼 순서나 필드 개수가 변경될 때 의도치 않은 데이터가 저장될 위험이 있습니다.

대체 방법으로는 컬럼명을 기준으로 명시적으로 매핑할 수 있는 방식들을 고려해볼 수 있겠네요 ㅎㅎ

Copy link
Author

Choose a reason for hiding this comment

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

말씀해주신 내용을 바탕으로 SimpleJdbcInsert를 학습해보았고,
? 기반의 순서 의존적인 매핑 방식이 컬럼 변경이나 추가에 취약할 수 있다는 점을 이해하게 되었습니다.

학습 과정에서 SimpleJdbcInsert가 컬럼명을 기반으로 명시적으로 매핑할 수 있고,
생성된 키를 별도의 KeyHolder 설정 없이도 반환할 수 있다는 점이 현재 구조와 잘 맞는다고 판단해 해당 방식으로 리팩터링했습니다.

@c0mpuTurtle
Copy link

미션 진행하시느라 수고 많으셨습니다 😃
dao 너무 잘 활용해주셨습니다~

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