궤도

[Spring] [면적면적[6]] 나의 서재 책 리스트 가져오기 (각기 다른 dto를 한 response에 넘기기 + MapStruct) 본문

💻 현생/📕 면적면적(스프링 실습)

[Spring] [면적면적[6]] 나의 서재 책 리스트 가져오기 (각기 다른 dto를 한 response에 넘기기 + MapStruct)

영이오 2021. 7. 30. 16:32

북적북적의 백엔드를 클론하고 있다.

 

Github

 

build.gradle

plugins {
	id 'org.springframework.boot' version '2.5.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	all {
		exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-log4j2'
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation 'org.mapstruct:mapstruct:1.4.2.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
	implementation 'org.projectlombok:lombok:1.18.18'
	runtimeOnly 'mysql:mysql-connector-java'
	implementation 'com.googlecode.json-simple:json-simple:1.1.1'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

test {
	useJUnitPlatform()
}

 

 

 

src/main/resources/application.yml

aladin:
  url: http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?ttbkey=비밀키&itemIdType=ISBN13&output=js&Version=20131101&ItemId={isbn}
naver:
  id: 아이디
  secret: 비번
secret:
  key: 비번
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3305/데베?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password: 비번
  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        format_sql: true
        show-sql: true
#logging:
#  level:
#    org:
#      hibernate:
#        type:
#          descriptor:
#            sql: trace
server:
  servlet:
    encoding:
      force-response: true

요구사항 정리

 

나의 서재 열람

[Request] {bookStatus, GET}

1. 입력값 유효성 확인

 

[Response] {List<Book>, HttpStatus.OK} / {HttpStatus.NO_CONTENT}

 

원래 나의 서재 -> 책 상세보기를 합쳐서 하려고 했는데 양이 많아서 나눈다.

그리고 이제 글을 좀 대충쓰려고 한다.

혹시 이 글이 도움이 되는 사람이 있다면 깃허브 주소도 있으니까 자세한 path는 거기로 보고...주요한 로직만 대충 설명하려 한다. 난 이제 지쳤어요 땡벌땡벌


나의 서재 열람

 

일단 request로 받는 bookStatus는 nullable이어야 한다. null이면 전체 리스트를 반환하고, 있으면 거기에 해당하는 책들을 보내주면 되니까...

 

아무튼 상태 상관없이 공통으로 넘겨줘야 하는 정보는 {id, title, thumbnail, bookStatus}이다. 그리고 책의 상태에 따라서

'읽은 책' : {startDate, endDate, scroe}

'읽고 있는 책' : {startDate, readPage, totPage}

'읽고 싶은 책' : {totPage, score, expectation}

이다.

 

그래서 이 공통된 부분은

 

@Getter
@AllArgsConstructor
public abstract class BookshelfBasic {

    private Long id;
    private String title;
    private String author;
    private String thumbnail;
    private BookStatus bookStatus;
}

추상 클래스로 만들고

 

public class BookshelfResDtos {

    @Getter
    public static class DoneBook extends BookshelfBasic{

        private LocalDate startDate;
        private LocalDate endDate;
        private int score;

        @Builder
        public DoneBook(Long id, String title, String author, String thumbnail, BookStatus bookStatus,
                        LocalDate startDate, LocalDate endDate, int score) {
            super(id, title, author, thumbnail, bookStatus);
            this.startDate = startDate;
            this.endDate = endDate;
            this.score = score;
        }
    }

    @Getter
    public static class ReadingBook extends BookshelfBasic{

        private LocalDate startDate;
        private int readPage;
        private Integer totPage;

        @Builder
        public ReadingBook(Long id, String title, String author, String thumbnail, BookStatus bookStatus,
                           LocalDate startDate, int readPage, Integer totPage) {
            super(id, title, author, thumbnail, bookStatus);
            this.startDate = startDate;
            this.readPage = readPage;
            this.totPage = totPage;
        }
    }

    @Getter
    public static class WishBook extends BookshelfBasic{

        private Integer totPage;
        private int score;
        private String expectation;

        @Builder
        public WishBook(Long id, String title, String author, String thumbnail, BookStatus bookStatus,
                        Integer totPage, int score, String expectation) {
            super(id, title, author, thumbnail, bookStatus);
            this.totPage = totPage;
            this.score = score;
            this.expectation = expectation;
        }
    }
}

걔를 상속하는 클래스들을 만들었다.

다른 클래스를 상속받고 거기에 빌더를 적용하려면 부모클래스는 AllArgsConstructor를 열어두고, 자식 클래스에 이런 생성자를 만들면 된다고 한다.

 

    /**
     * 내 서재 조회
     * localhost:8080/bookshelf?bookStatus=done
     *
     * @param user
     * @param bookStatus
     * @return ResponseEntity
     */
    @GetMapping("")
    public ResponseEntity<?> bookshelf(@AuthenticationPrincipal User user,
                                       @RequestParam(required = false) String bookStatus) {
        log.info("[Request] Retrieve all books " + user.getEmail());
        List<Object> result = bookshelfService.bookshelf(user, bookStatus);
        if (result.isEmpty())
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        return new ResponseEntity<>(result, HttpStatus.OK);
    }

컨트롤러는 이렇게 만들었는데, 앞서 말했듯이 bookStatus가 null일 때 모든 책을 받아오기로 했으니 required를 false로 놓는다. 사용자의 검색 조건에 따라 해당하는 책 리스트가 없을 수도 있다. 그렇기 때문에 결과 리스트가 비어있다면, NO_CONTENT를 보내주고 아니면 결과와 함께 OK를 보낸다.

 

    /**
     * bookShelf
     *
     * @param user
     * @param bookStatus
     * @return List<Object>
     */
    public List<Object> bookshelf(User user, String bookStatus) {
        validateInput(bookStatus);

        List<Object> bookList = new ArrayList<>();
        user.getBooks().stream()
                .sorted(Comparator.comparing(Book::getId).reversed()) //가장 최근에 저장된 책부터 나오도록 정렬
                .filter(o -> bookStatus == null || o.getBookStatus() == BookStatus.from(bookStatus)) //검색 조건 필터링
                .forEach(o -> {
                    if (o.getBookStatus() == BookStatus.DONE)
                        bookList.add(bookshelfMapper.INSTANCE.bookToDoneBook(o));
                    if (o.getBookStatus() == BookStatus.READING)
                        bookList.add(bookshelfMapper.INSTANCE.bookToReadingBook(o));
                    if (o.getBookStatus() == BookStatus.WISH)
                        bookList.add(bookshelfMapper.INSTANCE.bookToWishBook(o));
                });
        return bookList;
    }

    private void validateInput(String bookStatus) { //enum 확인
        if (Arrays.stream(BookStatus.values())
                .noneMatch(o -> bookStatus == null || o.toString().equalsIgnoreCase(bookStatus)))
            throw new InvalidReqParamException("BookStatus = " + bookStatus);
    }

서비스 부분 코드이다. 먼저 input이 bookStatus가 맞는지 확인한다.

 

양방향 매핑으로 사용자에게도 책 리스트를 넣어놨으니, 거기서 빼오면 되고...저장된 책들을 최신순으로 가져오니 id를 기준으로 내림차순 정렬하면 된다. 그리고 bookStatus에 따라서 필터를 하고 그 status에 따라서 각기 다른 dto에 넣는데, 처음엔 빌더 코드를 직접 짜서 엔티티를 dto에 넣어줬다. 근데 이걸 자동으로 해주는 라이브러리가 있었다.

 

MapStruct 공식문서

 

아무튼 이 공식문서를 보면서

 

package jpa.myunjuk.module.mapper.bookshelf;

import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.Memo;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

import static jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.*;
import static jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.*;

@Mapper(componentModel = "spring")
public interface BookshelfMapper {
    BookshelfMapper INSTANCE = Mappers.getMapper(BookshelfMapper.class);

    DoneBook toDto(Book book);
    BookshelfInfoDto bookToBookshelfInfoDto(Book book);
    DoneBook bookToDoneBook(Book book);
    ReadingBook bookToReadingBook(Book book);
    WishBook bookToWishBook(Book book);
    BookshelfMemoResDto memoToBookshelfMemoResDto(Memo memo);
}

스프링 데이터 jpa를 사용할 때처럼 인터페이스에 이름만 선언하면 된다.

사실 DoneBook toDto(Book book);은 사용하지 않는데, 이게 없으면 null로 셋팅이 된다. 아무래도 메소드 이름을 인식하지 못하는 듯 싶다...사실 잘 모르겠지만 아무튼 저런게 있어야 잘 작동해서 넣어놨다.

 

잘 생성됐다면 빌드 후 generated 폴더에

package jpa.myunjuk.module.mapper.bookshelf;

import javax.annotation.processing.Generated;
import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.Memo;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfInfoDto;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfInfoDto.BookshelfInfoDtoBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfMemoResDto;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos.BookshelfMemoResDto.BookshelfMemoResDtoBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.DoneBook;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.DoneBook.DoneBookBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.ReadingBook;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.ReadingBook.ReadingBookBuilder;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.WishBook;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfResDtos.WishBook.WishBookBuilder;
import org.springframework.stereotype.Component;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2021-07-30T12:34:39+0900",
    comments = "version: 1.4.2.Final, compiler: javac, environment: Java 14.0.1 (Oracle Corporation)"
)
@Component
public class BookshelfMapperImpl implements BookshelfMapper {

