[MySQL]트랜잭션의 격리 수준 파헤치기 2 (feat. 영속성 컨텍스트의 함정)

2025. 8. 5. 23:44·SW개발/Database

안녕하세요. 지난 포스팅에서는 MySQL에서의 트랜잭션 격리 수준에 대해 이해하는 주제를 다루었습니다.

오늘은 조금 더 실무의 경험에 가깝게 구성된 JPA에서의 트랜잭션의 격리 수준이 어떻게 적용되는지 알아보고자 합니다. 또, 영속성 컨텍스트의 함정도 같이 다룹니다.

 

[1편 보러 가기]

https://leffept.tistory.com/567

 

[MySQL]트랜잭션의 격리 수준 파헤치기 1

안녕하세요, 오랜만에 인사드립니다. 오늘은 트랜잭션의 격리 수준에 대해서 낱낱이 파헤쳐 보려고 합니다. MySQL의 트랜잭션 격리 수준에 따라서 어떠한 점들이 달라지게 되는지 알아보겠습니

leffept.tistory.com

 

Spring Data JPA 와 트랜잭션의 격리 수준

지난 포스팅을 통해 격리 수준은 Read Uncomitted, Read Comitted, Repeatable Read, Serializable 총 4가지가 있는 것을 배웠습니다. Spring의 격리 수준은 어떻게 구성되어 있을까요?

import org.springframework.transaction.annotation.Isolation;

public enum Isolation {
	/**
	 * Use the default isolation level of the underlying data store.
	 * <p>All other levels correspond to the JDBC isolation levels.
	 * @see java.sql.Connection
	 */
	DEFAULT(TransactionDefinition.ISOLATION_DEFAULT),

	READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED),

	READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),

	REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ),

	SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE);
}

데이터베이스의 격리 수준과 동일하게 구성되어 있습니다. @Transactional 어노테이션에 별도의 옵션을 지정하지 않으면 데이터베이스의 기본 격리 수준을 따라갑니다.

별도의 변경을 하지 않았다면 MySQL이면 Repeatable Read, PostrgreSQL/Oracle이면 Read Comitted가 적용됩니다.

 

Spring에서 지원하는 격리 수준 자체는 동일하지만 JPA에는 영속성 컨텍스트라는 개념이 존재해서, 애플리케이션 레벨에서는 조금 다르게 적용됩니다. 오늘은 이 부분을 집중적으로 다룹니다.

 

위에서 언급한 Isolation은 Spring의 JDBC 수준에서 동작하는 개념입니다. (JPA의 표준에서 동작하는 개념은 아닙니다)

 

JPA를 이용한 격리 수준의 이해

JPA의 영속성 컨텍스트로 인해서 트랜잭션의 격리 수준이 Read Comitted일 지라도 애플리케이션 레벨에서는 Repeatable Read처럼 읽는 것이 가능합니다.

 

영속성 컨텍스트란?
JPA 영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에서 엔티티 객체를 관리하는 가상의 데이터베이스 역할을 하는 논리적인 개념입니다.
영속성 컨텍스트는 엔티티의 생명주기를 관리하고, 동일성 보장, 변경 감지, 1차 캐시, 지연 로딩 등의 기능을 제공하여 데이터베이스 연동을 효율적으로 처리할 수 있도록 돕습니다. 

 

JPA의 Repeatable Read (영속성 컨텍스트)

JPA를 이용하면 애플리케이션 레벨에서의 Repeatable Read는 어떻게 가능한 것일까요? 예시와 함께 보겠습니다.

데이터베이스의 격리 수준은 Read Comitted로 가정

┌────────────────────────────────────────┬────────────────────────────────────────┐
│               트랜잭션 A                 │               트랜잭션 B                 │
├────────────────────────────────────────┼────────────────────────────────────────┤
│ @Transactional                         │                                        │
│ User user1 = userRepository            │                                        │
│     .findById(1L);                     │                                        │
│ -> DB 조회, 결과 : John                  │                                        │
│ -> 영속성 컨텍스트에 저장                   │                                        │
│                                        │                                        │
│                                        │ UPDATE User SET name = 'James' WHERE   │
│                                        │ id = 1;                                │
│                                        │ COMMIT                                 │
│                                        │                                        │
│ User user2 = userRepository            │                                        │
│     .findById(1L);                     │                                        │
│ -> DB 조회, 결과 : John (동일)            │                                        │
│ -> 영속성 컨텍스트에서 조회                  │                                        │
│                                        │                                        │
└────────────────────────────────────────┴────────────────────────────────────────┘

JPA는 일반적으로 트랜잭션 단위로 영속성 컨텍스트를 시작하고 종료합니다. 엔티티를 한번 조회하고 나면 그 결과를 캐시처럼 저장합니다.

