JAVA/JAVA | Spring 학습기록

[Spring] @Transactional로 DB 동시성 문제를 방지하자

kth990303 2022. 5. 2. 18:51
반응형

웹 데이터 애플리케이션을 만들 때, dao에서 sql문으로 db에 접근하고 service에서 dao 메서드들을 이용하여 하나의 트랜잭션을 관리한다. 그런데 만약 이 애플리케이션을 여러 명이서 동시에 사용한다면? 동시성 문제가 발생할 수 있다. 동시성 문제란, 두 개 이상의 세션이 공통된 자원을 읽고 쓸 때 발생할 수 있는 문제를 의미한다. 이번 포스팅에선 이러한 동시성 문제와 트랜잭션에 대해서 알아보고, @Transactional 어노테이션으로 해결하는 방법을 알아볼 것이다.

 

+) 22.10.13. 트랜잭션 격리레벨 설명 수정 및 보충


트랜잭션이란?

DBMS에서 데이터를 다루는 작업의 단위를 의미한다. 용어상 정의로는 이해하기 어렵지만, 트랜잭션의 성질을 알아보면 보다 더 쉽게 이해할 수 있을 것이다. 아래 예시코드를 보자.

Service layer에서 체스말을 움직이는 코드이다.

위 move 메서드에는 3개의 sql 쿼리문이 존재한다. 상대말을 delete해주는 쿼리문, 현재말을 update해주는 쿼리문, turn을 update해주는 쿼리문. 이 묶음들, 즉 데이터를 다루는 작업 단위를 transaction, 트랜잭션이라 한다. 만약 트랜잭션이 성공적으로 실행되지 않아서 move 메서드가 실행될 때 turn이 update되지 않고 다른 쿼리문만 실행되거나, piece만 update되고 turn이 바뀌지 않는다면 사용자에게 큰 혼란을 유발할 수 있다. 따라서 트랜잭션은 한 번 실행될 때 그 안의 작업들이 모두 수행되거나 수행되지 않아야 하며, 다른 트랜잭션과의 충돌로 데이터가 변경되는 경우를 반드시 피해야 한다. 다른 트랜잭션과의 충돌 문제가 바로 동시성 문제이다. 트랜잭션 실행 도중에 오류가 발생한다면 그 트랜잭션에 포함된 작업들은 모두 rollback돼야 한다. 이러한 트랜잭션의 성질들을 정리한 것을 트랜잭션 ACID 성질이라 한다. 이 내용에 대해선 db 카테고리 글에 자세히 포스팅할 예정이다.


동시성 문제 종류

두 개 이상의 세션에서 같은 데이터를 동시에 접근할 때 발생하는 문제 종류를 알아보자.

트랜잭션 1: 읽기 작업 / 트랜잭션 2: 쓰기 작업을 한다고 가정하자.

트랜잭션 두 개가 모두 읽기 작업을 할 때에는 문제가 발생하지 않고, 트랜잭션 두 개가 모두 쓰기 작업을 할 때에는 Exclusive lock(쓰기 락)으로 아예 접근을 막는다. 따라서 각각 읽기, 쓰기 작업을 수행할 때 격리레벨을 잘 설정해주는 처리를 해주어야 한다.

 

Dirty Read (오손 읽기)

트랜잭션 1이 트랜잭션 2에서 작업하고 있는 데이터를 읽었다고 가정하자.

이 때, 트랜잭션 2는 해당 데이터를 작업 후에 아직 트랜잭션 커밋은 하지 않았다고 하자.

그런데 트랜잭션 2에서 해당 데이터를 작업 전으로 rollback하고 커밋을 했다.

트랜잭션 1은 트랜잭션 2에서 커밋하지도 않은 (rollback 전) 데이터가 옳다고 생각한다.

이러한 문제를 Dirty Read라고 한다.

트랜잭션 1은 1번 유저의 나이가 21살로 오해한 채 개발한다.

위의 예시에서 Transaction 2는 commit을 하지 않고 작업하고 있다. 즉, 트랜잭션 2는 아직 끝나지 않고 진행중인 것이다.

이 상황에서 트랜잭션 1은 해당 데이터를 읽었고, 트랜잭션 2는 아직 해당 작업을 계속 작업 중 (commit 전)에 있으므로 트랜잭션 1은 올바르지 않은 데이터 값을 계속해서 조회하게 되는 것이다. 즉, 커밋하지 않은 작업 중인 데이터를 읽을 수 있는 상황 자체가 문제인 셈.

 

Non-repeatable Read (반복 불가능 읽기)

트랜잭션 1이 트랜잭션 2에서 작업하고 있는 데이터를 읽었다고 가정하자.

그런데 트랜잭션 2에서 갑자기 데이터를 변경했다. 그리고 이 변경한 데이터를 커밋했다. (위의 Dirty Read 상황과는 다르게 트랜잭션 2에서 작업을 끝내고 commit을 했다는 차이점이 있다.)

하지만 트랜잭션 1은 아직 commit 하지 않은 해당 트랜잭션 내에서 작업중인 상태에 있다. 

트랜잭션 1은 해당 트랜잭션 내에서 update하기 전 데이터, update한 후의 데이터를 모두 조회하게 되므로 혼란이 올 수 있게 된다.

이러한 문제를 Non-repeatable Read라고 한다.

