트랜잭션 격리 수준과 JPA의 옵션

이정진·2024년 7월 9일
0

Study

목록 보기
15/16

면접 질문으로 받으면서 데이터베이스 트랜잭션 격리 수준에 대한 질문을 받은 적이 있었다. LIVID에서 자유 주제로 매주 글을 하나씩 정리해서 공유하는 스터디를 참여하고 있었기에, 관련 내용을 주제로 준비했다. 마켓 컬리 기술 블로그에서 24년 6월에 이와 관련된 내용을 JPA 설정과 함께 다루고 있어 같이 정리해본다.

트랜잭션 격리 수준

정의

동시에 여러 트랜잭션이 처리 될 때, 트랜잭션끼리 얼마나 고립(격리)되어 있는가에 대한 구분

ACID

Atomicity (원자성)

트랜잭션과 관련된 일이 모두 수행되었거나 되지 않았거나를 보장하는 특징

커밋: 여러 쿼리가 성공적으로 처리되었다고 확정하는 명령어로, 하나의 트랜잭션이 성공적으로 수행되었다는 의미

롤백: 에러나 여러 이슈 때문에 트랜잭션으로 처리한 하나의 묶음 과정을 일어나기 전으로 돌리는 일을 의미

func (s *Service) Register(ctx context.Context, data string) (*Test, error) {
	// Transaction Begin
	tx := s.Repository.DB.Begin()
	defer func() {
		if err := recover(); err != nil {
			tx.Rollback()
		}
	}()

	// Business Logic
	test := Test{
		Data: data,
	}
	saveTest, queryErr := s.Repository.Create(ctx, tx, &test)
	if queryErr != nil {
		return nil, queryErr
	}

	// Transaction Commit
	if err := tx.Commit().Error; err != nil {
		tx.Rollback()
		return nil, err
	}

	return saveTest, nil
}

위 코드는 트랜잭션의 커밋과 롤백을 명확하게 이해할 때 도움이 되는 코드라고 생각해서 작성해보았다. (Golang + Echo + GORM 기반으로 작성한 코드다.)

//Transaction Commit 하단의 3줄을 제거한 상황에서 API 요청을 보내면, Response에서는 ID값이 증가한 상태로 답변이 오지만, 데이터베이스에는 저장되지 않은 것을 볼 수 있다. 즉, 커밋되지 않았기에 삽입 쿼리가 완료되지 않은 것이다.

Consistency (일관성, 정합성)

'허용된 방식'으로만 데이터를 변경해야하는 것을 의미

즉, 데이터베이스에 기록된 모든 데이터는 여러 가지 조건, 규칙에 따라 유효성을 가져야 한다.

Isolation (격리성)

트랜잭션 수행 시 서로 끼어들지 못하는 것

격리성의 세부적인 내용은 아래에서 별도로 다룬다.

Durability (지속성)

성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다는 것을 의미

즉, 데이터베이스에 시스템 장애가 발생해도 원래 상태로 복구하는 회복 기능이 있어야 한다는 것을 의미하며, 데이터베이스는 이를 위해 체크섬, 저널링, 롤백 등의 기능을 제공한다.

저널링: 파일 시스템 또는 데이터베이스 시스템에 변경 사항을 반영(commit)하기 전에 로깅하는 것, 트랜잭션 등 변경사항에 대한 로그를 남기는 것

격리수준

위의 트랜잭션의 ACID 중 격리성의 세부 격리 수준을 정리해보면 아래와 같습니다.

(출처: 면접을 위한 CS전공지식 노트 p.209)

Serializable

Serializable은 트랜잭션을 순차적으로 진행시키는 것을 의미 = 트랜잭션이 동시에 같은 행에 접근 불가

Repeatable Read

Repeatable Read는 하나의 트랜잭션이 수정한 행을 다른 트랜잭션이 수정할 수 없도록 막아주지만 새로운 행을 추가하는 것은 막지 않는다.

팬텀 리드(phantom read)

