문제 상황
내 프로젝트에는 최근 DDD와 헥사고날 아키텍쳐로 리팩토링을 하게되었는데
JPA 영속성 객체인 UserEntity와 User라는 POJO 도메인을 따로 분리시켜서 관리하고있다
따라서 모든 도메인로직은 외부 의존성이 없는 순수 자바 코드로 구현하고, 영속성은 JPA Entity가 따로 맡아서 관리한다.
그렇게 분리하게되면, Entity 객체<-> Domian 객체간의 mapping이 필요하다.
(나의 경우 PersistenceAdapter에서 UserEntity를 DB에서 로드한후에, Mapper를통해 User 도메인으로 변환시켜서 Service에게 반환한다.)
그런데 여기서 문제가 발생했다.
UserEntity에는 OneToMany로 이루어진 관계들이 많았기에, N+1문제가 발생하는것이었다.
UserEntity.java
package com.kjs990114.goodong.adapter.out.persistence.mysql.entity;
import com.kjs990114.goodong.common.time.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity(name = "user")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(nullable = false, unique = true)
private String nickname;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
private String profileImage;
@Builder.Default
private Role role = Role.USER;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<ContributionEntity> contributions = new ArrayList<>();
@OneToMany(mappedBy = "follower", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private Set<FollowEntity> followings = new HashSet<>();
@OneToMany(mappedBy = "followee", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private Set<FollowEntity> followers = new HashSet<>();
public enum Role {
USER,
ADMIN
}
public static UserEntity of(Long userId){
return UserEntity.builder().userId(userId).build();
}
}
심지어, Follow Table과는 Many to Many관계이다.. (followers Set과 , followee Set)
그렇기때문에 한번 User도메인을 불러올떄마다
1.UserEntity 조회하는 쿼리
2. UserEntity에 속한 ContributionEntity를 조회하는 쿼리
3. Set<FollowEntity> followees, 즉 FollowEntity를 조회하는 쿼리
4. Set<FollowEntity> followers, 즉, FollowEntity를 조회하는 쿼리
이런식으로 User를 한번 불러올때마다 4번의 쿼리를 실행한다,, N + 1문제를 실제로 직면하게되었다.
첫번째 시도 : FetchType.Lazy -> 효율적이긴 하지만 근본적으로 N + 1을 해결 불가
처음에는 , 각 하위 애그리거트에 FetchType.Lazy로 그것을 실제로 쓸때만 쿼리문을 실행하도록 설정하였다.
그러나 적용 후에도, 여전히 추가 쿼리가 발생했다,
어차피 연관 관계에 있는 하위 애그리거트를 사용해야 하므로( Domain객체로 Mapping할때 어차피 다 접근해야 한다), 지연로딩을 시켜도 나중에 로딩을 하게된다.
(하지만, 필요없는 연관 관계는 로딩을 안하게되어 조금의 성능 향상은 가능하다. 하지만 근본적은 해결은 불가능하다.)
두번째 시도 : fetch join -> 성공 , 하지만 문제를 또 다시 발견
@Query("SELECT DISTINCT user FROM user user " +
"LEFT JOIN FETCH user.followers " +
"LEFT JOIN FETCH user.followings " +
"LEFT JOIN FETCH user.contributions " +
"WHERE user.userId = :userId AND user.deletedAt IS NULL")
Optional<UserEntity> findByUserId(@Param("userId") Long userId);
두번째로는, JPQL 문에서 지원하는 fetch join을 통해, 처음 User를 불러올때 모든 하위 애그리거트를 join해서
한번의 쿼리만 실행하게하는 전략을 선택하기로 하였다. 또한 하위 애그리거트가 존재하지 않을 경우를 대비해서
Left Fetch Join을 사용해보았다.
그 결과는.. 한번의 쿼리만 실행한다!
하지만 이렇게 평화롭게 해결될 줄만 알았지만 문제를 두가지 발견했다.
1. Fetch Join의 첫번 째 문제 -> 중복 튜플
Fetch join을 적용해서 반환된 User 도메인으로, user.getContributions()를 사용해 반환해보았더니.
중복된 결과가 나온다는 것이었다...
문제의 원인은 followers와 followees때문이었다. 유저아이디가 2를 팔로우한사람이 1,3,4인데,
Join을 할때 Cartesian곱을 하기때문에 똑같은 Contribution이 총 3번이 나타나게 된다.
2. Fetch Join의 두번째 문제 -> 너무 많은 record
한 루트 애그리거트에 One to Many 관계가 쌓이게되면, 이를 모두 Join하게되면 너무 많은 record 결과가 나타난다
예를들어 어떤유저의 팔로우가 100명, 팔로워가 200명, contribution이 100회였다고했을때,
모두 Fetch join을 하게되면 100 * 200 * 100 = 2,000,000의 레코드가 발생하며 이는 심각한 성능 감소를 야기한다.
나의 최종 해결 방안-> Follow를 루트 애그리거트로 쓰기
User 엔티티안에 Follower Set, Followee Set를 넣는것은 아무리 생각해봐도 비효율적인것같았다.
따라서 나는 Follow Entity를 User Entity의 하위 애그리거트가 아닌,
Follow를 하나의 도메인으로 , 즉 루트 애그리거트로 따로 빼는 방법을 선택했다.
그렇게한 이유는 지금이야 그냥 팔로우, 언팔로우 기능이기때문에, USER에 하위 애그리거트에 속해있는게 자연스럽지만,
시간이지나 기능이 확장되어 팔로우 요청, 상태관리 등 추가 기능이 필요한경우, 이는 User도메인과는 점점 거리가 멀어진다.
애초에 Social이라는 도메인으로 관리하는게 나중에 확장성에 있어서 적합해 보인다고 판단하였다.
기존 도메인 구조
user 도메인안에, User 루트 애그리거트와 하위 애그리거트 2개 (Contribution, Follow)가 존재한다.
수정된 구조
social이라는 도메인 영역을 생성후 ,
그곳에 Follow 를 상위 애그리거트로 관리하기로 결정하였다.
이렇게되면 User 도메인 로직에서 Follow를 관리하던 것을 모두 Follow 도메인으로 이전시켜야 한다.
Follow와 User가 서로 다른 애그리거트영역이 되었다. 따라서 User , Follow가 서로 직접 참조를 하고있는 중에서
FK로 간접 참조를 하는 방식으로 변경해야한다.
-> 이경우 follow count나 follow list를 조회할때, N + 1문제가 또다시 발생하게된다.
-> 내가 생각한 해결법은 CQRS 패턴을 도입하여, CUD는 Domain 모델을 사용하고, R는 DTO모델을 사용해 분리하는것이다.
(DTO모델을 반환하면 N + 1문제가 해결되는 이유는, 애초에 DTO모델을 반환하기위해서 한번의 쿼리로 여러 애그리거트끼리 JOIN문을 통해 가져오기 때문이다. 기존 도메인 모델을 반환하면, Mapping하는 과정에서 모든 직접 참조 애그리거트를 다 방문해야하기때문에 N + 1문제가 생긴다.(그렇다고 모든걸 다 Fetch join 하기에는 비효율적이다!))
+ 추가
https://www.youtube.com/watch?v=zMAX7g6rO_Y
이 영상을 보고 많은것을 깨달았다. JPA repository에서, 꼭 Entity만 반환하는것이아니라, DTO를 반환하도록 직접적으로 JPQL문법으로 맵핑이 가능하다.
이 경우에 필요없는 Entity 레코드까지 반환하지 않으므로, 컬럼의 양이 방대한 Entity를 사용하지만, 실제 필요한 컬럼은 그중 일부일 경우에 효과가 크다.
JPA에서 반환할 때 DTO 맵핑을 통해, 필요한 컬럼만 조회하게됨으로써 DB성능이 크게 향상된다.
하지만 이 방법의 단점도 존재한다.
1. repository 에서 API 스펙에 의존하게되며,
2. repository 쿼리 재사용이 힘들다(각각의 요청마다 다른 쿼리를 작성해야하므로 자칫하면 로직이 뚱뚱해 질 수 있다).
하지만 불가피하게 성능향상이 필요한경우, 이러한 방법을 사용하면 좋을것같다.
나 같은경우 만약 user에서 follower , followee 연관관계를 계속 사용한다고 하면,
쿼리 반환시 follower count, followee count로 집계후, DTO 반환을 하는것이 적합한 전략이라고 생각한다.
그렇게하지않고 일단 fetch join후 domain에서 follower.size()등을통해 follower count를 계산하면, db에 부하를 감당하지 못하여
자칫 메모리가 다운될 가능성이 존재한다.
느낀 점
도메인의 바운더리를 설정하는 것이 성능의 영향을 미친다는것을 깨닫게 되었다.
처음에 설계할떄 Follow를 User에 종속시키는것은 직관적으로 옳다고 생각해서 설계를 했는데, 성능 이슈를 고려하지 못했다.
한 Entity안에 너무 많은 정보를 담으려하지말고, 관심사의 분리가 필요하다는 것을 배웠다.
'Trouble Shootings > 성능 개선' 카테고리의 다른 글
[Spring Boot] 지도 서비스 대용량 DB 조회 성능 개선 (0) | 2025.03.05 |
---|---|
[Spring boot] Redis로 JWT 관리하기 (1) | 2024.10.09 |
[Springboot + Redis] 레디스를 이용한 캐싱을 통해 API 성능 개선하기 ( +JMeter) (0) | 2024.09.27 |