levish
levish
이전으로

마이크로서비스를 하고 싶어서 나눈 건 아니었습니다

하나의 백엔드로 시작한 V7을 Auth, Region, Media와 Sitelink로 나누며 데이터의 책임과 장애 범위를 정한 과정.

Sevenwiki — The Knowledge Infrastructure

August 31, 2026

개발SevenwikiRustArchitecture

처음에는 당연히 하나의 백엔드였습니다

Sevenwiki의 초기 백엔드는 계정과 문서, 파일, 검색을 모두 한 애플리케이션에서 처리했습니다.

프로젝트를 빠르게 만들 때는 이 방식이 가장 편했습니다. 문서를 저장한 뒤 사용자 정보를 읽고, 파일 상태를 확인하고, 검색 색인을 갱신해도 모두 같은 프로세스 안에서 끝났습니다. 별도의 API 계약이나 이벤트 형식을 만들 필요도 없었습니다.

저도 처음부터 서비스를 여러 개로 나눌 생각은 없었습니다. 웹 프로젝트도 처음이었고, 일단 눈앞의 기능을 만드는 것만으로도 충분히 바빴습니다.

그런데 백엔드를 몇 번 갈아엎고 기능이 늘어나면서 이상한 상황이 하나씩 생겼습니다.

Auth의 Redis가 느려졌는데 공개 문서를 읽는 요청까지 같이 느려졌습니다. 한 Region의 검색 워커가 멈췄는데, 공통 큐를 사용하다 보니 관계없는 작업도 영향을 받았습니다. 파일 원본과 문서의 파일 참조를 같은 데이터처럼 다루자 어느 서비스가 최종 상태를 결정하는지도 애매해졌습니다.

코드를 어느 폴더에 둘지는 정할 수 있었지만, 장애가 났을 때 왜 여기까지 같이 멈춰야 하는지 설명하기 어려웠습니다.

그때부터 프로세스 수가 아니라 데이터의 책임부터 다시 보기 시작했습니다.

서비스 개수보다 두 가지 질문이 먼저였습니다

처음에는 저도 마이크로서비스 자료에 나오는 전형적인 분류를 떠올렸습니다. 사용자 서비스, 문서 서비스, 검색 서비스처럼 기능 이름대로 나누면 그럴듯해 보였습니다.

하지만 기능 이름만으로 나누면 서로의 데이터가 너무 자주 필요했습니다. 문서 서비스가 사용자 DB를 읽고, 검색 서비스가 문서 DB를 직접 읽고, 파일 서비스가 Region 테이블까지 알아야 한다면 프로세스만 여러 개일 뿐 사실상 하나의 애플리케이션입니다.

그래서 각 데이터마다 다음 두 질문을 붙였습니다.

  1. 이 데이터의 최종 상태를 결정하는 곳은 어디인가?
  2. 그곳이 멈췄을 때 어떤 기능까지 멈추는 것이 맞는가?

예를 들어 로그인과 장기 세션은 Auth가 없으면 처리할 수 없습니다. 하지만 이미 발급된 짧은 접근 토큰으로 공개 문서를 읽는 일까지 매번 Auth를 기다릴 필요는 없습니다.

파일의 원본과 변환 결과는 Media가 관리하는 편이 맞습니다. 반면 어느 문서가 그 파일을 사용하고 있는지는 각 Region의 데이터입니다.

Sitelink도 어느 한 Region의 부가기능으로 넣기 어려웠습니다. 서로 다른 사이트의 문서를 연결하는 데이터이므로 한쪽 Region만 최종 결정을 내려서는 안 됐습니다.

이 질문을 따라가며 지금의 네 영역이 나왔습니다.

현재 V7은 크게 네 서비스로 나뉩니다.

서비스맡고 있는 것멈췄을 때 먼저 영향을 받는 기능
Auth계정, 장기 세션, 사용자 정보, 보안 상태로그인, 토큰 갱신, 계정 보안 변경
Region문서, 리비전, ACL, 토론, 검색, 활동 기록해당 사이트의 읽기·편집·검색
Media파일 원본, 변환 결과, 공개 버전, 교체 제안업로드, 변환, 새 파일 버전 공개
Sitelink사이트 사이의 동일 주제 문서 연결연결 생성·수정과 Region 반영

