데이터 중심 애플리케이션 설계 - 7장

공상현 (Kong Sang Hyean)·2024년 6월 26일
0

K DEVCON DDIA STUDY

목록 보기
7/12

😊 Go to Learn이란?

K-devcon에서 주최하는 멘토링 프로그래밍으로 각 분야에서 전문가이신 멘토분들의 멘토링을 통하여 약 2-3달간 진행하는 프로그램입니다.

Go to Learn 1기 같은 경우 Flutter, Back-end, Full-stack, Writing 등 여러가지 주제가 담긴 멘토링 프로그램이 있었습니다.

그 중 Back-end를 중심으로 진행하는 DDIA 프로그램 같은 경우 데이터 중심 애플리케이션 설계라는 책을 매주 1장 씩 정독하고 요약하면서 괸련된 이야기를 논의하면서 진행하고 있습니다.

K-devcon 이란? : IT 정보를 공유하거나 위에서 설명한 Go to Learn 스터디 및 밋업을 개최하는 활동을 하고있는 IT 커뮤니티입니다.
K-devcon 홈페이지 바로가기


📖 7장 요약 및 정리

트랜젝션이란?

트랜잭션이란 애플리케이션에서 몇 개의 읽쓰기를 하나의 논리적 단위로 묶는 방법이다.

  • 프로그래밍 모델을 단순화하려는 목적으로 정의 하였다.
  • 트랜잭션을 사용함으로써 잠재적인 오류 시나리오와 동시성 문제를 무시할 수 있다. (안전성 보장)
  • 분산 데이터베이스가 등장하면서 트렌젝션을 포기하려는 움직임이 생기기 시작하였다.

ACID

트랜잭션에서 제공하는 안전성 보장을 의미한다.

  • 트랜잭션은 이러한 철학을 바탕으로 이루어진다. 단, 모든 시스템이 이 철학을 따르지는 않는다. (예시 : 리더 없는 복제)

원자성 : 시스템은 연산을 실행하기 전이나 실행한 후의 상태만 있다.

  • 원자성은 동시성과 관련이 없다.
  • 여러 쓰기 작업 중 결함이 생겨 일부만 처리되어진다면 지금까지 실행한 쓰기를 무시하거나 취소해야한다.

일관성 : 데이터베이스가 항시 좋은 상태에 있어야 한다.

  • ACID 내에서는 데이터에 대한 어떠한 선언이 있어야한다는 것이다.
  • 이와 같은 아이디어는 데이터베이스가 보장할 수 분야가 아니다.

격리성 : 동시에 실행되는 트랜잭션은 서로 격리된다. 즉, 트랜잭션은 다른 트랜잭션을 방해할 수 없다.

  • 데이터베이스의 동시성 문제(경쟁 조건)과 관련이 있다.
  • 격리성에 대한 의미에 모호함이 있다.

지속성 : 트랜잭션이 성공적으로 커밋되었다면 하드웨어 결함이 발생하거나 데이터베이스가 죽더라도 트랜잭션에서 기록한 모든 데이터는 손실되지 않는다는.

  • 완벽한 지속성은 존재하지 않는다.
  • 지속성을 유지시키는 방법 : 비휘발성 저장소에 저장하기 / 다른 노드로 복제하기

단일 객체 연산과 다중 객체 연산

원자성과 격리성이 클라이언트가 한 트랜잭션 내에서 여러 번의 쓰기를 한다면 어떻게 해야하는지 서술한다.

다중 객체 트랜젝션은 어떤 읽쓰기 연산이 동일한 트랜잭션에 속하는지 알아낼 수단이 있어야만 한다.

  • 관계형 트랜잭션은 이러한 수단이 TCP 연결을 기반으로 이루어져 있다.
  • 비관계형 트랜잭션은 이런 식으로 연산을 묶는 방법이 없어 데이터베이스가 부분적으로 갱신된 상태가 될 수 있다.

단일 객체 쓰기

원자성과 격리성은 단일 객체에도 변경하는 경우에도 적용이 이루어진다.

  • 원자성 : 장애 복구용 로그
  • 격리성 : 각 객체에 잠금을 사용하여 구현
  • 여러 클라이언트에서 동시에 같은 객체에 쓰려고 할 떄 갱신 손실을 방지해준다.
  • 단일 객체 쓰기는 일반적으로 사용하는 트랜잭션을 의미하지는 않는다.

