Repository: woowacourse/spring-roomescape-admin
Pull request #268: [1-3단계 - 방탈출 예약 관리] 밍트(김명지) 미션 제출합니다.
Author: login: Starlight258
Created: 2025-04-17T08:00:47Z | Merged: 2025-04-20T08:19:23Z

아서 안녕하세요 밍트라고 합니다 😁
뵙게 되어서 반갑습니다 ☺️

코드 리뷰 잘 부탁드립니다 🙇‍♀️

<!-- 

## 코드 리뷰 팁

- 코드와 관련된 질문이 있다면, 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/4228)

## 객체지향 생활체조 요구사항을 얼마나 잘 충족했다고 생각하시나요?
### 1~5점 중에서 선택해주세요.

- [ ] 1 (전혀 충족하지 못함)
- [ ] 2
- [ ] 3 (보통)
- [x] 4
- [ ] 5 (완벽하게 충족)

### 선택한 점수의 이유를 적어주세요.
<!-- 이유 작성 -->
`id`를 생성하는 부분을 `Controller`에서 주입해 준 부분이 `Controller`의 책임을 벗어난 것 같아 4점 주었습니다.

## 웹 개발 경험 체크
🌱 레벨2 시작 이전에, 웹 애플리케이션 개발 경험이 있나요?
- [ ] 아니오. 전혀 경험이 없습니다.
- [ ] 예. 예제 코드를 따라하거나 클론 코딩을 해봤습니다. (ex. 강의를 보고 따라함)
- [ ] 예. 웹 애플리케이션을 만들었지만 로컬에서만 실행해봤습니다.
- [x] 예. 웹 애플리케이션을 만들고 배포해봤습니다. (ex. 클라우드 혹은 리모트 서버에 배포)

## 어떤 부분에 집중하여 리뷰해야 할까요?
<!-- 리뷰어가 효과적으로 피드백할 수 있도록 중점적으로 리뷰 받고 싶은 내용을 *요약* 형태로 작성해 주세요.
리뷰해야 할 포인트를 요약해 강조해 주시면, 리뷰어가 코드 전체를 이 부분에 집중해 리뷰할 수 있습니다.
반면 특정 코드부분에 대한 피드백이 필요하다면, 이 곳에 적기 보다 해당 코드를 선택하고 코멘트를 남기는 것을 권장합니다. -->
Spring을 사용해서 요구사항을 잘 만족했는지 리뷰받고 싶습니다.
추가적인 개선사항이 있다면 피드백 받고 싶습니다.

### 📌 코드리뷰 질문 그라운드룰 (지우지 마세요)
✅ 많은 사람과 공유하고 토론해보는 것이 더 좋은 질문에는, 리뷰어가 💬말풍선 이모지를 남겨 토론을 추천할 수 있어요.  
✅ 토론을 추천받은 질문은 `#be-7기-잡담-질답`슬랙채널이나 캠퍼스에서 자유롭게 토론을 이어가보세요!  
✅ 코드에 대한 맥락 없이 리뷰어의 주관적인 의견을 묻거나, 단순히 현업에서는 어떻게 하는지를 묻는 질문은 코드 리뷰 본문에는 어울리지 않아요.  
코드 리뷰는 변경된 코드의 목적, 의도, 품질을 중심으로 논의하는 자리예요.  
✅ 코드와 관련된 질문이 있다면 PR 본문에 적기보다는, 해당 코드를 선택해 코멘트를 남겨주세요.



Diff:
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000..ddede1e374
--- /dev/null
+++ b/README.md
@@ -0,0 +1,85 @@
+# 방탕출 예약 관리
+## 홈 화면
+### 요청
+- 메서드 : GET
+- 요청 URL : /admin
+- 설명 : 어드민 페이지를 응답한다.
+
+`GET /reservations HTTP/1.1`
+
+## 예약 목록 조회
+### 요청
+- 메서드 : GET
+- 요청 URL : /reservations
+- 설명 : 예약 목록을 조회한다.
+
+`GET /reservations HTTP/1.1`
+
+### 응답
+```json
+HTTP/1.1 200 
+Content-Type: application/json
+
+[
+    {
+        "id": 1,
+        "name": "브라운",
+        "date": "2023-01-01",
+        "time": "10:00"
+    },
+    {
+        "id": 2,
+        "name": "브라운",
+        "date": "2023-01-02",
+        "time": "11:00"
+    }
+]
+```
+
+## 예약 추가  
+### 요청
+- 메서드 : POST
+- 요청 URL : /reservations
+- 설명 : 예약을 추가한다.
+- 조건 
+  - 이름
+    - 이름은 null이 될 수 없다.
+    - 이름은 10글자를 넘을 수 없다.
+  - dateTime (날짜 & 시간)
+    - 날짜와 시간은 null이 될 수 없다.
+    - 과거 날짜로 예약할 수 없다.
+
+```json
+POST /reservations HTTP/1.1
+content-type: application/json
+
+{
+    "date": "2023-08-05",
+    "name": "브라운",
+    "time": "15:40"
+}
+```
+
+### 응답
+```json
+HTTP/1.1 200 
+Content-Type: application/json
+
+{
+    "id": 1,
+    "name": "브라운",
+    "date": "2023-08-05",
+    "time": "15:40"
+}
+```
+
+## 예약 취소
+### 요청
+- 메서드 : DELETE
+- 요청 URL : /reservations
+- 설명 : 예약을 삭제한다.
+
+`DELETE /reservations/1 HTTP/1.1`
+
+### 응답
+`DELETE /reservations/1 HTTP/1.1`
diff --git a/build.gradle b/build.gradle
index ccd1e7a623..cc82be4427 100644
--- a/build.gradle
+++ b/build.gradle
@@ -21,6 +21,8 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-web'
     implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
     implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+    implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.4'
+
     runtimeOnly 'com.h2database:h2'
     testImplementation 'org.springframework.boot:spring-boot-starter-test'
     testImplementation 'io.rest-assured:rest-assured:5.3.1'
