본문 바로가기

도서/스프링으로하는 마이크로서비스 구축

동시성 문제와 해결

개요

교재에서는 애플리케이션 수준에서 동시성 문제를 해결하기 위해서 @Version 어노테이션을 사용해서 문제를 해결하고자 했습니다.  @Version 어노테이션은 낙관적 락킹 방법 중 하나로, 특정 데이터에 대해서 DB에 저장된 데이터의 버전 정보와 요청에 의한 버전 정보를 비교하고 두 값이 일치하지 않았을 때 stale data로 분류하여 데이터의 수정을 방지하는 방법을 의미합니다. 각 용어에 대한 설명이 다소 부족한 것 같지만 자세한 내용은 아래에서 추후에 설명하고자 합니다. 이번 포스팅에서는 동시성 문제란 무엇이며,  낙관적 락킹 방법을 포함한 다양한 동시성 해결 방법에 대해 정리해보려고 합니다. 


동시성 문제가 왜 아찔한가

현재 사내에서는 여러명의 사용자가 동시에 작업을 요청하는 경우가 빈번하게 발생하고 있습니다. 동시성 문제에 대한 예방 및 대처 방안이 없다면 발생할 난처한 상황을 하나 가정해보려고 합니다. 

상황을 설명하자면 다음과 같습니다. 

  • 특정 주문(ord)과 관련된 두 가지 작업을 두 명의 사용자가 요청을 보낸 상황을 가정합니다. 한 명의 사용자는 해당 주문이 취소된 주문인지 체크하는 요청을, 다른 한 명은 해당 주문을 수령자에게 발송처리하는 로직을 요청합니다. 
  • 두 사용자 모두 S1, S2 시점에서 읽어드린 주문은 정상 주문 상태입니다. 
  • F1 시점에서 해당 주문은 취소 주문 체크 결과 취소 주문 상태임이 확인되어 주문상태의 값을 취소주문으로 수정하는 작업을 수행했습니다. 
  • F2 시점에서 해당 주문은 이미 취소 주문 상태지만, 그 상황을 모르기 때문에 발송 처리를 진행합니다. 

만약 위와 같은 상황이 실제 서비스 상황에서 발생했을 경우 주문의 상품이 오배송 되고 작게는 비용 지출이 넓게는 서비스 사용자 감소라는 아찔한 상황을 이어질 수 있는 결과를 초래할 수 있습니다. 가정한 상황 말고도 다양한 상황에서 동시성 문제가 발생할 수 있으면 때문에 적절한 동시성 문제 해결 방법이 필요합니다. 

 

현재 사내에서는 홀딩을 걸어 두는 방법으로 여러 명의 사용자가 동일한 주문의 수정을 요하는 작업을 요청하지 못하도록 UI 수준에서 방지하고 있습니다. 이번 글에서는 이 방법 외에도 동시성 문제를 해결할 수 있는 다양한 방법에 대해 소개하고 정리해보려고 합니다. 


동시성 문제 해결 방법

해결 방법은 다양하지만 크게 3가지 방법에 대해 정리해 보려고 합니다. 

  • 낙관적 락/ 비관적 락
  • 충돌 방지 전략
  • 트랜잭션 격리 수준 설정

1. 낙관적 락(Optimistic Lock)/ 비관적 락(Pessimistic Lock)

먼저 소개할 방법은 낙관적 락입니다. 낙관적 락은 이름처럼 동시성 문제에 대해 낙관적입니다. 즉, 동시성 문제가 발생할 가능성이 낮다고 판단하기 때문에 데이터에 접근할 때 락을 걸지 않고 변경을 시도할 때 충돌 여부를 확인합니다. 

 

개요에서도 언급한 것 처럼 낙관적 락을 구현하는 대표적인 방법은 버전 번호나 타임스탬프 값을 확인해 다른 트랜잭션이 데이터를 수정했는지 확인하고 만약 데이터가 변경되었다면 현재 트랜잭션을 롤백하고 다시 시도하도록 합니다. 

 

버전 정보를 활용해서 낙관적 락을 구현했을 때 DB에 저장된 버전 정보와 요청 시점에서 버전 정보가 일치하지 않는다면 해당 데이터를 stale data로 판단합니다. stale data란, 애플리케이션이 참조하는 데이터가 DB에 저장된 최신 상태의 데이터와 일치하지 않는 데이터를 의미합니다. 

 

다음은 비관적 락입니다. 낙관적 락을 먼저 봤기에 비관적 락의 특징은 어느 정도 예측 가능할 것 같습니다. 비관적 락은 낙관적 락과 다르게 동시성 문제가 발생할 가능성이 높다고 판단하기 때문에 데이터에 접근할 때 락을 걸어 다른 트랜잭션이 데이터를 수정하지 못하도록 막는 방법입니다. 즉, 락이 해제되기 전까지는 다른 트랜잭션은 해당 데이터에 접근할 수 없는 차단(blocking) 접근 방식입니다. 낙관적 락은 반대로 (non-blocking) 방법입니다. 

위 이미지 처럼 비관적 락은 이전 트랜잭션이 락을 걸었다면 이후에 동일한 데이터에 대한 접근을 필요로 하는 다른 트랜잭션의 접근을 차단합니다. 만약 낙관적 락의 경우라면 T2 시점에서 접근은 허용되지만 수정 시 버전 정보나 타임스탬프 값을 보고 수정여부를 결정할 것입니다. 

