본문 바로가기

분산·다중처리와 부분 장애

한 운영체제가 여러 CPU를 관리하는 다중처리와, 독립된 컴퓨터들이 메시지로 협력하는 분산 실행이 무엇을 공유하고 어떻게 실패하는지 비교합니다. 응답이 오지 않았을 때 가능한 세 상태와 멱등성, 요청 번호로 중복을 막는 방법을 메모리 모델로 따라가고, 예외와 프로세스 종료가 남기는 상태를 구분합니다.

더 많은 일을 처리하려면 CPU 코어를 늘리거나 컴퓨터를 여러 대 연결하면 됩니다. 둘 다 처리량을 늘리지만 운영체제가 다루는 문제는 크게 다릅니다. 한 컴퓨터의 여러 코어는 같은 물리 메모리를 함께 읽고 쓸 수 있지만, 다른 컴퓨터의 메모리는 직접 볼 수 없고 네트워크로 메시지를 주고받아야만 데이터를 나눌 수 있습니다.

이 글에서는 두 방식이 무엇을 공유하고 어떻게 실패하는지 비교하고, 분산 환경에서 가장 흔한 문제인 "응답이 오지 않았을 때 다시 보내도 되는가"를 작은 모델로 따라가 봅니다.

한 컴퓨터 안의 다중처리

다중처리(multiprocessing) 시스템은 여러 프로세서가 한 운영체제 아래에서 메모리와 장치를 함께 씁니다. 오늘날의 서버와 PC는 대부분 대칭형 다중처리(symmetric multiprocessing, SMP)로, 모든 코어가 같은 역할을 하며 커널 코드와 사용자 프로그램을 어느 코어에서나 실행합니다. 이와 달리 비대칭형 다중처리(asymmetric multiprocessing)는 한 프로세서가 운영체제의 관리를 맡고 나머지는 정해진 일만 하는 구조로, 일부 임베디드 시스템에서 쓰입니다.

메모리를 함께 쓴다고 비용까지 같지는 않습니다. 프로세서 소켓이 여러 개인 서버는 소켓마다 가까운 메모리를 따로 두는 NUMA(non-uniform memory access) 구조여서, 다른 소켓에 붙은 메모리를 읽으면 더 느립니다. 그래서 5장 4편에서 본 것처럼 스케줄러는 스레드를 되도록 같은 코어와 같은 노드에 두려 하고, 코어를 두 배로 늘려도 캐시 경쟁과 잠금 때문에 속도가 두 배가 되지는 않습니다.

lscpu                     # 소켓, 코어, 스레드 수와 NUMA 노드
numactl --hardware        # NUMA 노드별 CPU와 메모리, 노드 사이의 상대 거리

여러 컴퓨터의 분산 실행

분산 시스템(distributed system)은 각자 메모리와 운영체제를 가진 컴퓨터, 곧 노드들이 네트워크로 메시지를 주고받으며 함께 일하는 시스템입니다. 다른 노드의 주소 0x1000은 내 노드의 0x1000과 아무 상관이 없으므로, 데이터를 나누려면 값을 바이트로 바꾸어 보내고 받는 쪽에서 다시 해석해야 합니다.

관점한 운영체제의 다중처리여러 노드의 분산 실행
데이터 공유같은 물리 메모리를 읽고 쓴다메시지로 값을 보내고 받는다
조정 방법스케줄러, 잠금, 메모리 관리통신 규약, 타임아웃, 재시도
고장의 모습대개 시스템 전체가 함께 멈춘다일부 노드나 링크만 고장 날 수 있다
주된 비용NUMA, 캐시, 잠금 경쟁네트워크 지연, 메시지 유실, 중복

가장 큰 차이는 고장의 모습입니다. 한 컴퓨터는 커널이 멈추면 모두 함께 멈추지만, 분산 시스템에서는 어떤 노드는 살아 있고 어떤 노드는 죽어 있거나, 노드는 모두 살아 있는데 그 사이의 네트워크만 끊기는 일이 생깁니다. 이렇게 시스템의 일부만 고장 나는 것을 부분 장애(partial failure)라고 합니다. 더 곤란한 것은, 상대가 느린 것인지 죽은 것인지 네트워크가 끊긴 것인지를 메시지만으로는 구별할 수 없다는 점입니다.

