궤도
[Spring] [면적면적[8]] 책에 메모 CRUD 하기 본문
북적북적의 백엔드를 클론하고 있다.
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] {Memo, POST}
1. 입력값 유효성 확인
[Response] {HttpStatus.NO_CONTENT}
메모 보기
[Request] {bookId, GET}
1. 입력값 유효성 확인
[Response] {List<Memo>, HttpStatus.OK} / {HttpStatus.NO_CONTENT}
메모 수정
[Request] {memoId, Memo, PUT}
1. 입력값 유효성 확인
[Response] {HttpStatus.NO_CONTENT}
메모 삭제
[Request] {memoId, DELETE}
1. 입력값 유효성 확인
[Response] {HttpStatus.NO_CONTENT}
메모 추가
저기 사진을 보면 알겠지만 메모엔 별게 없다. 해당 메모가 소속된 책의 id와 내용 그리고 저장날짜 정도가 필요한데, 저장 날짜는 그냥 now로 하면 되니까 책의 id와 내용만 필요하다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@NotNull
public static class BookshelfMemoReqDto {
private Long id;
private String content;
}
dto를 만들고
/**
* 메모 추가하기
* localhost:8080/bookshelf/memo
*
* @param user
* @param bookshelfMemoReqDto
* @return ResponseEntity
*/
@PostMapping("")
public ResponseEntity<?> bookshelfMemo(@AuthenticationPrincipal User user,
@Valid @RequestBody BookshelfMemoReqDto bookshelfMemoReqDto) {
log.info("[Request] Add memo book id = " + bookshelfMemoReqDto.getId());
bookshelfMemoService.bookshelfAddMemo(user, bookshelfMemoReqDto);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
이건 컨트롤러이다. 메모를 추가해도 별 일 안생기니까 그냥 NO_CONTENT로 보낸다.
/**
* bookshelfAddMemo
*
* @param user
* @param bookshelfMemoReqDto
* @return Memo
*/
@Transactional
public Memo bookshelfAddMemo(User user, BookshelfDetailDtos.BookshelfMemoReqDto bookshelfMemoReqDto) {
Book book = commonService.getBook(user, bookshelfMemoReqDto.getId());
return memoRepository.save(Memo.builder()
.book(book)
.content(bookshelfMemoReqDto.getContent())
.saved(LocalDateTime.now())
.build());
}
일전에 쓴 책이랑 유저에 대한 validation을 하는 getBook 메소드를 그대로 쓰고, 그냥 바로 빌더에 넣어서 저장한다.
이번 글은 별 내용이 없으니 테스트 코드는 마지막에 한번에 보여주도록 하겠다.
메모 보기
메모의 id와 내용 그리고 저장날짜를 보내주면 되겠다. 근데 최근에 저장한 메모부터 보내줘야 한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class BookshelfMemoResDto {
private Long id;
private String content;
private LocalDateTime saved;
}
그래서 이건 response로 보내줄 메모 리스트의 dto고
/**
* 책의 메모
* localhost:8080/bookshelf/memo?bookId=42
*
* @param user
* @param bookId
* @return ResponseEntity
*/
@GetMapping("")
public ResponseEntity<?> bookshelfRetrieveMemo(@AuthenticationPrincipal User user,
@RequestParam Long bookId) {
log.info("[Request] Retrieve all Memos in book id = " + bookId);
List<BookshelfMemoResDto> result = bookshelfMemoService.bookshelfMemo(user, bookId);
if (result.isEmpty())
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(result, HttpStatus.OK);
}
컨트롤러 코드
요청한 책에 대한 메모가 없을 수도 있으니 NO_CONTENT도 가능하다.
/**
* bookshelfMemo
*
* @param user
* @param id
* @return List<BookshelfMemoResDto>
*/
public List<BookshelfMemoResDto> bookshelfMemo(User user, Long id) {
Book book = commonService.getBook(user, id);
return memoRepository.findAllByBookOrderBySavedDesc(book).stream()
.map(bookshelfMapper.INSTANCE::memoToBookshelfMemoResDto)
.collect(Collectors.toList());
}
서비스 코드
마찬가지로 요청에 대한 validation을 getBook 메소드로 하고 뭐...메모 레포지토리에서 book에 대한 메모를 최신 저장 순으로 다 불러온 뒤, MapStruct 통해서 dto에 넣고 반환하면 된다.
메모 수정
메모를 수정하면 저장날짜도 바뀌나? 했는데 바뀌지 않는다. 그냥 내용만 수정하면 된다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@NotNull
public static class BookshelfUpdateMemoReqDto {
private String content;
}
겨우 1개지만 뭐...dto를 만들고(사실 이정도는 그냥 Map으로 해도 되지 않을까?)
/**
* 메모 수정하기
* localhost:8080/bookshelf/memo/233
*
* @param user
* @param memoId
* @param bookshelfUpdateMemoReqDto
* @return ResponseEntity
*/
@PutMapping("/{memoId}")
public ResponseEntity<?> bookshelfMemo(@AuthenticationPrincipal User user,
@PathVariable Long memoId,
@Valid @RequestBody BookshelfUpdateMemoReqDto bookshelfUpdateMemoReqDto) {
log.info("[Request] Update memo Memo id = " + memoId);
bookshelfMemoService.bookshelfUpdateMemo(user, memoId, bookshelfUpdateMemoReqDto);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
컨트롤러 코드
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Memo {
@Id
@Column(name = "memo_sn")
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_sn")
@NotNull
private Book book;
@Lob
@NotNull
private String content;
@NotNull
private LocalDateTime saved;
public void updateMemo(String content){
this.content = content;
}
}
메모 엔티티에도 비즈니스 로직 넣어주고
/**
* bookshelfUpdateMemo
*
* @param user
* @param id
* @param bookshelfUpdateMemoReqDto
*/
@Transactional
public void bookshelfUpdateMemo(User user, Long id, BookshelfUpdateMemoReqDto bookshelfUpdateMemoReqDto) {
Memo memo = memoRepository.findById(id)
.orElseThrow(() -> new NoSuchDataException("Memo id = " + id));
commonService.checkUser(user, memo.getBook());
memo.updateMemo(bookshelfUpdateMemoReqDto.getContent());
memoRepository.save(memo);
}
서비스 코드이다.
먼저 요청받은 메모 id에 대한 validation을 진행한다.
해당 id의 메모가 실제하는지 확인한뒤, memo.getBook().getUser()로 흘러가서 나온 user가 요청을 준 user와 동일한지 확인한다.
그리고 뭐 update하면 끝이다.
메모 삭제하기
이건 할게 더 없다.
/**
* 메모 삭제하기
* localhost:8080/bookshelf/memo/233
*
* @param user
* @param memoId
* @return ResponseEntity
*/
@DeleteMapping("/{memoId}")
public ResponseEntity<?> bookshelfMemo(@AuthenticationPrincipal User user,
@PathVariable Long memoId) {
log.info("[Request] Delete memo Memo id = " + memoId);
bookshelfMemoService.bookshelfDeleteMemo(user, memoId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
컨트롤러
/**
* bookshelfDeleteMemo
*
* @param user
* @param id
*/
@Transactional
public void bookshelfDeleteMemo(User user, Long id) {
Memo memo = memoRepository.findById(id)
.orElseThrow(() -> new NoSuchDataException("Memo id = " + id));
commonService.checkUser(user, memo.getBook());
memoRepository.delete(memo);
}
서비스
테스트 코드
package jpa.myunjuk.module.service;
import jpa.myunjuk.module.model.domain.Memo;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.dto.bookshelf.BookshelfDetailDtos;
import jpa.myunjuk.module.repository.BookRepository;
import jpa.myunjuk.module.repository.UserCharacterRepository;
import jpa.myunjuk.module.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest
@DisplayName("Bookshelf Memo Service Test")
@Transactional
public class BookshelfMemoServiceTest {
@Autowired
BookshelfMemoService bookshelfMemoService;
@Autowired
SearchService searchService;
@Autowired
UserRepository userRepository;
@Autowired
BookRepository bookRepository;
@Autowired
UserCharacterRepository userCharacterRepository;
@Test
@DisplayName("Retrieve all Memos | Success")
void allMemosSuccess() throws InterruptedException {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
BookshelfDetailDtos.BookshelfMemoReqDto req = getBookshelfMemoReqDto("hello hello hello this is memo.");
bookshelfMemoService.bookshelfAddMemo(user, req);
Thread.sleep(2000);
BookshelfDetailDtos.BookshelfMemoReqDto req2 = getBookshelfMemoReqDto("hello hello hello this is memo2.");
bookshelfMemoService.bookshelfAddMemo(user, req2);
List<BookshelfDetailDtos.BookshelfMemoResDto> memos = bookshelfMemoService.bookshelfMemo(user, 42L);
assertEquals("hello hello hello this is memo2.", memos.get(0).getContent());
}
@Test
@DisplayName("Add Memo | Success")
void addMemoSuccess() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
BookshelfDetailDtos.BookshelfMemoReqDto req = getBookshelfMemoReqDto("hello hello hello this is memo.");
Memo memo = bookshelfMemoService.bookshelfAddMemo(user, req);
assertEquals("hello hello hello this is memo.", memo.getContent());
assertEquals(42L, memo.getBook().getId());
assertEquals(user, memo.getBook().getUser());
assertEquals(0, Duration.between(memo.getSaved(), LocalDateTime.now()).getSeconds());
}
@Test
@DisplayName("Update Memo | Success")
void updateMemoSuccess() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
BookshelfDetailDtos.BookshelfMemoReqDto req = getBookshelfMemoReqDto("hello hello hello this is memo.");
Memo memo = bookshelfMemoService.bookshelfAddMemo(user, req);
bookshelfMemoService.bookshelfUpdateMemo(user, memo.getId(),
BookshelfDetailDtos.BookshelfUpdateMemoReqDto.builder()
.content("Updated Memo")
.build());
assertEquals("Updated Memo", memo.getContent());
}
@Test
@DisplayName("Delete Memo | Success")
void deleteMemoSuccess() {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
BookshelfDetailDtos.BookshelfMemoReqDto req = getBookshelfMemoReqDto("hello hello hello this is memo.");
Memo memo = bookshelfMemoService.bookshelfAddMemo(user, req);
int size = bookshelfMemoService.bookshelfMemo(user, 42L).size();
bookshelfMemoService.bookshelfDeleteMemo(user, memo.getId());
assertEquals(size - 1, bookshelfMemoService.bookshelfMemo(user, 42L).size());
}
private BookshelfDetailDtos.BookshelfMemoReqDto getBookshelfMemoReqDto(String content) {
return BookshelfDetailDtos.BookshelfMemoReqDto.builder()
.id(42L)
.content(content)
.build();
}
}
전체 메모 crud에 대한 테스트 코드다.
@Test
@DisplayName("Retrieve all Memos | Success")
void allMemosSuccess() throws InterruptedException {
User user = userRepository.findByEmail("test@test.com").orElse(null);
assertNotNull(user);
BookshelfDetailDtos.BookshelfMemoReqDto req = getBookshelfMemoReqDto("hello hello hello this is memo.");
bookshelfMemoService.bookshelfAddMemo(user, req);
Thread.sleep(2000);
BookshelfDetailDtos.BookshelfMemoReqDto req2 = getBookshelfMemoReqDto("hello hello hello this is memo2.");
bookshelfMemoService.bookshelfAddMemo(user, req2);
List<BookshelfDetailDtos.BookshelfMemoResDto> memos = bookshelfMemoService.bookshelfMemo(user, 42L);
assertEquals("hello hello hello this is memo2.", memos.get(0).getContent());
}
다 그냥 대충 읽어보면 알만한 코드지만 하나만 설명하면
이건 메모를 2개 저장하고 최신순으로 잘 나오는지 확인하는 코드다.
이제보니 존재하지 않는 메모 id로 request해서 실패하는 코드가 없긴 하구만
포스트맨은 귀찮아서 생략하는데 잘됐었답니다