본문 바로가기
프로그래밍/프로젝트

7. 도메인과 조회 로직 다시 만들기

by 카프카뮈 2021. 2. 26.

이전 글에서, ERD 설계를 간단히 마무리하는 데까지 포스팅이 되었다.

이후 여러 이슈가 해결되었고 개발에 진전이 있었으나, 아쉽게도 바로바로 포스팅을 하지 못했다.

그래서 오늘은, Book(도서) 엔티티 모델을 어떻게 만들었고,

어떤 식으로 테스트와 검증을 수행하도록 설계했는지 정리하고자 한다.

 

신나는 코딩시간!! 요새는 코드짤 시간도 참 없다..

 


먼저 Book 클래스의 코드이다.

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @CreatedDate
    private LocalDateTime createdAt;

    @NotNull
    @ISBN
    private String isbn;

    @NotBlank
    private String name;

    @NotBlank
    private String content;

    @NotNull
    private LocalDateTime publishDate;

    @NotBlank
    private String publisher;

    @NotBlank
    private String author;

    private String translator;

    @NotNull
    @Positive
    private Long price;

    @NotNull
    @URL
    private String thumbnail;

    @NotNull
    @Positive
    private Long page;
}

이런식으로 짜는게 맞는지, 아직 확신은 없다.

그러나 이렇게 어노테이션을 떡칠한 이유는, 이전 Validation 관련 글에 포스팅한 적이 있다.

Entity 모델에 Validation 어노테이션을 달아 두면 DTO와 달리 @Valid로 검사하는 곳이 없어 검증이 이뤄지지 않을 것 같지만,

실제로는 Repository에서 save를 수행하는 등의 동작을 할 때, flush 전 타이밍에 검사를 수행한다.

 

만약 DTO에 대한 검증이 잘 되었더라도, 실수로 잘못된 값을 서비스 레이어에서 넣었다면

잘못된 값이 저장소에 들어가는 것을 막기는 어려울 것이다.

오류를 DB의 규칙으로 잡지 못할 가능성도 크고, 이에 대한 예외 코드를 일일이 서비스에 달기도 어렵다.

그렇기 때문에, 일단 어노테이션으로 최대한 검증을 수행하려고 한다.

(@NotNull을 사용하면 자동생성되는 DDL에도 not null 옵션이 붙지만,

장기적으로는 자동 생성을 쓰지 않을 생각이라 그부분은 고려하지 않았다.)

 

또 하나 재미있는 부분이라면, @URL과 @ISBN.

javax.validation에 소속된 어노테이션이 아닌, Hibernate.Validator 프로젝트에 포함되는 검증 어노테이션이다.

테스트 코드로 확인해 본 결과, 의도한 대로 수행되는 것을 확인할 수 있었다.


Validation에 대한 테스트를 어떻게 수행하는지 보여줄 겸, 도메인 테스트 코드 중 일부를 첨부한다.

@DisplayName("예외 테스트 : 잘못된 ISBN이 들어오면 예외가 발생한다.")
@Test
void invalidISBNTest() {
    Book book = Book.builder()
            .id(도서_ID_1)
            .createdAt(도서_생성일)
            .isbn(도서_잘못된_ISBN)
            .name(도서_이름_1)
            .content(도서_내용)
            .publishDate(도서_출간일)
            .publisher(도서_출판사)
            .author(도서_작가)
            .translator(도서_번역가)
            .price(도서_가격)
            .thumbnail(도서_썸네일)
            .page(도서_페이지)
            .build();

    Set<ConstraintViolation<Book>> violations = validator.validate(book);

    for (ConstraintViolation<Book> violation : violations) {
        logger.info(violation::getMessage);
    }
    assertThat(violations.isEmpty()).isFalse();
    assertThat(violations.toString()).contains("올바르지 않은 ISBN입니다");
}

도메인 테스트의 경우 이런 식으로 작성했다.

아직은 비즈니스 로직이랄 부분이 없어서, 각각의 어노테이션이 잘 작동하는지 다양한 테스트를 넣었다.

validation 테스트는 별도 검색으로 방법을 찾았는데, 올바르게 수행되는 것을 확인할 수 있었다.

참고 링크


컨트롤러와 그 테스트를 살펴보자.