diff --git a/src/main/java/roomescape/RoomescapeApplication.java b/src/main/java/roomescape/RoomescapeApplication.java
index 7027067919..5fcfdde5d6 100644
--- a/src/main/java/roomescape/RoomescapeApplication.java
+++ b/src/main/java/roomescape/RoomescapeApplication.java
@@ -5,6 +5,7 @@
 
 @SpringBootApplication
 public class RoomescapeApplication {
+
     public static void main(String[] args) {
         SpringApplication.run(RoomescapeApplication.class, args);
     }
diff --git a/src/main/java/roomescape/controller/AdminController.java b/src/main/java/roomescape/controller/AdminController.java
new file mode 100644
index 0000000000..8d0ef8dc16
--- /dev/null
+++ b/src/main/java/roomescape/controller/AdminController.java
@@ -0,0 +1,20 @@
+package roomescape.controller;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/admin")
+public class AdminController {
+
+    @GetMapping
+    public String showAdminMainPage() {
+        return "/admin/index";
+    }
+
+    @GetMapping("/reservation")
+    public String showReservationManagementPage() {
+        return "/admin/reservation-legacy.html";
+    }
+}
diff --git a/src/main/java/roomescape/controller/ReservationController.java b/src/main/java/roomescape/controller/ReservationController.java
new file mode 100644
index 0000000000..ca97d84fe3
--- /dev/null
+++ b/src/main/java/roomescape/controller/ReservationController.java
@@ -0,0 +1,52 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import roomescape.domain.Reservation;
+import roomescape.domain.Reservations;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.response.ReservationResponse;
+
+@RestController
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final Reservations reservations = new Reservations();
+
+    @GetMapping
+    public List<ReservationResponse> readReservations() {
+        final List<ReservationResponse> dtos = ReservationResponse.from(reservations);
+        return dtos;
+    }
+
+    @PostMapping
+    public ReservationResponse createReservation(
+            @Valid @RequestBody final ReservationRequest reservationRequest) {
+        final Reservation reservation = makeReservation(reservationRequest);
+        return ReservationResponse.from(reservation);
+    }
+
+    @DeleteMapping("/{id}")
+    public void deleteReservation(@PathVariable final Long id) {
+        reservations.deleteById(id);
+    }
+
+    private Reservation makeReservation(final ReservationRequest reservationRequest) {
+        final LocalDateTime dateTime = LocalDateTime.of(reservationRequest.date(), reservationRequest.time());
+        try {
+            return reservations.addReservation(reservationRequest.name(), dateTime);
+        } catch (IllegalArgumentException e) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
+        }
+    }
+}
diff --git a/src/main/java/roomescape/domain/Counter.java b/src/main/java/roomescape/domain/Counter.java
new file mode 100644
index 0000000000..2e8d0d25c4
--- /dev/null
+++ b/src/main/java/roomescape/domain/Counter.java
@@ -0,0 +1,12 @@
+package roomescape.domain;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+public class Counter {
+
+    private final AtomicLong value = new AtomicLong(1);
+
+    public long getAndIncrease() {
+        return value.getAndIncrement();
+    }
+}
diff --git a/src/main/java/roomescape/domain/Reservation.java b/src/main/java/roomescape/domain/Reservation.java
new file mode 100644
index 0000000000..4163289ffe
--- /dev/null
+++ b/src/main/java/roomescape/domain/Reservation.java
@@ -0,0 +1,55 @@
+package roomescape.domain;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+public class Reservation {
+
+    private static final int NAME_MAX_LENGTH = 10;
+
+    private final long id;
+    private final String name;
+    private final LocalDateTime dateTime;
+
+    public Reservation(final long id, final String name, final LocalDateTime dateTime) {
+        validate(name, dateTime);
+        this.id = id;
+        this.name = name;
+        this.dateTime = dateTime;
+    }
+
+    private void validate(final String name, final LocalDateTime dateTime) {
+        validateName(name);
+        validateDateTime(dateTime);
+    }
+
+    private void validateName(final String name) {
+        if (name == null) {
+            throw new IllegalArgumentException("[ERROR] name은 null이 될 수 없습니다.");
+        }
+        if (name.length() > NAME_MAX_LENGTH) {
+            throw new IllegalArgumentException("[ERROR] 이름은 10글자를 넘을 수 없습니다.");
+        }
+    }
+
+    private void validateDateTime(final LocalDateTime dateTime) {
+        if (dateTime == null) {
+            throw new IllegalArgumentException("[ERROR] dateTime은 null이 될 수 없습니다.");
+        }
+        if (dateTime.toLocalDate().isBefore(LocalDate.now())) {
+            throw new IllegalArgumentException("[ERROR] 과거 날짜로 예약할 수 없습니다.");
+        }
+    }
+
+    public long getId() {
+        return id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public LocalDateTime getDateTime() {
+        return dateTime;
+    }
+}
diff --git a/src/main/java/roomescape/domain/Reservations.java b/src/main/java/roomescape/domain/Reservations.java
new file mode 100644
index 0000000000..bfa2d5ded6
--- /dev/null
+++ b/src/main/java/roomescape/domain/Reservations.java
@@ -0,0 +1,39 @@
+package roomescape.domain;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Reservations {
+
+    private final Map<Long, Reservation> reservations;
+    private final Counter counter;
+
+    public Reservations(final Map<Long, Reservation> reservations, final Counter counter) {
+        this.reservations = reservations;
+        this.counter = counter;
+    }
+
+    public Reservations() {
+        this(new HashMap<>(), new Counter());
+    }
+
+    public Reservation addReservation(final String name, final LocalDateTime dateTime) {
+        final long id = counter.getAndIncrease();
+        final Reservation reservation = new Reservation(id, name, dateTime);
+        reservations.put(id, reservation);
+        return reservation;
+    }
+
+    public void deleteById(final Long id) {
+        if (!reservations.containsKey(id)) {
+            throw new IllegalArgumentException("[ERROR] 해당 id가 없습니다.");
+        }
+        reservations.remove(id);
+    }
+
+    public Map<Long, Reservation> getReservations() {
+        return Collections.unmodifiableMap(reservations);
+    }
+}
diff --git a/src/main/java/roomescape/dto/request/ReservationRequest.java b/src/main/java/roomescape/dto/request/ReservationRequest.java
new file mode 100644
index 0000000000..5734bcbef7
--- /dev/null
+++ b/src/main/java/roomescape/dto/request/ReservationRequest.java
@@ -0,0 +1,18 @@
+package roomescape.dto.request;
+
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+public record ReservationRequest(
+
+        @NotNull
+        LocalDate date,
+
+        @NotNull
+        String name,
+
+        @NotNull
+        LocalTime time
+) {
+}
diff --git a/src/main/java/roomescape/dto/response/ReservationResponse.java b/src/main/java/roomescape/dto/response/ReservationResponse.java
new file mode 100644
index 0000000000..f819004e1c
--- /dev/null
+++ b/src/main/java/roomescape/dto/response/ReservationResponse.java
@@ -0,0 +1,23 @@
+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().values().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());
+    }
+}
diff --git a/src/main/resources/templates/admin/index.html b/src/main/resources/templates/admin/index.html
index 417102cf1c..abae957b87 100644
--- a/src/main/resources/templates/admin/index.html
+++ b/src/main/resources/templates/admin/index.html
@@ -1,38 +1,38 @@
 <!DOCTYPE html>
 <html lang="ko">
 <head>
