현재 DND 8기에서 진행하고 있는 프로젝트 하루 블럭의 문제 해결기이다.
문제의 발단은 통계(Report API)를 만들면서였다.
통계로써 월간 리포트를 만드는 일이 주어졌고, 여기서 정말 다양한 문제가 발생했다. 일단 월간 리포트의 다양한 기능을 위해서 제일 처음 해야했던 일은 해당 사용자의 월간 블록과 연관된 Task를 전부 가져오는 일이었다. 이를 위해 MonthlyBlockGetDao를 추가하였다.
일단 제일 초기의 MonthlyBlockGetDao이다.
public List<Block> getMonthlyBlock(String userEmail, Integer month) {
return queryFactory.selectFrom(block)
.leftJoin(block.tasks, task)
.fetchJoin()
.where(block.user.email.eq(userEmail)
.and(block.date.month().eq(month)))
.orderBy(block.createdAt.asc())
.fetch();
}
조회 상에서 N+1이 발생했다.
@BeforeEach
void setUp() {
User testUser = userRepository.save(User.builder()
.name("test")
.email("test@gmail.com")
.role(Role.USER)
.imagePath("")
.build()
);
for (int i = 1; i < 9; i++) {
Block savedBlock = blockRepository.save(Block.builder()
.user(testUser)
.title("content" + i)
.blockLock(true)
.blockColor("#111111")
.date(dateParser.parseDate("2021-03-0" + i))
.emotion("😁")
.build()
);
savedBlock.getId();
for (int j = 0; j < 3; j++) {
taskRepository.save(Task.builder()
.block(savedBlock)
.status(true)
.contents("task" + j)
.build()
);
}
}
}
@Test
@DisplayName("월별 블록을 정상적으로 조회할 수 있다.")
void getMonthlyBlock() {
//given
String userEmail = "test@gmail.com";
Integer month = 3;
//when
List<MonthlyBlockGetDTO> monthlyBlock = monthlyBlockGetDao.getMonthlyBlock(userEmail,
month);
//then
assertThat(monthlyBlock.size()).isEqualTo(8);
for (MonthlyBlockGetDTO block : monthlyBlock) {
assertThat(block.getTasks().size()).isEqualTo(3);
}
}
일단 다음과 같이 테스트 이전에 8개의 블록과 각각의 블록에 3개씩 태스크를 추가해두었는데, 블록의 개수가 24개로 나오게 되었다. 이는 oneToMany를 leftJoin하다보니 뻥튀기가 발생한 것을 빠르게 파악할 수 있었고, distinct를 추가함으로서 빠르게 해결할 수 있었다. 하지만 여기서 더 큰 문제가 발생했다. 무슨 짓을 해도 블록에 연관된 태스크 사이즈가 0으로 뜨는 문제가 발생했다. 그럴 수 밖에 없기는 했다.
일단 우리가 아는 leftJoin을 생각해보면,
+------------+--------------+----------------------+
| name | phone | selling |
+------------+--------------+----------------------+
| Mr Brown | 01225 708225 | Old House Farm |
| Miss Smith | 01225 899360 | NULL |
| Mr Pullen | 01380 724040 | The Willows |
| Mr Pullen | 01380 724040 | Tall Trees |
| Mr Pullen | 01380 724040 | The Melksham Florist |
+------------+--------------+----------------------+
이렇듯이 A의 한 열에 B의 여러 줄이 대응되기는 하지만 당연히 이러한 것이 자동으로 리스트로 들어오지는 않는 것이다.(당연하다 ;;)
이러한 문제를 처음으로 접했고, 고민을 하던 도중에 다음과 같은 접근을 시도했다.
일전에 QueryDsl 강의를 들은 적이 있지만 이러한 키워드를 접해본 적이 없어 굉장히 당황스러웠다. 이는 QueryDsl에서 제공하는 Result Aggregation (결과 집합) 으로
Result Aggregation이란 Querydsl의 결과를 특정 키를 기준 삼아 그룹화하는 것을 이야기한다.
여기서는 위의 Left Join의 결과를 토대로 Block을 기준으로 여러 줄의 Task를 Task List로 매핑하여 그룹화하여 붙여주는 것을 진행한다. 그리고 이 와중에 작은 문제가 발생했다.
public List<Block> getMonthlyBlock(String userEmail, Integer month) {
Map<Block, List<Task>> transform = queryFactory.selectFrom(block)
.where(block.user.email.eq(userEmail)
.and(block.date.month().eq(month)))
.orderBy(block.createdAt.asc())
.leftJoin(block.tasks, task)
.transform(groupBy(block).as(list(task)));
return transform.entrySet().stream()
.map(entry -> {
Block block = entry.getKey();
List<Task> tasks = entry.getValue();
block.setTasks(tasks);
return block;
})
.collect(Collectors.toList());
}
다음과 같이 QueryDsl의 Transform을 활용하도록 업데이트하였고, 위의 테스트에서 전체 Block 개수 8과 각 Block당 태스크 3개가 정상적으로 찍히는 것을 확인할 수 있었다. 여기서 간단한 퀴즈 위 코드에는 약간의 문제가 있었다.
JPA를 어느 정도 사용한 여러분이라면 알 수 있었을 것이다. 위에서 return 문에서 setTask를 통해 영속성 Context에서 관찰중인 Block 객체에 그대로 tasks를 넣었고, 이는 DirtyChecking을 통한 불필요한 Update문을 생성했다. 물론 내용 자체가 완전히 이전과 동일하기에 정상적인 작동에서는 문제가 없지만 불필요한 Update문이 많이 추가되었고 이를 방지하기 위해 다음과 같이 변경하였다.
public class MonthlyBlockGetDao {
private final JPAQueryFactory queryFactory;
public List<MonthlyBlockGetDTO> getMonthlyBlock(String userEmail, Integer month) {
Map<Block, List<Task>> transform = queryFactory.selectFrom(block)
.where(block.user.email.eq(userEmail)
.and(block.date.month().eq(month)))
.orderBy(block.createdAt.asc())
.leftJoin(block.tasks, task)
.transform(groupBy(block).as(list(task))); //querydsl dto로 keep를 아예 배제하는 방식으로 개선
return transform.entrySet().stream()
.map(entry -> {
Block block = entry.getKey();
List<Task> tasks = entry.getValue();
List<MonthlyTaskGetDTO> taskGetDTOS = tasks.stream().map(MonthlyTaskGetDTO::new)
.collect(Collectors.toList());
return MonthlyBlockGetDTO.builder()
.id(block.getId())
.title(block.getTitle())
.tasks(taskGetDTOS)
.build();
})
.collect(Collectors.toList());
}
}
Block과 Task 모두 Dto로 교체하였다. 해당 문제를 겪고, 다양한 글들을 찾아보니, 최대한 원 객체가 아닌 Dto로 필요한 정보들을 전달하는 것이 좋다고 생각하여 다음과 같이 변경하였다. 물론 이를 통해 이전과 같이 DirtyChecking을 통한 Update문이 발생하는 것을 줄인 것은 덤이다.
여기까지 되었으면 문제가 다 해결되었을 것이라고 생각할 것이고, 나 역시도 똑같이 생각했다. 하지만 문제는 여기서 끝이 아니었다. 물론 현재까지의 변경을 통해서 Dao, Service, Api(RestAssured) Test 모두 통과하였고, 적절한 값을 반환하고 있었다. 하지만 쿼리가 비정상적으로 많이 나가는 것을 발견했다.
대충 봤을 때 맨 처음의 block을 불러오는 쿼리가 1 나머지 쿼리가 N으로 보인다. 그렇지만 여기서 많은 의문이 들었다. keep를 갑자기 왜 불러오지???
@Entity
@Getter
@Table(name = "block")
@NoArgsConstructor
public class Block extends BaseEntity {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
@Column(name = "block_lock")
private Boolean blockLock;
@Column(name = "title")
private String title;
@Column(name = "block_color")
private String blockColor;
@DateTimeFormat(pattern = "yyyy-MM-dd")
@Column(name = "datetime")
private Date date;
@Column(name = "emotion")
private String emotion;
@OneToMany(mappedBy = "block", cascade = CascadeType.REMOVE)
private List<Task> tasks = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_email")
private User user;
@OneToOne(mappedBy = "block", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY)
private Keep keep;
@Builder
public Block(Boolean blockLock, String title, String blockColor, Date date,
String emotion, User user) {
this.blockLock = blockLock;
this.title = title;
this.blockColor = blockColor;
this.date = date;
this.emotion = emotion;
this.user = user;
}
}
keep은 블럭의 저장을 위해서 추가한 것으로 기존에 실수로 fetchType Lazy가 누락되어 있어 추가해주었다. 물론 추가한 이후에도 똑같이 해당 문제가 발생했다. 가장 근본적인 문제는 로직 가운데에서 어디에서도 사용하지 않은 keep이 불러지고 있다는 것이었다. 내가 모르는 cascade, 등과 같은 property로 인한 문제일까하여 해당 property를 제외하고 테스트해보아도 동일했다. Dto에 keep이 전혀 포함이 되어 있지 않고, lazy로 한 이후에도 발생하는 이유를 도저히 찾지 못했다.
그래서 연관관계에 문제가 있다고 판단하였다. 그러던 와중에 xxxToOne이지만 lazy로 설정되지 않은 많은 관계들을 확인할 수 있었다. 하지만 이러한 관계들을 모두 lazy로 변경하더라도 여전히 모든 대상 block들이 keep를 불러오는 것을 확인할 수 있었다. 긴 고뇌 끝에 찾은 이유는 다음과 같았다.
JPA에서 @OneToOne 양방향 연관 관계에서 연관 관계의 주인이 아닌 쪽 엔티티를 조회할 때, Lazy로 동작할 수 없다.
다시 말해,
연관관계의 주인이 호출할 때는 지연 로딩이 정상적으로 동작하지만, 연관관계의 주인이 아닌 곳에서 호출한다면 지연 로딩이 아닌 즉시 로딩으로 동작한다는 것을 알 수 있다.
Block의 Entity Code이다.
@Entity
@Getter
@Table(name = "block")
@NoArgsConstructor
public class Block extends BaseEntity {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
@Column(name = "block_lock")
private Boolean blockLock;
@Column(name = "title")
private String title;
@Column(name = "block_color")
private String blockColor;
@DateTimeFormat(pattern = "yyyy-MM-dd")
@Column(name = "datetime")
private Date date;
@Column(name = "emotion")
private String emotion;
@OneToMany(mappedBy = "block", cascade = CascadeType.REMOVE)
private List<Task> tasks = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_email")
private User user;
@OneToOne(mappedBy = "block", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY)
private Keep keep;
@Builder
public Block(Boolean blockLock, String title, String blockColor, Date date,
String emotion, User user) {
this.blockLock = blockLock;
this.title = title;
this.blockColor = blockColor;
this.date = date;
this.emotion = emotion;
this.user = user;
}
}
현재 연관관계의 주인은 Block이 아니라 Keep으로 설정되어 있다. 그렇기에 Block의 입장에서 Entity를 불러올 때, Lazy가 적용되지 않아 Eager로 불리면서 해당 이슈가 발생한 것이다. 그렇다면 이러한 상황에서 해당 이슈가 발생하는 이유는 무엇일까?
왜 이런 문제가 발생하는지 알아보기 전에, 지연 로딩이 동작하는 매커니즘을 이해해야 한다.
이렇게 지연 로딩으로 설정이 되어있는 엔티티를 조회할 때는 프록시로 감싸서 동작하게 되는데, 프록시는 null을 감쌀 수 없기 때문에 이와 같은 문제점이 발생하게 된다. 즉, 프록시의 한계로 인해 발생하는 문제이다.
여기까지만 적는 다면 이해하기 조금 힘들 수 있다.
DB의 관점에서 살펴보자. 결국 외래키는 한 쪽에만 들어간다. 여기서는 연관관계의 주인인 Keep에 들어가게 된다.
고로, Block라는 테이블에는 Keep을 참조할 수 있는 컬럼이 존재하지 않는다. 따라서 Block이 어떤 Keep에 의해 참조되고 있는지 알 수 없다.
Block이 어떤 Keep에 의해 참조되고 있는지 알 수 없다는 뜻은 만약 Keep이 null이더라도 Block는 이 사실을 알지 못한다는 것이다.
만약 Keep가 null이 아니라고 해도, Block의 입장에서는 Keep이 null인지 null이 아닌지 확인할 방법이 없다.
따라서 Keep의 존재 여부를 확인하는 쿼리를 실행하기 때문에 지연 로딩으로 동작하지 않는 것이다
OneToOne 양방향 매핑에서 연관관계의 주인이 아닌 쪽에서 조회하게 되면 프록시 객체를 생성할 수 없기 때문에 지연 로딩으로 설정해도 즉시 로딩으로 동작하게 된다.
그래서 해결은 어떻게 하나요?
여기서는 OneToOne을 FetchJoin하여 한 번에 불러오는 방법을 사용하였다.
추가적으로 아쉽게도 Transform 자체는 FetchJoin과 연결하여 사용이 불가능하다(FetchJoin을 하는 순간 Tuple이 반환되고 해당 값을 Transform을 통해 cast 할 수 없다). 그리하여 Block과 Task를 각각 불러오고 이를 Transform을 활용하여 list 매핑을 진행해주었다. 이 때, 조회한 Block List를 활용하여 Task를 in으로 한 번에 모두 불러올 수 있도록 하였다.
다음은 최종적인 MonthlyBlockGetDao이다.
@Service
@Transactional
@RequiredArgsConstructor
public class MonthlyBlockGetDao {
private final JPAQueryFactory queryFactory;
public List<MonthlyBlockGetDTO> getMonthlyBlock(String userEmail, Integer month) {
List<Block> blocks = queryFactory.selectFrom(block)
.leftJoin(block.tasks, task).fetchJoin()
.leftJoin(block.keep, keep).fetchJoin()
.where(block.user.email.eq(userEmail)
.and(block.date.month().eq(month)))
.orderBy(block.createdAt.asc())
.fetch();
List<Task> tasks = queryFactory.selectFrom(task)
.where(task.block.in(blocks))
.fetch();
Map<Block, List<Task>> transform = tasks.stream()
.collect(Collectors.groupingBy(Task::getBlock));
return transform.entrySet().stream()
.map(entry -> {
Block block = entry.getKey();
List<Task> taskList = entry.getValue();
List<MonthlyTaskGetDTO> taskGetDTOS = taskList.stream()
.map(MonthlyTaskGetDTO::new)
.collect(Collectors.toList());
return MonthlyBlockGetDTO.builder()
.id(block.getId())
.title(block.getTitle())
.tasks(taskGetDTOS)
.build();
})
.collect(Collectors.toList());
}
}
이를 통해 결국 초기에 1 + N(그 달의 블록 개수) 만큼의 쿼리를 1(블록 조회) + 1(태스크 조회)로 2번만에 해결할 수 있도록 하였다. 하루를 블록 단위로 나누어 일정을 기록하는 앱 특성상, 하루에 4개 정도의 블록만 작성하더라도 한번 요청에 100개 이상의 쿼리가 발생할 수 있었다. 이러한 사태를 방지할 수 있어 다행이다.
JPA를 사용할 때는 간편함에 매몰되지 않고 쿼리를 잘 확인하여 성능에 유의해야겠다.