이번주 위클리 페이퍼의 주제는
JPA에서 발생하는 N+1문제의 발생 원인과 해결 방안에 대해 설명해보자
트랜잭션의 ACID 속성 중 격리성이 보장되지 않을 때 발생할 수 있는 문제점들을 설명하고, 이를 해결하기 위한 트랜잭션 격리 수준들을 설명해보자
N+1 문제
ORM(Object Relational Mapping)에서 발생하는 문제로 하나의 SELECT 쿼리를 실행했지만 데이터베이스에서 N+1개의 SELECT 문을 실행하는 현상이다. 근본적인 원인은 RDBMS와 객체지향 언어의 패러다임 차이라고 한다.
디스코드의 채널은 여러 메시지를 가지고 있다. 만약 내가 여러 채널을 조회하려고 할 때, SELECT 쿼리 하나로 N개의 채널이 조회되고 FetchType.Lazy 을 설정했다면 메시지 리스트에는 프록시 객체가 생성될 것이다. 그리고 채널의 메시지 리스트를 조회하는 순간 N개의 SELECT문이 추가로 실행될 것이다.
이런 N+1문제를 해결하기 위한 방법을 설명해보겠다.
해결방법
FETCH JOIN
(가장 권장되는 방식)
연관 엔티티를 하나의 쿼리로 함께 가져오는 방식이다.
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllUsersWithOrders();
성능 최적화가 가장 확실하지만 페이징과 함께 사용하면 메모리에서 페이징 처리가 되므로 주의해야 한다. 또한 중복 데이터가 전송된다.
Entity Graph
@EntityGraph는 쿼리를 수정하지 않고도 자동으로 최적화를 제공해준다.
public interface ArticleRepository extends JpaRepository<Channel, Long> {
@EntityGraph(attributePaths = {"messages"})
List<Channel> findAll();
}
이런식으로 사용한다. Spring Data JPA의 기본 메서드를 사용하거나 여러 연관 관계를 조합해서 가져올 때 유리하다.
@BatchSize
N개의 쿼리를 배치 단위로 묶어서 N/배치크기 만큼의 쿼리로 줄이는 방법이다.
@Entity
public class User {
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
// 10개씩 배치로 처리
@BatchSize(size = 10)
private List<Message> messages;
}
모든 데이터가 필요하지 않거나 메모리 사용량을 제한하고 싶을 때 사용하기 좋다.
FetchType.EAGER
"항상" 연관 데이터를 가져오도록 설정하는 방법이다. <주의>가 필요함.
@Entity
public class Channel extends BaseEntity {
// LAZY를 EAGER로 변경
@OneToMany(mappedBy = "channel", fetch = FetchType.EAGER)
private List<Message> messages = new ArrayList<>();
}
이 방법을 사용할 시 불필요한 데이터 로딩이 많아진다.
트랜잭션의 ACID 속성
트랜잭션은 DBMS또는 유사한 시스템(성공과 실패가 분명하고 상호 독립적인 시스템)에서 상호작용의 단위 또는 일련의 연산이다.
여러개의 쿼리가 모여 하나의 트랜잭션을 구성하며, 이러한 트랜잭션을 쪼갤 수는 없다. 트랜잭션은 하나의 단위이므로 내부의 쿼리들은 모두 성공하거나, 실패하거나, 서로 영향을 주면 안 되는 등 데이터베이스의 일관성과 안정성을 보장하기 위한 중요한 개념이다.
트랜잭션은 ACID 원칙을 따라 동작하는데
Atomicity (원자성), Consistency (일관성), Isolation (격리성), Durability (영속성)의 약어이다.
원자성은 트랜잭션 내의 모든 쿼리는 "전부" 성공적으로 실행되어야 하고, 만약 하나라도 실패할 경우 이전에 수행 완료 됐던 작업이 있을지라도 전부 "롤백"하여 트랜잭션 실행 이전 상태로 복원해야 한다는 속성이다.
일관성은 모든 트랜잭션은 데이터베이스를 일관성 있는 상태로 유지시켜야 한다는 속성이다. 예를 들면 데이터베이스에서 정한 무결성 제약 조건 같은 것을 항상 만족시켜야 한다는 것과 같다. 일관성이 깨진다면 상품의 재고가 음수값을 가지게 되는 등 문제가 생길 수 있다.
격리성은 여러 트랜잭션들이 동시에 실행될 때 서로 영향을 주면 안 되고 독립적으로 실행되는 것 처럼 만들어야 한다는 속성이다.
속성은 성공한 트랜잭션의 결과는 안정적으로 보존되어야 하고, 영구적으로 반영되어야 한다는 속성이다. 즉 커밋된 트랜잭션의 결과는 데이터베이스에 "영구적으로 저장" 되어야 한다는 뜻이다.
격리성이 보장되지 않을 때 발생할 수 있는 문제점
1. Dirty Read
아직 커밋되지 않은 데이터를 읽는 상황이다. 예를들어 트랜잭션 1이 데이터를 수정하고 아직 커밋하지 않은 상태에서 트랜잭션 2가 그 수정된 데이터를 읽는 상황이 생길 수 있다. 이 때 트랜잭션 1이 롤백된다면 트랜잭션 2는 존재하지 않은 데이터를 읽고 있는 것이다.
1. 트랜잭션 1이 이름이 "정보석"인 사람을 조회한다.
2. 트랜잭션 2가 "정보석"의 나이를 수정하지만 커밋하지는 않는다.
3. 트랜잭선 1이 "정보석"을 다시 조회하자 수정된 나이가 나온다.
4. 트랜잭션 2가 롤백한다.
5. 트랜잭션 1은 사실 존재하지 않았던 데이터를 읽었다.
2. Non-Repeatable Read
동일한 트랜잭션 내에서 동일한 데이터를 두 번 읽었을 때 서로 다른 값을 얻는 현상이다.
1. 트랜잭션 1에서 "장발장" 이라는 사람을 조회한다.
2. 트랜잭션 2에서 "장발장"의 정보를 수정한다.
3. 트랜잭션 1이 다시 "장발장"을 조회한다. (동일한 데이터를 읽었지만 결과 값이 달라짐)
3. Phantom Read
동일한 쿼리를 두 번 실행했을 때, 결과로 나오는 행의 수가 달라지는 현상이다.
1. 트랜잭션 1에서 이름에 "김"이 들어가는 사람을 조회한다.
2. 트랜잭션 2에서 이름이 김춘식인 사람의 정보를 추가한다.
3. 트랜잭션 1이 다시 이름에 "김"이 들어가는 사람을 조회한다. (이전과 달리 김춘식이라는 행이 추가된 결과가 나옴)
트랜잭션 격리 수준
위와 같은 문제를 해결하기 위한 격리 수준이 존재한다.
각 격리 수준에 따라 해결되는 문제의 범위가 달라진다.
1. Read Uncommitted
모든 문제가 발생할 수 있는 레벨이다. Dirty Read 문제가 발생할 수 있다.
2. Read Committed
다른 트랜잭션이 커밋 완료한 데이터만 읽을 수 있는 격리 레벨이다.
만약 수정 사항이 커밋되지 않은 데이터를 읽으려고 한다면 수정되기 전의 데이터를 읽고 간다.
이 격리 레벨에선 Dirty Read 문제가 개선되지만 Non-Repeatable Read 문제가 발생할 수 있다.
이 문제는 그 다음의 격리 레벨을 통해 해결한다.
3. Repetable Read
트랜잭션이 시작될 때 읽은 데이터를 트랜잭션이 끝날 때 까지 같은 값으로 유지시키는 격리 레벨이다.
이 경우 Non-Repeatable Read 문제는 해결될 수 있으나, Phantom Read 문제가 발생할 수 있다.
4. Serializable
가장 엄격한 격리 레벨이다. 한 트랜잭션이 사용하는 데이터에 다른 트랜잭션이 절대 접근할 수 없게 한다.
말그대로 트랜잭션들을 순차적으로 진행시킨다. 따라서 교착상태가 일어날 확률이 높고 성능이 떨어진다.
'Weekly Paper' 카테고리의 다른 글
| [위클리 페이퍼] AWS RDS, GitHub Actions CI/CD (0) | 2025.07.07 |
|---|---|
| [위클리 페이퍼] 컨테이너와 Docker, 컨테이너 오케스트레이션 (0) | 2025.06.29 |
| [위클리 페이퍼] SQL의 DDL과 DML 비교, 역정규화가 필요한 상황 및 장단점 (0) | 2025.06.01 |
| [위클리 페이퍼] REST API의 장단점과 HTTP 요청 처리 과정 (2) | 2025.05.25 |
| [위클리 페이퍼] AOP, @Controller와 @RestController (0) | 2025.05.19 |