3-Tier로 나누면 계층마다 따로 늘리고 따로 고칠 수 있는 경계가 생긴다. 그 경계를 실제로 써서 App을 두 대로 늘리면 프로세스 하나 안에 숨어 있던 세션·캐시·이벤트 버스가 밖으로 나와야 하고, DB를 나누면 바로 업데이트 하고 읽는 전제가 사라진다. 3-Tier가 이 중 어디까지 해주고 어디서부터는 따로 해야 하는지 한번 알아보자.
3-Tier 아키텍처
3-Tier 아키텍처는 서비스를 Presentation(웹 서버, 정적 자원, TLS 종료), Application(비즈니스 로직, 여기서는 FastAPI), Data(관계형 DB) 세 계층으로 나누고 계층 사이를 네트워크로 잇는 구조다. 계층마다 지표를 따로 보고, 따로 재시작하고, 따로 늘릴 수 있다는 장점이 있다.
이 글에서 말하는 확장은 서버 대수를 늘리는 것만이 아니다. App을 여러 대로 늘리는 것, 한 대가 죽어도 나머지가 버티는 것, DB 읽기를 나눠 받는 것, 응답 밖에서 일을 처리하는 것까지 포함해서 본다. 3-Tier가 이 중 어디까지 해주고 어디서부터는 따로 해야 하는지 한번 알아보자.
처음에는 웹 서버, FastAPI, DB가 한 대에 들어 있었다. 느려지면 어디가 느린지 구분이 안 되고, 하나를 고치려고 재시작하면 나머지도 같이 멈춘다. 그래서 서버의 성능을 확충하기 시작했지만, 한 대의 성능을 계속 키우는 데에는 한계가 있었다(수직 확장, Vertical Scaling). 이때 Web / App / DB를 별도의 계층으로 분리하면 각 계층을 독립적으로 배포하고 확장할 수 있는 경계가 생긴다. 특히 상태를 갖지 않는 Application 계층은 여러 인스턴스로 복제하기 쉽다. 한 대의 성능을 계속 키우는 대신 같은 역할의 인스턴스를 여러 대 두면 필요한 계층만 수평으로 확장할 수 있다(수평 확장, Horizontal Scaling). 계층별 지표와 계층별 재시작을 얻었고, 대신 계층 사이에 네트워크가 생겼다.
App은 더 큰 서버로 바꿔도 교체 때마다 서비스를 내려야 하니, 같은 App을 여러 대 두는 쪽으로 간다. 사용자는 주소 하나로 접속하므로 앞단에 Load Balancer가 필요하고, 이때부터 인스턴스들이 서로를 모른다는 문제가 생긴다. 세션은 App 밖 공용 저장소로 빼거나 서명한 토큰(JWT)을 클라이언트가 들고 다니게 해서 해결하지만, 전자는 저장소가 새 의존성이 되고 후자는 발급한 토큰을 만료 전에 무효화하기 어렵다.
flowchart LR
U[사용자] --> W[Web 서버<br/>Presentation]
W --> A[App 서버<br/>Application]
A --> D[(DB<br/>Data)]LB(Load Balancer)란?
같은 App을 여러 대 두면 필요에 따라 인스턴스를 넣고 버리고 할 수 있다. 대신 "내가 유일한 프로세스"라는 가정이 더는 맞지 않는다. 로컬 캐시, 인메모리 세션, in-process 이벤트 버스가 그 가정 위에서 돌아가고 있었다.
LB는 요청을 순서대로 돌리거나, 연결이 적은 서버로 보내거나, 같은 사용자를 같은 서버로 보내는 식으로 나눈다. 분배 말고도 각 서버에 health check를 보내 응답 없는 서버를 목록에서 빼고, 바깥에는 주소 하나만 보이게 하며, TLS 종료 같은 공통 처리를 맡는다.
App 1이 죽었는데 LB가 계속 요청을 보내면 일부 사용자가 에러를 본다. Health check로 죽은 대상을 빼고 App을 최소 두 대 유지하면 한 대가 죽어도 서비스는 살아 있다. 단, 죽는 순간 그 안에서 돌던 작업은 같이 사라진다.
여기까지가 보통 설명하는 3티어 구성이다. 이 중 "인스턴스가 서로를 모른다"는 문제는 세션 저장소를 빼는 것으로 해결된 것으로 보는 경우가 많은데, 서버 안에 있던 상태는 세션 말고도 캐시와 이벤트 버스가 더 있다.
App 분리에서 생기는 한계
이전 생산성 앱 개발에서 FastAPI 모놀리식에 Domain Event와 in-process Event Bus를 붙였다. 할일이 바뀌면 일정이 따라 바뀌고, 그 전파를 서비스가 서로를 직접 호출하는 대신 이벤트를 발행해 관심 있는 서비스가 처리하는 구조였다.
그런데 그 글에는 넘어간 전제가 있다. 단일 프로세스, 단일 DB, 요청당 단일 세션. 이벤트를 모으는 곳도, 핸들러가 도는 곳도, 커밋되는 트랜잭션도 전부 같은 프로세스 안의 같은 메모리였다. 그래서 다른 프로세스가 상태 변경을 참조하는 경우가 애초에 존재하지 않았다.
그렇다면 이 서비스를 수평 확장할 수 있을까? 지금 구조에서는 상당히 껄끄럽다. 이 서비스가 서버 한 대에서 출발해 App 두 대로 늘어나는 과정을 3-Tier 관점에서 따라가 보자. 단순 개념 속에서 3티어 아키텍처는 Presentation / Application / Data로 나누고 끝나는데, 이벤트 버스는 App 서비스 간에 공유가 이루어지지 못한다. 분명 3티어 구조는 수평 확장을 매우 용이하게 한다고 배웠지만, 프로세스 안에 숨어 있던 상태 공유가 프로세스 경계 밖으로 드러난다.
이벤트 버스와 이벤트 큐
이벤트는 "마감이 바뀌었다"처럼 이미 일어난 사실을 알리는 메시지다. 여기서는 이해를 위해 이벤트 버스를 방송(fan-out, Pub/Sub) 모델로, 이벤트 큐를 줄 서기(work queue) 모델로 나눠 정의한다. 실제 제품은 둘을 여러 방식으로 조합한다. 방송은 구독한 쪽이 전부 받으므로 캐시를 지우거나 화면을 갱신하는 것처럼 여러 곳이 각자 반응해야 할 때 쓰고, 줄은 한 이벤트를 한 곳만 처리하므로 알림 발송처럼 한 번만 실행되어야 하는 작업에 쓴다.
flowchart LR
subgraph F[이벤트 버스: 방송]
E1[마감 변경] --> B1((버스))
B1 --> X1[일정 갱신]
B1 --> X2[캐시 갱신]
B1 --> X3[화면 갱신]
end
subgraph Q[이벤트 큐: 줄]
E2[마감 변경] --> Q1[[큐]]
Q1 --> W1[한 곳이 꺼내 알림 발송]
end이벤트 버스가 깨지는 지점
이전 이벤트 버스 구현 구조는 다음과 같다. 할일 서비스가 일정 서비스를 직접 호출하던 것을, 변경을 이벤트로 한 번 발행하고 관심 있는 쪽이 각자 구독하는 방식으로 바꾼 것이다.
flowchart LR
subgraph B[이전: 직접 호출]
T0[할일 변경] --> S0[일정 서비스 호출]
T0 --> N0[알림 서비스 호출]
T0 --> C0[캐시 갱신 호출]
endflowchart LR
subgraph A[이후: 이벤트 버스]
T1[할일] <-->|발행 / 구독| BUS((Event Bus))
BUS <-->|발행 / 구독| S1[일정]
BUS <-->|구독| N1[알림]
BUS <-->|구독| C1[캐시]
end이 그림의 Event Bus는 지금은 서버 한 대의 메모리 안에서 동작하는 파이썬 객체라서, 같은 서버 안의 구독자에게만 전달할 수 있다. 서버가 두 대가 되면 두 서버가 같이 쓸 수 있는 것으로 바꿔야 한다.
모두에게 vs 한 명에게
이 프로젝트의 이벤트를 나눠서 보면 성격이 조금 다르다.
방송 쪽에는 캐시 갱신과 WebSocket 푸시가 있다. 할 일 목록을 App 메모리에 캐시해 두었다면 App 1에서 바뀐 마감이 App 2에 전달되기 전까지 두 App이 다른 값을 보여줄 수 있고, 휴대폰에서 바꾼 마감을 데스크톱 화면에 바로 반영하려면 WebSocket으로 변경을 전달해야 한다. 다만 이런 이벤트는 한두 번 놓쳐도 큰 문제가 생기지는 않는다. 캐시는 몇 초 뒤 만료되고, 화면은 다음 동기화 때 다시 맞춰진다.
반면 줄 쪽에 있는 마감 임박 알림이나 외부 캘린더 동기화는 이야기가 다르다. 알림을 한 번 놓치면 아예 발송되지 않을 수 있고, 같은 이벤트를 두 번 처리하면 알림이 두 번 가거나 동기화가 중복될 수 있다. 그래서 이쪽은 이벤트가 빠지지 않는 것뿐 아니라 중복 처리까지 고려해야 한다.
결국 같은 이벤트라도 필요한 보장이 다르다. 이걸 구분하지 않고 전부 같은 방식으로 처리하면 필요 이상으로 복잡한 구조를 만들게 된다. 무엇을 놓쳐도 되는지, 중복되어도 되는지를 먼저 보고 그에 맞춰 전달 방식을 정하는 게 맞다고 생각한다.
해결 방안
앞 그림의 Event Bus 자리에 무엇을 둘지 정해보자.
Redis Pub/Sub와 Streams
세션 저장소로 Redis를 이미 쓰고 있다면 가장 가까운 선택지다. Pub/Sub은 발행하면 그 순간 연결된 구독자에게만 전달되고 저장하지 않는다. 구독자가 끊겨 있는 동안의 메시지는 저장되지 않아 나중에 받을 수 없으므로, 캐시 무효화나 WebSocket 푸시에는 쓸 만하고 알림 발송에 쓰기는 어렵다.
flowchart LR
A1[App 1] -->|방송 발행| R[(Redis)]
R -->|Pub/Sub: 즉시 전달, 저장 안 함| A2[App 2]
R -->|Pub/Sub| A3[App 3]
A1 -->|줄에 넣기| R
R -->|Streams| W[App 중 한 대]Streams는 저장되는 로그다. consumer group으로 여러 App이 나눠 읽고, 처리 확인(ack) 전까지는 미처리 목록(pending entries)에 남으며, 죽은 App의 메시지를 넘겨받으려면 그 목록에서 가져오는 처리(claim)를 따로 넣어야 한다. 두 용도를 같은 Redis에 두면 운영은 단순하지만 Redis 하나가 세션과 이벤트를 동시에 잃는 장애점이 된다.
RabbitMQ
발행자는 exchange에 보내고 exchange가 규칙에 따라 큐로 라우팅한다. fanout이면 바인딩된 모든 큐에 복사되고 direct면 라우팅 키로 고르므로, 방송과 줄을 설정으로 나눌 수 있다. 큐에서 꺼낸 메시지는 ack 전까지 브로커가 보관하고, 계속 실패한 메시지를 따로 모아두는 큐(dead-letter queue)도 기본 설정으로 있다.
flowchart LR
P[App 1] --> X1{{fanout exchange}}
X1 --> Q1[queue: app2] --> A2[App 2]
X1 --> Q2[queue: app3] --> A3[App 3]
P --> X2{{direct exchange}}
X2 --> Q3[queue: reminders] --> W[App 중 한 대]
Q3 -.nack 초과 / TTL.-> D[dead-letter queue]큐에 들어간 순서는 유지되지만, 소비자가 처리를 끝내는 순서는 별개 문제다. ack된 메시지는 제거되고 ack 전에 소비자가 죽으면 다시 전달되며, 제거된 뒤에는 되감기가 없다. App 두세 대 규모의 작업 큐로는 운영 부담이 크지 않다.
Kafka
파티션된 append-only 로그다. 할일 ID로 파티션을 정하면 같은 할일의 이벤트는 같은 파티션에 순서대로 쌓이고, 한 소비자 그룹 안에서 파티션은 한 소비자에게만 배정되므로 파티션 안의 로그 순서는 유지된다. 다만 이건 로그에 쌓인 순서이고, 소비자가 처리를 끝내는 순서나 DB에 반영되는 순서까지 같아지지는 않는다. 오프셋을 소비자가 관리하니 보존 기간 안에서는 다시 읽을 수 있고, 처리 대기 중인 메시지 수(consumer lag)도 기본으로 보여준다.
flowchart LR
P[App 1] -->|key = todo_id| T
subgraph T[topic: todo-events]
P0[partition 0]
P1[partition 1]
end
P0 -->|순서 보장| C1[consumer A]
P1 -->|순서 보장| C2[consumer B]
C1 -.offset / lag.-> M[(모니터링)]대신 클러스터 운영이 따로 있고, 한 소비자 그룹 안에서는 파티션 수가 병렬도의 상한이 된다. App 두 대, 초당 수십 건 정도인 이 프로젝트에는 과해 보인다.
AWS 관리형 서비스
SQS는 큐다. 기본 큐는 확인될 때까지 다시 보내지만 순서는 보장하지 않고, FIFO 큐는 그룹 단위 순서와 중복 제거를 주는 대신 처리량 상한이 있다. 받아간 메시지를 일정 시간 안에 처리 확인하지 않으면 다시 보이게 하고, 실패가 반복된 메시지는 별도 큐(DLQ)로 보낸다. SNS는 방송이다. 토픽에 발행하면 구독한 SQS 큐나 Lambda로 복사되므로, SNS 토픽에 SQS 큐 여러 개를 구독시키면 방송과 줄을 한 번에 만들 수 있다.
flowchart LR
P[App 1] --> SNS[SNS topic]
SNS --> Q1[SQS: app2 invalidate] --> A2[App 2]
SNS --> Q2[SQS: app3 invalidate] --> A3[App 3]
SNS --> Q3[SQS FIFO: reminders] --> W[App 중 한 대]
Q3 -.maxReceiveCount 초과.-> D[SQS DLQ]Transactional Outbox 패턴
그렇다면 굳이 카프카 같은 서비스를 만들지 말고 존재하는 DB를 App 서버가 정보를 공유하는 매개체로 쓰면 되는 거 아닐까? 우린 이 방식을 Transactional Outbox 패턴이라고 부른다. 구조는 아래와 같다.
flowchart LR
A1[App 1] -->|도메인 변경 + INSERT events<br/>한 트랜잭션| DB[(DB<br/>todos / schedules / events)]
A2[App 2] -->|도메인 변경 + INSERT events| DB
DB -->|SELECT ... SKIP LOCKED| W1[App 1 워커 루프]
DB -->|SELECT ... SKIP LOCKED| W2[App 2 워커 루프]발행은 도메인 변경과 같은 트랜잭션에서 이벤트 테이블에 한 행을 넣는 것이고, 수신은 각 App의 워커가 다른 App이 잡지 않은 행을 가져가 처리한 뒤 표시하는 것이다. 변경과 이벤트 기록이 한 트랜잭션에 묶이므로 변경은 커밋됐는데 이벤트가 없거나 이벤트는 있는데 변경이 롤백된 상태가 생기지 않고, 새로 들일 인프라도 없다. 브로커를 쓰더라도 발행 쪽은 이 테이블을 그대로 두고 릴레이 하나가 읽어 브로커로 넘기는 형태로 남는다.
비용은 수신이 폴링이라는 점이다. 폴링 주기만큼 지연이 생기고 App 수와 폴링 빈도만큼 쿼리가 DB로 가며, 한 이벤트를 한 App이 가져가는 큐 모델이라 방송처럼 모든 구독자가 같은 이벤트를 받아야 하면 구독자별 처리 위치를 관리하거나 이벤트를 구독자별로 복제해야 한다. App 몇 대, 초당 수십 건 규모의 줄 작업에는 이것만으로 충분한 경우가 많고, 규모가 커지면 폴링 대신 DB 변경 로그를 읽는 CDC(Debezium 같은 도구)를 릴레이로 쓴다.
메시지 서비스와 새로 생기는 오버헤드
프로세스 경계를 넘어야 하는 이벤트는 App 밖의 메시지 브로커로 보낸다. 발신자는 브로커에 발행만 하고, 각 App은 브로커를 구독해 핸들러를 돌린다. 다만 함수 호출에서는 생각할 일이 없던 실패 상황이 메시지에서는 몇 가지 생긴다.
함수 호출은 성공 아니면 예외지만, 메시지에는 "보냈는데 도착했는지 모름"이 있다. 다시 보내면 중복이 생기고 안 보내면 유실이 생기니, 한 번만 보내고 잊거나 확인될 때까지 다시 보내거나 둘 중 하나를 고르게 된다. 작업 큐(work queue) 쪽 이벤트는 다시 보내는 쪽을 고르고, 대신 같은 이벤트를 두 번 받아도 결과가 같도록(멱등하게) 핸들러를 만든다. 이벤트에 식별자를 두고 수신 측이 처리 기록을 남기는 방식이다. 알림 발송 같은 외부 호출은 되돌릴 수 없어서, 외부 API가 멱등 키를 받으면 중복 방지를 그쪽에 맡기고 아니면 발송 기록으로 줄이는 정도다. 그래도 기록을 남기는 커밋과 외부 호출 사이의 틈은 남는다.
브로커는 DB 트랜잭션에 참여하지 않는다. 발행 후 커밋이 실패하면 롤백된 변경의 이벤트가 나가고, 커밋 후 발행 전에 죽으면 이벤트가 사라진다. 이 틈을 메우는 방법이 앞의 Transactional Outbox다. 순서는 별개 문제다. 큐는 넣은 순서대로 꺼내지만 꺼내 가는 App이 여러 대면 처리가 끝나는 순서는 뒤집힐 수 있다. 마감을 10일로, 곧바로 12일로 바꾼 두 이벤트를 App 1과 App 2가 하나씩 가져갔는데 App 2가 먼저 끝내면 일정은 10일에 머문다. 같은 할일의 이벤트가 순서대로 처리되어야 하면 순차 처리되는 단위(같은 파티션이나 한 소비자만 읽는 큐)에 배치하거나, 이벤트에 버전 번호를 넣어 수신 측이 오래된 것을 버리게 한다.
브로커와 릴레이가 새 장애점으로 추가되고, 응답 시간만 보던 지표에 처리 대기 중인 메시지 수와 실패해서 따로 빠진 메시지 수가 붙는다. 응답은 빠른데 뒷처리가 밀리는 상태가 새로 생기므로, 이 지표가 없으면 분할 전보다 원인 분리가 어려워진다.
DB 분리에서 생기는 한계
DB가 한 대면 그 한 대가 죽는 순간 서비스 전체가 멈추니, Primary 한 대에 쓰고 복제본이 그걸 복사받는 구조로 나눈다. 여기서는 읽기 요청을 받는 복제본을 Replica, Primary가 죽었을 때 그 자리로 올라갈 수 있는 복제본을 Standby로 구분해 정의한다. 하나가 두 역할을 같이 할 수도 있지만 목적이 다르니 따로 본다.
flowchart LR
A[App] -->|쓰기| P[(Primary)]
A -->|읽기| R[(Replica)]
P -->|복사, 약간 늦게 도착| R
P -->|복사| S[(Standby)]
P -.죽으면.-> F[Standby가 Primary로 승격]나누고 나면 "쓰면 바로 읽힌다"가 더 이상 당연하지 않다. Replica는 복사가 도착할 때까지 예전 값을 보여주므로 마감을 바꾸고 새로고침했는데 예전 마감이 보이는 상황이 생기고, 승격하는 동안에는 쓰기가 잠깐 막힌다. 그래서 App은 어느 읽기를 Primary에서 하고 어느 읽기를 Replica에서 할지 정해야 한다.
해결 방안
복제본이 받았다고 답할 때까지 커밋을 기다리는 동기 복제(synchronous replication)는 지연이 없는 대신 쓰기가 느려지고 복제본이 멈추면 쓰기도 멈춘다. 기다리지 않는 비동기 복제(asynchronous replication)는 빠른 대신 지연이 생긴다. 보통은 비동기를 쓰고 지연을 App 쪽에서 다룬다.
Read/Write Splitting
쓰기는 Primary로, 읽기는 Replica로 보낸다. 쓴 직후의 읽기가 그 쓰기를 반영해야 한다는 요구(read-after-write consistency)는 쓸 때 세션에 시각을 적어두고 그 뒤 몇 초간의 읽기를 Primary로 보내거나, Replica가 그 쓰기까지 따라왔는지 확인한 뒤 읽게 해서 맞춘다.
flowchart LR
A[App] --> R{쓰기이거나<br/>방금 쓴 사용자?}
R -->|예| P[(Primary)]
R -->|아니오| S[(Replica)]
P -->|복사| SMonotonic Reads
사용자가 한 번 본 상태보다 오래된 상태를 다시 보지 않게 하는 것이다. Replica가 여러 대면 대마다 지연이 달라서 한 번 본 값이 다음 새로고침에서 예전 값으로 돌아갈 수 있다. 같은 사용자를 같은 Replica에 고정하는 것이 가장 단순하지만 Replica가 바뀌면 고정도 깨지므로, 사용자가 마지막으로 본 복제 위치를 기억해 두고 그 위치 이상을 반영한 Replica로만 보내는 방식도 있다.
flowchart LR
U[사용자] -->|마지막으로 본 위치 = 120| C{위치 120 이상 반영?}
C -->|Replica 1: 125| S1[(Replica 1)]
C -.Replica 2: 110, 제외.-> S2[(Replica 2)]Bounded Staleness
Replica의 복제 지연이 허용 범위를 넘으면 그 Replica를 읽기 대상에서 뱼는 방식이다. 중요한 것은 Replica가 Primary의 어느 시점까지 반영했느냐이고, 범위를 넘은 동안에는 읽기를 Primary나 다른 Replica로 돌린다.
flowchart LR
M[Replica 지연 확인] --> C{허용 범위 이내?}
C -->|예| S[(읽기 대상 유지)]
C -->|아니오| P[(읽기 대상에서 제외)]승격 자동화
Primary가 죽으면 Standby를 Primary로 올리는 것이 failover다. 관리형 DB(AWS RDS Multi-AZ)는 감지, 승격, 접속 주소 전환을 대신하고, 직접 운영하면 Patroni 같은 도구가 승격을, PgBouncer나 ProxySQL 같은 프록시가 주소 전환을 맡는다. 예전 Primary가 살아 있어 둘 다 쓰기를 받는 상황(split-brain)을 막기 위해 승격 전에 예전 Primary의 쓰기를 막는 절차(fencing)가 들어간다. App에는 승격 중 실패한 쓰기를 다시 시도하는 일이 남고, 다시 시도해도 결과가 같게 만드는 건 App 쪽에서 챙겨야 한다.
flowchart LR
A[App] --> X[프록시]
X --> P[(Primary)]
P -.죽으면.-> F[감지 → 예전 Primary 쓰기 차단 → Standby 승격]
F -->|주소 전환| X최종 아키텍처
flowchart TB
U([사용자])
subgraph P[Presentation]
LB{{Load Balancer}}
end
subgraph A[Application]
A1[App 1]
A2[App 2]
AN[App n]
end
subgraph M[App들이 공유하는 인프라]
S[(Session Store)]
B((Pub/Sub))
Q[[Work Queue]]
end
subgraph D[Data]
X[프록시 / 읽기·쓰기 분기]
PDB[(Primary)]
RDB[(Replica)]
SDB[(Standby)]
X -->|쓰기, 방금 쓴 사용자| PDB
X -->|읽기| RDB
PDB -->|복사| RDB
PDB -->|복사| SDB
end
U --> LB
LB --> A1
LB --> A2
LB -.-> AN
A --> S
A --> B
A --> Q
A --> XSession Store, Pub/Sub, Work Queue는 요청 처리 경로에 끼는 네 번째 계층이 아니라 App 인스턴스들이 같이 쓰는 인프라다. App은 DB와 이 인프라에 각각 직접 붙고, 어느 쪽에도 상태를 두지 않는다. 세 역할을 어느 제품으로 채울지는 앞의 해결 방안 중 하나를 고르는 문제고, Data의 분기 규칙과 승격 자동화도 같다.
결론
3티어로 나눈 뒤 얻은 것은 세 가지였다. 웹, App, DB 지표가 따로 보여서 어디가 느린지 바로 알 수 있고, App만 따로 늘릴 수 있고, App 한 대가 죽어도 나머지가 멈추지 않는다.
다만 나누는 것으로 끝나지 않았다. App이 두 대가 되자 App 안에 있던 세션, 캐시, 이벤트 버스를 밖으로 빼야 했고, 그중 이벤트 버스는 어떤 메시지 서비스를 쓸지, 유실과 중복을 어떻게 다룰지, 순서를 어디까지 보장할지를 정해야 했다. DB를 Primary와 Replica로 나누자 어느 읽기를 어디로 보낼지, 승격 중의 실패를 어떻게 처리할지가 추가됐다. 교과서의 3티어 그림에는 이런 결정이 나오지 않는다. 확장 자체는 분리로 가능해졌지만, 실제로 쓰려면 이 결정들을 하나씩 내려야 했다. 3티어가 만든 것은 확장 자체라기보다 확장할 수 있는 경계였고, 그 경계를 실제로 쓰기 시작하면서 분산 시스템의 문제가 나타났다.


