[Spring] 뉴스피드 프로젝트 트러블슈팅 기록
뉴스피드 프로젝트를 진행하면서 겪은 트러블슈팅의 과정들에 대한 기록입니다. 해당 프로젝트의 전체소스는 여기 에서 확인하실 수 있습니다.
트러블슈팅
⭐️ 주제
EmbeddedId로 복합키를 사용할 때 JPA save() 시 불필요한 SELECT 쿼리 발생 문제와 Persistable을 통한 해결
🔥 발생
BoardLike Entity에서 EmbeddedId(boardId + userId)를 PK로 사용하여 좋아요를 저장할 때,
boardLikeRepository.save(BoardLike.of(user, board)) 호출 시 예상과 달리 INSERT만 실행되지 않고,
SELECT + INSERT가 연이어 발생하였다.
즉, 저장 전에 불필요한 조회 쿼리가 항상 발생하는 현상이 생겼다.
LikeService.java
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
32
33
@Transactional
public LikeToggle.Response toggleBoardLike(Long boardId, Long loginUserId) {
Board findBoard = boardRepository.findByIdOrElseThrow(boardId);
if (findBoard.isOwnedBy(loginUserId)) {
throw new GlobalException(LikeErrorCode.CANNOT_LIKE_OWN_BOARD);
}
User findUser = userRepository.findByIdOrElseThrow(loginUserId);
BoardLikeId boardLikeId = BoardLikeId.builder()
.boardId(findBoard.getId())
.userId(findUser.getId())
.build();
boolean liked = boardLikeRepository.existsById(boardLikeId);
if (liked) {
boardLikeRepository.deleteById(boardLikeId);
boardRepository.decrementLikeCount(findBoard.getId());
} else {
boardLikeRepository.save(BoardLike.of(findBoard, findUser)); // 좋아요 저장 (문제 발생 지점)
boardRepository.incrementLikeCount(findBoard.getId());
}
long likeCount = boardRepository.findLikeCountById(findBoard.getId());
return LikeToggle.Response.builder()
.liked(!liked)
.likeCount(likeCount)
.build();
}
BoardLike.java
of 메서드에서 내부적으로 BoardLikeId를 생성하는 것을 확인할 수 있다.
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
@Getter
@Entity
@Table(name = "board_likes")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BoardLike extends BaseEntity {
@EmbeddedId
private BoardLikeId id;
@MapsId("boardId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", nullable = false)
private Board board;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
private BoardLike(BoardLikeId id, Board board, User user) {
this.id = id;
this.board = board;
this.user = user;
}
public static BoardLike of(Board board, User user) {
return new BoardLike(new BoardLikeId(board.getId(), user.getId()), board, user);
}
}
🔍 원인
- JPA의
save()는 Entity가 새로운 데이터인지 여부에 따라 동작 방식이 다르다.- 새로운 Entity라 판단되면
persist()실행 (바로INSERT) - 기존 Entity라 판단되면
merge()실행 (DB 조회 후INSERTorUPDATE)
- 새로운 Entity라 판단되면
EmbeddedId를 사용하면 ID 값이 null이 아니므로 JPA는 해당 Entity를 이미 존재하는 데이터로 판단한다.- 그 결과,
merge()로직이 실행되며 항상SELECT쿼리를 실행한 뒤INSERT가 발생하는 현상이 나타난다.
✅ 해결
Entity가 새로운 데이터임을 직접 JPA에게 명시하기 위해 Persistable<T> 인터페이스를 구현하였다.
isNew() 메서드를 오버라이딩하여 createdAt 값이 없는 경우에만 새로운 Entity로 간주하도록 변경했다.
BoardLike.java
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
32
public class BoardLike extends BaseEntity implements Persistable<BoardLikeId> {
@EmbeddedId
private BoardLikeId id;
@MapsId("boardId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", nullable = false)
private Board board;
@MapsId("userId")
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
private BoardLike(BoardLikeId id, Board board, User user) {
this.id = id;
this.board = board;
this.user = user;
}
public static BoardLike of(Board board, User user) {
return new BoardLike(new BoardLikeId(board.getId(), user.getId()), board, user);
}
// isNew() 메서드를 오버라이딩
@Override
public boolean isNew() {
return getCreatedAt() == null;
}
}
이로써 save() 호출 시 항상 persist()가 실행되어 불필요한 SELECT 쿼리 없이 바로 INSERT만 수행되도록 개선할 수 있었다.
💡 결론
@EmbeddedId를 사용할 때는 JPA가 Entity를 항상 “기존 데이터”로 잘못 판단할 수 있다.- 이로 인해 저장 시 불필요한 조회 쿼리가 추가되며, 좋아요와 같이 트래픽이 많은 기능에서는 성능 저하로 이어질 수 있다.
Persistable을 구현하여isNew()를 명시적으로 정의하면, JPA가 Entity의 생명주기를 올바르게 인식할 수 있고INSERT만 수행되는 효율적인 저장 로직을 보장할 수 있다.