Auth는 Region의 문서 ACL을 모릅니다. 사용자가 누구인지와 현재 보안 상태만 전달합니다. 실제로 그 사용자가 문서를 편집할 수 있는지는 Region이 자신의 ACL로 판단합니다.

Media는 비공개 원본과 변환 상태를 관리합니다. Region에는 필요한 공개 버전과 문서에 연결할 식별자만 전달합니다.

Sitelink는 제목 문자열이 아니라 (site_id, document_id)를 기준으로 문서를 연결합니다. 제목이 바뀌어도 같은 문서의 관계는 유지할 수 있습니다.

각 서비스가 다른 서비스의 PostgreSQL이나 Redis를 직접 읽지 않게 한 것도 같은 이유입니다. 내부 테이블을 공유하기 시작하면 데이터의 책임을 나눴다고 말하기 어렵습니다.

저장소까지 네 개로 쪼개지는 않았습니다

런타임과 데이터베이스를 나눴다고 코드 저장소까지 모두 분리한 것은 아닙니다.

V7은 하나의 Rust 워크스페이스입니다.

V7 workspace
  ├─ auth-server
  ├─ region-server / region-worker
  ├─ media-server
  ├─ sitelink-server
  ├─ 공통 DTO와 domain type
  ├─ network safety
  └─ E2E와 migration

같이 변경하고 같이 검사해야 하는 타입이 많았기 때문입니다. 서비스 간 이벤트 구조와 OpenAPI 모델, 공통 오류 형식이 한 저장소에 있으면 한 번의 CI에서 어긋난 변경을 찾기 쉽습니다.

반면 실제 실행 환경과 데이터베이스는 분리합니다.

같은 Rust workspace
          │
          ├─ Auth DB / Redis
          ├─ Region DB / Redis / Meilisearch
          ├─ Media DB / Object Storage
          └─ Sitelink DB

같은 VPS에 배포하더라도 각자 다른 Docker Compose 프로젝트와 네트워크를 사용합니다. 한 서버에서 실행된다는 이유로 다른 서비스의 DB 주소를 넘겨주지는 않습니다.

저에게 모노레포와 모놀리식 런타임은 같은 말이 아니었습니다. 함께 컴파일할 코드는 모으고, 함께 실패하면 안 되는 데이터는 나눴습니다.

비동기 작업도 한 바구니에 넣지 않았습니다

서비스를 나누면서 비동기 작업의 종류도 다시 보게 됐습니다.

문서를 저장한 뒤 링크와 include를 분석하는 일, 검색 색인을 갱신하는 일은 같은 Region 안에서 시작하고 끝납니다. 문서 변경과 작업 등록을 같은 PostgreSQL 트랜잭션에 넣을 수 있습니다.

반면 Auth의 세션 폐기를 Region에 알리거나, Media가 새 공개 버전을 각 사이트에 전달하는 일은 서로 다른 데이터베이스 사이를 건넙니다.

처음에는 둘 다 비동기 작업이니 JetStream 하나로 처리하면 깔끔할 것 같았습니다. 하지만 실제 실패 방식은 전혀 달랐습니다.

같은 Region 안에서 끝나는 일
  → PostgreSQL 작업 큐

다른 서비스의 데이터가 바뀌어야 하는 일
  → Transactional Outbox + JetStream

한 Region 안의 작업은 원본 변경과 같은 DB에 작업 행을 남깁니다. 서비스 사이의 이벤트는 원본 DB의 outbox에 먼저 기록한 뒤 JetStream으로 전달합니다.

기술을 하나로 통일하는 것보다, 어느 커밋과 함께 작업을 잃지 않아야 하는지를 맞추는 편이 더 중요했습니다.

중앙 서비스가 멈춰도 이미 받은 데이터는 쓰고 싶었습니다

서비스를 나눈 뒤에도 Region이 매 요청마다 Auth, Media와 Sitelink를 호출한다면 장애 범위는 크게 달라지지 않습니다. 네트워크 구간만 늘어날 뿐입니다.

