면접 질문으로 받으면서 데이터베이스 트랜잭션 격리 수준에 대한 질문을 받은 적이 있었다. LIVID에서 자유 주제로 매주 글을 하나씩 정리해서 공유하는 스터디를 참여하고 있었기에, 관련 내용을 주제로 준비했다. 마켓 컬리 기술 블로그에서 24년 6월에 이와 관련된 내용을 JPA 설정과 함께 다루고 있어 같이 정리해본다.
동시에 여러 트랜잭션이 처리 될 때, 트랜잭션끼리 얼마나 고립(격리)되어 있는가에 대한 구분
트랜잭션과 관련된 일이 모두 수행되었거나 되지 않았거나를 보장하는 특징
커밋: 여러 쿼리가 성공적으로 처리되었다고 확정하는 명령어로, 하나의 트랜잭션이 성공적으로 수행되었다는 의미
롤백: 에러나 여러 이슈 때문에 트랜잭션으로 처리한 하나의 묶음 과정을 일어나기 전으로 돌리는 일을 의미
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값이 증가한 상태로 답변이 오지만, 데이터베이스에는 저장되지 않은 것을 볼 수 있다. 즉, 커밋되지 않았기에 삽입 쿼리가 완료되지 않은 것이다.
'허용된 방식'으로만 데이터를 변경해야하는 것을 의미
즉, 데이터베이스에 기록된 모든 데이터는 여러 가지 조건, 규칙에 따라 유효성을 가져야 한다.
트랜잭션 수행 시 서로 끼어들지 못하는 것
격리성의 세부적인 내용은 아래에서 별도로 다룬다.
성공적으로 수행된 트랜잭션은 영원히 반영되어야 한다는 것을 의미
즉, 데이터베이스에 시스템 장애가 발생해도 원래 상태로 복구하는 회복 기능이 있어야 한다는 것을 의미하며, 데이터베이스는 이를 위해 체크섬, 저널링, 롤백 등의 기능을 제공한다.
저널링: 파일 시스템 또는 데이터베이스 시스템에 변경 사항을 반영(commit)하기 전에 로깅하는 것, 트랜잭션 등 변경사항에 대한 로그를 남기는 것
위의 트랜잭션의 ACID 중 격리성의 세부 격리 수준을 정리해보면 아래와 같습니다.
(출처: 면접을 위한 CS전공지식 노트 p.209)
Serializable은 트랜잭션을 순차적으로 진행시키는 것을 의미 = 트랜잭션이 동시에 같은 행에 접근 불가
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는 다른 트랜잭션이 커밋하지 않은 정보를 읽을 수 없다. 커밋 완료된 데이터에 대해서만 조회를 허용한다.
반복 가능하지 않은 조회(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)
하나의 트랜잭션이 커밋되기 이전에 다른 트랜잭션에 노출될 수 있다. 속도는 가장 빠르지만, 커밋되기 이전에 다른 트랜잭션에 데이터가 노출되므로 데이터 무결성을 위반할 가능성이 높다.
더티 리드(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)
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에 접근할 수 있는 환경이다.
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);
...
}
출처: 마켓 컬리 기술 블로그
@Transactional(readonly=true)
선언이 되어있는 상황이다. 3번 과정에서 신규 데이터가 적재되기 전 Snapshot 을 가지고 있던 Session일 경우 데이터가 조회되지 않아 ROLLBACK이 발생한 것이었다.
4번 과정이 정상적으로 실행된 이유는 그 다음 데이터 요청에서 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 로 요청
위 질문에 대한 작성자분의 의견이 궁금해 댓글을 남겼으며, 답글이 달리면 관련 내용을 업데이트할 예정이다.
마켓컬리 글에서 다룬 다양한 키워드들에 대한 추가 정리 내용이다.
(출처: 망나니 개발자, 2024. 07. 08)
Spring Data JPA의 기본 메소드는 @Transactional
이 기본적으로 적용되지만, JPA 인터페이스에 정의한 쿼리 메소드는 적용되지 않는다. 그렇기에, 트랜잭션을 적용하기 위해서는 명시적으로 추가해야 한다.
HikariCP는 커넥션 종료 시에 COMMIT 여부를 체크한다. 다만, 위에 언급했듯이 JPA 인터페이스나 QueryDSL의 메소드는 @Transactional
이 적용되지 않아 COMMIT이 실행되지 않는다. 그렇기에, 하단에 서술할 auto-commit일 경우, ROLLBACK이 실행된다는 점을 유의해야 한다.
open-in-view는 영속성 컨텍스트(Persistence Context)를 뷰 렌더링이 끝날 때까지 개방 상태로 유지하는 패턴이다.
Spring에서는 spring.jpa.open-in-view
설정으로 제어할 수 있다.
spring:
jpa:
open-in-view: true/false
마켓컬리에서는 open-in-view를 true로 설정했었기에, outerMethod의 사용자 조회 쿼리에서 ROLLBACK이 발생되지 않았다. 그 상태에서 innerMethod의 @Transactional(readonly=true)
로 인해 innerMethod가 종료되면서 COMMIT을 뱉어 갱신되지 않았다고 얘기한다.
이 문장에서도, COMMIT은 Snapshot을 갱신한다는 내용이 동일한 글 윗 부분에 적혀있다 보니 갱신되지 않았다는 문장의 의미가 어떤 것인지 확인을 위해 댓글을 남겼다. 답글이 달리면 업데이트할 예정이다.
그러나, open-in-view에 대해서는 다양한 관점과 내용이 있길래 Spring Boot의 open-in-view, 그 위험성에 대하여.라는 글을 첨부한다.
hikari.auto-commit은 각 SQL문에 대하여 자체적으로 단일 트랜잭션을 생성하여 오류가 발생하지 않은 쿼리에 대해 COMMIT을 수행하는 옵션이다.
true로 설정하면 안정적이지만 @Transactional
전후로 추가적인 쿼리가 발생하게 되어 성능상 손해가 발생한다.
마켓 컬리의 경우 auto-commit을 false로 하여 성능을 개선시켰다. (응답시간 1.5ms 감소, 40% 향상)
auto-commit은 application.yml파일에서 아래와 같이 설정할 수 있다.
spring:
datasource:
hikari:
auto-commit: false
먼저 테스트 환경은 아래와 같다.
MySQL에서는 실행된 모든 쿼리에 대해서 general_log에서 관리하고 있다.
그렇기에 general_log로 관리할 수 있도록 설정을 진행해야 한다.
SHOW VARIABLES LIKE "general_log%";
//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;
SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE'; // 출력 방식을 테이블로 설정 (FILE, TABLE, TABLE,FILE 등의 옵션 존재)
SELECT * FROM mysql.general_log LIMIT 100;
실제로 테스트 시 사용한 환경변수 파일은 아래와 같다.
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 쿼리가 실제 쿼리 전후로 도합 두 번 실행되고 있는 것을 알 수 있다.
false로 설정하면 동일한 요청에 대해서 두 개의 auto-commit 쿼리가 사라지고 실제 쿼리만 실행되는 것을 알 수 있다.
auto-commit 옵션을 성능 개선 목적으로 쏠쏠하게 써먹을 수 있을 것 같다.
다만, 개발하면서 늘 쿼리 종료시점에 COMMIT 이 실행되고 있는지에 대한 확인이 필요하다는 점은 기억해야 할 것이다.
@Transactional
은 메소드 정상 종료 시 COMMIT을, 예외 발생 시 ROLLBACK을 실행시키므로 이를 활용해 auto-commit을 사용하지 않으면서 COMMIT 실행을 보장할 수 있을 것이다.
스터디에서 얘기해보고 싶은 점 및 관련 내용
Q. (실제로 받았던 면접 질문) 은행에서는 DB 격리 수준을 무엇으로 설정하고 있을까요? 그 이유는 무엇일까요?
Q. 사이드 프로젝트든 현업이든 Transactional의 옵션을 어느 정도까지 세분화해서 다루어보았는지 궁금합니다.
Q. 실제로 개발하는 과정에서 격리 수준으로 인해 발생한 오류를 경험한 적이 있는지 궁금합니다.
Q. 마켓컬리는 open-in-view를 true로 사용한 이유는 무엇일까요?
Q. 마켓 컬리 기술 블로그에서는 @Transactional
이 선언되어 있는 innermethod가 있는 상황에서 outermethod에 추가적으로 @Transactional
을 선언해서 해결했습니다. 별도의 설명이 없었으므로 트랜잭션 중첩된 것으로 판단했는데, 트랜잭션이 중첩되는 설계가 좋은 설계인지 얘기해보고 싶습니다.
Q. auto-commit에 대해서 얼마나 극대화해서 쓰고 있는지 공유해보면 좋을 것 같습니다.
레퍼런스
스터디에서 공유하고자 정리한 내용을 올린 글입니다.
위 트랜잭션 샘플 코드는 Github Repo에서 확인하실 수 있습니다.