궤도
[Spring] [면적면적[7]] 나의 서재 책 수정, 삭제하기 본문
북적북적의 백엔드를 클론하고 있다.
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] {bookId, item, PUT}
1. 입력값 유효성 확인
2. 변화한 책 상태에 따라서 캐릭터 추가 또는 제거
[Response] {character, HttpStatus.OK} / {HttpStatus.NO_CONTENT}
책 삭제
[Request] {bookId, DELETE}
1. 입력값 유효성 확인
2. 삭제된 책의 상태에 따라서 캐릭터 제거
책 상태 수정
이 부분에 대해서 고민이 많았다. 앞서 책 상태에 따라서 dto를 다르게 해서 줬는데 수정 request를 받을 때에도 따로따로 해야하나? 싶다가도 분기 처리나 validation이 너무 어려울 것 같아서, 그냥 책을 추가할 떄와 똑같이 하기로 했다.
프론트를 해본 친구에게 물어봤는데 그냥 프론트에서 default 값을 설정하고 빈 부분은 채우면 된다고 한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class BookshelfUpdateReqDto{
@NotNull
@Enum(enumClass = BookStatus.class, ignoreCase = true)
private String bookStatus;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
@PastOrPresent
private LocalDate startDate;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
@PastOrPresent
private LocalDate endDate;
@NotNull
@Min(value = 0)
@Max(value = 10)
private Integer score;
@NotNull
@PositiveOrZero
private Integer readPage;
private String expectation;
}
그래서 이런 dto를 만들었다. 책을 추가할 때 쓰던 dto와 거의 유사한데 좀 다르다.
/**
* 책 상태 수정
* localhost:8080/42
*
* @param user
* @param bookId
* @param bookshelfUpdateReqDto
* @return ResponseEntity
*/
@PutMapping("/{bookId}")
public ResponseEntity<?> bookshelf(@AuthenticationPrincipal User user,
@PathVariable Long bookId,
@Valid @RequestBody BookshelfUpdateReqDto bookshelfUpdateReqDto) {
log.info("[Request] Update book " + bookId);
AddSearchDetailResDto result = bookshelfService.bookshelfUpdate(user, bookId, bookshelfUpdateReqDto);
if (result == null)
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(result, HttpStatus.OK);
}
컨트롤러의 코드이다. PathVariable로 수정할 책의 id를 받아오고, 아까만든 Dto를 RequestBody로 받는다.
책을 추가할 때와 마찬가지로 책을 수정할 떄도 새로운 캐릭터가 추가될 수 있다.
읽고 있는 중/읽고 싶은 책이 읽은 책이 될 떄이다.
한편 책을 추가할 때와 달리 책을 수정할 때에는 기존 캐릭터가 삭제될 수 있다.
읽은 책이 읽고 있는 중/읽고 싶은 책이 될 때이다.
전자의 경우 새로 추가된 캐릭터를 프론트에 알려줘야 하지만 후자의 경우 굳이 알려줄 필요가 없다.
그래서 새로 알려줄 캐릭터가 있는지 없는지에 따라 HttpStatus를 달리한다.
/**
* bookshelfUpdate
*
* @param user
* @param id
* @param req
* @return AddSearchDetailResDto
*/
@Transactional
public AddSearchDetailResDto bookshelfUpdate(User user, Long id, BookshelfUpdateReqDto req) {
Book book = commonService.getBook(user, id);
commonService.validateReadPage(req.getReadPage(), book.getTotPage());
commonService.validateDate(req.getStartDate(), req.getEndDate());
BookStatus before = book.getBookStatus(), after = BookStatus.from(req.getBookStatus());
book.updateBookStatus(BookStatus.from(req.getBookStatus()), req.getStartDate(), req.getEndDate(),
req.getScore(), req.getReadPage(), req.getExpectation());
bookRepository.save(book);
//책 상태의 변화에 따라 캐릭터를 추가 or 삭제
if (before == BookStatus.DONE && after != BookStatus.DONE)
charactersService.removeCharacters(user);
if (before != BookStatus.DONE && after == BookStatus.DONE)
return charactersMapper.toDto(charactersService.addNewCharacters(user));
return null;
}
서비스 부분의 코드이다. 먼저 RequestBody에서 하지못한 validation을 추가로 한다.
public Book getBook(User user, Long id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new NoSuchDataException("Book id = " + id));
checkUser(user, book);
return book;
}
public void checkUser(User user, Book book) {
if (!book.getUser().equals(user))
throw new AccessDeniedException("Book id = " + book.getId());
}
먼저 사용자가 요청한 책이 실제로 있는지 getBook으로 확인하고,
만약 실제한다면 이 책이 이번에 요청을 보낸 사용자의 책이 맞는지 확인한다.
public void validateReadPage(int readPage, Integer totPage) {
if (totPage != null && readPage > totPage)
throw new InvalidReqBodyException("page = " + readPage + " > " + totPage);
}
public void validateDate(LocalDate start, LocalDate end) {
if (end.isBefore(start))
throw new InvalidReqBodyException("date = " + start + " < " + end);
}
그리고 책을 추가할 때와 마찬가지로 쪽수와 날짜에 대한 validation도 한다.
BookStatus before = book.getBookStatus(), after = BookStatus.from(req.getBookStatus());
book.updateBookStatus(BookStatus.from(req.getBookStatus()), req.getStartDate(), req.getEndDate(),
req.getScore(), req.getReadPage(), req.getExpectation());
bookRepository.save(book);
//책 상태의 변화에 따라 캐릭터를 추가 or 삭제
if (before == BookStatus.DONE && after != BookStatus.DONE)
charactersService.removeCharacters(user);
if (before != BookStatus.DONE && after == BookStatus.DONE)
return charactersMapper.toDto(charactersService.addNewCharacters(user));
return null;
다시 돌아와서...validation 끝났으면 before에 사용자가 요청한 책의 현재 상태를 담고 after에 dto에 담긴 책의 상태를 담는다. 이따가 비교해야 하기 때문이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {
@Id
@Column(name = "book_sn")
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_sn")
@NotNull
private User user;
@NotNull
private String title;
@Column(length = 300)
private String thumbnail;
private String author;
private String publisher;
@Lob
private String description;
@NotNull
private String isbn;
private Integer totPage;
@Column(length = 300)
@NotNull
private String url;
@Enumerated(EnumType.STRING)
private BookStatus bookStatus;
private LocalDate startDate;
private LocalDate endDate;
private Integer score;
private Integer readPage;
@Lob
private String expectation;
public void updateBookStatus(BookStatus bookStatus, LocalDate startDate, LocalDate endDate,
Integer score, Integer readPage, String expectation){
this.bookStatus = bookStatus;
this.startDate = startDate;
this.endDate = endDate;
this.score = score;
this.readPage = readPage;
this.expectation = expectation;
}
}
setter를 닫아놨기 때문에 Book 엔티티에 책 수정을 위한 비즈니스 로직을 추가한다.
그리고 save를 하면 dirty checking으로 알아서 수정된다.
//책 상태의 변화에 따라 캐릭터를 추가 or 삭제
if (before == BookStatus.DONE && after != BookStatus.DONE)
charactersService.removeCharacters(user);
if (before != BookStatus.DONE && after == BookStatus.DONE)
return charactersMapper.toDto(charactersService.addNewCharacters(user));
return null;
마지막으로 책의 변화한 상태에 따라서 캐릭터를 추가하거나 삭제한다.
만약 before가 DONE인데 after가 그렇지 않다면 사용자의 전체 책 높이가 낮아지니 캐릭터가 빠질 수 있다.
만약 before가 DONE이 아닌데 after가 DONE이라면 사용자의 전체 책 높이가 높아니지 캐릭터가 더해질 수 있다.
후자의 경우엔 책 검색 후 추가하기에서 만든 addNewCharacters 메소드를 이용하고,
전자를 위해 removeCharacters 메소드를 만들어야 한다.
/**
* removeCharacters
*
* @param user
*/
public void removeCharacters(User user) {
double height = user.bookHeight();
List<UserCharacter> list = user.getUserCharacters().stream()
.filter(o -> o.getCharacters().getHeight() > height)
.collect(Collectors.toList());
if (list.isEmpty())
return;
user.getUserCharacters().removeAll(list);
userCharacterRepository.deleteAll(list);
}
먼저 사용자의 책 높이를 새로 계산하고,
User 엔티티에 양방향 매핑으로 유저가 보유한 캐릭터도 넣어놓았으니 그 리스트에서 사용자의 책 높이보다 키가 큰 캐릭터를 모아준다.
그 캐릭터들을 삭제하기 전에 User에서 먼저 캐릭터들을 삭제해준다. 그리고 그 후에 UserCharacter 엔티티에서 삭제해야 한다. UserCharacter 엔티티에서 먼저 삭제하려고 하면 User에 그 데이터들이 있어서 삭제가 안된다.
@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("Update book | Success : to Done")
void updateBookSuccessToDone() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
Book book = bookRepository.findById(72L).orElse(null);
assertNotNull(book);
assertNotNull(user);
int size = user.getUserCharacters().size();
BookshelfUpdateReqDto req = BookshelfUpdateReqDto.builder()
.bookStatus("done")
.startDate(LocalDate.parse("2021-07-20"))
.endDate(LocalDate.parse("2021-07-23"))
.score(10)
.readPage(1)
.build();
assertEquals(BookStatus.WISH, book.getBookStatus());
bookshelfService.bookshelfUpdate(user, 72L, req);
assertEquals(BookStatus.DONE, book.getBookStatus());
assertTrue(user.getUserCharacters().size() > size);
}
@Test
@DisplayName("Update book | Success : from Done")
void updateBookSuccessFromDone() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
Book book = bookRepository.findById(67L).orElse(null);
assertNotNull(book);
assertNotNull(user);
int size = user.getUserCharacters().size();
System.out.println("user.bookHeight() = " + user.bookHeight());
BookshelfUpdateReqDto req = BookshelfUpdateReqDto.builder()
.bookStatus("wish")
.startDate(LocalDate.parse("2021-07-20"))
.endDate(LocalDate.parse("2021-07-23"))
.score(10)
.readPage(1)
.build();
assertEquals(BookStatus.DONE, book.getBookStatus());
bookshelfService.bookshelfUpdate(user, 67L, req);
assertTrue(user.getUserCharacters().size() < size);
}
}
테스트 코드이다. 쪽수나 날짜에 대한 validation은 이전에 책 추가하기에서 테스트 코드를 작성했으니 할 필요가 없고, bookId에 대한 유효성은 해당 로직을 사용하는 다른 코드에서 validation을 해서 없다. 블로그에 아직 글을 안썼을 뿐 예외처리에 대한 validation은 다 있다.
아무튼 첫번째 경우는 done이 아닌 책이 done으로 update되어 캐릭터가 추가되는 경우이다.
그리고 두번째 경우는 done인 책이 다른 상태로 update되어 캐릭터가 삭제되는 경우이다.
귀찮으니 postman은 뺀다. 근데 해봤을 때 오류 없었다.
책 삭제
사실 수정을 했으면 삭제는 별것도 아니다. 그냥 id만 받으면 되고 삭제된 책이 DONE인 경우에만 캐릭터 삭제를 확인하면 되는데 캐릭터 삭제 로직도 이미 수정때 했으니까 쉽게 할 수 있다.
/**
* 책 삭제
* localhost:8080/42
*
* @param user
* @param bookId
* @return ResponseEntity
*/
@DeleteMapping("/{bookId}")
public ResponseEntity<?> bookshelf(@AuthenticationPrincipal User user,
@PathVariable Long bookId){
log.info("[Request] Delete book " + bookId);
bookshelfService.bookshelfDelete(user, bookId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
컨트롤러
/**
* bookshelfDelete
*
* @param user
* @param id
*/
@Transactional
public void bookshelfDelete(User user, Long id) {
Book book = commonService.getBook(user, id);
BookStatus bookStatus = book.getBookStatus();
user.getBooks().remove(book);
bookRepository.delete(book);
if (bookStatus == BookStatus.DONE) //삭제된 책이 읽은 책이라면
charactersService.removeCharacters(user);
}
서비스
그냥 수정때처럼 bookId에 대한 validation을 하고 양방향 매핑이니 user에서 먼저 책을 삭제한 뒤, 완전히 삭제하고 삭제된 책이 '읽은 책'이었다면 아까 만든 removeCharacters 메소드를 호출하면 된다.
@Test
@DisplayName("Delete book | Success")
void deleteBookSuccess() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
Book book = bookRepository.findById(67L).orElse(null);
assertNotNull(book);
assertNotNull(user);
int size = user.getBooks().size();
bookshelfService.bookshelfDelete(user, 67L);
assertNull(bookRepository.findById(67L).orElse(null));
assertTrue(user.getBooks().size() < size);
}
간단한 테스트
앞서 작성한 테스트들과 함께 돌렸다.