그래서 Region은 문서를 보여주는 데 필요한 일부 데이터를 로컬에 저장합니다.

  • Auth가 발급한 짧은 접근 토큰을 로컬 보안 정보와 함께 검증합니다.
  • Media가 공개한 파일 버전을 전달받아 문서 렌더링에 사용합니다.
  • Sitelink의 연결 정보를 받아 해당 문서 화면에서 바로 읽습니다.

Auth가 잠시 멈추면 새 로그인과 토큰 갱신은 어렵습니다. 그렇다고 이미 발급된 토큰으로 읽던 문서까지 바로 닫을 필요는 없습니다.

Media가 멈추면 새 업로드와 변환은 중단될 수 있습니다. 하지만 이미 공개되어 Region에 반영된 파일까지 사라져서는 안 됩니다.

Sitelink도 새 연결은 만들지 못하더라도, 기존에 전달받은 연결은 계속 보여줄 수 있습니다.

대신 새로운 문제가 생깁니다.

Region이 가진 데이터가 정말 최신인가?

동기 호출을 줄이는 대신 이벤트의 순서와 중복, 누락, 스냅샷 복구를 직접 다뤄야 했습니다.

백업에서 복구했다고 끝나는 게 아니었습니다

데이터베이스를 서비스별로 나누면 백업도 각각 할 수 있습니다. 여기까지만 보면 복구도 쉬워 보입니다.

하지만 Region DB를 어제로 복구했는데 JetStream의 ACK 위치는 오늘이라면 어떻게 해야 할까요? Auth DB가 더 과거로 돌아가 Region이 Auth보다 높은 보안 버전을 기억하고 있다면요? Media는 이전 파일 버전을 현재라고 생각하는데 Region에는 복구 전의 새 버전이 남아 있을 수도 있습니다.

단순히 각 DB가 정상적으로 켜졌다는 것만으로 전체 상태가 맞지는 않았습니다.

V7에서는 원본 서비스가 변경 순서와 recovery_generation을 관리합니다. 수신 서비스는 마지막으로 빠짐없이 반영한 버전을 저장합니다.

같은 복구 세대에서 일부 이벤트만 빠졌다면 변경 내역 API로 채웁니다. 보존 기간을 넘겼거나 복구 세대가 바뀌었다면 스냅샷으로 조회용 데이터를 다시 만듭니다.

서비스를 나눈 뒤 가장 어려웠던 부분은 요청을 어느 주소로 보낼지가 아니었습니다. 서로 다른 시점으로 돌아간 데이터베이스가 다시 같은 현재를 보게 만드는 일이었습니다.

분리하고 나니 할 일이 더 많아졌습니다

물론 이 구조가 공짜는 아닙니다.

  • 서비스 사이의 인증
  • 서명 키와 NATS 자격 증명 교체
  • outbox 전달 워커와 이벤트 수신 워커
  • 이벤트 중복과 순서 확인
  • Region에 저장한 데이터의 최신 상태 확인
  • 변경 내역과 스냅샷 API
  • 서비스별 배포 순서와 스키마 호환성

하나의 애플리케이션이었다면 없어도 됐을 코드가 꽤 많이 생겼습니다.

그래서 저는 V7이 마이크로서비스라는 사실 자체를 장점으로 보지는 않습니다. 다음 질문에 답하지 못하는 분리는 오히려 다시 합치는 편이 낫다고 생각합니다.

  • 데이터의 최종 책임자가 분명한가?
  • 장애가 번지는 범위가 실제로 줄었는가?
  • 중앙 호출 대신 로컬 데이터를 둘 이유가 충분한가?
  • 이벤트를 놓쳐도 원본에서 복구할 수 있는가?
  • 운영자가 어디까지 반영됐는지 볼 수 있는가?

V7의 네 서비스는 처음부터 멋진 다이어그램을 그려놓고 만든 결과가 아닙니다. 여러 번 구조를 갈아엎으며 이 질문에 답하다 보니 남은 구분입니다.

다음 글에서는 그중 가장 먼저 문제가 보였던 Auth와 Region 사이의 인증 이야기를 해보겠습니다. 로그인 서버를 매 요청마다 부르지 않으면서도, 로그아웃과 계정 정지를 제대로 반영하려면 어떻게 해야 했는지에 대한 이야기입니다.

시리즈 05 / 12
© 2026 levish