컨트롤러의 코드는 다음과 같다.

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @GetMapping("/api/books")
    public ResponseEntity<List<BookResponseDto>> getBooks() {
        final List<BookResponseDto> responses = bookService.getBooks()
                .stream()
                .map(BookResponseDto::new)
                .collect(Collectors.toList());

        return ResponseEntity.ok()
                .body(responses);
    }

    @GetMapping("/api/books/{bookId}")
    public ResponseEntity<BookResponseDto> getBook(@PathVariable final Long bookId) {
        final BookResponseDto response = new BookResponseDto(bookService.getBook(bookId));

        return ResponseEntity.ok()
                .body(response);
    }
}

일단 조회 부분만 구현하고 이슈를 종료해서, 다른 코드는 없다.

책의 경우 사용자가 값을 넣어 직접 생성하는 것보다는,

ISBN을 입력하면 외부 서버에 나머지 정보를 요청해 받아오는 게 계획이라...

그 부분은 별도 이슈로 만들어서 구현할 예정.(지금 이 탓에 webFlux를 열심히 공부중이다)

 

하여튼, 컨트롤러에서 특별한 부분이라면 DTO 정도가 아닐까 싶다.

사실 뭐...토이 프로젝트에서 DTO를 서비스용과 컨트롤러용으로 나눌 필요가 있을까 고민을 많이 했다.

하지만 게시물까지 작성하며 고민해 본 결과, 추후 외부 API도 쓰는 입장에서

충분히 두 레이어의 원하는 값이 달라질 수 있다는 생각이 들었다.


컨트롤러의 테스트 코드는 다음과 같다.

@WebMvcTest
public class BookControllerTest {
    private static final String API = "/api";
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @MockBean
    private BookService bookService;

    private MockMvc mockMvc;

    @BeforeEach
    void setUp(WebApplicationContext context) {
        this.mockMvc = MockMvcBuilders
                .webAppContextSetup(context)
                .build();
    }

