← Posts

Architecture · Microservices · Backend

나누면 무엇이 달라지나

배포·장애·확장 단위를 갈라놓는 대신 네트워크가 끼어든다. 얻는 것과 새로 지는 비용을 나란히 놓는다.

목차
  1. 같은 한 줄인데 의미가 달라졌다
  2. 얻는 것: 배포가 갈린다
  3. 얻는 것: 확장 단위가 갈린다
  4. 얻는 것: 장애 범위가 갈린다
  5. 대가 하나: 호출이 실패할 수 있게 된다
  6. 대가 둘: 데이터가 갈린다
  7. 대가 셋: 무슨 일이 있었는지 보기 어려워진다
  8. 대가 넷: 바꾸는 데 조율이 필요해진다
  9. 어디서 자르나
  10. 실무에서는 통째로 안 나눈다
  11. 정리

주문 코드가 재고를 부르던 그 한 줄이 그대로 남았는데, 어느 날부터 실패하기 시작했다.

같은 한 줄인데 의미가 달라졌다

주문을 만들 때 재고를 줄이는 코드가 있었다.

java
inventoryService.decrease(productId, quantity);

한 덩어리였을 때 이 줄은 메서드 호출이었다. 재고를 서비스로 떼어낸 뒤에도 코드 모양은 거의 그대로다. 안쪽이 HTTP 클라이언트로 바뀌었을 뿐이다.

java
inventoryClient.decrease(productId, quantity);   // 안에서 HTTP를 쏜다

모양은 같은데 일어날 수 있는 일이 완전히 달라졌다.

diagramdiagram

메서드 호출은 성공하거나 예외를 던진다. 네트워크 호출은 제3의 결과가 있다. 응답이 안 온 것이다. 이때 상대가 일을 안 한 건지, 했는데 답만 못 준 건지 부르는 쪽은 구별할 수 없다.

이 한 줄의 변화가 나머지 편들이 다루는 문제의 뿌리다. 먼저 그 대가를 치르고 무엇을 얻는지부터 본다.

얻는 것: 배포가 갈린다

앞 편의 세 가지 단위 중 첫째다. 서비스마다 저장소·빌드·배포가 따로 돌아간다.

결제 문구를 고치면 결제 서비스만 다시 올린다. 주문은 그대로 돌아간다. 배포 대기열이 사라지고, 한 번에 나가는 변경 덩어리가 작아진다. 변경이 작으면 문제가 났을 때 원인 후보도 작다.

참고

배포를 안전하고 빠르게 만드는 방법은 이 시리즈의 주제가 아니다(컨테이너CI/CD가 다룬다). 여기서는 “배포 단위가 갈렸다”는 사실과 그 결과만 다룬다.

얻는 것: 확장 단위가 갈린다

상품 조회만 바쁘면 상품 서비스만 늘린다. 결제는 한 대로 둔다. (늘린다는 것 자체가 무엇을 전제하고 무엇을 새로 만드는지는 수평 확장 글이 다룬다.)

diagramdiagram

의미가 자원 절약만은 아니다. 기능마다 필요한 게 다르다는 걸 인정할 수 있게 된다. 이미지 처리는 CPU를 많이 쓰고, 조회는 메모리 캐시가 중요하고, 배치는 오래 도는 게 정상이다. 한 프로세스에 있으면 이 셋이 같은 자원을 놓고 싸운다.

얻는 것: 장애 범위가 갈린다

프로세스가 다르니 하나가 메모리를 다 써도 다른 서비스는 살아 있다. 통계 쿼리가 결제를 멈추게 하지 못한다.

다만 이건 조건부로만 참이다. 프로세스는 갈렸지만 호출 관계는 남아 있다. 주문이 재고를 동기로 부르는 한, 재고가 느려지면 주문도 느려진다. 격리는 나누는 것만으로 생기지 않고 호출하는 방식으로 만들어야 한다. 그 방법이 다음 편의 주제다.

