[Spring] [면적면적[10]] 메인 화면 만들기
북적북적의 백엔드를 클론하고 있다.
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가 더 늦은 책이 먼저 나오는지 확인하는 테스트이다.
아주 잘된다.