분산 시스템의 기초 개론 – 2

이전 글에서 Write-Ahead-Log, Leader and Follower, Version Control, Majority Quorum, Generation Clock에 대해 언급 했다. 이번 글에서는 그 이후의 것을 알아본다.

이 글은 “Patterns of Distributed Systems” (Unmesh Joshi)의 책을 기초로 쓰였다.

Generation Clock

복잡한 장애 상황을 고려해 보자. 여기서는 편의를 위해 단일 Leader인 상황을 가정한다. 그리고 현재 Leader는 A이다.

A, B, C 라는 서버가 있다. A 서버가 최초에 Leader였다. A가 [id123=567] 이라는 수정 요청을 받았다. 슬프게도, 이 값을 B, C에 뿌리기 전에 A가 죽어버렸다. 이제 B가 Leader로 선출됐다. B에서도 [id123=888] 이라는 값이 저장됐다. B도 정보를 뿌리기 전에 죽어버렸다.

몇 분 후에 A와 B 모두 살아났다. 복구 과정속에서 각자 Leader일 때 commit된 정보를 Follow에게 뿌리게 된다. 이때, [id123=567][id123=888] 라는 상태가 공존하게 된다. B가 먼저 살아나서 [id123=888]이 적용 됐으나, A가 살아남에 따라 이전 상태인 [id123=567]로 데이터가 씌어질 수도 있다.

이 문제를 해결하기 위해서는 “Leader가 바뀔 때 마다” “세대(Generation) 번호“를 같이 기록해야 한다. 최초 A가 Leader일 때는 0세대이다. 그리고 B가 Leader로 변하자 1세대 라고 기록한다. 추후 복구 과정속에서 데이터가 겹친다면(Conflict) “더 높은 세대의 데이터“를 선택한다.

Generation Clock의 추가 효과

Generation Clock을 가지는 것은 또 다른 문제점을 해결할 수도 있다. A 서버가 죽은게 아니라 일시적으로 네트워크가 끊겼을 수도 있다. 이때, A 스스로는 Leader가 바뀌었다는것을 알아채지 못 할 수도 있다.

Generation Clock이 있으면 세대가 바뀌었음(=Leader가 바뀌었음)을 알아채고 스스로 Follower Mode로 전환할 수 있다.

High-Water Mark

High-Water Mark는 “최고점” 정도로 번역되는듯 하다. 그러나 분산 시스템에서는 “최대 어느 지점까지 처리했는지” 추적하는데 쓰인다. High-Water Mark를 통해 모든 구성 요소들이 어디까지 처리됐는지를 알 수 있다. 아래 예시를 통해서 단어의 느낌을 알아보자.

이전 단락에서 Majority Quorum(정족수 과반)에 의해 허가(accept) 받아야지 데이터가 승인(commit) 될 수 있게 하는 것이 정석이라 언급했다. 이 방법을 사용할 때도 고려할 것들이 있다.

기존에는 3개의 서버만 존재하고, 1개의 Leader만 있는 상황을 고려했다. 하지만 Majority Quorum에서는 이것이 단순한 문제가 아니게 된다. 전체 의사회(Quorum)의 반 이상이 승인(Accept)해야 하기 때문이다. A, B, C 3개 서버중, 본인을 포함해서 최소 2개 서버에서 승인을 받아야 된다.

예시 시나리오

편의를 위해 A, B, C 전체가 의사회(Quorum)이라고 하자. A가 [id333=666] 이라는 요청을 받았다. 그리고 B와 C에 [id333=666] 쓰기를 요청했다. A는 당연히 “동의”를 한 셈이다. B와 C중 한 곳에서 “허가”를 해 주면 된다.

B가 먼저 “허가”를 해 줬다고 하자. 그러면 과반 이상의 정족수가(Majority Quorum) 허가한 상태가 되어, 자동으로 “승인” 상태가 된다. 승인이 되면 이 상태를 각 구성원에 알려야 한다.

Quorum의 구성요소에게 “처리가 어디까지 됐는지” (처리된 가장 마지막 지점) 알림

각 구성원에게 어디까지 됐는지 알려주기 위해서는 요청에 번호를 붙여야만 한다. 다시 처음으로 돌아가서 요청에 번호를 붙여보자.

