3 분 소요

KEEPER R2 프로젝트를 진행하면서 맡은 Task 중 회원의 프로필을 조회하고 뿌려주는 기능이 있었습니다. KEEPER R2 홈페이지 배포가 8월 말에 계획이 되어있어 API의 성능은 미쳐 고려하지 못했습니다. 성능 문제는 이슈 발행 후 나중에 고쳐보자고 결정했는데 현재 대부분의 기능이 만들어졌고 여유가 있어 프로필 조회 API 최적화를 해봤습니다.

아래는 김영한님의 실전! JPA API 개발편 강의를 공부하면서 KEEPER R2 프로젝트에 적용해보는 과정을 정리했습니다.

V1 - 엔티티를 직접 노출

엔티티를 직접 노출하는 방법은 절대 사용해서 안됩니다. 개발의 관점에서 본다면 엔티티가 수정되었을 때 연관된 모든 API의 스펙이 깨져버리고 장애가 발생할 확률이 높아집니다. 보안의 관점에서 본다면 “페이지 내 중요정보 노출”1 평가항목에 걸릴 수 있습니다.

@GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }

V2 - 엔티티를 DTO로 변환

API에서 데이터를 뿌려줄 때 무조건 DTO로 감싸서 반환해야 합니다. 엔티티가 수정되었을 때 API의 스펙이 DTO로 고정되어 있기 때문에 장애가 발생하지 않습니다.

현재 KEEPER R2 프로젝트에서 대부분의 API는 V2에 머물러 있습니다. 트래픽이 적기 때문에 V2의 API로도 충분히 돌아가지만 고성능의 애플리케이션을 개발하기 위해서는 V3~V5까지의 성능 튜닝을 바라봐야 합니다. 아래는 KEEPER의 프로필 조회 V2 API입니다.

@GetMapping("/{memberId}/profile")
  public ResponseEntity<MemberProfileResponse> getMemberProfile(
      @PathVariable @PositiveOrZero long memberId
  ) {
    return ResponseEntity.ok(memberService.getMemberProfile(memberId));
  }
  public MemberProfileResponse getMemberProfile(long memberId) {
    return MemberProfileResponse.of(memberFindService.findById(memberId));
  }
public static MemberProfileResponse of(Member member) {
    return MemberProfileResponse.builder()
        .id(member.getId()) 
        .emailAddress(member.getProfile().getEmailAddress().get()) 
        .realName(member.getRealName()) 
        .birthday(member.getProfile().getBirthday()) 
        .thumbnailPath(member.getThumbnailPath()) 
        .generation(member.getGeneration())
        .point(member.getPoint())
        .memberType(member.getMemberType().getType().name())
        .memberJobs(member.getJobs())
        .follower(getFollower(member))
        .followee(getFollowee(member))
        .build();
  }

  private static List<MemberFriendResponse> getFollower(Member member) {
    return member.getFollowee().stream()
        .map(Friend::getFollower)
        .map(MemberFriendResponse::from)
        .toList();
  }

  private static List<MemberFriendResponse> getFollowee(Member member) {
    return member.getFollower().stream()
        .map(Friend::getFollowee)
        .map(MemberFriendResponse::from)
        .toList();
  }

일대다인 관계가 3개(memberJobs, follower, folloee)로 N + 1 문제가 발생하게 됩니다. 테스트 과정에서 Follower, Followee가 각각 3명씩일 때 SQL Query가 12번 나가는 것을 확인했습니다.

그럼 Follower, Followee 수가 많아진다면 얼마나 많은 쿼리가 날라갈까요? 아래는 Follower, Followee수가 각각 1000명일 때 발생하는 쿼리 수와 시간입니다. 굉장히 많은 쿼리가 날라가는 것을 알 수 있고 시간도 오래걸리는 것을 확인할 수 있습니다.

쿼리횟수

V3 - 엔티티를 DTO로 변환 + 페치조인

대부분의 성능문제는 V3의 페치조인과 @BatchSize로 해결할 수 있습니다. 페치 조인은 “JPQL에서 성능 최적화를 위해 제공하는 기능”이고.2 @BatchSize는 한번 쿼리가 실행될 때 가져오는 데이터의 양을 설정하는 기능입니다.

public MemberProfileResponse getMemberProfile(long memberId) {
    return MemberProfileResponse.of(
        memberFindService.findById(memberId)
    );
  }
@BatchSize(size = 1000)
@DynamicInsert
@DynamicUpdate
@Getter
@Entity
@EqualsAndHashCode(of = {"id"})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "member")
public class Member {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column(name = "id", nullable = false, updatable = false)
  private Long id;

