N+1 관련해서 질문있습니다.
17
작성한 질문수 1
안녕하세요. 우선 좋은 강의 제작해주신 토비님께 항상 감사하고 있어요. 이제 배운지 1년된 왕초보입니당..
혼자 배워보면서 개인 프로젝트를 만들고 있는데 JPA를 사용하고 있어요.
제가 궁금한 것이... N+1 관련한 문제입니다. 아 일단 프로젝트 주제는 복식부기 가계부에요.
@Entity
...
public class Journal extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ledger_id", nullable = false, updatable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Ledger ledger;
...
@OneToMany(mappedBy = "journal", fetch = FetchType.LAZY, cascade = CascadeType.ALL,
orphanRemoval = true)
private List<EntryLine> entries = new ArrayList<>(2);
...
public EntryLine getEntryLine(EntrySide side) {
switch (side) {
case CREDIT : this.entries.stream().filter(line -> line.isCredit()).findFirst()
.orElseThrow(...);
case DEBIT : this.entries.stream().filter(line -> line.isDebit()).findFirst()
.orElseThrow(...);
default : throw new ...
}
}
...
// Service에서 저장되기 전에 호출
public void validateSavable() {
...
validateJournalSave();
}
private void validateJournalSave() {
AccountType debit = getEntryLine(EntrySide.DEBIT).getAccountType();
AccountType credit = getEntryLine(EntrySide.CREDIT).getAccountType();
if(!this.transactionType.isValidPlacement(debit, credit)) {
throw new ...
}
}
}Journal Class에서 EntryLine List에 접근하고 있어요.
그리고 EntryLine Class는 이렇게 생겼어요.
@Entity
...
public class EntryLine extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "journal_id", nullable = false, updatable = false)
private Journal journal;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "account_id", nullable = false)
private Account account;
...
// private package 접근제어자 사용
// Account는 Category를 참조중이에요.
AccountType getAccountType() {
return this.account.getCategory().getAccountType();
}
}
거래가 저장되기 전에 Journal : validateJournalSave() 에서 this.transactionType에 따라 차변과 대변에 올바르게 위치하고 있는지 검사한 후 저장하고 있는데 이것을 생성과 수정할 때 두 곳에서 사용하고 있어요.
Ledger에 5개 Category가 있고, Account는 그 Category를 참조하고 Category에서만 AccountType이 있어요.
Journal이 각 EntryLine의 AccountType을 얻기 위해
Journal -> EntryLine -> Account -> Category -> getAccountType() 이렇게 흘러가네요.
이렇게 접근해도 설계상 괜찮은걸까요?
Journal을 저장할때는 @Query 사용해서 Fetch Join으로 필요한 Account를 가져오고 있는 상황이에요.
Journal이라는 엔티티가 비즈니스 로직 수행을 위해서 다른 엔티티의 필드까지 깊게 참조?? 가져오도록 설계하는게 옳은건지 모르겠어요.
답변 3
1
안녕하세요.
질문의 내용을 보니 두 가지로 구분해서 답변을 드리는게 좋겠습니다.
우선은 도메인 오브젝트 관점에서 설계에 대해서 먼저 생각해봐야 합니다. 이 경우엔 N+1은 관심사가 아니죠. 여기서 중요한 것은 각 오브젝트가 자신이 가진 정보를 가지고 필요한 기능을 제공하면서 다른 오브젝트와 적절하게 협력하는가의 문제입니다.
객체 설계 관점에서는 대체로 잘 작성된 코드로 보입니다. Journal 안에 Entry를 모두 가지고 있다면 이를 이용해서 필요한 Entry를 가져와 로직을 수행하도록 되어있네요. 그런데 질문하신 EntryLine의 getAccountType() 부분은 로직은 바르게 작성되어 있지만 꽤 여러 단계를 거쳐서 타입을 가져오는 구조로 짜여있네요. 이런 경우 모델 설계가 변경이 되는 경우 영향을 받을 수 있을 겁니다. 그래서 이렇게 여러 단계로 네비게이션해서 정보를 가져오는 것이 EntryLine 뿐 아니라 여기저기 나타날 가능성도 있겠죠. 그러면 중간에 한번 이 작업을 위임하는 코드를 넣는 것이 좋지 않을까 싶네요. 예를 들어 Account 내에 getType()을 만들고, 여기서 카테고리를 거쳐서 타입을 가져오게 만들면, Account를 사용하는 여러 로직에서 간단하게 Account의 타입 정보가 필요하니 요청해서 사용하면 되겠다라고 쉽게 이해하고 사용할 수 있을 겁니다. 나중에 타입 정보를 저장하는 방식이 바뀌더라도 Account.getType()을 쓰는 코드에는 문제가 없겠죠. EntryLine의 getAccountType()도 지금 구조를 그대로 가지고 있으면 될 듯합니다.
그런데 질문에서 N+1 얘기를 꺼내신 것은 이걸 JPA 엔티티로 사용할 경우 조회 성능의 문제가 발생할 여지가 있다고 보는 것이죠. N+1의 N의 크기가 얼마냐가 문제입니다. N이 만약 100만개라면, EntryLIne 하나를 찾기 위해 100만개를 로딩해야겠죠. 이게 N+1 형태로 일어나면 더 큰 문제일 것이겠네요. 만약 Journal 안에 EntryLine이 꽤 많다면, 그리고 매번 그 전부를 다 사용할 것이 아니라면 OneToMany를 lazy로 썼다고 하더라도 이걸 매번 전체를 로딩하는 것이 시스템 성능에 미치는 영향이 있지 않을까도 고민이 필요할 듯합니다. 물론 양이 작거나 매번 전체 entry를 다 써야 한다면 fetch join을 사용해서 한번에 조회해두고, 필요한 부분을 사용하면 될 것입니다. 그게 아니라면 필요한 EntryLine을 가져오는 기능을 Repository에 만들어두고 도메인 서비스 또는 애프리케이션 서비스에서 EntryLine을 가져와서 로직을 수행하는 코드로 만드는 방법을 트레이드 오프해야할 것입니다.
한번에 전체 컬렉션을 메모리로 다 로딩하는 문제를 제외하고 단지 N+1이 문제라면, 현재 lazy loading 구조로 관계가 연결되어 있으니 리포지토리에 Journal을 단독으로 가져오고 필요할 때 lazy하게 연관 엔티티를 다시 조회하는 방식과 Journal부터 EntryLine, Account, Category까지 한번에 fetch join으로 가져오는 두 가지 메소드를 만들고, 이후 데이터를 사용하는 방식에 따라서 선택하도록 하면 될 것입니다. N+1을 해결한다고 매번 매번 fetch join으로 Journal에 연결된 나머지 엔티티 전체를 로딩하는 것도 바람직하지 않기 때문에 선택적으로 데이터를 가져오는 리포지토리 구성을 해두는 것이 실전에서는 현실적입니다.
더 궁금하신 것이 있으면 댓글을 남겨주세요.
1
안녕하세요.
질문의 내용을 보니 두 가지로 구분해서 답변을 드리는게 좋겠습니다.
우선은 도메인 오브젝트 관점에서 설계에 대해서 먼저 생각해봐야 합니다. 이 경우엔 N+1은 관심사가 아니죠. 여기서 중요한 것은 각 오브젝트가 자신이 가진 정보를 가지고 필요한 기능을 제공하면서 다른 오브젝트와 적절하게 협력하는가의 문제입니다.
객체 설계 관점에서는 대체로 잘 작성된 코드로 보입니다. Journal 안에 Entry를 모두 가지고 있다면 이를 이용해서 필요한 Entry를 가져와 로직을 수행하도록 되어있네요. 그런데 질문하신 EntryLine의 getAccountType() 부분은 로직은 바르게 작성되어 있지만 꽤 여러 단계를 거쳐서 타입을 가져오는 구조로 짜여있네요. 이런 경우 모델 설계가 변경이 되는 경우 영향을 받을 수 있을 겁니다. 그래서 이렇게 여러 단계로 네비게이션해서 정보를 가져오는 것이 EntryLine 뿐 아니라 여기저기 나타날 가능성도 있겠죠. 그러면 중간에 한번 이 작업을 위임하는 코드를 넣는 것이 좋지 않을까 싶네요. 예를 들어 Account 내에 getType()을 만들고, 여기서 카테고리를 거쳐서 타입을 가져오게 만들면, Account를 사용하는 여러 로직에서 간단하게 Account의 타입 정보가 필요하니 요청해서 사용하면 되겠다라고 쉽게 이해하고 사용할 수 있을 겁니다. 나중에 타입 정보를 저장하는 방식이 바뀌더라도 Account.getType()을 쓰는 코드에는 문제가 없겠죠. EntryLine의 getAccountType()도 지금 구조를 그대로 가지고 있으면 될 듯합니다.
그런데 질문에서 N+1 얘기를 꺼내신 것은 이걸 JPA 엔티티로 사용할 경우 조회 성능의 문제가 발생할 여지가 있다고 보는 것이죠. N+1의 N의 크기가 얼마냐가 문제입니다. N이 만약 100만개라면, EntryLIne 하나를 찾기 위해 100만개를 로딩해야겠죠. 이게 N+1 형태로 일어나면 더 큰 문제일 것이겠네요. 만약 Journal 안에 EntryLine이 꽤 많다면, 그리고 매번 그 전부를 다 사용할 것이 아니라면 OneToMany를 lazy로 썼다고 하더라도 이걸 매번 전체를 로딩하는 것이 시스템 성능에 미치는 영향이 있지 않을까도 고민이 필요할 듯합니다. 물론 양이 작거나 매번 전체 entry를 다 써야 한다면 fetch join을 사용해서 한번에 조회해두고, 필요한 부분을 사용하면 될 것입니다. 그게 아니라면 필요한 EntryLine을 가져오는 기능을 Repository에 만들어두고 도메인 서비스 또는 애프리케이션 서비스에서 EntryLine을 가져와서 로직을 수행하는 코드로 만드는 방법을 트레이드 오프해야할 것입니다.
한번에 전체 컬렉션을 메모리로 다 로딩하는 문제를 제외하고 단지 N+1이 문제라면, 현재 lazy loading 구조로 관계가 연결되어 있으니 리포지토리에 Journal을 단독으로 가져오고 필요할 때 lazy하게 연관 엔티티를 다시 조회하는 방식과 Journal부터 EntryLine, Account, Category까지 한번에 fetch join으로 가져오는 두 가지 메소드를 만들고, 이후 데이터를 사용하는 방식에 따라서 선택하도록 하면 될 것입니다. N+1을 해결한다고 매번 매번 fetch join으로 Journal에 연결된 나머지 엔티티 전체를 로딩하는 것도 바람직하지 않기 때문에 선택적으로 데이터를 가져오는 리포지토리 구성을 해두는 것이 실전에서는 현실적입니다.
더 궁금하신 것이 있으면 댓글을 남겨주세요.
0
안녕하세요, 인프런 AI 인턴입니다. Jubuseong님!
프로젝트에서 N+1 문제와 관련된 질문을 주셨고, 이를 해결하기 위한 접근 방식에 대한 조언을 드리겠습니다.
N+1 문제에 대한 이해
N+1 문제는 주로 JPA에서 발생하며, 하나의 쿼리를 통해 기본 엔티티를 로드한 후 관련된 여러 엔티티를 각각의 쿼리로 로드하는 시나리오입니다. 이는 많게는 N+1개의 쿼리를 실행하게 되어 비효율적일 수 있습니다.
현재 설계에서의 문제점
Journal 엔티티가 EntryLine 리스트를 통해 AccountType 정보를 가져오기 위해 여러 엔티티를 탐색하는 부분은 깊은 객체 탐색을 유발할 수 있습니다. 특히, @ManyToOne(fetch = FetchType.LAZY) 설정으로 인해 각 EntryLine 객체별로 Account와 Category에 접근할 때 추가적인 쿼리가 발생하여 N+1 문제를 야기할 수 있습니다.
해결책 제안
Fetch Join 사용: 이미 사용 중이신
@Query및 Fetch Join을 통해Journal과 관련된EntryLine및 그 이상의 계층(Ledger, Account, Category)을 한번의 쿼리로 가져올 수 있습니다. 이를 통해 N+1 문제를 해결할 수 있으며, 관련 객체가 모두 초기화된 상태로 작업을 수행할 수 있습니다.DTO 사용: 깊은 탐색이 필요하지 않다면, 관련 데이터만 포함한 DTO를 사용하는 것도 좋은 방법입니다. DTO는 필요한 데이터만 포함하므로 성능 상 이점이 있으며, 필요 없는 객체 탐색을 예방할 수 있습니다.
쿼리 성능 모니터링 및 튜닝: 툴을 사용하여 JPA 쿼리가 생성하는 실제 SQL을 모니터링하고, 성능 상 병목이 없는지 확인하세요. 쿼리를 최적화하거나 인덱스를 추가해 성능을 개선할 수 있습니다.
추가적인 리소스
저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.
도메인 모델에서 관계와 규칙을 구분하는 방법
0
57
2
헥사고날 아키텍처에서의 배치, 시큐리티, 비동기 이벤트 처리는 어떻게 하나요?
0
126
2
어댑터에서 도메인에 직접 의존하는 경우에 대해
0
129
2
Member 도메인이 PasswordEncoder를 받는 구조 질문 있습니다.
0
116
2
MemberService와 EmailSender 책임 분리에 대한 질문
0
107
2
NonNullApi를 NullMarked로 대체하라고 합니다.
0
130
2
39. 문서와 코드 다듬기 updateInfo 테스트 질문 있습니다.
0
78
2
Repository Adapter 설계에 대해 피드백을 부탁드립니다
0
111
2
헥사고날 part2 강의 출시 예정일 문의 드립니다.
0
257
2
PT 문의사항
0
101
1
초기 어플리케이션 구동 시 compose.yml 파싱 오류
0
151
2
애플리케이션의 JPA 리턴과 도메인 모델
0
129
2
애그리거트 루트의 하위 도메인들의 depth가 깊어질 때 문의
0
137
2
페이징 처리를 해야한다면 어떻게 해야할까요?
0
194
2
애그리거트의 repository
0
119
2
Domain Expert가 정확히 어떤 역할을 하는 사람인가요?
0
236
1
회원 애플리케이션 서비스 테스트 (1)
0
107
2
정적 팩토리 메서드 관련 질문드립니다!
0
110
2
spotbug + @NonNullApi 로만 Null 방어가 될까요?
0
129
2
required 포트에 관해서
0
94
2
혹시 다음 편은 언제쯤 오픈할까요?
0
169
2
서비스 단위 테스트 코드 작성
0
98
2
domain 모듈에 entity를 정의한다고 했을때
0
101
2
여러 엔티티의 조합으로 리포트를 제공해야할 때
0
80
2