네트워크 운영체제와 분산 운영체제

네트워크 운영체제(network operating system)는 각 컴퓨터가 독립된 운영체제로 남아 있으면서, 원격 파일 공유나 원격 로그인 같은 기능으로 다른 컴퓨터의 자원을 쓰게 해 줍니다. 사용자는 어느 컴퓨터의 자원을 쓰는지 알고 있습니다. 분산 운영체제(distributed operating system)는 한 걸음 더 나아가 여러 컴퓨터를 하나의 시스템처럼 보이게 하려는 것입니다.

이처럼 자원이 어디에 있는지, 몇 개로 복제되어 있는지, 다른 곳으로 옮겨졌는지를 사용자가 몰라도 되게 하는 성질을 투명성(transparency)이라고 합니다. 하지만 원격 호출이 느리거나 실패할 수 있다는 사실까지 완전히 숨기면, 호출하는 쪽은 실패에 대비할 방법을 잃습니다. 또 여러 노드의 시계와 상태는 서로 조금씩 어긋나 있어서, 한 컴퓨터의 변수 하나를 읽듯 시스템 전체의 현재 상태를 한 번에 읽을 수는 없습니다. 그래서 오늘날의 시스템은 완전한 분산 운영체제를 만들기보다, 각 노드의 운영체제 위에 Kubernetes 같은 클러스터 관리 시스템과 분산 데이터베이스를 얹는 쪽으로 발전했습니다.

응답이 오지 않았을 때

클라이언트가 서버에 "보고서 작업을 접수해 달라"는 요청을 보냈는데, 정해진 시간 안에 응답이 오지 않았습니다. 이때 가능한 상태는 세 가지입니다. 요청이 서버에 닿지도 못했을 수도, 서버가 아직 처리하고 있을 수도, 서버는 처리를 마쳤는데 응답만 오는 길에 사라졌을 수도 있습니다. 타임아웃은 이 셋 가운데 어느 것인지 알려 주지 않습니다.

마지막 경우에 클라이언트가 같은 요청을 새 요청처럼 다시 보내면, 서버는 같은 작업을 두 번 접수합니다. 같은 연산을 여러 번 해도 한 번 한 것과 결과가 같은 성질을 멱등성(idempotency)이라고 하며, 데이터베이스 교재의 트랜잭션과 멱등성에서 같은 문제를 SQL로 다룹니다. "이름을 Kim으로 바꾼다"는 여러 번 해도 결과가 같으므로 원래 멱등하지만, "작업을 하나 추가한다"는 할 때마다 작업이 늘어나므로 멱등하지 않습니다. 멱등하지 않은 연산을 안전하게 다시 보내려면, 클라이언트가 요청마다 고유한 번호를 붙이고 서버가 번호별로 결과를 기록해 두면 됩니다.

jobs, results = [], {}                  # 서버 프로세스의 메모리: 접수한 작업, 요청 번호별 결과

def submit(request_id, payload):
    if request_id in results:           # 이미 처리한 요청이면 기록해 둔 결과를 돌려준다
        return results[request_id]
    jobs.append(payload)
    results[request_id] = len(jobs)     # 작업 번호를 결과로 기록한다
    return results[request_id]

first = submit("req-7", "report")       # 서버는 처리했지만 응답이 오는 길에 사라졌다
retry = submit("req-7", "report")       # 클라이언트가 같은 요청 번호로 다시 보낸다
print(f"첫 결과 {first}, 재시도 결과 {retry}, 접수된 작업 {len(jobs)}개")
첫 결과 1, 재시도 결과 1, 접수된 작업 1개

재시도는 기록해 둔 결과를 그대로 돌려받고, 작업은 하나만 접수되었습니다. 실제 서비스에서는 같은 번호의 요청 두 개가 동시에 도착하는 경우, 같은 번호에 다른 내용이 담겨 오는 경우, 기록을 얼마나 오래 보관할지도 정해야 합니다.