A는 14: [id333=666] 처럼 번호를 붙여서 B, C에게 데이터를 보낸다. B, C중 하나가 허가를 내주면 A는 최고점(High-Water Mark)=14 라고 기록하고 전파한다. High-Water Mark를 통해서 어디까지 처리가 됐는지를 각 서버들에게 알릴 수 있게 된다.

순서대로 번호를 붙여서 예시를 보자.

  1. A가 id555=1111], [id333=666], [id123=444] 라는 요청을 수신
  2. A가 들어온 순서대로 13: [id555=1111], 14: [id333=666], 15: [id123=444] 로 번호 붙이고, B와 C에게 허가 요청을 보냄
  3. 13번을 C가 허가(응답)해줌.
  4. (정족수가 넘었으므로, 작업이 어디까지 됐는지 기록하는 High-Water Mark = 13으로 수정)
  5. 13번을 B도 허가해 줌
  6. (이미 High-Water Mark가 13이므로 별다른 처리 없음 = 이미 13번 까지는 처리됐다고 알고 있음)
  7. High-Water Mark = 13 이라는 상태를 각 구성요소에게 전파
  8. ~~~~~ 계속해서 작업 진행 ~~~~~

14번도 마찬가지이다. 14번은 B가 먼저 응답한다. B가 응답하자 마자 현재 처리의 최고점=14 라는 상태를 가진다. C가 뒤늦게 14번 허가함을 보내도 이미 최고점이기 때문에 추가적인 반응이 없다.

High-Water Mark 전파는 실시간일 필요는 없음

High-Water Mark를 알리는 신호는 시간 단위로 (100ms 마다, 1초마다 등등) 보내도 되고, 매 Accept마다 보낼 수도 있다. 책에서는 서버가 정상 작동중인지 확인하는 Heart Beat에 포함할 것을 언급한다.

High-Water Mark를 전파하기 전에 Leader가 죽음

15번 요청을 B나 C에게서 요청을 허가받고, High-Water Mark를 올린 직후에 네트워크가 끊긴 상황을 가정해 보자. A는 아직 최고점=15를 B나 C에게 전파하지 못했다. 하지만 A 내부적으로는 15번 요청을 B와 C에게 허가를 받았기에 Commit 처리 된 상태이다.

B, C 입장에서는 Leader인 A와 연결이 끊겼다고 판단하고, 자기들끼리 처리를 이어 나간다. B가 다음 Leader가 됐고, 복구의 책임을 가졌다고 하자. B는 B, C의 Uncommitted 로그를 전부 확인한다. 그러면 15번 작업이 Quorum에 의해 정족수 이상의 승인을 받았음을 확인 할 수 있다. 이것을 통해 데이터를 최고점=15라는 정보를 확보 할 수 있다.

(다른 이야기) 추후 A가 살아난다면 A에 들어왔던 요청이 복구 될 수도 있음.

A에서만 요청을 받고 Quorum에 보내진 않은 데이터가 있을 수 있다. A가 살아나면 WAL에는 있지만 Quorum에서 허가(Accept)되진 않은 요청을 새로운 Leader에게 보낼 수 있다. 이때 Leader가 Uncommitted Transaction으로 보고, 충돌(conflict)이 없다면 복구 과정을 진행 할 수 있다. 만약 충돌이 있다면 Generation Clock에 의해 “더 늦게 쓰여진 데이터”가 남게 된다.

Idempotent Operation (멱등성 처리)

멱등성 처리는 일반 DBMS에서도 중요하다. 멱등성을 가진다는 말은 같은 작업을 수차례 반복해도 동일한 값을 가진다는 의미이다. 예를 들어, 첫번째 코드는 멱등성을 가지지만 두번째는 그렇지 않다. 첫번째 줄은 100번을 실행해도 id333에 10이 저장된다. 그러나 두번째 코드는 100번 실행하면 2000이라는 숫자가 저장될 것이다. 한번 더 저장하면 2020이 될 것이다.

[id333] = 10
[id333] = [id333] + 20

분산 처리를 하다보면 “재시도”가 발생할 수 있다. 이유야 다양할 것이다. Dead Lock이 감지되어 rollback 후 재시도를 할 수도 있다. 잠시 서버가 죽은 후 복구되며 WAL이 replay될 수도 있다. 작업이 재시도 될 수 있다면 반드시 멱등성을 가져야 한다.

작업 결과 응답을 위해서도 필요한 개념

