499. ORM (JPA) 객체 매핑과 N+1 문제
⚠️ 이 문서는 개발자가 귀찮은 SQL 쿼리를 직접 짜지 않고 자바(Java) 객체만 다루면 알아서 DB에 저장해 주는 마법 같은 기술인 ORM의 편리함 뒤에 숨겨져 있는, 데이터 100개를 가져오려고 쿼리를 100번이나 날려버려 서버를 터뜨리는 'N+1 문제'라는 가장 치명적인 함정을 다룹니다.
핵심 인사이트 (3줄 요약)
- 본질: ORM(Object-Relational Mapping)은 객체 지향 프로그래밍(Java)의 '객체'와 관계형 데이터베이스(RDB)의 '테이블'을 자동으로 매핑해 주는 기술이다. (대표 기술: JPA, Hibernate)
- N+1 문제:
팀목록 10개를 가져왔을 뿐인데(1번 쿼리), 각 팀에 속한직원목록을 보기 위해 팀마다 쿼리를 또 날려서(N번 쿼리) 총 11번의 쿼리가 날아가는 끔찍한 성능 저하 버그다.- 해결책: 백엔드 개발자는 이 마법을 맹신하지 말고,
Fetch Join이나Entity Graph를 사용해서 "가져올 때 쪼개서 가져오지 말고 한 번에 조인(JOIN)해서 가져와!"라고 ORM에게 정확히 지시해야 한다.
Ⅰ. 개요: SQL 없는 세상 (Context & Necessity)
과거의 개발자(MyBatis 시대)들은 자바 코드의 절반이 문자열로 된 SQL 쿼리였다.
String sql = "INSERT INTO Users (name, age) VALUES ('" + user.getName() + "', " + user.getAge() + ")";
코드가 지저분해지고, 테이블에 컬럼이 하나 추가되면 자바 코드를 수백 군데 고쳐야 했다. (패러다임 불일치)
**ORM (JPA)**의 등장으로 세상이 바뀌었다.
em.persist(user); // SQL 한 줄 안 짰는데 DB에 저장됨!
개발자는 그저 자바의 User 객체를 만들어서 저장하라고 던지면 끝이다. ORM 프레임워크(Hibernate)가 뒤에서 몰래 완벽한 INSERT SQL 문을 만들어서 DB에 쏴준다.
하지만 이 '자동화된 마법'은 구조를 제대로 모르는 주니어 개발자를 만나면 최악의 재앙(N+1)을 불러온다.
📢 섹션 요약 비유: ORM은 **'통역사'**와 같습니다. 나는 한국어(자바 객체)로 말하면 통역사가 알아서 완벽한 외국어(SQL)로 바꿔서 외국인(DB)에게 전달해 주죠. 엄청 편하지만, 내가 통역사가 어떤 외국어 단어를 썼는지 확인하지 않으면 뜻이 완전히 와전되어버릴 위험이 항상 존재합니다.
Ⅱ. 대참사의 시작: N+1 문제 ★
가장 흔한 [팀 (1)] - [직원 (N)] 관계의 게시판을 생각해 보자.
- 목표: "모든 팀의 이름과, 그 팀에 속한 직원들의 이름을 화면에 다 출력해 줘!"
- 최초 쿼리 (1):
List<Team> teams = teamRepository.findAll();(DB에SELECT * FROM 팀쿼리가 딱 1번 날아간다. 10개의 팀을 가져왔다.) - 루프와 추가 쿼리 (N):
개발자는 화면에 뿌리기 위해 자바에서
for문을 돌린다. "A팀 직원들 가져와!" $\rightarrow$ (SELECT * FROM 직원 WHERE 팀_id = A) "B팀 직원들 가져와!" $\rightarrow$ (SELECT * FROM 직원 WHERE 팀_id = B) ... 이렇게 팀 10개에 대해 각각 쿼리가 10번 더 날아간다. - 결과: 분명 나는 코드를 한 줄 썼을 뿐인데, DB에는 무려 11번(1 + 10번)의 쿼리가 날아갔다! 만약 팀이 1만 개였다면 10,001번의 쿼리가 날아가서 데이터베이스가 비명을 지르며 다운된다.
Ⅲ. N+1 문제의 3가지 해결책
면접에서 "JPA 써봤어요? N+1 문제 어떻게 해결하나요?"라는 질문의 모범 답안이다.
1. Fetch Join (페치 조인) ★ 가장 완벽한 정답
- 개념: "어차피 직원 데이터 쓸 거잖아? 처음 팀 가져올 때부터 미리 JOIN 해서 직원까지 싹 다 한방에 가져와!"
- JPQL 코드:
SELECT t FROM Team t JOIN FETCH t.employees - 결과:
N+1번 날아가던 쿼리가 딱 1번의 JOIN 쿼리로 끝난다.
2. EntityGraph (엔티티 그래프)
- 개념: Fetch Join과 똑같은 역할을 하지만, 쿼리를 직접 짜기 귀찮을 때 애노테이션(
@EntityGraph) 하나만 달아서 조인을 강제하는 방식이다.
3. Batch Size (배치 사이즈) 조절
- 개념: Fetch Join을 못 쓰는 상황(예: 페이징 처리)일 때 쓰는 타협안이다.
- "어차피 쿼리를 10번 더 날려야 해? 그럼 1번씩 10번 날리지 말고,
IN연산자(434번 문서)를 써서 10개를 한꺼번에 묶어서 1번만 쏴!" - 설정(
default_batch_fetch_size: 100)만 켜주면,1+N번 날아가던 쿼리가1+1번으로 획기적으로 줄어든다.
┌──────────────────────────────────────────────────────────────┐
│ ORM의 N+1 문제 발생과 Fetch Join 해결 시각화 │
├──────────────────────────────────────────────────────────────┤
│ │
│ [ ❌ N+1 문제 발생 (일반 조회) ] │
│ 1️⃣ SELECT * FROM 팀; (팀 3개 나옴) │
│ 2️⃣ SELECT * FROM 직원 WHERE 팀_id = 1; (A팀 직원 가져옴) │
│ 3️⃣ SELECT * FROM 직원 WHERE 팀_id = 2; (B팀 직원 가져옴) │
│ 4️⃣ SELECT * FROM 직원 WHERE 팀_id = 3; (C팀 직원 가져옴) │
│ ★ 총 4번의 네트워크 왕복(RTT) 발생! 🐌 │
│ │
│ [ 🟢 Fetch Join 해결 (한방 쿼리) ] │
│ 1️⃣ SELECT * FROM 팀 JOIN 직원 ON 팀.id = 직원.팀_id; │
│ ★ 총 1번의 통신으로 끝! (데이터베이스 내부에서 알아서 다 합쳐줌) 🚀 │
└──────────────────────────────────────────────────────────────┘
Ⅳ. 결론
"편리함은 추상화일 뿐, 물리적인 SQL을 대체할 수 없다."
ORM(JPA)은 백엔드 개발자의 생산성을 10배 이상 끌어올린 혁명적인 도구다. 하지만 N+1 문제는 "도구에 비즈니스 로직을 온전히 맡겼을 때 발생하는 최악의 부작용"을 보여주는 대표적인 사례다. 실무에서 훌륭한 백엔드 엔지니어는 ORM을 써서 코드를 예쁘게 짜면서도, 로컬 환경에서 쿼리 로그(SQL Log)를 항상 켜두고 "내가 짠 코드 한 줄이 실제 DB에서는 어떤 모양의 SQL로 번역되어 날아가는가?"를 집요하게 감시하고 튜닝(Fetch Join)하는 사람이다.
📌 관련 개념 맵
- 관련 철학: Object-Relational Impedance Mismatch (객체와 RDB의 패러다임 불일치)
- 프레임워크: JPA (Java Persistence API), Hibernate, TypeORM (Node.js)
- 로딩 전략: 지연 로딩 (Lazy Loading - 쓸 때 쿼리 날림), 즉시 로딩 (Eager Loading - 처음부터 다 가져옴)
- 관련 연산: IN 연산자 최적화 (434번 문서 - Batch Size 조절 시 작동 원리)
👶 어린이를 위한 3줄 비유 설명
- N+1 문제는 선생님이 "우리 반 30명, 각자 자기 집에 가서 부모님 이름 알아와!"라고 심부름을 30번이나 시키는 거예요. (엄청 지치죠)
- 가장 좋은 방법(Fetch Join)은 애초에 첫날 학교 올 때 "부모님 이름까지 적힌 가족관계증명서 딱 1장만 내!"라고 한 번에 다 걷어버리는 거예요.
- 피치 못할 사정(Batch Size)이 있다면, 30명한테 따로 시키지 말고 "너네 10명씩 묶어서 한 번에 가서 알아와!"라고 묶어서 시키는 방법이랍니다!