-  <meta charset="UTF-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  <title>방탈출 어드민</title>
-  <!-- Bootstrap CSS -->
-  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
-  <link rel="stylesheet" href="/css/style.css">
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>방탈출 어드민</title>
+    <!-- Bootstrap CSS -->
+    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
+    <link rel="stylesheet" href="/css/style.css">
 </head>
 <body>
 
 <nav class="navbar navbar-expand-lg navbar-light bg-light">
-  <a class="navbar-brand" href="/">
-    <img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
-  </a>
-  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
-          aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
-    <span class="navbar-toggler-icon"></span>
-  </button>
+    <a class="navbar-brand" href="/static">
+        <img src="/image/admin-logo.png" alt="LOGO" style="height: 40px;">
+    </a>
+    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
+            aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
+        <span class="navbar-toggler-icon"></span>
+    </button>
 
-  <div class="collapse navbar-collapse" id="navbarSupportedContent">
-    <ul class="navbar-nav ml-auto">
-      <li class="nav-item">
-        <a class="nav-link" href="/reservation">Reservation</a>
-      </li>
-      <li class="nav-item">
-        <a class="nav-link" href="/time">Time</a>
-      </li>
-    </ul>
-  </div>
+    <div class="collapse navbar-collapse" id="navbarSupportedContent">
+        <ul class="navbar-nav ml-auto">
+            <li class="nav-item">
+                <a class="nav-link" href="/reservation">Reservation</a>
+            </li>
+            <li class="nav-item">
+                <a class="nav-link" href="/time">Time</a>
+            </li>
+        </ul>
+    </div>
 </nav>
 
 <div class="content-container">
-  <h2 class="content-container-title">방탈출 어드민</h2>
+    <h2 class="content-container-title">방탈출 어드민</h2>
 </div>
 
 </body>
diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java
index 6f7b197915..a2730e9527 100644
--- a/src/test/java/roomescape/MissionStepTest.java
+++ b/src/test/java/roomescape/MissionStepTest.java
@@ -1,6 +1,13 @@
 package roomescape;
 
+import static org.hamcrest.Matchers.is;
+
 import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.test.annotation.DirtiesContext;
@@ -9,12 +16,152 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
 
+    private static final String DATE_FORMAT = "%d-%02d-%02d";
+
+    @Test
+    void 홈화면을_조회한다() {
+        RestAssured.given().log().all()
+                .when().get("/admin")
+                .then().log().all()
+                .statusCode(200);
+    }
+
     @Test
