초급 프로젝트가 끝나고 오랫만에 돌아온 위클리페이퍼이다.
- JPA에서 발생하는 N+1 문제의 발생 원인과 해결 방안에 대해 설명하세요.
- 트랜잭션의 ACID 속성 중 격리성(Isolation)이 보장되지 않을 때 발생할 수 있는 문제점들을 설명하고, 이를 해결하기 위한 트랜잭션 격리 수준들을 설명하세요.
1. JPA에서 발생하는 N+1 문제의 발생 원인과 해결방안
JPA에서 N+1은 실제로 많은 개발자들이 JPA를 사용할 때 마주치는 문제이다. 그렇다면 N+1이 무엇일까?
사실 1+N이라고 보는게 조금 더 이해하기 쉬울 것이다. 하나의 쿼리를 실행하면 그 쿼리에서 추가적으로 N개의 쿼리가 발생하기 때문에 1+N, 즉 N+1이라고 하는 것이다.
말로만 하면 별로 와닿지 않을 것 같으니 N+1의 예시를 보여주겠다.
N+1이 발생하는 상황 예시

위와 같은 테이블 구조가 있다.
JPA는 단방향으로 매핑한다면 아래와 같은 구조일 것이다.(Team은 Member를 모르니까 생략)
@Entity
public class Member {
@ManyToOne
@JoinColumn(name = "team_id")
private Team team; // Member는 Team을 알지만, Team은 Member 리스트가 없음
}
양방향으로 매핑한다면 아래와 같이 작성될 것이다.
@Entity
public class Team {
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
이 때 작성되는 비즈니스 로직은 다음과 같다.
@Transactional(readOnly = true)
public List<String> getMemberTeamNames() {
// 1. 모든 멤버를 조회 (쿼리 1번 실행)
List<Member> members = memberRepository.findAll();
// 2. 각 멤버의 팀 이름을 추출 (N+1 발생)
return members.stream()
.map(member -> member.getTeam().getName()) // 🔥 여기서 N번의 추가 쿼리 발생!
.collect(Collectors.toList());
}
이때 발생하는 쿼리는 아래와 같다.
-- [1번] findAll() 실행 시
SELECT * FROM member;
-- [N번] 루프 내부에서 member.getTeam().getName() 호출 시마다 실행
SELECT * FROM team WHERE id = 1; -- 첫 번째 멤버의 팀 조회
SELECT * FROM team WHERE id = 2; -- 두 번째 멤버의 팀 조회
SELECT * FROM team WHERE id = 5; -- 세 번째 멤버의 팀 조회
... (조회된 멤버 수 N만큼 반복)
양방향 매핑일 경우 다음과 같은 비즈니스 로직도 존재할 것이다.
@Transactional(readOnly = true)
public void printTeamMemberCount() {
// 1. 모든 팀을 조회 (쿼리 1번 실행)
List<Team> teams = teamRepository.findAll();
// 2. 각 팀의 멤버 수를 출력
for (Team team : teams) {
// team.getMembers()는 프록시 컬렉션이며, .size() 호출 시 DB를 조회함
System.out.println(team.getName() + "의 멤버 수: " + team.getMembers().size());
}
}
이때 발생하는 쿼리는 다음과 같다.
-- [1번] findAll() 실행 시
SELECT * FROM team;
-- [N번] team.getMembers().size() 호출 시마다 실행
SELECT * FROM member WHERE team_id = 1; -- 1번 팀의 멤버들 조회
SELECT * FROM member WHERE team_id = 2; -- 2번 팀의 멤버들 조회
SELECT * FROM member WHERE team_id = 3; -- 3번 팀의 멤버들 조회
... (조회된 팀 수 N만큼 반복)
이제 N+1 문제가 뭔지는 이해했을 것이라고 생각한다.
N+1이 발생하는 원인
그렇다면 도대체 왜 이 문제가 발생하는지 알아보도록 하자.
먼저 JPA와 DB의 차이에 대해서 이해할 필요가 있다. JPA는 엔티티 간 연관관계를 객체 그래프로 다루고, 관계형 DB에서는 이를 테이블과 조인으로 표현한다. 그 과정에서 JPA는 연관된 객체는 그래프를 타고 자유롭게 호출할 수 있지만, DB에서는 JOIN이 필요하다. 즉, 우리가 JPA에서 OneToMany로 연관관계를 만들어놓았으면 team.getMembers() 이런식으로 팀의 멤버에는 아주 쉽게 접근할 수 있지만, DB에서는 Team의 멤버를 얻기 위해서는 외래키를 이용해서 JOIN 하기 때문이다.
우리가 JPA에서 find 메서드로 team을 DB에서 가져올 때, team과 연관된 엔티티인 member를 가져오지 않는다. 이것이 바로 JPA의 꽃인 지연로딩(Lazy)이다. 지금 당장은 team의 멤버를 사용할지 안할지 모르니 일단은 proxy 객체로 채워 넣는 것이다. 그리고 실제로 team의 멤버를 어떤식으로든 접근할 때 그 때 멤버를 조회하는 것이다. 그러므로 team을 가져오는 쿼리 1번 + team의 각 멤버 조회 N번의 쿼리가 발생하는 것이다.
그렇다면 이런 의문이 생길 수도 있다. 어? 그러면 그냥 지연 로딩(Lazy)하지 말고 즉시 로딩(Eager) 하면 해결되는 거 아닌가요?
하지만 이는 해결방법이 아니다. 그 이유는 하이버네이트가 내부적으로 QueryTranslator로 JPA를 SQL로 변환할 때 Eager인지 Lazy인지 전혀 신경 쓰지 않기 때문이다. 그래서 결국 즉시 로딩은 일단 team을 가져오는 쿼리를 작성하고 eager는 엔티티를 반환하는 시점에서 데이터가 채워져 있어야 하므로 프록시 객체를 채워 넣는 대신 즉시 team의 각 멤버를 가져오는 쿼리 N번을 실행해서 데이터를 모두 채운 후 반환한다. 즉 시점의 차이일 뿐 결국 N+1이 발생하는 것은 같다.
N+1 해결 방법
자 이제 N+1이 발생하는 원인을 알았으니 해결 방법을 알아 낼 차례이다.
해결 방법은 크게 4가지이다.
1. Fetch Join
2. @EntityGraph
3. @Batchsize
4. DTO Protection
일단 1번과 2번은 JPQL 쿼리 튜닝을 통해 JOIN을 수행함으로서 연관된 엔티티를 전부 가져오는 것이다.
Fetch Join은 기본적으로 명시하지 않으면 INNER JOIN을 수행하며 연관된 데이터가 없는 경우에는 누락한다.(즉 멤버가 없는 팀은 누락한다는 것이다.)
@Query("select t from Team t join fetch t.members")
List<Team> findAllJoinFetch();
SELECT t.*, m.* FROM team t INNER JOIN member m ON t.id = m.team_id
@EntityGraph는 기본적으로 LEFT OUTER JOIN을 사용하며 연관된 데이터가 없는 경우에도 가져온다.
@EntityGraph(attributePaths = {"members"})
@Query("select t from Team t")
List<Team> findAllEntityGraph();
SELECT t.*, m.* FROM team t LEFT OUTER JOIN member m ON t.id = m.team_id
다음으로 배치사이즈는 IN절로 해당하는 것들을 전부 가져온다.
배치 사이즈로 나눠서 가져오므로 실제 쿼리 개수는 1 + ceil(N / size)가 된다. 사용방법은 아래와 같다.
// 글로벌 설정 방법(모두 적용됨)
# application.yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
// 특정 엔티티의 필드에 적용
@Entity
public class Team {
@BatchSize(size = 100)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
}
SELECT * FROM member WHERE team_id IN (1, 2, 3, ..., 100)
마지막으로 DTO Projection은 엔티티 필드의 전체가 아닌 일부만 조회해서 연관관계를 해소하는 방법이다.
public record TeamMemberCountDto(String teamName, Long memberCount) {}
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select new com.example.dto.TeamMemberCountDto(t.name, count(m)) " +
"from Team t left join t.members m group by t.name")
List<TeamMemberCountDto> findAllTeamSummary();
}
N+1을 해결하기 위한 방법을 언제 사용하면 좋을까?
- 연관된 엔티티를 수정해야 하는가?
- Yes ➡️ Fetch Join 또는 @EntityGraph (엔티티 상태 유지 필요)
- 데이터 양이 많아서 페이징 처리가 필요한가?
- Yes ➡️ @BatchSize (OneTo~ FetchJoin은 페이징 처리 어려움)
- 수정 없이 단순 통계나 목록 조회인가?
- Yes ➡️ DTO Projection (가장 가볍고 빠름)
2. 트랜잭션의 격리성이 보장되지 않을 때 발생할 수 있는 문제점 및 해결법
데이터베이스에서 여러 작업을 하나의 논리적 단위로 묶는 최소 단위를 트랜잭션(Transaction)이라고 하며, 이 트랜잭션이 안전하게 수행되기 위해서는 반드시 지켜야 하는 4가지 철칙이 있는데, 이를 ACID 속성이다.
트랜잭션의 4대 원칙, ACID 속성
- 원자성 (Atomicity): 트랜잭션 내의 작업들은 "모두 성공(Commit)하거나 모두 실패(Rollback)해야 한다" (All or Nothing)
- 일관성 (Consistency): 트랜잭션이 성공적으로 완료되면, 데이터베이스는 항상 일관된 상태를 유지해야 한다. (예: 계좌 이체 전후의 총금액은 같아야 함)
- 격리성 (Isolation): 둘 이상의 트랜잭션이 동시에 실행될 때, 서로의 작업에 끼어들거나 중간 상태를 볼 수 없도록 격리해야 한다.
- 지속성 (Durability): 성공적으로 커밋된 트랜잭션의 결과는 시스템이 고장 나더라도 영구적으로 보존되어야 한다.
격리성이 완벽하게 보장되지 않는 이유
격리성을 보장하기 위해서는 한 번에 하나의 트랜잭션만 처리하면 사실 매우 간단하게 보장할 수 있다. 우리가 화장실을 사용하는 것을 예로 들자. 화장실에서는 세면대에서 손을 씻을 수 있고, 샤워기로 목욕을 할 수 있고, 대변기와 소변기에서 각각 볼 일을 볼 수 있다.
그런데 화장실에 단 한 명씩 들어가게 되면 화장실에 들어가는 사람이 많을 경우 굉장히 밀릴 것이다. 그래서 각각 다른 일을 수행한다면 들여보내면 화장실을 들어가는 사람들이 밀리지 않을 것이다.
마찬가지로 수만 명의 유저가 동시에 접속하는 서비스에서 트랜잭션을 줄 세워 하나씩 처리한다면, 엄청난 성능 저하와 병목 현상이 발생할 것이다. 즉, 데이터베이스는 "데이터의 정합성(정확성)"과 "동시 처리 성능" 사이에서 딜레마에 빠지게 된다. 결국 성능을 위해 격리성을 조금씩 느슨하게 풀어주게 되는데, 이 과정에서 완벽히 격리되지 않은 트랜잭션들이 서로 간섭하며 치명적인 3가지 문제점이 발생한다.
격리성이 보장되지 않을 때 발생하는 3가지 문제점
① Dirty Read (더티 리드)
다른 트랜잭션이 아직 커밋하지 않은(작업 중인) 데이터를 읽어버리는 현상
- 상황: 트랜잭션 A가 특정 회원의 포인트를 100에서 200으로 수정함. (아직 Commit 전)
- 문제 발생: 이때 트랜잭션 B가 해당 회원의 포인트를 조회하니 200이 조회됩니다. 트랜잭션 B는 이 200이라는 값을 기준으로 후속 로직을 실행함.
- 치명적 결과: 만약 트랜잭션 A에서 에러가 발생해 작업을 100으로 Rollback해버린다면? 트랜잭션 B는 실제 DB에 존재하지도 않는 '200'이라는 더티(Dirty) 데이터를 가지고 비즈니스 로직을 처리한 셈이 되어 심각한 데이터 오염이 발생함.
② Non-Repeatable Read (논-리피터블 리드)
"한 트랜잭션 내에서 같은 조건으로 데이터를 두 번 읽었는데, 그 값이 달라지는 현상
- 상황: 트랜잭션 A가 1번 상품의 재고를 조회하니 10개임.
- 문제 발생: 그사이 트랜잭션 B가 1번 상품의 재고를 5개로 **수정(UPDATE)**하고 Commit 함.
- 치명적 결과: 트랜잭션 A가 다시 한번 1번 상품의 재고를 조회하면 이번에는 5개가 나옴. 하나의 트랜잭션 안에서는 데이터가 일관되어야 한다는 원칙이 깨진 것.
③ Phantom Read (팬텀 리드)
한 트랜잭션 내에서 같은 조건으로 레코드를 두 번 읽었는데, 처음에는 없던 '유령(Phantom)' 레코드가 나타나는 현상
- 상황: 트랜잭션 A가 "VIP 등급인 회원 목록"을 조회하니 5명이 조회됨.
- 문제 발생: 그사이 트랜잭션 B가 새로운 VIP 회원 1명을 **삽입(INSERT)**하고 Commit 함.
- 치명적 결과: 트랜잭션 A가 다시 "VIP 등급인 회원 목록"을 조회하면 6명이 조회됨. 값 자체가 바뀌는 Non-Repeatable Read와 달리, 결과의 '건수(집합)' 자체가 달라지는 현상.
트랜잭션의 격리수준 단계
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
| READ UNCOMMITTED (커밋되지 않은 읽기) |
발생 | 발생 | 발생 |
| READ COMMITTED (커밋된 읽기) |
방지 | 발생 | 발생 |
| REPEATABLE READ (반복 가능한 읽기) |
방지 | 방지 | 발생 |
| SERIALIZABLE (직렬화 가능) |
방지 | 방지 | 방지 |
- READ UNCOMMITTED: 남이 작업 중인 데이터도 다 읽을 수 있음. (정합성 문제가 너무 커서 실무에서는 사용하지 않음.)
- READ COMMITTED: 커밋이 완료된 데이터만 읽을 수 있음. Dirty Read는 막을 수 있어 가장 널리 사용되는 기본 수준. (대부분의 DBMS는 이 레벨의 격리수준을 사용함.)
- REPEATABLE READ: 트랜잭션이 시작되기 전 상태의 데이터만 일관되게 보여줌. Non-Repeatable Read까지 막아줍니다. (MySQL InnoDB는 갭 락, 넥스트 키 락 등의 메커니즘을 통해 이 레벨에서도 팬텀 리드까지 거의 방어해냄.)
- SERIALIZABLE: 모든 트랜잭션을 순서대로 하나씩 실행하는 것으로 완벽한 격리를 보장하지만, 성능이 극단적으로 떨어져 (데이터의 정합성이 매우 중요한 경우에만 사용됨.)