따라서, 다른 트랜잭션 B에서 데이터가 업데이트되어도 트랜잭션 A에서는 Repeatable Read가 가능해지는 것입니다.

 

Repeatable Read를 달성하기 위해 영속성 컨텍스트가 존재하는 것은 아니고, 영속성 컨텍스트의 1차 캐시 특징으로 인해서 반복 가능한 읽기가 가능한 것이라고 이해하시면 될 것 같습니다.

또한, DB 조회가 일어나지 않기에 성능적인 측면에서도 유리한 부분이 존재합니다.

 

영속성 컨텍스트의 함정

대부분의 케이스에서는 영속성 컨텍스트는 반복 가능한 읽기도 지원하고 조회 성능에도 많은 이점을 가져다줍니다. 하지만, 동시성 제어의 중요도가 높은 상황에서는 쉽게 함정에 빠질 수 있습니다. 

 

제가 실제로 경험했던 한 가지 사례를 함께 살펴보겠습니다.

┌──────────────────────────────────────┬──────────────────────────────────────┐
│             트랜잭션 A                 │             트랜잭션 B                 │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ couponRepository.findById(1L);       │                                      │
│ -> 쿠폰 조회, 상태 : ACTIVE             │                                      │
│                                      │ couponRepository.findById(1L);       │
│                                      │ -> 쿠폰 조회, 상태 : ACTIVE             │
│ 쿠폰 사용 기록 생성 (RESERVED)           │                                      │
│                                      │ 쿠폰 사용 기록 생성 (RESERVED)           │
│                                      │                                      │
│ 기타 비즈니스 로직 수행                   │                                      │
│                                      │ 기타 비즈니스 로직 수행                   │
│                                      │                                      │
│ couponRepository                     │                                     │
│   .findByIdForUpdate(1L);            │ couponRepository                    │
│ -> 락과 함께 쿠폰 조회, 상태 : ACTIVE     │    .findByIdForUpdate(1L);           │
│                                      │ -> 락 획득 대기                        │
│ 쿠폰 사용 기록 생성 (USED)               │                                      │
│ 쿠폰 상태 USED로 변경                    │                                      │
│                                      │                                      │
│ COMMIT                               │                                      │
│                                      │                                      │
│                                      │ -> 락 획득                            │
│                                      │ 조회(영속성 컨텍스트, DB에서 가져온 값 버림)  │
│                                      │ 상태 : ACTIVE                         │
│                                      │                                      │
│                                      │ 쿠폰 사용 기록 생성 (USED, 중복)          │
│                                      │ 쿠폰 상태 USED로 변경 (의미 X)            │
│                                      │                                      │
│                                      │ COMMIT                               │
└──────────────────────────────────────┴──────────────────────────────────────┘

처음에 기대했던 바는 트랜잭션 A에서 FOR UPDATE와 함께 락을 걸었기에 다른 트랜잭션 B에서는 커밋된 이후의 데이터인 USED가 조회되는 것을 예상했습니다.

하지만 예상과는 달리 락을 획득하여 조회한 트랜잭션 B도 ACTIVE 상태를 가지는 쿠폰이 조회되었습니다.

바로 영속성 컨텍스트 1차 캐시에서 엔티티가 조회되었기 때문입니다. 그렇게 쿠폰이 중복으로 사용되고 사용 기록도 2번 생성이 된 문제입니다.

 

어떻게 보면 영속성 컨텍스트의 특징을 온전하게 이해하지 못해서 발생한 문제라고 볼 수 있지만, 사실 이 문제의 본질은 락을 늦게 걸었기 때문입니다.

만약, 처음 쿠폰 상태를 조회할 때 FOR UPDATE로 락을 걸었다면 트랜잭션 A에서 쿠폰 사용을 마치기 전까지는 B에서 사용을 못 했을 것입니다.

하지만 쿠폰의 두 번째 조회에서 뒤늦게 락을 걸었기에 이전에 수행된 비즈니스 로직에서 여러 데이터는 변경되었을 것입니다. 예상했던 대로 쿠폰이 USED로 조회되었어도 충분히 다른 문제가 생길 수 있는 것이죠.

 

이제, 근본적인 문제는 락을 늦게 걸었다는 것을 이해했습니다. 여기에 몇가지 더 흥미롭게 생각해볼 부분도 존재합니다.

 

첫째로, 쿠폰을 조회할 때 처음에는 findById는 JPA의 Query Methods를 이용하였고 두 번째 조회에서는 JPQL + @Lock 어노테이션을 사용해 FOR UPDATE로 조회했습니다.

이렇게 구현한다면 두번째 조회에서는 JPQL로 작성되었기에 DB로 쿼리가 무조건 나가게 됩니다. @Lock 어노테이션도 있기에 FOR UPDATE 락도 함께 걸립니다.

