N+1 문제 발생
Cabi에 알람 기능이 추가되면서, AlarmStatus 테이블이 새로 추가되었다. 현재 진행 중인 리팩토링 작업 후 점검 차원에서 각 기능별 동작을 확인하고 있었는데, 뜬금없는 N+1 문제가 터졌다.
LocalDateTime now = LocalDateTime.now();
LentHistory userLentHistory = lentQueryService.getUserActiveLentHistoryWithLock(userId);
List<LentHistory> cabinetLentHistories =
lentQueryService.findCabinetActiveLentHistories(userLentHistory.getCabinetId());
Cabinet cabinet =
cabinetQueryService.getCabinetsWithLock(userLentHistory.getCabinetId());
int userRemainCount = cabinetLentHistories.size() - 1;
cabinetCommandService.changeUserCount(cabinet, userRemainCount);
lentCommandService.endLent(userLentHistory, now);
lentRedisService.setPreviousUserName(
cabinet.getCabinetId(), userLentHistory.getUser().getName());
LocalDateTime endedAt = userLentHistory.getEndedAt();
BanType banType = banPolicyService.verifyBan(endedAt, userLentHistory.getExpiredAt());
if (!banType.equals(BanType.NONE)) {
LocalDateTime unbannedAt = banPolicyService.getUnBannedAt(
endedAt, userLentHistory.getExpiredAt());
banHistoryCommandService.banUser(userId, endedAt, unbannedAt, banType);
}
if (cabinet.isLentType(SHARE)) {
LocalDateTime expiredAt = lentPolicyService.adjustSharCabinetExpirationDate(
userRemainCount, now, userLentHistory);
cabinetLentHistories.stream().filter(lh -> !lh.equals(userLentHistory))
.forEach(lh -> lentCommandService.setExpiredAt(lh, expiredAt));
}
Java
복사
이는 어드민이 유저가 대여 중인 사물함을 강제로 반납하는 로직인데, 리팩토링 이전의 코드이다.
로직의 유일한 getUser를 사용하는 조회 쿼리는 이와 같다.
하지만 이번 알람 기능 업데이트 이후 해당 로직에서 user + alarmStatus 조회가 유저 수 만큼 발생하기 시작했다.
문제 원인 분석과 Cabi 구조에 대한 고민
위 로직을 보면 alarmStatus를 조회하거나 값을 가져다가 사용하는 일이 전혀 없다. 로직을 디버깅 해보면 getUser를 할 때, alarmStatus를 조회하는 쿼리를 수행한다.
User와 AlarmStatus는 양방향 OneToOne 관계를 가지고 있는데, LAZY 로딩 설정이 안되어있다. LAZY 로딩이 설정이 되어 있지 않으니, User를 꺼내 사용할 때 EAGER 로딩으로 AlarmStatus를 추가로 조회해오는 것이다.
이와 같이 양쪽 모두 LAZY 로딩을 걸어주고 다시 테스트를 해보면,
User만 Join에서 빠졌을 뿐 여전히 N+1 문제가 발생한다. 양쪽 모두 LAZY를 걸어줬고 AlarmStatus를 사용하지도 않는데, 이걸 왜 조회해오지…?
관련 검색을 하던 중 OneToOne 양방향 매핑은 만악의 근원이라는 글을 읽게 되었다. 이 글을 읽고 Cabi의 구조에 대해 다시 한 번 고민해보게 되었다.
현재 AlarmStatus의 구조는 User와 1:1 매칭되어 User 한 명당 AlarmStatus 한 개씩 반드시 있어야 한다. 거기에 더해 User에서 쉽게 AlarmStatus를 조회하기 위해 양방향 관계를 추가했다. 사실 해당 알람의 구조를 결정할 때도 그랬지만, 나는 저 AlarmStatus 속성이 결국 User가 선택한 알람 방식이고, 각 User마다 하나씩 들고 있는 부분인데 User의 속성 개념으로 봐야하는게 아닌가? 라는 생각이 들었다. 결국 Cabi의 패키지 구조가 기능별로 묶여있고, 알람과 관련된 EventHandler와 여러 기능들이 이미 묶여있는 채로 존재하기 때문에 납득하고 넘어갔다.
이를 다시 생각해보니, 양방향 OneToOne 관계라는 것이 결국 그냥 내부의 속성으로 들어가는게 맞지 않았을까?라는 생각이 든다. 저 AlarmStatus가 User 내부로 들어왔을 때 User의 정규화에도 위배되지 않기도 하고, 구조 결정의 이유 중 하나였던 새로운 알람 기능이 생기면 User의 컬럼이 늘어나는건 AlarmStatus 테이블을 따로 두어도 똑같이 컬럼이 늘어나지 않나?라는 생각이 들었다.
사실 AlarmStatus가 User의 컬럼으로 들어가게되면 모두 해결될 문제이긴 하지만, 다시 위의 N+1 문제로 돌아와서 원인을 분석해보자면 OneToOne 양방향 매핑 관계에서는 EAGER 로딩이 수행될 수 있다고 한다. OneToOne에서는 mappedBy를 통해 연관관계 주인을 나타내는데, 정확히는 OneToOne 양방향 매핑 관계에서 연관관계의 주인이 아닌 쪽에서 항상 EAGER 로딩이 수행된다. 이는 OneToOne의 연관관계 주인, OneToMany, ManyToOne 관계에서는 발생하지 않는 현상인데, 그 이유로는 영속성 컨텍스트에서 연관 관계를 가지는 상대 엔티티의 존재 유무에 대한 처리 방식 때문이다.
OneToOne 연관관계의 주인과 ManyToOne의 경우에는 해당 엔티티가 연관 관계의 주인이 된다. 연관 관계의 주인이기 때문에 상대 엔티티에 대한 PK를 FK로 들고 있고, 그로 인해 식별관계라면 상대 엔티티의 존재 여부를 FK가 있는가 없는가로 판단할 수 있다. 항상 PK를 들고 있는 영속성 컨텍스트에서도 상대 엔티티의 존재를 확신하고 별도의 조회 없이 상대 엔티티 프록시를 생성하여 영속화하게 된다.
OneToMany의 경우에는 연관관계의 주인이 아니지만, 상대 엔티티가 존재하지 않더라도 일단 영속성 컨텍스트의 엔티티 안에 프록시 객체를 만들어 놓고 없으면 Empty Collection을 반환하면 되기 때문에 별도의 조회를 수행하지 않는다.
하지만 OneToOne 양방향 매핑에서 연관관계의 주인이 아닌 쪽에서의 엔티티는 영속화 할 때 상대의 존재에 대해 확인할 방법도 없고 Collection과 같은 대안도 없다. 그렇기 때문에 상대 엔티티가 존재한다는 확인을 위해 조회 쿼리를 EAGER로 수행하게 되는 것이다.
해결 방법
사실 이와 같은 OneToOne nullable 문제를 해결하기 위해서는 여러 대안이 있다.
1.
OneToOne 관계를 OneToMany, ManyToOne으로 분리하기
이 방법을 적용하면, 관계를 억지로 분리시켜 구조에 대한 이해를 방해하고 직관적이지 못한 느낌이라 테스트해보지 않았다. 별로 권장되는 방법은 아닌 것 같다.
2.
Fetch Join으로 한 번에 가져오기
여타 N+1 문제와 동일하게 Fetch Join을 통해 한 번에 조회해와서, 이후에 추가적인 조회를 방지하는 방법이다.
이와 같이 Fetch Join을 적용하고 나면,
이와 같이 한 번에 데이터를 들고 온 이후로 추가적인 쿼리는 발생하지 않는다.
하지만 위 로직에서는 AlarmStatus에 대한 부분이 전혀 사용되지 않음에도 불필요하게 테이블에 접근해 데이터를 가져오는 오버헤드가 있어서 불완전한 해결 방법이라 느껴졌다. 실제 AlarmStatus를 사용한다면 이 방법도 주효하겠지만, 위 로직에서는 AlarmStatus와 관련된 전부가 오버헤드이다.
•
optional 속성 이용하기
OneToOne의 속성 중 nullable에 대한 optional 속성이 있다. 기본값은 true로 되어있어 값이 null(상대 엔티티가 없음)이 들어올 수도 있음으로 간주되는데, 이를 false로 바꾸어 not null(상대 엔티티가 항상 있음)을 보장해준다면 Hibernate는 별도의 조회 없이 프록시를 생성한다.
이처럼 속성을 추가한 후 동작시켜보면 추가적인 쿼리가 발생하지 않는 것을 확인할 수 있다.
•
한쪽 연관관계를 끊어 단방향 연관관계로 바꾸기
알람 기능 업데이트로 인해 가장 많은 N+1이 발생하는 층 전체 사물함 조회의 경우 User + AlarmStatus 조회 쿼리가 100번 언저리로 발생한다. 문제는 QueryDSL을 사용하는 층 전제 조회 로직의 N+1 문제는 위 optional 방식이나 Fetch Join 방식으로는 해결이 전혀 되지 않았다.
다양한 시도를 했지만 정확한 원인은 찾지 못했고 QueryDSL 내부의 무언가 작용해 적용이 안되는 것 같다는 의견 외에 원인을 추측할만한 근거조차 얻지 못했다. 하지만 양방향 OneToOne의 구조가 가진 여러 문제점들을 고려해서, 해당 기능을 업데이트 하신 분과 논의 끝에 한쪽 관계를 끊어 User ← AlarmStatus의 단방향 OneToOne으로 변경하도록 결정했다.
변경 이후 기존의 어드민 강제 반납 로직은 물론, 층 전체 사물함 조회의 경우에도 N+1 문제가 전부 발생하지 않는 것을 확인했다.