junyeokk
Blog
Architecture·2024. 11. 08

마이크로서비스 분리

웹 서비스를 처음 만들 때는 보통 하나의 프로세스에 모든 기능을 넣는다. API 서버, 백그라운드 작업, 외부 데이터 수집까지 전부 한 덩어리로 돌아간다. 이것을 모놀리식(Monolithic) 아키텍처라고 한다.

[ 하나의 프로세스 ] ├── API 엔드포인트 ├── 데이터 크롤링 ├── 이메일 발송 ├── AI 처리 └── 스케줄링

이 구조가 잘 동작하는 건 초기 단계뿐이다. 서비스가 커지면 몇 가지 문제가 구체적으로 드러난다.


모놀리식의 한계

장애 전파

크롤러가 외부 API를 호출하다가 예외를 잡지 못하고 프로세스가 죽으면, 같은 프로세스에서 돌고 있던 API 서버도 같이 죽는다. 사용자가 서비스를 이용하는 도중에 크롤러 버그 때문에 전체가 다운되는 것이다.

javascript
[ 하나의 프로세스 ]
├── API 엔드포인트
├── 데이터 크롤링
├── 이메일 발송
├── AI 처리
└── 스케줄링

리소스 경합

AI 요약 작업이 CPU를 100% 잡아먹으면, 같은 프로세스의 API 요청 처리가 느려진다. Node.js는 싱글 스레드이기 때문에 CPU 집약적인 작업이 이벤트 루프를 블로킹하면 모든 요청의 응답 시간이 증가한다.

배포 단위의 문제

크롤러 로직만 수정했는데 API 서버까지 재배포해야 한다. 배포 빈도가 높아질수록 불필요한 다운타임이 생긴다.

스케일링 불가

API 서버에는 트래픽이 많고 크롤러는 한 대면 충분한 상황에서, 모놀리식이면 전체를 같이 스케일링해야 한다. 크롤러는 인스턴스가 여러 개 뜨면 중복 크롤링 문제까지 생긴다.


마이크로서비스란

마이크로서비스 아키텍처는 하나의 큰 애플리케이션을 독립적으로 배포 가능한 작은 서비스들로 분리하는 것이다. 각 서비스는 자체 프로세스에서 실행되고, 네트워크를 통해 통신한다.

[ API 서버 ] ←→ [ 메시지 큐 ] ←→ [ 크롤러 ] ↕ [ 이메일 워커 ]

핵심 원칙은 세 가지다.

  1. 독립 프로세스: 각 서비스가 별도 프로세스로 실행되어, 하나가 죽어도 다른 서비스에 영향 없음
  2. 독립 배포: 크롤러만 수정하면 크롤러만 배포
  3. 단일 책임: 각 서비스가 하나의 도메인만 담당

어떤 단위로 분리할 것인가

분리의 기준은 "이 기능이 죽었을 때 다른 기능도 죽어야 하는가?"이다. 답이 "아니오"면 분리 후보다.

일반적인 분리 기준:

기준예시
장애 격리크롤러 에러가 API에 영향 주면 안 됨
리소스 특성CPU 집약(AI 처리) vs I/O 집약(API)
스케일링 단위API는 수평 확장, 크롤러는 단일 인스턴스
배포 주기자주 바뀌는 부분과 안정적인 부분
팀 경계서로 다른 팀이 담당하는 기능

과도한 분리의 위험

마이크로서비스가 만능은 아니다. 분리할수록 복잡성이 기하급수적으로 증가한다.

  • 서비스 간 통신 오버헤드
  • 분산 트랜잭션의 어려움
  • 디버깅 난이도 증가 (로그가 여러 서비스에 분산)
  • 인프라 관리 비용 증가

2~3인 팀이 10개의 마이크로서비스를 운영하는 건 오버엔지니어링이다. "분리하지 않으면 안 되는 이유"가 명확할 때만 분리하는 것이 올바른 접근이다.


서비스 간 통신 방식

분리된 서비스들은 서로 통신해야 한다. 크게 동기 방식과 비동기 방식이 있다.

동기 통신 (HTTP/gRPC)