완화된 격리 수준 (비직렬성)

동시성 문제를 감추기 위하여 트랜잭션 격리가 등장하게 되었다.

직렬성 격리 : 데이터베이스가 여러 트랜잭션들이 직렬적으로 실행되는 것과 동일한 결과가 나오도록 보장한다.

  • 직렬성 격리는 성능 비용이 있고 어떤 동시성 이슈만 보호해주지만 모든 이슈로부터 보호해 주지는 않는다.

커밋 후 읽기 (더티 읽쓰기)

가장 기본적인 수준의 트랜잭션 격리이다. 위의 두 가지를 보장해준다.

  • 더티 읽기가 없음 : 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다.
  • 더티 쓰기가 없음 : 데이터베이스에서 쓸 때 데이터만 덮어쓰게 된다.
  • 널리 쓰이는 격리 수준이므로 여러 데이터베이스에서는 기본 설정으로 쓰인다.

더티 읽기 방지

더티 읽기 : 트랜잭션이 데이터베이스에 데이터를 썼지만 아직 커밋 및 어보트가 되지 않은 데이터를 볼 수 있을 경우를 의미한다.

  • 커밋 후 읽기 격리 수준에서 실행되는 트랜잭션은 더티 읽기를 막아야 한다.
  • 부분적으로 갱신되는 데이터베이스에 대한 혼란 이슈 발생
  • 동일한 잠금을 써서 객체를 읽기 원하는 트랜잭션이 잠시 잠금을 획득한 후 읽기가 끝난 후 바로 해제하는 방법이 있다. 그러나 지연으로 인한 연쇄작용 등으로 인하여 잘 쓰이기 않는 방법이다.

더티 쓰기 방지

더티 쓰기 : 아직 커밋되지 않은 트랜잭션 값을 덮어 씌웠을 경우를 의미한다.

  • 보통 먼저 쓴 트랜잭션이 커밋되거나 어보트될 때까지 두 번째 쓰기를 지연시키는 방법을 사용한다.
  • 두번의 카운터 증가 사이에 발생되는 경쟁 조건은 막지 못한다. 위 문제는 갱신 손실 방지에서 해결할 수 있다.
  • 로우 수준 잠금을 사용하여 더티 쓰기를 방지한다.

스냅숏 격리와 반복 읽기

비반복 읽기 (읽기 스큐) : 트랜잭션이 끝난 시점에서 데이터베이스를 읽으면 이전 질의에서 보았던 것과 다른 값이 나타나는 현상이다.

  • 여기서의 스큐는 시간적인 이상 현상을 뜻한다. (파티셔닝 같은 경우 스큐를 불균형을 뜻함)
  • 이러한 현상을 해결하기 위하여 스냅숏 격리를 활용한다.

스냅숏 격리

각 트랜잭션을 데이터베이스의 일관된 스냅숏으로부터 읽게하는 방법이다.

  • 시작할 때 데이터베이스에 커밋된 상태였던 모든 데이터를 보게 된다.
  • 전형적으로 더티 쓰기를 방지하기 위해 쓰기 잠금을 사용한다.
    • 따라서 읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다.
    • 다둥 버전 동시성 제어 (MVCC) 기법을 활용한다.

가시성 규칙

가시성 규칙을 정의함으로써 데이터베이스의 일관된 스냅숏을 제공할 수 있다.

  • 각 트랜잭션이 시작할 때 그 시점에서 진행 중인 모든 트랜잭션 목록을 만든다.
  • 어보트된 트랜잭션이 쓴 데이터는 무시한다.
  • 트랜잭션 ID가 더 큰 트랜잭션이 쓴 데이터는 모두 무시한다. (현재 트랜잭션이 시작한 이후는 전부 무시)
  • 무시되지 않는 데이터는 애플리케이션의 질의로 볼 수 있다.

색인

