본문 바로가기
  • Where there is a will there is a way.
개발/기타개발

트랜잭션에 대한 심화 이해

by 소확행개발자 2020. 6. 30.

현대의 거의 모든 관계형 데이터베이스와 일부 비관계형 데이터베이스는 트랜잭션을 지원한다. 2000년대 후반에는 비관계형 데이터베이스가 인기를 얻기 시작하면서 새로운 데이터 모델을 선택할 수 있게 하고 기본적으로 복제와 파티셔닝을 제공함으로써 관계형 데이터베이스의 현 상황을 개선하는 것을 목표로 했다. 새로운 세대의 데이터베이스 중 다수는 트랜잭션을 완전히 포기하거나 과거에 인식되던 것보다 훨씬 약한 보장을 의미하는 단어로 트랜잭션의 의미를 재정의 했다.

 

다른 모든 기술적 설계 선택과 마찬가지로 트랜잭션은 이점과 한계가 있다. 

 

트랜잭션은 기본적으로 ACID 의 특성을 지닌다. 이에 대해서도 설명이 되어있지만 기본적인 이야기는 본인 외에도 다른곳에도 정리가 잘 되어있기 때문에 생략한다. 

 

단일 객체의 쓰기

원자성과 격리성은 단일 객체를 변경하는 경우에도 적용된다. 

 

1. 가령 돈 뿌리기 ( 카카오 페이 ) 기능이 수행도중 네트워크에 연결이 끊기면 뿌리기 생성중 데이터 조각을 어떻게 저장할 것인가 ?

2. 받기를 통해서 데이터베이스가 업데이트 도중에 전원이 나가면 어떻게 데이터가 붙을 것인가 ?

3. 받기를 하고있을 때 다른 프로세서 에서 뿌리기 조회를 하면 어떤 값을 조회하게 될 것인가 ?

 

1,2 같은 경우에는 장애 복구용 로그를 써서 구현할 수 있고

3. 격리성은 각 객체에 잠금을 사용해 ( 한 스레드만 객체에 접근하도록 ) 구현할 수 있다. 

 

이런 단일 객체 연산은 여러 클라이언트에서 동시에 같은 객체에 쓰려고 할 때 갱신 손실을 방지하므로 유용하다. 그러나 일반적으로 쓰이는 의미의 트랜잭션은 아니다. ( compare and set 과 다른 객체 연산은 경량 트랜잭션? 이라고 불린다 )

 

트랜잭션은 보통 다중 연산을 하나의 실행 단위로 묶는 메커니즘으로 이해된다. 

 

다중 객체 트랜잭션의 필요성 

다중 객체 트랜잭션이 정말로 필요할까 ? 키 - 값 데이터 모델과 단일 객체 연산만 사용해서 애플리케이션을 구현하는게 가능할까 ? 물론 단일 객체 삽입, 갱신, 삭제만으로도 충반한 사용사례가 있지만 

 

만약 관계형 데이터 모델에서 뿌리기 테이블에 받는 사람이 관계가 맺어져 있고 뿌리기 테이블에 받는 사람 정보가 올바르고 최신 정보가 들어가야함을 보장해야 한다면? 

 

그럴경우 다중 객체의 트랜잭션이 필요하지 않을까 ? 이건 좀더 심화적인 이야기 이므로 다음에..

 

오류에 대한 처리

트랜잭션의 핵심 기능은 오류가 생기면 어보트 되고 안전하게 재시도할 수 있다는 것이다. ACID 데이터베이스는 이 철학을 바탕으로 한다. 

원자성에서 오류에 대한 처리를 보장하는데 원자성은 생각해보면 동시성과 관련이 없다. 원자성은 여러 프로세스가 동시에 같은 데이터에 접근하려고 할 때 무슨일이 생기는지랑은 관련이 없다. 

 

하지만 주의할 점은 모든 시스템이 해당 철학을 따르지 못한다. 만약 마이크로서비스에 트랜잭션을 보장해야 한다면 어떻게 할 것인가? 이 때 오류 복구는 애플리케이션에게 책임이 있다. 이에 대해서 낙관적으로만 생각할게 아니다. 

 

2020/05/24 - [개발/기타개발] - 마이크로 서비스 트랜잭션

 

마이크로 서비스 트랜잭션

내가 정리한 내용은 마이크로 서비스 패턴 (공)저: 크리스 리처드슨 에서 참조한 내용이다. 기존 앤터프라이즈 애플리케이션을 개발할 때 createOrder() 를 만든다고 해보자 1. 주문 가능한 소비자인

derekpark.tistory.com

위에 글에서 관련 문제일 때 트랜잭션에 해결에 대한 실마리가 나온다. 

 

완화된 격리 수준

두 트랜잭션이 동일한 데이터에 접근하지 않으면 서로 의존하지 않으므로 안전하게 병렬 실행될 수 있다. 동시성 문제는 트랜잭션이 다른 트랜잭션에서 동시에 변경한 데이터를 읽거나 두 트랜잭션이 동시에 같은 데이터를 변경하려고 할 때만 나타난다. 

 

많은 사람들이 직렬성 격리를 생각할 것이다. 나 또한 그랬다. 가장 직관적이면서도 안전한 방법인거 같다. ( 동시성 없이 한 번에 트랜잭션 하나만 실행 ) 

 

하지만 현실에서 직렬성 격리는 성능 비용이 있고 많은 데이터 베이스들은 그 비용을 지불하려고 하지 않는다. 따라서 어떤 동시성 이슈로부터는 보호해주지만 모든 이슈로부터 보호해주지는 않는 완화된 격리수준을 사용한다. 

 