대가 하나: 호출이 실패할 수 있게 된다

맨 앞에서 본 그것이다. 이제 모든 서비스 간 호출에 대해 답을 준비해야 한다.

  • 응답이 안 오면 얼마나 기다릴 것인가
  • 실패하면 다시 부를 것인가
  • 다시 불렀는데 상대가 이미 처리했으면 어떻게 되나
  • 못 부르면 사용자에게 무엇을 보여줄 것인가

한 덩어리에서는 이런 질문 자체가 없었다. 이제 호출마다 이 네 개가 따라붙는다.

대가 둘: 데이터가 갈린다

서비스를 나누면 데이터베이스도 나눠야 한다. 이게 초중급이 가장 많이 놓치는 지점이다.

코드만 갈라놓고 DB를 공유하면, 두 서비스가 같은 테이블을 각자 읽고 쓴다. 그러면 한쪽이 스키마를 바꿀 때 다른 쪽이 조용히 깨진다. 배포를 갈랐는데 배포할 때마다 상대에게 물어봐야 하는 상태가 된다. 얻으려던 걸 못 얻는다.

그래서 테이블에 주인을 정한다. 재고 테이블은 재고 서비스만 만진다. 다른 서비스는 API로 물어본다.

그 순간 두 가지가 사라진다.

조인이 사라진다. “이 주문에 들어간 상품 이름”을 쿼리로 못 붙인다. 주문 목록을 가져오고, 상품 서비스에 id 목록으로 다시 물어서, 애플리케이션이 합친다.

한 트랜잭션이 사라진다. 주문 저장과 재고 차감을 같이 되거나 같이 안 되게 만들 수 없다. 서로 다른 DB에 있기 때문이다.

참고

이 둘은 이미 겪어본 이야기이기도 하다. 샤딩에서 데이터를 여러 대로 쪼갰을 때 잃은 것이 정확히 조인과 ACID였다. 그쪽은 저장소를 쪼개서, 여기는 서비스를 쪼개서 같은 걸 잃는다. 경계를 그으면 그 너머로는 DB가 아무것도 보장해주지 않는다는 게 공통점이다.

트랜잭션을 못 묶으면 무엇을 하나. 그게 이 시리즈의 마지막 편이다. 지금은 “묶을 수 없다”는 사실만 확인해 둔다.

대가 셋: 무슨 일이 있었는지 보기 어려워진다

한 덩어리에서는 에러 하나에 스택 트레이스 하나였다. 이제 사용자 요청 하나가 서비스 넷을 지난다.

diagramdiagram

“주문이 실패했다”는 신고가 들어오면 로그 세 곳을 시간으로 맞춰가며 뒤져야 한다. 그래서 나눈 시스템에는 처음부터 아래가 필요하다.

  • 로그를 한곳에 모은다. 서비스마다 흩어져 있으면 사실상 못 본다
  • 요청마다 식별자를 붙여 끝까지 넘긴다. 이 값으로 서비스를 가로질러 한 요청의 경로를 이어 붙인다
  • 서비스 간 호출의 성공률과 지연을 잰다. 어느 구간이 느린지 모르면 대응할 수가 없다

이건 나눈 다음에 여유 생기면 하는 일이 아니다. 없으면 장애가 났을 때 원인을 못 찾는다. 나누기의 준비물에 가깝다.

대가 넷: 바꾸는 데 조율이 필요해진다

한 덩어리에서 주문과 재고에 걸친 규칙을 바꾸려면 한 커밋으로 고치고 한 번 배포했다. 이제는 재고 API를 바꾸고, 주문이 새 API를 쓰게 고치고, 순서를 지켜서 배포해야 한다.

