궤도

[Spring] [면적면적[10]] 메인 화면 만들기 본문

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

[Spring] [면적면적[10]] 메인 화면 만들기

영이오 2021. 8. 2. 20:57

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

 

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 group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.6.RELEASE'
	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: 비번
cloud:
  aws:
    credentials:
      access-key: 엑세스 키
      secret-key: 시크릿 키
    s3:
      bucket: 버킷 이름
    region:
      static: 지역
    stack:
      auto: false
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
logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

요구사항 정리

 

메인 화면

[Request] {year, month, GET}

1. 입력값 유효성 확인

 

[Response] {bookInfo, character, HttpStatus.OK} / {HttpStatus.NO_CONTENT}


메인화면

 

처음에 쌓아보기와 리스트형 보기의 api를 분리해야 하나?라는 생각이 들었다.

근데 몇번 이것저것 해보니까...그냥 합치는게 맞을 것 같다. 둘의 정보 차이를 고려했을 때 성능면에선 합치는게 더 나을 것 같았기 때문이다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class HomeDto {

    private int size;
    private HeightInfo heightInfo;
    private List<Item> itemList = new ArrayList<>();

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @ToString
    public static class HeightInfo {
        private String name;
        private String img;
        private double totHeight;
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @ToString
    public static class Item {
        private String title;
        private Integer totPage;
        private int score;
        private String author;
        private String thumbnail;
    }
}

dto를 만들었다.

 

화면을 보면 알겠지만 사용자가 선택한 옵션에 따라 책의 높이와 그에 따른 캐릭터를 보여준다.

이걸 HeightInfo라는 이너 클래스에 넣었고, 책의 목록은 Item이란 이너 클래스에 넣는다.

 

@Slf4j
@RestController
@RequiredArgsConstructor
public class HomeController {

    private final HomeService homeService;

    @GetMapping("/")
    public ResponseEntity<?> home(@AuthenticationPrincipal User user,
                                  @RequestParam(required = false) Integer year,
                                  @RequestParam(required = false) Integer month) {
        log.info("[Request] home " + user.getEmail());
        HomeDto result = homeService.home(user, year, month);
        if (result.getSize() == 0)
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        return new ResponseEntity<>(homeService.home(user, year, month), HttpStatus.OK);
    }
}

컨트롤러 코드이다.

전체보기가 가능하기 때문에 required를 false로 한다.

검색조건에 걸리는 책이 없을 수도 있기 때문에 sizse 값을 체크한다.

 

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class HomeService {

    private final HomeMapper homeMapper;
    private final CharactersRepository charactersRepository;

    public HomeDto home(User user, Integer year, Integer month) {
        validateYear(year);
        validateMonth(month);
        List<Item> itemList = user.getBooks().stream()
                .sorted((o1, o2) -> o2.getEndDate().compareTo(o1.getEndDate()))
                .sorted(((o1, o2) -> o2.getStartDate().compareTo(o1.getStartDate())))
                .filter(o -> o.getBookStatus() == BookStatus.DONE)
                .filter(o -> year == null || o.getEndDate().getYear() == year)
                .filter(o -> month == null || o.getEndDate().getMonthValue() == month)
                .map(homeMapper.INSTANCE::toDto)
                .collect(Collectors.toList());

        return builder()
                .size(itemList.size())
                .heightInfo(getCharacters(calcHeight(itemList)))
                .itemList(itemList)
                .build();
    }

    private HeightInfo getCharacters(double height) {
        Characters result = charactersRepository.findFirstByHeightLessThanEqualOrderByHeightDesc(height);
        return HeightInfo.builder()
                .totHeight(height)
                .name(result.getName())
                .img(result.getImg())
                .build();
    }

    private double calcHeight(List<Item> itemList) {
        return itemList.stream()
                .filter(o -> o.getTotPage() != null)
                .mapToInt(Item::getTotPage).sum() * 0.005;
    }

    private void validateYear(Integer year) {
        if (year != null && (year < 2000 || year > LocalDate.now().getYear()))
            throw new InvalidReqParamException("year = " + year);
    }

    private void validateMonth(Integer month) {
        if (month != null && (month < 1 || month > 12))
            throw new InvalidReqParamException("month = " + month);
    }
}

코드가 개판인가 싶지만 서비스 코드...

 

    private void validateYear(Integer year) {
        if (year != null && (year < 2000 || year > LocalDate.now().getYear()))
            throw new InvalidReqParamException("year = " + year);
    }

    private void validateMonth(Integer month) {
        if (month != null && (month < 1 || month > 12))
            throw new InvalidReqParamException("month = " + month);
    }

일단 입력에 대한 validation을 한다.

 

        List<Item> itemList = user.getBooks().stream()
                .sorted((o1, o2) -> o2.getEndDate().compareTo(o1.getEndDate()))
                .sorted(((o1, o2) -> o2.getStartDate().compareTo(o1.getStartDate())))
                .filter(o -> o.getBookStatus() == BookStatus.DONE)
                .filter(o -> year == null || o.getEndDate().getYear() == year)
                .filter(o -> month == null || o.getEndDate().getMonthValue() == month)
                .map(homeMapper.INSTANCE::toDto)
                .collect(Collectors.toList());

sql 쿼리 날릴 수 있긴 한데, 양방향 매핑으로 저장해서 자바 코드로 처리한다.

사용자가 보유한 책 중에서 DONE만 빼오고, 쿼리 조건에 따라 추가 필터링을 한다.

 

그리고 정렬을 해야한다. 다 읽은 날짜가 최근인 기준으로 정렬하고 그 날짜가 같다면 읽기 시작한 날짜가 최근인 기준으로 정렬한다. 이 부분에서 문제가 좀 있었는데...

        List<Item> itemList = user.getBooks().stream()
                .sorted(Comparator
                        .comparing(Book::getEndDate).reversed()
                        .thenComparing(Book::getStartDate).reversed())
                .filter(o -> o.getBookStatus() == BookStatus.DONE)
                .filter(o -> year == null || o.getEndDate().getYear() == year)
                .filter(o -> month == null || o.getEndDate().getMonthValue() == month)
                .map(homeMapper.INSTANCE::toDto)
                .collect(Collectors.toList());

이 코드가 안먹혔다...날짜는 뭐가 좀 다른가 보다.

 

package jpa.myunjuk.module.mapper.bookshelf;

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

import static jpa.myunjuk.module.model.dto.HomeDto.*;

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

    Item toDto(Book book);
}

