본문 바로가기
코드 분석

🌊 우테코 프로젝트 흡수하기 version 1 [프로젝트명 - 달록]

by sangyunpark99 2024. 9. 24.
우아한테크코스 프로젝트 코드 흡수하기 편입니다.
이번편은 "달록" 프로젝트입니다.
주로 코드를 분석하고 왜 사용하는지에 대해 적어보려고 합니다.
Backend만 다루려고 합니다.

 

출처

https://github.com/woowacourse-teams/2022-dallog

 

GitHub - woowacourse-teams/2022-dallog: 달력이 기록을 공유할 때, 달록 🌙

달력이 기록을 공유할 때, 달록 🌙. Contribute to woowacourse-teams/2022-dallog development by creating an account on GitHub.

github.com

 

🔎 어떤 프로젝트일까?

1차 데모데이

https://www.youtube.com/watch?v=CpEPET2jXO4&list=PLgXGHBqgT2TsWUA5puZimG3DDlJTd370Q&index=82

 

 

[feat] 일정 등록 기능을 구현한다. #14

https://github.com/woowacourse-teams/2022-dallog/pull/14/commits/522be70b9f2c12144891a7f43b6733b3a46ef50e

 

[feat] 일정 등록 기능을 구현한다. by devHudi · Pull Request #14 · woowacourse-teams/2022-dallog

💯 테스트는 잘 통과했나요? 🏗️ 빌드는 성공했나요? 🧹 불필요한 코드는 제거했나요? 💭 이슈는 등록했나요? 🏷️ 라벨은 등록했나요? 🌈 알록달록한가요? 작업 내용 일정 등록 기능을 구

github.com

 

 

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로 설정된 새 빌드를 만든다.

 

참고 문서

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ResponseEntity.html#created(java.net.URI)

 

ResponseEntity (Spring Framework 6.1.13 API)

Create a ResponseEntity with a body, headers, and a raw status code.

docs.spring.io

 

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 테스트가 해당 포트에서 실행되는 애플리케이션을 대상으로 요청을 보낼 수 있도록 설정합니다.

 

RestAssuredJava로 작성된 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은 조회기간이 겹치는 모든 스케줄을 찾는 것이 목적이다.