이번에는 Board 게시판을 추가하였다.
뿐만 아니라 이미지 업로드 기능도 추가하였다.
이후 성능최적화를 위하여 N+1문 제도 해결하였는데, 아래에 자세한 설명이 있다.
이번에 포스팅은 중요한 것 위주로 설명되어 있다.
SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers( "/v3/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// CSRF 설정 Disable
http.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
// exception handling 할 때 우리가 만든 클래스를 추가
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
.and()
.authorizeHttpRequests()
.antMatchers("/swagger-ui/**", "/v3/**").permitAll() // swagger
.antMatchers("/api/sign-up", "/api/sign-in", "/api/reissue").permitAll()
.antMatchers(HttpMethod.GET, "/api/members").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.PUT, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/members/{id}").hasAnyAuthority( "ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/messages").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/sender").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/sender/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/receiver").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/messages/receiver/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/messages/sender/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/messages/receiver/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/boards").authenticated()
.antMatchers(HttpMethod.GET, "/api/boards/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.GET, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.PUT, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.antMatchers(HttpMethod.DELETE, "/api/boards/{id}").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN")
.anyRequest().authenticated() // 나머지 API 는 전부 인증 필요
// JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
기능이 추가될수록 점점 길어지는데, 마지막 boards에 대한 접근이 추가되었다.
Board
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
@Lob
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member;
@OneToMany(mappedBy = "board",fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Image> images;
public Board(String title, String content, Member member, List<Image> images) {
this.title = title;
this.content = content;
this.member = member;
this.images = new ArrayList<>();
addImages(images);
}
public ImageUpdatedResult update(BoardUpdateRequestDto req) {
this.title = req.getTitle();
this.content = req.getContent();
ImageUpdatedResult result = findImageUpdatedResult(req.getAddedImages(), req.getDeletedImages());
addImages(result.getAddedImages());
deleteImages(result.getDeletedImages());
onPreUpdate();
return result;
}
private void addImages(List<Image> added) {
added.forEach(i -> {
images.add(i);
i.initBoard(this);
});
}
private void deleteImages(List<Image> deleted) {
deleted.forEach(di -> this.images.remove(di));
}
private ImageUpdatedResult findImageUpdatedResult(List<MultipartFile> addedImageFiles, List<Integer> deletedImageIds) {
List<Image> addedImages = convertImageFilesToImages(addedImageFiles);
List<Image> deletedImages = convertImageIdsToImages(deletedImageIds);
return new ImageUpdatedResult(addedImageFiles, addedImages, deletedImages);
}
private List<Image> convertImageIdsToImages(List<Integer> imageIds) {
return imageIds.stream()
.map(id -> convertImageIdToImage(id))
.filter(i -> i.isPresent())
.map(i -> i.get())
.collect(toList());
}
private Optional<Image> convertImageIdToImage(int id) {
return this.images.stream().filter(i -> i.getId() == (id)).findAny();
}
private List<Image> convertImageFilesToImages(List<MultipartFile> imageFiles) {
return imageFiles.stream().map(imageFile -> new Image(imageFile.getOriginalFilename())).collect(toList());
}
@Getter
@AllArgsConstructor
public static class ImageUpdatedResult {
private List<MultipartFile> addedImageFiles;
private List<Image> addedImages;
private List<Image> deletedImages;
}
}
Board와 멤버는 다대일 관계이므로 PK를 보드가 들고 있으며, LAZY지연 로딩으로 설정해 주었다.
LAZY를 걸어두어 실제 멤버객체를 사용할 때 select쿼리가 나가게 된다. -> 이는 아래에서 더 자세하게 다룬다.
뿐만 아니라 멤버가 삭제되면, 보드도 삭제되도록 OnDelete조건을 CASCADE로 주었다.
Board와 Image는 일대대 관계이므로 PK는 이미지에 넘겨주고, 여기서는 mappedBy를 사용하였다.
또한 고아객체를 걸어두어, 해당 이미지가 제거되면 삭제된다.
단순히 mappedBy(읽기 전용)로 연결되어 있어서, 실제 images에 값을 안 넣어줘도 될 것 같지만, 2가지 문제가 생길 수 있기 때문에 객체 지향적으로 값을 넣어주었다.
만약 값을 넣어주는 메서드가 없다면?
1. 한 트랜잭션 안에서 추가 및 조회가 이루어진다면, 보드에는 이미지가 없고 디비에만 업데이트되어 있을 것이다.
우리가 board.getImages(~~)를 통해 찾게 된다면, 영속성콘텍스트의 1차 캐시에서 데이터를 가져오기 때문에,
값이 없다고 나올 것이다.
2. 테스트 코드 작성 시에 어려움이 있다.
따라서 실제 addImages라는 메서드를 반드시 선언해주어야 한다.
Image
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Image extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "image_id")
private Long id;
@Column(nullable = false)
private String uniqueName;
@Column(nullable = false)
private String originName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id",nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Board board;
private final static String[] supportedExtension = {"jpg", "jpeg", "gif", "bmp", "png"};
public Image(String originName) {
this.originName = originName;
this.uniqueName = generateUniqueName(extractExtension(originName));
}
public void initBoard(Board board) {
if(this.board == null) {
this.board = board;
}
}
private String generateUniqueName(String extension) {
return UUID.randomUUID().toString() + "." + extension;
}
private String extractExtension(String originName) {
try {
String ext = originName.substring(originName.lastIndexOf(".") + 1);
if(isSupportedFormat(ext)) return ext;
} catch (StringIndexOutOfBoundsException e) { }
throw new UnsupportedImageFormatException();
}
private boolean isSupportedFormat(String ext) {
return Arrays.stream(supportedExtension).anyMatch(e -> e.equalsIgnoreCase(ext));
}
}
이미지의 입장에서 보드는 다대일이므로 Pk값을 가지고 있다.
업로드된 파일의 마지막 이 jpg, jpeg, gif, bmp, png로 끝나지 않는다면, 예외를 발생시켰다.
BoardController
@Api(value = "Board Controller", tags = "Board")
@RequiredArgsConstructor
@Slf4j
@RestController
@RequestMapping("/api")
public class BoardController {
private final MemberRepository memberRepository;
private final BoardService boardService;
@ApiOperation(value = "게시글 생성", notes = "게시글을 작성합니다.")
@PostMapping("/boards")
@ResponseStatus(HttpStatus.CREATED)
public Response createBoard(@Valid @ModelAttribute BoardCreateRequestDto req) {
boardService.createBoard(req, getPrincipal());
return Response.success();
}
@ApiOperation(value = "게시글 전체 조회" , notes = "게시글 전체를 조회합니다.")
@GetMapping("/boards")
@ResponseStatus(HttpStatus.OK)
public Response findAllBoards(@RequestParam(defaultValue = "0") Integer page){
return Response.success(boardService.findAllBoards(page));
}
@ApiOperation(value = "게시글 단건 조회" , notes = "게시글을 단건 조회합니다.")
@GetMapping("/boards/{id}")
@ResponseStatus(HttpStatus.OK)
public Response findBoard(@ApiParam(value = "게시글 id", required = true) @PathVariable Long id){
return Response.success(boardService.findBoard(id));
}
@ApiOperation(value = "게시글 수정", notes = "게시글을 수정합니다.")
@PutMapping("/boards/{id}")
@ResponseStatus(HttpStatus.OK)
public Response editBoard(@ApiParam(value = "게시글 id", required = true) @PathVariable Long id,
@Valid @ModelAttribute BoardUpdateRequestDto req) {
Member member = getPrincipal();
boardService.editBoard(id, req, member);
return Response.success();
}
@ApiOperation(value = "게시글 삭제" ,notes = "게시글을 삭제합니다.")
@DeleteMapping("/boards/{id}")
@ResponseStatus(HttpStatus.OK)
public Response deleteBoard(@ApiParam(value = "게시글 id", readOnly = true) @PathVariable Long id){
boardService.deleteBoard(id, getPrincipal());
return Response.success();
}
@ApiOperation(value = "게시글 검색" , notes = "게시글을 검색합니다.")
@GetMapping("/boards/search/{keyword}")
@ResponseStatus(HttpStatus.OK)
public Response searchBoard(@PathVariable String keyword,@RequestParam(defaultValue = "0") Integer page){
return Response.success(boardService.searchBoard(keyword, page));
}
public Member getPrincipal(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return memberRepository.findByUsername(authentication.getName())
.orElseThrow(MemberNotFoundException::new);
}
}
게시글 전체조회와 게시글 검색의 경우 여러 개의 보드정보를 가져오기 때문에, 페이징처리를 해줄 것이다.
단순히 페이지를 넘기고 자세한 로직은 서비스에서 이루어진다.
이미지 데이터를 받아야 하므로 @ModelAttribute를 선언해 주었다.
BoardService
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final FileService fileService;
@Transactional
public void createBoard(BoardCreateRequestDto req, Member member){
List<Image> images = req.getImages().stream()
.map(i -> new Image(i.getOriginalFilename()))
.collect(toList());
Board board = new Board(req.getTitle(), req.getContent(), member, images);
boardRepository.save(board);
uploadImages(board.getImages(), req.getImages());
}
@Transactional(readOnly = true)
public BoardFindAllWithPagingResponseDto findAllBoards(Integer page){
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("id").descending());
Page<Board> boards = boardRepository.findAll(pageRequest);
List<BoardFindAllResponseDto> boardsWithDto = boards.stream().map(BoardFindAllResponseDto::toDto)
.collect(toList());
return BoardFindAllWithPagingResponseDto.toDto(boardsWithDto, new PageInfoDto(boards));
}
@Transactional(readOnly = true)
public BoardFindResponseDto findBoard(Long id){
Board board = boardRepository.findById(id).orElseThrow(BoardNotFoundException::new);
Member member = board.getMember();
return BoardFindResponseDto.toDto(member.getUsername(), board);
}
@Transactional
public void editBoard(Long id, BoardUpdateRequestDto req, Member member) {
Board board = boardRepository.findById(id).orElseThrow(BoardNotFoundException::new);
validateBoardWriter(board,member);
Board.ImageUpdatedResult result = board.update(req);
uploadImages(result.getAddedImages(), result.getAddedImageFiles());
deleteImages(result.getDeletedImages());
}
@Transactional
public void deleteBoard(Long id, Member member){
Board board = boardRepository.findById(id).orElseThrow(BoardNotFoundException::new);
validateBoardWriter(board, member);
deleteImages(board.getImages());
boardRepository.delete(board);
}
@Transactional(readOnly = true)
public BoardFindAllWithPagingResponseDto searchBoard(String keyword,Integer page){
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("id").descending());
Page<Board> boards = boardRepository.findAllByTitleContaining(keyword,pageRequest);
List<BoardFindAllResponseDto> boardsWithDto = boards.stream().map(BoardFindAllResponseDto::toDto)
.collect(toList());
return BoardFindAllWithPagingResponseDto.toDto(boardsWithDto, new PageInfoDto(boards));
}
private void validateBoardWriter(Board board, Member member){
if (!member.equals(board.getMember())){
throw new MemberNotEqualsException();
}
}
private void uploadImages(List<Image> images, List<MultipartFile> fileImages) {
IntStream.range(0, images.size())
.forEach(i -> fileService.upload(fileImages.get(i), images.get(i).getUniqueName()));
}
private void deleteImages(List<Image> images) {
images.forEach(i -> fileService.delete(i.getUniqueName()));
}
}
BoardCreateRequestDto의 경우 제목, 내용, 이미지 파일이 포함되어 있다.
이후 이미지의 이름을 가지고 이미지 리스트를 만들어서 게시판을 생성해 준다.
uploadImages는 실제 이미지를 보관하는 곳에 저장하는 메서드이다.
전체 게시판 조회의 경우 페이징처리를 해주었다.
Page정보들을 보기 편하도록 PageInfoDto를 씌워서 넘기도록 하였다.
BoardFindAllWithPagingResponseDto의 경우 실제 전체 조회할 경우 보이는 게시판들의 정보들과 페이징 정보들이 담겨있다.
수정과 삭제의 경우 해당 유저가 게시판을 작성한 사람이 맞는지 검사하는 로직이 들어있다.
글의 삭제는 해당 작성자만 삭제할 수 있다. 이 부분은 나중에 어드민의 경우 삭제할 수 있는 로직을 추가해야 할 것 같다.
BoardUpdateRequestDto의 경우 제목, 내용, 추가된 이미지, 삭제할 이미지의 번호들이 담겨있다.
검색도 마찬가지로 페이징 처리를 해주었다.
BoardRepository
public interface BoardRepository extends JpaRepository<Board,Long> {
Page<Board> findAll(Pageable pageable);
Page<Board> findAllByTitleContaining(String keyword, Pageable pageable);
}
둘 다 페이징을 처리해야 하므로 Pageable을 받고 있다.
LocalFileService
@Service
@Slf4j
public class LocalFileService implements FileService{
private String location = "/Users/kimtaesoo/Desktop/image/";
@PostConstruct
void postConstruct() {
File dir = new File(location);
if (!dir.exists()) {
dir.mkdir();
}
}
@Override
public void upload(MultipartFile file, String filename) {
try {
file.transferTo(new File(location + filename));
} catch(IOException e) {
throw new FileUploadFailureException(e);
}
}
@Override
public void delete(String filename) {
new File(location + filename).delete();
}
}
우선 로컬서버에 저장할 곳을 지정해 주었다.
만약 나중에 배포를 한다면 배포용 클래스를 따로 만들어주어야 한다.
FileService인터페이스를 만든 이유도 이러한 이유 때문이다.
WebConfig
@EnableWebMvc
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final MessageSource messageSource;
private String location = "/Users/kimtaesoo/Desktop/image/";
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/image/**")
.addResourceLocations("file:" + location)
.setCacheControl(CacheControl.maxAge(Duration.ofHours(1L)).cachePublic());
}
@Override
public Validator getValidator() {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource);
return bean;
}
}
WebConfig는 이미지가 저장될 로컬 저장소 주소를 넣어주었다.
캐시설정을 해주어서 1시간 이내에 재요청이 오면 캐시를 통해 받게 된다.
이제 테스트 코드를 작성해 보자.
BoardController
@ExtendWith(MockitoExtension.class)
class BoardControllerTest {
@InjectMocks
BoardController boardController;
@Mock
MemberRepository memberRepository;
@Mock
BoardService boardService;
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
public void beforeEach(){
mockMvc = MockMvcBuilders.standaloneSetup(boardController).build();
}
@Test
public void 게시글생성_테스트() throws Exception{
//given
List<MultipartFile> images = new ArrayList<>();
images.add(new MockMultipartFile("test1","test1.png", MediaType.IMAGE_PNG_VALUE,"test1".getBytes()));
images.add(new MockMultipartFile("test2","test2.png", MediaType.IMAGE_PNG_VALUE,"test2".getBytes()));
BoardCreateRequestDto req = new BoardCreateRequestDto("title", "content", images);
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when,then
mockMvc.perform(
multipart("/api/boards")
.file("images", images.get(0).getBytes())
.file("images", images.get(1).getBytes())
.param("title", req.getTitle())
.param("content", req.getContent())
.with(requestBoardProcessor -> {
requestBoardProcessor.setMethod("POST");
return requestBoardProcessor;
})
.contentType(MediaType.MULTIPART_FORM_DATA))
.andExpect(status().isCreated());
}
@Test
public void 게시글전체조회_테스트()throws Exception{
//given
Integer page = 0;
//when
mockMvc.perform(
get("/api/boards"))
.andExpect(status().isOk());
//then
verify(boardService).findAllBoards(page);
}
@Test
public void 게시글단건조회_테스트() throws Exception{
//given
Long id = 1L;
//when
mockMvc.perform(
get("/api/boards/{id}",id))
.andExpect(status().isOk());
//then
verify(boardService).findBoard(id);
}
@Test
public void 게시글수정_테스트() throws Exception{
//given
Long id =1L;
List<MultipartFile> addImages = new ArrayList<>();
addImages.add(new MockMultipartFile("test1","test1.png",MediaType.IMAGE_PNG_VALUE,"test1".getBytes()));
List<Integer> deletedImages = List.of(1);
BoardUpdateRequestDto req = new BoardUpdateRequestDto("title", "content", addImages, deletedImages);
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when, then
mockMvc.perform(
multipart("/api/boards/{id}", id)
.file("addedImags", addImages.get(0).getBytes())
.param("deletedImages", String.valueOf(deletedImages.get(0)))
.param("title", req.getTitle())
.param("content", req.getContent())
.with(requestBoardProcessor -> {
requestBoardProcessor.setMethod("PUT");
return requestBoardProcessor;
})
.contentType(MediaType.MULTIPART_FORM_DATA)
).andExpect(status().isOk());
}
@Test
public void 게시글삭제_테스트() throws Exception{
//given
Long id = 1L;
Member member = createMember();
Authentication authentication = new UsernamePasswordAuthenticationToken(member.getId(), "", Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
given(memberRepository.findByUsername(authentication.getName())).willReturn(Optional.of(member));
//when
mockMvc.perform(
delete("/api/boards/{id}", id)
).andExpect(status().isOk());
//then
verify(boardService).deleteBoard(id,member);
}
@Test
public void 게시글검색_테스트() throws Exception{
//given
String keyword = "title";
Integer page = 0;
//when
mockMvc.perform(
get("/api/boards/search/{keyword}", keyword)
).andExpect(status().isOk());
//then
verify(boardService).searchBoard(keyword, page);
}
}
BoardService
@ExtendWith(MockitoExtension.class)
class BoardServiceTest {
@InjectMocks
BoardService boardService;
@Mock
BoardRepository boardRepository;
@Mock
FileService fileService;
@Test
public void 게시판생성_테스트() throws Exception{
//given
BoardCreateRequestDto req = new BoardCreateRequestDto("title", "content", List.of(
new MockMultipartFile("test", "test.png", MediaType.IMAGE_PNG_VALUE, "test".getBytes())
));
Member member = createMember();
//when
boardService.createBoard(req,member);
//then
verify(boardRepository).save(any());
}
@Test
public void 게시글전체조회_테스트() throws Exception{
//given
Integer page = 1;
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("id").descending());
List<Board> boards = new ArrayList<>();
boards.add(createBoard());
Page<Board> boardsWithPaging = new PageImpl<>(boards);
given(boardRepository.findAll(pageRequest)).willReturn(boardsWithPaging);
//when
BoardFindAllWithPagingResponseDto result = boardService.findAllBoards(page);
//then
assertThat(result.getBoards().size()).isEqualTo(1);
}
@Test
public void 게시글단건조회_테스트() throws Exception{
//given
Long id = 1L;
Board board = createBoard();
given(boardRepository.findById(id)).willReturn(Optional.of(board));
//when
BoardFindResponseDto result = boardService.findBoard(id);
//then
assertThat(result.getTitle()).isEqualTo("title");
}
@Test
public void 게시글수정_테스트() throws Exception{
//given
Long id = 1l;
BoardUpdateRequestDto req = new BoardUpdateRequestDto("title","content",List.of(
new MockMultipartFile("test", "test.png", MediaType.IMAGE_PNG_VALUE, "test".getBytes())
),List.of());
Member member = createMember();
Board board = new Board("t","c",member,List.of(new Image("a.png")));
given(boardRepository.findById(id)).willReturn(Optional.of(board));
//when
boardService.editBoard(id,req,member);
//then
assertThat(board.getTitle()).isEqualTo("title");
}
@Test
public void 게시글삭제_테스트() throws Exception{
//given
Long id = 1l;
Board board = createBoard();
Member member = board.getMember();
given(boardRepository.findById(id)).willReturn(Optional.of(board));
//when
boardService.deleteBoard(id,member);
//then
verify(boardRepository).delete(any());
}
@Test
public void 게시글검색_테스트() throws Exception{
//given
String keyword = "title";
Integer page = 1;
PageRequest pageRequest = PageRequest.of(page, 10, Sort.by("id").descending());
List<Board> boards = new ArrayList<>();
boards.add(createBoard());
Page<Board> boardsWithPaging = new PageImpl<>(boards);
given(boardRepository.findAllByTitleContaining(keyword,pageRequest)).willReturn(boardsWithPaging);
//when
BoardFindAllWithPagingResponseDto result = boardService.searchBoard(keyword,page);
//then
assertThat(result.getBoards().size()).isEqualTo(1);
}
}
테스트도 잘 작동하는 것을 볼 수 있다.
이제 포스트맨을 이용하여 확인해 보자.
우선 게시판을 생성해 보자.
이미지 파일을 담고 있기 때문에 form-data에 정보를 입력하였다.
게시판 전체 조회를 해보자.
입력한 3개의 데이터가 잘 반환된다.
쿼리를 보면 select가 2번 나가게 된다.
이유는 Lazy설정에 있다.
게시판의 정보에 member객체는 프록시 객체를 가지고 있기 때문에, 실제 사용될 때 실제 멤버를 조회하게 된다.
따라서 이러한 N+1문제를 해결하기 위해
다음과 같이 코드를 변경해야 한다.
public interface BoardRepository extends JpaRepository<Board,Long> {
@Query(value = "select b from Board b join fetch b.member m"
, countQuery = "select count(b) from Board b order by b.id desc ")
Page<Board> findAll(Pageable pageable);
Page<Board> findAllByTitleContaining(String keyword, Pageable pageable);
}
이러면 쿼리가 1번만 나가게 된다.
다음으로 단건조회를 해보자.
단건조회도 잘 작동한다.
이미지도 확인해 보자.
캐시의 경우에도 우리가 설정한 대로 잘 나오게 된다.
수정을 테스트해 보자.
기존의 강아지사진이었던 id3이 사라지고, 다른 사진으로 바뀌었다.
제목검색과 원하는 페이지는 쿼리 파라미터로 받는다.
이것 또한 쿼리를 보면 2번 나가게 된다.
이것 또한 아까와 같은 상황이다.
이것도 바꾸어주자.
public interface BoardRepository extends JpaRepository<Board,Long> {
@Query(value = "select b from Board b join fetch b.member m"
, countQuery = "select count(b) from Board b order by b.id desc ")
Page<Board> findAll(Pageable pageable);
@Query(value = "select b from Board b join fetch b.member m where b.title like '%title%'"
, countQuery = "select count(b) from Board b order by b.id desc")
Page<Board> findAllByTitleContaining(@Param("title") String keyword, Pageable pageable);
}
한 번에 조회하게 된다.
마지막으로 게시판 삭제를 해보자.
3번이 잘 삭제되었다.
실제 우리가 지정한 저장소에도 잘 저장된 모습이다.
스웨거에 추가된 상황을 보자.
자세한 코드는 아래 깃허브에서 볼 수 있다.
https://github.com/kimtaesoo99/community
'프로젝트 > 커뮤니티' 카테고리의 다른 글
[프로젝트] 커뮤니티 REST API 서버만들기 #6 Comment API만들기 (0) | 2023.01.25 |
---|---|
[프로젝트] 커뮤니티 REST API 서버만들기 #5 게시판 부가기능 추가 (0) | 2023.01.24 |
[프로젝트] 커뮤니티 REST API 서버만들기 #3 - Message API 만들기 (6) | 2023.01.21 |
[프로젝트]커뮤니티 REST API 서버만들기 #2 - Member API 만들기 (2) | 2023.01.20 |
[프로젝트] 커뮤니티 REST API 서버 만들기 #1 로그인기능 구현 + Swagger (2) | 2023.01.18 |