Repository: next-step/spring-roomescape-playground
Pull request #451: [그리디] 김지우 Spring JDBC 5,6단계 제출합니다.
Author: login: Ji-Woo-Kim
Created: 2025-05-16T09:18:57Z | Merged: 2025-05-20T01:12:18Z

은우님 안녕하세요~! 반갑습니다 🙌 
이번 JDBC 리뷰 맡아주셔서 감사드리고, 열심히 공부해보도록 하겠습니다!
잘 부탁드립니다 🙇‍♀️ 

---

일단 이번 미션에서 `[순수 JDBC로 구현] -> [Spring JDBC로 변경] 이 과정이 커밋에 남아야 해요. `이런 요구사항이 있었어요!

Spring JDBC를 적용하는 것보다 순수 JDBC를 구현하는게 예상보다 오래걸렸는데, 이 과정 덕분에 Spring JDBC의 유용함, 편리함을 몸소 느끼게 된 것 같아요 😄 

미션 진행 사항은 다음과 같습니당 :

- H2 인메모리 데이터베이스 및 Spring JDBC 설정
  - build.gradle에 spring-boot-starter-jdbc, h2 의존성 추가
  - application.properties 설정
  - schema.sql로 reservation 테이블 스키마 생성

- DAO 리팩토링
  - ReservationDAO 클래스에서 DB 연동
  - add/find/update/delete 기능 모두 JdbcTemplate 적용

- /reservations 조회 API 기능 DB 연동 완료
  - ReservationController의 read() 메서드 로직 수정

- 조회 API 기능에 대한 테스트 코드 작성 및 통과
  - 오단계() test passed 확인

---

깃 커밋 로그 정리하여 다시 PR 보냅니다!
이외에 컴파일 안 되는 부분, 이전 단계 테스트 코드 살짝 수정한 부분 있습니다.
미션 제출 마감 전에 해결했어야 하는 부분인데 뒤늦게 해결하게 되어 죄송합니다 ㅜㅜ
그리고 양해해주셔서 감사해요 🥹 


Diff:
diff --git a/build.gradle b/build.gradle
index e329479a6..21e7c172b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -18,7 +18,6 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-validation'
     implementation 'org.springframework.boot:spring-boot-starter-jdbc'
     runtimeOnly 'com.h2database:h2'
-    implementation 'org.projectlombok:lombok'
     annotationProcessor 'org.projectlombok:lombok'
     compileOnly 'org.projectlombok:lombok'
     testImplementation 'org.springframework.boot:spring-boot-starter-test'
diff --git a/src/main/java/roomescape/controller/ReservationController.java b/src/main/java/roomescape/controller/ReservationController.java
index 7c80c8af9..f8429b240 100644
--- a/src/main/java/roomescape/controller/ReservationController.java
+++ b/src/main/java/roomescape/controller/ReservationController.java
@@ -1,21 +1,23 @@
 package roomescape.controller;
 
 import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
 import org.springframework.web.bind.annotation.*;
-import roomescape.domain.*;
 import roomescape.dto.*;
-import roomescape.exception.reservation.NotReservationFoundException;
+import roomescape.service.ReservationService;
 
-import java.net.URI;
 import java.util.*;
-import java.util.concurrent.atomic.AtomicLong;
 
