2025. 12. 3. 17:24ㆍSpringBoot

이번 포스팅에서는 SNS 피드에 좋아요를 표시하는 기능을 구현하는 방법을 알아보겠습니다.
문제 상황
인상깊은 책의 문장을 기록하고 공유하는 SNS 개인 프로젝트를 개발하고 있습니다.
피드에 여러 인용구 게시물이 뜰 때 해당 게시글을 내가 좋아요를 눌렀는지 누르지 않았는지 표시하기 위해 좋아요를 눌렀을 경우 핑크색으로 채워진 하트, 좋아요를 누르지 않았을 경우 빈 하트로 표시하고 싶었습니다.

인용구와 좋아요의 관계는 1(인용구) : N(좋아요)
즉, 단방향 관계로 좋아요(like) 측에서 인용구(quote)에 관한 정보를 들고 있습니다.


반대로 인용구(quote)측에서는 좋아요(like)에 관한 정보를 가지고 있지 않습니다.

문제점은 여기서 발생했습니다.
사용자의 피드에 보여질 quote들을 제공하는 "/quotes" API에서는 quotes 테이블에서 데이터들을 Page 객체로 쿼리해 온 다음,
아래와 같은 응답 DTO로 바꿔서 응답을 내려주고 있습니다.
@GetMapping("/quotes")
public ResponseEntity<PageQuoteResponse> getQuotes(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<Quote> quotes = quoteQueryService.getQuotes(page, size);
PageQuoteResponse pageQuoteResponse = PageQuoteResponse.of(quotes.getContent(), quotes.getSize(), quotes.getNumber(), quotes.getTotalElements(), quotes.getTotalPages());
return ResponseEntity.ok(pageQuoteResponse);
}
아래는 QuoteResponse 응답 DTO 객체로, quote 도메인에서 공용으로 사용하고 있는 응답 DTO 입니다.

아래 DTO는 응답 결과가 Page 객체일 경우 필요한 정보들만을 조합해 응답해주기 위한 Page 전용 응답 DTO입니다.

결과적으로 응답 DTO들을 보면, 각각의 인용구들에 좋아요 정보가 없음을 확인할 수 있습니다.
인용구에 좋아요누름 여부를 확인하려면 좋아요에 관한 정보를 함께 내려주어야지 아래 사진과 같이 좋아요를 누른 인용구와 누르지 않은 인용구로 구분할 수 있습니다.

해결 방법
이 문제를 해결하기 위해서 2가지 방법을 생각해보았습니다.
1. 현재와 같은 단방향 연관관계를 유지하면서 "quotes 테이블에서 피드에 보여줄 quote 조회 + likes 테이블에서 로그인한 유저의 like 조회"합니다. 그리고 controller에서 조회한 2가지를 서로 합쳐 quote에 좋아요 정보를 추가합니다.
2. 단방향인 양방향으로 연관관계를 바꿉니다. @OneToMany를 이용해 Quote 엔티티에 리스트로 여러개의 Like 정보를 추가합니다.
public class Quote extends BaseEntity {
@OneToMany(mappedBy = 'quote', cascade = CascadeType.REMOVE)
private List<Like> likes = new ArrayList<>();
}
public class Like {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "quote_id", nullable = false)
private Quote quote;
}
선택 이유
결론적으로는 1번 방법을 선택했습니다. 2번 방법으로 양방향 관계를 맺을 경우 다음과 같은 단점이 존재합니다.
만약에 굉장히 인기가 많은 게시물이어서 100만개의 좋아요가 달린다고 친다면,
피드 화면을 만들기 위해 quote를 조회할 때
내가 좋아요를 누른 게시물인지 판단하기 위해서 quote.getLikes()를 호출하게 될 것입니다.
매번 getLikes()를 호출하는 순간 메모리에 100만개의 Like 객체를 매번 로딩하게 될 것이고 이는 비효율적인 메모리 사용이라고 생각했습니다.

또한 객체의 정보에 있어서 좋아요 정보에 대한 책임은 Like 객체가 지는 것이 맞다고 생각했습니다.
Quote 객체의 본연의 정보는 책정보, 문장, 나의 생각으로 이루어져 있고 해당 Quote를 좋아하는지의 여부는 Quote 본연의 정보에 포함되지 않는 것이라 생각했습니다.
결과
1번째 방법으로 진행하기 위해서 총 2개의 쿼리를 실행하도록 해야합니다.
1. quotes 테이블에서 deletedAt이 null인 row를 Page로 조회하기
2. likes 테이블에서 user_id가 현재 로그인한 유저이면서 quote_id가 뫄뫄인 row 조회하기
1번째 쿼리를 만들기 위해서는 Pageable객체를 다음과 같이 생성한 후, findByDeletedAtIsNull 함수를 이용해 Page 객체로 Quote 객체를 쿼리합니다.

