Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:5.3.1'
}
Expand Down
1 change: 0 additions & 1 deletion src/main/java/roomescape/RoomescapeApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@ public class RoomescapeApplication {
public static void main(String[] args) {
SpringApplication.run(RoomescapeApplication.class, args);
}

}
12 changes: 12 additions & 0 deletions src/main/java/roomescape/controller/HomeController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package roomescape.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
}
57 changes: 57 additions & 0 deletions src/main/java/roomescape/controller/ReservationController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package roomescape.controller;

import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
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.ResponseBody;
import roomescape.dto.ReservationCreateRequest;
import roomescape.dto.ReservationCreateResponse;
import roomescape.model.Reservation;
import roomescape.service.ReservationService;

@Controller
public class ReservationController {
private final ReservationService reservationService = new ReservationService();

/*
* View Mapping
*/

@GetMapping("/reservation")
public String reservation() {
return "reservation";
}

/*
* API Mapping
*/

@ResponseBody
@GetMapping("/reservations")
public List<Reservation> getReservations() {

Choose a reason for hiding this comment

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

위처럼 사용하는 것과 @RestController를 사용하는 것 중 어느 쪽을 더 선호하시나요? 선호하시는 이유도 궁금합니다!

Choose a reason for hiding this comment

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

추가로, 이 메서드 그대로 @ResponseBody만 떼면 어떤 일이 일어나나요?

Copy link
Author

Choose a reason for hiding this comment

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

위처럼 사용하는 것과 @RestController를 사용하는 것 중 어느 쪽을 더 선호하시나요? 선호하시는 이유도 궁금합니다!

@RestController의 경우 @Controller@ResponseBody가 결합된 형태와 같다고 알고있습니다. 보통의 JSON 데이터만을 반환하는 컨트롤러의 경우 @RestController를 선호하긴 하나, 현재 구현의 경우 컨트롤러 내부에 view template을 반환하는 로직이 함께 있어 기본 형태는 @Controller로 정의하고, JSON 데이터로 반환이 필요한 엔드포인트의 경우 @ResponseBody 어노테이션을 추가하여 JSON 데이터를 반환할 수 있도록 설정해두었습니다!

Copy link
Author

Choose a reason for hiding this comment

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

추가로, 이 메서드 그대로 @ResponseBody만 떼면 어떤 일이 일어나나요?

말씀해주신 상황을 테스트해보니, 현재 ReservationController의 경우 @Controller 어노테이션이 달려 있어 @ResponseBody가 없는 경우 view template을 우선적으로 찾으려고 시도하는것을 확인할 수 있었습니다. 하지만 해당 메소드의 경우 view template를 리턴하지 않기 때문에 다음과 같은 오류가 출력되고 있음을 확인했습니다:

org.thymeleaf.exceptions.TemplateInputException: Error resolving template [reservations], template might not exist or might not be accessible by any of the configured Template Resolvers
	at org.thymeleaf.engine.TemplateManager.resolveTemplate(TemplateManager.java:869) ~[thymeleaf-3.1.1.RELEASE.jar:3.1.1.RELEASE]
	at org.thymeleaf.engine.TemplateManager.parseAndProcess(TemplateManager.java:607) ~[thymeleaf-3.1.1.RELEASE.jar:3.1.1.RELEASE]
	at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1103) ~[thymeleaf-3.1.1.RELEASE.jar:3.1.1.RELEASE]
	at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1077) ~[thymeleaf-3.1.1.RELEASE.jar:3.1.1.RELEASE]
	at org.thymeleaf.spring6.view.ThymeleafView.renderFragment(ThymeleafView.java:372) ~[thymeleaf-spring6-3.1.1.RELEASE.jar:3.1.1.RELEASE]
	at org.thymeleaf.spring6.view.ThymeleafView.render(ThymeleafView.java:192) ~[thymeleaf-spring6-3.1.1.RELEASE.jar:3.1.1.RELEASE]
	at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1415) ~[spring-webmvc-6.0.9.jar:6.0.9]
	at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1159) ~[spring-webmvc-6.0.9.jar:6.0.9]

return reservationService.getReservations();
}