그리고 배포는 동시에 안 일어난다. 잠깐이든 며칠이든 옛 버전과 새 버전이 같이 떠 있는 구간이 생긴다. 그래서 API를 바꿀 때 규칙이 필요하다.

  • 필드를 더하는 변경은 대체로 안전하다. 모르는 필드는 무시하면 된다
  • 필드를 빼거나 의미를 바꾸는 변경은 위험하다. 먼저 새것을 추가해 양쪽을 다 지원하고, 아무도 옛것을 안 쓸 때 뺀다
  • 누가 이 API를 쓰는지 알고 있어야 한다. 모르면 뺄 수 있는지 판단이 안 된다

여기서 대가의 성격이 드러난다. 나누기는 일을 없애는 게 아니라 옮긴다. 컴파일러가 잡아주던 걸 사람이 지키게 된다.

어디서 자르나

경계를 어디에 그을지가 나누기의 전부다. 잘못 그으면 서비스 둘이 늘 같이 바뀌고, 그러면 배포를 갈라놓은 의미가 없다.

기준은 앞 편과 같다. 함께 바뀌는 것끼리 묶고, 따로 바뀌는 것을 가른다. 요구사항이 들어올 때마다 두 서비스를 같이 고치고 있으면 그건 한 서비스여야 했던 것이다.

여기에 하나 더 붙는다. 한 번의 변경이 여러 서비스의 데이터를 동시에 맞춰야 한다면 그 선은 잘못 그은 것이다. Aggregate에서 “이 둘이 항상 동시에 맞아야 하는가”로 덩어리를 정했던 그 질문이, 여기서는 서비스 경계를 정하는 질문이 된다. 항상 동시에 맞아야 하는 것들이 서비스 둘에 걸치면 매번 분산된 정합성 문제를 푸는 일이 생긴다.

주의

기술 계층으로 자르는 건 대개 실패한다. “조회 서비스”·“저장 서비스”처럼 자르면 기능 하나를 추가할 때마다 모든 서비스를 고쳐야 한다. 업무 단위로 잘라야 변경이 한 서비스 안에서 끝난다.

실무에서는 통째로 안 나눈다

계획을 세워 한 번에 여러 서비스로 쪼개는 방식은 거의 성공하지 못한다. 경계를 검증할 기회 없이 전부를 걸기 때문이다.

대신 이렇게 한다.

  1. 한 덩어리 안에서 모듈 경계를 먼저 세운다. 앞 편에서 본 일이다
  2. 가장 먼저 떼어낼 하나를 고른다. 경계가 안정적이고, 나눠서 얻는 게 분명한 것으로. 부하 특성이 유별나거나 배포 주기가 다른 기능이 후보다
  3. 그 하나만 떼고 운영해 본다. 로그 수집·요청 식별자·호출 실패 대응을 여기서 다 겪는다
  4. 필요할 때 다음 하나를 뗀다. 남은 덩어리는 그대로 두는 게 정상이다

이렇게 하면 대부분 한 덩어리와 몇 개의 서비스가 공존하는 모양에서 멈춘다. 그게 미완성이 아니다. 나누는 비용을 치를 이유가 있는 곳만 나눈 결과다.

정리

핵심 변화메서드 호출이 네트워크 호출이 된다. 응답이 안 오는 제3의 결과가 생긴다
얻는 것배포 단위·확장 단위·장애 범위가 갈린다
장애 격리는나누는 것만으로 안 생긴다. 부르는 방식으로 만들어야 한다
데이터DB도 갈라야 한다. 그러면 조인과 한 트랜잭션을 잃는다
추적로그 수집·요청 식별자·호출 지표는 준비물이지 나중 일이 아니다
API 변경옛 버전과 새 버전이 공존하는 구간을 전제로 바꾼다
경계함께 바뀌는 것끼리. 항상 동시에 맞아야 하는 것을 가르지 않는다

나눈 뒤 첫 문제는 “그래서 서비스끼리 어떻게 부르나”다. 다음 글에서 가장 단순한 답인 동기 호출부터 본다. 단순한 만큼 한 곳의 느려짐이 어떻게 전체로 번지는지도 거기서 드러난다.

관련 글