조금 복잡한 시나리오를 생각해 보자. 여기서도 A, B, C 라는 구성요소 3개가 등장한다. 마찬가지로 A가 Leader이다.

ESM이라는 사람이 A에게 “10이라는 데이터를 미국에 보내줘” 라고 요청한다. 슬프게도 요청 중간에 A가 죽어버렸다. ESM은 처리 결과를 알지 못한다. 하지만 이 시스템은 발송 여부를 반드시 알려줘야 한다. 발송 여부를 알 수 없으면 안된다는 제약이 있다.

이 문제를 해결하기 위해서 각 요청에 ID를 발급하고 알려준다. 이제 ESM은 A에 REQ-10: 10이라는 데이터를 미국에 전송 이라고 보낸다. A는 결과 정보를 저장해 둔다. 결과 목록에 REQ-10 을 항목을 만들고, 그곳에 “성공” 또는 “실패”를 저장한다.

만약 ESM이 요청을 보내고 TCP-ACK를 받기 전에 네트워크가 손상됐다고 하자. 네트워크가 복구되자 마자 ESM은 이 요청의 결과를 알고 싶어한다. 그러면 REQ-10: 10이라는 데이터를 미국에 전송이라는 요청을 한번 더 보내면 된다. 이미 정상 접수된 항목이라면 결과 목록의 결과가 즉시 응답될 것이다. 접수가 안됐다면 처리가 이루어 질 것이다.

결과 정보를 공유하면 다른 구성요소에게도 결과를 물을 수 있음

결과 정보를 여러 서버에 복제하면 효과가 더 크다. Quorum에 요청을 보낼때 요청 ID도 같이 보낸다고 하자. Leader인 A가 정족수 과반의 허가를 받았다. 처리를 다 하고 나서 Quorum에 결과를 공유하자 마자 서버가 죽었다. 그렇다면 ESM은 옆의 구성요소에 REQ-10의 작업 결과를 물어보면 된다.

허가를 받자마자 A가 죽어버렸다 해도 된다. B가 다음 Leader가 되어 복구를 담당한다고 하자. B는 Quorum의 데이터를 모아서 Uncommitted Transaction을 복구한다. (멱등성을 통해) 로그를 Replay를 하고, 이 결과를 Quorum에 공유하면 ESM은 다른 구성요소에서라도 결과를 알아 낼 수 있다. REQ-10이 어디에서도 발견되지 않았다면 짜피 Accept 되지 않은 요청이므로 실행하면 된다.

특히 Load Balancer 같은 것이 전면에 있다면 사용자는 뒷동네의 상황을 알 필요 없어진다. 클라이언트 입장에서는 [600초 내에 응답이 없으면 Retry를 한다~] 와 같이 처리하면 된다.

Lamport Clock

분산 시스템에서는 “일의 순서”가 중요하다. 1개의 서버에서만 동작한다면 쉽게 문제를 해결 할 수 있다. 모든 요청마다 번호를 붙이면 된다. 그러면 Total Order가 가능하다.

그러나 여러 개의 서버가 등장하면 순서 보장이 어려워 진다. 현재 시간으로 동기화 하는 것은 불가능 하다. 모든 서버가 완전히 정확한 시간을 동기화 하는 것은 이상 세계에서나 가능하기 때문이다. 10ms라도 차이나면 일의 순서가 바뀔 수 있다.

Lamport Clock은 가상의 논리적 시계로서, 시스템 시간과 상관 없이 독립적인 순서 보장을 가능하게 한다.

그냥 시간 값을 사용 할 수 없음을 보여주는 예시

선착순 1명을 뽑는 프로그램이 있다. 이 시스템은 요청이 들어온 순서가 매우, 엄격하게 중요하다. 부하 분산을 위해 n개의 분산 서버를 뒀다. A → B 순서로 신호가 들어왔다. 하지만 A의 요청을 받은 서버가 표준 시간보다 2ms 더 느렸다. 이 경우, 시간 값으로만 처리하면 B가 1등으로 착각 할 수 있다.

Version Control에도 이 문제를 고려해야 한다. A, B, C 라는 서버가 있다고 하자. A는 1분 빠르고 (현재 시각 = 05:29) B는 1분 늦다 (현재 시각 = 05:31). 다행히 C는 정상이다. C에서 05:30에 [id111=333], [id555=777] 요청이 들어와서 처리하고 A와 B에 뿌렸다. A와 B도 비슷한 시간에 요청이 들어왔다. 이때, 어느것이 최신 값인지를 구별 할 수 있어야 한다. (최신 값 만이 시스템에 남는다.)