@ResponseBody
@PostMapping("/reservations")
public ResponseEntity<ReservationCreateResponse> createReservation(@RequestBody ReservationCreateRequest request) {
Reservation reservation = reservationService.createReservation(request);

URI location = URI.create("/reservations/" + reservation.id());
return ResponseEntity.created(location).body(ReservationCreateResponse.from(reservation));
}

@ResponseBody
@DeleteMapping("/reservations/{id}")
public ResponseEntity<Void> deleteReservation(@PathVariable int id) {
reservationService.deleteReservation(id);

return ResponseEntity.noContent().build();

Choose a reason for hiding this comment

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

ResponseBody를 사용하지 않는 요청의 반환 타입으로 ResponseEntity를 쓸 수도 있지만
@ResponseStatus를 이용하여 응답 Status를 명시하는 방법도 있습니다. 어느 쪽을 선호하시나요?

Copy link
Author

Choose a reason for hiding this comment

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

구현 당시에는 ResponseEntity를 통한 방법밖에 몰라서 이러한 방식으로 구현을 진행했습니다. 승현님께서 말씀해주신 @ResponseStatus어노테이션을 사용하면 굳이 리턴타입을 명시하지 않아도 된다는 점에서 더 효율적인 것 같아 해당 방식으로 수정을 진행했습니다!

b28c395 커밋에 반영했습니다.

}
}
3 changes: 3 additions & 0 deletions src/main/java/roomescape/dto/ErrorResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package roomescape.dto;

public record ErrorResponse(String message) { }
3 changes: 3 additions & 0 deletions src/main/java/roomescape/dto/ReservationCreateRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package roomescape.dto;

public record ReservationCreateRequest(String name, String date, String time) { }
10 changes: 10 additions & 0 deletions src/main/java/roomescape/dto/ReservationCreateResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package roomescape.dto;

import roomescape.model.Reservation;

public record ReservationCreateResponse(int id, String name, String date, String time) {
public static ReservationCreateResponse from(Reservation reservation) {
return new ReservationCreateResponse(reservation.id(), reservation.name(), reservation.date(), reservation.time());
}
}

25 changes: 25 additions & 0 deletions src/main/java/roomescape/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package roomescape.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import roomescape.dto.ErrorResponse;