낙관적 락에 비해 비관적 락이 더 안정적으로 동시성 문제를 해결할 수 있다는 분명한 장점이 있지만, 비관적 락을 사용하는 것은 치명적인 단점이 존재합니다. 락을 걸어둔 트랜잭션 외에 다른 트랜잭션의 접근을 막기 때문에 성능이 떨어진다는 단점이 존재합니다. 때문에 낙관적 락과 비관적을 적절하게 사용함으로써 동시성 문제와 성능을 함께 고려하는 것이 중요합니다. 


2. 충돌 방지 전략

동시성 문제를 해결할 수 있는 두번째 방법은 충돌 방지 전략입니다. 충돌 방지전략은 크게 두 가지로 나뉘며 충돌 회피 전략과 충돌 감지 전략으로 나뉩니다. 

 

충돌 회피 전략의 방법중 하나는 데이터 파티셔닝 방법이 있습니다. 데이터 파티셔닝은 DB 수준에서 동시성 문제를 해결하는 방법입니다. 데이터의 변경이 자주 발생할 것이라고 판단되는 데이터를 분산해서 저장함으로써 다수의 사용자의 데이터 접근 가능성을 낮추는 방법입니다. 


3. 트랜잭션 격리 수준(Transaction Isolation Level)

마지막 방법인 트랜잭션 격리 수준이란, DB에서 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않도록 격리하는 정도를 결정하는 방법입니다. SQL 표준은 네 가지 트랜잭션 격리 수준을 정의하고 있으며, 각각의 수준은 동시성 제어와 성능 사이의 균형을 다르게 설정합니다.

 

각 트랜잭셕 격리 수준에 대해 정리하기 전에 대표적인 동시성 문제 3개에 대해서 정리해보려고 합니다. 3개의 동시성 문제를 먼저 보는 이유는 각 트랜잭션 격리 수준에서 발생할 수 있는 문제를 분류하기 위함입니다. 

 

1. Dirty Read (더티 리드)

 

Dirty Read는 한 트랜잭션이 다른 트랜잭션에서 아직 커밋되지 않은 데이터를 읽는 상황입니다. 이는 다른 트랜잭션이 롤백되면 읽은 데이터가 잘못된 데이터일 수 있음을 의미합니다. 예를 들자면,  

  • 트랜잭션 A: 사용자 데이터의 이름을 "John"에서 "Doe"로 업데이트하고 아직 커밋하지 않음.
  • 트랜잭션 B: 트랜잭션 A가 커밋하기 전에 사용자 데이터를 읽어서 "Doe"를 읽음.
  • 트랜잭션 A: 롤백하여 이름을 다시 "John"으로 되돌림.

결과적으로, 트랜잭션 B는 커밋되지 않은 잘못된 데이터를 읽게 됩니다.

 

2. Non-Repeatable Read (반복 불가능한 읽기)

 

Non-Repeatable Read는 같은 트랜잭션 내에서 같은 데이터를 두 번 읽을 때, 다른 값이 반환되는 상황입니다. 이는 다른 트랜잭션이 중간에 데이터를 변경했기 때문입니다. 예를 들자면, 

  • 트랜잭션 A: 사용자 데이터의 이름을 읽어서 "John"을 얻음.
  • 트랜잭션 B: 사용자 데이터의 이름을 "Doe"로 업데이트하고 커밋함.
  • 트랜잭션 A: 같은 사용자 데이터를 다시 읽어서 "Doe"를 얻음.

결과적으로, 트랜잭션 A는 같은 데이터에 대해 두 번의 읽기에서 다른 값을 얻게 됩니다.

 

3. Phantom Read (팬텀 리드)

 

Phantom Read는 같은 트랜잭션 내에서 같은 조건으로 여러 번 조회할 때, 조회 결과 집합에 서로 다른 행이 포함되는 상황입니다. 이는 다른 트랜잭션이 행을 삽입하거나 삭제했기 때문입니다. 예를 들자면, 

  • 트랜잭션 A: SELECT * FROM users WHERE age > 30을 실행하여 10명의 사용자를 얻음.
  • 트랜잭션 B: 새로운 사용자를 추가하여 나이가 35인 사용자를 삽입하고 커밋함.
  • 트랜잭션 A: 같은 쿼리를 다시 실행하여 11명의 사용자를 얻음.

결과적으로, 트랜잭션 A는 같은 쿼리에서 서로 다른 결과 집합을 얻게 됩니다.

 

이제 트랜잭셕 격리 수준에 대해 정리하자면 다음과 같습니다. 

  • Read Uncommited
    • 트랜잭션에서 커밋되지 않은 변경사항도 다른 트랜잭션이 읽을 수 있습니다. 가장 낮은 격리 수준에 해당합니다. 
  • Read committed
    • 트랜잭션에서 커밋된 데이터만 읽을 수 있습니다. 대부분의 시스템에서의 격리 수준에 해당합니다. 
  • Repeatable Read
    • 트랜잭션이 시작된 후부터 커밋될 때까지, 동일한 데이터를 여러 번 읽어도 항상 같은 결과를 보장합니다.
    • 일관된 읽기를 위해 데이터베이스 시스템이 더 많은 락을 사용합니다.
  • Serializable
    • 가장 높은 격리 수준으로, 모든 트랜잭션이 순차적으로 실행되는 것처럼 보이도록 합니다.
    • 동시성 제어를 위해 트랜잭션 간의 충돌을 방지하며, 데이터 일관성을 최대로 보장합니다.


동시성 문제는 시스템 전체의 신뢰도에 영향을 줄 만큼 반드시 예방해야 하는 문제입니다. 동시성 문제와 시스템 성능을 함께 고려하기 위해서는 소개한 해결방법의 예를 적절히 분배하여 사용하는 것이 중요한 것 같습니다. 

반응형