A가 50초 뒤에 요청을 받았음에도 (A - 05:29:50) [id111=333](C - 05:30:00) [id111=333] 보다 늦었다고 판정되어 최신 값이 아니게 됐다. B는 30초 이전에 요청을 받았음에도 (C - 05:30:00) [id555=777] 가 아닌 (B - 05:30:30) [id555=777] 가 최신값이 됐다. 그러므로, 단순히 시간 값으로 “이것이 최신 값이다” 를 보장할 수 없다.

Lamport Clock의 구조

각 구성 요소는 각자의 카운터를 가진다. 그리고 단위 작업이 있을 때 마다 각자의 숫자를 +1 한다. 이 숫자는 다른 서버에 요청을 보낼 때 같이 보낸다. 그러면 해당 숫자를 받은쪽에서 본인의 카운터와 상대방에게서 받은 숫자더 큰 숫자 + 1 Counter에 저장한다

구성 요소간 통신을 할 수도 있지만, 사용자가 통신하면서도 사용할 수 있다. 아까 예시를 다시 보자. [id111=333], [id555=777] 상태에서 ESM이 A: [id111=333]B: [id555=777] 요청을 했다. 이때, 단순히 시간 값을 사용하면 순서가 엉킨다. 하지만 사용자를 통해서 A의 Lamport Clock을 B에게 넘긴다면 최소한 B는 A보다 큰 clock을 가지게 된다. (순서가 유지된다)

그러나 Lamport Clock가 제대로 동작하기 위해서는 “연관된 작업”이어야 한다. 만약 A와 B가 계속 독립적으로 운영된다면 각자의 숫자를 가지게 될 것이다. 그리고 그 동안에는 숫자가 더 크다고 해서 “최신 상태”이다는 보장을 할 수 없다. A가 요청을 더 많이 받았다면 A의 Lamport Clock이 당연히 클 것이다. 그러므로 연관된 작업 순서 사이에서만 순서가 보장된다. Lamport Clock을 이용한 버전 값 자체는 Partial Order이다.

Lamport Clock은 연관되지 않은 작업끼리는 순서 보장 불가

잠시 여러 서버에 샤딩된 DB를 생각해 보자. 규칙없이 여러 서버에 데이터가 나뉘어져 있다면 최신 값이 무엇인지 알 수 없다. 각 서버마다 별개의 Clock을 가지므로 순서를 매길 수 없기 때문이다. 이 경우 담당 서버 = ROW_ID % 3와 같이 특정 데이터의 처리 요소는 한 곳로 제한해야 한다. 그렇다면 최신 값은 당연히 그 서버에 마지막으로 저장된 값 일 것이다.

Hybrid Clock

Lamport Clock은 순서를 알려주기에는 좋다. 그러나 처리 했을때의 시간을 알 수는 없다. 일반 사용자는 실제 처리된 시간을 알기 원한다. 또한, 조금은 틀리더라도 전체적인 순서 보장이 필요하다. Timestamp가 들어가면 조금의 오차는 있더라도 전체적인 순서를 알 수 있다. 그러므로 서버의 Timestamp를 섞어 쓰는 방법이 필요하다. 이게 Hybrid Clock이다.

Hybrid Clock = (Server Timestamp, Lamport Clock) 이다. 각 구성요소간 메세지를 보낼때 Hybrid Clock을 포함해서 보낸다. 메세지를 수신 하는 서버는 Hybrid Clock = (Max(메세지 내의 Timestamp, 내 서버의 Timestamp), Max(메세지 내의 Lamport Clock, 내 서버의 Lamport Clock) + 1) 을 사용한다. 시간 값이 있으므로 “동일한 시간 값의 Lamport Clock”을 사용하면 된다.:

시간 차이는 계속해서 저장되진 않는다. 대신, Timestamp의 최대값을 저장한다. “+5분을 해야한다” 처럼 보정 값을 가지는 대신에 “현재 알려진 최대의 Timestamp”를 저장하고, max(시스템 Timestamp, 알려진 최대 Timestamp)를 사용한다. 덕분에 서버 시간이 미래일 경우 그냥 과거로 돌리면 된다. 그러면 서버의 Timestamp가 아까의 미래 시간이 될 때 까지 알려진 최대 Timestamp가 쓰일 것이며, Lamport Clock의 숫자가 늘 것이다.

