Home 추천 알고리즘에서의 update 쿼리 N+1 문제
Post
Cancel

추천 알고리즘에서의 update 쿼리 N+1 문제

웹툰 개인화 추천 알고리즘을 작성하면서 만났던 문제에 대해 기술하려고 한다.

문제

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
@Entity  
@Getter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
public class Recommendation extends BaseEntity {  
  
    @Id @GeneratedValue  
    private Long id;  
  
    @OneToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "account_id")  
    private Account account;  
  
    @OneToMany(fetch = FetchType.LAZY)  
    @JoinColumn(name = "webtoon_id")  
    private Set<Webtoon> recommWebtoon = new HashSet<>();  
  
    private Long evaluationCount;  
  
    @Builder  
    public Recommendation(Account account, Set<Webtoon> recommWebtoon, Long evaluationCount) {  
	    this.account = account;  
        this.recommWebtoon = recommWebtoon;  
        this.evaluationCount = evaluationCount;  
    }  
}

위와 같이 유저들의 개인별 추천 웹툰을 담는 Recommendation 엔티티가 있다.

이 엔티티의 존재 이유는 추천 결과를 저장해두고, 만약 유저가 새롭게 평가한 웹툰이 없다면 다시 추천 요청을 해도 해당 엔티티의 정보를 조회해 가져다 줄 뿐 새롭게 추천 로직을 동작해 서버에 부하를 주는 것을 방지하기 위함이었다.

해당 데이터의 evaluationCount, 즉 평가 개수가 다르다면 새롭게 추천 로직을 동작시켜 새로운 추천 웹툰들을 Recommendation 엔티티에 다시 저장해야 한다.

위 엔티티에서 추천 웹툰들을 OneToMany 관계로 정의하고 있다.

여기서 문제가 발생한다.

새롭게 추천 결과를 저장할 때, 매핑이 OneToMany로 되어있기 때문에 Webtoon 엔티티의 값이 하나하나 업데이트가 되는 것이다.

즉 한 번의 추천 결과를 저장하는데, 만약 추천 결과 웹툰의 개수가 30개라면 30개의 업데이트 쿼리가 발생한다.

즉 한 번의 insert 쿼리에 N개의 update 쿼리가 발생하는 N+1문제가 발생했다.

  • N+1 update 쿼리 문제
  • Recommendation 엔티티에만 Webtoon과의 관계를 설정해 Webtoon 테이블에는 webtoon_id가 업데이트 됨.

해결 방안

  • 단순하게 하나의 엔티티에만 OneToMany 관계를 설정하지 않음.
  • 추천 테이블과 웹툰 테이블에 중간 테이블을 두어 ManyToOne 관계를 통해 설계 조정.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RecommendationWebtoon {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "recommendation_id")  
    private Recommendation recommendation;  
  
    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "webtoon_id")  
    private Webtoon webtoon;  
  
    public RecommendationWebtoon(Recommendation recommendation, Webtoon webtoon) {  
	    this.recommendation = recommendation;  
        this.webtoon = webtoon;  
    }  
}

위 처럼 RecommendationWebtoon 이라는 중간 테이블을 하나 설정해두고, ManyToMany 관계 대신, 각각 ManyToOne 관계로 매핑한다.

  • ManyToMany를 사용하지 않는 이유(복습)
    • ManyToMany를 사용하면 단순 PK, FK값만 들어가게 된다.
    • 따라서 Recommendation과 Webtoon 관계에서 새로운 추가적인 정보가 필요할 때 컬럼을 추가할 수 없다.

