8단계에서 JPA의 신세계를 맛본 개발자들은 흥분에 차서 이렇게 외칩니다. "와! SQL 안 짜고 @Entity 하나만 달면 끝이네! 이제 JPA 마스터다!" 그러나 그 오만함은 실무의 복잡한 비즈니스 모델을 만나는 순간 처참히 박살 납니다. 현실의 데이터베이스는 단일 테이블로 존재하지 않습니다. 하나의 회원(Member)은 여러 개의 주문(Order)을 할 수 있고, 하나의 게시글(Post)에는 수많은 댓글(Comment)이 달립니다. RDB는 '외래 키(Foreign Key)' 하나로 이 관계를 멋지게 풀지만, 자바(Java) 객체는 서로의 '참조(Reference)'를 가져야만 관계가 맺어집니다. 여기서 발생하는 패러다임의 충돌을 해결하는 것이 바로 JPA의 꽃이라 불리는 '연관관계 매핑(Association Mapping)'입니다. 그리고 이 매핑을 잘못했을 때 실서버의 성능을 10분의 1로 박살 내버리는 끔찍한 악몽, 'N+1 문제'가 기다리고 있습니다.
이번 포스팅에서는 면접 1순위 질문이자 JPA 실무의 가장 큰 진입장벽인 연관관계 매핑과 N+1 문제 해결책에 대하여 다뤄보겠습니다.

🔗 1. 연관관계의 주인을 정하라 (@ManyToOne)
게시글(Post)과 댓글(Comment)의 관계를 생각해 봅시다. 1개의 게시글은 여러 댓글을 가집니다(1:N). 반대로 댓글 입장에서 게시글은 1개입니다(N:1). 데이터베이스에서는 무조건 '다(N)' 쪽인 댓글 테이블이 외래 키(FK)를 가집니다.
JPA 설계의 절대 원칙 1번: "외래 키를 가진 쪽(다, N)이 연관관계의 주인이 된다!" 따라서 Comment 엔티티에 @ManyToOne과 @JoinColumn을 달아주어 주도권을 쥐어주어야 합니다.
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
// 🌟 내가 '다(N)'이고 게시글이 '1(One)'이다. 내가 외래 키(post_id)의 주인이다!
@ManyToOne(fetch = FetchType.LAZY) // 실무 절대 원칙: 모든 매핑은 무조건 LAZY(지연 로딩)로 통일!
@JoinColumn(name = "post_id")
private Post post;
}
🪞 2. 양방향 매핑과 mappedBy의 마법 (@OneToMany)
위의 세팅만으로도 데이터 베이스 저장은 완벽하게 작동합니다(단방향). 하지만 실무에서는 게시글 객체(Post)를 조회한 뒤 post.getComments() 처럼 객체 그래프를 타고 댓글 목록을 쫘르륵 가져와야 하는 일이 빈번합니다. 이럴 때 양방향 매핑을 열어줍니다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
// 🌟 나는 '1(One)'이고 댓글이 '다(Many)'이다.
// 주의: 나는 주인이 아니다! 주인은 Comment 클래스의 'post' 필드이다(mappedBy)
@OneToMany(mappedBy = "post")
private List<Comment> comments = new ArrayList<>();
}
mappedBy 속성은 JPA에게 "내(Post)가 외래 키를 관리하는 주인이 아니야. 난 그저 거울(읽기 전용)일 뿐이니, 외래 키 업데이트는 저쪽(Comment의 post 필드)에 맡겨!"라고 선언하는 매우 중요한 마법의 키워드입니다.
💣 3. 공포의 N+1 문제: 데이터베이스 테러리스트
자, 연관관계도 완벽하게 짰고 실무 원칙대로 FetchType.LAZY(지연 로딩)도 걸어두었습니다. 이제 게시글 10개가 있는 메인 페이지를 보여주기 위해 postRepository.findAll()을 호출했습니다. 무슨 일이 벌어질까요?
- 1.
SELECT * FROM post쿼리가 1번 날아가서 게시글 10개를 가져옵니다. (여기까진 정상) - 2. 화면에 게시글 제목과 함께 각 게시글에 달린 댓글 수를 출력하기 위해 for문을 돌며
post.getComments().size()를 호출합니다. - 3. 지연 로딩(LAZY)이 걸려있으므로, JPA는 루프를 돌 때마다 "어? 1번 게시글 댓글이 필요하네? SELECT 쳐야지", "어? 2번 게시글 댓글도 필요하네? 또 SELECT 쳐야지" 하며 미친 듯이 쿼리를 날리기 시작합니다.
결과적으로 최초 게시글을 가져오는 쿼리(1번) + 각 게시글마다 댓글을 가져오는 쿼리(N번, 게시글이 10개면 10번)가 발생하여, 단 한 번의 API 호출에 DB로 11번의 쿼리(1+N)가 폭격처럼 쏟아집니다. 만약 게시글이 1,000개였다면? 1,001개의 쿼리가 날아가고 여러분의 DB 서버는 즉시 터져버립니다. 이것이 바로 N+1 문제입니다.
🚑 4. N+1 문제의 궁극적 해결책: Fetch Join
이 끔찍한 사태를 해결하는 가장 깔끔하고 강력한 실무 표준 해결책은 '페치 조인(Fetch Join)'을 사용하는 것입니다. JPA가 제공하는 JPQL 문법을 사용하여 "게시글을 가져올 때, 어차피 댓글도 곧바로 다 쓸 거니까 처음부터 조인(JOIN) 맺어서 한 방에 싹 다 가져와 줘!"라고 지시하는 것입니다.
public interface PostRepository extends JpaRepository<Post, Long> {
// 🌟 일반 JOIN이 아니라 FETCH JOIN을 씁니다.
// 이렇게 하면 1000개의 쿼리가 단 1개의 쿼리로 압축되는 기적이 일어납니다!
@Query("SELECT p FROM Post p JOIN FETCH p.comments")
List<Post> findAllWithComments();
}