    @Override
    public DoneBook toDto(Book book) {
        if ( book == null ) {
            return null;
        }

        DoneBookBuilder doneBook = DoneBook.builder();

        doneBook.id( book.getId() );
        doneBook.title( book.getTitle() );
        doneBook.author( book.getAuthor() );
        doneBook.thumbnail( book.getThumbnail() );
        doneBook.bookStatus( book.getBookStatus() );
        doneBook.startDate( book.getStartDate() );
        doneBook.endDate( book.getEndDate() );
        if ( book.getScore() != null ) {
            doneBook.score( book.getScore() );
        }

        return doneBook.build();
    }

    @Override
    public BookshelfInfoDto bookToBookshelfInfoDto(Book book) {
        if ( book == null ) {
            return null;
        }

        BookshelfInfoDtoBuilder bookshelfInfoDto = BookshelfInfoDto.builder();

        bookshelfInfoDto.id( book.getId() );
        bookshelfInfoDto.description( book.getDescription() );
        bookshelfInfoDto.publisher( book.getPublisher() );
        bookshelfInfoDto.isbn( book.getIsbn() );
        bookshelfInfoDto.totPage( book.getTotPage() );
        bookshelfInfoDto.url( book.getUrl() );

        return bookshelfInfoDto.build();
    }

