JPA(Java Persistence API)를 사용하면 개발자는 SQL을 직접 작성하지 않고도 객체 지향적인 방식으로 데이터베이스와 상호작용할 수 있습니다. 이러한 편리함의 이면에는 JPA의 동작 방식을 정확히 이해해야만 최적의 성능을 낼 수 있다는 과제가 숨어있습니다. 특히 엔티티 간의 연관관계를 어떻게 가져올지를 결정하는 '페치(Fetch) 전략'은 애플리케이션의 성능에 지대한 영향을 미칩니다.
많은 개발자들이 N+1 문제와 같은 성능 저하를 겪는 주된 원인 중 하나가 바로 이 페치 전략에 대한 이해 부족입니다. 이 글에서는 JPA의 두 가지 주요 페치 전략인 즉시 로딩(Eager Loading)과 지연 로딩(Lazy Loading)의 개념과 동작 방식, 그리고 각각의 장단점을 심층적으로 분석합니다. 또한, 실무에서 마주할 수 있는 문제들을 해결하고 최적의 성능을 이끌어내는 모범 사례까지 자세히 알아보겠습니다.
1. JPA 페치 전략이란 무엇인가?
페치 전략은 한마디로 "연관된 엔티티를 언제 데이터베이스에서 조회할 것인가?"를 결정하는 정책입니다. 예를 들어, '회원(Member)' 엔티티와 '팀(Team)' 엔티티가 1:N 관계를 맺고 있다고 가정해 봅시다. 특정 회원을 조회할 때, 그 회원이 속한 팀 정보까지 함께 조회해야 할까요, 아니면 팀 정보가 실제로 필요한 시점에 별도로 조회해야 할까요? 이 선택에 따라 데이터베이스에 전달되는 SQL 쿼리의 수와 종류가 달라지며, 이는 곧 애플리케이션의 응답 속도와 직결됩니다.
JPA는 두 가지 페치 전략을 제공합니다.
- 즉시 로딩 (Eager Loading,
FetchType.EAGER
): 엔티티를 조회할 때 연관된 엔티티도 함께 즉시 조회하는 전략입니다. - 지연 로딩 (Lazy Loading,
FetchType.LAZY
): 연관된 엔티티는 실제 사용되는 시점까지 조회를 미루고, 우선 현재 엔티티만 조회하는 전략입니다.
이 두 전략의 차이를 이해하는 것이 JPA 성능 튜닝의 첫걸음입니다.
2. 즉시 로딩 (EAGER Loading): 편리함 속의 함정
즉시 로딩은 이름 그대로 엔티티를 조회하는 시점에 연관된 모든 데이터를 한 번에 불러오는 방식입니다. JPA는 연관관계의 종류에 따라 기본 페치 전략을 다르게 설정하는데, @ManyToOne
과 @OneToOne
관계의 기본값은 바로 이 즉시 로딩입니다.
동작 방식과 예제
다음과 같이 회원(Member
)과 팀(Team
) 엔티티가 있다고 가정해 보겠습니다. Member
는 하나의 Team
에 속합니다(N:1 관계).
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
@ManyToOne(fetch = FetchType.EAGER) // 기본값이 EAGER이므로 생략 가능
@JoinColumn(name = "team_id")
private Team team;
// ... getters and setters
}
@Entity
public class Team {
@Id @GeneratedValue
@Column(name = "team_id")
private Long id;
private String name;
// ... getters and setters
}
이제 EntityManager
를 통해 특정 회원을 조회하는 코드를 실행해 보겠습니다.
Member member = em.find(Member.class, 1L);
이 코드가 실행될 때 JPA가 생성하는 SQL은 어떤 모습일까요? JPA는 Member
를 조회하면서 연관된 Team
도 즉시 필요할 것이라 판단하고, 처음부터 두 테이블을 조인(JOIN)하는 쿼리를 생성합니다.
SELECT
m.member_id as member_id1_0_0_,
m.team_id as team_id3_0_0_,
m.username as username2_0_0_,
t.team_id as team_id1_1_1_,
t.name as name2_1_1_
FROM
Member m
LEFT OUTER JOIN -- (optional=true가 기본값이므로 외부 조인)
Team t ON m.team_id=t.team_id
WHERE
m.member_id=?
보시다시피 단 한 번의 쿼리로 회원 정보와 팀 정보를 모두 가져왔습니다. 코드상에서는 member.getTeam()
을 호출하지 않았음에도 불구하고, 팀 데이터는 이미 1차 캐시(영속성 컨텍스트)에 로드되어 있습니다. 이것이 즉시 로딩의 핵심 동작입니다.
즉시 로딩의 문제점
언뜻 보기에는 편리해 보이지만, 즉시 로딩은 심각한 성능 문제를 유발할 수 있는 여러 함정을 가지고 있습니다.
1. 불필요한 데이터 로딩
가장 큰 문제는 사용하지 않는 데이터까지 항상 조회한다는 점입니다. 만약 비즈니스 로직에서 회원의 이름만 필요하고 팀 정보는 전혀 필요 없다면, 불필요한 조인으로 인해 데이터베이스에 부하를 주고 네트워크 트래픽을 낭비하게 됩니다. 애플리케이션이 복잡해지고 연관관계가 많아질수록 이러한 낭비는 기하급수적으로 늘어납니다.
2. N+1 문제 발생
즉시 로딩은 JPQL(Java Persistence Query Language)을 사용할 때 예기치 않은 N+1 문제를 일으키는 주범입니다. N+1 문제란, 첫 번째 쿼리로 N개의 결과를 얻은 후, 이 N개의 결과 각각에 대해 추가적인 쿼리가 발생하는 현상을 말합니다.
예를 들어, 모든 회원을 조회하는 JPQL을 실행해 봅시다.
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
이 JPQL은 SQL로 번역될 때 SELECT * FROM Member
와 같이 회원 테이블만 조회하는 쿼리를 먼저 실행합니다. (1번의 쿼리)
하지만 Member
의 team
필드는 즉시 로딩(EAGER)으로 설정되어 있습니다. JPA는 조회된 각 Member
객체에 대해 Team
정보를 채워 넣어야 하므로, 각 회원이 속한 팀을 조회하기 위한 추가 쿼리를 실행하게 됩니다. 만약 회원이 100명이라면, 100개의 팀을 조회하기 위해 100번의 추가 쿼리가 발생합니다. (N번의 쿼리)
결과적으로 총 1 + N 번의 쿼리가 데이터베이스로 전송되어 심각한 성능 저하를 유발합니다. 이는 JPA를 처음 사용하는 개발자들이 가장 흔하게 겪는 실수 중 하나입니다.
3. 지연 로딩 (LAZY Loading): 성능을 위한 현명한 선택
지연 로딩은 즉시 로딩의 문제점을 해결하기 위한 전략입니다. 연관된 엔티티를 처음부터 로드하지 않고, 해당 엔티티가 실제로 필요한 시점(예: getter 메서드 호출)에 비로소 데이터베이스에서 조회합니다.
@OneToMany
, @ManyToMany
와 같이 컬렉션을 다루는 연관관계의 기본 페치 전략은 지연 로딩입니다. JPA 설계자들은 컬렉션에 수많은 데이터가 담길 수 있으므로, 이를 즉시 로딩하는 것은 매우 위험하다고 판단했기 때문입니다. 그리고 이것이 바로 우리가 모든 연관관계에 적용해야 할 모범 사례입니다.
동작 방식과 예제
앞선 예제의 Member
엔티티를 지연 로딩으로 변경해 보겠습니다.
@Entity
public class Member {
// ...
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩으로 명시적 변경
@JoinColumn(name = "team_id")
private Team team;
// ...
}
이제 다시 동일한 조회 코드를 실행합니다.
// 1. 회원 조회
Member member = em.find(Member.class, 1L);
// 2. 팀 정보는 아직 로드되지 않음 (프록시 객체 상태)
Team team = member.getTeam();
System.out.println("Team class: " + team.getClass().getName());
// 3. 팀의 이름을 실제로 사용하는 시점
String teamName = team.getName(); // 이 시점에 팀 조회 쿼리 발생
이 코드의 실행 흐름과 SQL을 단계별로 살펴보겠습니다.
em.find()
호출 시, JPA는Member
테이블만 조회하는 간단한 SQL을 실행합니다.SELECT * FROM Member WHERE member_id = 1;
- 조회된
member
객체의team
필드에는 실제Team
객체 대신, 프록시(Proxy) 객체가 채워집니다. 이 프록시 객체는 껍데기만 있고 실제 데이터는 없는 가짜 객체입니다.team.getClass()
를 출력해보면Team$HibernateProxy$...
와 같은 형태의 클래스 이름이 나오는 것을 확인할 수 있습니다. team.getName()
과 같이 프록시 객체의 메서드를 호출하여 실제 데이터에 접근하는 순간, 프록시 객체는 영속성 컨텍스트에 진짜 객체의 로딩을 요청합니다. 이때 비로소Team
을 조회하는 두 번째 SQL이 실행됩니다.SELECT * FROM Team WHERE team_id = ?; -- member가 참조하는 team_id
이처럼 지연 로딩은 꼭 필요한 데이터만, 필요한 시점에 조회하므로 초기 로딩 속도가 빠르고 시스템 자원을 효율적으로 사용할 수 있습니다.
지연 로딩 사용 시 주의점: `LazyInitializationException`
지연 로딩은 강력하지만, 한 가지 주의해야 할 점이 있습니다. 바로 `LazyInitializationException` 예외입니다.
이 예외는 영속성 컨텍스트가 종료된 상태(준영속 상태)에서 지연 로딩으로 설정된 연관 엔티티에 접근하려 할 때 발생합니다. 프록시 객체는 영속성 컨텍스트를 통해 실제 데이터를 로딩하는데, 영속성 컨텍스트가 닫혀버리면 더 이상 데이터베이스에 접근할 수 없기 때문입니다.
이 문제는 주로 OSIV(Open Session In View) 설정을 끄거나, 트랜잭션 범위 밖에서 프록시 객체를 초기화하려고 할 때 발생합니다. 예를 들어, Spring MVC 컨트롤러에서 다음과 같은 코드를 작성하면 예외를 마주하게 됩니다.
@Controller
public class MemberController {
@Autowired
private MemberService memberService;
@GetMapping("/members/{id}")
public String getMemberDetail(@PathVariable Long id, Model model) {
Member member = memberService.findMember(id); // 서비스 계층에서 트랜잭션 종료
// member는 준영속 상태가 됨
// 여기서 member.getTeam()은 프록시 객체를 반환
// member.getTeam().getName()을 호출하면 LazyInitializationException 발생!
String teamName = member.getTeam().getName();
model.addAttribute("memberName", member.getUsername());
model.addAttribute("teamName", teamName);
return "memberDetail";
}
}
이 문제를 해결하기 위해서는 트랜잭션 범위 안에서 연관 엔티티를 모두 사용하거나, 뒤에서 설명할 페치 조인(Fetch Join)을 사용하여 필요한 데이터를 미리 함께 조회해야 합니다.
4. 실무를 위한 페치 전략: 가이드라인과 해결책
지금까지의 내용을 종합해 볼 때, JPA 페치 전략에 대한 명확한 가이드라인을 세울 수 있습니다.
"모든 연관관계는 지연 로딩(
FetchType.LAZY
)으로 설정하라."
이것이 JPA를 사용하는 애플리케이션의 성능을 지키는 가장 중요한 첫 번째 원칙입니다. 즉시 로딩은 예측하지 못한 SQL을 유발하고, 애플리케이션의 확장성을 저해하는 주된 요인이기 때문입니다. 모든 연관관계를 지연 로딩으로 기본 설정한 뒤, 특정 유스케이스에서 연관된 엔티티가 함께 필요한 경우에만 선별적으로 데이터를 가져오는 전략을 사용해야 합니다.
이렇게 선별적으로 데이터를 가져오는 대표적인 방법이 바로 페치 조인(Fetch Join)과 엔티티 그래프(Entity Graph)입니다.
해결책 1: 페치 조인 (Fetch Join)
페치 조인은 JPQL에서 사용할 수 있는 특별한 조인 기능으로, N+1 문제를 해결하는 가장 효과적인 방법 중 하나입니다. SQL의 조인 종류를 지정하는 것이 아니라, 조회 대상 엔티티와 연관된 엔티티를 SQL 한 번으로 함께 조회하도록 JPA에게 명시적으로 지시하는 역할을 합니다.
앞서 N+1 문제를 일으켰던 "모든 회원 조회" 시나리오를 페치 조인으로 개선해 보겠습니다.
// "JOIN FETCH" 키워드를 사용
String jpql = "SELECT m FROM Member m JOIN FETCH m.team";
List<Member> members = em.createQuery(jpql, Member.class)
.getResultList();
for (Member member : members) {
// 추가 쿼리 발생 없이 팀 이름 접근 가능
System.out.println("Member: " + member.getUsername() + ", Team: " + member.getTeam().getName());
}
이 JPQL이 실행되면, JPA는 다음과 같이 처음부터 Member
와 Team
을 조인하는 SQL을 생성합니다.
SELECT
m.member_id, m.username, m.team_id,
t.team_id, t.name
FROM
Member m
INNER JOIN -- 페치 조인은 기본적으로 내부 조인을 사용
Team t ON m.team_id = t.team_id
단 한 번의 쿼리로 모든 회원과 각 회원이 속한 팀 정보를 모두 가져왔습니다. 조회된 Member
객체의 team
필드에는 프록시가 아닌 실제 Team
객체가 채워져 있으므로, N+1 문제나 `LazyInitializationException` 걱정 없이 연관 엔티티를 사용할 수 있습니다.
해결책 2: 엔티티 그래프 (@EntityGraph)
페치 조인은 강력하지만, JPQL 쿼리 자체에 페치 전략이 종속된다는 단점이 있습니다. 엔티티 그래프는 JPA 2.1부터 도입된 기능으로, 페치 전략을 쿼리와 분리하여 더욱 유연하고 재사용 가능하게 만들어 줍니다.
엔티티에 @NamedEntityGraph
를 정의하고, Repository 메서드에서 @EntityGraph
어노테이션으로 해당 그래프를 사용하겠다고 지정할 수 있습니다.
@NamedEntityGraph(
name = "Member.withTeam",
attributeNodes = {
@NamedAttributeNode("team")
}
)
@Entity
public class Member {
// ...
}
// Spring Data JPA Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
// findAll 메서드를 오버라이드하면서 @EntityGraph 적용
@Override
@EntityGraph(attributePaths = {"team"}) // 또는 @EntityGraph(value = "Member.withTeam")
List<Member> findAll();
}
이제 memberRepository.findAll()
을 호출하면, Spring Data JPA가 페치 조인이 적용된 JPQL을 자동으로 생성하여 실행합니다. 이를 통해 JPQL을 직접 작성하지 않고도 N+1 문제를 해결할 수 있어 코드가 훨씬 깔끔해집니다.
5. `optional` 속성과 조인 전략의 관계
원문에서 언급된 `optional` 속성은 페치 전략과 직접적인 관련은 없지만, JPA가 생성하는 SQL의 조인 종류(INNER JOIN
vs LEFT OUTER JOIN
)에 영향을 미치는 중요한 속성입니다.
@ManyToOne(optional = true)
(기본값): 연관관계가 필수적이지 않음(nullable)을 의미합니다. 즉, 회원이 팀에 소속되지 않을 수도 있습니다. 이 경우 JPA는 팀이 없는 회원도 조회 결과에 포함해야 하므로LEFT OUTER JOIN
을 사용합니다.@ManyToOne(optional = false)
: 연관관계가 필수적임(non-nullable)을 의미합니다. 모든 회원은 반드시 팀에 소속되어야 합니다. 이 경우 JPA는 두 테이블에 모두 데이터가 존재함을 확신할 수 있으므로 성능상 더 유리한INNER JOIN
을 사용합니다.
반면, @OneToMany
나 @ManyToMany
와 같은 컬렉션 기반 연관관계에서는 `optional` 속성이 조인 타입에 영향을 주지 않고 거의 항상 LEFT OUTER JOIN
이 사용됩니다. 이는 연관된 컬렉션이 비어있는 경우(예: 팀에 소속된 회원이 아직 없는 경우)에도 부모 엔티티(팀)는 조회되어야 하기 때문입니다.
결론: 현명한 개발자의 선택
JPA 페치 전략은 애플리케이션의 성능을 좌우하는 핵심 요소입니다. 내용을 다시 한번 정리하며 마무리하겠습니다.
- 모든 연관관계는 무조건 지연 로딩(
FetchType.LAZY
)으로 설정하라. 이것이 성능 문제의 90%를 예방하는 황금률입니다. - 즉시 로딩(
FetchType.EAGER
)은 사용하지 마라. 특히 JPQL과 함께 사용할 때 N+1 문제를 유발하는 주범이며, 예측 불가능한 SQL을 생성하여 유지보수를 어렵게 만듭니다. - 데이터가 함께 필요한 경우에는 페치 조인(Fetch Join)이나 엔티티 그래프(@EntityGraph)를 사용하여 필요한 데이터만 선별적으로 한 번에 조회하라. 이는 N+1 문제와 `LazyInitializationException`을 동시에 해결하는 가장 좋은 방법입니다.
optional=false
설정을 통해 불필요한 외부 조인을 내부 조인으로 최적화할 수 있습니다.
단순히 코드가 동작하는 것에 만족하지 않고, 그 이면에서 어떤 SQL이 실행되는지 항상 관심을 가지는 습관이 중요합니다. `hibernate.show_sql`, `p6spy`와 같은 도구를 활용하여 실행되는 쿼리를 꾸준히 모니터링하고, 페치 전략을 현명하게 사용하여 안정적이고 성능 좋은 애플리케이션을 만들어 나가시길 바랍니다.
0 개의 댓글:
Post a Comment