한 서비스가 다른 서비스를 직접 호출하고 응답을 기다린다.

javascript
// 크롤러에서 처리되지 않은 예외 발생
async function crawlFeeds() {
  const response = await fetch(externalUrl); // 타임아웃, 네트워크 에러 등
  const data = await response.json();        // 파싱 에러
  // → 프로세스 전체가 죽을 수 있음
}

장점은 구현이 단순하고 결과를 즉시 받을 수 있다는 것이다. 단점은 호출받는 서비스가 다운되면 호출하는 서비스도 영향받는다는 것이다. 서비스 간 강한 결합이 생긴다.

비동기 통신 (메시지 큐)

서비스가 메시지 큐에 메시지를 넣고, 다른 서비스가 꺼내서 처리한다. 서로 직접 알 필요가 없다.

javascript
[ API 서버 ]  ←→  [ 메시지 큐 ]  ←→  [ 크롤러 ]

                  [ 이메일 워커 ]

두 서비스는 메시지 큐를 통해서만 연결되어 있다. 이메일 워커가 다운되어도 메시지는 큐에 쌓여 있다가, 워커가 복구되면 처리된다. 메시지 유실 없이 장애를 견딜 수 있다.

방식결합도장애 전파실시간성구현 복잡도
HTTP 직접 호출강함있음높음낮음
메시지 큐약함없음낮음중간
이벤트 브로커매우 약함없음중간높음

대부분의 경우 메시지 큐 기반 비동기 통신이 마이크로서비스의 장점을 가장 잘 살린다. 즉시 응답이 필요한 경우에만 동기 통신을 사용하면 된다.


프로젝트 구조

마이크로서비스로 분리한다고 해서 반드시 별도 저장소가 필요한 건 아니다. 모노레포(Monorepo) 구조에서도 각 서비스를 독립 프로세스로 실행할 수 있다.

my-project/ ├── server/ # API 서버 │ ├── src/ │ ├── package.json │ └── Dockerfile ├── feed-crawler/ # 크롤러 서비스 │ ├── src/ │ ├── package.json │ └── Dockerfile ├── email-worker/ # 이메일 워커 │ ├── src/ │ ├── package.json │ └── Dockerfile └── docker-compose.yml

각 서비스가 자체 package.jsonDockerfile을 가진다. 의존성이 독립적이므로 크롤러에 라이브러리를 추가해도 API 서버에 영향 없다.

Docker Compose로 로컬 개발

로컬에서 여러 서비스를 동시에 띄울 때 Docker Compose가 유용하다.

yaml
// API 서버에서 크롤러 서비스를 직접 호출
const response = await fetch('http://crawler-service:3001/crawl', {
  method: 'POST',
  body: JSON.stringify({ url: feedUrl }),
});
const result = await response.json();

depends_on으로 서비스 간 시작 순서를 지정한다. 다만 depends_on은 "컨테이너가 시작됨"만 보장하고 "서비스가 준비됨"은 보장하지 않는다. MySQL이 컨테이너는 떴는데 아직 연결을 받을 준비가 안 된 상태에서 서버가 연결을 시도하면 실패한다.

이 문제는 healthcheck를 설정하거나, 애플리케이션에서 재연결 로직을 구현해서 해결한다.

yaml
// API 서버: 이메일 발송 요청을 큐에 넣음
await channel.publish('exchange', 'email.send', Buffer.from(
  JSON.stringify({ to: 'user@example.com', template: 'welcome' })
));

// 이메일 워커: 큐에서 메시지를 꺼내서 처리
channel.consume('email-queue', async (msg) => {
  const { to, template } = JSON.parse(msg.content.toString());
  await sendEmail(to, template);
  channel.ack(msg);
});

공유 코드 처리

서비스를 분리하면 여러 서비스에서 공통으로 사용하는 코드가 생긴다. 엔티티 정의, 유틸리티 함수, 타입 정의 등이다.

방법 1: 공유 패키지

모노레포에서 공유 코드를 별도 패키지로 만들어 각 서비스가 참조한다.