    @Override
    public DoneBook bookToDoneBook(Book book) {
        if ( book == null ) {
            return null;
        }

        DoneBookBuilder doneBook = DoneBook.builder();

        doneBook.id( book.getId() );
        doneBook.title( book.getTitle() );
        doneBook.author( book.getAuthor() );
        doneBook.thumbnail( book.getThumbnail() );
        doneBook.bookStatus( book.getBookStatus() );
        doneBook.startDate( book.getStartDate() );
        doneBook.endDate( book.getEndDate() );
        if ( book.getScore() != null ) {
            doneBook.score( book.getScore() );
        }

        return doneBook.build();
    }

    @Override
    public ReadingBook bookToReadingBook(Book book) {
        if ( book == null ) {
            return null;
        }

        ReadingBookBuilder readingBook = ReadingBook.builder();

        readingBook.id( book.getId() );
        readingBook.title( book.getTitle() );
        readingBook.author( book.getAuthor() );
        readingBook.thumbnail( book.getThumbnail() );
        readingBook.bookStatus( book.getBookStatus() );
        readingBook.startDate( book.getStartDate() );
        if ( book.getReadPage() != null ) {
            readingBook.readPage( book.getReadPage() );
        }
        readingBook.totPage( book.getTotPage() );

        return readingBook.build();
    }

