한 대의 서버로 끝나지 않는 이유
처음 만든 서비스는 보통 한 대의 서버에서 시작한다. 애플리케이션 서버 하나, 데이터베이스 하나, 파일 저장소 하나면 구조가 단순하고 디버깅도 쉽다. 하지만 사용자가 늘고 기능이 많아지면 한 대의 서버는 곧 한계에 부딪힌다. CPU가 부족할 수 있고, 메모리가 모자랄 수 있으며, 네트워크 대역폭이나 디스크 I/O가 병목이 될 수도 있다.
이때 선택지는 크게 두 가지다. 수직 확장은 더 좋은 장비로 바꾸는 방식이다. CPU 코어를 늘리고, 메모리를 키우고, 더 빠른 디스크를 붙인다. 구조를 크게 바꾸지 않아도 되므로 단순하다. 그러나 장비 성능은 무한히 커질 수 없고, 고성능 장비일수록 비용이 급격히 증가한다. 무엇보다 한 대에 장애가 나면 전체 서비스가 멈출 수 있다.
수평 확장은 서버 수를 늘리는 방식이다. 같은 역할을 하는 서버를 여러 대 두고 요청을 나누어 처리한다. 현대 웹 서비스는 대부분 이 방향으로 확장한다. 서버 한 대가 처리할 수 있는 요청 수에 한계가 있어도, 여러 대가 함께 처리하면 전체 처리량을 늘릴 수 있다. 또 일부 서버에 장애가 나더라도 나머지 서버가 요청을 계속 받을 수 있다.
분산 시스템은 이렇게 여러 컴퓨터가 네트워크로 연결되어 하나의 서비스를 제공하는 구조다. 핵심은 단순히 서버가 많다는 점이 아니다. 여러 서버가 함께 동작하면서도 사용자에게는 하나의 시스템처럼 보여야 한다는 점이 어렵다.
AI 생성 이미지
로드 밸런서는 트래픽의 입구를 정리한다
수평 확장을 하려면 사용자의 요청을 여러 서버에 나누어 보내야 한다. 이 역할을 하는 구성 요소가 로드 밸런서다. 로드 밸런서는 식당 입구에서 손님을 빈 테이블로 안내하는 직원과 비슷하다. 요청이 들어오면 현재 사용 가능한 서버 중 하나를 선택해 전달한다.
가장 단순한 방식은 요청을 순서대로 서버에 나누는 라운드 로빈이다. 하지만 실제 운영에서는 서버의 상태, 연결 수, 응답 시간, 가중치 등을 함께 고려한다. 어떤 서버가 배포 중이거나 장애 상태라면 로드 밸런서는 그 서버로 요청을 보내지 않아야 한다. 이를 위해 헬스 체크를 수행한다. 헬스 체크는 서버가 살아 있는지, 요청을 처리할 준비가 되었는지 주기적으로 확인하는 과정이다.
로드 밸런싱이 잘 동작하려면 애플리케이션 서버가 가능하면 무상태에 가까워야 한다. 무상태란 특정 사용자의 요청을 반드시 같은 서버가 처리하지 않아도 되는 구조를 말한다. 로그인 세션을 서버 메모리에만 저장하면 문제가 생긴다. 첫 번째 요청은 A 서버로 가고, 두 번째 요청은 B 서버로 가면 B 서버는 사용자의 로그인 상태를 모를 수 있다. 그래서 세션을 별도 저장소에 두거나, 토큰 기반 인증을 사용하거나, 불가피한 경우에는 같은 사용자를 같은 서버로 보내는 sticky session을 사용한다.
로드 밸런서는 확장성과 가용성을 높이지만 만능은 아니다. 모든 서버가 같은 데이터베이스를 바라보고 있다면 병목은 데이터베이스로 이동한다. 애플리케이션 서버를 10대로 늘려도 데이터베이스가 한 대라면 쓰기 요청에서 한계가 올 수 있다. 분산 시스템 설계는 병목을 한 계층에서 다른 계층으로 옮기고, 다시 그 병목을 찾아 완화하는 과정이기도 하다.
복제는 가용성을 높이지만 일관성 문제를 만든다
서버 한 대나 데이터베이스 한 대에 모든 것을 맡기면 장애에 취약하다. 그래서 중요한 데이터는 여러 곳에 복사해 둔다. 이를 복제라고 한다. 복제본이 있으면 주 서버에 장애가 나도 다른 서버가 읽기 요청을 처리하거나, 새로운 주 서버로 승격되어 서비스를 이어 갈 수 있다.
복제는 읽기 확장에도 도움이 된다. 예를 들어 주 데이터베이스는 쓰기를 담당하고, 여러 읽기 복제본이 조회 요청을 나누어 처리할 수 있다. 상품 상세 조회, 게시글 읽기, 사용자 프로필 조회처럼 읽기 비중이 큰 서비스에서는 효과가 크다.
하지만 복제에는 중요한 비용이 있다. 데이터가 여러 곳에 존재하면 항상 같은 값을 가지고 있는지 확인해야 한다. 주 서버에 쓰기가 완료된 직후 복제본에는 아직 반영되지 않았을 수 있다. 이 지연을 복제 지연이라고 한다. 사용자가 닉네임을 바꾼 뒤 바로 프로필을 조회했는데 예전 닉네임이 보이는 현상이 여기서 발생할 수 있다.
개발자는 이 현상을 버그로만 볼 것이 아니라 설계의 결과로 이해해야 한다. 어떤 기능은 최신 값이 반드시 필요하다. 결제 완료 여부, 재고 차감, 권한 변경 같은 데이터는 오래된 값을 읽으면 문제가 크다. 반면 게시글 조회 수나 추천 목록처럼 약간 늦게 반영되어도 되는 데이터도 있다. 복제를 사용할 때는 모든 데이터를 같은 기준으로 보지 말고, 최신성이 필요한 데이터와 지연을 허용할 수 있는 데이터를 구분해야 한다.
샤딩은 데이터를 나누지만 운영 난도를 높인다
복제가 같은 데이터를 여러 곳에 복사하는 방식이라면, 샤딩은 데이터를 여러 조각으로 나누어 서로 다른 서버에 저장하는 방식이다. 사용자 ID를 기준으로 1번부터 100만 번까지는 A 샤드, 100만 1번부터는 B 샤드에 저장하는 식이다. 또는 사용자 ID의 해시값을 이용해 여러 샤드에 고르게 분배할 수도 있다.
샤딩의 목적은 저장 용량과 처리량을 늘리는 것이다. 한 데이터베이스가 모든 데이터를 저장하고 모든 요청을 처리하기 어려울 때, 데이터를 여러 서버에 나누면 각 서버가 담당하는 범위가 줄어든다. 대규모 서비스에서 샤딩은 피하기 어려운 선택이 되곤 한다.
문제는 샤딩 이후부터 시스템이 훨씬 복잡해진다는 점이다. 먼저 어떤 기준으로 데이터를 나눌지 결정해야 한다. 기준이 잘못되면 특정 샤드에만 트래픽이 몰리는 핫스팟이 생긴다. 예를 들어 생성 시간이 최신인 데이터에 요청이 몰리는데 시간 기준으로 샤딩하면 최신 샤드만 바빠질 수 있다.
샤드 간 조인도 어렵다. 한 사용자의 주문은 A 샤드에 있고, 결제 정보는 B 샤드에 있다면 단일 데이터베이스에서처럼 간단히 조인하기 어렵다. 애플리케이션이 여러 샤드에 요청을 보내 결과를 합쳐야 할 수 있다. 트랜잭션도 복잡해진다. 한 샤드 안에서는 ACID 트랜잭션을 비교적 단순하게 사용할 수 있어도, 여러 샤드를 동시에 변경하는 작업은 분산 트랜잭션, 실패 처리, 보상 로직 등을 함께 고민해야 한다.
또 샤드 수를 늘리는 리샤딩도 큰 작업이다. 이미 저장된 데이터를 다시 분배해야 하고, 이전 경로와 새 경로가 공존하는 기간도 생긴다. 샤딩은 확장성을 주지만, 그 대가로 쿼리, 배포, 장애 대응, 데이터 이동의 난도를 높인다.
CAP 정리는 선택의 언어다
분산 시스템을 설명할 때 자주 등장하는 개념이 CAP 정리다. CAP는 일관성, 가용성, 네트워크 분할 내성을 뜻한다.
일관성: 성공한 쓰기 결과를 이후의 읽기에서 일관되게 볼 수 있다.
가용성: 요청을 받은 정상 노드가 오류가 아닌 응답을 계속 반환한다.
분할 내성: 네트워크가 끊겨 일부 노드끼리 통신할 수 없는 상황도 시스템이 견뎌야 한다.
분산 시스템에서는 네트워크 분할을 피할 수 없다고 봐야 한다. 서버는 살아 있지만 네트워크 문제로 서로 통신하지 못할 수 있다. 이때 시스템은 선택해야 한다. 최신 데이터 보장을 위해 일부 요청을 거절할 것인가, 아니면 일단 응답을 계속 제공하되 잠시 데이터 불일치를 허용할 것인가.
예를 들어 은행 계좌 이체 시스템은 일관성을 강하게 요구한다. 네트워크가 불안정할 때 잔액을 정확히 확인할 수 없다면 요청을 실패시키는 편이 낫다. 반대로 소셜 미디어의 좋아요 수나 피드 추천은 잠시 오래된 값을 보여 주더라도 서비스를 계속 제공하는 편이 사용자 경험에 더 나을 수 있다.
CAP 정리는 세 가지 중 항상 두 가지만 고른다는 단순 암기 문장이 아니다. 실제로는 네트워크 분할이 발생했을 때 어떤 기능에서 일관성을 우선할지, 어떤 기능에서 가용성을 우선할지 정하는 판단 도구에 가깝다. 같은 서비스 안에서도 결제, 로그인, 알림, 통계는 서로 다른 선택을 할 수 있다.
장애는 예외가 아니라 정상 흐름이다
단일 프로세스 안의 함수 호출은 보통 성공하거나 즉시 실패한다. 하지만 네트워크 호출은 다르다. 요청이 상대 서버에 도착했는지, 상대가 처리했지만 응답만 유실되었는지, 중간 장비에서 지연되는지 클라이언트가 정확히 알 수 없는 경우가 많다. 그래서 분산 시스템에서는 실패를 예외적 사건이 아니라 항상 발생 가능한 정상 흐름으로 다룬다.
가장 기본적인 장치가 타임아웃이다. 외부 API나 다른 서비스의 응답을 무한정 기다리면 요청을 처리하는 스레드나 커넥션이 계속 묶인다. 작은 지연이 누적되면 전체 서비스 장애로 번질 수 있다. 타임아웃은 기다림의 한계를 정하는 장치다. 너무 짧으면 정상 요청도 실패하고, 너무 길면 장애 전파를 막지 못한다. 호출 대상의 평소 응답 시간과 사용자 요청의 전체 제한 시간을 고려해 정해야 한다.
타임아웃 다음에는 재시도를 생각하게 된다. 일시적인 네트워크 오류나 순간적인 과부하는 다시 시도하면 성공할 수 있다. 하지만 재시도는 조심해야 한다. 이미 상대 서버가 요청을 처리했는데 응답만 실패했을 수도 있다. 이 상태에서 같은 결제 요청을 다시 보내면 중복 결제가 발생할 수 있다. 또한 장애 중인 서버에 모든 클라이언트가 동시에 재시도하면 트래픽이 폭증해 장애를 더 키운다. 재시도에는 횟수 제한, 점진적 대기, 무작위 지연 같은 정책이 필요하다.
이때 중요한 개념이 멱등성이다. 멱등한 요청은 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같다. 주문 생성 API라면 클라이언트가 생성한 요청 ID를 함께 보내고, 서버는 같은 요청 ID가 다시 오면 새 주문을 만들지 않고 기존 결과를 반환할 수 있다. 분산 시스템에서 멱등성은 재시도를 안전하게 만드는 핵심 설계다.
서킷 브레이커는 장애 전파를 끊는다
분산 시스템의 무서운 점은 한 서비스의 장애가 다른 서비스로 빠르게 번진다는 데 있다. A 서비스가 B 서비스를 호출하고, B 서비스가 느려지면 A 서비스의 스레드가 대기한다. A 서비스가 느려지면 A를 호출하는 다른 서비스도 느려진다. 결국 처음 장애가 난 곳보다 훨씬 넓은 영역이 영향을 받는다.
서킷 브레이커는 이런 전파를 막기 위한 패턴이다. 전기 회로에서 과부하가 발생하면 차단기가 내려가듯, 특정 외부 호출의 실패율이나 지연이 기준을 넘으면 잠시 호출을 중단한다. 계속 실패할 요청을 보내며 자원을 낭비하지 않고, 즉시 실패 응답을 주거나 대체 응답을 제공한다.
서킷 브레이커는 보통 닫힘, 열림, 반열림 상태를 가진다. 정상일 때는 닫힘 상태로 요청을 보낸다. 실패가 많아지면 열림 상태가 되어 요청을 차단한다. 일정 시간이 지나면 일부 요청만 시험적으로 보내는 반열림 상태가 된다. 이 요청들이 성공하면 다시 닫히고, 실패하면 다시 열린다.
이 패턴의 목적은 실패를 숨기는 것이 아니다. 실패 범위를 제한하고 시스템이 회복할 시간을 주는 것이다. 추천 서비스에 장애가 나면 기본 인기 상품을 보여 줄 수 있고, 알림 서비스가 느리면 핵심 주문 처리는 계속 진행한 뒤 알림을 나중에 보낼 수 있다. 모든 기능을 같은 중요도로 묶으면 작은 장애가 전체 장애가 된다.
개발자가 판단해야 할 것
분산 시스템의 핵심은 기술 이름을 많이 아는 것이 아니라 선택의 비용을 이해하는 것이다. 서버를 여러 대로 늘리면 처리량은 늘지만 상태 관리가 어려워진다. 복제는 가용성을 높이지만 복제 지연을 만든다. 샤딩은 저장 용량과 처리량을 늘리지만 쿼리와 운영을 복잡하게 만든다. CAP는 장애 상황에서 무엇을 포기할지 묻는다. 타임아웃, 재시도, 멱등성, 서킷 브레이커는 실패가 전파되지 않도록 경계를 만든다.
실무에서 좋은 분산 시스템은 장애가 전혀 없는 시스템이 아니다. 장애가 나도 어디까지 영향을 줄지 예측 가능하고, 일부 기능이 실패해도 핵심 기능은 유지되며, 재시도해도 데이터가 망가지지 않는 시스템이다. 여러 서버가 함께 동작하는 순간부터 개발자는 정상 경로뿐 아니라 지연, 중복, 순서 뒤바뀜, 부분 실패를 함께 설계해야 한다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.