단순하게 색인이 객체의 모든 버전을 가르키게 하고 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 하는 것을 의미한다.

  • 같은 페이지에 동일한 객체의 다른 버전들을 저장될 수 있다면 색인 갱신을 회피한다.
  • append-only / copy-on-write
  • 색인 목적으로 B 트리 자료구조를 활용한다.

갱신 손실 방지

갱신 손실 문제 : 애플리케이션이 데이터베이스에서 값을 읽고 변경한 후 변경된 값을 다시 쓸 떄 발생할 수 있다. (read-modify-write 주기)

  • clobber : 두 트랜잭션이 작업을 동시에 한다면 변경 중 하나는 손실 될 수 있는 현상이다.

원자적 쓰기 연산

애플리케이션 코드에서 read-modify-write 주기를 구현할 필요를 없애주는 역할을 한다.

  • 원자적 연산은 보통 객체를 읽을 때 그 객체에 독점적인 잠금을 획득하여 구현한다.
  • 갱신이 적용될 때 따지 다른 트랜잭션에서 그 객체를 읽지 못하게 한다. (커서 안정성을 유지시키기 위한 방법)

명시적인 잠금

애플리케이션에서 갱신할 객체를 명시적으로 잠그는 행동이다. 동시에 다른 트랜잭션이 같은 객체를 읽으려고 한다면 주기가 완료될 때까지 기다리도록 강제한다.

  • 경쟁 조건을 유발하기 쉬우므로 위 동작이 올바르게 동작하게 하려면 애플리케이션 로직에 대해 신중하게 생각해야한다.

갱신 손실 자동 감지

병렬 실행을 허용하여 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트시키고 read-modify-write 주기를 재시도하도록 강제하는 방법

  • 스냅숏 격리와 결합하여 효율적으로 실행 할 수 있다.
  • 애플리케이션 코드에서 어떠한 데이터베이스 기능도 쓸 필요가 없게 도와준다.

Compare-and-set

값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용하는 방법이다. 현재 값이 이전에 읽은 값과 일치하지 않는다면 read-modify-write 주기를 재시도

충돌 해소와 복제

복제가 적용된 데이터베이스에서 갱신 손실을 막는 방법이다.

  • 데이터의 복사본이 있기 때문에 다른 노드들까지 갱신 손실을 방지하기 위한 추가적인 방법이 필요하다.
  • 쓰기가 동시에 실행 될 때 형재(sibling)을 생성하는 것을 허용하고 사후에 충돌을 해소하고 병하는 방법을 사용한다.
  • 원자적 연산은 잘 동작하나 최종 쓰기 승리 충돌 해소방법 같은 경우 갱신 손실이 발생하기 쉽다.

쓰기 스큐와 팬텀

쓰기 스큐 : 각 트랜잭션에서 다른 객체를 갱신하게 되지만 충돌이 발생하게 되는 경우

  • 두 트랜잭션이 같은 객체들을 읽어서 그 중 일부를 갱신할 때 나타날 수도 있다.
  • 다른 트랜잭션이 하나의 동일한 객체를 갱신하는 타이밍에 따라 특별한 경우에 발생 할 수도 있다.

팬텀

어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 뜻한다.

  • 어떤 검색 조건에 부합하는 로우가 존재하지 않는지 확인하고 쓰기 작업이 같은 조건에 부합하는 로우를 추가한다.

충돌 구체화

인위적으로 데이터베이스에 잠금 객체를 추가하는 방법이다.

  • 팬텀을 데이터베이스에 존재하는 구체적인 로우 집합에 대한 잠금 충돌로 변환하는 방법이다.
  • 충돌을 구체화하기 어렵기 때문에 다른 대안이 불가능할 때 최후의 방법으로 고려되어진다.

직렬성

여러 트랜잭션이 병령로 실행되더라도 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장하는 방법이다.

실제적인 직렬 실행

한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행하는 방법이다.

단일 스래드에서 실행 할 수 있었던 이유

  • 램 가격이 저렴해져서 활성화된 데이터셋 전체를 메모리에 유지할 수 있게 되었다.
  • 트랜젝션이 보통 짧고 실행하는 읽쓰기의 개수가 적었기 때문이다.