더 깊이: 커널이 연결을 포기하기까지 걸리는 시간

응용 프로그램이 시간 제한을 두지 않으면, 상대가 사라졌다는 사실은 커널의 TCP가 알아챌 때까지 드러나지 않습니다. 그런데 그 시간이 생각보다 깁니다. 커널의 ip-sysctl 문서에 따르면 보낸 데이터의 확인 응답이 오지 않을 때 TCP는 재전송 간격을 늘려 가며 다시 보내는데, tcp_retries2의 기본값 15에서는 연결을 끊기까지의 가상의 시간이 924.6초이고 실제 시간은 이보다 짧아지지 않습니다. 보낸 데이터 없이 연결이 놀고 있다면 재전송도 일어나지 않습니다. 이때 연결 유지 확인(keepalive)은 소켓에 SO_KEEPALIVE를 켠 경우에만 동작하므로, 켜지 않았다면 커널은 상대가 사라진 것을 알 수 없고, 켰더라도 14장 3편에서 본 연결 유지 확인이 첫 확인 패킷을 보내는 2시간 뒤까지는 알 수 없습니다.

그래서 응답을 기다리는 쪽은 자기에게 맞는 기한을 직접 정해야 합니다. 요청마다 응용 프로그램의 시간 제한을 두는 것 말고도, Linux 2.6.37부터 있는 TCP_USER_TIMEOUT 소켓 옵션으로 보낸 데이터가 확인되지 않은 채 남아 있을 수 있는 최대 시간을 밀리초 단위로 정할 수 있습니다. 그 시간이 지나면 커널이 연결을 끊고 응용 프로그램에 ETIMEDOUT을 돌려줍니다. tcp(7) 매뉴얼은 이 옵션을 쓰지 않으면 보통의 WAN 환경에서 실패를 알기까지 20분이 걸릴 수도 있다고 적고 있습니다.

예외와 프로세스 종료가 남기는 것

위 모델에서 jobs와 results는 둘 다 서버 프로세스의 메모리에 있습니다. 그래서 "중간에 실패했다"는 말만으로는 무엇이 남았는지 알 수 없고, 함수 하나가 예외로 멈춘 것인지 프로세스 전체가 죽은 것인지 나누어 따져야 합니다. 다음 코드는 작업을 추가한 직후, 완료를 기록하기 전에 실패하는 경우를 흉내 냅니다.

jobs, results = [], {}

def submit(request_id, payload, fail_after_effect=False):
    if request_id in results:
        return results[request_id]
    jobs.append(payload)                            # 효과를 먼저 남기고
    if fail_after_effect:
        raise RuntimeError("완료를 기록하기 전에 실패")
    results[request_id] = len(jobs)                 # 그다음 완료를 기록한다
    return results[request_id]

try:
    submit("req-7", "report", fail_after_effect=True)
except RuntimeError as error:
    print("첫 시도:", error)
retry = submit("req-7", "report")
print(f"같은 프로세스에서 재시도: 결과 {retry}, 접수된 작업 {len(jobs)}개")

jobs, results = [], {}                              # 영구 저장 없이 프로세스가 다시 시작되었다
restarted = submit("req-7", "report")
print(f"재시작한 프로세스에서 재시도: 결과 {restarted}, 접수된 작업 {len(jobs)}개")
첫 시도: 완료를 기록하기 전에 실패
같은 프로세스에서 재시도: 결과 2, 접수된 작업 2개
재시작한 프로세스에서 재시도: 결과 1, 접수된 작업 1개

함수만 예외로 멈추고 프로세스가 살아 있으면, 작업은 남았는데 완료 기록은 없으므로 같은 번호의 재시도가 작업을 하나 더 추가해 중복이 생깁니다. 프로세스가 죽었다가 다시 시작되면 메모리가 통째로 사라지므로, 중복은 없지만 처음 접수한 작업도 함께 사라졌습니다. 마지막 줄은 실제로 프로세스를 죽인 것이 아니라, 새 프로세스의 빈 메모리를 흉내 내려고 변수를 비운 것입니다.