그리고 Recommendation 엔티티를 보자.

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
@Entity  
@Getter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
public class Recommendation extends BaseEntity {  
  
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @OneToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "account_id")  
    private Account account;  
  
    @OneToMany(mappedBy = "recommendation", cascade = CascadeType.ALL, orphanRemoval = true)  
    private Set<RecommendationWebtoon> recommWebtoons = new HashSet<>();  
  
    private Long evaluationCount;  
  
    public void addRecommendedWebtoon(Webtoon webtoon) {  
	    RecommendationWebtoon recommWebtoon = new RecommendationWebtoon(this, webtoon);  
        recommWebtoons.add(recommWebtoon);  
    }  
  
    public void clearRecommendedWebtoons() {  
	    this.recommWebtoons.clear();  
    }  
  
    public void updateEvaluationCount(Long evaluationCount) {  
	    this.evaluationCount = evaluationCount;  
    }  
 //...
}
  • OneToMany
    • cascade, orphanremoval
    • 이 설정을 통해 부모, 자식 객체간의 영속성 관리 및 삭제를 통해 Recommendation 엔티티를 통해 자식의 생명 주기를 관리한다.
    • 부모에만 신경써도 자식 엔티티도 관리가 된다.
    • 단, 특정 엔티티가 개인 소유할 때만 사용해야하는 옵션이다.
  • 편의성 메서드
    • 추천 웹툰을 저장할 때 단순 Webtoon 객체가 아닌 RecommendationWebtoon 객체를 저장해야 하기 때문에 편의성 메서드를 작성했다.
    • 추천 결과 값이 Webtoon 객체이기 때문이다.

이렇게 구현함으로써 웹툰 엔티티에 의도하지 않던 N개의 쿼리가 발생하는 문제는 해결되었다.

그리고 중간 테이블을 둠으로써 데이터의 관리가 용이해졌다.

또 다른 문제

그런데 이번엔 update 쿼리 대신 추천되는 웹툰의 개수만큼 insert 쿼리가 발생했다.

약 30개의 웹툰이 추천되기 때문에 30개의 insert 쿼리가 발생한다.

이 문제는 Batch_Size를 적용해 해결하기로 결정했다.

해결 과정

Batch_Size를 적용하려 했는데 insert 쿼리에는 적용이 되지 않았다.

이유를 알아보자.

  • ID 값 생성 방식이 IDENTITY 방식이기 떄문이다.
    • JPA Batch Insert는 쓰기 지연을 사용해 동작한다.
    • 그런데 id의 생성방식이 IDENTITY 전략이라면 insert 시 각 레코드에 대한 기본 키 값 생성을 DB에 의존한다.
    • 생성된 키를 검색해 엔티티에 할당할 수 있도록 각 insert문이 개별적으로 실행되는 것이다.

따라서 id값 생성 전략을 Sequence 방식으로 변경하면 insert 쿼리를 계속해서 실행하지 않는다.

  • Sequence 전략에서 고려해야 할 점
    • SEQUENCE 전략도 IDENTITY 전략과 동일한 문제가 있다. 데이터베이스가 직접 기본키를 생성해 주기 때문이다.
    • persist() 가 호출 되기 전 기본키를 가져와야 하므로 하이버네이트에서 hibernate: call next value for USER_PK_SEQ 을 실행하여 기본키를 가져온다.

그러나 allocationSize 의 크기를 적당히 설정하여 성능 저하를 개선시킬 수 있다.

선택

insert 문은 생성된 ID를 검색하기 위해 데이터베이스를 왕복하게 된다. 각 작업에는 자체 네트워크 왕복 및 데이터베이스 처리 시간이 필요하기 때문에 효율성이 떨어질 수 있다는 뜻이다.

Sequence 전략을 사용할 때 Hibernate는 시퀀스의 allocationSize 덕분에 데이터베이스에 대한 단일 왕복으로 ID 값 블록을 가져올 수 있다.

  • MySQL 환경에서는 Sequence 전략을 지원하지 않기 때문에 적용할 수 없었다.

최종 해결 방안

JPA Batch Insert는 사용할 수 없다는 것을 깨달았다.

insert 쿼리가 30개 정도 발생하는 부분은 사실 큰 부하라고 생각이 들진 않았지만 개선의 여지가 있는 것이기 때문에 계속해서 고민하게 되었다.

여러 방법을 찾아본 결과, JdbcTemplate를 사용해 Bulk 연산을 수행하는 방법이 있었다.