my-project/ ├── shared/ │ ├── entities/ # 공통 엔티티 │ ├── types/ # 공통 타입 │ └── package.json ├── server/ ├── feed-crawler/ └── email-worker/
json
my-project/
├── server/           # API 서버
│   ├── src/
│   ├── package.json
│   └── Dockerfile
├── feed-crawler/     # 크롤러 서비스
│   ├── src/
│   ├── package.json
│   └── Dockerfile
├── email-worker/     # 이메일 워커
│   ├── src/
│   ├── package.json
│   └── Dockerfile
└── docker-compose.yml

방법 2: 코드 복사

공유할 코드가 적고 서비스 간 변경 주기가 다르면, 그냥 복사하는 게 나을 수도 있다. DRY 원칙을 맹목적으로 따르다가 서비스 간 결합이 생기는 것보다, 약간의 중복을 허용하는 게 더 건강한 아키텍처일 수 있다.


데이터 저장소 전략

마이크로서비스 설계에서 가장 중요한 결정 중 하나가 데이터베이스를 어떻게 할 것인가이다.

공유 데이터베이스

모든 서비스가 같은 데이터베이스에 접근한다.

[ API 서버 ] ──→ [ MySQL ] ←── [ 크롤러 ] ↑ [ 이메일 워커 ]

장점은 단순하다는 것이다. 조인이 자유롭고 트랜잭션도 간단하다. 소규모 팀에서 2~3개 서비스를 운영할 때는 이 방식이 현실적이다. 하지만 한 서비스가 스키마를 변경하면 다른 서비스에도 영향을 줄 수 있고, 데이터베이스가 단일 장애 지점이 된다.

서비스별 데이터베이스

각 서비스가 자기만의 데이터베이스를 가진다.

[ API 서버 ] → [ MySQL-1 ] [ 크롤러 ] → [ MySQL-2 ] [ 워커 ] → [ Redis ]

서비스 간 데이터 격리가 완벽하지만, 서비스 간 데이터를 조합하려면 API 호출이나 이벤트를 통해야 해서 복잡성이 크게 증가한다. 대규모 조직에서 서비스 간 경계가 명확할 때 적합하다.

현실적인 선택

팀 규모가 작을 때는 공유 데이터베이스로 시작하고, 서비스 간 스키마 충돌이 실제로 문제가 될 때 분리하는 것이 실용적이다. 처음부터 완벽한 분리를 목표로 하면 개발 속도만 느려진다.


서비스 간 장애 대응

마이크로서비스에서 특정 서비스가 죽었을 때 전체 시스템이 어떻게 동작할지를 미리 설계해야 한다.

Graceful Shutdown

서비스를 종료할 때 진행 중인 작업을 마무리한 후 종료해야 한다. SIGTERM 신호를 받으면 새 요청을 거부하고, 진행 중인 요청이 완료되면 프로세스를 종료한다.

javascript
version: '3.8'
services:
  server:
    build: ./server
    ports:
      - "3000:3000"
    depends_on:
      - mysql
      - redis
      - rabbitmq

  feed-crawler:
    build: ./feed-crawler
    depends_on:
      - mysql
      - redis

  email-worker:
    build: ./email-worker
    depends_on:
      - rabbitmq

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: my_app

  redis:
    image: redis:7-alpine

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "5672:5672"
      - "15672:15672"

헬스 체크

각 서비스에 헬스 체크 엔드포인트를 두어 서비스가 정상인지 확인한다. 오케스트레이터(Docker, Kubernetes)가 이 엔드포인트를 주기적으로 호출하고, 응답이 없으면 서비스를 재시작한다.

javascript
mysql:
  image: mysql:8.0
  healthcheck:
    test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
    interval: 10s
    timeout: 5s
    retries: 5

server:
  depends_on:
    mysql:
      condition: service_healthy

재시도와 Circuit Breaker

서비스 간 호출이 실패했을 때 바로 에러를 반환하는 대신, 재시도를 하거나 Circuit Breaker 패턴을 적용한다.

Circuit Breaker는 연속 실패가 일정 횟수를 넘으면 "회로를 열어서" 일정 시간 동안 호출 자체를 차단한다. 장애 중인 서비스에 계속 요청을 보내서 상황을 악화시키는 것을 방지한다.

