TIL22 - 뉴스피드 프로젝트 (좋아요 기능, 코드리뷰, 안티패턴)
1. 요구사항
ex1) 업그레이드 뉴스피드
- 정렬 기능
- 수정일자 기준 최신순
- 좋아요 많은 순
- 기간별 검색 기능
- 예) 2025.04.07 ~ 2025.04.08 동안 작성된 뉴스피드 게시물 검색
ex3) 좋아요
- 게시물 및 댓글 좋아요 / 좋아요 취소 기능
- 사용자가 게시물이나 댓글에 좋아요를 남기거나 취소할 수 있습니다.
- 본인이 작성한 게시물과 댓글에 좋아요를 남길 수 없습니다.
- 같은 게시물에는 사용자당 한 번만 좋아요가 가능합니다.
내가 게시글을 담당해서 이참에 게시글 CRUD 외에 게시글과 관련된 부분들을 추가로 작업하였다.
2. 게시글 좋아요 개발 흐름
1) 기능 목적
- 사용자가 게시글에 좋아요를 누를 수 있도록 한다.
- 동일 사용자는 한 번만 좋아요가 가능하다
- 다시 누르면 좋아요가 취소되는 토글 형식으로 구성한다
- 본인이 작성한 게시글엔 좋아요를 누를 수 없다.
✨ 왜 좋아요는 토글 방식으로 구현했을까?✨
토글 방식이란?
- 처음 누르면 좋아요 등록
- 다시 누르면 좋아요 취소
- 즉, 하나의 엔드포인트(/posts/{postId}/like)로 좋아요 추가/취소를 동시에 처리 하는 방식
/like, /unlike 분리할 필요 없이 하나의 API에서 좋아요 추가/취소를 동시에 처리하는 방식이다. 이렇게 사용하면 API 설계가 단순화 된다. 또한 유저는 좋아요 버튼만 누르면, 상태는 자동으로 변경된다. 그래서 좋아요 상태만 변경하는 식으로 구현했다.
2) 엔티티 설계 Post <-> PostLike <-> User 관계
User 1 ────────┐
│ (1:N)
PostLike N ──────── 1 Post
- 하나의 유저는 여러 개의 PostLike를 가질 수 있음
- 하나의 Post도 여러 개의 PostLike를 가질 수 있음
@Table(name = "post_like")
public class PostLike extends BaseTime{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
//연관관계 편의 메서드를 위해 필요한 세터만 열었음
public void setPost(Post post) {
this.post = post;
}
//문제의 코드!!!!!!!!
public static PostLike of(User user, Post post) {
return PostLike.builder()
.user(user)
.post(post)
.build();
}
}
✅ Post - PostLike 따로 엔티티를 분리한 이유
- PostLike를 만들지 않았으면 User와 Post가 연결 되는 것인데 다대다 관계는 실무에서 거의 사용하지 않을 뿐더러 관계가 복잡해질 것 같아서 분리했다.
- 좋아요 누른 시간, 아이디 등을 따로 저장하기도 어렵다
- 그래서 중간 엔티티 `postLike` 엔티티를 통해 일대 다, 일대 다 이렇게 연결했다.
🔥🔥 우리 조의 고민!
여담이지만 게시글 좋아요/ 댓글 좋아요 테이블 분리에 대해서도 튜터님께 질문을 하고 같이 팀원들과 얘기를 나눠봤다.
- 게시글/댓글 좋아요 테이블을 분리할 경우
- 아무래도 강하게 결합된 도메인인데, 테이블 단위로 분리했을 때 발생할 수 있는 코드의 복잡성
- 하지만 조회 성능이나 쿼리 튜닝이 훨씬 편하다는 점
- 게시글/댓글 좋아요 테이블을 합칠 경우
- 게시글에 좋아요를 누르면 댓글 쪽에 null 대비를 해야함
어떤 기술을 쓸 때 왜 이렇게 사용했는지 생각해보라는게 튜터님의 답변이였고 테이블을 분리/합칠 경우에 생기게 되는 장단점에 대해서 생각해보는 시간을 가져보라고 했다.
우리가 내린 결론은 테이블 분리였다. 분리하면 Entity 관계도 깔끔해질거고 통합 테이블을 쓴다면 Post의 좋아요인지 Comment의 주머니인지 판단해야 하기 때문에 쿼리가 복잡해질 것 같았다.
그리고 테이블을 분리하게 되면 나중에 자유롭게 확장할 수 있어서 논의 끝에 좋아요를 게시글 / 댓글 테이블로 분리하게 되었다.
✅ 유저 연관 관계
- 한 명의 유저는 여러 게시글에 좋아요를 누를 수 있음 - `유저 1` -> `좋아요N`
- LAZY 전략을 써서 필요할 때만 유저 정보를 불러옴
- JoinColumn: 외래키는 `user_id`
✨ ✨ 지연 로딩을 사용해야 하는 이유✨ ✨
A엔티티와 B엔티티가 연관이 되어있다. JPA가 A 엔티티를 로드하면 A가 참조하는 B 엔티티까지 로드되어야 한다.
과연 이렇게 디비와 자주 소통하는 방식이 성능적으로 좋은걸까? ㄴㄴ
그래서 JPA(하이버네이트)는 성능 최적화를 위해, 연관된 엔티티는 가짜 객체(프록시)로 만들어 실제 엔티티 객체 생성을 지연시키는 전략을 구사하는데 이것을 지연 로딩(Lazy Loading) 이라고 한다.
하지만 바로 실제 엔티티를 로드하는 즉시 로딩이라는게 있는데 왜 즉시로딩은 권하지 않을까?
결론만 말하자면 즉시 로딩을 사용하면 개발자가 예상치 못한 여러 SELECT문이 추가로 실행되는 N+1 문제가 발생한다이게 뭘까? 나도 잘 몰라서 공부하려고 한다.
즉시로딩을 사용하게 되면 개발자가 제어할 수 없는 쿼리가 실행되는 불상사가 생기고 연관관계에서 많이 사용되는
@ManyToOne, @OneToOne은 디폴트가 즉시 로딩이므로, 지연로딩으로 꼭! 설정을 바꿔줘야한다.
✅ 생성자 및 정적 팩토리
객체 생성 시 new PostLike(...)보다 PostLike.of(...)를 쓰면 가독성 + 유지보수성이 용이할 듯 하여 정적 팩토리 메서드를 사용했다.
✅ 연관관계 편의 메서드
이 메서드는 Post 엔티티의 `addPostLike()`로 호출되기 위해 열어둔 메서드이다엔티티에선 세터를 전체 개방하는 것은 좋지 않아 이런 방법을 선택했다.
(이 부분은 내 생각이 잘못된 것 같아서 밑줄을 그었다.)
🔍 코드 리뷰 1 🔍
감사하게도 조원분이 코드리뷰를 꼼꼼하게 해서 내 코드의 문제점을 알게되었다. 바로 Entity에서 정적 팩토리 메서드를 넣은 문제이다
엔티티에 정적 메서드를 넣었는데 엔티티 클래스에 정적 팩토리 메서드(createPost)를 사용하는 대신, 레코드(Record) 형태나 DTO 중심 생성 방식으로 바꾸어보시는 건 어떨까요 ? Request DTO → Entity로 변환하는 책임은 서비스나 DTO 쪽에 두는 것이 더 적합할 것 같아서요 ! 역할분리나 책임에 대해 고민해보시는 것도 좋을 것 같습니다 ~~
라고 코드리뷰가 왔다. 객체의 생성 책임을 엔티티가 아닌 외부(DTO나 서비스)에 두라고 하신 말씀같다. 그래서 이 부분에 대해서 다시 생각해봤다.
✅ 왜 정적 팩토리 메서드를 Entity에서 분리할까?
📌 Entity 안에 생성 로직이 있을 경우 문제점
문제 | 설명 |
SRP 위반 | Entity는 "상태와 행위"를 나타내야 하는데, 외부 요청 객체에 의존하는 건 역할 범위가 넓어짐 |
테스트 어려움 | Entity 테스트가 의존성에 묶임 (예: request가 필요함) |
DTO 책임 침해 | 요청에 따라 엔티티를 만들 수 있는 정보를 이미 가진 건 DTO임 |
✅ 권장 방식: DTO → Entity 변환 책임은 서비스나 DTO가 가진다
1. DTO 내부에서 Entity로 변환하는 방식
public record CreatePostRequest(
String content,
String image
) {
public Post toEntity(User user) {
return Post.builder()
.user(user)
.content(this.content)
.image(this.image)
.likeCount(0)
.build();
}
}
2. 또는 서비스에서 직접 매핑(이 방법을 알려주셨다)
public Post createPost(CreatePostRequest request, Long userId) {
Post post = Post.builder()
.user(user)
.content(request.content())
.image(request.image())
.likeCount(0)
.build();
return postRepository.save(post);
}
✅ 전체 프로젝트에 있었던 Post, User 등등의 모든 Entity에 정적 팩토리 제거 하였고 RequestDto에 toEntity()를 사용해 변환 책임을 부여했다.
✅ 정리하자면
Entity는 상태만 가진다, 요청 기반 생성은 외부에서 수행 - 책임 분리
🔍 코드 리뷰2 🔍
일단 이 코드는 PostLike가 아닌 Post이다.
Post는 1이고 여러 PostLike를 가질 수 있어서 @OneToMany 로 연관관계를 설정했었다.
나는 @ManyToMany만 실무에서 거의 쓰이지 않는다고 알고있었다.
하지만 양방향 연관관계가 지양되어야할 안티 패턴이 될 수 있다는 피드백을 듣고
왜 이것을 사용했는지, 양방향 연관관계는 왜 안티패턴이 되는지에 대해 다시 생각해보는 시간을 가졌다.
먼저 왜 `OneToMany`가 안티패턴이 될 수 있을까?
🚫 이유 1: Lazy Loading + N+1 문제
- `Post`를 조회할 때마다 `postLikes`를 가져오면 쿼리가 n 개 추가로 나간다.
- 특히 리스트나 페이지 조회 시 성능 이슈가 발생할 수 있다.
🚫 이유 2: 연관관계의 "소유자"가 아니다.
- 무슨 소리냐 `mappedBy = "post"` 로 선언하면 insert/update의 책임이 PostLike 쪽에 있다.
그럼에도 내가 처음에 @OneToMany로 연관관계를 맺었던 이유는 아마
- 조회 대신에 카운트만 사용하니 조회하거나 조작하는데 부담이 없을 거란 판단.
- 또 cascade + orphanRemoval 로 부모 삭제 시 자식 자동 삭제! (게시글 삭제 시 좋아요도 같이 삭제)
- 편의 메서드를 작성해 양방향 연관관계를 맺어줌
✨ 요약 정리
정리하자면 @OneToMany는 N+1 문제, 정렬/페이징 어려움, 성능저하 등등의 이유로 안티 패턴이 될 수 있다만
지금 구조에선 조회 대신에 좋아요 개수만 카운트만 할 거고 좋아유 수도 많지 않거나 직접 접근을 하지 않기에
내 생각에는 지금 구조에선 이렇게 쓰는 것도 괜찮을 것 같다는 생각도 있는 반면
하지만 안티패턴이라는 것에 대해선 생각해볼 필요가 있는 듯 하여.
현재는 게시글 - 게시글 좋아요와 양방향 연관관계를 맺는 코드는 지웠다.
3) 레파지토리 설계
public interface PostLikeRepository extends JpaRepository<PostLike, Long> {
// 유저가 이미 이 게시글에 좋아요 했는지 확인 (중복 방지)
@Query("SELECT pl FROM PostLike pl WHERE pl.user.userId = :userId AND pl.post.id = :postId")
Optional<PostLike> findByUserIdAndPostId(@Param("userId") Long userId, @Param("postId") Long postId);
// 존재 여부만 확인 (boolean 반환)
@Query("SELECT COUNT(pl) > 0 FROM PostLike pl WHERE pl.user.userId = :userId AND pl.post.id = :postId")
boolean existsByUserIdAndPostId(@Param("userId") Long userId, @Param("postId") Long postId);
// 좋아요 취소 (delete)
@Modifying
@Query("DELETE FROM PostLike pl WHERE pl.user.userId = :userId AND pl.post.id = :postId")
void deleteByUserIdAndPostId(@Param("userId") Long userId, @Param("postId") Long postId);
}
`PostLikeRepository`는 `PostLike ` 엔티티를 기반으로 좋아요 관련 데이터 조회를 하는 인터페이스.
`JpaRepository<PostLike, Long>` 을 상속하므로 기본적인 CRUD는 이미 제공됐고, 추가로 커스텀 쿼리가 필요한 기능들을 `JPQL`로 구현했다.
1. findByUserIdAndPostId(...)
해당 유저가 해당 게시글에 좋아요를 눌렀는지 확인하기 위해, 반환 타입이 `Optional`
있으면 Optional.of(PostLike), 없으면 Optional.empty()
4) 서비스 설계
@Override
@Transactional
public void toggleLike(Long postId, Long userId) {
// 게시글 존재 여부 확인
Post post = postRepository.findById(postId)
.orElseThrow(() -> new CustomException(ErrorCode.POST_NOT_FOUND));
// 유저 존재 확인
User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
// 본인 글에 좋아요 금지
if (post.getUser().getId().equals(userId)) {
throw new CustomException(ErrorCode.SELF_LIKE_NOT_ALLOWED);
}
// 중복 방지
Optional<PostLike> like = postLikeRepository.findByUserIdAndPostId(userId, postId);
if (like.isPresent()) {
//이미 좋아요를 눌렀다면 삭제
postLikeRepository.delete(like.get());
post.decrementLikeCount();
} else {
// 좋아요 추가
PostLike postLike = PostLike.of(user, post);
postLikeRepository.save(postLike);
post.incrementLikeCount();
}
}
✅ 1. @Transactional을 사용하는 이유
예외 발생 시 롤백
동시성 문제 최소화 : 여러 유저가 동시에 좋아요를 누를 경우에 대해 데이터 정합성 확보
데이터 일관성 : 좋아요 추가/삭제와 likeCount 증감은 반드시 함께 성공 or 실패 해야함
예를 들어 좋아요를 눌러서 등록됐는데 likeCount가 안오르면 → ❌ 데이터 불일치
👉 트랜잭션은 "모든 작업을 하나의 묶음"으로 처리해줘서 데이터 무결성을 보장한다
✅ 2. `postRepository.findById(postId)` - 게시글이 실제로 존재하지 않으면 예외 던짐
✅ 3. `userRepository.findById(userId)` - 세션에서 가져온 userId가 DB에 존재하는지 확인
✅ 4. 본인 게시글 좋아요 금지 - 서버에서 반드시 막아야 함
✅ 5. 좋아요 여부 확인 - 이미 눌렀는지 확인, 있으면 좋아요 취소, 없으면 좋아요 추가
서비스 로직 정리
[요청] POST /posts/{id}/like
|
└── [Service Layer]
├─ 1. 게시글 유효성 검사
├─ 2. 유저 유효성 검사
├─ 3. 본인 게시글인지 확인
├─ 4. 좋아요 여부 조회
│ ├─ 있다 → 삭제 + -1
│ └─ 없다 → 저장 + +1
└── 5. 트랜잭션으로 한번에 처리
5) 컨트롤러 설계
@PostMapping("/{postId}/like")
public ResponseEntity<ApiResponseDto<Void>> toggleLike(@PathVariable Long postId,
@SessionAttribute("userId") Long userId) {
postService.toggleLike(postId, userId);
return ResponseEntity
.ok(ApiResponseDto.success(SuccessCode.TOGGLE_LIKE, "/api/posts/" + postId + "/like"));
}
- POST: 리소스의 상태를 변경하는 작업 → 좋아요 추가 or 제거
- /{postId}/like:
- /posts/{id}는 특정 게시글
- /like는 해당 게시글에 대한 좋아요 행위를 의미
- POST를 사용한 이유
- 단순 토글 행동에 가깝기 때문에,
- 또한 클라이언트에서 한 요청으로 상태 변경을 유도하므로 POST가 적합하다 생각
- `@PathVariable Long postId`
- url값 중에서 {postId} 값을 바인딩 해줌
- 어떤 게시글에 좋아요를 누를 건지 식별하는 핵심 값
- @SessionAttribute("userId") Long userId
- 현재 로그인 된 유저의 ID를 세션에서 추출
6) 테스트 코드 작성
힘들어서 나중에쓸랭.
@Test
@DisplayName("좋아요 등록 및 취소 토글 테스트")
void toggleLikeTest() throws Exception {
User writer = User.builder().username("작성자").build();
em.persist(writer);
User liker = User.builder().username("좋아요누를사람").build();
em.persist(liker);
Post post = Post.createPost(writer, "좋아요 테스트", null);
em.persist(post);
em.flush();
session.setAttribute("userId", liker.getId()); // 작성자와 다른 유저
// 1. 좋아요 누르기
mockMvc.perform(post("/api/posts/" + post.getId() + "/like")
.session(session))
.andExpect(status().isOk());
em.flush();
em.clear();
// 2. 좋아요 수 증가 확인
Post likedPost = em.find(Post.class, post.getId());
assertThat(likedPost.getLikeCount()).isEqualTo(1);
// 3. 좋아요 다시 눌러서 취소
mockMvc.perform(post("/api/posts/" + post.getId() + "/like")
.session(session))
.andExpect(status().isOk());
em.flush();
em.clear();
// 4. 좋아요 수 감소 확인
Post unlikedPost = em.find(Post.class, post.getId());
assertThat(unlikedPost.getLikeCount()).isEqualTo(0);
}
@Test
@DisplayName("존재하지 않는 게시글 좋아요 시도 시 404 오류")
void toggleLikeNonExist() throws Exception {
Long nonExistPostId = 9999L;
User liker = User.builder().name("좋아요누르는사람").build();
em.persist(liker);
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", liker.getUserId());
mockMvc.perform(post("/api/posts/" + nonExistPostId + "/like")
.session(session))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("존재하지 않는 게시글입니다."));
}
3. 마치며
깃 사용이 매우매우 어려워서 사소한 사고도 쳤지만
ㅎㅎ
PR을 통해 코드 리뷰를 받아서 배운 점들이 많았다.
팀원분이 꼼꼼하게 코드 리뷰를 해주셔서 다시 생각한 부분도 많았고 오늘 하루 유익한 시간을 보냈다.