맹목적으로 도구에 의존하기 보다는 존재하는 동시성 문제의 종류를 잘 이해하고 방지하는 방법을 배울 필요가 있다. 그러면 사용 가능한 도구를 써서 신뢰성 있고 올바르게 동작하는 애플리케이션을 만들 수 있다. 

 

그렇다면 완화된 격리 수준은 어떤 종류가 있을까 ?

 

1. 커밋하지 않은 읽기 ( read uncommited ) -> dirty read 

2. 커밋 후 읽기 ( read commited ) -> non repeatable read 

커밋 후 읽기 수준에서는 두가지가 보장된다.

  • 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다. dirty read 가 없음
  • 데이터베이스에서 쓰기를 할 때 커밋된 데이터만 덮어쓰게 된다. dirty write 가 없음
    • 보통 더티 쓰기를 방지하기 위해서는 먼저 쓴 트랜잭션이 커밋되거나 어보트 될 때까지 두 번째 쓰기를 지연시키는 방법을 사용한다
  • 더티읽기를 방지하기 위해서는 직렬적으로 기다려도 되지만 성능상의 문제로 안쓴다. 객체에 대해 데이터베이스는 과거에 커밋된 값과 현재 쓰기 잠금을 갖고 있는 트랜잭션에서 쓴 새로운 값을 모두 기억하게끔 한다. 그래서 해당 트랜잭션이 실행 중인 동안 그 객체를 읽는 다른 트랜잭션들은 과거의 값을 읽게 된다. 
  • 스냅숏을 만들어 관리한다. 

문득 카카오페이 과제가 떠오른다. 만약 커밋 후 읽기 레벨에서 카카오페이 뿌리기 를 받는다면 어떻게 될까 ?

더티 읽기가 방지되었지만 받은사람의 카운터를 증가시키는데 발생하는 경쟁조건을 막지 못한다. 이 경우를 막는 방법을 갱신 손실 방지라고 한다. 

 

 

3. 갱신손실방지 

위에서 설명한 카카오페이 과제같은 경우처럼 갱신 손실은 흔한 문제이면서 중요한 문제이다.

  • 원자적 쓰기 연산
    • 여러 데이터 베이스에서 원자적 갱신 연산을 제공한다. 이런 연산은 애플리케이션 코드에서 read-modify-write 주기를 구현할 필요를 없애준다. 
    • 이런 연산을 써서 코드를 표현할 수 있다면 이것들이 보통 가장 좋은 해결책이다. 
    •  
update counters set value = value + 1 where key = 'foo';

하지만 객체 관계형 매핑 프레임워크를 사용하면 뜻하지 않게 데이터베이스가 제공하는 원자적 연산을 사용하는 대신

불안전한 read - modify - write 주기를 실행하는 코드를 작성해야 할때가 많다. 

 

  • 명시적인 잠금 

애플리케이션은 복잡한 비즈니스 로직을 구현해야 하는 경우가 많기 때문에 주로 명시적인 잠금을 사용한다. 즉 애플리케이션에서 갱신할 객체를 명시적으로 잠그는 것이다. 

 

그러면 애플리케이션에서 read-modify-write 주기를 수행할 수 있고 다른 트랜잭션이 동시에 같은 객체를 읽으려고 하면 첫 번째 주기가 완료될 때까지 기다리도록 강제된다. 

 

예제

 

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "10000")})
    @Query("SELECT distribute FROM Distribute distribute join fetch distribute.recipients WHERE distribute.id = ?1")
    Distribute findByIdWithRocking(int distributeId);
Hibernate: 
    select
        distribute0_.id as id1_0_0_,
        recipients1_.id as id1_1_1_,
        distribute0_.amount as amount2_0_0_,
        distribute0_.reg_date as reg_date3_0_0_,
        distribute0_.room_id as room_id4_0_0_,
        distribute0_.user_id as user_id5_0_0_,
        recipients1_.amount as amount2_1_1_,
        recipients1_.distribute_id as distribu4_1_1_,
        recipients1_.user_id as user_id3_1_1_,
        recipients1_.distribute_id as distribu4_1_0__,
        recipients1_.id as id1_1_0__ 
    from
        distribute distribute0_ 
    inner join
        recipient recipients1_ 
            on distribute0_.id=recipients1_.distribute_id 
    where
        distribute0_.id=? for update < - 이부분
        
 Hibernate: 
    update
        recipient 
    set
        amount=?,
        distribute_id=?,
        user_id=? 
    where
        id=?

 

 

  • compare - and - set

이 연산의 목적은 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 '회피' 하는 것이다. 현재 값이 이전에 읽은 값과 일치하지 않으면 갱신은 반영되지 않는다.

 

따라서 갱신이 적용됐는지 확인하고 필요하다면 재시도해야 한다. 그러나 데이터베이스가 where 절이 오래된 스냅숏으로부터 읽는 것을 허용한다면 이 구문은 갱신 손실을 막지 못할 수도 있다. 

 

비관적 동시성 제어 대 낙관적 동시성 제어

낙관적락이 좋아요 비관적락이 좋아요 ? 라고 물어본다면 어떻게 답할것인가 ?

 

낙관적락은 말 그대로 동시성 제어중 괜찮아질 거라는 희망을 갖고 계속 진행한다는 뜻이다. 나쁜상황이 발생되면 트랜잭션은 어보트 되고 재시도 해야 한다. 만약 경쟁이 심한 비즈니스라면 어보트할 트랜잭션의 비율이 높아지므로 성능이 떨어진다. 

 

그러나 예비 용량이 충분하고 트랜잭션 사이의 경쟁이 너무 심하지 않으면 낙관적 동시성 제어 기법은 비관적 동시성 제어보다 성능이 좋은 경향이 있다. 

댓글