한 트랜잭션 내에서 동일한 쿼리를 보냈을 때, 해당 조회 결과가 다른 경우를 의미한다. (값은 변경되지 않는다.)

트랜잭션 A, B가 존재
1. 트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재
2. 트랜잭션 B에서 1번 테이블에 1개의 Row 삽입
3. 트랜잭션 B Commit
4. 트랜잭션 A에서 1번 테이블을 조회 = 3개의 Row 존재

Read Committed

Read Committed는 다른 트랜잭션이 커밋하지 않은 정보를 읽을 수 없다. 커밋 완료된 데이터에 대해서만 조회를 허용한다.

반복 가능하지 않은 조회(non-repeatable read)

한 트랜잭션 내의 같은 행에 두 번 이상 조회가 발생했을 때, 그 값이 다른 경우를 말한다. (동일한 행에서 값의 변경이 일어난다.)

트랜잭션 A, B가 존재
1. 트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 20)
2. 트랜잭션 B에서 1번 테이블의 2번 row의 값을 30으로 변경
3. 트랜잭션 B Commit
4. 트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 30)

Read Uncommitted

하나의 트랜잭션이 커밋되기 이전에 다른 트랜잭션에 노출될 수 있다. 속도는 가장 빠르지만, 커밋되기 이전에 다른 트랜잭션에 데이터가 노출되므로 데이터 무결성을 위반할 가능성이 높다.

더티 리드(dirty read)

한 트랜잭션이 실행 중일 때 다른 트랜잭션에 의해 수정되었지만 아직 ‘커밋되지 않은’ 행의 데이터를 읽을 수 있을 떄 발생

트랜잭션 A, B가 존재
1. 트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 20)
2. 트랜잭션 B에서 1번 테이블의 2번 row의 값을 30으로 변경
3. 트랜잭션 A에서 1번 테이블을 조회 = 2개의 Row 존재 (1번 row의 값 10, 2번 row의 값 30)

데이터베이스별 격리 수준의 Default Condition

  • MySQL의 기본 격리 수준: Repeatable Read (출처)

  • PostgreSQL의 기본 격리 수준: Committed Read (출처)

  • Oracle의 기본 격리 수준: Committed Read (출처)

  • MSSQL의 기본 격리 수준: Committed Read (출처)

  • MongoDB의 기본 격리 수준: Uncommitted Read (출처)

추가적으로 알아볼만한 사항
Q. 오라클은 Repeatable Read 수준을 지원하지 않는다. 그렇다면, Non Repeatalbe Read 문제를 어떻게 해결할 수 있을까?
A. 오라클은 Exclusive Lock을 통해 이를 해결할 수 있다. 자세한 건 트랜잭션 격리수준 기술 블로그를 참고하면 좋을 것이다.

마켓컬리 기술 블로그 내용 정리

위 트랜잭션 격리수준은 마켓컬리의 기술 블로그 내용 중 격리 수준을 Committed Read로 낮추어 해결하는 방법을 생각했었다는 부분에서 파생되어 정리한 내용이다.
이제는 마켓컬리 기술 블로그의 내용을 간략하게 정리한다.

이슈

환경

회원 서비스와 멤버십 서비스가 분리되어 있으며, 두 서비스가 동일한 DB에 접근할 수 있는 환경이다.

상황

  1. 회원 서비스에서 INSERT 쿼리 실행
  2. 회원 서비스에서 SELECT 쿼리 실행 (회원 정보 정상적으로 조회)
  3. 멤버십 서비스에서 SELECT 쿼리 실행 (간헐적으로 회원 정보 조회 X)
  4. 멤버십 서비스에서 다시 SELECT 쿼리 실행 (회원 정보 정상적으로 조회)

관련 예시 코드

