Repository: woowacourse/spring-roomescape-admin
Pull request #302: [4-9단계 - 방탈출 예약 관리] 메이(김시원) 미션 제출합니다.
Author: login: seaniiio
Created: 2025-04-24T02:43:23Z | Merged: 2025-04-28T11:50:11Z

<!-- 

## 코드 리뷰 팁

- 코드와 관련된 질문이 있다면, PR 본문에 적기 보다는 해당 코드를 선택하고 코멘트를 남겨주세요.
  - [참고: Adding comments to a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#adding-comments-to-a-pull-request)

-->
## 체크 리스트
- [x] 미션의 필수 요구사항을 모두 구현했나요?
- [x] Gradle `test`를 실행했을 때, 모든 테스트가 정상적으로 통과했나요?
- [x] 애플리케이션이 정상적으로 실행되나요?
- [x] [프롤로그](https://prolog.techcourse.co.kr)에 셀프 체크를 작성했나요?
  <!-- 작성한 셀프 체크의 링크를 남겨주세요. -->
  - [프롤로그 링크입니다!](https://prolog.techcourse.co.kr/studylogs/4212)

## 객체지향 생활체조 요구사항을 얼마나 잘 충족했다고 생각하시나요?
### 1~5점 중에서 선택해주세요.

- [ ] 1 (전혀 충족하지 못함)
- [ ] 2
- [x] 3 (보통)
- [ ] 4
- [ ] 5 (완벽하게 충족)

### 선택한 점수의 이유를 적어주세요.
<!-- 이유 작성 -->
객체 지향에 많은 신경을 쓰지 않았다고 생각합니다.

## 웹 개발 경험 체크
🌱 레벨2 시작 이전에, 웹 애플리케이션 개발 경험이 있나요?
- [ ] 아니오. 전혀 경험이 없습니다.
- [ ] 예. 예제 코드를 따라하거나 클론 코딩을 해봤습니다. (ex. 강의를 보고 따라함)
- [ ] 예. 웹 애플리케이션을 만들었지만 로컬에서만 실행해봤습니다.
- [x] 예. 웹 애플리케이션을 만들고 배포해봤습니다. (ex. 클라우드 혹은 리모트 서버에 배포)

## 어떤 부분에 집중하여 리뷰해야 할까요?
<!-- 리뷰어가 효과적으로 피드백할 수 있도록 중점적으로 리뷰 받고 싶은 내용을 *요약* 형태로 작성해 주세요.
리뷰해야 할 포인트를 요약해 강조해 주시면, 리뷰어가 코드 전체를 이 부분에 집중해 리뷰할 수 있습니다.
반면 특정 코드부분에 대한 피드백이 필요하다면, 이 곳에 적기 보다 해당 코드를 선택하고 코멘트를 남기는 것을 권장합니다. -->

안녕하세요 범블비!

이번 미션도 리뷰 잘 부탁드립니다 😀

### 레이어드 아키텍쳐

레이어드 아키텍쳐를 적용했는데, **각 계층별로 책임이 알맞게 분배됐는지**, **각 계층별 데이터 전달을 적절한 방법으로 하고 있는지** 궁금합니다. 

제가 생각한 각 계층의 역할은 아래와 같습니다.

- 컨트롤러 → 요청을 받아오고 응답을 내려준다
- 서비스 → 유효성 검사, 요청 데이터를 기반으로 도메인 객체 생성, 도메인 객체 기반으로 응답 객체 생성
- 레포지토리 → db의 레코드를 읽어와서 알맞은 도메인 객체(혹은 DTO)를 생성, 도메인 객체를 db 레코드에 추가

#### 레이어드 계층 적용 계기
- db 기능이 추가되고, repository 계층을 추가하게 됐습니다.
- 도메인끼리 의존성(`Reservation` -> `ReservationTime`)이 생기고 나서 service의 필요성을 느꼈습니다. `Reservation`을 추가하거나 읽는 과정에서 `reservation` 테이블뿐만 아니라 `reservation_time` 테이블에도 접근해야 했기 때문입니다.(`ReservationController`에 `ReservationDao`와 `ReservationTimeDao`가 존재하는 것을 보고 개선의 필요성을 느꼈습니다.)
- 예약이 이미 존재하는지 확인하는 책임은 도메인에도, 컨트롤러에도 어울리지 않다고 생각했습니다. db에 저장된 데이터를 기반으로 검증하는 것이기 때문에, service 계층에 위임하는 것이 맞겠다는 생각이 들었습니다.

이 외에도 많은 지적 부탁드립니다! 

### 📌 코드리뷰 질문 그라운드룰 (지우지 마세요)
✅ 많은 사람과 공유하고 토론해보는 것이 더 좋은 질문에는, 리뷰어가 💬말풍선 이모지를 남겨 토론을 추천할 수 있어요.  
✅ 토론을 추천받은 질문은 `#be-7기-잡담-질답`슬랙채널이나 캠퍼스에서 자유롭게 토론을 이어가보세요!  
✅ 코드에 대한 맥락 없이 리뷰어의 주관적인 의견을 묻거나, 단순히 현업에서는 어떻게 하는지를 묻는 질문은 코드 리뷰 본문에는 어울리지 않아요.  
코드 리뷰는 변경된 코드의 목적, 의도, 품질을 중심으로 논의하는 자리예요.  
✅ 코드와 관련된 질문이 있다면 PR 본문에 적기보다는, 해당 코드를 선택해 코멘트를 남겨주세요.



Diff:
diff --git a/src/main/java/roomescape/controller/AdminController.java b/src/main/java/roomescape/controller/AdminController.java
index 8d0ef8dc16..4f025c63a2 100644
--- a/src/main/java/roomescape/controller/AdminController.java
+++ b/src/main/java/roomescape/controller/AdminController.java
@@ -15,6 +15,11 @@ public String showAdminMainPage() {
 
     @GetMapping("/reservation")
     public String showReservationManagementPage() {
-        return "/admin/reservation-legacy.html";
+        return "/admin/reservation.html";
+    }
+
+    @GetMapping("/time")
+    public String showTimeManagementPage() {
+        return "/admin/time.html";
     }
 }
diff --git a/src/main/java/roomescape/controller/ReservationController.java b/src/main/java/roomescape/controller/ReservationController.java
index 22db9d23ce..9a440c83f3 100644
--- a/src/main/java/roomescape/controller/ReservationController.java
+++ b/src/main/java/roomescape/controller/ReservationController.java
@@ -1,49 +1,39 @@
 package roomescape.controller;
 
+import jakarta.validation.Valid;
 import org.springframework.http.ResponseEntity;
-import org.springframework.stereotype.Controller;
 import org.springframework.web.bind.annotation.*;
-import roomescape.domain.Reservation;
-import roomescape.domain.Reservations;
 import roomescape.dto.request.ReservationRequest;
 import roomescape.dto.response.ReservationResponse;
+import roomescape.service.ReservationService;
 
-import java.time.LocalDateTime;
 import java.util.List;
-import java.util.concurrent.atomic.AtomicLong;
 
-@Controller
+@RestController
 @RequestMapping("/reservations")
 public class ReservationController {
 
-    private final AtomicLong counter = new AtomicLong(1);
-    private final Reservations reservations = new Reservations();
+    private final ReservationService reservationService;
+
+    public ReservationController(final ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
 
     @GetMapping
     public ResponseEntity<List<ReservationResponse>> readReservations() {
-        List<ReservationResponse> dtos = ReservationResponse.from(reservations);
-        return ResponseEntity.ok(dtos);
+        List<ReservationResponse> reservations = reservationService.findAllReservations();
+        return ResponseEntity.ok(reservations);
     }
 
     @PostMapping
-    public ResponseEntity<?> createReservation(@RequestBody ReservationRequest reservationRequest) {
-        try {
-            LocalDateTime dateTime = LocalDateTime.of(reservationRequest.date(), reservationRequest.time());
-            Reservation reservation = new Reservation(counter.getAndIncrement(), reservationRequest.name(), dateTime);
-            reservations.add(reservation);
-            return ResponseEntity.ok(ReservationResponse.from(reservation));
-        } catch (IllegalArgumentException e) {
-            return ResponseEntity.badRequest().body(e.getMessage());
-        }
+    public ResponseEntity<?> createReservation(@Valid @RequestBody ReservationRequest request) {
+        ReservationResponse response = reservationService.createReservation(request);
+        return ResponseEntity.ok(response);
     }
 
     @DeleteMapping("/{id}")
     public ResponseEntity<?> deleteReservation(@PathVariable final Long id) {
-        try {
-            reservations.deleteById(id);
-            return ResponseEntity.ok().build();
-        } catch (IllegalArgumentException e) {
-            return ResponseEntity.badRequest().body(e.getMessage());
-        }
+        reservationService.deleteReservation(id);
+        return ResponseEntity.ok().build();
     }
 }
diff --git a/src/main/java/roomescape/controller/ReservationTimeController.java b/src/main/java/roomescape/controller/ReservationTimeController.java
new file mode 100644
index 0000000000..818d15f2d8
--- /dev/null
+++ b/src/main/java/roomescape/controller/ReservationTimeController.java
@@ -0,0 +1,45 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationTimeResponse;
+import roomescape.service.ReservationService;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/times")
+public class ReservationTimeController {
+
+    private final ReservationService reservationService;
+
+    public ReservationTimeController(final ReservationService reservationService) {
+        this.reservationService = reservationService;
+    }
+
+    @PostMapping
+    public ResponseEntity<ReservationTimeResponse> createTime(@Valid @RequestBody final ReservationTimeRequest reservationTimeRequest) {
+        ReservationTimeResponse response = reservationService.createReservationTime(reservationTimeRequest);
+        return ResponseEntity.ok(response);
+    }
+
+    @GetMapping
+    public ResponseEntity<List<ReservationTimeResponse>> readTimes() {
+        List<ReservationTimeResponse> timeResponses = reservationService.findAllReservationTimes();
+        return ResponseEntity.ok().body(timeResponses);
+    }
+
+    @DeleteMapping("/{id}")
+    public ResponseEntity<Void> deleteTime(@PathVariable final Long id) {
+        reservationService.deleteReservationTime(id);
+        return ResponseEntity.ok().build();
+    }
+
+    @DeleteMapping
+    public ResponseEntity<Void> deleteAll() {
+        reservationService.deleteAllReservationTimes();
+        return ResponseEntity.ok().build();
+    }
+}
diff --git a/src/main/java/roomescape/dao/ReservationDao.java b/src/main/java/roomescape/dao/ReservationDao.java
new file mode 100644
index 0000000000..28e2076055
--- /dev/null
+++ b/src/main/java/roomescape/dao/ReservationDao.java
@@ -0,0 +1,70 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import roomescape.dto.create.ReservationCreate;
+import roomescape.dto.read.ReservationRead;
+
+import java.sql.PreparedStatement;
+import java.util.List;
+
+@Repository
+public class ReservationDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationDao(final JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public long save(final ReservationCreate reservationCreate) {
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        String query = "INSERT INTO reservation(name, date, time_id) VALUES (?, ?, ?)";
+        jdbcTemplate.update(connection -> {
+            PreparedStatement ps = connection.prepareStatement(query, new String[]{"id"});
+            ps.setString(1, reservationCreate.name());
+            ps.setString(2, reservationCreate.date());
+            ps.setLong(3, reservationCreate.timeId());
+            return ps;
+        }, keyHolder);
+
+        return keyHolder.getKey().longValue();
+    }
+
+    public List<ReservationRead> getAll() {
+        String query = "SELECT r.id as reservation_id, r.name, r.date, t.id as time_id, t.start_at as time_value " +
+                "FROM reservation r " +
+                "INNER JOIN reservation_time t on r.time_id = t.id";
+        return jdbcTemplate.query(query, reservationRowMapper());
+    }
+
+    public int delete(final Long id) {
+        String query = "DELETE FROM reservation WHERE id = ?";
+        int count = jdbcTemplate.update(query, id);
+        return count;
+    }
+
+    public void deleteAll() {
+        String query = "DELETE FROM reservation";
+        jdbcTemplate.update(query);
+    }
+
+    public boolean isReservationExist(final String date, final Long timeId) {
+        String query = "SELECT count(*) FROM reservation WHERE date = ? AND time_id = ?";
+        int count = jdbcTemplate.queryForObject(query, Integer.class, date, timeId);
+        return count != 0;
+    }
+
+    private RowMapper<ReservationRead> reservationRowMapper() {
+        return (resultSet, rowNum) -> new ReservationRead(
+                resultSet.getLong("id"),
+                resultSet.getString("name"),
+                resultSet.getString("date"),
+                resultSet.getLong("time_id"),
+                resultSet.getString("time_value")
+        );
+    }
+}
diff --git a/src/main/java/roomescape/dao/ReservationTimeDao.java b/src/main/java/roomescape/dao/ReservationTimeDao.java
new file mode 100644
index 0000000000..04b820105e
--- /dev/null
+++ b/src/main/java/roomescape/dao/ReservationTimeDao.java
@@ -0,0 +1,62 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import roomescape.dto.create.ReservationTimeCreate;
+import roomescape.dto.read.ReservationTimeRead;
+
+import java.sql.PreparedStatement;
+import java.util.List;
+
+@Repository
+public class ReservationTimeDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationTimeDao(final JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public long save(final ReservationTimeCreate reservationTimeCreate) {
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        String query = "INSERT INTO reservation_time(start_at) VALUES (?)";
+        jdbcTemplate.update(connection -> {
+            PreparedStatement ps = connection.prepareStatement(query, new String[]{"id"});
+            ps.setString(1, reservationTimeCreate.startAt());
+            return ps;
+        }, keyHolder);
+        return keyHolder.getKey().longValue();
+    }
+
+    public List<ReservationTimeRead> getAll() {
+        String query = "SELECT * FROM reservation_time";
+        return jdbcTemplate.query(query, timeRowMapper());
+    }
+
+    public ReservationTimeRead findById(final Long timeId) {
+        String query = "SELECT * FROM reservation_time WHERE id = ?";
+        return jdbcTemplate.queryForObject(query, timeRowMapper(), timeId);
+    }
+
+    private RowMapper<ReservationTimeRead> timeRowMapper() {
+        return (resultSet, rowNum) -> {
+            Long id = resultSet.getLong("id");
+            String time = resultSet.getString("start_at");
+            return new ReservationTimeRead(id, time);
+        };
+    }
+
+    public int delete(final Long id) {
+        String query = "DELETE FROM reservation_time WHERE id = ?";
+        int count = jdbcTemplate.update(query, id);
+        return count;
+    }
+
+    public void deleteAll() {
+        String query = "DELETE FROM reservation_time";
+        jdbcTemplate.update(query);
+    }
+}
diff --git a/src/main/java/roomescape/domain/Reservation.java b/src/main/java/roomescape/domain/Reservation.java
index 79d10215a1..8f55f3e163 100644
--- a/src/main/java/roomescape/domain/Reservation.java
+++ b/src/main/java/roomescape/domain/Reservation.java
@@ -1,24 +1,29 @@
 package roomescape.domain;
 
 import java.time.LocalDate;
-import java.time.LocalDateTime;
+import java.util.Objects;
 
 public class Reservation {
 
     private static final int MAX_NAME_LENGTH = 10;
-    private final Long id;
+    private Long id;
     private final String name;
-    private final LocalDateTime dateTime;
+    private final LocalDate date;
+    private final ReservationTime time;
 
-    public Reservation(final Long id, final String name, final LocalDateTime dateTime) {
-        validate(id, name, dateTime);
+    public Reservation(final Long id, final String name, final LocalDate date, final ReservationTime time) {
+        validate(id, name, date);
         this.id = id;
         this.name = name;
-        this.dateTime = dateTime;
+        this.date = date;
+        this.time = time;
     }
 
-    public boolean isSameId(final Long givenId) {
-        return id.equals(givenId);
+    public Reservation(final String name, final LocalDate date, final ReservationTime time) {
+        validate(name, date);
+        this.name = name;
+        this.date = date;
+        this.time = time;
     }
 
     public Long getId() {
@@ -29,14 +34,27 @@ public String getName() {
         return name;
     }
 
-    public LocalDateTime getDateTime() {
-        return dateTime;
+    public LocalDate getDate() {
+        return date;
+    }
+
+    public ReservationTime getTime() {
+        return time;
     }
 
-    private void validate(final Long id, final String name, final LocalDateTime dateTime) {
+    public void setId(final long id) {
+        this.id = id;
+    }
+
+    private void validate(final String name, final LocalDate date) {
+        validateName(name);
+        validateDate(date);
+    }
+
+    private void validate(final Long id, final String name, final LocalDate date) {
         validateId(id);
         validateName(name);
-        validateDateTime(dateTime);
+        validateDate(date);
     }
 
     private void validateId(final Long id) {
@@ -54,14 +72,27 @@ private void validateName(final String name) {
         }
     }
 
-    private void validateDateTime(final LocalDateTime dateTime) {
-        if (dateTime == null) {
+    private void validateDate(final LocalDate date) {
+        if (date == null) {
             throw new IllegalArgumentException("[ERROR] 예약 날짜는 null이 될 수 없습니다.");
         }
 
-        boolean isPast = dateTime.toLocalDate().isBefore(LocalDate.now());
+        boolean isPast = date.isBefore(LocalDate.now());
         if (isPast) {
             throw new IllegalArgumentException("[ERROR] 과거 날짜에 대한 예약을 할 수 없습니다.");
         }
     }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        final Reservation that = (Reservation) o;
+        return Objects.equals(id, that.id);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(id);
+    }
 }
diff --git a/src/main/java/roomescape/domain/ReservationTime.java b/src/main/java/roomescape/domain/ReservationTime.java
new file mode 100644
index 0000000000..e2853aedaa
--- /dev/null
+++ b/src/main/java/roomescape/domain/ReservationTime.java
@@ -0,0 +1,44 @@
+package roomescape.domain;
+
+import java.time.LocalTime;
+import java.util.Objects;
+
+public class ReservationTime {
+
+    private Long id;
+    private final LocalTime startAt;
+
+    public ReservationTime(final LocalTime startAt) {
+        this.startAt = startAt;
+    }
+
+    public ReservationTime(final Long id, final LocalTime startAt) {
+        this.id = id;
+        this.startAt = startAt;
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public LocalTime getStartAt() {
+        return startAt;
+    }
+
+    public void setId(final Long id) {
+        this.id = id;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        final ReservationTime that = (ReservationTime) o;
+        return Objects.equals(id, that.id);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(id);
+    }
+}
diff --git a/src/main/java/roomescape/domain/Reservations.java b/src/main/java/roomescape/domain/Reservations.java
deleted file mode 100644
index d6792c3f80..0000000000
--- a/src/main/java/roomescape/domain/Reservations.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package roomescape.domain;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-public class Reservations {
-
-    private final List<Reservation> reservations;
-
-    public Reservations() {
-        this.reservations = new ArrayList<>();
-    }
-
-    public synchronized void add(final Reservation reservation) {
-        reservations.add(reservation);
-    }
-
-    public synchronized void deleteById(final Long id) {
-        Reservation reservation = findById(id);
-        reservations.remove(reservation);
-    }
-
-    private Reservation findById(final Long id) {
-        return reservations.stream()
-                .filter(reservation -> reservation.isSameId(id))
-                .findFirst()
-                .orElseThrow(() -> new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다."));
-    }
-
-    public synchronized List<Reservation> getReservations() {
-        return Collections.unmodifiableList(reservations);
-    }
-}
diff --git a/src/main/java/roomescape/dto/create/ReservationCreate.java b/src/main/java/roomescape/dto/create/ReservationCreate.java
new file mode 100644
index 0000000000..876f24bbb0
--- /dev/null
+++ b/src/main/java/roomescape/dto/create/ReservationCreate.java
@@ -0,0 +1,8 @@
+package roomescape.dto.create;
+
+public record ReservationCreate(
+        String name,
+        String date,
+        Long timeId
+) {
+}
diff --git a/src/main/java/roomescape/dto/create/ReservationTimeCreate.java b/src/main/java/roomescape/dto/create/ReservationTimeCreate.java
new file mode 100644
index 0000000000..9752480840
--- /dev/null
+++ b/src/main/java/roomescape/dto/create/ReservationTimeCreate.java
@@ -0,0 +1,6 @@
+package roomescape.dto.create;
+
+public record ReservationTimeCreate(
+        String startAt
+) {
+}
diff --git a/src/main/java/roomescape/dto/read/ReservationRead.java b/src/main/java/roomescape/dto/read/ReservationRead.java
new file mode 100644
index 0000000000..0759841932
--- /dev/null
+++ b/src/main/java/roomescape/dto/read/ReservationRead.java
@@ -0,0 +1,10 @@
+package roomescape.dto.read;
+
+public record ReservationRead(
+        Long reservationId,
+        String name,
+        String date,
+        Long timeId,
+        String timeValue
+) {
+}
diff --git a/src/main/java/roomescape/dto/read/ReservationTimeRead.java b/src/main/java/roomescape/dto/read/ReservationTimeRead.java
new file mode 100644
index 0000000000..18658e4e41
--- /dev/null
+++ b/src/main/java/roomescape/dto/read/ReservationTimeRead.java
@@ -0,0 +1,7 @@
+package roomescape.dto.read;
+
+public record ReservationTimeRead(
+        Long id,
+        String startAt
+) {
+}
diff --git a/src/main/java/roomescape/dto/request/ReservationRequest.java b/src/main/java/roomescape/dto/request/ReservationRequest.java
index 663e7bd56b..9e049f3aaa 100644
--- a/src/main/java/roomescape/dto/request/ReservationRequest.java
+++ b/src/main/java/roomescape/dto/request/ReservationRequest.java
@@ -1,7 +1,11 @@
 package roomescape.dto.request;
 
+import jakarta.validation.constraints.NotNull;
+
 import java.time.LocalDate;
-import java.time.LocalTime;
 
-public record ReservationRequest(LocalDate date, String name, LocalTime time) {
+public record ReservationRequest(
+        @NotNull LocalDate date,
+        @NotNull String name,
+        @NotNull Long timeId) {
 }
diff --git a/src/main/java/roomescape/dto/request/ReservationTimeRequest.java b/src/main/java/roomescape/dto/request/ReservationTimeRequest.java
new file mode 100644
index 0000000000..a5918e176b
--- /dev/null
+++ b/src/main/java/roomescape/dto/request/ReservationTimeRequest.java
@@ -0,0 +1,8 @@
+package roomescape.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+
+import java.time.LocalTime;
+
+public record ReservationTimeRequest(@NotNull LocalTime startAt) {
+}
diff --git a/src/main/java/roomescape/dto/response/ReservationResponse.java b/src/main/java/roomescape/dto/response/ReservationResponse.java
index fcd3bad555..082fc78044 100644
--- a/src/main/java/roomescape/dto/response/ReservationResponse.java
+++ b/src/main/java/roomescape/dto/response/ReservationResponse.java
@@ -1,23 +1,10 @@
 package roomescape.dto.response;
 
-import roomescape.domain.Reservation;
-import roomescape.domain.Reservations;
-
 import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.util.List;
-
-public record ReservationResponse(Long id, String name, LocalDate date, LocalTime time) {
-
-    public static List<ReservationResponse> from(final Reservations reservations) {
-        return reservations.getReservations().stream()
-                .map(ReservationResponse::from)
-                .toList();
-    }
 
-    public static ReservationResponse from(final Reservation reservation) {
-        LocalDateTime dateTime = reservation.getDateTime();
-        return new ReservationResponse(reservation.getId(), reservation.getName(), dateTime.toLocalDate(), dateTime.toLocalTime());
-    }
+public record ReservationResponse(
+        Long id,
+        String name,
+        LocalDate date,
+        ReservationTimeResponse time) {
 }
diff --git a/src/main/java/roomescape/dto/response/ReservationTimeResponse.java b/src/main/java/roomescape/dto/response/ReservationTimeResponse.java
new file mode 100644
index 0000000000..636850f00d
--- /dev/null
+++ b/src/main/java/roomescape/dto/response/ReservationTimeResponse.java
@@ -0,0 +1,8 @@
+package roomescape.dto.response;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.time.LocalTime;
+
+public record ReservationTimeResponse(Long id, @JsonFormat(pattern = "HH:mm") LocalTime startAt) {
+}
diff --git a/src/main/java/roomescape/exception/ReservationExceptionHandler.java b/src/main/java/roomescape/exception/ReservationExceptionHandler.java
new file mode 100644
index 0000000000..6d25f3052f
--- /dev/null
+++ b/src/main/java/roomescape/exception/ReservationExceptionHandler.java
@@ -0,0 +1,15 @@
+package roomescape.exception;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice
+public class ReservationExceptionHandler {
+
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException exception) {
+        return ResponseEntity.badRequest()
+                .body(exception.getMessage());
+    }
+}
diff --git a/src/main/java/roomescape/service/ReservationService.java b/src/main/java/roomescape/service/ReservationService.java
new file mode 100644
index 0000000000..60b94ddb11
--- /dev/null
+++ b/src/main/java/roomescape/service/ReservationService.java
@@ -0,0 +1,151 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.create.ReservationCreate;
+import roomescape.dto.create.ReservationTimeCreate;
+import roomescape.dto.read.ReservationRead;
+import roomescape.dto.read.ReservationTimeRead;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        checkReservationExist(request);
+        ReservationTimeRead readTime = reservationTimeDao.findById(request.timeId());
+        ReservationTime time = toReservationTime(readTime);
+
+        Reservation reservation = toReservation(request, time);
+        ReservationCreate reservationCreate = toReservationCreate(reservation);
+        long id = reservationDao.save(reservationCreate);
+
+        ReservationTimeResponse timeResponse = toReservationTimeResponse(time);
+        reservation.setId(id);
+        return toReservationResponse(reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationRead> reservationReads = reservationDao.getAll();
+        List<Reservation> reservations = toReservations(reservationReads);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTimeRead readTime = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTime time = toReservationTime(readTime);
+                    ReservationTimeResponse timeResponse = toReservationTimeResponse(time);
+                    return toReservationResponse(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = toReservationTime(request);
+        ReservationTimeCreate reservationTimeCreate = toReservationTimeCreate(reservationTime);
+        long id = reservationTimeDao.save(reservationTimeCreate);
+
+        reservationTime.setId(id);
+        return toReservationTimeResponse(reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTimeRead> readTimes = reservationTimeDao.getAll();
+        List<ReservationTime> times = readTimes.stream()
+                .map(this::toReservationTime)
+                .toList();
+        return toReservationTimeResponses(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationExist(final ReservationRequest request) {
+        if (reservationDao.isReservationExist(request.date().toString(), request.timeId())) {
+            throw new IllegalArgumentException("[ERROR] 해당 날짜와 시간에 대한 예약 기록이 존재합니다.");
+        }
+    }
+
+    private List<Reservation> toReservations(List<ReservationRead> reads) {
+        return reads.stream()
+                .map(this::toReservation)
+                .toList();
+    }
+
+    private Reservation toReservation(ReservationRead read) {
+        return new Reservation(
+                read.reservationId(),
+                read.name(),
+                LocalDate.parse(read.date(), DATE_FORMATTER),
+                new ReservationTime(read.timeId(), LocalTime.parse(read.timeValue(), TIME_FORMATTER)));
+    }
+
+    private ReservationCreate toReservationCreate(Reservation reservation) {
+        return new ReservationCreate(reservation.getName(), reservation.getDate().toString(), reservation.getTime().getId());
+    }
+
+    private Reservation toReservation(ReservationRequest request, ReservationTime time) {
+        return new Reservation(request.name(), request.date(), time);
+    }
+
+    private ReservationTime toReservationTime(ReservationTimeRead read) {
+        return new ReservationTime(read.id(), LocalTime.parse(read.startAt(), TIME_FORMATTER));
+    }
+
+    private ReservationTime toReservationTime(ReservationTimeRequest request) {
+        return new ReservationTime(request.startAt());
+    }
+
+    private ReservationTimeCreate toReservationTimeCreate(ReservationTime time) {
+        return new ReservationTimeCreate(time.getStartAt().toString());
+    }
+
+    private ReservationResponse toReservationResponse(final Reservation reservation, final ReservationTimeResponse timeResponse) {
+        return new ReservationResponse(reservation.getId(), reservation.getName(), reservation.getDate(), timeResponse);
+    }
+
+    private List<ReservationTimeResponse> toReservationTimeResponses(final List<ReservationTime> times) {
+        return times.stream()
+                .map(this::toReservationTimeResponse)
+                .toList();
+    }
+
+    private ReservationTimeResponse toReservationTimeResponse(final ReservationTime time) {
+        return new ReservationTimeResponse(time.getId(), time.getStartAt());
+    }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index e69de29bb2..e90e3b2d9c 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+spring.h2.console.enabled=true
+spring.datasource.url=jdbc:h2:mem:database
diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql
new file mode 100644
index 0000000000..70f7697df4
--- /dev/null
+++ b/src/main/resources/data.sql
@@ -0,0 +1,5 @@
+INSERT INTO reservation_time(start_at)
+VALUES ('10:00');
+
+INSERT INTO reservation_time(start_at)
+VALUES ('11:00');
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
new file mode 100644
index 0000000000..86af86d62e
--- /dev/null
+++ b/src/main/resources/schema.sql
@@ -0,0 +1,17 @@
+CREATE TABLE reservation_time
+(
+    id   BIGINT       NOT NULL AUTO_INCREMENT,
+    start_at VARCHAR(255) NOT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE TABLE reservation
+(
+    id      BIGINT       NOT NULL AUTO_INCREMENT,
+    name    VARCHAR(255) NOT NULL,
+    date    VARCHAR(255) NOT NULL,
+    time_id BIGINT,
+    PRIMARY KEY (id),
+    FOREIGN KEY (time_id) REFERENCES reservation_time (id),
+    UNIQUE(date, time_id)
+);
diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java
index de91b512e6..6a284e96b7 100644
--- a/src/test/java/roomescape/MissionStepTest.java
+++ b/src/test/java/roomescape/MissionStepTest.java
@@ -3,13 +3,22 @@
 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.controller.ReservationController;
+import roomescape.dto.response.ReservationResponse;
 
+import java.lang.reflect.Field;
+import java.sql.Connection;
+import java.sql.SQLException;
 import java.time.LocalDate;
 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.equalTo;
 import static org.hamcrest.Matchers.is;
 
@@ -43,10 +52,10 @@ public class MissionStepTest {
 
     @Test
     void 삼단계() {
-        Map<String, String> params = new HashMap<>();
+        Map<String, Object> params = new HashMap<>();
         params.put("name", "브라운");
         params.put("date", makeTodayMessage());
-        params.put("time", "15:40");
+        params.put("timeId", 1);
 
         RestAssured.given().log().all()
                 .contentType(ContentType.JSON)
@@ -76,10 +85,10 @@ public class MissionStepTest {
 
     @Test
     void 올바르지_않은_형식의_예약을_생성하려는_경우_400_코드를_응답한다() {
-        Map<String, String> params = new HashMap<>();
+        Map<String, Object> params = new HashMap<>();
         params.put("name", "브라운");
         params.put("date", makeYesterdayMessage());
-        params.put("time", "15:40");
+        params.put("timeId", 1);
 
         RestAssured.given().log().all()
                 .contentType(ContentType.JSON)
@@ -90,6 +99,51 @@ public class MissionStepTest {
                 .body(equalTo("[ERROR] 과거 날짜에 대한 예약을 할 수 없습니다."));
     }
 
+    @Test
+    void 이름이_null인_경우_400_코드를_응답한다() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("name", null);
+        params.put("date", makeTodayMessage());
+        params.put("timeId", 1);
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(400);
+    }
+
+    @Test
+    void 날짜가_null인_경우_400_코드를_응답한다() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("name", "메이");
+        params.put("date", null);
+        params.put("timeId", 1);
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(400);
+    }
+
+    @Test
+    void 시간이_null인_경우_400_코드를_응답한다() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("name", "메이");
+        params.put("date", makeTodayMessage());
+        params.put("timeId", null);
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(400);
+    }
+
     @Test
     void 존재하지_않는_예약_기록을_삭제하려는_경우_400_코드를_응답한다() {
         RestAssured.given().log().all()
@@ -108,4 +162,139 @@ private String makeTodayMessage() {
         LocalDate today = LocalDate.now();
         return String.format(DATE_FORMAT, today.getYear(), today.getMonthValue(), today.getDayOfMonth());
     }
+
+    @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_id) VALUES (?, ?, ?)", "브라운", makeTodayMessage(), 1L);
+
+        List<ReservationResponse> reservations = RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200).extract()
+                .jsonPath().getList(".", ReservationResponse.class);
+
+        Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class);
+
+        assertThat(reservations.size()).isEqualTo(count);
+    }
+
+    @Test
+    void 육단계() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("timeId", 1);
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200);
+
+        Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class);
+        assertThat(count).isEqualTo(1);
+
+        RestAssured.given().log().all()
+                .when().delete("/reservations/1")
+                .then().log().all()
+                .statusCode(200);
+
+        Integer countAfterDelete = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class);
+        assertThat(countAfterDelete).isEqualTo(0);
+    }
+
+    @Test
+    void 칠단계() {
+        Map<String, String> params = new HashMap<>();
+        params.put("startAt", "10:00");
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/times")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/times")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(3));
+
+        RestAssured.given().log().all()
+                .when().delete("/times/3")
+                .then().log().all()
+                .statusCode(200);
+    }
+
+    @Test
+    void 팔단계() {
+        Map<String, Object> reservation = new HashMap<>();
+        reservation.put("name", "브라운");
+        reservation.put("date", makeTodayMessage());
+        reservation.put("timeId", 1);
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(reservation)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200);
+
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(1));
+    }
+
+    @Autowired
+    private ReservationController reservationController;
+
+    @Test
+    void 구단계() {
+        boolean isJdbcTemplateInjected = false;
+
+        for (Field field : reservationController.getClass().getDeclaredFields()) {
+            if (field.getType().equals(JdbcTemplate.class)) {
+                isJdbcTemplateInjected = true;
+                break;
+            }
+        }
+
+        assertThat(isJdbcTemplateInjected).isFalse();
+    }
+
+    @Test
+    void 예약하려는_날짜와_시간에_이미_예약이_존재하는_경우_400_코드를_응답한다() {
+        jdbcTemplate.update("INSERT INTO reservation (name, date, time_id) VALUES (?, ?, ?)", "브라운", makeTodayMessage(), 1L);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("timeId", 1);
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(400);
+    }
 }
diff --git a/src/test/java/roomescape/dao/ReservationDaoTest.java b/src/test/java/roomescape/dao/ReservationDaoTest.java
new file mode 100644
index 0000000000..881d6a390b
--- /dev/null
+++ b/src/test/java/roomescape/dao/ReservationDaoTest.java
@@ -0,0 +1,112 @@
+package roomescape.dao;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import roomescape.dto.create.ReservationCreate;
+
+import javax.sql.DataSource;
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+
+class ReservationDaoTest {
+
+    private static ReservationDao reservationDao;
+
+    @BeforeAll
+    static void setUp() {
+        DataSource dataSource = new DriverManagerDataSource(
+                "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1",
+                "sa",
+                ""
+        );
+
+        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
+
+        jdbcTemplate.execute("DROP TABLE IF EXISTS reservation");
+        jdbcTemplate.execute("DROP TABLE IF EXISTS reservation_time");
+
+        jdbcTemplate.execute(
+                "CREATE TABLE reservation_time (" +
+                        "id BIGINT NOT NULL AUTO_INCREMENT, " +
+                        "start_at VARCHAR(255) NOT NULL, " +
+                        "PRIMARY KEY (id)" +
+                        ")"
+        );
+
+        jdbcTemplate.execute(
+                "CREATE TABLE reservation (" +
+                        "id BIGINT NOT NULL AUTO_INCREMENT, " +
+                        "name VARCHAR(255) NOT NULL, " +
+                        "date VARCHAR(255) NOT NULL, " +
+                        "time_id BIGINT, " +
+                        "PRIMARY KEY (id), " +
+                        "FOREIGN KEY (time_id) REFERENCES reservation_time (id), " +
+                        "UNIQUE(date, time_id) " +
+                        ")"
+        );
+
+        jdbcTemplate.execute("INSERT INTO reservation_time(start_at) VALUES ('11:10')");
+
+        reservationDao = new ReservationDao(jdbcTemplate);
+    }
+
+    @BeforeEach
+    void clear() {
+        reservationDao.deleteAll();
+    }
+
+    @Test
+    void 데이터베이스에_예약_기록을_추가할_수_있다() {
+        // when & then
+        assertDoesNotThrow(() -> reservationDao.save(new ReservationCreate("메이", getTodayDate(), 1L)));
+    }
+
+    @Test
+    void 데이터베이스에서_예약_목록을_가져올_수_있다() {
+        // given
+        reservationDao.save(new ReservationCreate("메이", getTodayDate(), 1L));
+        reservationDao.save(new ReservationCreate("may", getTomorrowDate(), 1L));
+
+        // when & then
+        assertThat(reservationDao.getAll().size())
+                .isEqualTo(2);
+    }
+
+    @Test
+    void 데이터베이스의_예약_목록을_삭제할_수_있다() {
+        // given
+        reservationDao.save(new ReservationCreate("메이", getTodayDate(), 1L));
+        reservationDao.save(new ReservationCreate("메이", getTomorrowDate(), 1L));
+
+        Long id = reservationDao.getAll().get(0).reservationId();
+
+        // when & then
+        assertThat(reservationDao.delete(id))
+                .isEqualTo(1);
+    }
+
+    @Test
+    void 데이터베이스에_중복_예약을_저장할_수_없다() {
+        // given
+        reservationDao.save(new ReservationCreate("메이", getTodayDate(), 1L));
+
+        // when & then
+        assertThatThrownBy(() -> reservationDao.save(new ReservationCreate("may", getTodayDate(), 1L)))
+                .isInstanceOf(DuplicateKeyException.class);
+    }
+
+    private String getTodayDate() {
+        return LocalDate.now().toString();
+    }
+
+    private String getTomorrowDate() {
+        return LocalDate.now().plusDays(1).toString();
+    }
+}
diff --git a/src/test/java/roomescape/dao/ReservationTimeDaoTest.java b/src/test/java/roomescape/dao/ReservationTimeDaoTest.java
new file mode 100644
index 0000000000..3e7f78956f
--- /dev/null
+++ b/src/test/java/roomescape/dao/ReservationTimeDaoTest.java
@@ -0,0 +1,79 @@
+package roomescape.dao;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import roomescape.dto.create.ReservationTimeCreate;
+
+import javax.sql.DataSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class ReservationTimeDaoTest {
+
+    private static final String START_AT_NINE = "09:00";
+    private static final String START_AT_TEN = "10:00";
+    private static ReservationTimeDao reservationTimeDao;
+
+    @BeforeAll
+    static void setUp() {
+        DataSource dataSource = new DriverManagerDataSource(
+                "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1",
+                "sa",
+                ""
+        );
+
+        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
+
+        jdbcTemplate.execute("DROP TABLE IF EXISTS reservation");
+        jdbcTemplate.execute("DROP TABLE IF EXISTS reservation_time");
+
+        jdbcTemplate.execute(
+                "CREATE TABLE reservation_time (" +
+                        "id BIGINT NOT NULL AUTO_INCREMENT, " +
+                        "start_at VARCHAR(255) NOT NULL, " +
+                        "PRIMARY KEY (id)" +
+                        ")"
+        );
+
+        reservationTimeDao = new ReservationTimeDao(jdbcTemplate);
+    }
+
+    @BeforeEach
+    void clear() {
+        reservationTimeDao.deleteAll();
+    }
+
+    @Test
+    void 데이터베이스에_예약_시간을_추가할_수_있다() {
+        // when & then
+        assertThat(reservationTimeDao.save(new ReservationTimeCreate(START_AT_TEN)))
+                .isEqualTo(1);
+    }
+
+    @Test
+    void 데이터베이스에서_예약_시간_목록을_가져올_수_있다() {
+        // given
+        reservationTimeDao.save(new ReservationTimeCreate(START_AT_NINE));
+        reservationTimeDao.save(new ReservationTimeCreate(START_AT_TEN));
+
+        // when & then
+        assertThat(reservationTimeDao.getAll().size())
+                .isEqualTo(2);
+    }
+
+    @Test
+    void 데이터베이스의_예약_시간을_삭제할_수_있다() {
+        // given
+        reservationTimeDao.save(new ReservationTimeCreate(START_AT_NINE));
+        reservationTimeDao.save(new ReservationTimeCreate(START_AT_TEN));
+
+        Long id = reservationTimeDao.getAll().get(0).id();
+
+        // when & then
+        assertThat(reservationTimeDao.delete(id))
+                .isEqualTo(1);
+    }
+}
diff --git a/src/test/java/roomescape/domain/ReservationTest.java b/src/test/java/roomescape/domain/ReservationTest.java
index e8387987ad..4237bd4a15 100644
--- a/src/test/java/roomescape/domain/ReservationTest.java
+++ b/src/test/java/roomescape/domain/ReservationTest.java
@@ -3,34 +3,38 @@
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 
-import java.time.LocalDateTime;
+import java.time.LocalDate;
+import java.time.LocalTime;
 
 class ReservationTest {
 
     @Test
     void 아이디가_null인_경우_예외를_발생시킨다() {
-        Assertions.assertThatThrownBy(() -> new Reservation(null, "밍트", LocalDateTime.now()))
+        // when & then
+        Assertions.assertThatThrownBy(() -> new Reservation(null, "밍트", LocalDate.now(), new ReservationTime(LocalTime.now())))
                 .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("[ERROR] 예약 기록 id는 null이 될 수 없습니다.");
     }
 
     @Test
     void 이름이_null인_경우_예외를_발생시킨다() {
-        Assertions.assertThatThrownBy(() -> new Reservation(1L, null, LocalDateTime.now()))
+        // when & then
+        Assertions.assertThatThrownBy(() -> new Reservation(1L, null, LocalDate.now(), new ReservationTime(LocalTime.now())))
                 .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("[ERROR] 예약자 이름은 null이 될 수 없습니다.");
     }
 
     @Test
     void 이름이_10글자_초과인_경우_예외를_발생시킨다() {
-        Assertions.assertThatThrownBy(() -> new Reservation(1L, "잠실에사는비행기데코피크민", LocalDateTime.now()))
+        // when & then
+        Assertions.assertThatThrownBy(() -> new Reservation(1L, "잠실에사는비행기데코피크민", LocalDate.now(), new ReservationTime(LocalTime.now())))
                 .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("[ERROR] 예약자 이름은 10글자를 초과할 수 없습니다.");
     }
 
     @Test
     void 날짜가_null인_경우_예외를_발생시킨다() {
-        Assertions.assertThatThrownBy(() -> new Reservation(2L, "메이", null))
+        Assertions.assertThatThrownBy(() -> new Reservation(2L, "메이", null, new ReservationTime(LocalTime.now())))
                 .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("[ERROR] 예약 날짜는 null이 될 수 없습니다.");
     }
@@ -38,9 +42,10 @@ class ReservationTest {
     @Test
     void 날짜가_과거인_경우_예외를_발생시킨다() {
         // given
-        LocalDateTime yesterday = LocalDateTime.now().minusDays(1);
+        LocalDate yesterday = LocalDate.now().minusDays(1);
 
-        Assertions.assertThatThrownBy(() -> new Reservation(2L, "메이", yesterday))
+        // when & then
+        Assertions.assertThatThrownBy(() -> new Reservation(2L, "메이", yesterday, new ReservationTime(LocalTime.now())))
                 .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("[ERROR] 과거 날짜에 대한 예약을 할 수 없습니다.");
     }
diff --git a/src/test/java/roomescape/domain/ReservationsTest.java b/src/test/java/roomescape/domain/ReservationsTest.java
deleted file mode 100644
index 3eba4a4bf0..0000000000
--- a/src/test/java/roomescape/domain/ReservationsTest.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package roomescape.domain;
-
-import org.junit.jupiter.api.Test;
-
-import java.time.LocalDateTime;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-class ReservationsTest {
-
-    @Test
-    void 예약_기록을_추가할_수_있다() {
-        // given
-        Reservations reservations = new Reservations();
-        reservations.add(new Reservation(1L, "메이", LocalDateTime.now()));
-        reservations.add(new Reservation(2L, "밍트", LocalDateTime.now()));
-
-        // when & then
-        assertThat(reservations.getReservations().size())
-                .isEqualTo(2);
-    }
-
-    @Test
-    void 존재하지_않는_예약_기록을_삭제하려는_경우_예외를_발생시킨다() {
-        // given
-        Reservations reservations = new Reservations();
-        reservations.add(new Reservation(1L, "메이", LocalDateTime.now()));
-        reservations.add(new Reservation(2L, "밍트", LocalDateTime.now()));
-
-        // when & then
-        assertThatThrownBy(() -> reservations.deleteById(3L))
-                .isInstanceOf(IllegalArgumentException.class)
-                .hasMessage("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
-    }
-}


Reviews:
user: login: ddaaac
body: 안녕하세요 메이 👋 

구현 잘 해주셨네요.

몇 가지 코멘트 남겼으니 확인해주세요.
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-25T06:06:21Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T04:32:03Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T04:50:33Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T05:20:07Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T05:35:56Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T05:44:07Z
user: login: ddaaac
body: 안녕하세요 메이 👋 

구현 잘 해주셨네요.

몇 가지 코멘트 추가로 남겼으니 확인해주세요.
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-27T06:52:55Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T07:23:52Z
user: login: seaniiio
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-27T07:49:03Z
user: login: ddaaac
body: 안녕하세요 메이 👋 

리뷰 반영 잘 해주셔서 더 남길 코멘트가 없네요.

고생 많으셨고 다음 단계도  화이팅하세요!
state: APPROVED
author_association: NONE
submitted_at: 2025-04-28T11:50:05Z

Review comments:
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: 예약이 저장되고 id가 부여되면 그 예약은 식별이 가능해집니다. id를 Reservation 안에 넣어주는 건 어떨까요?
만약 여기서 바로 값을 반환하는게 아니라 저장된 Reservation으로 뭔가 로직이 생기거나 하면 저장은 됐지만 id가 없는 Reservation이 돌아다닐 것 같아요.
created_at: 2025-04-25T05:29:16Z
author_association: NONE
diff_hunk: @@ -0,0 +1,63 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import roomescape.domain.ReservationTime;
+
+import java.sql.PreparedStatement;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Repository
+public class ReservationTimeDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationTimeDao(final JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public long save(final ReservationTime reservationTime) {
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        String query = "INSERT INTO reservation_time(start_at) VALUES (?)";
+        jdbcTemplate.update(connection -> {
+            PreparedStatement ps = connection.prepareStatement(query, new String[]{"id"});
+            ps.setString(1, reservationTime.getStartAt().toString());
+            return ps;
+        }, keyHolder);
+        return keyHolder.getKey().longValue();
path: src/main/java/roomescape/dao/ReservationTimeDao.java
user: login: ddaaac
body: 아니면 여기서 Reservation 안에 id를 채워서 반환해도 좋을 것 같구요.
created_at: 2025-04-25T05:29:55Z
author_association: NONE
diff_hunk: @@ -0,0 +1,63 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import roomescape.domain.ReservationTime;
+
+import java.sql.PreparedStatement;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Repository
+public class ReservationTimeDao {
path: src/main/java/roomescape/dao/ReservationTimeDao.java
user: login: ddaaac
body: 전반적으로 Dao 잘 구현해주셨습니다 👍 
created_at: 2025-04-25T05:30:05Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.domain;
+
+import java.time.LocalTime;
+import java.util.Objects;
+
+public class ReservationTime {
+
+    private Long id;
+    private final LocalTime startAt;
+
+    public ReservationTime(final LocalTime startAt) {
+        this.startAt = startAt;
+    }
+
+    public ReservationTime(final Long id, final LocalTime startAt) {
+        this.id = id;
+        this.startAt = startAt;
+    }
+
+    public boolean isSameId(final Long otherId) {
+        return this.id.equals(otherId);
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public LocalTime getStartAt() {
+        return startAt;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        final ReservationTime that = (ReservationTime) o;
+        return Objects.equals(id, that.id) && Objects.equals(startAt, that.startAt);
+    }
path: src/main/java/roomescape/domain/ReservationTime.java
user: login: ddaaac
body: 보통 식별자가 부여된 객체의 [동등성](https://steady-coding.tistory.com/534)을 부여할 떄는 id만 비교합니다. equals를  id만 비교하게 바꿔도 될 것 같아요.
created_at: 2025-04-25T05:32:24Z
author_association: NONE
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.domain;
+
+import java.time.LocalTime;
+import java.util.Objects;
+
+public class ReservationTime {
+
+    private Long id;
+    private final LocalTime startAt;
path: src/main/java/roomescape/domain/ReservationTime.java
user: login: ddaaac
body: 적당한 자바 표준 시간 객체를 사용해주셨군요 👍 
created_at: 2025-04-25T05:51:50Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationsDtos);
+
+        long count = reservations.stream()
+                .filter(reservation -> reservation.isSameDateAndTime(date, timeId))
+                .count();
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: 쿼리로 풀어보는 건 어떨까요?

보통 getAll과 같은 풀 테이블 스캔은 피하는 게 좋거든요.
created_at: 2025-04-25T05:56:33Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: > 도메인끼리 의존성(Reservation -> ReservationTime)이 생기고 나서 service의 필요성을 느꼈습니다. Reservation을 추가하거나 읽는 과정에서 reservation 테이블뿐만 아니라 reservation_time 테이블에도 접근해야 했기 때문입니다.(

Reservation이라는 도메인을 구성하기 위해서는 예약 정보 + 시간 두 가지 정보가 필요한데 두 정보는 다른 테이블에 저장되니 이를 조립하기 위한 용도로 서비스 계층을 도입했다는 얘기네요. 좋은 접근입니다!

서비스 레이어에는 몇 가지 역할이 있겠지만, 개인적으로는 인프라와 도메인을 이어주는 역할이 가장 중요하다고 생각해요. 계층 도입의 이유가 명확하네요.
created_at: 2025-04-25T06:00:57Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationsDtos);
+
+        long count = reservations.stream()
+                .filter(reservation -> reservation.isSameDateAndTime(date, timeId))
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: > 객체 지향에 많은 신경을 쓰지 않았다고 생각합니다.


이렇게 느껴질 수도 있겠지만 이건 그냥 객체 관계라고 할만한 요구사항이나 로직이 없어서 그런거에요.
객체 지향에 신경을 쓰지 않았다면 서비스 레이어에 getter가 많이 보이게 됩니다.

지금은 id나 값을 가져와야하기 때문에 사용하는 getter 이외에는 모두 객체에 메세지를 보내는 방식으로 잘 구현되어있어요.
created_at: 2025-04-25T06:02:36Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: > 예약이 이미 존재하는지 확인하는 책임은 도메인에도, 컨트롤러에도 어울리지 않다고 생각했습니다. db에 저장된 데이터를 기반으로 검증하는 것이기 때문에, service 계층에 위임하는 것이 맞겠다는 생각이 들었습니다.

지금처럼 getAll을 써도 문제가 되는 환경이 아니라면

Reservations라는 일급컬렉션을 만들고 `Reservations(reservationDao.getAll()).isAlredayExist(date, timeId)`처럼 도메인으로 풀 수 있습니다. 서비스에 로직을 두는 것보단 도메인에 두는 게 더 어울릴거구요.

다만, 인프라적인 다른 요구사항들을 고려해봤을 때 이렇게 푸는 건 무리가 있어서 서비스에 로직을 두는 게 적당하겠죠.
created_at: 2025-04-25T06:06:08Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationsDtos);
+
+        long count = reservations.stream()
+                .filter(reservation -> reservation.isSameDateAndTime(date, timeId))
+                .count();
path: src/main/java/roomescape/service/ReservationService.java
user: login: seaniiio
body: 좋습니다! 나중에 레코드가 아주 많은 상태에서 `getAll()`을 한다면 메모리 차지도 심해지고, db와의 통신 비용도 커지겠네요.

그런데 이미 예약이 존재한다는 예외를 어디서 던져주는게 자연스러울까요?

### `dao`에 예약이 존재하는지 확인하고 boolean을 리턴하는 메서드 만들기 -> `service`에서 예외 던짐
- 처음에는 "예악이 존재하는 경우 예외를 발생시킨다는 것"은 비즈니스 로직이라고 보고, 책임을 서비스로 위임하는 것이 자연스럽다고 생각했습니다.
- 다만 `dao#save`와 별개로 작성되기 때문에, `service`에서 `dao#save`를 호출하기 전에 무조건 검사 로직을 호출해야 합니다.

### `dao`에서 save하기 전에 예약 존재하는지 알아서 확인하기 -> `dao`에서 예외 던짐
- db에 `save`하기 전에 항상 검사 로직이 호출됩니다.

저는 "검사 이후 save"가 무조건 같이 수행되도록 하는 것이 중요하다고 생각해서, `dao#save`에 유효성 검사 로직을 추가하도록 했습니다. 이에 대해 어떻게 생각하시는지 궁금합니다!

[refactor: 중복 예약 유효성 검사 기능을 dao 계층으로 이동](https://github.com/woowacourse/spring-roomescape-admin/pull/302/commits/3d403c71d342e1c76ba7297db1e1ccef08540395)
created_at: 2025-04-26T04:32:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,44 @@
+package roomescape.domain;
+
+import java.time.LocalTime;
+import java.util.Objects;
+
+public class ReservationTime {
+
+    private Long id;
+    private final LocalTime startAt;
+
+    public ReservationTime(final LocalTime startAt) {
+        this.startAt = startAt;
+    }
+
+    public ReservationTime(final Long id, final LocalTime startAt) {
+        this.id = id;
+        this.startAt = startAt;
+    }
+
+    public boolean isSameId(final Long otherId) {
+        return this.id.equals(otherId);
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public LocalTime getStartAt() {
+        return startAt;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        final ReservationTime that = (ReservationTime) o;
+        return Objects.equals(id, that.id) && Objects.equals(startAt, that.startAt);
+    }
path: src/main/java/roomescape/domain/ReservationTime.java
user: login: seaniiio
body: 도메인의 id가 애초에 식별 기능을 위해 존재하기 때문에, equals에서 id만 이용하는 것이 좋을 것 같습니다. 놓쳤던 부분인데 알려주셔서 감사합니다!

[refactor: domain 동등성 비교에 id 이용하도록 수정](https://github.com/woowacourse/spring-roomescape-admin/pull/302/commits/db6f30f02317c7044a20d8e3a3abc581c6d8c6e3)
created_at: 2025-04-26T04:50:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,63 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import roomescape.domain.ReservationTime;
+
+import java.sql.PreparedStatement;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+@Repository
+public class ReservationTimeDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationTimeDao(final JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public long save(final ReservationTime reservationTime) {
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        String query = "INSERT INTO reservation_time(start_at) VALUES (?)";
+        jdbcTemplate.update(connection -> {
+            PreparedStatement ps = connection.prepareStatement(query, new String[]{"id"});
+            ps.setString(1, reservationTime.getStartAt().toString());
+            return ps;
+        }, keyHolder);
+        return keyHolder.getKey().longValue();
path: src/main/java/roomescape/dao/ReservationTimeDao.java
user: login: seaniiio
body: `Reservation`, `ReservationTime` 모두 저장 이후 `id`를 세팅해주는 기능이 필요할 것 같습니다!

### 방법 1) `dao`에서 `domain`에 `id` 채워주기

- `domain`의 값을 조작하는 로직을 `dao`가 가져도 되는지 궁금합니다. 제가 생각한 계층별 역할에 알맞지 않다고 생각했기 때문입니다.

> 서비스 → 유효성 검사, 요청 데이터를 기반으로 도메인 객체 생성, 도메인 객체 기반으로 응답 객체 생성
레포지토리 → db의 레코드를 읽어와서 알맞은 도메인 객체(혹은 DTO)를 생성, 도메인 객체를 db 레코드에 추가
> 

### 방법 2) `dao`에서 `id`를 반환하기만 하고 `service`에서 `domain`에 채우기

- 도메인을 관리하는 역할을 `service`가 맡는 것이 적절하다고 생각합니다.
- `service`에서 `setId`를 실수로 빼먹는 경우, id가 없는 객체가 돌아다니게 됩니다.

도메인 조작 로직을 `dao`에서 수행하는 것이 적절하지 않은 것 같아서, 일단 방법2를 적용해봤습니다.

[feat: 테이블에 저장 이후 반환 받은 id를 도메인 객체에 세팅해주는 기능 추가](https://github.com/woowacourse/spring-roomescape-admin/pull/302/commits/1cd500990386125b14f582a039e364822d9094f3)
created_at: 2025-04-26T05:20:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
path: src/main/java/roomescape/service/ReservationService.java
user: login: seaniiio
body: 처음에는 풀 테이블 스캔을 아무렇지 않게 생각했기 때문에, 유효성 검사를 위해 `Reservations`라는 도메인을 만드는 것이 좋겠다고 생각했었습니다. 그런데 범블비가 남겨주신 리뷰를 보고, _save할 때마다 많은 비용을 소모한다는 것을 감수하면서까지 역할 분리를 철저히 해야할까?_ 라고 생각이 바뀌었습니다.

앞으로는 객체지향뿐만 아니라 더 많은 것을 고려해야겠네요! 감사합니다.
created_at: 2025-04-26T05:35:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
path: src/main/java/roomescape/service/ReservationService.java
user: login: seaniiio
body: 저는 이번 미션을 수행하는 과정에서, 비즈니스 로직의 흐름을 담당하는 것이 서비스 계층의 역할이라고 생각했는데요.(컨트롤러에서 비즈니스 로직을 빼내기 위해 서비스 도입) 범블비가 말씀해주신 “인프라와 도메인을 이어주는 역할”이라고 하면, 도메인 여러개를 잘 조합해서 db에 저장해줘야 하는 경우 등을 말씀해주신 걸까요?
created_at: 2025-04-26T05:44:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,74 @@
+package roomescape.dao;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import roomescape.dto.create.ReservationCreate;
+import roomescape.dto.read.ReservationRead;
+
+import java.sql.PreparedStatement;
+import java.util.List;
+
+@Repository
+public class ReservationDao {
+
+    private final JdbcTemplate jdbcTemplate;
+
+    public ReservationDao(final JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public long save(final ReservationCreate reservationCreate) {
+        checkReservationAlreadyExist(reservationCreate.date(), reservationCreate.timeId());
+
+        KeyHolder keyHolder = new GeneratedKeyHolder();
+        String query = "INSERT INTO reservation(name, date, time_id) VALUES (?, ?, ?)";
+        jdbcTemplate.update(connection -> {
+            PreparedStatement ps = connection.prepareStatement(query, new String[]{"id"});
+            ps.setString(1, reservationCreate.name());
+            ps.setString(2, reservationCreate.date());
+            ps.setLong(3, reservationCreate.timeId());
+            return ps;
+        }, keyHolder);
+
+        return keyHolder.getKey().longValue();
+    }
+
+    public List<ReservationRead> getAll() {
path: src/main/java/roomescape/dao/ReservationDao.java
user: login: ddaaac
body: > dao에서 사용할 dto를 추가적으로 생성했습니다.(create / read) 그리고 dto 안에서 도메인으로 변환하는 로직 또한 service로 옮겼습니다.

재미있는 시도네요. 나중에 가면 더 계층을 세세하게 나눠볼 수도 있을 것 같지만, 이 미션에서는 이정도 고민을 하고 넘어가면 될 것 같습니다. 계층과 클래스의 복잡도가 늘어나는 부분도 고민해볼만 할 것 같구요.
정답은 없으니 미션을 진행하면서 다양한 생각과 시도를 해보시면 좋을 것 같아요.

created_at: 2025-04-27T06:35:43Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: 객체지향은 객체 간의 메세지 전송이라고 표현할 수 있습니다.
기본적으로는 도메인 객체 간에 메세지를 전송해야겠죠.

하지만 구현을 하다보면 객체끼리 메세지를 주고 받을 수 없는 경우가 있습니다. 대표적인 예시가 DB에 객체가 저장되어있는 경우가 있겠죠. DTO와 같은 데이터 덩어리를 도메인의 언어로 해석해줄 수 있는 계층도 필요하구요. 

이런 문제를 해결해줄 수 있는 계층이 서비스 계층이라고 생각해요. 객체의 메세지 전송을 돕는 계층인거죠.

개인적으로 `비즈니스 로직의 흐름`과 같이 얘기를 하면 서비스 계층이 비즈니스 로직을 담당하는 것처럼 느껴집니다. 위와 같은 이유로 저는 서비스 계층에 비즈니스 로직이 들어서지 않았으면 좋겠구요.
created_at: 2025-04-27T06:48:11Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationsDtos);
+
+        long count = reservations.stream()
+                .filter(reservation -> reservation.isSameDateAndTime(date, timeId))
+                .count();
path: src/main/java/roomescape/service/ReservationService.java
user: login: ddaaac
body: Dao는 말 그대로 데이터에 접근하는 객체입니다.

"예약이 중복되면 안된다"는 이번 요구사항을 처리하기 위한 도메인 로직이구요. 이번 요구사항은 ReservationService#create에 담겨있습니다.
새로운 요구사항에서는 중복된 예약을 만들어야할 수도 있습니다.
메이가 이전 코멘트에서 Dao에 도메인 로직이 들어가는 게 어색하다고 말씀해주셨는데 지금은 도메인 로직이 Dao에 들어있기도 하구요.

created_at: 2025-04-27T06:52:18Z
author_association: NONE
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
+
+    private final ReservationDao reservationDao;
+    private final ReservationTimeDao reservationTimeDao;
+
+    public ReservationService(final ReservationDao reservationDao, final ReservationTimeDao reservationTimeDao) {
+        this.reservationDao = reservationDao;
+        this.reservationTimeDao = reservationTimeDao;
+    }
+
+    public ReservationResponse createReservation(final ReservationRequest request) {
+        Long timeId = request.timeId();
+        ReservationTime time = reservationTimeDao.findById(timeId);
+        ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+
+        checkReservationAlreadyExist(request.date(), timeId);
+
+        Reservation reservation = new Reservation(request.name(), request.date(), time);
+        long id = reservationDao.save(reservation);
+        return ReservationResponse.of(id, reservation, timeResponse);
+    }
+
+    public List<ReservationResponse> findAllReservations() {
+        List<ReservationDto> reservationDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationDtos);
+
+        return reservations.stream()
+                .map(reservation -> {
+                    ReservationTime time = reservationTimeDao.findById(reservation.getTime().getId());
+                    ReservationTimeResponse timeResponse = ReservationTimeResponse.of(time);
+                    return ReservationResponse.of(reservation, timeResponse);
+                }).toList();
+    }
+
+    public void deleteReservation(final Long id) {
+        int count = reservationDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 예약 기록이 존재하지 않습니다.");
+        }
+    }
+
+    public ReservationTimeResponse createReservationTime(final ReservationTimeRequest request) {
+        ReservationTime reservationTime = request.toReservationTime();
+        long id = reservationTimeDao.save(reservationTime);
+        return ReservationTimeResponse.of(id, reservationTime);
+    }
+
+    public List<ReservationTimeResponse> findAllReservationTimes() {
+        List<ReservationTime> times = reservationTimeDao.getAll();
+        return ReservationTimeResponse.from(times);
+    }
+
+    public void deleteReservationTime(final Long id) {
+        int count = reservationTimeDao.delete(id);
+        if (count == 0) {
+            throw new IllegalArgumentException("[ERROR] 해당 id에 대한 시간 정보가 존재하지 않습니다.");
+        }
+    }
+
+    public void deleteAllReservationTimes() {
+        reservationTimeDao.deleteAll();
+    }
+
+    private void checkReservationAlreadyExist(final LocalDate date, final Long timeId) {
+        List<ReservationDto> reservationsDtos = reservationDao.getAll();
+        List<Reservation> reservations = ReservationDto.toReservations(reservationsDtos);
+
+        long count = reservations.stream()
+                .filter(reservation -> reservation.isSameDateAndTime(date, timeId))
+                .count();
path: src/main/java/roomescape/service/ReservationService.java
user: login: seaniiio
body: “중복 예약 불가”는 도메인 규칙이니 `dao`가 책임지기에 적절하지 않다는 범블비의 말씀에 완전히 동의합니다. 다만 제가 우려했던 부분은, `service`에서 `dao#save`를 호출하기 전에 무조건 유효성 검사를 해주어야 한다는 점이었습니다. 만약 `service`에 `dao#save`를 사용하는 새로운 메서드를 만들다가 이를 빼먹는 경우가 발생한다면, db에 중복 예약이 저장되는 치명적인 문제가 생길 수 있다고 생각했어요. 이게 책임 분리를 하지 않아 생기는 문제보다 더 큰 문제가 될 수 있다고 판단해서 `dao`에서 예외를 던지도록 구현했었습니다.

그런데 db에 unique 제약 조건을 추가하면 **db에서의 안전성 + 책임 분리**를 할 수 있을 것 같습니다. 이렇게 수정해보겠습니다!

[refactor: 중복 예약 검증 로직 service로 이동](https://github.com/woowacourse/spring-roomescape-admin/pull/302/commits/f38b6d6f9bdbf6bde6194cfac9ffaca94c20e10b)
created_at: 2025-04-27T07:23:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+package roomescape.service;
+
+import org.springframework.stereotype.Service;
+import roomescape.dao.ReservationDao;
+import roomescape.dao.ReservationTimeDao;
+import roomescape.domain.Reservation;
+import roomescape.domain.ReservationTime;
+import roomescape.dto.ReservationDto;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.request.ReservationTimeRequest;
+import roomescape.dto.response.ReservationResponse;
+import roomescape.dto.response.ReservationTimeResponse;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+public class ReservationService {
path: src/main/java/roomescape/service/ReservationService.java
user: login: seaniiio
body: - db에 존재하는 데이터를 기반으로 객체지향적 코드를 짜기 위해, 중간에서 잘 변환해주는 역할을 하는게 `service` (범블비가 말씀해주신 **인프라와 도메인을 이어주는 역할**)
- 다만 풀 데이터 스캔이 필요한 경우처럼 도메인에 로직을 두려면 비용이 많이 드는 경우에는, 예외적으로 `service`에 비즈니스 로직을 둘 수도 있다.

범블비의 리뷰를 통해 `service` 계층의 역할에 대한 생각을 정리할 수 있었습니다. 감사합니다!
created_at: 2025-04-27T07:49:03Z
author_association: MEMBER

PR comments:
user: login: seaniiio
created_at: 2025-04-26T07:14:21Z
body: 안녕하세요 범블비!

리뷰 주신 내용을 적용하는 과정에서, **`dao`에 도메인 관련 로직을 넣는 것이 적절한가?** 에 대해 고민했습니다. 도메인을 조작하는 코드는 `service` 한 곳에서만 담당하게 하는 것이 SRP를 지키는 것이라 생각했고, `dao`에서 `domain`에 직접 의존하지 않도록 하기 위해, `dao`에서 사용할 `dto`를 추가적으로 생성했습니다.(create / read) 그리고 `dto` 안에서 도메인으로 변환하는 로직 또한 `service`로 옮겼습니다. `dto`는 데이터를 전달하는 역할을 해야하는데, 도메인 객체를 생성하는 것까지 담당하면 두 가지 역할을 수행한다고 생각했기 때문입니다. 결과적으로 도메인 객체를 조작하는 로직은 전부 `service`에 담기게 되었습니다.

`dto` 클래스가 많아지고 코드가 복잡해졌지만, 제가 생각한 `dao`와 `service`에 역할에 맞게 코드를 수정해봤습니다!
author_association: MEMBER
user: login: seaniiio
created_at: 2025-04-27T07:54:07Z
body: 안녕하세요 범블비!

`dao`에 존재하던 도메인 규칙을 `service`로 옮기고(`service`에 존재하는 이유는 `domain`에서 관리하려면 풀 데이터 스캔 후 도메인 객체 생성 필요하기 때문), db에 제약조건을 추가했습니다.

항상 생각을 정리할 수 있는 좋은 리뷰 남겨주셔서 감사합니다.
author_association: MEMBER