우아한테크코스 프로젝트 코드 흡수하기 편입니다.
이번편은 "달록" 프로젝트입니다.
주로 코드를 분석하고 왜 사용하는지에 대해 적어보려고 합니다.
Backend만 다루려고 합니다.
출처
https://github.com/woowacourse-teams/2022-dallog
🔎 어떤 프로젝트일까?
1차 데모데이
https://www.youtube.com/watch?v=CpEPET2jXO4&list=PLgXGHBqgT2TsWUA5puZimG3DDlJTd370Q&index=82
[feat] 일정 등록 기능을 구현한다. #14
Commit명 - feat: ScheduleController 구현
ScheduleController
package com.allog.dallog.schedule.controller;
import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;
import com.allog.dallog.schedule.service.ScheduleService;
import java.net.URI;
import javax.validation.Valid;
import org.springframework.http.ResponseEntity;
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;
@RequestMapping("/api/schedules")
@RestController
public class ScheduleController {
private final ScheduleService scheduleService;
public ScheduleController(ScheduleService scheduleService) {
this.scheduleService = scheduleService;
}
@PostMapping
public ResponseEntity<Void> save(@Valid @RequestBody ScheduleCreateRequest request) {
Long id = scheduleService.save(request);
return ResponseEntity.created(URI.create("/api/schedules/" + id)).build();
}
}
코드 분석
return ResponseEntity.created(URI.create("/api/schedules/" + id)).build();
🔎 ResponseEntity.created란?
공식 문서에는 다음과 같이 나와있다.
Cretaed Status와 위치 헤더가 주어진 URI로 설정된 새 빌드를 만든다.
참고 문서
ScheduleCreateRequest
package com.allog.dallog.schedule.dto.request;
import com.allog.dallog.schedule.domain.Schedule;
import java.time.LocalDateTime;
import javax.validation.constraints.NotNull;
import org.springframework.format.annotation.DateTimeFormat;
public class ScheduleCreateRequest {
@NotNull
private String title;
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime startDateTime;
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime endDateTime;
@NotNull
private String memo;
private ScheduleCreateRequest() {
}
public ScheduleCreateRequest(final String title, final LocalDateTime startDateTime,
final LocalDateTime endDateTime, final String memo) {
this.title = title;
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
this.memo = memo;
}
public Schedule toEntity() {
return new Schedule(title, startDateTime, endDateTime, memo);
}
public String getTitle() {
return title;
}
public LocalDateTime getStartDateTime() {
return startDateTime;
}
public LocalDateTime getEndDateTime() {
return endDateTime;
}
코드 분석
private ScheduleCreateRequest() {
}
😗 기본 생성자를 private 해주는 이유
기본 생성자를 private으로 설정하면 외부에서 직접 객체를 생성할 수 없도록 강제할 수 있다. 이 경우, 외부에서는 반드시 매개변수를 받는 생성자를 사용하여 객체를 생성해야 한다. 필수 필드가 초기화되지 않은 상태로 객체가 만들어지는 것을 막을 수 있다.
Commit명 - ScheduleService 구현
Schedule Entity
package com.allog.dallog.schedule.domain;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name = "SCHEDULES")
public class Schedule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@Column(nullable = false)
private String title;
@Embedded
private Period period;
@Column(nullable = false)
private String memo;
protected Schedule() {
}
public Schedule(final String title, final LocalDateTime startDateTime,
final LocalDateTime endDateTime, final String memo) {
this.title = title;
this.period = new Period(startDateTime, endDateTime);
this.memo = memo;
}
public Long getId() {
return Id;
}
}
🔎 protected Schedule은 왜 해줄까?
JPA가 내부적으로 동작하는데 리플렉션이란 기술을 사용하고, JPA에서 리플렉션 기술을 사용하기 위해 기본 생성자가 필요하기 때문이다. public 보다는 제한적으로 오픈되는 protcted 가 조금 더 선호되는 편이다.
참고로 protected 생성자는 객체 생성 시 무분별한 접근을 방지하여 캡슐화를 강화한다. public으로 선언하면 모든 외부 코드가 객체를 생성할 수 있어 오용될 수 있으므로, protected를 통해 의도치 않은 객체 생성을 제한할 수 있다.
Period
package com.allog.dallog.schedule.domain;
import com.allog.dallog.schedule.exception.InvalidPeriodException;
import java.time.LocalDateTime;
import javax.persistence.Column;
import javax.persistence.Embeddable;
@Embeddable
public class Period {
@Column(nullable = false)
private LocalDateTime startDateTime;
@Column(nullable = false)
private LocalDateTime endDateTime;
protected Period() {
}
public Period(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
validate(startDateTime, endDateTime);
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
}
private void validate(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
if (startDateTime.isAfter(endDateTime)) {
throw new InvalidPeriodException("종료일시가 시작일시보다 이전일 수 없습니다.");
}
}
}
final 키워드를 붙여주는 것이 인상적이다.
Schedule
package com.allog.dallog.schedule.dto.request;
import com.allog.dallog.schedule.domain.Schedule;
import java.time.LocalDateTime;
public class ScheduleCreateRequest {
private String title;
private LocalDateTime startDateTime;
private LocalDateTime endDateTime;
private String memo;
public ScheduleCreateRequest(final String title, final LocalDateTime startDateTime,
final LocalDateTime endDateTime, final String memo) {
this.title = title;
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
this.memo = memo;
}
public Schedule toEntity() {
return new Schedule(title, startDateTime, endDateTime, memo);
}
public String getTitle() {
return title;
}
public LocalDateTime getStartDateTime() {
return startDateTime;
}
public LocalDateTime getEndDateTime() {
return endDateTime;
}
public String getMemo() {
return memo;
}
}
ScheduleRepository
package com.allog.dallog.schedule.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
}
InvalidPeriodException
package com.allog.dallog.schedule.exception;
public class InvalidPeriodException extends RuntimeException {
public InvalidPeriodException() {
super("잘못된 기간입니다.");
}
public InvalidPeriodException(final String message) {
super(message);
}
}
오버로딩을 통해서 두가지의 생성자를 선언해준다.
ScheduleService
package com.allog.dallog.schedule.service;
import com.allog.dallog.schedule.domain.Schedule;
import com.allog.dallog.schedule.domain.ScheduleRepository;
import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
@Service
public class ScheduleService {
private final ScheduleRepository scheduleRepository;
public ScheduleService(final ScheduleRepository scheduleRepository) {
this.scheduleRepository = scheduleRepository;
}
@Transactional
public Long save(final ScheduleCreateRequest request) {
Schedule schedule = scheduleRepository.save(request.toEntity());
return schedule.getId();
}
}
AcceptanceTest
package com.allog.dallog.acceptance;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
public class AcceptanceTest {
@LocalServerPort
int port;
@BeforeEach
public void setUp() {
RestAssured.port = port;
}
}
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT 옵션은 애플리케이션을 실제로 실행하여, 무작위 포트를 사용해 테스트가 진행되도록 한다. 이렇게 하면 여러 테스트 간의 포트 충돌을 방지할 수 있다.
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)는 매 테스트 메소드 실행 전후로 애플리케이션 컨텍스트를 초기화합니다. 각 테스트 메소드가 실행되기 전에 Spring 컨텍스트를 재설정하는 것을 의미합니다. 이는 테스트 간의 상호 간섭을 방지하는 데 유용합니다.
@LocalServerPort int port SpringBootTest가 애플리케이션을 무작위 포트에서 실행할 때, 그 포트 번호를 자동으로 주입한다.
RestAssured.port = port;
RestAssured가 API 요청을 보낼 때 사용할 포트를 지정하는 부분입니다. @LocalServerPort로 주입받은 무작위 포트를 RestAssured의 테스트 포트로 설정하여, 모든 API 테스트가 해당 포트에서 실행되는 애플리케이션을 대상으로 요청을 보낼 수 있도록 설정합니다.
RestAssured는 Java로 작성된 RESTful API 테스트 라이브러리입니다. REST API의 엔드포인트를 쉽게 테스트할 수 있도록 다양한 기능을 제공한다.
SchedulesAcceptanceTest
@DisplayName("일정 관련 기능")
public class SchedulesAcceptanceTest extends AcceptanceTest {
@DisplayName("정상적인 일정정보를 등록하면 상태코드 201을 반환한다.")
@Test
public void 정상적인_일정정보를_등록하면_상태코드_201을_반환한다() {
// given
Map<String, String> params = new HashMap<>();
params.put("title", "알록달록 회의");
params.put("startDateTime", "2022-07-04T13:00");
params.put("endDateTime", "2022-07-05T07:00");
params.put("memo", "알록달록 회의가 있어요");
// when
ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(params)
.when().post("/api/schedules")
.then().log().all()
.extract();
// then
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
}
}
AcceptanceTest를 상속받아 테스트 코드를 작성했다.
reponse를 받아오는 부분에 대해 편리하게 사용이 가능한 것 같다.
PeriodTest
class PeriodTest {
@DisplayName("종료일시가 시작일시 이전이라면 예외를 던진다.")
@Test
void 종료일시가_시작일시_이전이라면_예외를_던진다() {
// given
LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 0, 0);
LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 1, 0, 0);
// when & then
assertThatThrownBy(() -> new Period(startDateTime, endDateTime))
.isInstanceOf(InvalidPeriodException.class)
.hasMessage("종료일시가 시작일시보다 이전일 수 없습니다.");
}
}
Period에서 validate에 대한 유효성을 검증하기 위해 테스트 코드를 작성
private void validate(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
if (startDateTime.isAfter(endDateTime)) {
throw new InvalidPeriodException("종료일시가 시작일시보다 이전일 수 없습니다.");
}
}
ScheduleTest
public class ScheduleTest {
@DisplayName("일정을 생성한다.")
@Test
void 일정을_생성한다() {
// given
String title = "알록달록 회의";
LocalDateTime startDateTime = LocalDateTime.of(2022, 7, 5, 12, 30);
LocalDateTime endDateTime = LocalDateTime.of(2022, 7, 6, 14, 30);
String memo = "알록달록 팀회의 - 선릉 큰 강의실";
// when & then
Assertions.assertDoesNotThrow(() -> new Schedule(title, startDateTime, endDateTime, memo));
}
}
assertDoseNotThrow()
특정 코드가 예외를 발생시키지 않는지를 확인하는 데 사용되는 단언문입니다. 코드 블록을 람다 표현식으로 전달하고, 그 코드가 예외 없이 실행되면 테스트는 통과하며, 예외가 발생하면 테스트가 실패한다.
ScheduleServiceTest
@SpringBootTest
class ScheduleServiceTest {
@Autowired
private ScheduleService scheduleService;
@DisplayName("새로운 일정을 생성한다")
@Test
void 새로운_일정을_생성한다() {
// given
String title = "알록달록 회의";
LocalDateTime startDateTime = LocalDateTime.of(2022, 7, 5, 12, 30);
LocalDateTime endDateTime = LocalDateTime.of(2022, 7, 6, 14, 30);
String memo = "알록달록 팀회의 - 선릉 큰 강의실";
ScheduleCreateRequest scheduleCreateRequest = new ScheduleCreateRequest(title,
startDateTime, endDateTime, memo);
// when
Long id = scheduleService.save(scheduleCreateRequest);
// then
assertThat(id).isNotNull();
}
}
Request - 월별 일정 조회 기능을 구현한다.
ListReponse
public class ListResponse<T> {
private List<T> data;
private ListResponse() {
}
public ListResponse(List<T> data) {
this.data = data;
}
public List<T> getData() {
return data;
}
}
제너릭을 사용해서 데이터의 Type을 유연하게 가져가주었다.
ScheduleController
@RequestMapping("/api/schedules")
@RestController
public class ScheduleController {
private final ScheduleService scheduleService;
public ScheduleController(final ScheduleService scheduleService) {
this.scheduleService = scheduleService;
}
@PostMapping
public ResponseEntity<Void> save(@Valid @RequestBody final ScheduleCreateRequest request) {
Long id = scheduleService.save(request);
return ResponseEntity.created(URI.create("/api/schedules/" + id)).build();
}
@GetMapping
public ResponseEntity<ListResponse<ScheduleResponse>> findByYearAndMonth(
@RequestParam final int year, @RequestParam final int month) {
List<ScheduleResponse> responses = scheduleService.findByYearAndMonth(year, month);
return ResponseEntity.ok(new ListResponse<>(responses));
}
}
Period
@Embeddable
public class Period {
@Column(nullable = false)
private LocalDateTime startDateTime;
@Column(nullable = false)
private LocalDateTime endDateTime;
protected Period() {
}
public Period(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
validate(startDateTime, endDateTime);
this.startDateTime = startDateTime;
this.endDateTime = endDateTime;
}
private void validate(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {
if (startDateTime.isAfter(endDateTime)) {
throw new InvalidPeriodException("종료일시가 시작일시보다 이전일 수 없습니다.");
}
}
public LocalDateTime getStartDateTime() {
return startDateTime;
}
public LocalDateTime getEndDateTime() {
return endDateTime;
}
}
startDateTime이랑 endDateTime을 가져올 수 있는 get 메서드를 선언해주었다.
Schedule
@Entity
@Table(name = "SCHEDULES")
public class Schedule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
@Column(nullable = false)
private String title;
@Embedded
private Period period;
@Column(nullable = false)
private String memo;
protected Schedule() {
}
public Schedule(final String title, final LocalDateTime startDateTime,
final LocalDateTime endDateTime, final String memo) {
this.title = title;
this.period = new Period(startDateTime, endDateTime);
this.memo = memo;
}
public Long getId() {
return Id;
}
public String getTitle() {
return title;
}
public LocalDateTime getStartDateTime() {
return period.getStartDateTime();
}
public LocalDateTime getEndDateTime() {
return period.getEndDateTime();
}
public String getMemo() {
return memo;
}
}
period에 대한 title, getStartDateTime, getEndDateTime의 필드값을 가져올 수 있는 getter를 선언해준다.
ScheduleRepository
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
@Query("SELECT s "
+ "FROM Schedule s "
+ "WHERE s.period.startDateTime <= :endDate AND s.period.endDateTime >= :startDate")
List<Schedule> findByBetween(final LocalDateTime startDate, final LocalDateTime endDate);
}
@Query 어노테이션을 사용해 직접 쿼리를 작성해주었다.
findByBetween은 조회기간이 겹치는 모든 스케줄을 찾는 것이 목적이다.