실용적인 사용을 위한 트랜잭션 직렬 제한사항

  • 모든 트랜잭션은 작고 빨라야한다.
  • 활성화된 데이터셋이 메모리에 적재될 수 있는 경우로 사용이 제한된다.
  • 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮아야한다.

스토어드 프로시저

데이터베이스 트랜잭션이 사용자의 입력을 기다려야 한다면 대부분 유휴상태지만 잠재적으로 매우 많은 동시 실행 트랜잭션을 지원해야한다.

  • OLTP 애플리케이션은 트랜잭션 내 대화식으로 사용자 응답을 대기하는 것을 회피함으로써 짧게 유지한다.
  • 이런 식의 방식은 각 사이의 네트워크 통신에 많은 시간을 소비하게 된다.
  • 트랜잭션을 순차적으로 처리하는 시스템들은 상호작용하는 다중 구문 트랜잭션을 허용하지 않는다. 위의 문제를 해결하기 위해서 스토어드 프로시저 형태로 데이터베이스에 미리 제출해야한다.

스토어드 프로시저 장단점

  • 스토어드 프로시저용 언어는 생태계가 상대적으로 빈약하고 관리하기 어렵다는 단점이 존재한다.
  • 위의 문제를 극복하는 방법 중 하나는 기존의 범용 프로그래밍 언어를 사용하는 방법이 있다.
  • 위를 사용하게 된다면 모든 트랜잭션을 단일 스레드에서 실행하는 게 현실성이 있다.

스토어드 프로시저 파티셔닝

여러 CPU 코어와 여러 노드로 확장하기 위하여 데이터를 파티셔닝 할 수도 있다.

  • 각 파티션은 다른 파티션과 독립젇으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있다.
  • 데이터베이스가 해당 트랜잭션이 접근하는 모든 파티션에 걸쳐서 코디네이션을 해야한다. 위 덕분에 단일 파티션보다 실행 시간보다 느려지게 된다.

2단계 잠금 (2PL)

2단계 잠금은 기존 잠금 요구사항과 비슷하지만 기존의 요구사항이 더 강하다.

  • 쓰기를 실행하는 트랜잭션이 없는 객체는 여러 트랜잭션에서 동시에 읽을 수 있다. 그러나 누군가 어떤 객체에 쓰려고 하면 독점적인 접근이 필요하다.
  • 쓰기 트랜잭션은 다른 쓰기 트랜잭션뿐만 아니라 읽기 트랜잭션도 진행하지 못하게 막고 그 역도 성립한다.
  • 직렬성을 제공하므로 앞에서 설명했던 갱신 손실과 쓰기 스큐를 포함한 모든 갱쟁 조건으로부터 보호해준다.

구현 및 성능

읽쓰기를 막는 것은 공유 모드 및 독점 모드로 사용되는 잠금을 사용하여 구현할 수 있다.

  • 잠금이 많이 사용되므로 교착 상태가 발생할 수도 있다. 따라서 교착 상태를 자동으로 감지하고 그 중 하나를 어보트시켜 다른 트랜잭션들이 진행할 수 있게 한다.
  • 2단계 잠금을 사용하게 되면 완화된 격리 수준을 쓸 때보다 처리량과 질의 응답 시간이 크게 나빠진다. 응답시간이 나빠지는 원인은 동시성이 줄어들고 오버헤드 발생 그리고 불안정한 상대가 있다.

서술 잠금

직렬성 격리를 쓰는 데이터베이스에서 발생하는 팬텀을 막는 잠금 기법이다.

  • 공유/독점 잠금과 비슷하게 동작하지만 검색 조건에 부합하는 모든 객체에 속한다.
  • 미래에 추가될 수 있는 객체에서도 적용할 수 있다.

색인 범위 잠금

조건에 부합하는 잠금을 확인하는데 시간이 오래 걸리기 때문에 위를 간략화한 것이다.

  • 어떤 방법을 쓰든지 간략화한 검색 조건이 색인 중 하나에 붙는다.
  • 다른 트랜잭션이 같은 방을 사용하거나 시간이 겹치는 예약을 삽입, 갱신, 삭제하길 원한다면 색인의 같은 부분을 갱신해야한다.
  • 팬텀과 쓰기 스큐로부터 보호해준다.