그리고 mapStruct 사용해서 dto로 변환했다.

 

이렇게 하면 dto 중에서 책 리스트가 완성된다.

이제 책 높이를 구하고 캐릭터를 구하면 된다.

 

    private double calcHeight(List<Item> itemList) {
        return itemList.stream()
                .filter(o -> o.getTotPage() != null)
                .mapToInt(Item::getTotPage).sum() * 0.005;
    }

User 엔티티에 비즈니스 로직으로 높이 구하기를 넣긴 했다.

근데, 지금 이상태에서 또 굳이 그 메소드를 쓰는건 모든 책 리스트를 2번 호출하는거라 이미 구한 itemList로 계산한다.

 

사실 지금 그 부분을 비즈니스 로직을 통한 리턴값이 아니라 칼럼으로 넣을까 고민중이다...아무래도 계산마다 책 리스트를 다 불러오는건 성능에 문제가 될 것 같아서

 

    private HeightInfo getCharacters(double height) {
        Characters result = charactersRepository.findFirstByHeightLessThanEqualOrderByHeightDesc(height);
        return HeightInfo.builder()
                .totHeight(height)
                .name(result.getName())
                .img(result.getImg())
                .build();
    }

아무튼 높이를 구했으면, 그 높이보다 키가 작은 캐릭터 중 가장 키가 큰 캐릭터를 불러온다. 그니까 lower bound 느낌

그걸 구하는 방법은 그냥 height보다 키가 작은 캐릭터 다 불러와서 키 내림차순으로 정렬하고 가장 첫번째 캐릭터 가져오면 된다.

 

그렇게 HeigthInfo도 채워준다.

 

        return builder()
                .size(itemList.size())
                .heightInfo(getCharacters(calcHeight(itemList)))
                .itemList(itemList)
                .build();

이렇게 만든 dto 데이터들 조각모음해서 하나로 합치고 리턴하면 끝

 

@SpringBootTest
@DisplayName("Home Service Test")
@Transactional
class HomeServiceTest {

    @Autowired
    UserRepository userRepository;

    @Autowired
    HomeService homeService;

    @Autowired
    SearchService searchService;

    @Test
    @DisplayName("Home Service | Success")
    void homeServiceSuccess() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        SearchReqDto searchReqDto1 = getSearchReqDto("first book", "1234567890 1234567890123", "done", "2021-07-26", 1000, 11);
        SearchReqDto searchReqDto2 = getSearchReqDto("second book", "1234567890 1234567890124", "done", "2021-07-27", 1000, 11);
        searchService.addSearchDetail(user, searchReqDto1);
        searchService.addSearchDetail(user, searchReqDto2);


        int count = (int) user.getBooks().stream()
                .filter(o -> o.getBookStatus() == BookStatus.DONE)
                .count();
        HomeDto result = homeService.home(user, null, null);
        List<Item> itemList = result.getItemList();
        assertEquals(count, result.getSize());
        assertEquals("second book", itemList.get(0).getTitle());
    }

    @Test
    @DisplayName("Home Service | Fail : Invalid Year")
    void homeServiceFailInvalidYear() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        InvalidReqParamException e = assertThrows(InvalidReqParamException.class, () ->
                homeService.home(user, 13, 1));
        assertEquals("year = 13", e.getMessage());
    }

    @Test
    @DisplayName("Home Service | Fail : Invalid Month")
    void homeServiceFailInvalidMonth() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        InvalidReqParamException e = assertThrows(InvalidReqParamException.class, () ->
                homeService.home(user, 2014, 13));
        assertEquals("month = 13", e.getMessage());
    }

    private SearchReqDto getSearchReqDto(String title, String isbn, String bookStatus, String endDate, Integer totPage, int readPage) {
        return SearchReqDto.builder()
                .title(title)
                .url("http://blahblah")
                .isbn(isbn)
                .bookStatus(bookStatus)
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse(endDate))
                .score(5)
                .totPage(totPage)
                .readPage(readPage)
                .build();
    }
}

테스트 코드 짰다.

 

    @Test
    @DisplayName("Home Service | Success")
    void homeServiceSuccess() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);

        SearchReqDto searchReqDto1 = getSearchReqDto("first book", "1234567890 1234567890123", "done", "2021-07-26", 1000, 11);
        SearchReqDto searchReqDto2 = getSearchReqDto("second book", "1234567890 1234567890124", "done", "2021-07-27", 1000, 11);
        searchService.addSearchDetail(user, searchReqDto1);
        searchService.addSearchDetail(user, searchReqDto2);


        int count = (int) user.getBooks().stream()
                .filter(o -> o.getBookStatus() == BookStatus.DONE)
                .count();
        HomeDto result = homeService.home(user, null, null);
        List<Item> itemList = result.getItemList();
        assertEquals(count, result.getSize());
        assertEquals("second book", itemList.get(0).getTitle());
    }

이것만 보자면 책을 2개 넣어보고, endDate가 더 늦은 책이 먼저 나오는지 확인하는 테스트이다.

 

 

아주 잘된다.

Comments