실제 서비스에서는 작업이 데이터베이스나 작업 큐 같은 영구 저장소에 들어갑니다. 그러면 프로세스가 죽어도 작업은 남지만, 완료 기록을 함께 남기지 못했다면 재시도가 작업을 또 추가합니다. 그래서 작업이라는 효과와 완료 기록을 한 번에 함께 저장해야 하며, 이것이 다음 글의 주제입니다.

클라이언트, 서버, 미들웨어

분산 시스템의 가장 흔한 구조는 요청하는 클라이언트와 자원을 관리하는 서버로 나누는 것입니다. 웹 서비스는 흔히 화면을 맡는 층, 업무 규칙을 맡는 층, 데이터를 저장하는 층의 3계층으로 나누는데, 이는 역할을 나눈 것이지 서버가 꼭 세 대라는 뜻은 아닙니다. 통신, 인증, 데이터 변환, 서비스 찾기처럼 여러 서비스에 공통으로 필요한 기능은 미들웨어(middleware)가 맡습니다.

노드를 늘리면 처리량과 가용성을 높일 수 있지만, 복제와 통신, 운영의 비용이 늘어납니다. 서버를 두 대로 늘렸다고 두 배로 안전해지는 것도 아닙니다. 두 서버가 같은 전원, 같은 스위치, 같은 저장 장치에 기대고 있다면 그 하나가 고장 날 때 함께 멈추기 때문입니다.

완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다

효과와 완료 기록을 같은 트랜잭션에 둔다. 존속한 DB에서 동일 요청을 조회한다. 각 단계의 값과 전환 이유를 비교합니다.

완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다

같은 DB 트랜잭션의 효과와 완료 기록이 확정된 뒤 응답 전에 worker가 죽은 성공 경로입니다.

완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다같은 DB 트랜잭션의 효과와 완료 기록이 확정된 뒤 응답 전에 worker가 죽은 성공 경로입니다. 실선 화살표는 설명용 선행 순서, 점선은 worker 재시작 경계입니다. DB 파일은 경계 밖에 남습니다. 효과만 저장했거나 완료 기록이 만료된 경우에는 이 결과 재사용 경로가 성립하지 않습니다. 외부 효과·동시 요청의 별도 조건은 본문에서 다룹니다.재시작 경계 · 저장소 존속COMMITcrashlookup작업+완료 기록같은 DB에 저장원자 COMMIT둘이 함께 확정worker 재시작DB 파일 유지동일 요청 재조회기록으로 결과 재사용실선: 사건 순서 · 점선: 재시작 경계
완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다같은 DB 트랜잭션의 효과와 완료 기록이 확정된 뒤 응답 전에 worker가 죽은 성공 경로입니다. 실선 화살표는 설명용 선행 순서, 점선은 worker 재시작 경계입니다. DB 파일은 경계 밖에 남습니다. 효과만 저장했거나 완료 기록이 만료된 경우에는 이 결과 재사용 경로가 성립하지 않습니다. 외부 효과·동시 요청의 별도 조건은 본문에서 다룹니다.재시작 경계저장소 존속COMMITcrashlookup작업+완료 기록같은 DB에 저장원자 COMMIT둘이 함께 확정worker 재시작DB 파일 유지동일 요청 재조회기록으로 결과 재사용실선: 사건 순서점선: 재시작 경계
실선 화살표는 설명용 선행 순서, 점선은 worker 재시작 경계입니다. DB 파일은 경계 밖에 남습니다. 효과만 저장했거나 완료 기록이 만료된 경우에는 이 결과 재사용 경로가 성립하지 않습니다. 외부 효과·동시 요청의 별도 조건은 본문에서 다룹니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 함께 저장부터 다시 조회까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
함께 저장

효과와 완료 기록을 같은 트랜잭션에 둔다.

  • 완료 기록 1: 같은 DB의 작업+완료 기록
  • COMMIT 준비
COMMIT 뒤 crash

둘이 확정됐지만 응답 전에 worker가 죽는다.

  • 완료 기록 1: DB 기록 확정
  • 완료 기록 2: 응답 미전달
  • worker 재시작 필요
다시 조회

