일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 2024 정보처리기사 수제비 실기
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch3
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch9
- 스프링 db1 - 스프링과 문제 해결
- @Aspect
- 자바의 정석 기초편 ch13
- 자바의 정석 기초편 ch11
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch12
- 스프링 db2 - 데이터 접근 기술
- 타임리프 - 기본기능
- 스프링 입문(무료)
- 스프링 mvc2 - 타임리프
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch2
- 게시글 목록 api
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch8
- 스프링 mvc2 - 로그인 처리
- 스프링 고급 - 스프링 aop
- 코드로 시작하는 자바 첫걸음
- 2024 정보처리기사 시나공 필기
- jpa 활용2 - api 개발 고급
- 스프링 mvc1 - 서블릿
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch7
- Today
- Total
나구리의 개발공부기록
게시글 목록 API 만들기2, 이미지 연관관계 매핑 본문
1. 게시글 목록 API 만들기 - 추가 개발
1) PR 코멘드 반영
결국 PR에서 추가적인 지적 사항이 들어와서 수정하였다.
1. PostController의 파라미터가 너무 많은데, 객체로 받는것이 좋겠다.
2. PostDTO가 뭐하는 DTO인지 모르겠다.
3. Service 로직에서 Post를 DTO로 변환하는 로직을 깔끔하게 하는 것이 좋겠다
이부분은 금방 수정할 수 있는 부분이라서 다행이 수정을 금방했다!
우선 PostDTO를 더 직관적인 이름으로 FindAllPostDTO로 바꿨다.
전체 Post를 찾는 DTO라는 뜻...? 그리고 해당 DTO의 로직에 Post를 받는 생성자를 추가했다.
코드 더보기
package com.ani.taku_backend.post.model.dto;
@Data
public class FindAllPostDTO {
private Long id;
private Long userId; // User 객체 대신 ID만 포함
private Long categoryId; // Category 객체 대신 ID만 포함
private String title;
private String content;
// private Imege imegeId; // 이미지 Entity와 연동한뒤 기능 추가 개발
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long views;
private Long likes;
public FindAllPostDTO(Post post) {
this.id = post.getId();
this.userId = post.getUser().getUserId();
this.categoryId = post.getCategory().getId();
this.title = post.getTitle();
this.content = post.getContent();
this.createdAt = post.getCreatedAt();
this.updatedAt = post.getUpdatedAt();
this.views = post.getViews();
this.likes = post.getLikes();
}
}
이렇게 하면 Service에서 Post를 FindAllPostDTO로 변환할 때 이렇게 메서드 참조로 간단하게 변경할 수 있어진다.
그리고 하는 김에 keyword를 검증하는 로직을 삼항 연산자로 최적화 했다!
확실히 이렇게 제거를 하니까 코드가 깔끔해보인다.
코드 더보기
package com.ani.taku_backend.post.service;
@Service
@Slf4j
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public List<FindAllPostDTO> findAllPost(String filter, Long lastValue, boolean isAsc, int limit, String keyword) {
/**
* 검증 로직
* - 공백만 있는 keyword null 처리
* - 공백 제거(양옆, 중간)
*/
if (keyword != null) {
keyword = keyword.trim().isEmpty() ? null : keyword.replaceAll("\\s+", "");
}
List<Post> allPost = postRepository.findAllPostWithNoOffset(filter, lastValue, isAsc, limit, keyword);
return allPost.stream().map(FindAllPostDTO::new).toList();
}
}
그리고 Controller의 파라미터들을 받는 객체를 추가로 생성하기 위해 엄청나게 직관적으로 FindAllPostParamDTO를 생성했다.
컨트롤러에 지저분하게 붙어있던 스웨거의 코드들도 DTO로 옮겼고, filter를 Enum을 만들어 놓고 String으로 받고 있던 부분도 Enum을 받도록 수정했다.
코드 더보기
package com.ani.taku_backend.post.model.dto;
@Data
public class FindAllPostParamDTO {
@Schema(description = "정렬 기준", defaultValue = "latest")
private SortFilterType filter;
@Schema(description = "정렬 기준의 마지막 값")
private Long lastValue;
@Schema(description = "정렬 방향(true = 오름차순, false = 내림차순)", defaultValue = "false")
private boolean isAsc = false;
@Schema(description = "페이지당 항목 수", defaultValue = "20")
private int limit = 20;
@Schema(description = "검색어")
private String keyword;
}
이렇게 수정한 덕분에 컨트롤러의 코드도 매우 깔끔해졌다.
특히 스웨거 코드가 사라진것이.. 매우 가독성을 높여주는 것 같다.
일단 코드를 push하고.. 다음 코멘트를 기다려보자
코드 더보기
package com.ani.taku_backend.post.controller;
@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
@Operation(summary = "커뮤니티 게시글 조회(정렬, 검색(개발중))", description = """
필터 조건, 검색어(개발 중), 정렬 순서에 따라 게시글 목록을 조회
1. filter: 정렬 기준 선택
- latest: 최신순 (기준 값: 게시글 Id)
- likes : 좋아요순 (기준 값: 좋아요 수)
- views : 조회수순 (기준 값: 조회수)
2. lastValue: 선택 된 정렬 기준의 마지막 정수 값, 타입 Long
- 정렬 기준이 최신 순이면 현재 데이터의 마지막 Id 값
- 정렬 기준이 좋아요 순이면 현재 데이터의 마지막 좋아요 값
- 정렬 기준이 조회수 순이면 현재 데이터의 마지막 조회수 값
"""
)
@GetMapping
public ResponseEntity<MainResponse<List<FindAllPostDTO>>> findAllPost(FindAllPostParamDTO findAllPostParamDTO) {
List<FindAllPostDTO> posts = postService.findAllPost(
findAllPostParamDTO.getFilter().toString(),
findAllPostParamDTO.getLastValue(),
findAllPostParamDTO.isAsc(),
findAllPostParamDTO.getLimit(),
findAllPostParamDTO.getKeyword());
return ResponseEntity.ok(MainResponse.getSuccessResponse(posts));
}
}
다음 코멘트가 금방 도착했다!
보통은 이런 DTO를 만들때 요청 받을 때는 ###RequestDTO, 응답(반환)할 때는 ###ResponseDTO로 한다고한다.
줄여서는 ResDTO, ResDTO로도 하는 것 같다!
그래서 내나름대로.. 명확하게 지었다고 생각했던 DTO는 명확하지 않았던 것이었고! 아래처럼 이름이 변경되었다.
가급적 행동? 기능?도 표시하는게 좋다고하여 FindAllPostRequestDTO 이건 너무 긴거같아서 List로 대체하였다.
1. FindAllPostParamDTO -> PostListRequestDTO
2.FindAllPostDTO -> PostListResonseDTO
2) Image와 매핑
dev 브렌치와 병합한 후 전체 게시글 조회하는 API에 ImageUrl을 같이 전달하기위해 다른분이 생성해 놓은 Image Entity와의 연관관계 매핑을 도전했다.
직접 해보려니 머리가 너무 안돌아갔다.
일단 처음 접근은 ERD를 보고 관계를 다시 파악했는데 Image와 Post의 테이블 중간에 Community_Images라는 중간 테이블로 일대다, 다대일 관계를 맺고있었다
과거 강의에서 배웠던 내용 중에서 중간 매핑 테이블이 자동으로 생성되었던 기억과 다대다 매핑은 일대다 다대일로 풀러서 해야한다는 기억이 뒤죽박죽 섞여서 Entity의 필드들을 연관관계 매핑하는데 한참 애먹었다..
처음엔 무작정 시도하다가 자꾸 아닌 것 같아서 블로그에 적힌 강의들을 찬찬히 읽어봤는데 역시나 강의를 한번 완강한거로는 내머리가 제대로 기억하지 못하는 것이였다
중간 매핑 테이블이 자동으로 생성되었던 것은 다대다 관계로 연관관계 매핑을 했을 때 생겼던 것이고, 강의에서 해당 내용도 진행했었기에 이런 기억을 가지고 있었던 것이며 그것과 별개로 실무에서는 다대다 관계보다는 중간에 새로 생성되는 테이블을 직접 Entity로 작성하여 테이블을 생성하고 일대다 다대일 관계로 풀어내야한다는 내용도 적혀있었다.
결국 몇시간을 삽질한 끝에 연관관계 매핑을 마쳤는데.. 달라진 코드는 결국 몇줄 되지도 않았다..
Image
package com.ani.taku_backend.common.model.entity;
/**
* 이미지 엔티티
*/
@Entity
@Table(name = "images")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Image extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "uploader_id", nullable = false)
private User user;
@Column(name = "file_name" , length = 255)
private String fileName;
@Column(name = "image_url" , length = 500)
private String imageUrl;
@Column(name = "original_name" , length = 255)
private String originalName;
@Column(name = "file_type" , length = 50)
private String fileType;
@Column(name = "file_size")
private Integer fileSize;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@OneToMany(mappedBy = "image")
private List<CommunityImage> communityImage;
}
CommunityImage
package com.ani.taku_backend.post.model.entity;
/**
* 커뮤니티 이미지 Entity
*/
@Entity
@Table(name = "community_images")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class CommunityImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "image_id")
private Image image;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
Post
package com.ani.taku_backend.post.model.entity;
/**
* 커뮤니티 게시글 Entity
*/
@Table(name = "posts")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@ToString
@Builder
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id") // 외래 키 컬럼 이름 명시
private Category category;
@OneToMany(mappedBy = "post")
private List<CommunityImage> communityImages;
private String title;
private String content;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long views;
private Long likes;
private LocalDateTime deletedAt ;
// 조회수 증가
public void addViews() {
this.views++;
}
// 좋아요 수 증가
public void addLikes() {
this.likes++;
}
// 좋아요 수 감소
public void subLikes() {
if (this.likes > 0) {
this.likes--;
}
}
}
추가적으로 응답 API에 이미지 URL을 전송하기 위해 PostListResponseDTO의 생성자에 ImageUrl을 입력하는 코드를 추가하였다.
일단 직관적으로 List인 getCommunityImages에서 get(0)으로 글의 가장 처음으로 등록한 이미지의 URL를 가져오도록 작성하였는데, 이 코드가 이때는 문제가 있는지 몰랐다.
코드 더보기
package com.ani.taku_backend.post.model.dto;
@Data
public class PostListResponseDTO {
private Long id;
private Long userId; // User 객체 대신 ID만 포함
private Long categoryId; // Category 객체 대신 ID만 포함
private String title;
private String content;
private String imageUrl; // Image 링크를 응답
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private Long views;
private Long likes;
public PostListResponseDTO(Post post) {
this.id = post.getId();
this.userId = post.getUser().getUserId();
this.categoryId = post.getCategory().getId();
this.title = post.getTitle();
this.content = post.getContent();
this.imageUrl = post.getCommunityImages().get(0).getImage().getImageUrl();
this.createdAt = post.getCreatedAt();
this.updatedAt = post.getUpdatedAt();
this.views = post.getViews();
this.likes = post.getLikes();
}
}
연관관계 매핑을 했으니 Querydsl의 쿼리문을 수정하려고 했는데 처음에는 냅다 join과 fetchjoin을 입력하고 테스트를 진행했는데 로그에 경고문구가 뜨고 제대로 페이징이 안되고 있었다..
이를 해결하기위해서 수시간을 갈아 넣은 결과 강의에서 배웠던 글에서 이유를 알 수 있었는데... fetchjoin은 페이징과 함께 사용할 때 문제가 발생한다는 것을 알았다.
이렇게 될 경우 다쪽에 페이징이 적용되어 제대로 페이지네이션이 동작하지 않을 뿐더러 모든 데이터를 조회한 후 메모리에서 페이징을 하도록 동작하여 데이터가 많았다면 메모리 에러가 날 수도 있는 상황이였다..
글에서 몇가지 방법이 있긴했는데 이것저것 다 적용해보기가 어려운 상황이여서 일단 글에서 나와있는데로 fetchjoin을 적용하지 않고 일반 join과 @BatchSize로 해당 문제를 해결하기위해 적용했다.
@BatchSize(size = 1000)
@OneToMany(mappedBy = "post")
private List<CommunityImage> communityImages;
그리고 쿼리에서도 delete가 된 게시글인지 아닌지 검증하기위한 코드가 필요하다는 것을 추가적으로 알게되어서 이를 반영하여 쿼리문을 수정하였다
코드 더보기
package com.ani.taku_backend.post.repository.impl;
@Repository
@RequiredArgsConstructor
public class PostRepositoryCustomImpl implements PostRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@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)
.join(post.communityImages, communityImage)
.join(communityImage.image, image)
.where(notDeleted, byCategory, bySortFilter, byKeyword)
.orderBy(mainSort, subSort)
.limit(limit)
.fetch();
}
/**
* 삭제된 데이터는 제외
*/
private BooleanExpression getNotDeleted(QPost post) {
return post.deletedAt.isNull();
}
/**
* 카테고리 구분
*/
private BooleanExpression getCategory(Long categoryId, QPost post) {
if (categoryId != null) {
return post.category.id.eq(categoryId);
}
return null;
}
/**
* 제목 + 내용으로 키워드 검색
*/
private BooleanExpression getKeyword(String keyword, QPost post) {
if (keyword != null) {
return post.title.contains(keyword).or(post.content.contains(keyword));
} else {
return null;
}
}
/**
* 정렬 필터 선택
* - isAsc -> true, 오름 차순
*/
private BooleanExpression getSortFilter(String filter, Long lastValue, boolean isAsc, QPost post) {
if (SortFilterType.LIKES.getValue().equalsIgnoreCase(filter) && lastValue != null) {
return isAsc ? post.likes.gt(lastValue) : post.likes.lt(lastValue);
} else if (SortFilterType.VIEWS.getValue().equalsIgnoreCase(filter) && lastValue != null) {
return isAsc ? post.views.gt(lastValue) : post.views.lt(lastValue);
} else if (lastValue != null) {
return isAsc ? post.id.gt(lastValue) : post.id.lt(lastValue);
}
return null;
}
/**
* 첫번째 정렬 기준
*/
private OrderSpecifier<?> getMainSort(String filter, boolean isAsc, QPost post) {
if (SortFilterType.LIKES.getValue().equalsIgnoreCase(filter)) {
return isAsc ? post.likes.asc() : post.likes.desc();
} else if (SortFilterType.VIEWS.getValue().equalsIgnoreCase(filter)) {
return isAsc ? post.views.asc() : post.views.desc();
}
return isAsc ? post.id.asc() : post.id.desc();
}
/**
* 두번째 정렬 기준
*/
private OrderSpecifier<?> getSubSort(boolean isAsc, QPost post) {
return isAsc ? post.id.asc() : post.id.desc();
}
}
그리고 배웠던 내용을 다시 살펴보니 연관관계 편의 메서드라는 것이 있었는데, 연관관계를 맺었을 때 연관관계 편의메서드를 작성하여 연관관계 주인이 변경되면 연관된 엔터티도 함께 적용되도록 해야하는 것 같았다..
정확하게 이해하지는 못했지만 일단 Post와 CommunityImage와 연관관계 메서드를 작성하고, 글이 삭제가 되었을 때 deletedAt 필드에 값을 입력해주는 메서드까지 추가로 작성했다
Post
/**
* 연관관계 편의 메서드
*/
public void addCommunityImage(CommunityImage communityImage) {
this.communityImages.add(communityImage);
communityImage.assignPost(this);
}
public void removeCommunityImage(CommunityImage communityImage) {
this.communityImages.remove(communityImage);
communityImage.unassignPost();
}
// === Soft Delete ===
public void softDelete() {
this.deletedAt = LocalDateTime.now();
}
CommunityImage
/**
* 연관관계 편의 메서드
*/
void assignPost(Post post) {
this.post = post;
}
void unassignPost() {
this.post = null;
}
해당 내용을 완벽하게 습득한게 아니기에.. 팀프로젝트가 끝나면 강의의 내용을 토대로 개인프로젝트를 진행하면서 복습을 꼭 해야할 것 같다.
그리고 PostListResponseDTO에 ImageUrl을 가져오기 위해 작성했던 코드는 리포지토리에서 꺼내온 post에 이미지가 없을 경우 비어있는 리스트에 .get(0)으로 꺼내려고하다보니 IndexOutOfBoundsException이 발생할 수 있다고 GPT가 알려주었다..
최종적으로 코드에 문제가 있는지 검증하기 위해서 GPT에 작성한 코드를 코드리뷰를 맡겨보는 편인데 나같이 초보 개발자들에게는 매우 유용하게 사용하는 방법이다.
결국 null값에 안전한 stream 문법을 사용하도록 DTO 코드를 수정하면서 드디어! 3일간에 걸친....... 게시글 목록 API 개발이 종료되었다
// 이미지가 여러 장일 경우 첫 번째 이미지 URL 가져오기
this.imageUrl = post.getCommunityImages()
.stream()
.findFirst()
.map(communityImage -> communityImage.getImage().getImageUrl())
.orElse(null);
추가적으로 연관관계 매핑을 했을 때
이게.. 실제 강의를 들었을 때나 클론코딩같은 것을 할때는 당연히 고려해야할 사항이 적고 해야할 것들이 정해져있다보니 엄청 금방 개발할 텐데,
팀프로젝트를 해보니 No Offset 방식이나 처음 접해보는 개념들을 적용해보고 또 검색해가면서 알아보고, 그리고 ERD를 보면서 머리를 굴려서 이게 맞나, 저게 맞나 이게 더나을까? 저게 더 나을까 하는 고민이 시간을 엄청 길게 잡아먹게 되는 요인이였다.
이렇게 작성하다보면 저게 문제가 있고, 저 문제를 해결하려고하면 또 다른 고려사항도 생기고.. 아직 익숙해 지는데에는 한참 남은 것 같다.
확실히 이론과 별개로 익숙해지려면 몸으로 많이 익히는 것도 중요한 것 같다.
확실하게 검증 테스트를 하고 넘어가는게 좋겠지만 일단 개발자체가 너무 딜레이 되어서 나머지 CUD API를 만들고 한꺼번에 테스트를 진행해서 문제를 검토해야겠다.
'프로젝트, 개발 공부 회고 > 덕후프로젝트' 카테고리의 다른 글
게시글 CRUD API 수정 및 개발 완료, 테스트 진행과 최종 PR (3) | 2024.12.27 |
---|---|
게시글 CUD API 추가 개발 (1) | 2024.12.27 |
취업을 위한 첫 팀 프로젝트 시작, 게시글 목록 API 만들기1, NoOffset Cursor(커서) 방식 페이징, 검색 기능 포함 (3) | 2024.12.20 |