-@RestController
+@Controller
 public class ReservationController {
 
-    private List<Reservation> reservations = new ArrayList<>();
-    private AtomicLong index = new AtomicLong(1);
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
 
     @GetMapping("/reservation")
     public String reservationPage() {
@@ -23,32 +25,22 @@ public String reservationPage() {
     }
 
     @GetMapping("/reservations")
-    public ResponseEntity<List<ReservationResponseDto>> read() {
-        List<ReservationResponseDto> responseDtos = reservations.stream()
-                .map(ReservationResponseDto::new)
-                .toList();
-
-        return ResponseEntity.ok(responseDtos);
+    @ResponseBody
+    public ResponseEntity<List<ReservationResponseDto>> findAll() {
+        return ResponseEntity.ok(reservationService.findAllReservations());
     }
 
     @PostMapping("/reservations")
+    @ResponseBody
     public ResponseEntity<ReservationResponseDto> create(@Valid @RequestBody ReservationRequestDto requestDto) {
-        Reservation newReservation = Reservation.fromDto(index.getAndIncrement(), requestDto);
-        reservations.add(newReservation);
-
-        URI location = URI.create("/reservations/" + newReservation.getId());
-        return ResponseEntity.created(location).body(new ReservationResponseDto(newReservation));
+        ReservationResponseDto newReservation = reservationService.addReservation(requestDto);
+        return ResponseEntity.status(HttpStatus.CREATED).body(newReservation);
     }
 
     @DeleteMapping("/reservations/{id}")
+    @ResponseBody
     public ResponseEntity<Void> delete(@PathVariable Long id) {
-        Reservation reservation = reservations.stream()
-                .filter(it -> Objects.equals(it.getId(), id))
-                .findFirst()
-                .orElseThrow(() -> new NotReservationFoundException("Reservation not found"));
-
-        reservations.remove(reservation);
-
+        reservationService.deleteReservation(id);
         return ResponseEntity.noContent().build();
     }
 }
diff --git a/src/main/java/roomescape/dao/ReservationDAO.java b/src/main/java/roomescape/dao/ReservationDAO.java
deleted file mode 100644
index 125560af3..000000000
--- a/src/main/java/roomescape/dao/ReservationDAO.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package roomescape.dao;
-
-import jakarta.annotation.PostConstruct;
-import java.sql.Connection;
-import java.sql.DriverManager;
-import java.sql.SQLException;
-import java.time.LocalDate;
-import java.time.LocalTime;
-import org.springframework.stereotype.Component;
-import roomescape.domain.Reservation;
-
-@Component
-public class ReservationDAO {
-
-    private static final String URL = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1";
-    private static final String USERNAME = "sa";
-    private static final String PASSWORD = "";
-
-    public Connection getConnection() {
-        // 드라이버 연결
-        try {
-            return DriverManager.getConnection(URL, USERNAME, PASSWORD);
-        }
-        catch (final SQLException e) {
-            System.err.println("DB 연결 오류:" + e.getMessage());
-            e.printStackTrace();
-            return null;
-        }
-    }
-
-    @PostConstruct
-    public void createTable() {
-        final var query = "CREATE TABLE reservation(id long, name varchar(10), date varchar(10), time varchar(5));";
-        try (
-                final var connection = getConnection();
-                final var preparedStatement = connection.prepareStatement(query)
-        ) {
-            preparedStatement.executeUpdate();
-        }
-        catch (final SQLException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    public void addReservation(final Reservation reservation) {
-        final var query = "INSERT INTO reservation VALUES(?, ? , ?, ?)";
-        try (
-                final var connection = getConnection();
-                final var preparedStatement = connection.prepareStatement(query)
-        ) {
-            preparedStatement.setLong(1, reservation.getId());
-            preparedStatement.setString(2, reservation.getName());
-            preparedStatement.setString(3, reservation.getDate().toString());
-            preparedStatement.setString(4, reservation.getTime().toString());
-            preparedStatement.executeUpdate();
-        }
-        catch (final SQLException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    public Reservation findById(final Long id) {
-        final var query = "SELECT * FROM reservation WHERE id = ?";
-        try (
-                final var connection = getConnection();
-                final var preparedStatement = connection.prepareStatement(query)
-        ) {
-            preparedStatement.setLong(1, id);
-
-            final var resultSet = preparedStatement.executeQuery();
-            if (resultSet.next()) {
-                return new Reservation(
-                        resultSet.getLong("id"),
-                        resultSet.getString("name"),
-                        LocalDate.parse(resultSet.getString("date")),
-                        LocalTime.parse(resultSet.getString("time"))
-                );
-            }
-        }
-        catch (final SQLException e) {
-            throw new RuntimeException(e);
-        }
-
-        return null;
-    }
-
-}
diff --git a/src/main/java/roomescape/dao/ReservationDao.java b/src/main/java/roomescape/dao/ReservationDao.java
new file mode 100644
index 000000000..1e78bf8dc
--- /dev/null
+++ b/src/main/java/roomescape/dao/ReservationDao.java
@@ -0,0 +1,81 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Component;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.exception.reservation.NotReservationFoundException;
+import roomescape.exception.reservation.ReservationInsertFailedException;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.Optional;
+
+@Component
+public class ReservationDao {
+
+    private final NamedParameterJdbcTemplate namedJdbcTemplate;
+
+    public ReservationDao(NamedParameterJdbcTemplate namedJdbcTemplate) {
+        this.namedJdbcTemplate = namedJdbcTemplate;
+    }
+
+    private final RowMapper<Reservation> reservationRowMapper = (resultSet, rowNum) -> new Reservation(
+            resultSet.getLong("id"),
+            resultSet.getString("name"),
+            LocalDate.parse(resultSet.getString("date")),
+            LocalTime.parse(resultSet.getString("time"))
+    );
+
+    public Reservation addReservation(ReservationRequestDto dto) {
+        String sql = "INSERT INTO reservation (name, date, time) VALUES (:name, :date, :time)";
+        BeanPropertySqlParameterSource parameterSource = new BeanPropertySqlParameterSource(dto);
+
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        namedJdbcTemplate.update(sql, parameterSource, keyHolder, new String[]{"id"});
+
+        Long id = Optional.ofNullable(keyHolder.getKey())
+                .map(Number::longValue)
+                .orElseThrow(() -> new ReservationInsertFailedException("예약 ID 생성에 실패했습니다."));
+        return findById(id);
+    }
+
+    public Reservation findById(Long id) {
+        String sql = "SELECT * FROM reservation WHERE id = :id";
+        MapSqlParameterSource parameterSource = new MapSqlParameterSource("id", id);
+
+        return namedJdbcTemplate.queryForObject(sql, parameterSource, reservationRowMapper);
+    }
+
+    public List<Reservation> findAll() {
+        String sql = "SELECT * FROM reservation";
+        return namedJdbcTemplate.query(sql, reservationRowMapper);
+    }
+
+    public void updateReservation(Long id, ReservationRequestDto dto) {
+        String sql = "UPDATE reservation SET name = :name, date = :date, time = :time WHERE id = :id";
+        MapSqlParameterSource parameterSource = new MapSqlParameterSource()
+                .addValue("id", id)
+                .addValue("name", dto.getName())
+                .addValue("date", dto.getDate().toString())
+                .addValue("time", dto.getTime().toString());
+
+        namedJdbcTemplate.update(sql, parameterSource);
+    }
+
+    public void deleteReservationById(Long id) {
+        String sql = "DELETE FROM reservation WHERE id = :id";
+        MapSqlParameterSource params = new MapSqlParameterSource("id", id);
+
+        int result = namedJdbcTemplate.update(sql, params);
+        if (result == 0) {
+            throw new NotReservationFoundException("존재하지 않는 예약입니다.");
+        }
+    }
+}
diff --git a/src/main/java/roomescape/domain/Reservation.java b/src/main/java/roomescape/domain/Reservation.java
index 52cd93b2e..168cb51cf 100644
--- a/src/main/java/roomescape/domain/Reservation.java
+++ b/src/main/java/roomescape/domain/Reservation.java
@@ -1,7 +1,9 @@
 package roomescape.domain;
 
 import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
 import lombok.Getter;
+import lombok.NoArgsConstructor;
 import roomescape.dto.ReservationRequestDto;
 
 import java.time.LocalDate;
@@ -9,14 +11,12 @@
 
 @Getter
 @AllArgsConstructor
+@NoArgsConstructor
+@EqualsAndHashCode
 public class Reservation {
 
     private Long id;
     private String name;
     private LocalDate date;
     private LocalTime time;
-
-    public static Reservation fromDto(Long id, ReservationRequestDto dto) {
-        return new Reservation(id, dto.getName(), dto.getDate(), dto.getTime());
-    }
 }
diff --git a/src/main/java/roomescape/dto/ReservationRequestDto.java b/src/main/java/roomescape/dto/ReservationRequestDto.java
index 14c01ffca..d18d6e6b3 100644
--- a/src/main/java/roomescape/dto/ReservationRequestDto.java
+++ b/src/main/java/roomescape/dto/ReservationRequestDto.java
@@ -3,11 +3,13 @@
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import lombok.Data;
+import lombok.NoArgsConstructor;
 
 import java.time.LocalDate;
 import java.time.LocalTime;
 
 @Data
+@NoArgsConstructor
 public class ReservationRequestDto {
 
     @NotBlank(message = "이름은 필수 입력 항목입니다.")
@@ -19,9 +21,4 @@ public class ReservationRequestDto {
     @NotNull(message = "예약 시간은 필수 입력 항목입니다.")
     private LocalTime time;
 
-    public ReservationRequestDto(String name, LocalDate date, LocalTime time) {
-        this.name = name;
-        this.date = date;
-        this.time = time;
-    }
 }
diff --git a/src/main/java/roomescape/exception/GlobalExceptionHandler.java b/src/main/java/roomescape/exception/GlobalExceptionHandler.java
index 63e1ac21b..daee77601 100644
--- a/src/main/java/roomescape/exception/GlobalExceptionHandler.java
+++ b/src/main/java/roomescape/exception/GlobalExceptionHandler.java
@@ -11,7 +11,7 @@
 public class GlobalExceptionHandler {
 
     @ExceptionHandler(ReservationException.class)
-    public ResponseEntity<String> handleCustomExceptions(ReservationException ex) {
+    public ResponseEntity<String> handleReservationExceptions(ReservationException ex) {
         return ResponseEntity.status(ex.getHttpStatus()).body(ex.getMessage());
     }
 
diff --git a/src/main/java/roomescape/exception/reservation/ReservationInsertFailedException.java b/src/main/java/roomescape/exception/reservation/ReservationInsertFailedException.java
new file mode 100644
index 000000000..66db407a2
--- /dev/null
+++ b/src/main/java/roomescape/exception/reservation/ReservationInsertFailedException.java
@@ -0,0 +1,10 @@
+package roomescape.exception.reservation;
+
+import org.springframework.http.HttpStatus;
+
+public class ReservationInsertFailedException extends ReservationException{
+
+    public ReservationInsertFailedException(String message) {
+        super(message, HttpStatus.INTERNAL_SERVER_ERROR, "RESERVATION_INSERT_FAILED");
+    }
+}
diff --git a/src/main/java/roomescape/service/ReservationService.java b/src/main/java/roomescape/service/ReservationService.java
new file mode 100644
index 000000000..7823be9a3
--- /dev/null
+++ b/src/main/java/roomescape/service/ReservationService.java
@@ -0,0 +1,36 @@
+package roomescape.service;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.dto.ReservationResponseDto;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class ReservationService {
+
+    private final ReservationDao reservationDAO;
+
+    public List<ReservationResponseDto> findAllReservations() {
+        return List.copyOf(reservationDAO.findAll()
+                .stream()
+                .map(ReservationResponseDto::new)
+                .collect(Collectors.toList())
+        );
+    }
+
+    public ReservationResponseDto addReservation(ReservationRequestDto requestDto) {
+        Reservation reservation = reservationDAO.addReservation(requestDto);
+        return new ReservationResponseDto(reservation);
+    }
+
+    public void deleteReservation(Long id) {
+        reservationDAO.deleteReservationById(id);
+    }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 2e5cee3a8..b3feafc7a 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,4 +1,8 @@
-# h2-console \uD65C\uC131\uD654 \uC5EC\uBD80
 spring.h2.console.enabled=true
-# db url
-spring.datasource.url=jdbc:h2:
+spring.h2.console.path=/h2-console
+
+spring.datasource.url=jdbc:h2:mem:database
+spring.datasource.driver-class-name=org.h2.Driver
+spring.datasource.username=sa
+spring.datasource.password=
+spring.sql.init.mode=always
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
new file mode 100644
index 000000000..6fcf908e7
--- /dev/null
+++ b/src/main/resources/schema.sql
@@ -0,0 +1,7 @@
+CREATE TABLE reservation (
+                             id BIGINT NOT NULL AUTO_INCREMENT,
+                             name VARCHAR(255) NOT NULL,
+                             date VARCHAR(255) NOT NULL,
+                             time VARCHAR(255) NOT NULL,
+                             PRIMARY KEY (id)
+);
diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java
index c8615ff55..ce24d8b19 100644
--- a/src/test/java/roomescape/MissionStepTest.java
+++ b/src/test/java/roomescape/MissionStepTest.java
@@ -3,12 +3,19 @@
 import io.restassured.RestAssured;
 import io.restassured.http.ContentType;
 import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.test.annotation.DirtiesContext;
+import roomescape.domain.Reservation;
 
+import java.sql.Connection;
+import java.sql.SQLException;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.Matchers.is;
 
 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@@ -34,7 +41,7 @@ public class MissionStepTest {
                 .when().get("/reservations")
                 .then().log().all()
                 .statusCode(200)
-                .body("size()", is(3)); // 아직 생성 요청이 없으니 Controller에서 임의로 넣어준 Reservation 갯수 만큼 검증하거나 0개임을 확인하세요.
+                .body("size()", is(0)); // 아직 생성 요청이 없으니 Controller에서 임의로 넣어준 Reservation 갯수 만큼 검증하거나 0개임을 확인하세요.
     }
 
     @Test
@@ -50,7 +57,6 @@ public class MissionStepTest {
                 .when().post("/reservations")
                 .then().log().all()
                 .statusCode(201)
-                .header("Location", "/reservations/1")
                 .body("id", is(1));
 
         RestAssured.given().log().all()
@@ -88,8 +94,38 @@ public class MissionStepTest {
 
         // 삭제할 예약이 없는 경우
         RestAssured.given().log().all()
-                .when().delete("/reservations/1")
+                .when().delete("/reservations/999")
                 .then().log().all()
                 .statusCode(400);
     }
+
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    @Test
+    void 오단계() {
+        try (Connection connection = jdbcTemplate.getDataSource().getConnection()) {
+            assertThat(connection).isNotNull();
+            assertThat(connection.getCatalog()).isEqualTo("DATABASE");
+            assertThat(connection.getMetaData().getTables(null, null, "RESERVATION", null).next()).isTrue();
+        } catch (SQLException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    void 육단계() {
+        jdbcTemplate.update("INSERT INTO reservation (name, date, time) VALUES (?, ?, ?)", "브라운", "2023-08-05", "15:40");
+
+        List<Reservation> reservations = RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200).extract()
+                .jsonPath().getList(".", Reservation.class);
+
+        Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class);
+
+        assertThat(reservations.size()).isEqualTo(count);
+    }
+
 }
diff --git a/src/test/java/roomescape/ReservationDAOTest.java b/src/test/java/roomescape/ReservationDAOTest.java
deleted file mode 100644
index 2c86f4cc4..000000000
--- a/src/test/java/roomescape/ReservationDAOTest.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package roomescape;
-
-import static java.sql.DriverManager.getConnection;
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.time.LocalDate;
-import java.time.LocalTime;
-import org.junit.jupiter.api.Test;
-
-import java.sql.SQLException;
-import org.springframework.boot.test.context.SpringBootTest;
-import roomescape.dao.ReservationDAO;
-import roomescape.domain.Reservation;
-
-@SpringBootTest
-class ReservationDAOTest {
-
-    private final ReservationDAO reservationDAO = new ReservationDAO();
-
-    @Test
-    void connection() {
-        try (final var connection = reservationDAO.getConnection()){
-            assertThat(connection).isNotNull();
-        }
-        catch (SQLException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    @Test
-    void addReservation() {
-        final var reservation = new Reservation(1L, "testUser", LocalDate.parse("2023-08-05"),
-                LocalTime.parse("19:25"));
-        reservationDAO.addReservation(reservation);
-    }
-
-    @Test
-    void findById() {
-        final var reservation = new Reservation(1L, "testUser", LocalDate.parse("2023-08-05"),
-                LocalTime.parse("19:25"));
-        reservationDAO.addReservation(reservation);
-
-        assertThat(reservation).isEqualTo(reservationDAO.findById(1L));
-    }
-}


Reviews:
user: login: be-student
body: 안녕하세요 지우님, 바쁜 와중에도 꼼꼼하게 코드를 작성해 주셔서 정말 감사드립니다! 🙇
제가 회사에서 리뷰를 계속 하다보니, 칭찬을 남기는 것을 까먹는 안좋은 습관이 생겨버렸네요
지우님이 리뷰를 읽어 보시면서 혹시라도 딱딱하거나 빠른 말투로 느껴지는 부분이 있다면 너그럽게 이해해 주시면 감사하겠습니다.
지우님 코드에는 이미 깔끔한 네이밍과 명확한 역할 분리가 보여서, 클래스 간 책임이 잘 드러나 있다는 인상을 받았어요. 특히 DTO와 도메인 객체를 잘 나누고, 검증 로직을 적절히 배치해 두신 점이 인상적이었습니다.
다만 제가 리뷰를 남기다 보면 자연스레 개선할 점 위주로 눈에 들어오는데, 이 또한 더 나은 코드 품질을 위해 함께 고민해 보고 싶은 마음에서임을 알아주셨으면 좋겠습니다.
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-05-16T12:15:11Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-17T16:02:19Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-17T16:08:09Z
user: login: be-student
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T05:05:05Z
user: login: be-student
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T05:09:02Z
user: login: be-student
body: 안녕하세요 지우님
적용해주신 변경점들은 확인해보았습니다! 고생 많으셨어요

적어주신 내용을 기준으로 봤을 때 

## 1번

적어주신 형태로 insert 와 동시에 key 를 가져오는 것은 현업에서도 무조건 지켜서 사용하는 방식입니다!
잘 해주셨어요

## 2번

>Dao를 리팩토링할 때 파라미터 바인딩 방식의 BeanPropertySqlParameterSource과 MapSqlParameterSource를 알게 되었습니다.
실수를 방지하려면 Bean-방식이 유용할 것 같아서 이를 적극 활용하려고 했으나 RequestDto에 id 필드가 없어 Map-방식도 이용해보았습니다.

> 처음 알게 된 내용을 적용한 부분이라 혹시 개선방안, 유의해야 할 부분이 있다면 알려주시기 바랍니다!
 
라이브 환경이라면 무조건 MapSql 쪽을 쓴다고 보시면 됩니다
코드상의 변수명과 db 의 column 이 달라지는 것은 너무 자주 있는 상황이다보니 이를 동일하게 일치시키는 것을 피하기 위해서라도 MapSql 같은 형태를 쓸 것 같아요
물론 라이브 환경에서 jdbc template 을 쓰는 것 자체는 더 적겠지만요

그 외에는 코드상에서 크게 피드백 드릴 것은 없는 것 같아요

## 당부의 말씀

지우님, 이번에 드리고 싶은 말씀은 코드 자체보다는 직접 확인하고 테스트해보는 과정에 대한 부분이에요.

혹시 아래 항목 중에서 어떤 부분들을 직접 해보셨는지 궁금합니다:
1. 이 프로젝트의 API가 호출되는 시점이 언제인지 확인해보았다
2. 프로젝트의 웹페이지에 직접 접속해보았다
3. 코드 작성 후, localhost:8080에서 실제 웹 페이지를 통해 테스트해보았다
4. 코드 작성 후, 스프링 애플리케이션을 실행해보았다

이런 과정들은 과제나 PR을 한 번 더 왔다 갔다 하는 것보다 훨씬 더 중요하다고 생각합니다.
작동 여부를 스스로 확인하는 습관은 앞으로 개발을 하면서도 꼭 필요한 부분이니까요.

이번 PR에서는 별도의 수정 요청 없이, 동작하는 최소한의 코드만 올려주시면 머지해드릴게요.
앞으로는 이런 점들 꼭 챙겨가시면서 작업해주시면 좋겠습니다 
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-05-18T06:14:47Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T16:57:30Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T16:58:26Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T17:23:18Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T17:26:06Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T17:28:18Z
user: login: Ji-Woo-Kim
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-18T18:09:35Z
user: login: be-student
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-19T16:21:08Z
user: login: be-student
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-19T16:45:07Z

Review comments:
diff_hunk: @@ -13,7 +13,14 @@ repositories {
 }
 
 dependencies {
-    implementation 'org.springframework.boot:spring-boot-starter'
+    implementation 'org.springframework.boot:spring-boot-starter-web'
+    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+    implementation 'org.springframework.boot:spring-boot-starter-validation'
+    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+    runtimeOnly 'com.h2database:h2'
+    implementation 'org.projectlombok:lombok'
path: build.gradle
user: login: be-student
body: 소소하지만 lombok 은 implementation 할 필요가 없습니다! 🙇 
created_at: 2025-05-16T11:35:15Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
path: src/main/java/roomescape/controller/ReservationController.java
user: login: be-student
body: 이 부분은 HomeController 로 이동해야 할 것 같아요
created_at: 2025-05-16T11:35:59Z
author_association: NONE
diff_hunk: @@ -0,0 +1,36 @@
+package roomescape.service;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.dto.ReservationResponseDto;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class ReservationService {
+
+    private final ReservationDao reservationDAO;
+
+    public List<ReservationResponseDto> findAllReservations() {
+        return List.copyOf(reservationDAO.findAll()
+                .stream()
+                .map(ReservationResponseDto::new)
+                .collect(Collectors.toList())
+        );
+    }
+
+    public ReservationResponseDto addReservation(@Valid ReservationRequestDto requestDto) {
path: src/main/java/roomescape/service/ReservationService.java
user: login: be-student
body: Controller 레이어에서 검사한 것을 Service 에서 중복으로 검사하는 것 같아요!
created_at: 2025-05-16T11:37:27Z
author_association: NONE
diff_hunk: @@ -0,0 +1,24 @@
+package roomescape.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import roomescape.dto.ReservationRequestDto;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+@Getter
+@AllArgsConstructor
+@EqualsAndHashCode
+public class Reservation {
+
+    private Long id;
+    private String name;
+    private LocalDate date;
+    private LocalTime time;
+
+    public static Reservation fromDto(Long id, ReservationRequestDto dto) {
+        return new Reservation(id, dto.getName(), dto.getDate(), dto.getTime());
+    }
path: src/main/java/roomescape/domain/Reservation.java
user: login: be-student
body: 사용하지 않는 코드는 지워주어도 좋을 것 같아요!
created_at: 2025-05-16T11:38:24Z
author_association: NONE
diff_hunk: @@ -0,0 +1,75 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.exception.reservation.NotReservationFoundException;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+
+@Component
+public class ReservationDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationDao(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public Reservation addReservation(ReservationRequestDto dto) {
+        jdbcTemplate.update(
+                "INSERT INTO reservation (name, date, time) VALUES (?, ?, ?)",
+                dto.getName(),
+                dto.getDate().toString(),
+                dto.getTime().toString()
+        );
+
+        Long id = jdbcTemplate.queryForObject("SELECT MAX(id) FROM reservation", Long.class);
+        return findById(id);
+    }
path: src/main/java/roomescape/dao/ReservationDao.java
user: login: be-student
body: 이 코드는 동시성 문제가 발생할 수 있는데요
의도적으로 그 상황을 발생시킨 코드입니다!
jdbc 템플릿에서 insert 이후에 바로 id 를 받아오는 기능을 어떻게 할 수 있는지 찾아보셔도 좋을 것 같아요
````java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
class ReservationDaoConcurrencyTest {

    @Autowired
    private ReservationDao reservationDao;
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    void setUp() {
        // 스키마 생성
        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS reservation (" +
                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
                "name VARCHAR(255), date VARCHAR(255), time VARCHAR(255)" +
                ")");
    }

    @Test
    void addReservationConcurrencyIssue() throws InterruptedException {
        int threadCount = 2;
        CountDownLatch readyLatch = new CountDownLatch(threadCount);
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch doneLatch = new CountDownLatch(threadCount);

        AtomicReference<Long> idThread1 = new AtomicReference<>();
        AtomicReference<Long> idThread2 = new AtomicReference<>();

        Runnable task1 = () -> {
            readyLatch.countDown();
            try {
                startLatch.await();
                ReservationRequestDto dto = new ReservationRequestDto();
                dto.setName("Team A");
                dto.setDate(LocalDate.now());
                dto.setTime(LocalTime.now());
                idThread1.set(reservationDao.addReservation(dto).getId());
            } catch (InterruptedException ignored) {
            } finally {
                doneLatch.countDown();
            }
        };

        Runnable task2 = () -> {
            readyLatch.countDown();
            try {
                startLatch.await();
                ReservationRequestDto dto = new ReservationRequestDto();
                dto.setName("Team B");
                dto.setDate(LocalDate.now());
                dto.setTime(LocalTime.now());
                idThread2.set(reservationDao.addReservation(dto).getId());
            } catch (InterruptedException ignored) {
            } finally {
                doneLatch.countDown();
            }
        };

        new Thread(task1).start();
        new Thread(task2).start();

        // 두 쓰레드 모두 준비될 때까지 대기
        readyLatch.await();
        // 동시에 시작
        startLatch.countDown();
        // 완료될 때까지 대기
        doneLatch.await();

        System.out.println("Thread1 got ID: " + idThread1.get());
        System.out.println("Thread2 got ID: " + idThread2.get());

        // 동시성 문제로 두 쓰레드가 같은 ID를 받을 수도 있다!
        assertThat(idThread1.get()).isNotEqualTo(idThread2.get());
    }
}
```
created_at: 2025-05-16T11:54:54Z
author_association: NONE
diff_hunk: @@ -0,0 +1,75 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.exception.reservation.NotReservationFoundException;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+
+@Component
+public class ReservationDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationDao(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public Reservation addReservation(ReservationRequestDto dto) {
+        jdbcTemplate.update(
+                "INSERT INTO reservation (name, date, time) VALUES (?, ?, ?)",
+                dto.getName(),
+                dto.getDate().toString(),
+                dto.getTime().toString()
+        );
+
+        Long id = jdbcTemplate.queryForObject("SELECT MAX(id) FROM reservation", Long.class);
+        return findById(id);
+    }
path: src/main/java/roomescape/dao/ReservationDao.java
user: login: be-student
body: jdbc template 을 통해서 여러개의 인자를 insert 하게 되면 실수의 여지가 꽤 커지는데요
이럴 때 [namedJdbcTemplate](https://mungto.tistory.com/445) 을 통해서 조금 더 안정적으로 기능 개발을 할 수 있을 것 같아요
created_at: 2025-05-16T12:02:12Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
+    @GetMapping("/reservations")
+    public ResponseEntity<List<ReservationResponseDto>> findAll() {
+        return ResponseEntity.ok(reservationService.findAllReservations());
+    }
+
+    @PostMapping("/reservations")
+    public ResponseEntity<ReservationResponseDto> create(@Valid @RequestBody ReservationRequestDto requestDto) {
+        ReservationResponseDto newReservation = reservationService.addReservation(requestDto);
+
+        URI location = URI.create("/reservations/" + newReservation.getId());
path: src/main/java/roomescape/controller/ReservationController.java
user: login: be-student
body: HTTP 201 Created 응답에서 Location 헤더는 생성된 자원의 URI를 의미하고, 일반적으로 해당 URI로 GET 요청이 가능해야 합니다.
그런데 현재 /reservations/{id}에 대한 GET API가 없다면 이 URI는 무의미하므로,

GET API를 추가하거나
Location 헤더를 제거하는 것이 더 좋을 것 같습니다
created_at: 2025-05-16T12:06:47Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
+    @GetMapping("/reservations")
+    public ResponseEntity<List<ReservationResponseDto>> findAll() {
+        return ResponseEntity.ok(reservationService.findAllReservations());
+    }
+
+    @PostMapping("/reservations")
+    public ResponseEntity<ReservationResponseDto> create(@Valid @RequestBody ReservationRequestDto requestDto) {
+        ReservationResponseDto newReservation = reservationService.addReservation(requestDto);
+
+        URI location = URI.create("/reservations/" + newReservation.getId());
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Ji-Woo-Kim
body: 현재는 Location 헤더를 제거하는 방향으로 리팩토링 했습니다!
후에 기능 요구사항에 맞춰 GET api를 추가하는 것이 좋을 것 같다고 생각됩니다.
created_at: 2025-05-17T16:02:19Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Ji-Woo-Kim
body: 지난 MVC 미션을 하면서 ,
`HomeController`를 `PageController`로 네이밍하여 home페이지와 reservation페이지를 랜더링하도록 구현했었습니다.

이 때, 이후 보여주어야 할 페이지가 많아진다면 `PageController`에 메서드가 많아질 문제도 있고 
예약 화면 관련 로직을 찾을 때 `ReservationController`를 먼저 확인하게 될 가능성이 높은 점 등을 고려하여 
`reservationPage()`를 다시 `ReservationController`로 이동했습니다.

이 리뷰를 통해 저는 `Controller`를 api domain에 따라 네이밍하고 그에 맞는 관련 로직을 담아두는 것이 더 유용한 설계라고 생각했습니다.

은우님께서는 혹시 이와 관련해서 어떤 의견이신지 궁금합니다!
created_at: 2025-05-17T16:08:09Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
+    @GetMapping("/reservations")
+    public ResponseEntity<List<ReservationResponseDto>> findAll() {
+        return ResponseEntity.ok(reservationService.findAllReservations());
+    }
+
+    @PostMapping("/reservations")
+    public ResponseEntity<ReservationResponseDto> create(@Valid @RequestBody ReservationRequestDto requestDto) {
+        ReservationResponseDto newReservation = reservationService.addReservation(requestDto);
+
+        URI location = URI.create("/reservations/" + newReservation.getId());
path: src/main/java/roomescape/controller/ReservationController.java
user: login: be-student
body: 이 부분은 [HATEOAS](https://dev-coco.tistory.com/187)
라고 하는 명칭으로 검색해보시면 더 자세하게 알 수 있을 것 같아요
created_at: 2025-05-18T05:05:04Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
path: src/main/java/roomescape/controller/ReservationController.java
user: login: be-student
body: 지우님 혹시 
localhost:8080/reservation 으로 접속해보셨나요?

`@Controller` 어노테이션 + string 반환 = html 렌더링
`@RestController` 어노테이션 + string 반환 = string 만 반환

이렇게 2가지가 명확한 차이가 있어요
만약 그런 것을 의도하신 것이라면, RestController 말고, Controller 어노테이션이 달린 ReservationController 의 새로운 버전이 있어야 할 것이고, 그 장소로 옮겨주셔야 정상적으로 동작할 것으로 보여요
created_at: 2025-05-18T05:09:02Z
author_association: NONE
diff_hunk: @@ -1,45 +0,0 @@
-package roomescape;
-
-import static java.sql.DriverManager.getConnection;
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.time.LocalDate;
-import java.time.LocalTime;
-import org.junit.jupiter.api.Test;
-
-import java.sql.SQLException;
-import org.springframework.boot.test.context.SpringBootTest;
-import roomescape.dao.ReservationDAO;
-import roomescape.domain.Reservation;
-
-@SpringBootTest
-class ReservationDAOTest {
-
-    private final ReservationDAO reservationDAO = new ReservationDAO();
-
-    @Test
-    void connection() {
-        try (final var connection = reservationDAO.getConnection()){
-            assertThat(connection).isNotNull();
-        }
-        catch (SQLException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    @Test
-    void addReservation() {
-        final var reservation = new Reservation(1L, "testUser", LocalDate.parse("2023-08-05"),
-                LocalTime.parse("19:25"));
-        reservationDAO.addReservation(reservation);
-    }
-
-    @Test
-    void findById() {
-        final var reservation = new Reservation(1L, "testUser", LocalDate.parse("2023-08-05"),
-                LocalTime.parse("19:25"));
-        reservationDAO.addReservation(reservation);
-
-        assertThat(reservation).isEqualTo(reservationDAO.findById(1L));
-    }
-}
path: src/test/java/roomescape/ReservationDAOTest.java
user: login: be-student
body: 혹시 이 부분이 없어진 이유는 어떤 것일까요? 테스트코드를 없애는 것은 웬만하면 없으면 좋을 것 같아요
created_at: 2025-05-18T05:23:07Z
author_association: NONE
diff_hunk: @@ -0,0 +1,81 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Component;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.exception.reservation.NotReservationFoundException;
+import roomescape.exception.reservation.ReservationInsertFailedException;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.Optional;
+
+@Component
+public class ReservationDao {
+
+    private final NamedParameterJdbcTemplate namedJdbcTemplate;
+
+    public ReservationDao(NamedParameterJdbcTemplate namedJdbcTemplate) {
+        this.namedJdbcTemplate = namedJdbcTemplate;
+    }
+
+    private final RowMapper<Reservation> reservationRowMapper = (resultSet, rowNum) -> new Reservation(
+            resultSet.getLong("id"),
+            resultSet.getString("name"),
+            LocalDate.parse(resultSet.getString("date")),
+            LocalTime.parse(resultSet.getString("time"))
+    );
+
+    public Reservation addReservation(ReservationRequestDto dto) {
+        String sql = "INSERT INTO reservation (name, date, time) VALUES (:name, :date, :time)";
+        BeanPropertySqlParameterSource parameterSource = new BeanPropertySqlParameterSource(dto);
+
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        namedJdbcTemplate.update(sql, parameterSource, keyHolder, new String[]{"id"});
+
+        Long id = Optional.ofNullable(keyHolder.getKey())
+                .map(Number::longValue)
+                .orElseThrow(() -> new ReservationInsertFailedException("예약 ID 생성에 실패했습니다."));
path: src/main/java/roomescape/dao/ReservationDao.java
user: login: be-student
body: 이번 PR에서 이전과 유사하게 컴파일 이슈가 있어서 리뷰에 어려움이 있었어요. PR을 보내주실 때, 한 번쯤 실행까지 확인해주시면 더 원활하게 리뷰가 진행될 수 있을 것 같아요
앞으로 과제나 개발을 해나가실 때도 이런 부분을 꼭 챙겨주시면 좋겠습니다. 작은 부분이지만 서로의 시간을 존중하는 좋은 습관이 될 것 같아요!
created_at: 2025-05-18T05:44:06Z
author_association: NONE
diff_hunk: @@ -9,14 +10,11 @@
 
 @Getter
 @AllArgsConstructor
+@EqualsAndHashCode
path: src/main/java/roomescape/domain/Reservation.java
user: login: be-student
body: 혹시 이 부분이  들어간 이유는 무엇일까요?
의도가 없이 그냥 추가하고 변경되는 것은 최대한 지양해주시면 좋을 것 같아요
created_at: 2025-05-18T05:50:12Z
author_association: NONE
diff_hunk: @@ -88,8 +92,38 @@ public class MissionStepTest {
 
         // 삭제할 예약이 없는 경우
         RestAssured.given().log().all()
-                .when().delete("/reservations/1")
+                .when().delete("/reservations/999")
                 .then().log().all()
                 .statusCode(400);
     }
+
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    @Test
+    void 오단계() {
+        try (Connection connection = jdbcTemplate.getDataSource().getConnection()) {
+            assertThat(connection).isNotNull();
+            assertThat(connection.getCatalog()).isEqualTo("DATABASE");
+            assertThat(connection.getMetaData().getTables(null, null, "RESERVATION", null).next()).isTrue();
+        } catch (SQLException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+//    @Test
+//    void 육단계() {
+//        jdbcTemplate.update("INSERT INTO reservation (name, date, time) VALUES (?, ?, ?)", "브라운", "2023-08-05", "15:40");
+//
+//        List<Reservation> reservations = RestAssured.given().log().all()
+//                .when().get("/reservations")
+//                .then().log().all()
+//                .statusCode(200).extract()
+//                .jsonPath().getList(".", Reservation.class);
+//
+//        Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class);
+//
+//        assertThat(reservations.size()).isEqualTo(count);
+//    }
+
path: src/test/java/roomescape/MissionStepTest.java
user: login: be-student
body: 6단계 미션을 하시면 6단계 테스트코드는 살려서 진행해보시면 어떨까요?
created_at: 2025-05-18T05:50:59Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Ji-Woo-Kim
body: controller 부분을 리팩토링하면서, 
`PageController`(현 HomeController)엔 뷰를 반환하는 메서드만 존재했고
`ReservationController`엔 json을 반환하는 메서드만 남게되어 
`@Controller`를 `@RestController`로 바꿨는데 
이후 다시 현재와 같은 방식으로 리팩하면서 차마 이 부분은 고치지 않고 넘어간 것 같습니다.

이후, nextstep에서 제공하는 테스트 코드 패스에서 어려움이 많아 리뷰진행이 늦어졌었는데요 
테스트코드가 정상 작동하는 것에 급급하여, localhost로 직접 확인 과정을 누락해버렸네요...
죄송합니다 😢 
created_at: 2025-05-18T16:57:30Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Ji-Woo-Kim
body: 우선 현재는 `ReservationController`의 어노테이션을 `@Controller`로 바꾸고 
json을 반환하는 메서드엔 `@ResponseBody`를 각각 붙여주었습니다!
created_at: 2025-05-18T16:58:26Z
author_association: NONE
diff_hunk: @@ -1,45 +0,0 @@
-package roomescape;
-
-import static java.sql.DriverManager.getConnection;
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.time.LocalDate;
-import java.time.LocalTime;
-import org.junit.jupiter.api.Test;
-
-import java.sql.SQLException;
-import org.springframework.boot.test.context.SpringBootTest;
-import roomescape.dao.ReservationDAO;
-import roomescape.domain.Reservation;
-
-@SpringBootTest
-class ReservationDAOTest {
-
-    private final ReservationDAO reservationDAO = new ReservationDAO();
-
-    @Test
-    void connection() {
-        try (final var connection = reservationDAO.getConnection()){
-            assertThat(connection).isNotNull();
-        }
-        catch (SQLException e) {
-            throw new RuntimeException(e);
-        }
-    }
-
-    @Test
-    void addReservation() {
-        final var reservation = new Reservation(1L, "testUser", LocalDate.parse("2023-08-05"),
-                LocalTime.parse("19:25"));
-        reservationDAO.addReservation(reservation);
-    }
-
-    @Test
-    void findById() {
-        final var reservation = new Reservation(1L, "testUser", LocalDate.parse("2023-08-05"),
-                LocalTime.parse("19:25"));
-        reservationDAO.addReservation(reservation);
-
-        assertThat(reservation).isEqualTo(reservationDAO.findById(1L));
-    }
-}
path: src/test/java/roomescape/ReservationDAOTest.java
user: login: Ji-Woo-Kim
body: 순수 JDBC에 대한 요구사항에 대한 테스트 코드여서 Spring jdbc를 적용하면서 지웠던 부분입니다.!
다시 생각해보니 이 테스트 코드도 spring jdbc를 적용하여 리팩토링 했어야 하는게 맞는 것 같습니다ㅜㅜ
created_at: 2025-05-18T17:23:18Z
author_association: NONE
diff_hunk: @@ -9,14 +10,11 @@
 
 @Getter
 @AllArgsConstructor
+@EqualsAndHashCode
path: src/main/java/roomescape/domain/Reservation.java
user: login: Ji-Woo-Kim
body: 순수 Jdbc 구현 시작 시 페어프로그래밍을 진행했었는데요,
이때 객체 자체로 동등성을 비교하는 테스트 코드를 위해 추가했던 어노테이션입니다.

이후 springjdbc를 적용시키면서 제거할까 했는데 도메인 객체라면 갖고 있으면 좋을 어노테이션이라는 말을 보고 그대로 남겨두었었습니다!
필수적으로 필요한 것이 아니라면 지우는 것이 좋을까요?
created_at: 2025-05-18T17:26:06Z
author_association: NONE
diff_hunk: @@ -88,8 +92,38 @@ public class MissionStepTest {
 
         // 삭제할 예약이 없는 경우
         RestAssured.given().log().all()
-                .when().delete("/reservations/1")
+                .when().delete("/reservations/999")
                 .then().log().all()
                 .statusCode(400);
     }
+
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    @Test
+    void 오단계() {
+        try (Connection connection = jdbcTemplate.getDataSource().getConnection()) {
+            assertThat(connection).isNotNull();
+            assertThat(connection.getCatalog()).isEqualTo("DATABASE");
+            assertThat(connection.getMetaData().getTables(null, null, "RESERVATION", null).next()).isTrue();
+        } catch (SQLException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+//    @Test
+//    void 육단계() {
+//        jdbcTemplate.update("INSERT INTO reservation (name, date, time) VALUES (?, ?, ?)", "브라운", "2023-08-05", "15:40");
+//
+//        List<Reservation> reservations = RestAssured.given().log().all()
+//                .when().get("/reservations")
+//                .then().log().all()
+//                .statusCode(200).extract()
+//                .jsonPath().getList(".", Reservation.class);
+//
+//        Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class);
+//
+//        assertThat(reservations.size()).isEqualTo(count);
+//    }
+
path: src/test/java/roomescape/MissionStepTest.java
user: login: Ji-Woo-Kim
body: nextstep 6단계 페이지에서 다음과 같은 문구가 있었습니다.

> 예약 추가/취소 API는 7단계에서 데이터베이스 전환이 진행되기 때문에 6단계에서는 예약 관리 기능이 정상동작 하지 않습니다.

제가 이를 육단계() 테스트는 정상작동되지 않는다로 오역하여 추후 7단계 진행하면서 자연스레 통과될 테스트라고 생각해버린 것 같습니다.

현재 테스트 통과되는 것 확인 됐습니다!
created_at: 2025-05-18T17:28:18Z
author_association: NONE
diff_hunk: @@ -0,0 +1,81 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Component;
+import roomescape.domain.Reservation;
+import roomescape.dto.ReservationRequestDto;
+import roomescape.exception.reservation.NotReservationFoundException;
+import roomescape.exception.reservation.ReservationInsertFailedException;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.Optional;
+
+@Component
+public class ReservationDao {
+
+    private final NamedParameterJdbcTemplate namedJdbcTemplate;
+
+    public ReservationDao(NamedParameterJdbcTemplate namedJdbcTemplate) {
+        this.namedJdbcTemplate = namedJdbcTemplate;
+    }
+
+    private final RowMapper<Reservation> reservationRowMapper = (resultSet, rowNum) -> new Reservation(
+            resultSet.getLong("id"),
+            resultSet.getString("name"),
+            LocalDate.parse(resultSet.getString("date")),
+            LocalTime.parse(resultSet.getString("time"))
+    );
+
+    public Reservation addReservation(ReservationRequestDto dto) {
+        String sql = "INSERT INTO reservation (name, date, time) VALUES (:name, :date, :time)";
+        BeanPropertySqlParameterSource parameterSource = new BeanPropertySqlParameterSource(dto);
+
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        namedJdbcTemplate.update(sql, parameterSource, keyHolder, new String[]{"id"});
+
+        Long id = Optional.ofNullable(keyHolder.getKey())
+                .map(Number::longValue)
+                .orElseThrow(() -> new ReservationInsertFailedException("예약 ID 생성에 실패했습니다."));
path: src/main/java/roomescape/dao/ReservationDao.java
user: login: Ji-Woo-Kim
body: 어떤 원인이었는지는 아직 정확히 모르겠으나 
![image](https://github.com/user-attachments/assets/fa4ed732-d5db-4184-837b-aa06741da157)
이 commit에 새로 작성한 예외클래스도 같이 포함시킨 것으로 기억하고 있는데 누락되었나 봅니다.

이유가 어떻든 리뷰하시는데 불편드리게 되어 너무 죄송해요.. 😓 
제가 좀 더 꼼꼼히 살피도록 노력하겠습니다 죄송합니다..
created_at: 2025-05-18T18:09:35Z
author_association: NONE
diff_hunk: @@ -9,14 +10,11 @@
 
 @Getter
 @AllArgsConstructor
+@EqualsAndHashCode
path: src/main/java/roomescape/domain/Reservation.java
user: login: be-student
body: entity 의 경우에는 동등성, 동일성 중에서 동등성으로 보통 처리하게 되는데요
https://steady-coding.tistory.com/534

그렇다면 동등성을 보장하는 기준은 어떻게 될까요?
https://dev-ws.tistory.com/66
이 블로그를 참고하시면 좋을 것 같은데요

entity 의 경우에는 모든 필드를 통해서 동등성을 하는 것 보다는 id 만을 기준으로 비교하면 좋을 것 같아요
created_at: 2025-05-19T16:21:08Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.*;
+import roomescape.service.ReservationService;
+
+import java.net.URI;
+import java.util.*;
+
+@RestController
+public class ReservationController {
+
+    private final ReservationService reservationService;
+
+    public ReservationController(ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @GetMapping("/reservation")
+    public String reservationPage() {
+        return "reservation";
+    }
+
path: src/main/java/roomescape/controller/ReservationController.java
user: login: be-student
body: 이렇게 하시는 것도 저는 충분히 좋다고 생각합니다
저는 만약 이런 상황이라면 Controller 로만 되어있는 컨트롤러랑, RestController 가 붙어있는 2개를 나눠서 처리하는 것을 선호합니다
그래서 아주 사소하지만 ReservationViewController / ReservationController 의 형태로 각각 나눈다던가 하는 방향으로 해도 좋을 것 같아요!
created_at: 2025-05-19T16:45:07Z
author_association: NONE

PR comments:
user: login: Ji-Woo-Kim
created_at: 2025-05-17T16:38:05Z
body: 은우님, 빠른 리뷰 감사합니다!
리뷰 주신 내용 살펴보고 코드 리팩토링 진행했습니다.

## ✅ 리팩토링 포인트
### 1. 불필요한 코드, 중복되는 로직 삭제
### 2. Location 헤더 제거
Get api를 새로 추가하는 방안도 고려했으나 관련 기능에 대한 요구사항이 명확히 정해져 있지 않기도 하고, 현재 요구되는 것들에 집중하여 코드를 구현하는 것이 좋을 것 같아 Location 헤더 제거하였습니다.
반면 nextstep에서 제공하는 테스트코드에서는 header를 검증하는 로직이 있었는데요, 이 부분도 그냥 제거하여 테스트 통과하도록 수정하였습니다.
### 3. ReservationDao `JdbcTemplate` -> `NamedParameterJdbcTemplate` 전환
리뷰 이전 코드에선 은우님 말씀처럼 파라미터 바인딩 방식이 실수를 유발하거나 가독성을 해치는 것을 알게 되었습니다. 그래서 제공해주신 링크와 추가로 관련 내용 찾아보고 `NamedParameterJdbcTemplate` 을 적용해보았습니다. 파라미터에 명시적으로 이름을 사용하니 더 가독성이 좋아진 것 같습니다.
### 3. ReservationDao `addreservation()`
- `BeanPropertySqlParameterSource` 적용
- KeyHolder 적용 : 기존 SELECT MAX 로 id를 가져오는 방식이 동시성 문제가 발생할 수 있다는 것을 해결하기 위해 GeneratedKeyHolder를 사용해 Id를 정확하게 추출할 수 있도록 리팩토링 했습니다. 이 방식이 적절한 해결책인지에 대한 확신이 없어 은우님의 의견이 궁금한 부분입니다!
- RowMapper 적용
### 4. 예외 처리 개선
`ID 생성 실패` 상황에 대한 `ReservationInsertFailedException` 추가 구현

---

Dao를 리팩토링할 때 파라미터 바인딩 방식의 BeanPropertySqlParameterSource과 MapSqlParameterSource를 알게 되었습니다.
실수를 방지하려면 Bean-방식이 유용할 것 같아서 이를 적극 활용하려고 했으나 RequestDto에 id 필드가 없어 Map-방식도 이용해보았습니다.

처음 알게 된 내용을 적용한 부분이라 혹시 개선방안, 유의해야 할 부분이 있다면 알려주시기 바랍니다!

감사합니다 😄 
author_association: NONE
user: login: Ji-Woo-Kim
created_at: 2025-05-19T12:14:15Z
body: 은우님, 
정확히 어떤 원인으로 예외 관련 클래스가 커밋 누락되었는지 모르겠으나
해당 부분 다시 커밋-푸쉬 해두었습니다.

이번에 리뷰하실 때 제가 기본적인 부분들을 꼼꼼히 살피지 못 한 점으로 인해 여러 불편함을 드리게 된 것 같아 아쉽고 죄송한 마음입니다.

당부해주신 말씀 참고하셔 앞으로의 미션에선 같은 실수 반복하지 않도록 노력해보겠습니다.

감사합니다!
author_association: NONE