바로 구현 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override  
public void batchInsertRecommendationWebtoons(Set<RecommendationWebtoon> recommendationWebtoons) {  
	  String sql = "insert into recommendation_webtoon (recommendation_id, webtoon_id) values (?, ?)";  
  
	  List<Object[]> batchArgs = new ArrayList<>();  
  
      for (RecommendationWebtoon recommendationWebtoon : recommendationWebtoons) {  
	    Object[] values = {  
		        recommendationWebtoon.getRecommendation().getId(),  
                recommendationWebtoon.getWebtoon().getId()  
	    };  
        batchArgs.add(values);  
    }  
  
    jdbcTemplate.batchUpdate(sql, batchArgs);  
}
  • 일괄 삽입할 데이터를 List에 집어넣고 batchUpdate 메서드를 사용해 한 번에 DB에 삽입하도록 한다.

또 중요한 부분은 이렇게 작성한 벌크 연산 메서드를 어떻게 사용하는 지 이다.

이를 사용해 작성하는 추천 알고리즘에 대한 내용은 다음 포스트에서 작성한다.

(참고) 벌크 연산 적용 오류

NULL not allowed for column “ID”; 가 계속 발생했다.

해결하려고 많이 찾아보았는데, H2 DataBase의 문제라는 의견을 보게 되었고 어차피 배포 시 MySQL 을 사용해 배포할 생각이었어서 이참에 MySQL을 연동하기로 했다.

MySQL로 적용하고 Auto Increment가 잘 적용되면서 해결되었다.

결론

  • N+1 쿼리 개선 전

  • N+1 쿼리 중간 엔티티 생성 및 bulkInsert 적용

발생하는 insert 쿼리의 개수가 약 30개정도 뿐이여서 큰 성능 개선은 없었지만, 조금이라도 성능의 개선이 있었다. (약 7%)

그리고 데이터나 추천 웹툰의 개수가 커진다면 문제가 발생할 수 있는 부분들을 개선했기 때문에 의미가 있었다고 생각한다.

추가

추천 알고리즘을 리팩토링하면서 또 다른 N+1 문제가 발생하는 것을 발견했다.

  • 사용자의 추천 결과를 가져올 때 해당 웹툰의 정보에 접근을 위해 select 쿼리가 발생한다.
    • 추천 웹툰의 개수만큼 쿼리가 발생한다.
1
2
@Query("select r from Recommendation r join fetch r.recommWebtoons w where r.account.id = :userId")
Optional<Recommendation> findByAccountId(@Param(("userId")) Long userId);

추천 결과를 가져오고 그 부분을 DTO로 매핑하는 과정에서 Webtoon 정보를 가져오는데, 그 때 select 쿼리가 발생했다.

그러나 이곳에서만 select 쿼리 문제가 발생하는 것이 아니었다.

  • 사용자의 평가데이터를 가지고 추천 로직을 작성했다.
    • 그 평가 엔티티에도 당연히 Webtoon이 포함되어있고, 그 Webtoon에 대해 어떤 추천 로직을 적용하기 위해 getWebtoon()을 호출했다.
    • Evaluation.getWebtoon 에서도 N+1 문제가 발생했다.
    • 이 부분도 페이징 등의 작업이 필요없기 때문에 페치조인으로 해결한다.
1
2
@Query("select e from Evaluation e join fetch e.webtoon where e.account.id = :accountId")
List<Evaluation> findByAccountId(@Param("accountId") Long accountId);
  • 사용자의 추천 웹툰 리스트를 업데이트할 때 해당 리스트를 초기화하는데, delete 쿼리가 N개 발생한다.

cascade 설정 및 orphanRemoval 설정을 통해 기존에 변경 감지를 통한 delete를 해주었는데, Modifying 애노테이션을 통하여 delete 연산에 대해 bulk 처리를 할 수 있었다.

1
2
3
@Modifying
@Query("delete from RecommendationWebtoon rw where rw.recommendation.id = :recommendationId")
void deleteByRecommendationId(@Param("recommendationId") Long recommendationId);

delete 쿼리가 이제 한 번 밖에 나타나지 않는 것을 볼 수 있다.

이러한 개선 결과를 볼 수 있었다. (14%)

페치 조인의 단점 (복습)

  • 페치조인 대상에게는 별칭을 줄 수 없다.
  • 페이징API를 사용할 수 없다.
    • 메모리에서 페이징을 처리하기 때문에 데이터가 많을 시 예외가 발생할 수 있다.
This post is licensed under CC BY 4.0 by the author.

Spring Rest Docs vs Swagger

MBTI별 선호 웹툰 조회 시 발생한 N+1 문제