존속한 DB에서 동일 요청을 조회한다.

  • 완료 기록 1: DB 파일 유지
  • 완료 기록 2: 완료 기록 존재
  • 저장 결과 재사용; 효과만 저장/만료/외부효과 조건 별도

완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다

효과와 완료 기록을 같은 트랜잭션에 둔다. 존속한 DB에서 동일 요청을 조회한다. 각 단계의 값과 전환 이유를 비교합니다.

완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다

같은 DB 트랜잭션의 효과와 완료 기록이 확정된 뒤 응답 전에 worker가 죽은 성공 경로입니다.

완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다같은 DB 트랜잭션의 효과와 완료 기록이 확정된 뒤 응답 전에 worker가 죽은 성공 경로입니다. 실선 화살표는 설명용 선행 순서, 점선은 worker 재시작 경계입니다. DB 파일은 경계 밖에 남습니다. 효과만 저장했거나 완료 기록이 만료된 경우에는 이 결과 재사용 경로가 성립하지 않습니다. 외부 효과·동시 요청의 별도 조건은 본문에서 다룹니다.재시작 경계 · 저장소 존속COMMITcrashlookup작업+완료 기록같은 DB에 저장원자 COMMIT둘이 함께 확정worker 재시작DB 파일 유지동일 요청 재조회기록으로 결과 재사용실선: 사건 순서 · 점선: 재시작 경계
완료 기록이 재시작 뒤에도 남아야 결과를 재사용한다같은 DB 트랜잭션의 효과와 완료 기록이 확정된 뒤 응답 전에 worker가 죽은 성공 경로입니다. 실선 화살표는 설명용 선행 순서, 점선은 worker 재시작 경계입니다. DB 파일은 경계 밖에 남습니다. 효과만 저장했거나 완료 기록이 만료된 경우에는 이 결과 재사용 경로가 성립하지 않습니다. 외부 효과·동시 요청의 별도 조건은 본문에서 다룹니다.재시작 경계저장소 존속COMMITcrashlookup작업+완료 기록같은 DB에 저장원자 COMMIT둘이 함께 확정worker 재시작DB 파일 유지동일 요청 재조회기록으로 결과 재사용실선: 사건 순서점선: 재시작 경계
실선 화살표는 설명용 선행 순서, 점선은 worker 재시작 경계입니다. DB 파일은 경계 밖에 남습니다. 효과만 저장했거나 완료 기록이 만료된 경우에는 이 결과 재사용 경로가 성립하지 않습니다. 외부 효과·동시 요청의 별도 조건은 본문에서 다룹니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 함께 저장부터 다시 조회까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
함께 저장

효과와 완료 기록을 같은 트랜잭션에 둔다.

  • 완료 기록 1: 같은 DB의 작업+완료 기록
  • COMMIT 준비
COMMIT 뒤 crash

둘이 확정됐지만 응답 전에 worker가 죽는다.

  • 완료 기록 1: DB 기록 확정
  • 완료 기록 2: 응답 미전달
  • worker 재시작 필요
다시 조회

존속한 DB에서 동일 요청을 조회한다.

  • 완료 기록 1: DB 파일 유지
  • 완료 기록 2: 완료 기록 존재
  • 저장 결과 재사용; 효과만 저장/만료/외부효과 조건 별도

그림은 작업과 완료 기록을 같은 데이터베이스에 한 트랜잭션으로 저장한 경우입니다. 두 기록을 확정한 뒤 응답을 보내기 전에 서버 프로세스가 죽어도, 데이터베이스 파일에는 둘이 함께 남아 있으므로 같은 번호의 재시도는 저장된 결과를 다시 돌려받습니다. 앞의 메모리 모델과 달리 프로세스가 다시 시작되어도 기록이 사라지지 않는다는 점이 핵심이며, 이를 실제 코드로 확인하는 것이 다음 글입니다.