-    void 일단계() {
+    void 예약_페이지를_조회한다() {
         RestAssured.given().log().all()
-                .when().get("/")
+                .when().get("/admin/reservation")
                 .then().log().all()
                 .statusCode(200);
     }
 
+    @Test
+    void 예약_목록을_조회한다() {
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Nested
+    class 예약_추가_테스트 {
+
+        @Test
+        void 예약을_추가한다() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", "브라운");
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            RestAssured.given().log().all()
+                    .contentType(ContentType.JSON)
+                    .body(params)
+                    .when().post("/reservations")
+                    .then().log().all()
+                    .statusCode(200)
+                    .body("id", is(1));
+
+            RestAssured.given().log().all()
+                    .when().get("/reservations")
+                    .then().log().all()
+                    .statusCode(200)
+                    .body("size()", is(1));
+        }
+
+        @Test
+        void 이름_null_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", null);
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
+
+        @Test
+        void 이름_size_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", "잠실에사는비행기데코피크민");
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
+
+        @Test
+        void 날짜_null_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", "밍트");
+            params.put("date", null);
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
+
+        @Test
+        void 과거_날짜_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", "밍트");
+            params.put("date", makeYesterdayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
+
+        @Test
+        void 시간_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", "밍트");
+            params.put("date", makeTodayMessage());
+            params.put("time", null);
+
+            createBadReservation(params);
+        }
+
+        private void createBadReservation(final Map<String, String> params) {
+            RestAssured.given().log().all()
+                    .contentType(ContentType.JSON)
+                    .body(params)
+                    .when().post("/reservations")
+                    .then().log().all()
+                    .statusCode(400);
+        }
+    }
+
+    private String makeYesterdayMessage() {
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+        return String.format(DATE_FORMAT, yesterday.getYear(), yesterday.getMonthValue(), yesterday.getDayOfMonth());
+    }
+
+    private String makeTodayMessage() {
+        LocalDate today = LocalDate.now();
+        return String.format(DATE_FORMAT, today.getYear(), today.getMonthValue(), today.getDayOfMonth());
+    }
+
+    @Test
+    void 예약을_삭제한다() {
+        Map<String, String> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("time", "15:40");
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("id", is(1));
+
+        RestAssured.given().log().all()
+                .when().delete("/reservations/1")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
 }
diff --git a/src/test/java/roomescape/domain/ReservationTest.java b/src/test/java/roomescape/domain/ReservationTest.java
new file mode 100644
index 0000000000..d7e030bace
--- /dev/null
+++ b/src/test/java/roomescape/domain/ReservationTest.java
@@ -0,0 +1,38 @@
+package roomescape.domain;
+
+import java.time.LocalDateTime;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class ReservationTest {
+
+    @Test
+    void 이름이_null인_경우_예외를_발생시킨다() {
+        Assertions.assertThatThrownBy(() -> new Reservation(1L, null, LocalDateTime.now()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[ERROR] name은 null이 될 수 없습니다.");
+    }
+
+    @Test
+    void 이름이_10글자를_넘을_경우_예외를_발생시킨다() {
+        final String name = "잠실에사는비행기데코피크민";
+        Assertions.assertThatThrownBy(() -> new Reservation(1L, name, LocalDateTime.now()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[ERROR] 이름은 10글자를 넘을 수 없습니다.");
+    }
+
+    @Test
+    void 날짜가_null인_경우_예외를_발생시킨다() {
+        Assertions.assertThatThrownBy(() -> new Reservation(2L, "메이", null))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[ERROR] dateTime은 null이 될 수 없습니다.");
+    }
+
+    @Test
+    void 과거_날짜인_경우_예외를_발생시킨다() {
+        LocalDateTime yesterday = LocalDateTime.now().minusDays(1);
+        Assertions.assertThatThrownBy(() -> new Reservation(2L, "메이", yesterday))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("[ERROR] 과거 날짜로 예약할 수 없습니다.");
+    }
+}
diff --git a/src/test/java/roomescape/domain/ReservationsTest.java b/src/test/java/roomescape/domain/ReservationsTest.java
new file mode 100644
index 0000000000..70c5cdae3e
--- /dev/null
+++ b/src/test/java/roomescape/domain/ReservationsTest.java
@@ -0,0 +1,36 @@
+package roomescape.domain;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalDateTime;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class ReservationsTest {
+
+    private final Reservations reservations = new Reservations();
+
+    @Test
+    void 예약을_추가한다() {
+        // Given
+        LocalDateTime tomorrow = LocalDateTime.now().plusDays(1);
+
+        // When & Then
+        Assertions.assertThatCode(() -> {
+            reservations.addReservation("밍트", tomorrow);
+        }).doesNotThrowAnyException();
+    }
+
+    @Test
+    void 예약을_삭제한다() {
+        // Given
+        final LocalDateTime tomorrow = LocalDateTime.now().plusDays(1);
+        reservations.addReservation("밍트", tomorrow);
+
+        // When
+        reservations.deleteById(1L);
+
+        // Then
+        assertThat(reservations.getReservations()).isEmpty();
+    }
+}


Reviews:
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-17T08:23:40Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-17T08:24:58Z
user: login: Hyunta
body: 안녕하세요 밍트,
레벨 2 첫번째 미션으로 만나게 된 아서입니다.
첫 미션 진행하시느라 고생하셨습니다.
몇가지 커멘트와 질문 남겼으니 확인 부탁드립니다.
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-17T12:52:58Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-18T11:32:27Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T10:35:31Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T11:13:06Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T12:14:25Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T12:25:44Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T12:37:14Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T12:52:37Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T15:50:07Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T16:28:51Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-19T16:29:57Z
user: login: Hyunta
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-20T08:03:58Z
user: login: Hyunta
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-20T08:06:08Z
user: login: Hyunta
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-20T08:13:06Z
user: login: Hyunta
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-20T08:18:12Z
user: login: Hyunta
body: 안녕하세요 밍트,
남겨주신 질문에 대한 커멘트 달아드렸습니다.
확인해보시고 모호하거나, 궁금한 점이 있으면 DM으로 알려주세요.
이번 PR은 머지하도록 하겠습니다.
state: APPROVED
author_association: NONE
submitted_at: 2025-04-20T08:19:16Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-20T11:15:56Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T13:42:58Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T13:45:13Z
user: login: Starlight258
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T13:45:58Z

Review comments:
diff_hunk: @@ -0,0 +1,17 @@
+package roomescape.dto.request;
+
+import jakarta.validation.constraints.FutureOrPresent;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+public record ReservationRequest(
+        @NotNull @FutureOrPresent
+        LocalDate date,
+        @NotBlank @Size(max = 10)
+        String name,
+        @NotNull
+        LocalTime time) {
path: src/main/java/roomescape/dto/request/ReservationRequest.java
user: login: Starlight258
body: ### 도메인에 `dto`의 검증을 중복으로 두는 것이 좋을까요?
도메인에 대한 검증을 수행할 때 요청 전에 dto`에서` 수행하면 더 빠르게 검증을 수행할 수 있는 장점이 있습니다. 하지만 도메인의 검증 로직이 여러 `dto`에 분산될 수 있는데요. 

현재는 요청을 통해서만 예약이 생성되므로 검증 코드를 dto에 두었습니다. 그리고 도메인은 필드가 null인지만 검증하였습니다.

하지만 요청이 아닌 내부에서 `Reservation`을 생성하는 경우 검증이 제대로 수행되지 않는 문제가 있는데요. 생성자에서도 날짜, 이름 등의 필드에 중복으로 검증을 수행하는 것이 좋을까요?
created_at: 2025-04-17T08:23:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: ## Controller 필드
`Controller`에 id를 생성하는 필드를 두기보다 `Counter`와 같은 객체를 생성하여 두는 것이 좋을까요? 

객체 생성은 어느 곳에서 하는 것이 좋을까요?
created_at: 2025-04-17T08:24:58Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Hyunta
body: 아래 선택지가 있을 것 같습니다.

1. Controller에서 괸리
2. Counter 객체를 새로 생성
3. Reservation or Reservations에서 관리

어떤 객체가 책임을 가지는 것이 좋을까요?
어떤 장단점이 있을지 고민해봅시다.
created_at: 2025-04-17T09:37:04Z
author_association: NONE
diff_hunk: @@ -21,6 +21,8 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-web'
     implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
     implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+    implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.4'
+
path: build.gradle
user: login: Hyunta
body: 해당 라이브러리가 필수적이었을까요?
어떤 고민을 하신 후에 라이브러리에 의존하기로 하셨을까요?
created_at: 2025-04-17T12:41:05Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Hyunta
body: `@RestController` 라는 애노테이션을 한번 알아볼까요?
created_at: 2025-04-17T12:42:29Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
+    private final Reservations reservations = new Reservations(new ArrayList<>());
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Hyunta
body: ```suggestion
    private final Reservations reservations = new Reservations();
```

빈 리스트로 생성하는 경우에는 아무 인자를 전달하지 않고, 내부에서 기본생성자를 정의하여 `new ArrayList<>()` 를 만들어줄 수 있을 것 같습니다.
어떻게 생각하시나요?
created_at: 2025-04-17T12:44:19Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
+    private final Reservations reservations = new Reservations(new ArrayList<>());
+
+    @GetMapping
+    public ResponseEntity<List<ReservationResponse>> readReservations() {
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Hyunta
body: ResponseEntity는 왜 쓰셨나요? 다른 방법은 없었을까요?
created_at: 2025-04-17T12:44:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,34 @@
+package roomescape.domain;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class Reservations {
+
+    private final List<Reservation> reservations;
path: src/main/java/roomescape/domain/Reservations.java
user: login: Hyunta
body: 여러 자료구조 중에서 List를 선택하신 이유가 있을까요?
created_at: 2025-04-17T12:47:21Z
author_association: NONE
diff_hunk: @@ -0,0 +1,17 @@
+package roomescape.dto.request;
+
+import jakarta.validation.constraints.FutureOrPresent;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+public record ReservationRequest(
+        @NotNull @FutureOrPresent
+        LocalDate date,
+        @NotBlank @Size(max = 10)
+        String name,
+        @NotNull
+        LocalTime time) {
path: src/main/java/roomescape/dto/request/ReservationRequest.java
user: login: Hyunta
body: 좋은 질문입니다!
DTO에서도 검증하고 도메인에서도 검증하게 되면 동일한 내용을 두번 검증하게 됩니다.
말씀해주신 것처럼 dto에서만 검증하게 되면 내부에서 도메인을 생성할 때는 규칙에 위반된 객체를 생성할 수 있게 됩니다.

레벨 1때 기억을 한번 되살려볼까요?
예약 날짜가 과거가 아닌지, 이름의 길이가 너무 길지는 않은지, 필수 값이 입력이 됐는지 어디서 검증하셨었나요?
왜 그곳에서 검증을 했었나요?
created_at: 2025-04-17T12:50:32Z
author_association: NONE
diff_hunk: @@ -9,12 +16,133 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
 
+    private static final String DATE_FORMAT = "%d-%02d-%02d";
+
     @Test
     void 일단계() {
         RestAssured.given().log().all()
-                .when().get("/")
+                .when().get("/admin")
                 .then().log().all()
                 .statusCode(200);
     }
 
+    @Test
+    void 이단계() {
+        RestAssured.given().log().all()
+                .when().get("/admin/reservation")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Test
+    void 삼단계() {
+        Map<String, String> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("time", "15:40");
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("id", is(1));
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(1));
+
+        RestAssured.given().log().all()
+                .when().delete("/reservations/1")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Nested
+    class 예약_요청_유효성_검증 {
+
+        @Test
+        void 이름_null_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", null);
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
path: src/test/java/roomescape/MissionStepTest.java
user: login: Hyunta
body: RestAssured를 사용해보시면서 어떠셨나요?
어려운 점은 없었나요?
created_at: 2025-04-17T12:51:45Z
author_association: NONE
diff_hunk: @@ -9,12 +16,133 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
path: src/test/java/roomescape/MissionStepTest.java
user: login: Hyunta
body: `@DirtiesContext` 이 애노테이션은 어떤 역할을 하나요?
created_at: 2025-04-17T12:52:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,17 @@
+package roomescape.dto.request;
+
+import jakarta.validation.constraints.FutureOrPresent;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+public record ReservationRequest(
+        @NotNull @FutureOrPresent
+        LocalDate date,
+        @NotBlank @Size(max = 10)
+        String name,
+        @NotNull
+        LocalTime time) {
path: src/main/java/roomescape/dto/request/ReservationRequest.java
user: login: Starlight258
body: 레벨 1에서는 생성자에서 검증을 수행했습니다. 객체가 생성되는 모든 지점에서 검증을 안전하게 수행하기 위해서입니다.

이번에 생성자에서 검증하지 않고, `dto`에서 검증한 이유는 `validation 어노테이션`을 사용하기 위해서입니다.
해당 어노테이션을 통해 간편하게 검증을 수행할 수 있습니다.

하지만 `dto`에 검증 로직을 둔 이유가 단순히 `validation` 어노테이션을 사용하기 위함이라는 것은 기술의 사용만을 위해 설계가 바뀌는 것이므로 좋지 않다고 생각합니다.

dto는 외부 요청에 대한 유효성과 같은 아주 기본적인 검증만 수행하고, 도메인 검증(예약은 미래 날짜만 가능하다 등)은 도메인에 두는 것이 좋아보입니다.
> dto의 검증과 도메인 검증이 같은 코드이지만 중복이라고 보지 못할 수도 있을 것 같습니다. 목적이 다르기 때문입니다.

[refactor: 검증 로직을 도메인으로 이동](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/07ee1bca9ac0eca2419f326df4a3c0891f05d801)

created_at: 2025-04-18T11:32:27Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: ## `id` 책임
### 1. `Controller`에서 관리 -> ❌
- `Controller`가 `Reservation`에 대한 `id`를 필드로 가지는 것은 `controller`의 책임인 흐름 관리를 벗어나기 때문에 좋지 않습니다.

### 2. `Counter` 객체를 새로 생성 -> 채택 ⭕
- `Counter` 객체가  `id` 증가 횟수를 관리할 수 있습니다.
- `Reservations`은 단순히 `Counter`를 이용하여 다음 `id`가 무엇인지 알 수 있습니다.
- `Reservations` 외의 다른 객체에서도 `Counter`를 이용하여 `id`를 관리할 수 있습니다.

### 3. `Reservations` 내에 두기 -> ❌
- `id`는 예약 집합의 순번과 관련되므로, `Controller`에 두는 것보다 `Reservations` 내부에 `id`를 두어 관리하는 것이 적절합니다.
- 하지만 다른 객체들도 객체 하나당 `id`가 1씩 증가하므로 재사용성을 위하여 `Counter` 객체를 생성하여 이용하는 방식이 더 좋습니다.

[feat: id 책임 Controller에서 Reservations로 이동](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/83371a979e9b1785de82d22207689eeec9d0a068)

created_at: 2025-04-19T10:35:31Z
author_association: NONE
diff_hunk: @@ -21,6 +21,8 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-web'
     implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
     implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+    implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.4'
+
path: build.gradle
user: login: Starlight258
body: spring 문서를 읽어보던 중에 [validation](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/validation.html) 을 통해 검증을 어노테이션을 이용해 쉽게 수행할 수 있음을 알게 되었습니다.
따라서 해당 라이브러리를 도입하였습니다.

혹시 해당 라이브러리를 사용하지 않는 것을 추천하시나요?
created_at: 2025-04-19T11:13:06Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: `@RestController`는 `@Controller`와 `@ReponseBody`가 결합된 어노테이션입니다.
저희는 `Rest API`(`Rest` 아키텍처를 준수하는 웹 `api`, 데이터만 전달)를 구현하기 때문에, 항상 뷰가 아닌 데이터를 반환합니다.

따라서 `@Controller`보다는 `@RestController`를 사용하는 것이 좋습니다.
> 현재 코드에서 `@ResponseBody`를 사용하지 않은 이유는 `ResponseEntity`가 반환 객체의 직렬화를 수행하기 때문입니다.

[refactor:](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/dabf5667b0805c43abb17bda6292793af0f7343f) @RestController[로 수정](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/dabf5667b0805c43abb17bda6292793af0f7343f)

created_at: 2025-04-19T12:14:25Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
+    private final Reservations reservations = new Reservations(new ArrayList<>());
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: 좋습니다! 더 간단해질 것 같아요!
[refactor: 내부에서 필드를 생성하여 주입하는 생성자 추가](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/a177c37205daa6d49bb9ab427c4a022683c1aed8)

`Reservations`의 테스트 용이성을 위해 기존에 리스트를 받는 생성자는 유지하는 것이 좋을까요? 현재 테스트에서 사용하지는 않지만, 후에 예약 여러개를 한번에 추가할 때 사용할 수 있을 것 같습니다.
created_at: 2025-04-19T12:25:43Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
+    private final Reservations reservations = new Reservations(new ArrayList<>());
+
+    @GetMapping
+    public ResponseEntity<List<ReservationResponse>> readReservations() {
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: `ResponseEntity`는 반환 객체를 직렬화(`@ResponseBody`)해줄 뿐 아니라, `상태 코드`와 `헤더` 등을 함께 설정할 수 있습니다. 현재는 상태 코드가 모두 200이고, 헤더가 필요하지 않지만 후에 추가될 수 있어 `ResponseEntity`를 사용했습니다.

그런데 생각해보면 현재 단순히 객체를 직렬화하여 `json`으로 반환하고만 있는데, 굳이 `ResponseEntity`를 사용할 필요가 있을까? 라는 생각이 들었습니다. 만약 다른 상태코드나, 헤더가 추가될 필요성이 있을 때 사용하는 것이 더 단순하고 명확할 것 같습니다. 수정하겠습니다!

[refactor: ResponseEntity를 사용하지 않도록 수정](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/e59819fc53004bc9c4137f736ecbc1ca9d3f3e06)
created_at: 2025-04-19T12:37:14Z
author_association: NONE
diff_hunk: @@ -0,0 +1,34 @@
+package roomescape.domain;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class Reservations {
+
+    private final List<Reservation> reservations;
path: src/main/java/roomescape/domain/Reservations.java
user: login: Starlight258
body: `Reservations`는 예약 집합을 나타내는 일급 컬렉션입니다. 일급 컬렉션을 사용하여 컬렉션에 대한 동작을 클래스 내부에서 관리하여 캡슐화를 지킬 수 있습니다.

많은 생각 없이 `List`를 사용했습니다. 그런데 매번 `id`로 먼저 접근하고, `id`는 고유한 값이므로 `Map<Long, Reservation>` 을 사용하는 것이 적합한 자료구조입니다.

앞으로 자료구조를 사용할 때 한번 더 생각해보고 사용하겠습니다! 감사합니다.

[refactor: Reservations 자료구조 Map으로 변경](https://github.com/woowacourse/spring-roomescape-admin/pull/268/commits/b052436adefaed48af1b592f5343969bb93601ae)
created_at: 2025-04-19T12:52:37Z
author_association: NONE
diff_hunk: @@ -9,12 +16,133 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
 
+    private static final String DATE_FORMAT = "%d-%02d-%02d";
+
     @Test
     void 일단계() {
         RestAssured.given().log().all()
-                .when().get("/")
+                .when().get("/admin")
                 .then().log().all()
                 .statusCode(200);
     }
 
+    @Test
+    void 이단계() {
+        RestAssured.given().log().all()
+                .when().get("/admin/reservation")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Test
+    void 삼단계() {
+        Map<String, String> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("time", "15:40");
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("id", is(1));
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(1));
+
+        RestAssured.given().log().all()
+                .when().delete("/reservations/1")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Nested
+    class 예약_요청_유효성_검증 {
+
+        @Test
+        void 이름_null_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", null);
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
path: src/test/java/roomescape/MissionStepTest.java
user: login: Starlight258
body: 이전에 통합 테스트를 할 때는 `mockMvc`를 사용했습니다. 그런데 처음 `RestAssured`를 사용하면서, `bdd style(given(), when(), then())`으로 작성할 수 있어 훨씬 가독성이 좋았어요. 

어려운 점은, `restAssured` 공식 문서를 처음에 봤는데 생각보다 이해하기 쉽지 않았어요. 그래서 다른 블로그들을 봤는데도 잘 정리된 자료를 아직 찾지 못해서 학습하기 어려운 것 같아요. 결국 gpt를 이용해서 `restAssured` 사용 방법과 webclient vs restAssured vs test RestTemplate 을 찾아봤어요.

저는 **`restAssured`를 처음 접해보았는데 자주 쓰이는 라이브러리인지** 궁금해요!

그리고 학습 측면에서, 아서는 **새로운 키워드나 라이브러리 등을 학습할 때 어떻게 공부하시는지 궁금합니다**. 공식문서와 영어 유명 블로그들([baeldung](https://www.baeldung.com/author/baeldung))을 보면서 학습하는데, 제가 느끼기에 몇몇은 모호하게 쓰여진 글이 많고, 표현이 풍부해서 차근차근 읽어보고 스스로 생각을 정리해야 이해할 수 있더라구요. (제 영어 실력이 아직 부족해서도 있습니다..) 

gpt에게 물어보면 바로 답이 나오는데, 공식 문서를 먼저 보고 영어 블로그들을 보다보니 효율이 낮다고 느껴지는 경우도 많았습니다. 그런데 미션을 하며 공식 문서를 계속 보다보니 공부한 내용이 기억에 더 오래 남고, 잘 정리된 문서를 보면 도움이 많이 되었습니다. gpt를 어떻게, 어느정도까지 학습에 활용해야할지 너무 고민이 되어서 질문 드리게 되었습니다! 
> 현재 영어 문서와 한글 번역본을 함께 비교하며 보고 있어요.
created_at: 2025-04-19T15:50:07Z
author_association: NONE
diff_hunk: @@ -9,12 +16,133 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
path: src/test/java/roomescape/MissionStepTest.java
user: login: Starlight258
body: `@DirtiesContext`는 `Spring ApplicationContext`가 오염될 경우, 오염된 컨텍스트를 캐시에서 제거하고 닫은 후에 새로운 컨텍스트를 생성합니다.

현재 `Reservations`는 상태를 가지므로, 다음 테스트에서 해당 빈의 상태가 유지되어서는 안되기 때문에 각 테스트 메서드 전에 오염 여부를 확인하도록 작성하였습니다.

created_at: 2025-04-19T16:28:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,52 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import roomescape.domain.Reservation;
+import roomescape.domain.Reservations;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.response.ReservationResponse;
+
+@RestController
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final Reservations reservations = new Reservations();
+
+    @GetMapping
+    public List<ReservationResponse> readReservations() {
+        final List<ReservationResponse> dtos = ReservationResponse.from(reservations);
+        return dtos;
+    }
+
+    @PostMapping
+    public ReservationResponse createReservation(
+            @Valid @RequestBody final ReservationRequest reservationRequest) {
+        final Reservation reservation = makeReservation(reservationRequest);
+        return ReservationResponse.from(reservation);
+    }
+
+    @DeleteMapping("/{id}")
+    public void deleteReservation(@PathVariable final Long id) {
+        reservations.deleteById(id);
+    }
+
+    private Reservation makeReservation(final ReservationRequest reservationRequest) {
+        final LocalDateTime dateTime = LocalDateTime.of(reservationRequest.date(), reservationRequest.time());
+        try {
+            return reservations.addReservation(reservationRequest.name(), dateTime);
+        } catch (IllegalArgumentException e) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
+        }
+    }
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: 예외 처리하는 더 좋은 방법이 있는지 궁금합니다!

그리고 추가적으로, `ResponseStatusException`을 사용하고 있는데 이를 유지하는 것이 좋을지, `ResponseEntity`로 400번대 예외를 반환하면 좋을지 고민이 됩니다. 

`ResponseStatusException`은 상태 코드를 쉽게 설정하여 예외를 던질 수 있습니다. 그런데 예외시 응답 구조가 고정됩니다. 따라서 응답 구조를 커스텀하려면 `ResponseEntity`를 쓰는 것이 좋습니다. 

- `ResponseStatusException` 응답 구조
```
{
    "timestamp": "2022-05-20T19:17:54.442+00:00",
    "status": 503,
    "error": "Service Unavailable",
    "message": "1-2 AM is service downtime!",
    "path": "/api/v1/student/1"
}
```
> 출처 : [Spring Boot Exception Handling — ResponseStatusException](https://farzinpashaeee.medium.com/spring-boot-exception-handling-responsestatusexception-8e1921e6c2a2)
created_at: 2025-04-19T16:29:57Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Hyunta
body: 적절히 고민해서 잘 결정해주셨네요 👍🏻
추후에 요구사항을 반영하면서 어떻게 변하게 될지 한번 살펴보시죠.
created_at: 2025-04-20T08:03:58Z
author_association: NONE
diff_hunk: @@ -21,6 +21,8 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-web'
     implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
     implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+    implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.4'
+
path: build.gradle
user: login: Hyunta
body: 예전에 제가 크루였을 때는 의존성을 추가함에 있어서 엄격하게 진행되었습니다.
라이브러리를 사용하기 보다는 그 기술이 왜 필요하고, 정말 필요하다면 스스로 만들 수는 없는지 고민을 해보도록 했습니다.
그리고 지금 학습하는데 있어서 해당 라이브러리가 도움이 될 수도 있지만, 방해가 될 수도 있기 때문에 고민해보세요.
해당 라이브러리를 비추천한다기 보다는 정말 필요하다면 쓰는 것이 맞다고 생각합니다.
created_at: 2025-04-20T08:06:08Z
author_association: NONE
diff_hunk: @@ -9,12 +16,133 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
 
+    private static final String DATE_FORMAT = "%d-%02d-%02d";
+
     @Test
     void 일단계() {
         RestAssured.given().log().all()
-                .when().get("/")
+                .when().get("/admin")
                 .then().log().all()
                 .statusCode(200);
     }
 
+    @Test
+    void 이단계() {
+        RestAssured.given().log().all()
+                .when().get("/admin/reservation")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Test
+    void 삼단계() {
+        Map<String, String> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("time", "15:40");
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("id", is(1));
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(1));
+
+        RestAssured.given().log().all()
+                .when().delete("/reservations/1")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Nested
+    class 예약_요청_유효성_검증 {
+
+        @Test
+        void 이름_null_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", null);
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
path: src/test/java/roomescape/MissionStepTest.java
user: login: Hyunta
body: RestAssurerd는 엔드포인트를 테스트할 때 자주 쓰입니다.

 > 아서는 새로운 키워드나 라이브러리 등을 학습할 때 어떻게 공부하시는지 궁금합니다

저도 비슷한 흐름으로 시작하는 것 같아요.
그런데 라이브러리마다 학습 방법이 조금 달라지는 것 같은데, 레퍼런스가 많다면 여러 글이나 영상을 찾아봅니다.
없을 경우에는 그리고 더 깊게 알고 싶을 때는 라이브러리 코드를 직접 들어가서 보는 것을 선호합니다.
공식 문서가 잘 되어 있다면, 그 문서를 참고하는 것이 제일 좋은 방법이라고 생각합니다.

ChatGPT 는 꼭 잘 사용할 수 있어야 합니다. 확실히 학습 속도도 빠르고 효율적이니까요.
그런데 학습할 때는 학습 속도가 빠른 것이 항상 도움이 된다고 생각하지는 않습니다.
2~3일동안 고민해서 습득한 지식과 ChatGPT를 통해서 몇분만에 습득한 지식의 휘발성은 차이가 납니다.
어렵게 습득한만큼 어렵게 까먹는다고 생각해서 되도록 처음 학습하는 분야에서는 개요나 큰 흐름 정도만 채우고 디테일은 제가 직접 찾아가면서 정리하는 방식으로 저는 학습하고 있습니다.
그래서 지금은 비효율적이라고 생각이 드셔도 밍트가 하시는 방법이 저는 좋은 방법이라고 생각합니다.
created_at: 2025-04-20T08:13:06Z
author_association: NONE
diff_hunk: @@ -0,0 +1,52 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import roomescape.domain.Reservation;
+import roomescape.domain.Reservations;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.response.ReservationResponse;
+
+@RestController
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final Reservations reservations = new Reservations();
+
+    @GetMapping
+    public List<ReservationResponse> readReservations() {
+        final List<ReservationResponse> dtos = ReservationResponse.from(reservations);
+        return dtos;
+    }
+
+    @PostMapping
+    public ReservationResponse createReservation(
+            @Valid @RequestBody final ReservationRequest reservationRequest) {
+        final Reservation reservation = makeReservation(reservationRequest);
+        return ReservationResponse.from(reservation);
+    }
+
+    @DeleteMapping("/{id}")
+    public void deleteReservation(@PathVariable final Long id) {
+        reservations.deleteById(id);
+    }
+
+    private Reservation makeReservation(final ReservationRequest reservationRequest) {
+        final LocalDateTime dateTime = LocalDateTime.of(reservationRequest.date(), reservationRequest.time());
+        try {
+            return reservations.addReservation(reservationRequest.name(), dateTime);
+        } catch (IllegalArgumentException e) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
+        }
+    }
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Hyunta
body: 벌써 예외처리에 대해서 고민해주고 계시군요.
저는 말씀해주신 것처럼 ResponseEntity를 주로 씁니다.
미션을 진행하시면서 ResponseEntity와 ResponseStatus 둘다 직접 경험해보시면서 장단점을 정리해보시면 도움이 되실 것 같네요.

다음 미션을 진행하시면서 예외 처리에 대한 고민을 본격적으로 해보실 수 있을 것 같습니다.
created_at: 2025-04-20T08:18:12Z
author_association: NONE
diff_hunk: @@ -21,6 +21,8 @@ dependencies {
     implementation 'org.springframework.boot:spring-boot-starter-web'
     implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
     implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+    implementation 'org.springframework.boot:spring-boot-starter-validation:3.4.4'
+
path: build.gradle
user: login: Starlight258
body: 라이브러리를 단순히 사용하면, 해당 작동 원리를 이해할 기회를 놓치는 것일수도 있겠네요!
스프링을 사용할 때, 단순히 잘 사용하는 것에 그치는 것이 아니라  작동 원리를 생각해보고, 구현할 수 있으면 구현해보도록 하겠습니다!
`@NotNull `어노테이션은 직접 만들 수 있을 것 같아요~

의존성에 대해 한번 더 생각해볼 기회를 주셔서 감사합니다! ☺️
created_at: 2025-04-20T11:15:56Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+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 java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+@Controller
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final AtomicLong counter = new AtomicLong(1);
+    private final Reservations reservations = new Reservations(new ArrayList<>());
+
+    @GetMapping
+    public ResponseEntity<List<ReservationResponse>> readReservations() {
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: 생각해봤는데 질문 주신 의도가 "`ResponseEntity`를 사용하지 말라"가 아닌, `ResponseEntity` 외에 반환할 수 있는 객체를 이야기주신 것 같아 정리해보았습니다!

- 객체 with`@ResponseBody` : `HttpMessageConverter`를 이용해 객체를 데이터 포맷(`JSON`, `XML`..) 변환
- `ResponseEntity` : `HTTP` 상태 코드, 헤더, 본문을 모두 제어할 수 있는 응답 객체
- `HttpServletResponse` : `Servlet API`를 직접 사용하여 `HTTP` 응답을 세밀하게 제어할 수 있는 응답 객체, 직접 사용하지 않고 추상화된 `ResponseEntity`나 `@ResponseBody`를 사용하는 것을 추천
- `ModelAndView` : `Model`과 `View`를 함께 반환, `템플릿` 엔진을 사용한 웹 페이지 렌더링에 사용되는 응답 객체
- `String` : 뷰 이름, 간단한 뷰 렌더링에 사용되는 응답 객체
- `void` : `HttpServletResponse`를 매개변수로 받아 응답을 직접 작성, 응답을 작성하지 않고 `@ResponseBody`가 없을 경우 요청 `url`을 `view` 이름으로 간주한다.

created_at: 2025-04-21T13:42:58Z
author_association: NONE
diff_hunk: @@ -9,12 +16,133 @@
 @DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
 public class MissionStepTest {
 
+    private static final String DATE_FORMAT = "%d-%02d-%02d";
+
     @Test
     void 일단계() {
         RestAssured.given().log().all()
-                .when().get("/")
+                .when().get("/admin")
                 .then().log().all()
                 .statusCode(200);
     }
 
+    @Test
+    void 이단계() {
+        RestAssured.given().log().all()
+                .when().get("/admin/reservation")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Test
+    void 삼단계() {
+        Map<String, String> params = new HashMap<>();
+        params.put("name", "브라운");
+        params.put("date", makeTodayMessage());
+        params.put("time", "15:40");
+
+        RestAssured.given().log().all()
+                .contentType(ContentType.JSON)
+                .body(params)
+                .when().post("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("id", is(1));
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(1));
+
+        RestAssured.given().log().all()
+                .when().delete("/reservations/1")
+                .then().log().all()
+                .statusCode(200);
+
+        RestAssured.given().log().all()
+                .when().get("/reservations")
+                .then().log().all()
+                .statusCode(200)
+                .body("size()", is(0));
+    }
+
+    @Nested
+    class 예약_요청_유효성_검증 {
+
+        @Test
+        void 이름_null_검증_테스트() {
+            Map<String, String> params = new HashMap<>();
+            params.put("name", null);
+            params.put("date", makeTodayMessage());
+            params.put("time", "15:40");
+
+            createBadReservation(params);
+        }
path: src/test/java/roomescape/MissionStepTest.java
user: login: Starlight258
body: 감사합니다 아서!! 공식 문서를 주로 보되, 전체적인 흐름은 gpt를 이용해서 학습하겠습니다!
나무와 숲을 동시에 볼 수 있어 올바른 방향으로 잘 공부할 수 있을 것 같습니다!
감사합니다 :) 큰 도움이 되었어요! 😊
created_at: 2025-04-21T13:45:13Z
author_association: NONE
diff_hunk: @@ -0,0 +1,52 @@
+package roomescape.controller;
+
+import jakarta.validation.Valid;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import roomescape.domain.Reservation;
+import roomescape.domain.Reservations;
+import roomescape.dto.request.ReservationRequest;
+import roomescape.dto.response.ReservationResponse;
+
+@RestController
+@RequestMapping("/reservations")
+public class ReservationController {
+
+    private final Reservations reservations = new Reservations();
+
+    @GetMapping
+    public List<ReservationResponse> readReservations() {
+        final List<ReservationResponse> dtos = ReservationResponse.from(reservations);
+        return dtos;
+    }
+
+    @PostMapping
+    public ReservationResponse createReservation(
+            @Valid @RequestBody final ReservationRequest reservationRequest) {
+        final Reservation reservation = makeReservation(reservationRequest);
+        return ReservationResponse.from(reservation);
+    }
+
+    @DeleteMapping("/{id}")
+    public void deleteReservation(@PathVariable final Long id) {
+        reservations.deleteById(id);
+    }
+
+    private Reservation makeReservation(final ReservationRequest reservationRequest) {
+        final LocalDateTime dateTime = LocalDateTime.of(reservationRequest.date(), reservationRequest.time());
+        try {
+            return reservations.addReservation(reservationRequest.name(), dateTime);
+        } catch (IllegalArgumentException e) {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
+        }
+    }
path: src/main/java/roomescape/controller/ReservationController.java
user: login: Starlight258
body: 다음 미션에 예외 처리가 있군요! 장단점을 따져가며 차근차근 공부해보도록 하겠습니다! 감사합니다 :)
created_at: 2025-04-21T13:45:58Z
author_association: NONE

PR comments:
user: login: Starlight258
created_at: 2025-04-19T16:32:32Z
body: @Hyunta 아서~ 리뷰 감사드립니다! 🙇‍♀️
덕분에 작성한 코드를 다시 되돌아보고 개선할 수 있었습니다!
리뷰 반영하여 수정하였습니다!
개선할 부분 있으면 피드백 마구마구 해주시면 감사드리겠습니다 😄
author_association: NONE
user: login: Starlight258
created_at: 2025-04-21T13:46:46Z
body: @Hyunta 리뷰 감사드립니다! 🙇‍♀️
author_association: NONE