    @OneToMany(mappedBy = "member", cascade = ALL, orphanRemoval = true)
  private final Set<MemberHasMemberJob> memberJob = new HashSet<>();

  @OneToMany(mappedBy = "follower", cascade = ALL, orphanRemoval = true)
  private final Set<Friend> follower = new HashSet<>();

  @OneToMany(mappedBy = "followee")
  private final Set<Friend> followee = new HashSet<>();

위의 코드는 @BatchSize를 조절해 성능 최적화를 한 코드입니다. 단지 엔티티위에 @BatchSize 어노테이션만 붙여도 엄청난 성능 최적화를 하는 것을 확인할 수 있습니다.

쿼리횟수

아래의 코드는 쿼리의 수를 최대한 줄이고 속도도 빠른 완성본 코드입니다. ToOne 관계는 모두 JOIN FETCH를 사용했고 memberJob 컬렉션 한 개만 JOIN FETCH를 했습니다. Follower, Followee는 배치 사이즈를 이용해 성능을 끌어 올렸습니다.

  @Query("SELECT m FROM Member m " +
      "JOIN FETCH m.memberType " +
      "JOIN FETCH m.memberJob mj " +
      "JOIN FETCH mj.memberJob " +
      "WHERE m.id = :id")
  Optional<Member> findMemberProfileWithFetchJoin(@Param("id") long id);

주의사항

  1. 둘 이상의 컬렉션은 페치조인을 하면 안됩니다.
  2. 컬렉션 페치조인을 하면 페이징 API를 사용하면 안됩니다.

첫번째 문제는 OutOfMemory 에러가 발생할 수 있습니다. Follower, Followee가 각각 2000명씩 넣고 한꺼번에 퍼올렸을 때 에러가 발생을 하고 Follower, Followee 수를 낮춰서 성공해도 최적화 진행하기 전보다 쿼리 수는 줄어도 전체적인 API 실행 시간은 기하급수적으로 늘어나게 됩니다.

참고) 둘 이상의 컬렉션이 뜻하는 의미는 1:N:N 관계와 1:N 관계가 두 번있는 관계(=Follower, Followee) 두 가지 모두 입니다.3

  @Query("select distinct m from Member m " +
      "join fetch m.memberType " +
      "join fetch m.memberJob " +
      "join fetch m.follower " +
      "join fetch m.followee " +
      "where m.id = :id")
  Optional<Member> findMemberByFetchJoin(@Param("id") long id);

쿼리횟수

두번째 문제도 OutOfMemory 에러가 발생할 수 있고 원하는 페이징 결과가 안 나올 수 있습니다. 페치 조인을 하면 데이터 ROW의 수가 다 쪽의 개수만큼 증가하기 때문이고 메모리에서 페이징을 하기 때문에 OutOfMemory가 발생할 수 있습니다.

성능 개선 결과

아래는 성능 개선 결과입니다.

최적화_결과

마치며

엔티티 조회 방식은 페치조인과 배치사이즈 옵션을 넣어서 해결이 안되면 서비스의 트래픽이 정말 많을 겁니다. 그럼 사실 캐시를 사용해서 해결해야지 DTO로 직접 조회한다고 해서 해결이 될까? 이게 약간 의문입니다. (실전! 스프링 부트와 JPA 활용 - 김영한)

개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다. 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고 간다. 엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적할 수 있다. 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다. (실전! 스프링 부트와 JPA 활용 - 김영한)

애플리케이션의 성능을 극한으로 올리고 싶어 V5, V6까지 코드를 작성하는데 복잡도가 너무 올라가고 개발 생산성은 수직으로 떨어지는 경험을 했습니다. 위의 인용문처럼 개발자는 항상 성능 최적화와 코드 복잡도 사이에서 줄타기를 잘 해야 한다고 생각합니다.

참고문헌

[1] 중요 정보 노출

[2] (참고: 자바 ORM 표준 JPA - 김영한)

[3] (참고: 실전! 스프링 부트와 JPA 활용2 - 김영한) URL: https://www.inflearn.com/questions/218027/%EC%BB%AC%EB%A0%89%EC%85%98%EC%97%90-%EB%8C%80%ED%95%9C-fetch-join%EC%9D%80-1%EA%B0%9C%EB%A7%8C-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC-%ED%95%9C%EB%8B%A4%EC%97%90-%EB%8C%80%ED%95%9C-%EC%A7%88%EB%AC%B8

카테고리:

업데이트: