| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- NIO
- grafana
- mysql
- DevOps
- jvm
- Kubernetes
- kafka
- 백엔드
- CloudNative
- RDBMS
- GitOps
- redis
- Kotlin
- helm
- SpringBoot
- webflux
- Java
- 데이터베이스
- 성능 최적화
- selector
- JPA
- docker
- 트랜잭션
- 동시성제어
- spring boot
- prometheus
- monitoring
- 성능최적화
- netty
- 백엔드개발
- Today
- Total
유성
JPA의 진짜 설계 의도: 왜 N+1 문제를 방치하였나? 본문
이 글에서는 객체와 테이블 간 구조적 차이를 JPA가 어떻게 해소하는지, 그리고 그 과정에서 왜 'N+1 문제'가 발생하게 되는지를 다룹니다.
1. JPA란 무엇인가?
JPA는 자바 객체와 관계형 데이터베이스(RDB) 간의 매핑을 자동화하여 개발 생산성을 높여주는 ORM(Object-Relational Mapping) 기술입니다.
쉽게 말해, Java는 객체(Object), DB는 테이블(Table)을 중심으로 구성되기 때문에, 이 둘을 자연스럽게 이어주는 다리가 바로 JPA입니다.
2. 객체와 관계형 데이터 간의 연관관계 매핑
객체지향에서 객체는 다른 객체와 관계를 맺으며 동작합니다. 예를 들어, 고객이 쿠폰을 여러 개 소유하고 있다고 해보겠습니다.
class Customer {
String name;
List<Coupon> coupons;
}
Java에선 단지 List<Coupon> 으로 표현되는 구조지만, 이 관계를 SQL로 옮기면 다음과 같은 JOIN이 필요합니다.
SELECT c.name, cp.*
FROM customer c
JOIN coupon cp ON cp.customer_id = c.id;
단순히 고객 정보를 조회하고 싶을 뿐인데, 쿠폰 테이블까지 JOIN하게 되며 불필요한 조회가 발생합니다.
그래서 “그럼 리스트를 지우면 되지 않나요?“라는 질문을 할 수 있습니다. 맞습니다.
JPA에서 흔히 발생하는 문제의 대부분은 사라집니다.

하지만 그렇게 되면 더 근본적인 질문으로 이어집니다. "그렇다면 JPA는 왜 써야 하나요?"
즉, JPA는 객체 간의 관계를 테이블로 매핑하면서 발생하는 본질적 충돌을 감수하고도 객체지향 설계를 유지하기 위한 도구입니다.
'N+1 문제'는 이러한 충돌이 만들어낸 부작용(Side Effect)중 하나에 불과합니다.
3. 프록시 객체란 무엇인가?
JPA는 구조적 차이에 대한 연결을 위해 프록시 객체라는 개념을 도입합니다.
프록시가 없다면 어떤 일이 벌어지는지 한번 보겠습니다. 아래는 객체 간 관계입니다.
Customer -> Coupon <- CouponHistory -> Store -> Seller -> BankAccount
이 구조를 RDB 관점에서 풀어보면 다음과 같은 쿼리가 필요합니다.
SELECT * FROM customer
JOIN coupon ...
JOIN coupon_history ...
JOIN store ...
JOIN seller ...
JOIN bank_account;
하지만 우리는 단지 id = 1인 고객을 알고 싶었을 뿐입니다.
그런데 JPA는 그 고객이 가진 쿠폰, 쿠폰의 사용 이력, 사용된 상점, 판매자, 판매자의 계좌까지 전부 알려줍니다.
개발자 대부분은 이렇게 깊게 연결된 정보를 원하지 않습니다. 우리는 “필요할 때만” 연관된 데이터를 알고 싶은 경우가 많습니다.
프록시 객체는 이를 어떻게 끊어내는가?
Customer -|> Coupon <|- CouponHistory -|> Store -|> Seller -|> BankAccount
그러므로 위 화살표들을 완전히 끊어낼 수 없지만 필요할 때만 접근할 수 있게 만들어야 합니다.
이 역할을 바로 프록시 객체가 담당합니다.
예를 들어, Customer가 가지고 있는 List<Coupon> 이 프록시 객체로 설정되어 있다면, JPA는 이렇게 생각합니다.
"Customer는 id가 1인 고객이야. 그리고 쿠폰은 id가 4, 5, 7이라는 것만 알아. 쿠폰의 상세 정보가 필요하면 그때 가서 조회할게."
이는 RDBMS 관점에서 보면 다음과 같은 뜻이 됩니다.
"지금은 JOIN을 하지 않았지만, 쿠폰 ID는 알고 있으니 필요하면 따로 SELECT 해줄게."
즉, 프록시 객체는 실제 객체가 아닌, 그 객체를 만들 수 있는 fetch 정보를 지닌 '가짜 객체'입니다.
Hibernate에서는 이를 바이트코드 조작(CGLIB 등)으로 동적으로 생성하며, 해당 객체는 내부적으로 HibernateProxy 또는 PersistentBag 같은 프록시 클래스로 감싸져 동작합니다.
4. 프록시 객체는 어떻게 생성되는가?
프록시 객체는 두 가지 경우에 생성됩니다.
- EntityManger.getReference()를 사용할 때
- @OneToMany, @ManyToOne, @OneToOne 등에 fetch = FetchType.LAZY로 설정했을 때
예시 코드
1. Coupon couponProxy = em.getReference(Coupon.class, 1L);
2. @OneToMany(mappedBy = "customer", fetch = FetchType.LAZY)
private List<Coupon> coupons;
Hibernate는 다음과 같이 내부적으로 프록시 클래스를 생성합니다.
public class Coupon$$EnhancerByHibernateProxy extends Coupon implements HibernateProxy {
private LazyInitializer lazyInitializer;
public String getCouponName() {
if (lazyInitializer.isUninitialized()) {
lazyInitializer.initialize(); // 이 시점에 SELECT 수행
}
return super.getCouponName();
}
...
}
즉, getCouponName()을 호출하는 바로 그 순간에 쿼리가 실행됩니다.
프록시 객체는 null이 아니지만, 실제 필드는 비어 있고, 접근 시점에 select 쿼리를 날리는 구조입니다.
프록시 객체의 종류
- EnhancerByHibernateProxy : 단일 엔티티 객체에 대한 프록시
- PersistentBag : 컬렉션에 대한 프록시
이들은 내부적으로 initialize() 트리거를 가지고 있어 접근 시 실제 객체를 초기화합니다.
특히 컬렉션 프록시(PersistentBag)는 hibernate.default_batch_fetch_size 등의 설정을 통해 여러 엔티티를 한 번에 로딩(batch loading)할 수 있는 기능도 내장하고 있습니다.
5. N+1 문제가 발생하는 이유
이제 프록시 객체가 어떻게 동작하는지 알게 되었으니, 자연스럽게 N+1 문제가 왜 발생하는지도 이해할 수 있습니다.
예를 들어, Customer 엔티티가 List<Coupon>을 LAZY 로딩하고 있다고 해보겠습니다.
JPA는 Customer를 조회할 때 Coupon을 즉시 조회하지 않고, 프록시 객체(PersistentBag)를 통해 참조만 설정해 둡니다.
하지만 이후 트리거 Customer.getCoupons()를 호출하면, 내부적으로 프록시에 포함된 각 Coupon 객체들을 초기화(fetch) 하기 위해 별도의 쿼리를 수행하게 됩니다.
이때 Customer에 대한 조회 1번 + Coupon n건에 대한 조회 n번이 발생하면서 총 n+1개의 쿼리가 실행되고, 이것이 바로 N+1 문제입니다.
요약하자면 객체지향에서는 단순한 참조지만, 관계형 DB에서는 별도 쿼리로 조회해야 하며, 이는 객체와 RDB 간의 간극에서 비롯된 부작용이며, 프록시 객체의 구조적 특성에 의해 나타납니다.
6. N+1 문제 해결 방법
N+1 문제를 해결하는 방법은 크게 두 가지 접근 방식으로 나뉩니다.
1) 즉시 초기화 방식 (즉시 조회 전략)
- 연관 필드에 fetch = FetchType.EAGER 설정 (부작용이 많아 권장하지 않음)
- JPQL, EntityGraph, QueryDSL 등에서 fetch join을 명시적으로 사용
2) 지연 초기화 방식 + 배치 최적화
- 프록시 객체의 getter를 호출하거나 Hiberante의 Hibernate.initalize()로 수동 초기화
- hibernate.default_batch_fetch_size 설정을 통해 배치 로딩 적용 (여러 프록시를 일괄 조회 가능)
PersistentBag은 내부적으로 batch size에 따라 적절히 데이터를 fetch합니다.
다음은 설정을 활용했을 때 쿼리 수가 어떻게 변하는지 예시입니다.
- (batch fetch size가 30일 경우이며 관계는 데이터의 개수를 의미합니다)
- 1:1 관계 : 메인(1) + 연관(1) = 2번 쿼리
- 1:30 관계 : 메인(1) + 연관(1) = 2번 쿼리
- 1:97 관계 : 메인(1) + 연관(4) = 5번 쿼리
즉, 연관 데이터 수가 늘어나도 n번씩 쿼리를 날리는 것이 아니라 일괄적으로 묶어서 가져오므로 성능이 크게 향상됩니다.
언제 즉시 vs 지연을 선택해야 할까?
즉시 로딩은 처음부터 연관 객체를 모두 가져오므로 쿼리 수는 적지만, 불필요한 데이터를 끌고 와서 오히려 성능이 나빠질 수 있습니다.
지연 로딩은 접근 시점에만 fetch 하므로 유연하지만, 잘못 사용하면 N+1 문제가 발생할 수 있습니다.
하지만 batch size와 같은 설정을 통해 지연 로딩도 효율적으로 사용할 수 있으며, 필요한 곳에서만 의도적으로 fetch 할 수 있다는 점에서 코드의 가독성과 유연성이 높아집니다.
추가로 pagenation 처리를 할 때 부모(1) 기준 자식(n)으로 fetch join 이 이루어지면 페이징 처리가 불가능합니다. 이 때에는 꼭 부모 기준으로 paging 처리 후 자식을 batch fetch 하는것이 더 적합합니다.
쿼리를 1번 대신 2번 사용한다고 우려할만큼 성능을 잡아먹지 않으며,
성능과 복잡성(가독성과 유지보수성) 간의 Trade-off를 적절히 고려하여야 합니다.
@SuppressWarnings("unchecked")
public static <T> T initializeProxyEntity(T proxyEntity) {
if (proxyEntity == null) {
return null;
}
Hibernate.initialize(proxyEntity);
if (proxyEntity instanceof HibernateProxy) {
return (T) ((HibernateProxy) proxyEntity).getHibernateLazyInitializer().getImplementation();
}
return proxyEntity;
}
위와 같은 유틸 메서드를 만들어서 사용하면 명시적 초기화의 의미를 전달할 수 있습니다.
7. 지연 로딩간 발생할 수 있는 문제점 - LazyInitializationException
프록시 객체를 영속성 컨텍스트가 종료된 이후에 초기화하려고 하면 LazyInitializationException이 발생합니다.
즉, 트랜잭션 외부에서 프록시를 접근하면 DB와의 세션이 끊겨 쿼리를 수행할 수 없어 예외가 발생합니다.
따라서 반드시 트랜잭션 범위 내에서 프록시 객체를 접근하거나, DTO로 미리 데이터를 변환해서 반환하는 방식이 필요합니다.
마무리하며
N+1 문제는 단순한 쿼리 최적화 문제가 아니라, 객체지향 모델과 관계형 모델 사이의 구조적 충돌에서 비롯된 필연적인 결과입니다.
그렇기 때문에 이를 무조건 fetch join으로 해결하기보다는,
- 객체 설계를 단순화할지
- 연관관계를 명확히 유지하면서 지연로딩을 최적화할지
상황에 맞는 전략적 선택과 Trade-off가 중요합니다.
복잡한 쿼리 최적화보다는 코드의 명확성과 생산성을 먼저 고려하고, 정말 필요한 경우에만 성능 튜닝을 적용하는 접근이 더 현실적일 수 있습니다.
'Spring Data' 카테고리의 다른 글
| @Transactional 파라미터 해부: 소스 코드로 보는 8가지 속성 (0) | 2026.02.03 |
|---|---|
| Spring Boot에서 MySQL Master-Slave (Replication) 효율적인 연결 (0) | 2026.01.22 |
| [JPA] fetchJoin 과 Paging 처리의 한계 및 해결 방안 (0) | 2025.03.12 |
| RDBMS 격리 수준 (0) | 2024.11.25 |
| 예외 발생 시 롤백 방지하기: Spring에서 독립적인 트랜잭션 설정 방법 (3) | 2024.09.07 |