궤도

[Spring] [면적면적[7]] 나의 서재 책 수정, 삭제하기 본문

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

[Spring] [면적면적[7]] 나의 서재 책 수정, 삭제하기

영이오 2021. 7. 30. 18:09

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

 

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] {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);
    }

간단한 테스트

 

앞서 작성한 테스트들과 함께 돌렸다.

Comments