직렬성 스냅숏 격리 (SSI)

직렬성 스냅숏 격리는 완전한 직렬성을 제공하지만 스냅숏 격리에 비해 약간의 성능 손해만 발생한다.

비관적 동시성 제어 (상소 배제와 비슷)인 2단계 잠금과 달리 SSI는 낙관적 동시성 제어이다.

  • 낙관성 동시성 제어 기법은 위험한 상황이 발생할 가능성이 있을 때 모든 것이 괜찮아질 거라는 희망을 가지고 계속 진행하는 방식이다.
  • 예비 용량이 충분하고 트랜잭션 사이의 갱쟁이 너무 심하지 않으면 성능이 좋아진다.

트랜잭션이 다른 트랜잭션들이 잡고있는 잠금을 기다리느라 차단 될 필요가 없다. (2단계 잠금)

  • 질의 지연 시간 예측이 쉽고 변동을 적게 만들 수 있다.

단일 CPU 코더의 처리량에 제한되지 않는다는 장점도 있다. (순차 실행)

  • 직렬성 격리를 보장하면서 여러 파티션으로부터 읽고 쓸 수 있다.

오래된 MVCC 읽기 감지하기

트랜잭션이 MVCC 데이터베이스의 일관된 스냅숏에서 읽으면 아직 커밋되지 않은 데이터는 무시한다.

  • 스냅숏에서 읽을 때 무시되어쑈던 쓰기가 현재 영향이 발생하는 이상 현상이 발생 할 수 있다
  • 위를 막기 위하여 가시성 규칙에 따라 다른 트랜잭션의 쓰기를 무시하는 경우를 추적해야한다. 따라서 커밋하려고 할 때 무시된 쓰기 중에 커밋이 있는지 확인을 진행한다.
  • 불필요한 어보트를 피해서 스냅숏의 특성을 유지할 수 있다.

과거의 읽기에 영향을 미치는 쓰기 감지하기

데이터를 읽은 후 다른 트랜잭션에서 그 데이터를 변경할 때 색인 범위 잠금과 비슷한 잠금을 사용한다. 위와의 차이점은 다른 트랜잭션을 차단하지 않는다는 점이 차이점이다.

  • 트랜잭션이 데이터베이스에 쓸 때 영향받는 데이터를 최근에 읽은 트랜잭션에 있는지 색인에서 확인해야한다.

😉 필자의 생각

7장에서는 크게 초장에는 트랜잭션와 관련된 주제 및 속성을 설명한 다음 실제 동시성과 관련되어진 트랜잭션의 문제 및 해결 방법에 대해 설명해주는 장이었다. 여러 문제 해결 방법을 설명하다보니 유난히 길었던 장이었던 것 같았다.

7장을 읽어보면서 마지막 부분 중 하나인 낙관적 동시성 제어에 대한 설명 같은 경우 이해가 되지 않은 부분이 많아 모임 때 이 점에 대해 논의를 들었던 것이 있었다. 순서대로 트랜잭션 처리를 해야하기 때문에 오차를 허용하지 않아야하는 비관적 동시성 제어(직렬성)와 달리 낙관적 동시성 제어는 스냅샷 읽기 같은 경우 허용을 하기 때문에 직렬성와 달리 상대적으로 낙관적이었기 때문에 낙관적 동시성 제어라고 불리는 것 같았다.

동시성 문제가 많이 다루다보니 교착 상태(데드락) 등 운영체제 내에서 동시성 문제 용어 및 그 문제를 해결하는 방법 등이 떠올랐었다. 또한 연초에 과제 전형을 복습하면서 실행 중에 갑자기 동일한 요청이 들어올 경우 처리 방법이 뭐가 있는지 의문에 대한 해결 실마리를 잡았었다. 애플리케이션 내에서 위의 같은 의문을 해결해야할 경우 명시적인 잠금을 활용하여 각 요청마다 순차적으로 처리하면 될 것 같았다. 그 예시로 자바 같은 경우 @Transactional 등을 사용하면 될 것 같았다.

profile
개발자 같은 거 합니다. 1인분 하는 개발자로서 살아갈려고 노력 중입니다.

0개의 댓글