웹툰 개인화 추천 알고리즘을 작성하면서 만났던 문제에 대해 기술하려고 한다.
문제
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를 사용할 수 없다.
- 메모리에서 페이징을 처리하기 때문에 데이터가 많을 시 예외가 발생할 수 있다.