트랜잭션 1은 원래 수정 전 데이터를 알고 있는 상태에서, 트랜잭션 2가 수정한 데이터 값을 읽게 된다.

 

Phantom Read (유령 데이터 읽기)

트랜잭션 1이 트랜잭션 2가 작업한 데이터를 먼저 읽었다고 하자.

그런데 트랜잭션 2에서 추가로 데이터를 insert했다. 그리고 이 변경한 데이터를 커밋했다. (위의 Non-repeatable Read 상황과는 다르게 insert 작업을 했다는 차이점이 있다.)

하지만 트랜잭션 1은 아직 commit 하지 않은 해당 트랜잭션 내에서 작업중인 상태에 있다. 

트랜잭션 1은 해당 트랜잭션 내에서 Insert된 유령 데이터를 읽게 된다.

이러한 문제를 Phantom Read라고 한다.

트랜잭션 1은 원래 존재하지 않았던, 트랜잭션 2가 추가한 데이터를 보게 된다.

참고로 해당 현상은 MySQL의 InnoDB에선 발생하지 않는다.

이는 MySQL에서 제공해주는 특이한 락 중 하나인 gap lock 때문이다.

시간이 나면 이후에 해당 이유를 포스팅하도록 하겠다.


@Transactional 어노테이션

스프링의 @Transactional 어노테이션을 사용하면 트랜잭션 관련 문제가 발생할 때 rollback시킬 수 있으며, 동시성 문제에 대한 걱정도 해결할 수 있다. 

  Level Dirty Read Non-repeatable Read Phantom Read
READ UNCOMMITED 0 O O O
READ COMMITED 1 X O O
REPEATABLE READ 2 X X O
SERIALIZABLE 3 X X X

트랜잭션 고립 레벨을 설정해줄 때, 읽기 기능을 제한하는 공유락과 읽기/쓰기 기능을 제한하는 배타락을 이용하여 설정해준다. 자세한 내용은 db 카테고리 글에 자세히 포스팅할 예정이다.

 

READ UNCOMMITED는 고립 레벨 0에 해당되는 수준이며, 아무런 락을 걸지 않는다. 따라서 동시에 접근할 때 모든 sql문에 제한이 없으며, Dirty Read, Non-repeatable Read, Phantom Read 모두 발생할 수 있다. READ COMMITED는 고립 레벨 1에 해당되는 수준이며, 해당 레벨에서는 커밋하기 전 작업 중인 데이터의 값을 읽을 수 없다. 해당 격리레벨부터 Spring에서 Undo 영역을 이용한 MVCC를 사용하여 Consistence Read를 보장해준다. 그렇기 때문에 Dirty Read가 발생하지 않는다. REPEATABLE READ는 고립 레벨 2에 해당되는 수준이며, 해당 레벨에서는 커밋하기 전 작업 중인 데이터의 값을 읽을 수 없을 뿐 아니라, 읽기 트랜잭션이 끝나기 전까지 Undo 영역을 타 트랜잭션에서 쓰기 작업 후 커밋한 데이터로 덮어쓸 수 없다. 그렇기 때문에 Non-Repeatable Read가 발생하지 않게 된다. SERIALIZABLE은 고립 레벨 3에 해당되는 수준이며, 해당 레벨은 트랜잭션을 다른 트랜잭션으로부터 완전히 분리시킨다. 특정 트랜잭션이 작업을 하고 있는 동안, 타 트랜잭션은 별도의 작업을 할 수 없다.

 

단순히 @Transactional 어노테이션만 붙여주면 트랜잭션 고립 레벨은 jdbc 기본 isolation level을 따르게 된다. 만약 MYSQL을 사용중이라면 defalut transaction isolation level은 2, ORACLE을 사용한다면 1, MariaDB를 사용한다면 2가 되는 것이다.

 

하지만 반드시 고립 레벨이 높다고 해서 좋은 것은 아니다.

고립성이 높아지는 (동시성이 낮아지는) 만큼 성능은 떨어지고, 다른 트랜잭션에서 제한되는 점이 많아지기 때문이다.

따라서 SERIALIZABLE에 해당되는 레벨3 고립 레벨은 현업에서 거의 사용하지 않는다고 한다.

보통은 레벨1, 레벨2를 많이 사용하는 듯하다.


@Transactional(readOnly = true)

만약 데이터를 단순 읽기 작업만 하는 트랜잭션이라면 transaction의 readOnly 값을 true로 설정해두면 성능을 더 개선시킬 수 있다.

@Transactional(readOnly = true)
public List<RoomDto> showRooms() {
    return roomDao.findAll();
}

readOnly로 설정해두게 되면 SELECT 문만 지원하게 되며, UPDATE, INSERT, DELETE 쿼리문은 사용할 수 없게 된다. 따라서 불필요한 지원을 막아주고 메모리 사용량을 줄여 성능 개선이 가능하게 된다. 또한, 타 개발자가 해당 코드를 보고 읽기 작업만 하는 메서드임을 바로 파악할 수 있다는 장점도 존재한다.

 

참고로 트랜잭션 동시성 문제를 방지하기 위한 격리 수준을 설정하는 isolation level과는 아예 다른 개념이다. 


database cs 지식과 스프링이 서로 연관되어 신기하기도 하고 재밌기도 하고 머리아프기도 했다.

데이터베이스랑 스프링, 올해 열심히 공부해서 씹어먹을 수 있게 해야겠다.

 

참고글:

 

반응형