_z_z_
6월_2주차. N+1 문제, 트랜잭션의 격리성과 격리수준 본문
Q1. JPA에서 발생하는 N+1 문제의 발생 원인과 해결 방안에 대해 설명하세요.,
Q2. 트랜잭션의 ACID 속성 중 격리성(Isolation)이 보장되지 않을 때 발생할 수 있는 문제점들을 설명하고, 이를 해결하기 위한 트랜잭션 격리 수준들을 설명하세요.
1. JPA N+1문제
발생 원인
N+1 문제는 연관된 엔티티를 조회할 때 발생하는 성능 문제
발생 과정
- 1개의 쿼리로 N개의 부모 엔티티를 조회한 후
- 각 부모 엔티티의 연관된 자식 엔티티를 조회하기 위해 1개의 추가 쿼리가 실행
- 총 N+1개의 쿼리가 실행되어 성능 저하 발생
- 예를 들어, 메시지 10개를 조회했을 때, 각 메시지의 첨부파일을 @OneToMany로 가지고 있으면, 최대 11번의 쿼리가 발생됨
1 - 1. findAllByChannelId 메서드의 N+1문제
@Transactional(readOnly = true)
@Override
public PageResponse<MessageDto> findAllByChannelId(UUID channelId, Instant cursor,
Pageable pageable) {
// 1개 쿼리: 메시지 목록 조회
Slice<Message> messageSlice = messageRepository.findAllByChannelIdOrderByCreatedAtDesc(channelId, pageRequest);
// 🚨 N+1 문제 발생!
return pageResponseMapper.fromSlice(messageSlice.map(messageMapper::toDto));
// ↑ 여기서 각 Message를 DTO로 변환할 때
}
→ attachments 는 LAZY 로딩이 기본이라, 메시지마다 각각 쿼리를 또 날리게 됨 → N+1
@Component
@RequiredArgsConstructor
public class MessageMapper {
private final BinaryContentRepository binaryContentRepository;
private final BinaryContentMapper binaryContentMapper;
private final UserMapper userMapper;
public MessageDto toDto(Message message) {
if (message == null) {
return null;
}
// 각 메시지마다 작성자 조회 쿼리 실행
UserDto author = userMapper.toDto(message.getAuthor());
// 각 메시지마다 첨부파일 ID 추출 시 첨부파일 조회 쿼리 실행
List<UUID> attachmentIds = Optional.ofNullable(message.getAttachments())
.orElse(List.of())
.stream()
.map(BinaryContent::getId)
.toList();
// 각 메시지마다 첨부파일 다시 조회 쿼리 실행(중복 조회)
List<BinaryContent> binaryContents = binaryContentRepository.findAllByIdIn(attachmentIds);
List<BinaryContentDto> binaryContentDtos = binaryContents.stream()
.map(binaryContentMapper::toDto)
.toList();
return new MessageDto(
message.getId(),
message.getCreatedAt(),
message.getUpdatedAt(),
message.getContent(),
message.getChannel().getId(),
author,
binaryContentDtos
);
}
}
실제 실행되는 SQL
-- 1개 쿼리: 메시지 조회 (페이지 크기 50개)
SELECT m.* FROM messages m
WHERE m.channel_id = 'channel123'
ORDER BY m.created_at DESC
LIMIT 51;
-- 각 메시지마다 작성자 조회 (N개 쿼리)
SELECT u.* FROM users u WHERE u.id = 'user1';
SELECT u.* FROM users u WHERE u.id = 'user2';
SELECT u.* FROM users u WHERE u.id = 'user1'; -- 중복!
SELECT u.* FROM users u WHERE u.id = 'user3';
-- ... (메시지 개수만큼 반복)
-- 각 메시지마다 채널 조회 (N개 쿼리 - 불필요한 중복!)
SELECT c.* FROM channels c WHERE c.id = 'channel123';
SELECT c.* FROM channels c WHERE c.id = 'channel123';
-- ... (메시지 개수만큼 같은 채널 반복 조회!)
-- 각 메시지마다 첨부파일 조회 (N개 쿼리)
SELECT bc.* FROM message_attachments ma
JOIN binary_contents bc ON ma.attachment_id = bc.id
WHERE ma.message_id = 'msg1';
-- ... (메시지 개수만큼 반복)
해결 방안
MessageRepository에 Fetch Join 추가
@Repository
public interface MessageRepository extends JpaRepository<Message, UUID> {
// 기존 메서드들...
Slice<Message> findAllByChannelIdOrderByCreatedAtDesc(UUID channelId, Pageable pageable);
// 🔧 N+1 해결 버전 - 작성자와 함께 조회
@Query("""
select m from Message m
join fetch m.author
where m.channel.id = :channelId
order by m.createdAt desc
""")
Slice<Message> findAllByChannelIdOrderByCreatedAtDescWithAuthor(UUID channelId, Pageable pageable);
// 🔧 커서 기반 페이징 + 작성자
@Query("""
select m from Message m
join fetch m.author
where m.channel.id = :channelId
and m.createdAt < :cursor
order by m.createdAt desc
""")
Slice<Message> findAllByChannelIdAndCreatedAtLessThanOrderByCreatedAtDescWithAuthor(
UUID channelId, Instant cursor, Pageable pageable);
}
1 - 2. findAllByUserId 메서드의 N+1문제
@Transactional(readOnly = true)
@Override
public List<ChannelDto> findAllByUserId(UUID userId) {
// 1개 쿼리: 채널 목록 조회
return channelRepository.findAllAccessibleByUser(userId).stream()
.map(channelMapper::toDto) // N+1 문제 발생
.toList();
}
ChannelMapper에서 N+1 발생 과정
@Component
@RequiredArgsConstructor
public class ChannelMapper {
private final MessageRepository messageRepository;
private final ReadStatusRepository readStatusRepository;
private final UserMapper userMapper;
public ChannelDto toDto(Channel channel) {
if (channel == null) {
return null;
}
// 각 채널마다 최신 메시지 조회 쿼리 실행
Slice<Message> latestMessages = messageRepository.findAllByChannelIdOrderByCreatedAtDesc(
channel.getId(), PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createdAt"))
);
Instant lastMessageAt = latestMessages.stream()
.map(Message::getCreatedAt)
.findFirst().orElse(Instant.MIN);
// 각 채널마다 Readstatus 조회 쿼리 실행
List<ReadStatus> readStatuses = readStatusRepository.findAllByChannelId(channel.getId());
// 각 Readstatus 마다 User 조회 쿼리 실행
List<UserDto> participants = readStatuses.stream()
.map(ReadStatus::getUser) // N개 User 조회 쿼리
.map(userMapper::toDto) // 각 User를 DTO로 변환
.toList();
return new ChannelDto(
channel.getId(),
channel.getType(),
channel.getName(),
channel.getDescription(),
participants,
lastMessageAt
);
}
}
해결방안
MessageRepository에 @EntityGraph추가
@Repository
public interface MessageRepository extends JpaRepository<Message, UUID> {
// @EntityGraph 사용 (메서드명 + fetch join)
@EntityGraph(attributePaths = {"author"})
Slice<Message> findAllByChannelIdOrderByCreatedAtDesc(UUID channelId, Pageable pageable);
@EntityGraph(attributePaths = {"author"})
Slice<Message> findAllByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc(
UUID channelId, Instant cursor, Pageable pageable);
// 여러 연관 엔티티 함께 로드
@EntityGraph(attributePaths = {"author", "attachments"})
Slice<Message> findAllByChannelIdOrderByCreatedAtDescWithDetails(UUID channelId, Pageable pageable);
}
Mapper 최적화
@Component
@RequiredArgsConstructor
public class ChannelMapper {
private final UserMapper userMapper;
// 🔧 Repository 호출 제거 - 이미 로드된 데이터 사용
public ChannelDto toDto(Channel channel) {
if (channel == null) {
return null;
}
// 이미 fetch join으로 로드된 데이터 사용
Instant lastMessageAt = Optional.ofNullable(channel.getLatestMessage())
.map(Message::getCreatedAt)
.orElse(Instant.MIN);
// 이미 fetch join으로 로드된 ReadStatus와 User 사용
List<UserDto> participants = channel.getReadStatuses().stream()
.map(ReadStatus::getUser) // 이미 로드됨 - 추가 쿼리 없음
.map(userMapper::toDto)
.toList();
return new ChannelDto(
channel.getId(),
channel.getType(),
channel.getName(),
channel.getDescription(),
participants,
lastMessageAt
);
}
}
해결방안 장단점
| 해결방법 | 단점 | |
| fetch join | JPQL에서 join fetch 사용해서 연관 엔티티까지 한 번에 가져옴 | 페이징 불가, 중복 데이터 |
| EntityGraph | 필요한 관계만 선택적으로 페이지 조인 처럼 사용 | 유지보수 쉬움, 페이징 가능 |
| DTO로 직접 조회 | new 키워드로 필요한 데이터만 직접 뽑아서 조회 | 재사용 어려움, 코드 복잡 |
| Batch Size 설정 | LAZY 로딩이더라도 일괄 로딩되도록 설정 | 모든 경우에 적용되지 않음 |
상황별 권장 방식
| 단건 조회, 페이징 필요 없음 | Fetch Join |
| 다건 조회 + 페이징 필요 | EntityGraph 또는 DTO 직접 조회 |
| 단순 리스트, 연관 데이터 거의 안씀 | LAZY 그대로 유지 |
| 무조건 전체 조회 성능 우선 | Fetch Join + distinct + 페이징은 Memory 기반 |
2. 트랜잭션 격리성과 격리 수준
격리성?
: 동시에 실행되는 여러 트랜잭션들이 서로 간섭하지 않고 독립적으로 실행되는 것 처럼 보이게 하는 특성
격리성이 부족하다면?
1. Dirty Read
아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 현상
2. Non-Repeatable Read(반복 불가능 읽기)
같은 트랜잭션 내에서 같은 데이터를 여러 번 읽을 때 결과가 다른 현상
3. Phantom Read
같은 조건으로 범위 검색을 했을 때 이전에 없던 레코드가 나타나는 현상
트랜잭션 격리 수준들
1. 가장 낮은 격리 수준 - 모든 문제 발생 가능 (Isolation.READ_UNCOMMITTED)
- 커밋되지 않은 데이터도 읽기 가능
- 더티리드, 반복 불가능읽기, 팬텀리드 모두 발생
- 성능은 가장 좋음
2. Read Committed(Level 1)
커밋된 데이터만 읽기 가능, 더티리드 방지 나머지 발생 가능
동작 원리 :
사용 사례: 대부분의 웹 애플리케이션 (Oracle, PostgreSQL 기본값)
3. Repeatable Read(Level 2)
같은 트랜잭션 내에서 같은 데이터는 항상 동일한 결과 보장
팬텀 리드 발생가능
동작 원리 :
사용 사례: MySQL InnoDB 기본값, 금융 시스템의 잔액 조회
4. Serializable (Level 3)
가장 높은 격리 수준 - 모든 문제 해결
성능이 가장 낮은 단점이 있음
동작 원리:
- 트랜잭션들이 순차적으로 실행되는 것과 동일한 결과
- 범위 잠금 사용
실무 선택 기준
일반적인 웹 애플리케이션(일반적인 CRUD) → Read Committed 기본값 사용
금융 거래 시스템(정확한 잔액 계산 중요) → Seralizable 또는 Repeatable read
통계/리포트 조회(대략적인 통계, 속도 우선) → Read uncomitted(빠른 조회)
재고관리(재고 수량 일관성 중요) → Repeatable read
데이터베이스별 기본값
- MySQL InnoDB: REPEATABLE READ
- PostgreSQL: READ COMMITTED
- Oracle: READ COMMITTED
- SQL Server: READ COMMITTED
3. (수정)번외로, 이번 플젝에서 생긴 전파 문제 상황 트랜잭션/영속성 컨텍스트 이슈
** 수정 **
트랜잭션 전파라기보단, 같은 트랜잭션에서 참조 관계가 유지된 상태로 flush가 발생하면서 db 무결성 제약과 충돌하는 문제
커밋 flush 시점에 INSERT/UPDATE/DELETE 쿼리 실행 순서 + DB FK 무결성 제약이 겹치며 삭제가 실패(롤백) 하는 문제였다
***
@Transactional 있을 때(동일 트랜잭션에서 충돌)
@Transactional
public void deleteEmployee(Long id) {
Employee employee = findById(id);
// 1) ChangeLog에서 employee를 FK로 참조한 상태로 저장
ChangeLog changeLog = changeLogService.create(employee, ...);
changeLogService.save(changeLog);
// 2) 같은 트랜잭션 안에서 employee 삭제 시도
employeeRepository.delete(employee);
// 커밋 시점 flush에서 INSERT(ChangeLog) / DELETE(Employee) 순서 및 FK 참조 상태에 따라
// DB 무결성 제약 위반이 발생할 수 있음
}
@Transactional 없을 때
작업이 여러 트랜잭션으로 분리되며 증상이 달라 보인 것 뿐, 전파 때문에 된다/안된다라고 일반화하기엔 위험하다
public void deleteById(Long id) { // 명시적 트랜잭션 없음
// 1) findById는 보통 짧은 트랜잭션으로 끝나며 employee는 DETACHED가 될 수 있음
Employee employee = employeeRepository.findById(id);
// 2) ChangeLog 저장이 별도 트랜잭션으로 커밋될 수 있음
ChangeLog changeLog = changeLogService.create(employee, ...);
changeLogService.saveChangeLogWithDetails(changeLog, diffs);
// 3) employee 삭제도 별도 트랜잭션으로 수행될 수 있음
employeeRepository.delete(employee);
}
⇒ 플젝에서의 문제는 JPA 영속성 컨텍스트 내 엔티티 생명주기 충돌, 엔티티 참조 관계와 상태 전이 불일치 문제.
전파와 격리수준 차이점
| 구분 | Isolation (격리수준) | Propagation (전파) |
| 문제 범위 | 동시성 문제 | 트랜잭션 경계 문제 |
| 대상 | 여러 트랜잭션 간 | 하나의 호출 체인 내 |
| 목적 | 데이터 일관성 | 비즈니스 로직 경계 |
| 해결하는 것 | Dirty Read, Phantom Read 등 | 롤백 범위, 커밋 범위 |
'쪼드잇 > 위클리페이퍼' 카테고리의 다른 글
| 6월_4주차. 컨테이너와 도커 (3) | 2025.06.30 |
|---|---|
| 6월_3주차. 입력값 검증의 범위와 책임, Mockito의 Mock, Stub. Spy (5) | 2025.06.23 |
| 5월_4주차. DDL과 DML, 역정규화 (5) | 2025.06.02 |
| 5월_3주차. REST, HTTP요청 처리과정 (4) | 2025.05.25 |
| 5월_2주차. AOP, @Controller vs @RestController와 MVC의 요청 처리 흐름 (1) | 2025.05.18 |