javascript
my-project/
├── shared/
│   ├── entities/      # 공통 엔티티
│   ├── types/         # 공통 타입
│   └── package.json
├── server/
├── feed-crawler/
└── email-worker/

HALF_OPEN 상태에서는 한 건의 요청만 통과시켜 서비스가 복구되었는지 확인한다. 성공하면 CLOSED로 돌아가고, 실패하면 다시 OPEN 상태가 된다.


로깅과 모니터링

서비스가 분리되면 로그도 분산된다. 사용자 요청 하나가 API 서버 → 메시지 큐 → 이메일 워커를 거치는데, 문제가 생겼을 때 어떤 서비스에서 실패했는지 추적하려면 모든 서비스의 로그를 한곳에 모아야 한다.

Correlation ID

요청마다 고유 ID를 부여하고, 서비스 간 전달할 때 이 ID를 함께 넘긴다. 로그에 이 ID를 포함시키면 분산된 로그에서 하나의 요청 흐름을 추적할 수 있다.

javascript
// feed-crawler/package.json
{
  "dependencies": {
    "@my-project/shared": "file:../shared"
  }
}

중앙 집중 로깅

각 서비스의 로그를 ELK Stack(Elasticsearch + Logstash + Kibana)이나 Loki + Grafana 같은 도구로 수집한다. 서비스별로 SSH 접속해서 로그 파일을 읽는 건 서비스가 3개만 넘어도 비현실적이다.


모놀리식에서 마이크로서비스로의 전환

기존 모놀리식을 마이크로서비스로 전환할 때 가장 중요한 건 점진적 분리다. 한 번에 모든 걸 분리하려 하면 높은 확률로 실패한다.

단계별 전환 전략

1단계: 경계 식별

코드베이스에서 기능 경계를 찾는다. 모듈 간 의존성이 적은 부분, 독립적으로 동작할 수 있는 부분을 먼저 찾는다.

2단계: Strangler Fig 패턴

새 기능을 별도 서비스로 만들고, 기존 모놀리식의 해당 기능을 점진적으로 새 서비스로 라우팅한다. 이름은 교살 무화과 나무에서 따왔다. 새 서비스가 기존 시스템을 감싸면서 점진적으로 대체하는 모습이 비슷하다.

[클라이언트] → [프록시/라우터] ├── /api/feeds → [새 크롤러 서비스] └── /api/* → [기존 모놀리식]

3단계: 메시지 큐 도입

동기 호출을 비동기 메시지로 전환한다. 서비스 간 직접 호출을 메시지 큐 경유로 바꾸면 결합도가 크게 낮아진다.

4단계: 데이터 분리 (필요시)

서비스별로 데이터 접근 패턴이 확실히 다를 때만 데이터베이스를 분리한다.


마이크로서비스를 도입하지 말아야 할 때

마이크로서비스가 과대평가되는 경우가 많다. 다음 상황에서는 모놀리식이 더 나은 선택이다.

  • 팀이 5명 이하
  • 서비스가 아직 MVP 단계
  • 도메인 경계가 불확실
  • DevOps 역량이 부족 (CI/CD, 모니터링, 컨테이너 운영)
  • 서비스 간 데이터 공유가 빈번

Martin Fowler의 유명한 조언이 있다: "모놀리식으로 시작하라(MonolithFirst)." 모놀리식에서 시작해서 경계가 명확해지면 그때 분리하는 것이 가장 현실적인 접근이다.


정리

  • "이 기능이 죽으면 다른 기능도 죽어야 하는가"가 분리 판단 기준이고, 메시지 큐 기반 비동기 통신이 결합도를 가장 효과적으로 낮춘다
  • 공유 DB로 시작해서 스키마 충돌이 실제 문제가 될 때 분리하고, Strangler Fig 패턴으로 점진적으로 전환한다
  • 5명 이하 팀이나 MVP 단계에서는 모놀리식이 거의 항상 정답이고, Correlation ID와 중앙 로깅은 분리 전에 미리 준비해두면 전환이 수월하다

관련 문서