public Membership outerMethod(long memberNo) {
    
        var member = memberRepository.findByMemberNo(memberNo)
                .orElseThrow(() -> new RuntimeException("요청한 리소스를 찾을 수 없습니다.");
                
        var innerResult = service.innerMethod(memberNo);
        return ...;
    }
    
@Transactional(readOnly = true)
public InnerResult innerMethod(long memberNo) {
    
        var member = memberRepository.findByMemberNo(memberNo);
...
}

출처: 마켓 컬리 기술 블로그

  • innerMethod는 @Transactional(readonly=true) 선언이 되어있는 상황이다.

원인

3번 과정에서 신규 데이터가 적재되기 전 Snapshot 을 가지고 있던 Session일 경우 데이터가 조회되지 않아 ROLLBACK이 발생한 것이었다.
4번 과정이 정상적으로 실행된 이유는 그 다음 데이터 요청에서 Snapshot 이 갱신되어 데이터가 조회 가능했던 것이라고 한다.

Snapshot 갱신

MySQL의 InnoDB 엔진은 MVCC를 사용해 특정 시점의 데이터베이스 Snapshot을 쿼리에 제공한다. (MVCC는 다중 버전 동시성 제어라는 의미의 약자다.)

트랜잭션 내에서 최초 쿼리 발생 시 Snapshot을 읽어오고, 이 Snapshot 이후의 변경사항에 대해서는 트랜잭션 내에서는 확인할 수 없다. 이를 Consistent Nonlocking Reads라고 한다.

Snapshot을 갱신하기 위해서는, COMMIT, ROLLBACK, BEGIN을 실행하여 갱신할 수 있다.

해결책

@Transactional(readonly=true) 을 외부 메소드에 추가

트랜잭션 중첩?

innerMethod에 @Transactional(readonly=true)를 선언한 상태에서, outerMethod에 다시 @Transactional(readonly=true)를 선언해주었는데, innerMethod에서 해당 선언을 지웠다는 얘기가 없었으므로 트랜잭션 중첩이 되었다고 생각했다.

처음에는 Master DB와 Slave DB를 구분하기 위한 것인가 했으나, 해당 글에서 아래와 같이 Master DB와 Slave DB 연결에 대한 내용을 정리하고 있어 해당사항이 아님을 확인했다.

Master DB 연결 vs Slave DB 연결

MariaDB Connector/J 의 aurora 모드는 읽기 전용(readOnly=true)으로 설정하면 Slave DB로 로드 밸런싱을 지원해요.

  • @Transactional 설정 또는 설정이 없는 경우는 Master DB 로 요청
  • @Transactional(readOnly=true) 설정인 경우 Slave DB 로 요청

위 질문에 대한 작성자분의 의견이 궁금해 댓글을 남겼으며, 답글이 달리면 관련 내용을 업데이트할 예정이다.

Transaction in Java/Spring + JPA

마켓컬리 글에서 다룬 다양한 키워드들에 대한 추가 정리 내용이다.

@Transactional의 다양한 옵션

  • readonly: 읽기/쓰기 또는 읽기 전용 트랜잭션 선택 여부
  • isolation: 선택적 격리 수준
  • timeout: 트랜잭션 타임아웃
  • propagation: 선택적 전파 설정 (REQUIRED가 기본값)

(출처: 망나니 개발자, 2024. 07. 08)

개발자가 정의한 쿼리 메소드와 HikariCP

Spring Data JPA의 기본 메소드는 @Transactional 이 기본적으로 적용되지만, JPA 인터페이스에 정의한 쿼리 메소드는 적용되지 않는다. 그렇기에, 트랜잭션을 적용하기 위해서는 명시적으로 추가해야 한다.

HikariCP는 커넥션 종료 시에 COMMIT 여부를 체크한다. 다만, 위에 언급했듯이 JPA 인터페이스나 QueryDSL의 메소드는 @Transactional 이 적용되지 않아 COMMIT이 실행되지 않는다. 그렇기에, 하단에 서술할 auto-commit일 경우, ROLLBACK이 실행된다는 점을 유의해야 한다.

open-in-view

open-in-view는 영속성 컨텍스트(Persistence Context)를 뷰 렌더링이 끝날 때까지 개방 상태로 유지하는 패턴이다.

Spring에서는 spring.jpa.open-in-view 설정으로 제어할 수 있다.

spring:
  jpa:
    open-in-view: true/false

옵션

  • true(기본값): 트랜잭션이 끝나도 영속성 컨텍스트를 종료하지 않고, 뷰 렌더링이 끝날 때까지 유지한다.
  • false: 트랜잭션이 끝나면 영속성 컨텍스트도 닫힌다.

장점

  • 지연 로딩(Lazy Loading)을 뷰 렌더링 시점까지 사용할 수 있다.
  • 컨트롤러나 뷰에서 필요한 데이터를 편리하게 조회할 수 있다.

단점

  • 데이터베이스 커넥션을 오랫동안 유지하므로 리소스 사용이 증가한다.
  • 뷰 렌더링 중 데이터베이스 작업이 발생할 수 있어 성능 문제가 생길 수 있다.
  • 애플리케이션 복잡도가 증가하고 예측하기 어려운 문제가 발생할 수 있다.

마켓컬리에서는?

마켓컬리에서는 open-in-view를 true로 설정했었기에, outerMethod의 사용자 조회 쿼리에서 ROLLBACK이 발생되지 않았다. 그 상태에서 innerMethod의 @Transactional(readonly=true)로 인해 innerMethod가 종료되면서 COMMIT을 뱉어 갱신되지 않았다고 얘기한다.

이 문장에서도, COMMIT은 Snapshot을 갱신한다는 내용이 동일한 글 윗 부분에 적혀있다 보니 갱신되지 않았다는 문장의 의미가 어떤 것인지 확인을 위해 댓글을 남겼다. 답글이 달리면 업데이트할 예정이다.

그러나, open-in-view에 대해서는 다양한 관점과 내용이 있길래 Spring Boot의 open-in-view, 그 위험성에 대하여.라는 글을 첨부한다.

auto-commit

hikari.auto-commit은 각 SQL문에 대하여 자체적으로 단일 트랜잭션을 생성하여 오류가 발생하지 않은 쿼리에 대해 COMMIT을 수행하는 옵션이다.
true로 설정하면 안정적이지만 @Transactional 전후로 추가적인 쿼리가 발생하게 되어 성능상 손해가 발생한다.

마켓 컬리의 경우 auto-commit을 false로 하여 성능을 개선시켰다. (응답시간 1.5ms 감소, 40% 향상)

auto-commit 설정

auto-commit은 application.yml파일에서 아래와 같이 설정할 수 있다.

spring:
  datasource:
    hikari:
      auto-commit: false

auto-commit 적용 여부에 따른 차이 직접 확인하기

먼저 테스트 환경은 아래와 같다.

  • Java17
  • Spring Boot 3.x
  • MySQL 8.x

MySQL 실행된 쿼리 확인하는 법

MySQL에서는 실행된 모든 쿼리에 대해서 general_log에서 관리하고 있다.
그렇기에 general_log로 관리할 수 있도록 설정을 진행해야 한다.

  1. general_log의 ON/OFF 여부 확인
SHOW VARIABLES LIKE "general_log%"; 
  1. table engine 확인 및 변경 (general_log가 OFF여야만 적용이 가능)
//table engine 확인
SHOW TABLE STATUS LIKE 'general_log'\G
SELECT engine FROM information_schema.TABLES WHERE table_name='general_log';

//table engine 변경
ALTER TABLE mysql.general_log engine=MyIsam;
  1. general_log를 ON으로 설정
SET GLOBAL general_log = 'ON';
  1. general_log의 출력 방식 설정 (DB Tool에서 Table 형식으로 확인할 것이게 TABLE로 설정 진행)
SET GLOBAL log_output = 'TABLE'; // 출력 방식을 테이블로 설정 (FILE, TABLE, TABLE,FILE 등의 옵션 존재)
  1. 로그 조회
SELECT * FROM mysql.general_log LIMIT 100;

application.yml

실제로 테스트 시 사용한 환경변수 파일은 아래와 같다.

server:
  port: 8080

spring:
  application:
    name: auto-commit
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/{데이터베이스}
    username: root
    password: {비밀번호}
    hikari:
      auto-commit: false
  jpa:
    open-in-view: true
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQLDialect

logging:
  level:
    org.springframework.orm.jpa: TRACE
    org.springframework.transaction: TRACE

auto-commit: true

실제로 auto-commit 쿼리가 실제 쿼리 전후로 도합 두 번 실행되고 있는 것을 알 수 있다.

auto-commit: false

false로 설정하면 동일한 요청에 대해서 두 개의 auto-commit 쿼리가 사라지고 실제 쿼리만 실행되는 것을 알 수 있다.

auto-commit 옵션을 성능 개선 목적으로 쏠쏠하게 써먹을 수 있을 것 같다.
다만, 개발하면서 늘 쿼리 종료시점에 COMMIT 이 실행되고 있는지에 대한 확인이 필요하다는 점은 기억해야 할 것이다.
@Transactional메소드 정상 종료 시 COMMIT을, 예외 발생 시 ROLLBACK을 실행시키므로 이를 활용해 auto-commit을 사용하지 않으면서 COMMIT 실행을 보장할 수 있을 것이다.


스터디에서 얘기해보고 싶은 점 및 관련 내용

Q. (실제로 받았던 면접 질문) 은행에서는 DB 격리 수준을 무엇으로 설정하고 있을까요? 그 이유는 무엇일까요?

  • 당시에는 긴장하고 정신을 못 차린 상태로 Serializable을 통해 성능을 포기하더라도 데이터 정합성을 최우선으로 챙길 것이라고 답변했다. 조금 지나고 생각해보니, 데이터 유실률은 어느 정도까지 수용할 수 있는지 등의 세부적인 옵션을 확인한 이후에 Repeatable Read 기반으로 메세지 큐 등의 인프라로 성능 개선 방법을 제안하는 것이 더 좋아보인다.
    (실력 낮은 개인의 의견이므로 이는 정답이 아니다.)

Q. 사이드 프로젝트든 현업이든 Transactional의 옵션을 어느 정도까지 세분화해서 다루어보았는지 궁금합니다.

  • 트랜잭션 중첩은 별로 없는 것 같았다.

Q. 실제로 개발하는 과정에서 격리 수준으로 인해 발생한 오류를 경험한 적이 있는지 궁금합니다.

  • 일단 나는 없었는데, 스터디에 계신 분들은 있다고 한다.

Q. 마켓컬리는 open-in-view를 true로 사용한 이유는 무엇일까요?

  • 정말 궁금하다. 댓글로 물어본 이후, 결과를 추후 업데이트할 예정이다.

Q. 마켓 컬리 기술 블로그에서는 @Transactional 이 선언되어 있는 innermethod가 있는 상황에서 outermethod에 추가적으로 @Transactional 을 선언해서 해결했습니다. 별도의 설명이 없었으므로 트랜잭션 중첩된 것으로 판단했는데, 트랜잭션이 중첩되는 설계가 좋은 설계인지 얘기해보고 싶습니다.

  • 별도의 propagation 옵션 설정을 하지 않았기에, 자식 트랜잭션은 기존 부모 트랜잭션에 참여하게 될텐데, 이는 에러가 발생했을 때 명확한 에러 발생 지점을 파악하기 어려워 좋은 설계는 아니지 않을까?라고 생각하고 있다.

Q. auto-commit에 대해서 얼마나 극대화해서 쓰고 있는지 공유해보면 좋을 것 같습니다.

  • 스터디에 참여하고 계신 현업 개발자분께서 팀에서 auto-commit 옵션을 false로 사용하는 것 같다고 답변해주셨다.

레퍼런스

스터디에서 공유하고자 정리한 내용을 올린 글입니다.
위 트랜잭션 샘플 코드는 Github Repo에서 확인하실 수 있습니다.

0개의 댓글