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

이벤트 버스가 깨지는 지점