Clock-Bound Wait

Hybrid Time과 같이 시간 기반으로 순서를 처리하다 보면 마찬가지로 순서가 맞지 않을 수 있다. 또한, 저장된 데이터의 시간 값이 현재 서버의 시간보다 미래일 수도 있다. 로직에 의해서 미래의 값은 예약 같은 효과로 데이터가 보이지 않을 수 있다. 트랜잭션을 위해 멀티버전을 사용해야 할 경우 read 메서드가 read(Key, 시간(Clock)값)모양일 수도 있다. 이 경우, 미래의 시간 값이 저장되어 있으면 사용자는 계속해서 과거값만 보게 된다.

이때 서버간 시간 차이가 (clock skew) 보장되면 문제를 파훼할 수 있다. 각 서버간의 시간 차이가 최대 5초라고 하자. 그러면 요청을 처리하고 Commit 할 때 5초를 기다리면 된다. 그러면 “미래의 값”이 보이진 않는다.

시간이 가장 느린 서버가 15초이고, 가장 빠른곳이 20초라고 하자. 20초인 서버에서 “20초”가 찍힌 데이터를 저장하려 한다. 저장시 무조건 5초를 기다리므로 가장 빠른 곳은 25초이고 가장 느린곳도 20초이다. 그러므로 최소한 미래의 시간이 보이진 않는다.

쓰기 처리량 향상을 위해 Read시 대기를 할 수도 있음

그러나, 이 경우 “최대 시간 차이”만큼 기다려야 한다는 문제가 생긴다. 20초에 Update요청을 넣었어도 25초 까지는 적용되지 않는다. 다른 사용자들이 22초, 24초에 읽기 시도를 해도 과거 값이 보인다. 또한, 쓰기 작업도 대기가 발생한다. 그로 인해 쓰기 처리량도 줄어든다.

때문에, 일반적으로 200ms ~ 500ms의 시간 차이를 가정하고 분산 프로그램을 만든다고 한다. 이 숫자는 여러 데이터센터 속의 서버 시간을 비교했을 때, 주기적으로 NTP 시간 동기화를 하는 서버들 간의 최대 시간 차이라고 한다.

그럼에도 불구하고 매 쓰기 요청마다 200ms를 기다리는 것은 성능상 좋지 못하다. 어짜피 CockroachDB, YugabyteDB와 같은 상용 분산 DB는 Multi-Version 구조로 인해 시간 값이 필요하다. 그래서 쓰기시 200ms를 대기하는 대신에, 읽기 시에 read(key, 현재시간 + 200ms(~ 500ms))를 요청한다고 한다.

만약 미래의 시간이 수신된다면, Client 측에서 해당 시간차이 만큼을 대기하여 Clock을 일치 시킨다. 미래의 값이 존재한다면 논리적 오류가 발생할 수 있기 때문이다. Client에서 절대 미래의 값을 다루진 않기 위해, Client에서 sleep(해당 레코드의 최신 시간 - 현재 시간)을 한 뒤에 다시 읽기 시도를 한다고 한다.

여기 까지…

여기까지 데이터를 적절하게 저장·처리 하기 위해 필요한 것을 다뤘다. 서버 장애 발생후 복구하는 동안 충돌(Conflict)이 나지 않게 Generation을 도입했다. Majority Quorum을 통해 Leader가 죽어도 Uncommitted Transaction을 복구할 수 있게 했다. Majority Quorum을 쓰기 위해 어디까지 처리했는지 전파하도록 High-Water Mark를 가져왔다.

Leader가 처리중에 죽어도 지속적인 처리 및 결과 반환이 가능하도록 멱등성을 가져왔고, Multi-Version 처리 및 다양한 곳에 활용 할 수 있는 Clock 개념(Lamport, Hybrid) 을 들고왔다. Clock을 적용하며 발생할 수 있는 시간차에 대해 잠시 다뤘다.

앞으로 남은건 데이터 파티셔닝(샤딩)과 코어 시스템 관리 영역이다. 파티셔닝시 데이터 정합성을 위한 Two-phase commit과 gossip Protocol 같은게 남은 셈이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

목차