@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ReservationNotFoundException.class)
public ResponseEntity<ErrorResponse> handleReservationNotFoundException(ReservationNotFoundException e) {
return new ResponseEntity<>(new ErrorResponse(e.getMessage()), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(ReservationValidationException.class)
public ResponseEntity<ErrorResponse> handleReservationValidationException(ReservationValidationException e) {
return new ResponseEntity<>(new ErrorResponse(e.getMessage()), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleFallbackException() {
return new ResponseEntity<>(new ErrorResponse("내부 서버 오류가 발생했습니다."), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class ReservationNotFoundException extends RuntimeException {
public ReservationNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package roomescape.exception;

public class ReservationValidationException extends RuntimeException {
public ReservationValidationException(String message) {
super(message);
}
}
3 changes: 3 additions & 0 deletions src/main/java/roomescape/model/Reservation.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package roomescape.model;

public record Reservation(int id, String name, String date, String time) { }

Choose a reason for hiding this comment

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

도메인 객체를 record로 설계하셨군요!
나중에 예약 시간이나 예약자 이름이 변경되는 경우처럼
특정 Reservation을 수정하는 경우엔 어떻게 대응하실 계획이신가요?

Copy link
Author

Choose a reason for hiding this comment

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

현재 record로 설계된 구조 특성상 immutable하기 때문에 만약 값에 대한 수정이 필요하다면 변경 값이 들어간 새로운 객체를 생성하여 교체하는 방식으로 진행해야할 것 같습니다!

60 changes: 60 additions & 0 deletions src/main/java/roomescape/service/ReservationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package roomescape.service;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import roomescape.dto.ReservationCreateRequest;
import roomescape.exception.ReservationNotFoundException;
import roomescape.exception.ReservationValidationException;
import roomescape.model.Reservation;

public class ReservationService {
private final AtomicInteger id;

Choose a reason for hiding this comment

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

좋은 시도입니다.

그런데 ReservationController에서 ReservationService를 사용할 때,
new ReservationService()로 새 인스턴스를 만들어 사용하고 있습니다.
만약 ReservationService2Controller가 생겼을 때 거기서도 new ReservationService() 방식으로 이 서비스를 사용한다면, id값이 0부터 다시 카운팅되어 중복 id를 가진 Reservation이 생길 가능성이 있겠네요.
위 문제를 방지하려면 어떤 방법이 있을까요?

Copy link
Author

Choose a reason for hiding this comment

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

해당 문제의 경우 ReservationService에 싱글톤 패턴을 적용하여 모든 컨트롤러가 같은 인스턴스를 사용하게 하는 방식으로 해결할 수 있을 것 같습니다!

이와 관련해서 혹시 Spring 자체적으로 처리하는 방식이 있는지 궁금하여 찾아보니 @Service 어노테이션을 사용하면, 해당 어노테이션이 붙은 클래스를 Spring에서 Bean으로 등록하여 자체적으로 싱글톤 객체로 관리해준다는 것을 확인하여 적용해보았습니다!

171cc5c 커밋에 반영했습니다.

Choose a reason for hiding this comment

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

맞습니다! 정확히는 @service 어노테이션 내부에 있는 https://github.com/component 어노테이션이 주인공입니다.
@controller, @service, @repository, @configuration 등 수한님께서 사용하신 스프링 어노테이션 중 많은 것들이 내부에 https://github.com/component를 가지고 있습니다. 클래스에 https://github.com/component가 달린 클래스는 스프링 컨테이너 실행시 전부 스캔하여 스프링 빈으로 등록해 둡니다.

private final List<Reservation> reservations;

Choose a reason for hiding this comment

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

동시성을 우려해 AtomicInteger를 쓰셨는데, 그럼 이 List는 동시성에서 안전한 자료구조인가요?

Copy link
Author

@chemistryx chemistryx Nov 13, 2025

Choose a reason for hiding this comment

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

현재 생성 요청 시 증가되는 ID 값에 대한 동시성 문제만 고려하고 있는데, 말씀해주신대로 실제 Reservation이 배열에 삽입 / 삭제되는 과정에서도 동시성 문제가 발생할 수 있다는 사실을 간과한 것 같습니다. 이와 관련해서 ArrayList 기반 구조를 유지하면서 동시성이 보장되는 자료구조에 대해 찾아보니 크게 두가지가 있었습니다.

  1. Collections.synchronizedList()
  2. CopyOnWriteArrayList

두 자료구조 모두 thread-safe하여 동시성이 보장된다는 것은 확인하였지만, CopyOnWriteArrayList의 경우 쓰기 시 새로운 배열을 복사한 뒤 교체하는 방식을 통해 thread-safety를 구현하기 때문에 쓰기 연산 성능에서 단점이 존재한다고 파악하였습니다. ResevationService의 경우 예약 추가/삭제가 빈번할 것으로 예상되기에 쓰기 성능에서 상대적인 이점을 가진 Collections.synchronizedList()를 도입하여 수정을 진행했습니다!

147570b 커밋에 반영했습니다.

Choose a reason for hiding this comment

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

잘 학습해주셨습니다. 대신 Collections.synchronizedList()는 읽기/쓰기 연산에 synchronized를 그냥 쓰기 때문에 병렬성이 없습니다. 따라서 읽기가 많은 작업에서는 데이터베이스의 SERIALIZABLE 격리레벨 만큼이나 느린 성능을 보입니다. 적절하게 쓰는 것이 중요하다는 사실도 기억해주시면 좋을 것 같아요!


public ReservationService() {
this.id = new AtomicInteger(0);
this.reservations = new ArrayList<>();

// populateDefaults();
}

public List<Reservation> getReservations() {
return reservations;
}

public Reservation createReservation(ReservationCreateRequest request) {
if (request.name() == null || request.name().isBlank()) {

Choose a reason for hiding this comment

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

Spring Framework에는 util이라는 패키지를 제공해주는데, 위 코드를 한 번에 검증해주는 메서드가 있을 겁니다. 찾아보세요!

Copy link
Author

Choose a reason for hiding this comment

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

찾아보니 org.springframework.util 아래에 StringUtils::hasText()라는 헬퍼 메소드가 있는 것을 확인했습니다!
따라서 해당 메소드를 사용하여 검증 로직을 구성하도록 수정했습니다..!

ea93181 커밋에 반영했습니다.

throw new ReservationValidationException("이름은 공백일 수 없습니다.");
}
if (request.date() == null || request.date().isBlank()) {
throw new ReservationValidationException("날짜는 공백일 수 없습니다.");
}
if (request.time() == null || request.time().isBlank()) {
throw new ReservationValidationException("시간은 공백일 수 없습니다.");
}

Reservation reservation = new Reservation(id.incrementAndGet(), request.name(), request.date(), request.time());

Choose a reason for hiding this comment

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

date와 time을 String 그대로 영속화하는 것보다 더 자연스러운 형태로 영속화하는 것이 확장성 면에서 더 유리합니다.
어떻게 저장해야 더 자연스러울까요?

Copy link
Author

Choose a reason for hiding this comment

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

아무래도 date는 날짜, time은 시간을 의미하니 이와 관련된 타입(LocalDate, LocalTime)을 쓰는 방식이 값 검증이나 날짜/시간 연산 측면에서 더 바람직한 것 같습니다.

해당 타입에 맞게 model 및 DTO에 대한 수정을 진행했습니다!

3c2b610 커밋에 반영했습니다!

reservations.add(reservation);

return reservation;
}

public void deleteReservation(int id) {
boolean removed = reservations.removeIf((reservation -> reservation.id() == id));

if (!removed) {
throw new ReservationNotFoundException("예약을 찾을 수 없습니다.");

Choose a reason for hiding this comment

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

'Http 메서드의 멱등성' 키워드로 레퍼런스를 찾아보세요,
찾아보신 후, 이미 제거된 요소를 제거하려 할 때 예외를 뱉는 것이 좋은지, 아니면 다른 의견을 갖게 되셨는지 수한님의 생각을 알려주세요!

Copy link
Author

Choose a reason for hiding this comment

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

HTTP 메소드의 멱등성에 대한 개념은 처음 접해보는지라 관련해서 한번 찾아보았습니다.

멱등성 (Idempotency): 같은 요청을 여러번 보내더라도 결과가 동일하게 보장되는 속성

RFC 표준에 의하면 DELETE 메소드의 경우 멱등성이 보장되어야 한다고 나와 있습니다. 하지만 현재 제 구현같은 경우에는 만약 삭제할 리소스가 존재한다면 204를, 삭제될 리소스가 존재하지 않는다면 400을 던지도록 구현되어있는데, 이는 멱등성을 만족하지 않는다고 볼 수 있습니다.

이와 관련해서 생각해보았는데, 만약 제거된 리소스에 대해 다시 요청을 했을때 예외를 던진다면 사용자 입장에서는 삭제 요청 자체가 실패했다고 생각할 수도 있을 것 같아 멱등성을 유지하는 측면으로 수정하는 것이 더 적절하다고 판단했습니다.

1e8d8bf 커밋에 반영했습니다!

}
}

private void populateDefaults() {
reservations.addAll(
List.of(
new Reservation(id.incrementAndGet(), "브라운", "2025-01-01", "10:00"),
new Reservation(id.incrementAndGet(), "브라운", "2025-01-02", "11:00"),
new Reservation(id.incrementAndGet(), "브라운", "2025-01-03", "12:00")
)
);
}
}
76 changes: 76 additions & 0 deletions src/test/java/roomescape/MissionStepTest.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package roomescape;

import static org.hamcrest.Matchers.is;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@SuppressWarnings("NonAsciiCharacters")
public class MissionStepTest {

@Test
Expand All @@ -16,4 +22,74 @@ public class MissionStepTest {
.then().log().all()
.statusCode(200);
}

@Test
void 이단계() {
RestAssured.given().log().all()
.when().get("/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", "2023-08-05");
params.put("time", "15:40");

RestAssured.given().log().all()
.contentType(ContentType.JSON)
.body(params)
.when().post("/reservations")
.then().log().all()
.statusCode(201)
.header("Location", "/reservations/1")
.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(204);

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", "");
params.put("time", "");

// 필요한 인자가 없는 경우
RestAssured.given().log().all()
.contentType(ContentType.JSON)
.body(params)
.when().post("/reservations")
.then().log().all()
.statusCode(400);

// 삭제할 예약이 없는 경우
RestAssured.given().log().all()
.when().delete("/reservations/1")
.then().log().all()
.statusCode(400);
}
}