    @Override
    public WishBook bookToWishBook(Book book) {
        if ( book == null ) {
            return null;
        }

        WishBookBuilder wishBook = WishBook.builder();

        wishBook.id( book.getId() );
        wishBook.title( book.getTitle() );
        wishBook.author( book.getAuthor() );
        wishBook.thumbnail( book.getThumbnail() );
        wishBook.bookStatus( book.getBookStatus() );
        wishBook.totPage( book.getTotPage() );
        if ( book.getScore() != null ) {
            wishBook.score( book.getScore() );
        }
        wishBook.expectation( book.getExpectation() );

        return wishBook.build();
    }

    @Override
    public BookshelfMemoResDto memoToBookshelfMemoResDto(Memo memo) {
        if ( memo == null ) {
            return null;
        }

        BookshelfMemoResDtoBuilder bookshelfMemoResDto = BookshelfMemoResDto.builder();

        bookshelfMemoResDto.id( memo.getId() );
        bookshelfMemoResDto.content( memo.getContent() );
        bookshelfMemoResDto.saved( memo.getSaved() );

        return bookshelfMemoResDto.build();
    }
}

이런게 생긴다. 내가 직접만들 때에는 엔티티에 대한 null 체크도 해야했는데, 이건 null이면 자동으로 null로 넣어줘서 좋은 것 같다! dto->entity도 가능한데 아직 그건 안써봤다.

 

        List<Object> bookList = new ArrayList<>();
        user.getBooks().stream()
                .sorted(Comparator.comparing(Book::getId).reversed()) //가장 최근에 저장된 책부터 나오도록 정렬
                .filter(o -> bookStatus == null || o.getBookStatus() == BookStatus.from(bookStatus)) //검색 조건 필터링
                .forEach(o -> {
                    if (o.getBookStatus() == BookStatus.DONE)
                        bookList.add(bookshelfMapper.INSTANCE.bookToDoneBook(o));
                    if (o.getBookStatus() == BookStatus.READING)
                        bookList.add(bookshelfMapper.INSTANCE.bookToReadingBook(o));
                    if (o.getBookStatus() == BookStatus.WISH)
                        bookList.add(bookshelfMapper.INSTANCE.bookToWishBook(o));
                });
        return bookList;

아무튼 그래서 매퍼는 이런식으로 쓰는 것이고 최종적인 bookList에 어떤 dto가 들어올지 모르니까 Object로 선언했다.

 

@SpringBootTest
@DisplayName("Bookshelf Service Test")
@Transactional
class BookshelfServiceTest {

    @Autowired
    BookshelfService bookshelfService;
    @Autowired
    SearchService searchService;
    @Autowired
    UserRepository userRepository;
    @Autowired
    BookRepository bookRepository;
    @Autowired
    UserCharacterRepository userCharacterRepository;

    @Test
    @DisplayName("Retrieve all from Bookshelf | Success")
    void allBookshelfSuccess() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        assertEquals(user.getBooks().size(), bookshelfService.bookshelf(user, null).size());
    }


    @Test
    @DisplayName("Retrieve all from Bookshelf | Fail : Invalid Param")
    void allBookshelfFailInvalidParam() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        InvalidReqParamException e = assertThrows(InvalidReqParamException.class, () ->
                bookshelfService.bookshelf(user, "wrong"));

        assertEquals("BookStatus = wrong", e.getMessage());
    }
}

테스트는 간단하게 param에 대한 체크 정도만 했다. 여기서 더 코드를 짜기엔 너무 현재 테스트 유저 상태에 의존을 심하게 하는 듯하여,,,

 

 

전체 검색
읽은 책

 

읽고 있는 책

 

읽고 싶은 책

 

나름 쪽수와...장르나 이런 다양한걸 고려하다보니 별별 책이 들어갔다.

아무튼 내가 원하는 대로 책 상태에 따라 다른 dto에 들어가 있고, 최근에 저장한 책부터 나오는걸 볼 수 있다!

Comments