정리

  • 다중처리는 한 운영체제가 같은 물리 메모리를 쓰는 여러 프로세서를 관리하는 것이고, 분산 실행은 각자 메모리와 운영체제를 가진 노드들이 메시지로 협력하는 것입니다.
  • NUMA처럼 메모리를 함께 써도 위치에 따라 비용이 다르고, 코어를 늘린 만큼 빨라지지도 않습니다.
  • 분산 시스템에서는 일부만 고장 나는 부분 장애가 생기며, 상대가 느린지 죽었는지 메시지만으로는 구별할 수 없습니다.
  • 응답이 없을 때 요청은 도착하지 않았거나, 처리 중이거나, 처리되었는데 응답만 사라졌을 수 있으므로, 멱등하지 않은 요청은 요청 번호와 완료 기록으로 중복을 막습니다.
  • 함수의 예외와 프로세스의 종료는 남기는 상태가 다르며, 효과와 완료 기록이 함께 영구히 저장되어야 재시도가 안전합니다.

연습 문제

1. 새 번호로 다시 보내면

첫 모델에서 클라이언트가 응답을 받지 못하자, 같은 내용의 요청을 req-7이 아닌 새 번호 req-8로 다시 보냈습니다. 두 호출의 결과와 접수된 작업 수는 어떻게 되나요? 요청 번호는 누가, 언제 정해야 하나요?

해설 보기

서버는 req-8의 기록이 없으므로 새 요청으로 처리해, 첫 호출의 결과는 1, 두 번째 호출의 결과는 2이고 작업은 2개가 됩니다. 서버로서는 새 번호의 요청이 재시도인지 정말 새로운 요청인지 알 수 없기 때문입니다. 그래서 요청 번호는 클라이언트가 요청을 처음 만들 때 한 번 정하고, 재시도할 때는 같은 번호를 그대로 써야 합니다. 재시도할 때마다 번호를 새로 만들면 중복을 막는 장치가 아무 일도 하지 못합니다.

2. 예외와 종료가 남긴 것

두 번째 모델에서 jobs.append() 뒤에 함수가 예외로 멈추었고 프로세스는 살아 있습니다. 무엇이 남았고, 같은 번호의 재시도는 왜 작업을 하나 더 추가하나요? 영구 저장 없이 프로세스 자체가 죽고 다시 시작되었다면, 처음 접수한 작업이 남아 있다고 말할 수 있나요?

해설 보기

프로세스가 살아 있으므로 jobs에는 작업 하나가 남았지만, results에는 완료 기록이 없습니다. 재시도는 results만 보고 처리 여부를 판단하므로 이 요청을 처음 보는 것으로 여기고 작업을 하나 더 추가합니다. 프로세스가 죽었다면 jobs와 results가 모두 메모리에 있었으므로 함께 사라지고, 다시 시작한 프로세스에서는 처음 접수한 작업이 남아 있지 않습니다. 작업은 영구 저장소에 들어가는데 완료 기록만 메모리에 있다면, 그때는 작업이 남은 채 기록만 사라져 재시도가 중복을 만듭니다.

3. NUMA와 원격 호출

"NUMA에서 다른 소켓의 메모리를 읽는 것이나 다른 서버의 서비스를 부르는 것이나, 결국 멀리 있는 메모리에 접근하는 것"이라는 설명은 무엇을 빠뜨렸나요?

해설 보기

NUMA의 원격 메모리 접근은 한 운영체제의 한 주소 공간 안에서 일어나는 보통의 메모리 읽기로, 느릴 뿐 하드웨어가 결과를 보장하며 따로 실패하지 않습니다. 원격 서비스 호출은 별도의 주소 공간과 운영체제를 가진 노드에 값을 바이트로 바꾸어 보내고 받는 일이므로, 메시지가 사라지거나 늦게 오거나 중복될 수 있고 상대 노드만 따로 죽을 수도 있습니다. 따라서 원격 호출에는 타임아웃, 재시도, 멱등성 같은 부분 장애에 대한 대비가 필요하지만, NUMA의 메모리 접근에는 필요하지 않습니다. 둘을 같은 것으로 보면 지연의 차이만 보고 실패의 차이를 놓치게 됩니다.

다음 글 지속 저장과 안전한 재시도에서는 파일 데이터베이스를 써서 작업과 완료 기록을 함께 저장하고, 실제로 프로세스를 강제로 종료해 무엇이 남는지 확인합니다.