2번째 쿼리를 만들기 위해서
다음과 같이 userId과 List<Quote>를 파라미터로 받아 findAll~~In Jpa 함수를 이용합니다.
그리고 {QuoteId : LikeId}와 같은 Map 형태로 변환하여 리턴합니다.

다음은 응답 DTO를 변경해야합니다. 현재 응답 DTO에 새롭게 포함되어야 할 정보는 likeId입니다.
기존 응답 DTO에는 likeId가 없으므로 이 부분을 추가해주어야 했습니다.
(기존 응답 DTO)

이 응답 DTO는 다른 API들에서도 사용하고 있는 공용 DTO였기 때문에 여기서 likeId를 추가하게 되면 다른 API들에서도 불필요한 "likes 테이블에서 user_id가 현재 로그인한 유저이면서 quote_id가 뫄뫄인 row 조회하기" 쿼리를 수행해 응답 DTO에 넣어주어야 했습니다.
따라서 DTO 재사용을 멈추고 새로운 피드용 응답 DTO인 QuoteFeedResponse DTO를 생성해서 사용했습니다.
추가적으로 QuoteResponse와 QuoteFeedResponse(피드 응답)는 엄연히 다른 목적을 가진 객체로 원래 목적이 다른 것은 분리해서 사용하는 것이 좋습니다.
저 역시 이번 기회에 하나의 DTO를 가지고 여러 API에서 같이 사용하고 있다는 것이 안티패턴임을 알게되었습니다..!
아래와 같이 피드용 응답 DTO를 만들었습니다.

JSON이 아래와 같이 중첩되어져서 나오는걸 막기 위해서 @JsonUnwrapped 어노테이션을 사용해 기존 QuoteRespnose를 사용합니다.
{
"quote": {
"id": 1,
"title": "어린왕자",
"author": "생텍쥐페리"
},
"myLikeId": 123
}
@JsonUnwrapped를 사용하면 아래와 같이 중첩하지 않고 필드를 합칠 수 있습니다.
{
"id": 1,
"title": "어린왕자",
"author": "생텍쥐페리",
"myLikeId": 123
}
이제 컨트롤러에서 2가지의 쿼리 결과를 새로 만든 QuoteFeedResponse 응답 DTO에 맞게 변환해서 응답합니다.
먼저 파라미터는 유저 정보, page, size 값을 받아옵니다.

먼저 quote 도메인 서비스 측에서 quote 데이터를 받아옵니다. 그 후 좋아요 데이터를 가져오기 위해서 like 도메인 서비스에서 Map형태로 변환된 데이터를 받아옵니다.

이후 Page객체로 받아왔던 quote를 QuoteResponse DTO 형태로 변환합니다. 그리고 각각 quote들의 id로 Map 데이터에서 get합니다. 얻어진 좋아요 Id와 함께 QuoteFeedResponse DTO를 조립합니다.

프론트의 자바스크립트 코드에서는 응답에서 좋아요 정보를 꺼내고 만약 있다면 핑크색으로 하트를 채우고 없다면 빈 하트를 그릴 수 있도록 합니다.

이제 양방향 연관관계, 메모리 부하 없이 쿼리 2개로
이렇게 내가 좋아요를 눌렀던 게시물과 누르지 않은 게시물을 구분해서 피드에 표현할 수 있게 되었습니다.

'SpringBoot' 카테고리의 다른 글
| [SpringBoot] 뉴스피드 시스템 아키텍처 디자인하기 (0) | 2025.12.11 |
|---|---|
| [SpringBoot] SpringBoot에서 S3로 업로드하는 3가지 방법 비교하기 (Stream, MultipartFile, AWS Mulitpart) (0) | 2025.05.15 |
| [SpringBoot] Bulk insert를 활용한 속도 개선 (0) | 2025.05.09 |
| [SpringBoot] Facade 패턴을 활용한 동시성 트랜잭션 범위 문제 해결하기 (1) | 2025.05.08 |
| [SpringBoot] 토스페이먼츠 결제 연동하기 (1) | 2025.04.29 |