서론
위 글은 작년에 읽었던 MSA 아키텍처 구축하기 책을 요약 겸 복습하기 위해 정리한 글입니다. MSA 아키텍처에 대해서 이미 알고 있다는 가정 하에, 글을 작성합니다. MSA 아키텍처가 처음이신 분은 해당 글을 참고해주세요.
핵심요약
- 마이크로 서비스를 하는 이유는 뭘까?
- 마이크로서비스 간 경계를 구분하는 적합한 기준은 무엇일까?
- 마이크로서비스 모델링 방법
- 정보 은닉
- 결합
- 응집력
- 도메인 주도 설계
- 마이크로서비스 모델링 방법
- 전환을 결정했다면, 어떻게 MSA로 전환하는 것이 좋을까?
- 마이크로서비스 통신 방식
- 비동기 호출과 동기 호출
- 이벤트 기반의 협업 방식
- 공통 데이터 방식
- 마이크로서비스의 통신을 구현하는 방법은 무엇일까?
- 마이크로서비스에서 버전 관리를 어떻게 처리해야 할까?
- 분산 트랜잭션은 어떻게 보장할까?
- 마이크로서비스는 어떻게 배포할까?
- 마이크로서비스 테스트에 따르는 어려움은 무엇인가?
- 마이크로서비스 아키텍처의 관찰가능성을 향상시키는 방법은 무엇인가?
- 마이크로서비스에서 인증 시스템은 어떻게 구축할 것인가?
- 마이크로서비스가 애플리케이션의 회복 탄력성을 향상시키는 방법은 무엇인가?
- 마이크로서비스에서 시스템을 확장하려면 어떻게 해야할까?
- 전담 프론트엔드 팀 구축부터 BFF, 그래프 QL을 사용하는 데까지 마이크로서비스와 사용자 인터페이스가 어떻게 함께 작동할 수 있을까?
마이크로 서비스를 하는 이유는 뭘까?
마이크로 서비스를 하는 이유는 뭘까? 여러 이유가 있겠지만, 개인적으로 MSA 전환을 하는 가장 큰 이유는 확장성이라 생각한다. 여러 도메인을 하나의 서버에서 처리한다고 가정하자. 모놀리식 아키텍처에서 MSA로 부분 전환한 토스 뱅크의 사례를 예로 들어보자.
토스뱅크가 카드 결제 시 결제 금액의 30%를 환급해주는 파격적인 이벤트를 모놀리식 시스템 구조에서 진행한다고 할 때, 카드 서비스는 평소보다 훨씬 많은 트래픽이 들어올 것이고, 이 트래픽이 수용할 수 있는 임계점을 넘어서면, 이벤트를 진행하는 카드 서비스 뿐만 아니라 전혀 상관 없는 계좌 개설이나, 대출 약정 서비스들까지도 마비 될 것이다. 물론 미리 이벤트를 알고 있기 때문에, 전체 시스템의 가용성을 확보해둘 수도 있다. 그렇지만 불필요하게 전체 시스템의 가용성을 확보해두어야 한다는 점에서 비효율이 발생한다. 거대한 모놀리식 구조로는 자주 변경이 일어나고 배포하는 것도 불편함이 따른다. 그 외에도 다양한 장점들이 있겠지만, 이정도면 충분하다. (다만, 이로 인해 모놀리식에 비해 구조가 굉장히 복잡해지는 단점이 있다.)
마이크로 서비스 간 경계를 구분하는 적합한 기준은 무엇일까?
본질적으로 마이크로 서비스는 모델과 모든 연관된 문제 사이에서 네트워크 기반의 상호작용이 이뤄지더라도 결국 모듈식 분해의 또 다른 형태다. 흔히 우리가 아는 멀티 모듈 구조 말이다. 따라서 이 구조를 잘 이해하는 것은 향후 MSA 에서 경계를 정의하는 방법을 찾는데 많은 도움을 얻을 수 있다는 점에서 중요하다.
모듈 경계를 정의하는 가장 효과적인 방법은 DDD의 경계 콘텍스트(bounded context)를 활용하는 것이다. 경계 콘텍스트란 도메인 모델이 유효하고 일관되게 작동할 수 있는 범위를 정의하며, 각각의 경계 콘텍스트는 독립적인 도메인 언어와 규칙을 가질 수 있다. 바운디드 컨텍스트는 하나의 단일 모델을 강제하는 것이 아니라, 컨텍스트에 따라 적절한 모델과 용어를 정의하는 것이 핵심이다. 이를 통해 서비스 간 의존성을 줄이고, 명확한 경계를 유지할 수 있기 때문이다. 이를 이해하기 쉽게, 예를 들어 설명해보겠다. 전자상거래 시스템을 구축한다고 할 때, 해당 시스템의 사용자가 충분히 많아, 모놀리식 구조보다 MSA 아키텍처를 도입하는 것이 알맞은 상황임을 가정해보자. 이 시스템은 기능적으로 주문, 결제, 배송을 처리할 수 있어야 한다. 이는 세 가지 경계 컨텍스트로 세분화하여 나눠 볼 수 있다.
이 때, 동일한 개념도 각 컨텍스트에서는 다르게 정의될 수 있다. 주문 컨텍스트에선 고객이 상품을 구매하면 주문(Order)가 생성되지만, 결제 컨텍스트에선 주문(Order)이 결제 요청(PaymentRequest)로 표현될 수 있다. 또한 배송 컨텍스트에선 배송 요청으로 표현되기도 한다. 따라서 각각을 하나의 도메인 모델로 묶기보다는 여러 개의 경계 콘텍스트로 나누는 것이 유리하다.
- 주문(Order) 경계 콘텍스트
주문과 관련된 비즈니스 로직을 처리하는 부분으로, 중요한 개념은 "주문", "상품", "수량", "배송지" 등이다. 이 경계 내에서는 주문 처리 과정이 중요하며, 주문의 상태나 트랜잭션 관리가 핵심이다.
- 주요 도메인 언어: 주문 생성, 주문 승인, 주문 취소, 주문 상태
- 모델: 주문, 주문 항목, 배송지, 결제 정보 등
- 주요 비즈니스 규칙: 주문은 한 번에 여러 개의 상품을 포함할 수 있으며, 결제가 완료되기 전까지 주문은 "대기" 상태로 유지된다.
- 결제(Payment) 경계 콘텍스트
결제 관련 로직을 담당하는 경계 콘텍스트이다. "결제", "결제 수단", "결제 승인" 등의 개념이 여기에 포함된다. 이 경계 내에서는 결제와 관련된 처리만 집중적으로 관리된다.
- 주요 도메인 언어: 결제 승인, 결제 실패, 결제 취소
- 모델: 결제, 결제 수단(카드, 계좌 이체 등), 결제 상태
- 주요 비즈니스 규칙: 결제 수단은 여러 가지가 있을 수 있으며, 결제 실패 시 후속 조치가 필요하다.
- 배송(Shipping) 경계 콘텍스트
배송은 주문이 완료된 후 실행되는 비즈니스 로직입니다. 이 경계는 배송 정보, 추적 번호, 배송 상태 등에 중점을 둡니다.
- 주요 도메인 언어: 배송 시작, 배송 완료, 배송 추적
- 모델: 배송, 배송 추적, 물류 업체
- 주요 비즈니스 규칙: 배송 상태는 "출발", "배송 중", "배송 완료"로 변할 수 있다.
각 경계 콘텍스트 간의 관계
각 경계 콘텍스트는 독립적으로 관리되지만, 상호작용이 필요할 때는 컨텍스트 맵(Context Map)을 사용해 관계를 정의한다.
- 주문(Order) 경계와 결제(Payment) 경계는 주문 생성 후 결제 상태를 처리하기 위해 상호작용합니다. 이 때, "주문 생성" 이벤트가 발생하면 "결제 승인"이 필요한 상황이 생긴다.
- 주문(Order) 경계와 배송(Shipping) 경계는 주문이 "결제 완료" 상태일 때 배송을 시작한다.
이처럼 MSA에서 서비스를 구분할 때도, 위와 같이 경계 콘텍스트로 구분하여 각 영역의 책임을 명확히 하고, 복잡성을 줄일 수 있다.
경계 콘텍스트 구분을 통한 이점
- 모듈화 : 각 경계 콘텍스트는 독립적으로 변경, 배포가 가능하여 시스템을 더 유연하게 만들 수 있다.
- 비즈니스 언어 일관성 : 각 경계 콘텍스트 내에서는 도메인 언어가 일관되게 사용되어 이해가 용이하고 오류를 줄일 수 있다.
- 스케일링 : 각 경계가 독립적으로 확장될 수 있어, 트래픽 증가에 대응하기 좋다.
향상된 개발 시간(더 많은 작업을 병렬로 수행할 수 있으며, 개발자 한 명이 추가되는 비용을 줄일 수 있어)과 이해도(각 모듈을 따로따로 살펴보고 이해할 수 있어)이라는 부수적인 장점도 얻을 수 있다.
전환을 결정했다면, 어떻게 MSA로 전환하는 것이 좋을까?
1) 점진적인 마이그레이션과 API 버저닝
만약 도입을 결정했다면 어떻게 해야 할까? 결론부터 말하면, 점진적으로 도입하는 것이 좋다. 운영 중인 서비스에서 떼어내는 경우가 많기 때문에, 영향범위를 최소화 해야 하기 때문이다. 이를 위해, 먼저 API 버저닝을 통해 클라이언트와 서버 간의 호환성을 유지 할 수 있다.
2) 서비스의 독립성과 부하 분리
또한 트래픽이 많이 몰리고 변경이 자주 발생되는 도메인부터 분리하는 것이 좋다. MSA의 핵심적인 장점 중 하나인 각 도메인의 독립성과 유연한 배포를 최대로 활용할 수 있기 때문이다. 이를 통해, 모놀리식 아키텍처에서 발생할 수 있는 부하를 최소화할 수 있다.
3) 서비스 분리 순서
먼저 백엔드 → 데이터베이스(데이터) → 프론트엔드 순으로 점진적인 변화를 가져가는 것이 효과적이다. 백엔드 서비스 단위는 앞서 말했듯, Bounded Context를 적용하여 서비스 경계를 구분한다. 이후 서비스간 인터페이스 방식을 결정하며, 인터페이스를 위한 기술셋을 선택하면 된다.
4) 기타 고려사항
이 외에도 안정적인 운영을 위해 앞서 언급된 활성화 기술들을 적극 활용해야 한다. 로그 집계와 분산 추적이 가능한 로그 시스템 구축, 여러 컨테이너를 관리하기 위해 쿠버네티스를 통한 컨테이너 오케스트레이션 활용, 데이터 동기화를 위한 카프카 활용 등이 대표적이다. 이에 관해선 추후에 더 자세하게 다뤄보겠다. (참고링크 : https://waspro.tistory.com/718)
마이크로서비스 간에는 어떻게 통신할까?
동기식 블로킹(synchronous blocking), 비동기식 논블로킹(asynchronous nonblocking), 크게 2가지가 있다.
1) 동기식 블로킹(synchronous blocking)
동기식 블로킹은 요청 및 응답 (request-response) 방식만 존재한다. 이 방식은 마이크로서비스가 일종의 호출을 다운스트림 프로세스에 보내고 호출이 완료돼 응답이 수신될 때까지 대기하는 방식이다. 장점은 간단하고 친숙하다는 것이며, 단점은 시간적 결합(서비스 간 호출이 순차적으로 이루어지고, 각 서비스의 응답이 완료될 때까지 기다려야 하므로 전체 시스템의 응답 시간이 각 서비스의 처리 시간에 의존하게 되는 상황)이다. 이로 인해 응답을 기다려야 하기 때문에, 응답 속도가 늦어질 수 있고 하나의 시스템의 장애가 시스템이 다운스트림 장애로 이어지는 연쇄적인 문제가 발생할 수 있다는 치명적인 단점이 있다.
2) 비동기식 논블로킹(asynchronous blocking)
세부적으로 아래 3가지 경우로 나뉠 수 있다. 공통 데이터(common data) 방식, 요청 및 응답 (request-response) 방식, 이벤트 기반(event driven) 방식.
먼저, 첫번째 방식인 공통 데이터 방식이다. 공통 데이터 방식에선 여러 마이크로서비스가 동일한 데이터 저장소(예: 데이터베이스, 캐시 등)에 접근하여 데이터를 읽고 쓸 수 있다. 서비스들이 같은 데이터를 사용하고, 데이터를 업데이트할 때 해당 정보가 여러 서비스에 실시간으로 반영된다.
두번째로 요청 및 응답 (request-response) 방식은 동기식과는 달리, 기본적인 방식도 이 방식에선 복잡하다. 주문 처리기에서 창고 서비스로 바로 이동하는 대신 재고 예약 요청을 큐로 보낸다. 또한 창고 서비스에선 재고 예약됨 응답을 다른 큐를 통해 주문 처리기로 보낸다. 이처럼 이 방식에선 요청을 보내는 곳과 응답을 받는 곳이 어디인지 알고 있어야 한다.
마지막으로 이벤트 기반(event driven) 통신 방식에서는 서비스가 다른 서비스에 직접적으로 응답을 기다리지 않고, 이벤트를 발행하고 이벤트를 수신하여 비동기적으로 처리한다. 이벤트는 메시지 브로커(예: Kafka, RabbitMQ 등)를 통해 전달되며, 수신된 이벤트에 따라 서비스를 처리한다. 가장 큰 장점은 장애 전파가 되지 않는 점이다. 서비스들이 서로 독립적으로 처리되므로, 하나의 서비스가 느리거나 실패해도 다른 서비스는 영향을 받지 않는다. 가장 큰 단점은 복잡해진다는 점이다. 이벤트의 흐름을 추적하거나 디버깅하는 것이 어려울 수 있으며, 서비스 간의 의존 관계를 파악하는 것이 복잡해집니다. 그 외에도 실패 시 재처리, 데이터 일관성 보장을 위한 처리 등이 필요해지기 때문에 고민해야 할 것들이 많아진다.
마이크로서비스의 통신을 구현하는 방법은 무엇일까?
앞서 보았듯, 마이크로서비스 간 통신 방식에는 크게 2가지가 있고 이를 통신을 구현하는 방식에도 원격 프로시저 호출, REST, GraphQL, 메시지 브로커 등의 선택지가 존재한다.
REST
REST API란 API Path만 보고도 자원에 대한 동작을 예측할 수 있도록 작성된 API 설계 방법론이다. 대표적으로 GET은 조회, POST는 생성, DELETE는 삭제, PUT/PATCH는 수정 동작을 나타낸다. 예를 들어, [GET] /v1/users는 전체 유저 목록을 조회하는 API이며, [POST] /v1/posts는 게시글을 등록하는 API이다.
REST API에는 한계점도 존재한다. JSON 기반으로 통신하기 때문에 데이터를 직렬화하고 역직렬화하는 과정에서 추가적인 리소스가 소모된다. 이로 인해 gRPC와 같은 이진 프로토콜을 사용하는 통신 방법보다 속도가 느리다. 또한 필요한 데이터를 효율적으로 가져오지 못하는 문제가 있어, GraphQL을 통해 클라이언트가 원하는 데이터만 조회할 수 있도록 해결할 수 있다.
원격 프로시저 호출 (remote procedure call)
일반적으로 REST에 비해 서비스 간 통신 속도가 빠르다는 장점을 가진 방식으로, 로컬 호출을 통해 어딘가에 있는 원격 서비스를 실행하는 기술을 말한다. 마치 원격 호출을 로컬 호출처럼 보이게 하는 특성이 있다. SOAP나 gRPC 등이 여기에 속한다. RPC 프레임워크는 데이터를 직렬화되거나 역직렬화되는 방법을 정의한다. 예를 들어, RPC의 대표격인 gRPC는 프로토콜 버퍼 직렬화 방식을 사용한다. 아래는 Java & Spring을 활용하여 gRPC를 기반으로 클라이언트가 "World"를 보내고 서버에서 "Hello, World"로 출력하는 간단한 통신 예제이다.
// gRPC 프로토콜 버퍼 정의
syntax = "proto3";
package hello;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
이 때, RPC 기술 대부분은 명시적인 스키마, 인터페이스 정의 언어(interface definition language)를 제공해줘야 한다. gRPC의 경우에는 위와 같이 .proto 파일을 생성해줘야 한다. .proto 파일은 gRPC에서 API의 스키마(인터페이스 정의)를 제공하는 역할을 한다. 아래와 같이 구현할 수 있다. .proto 파일은 서버와 클라이언트 모두에게 필요하며, 일반적으로 src/main/proto/hello.proto 경로에 둔다.
1) 서버 시작
// 서버 코드
public class HelloServer {
public static void main(String[] args) throws Exception {
Server server = ServerBuilder.forPort(8080)
.addService(new GreeterImpl())
.build()
.start();
System.out.println("Server started...");
server.awaitTermination();
}
}
- ServerBuilder.forPort(8080) → 포트 8080에서 서버를 실행함
- .addService(new GreeterImpl()) → GreeterImpl 클래스(구현된 gRPC 서비스)를 등록
- .start() → 서버 시작
- server.awaitTermination(); → 서버가 종료될 때까지 대기
2) 클라이언트가 서버에 연결
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
public class HelloClient {
public static void main(String[] args) {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080)
.usePlaintext()
.build();
}
}
- ManagedChannelBuilder.forAddress("localhost", 8080) → 클라이언트가 localhost:8080에 연결을 시도함.
- .usePlaintext() → TLS(보안 통신) 없이 일반 텍스트로 통신함.
- .build(); → 채널을 생성.
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
public class HelloClient {
public static void main(String[] args) {
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080)
.usePlaintext()
.build();
GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
HelloRequest request = HelloRequest.newBuilder().setName("World").build();
HelloReply response = stub.sayHello(request);
System.out.println(response.getMessage());
channel.shutdown();
}
}
3) 클라이언트가 요청을 보냄
GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
- GreeterGrpc.newBlockingStub(channel) → 동기 방식으로 gRPC 요청을 보낼 Stub(클라이언트용 객체) 생성한다.
HelloRequest request = HelloRequest.newBuilder().setName("World").build();
HelloReply response = stub.sayHello(request);
- HelloRequest.newBuilder().setName("World").build(); → HelloRequest 객체를 생성. (name = "World")
- stub.sayHello(request); → 서버로 sayHello 요청을 보낸다.
- 클라이언트는 서버가 응답할 때까지 대기(Blocking)한다.
4) 서버가 요청을 처리한다
@Override
public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
String greeting = "Hello, " + request.getName();
HelloReply reply = HelloReply.newBuilder().setMessage(greeting).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
- sayHello(HelloRequest request, StreamObserver responseObserver)
- 클라이언트가 보낸 HelloRequest 객체를 받아서 처리한다.
- request.getName() → "World" 값을 가져온다.
- "Hello, World" 메시지를 포함한 HelloReply 객체 생성한다.
- responseObserver.onNext(reply); → 클라이언트에게 응답 전송한다.
- responseObserver.onCompleted(); → 응답 완료한다.
5) 클라이언트가 응답을 받는다
System.out.println(response.getMessage());
결과 "Hello, World"
6) 클라이언트가 채널 종료
channel.shutdown();
- 클라이언트가 gRPC 채널을 종료하여 리소스를 해제
장점과 단점
알기 쉽게 REST에 비교하여 장점을 먼저 설명하면
- 성능
- RPC는 바이너리 프로토콜(예: Protocol Buffers)을 사용하여 데이터를 직렬화하고 전송하므로, REST보다 빠르고 효율적이다.
- HTTP/2와 멀티플렉싱을 활용한 gRPC는 더 낮은 지연 시간과 더 빠른 데이터 전송을 제공한다.
- 기능
- RPC는 메서드 호출을 기반으로 하므로, 자연스러운 함수 호출처럼 서비스 간 통신이 이루어져 개발자가 사용하는 방식이 직관적이다.
- 양방향 스트리밍을 지원하는 gRPC와 같은 RPC 시스템은 클라이언트와 서버 간 실시간 양방향 데이터 전송을 가능하게 한다.
- 강력한 타입 시스템
- RPC는 명확한 정적 타입(예: Protocol Buffers)을 정의하고, 이를 기반으로 API를 생성하여 형식 검증이 자동으로 이루어진다. 이는 잘못된 데이터가 들어가는 오류를 줄이는 데 유리하다.
장점만 있는 것은 아니다. 단점은 아래와 같다.
- 복잡성
- RPC는 설정과 구현이 복잡할 수 있다. 특히 서비스 간 통신이 여러 프로토콜이나 시스템을 포함하는 경우, 이를 관리하는 데 추가적인 노력이 필요하다.
- 서비스 디스커버리, 로드 밸런싱, 상호 인증 등의 부가적인 설정이 필요할 수 있다.
- 웹과의 호환성
- RPC는 주로 서버 간 통신에서 유리하며, 웹 브라우저와의 호환성이 떨어질 수 있다. 예를 들어, gRPC-web을 사용하려면 추가적인 구성이 필요하다.
- 반면, REST는 HTTP를 기반으로 하여 웹 브라우저 및 다양한 클라이언트에서 쉽게 접근할 수 있다.
- 상호 운용성
- REST는 HTTP를 기반으로 하기 때문에, 거의 모든 클라이언트와 쉽게 상호작용할 수 있다. RPC는 특정 클라이언트 라이브러리나 언어에 의존할 수 있어, 상호 운용성에서 제한이 있을 수 있다.
- 유연성 부족
- REST는 URI, HTTP 메서드(GET, POST, PUT 등)를 사용하여 유연한 API 설계를 지원하지만, RPC는 호출할 메서드나 함수 이름에 따라 호출되는 방식이 고정적이다. 이로 인해 유연한 API 설계가 어려울 수 있다.
정리하면 RPC는 고성능, 효율적인 데이터 처리, 정적 타입 지원이 필요한 내부 서비스 간 통신에 적합하다. REST는 웹 친화적이고, 다양한 클라이언트와 호환성이 중요하며, 유연한 API 설계가 필요한 경우 유리하다.
GraphQL
REST API 방식에서 Over-fetching, Under-fetching과 같은 문제가 발생한다. REST API는 특정 엔드포인트에서 정해진 응답을 반환하기 때문에, 클라이언트가 필요 없는 데이터까지 포함될 수 있다. 또는 한 번의 요청으로 원하는 데이터를 가져올 수 없어서 추가 요청 필요한 경우도 있다. GraphQL은 클라이언트가 원하는 데이터만 요청할 수 있도록 설계된 쿼리 언어이다. REST API는 정해진 엔드포인트에서 고정된 응답을 주지만, GraphQL은 하나의 엔드포인트에서 동적으로 필요한 데이터만 선택하여 응답할 수 있다. 예를 들어, 유저 정보가 필요할 때 REST API에서는 /users/{id}를 호출하면 고정된 유저 데이터가 반환된다. 그렇지만 GraphQL에서는 아래와 같이, 원하는 필드만 선택해서 가져올 수 있다.
query { user(id: 1) { name, email } }
메시지 브로커
미들웨어라고 하는 중개자로서 프로세스 간 통신을 관리하는 방식이다. 대표적으로 RabbitMQ, ActiveMQ, Kafka가 있으며, Redis와 AWS SQS도 메세지 브로커 역할을 수행할 수 있다. 서비스 간 통신 과정에서 비동기 통신이 가능하여, 서비스 간 결합도는 낮추며, 빠른 요청 및 응답 처리가 가능하다는 점에서 범용적으로 사용되고 있다. 브로커는 보통 토픽과 큐를 제공하는 경우가 많다.
큐는 일반적으로 두 지점(point to point)간이다. 발신자는 큐에 메시지를 넣고 소비자는 해당 큐에서 읽는다. 토픽 기반 시스템을 사용하면 여러 소비자가 토픽을 구독할 수 있으며, 구독한 각 소비자는 해당 메시지의 복사본을 받는다.
언뜻 보기에 큐는 단일 소비자 그룹이 있는 토픽과 비슷하지만, 큐는 메시지에 전송되는 대상에 대한 정보가 있는 반면, 토픽에서는 이 정보가 발실자에게 숨겨지므로 메시지를 누가 받을지 발신자는 알지 못한다. 메시지 브로커의 장점으로는 비동기 통신에 특화되어있다는 것이다. 또한 메시지를 보관하는 기능도 있기 때문에, 다운스트림에서 당장 수신을 못했다 하더라도 문제가 되지 않는다. 동일한 상황에서 HTTP 프로토콜을 사용했다면 데이터가 다운스트림에 도달하지 못했을 때, '호출을 재시도 할까? 얼마나 재시도를 해야할까? 아니면 포기해야할까?'와 같은 실패 정책을 별도로 고민했어야 할 것이다.
전달 보장을 하기 위해 브로커는 아직 전달되지 않은 메시지가 배달될 떄까지 지속적인 방법으로 메시지를 유지해야한다. 이를 보장하기 위한 방법으로 클러스터 기반 시스템으로 실행돼, 한 머신이 고장나더라도 메시지가 손실되지 않을 수 있다.
또한 정확히 한 번 메시지를 전달해야 하는 것도 보장해야하는데, 간단한 예시로는 메시지에 고유 ID를 포함시켜 수신자 쪽에서 해당 메시지를 이미 받은적이 있는지 확인하는 것이다.
카프카는 대규모를 위해 설계된 대표적은 메세지 브로커로, 메시지 영속성이라는 특징이 있는데, 카프카는 메시지 저장 기간을 설정할 수 있다.
마이크로서비스에서 버전 관리를 어떻게 처리해야 할까?
크게 3가지 방식이 있다. 락스텝 배포, 호환되지 않는 마이크로서비스의 공존, 기존 인터페이스 에뮬레이션. 이 중 기존 인터페이스 에뮬레이션이 가장 추천된다.
락스텝 배포
마이크로서비스를 모든 버전에서 동시에 업데이트하여 시스템 전체의 호환성을 보장하는 방식이다. 서비스의 순차적 업데이트나 롤백 없이, 모든 변경 사항이 동시에 반영된다. 이는 독립적인 배포가 가능하다는 MSA 장점을 활용하지 못하므로, 추천되지 않는다. 또한 서비스가 여러 팀에 걸쳐 있어, 동시 배포 진행이 어려울 수 있다.
호환되지 않는 마이크로서비스의 공존
서비스의 여러 버전이 동시에 운영될 수 있도록 하여, 각 버전 간의 호환성을 유지하며 점진적인 전환이 가능하다. 넷플릭스에서 드물게 사용하는 방식인데, 구 버전과 신 버전을 나란히 공존시키고 신규 버전 서비스를 사용중인 유저는 신규 버전의 피쳐를, 구 버전 서비스를 사용중인 유저는 구 버전의 피쳐를 상요하도록 라우팅하는 것이다. 레거시 장치가 여전히 이전 버전의 API와 묶인 경우 오래된 소비자들을 변경하는 비용이 너무 높은 상황에서 생각할 수 있는 방법이지만, 이는 최선의 방법은 아니다. 구 버전과 신 버전이 공존하는 방법은 카나리아 배포 방식일 경우, 적합하다. 이 배포 방식은 '단계적으로, 점진적으로 서비스를 제공'하는 방식에 초점을 맞췄기 때문에, 구 버전과 신 버전이 공존해야하는 상황에서 사용된다.
기존 인터페이스 에뮬레이션
새로운 서비스가 기존 인터페이스의 동작을 모방하여 기존 클라이언트가 새로운 시스템에 적응하지 않도록 지원하는 방법이다. 동일 MSA 서비스에서 신 / 구 인터페이스를 함께 재공한다. 이를 통해 새로운 인터페이스와 함께 새로운 MSA 서비스를 가능한 빨리 출시할 수 있으며, 동시에 소비자가 옮겨갈 시간을 확보할 수 있다. 모든 소바자가 더 이상 구 엔드포인트를 사용하지 않으면 구 인터페이스를 제거한다. 또한 명확한 라우팅을 위해 API 경로에도 신 / 구 인터페이스의 구분이 필요된다. 단, 삭제되어야 할 인터페이스에 대해서는 명확한 네이밍이 필요하다. (저자는 폐기 예정인 인터페이스는 v1과 같은 접두사를 붙여 구분했다.) 또한 명확한 라우팅을 위해 API 경로에도 신 / 구 인터페이스의 구분이 필요된다.
분산 트랜잭션은 어떻게 보장할까?
새로운 고객이 온보딩이 완료되어, 고객의 상태를 보류중에서 확인 상태로 변경하고 보류중인 고객 목록에서도 해당 행을 삭제하려는 상황을 가정해보자. 기존에는 하나의 트랜잭션에서 2개의 데이터를 변경했다. 그러나 MSA로 전환되어, 그림 6-2처럼 서로 다른 DB에 데이터가 적재되면서 2개의 트랜잭션으로 쪼게졌다. 이는 하나의 트랜잭션 내에서 처리될 수 없으므로 트랜잭션의 ACID 중 A, 즉 원자성 (Atomicity)을 보장할 수 없음을 뜻한다.
원자성 (Atomictity) : All or Nothing의 개념으로, 작업 단위를 일부만 수행하지 않는다는 것을 의미한다.
이를 해결하기 위해 분산 트랜잭션을 구현하는 방법에 대해 알아보자.
분산 트랜잭션 - 2단계 커밋
2단계 커밋 (Two-Phase Commit) 알고리즘, 줄여서 2PC라고 부른다. 2PC는 투표(voting) 단계와 커밋(commit) 단계라는 2단계로 나뉜다. 그래서 2단계 커밋이다.
1) 투표 단계
중앙 조정자는 트랜잭션에 참가할 모든 워커에 연락하고 일부 상태 변경이 가능한지 여부를 확인 요청한다. 그림 6-3에서 보듯, 2가지 요청에 대해 모든 워커가 요청 받은 상태 변경이 가능하다고 응답해야 처리되며, 하나라도 변경을 수행할 수 없다고 하면 전체 연산은 중단된다. 워커가 변경할 수 있다고 알려준 직후에도 변경 사항이 즉시 반영되지 않을 수 있다. 다른 트랜잭션에서 해당 데이터를 수정할 수 있기 때문이다. 이를 보장하기 위해, 해당 레코드를 잠가야 한다. 2단계 커밋 내 조정자와 참가자들 사이의 지연 시간이 길어질수록, 또한 워커가 응답을 처리하는 속도가 느릴 수록 불일치 구간은 더 커질 수 있다.
2) 커밋 단계
커밋 단계에서 변경 사항이 실제로 적용된다. 커밋이 두 번째 단계에서 이뤄질 수 있도록 로컬 자원을 잠궈야 한다. 워커는 데이터를 두 곳에서 변경을 완료 한 후, 잠금을 해제한다.
장점은 트랜잭션의 원자성을 보장할 수 있다는 점이며, 이를 통해 데이터 일관성을 유지할 수 있다. 또한 구현이 비교적 간단하다. 그렇지만 장점에 비해, 단점이 커 장점을 상쇄할 정도다.
- SPOF : 단일 2PC의 코디네이터가 장애가 나면, 트랜잭션 중단 혹은 복구가 어려운 상황이 발생한다.
- Blocking 문제 : 만약 일부 참가자가 장애가 나거나 응답을 하지 않으면, 다른 모든 참가자들이 기다려야 하며, 이는 시스템을 장기간 블로킹 상태로 만들 수 있다.
- 성능 문제 : 모든 참가자가 순차적으로 확인하고 결정을 내려야 하므로, 대규모 시스템에서는 성능이 저하될 수 있다. 이러한 문제는 워커가 늘어날수록 더 커진다.
- 복잡한 오류 처리: 실패 발생 시 복구 과정이 복잡하고, 메시지 손실이나 중복 처리가 어려운 경우가 많다.
분산 트랜잭션, 그냥 안된다고 하라
위와 같은 문제점들이 있기 때문에, 2PC와 같은 분산 트랜잭션은 피하는 것이 상책이다.
- 처음부터 DB를 분리하지 말자
정말 원자적이고 처리되어야 하는 데이터가 있을 경우, 해당 데이터를 단일 DB에 남겨두고 단일 서비스(또는 모놀리스)에 해당 상태를 관리하는 기능도 그대로 남겨두자.
- 그럼에도 데이터를 분해해야 한다면
여러 서비스에서 분산 DB에 작업을 수행하면서도 잠금을 피할 수 있는 방법이 필요하다. 그렇다면 사가(SAGA) 패턴을 고려할 수 있다.
분산 트랜잭션 - 사가(SAGA)패턴
사가는 여러 상태 변경을 조정할 수 있지만 자원을 잠금 필요가 없는 알고리즘으로 설계됐다. 사가는 원래 단일 DB에서 작동하는 LLT(Long Lived transaction 장기 트랜잭션)를 지원하기 위한 메커니즘으로 구상됐지만, 여러 서비스에 걸친 변경 사항을 조정할 경우에도 효과적이다. 단, 사가는 일반적인 DB 트랜잭션의 ACID 관점에서 원자성을 제공하지는 않는다. LLT를 개별 트랜잭션으로 나누기 때문에 사가 자체 수준에서는 원자성을 갖지 않는다. 필요한 경우 각 트랜잭션이 ACID 트랜잭션 변경과 연관될 수 있으므로 전체 사가 내에서 개별 트랜잭션 각각에 대한 원자성은 있다.
위와 같은 결제 흐름을 예시로 살펴보자. 여기서 주문 프로세스는 하나의 사가로 표현되며, 이 흐름의 각 단계는 각각 서로 다른 MSA 서비스에서 처리된다. 각각 서비스 내부의 모든 상태 변경은 로컬 ACID 트랜잭션 내에서 처리될 수 있다. 예를 들어 창고 서비스를 사용해 재고를 확인하고 예약하는 경우, 일반 트랜잭션 내에서 처리가 가능하다.
1) 사가 실패 모드
사가를 개별 트랜잭션으로 분해하기 위해선 실패 처리 방법을 고려해야 한다. 여기에는 두 가지 종류가 있다. 역방향 복구와 순방향 복구.
- 역방향 복구 :
- 순방향 복구 :
2) 사가 롤백
3) 롤백을 줄이기 위해 워크플로 단계를 재정렬
사가 패턴 구현
사가 패턴에는 크게 2가지 종류가 있다. 오케스트레이션형 사가, 코레오그래피형 사가.
1) 오케스트레이션형 사가
중앙 조정자 (= 오케스트레이터)를 사용해 실행 순서를 정의하고 필요한 보상 조치를 트리거한다. 각 서비스는 오케스트레이터의 명령을 받고 트랜잭션을 실행하고 트랜잭션이 실패하면 오케스트레이터가 보상 트랜잭션 실행한다.
- 장점
- 중앙 집중형 관리로 인해 흐름을 제어하기 쉬움
- 비즈니스 로직이 명확하여 디버깅 및 유지보수가 용이함
- 장애 복구가 직관적이며, 실패 처리 로직을 중앙에서 일괄 관리 가능
- 단점
- 오케스트레이터가 병목(bottleneck)이 될 가능성이 있음
- 중앙 집중 방식이므로 확장성 측면에서 불리
- 서비스 간 강한 결합 발생 가능 (오케스트레이터가 모든 서비스의 트랜잭션을 알아야 함)
2) 코레오그래피형 사가
코레오그래피형 사가는 여러 MSA 서비스 사이에서 사가 운영에 대한 책임을 분산시키는 것을 목표로 한다. 각 서비스가 독립적으로 이벤트를 발행하고, 이를 구독하여 트랜잭션을 실행하는 방식으로 특정 트랜잭션이 성공하면 이벤트를 발생시키고, 관련된 서비스가 해당 이벤트를 받아서 후속 트랜잭션을 수행한다. 그리고 트랜잭션이 실패하면 보상 이벤트를 발생시켜 정합성을 유지한다.
- 장점
- 서비스 간 결합도가 낮음, 확장성이 뛰어남
- 개별 서비스가 독립적으로 동작하므로 MSA에서 더 적합
- 이벤트 기반 아키텍처를 활용하면 유연하고 확장성이 좋은 구조를 가질 수 있음
- 단점
- 흐름을 추적하기 어려움 (디버깅 및 장애 분석이 복잡함)
- 각 서비스가 어떤 이벤트를 받아야 하는지 명확하게 정의해야 함
- 이벤트 설계가 잘못되면 이벤트 폭풍(Event Storming) 발생 가능
둘 중 무엇을 사용하면 좋을까? 저자의 후기에 의하면, 느슨하게 결합된 아키텍처를 통해 얻을 수 있는 이점보다 사가의 진행 상황을 추적하는 것과 추가적인 복잡성이 더 컸다. 즉, 오케스트레이션형 사가 패턴을 사용하는 것이 낫다. 그렇지만 여러 팀이 관여하는 경우, 더더욱 세분화된 코레오그래피형 사가를 사용하는 것도 추천한다.
마이크로서비스는 어떻게 배포할까?
마이크로서비스는 독립적으로 배포 가능해야 하며, 이를 위해 다양한 배포 전략과 자동화 도구를 활용한다.
마이크로서비스 배포 방식
1) 개별 서비스 단위 배포
각 서비스가 독립적으로 배포됨 → 하나의 서비스 변경이 다른 서비스에 영향을 주지 않음. 예시, 결제 서비스만 업데이트 가능, 주문 서비스는 그대로 유지
2) 컨테이너 기반 배포 (Docker + Kubernetes)
각 서비스는 컨테이너로 패키징되어 Kubernetes(K8s) 등의 오케스트레이션 도구를 통해 배포됨. 예시, 주문 서비스, 결제 서비스, 알림 서비스 각각의 Docker 컨테이너를 K8s에 배포됨.
3) 서버리스 (FaaS) 배포
특정 기능을 AWS Lambda, Google Cloud Functions 같은 서버리스 환경에서 실행함. 예시, 이메일 전송 서비스만 AWS Lambda로 배포
- 장점: 인프라 관리 부담이 적고 자동 확장이 가능함
배포 전략
1) 롤링 업데이트 (Rolling Update)
기존 버전을 점진적으로 교체하면서 무중단 배포 수행. v1.0 인스턴스를 v1.1로 교체하면서 트래픽을 점진적으로 이동. Kubernetes Deployment, AWS ECS Rolling Update 등을 활용 할 수 있다.
2) 블루-그린 배포 (Blue-Green Deployment)
새로운 버전(Blue)을 배포한 후 트래픽을 기존 버전(Green)에서 전환. 예시, 현재 실행 중인 v1.0(Green)을 유지하면서 v1.1(Blue)을 배포 후 트래픽 스위칭. Nginx, AWS ALB, Kubernetes Service 등을 활용 할 수 있다.
3) 카나리아 배포 (Canary Deployment)
일부 사용자(트래픽)에게 새 버전을 제공하여 문제가 없으면 점진적으로 확장함. 예시, 5% 트래픽을 새 버전으로 보내고 이상 없으면 100%로 확장한다. Istio, Kubernetes, AWS CodeDeploy 등을 활용 할 수 있다.
4) A/B 테스트 배포
사용자 그룹별로 다른 버전을 제공하여 성능 비교. 예시, 로그인 UI v1과 v2를 50:50으로 나누어 사용자 반응 확인. Kubernetes, Istio, Nginx 등을 활용 할 수 있다.
자동화된 배포 파이프라인 (CI/CD)
마이크로서비스는 CI/CD(Continuous Integration / Continuous Deployment) 파이프라인을 활용하여 자동 배포한다.
1) 주요 도구
CI/CD 도구: GitHub Actions, GitLab CI/CD, Jenkins, ArgoCD, Spinnaker
컨테이너 관리: Docker, Kubernetes(K8s), Helm
배포 자동화: Terraform(IaC), Ansible, FluxCD
2) CI/CD 파이프라인 예시
- 개발자가 코드를 푸시하면 GitHub Actions가 빌드 및 테스트 수행
- Docker 이미지를 생성하여 컨테이너 레지스트리(ECR, GCR, Docker Hub)에 저장
- Kubernetes에 배포하면서 롤링 업데이트 실행
- Istio로 트래픽을 조정하여 카나리아 배포 실행
마이크로서비스 테스트에 따르는 어려움은 무엇인가?
마이크로서비스 테스트는 서비스 간 복잡한 의존성과 분산 환경 때문에 어렵고, 다음과 같은 주요 어려움이 있다.
1) 서비스 간 의존성으로 인한 테스트 어려움
마이크로서비스는 독립적으로 배포되지만, 실제 운영 환경에서는 서로 의존함. 하나의 서비스만 테스트하기 어렵고, 전체 연동이 필요할 때가 많음.
💡 예시: A 서비스가 B, C 서비스를 호출하는데, B 또는 C가 다운되면 테스트 불가능.
2) 테스트 환경 구축의 어려움
마이크로서비스는 데이터베이스, 메시지 큐(Kafka), 캐시(Redis) 등 여러 인프라가 필요함. 실제 운영 환경을 재현하려면 비용이 많이 들고 관리가 어려움.
💡 예시: 금융 서비스에서 실제 고객 데이터를 테스트하려면 DB 샤딩, Kafka 이벤트 흐름까지 고려해야 함
3) 분산 시스템에서의 상태 관리 어려움
서비스가 개별적으로 배포되므로, 테스트할 때 항상 동일한 상태를 유지하기 어려움. 여러 서비스 간의 데이터 정합성을 검증하는 것이 까다로움.
💡 예시: 주문 서비스에서 상품 재고 차감이 잘 되는지 테스트하려면, 재고 서비스와 동기화된 상태가 필요
4) 비동기 메시징 시스템 테스트의 복잡성
Kafka, RabbitMQ 같은 비동기 메시징 시스템을 테스트하는 것은 동기 호출보다 훨씬 복잡함. 메시지가 제대로 전송/소비되었는지 검증하는데 시간 지연과 메시지 순서 문제 발생 가능.
💡 예시: Kafka에서 "주문 생성" → "결제 요청" → "결제 성공" 흐름을 테스트할 때, 각 이벤트가 올바른 순서로 처리되는지 확인 어려움
5) 테스트 자동화의 어려움
단일 모놀리식 서비스보다 테스트 종류(Unit, Integration, Contract, E2E)가 많음. 테스트 자동화를 위해 각 서비스 간의 API 계약(Contract)을 정의해야 함.
💡 예시: A 서비스가 B 서비스를 호출하는데, B의 API 스펙이 변경되면 A의 테스트가 깨질 수 있음 → Contract Testing 필요
해결방안
- 서비스 간 의존성 : Mock 서버 활용 (WireMock, TestContainers)
- 테스트 환경 구축 어려움 : 로컬 환경에서 Docker Compose/Kubernetes 사용
- 데이터 정합성 문제 : CDC (Change Data Capture), Outbox 패턴 사용
- 비동기 메시지 테스트 어려움 : Testcontainers-Kafka, Embedded Kafka 사용
- 테스트 자동화 어려움 : Contract Testing (Pact, Spring Cloud Contract) 도입
마이크로서비스 테스트는 의존성, 상태 관리, 비동기 메시징, 환경 구축 등 여러 가지 어려움이 있음.
Mocking, TestContainers, Contract Testing 같은 기법을 활용하면 효율적인 테스트가 가능.
기업에서는 CI/CD에 자동화된 통합 테스트와 계약 테스트를 도입하여 문제를 해결하고 있다.
마이크로서비스 아키텍처의 관찰가능성을 향상시키는 방법은 무엇인가?
Observability는 로그(Log), 메트릭(Metric), 트레이스(Trace) 를 활용하여 시스템의 상태를 파악하는 것으로, 마이크로서비스 환경에서는 서비스가 분산되어 있기 때문에 추적, 모니터링, 디버깅이 어려워 이를 강화할 필요가 있다.
로그 집계
각 서비스에서 발생하는 로그를 단일 저장소로 집계하여 쉽게 조회할 수 있도록 해야 한다. 아래와 같은 방식을 사용할 수 있다.
- 구조화된 로그(JSON, key-value 형태) 사용 → 분석 용이
- 로그 수집 및 저장 도구 사용
- ELK Stack (Elasticsearch, Logstash, Kibana)
- Fluentd + Grafana Loki
- AWS CloudWatch, Google Cloud Logging
- 분산 환경에서 Correlation ID 활용 (요청별 고유 ID를 모든 로그에 포함)
{
"timestamp": "2025-02-25T12:00:00Z",
"level": "INFO",
"service": "order-service",
"correlation_id": "abcd-1234-xyz",
"message": "Order processed successfully",
"order_id": "56789"
}
로그 집계는 초기 단계의 MSA 어플리케이션의 가시성을 높이는데, 가장 단순하면서도 효과적인 방법이다. 단점으로는 트래픽과 서비스가 늘어남에 따라, 엄청난 양의 데이터가 생성된다는 것이다. 이로 인해 더 많은 하드웨어가 필요하며 서비스 제공자에게 지불하는 요금도 증가할 수 있다.
로그 집계 과정에서 Correlation ID를 사용하는 이유
여기서 Correlation ID를 사용하는 이유는 요청 흐름을 추적하기 위함이다. 마이크로서비스 환경에서는 하나의 요청이 여러 서비스에 걸쳐 수행되기 때문에, 서비스 간 호출 관계를 추적하기가 어렵다. Correlation ID를 사용하면 하나의 요청에 대한 모든 로그를 묶어서 조회할 수 있다.
💡 예제
- order-service → payment-service → shipping-service 요청이 전달될 때, 모든 로그에 동일한 Correlation ID를 포함
- 이후 Kibana, Elasticsearch, Loki 같은 로그 분석 도구에서 Correlation ID로 검색하면 하나의 요청에 대한 모든 서비스 로그를 한 번에 조회 가능
서비스가 많아지면 어떤 서비스에서 문제가 발생했는지 찾기가 어렵다. Correlation ID를 활용하면 장애 발생 시 어떤 요청이 어디에서 실패했는지 빠르게 파악할 수 있다.
[2025-02-25T12:00:00Z] [INFO] [order-service] [CorrelationID: abcd-1234] 주문 생성 요청
[2025-02-25T12:00:02Z] [INFO] [payment-service] [CorrelationID: abcd-1234] 결제 승인 완료
[2025-02-25T12:00:03Z] [ERROR] [shipping-service] [CorrelationID: abcd-1234] 배송 서비스 오류 발생
마이크로서비스에서는 서비스별로 로그가 분산되어 있고, 각 서비스는 다른 인프라, 다른 서버에서 실행될 수 있다. Correlation ID를 사용하면 분산된 로그를 하나의 요청 단위로 정렬하여 볼 수 있다.
GET logs/_search
{
"query": {
"match": {
"correlation_id": "abcd-1234"
}
}
}
Correlation ID를 활용하면 요청이 서비스 간 이동하는 과정에서 어디에서 시간이 오래 걸리는지 분석 가능하다.
💡 예제: 요청별 서비스 응답 시간
[Correlation ID: abcd-1234]
order-service: 50ms
payment-service: 200ms
shipping-service: 1200ms (병목 구간)
메트릭 집계
실시간으로 서비스 상태를 측정할 수 있도록 메트릭을 수집하고 대시보드를 구축해야 한다.
✅ 방법
- Prometheus + Grafana → 시간 기반 메트릭 수집 및 시각화
- Spring Boot Actuator → 기본적인 메트릭 제공 (/actuator/metrics)
- 서비스 헬스체크 및 SLA 모니터링
💡 주요 모니터링 대상
- CPU, 메모리, 디스크 사용량
- TPS(초당 트랜잭션 수), 응답 시간
- 에러율, 재시도율, 서킷 브레이커 동작 횟수
- Kafka, RabbitMQ 메시지 소비량
분산 트레이싱
분산 환경에서는 요청이 여러 서비스에 걸쳐 수행되므로, 트랜잭션 흐름을 추적해야 한다. 앞서 다뤘던 Correlation ID이 "누가 어떤 요청을 처리했는지" 확인하는 데 초점을 뒀다면, 분산 트레이싱은 "서비스 간 호출 흐름과 지연 시간"을 분석하는 데 필요하다.
✅ 방법
- OpenTelemetry (표준 트레이싱 라이브러리)
- Jaeger, Zipkin (트랜잭싱 시각화 도구)
- Spring Cloud Sleuth (Spring Boot 기반 트레이싱)
💡 트레이싱 흐름 예제
- order-service → payment-service → shipping-service 요청 전달
- 각 서비스에서 Trace ID, Span ID를 포함한 로그 생성
- Jaeger/Zipkin을 통해 호출 경로 및 성능 분석 가능
Trace ID: abcd-1234
├── [order-service] (Span ID: 1111, 실행 시간: 50ms)
│ ├── [payment-service] (Span ID: 2222, 실행 시간: 200ms)
│ ├── [shipping-service] (Span ID: 3333, 실행 시간: 1200ms) ❌ (병목 발생)
서비스 상태 자동 감지 및 알림
관찰가능성을 높이려면 문제가 발생할 때 즉시 감지하고 알림을 받을 수 있어야 한다.
✅ 방법
- Prometheus Alertmanager → 특정 임계값 초과 시 알림
- Grafana Alerts, Datadog, New Relic → 실시간 모니터링 및 알림
- Slack, PagerDuty 연동 → 장애 감지 시 즉시 알림 전송
💡 예제: Prometheus Alertmanager Rule
groups:
- name: high_latency_alerts
rules:
- alert: HighResponseTime
expr: http_request_duration_seconds{service="order-service"} > 1.5
for: 2m
labels:
severity: critical
annotations:
summary: "Order Service 응답 시간 지연"
마이크로서비스에서 인증 시스템은 어떻게 구축할 것인가?
중앙 집중식 인증 시스템 사용 (Identity Provider)
마이크로서비스 환경에서 인증을 효율적으로 관리하기 위해, Identity Provider (IDP) 또는 Auth Server를 사용하여 중앙 집중식으로 인증을 처리할 수 있다.
- OAuth 2.0 및 OpenID Connect (OIDC) 같은 표준을 활용해 인증을 중앙에서 관리한다.
- JWT (JSON Web Token)를 사용하여, 각 서비스에서 토큰 기반 인증을 통해 인증 정보를 전달하고 확인한다.
💡 예시
- Auth0, Okta 또는 Keycloak 같은 외부 인증 서버를 사용하여 인증을 처리하고, JWT를 사용해 각 서비스로 인증 정보를 전달.
API Gateway를 통한 인증 처리
API Gateway는 모든 외부 요청을 처리하는 진입점 역할을 하므로, 인증을 처리하는 중앙 지점으로 활용할 수 있습니다.
- 인증 토큰 검증: API Gateway에서 JWT를 검증한 후, 유효한 요청만 서비스로 전달.
- 싱글 사인온(SSO): API Gateway가 인증 정보를 기반으로 SSO(싱글 사인온)을 관리할 수 있도록 설정할 수 있습니다.
💡 예시
- Spring Cloud Gateway 또는 Zuul을 사용하여 JWT 검증을 API Gateway에서 수행하고, 인증된 요청만 내부 마이크로서비스로 전달.
서비스 간 인증 (Service-to-Service Authentication)
마이크로서비스 간에도 인증이 필요합니다. 이를 위해 mTLS (Mutual TLS) 또는 API 키를 사용하는 방법이 있다.
- mTLS (Mutual TLS): 서비스 간에 서버와 클라이언트가 서로 인증하는 방식으로 보안을 강화할 수 있습니다.
- API 키: 마이크로서비스 간 인증에 고유한 API 키를 사용하여 인증을 처리할 수 있습니다.
💡 예시
- Istio와 같은 서비스 메쉬를 사용하여 mTLS로 서비스 간 인증을 처리하고, API Gateway를 통해 외부 서비스와의 인증을 관리.
마이크로서비스가 애플리케이션의 회복 탄력성을 향상시키는 방법은 무엇인가?
마이크로서비스 아키텍처는 각 서비스가 독립적으로 실행되기 때문에 시스템의 일부 서비스에 장애가 발생해도 전체 시스템에 미치는 영향을 최소화할 수 있다. 이를 통해 애플리케이션의 회복 탄력성을 크게 향상시킬 수 있다. 아래는 마이크로서비스가 애플리케이션의 회복 탄력성을 향상시키는 구체적인 방법들이다.
독립적인 서비스 설계
마이크로서비스는 작고 독립적인 서비스로 구성되기 때문에 한 서비스의 장애가 전체 시스템에 미치는 영향을 제한할 수 있다.
- 장애 격리: 한 서비스에서 오류가 발생해도 다른 서비스에는 영향을 주지 않도록 격리.
- 장애 분리: 각 서비스가 다른 서비스에 의존하지 않거나, 의존성을 비동기식 통신 (예: Kafka, SQS) 으로 처리하여 장애 전파를 차단.
💡 예시
- 주문 서비스와 결제 서비스가 독립적으로 실행되며, 결제 서비스가 다운되더라도 주문 서비스는 영향을 받지 않음.
장애 대응 전략 (Failover 및 재시도 메커니즘)
마이크로서비스 아키텍처에서는 장애 발생 시 자동으로 다른 시스템으로 전환하거나, 재시도 메커니즘을 통해 지속적인 서비스 제공을 보장한다.
- 자동 장애 조치(Failover): 장애가 발생하면 다른 인스턴스로 자동 전환하여 서비스 중단을 최소화.
- 재시도 (Retry): 실패한 요청에 대해 일정 횟수까지 자동 재시도를 시도하여 일시적인 장애를 회피.
Failover 및 복구 전략
장애 발생 시 서비스가 자동으로 복구되도록 설계하는 것이 중요하다. 이를 위해 여러 가지 전략을 사용할 수 있다.
- 복제(Replication): 여러 인스턴스를 두어 하나의 인스턴스에 장애가 발생해도 다른 인스턴스가 이를 대체하도록 한다.
- 자동화된 복구: Kubernetes와 같은 플랫폼에서 Pod가 죽으면 자동으로 새로운 인스턴스를 띄우는 방식이다.
- 데이터 백업: 장애 발생 시 데이터 손실을 방지하기 위해 주기적으로 데이터를 백업하고, 장애 시 빠르게 복구할 수 있도록 한다.
재시도(Retry) 패턴
재시도 패턴은 서비스가 일시적으로 장애가 발생했을 때, 일정 횟수만큼 자동으로 다시 시도하는 방식이다. 이 방식은 장애가 일시적인 경우 시스템을 안정적으로 유지하는 데 유용하다.
- Exponential Backoff: 재시도 간 간격을 점차 늘려 가는 방식이다. 예를 들어, 첫 번째 재시도는 1초, 두 번째 재시도는 2초, 세 번째 재시도는 4초 등으로 늘려서 장애가 복구될 수 있는 시간을 확보한다.
- Max Retries 설정: 최대 재시도 횟수를 설정하여 무한 재시도를 방지하고, 일정 횟수 이상 실패하면 Circuit Breaker를 활성화하도록 설정한다.
@Retry(name = "serviceA", fallbackMethod = "fallbackMethod")
fun callExternalService(): String {
return externalService.getData()
}
fun fallbackMethod(exception: Throwable): String {
return "Fallback response"
}
Circuit Breaker 패턴
Circuit Breaker는 시스템에서 장애가 발생하는 것을 감지하고 그 장애가 다른 시스템으로 확산되는 것을 방지하는 패턴이다. 이 패턴을 통해 서비스 간의 의존성을 격리하여 서비스 안정성을 유지하고, 빠르게 장애를 탐지하여 장애 대응을 할 수 있다.
작동 원리
Circuit Breaker는 Closed, Open, Half-Open 총 3가지 상태로 작동한다.
- Closed: 정상적인 상태로, 서비스 호출이 이루어집니다.
- Open: 장애가 발생하면 Circuit Breaker가 열리고 더 이상 호출을 차단합니다. 이때 서비스가 임시적으로 오류 상태에 있다고 판단합니다.
- Half-Open: 일정 시간이 지나면 시스템이 복구되었는지 점검하는 상태입니다. 이때 일부 요청을 보내어 시스템의 상태를 점검합니다. 정상적으로 응답하면 Closed 상태로 복귀하고, 여전히 장애가 지속되면 다시 Open 상태로 전환됩니다.
Circuit Breaker 패턴 구현 방법
- Resilience4J: Resilience4J는 Java 환경에서 사용되는 라이브러리로, Circuit Breaker, Retry, Rate Limiter, Bulkhead 등 다양한 복원력 패턴을 제공합니다.
- 설정: @CircuitBreaker 애노테이션을 사용하여 메서드에 Circuit Breaker를 적용할 수 있습니다.
- 동작: 일정한 실패 비율을 넘어서면 해당 서비스 호출을 차단하고, 자동으로 서비스 복구를 시도합니다.
- Hystrix: Hystrix는 Netflix에서 개발한 라이브러리로, 마이크로서비스 간의 장애 격리를 구현하기 위해 많이 사용됩니다. 그러나 Hystrix는 현재 End of Life (EOL) 상태입니다. 대신 Resilience4J나 Spring Cloud Circuit Breaker가 대안으로 많이 사용됩니다.
@CircuitBreaker(name = "serviceA", fallbackMethod = "fallbackMethod")
fun callExternalService(): String {
// 외부 서비스 호출
return externalService.getData()
}
fun fallbackMethod(exception: Throwable): String {
// 장애 발생 시 반환될 대체 로직
return "Fallback response"
}
마이크로서비스에서 시스템을 확장하려면 어떻게 해야할까?
마이크로서비스에서 시스템을 확장하는 것은 서비스의 성능과 가용성을 유지하면서, 새로운 기능을 추가하거나 사용량의 증가를 처리하는 데 필요한 다양한 전략을 적용하는 과정이다. 다음은 마이크로서비스에서 시스템을 확장하기 위한 주요 방법이다.
수평적 확장 (Horizontal Scaling)
수평적 확장은 여러 인스턴스나 노드를 추가하여 서비스를 확장하는 방법이다. 이를 통해 트래픽을 분산시키고 성능을 개선할 수 있다.
- Kubernetes (K8s): 자동화된 컨테이너 배포와 스케일링을 지원한다. Kubernetes는 서비스의 트래픽 양에 맞춰 자동으로 새로운 파드(pod)를 배포할 수 있다.
- 로드 밸런싱: 서비스의 요청을 여러 서버 인스턴스로 분배하는 로드 밸런서를 사용하여 부하를 분산한다.
- 서버리스: 서버리스 환경에서는 필요할 때마다 새로운 인스턴스를 자동으로 배포하고, 트래픽을 자동으로 처리할 수 있다.
서비스 분할 (Decomposition) 및 독립적인 확장
- 도메인 별 마이크로서비스: 마이크로서비스를 도메인 별로 분리하고 각 서비스가 독립적으로 확장되도록 설계한다. 예를 들어, 사용자 서비스와 결제 서비스는 서로 독립적으로 확장될 수 있다.
- 마이크로서비스의 독립적 배포: 각 서비스는 다른 서비스의 영향을 받지 않고 독립적으로 배포되고 확장될 수 있어야 한다. 이를 위해 CI/CD 파이프라인을 구축하고, 자동화된 배포 및 롤백을 지원한다.
데이터베이스 확장
마이크로서비스에서 데이터베이스를 확장하는 것은 중요한 요소이다. 데이터베이스의 확장 전략은 데이터의 분할과 복제 등을 포함한다.
- 샤딩 (Sharding): 데이터베이스를 수평으로 분할하여 각 샤드에 데이터를 분배한다. 예를 들어, 사용자 ID를 기준으로 데이터를 여러 데이터베이스에 분산할 수 있다.
- 데이터 복제: 데이터를 여러 지역에 분산시켜 가용성과 내구성을 향상시킨다. 이를 통해 읽기 성능을 개선하고, 장애 발생 시 자동으로 다른 복제본으로 전환할 수 있다.
- 이벤트 소싱: 이벤트 로그를 통해 시스템 상태를 추적하고, 데이터베이스를 확장하면서도 상태를 복원할 수 있는 방식이다.
캐시와 데이터 최적화
확장성을 높이기 위해 캐싱을 사용하여 데이터베이스의 부하를 줄이고, 응답 시간을 개선할 수 있다.
- Redis, Memcached: 자주 조회되는 데이터를 캐시하여 데이터베이스 쿼리 횟수를 줄이고, 성능을 향상시킬 수 있다.
- CDN (Content Delivery Network): 정적 자산(이미지, 비디오 등)을 전 세계 여러 위치에 분배하여 콘텐츠 로딩 시간을 단축시킬 수 있다.
마이크로서비스 시스템을 확장하는 방법은 여러 가지가 있습니다. 수평적 확장, 서비스 분할, 이벤트 기반 아키텍처, 캐시 사용, 장애 복구 및 고가용성 전략 등 다양한 방법을 통해 성능을 개선하고, 확장성과 가용성을 높일 수 있다. 이를 위해 자동화, 모니터링, 최적화와 같은 전략이 필수적이며, 클라우드 기반의 Kubernetes와 같은 도구를 적극 활용하여 시스템을 동적으로 확장하는 것이 중요합니다.
전담 프론트엔드 팀 구축부터 BFF, 그래프 QL을 사용하는 데까지 마이크로서비스와 사용자 인터페이스가 어떻게 함께 작동할 수 있을까?
전담 프론트엔드 팀이 마이크로서비스 기반의 시스템을 다루는 과정에서, 사용자 인터페이스(UI)는 백엔드 서비스들과 원활하게 통합되어야 한다. BFF (Backend for Frontend)와 GraphQL을 활용하는 것은 이러한 통합을 원활하게 하고, 다양한 사용자 경험을 제공하는 데 매우 효과적인 방법 중 하나다. 이를 통해 프론트엔드 개발자와 백엔드 서비스 간의 효율적인 협업이 가능해집니다.
전담 프론트엔드 팀 구축
프론트엔드 팀은 사용자 인터페이스를 담당하는 중요한 역할을 한다. 특히 마이크로서비스 아키텍처에서는 프론트엔드와 백엔드의 의존성을 명확히 분리하고, 서비스 간 인터페이스를 통합하는 방식이 중요하다.
전담 프론트엔드 팀의 역할
- UI/UX 설계: 다양한 사용자 요구사항을 충족시킬 수 있는 직관적이고 반응적인 UI 설계.
- 서비스 통합: 다양한 마이크로서비스로부터 데이터를 집합적이고 효율적으로 가져와 사용자에게 제공.
- API 관리: 프론트엔드와의 통신을 담당하는 API에 대한 관리와 최적화.
전담 프론트엔드 팀은 마이크로서비스와의 통합을 위해 다양한 패턴을 활용할 수 있다.
BFF (Backend for Frontend)
BFF는 프론트엔드와 백엔드 간의 중간 레이어 역할을 하여 클라이언트의 요구에 맞춘 API를 제공한다. 이는 각각의 클라이언트 애플리케이션(예: 웹, 모바일)에서 필요로 하는 데이터와 기능을 최적화하는 데 사용된다.
BFF의 필요성
- 복잡한 데이터 처리: 여러 마이크로서비스에서 데이터를 가져오는 것을 프론트엔드 단에서 처리하지 않고, BFF 서버가 처리합니다.
- 클라이언트 맞춤형 API 제공: 각 클라이언트(모바일 앱, 웹 앱 등)에 최적화된 API를 제공하여 불필요한 데이터 처리와 요청을 줄입니다.
- 서비스 간 집합 데이터 반환: 여러 서비스에서 제공하는 정보를 하나의 API 호출로 묶어 제공함으로써 클라이언트에서 여러 번 요청을 보내지 않아도 됩니다.
BFF 패턴 적용 예시
- 백엔드 마이크로서비스들이 각자 특정 도메인(예: 주문, 결제, 사용자)을 처리합니다.
- BFF는 각 프론트엔드의 요구 사항에 맞게 이러한 여러 서비스를 결합하여 하나의 API로 반환합니다.
- 예를 들어, 웹과 모바일에서 다른 데이터가 필요할 때, 웹용 BFF와 모바일용 BFF를 별도로 관리하여 각각 최적화된 API를 제공합니다.
// BFF 서버 (Kotlin + Spring Boot 예시)
@RestController
class UserController(private val userService: UserService) {
@GetMapping("/user-profile")
fun getUserProfile(@RequestParam userId: Long): UserProfile {
return userService.getUserProfile(userId) // 여러 서비스 호출을 조합
}
}
GraphQL을 통한 데이터 통합
GraphQL은 페이스북에서 개발한 데이터 쿼리 언어로, 클라이언트가 필요한 데이터를 정확히 요청할 수 있도록 해준다. 마이크로서비스 환경에서는 여러 서비스에서 제공하는 데이터를 하나의 API로 집합적이고 효율적으로 제공한다.
GraphQL의 장점
- 클라이언트 맞춤형 데이터 반환: 클라이언트는 필요한 데이터만 요청할 수 있으며, 서버는 그에 맞춰 응답을 최적화합니다.
- 단일 API 엔드포인트: GraphQL은 여러 서비스를 통합하여 단일 API 엔드포인트로 데이터를 제공합니다.
- 쿼리 언어: 클라이언트는 쿼리 언어를 사용하여 원하는 데이터를 정확히 요청합니다.
GraphQL과 마이크로서비스 통합 예시
- 각 마이크로서비스는 자체적으로 데이터를 처리하고, GraphQL 서버를 통해 데이터를 클라이언트에게 전달합니다.
- GraphQL 서버는 여러 마이크로서비스에 대해 필요한 데이터만을 요청하고 조합하여 반환합니다.
# 클라이언트가 요청할 GraphQL 쿼리 예시
query {
user(id: 123) {
name
email
orders {
product
price
}
}
}
위 쿼리는 user 데이터와 그에 관련된 orders 데이터를 한번에 가져온다.
GraphQL 서버는 내부적으로 여러 마이크로서비스에서 데이터를 끌어와 조합하여 반환한다.
BFF + GraphQL 활용
BFF와 GraphQL을 함께 사용하면 마이크로서비스와 UI 간의 상호작용이 더욱 효율적이고 직관적이다. BFF는 프론트엔드 요구에 맞춘 API를 제공하고, GraphQL은 마이크로서비스에서 데이터를 효율적으로 집합하여 제공할 수 있다. 이를 통해 서비스의 복잡도를 줄이면서도 클라이언트의 다양한 요구사항을 충족시킬 수 있다.
BFF + GraphQL 통합 예시
- BFF 서버에서 GraphQL 엔드포인트를 제공한다.
- 프론트엔드는 GraphQL을 사용하여 필요한 데이터를 요청한다.
- BFF는 GraphQL 서버와 연동하여 필요한 마이크로서비스 데이터를 조합해 클라이언트에게 응답한다.
레퍼런스
'기타' 카테고리의 다른 글
1분강의 TechSpec (1) | 2025.02.19 |
---|---|
조편성 TechSpec과 테스트케이스 (0) | 2024.12.24 |
[기타] Tech Spec으로 프로젝트 성공 확률 높이기 (5) | 2024.11.10 |
[리팩토링 2판] Chapter 6 기본적인 리팩터링 (0) | 2024.08.26 |
[리팩토링 2판] Chapter 3 코드에서 나는 악취 (2) | 2024.08.12 |
댓글