«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
Recent Posts
Recent Comments
관리 메뉴

_z_z_

6월_2주차. N+1 문제, 트랜잭션의 격리성과 격리수준 본문

쪼드잇/위클리페이퍼

6월_2주차. N+1 문제, 트랜잭션의 격리성과 격리수준

hyohyo_zz 2025. 6. 16. 03:39
더보기

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 등 롤백 범위, 커밋 범위