일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch7
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch9
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch13
- 자바의 정석 기초편 ch1
- 스프링 mvc2 - 타임리프
- jpa - 객체지향 쿼리 언어
- @Aspect
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch4
- jpa 활용2 - api 개발 고급
- 게시글 목록 api
- 스프링 db1 - 스프링과 문제 해결
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch14
- 타임리프 - 기본기능
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch2
- 자바의 정석 기초편 ch8
- 스프링 mvc1 - 스프링 mvc
- 스프링 입문(무료)
- 자바의 정석 기초편 ch3
- 자바의 정석 기초편 ch12
- 코드로 시작하는 자바 첫걸음
- 2024 정보처리기사 수제비 실기
- Today
- Total
나구리의 개발공부기록
게시글 CRUD API 수정 및 개발 완료, 테스트 진행과 최종 PR 본문
1. 게시글 CRUD 최종 작업
1) PR후 재개발
다른분에 개발한 게시글 상세조회 API와 합치고 PR에서의 지적사항이 돌아왔다
이미지의 저장로직과 삭제로직이 어디에 있는지에 대한 부분이였는데 이전글에서 판단했던 내용을 설명하였고 실제 이미지 저장소인 클라우드플레어에 이미지는 용량이 별로 크지 않으니 삭제하지 않는 방향으로 완전히 정해졌다
문제는 이미지 저장로직인데 개발할 당시에는 몰랐지만 프론트엔드 개발자와 소통을 해서 해결할 수도 있긴하지만 일반적으로 이렇게 String을 클라이언트에 전달하지않는다는 것이였다
실시간으로 클라이언트에서 바로 이미지를 DB에 저장하는 방식은 많이 사용하기도해서 이부분은 문제가 없으나, 만약 이렇게 할꺼면 이미지를 저장하고 이미지 URL을 반환하는 FileController와 FileService를 수정하고 별도의 API를 만들어서 반환값을 JSON으로 반환해야 한다는 이야기와 함께, 이런 경우 이미지가 업데이트 될 때 로딩 데이터를 전송한다던가하는 부분을 프론트엔드 개발자와 소통해서 개발해야 한다는 것이였다..
이런 부분을 직접 적용해보면 좋겠다는 생각은 했으나.. 지금 게시글 CRUD 개발하는 것만으로도 벅찼던 나에게는 또다른 대안인 서버에서 이미지를 저장하는 방식으로 변경하여 작업하기로 하였다.
즉, API가 두개가아니라 하나로 글을 저장함과 동시에 서버에서 이미지도 저장하여 반환하는 것으로 결정을하고 API를 수정하기위해 개발을 하였다
이부분을 해결하기 위해선 FileService에 작성된 uploadFile 메서드를 사용해야 했는데 여기는 매개변수로 MultipartFile을 받고 있어서 컨트롤러에서 클라이언트에서 정보를 받을 때 Json정보와 함께 이미지 데이터도 함께 받아야 했다
여기서 @RequestPart라는 애노테이션을 처음으로 알게 되었는데 간단하게 표현해보자면 전달받고자 하는 파라미터를 여러가지의 타입으로 편하게 받을 수 있는 애노테이션으로 파악하고 이부분을 컨트롤러에 적용하였다
그리고 이 과정에서 공통 반환 객체가 dev 브랜치에 머지가 되어서 전체적으로 모두가 개발하는 반환타입을 통일하도록 최종 픽스 되어서 dev 브렌치의 내용을 내가 작업하는 브렌치쪽으로 병합하여 발생되는 오류를 모두 수정하여 응답값을 공통 반환 객체로하고 발생되는 에러도 공통 에러로 처리하도록 수정하였다.
PostController
@RequireUser
@PostMapping
public ApiResponse<Long> createPost(
PrincipalUser principalUser,
@Valid @RequestPart("createPost") PostCreateUpdateRequestDTO requestDTO,
@RequestPart(value = "postImage", required = false) List<MultipartFile> imageList) {
Long createPostId = postService.createPost(requestDTO, principalUser, imageList);
return ApiResponse.created(createPostId);
}
@RequireUser
@PutMapping("/{postId}")
public ApiResponse<Long> updatePost(@PathVariable Long postId,
PrincipalUser principalUser,
@Valid @RequestPart("updatePost") PostCreateUpdateRequestDTO requestDTO,
@RequestPart(value = "postImage", required = false) List<MultipartFile> imageList) {
Long updatePostId = postService.updatePost(requestDTO, principalUser, postId, imageList);
return ApiResponse.ok(updatePostId);
}
이렇게 받은 정보로 서비스에서는 RDB에 저장하는 로직은 대부분 그대로 유지하고 매개변수로 넘어온 MultipartFile들을 하나씩 꺼내서 uploadFile메서드를 통해 클라우드 플레어에 저장하도록하고 반환되는 URL정보에서 UUID를 분리하여 RDB의 Image 테이블에 정보들을 저장하는 방식으로 구현하였다.
수정도 기존과 로직은 동일한 상태에서 위의 방식을 적용하여 수정 개발을 생각보다 금방 하였다
(3시간 30분 정도..? 맨땅에서 처음부터 생각하고 지금까지 개발을 해왔던 시간을 생각해보면 나름대로 금방 했다고 생각했다..)
PostServese - createPost
/**
* 게시글 작성
*/
@RequireUser
@Transactional
public Long createPost(PostCreateUpdateRequestDTO postCreateRequestDTO, PrincipalUser principalUser, List<MultipartFile> imageList) {
// 유저 정보 가져오기
User user = principalUser.getUser();
// 카테고리 확인
Category category = categoryRepository.findById(postCreateRequestDTO.getCategoryId())
.orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다. ID: " + postCreateRequestDTO.getCategoryId()));
// 게시글 생성 및 저장
Post post = getPost(postCreateRequestDTO, user, category);
postRepository.save(post);
// 이미지 생성 및 저장
for (MultipartFile image : imageList) {
try {
String imageUrl = fileService.uploadFile(image);
if ((postCreateRequestDTO.getImagelist() != null) && !postCreateRequestDTO.getImagelist().isEmpty()) {
validateImageCount(postCreateRequestDTO.getImagelist()); // 5개 이상이면 예외 발생
saveImage(postCreateRequestDTO, user, post, imageUrl);
}
} catch (IOException e) {
throw new FileException("파일 업로드에 실패 하였습니다");
}
}
return post.getId();
}
private void saveImage(PostCreateUpdateRequestDTO postCreateRequestDTO, User user, Post post, String imageUrl) {
String fileName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1);
for (ImageCreateRequestDTO getImage : postCreateRequestDTO.getImagelist()) {
Image image = Image.builder()
.user(user)
.fileName(fileName)
.imageUrl(imageUrl)
.originalName(getImage.getOriginalFileName())
.fileType(getImage.getFileType())
.fileSize(getImage.getFileSize())
.deletedAt(null)
.build();
imageRepository.save(image);
post.addCommunityImage(CommunityImage
.builder()
.image(image)
.build());
}
}
PostServese - updatePost
/**
* 게시글 업데이트
*/
@RequireUser
@Transactional
public Long updatePost(PostCreateUpdateRequestDTO postUpdateRequestDTO, PrincipalUser principalUser, Long postId, List<MultipartFile> imageList) {
// 유저 정보 가져오기
User user = principalUser.getUser();
// 게시글 조회, 없으면 예외
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostException.PostNotFoundException("ID: " + postId));
// 수정 권한 확인
if (!user.getUserId().equals(post.getUser().getUserId())) {
throw new PostException.PostAccessDeniedException("게시글을 수정할 권한이 없습니다.");
}
// 카테고리 확인
Category newCategory = null;
if (postUpdateRequestDTO.getCategoryId() != null && !postUpdateRequestDTO.getCategoryId().equals(post.getCategory().getId())) {
newCategory = categoryRepository.findById(postUpdateRequestDTO.getCategoryId())
.orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다. ID: " + postUpdateRequestDTO.getCategoryId()));
}
// 이미지 수정
for (MultipartFile image : imageList) {
try {
String imageUrl = fileService.uploadFile(image);
if ((postUpdateRequestDTO.getImagelist() != null) && !postUpdateRequestDTO.getImagelist().isEmpty()) {
validateImageCount(postUpdateRequestDTO.getImagelist()); // 5개 이상이면 예외 발생
updateImage(postUpdateRequestDTO, user, post, imageUrl);
}
} catch (IOException e) {
throw new FileException("파일 업로드에 실패 하였습니다");
}
}
// 게시글 수정
post.updatePost(postUpdateRequestDTO.getTitle(), postUpdateRequestDTO.getContent(), newCategory);
return post.getId();
}
private void updateImage(PostCreateUpdateRequestDTO postUpdateRequestDTO, User user, Post post, String imageUrl) {
String fileName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1);
List<String> fileNameByPostIdList = imageRepository.findFileNamesByPostId(post.getId());
// 기존 게시글에 저장된 이미지와 요청으로 들어온 이미지의 파일네임을 비교하여 같은것과 다른것을 분리
Map<Boolean, List<ImageCreateRequestDTO>> partitionedImages = postUpdateRequestDTO.getImagelist()
.stream()
.collect(Collectors.partitioningBy(
imageDTO -> fileNameByPostIdList.contains(fileName)));
// 이미지 삭제 대상: 기존 게시글에 저장된 이미지와 분리된 파티션의 fasle인 대상중에서 요청으로 넘어온 대상을 제외한 대상
List<String> deleteFileNameList = fileNameByPostIdList
.stream()
.filter(deleteFileName -> partitionedImages.get(false)
.stream()
.noneMatch(imageDTO -> fileName.equals(deleteFileName)))
.toList();
// 새로 추가된 이미지 정보만 담긴 ImageCreateRequestDTO
List<ImageCreateRequestDTO> newImageCreateRequestDTO = partitionedImages.get(true);
// 이미지 소프트 딜리트
if (!deleteFileNameList.isEmpty()) {
imageRepository.softDeleteByFileNames(deleteFileNameList);
}
// 새로 추가될 이미지 정보가 있으면 저장
if (!newImageCreateRequestDTO.isEmpty()) {
for (ImageCreateRequestDTO newImageDTO : newImageCreateRequestDTO) {
Image image = Image.builder()
.user(user)
.fileName(fileName)
.imageUrl(imageUrl)
.originalName(newImageDTO.getOriginalFileName())
.fileType(newImageDTO.getFileType())
.fileSize(newImageDTO.getFileSize())
.build();
imageRepository.save(image);
post.addCommunityImage(
CommunityImage.builder()
.image(image)
.post(post)
.build()
);
}
}
}
물론 이때도 updateImage로직에서 partitioningBy로 이미지를 분류하고 기존에 등록된 이미지를 구분하는 로직에서 문제가 있는지는 몰랐기 때문에 이상태로 PR이 올라갔다...
2. 테스트와 버그 픽스
1) 테스트에 사용할 초기화 클래스
PR은 올라갔고 지금 개발된 로직들이 정상적으로 동작하는지 확인해보기 위해 테스트를 진행하고 그 결과를 PR하여 Post관련 API를 마무리지으려고 하였다.
그래서 지금까지는 H2DB의 메모리 DB에서 테스트를 했는데, Local DB에서 직접 데이터를 집어놓고 테스트를 하기로하고 설정을 변경하였다
김영한님 강의에서 H2DB를 Local에서 사용하는 방법을 자주 다루었기 때문에 쉽게 세팅할 수 있었고, application-local.yml파일에 url을 수정하여 local 프로필을 사용할 때 나의 localDB를 사용하도록 설정하였다
spring:
datasource:
url: 'jdbc:h2:tcp://localhost/~/h2db/taku-db'
driver-class-name: org.h2.Driver
username: sa
password:
어떤 방법으로 데이터를 초기화를 진행할까 고민하다가 유저나 카테고리 등은 몇개 없어도 게시글이나 이미지는 상당히 많이 입력해주어야하기에 별도의 초기화 테스트클래스를 생성하여 테스트코드에서 해당 메서드를 호출하여 DB에 데이터를 입력하는 방식으로 접근하였다.
그러기 위해서 각 Entity들을 테이블로 생성해야했는데 이런건 역시 GPT가 빠르게 할 수 있으므로 모든 도메인을 전달하여 GPT에게 한장짜리로 Create문을 만들으라고 시켰다
절대 못해서 그런게 아니다 나는 시간을 아끼고싶었다. 정말이다.
GPT가 만들어내는 코드나 스크립트문들은 아직까지는 매번 정확하지는 않아서 일부를 수정하여 테이블들을 생성하고, 이제 데이터를 초기화하기위해 코드를 작성하였다
test코드이므로 test하위 경로에 유저는 2개만 생성하고 카테고리도 4개를 각각 2유저가 2개씩 생성하도록 초기화 코드를 작성하였다.
유저 / 카테고리 초기화 코드
private final Random random = new Random();
@Transactional
List<User> createUser() {
// 유저 1 생성
User user1 = User.builder()
.nickname("User1")
.providerType("KAKAO")
.profileImg("https://example.com/user1.jpg")
.status("ACTIVE")
.domesticId("domestic123")
.gender("Male")
.ageRange("20-29")
.role("USER")
.email("user1@example.com")
.build();
// 유저 2 생성
User user2 = User.builder()
.nickname("User2")
.providerType("KAKAO")
.profileImg("user2.jpg")
.status("ACTIVE")
.domesticId("domestic456")
.gender("Female")
.ageRange("30-39")
.role("USER")
.email("user2@example.com")
.build();
// 저장
return userRepository.saveAll(List.of(user1, user2));
}
@Transactional
List<Category> createCategory(List<User> users) {
User user1 = users.get(0);
User user2 = users.get(1);
List<Category> categories = List.of(
Category.builder().name("나루토").user(user1).createdType("USER").status("ACTIVE").viewCount(random.nextLong(1, 20001)).build(),
Category.builder().name("원피스").user(user1).createdType("USER").status("ACTIVE").viewCount(random.nextLong(1, 20001)).build(),
Category.builder().name("귀멸의칼날").user(user2).createdType("USER").status("ACTIVE").viewCount(random.nextLong(1, 20001)).build(),
Category.builder().name("짱구").user(user2).createdType("USER").status("ACTIVE").viewCount(random.nextLong(1, 20001)).build()
);
return categoryRepository.saveAll(categories);
}
게시글을 생성할 때 게시글에 저장할 이미지가 필요하기 때문에 게시글을 생성할 때 이미지를 생성하도록 하였고 이미지는 각 게시글마다 0 ~ 5개까지 랜덤하게, 그리고 카테고리도 게시글마다 골고루 입력되도록 초기화 코드를 작성하였다.
게시글 / 이미지 초기화 코드
@Transactional
public List<Post> createPost(List<User> users, List<Category> categories, int count) {
String[] titles = {"Post", "Title", "Song"};
String[] contents = {"Content", "Story", "Lyrics"};
List<Post> posts = new ArrayList<>();
for (int i = 0; i < count; i++) {
User user = users.get(random.nextInt(users.size()));
Category category = categories.get(random.nextInt(categories.size()));
// 제목과 내용 템플릿 선택
int value = i % 3; // 3가지 템플릿 순환
String title = titles[value] + i;
String content = contents[value] + i;
Post post = Post.builder()
.user(user)
.category(category)
.title(title)
.content(content)
.views(random.nextLong(1, 5001))
.likes(random.nextLong(1, 5001))
.build();
postRepository.save(post);
// 랜덤 이미지
int imageCount = random.nextInt(6);
for (int j = 0; j <= imageCount; j++) {
Image image = Image.builder()
.user(user)
.fileName("image_" + i + ".jpg")
.imageUrl("https://example.com/image_" + i + ".jpg")
.originalName("original_image_" + i + ".jpg")
.fileType("jpg")
.fileSize(random.nextInt(5000))
.build();
imageRepository.save(image);
CommunityImage communityImage = CommunityImage.builder()
.image(image)
.post(post)
.build();
post.addCommunityImage(communityImage);
}
posts.add(post);
}
return posts;
}
3) 전체 조회 테스트 및 코드 수정
작성한 초기화 코드를 호출하여 더미데이터 입력을 먼저 진행하고 전체 조회 테스트 부터 진행하기 위해 코드를 작성하였다.
구현한 기능들이 모두 동작하는지 확인하기 위해서 키워드, 정렬 기준, 페이징, 카테고리 검증등을 골고루 입력하여 아래처럼 3가지의 테스트 케이스가 완성되었다!
테스트 케이스
@Test
void findAllPostsFilterLatest() {
Random random = new Random();
long lastValue = random.nextLong(1, 5001);
List<PostListResponseDTO> postListResponseDTOS = postService.findAllPost("latest", lastValue, false, 20, "Post", 2L);
System.out.println("lastValue = " + lastValue);
for (PostListResponseDTO postListResponseDTO : postListResponseDTOS) {
System.out.println("id = " + postListResponseDTO.getId() +
" title = " + postListResponseDTO.getTitle() +
" content = " + postListResponseDTO.getContent() +
" ImageUrl = " + postListResponseDTO.getImageUrl() +
" category" + postListResponseDTO.getCategoryId());
}
}
@Test
void findAllPostsFilterViews() {
Random random = new Random();
long lastValue = random.nextLong(1, 3000);
List<PostListResponseDTO> postListResponseDTOS = postService.findAllPost("views", lastValue, false, 20, "tor", 3L);
System.out.println("lastValue = " + lastValue);
for (PostListResponseDTO postListResponseDTO : postListResponseDTOS) {
System.out.println("views = " + postListResponseDTO.getViews() +
" id = " + postListResponseDTO.getId() +
" title = " + postListResponseDTO.getTitle() +
" content = " + postListResponseDTO.getContent() +
" ImageUrl = " + postListResponseDTO.getImageUrl() +
" category" + postListResponseDTO.getCategoryId());
}
}
@Test
void findAllPostsFilterLikes() {
Random random = new Random();
long lastValue = random.nextLong(1, 3000);
List<PostListResponseDTO> postListResponseDTOS = postService.findAllPost("likes", lastValue, true, 20, "ong", 1L);
System.out.println("lastValue = " + lastValue);
for (PostListResponseDTO postListResponseDTO : postListResponseDTOS) {
System.out.println("likes = " + postListResponseDTO.getLikes() +
" id = " + postListResponseDTO.getId() +
" title = " + postListResponseDTO.getTitle() +
" content = " + postListResponseDTO.getContent() +
" ImageUrl = " + postListResponseDTO.getImageUrl() +
" category" + postListResponseDTO.getCategoryId());
}
}
그러나 검색기능이 코드는 정상적으로 동작하는데 조회결과가 빈 리스트로 반환되는 이런 충격적인 결과가 발생하였다..!!
처음엔 검색, 필터 이런 기능이 문제라고 생각하고 작성된 쿼리문을 보았는데, 전혀 문제를 파악할 수 없어서.. 디버깅 천재 GPT에게 왜 데이터가 조회가 되지 않는지 물어보았다.
GPT가 작성된 Querydsl코드에서 inner join을 사용하고 있는데 이것은 Post와 Image 둘다 있거나 없을때 사용하는 것이고 내가 원하는 것은 leftjoin을 사용해야한다는 것이였다.
물론.. 알고있는 내용이였다. 근데 이것을 적용할 때는 쿼리를 작성해야한다는 급급한 마음에 이것을 생각하지 못했었다... 바보다.
이제라도 문제가된 부분을 바로 잡았으니 쿼리를 수정하고 테스트를 진행하였다
쿼리 코드 수정
@Override
public List<Post> findAllPostWithNoOffset(String filter, Long lastValue, boolean isAsc, int limit, String keyword, Long categoryId) {
QPost post = QPost.post;
BooleanExpression byCategory = getCategory(categoryId, post); // 카테고리 구분
BooleanExpression bySortFilter = getSortFilter(filter, lastValue, isAsc, post); // 정렬 필터
BooleanExpression byKeyword = getKeyword(keyword, post); // 키워드 검색
BooleanExpression notDeleted = getNotDeleted(post); // Soft Delete 조건
OrderSpecifier<?> mainSort = getMainSort(filter, isAsc, post); // 첫번째 정렬 기준
OrderSpecifier<?> subSort = getSubSort(isAsc, post); // 두번째 정렬 기준
return jpaQueryFactory
.selectFrom(post)
.leftJoin(post.communityImages, communityImage)
.leftJoin(communityImage.image, image)
.where(notDeleted, byCategory, bySortFilter, byKeyword)
.orderBy(mainSort, subSort)
.limit(limit)
.fetch();
}
이렇게 쿼리를 변경하고 테스트를 수행해보니 모든 테스트 결과가 정상적으로 수행되어 결과는 잘 조회되었으나 테스트코드에서는 N + 1 문제가 발생되고있었다.
이유를 살펴보니 반환되는 데이터의 List를 하나씩 꺼내서 확인을 하다보니 그 개수만큼 N + 1 쿼리가 발생되는 것이였다.
하지만 이 문제는 API를 반환할때는 발생하지 않는데, 애초에 API에서 List로 데이터를 전달하기 때문에 다시 접근하여 Lazy로딩이 동작하여 추가 쿼리가 발생할 이유가 없기 때문이다.
그래서 테스트 케이스에서 반복문으로 값을 보기좋게 출력하는 것을 제거하고 리스트를 통채로 출력하게 해보면 정상적으로 한번의 쿼리로 모든 데이터가 반환되는 것까지 확인을 하였다.
4) 게시글 생성, 수정, 삭제, 상세조회 테스트 및 코드 수정
게시글 생성 테스트에서도 문제가 발생했는데 일단.. PostService의 createPost, updatePost에서는 스프링 시큐리티 정보를 받도록 정의한 PrincipalUser라는 클래스 타입으로 매개변수를 받아서 동작하도록 되어있었다.
이부분도 정확하게 할 줄 몰라서 GPT에게 문제 해결방법을 알려달라고 하였는데, 여러가지 문제 해결 방법을 알려주었으나.. @BeforeEach애노테이션으로 DB에서 유저 정보를 꺼내서 해당 유저정보로 SecurityContext에 설정하는 방식을 택했다.
해당 코드가 어떤 느낌으로 동작하는지는 이해는 하였으나 내부적으로 어떻게 동작하는지에 대해서는 나중에 스프링 시큐리티를 공부하면서 파악해봐야 할 것 같다.
이번에 스프링 시큐리티를 처음써보면서 편리하면서도 어려운부분이 존재하는 것 같았다.
게시글 생성 테스트 케이스
@BeforeEach
void mockAuthentication() {
// Mock User 생성
String email = "user2@example.com";
// UserRepository에서 User 엔티티를 조회
User user = userRepository.findByEmail(email).orElseThrow(() -> new IllegalArgumentException("User not found"));
// PrincipalUser 객체 생성
PrincipalUser principalUser = new PrincipalUser(user);
// 권한 생성
var authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
// SecurityContext에 PrincipalUser 설정
var authentication = new UsernamePasswordAuthenticationToken(principalUser, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
@Test
@Transactional
void createPost() {
PrincipalUser principalUser = (PrincipalUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PostCreateUpdateRequestDTO savePostDTO = new PostCreateUpdateRequestDTO();
savePostDTO.setTitle("저장 제목1");
savePostDTO.setContent("저장 내용1");
savePostDTO.setCategoryId(4L);
savePostDTO.setImagelist(null);
Long postId = postService.createPost(savePostDTO, principalUser, null);
Post post = postRepository.findById(postId).get();
System.out.println("저장한 게시글 Id = " + postId + " Id로 조회한 게시글 Id = " + post.getId());
System.out.println("게시글 작성자 = " + post.getUser().getNickname() + " 로그인 유저 = " + principalUser.getUser().getNickname());
assertThat(post.getUser().getNickname()).isEqualTo(principalUser.getUser().getNickname());
}
그러나 정상적으로 게시글이 저장되지 않았는데 가장큰 문제로 이미지가 조회되질 않는 것이였다.
이부분도 한참을 검색하다가 결국 GPT에게 문제를 물어봤는데 일대다 다대일 양방향 연관관계 매핑시 연관관계의 주인을 설정하는곳에서 cascade 옵션을 주어야 정상적으로 동작한다는 것을 알았다.
우리는 softDelete로 진행해야하기에 cascade 옵션과 orphanRemoval 옵션을 주지 않았는데, casecade 옵션을 PERSIST로 하면 영속화 할때만 동작하고 삭제할 때는 동작하지 않도록 할 수 있었다.
이부분도 김영한님 강의에서 주의깊게 다루는 내용이였는데.. 까먹었었다.
cascade는 연관된 엔터티의 데이터를 함께 삭제하거나 할 때 적용하는 옵션이고 orpahRemoval은 부모 엔터티와 연관관계가 끊어진 고아 객체를 삭제하는 옵션인데 자세한 내용은 김영한님 강의에서 배우는 것을 권장한다.
@Builder.Default
@BatchSize(size = 1000)
@OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST)
private List<CommunityImage> communityImages = new ArrayList<>();
그래서 위처럼 옵션을 주어서 Post가 변경되었을 때 communityImages도 변경되도록 하였고 만약 해당 옵션을 주지 않으면 연관관계를 맺은 엔터티도 직접 save를 통해 저장을 해주어야 함
그리고 이 과정에서 PostService 코드에서 수정과 삭제를 하는 코드가 너무 중복이 심한것 같아서 리펙토릭을 실시하였는데..
이 과정에서 updatePost에서 이미지 업데이트할 때 잘못 작성했던 코드가 수정 되었다.. 그래서 그때 작성했던 코드가 잘못된 채로 계속 PR이 되었는지 몰랐었다..
어쨋든 결국 이미지저장을 위해 검증해야할 상황만 다르고 이미지를 저장하는 로직은 create나 update나 모두 동일했기 때문에 저장하는 로직은 processImage라는 메서드로 통합하였고 검증해야하는 분기만 updateImages, saveImages로 수정하였다.
그리고 기존에 이미지를 검증하는 로직도 DTO의 정보로 검증하는 것이아니라 직접 넘어온 MultipartFile에서 정보를 꺼내서 기존이미지 파일에서 삭제하고 넘어온 이미지들은 softdelete하고, 동일한 이미지는 중복된 이미지로 처리하여 저장하지 않도록하고 이미지 리스트가 null로 입력이 되면 기존에 등록되었던 이미지가 삭제된 것으로 간주하여 모두 이미지를 softdelete하도록 작성하였다.
PostService 리펙토링
package com.ani.taku_backend.post.service;
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
private final CategoryRepository categoryRepository;
private final ImageRepository imageRepository;
private final FileService fileService;
/**
* 게시글 전체 조회
*/
public List<PostListResponseDTO> findAllPost(String filter, Long lastValue, boolean isAsc, int limit, String keyword, Long categoryId) {
/**
* 검증 로직
* - 공백만 있는 keyword null 처리
* - 공백 제거(양옆, 중간)
*/
if (keyword != null) {
keyword = keyword.trim().isEmpty() ? null : keyword.replaceAll("\\s+", "");
}
List<Post> allPost = postRepository.findAllPostWithNoOffset(filter, lastValue, isAsc, limit, keyword, categoryId);
return allPost.stream().map(PostListResponseDTO::new).toList();
}
/**
* 게시글 작성
*/
@RequireUser
@Transactional
public Long createPost(PostCreateUpdateRequestDTO postCreateRequestDTO, PrincipalUser principalUser, List<MultipartFile> imageList) {
// 유저 정보 가져오기
User user = principalUser.getUser();
// 카테고리 확인
Category category = categoryRepository.findById(postCreateRequestDTO.getCategoryId())
.orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다. ID: " + postCreateRequestDTO.getCategoryId()));
// 게시글 생성 및 저장
Post post = getPost(postCreateRequestDTO, user, category);
postRepository.save(post);
// 이미지 생성 및 저장
if (imageList != null && !imageList.isEmpty()) {
saveImages(postCreateRequestDTO, imageList, user, post);
}
return post.getId();
}
/**
* 게시글 업데이트
*/
@RequireUser
@Transactional
public Long updatePost(PostCreateUpdateRequestDTO postUpdateRequestDTO, PrincipalUser principalUser, Long postId, List<MultipartFile> imageList) {
// 유저 정보 가져오기
User user = principalUser.getUser();
// 게시글 조회, 없으면 예외
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostException.PostNotFoundException("ID: " + postId));
// 수정 권한 확인
if (!user.getUserId().equals(post.getUser().getUserId())) {
throw new PostException.PostAccessDeniedException("게시글을 수정할 권한이 없습니다.");
}
// 카테고리 확인
Category newCategory = null;
if (postUpdateRequestDTO.getCategoryId() != null && !postUpdateRequestDTO.getCategoryId().equals(post.getCategory().getId())) {
newCategory = categoryRepository.findById(postUpdateRequestDTO.getCategoryId())
.orElseThrow(() -> new IllegalArgumentException("카테고리를 찾을 수 없습니다. ID: " + postUpdateRequestDTO.getCategoryId()));
}
// 이미지 수정
updateImages(postUpdateRequestDTO, imageList, user, post);
// 게시글 수정
post.updatePost(postUpdateRequestDTO.getTitle(), postUpdateRequestDTO.getContent(), newCategory);
return post.getId();
}
/**
* 게시글 삭제
*/
@RequireUser
@Transactional
public Long deletePost(Long postId, PrincipalUser principalUser) {
User user = principalUser.getUser();
Post post = postRepository.findById(postId)
.orElseThrow(() -> new PostException.PostNotFoundException("ID: " + postId));
if (!user.getUserId().equals(post.getUser().getUserId())) {
throw new PostException.PostAccessDeniedException("게시글을 삭제할 권한이 없습니다.");
}
post.softDelete();
post.getCommunityImages().forEach(communityImage -> {
communityImage.getImage().softDelete();
post.removeCommunityImage(communityImage);
});
return post.getId();
}
@Transactional
protected void saveImages(PostCreateUpdateRequestDTO postCreateRequestDTO, List<MultipartFile> imageList, User user, Post post) {
for (MultipartFile image : imageList) {
try {
String imageUrl = fileService.uploadFile(image);
if ((postCreateRequestDTO.getImagelist() != null) && !postCreateRequestDTO.getImagelist().isEmpty()) {
validateImageCount(postCreateRequestDTO.getImagelist()); // 5개 이상이면 예외 발생
}
} catch (IOException e) {
throw new FileException("파일 업로드에 실패 하였습니다");
}
}
}
@Transactional
protected void updateImages(PostCreateUpdateRequestDTO requestDTO, List<MultipartFile> imageList, User user, Post post) {
if (requestDTO.getImagelist() != null) {
validateImageCount(requestDTO.getImagelist());
}
if (imageList == null || imageList.isEmpty()) {
post.getCommunityImages().forEach(communityImage -> {
Image image = communityImage.getImage();
image.softDelete(); // Soft delete 호출
});
return;
}
List<String> existingFileNames = imageRepository.findFileNamesByPostId(post.getId());
List<String> newFileNames = imageList.stream().map(MultipartFile::getOriginalFilename).toList();
// 기존 파일 삭제 대상
List<String> filesToDelete = existingFileNames.stream()
.filter(existing -> !newFileNames.contains(existing))
.toList();
// 기존 파일 소프트 삭제
if (!filesToDelete.isEmpty()) {
imageRepository.softDeleteByFileNames(filesToDelete);
}
// 새로 추가된 파일 필터링 (기존 파일과 중복되지 않은 파일만 선택)
List<MultipartFile> filesToAdd = imageList.stream()
.filter(file -> !existingFileNames.contains(file.getOriginalFilename()))
.toList();
// 새 파일 업로드 및 저장
for (MultipartFile image : filesToAdd) {
try {
String imageUrl = fileService.uploadFile(image);
processImage(requestDTO, user, post, imageUrl);
} catch (IOException e) {
throw new FileException("파일 업로드에 실패하였습니다.");
}
}
}
@Transactional
void processImage(PostCreateUpdateRequestDTO requestDTO, User user, Post post, String imageUrl) {
String fileName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1);
for (ImageCreateRequestDTO imageDTO : requestDTO.getImagelist()) {
Image image = Image.builder()
.user(user)
.fileName(fileName)
.imageUrl(imageUrl)
.originalName(imageDTO.getOriginalFileName())
.fileType(imageDTO.getFileType())
.fileSize(imageDTO.getFileSize())
.build();
imageRepository.save(image);
post.addCommunityImage(
CommunityImage.builder()
.image(image)
.post(post)
.build()
);
}
}
private Post getPost(PostCreateUpdateRequestDTO postCreateRequestDTO, User user, Category category) {
return Post.builder()
.user(user)
.category(category)
.title(postCreateRequestDTO.getTitle())
.content(postCreateRequestDTO.getContent())
.views(0L)
.likes(0L)
.build();
}
private void validateImageCount(List<ImageCreateRequestDTO> imageList) {
if (imageList != null && imageList.size() > 5) {
throw new FileException.FileUploadException("5개 이상 이미지를 등록할 수 없습니다.");
}
}
}
이렇게 수정된 PostService를 통해서 게시글 상세조회와 게시글 삭제 테스트도 정상적으로 조회 및 동작하는 것을 확인하고 최종 PR을 요청하여 일단 이 API로 프론트엔드와 소통하여 커뮤니티 게시글 관련 개발이 진행될 것 같다.
완벽한 테스트는 아니였지만 테스트를 진행하면서 발견한 버그도 많았기에 테스트의 중요성도 다시한번 깨닫게 되었다
나머지 테스트 코드
@Test
@Transactional
void deletePost() {
PrincipalUser principalUser = (PrincipalUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
PostCreateUpdateRequestDTO savePostDTO = new PostCreateUpdateRequestDTO();
savePostDTO.setTitle("삭제할 제목1");
savePostDTO.setContent("삭제할 내용1");
savePostDTO.setCategoryId(1L);
savePostDTO.setImagelist(null);
Long savePostId = postService.createPost(savePostDTO, principalUser, null);
Long deletePostId = postService.deletePost(savePostId, principalUser);
Post post = postRepository.findById(deletePostId).get();
System.out.println("삭제 일시 = " + post.getDeletedAt());
assertThat(post.getDeletedAt()).isNotNull();
}
@Test
void findPostDetail() {
Random random = new Random();
long randomPostId = random.nextLong(1, 3000);
PostDetailResponseDTO postDTO = postReadService.getPostDetail(randomPostId, true, 1L);
System.out.println("게시글Id = " + postDTO.getPostId() +
" 제목 = " + postDTO.getTitle() +
" 내용 = " + postDTO.getContent() +
" 좋아요 수 = " + postDTO.getLikes() +
" 조회수 = " + postDTO.getViewCount() +
" 이미지 " + postDTO.getImageUrls());
}
5) 포스트에서 아직 남은 작업
몽고DB를 사용하여 좋아요를 꺼내는 작업과, 내가할일은 아니지만 게시글 상세조회에서 댓글을 표시해야하는 일이 아직 남았다!
이부분은 추후에 댓글작업과 몽고DB세팅이 끝나는대로 마무리 지을예정이다.
사실 회고록을 쓰면서도 상당히 많은 버그를 발견하여 중간중간에 수정하는 과정이 있었고 개발을 다했다고해서 내 개발이 완벽하다고 생각하는것은 매우 오만한 생각이라는 것을 다시한번 느꼈다.
개발을 진행하면서 온갖 버그.. 그리고 컴파일 오류가 얼마나 파악하기 쉬운 오류인지 다시금 깨닫게 되었다.
updatePost에서 softdelete가 진행되지 않는 버그를 한참동안 헤매다가 마음을 다잡고 천천히 코드를 다시 읽다가 null일때 처리를 빠트렸는데.. 이런 비즈니스 로직의 버그가 정말 버그를 파악하는 것조차도, 어디서 버그가 발생했는지 찾는것 조차도 너무 오래걸리는 작업이였다.
다시한번 컴파일 오류에 감사함을 느끼며 이번 첫 팀프로젝트로 Post API를 개발하면서 느꼈던 경험을 가지고 상품 개발은 좀 더 발전된 모습으로 개발에 임하길 기대한다.
'프로젝트, 개발 공부 회고 > 덕후프로젝트' 카테고리의 다른 글
게시글 CUD API 추가 개발 (1) | 2024.12.27 |
---|---|
게시글 목록 API 만들기2, 이미지 연관관계 매핑 (0) | 2024.12.22 |
취업을 위한 첫 팀 프로젝트 시작, 게시글 목록 API 만들기1, NoOffset Cursor(커서) 방식 페이징, 검색 기능 포함 (3) | 2024.12.20 |