🎯 5. 마무리 및 다음 단계
지금까지 JPA 초보자들을 절망에 빠뜨리는 양방향 연관관계 매핑의 절대 원칙(주인 정하기, mappedBy)과, 실무 운영 서버를 순식간에 터뜨려버리는 극악의 'N+1 문제'의 발생 원리 및 Fetch Join을 통한 완벽한 방어법에 대해 다루어 보았습니다. 이 원리를 깨우친 순간, 여러분은 진정한 JPA 실무자로 거듭난 것입니다.
그런데 방금 페치 조인 예제에서 우리가 @Query라는 어노테이션 안에 살짝 SQL 비슷한 문법(JPQL)을 적어 넣었습니다. 사실 스프링 데이터 JPA(Spring Data JPA)는 저런 문자열 쿼리를 치지 않고도, 그저 '메서드 이름'을 기가 막히게 잘 짓는 것만으로도 수백 가지의 복잡한 쿼리를 자동으로 생성해 내는 엄청난 마법을 품고 있습니다. 이어지는 10단계 포스팅에서는 "코드 한 줄 안 짜고 메서드 이름만으로 페이징(Paging)과 정렬, 검색을 박살 낸다!" Spring Data JPA의 핵심 무기, QueryMethod(쿼리 메서드)의 완벽 활용 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] QueryDSL 입문: 타입 세이프(Type-safe)한 동적 쿼리의 끝판왕 (0) | 2026.07.27 |
|---|---|
| [Spring Boot] Spring Data JPA의 마법: QueryMethod로 검색과 페이징을 박살내다 (0) | 2026.07.23 |
| [Spring Boot] JPA(Hibernate) 기초: 객체와 관계형 DB의 패러다임 불일치를 부수다 (0) | 2026.07.23 |
| [Spring Boot] 전역 예외 처리(Global Exception Handling): 에러의 혼돈을 우아하게 제압하라 (0) | 2026.07.23 |
| [Spring Boot] 의존성 주입(DI)과 IoC 컨테이너: 객체의 생과 사를 스프링에게 맡기다 (0) | 2026.07.23 |