다만, JPQL에서는 DB에서 조회한 결과가 있어도 영속성 컨텍스트에 엔티티가 이미 존재한다면 조회한 결과를 버리고 영속성 컨텍스트의 엔티티를 그대로 이용하게 됩니다.

이 또한 예상한 결과와 달라지는 부분이죠.

 

왜 DB의 조회 결과를 버리게 될까요?

트랜잭션 내에서 엔티티가 영속성 컨텍스트에 존재하는 것은 이미 조회가 된 상황입니다. 그리고 그 엔티티는 언제든 수정이 될 수 있고 트랜잭션이 진행중 이기에 아직 커밋도 되지 않은 상황입니다.

이러한 상황에서 JPQL로 DB에서 조회한 내용을 영속성 컨텍스트에 덮어씌운다면 수정 중인 사항이 사라질 수 있습니다. 그래서 DB에서 조회했던 데이터를 폐기하고 영속성 컨텍스트의 엔티티를 사용하게 됩니다.
JPA 영속성 컨텍스트의 구현 방식으로 인해 생긴 현상이라고 이해하시면 됩니다.

 

둘째로, 영속성 컨텍스트를 사용하지 않는 네이티브 쿼리로 구현했다면 트랜잭션 B에서 쿠폰을 두 번째 조회하는 시점에서의 상태는 USED일 것입니다. 하지만 이미 비즈니스 로직을 수행하면서 여러 데이터가 변경된 시점이기에 여전히 예상한 결과와는 달랐을 것입니다.

 

 

내용이 길고 복잡했는데요, 다시 정리해 보면 다음과 같습니다.

  • 처음 조회 시점부터 락을 거는 것이 근본적으로 문제를 해결하는 방법입니다.
  • JPA의 영속성 컨텍스트 동작으로 인해 JPQL로 가져온 결과가 폐기될 수 있습니다. 
    • 다만, 영속성 컨텍스트는 트랜잭션 내에서만 동작하고 예시처럼 조회를 두 번하는 로직은 상황은 사실 드뭅니다.
  • 만약 네이티브 쿼리로 작성했다면 쿠폰의 사용 기록은 생성 안 되었겠지만, 비즈니스 로직 처리로 인해 데이터가 꼬였을 것입니다.
  • 영속성 컨텍스트의 REPETABLE READ 동작이 때로는 함정에 빠지게 할 수 있습니다.

 

마치며

트랜잭션과 격리 수준, JPA의 영속성 컨텍스트와 REPEATABLE 그리고 실무에서 발생할 수 있는 문제까지 모두 다루어 보았습니다. 진짜로 본질적인 부분까지 파보려니 꽤 헷갈리는 부분이 많았던 것 같습니다.

 

최근의 개발은 여러가지 복잡한 개념들이 많이 얽혀있어도 개발자는 추상화된 형태로 사용만 하는 경우가 많습니다. 대부분의 상황에서는 잘 동작하지만 온전하게 이해하지 못하고 사용하는 부분에서는 함정에 빠지거나 올바르지 않은 해결책을 찾게 되는 것 같습니다.

 

저 역시도 문제만 해결하고 넘어갈 수 있었지만 다시 생각해보면서 오랜만에 깊게 파보게 된 경험이었던 것 같습니다.

 

 

긴 글 읽어주셔서 감사합니다.

 

728x90

'SW개발 > Database' 카테고리의 다른 글

[MySQL]트랜잭션의 격리 수준 파헤치기 1  (4) 2025.07.24
[Database] PostgreSQL 타입 정리  (0) 2023.11.19
'SW개발/Database' 카테고리의 다른 글
  • [MySQL]트랜잭션의 격리 수준 파헤치기 1
  • [Database] PostgreSQL 타입 정리
Leffe_pt
Leffe_pt
개발자로서 성장하면서 배워온 지식과 경험을 공유하는 공간입니다.
  • Leffe_pt
    Leffe's tistory
    Leffe_pt
  • 전체
    오늘
    어제
    • 분류 전체보기 (309)
      • SW개발 (305)
        • 코딩테스트 (172)
        • 개발이야기 (23)
        • IT 용어 (17)
        • Python (22)
        • Django (46)
        • Flask (2)
        • Database (3)
        • SQLAlchemy (0)
        • Javascript (5)
        • Linux, Unix (3)
        • JAVA (2)
        • Spring (10)
      • 회고 (4)
      • 사진 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    django
    플레이스토어
    g
    컨트리뷰터
    트리 #AVL #알고리즘 #자료구조
    배공파용
    어플리케이션
    Contributor
    라이프 스타일
    배달
    오픈소스
    음식
    배달비 공유
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Leffe_pt
[MySQL]트랜잭션의 격리 수준 파헤치기 2 (feat. 영속성 컨텍스트의 함정)
상단으로

티스토리툴바