    @DisplayName("'/books'로 GET 요청 시, 도서의 목록을 반환한다.")
    @Test
    void getBooksTest() throws Exception {
        List<BookResponseServiceDto> bookResponses = BookGenerator.createBooks()
                .stream()
                .map(BookResponseServiceDto::new)
                .collect(Collectors.toList());
        when(bookService.getBooks()).thenReturn(bookResponses);

        this.mockMvc.perform(get(API + "/books")
                .accept(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].id").value(도서_ID_1))
                .andExpect(jsonPath("$[1].id").value(도서_ID_2))
                .andExpect(jsonPath("$[0].name").value(도서_이름_1))
                .andExpect(jsonPath("$[1].name").value(도서_이름_2));
    }

    @DisplayName("'/books/{id}'로 GET 요청 시, 해당 도서를 반환한다.")
    @Test
    void readBookTest() throws Exception {
        BookResponseServiceDto bookResponse = new BookResponseServiceDto(BookGenerator.createBook());
        when(bookService.getBook(도서_ID_1)).thenReturn(bookResponse);

        this.mockMvc.perform(get(API + "/books/" + 도서_ID_1)
                .accept(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(도서_ID_1))
                .andExpect(jsonPath("$.name").value(도서_이름_1));
    }

    @DisplayName("예외 테스트 : '/books/{id}'로 GET 요청 시, 해당 도서가 없다면 예외를 발생시킨다.")
    @Test
    public void readBookNotFoundException() throws Exception {
        when(bookService.getBook(도서_ID_1)).thenThrow(new BookNotFoundException(도서_ID_1));

        //then
        this.mockMvc.perform(get(API + "/books/" + 도서_ID_1)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("message").value(ErrorCode.ENTITY_NOT_FOUND.getMessage()))
                .andExpect(jsonPath("status").value(ErrorCode.ENTITY_NOT_FOUND.getStatus()))
                .andExpect(jsonPath("code").value(ErrorCode.ENTITY_NOT_FOUND.getCode()))
                .andExpect(jsonPath("errors").isEmpty());
    }
}

통합 테스트가 아닌 레이어 테스트인 만큼 Mock을 최대한 활용했다.

일반 테스트는 보기에 무난해 보일 듯 싶고, 눈에 띄는 부분이라면 예외 테스트가 아닐까.

컨트롤러에서 발생하는 예외를 핸들링하는 ControllerAdvice 클래스를 만들어 두었는데,

그 덕에 예외가 발생할 경우 그 값과 코드에 대해 쉽게 검증할 수 있었다.

(이 내용은 다음에 포스팅하겠다)

 

또한, 테스트를 쉽게 하기 위해 BookGenerator라는 클래스도 만들었다.

BookGenerator는 도서 생성에 필요한 상수값들과 도서 생성을 돕는 static 메서드를 가진다.

테스트할 때마다 똑같은 Book을 만드는 데에 코드를 길게 쓸 필요는 없으니까,

BookGenerator를 통해 간단한 생성을 수행하는 것이다.


마지막으로 Service 코드가 어떤 식으로 구현되었는지 보자.

@RequiredArgsConstructor
@Service
public class BookService {
    private final BookRepository bookRepository;

    @Transactional(readOnly = true)
    public List<BookResponseServiceDto> getBooks() {
        return bookRepository.findAll()
                .stream()
                .map(BookResponseServiceDto::new)
                .collect(Collectors.toList());
    }

    @Transactional(readOnly = true)
    public BookResponseServiceDto getBook(Long bookId) {
        final Book book = bookRepository.findById(bookId)
                .orElseThrow(() -> new BookNotFoundException(bookId));

        return new BookResponseServiceDto(book);
    }
}

간단한 코드. @Transactional(readOnly = true) 옵션을 주었다.

그 이유에 대해서는 백기선님이 코멘트한 내용이 있는데, 링크를 달아둔다.

 

도서 개별조회 기능의 경우, 조회하지 못하면 커스텀한 예외가 호출된다.

이에 대해 핸들링할 수 있도록 ControllerAdvice에 옵션을 추가해뒀다.


테스트 코드는 어떨까?

@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
    private BookService bookService;

    @Mock
    private BookRepository bookRepository;

    @BeforeEach
    void setUp() {
        bookService = new BookService(bookRepository);
    }

    @DisplayName("Book 전체 목록 조회 요청 시 올바르게 수행된다.")
    @Test
    void getBooksTest() {
        List<Book> books = BookGenerator.createBooks();
        when(bookRepository.findAll()).thenReturn(books);

        List<BookResponseServiceDto> foundBooks = bookService.getBooks();

        assertThat(foundBooks)
                .hasSize(2)
                .extracting("name")
                .containsOnly(도서_이름_1, 도서_이름_2);
    }

    @DisplayName("Book 개별 조회 요청 시 올바르게 수행된다.")
    @Test
    void getBookTest() {
        Book book = BookGenerator.createBook();
        when(bookRepository.findById(도서_ID_1)).thenReturn(Optional.of(book));

        BookResponseServiceDto foundBook = bookService.getBook(도서_ID_1);

        assertThat(foundBook.getId()).isEqualTo(도서_ID_1);
        assertThat(foundBook.getName()).isEqualTo(도서_이름_1);
        assertThat(foundBook.getIsbn()).isEqualTo(도서_ISBN);
    }

    @DisplayName("예외 테스트 : Book 개별 조회 요청 시 해당 도서가 없다면 예외가 발생한다.")
    @Test
    void getBookWithExceptionTest() {
        when(bookRepository.findById(도서_ID_1)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> bookService.getBook(도서_ID_1))
                .isInstanceOf(BookNotFoundException.class)
                .hasMessage(도서_ID_1 + "에 해당하는 도서를 찾을 수 없습니다.");
    }
}

이전 게시물과 비교하여 특별한 내용은 없다.

예외에 대한 검사를 위해, mock에서 Optional.empty()를 발생시킨 것 정도가 참고할 만하다.


오늘의 게시물은 특별한 내용을 전달하기보다, 스스로 진행상황을 정리하고 공유하는 데에 초점을 두었다.

앞으로 포스팅할 내용은 다음과 같다.

  • 예외처리를 위한 핸들러와 예외 구조 정리
  • Logback을 통해 로그 정리하고 파일로 모아두기
  • 통합 테스트 구현하기

앞으로 정리할 내용이 많다.

 

코드를 보며 이해가 가지 않거나, 잘못된 내용이 있다면 지적 부탁드립니다.

반응형

댓글