🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

분산 시스템의 과제 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 21. · 3 Views

분산 시스템의 과제 완벽 가이드

마이크로서비스 아키텍처에서 반드시 알아야 할 분산 시스템의 핵심 문제들을 실무 중심으로 정리했습니다. 서비스 간 통신, 장애 전파, 데이터 일관성 등 실제 현장에서 자주 만나는 과제를 이해하기 쉽게 설명합니다.


목차

  1. 분산_시스템이란
  2. 서비스_간_통신_문제
  3. 장애_전파와_연쇄_실패
  4. 네트워크_지연과_타임아웃
  5. 데이터_일관성_문제
  6. 분산_시스템_설계_원칙

1. 분산 시스템이란

김개발 씨는 이제 막 회사의 새 프로젝트에 투입되었습니다. 팀장님이 "우리는 마이크로서비스로 구축된 분산 시스템을 운영합니다"라고 말씀하셨는데, 대체 분산 시스템이 뭘까요?

분산 시스템은 여러 개의 독립적인 컴퓨터가 네트워크로 연결되어 하나의 시스템처럼 동작하는 구조입니다. 마치 회사의 각 부서가 독립적으로 일하면서도 하나의 목표를 향해 협력하는 것과 같습니다.

각 서비스는 자신의 역할에 집중하면서 필요할 때 서로 통신합니다.

다음 코드를 살펴봅시다.

// 주문 서비스 - 분산 시스템의 한 구성 요소
@RestController
public class OrderController {
    @Autowired
    private ProductService productService;  // 다른 서비스와 통신

    @PostMapping("/orders")
    public Order createOrder(@RequestBody OrderRequest request) {
        // 상품 서비스에서 재고 확인 (네트워크 통신 발생)
        Product product = productService.getProduct(request.getProductId());

        // 주문 생성 로직
        Order order = new Order(request.getUserId(), product);

        return orderRepository.save(order);
    }
}

김개발 씨는 지난주까지 모놀리식 애플리케이션을 개발했습니다. 모든 기능이 하나의 프로젝트 안에 들어있어서 편했습니다.

그런데 새 프로젝트는 전혀 다른 구조였습니다. 팀장님이 화면을 켜고 설명해주셨습니다.

"여기 보세요. 주문 서비스, 상품 서비스, 결제 서비스, 배송 서비스가 각각 다른 서버에서 실행되고 있죠.

이게 바로 분산 시스템입니다." 그렇다면 분산 시스템이란 정확히 무엇일까요? 쉽게 비유하자면, 분산 시스템은 마치 대형 병원과 같습니다.

접수처, 진료실, 검사실, 약국이 각각 다른 공간에서 독립적으로 운영되지만, 환자 한 명을 치료하기 위해 서로 정보를 주고받으며 협력합니다. 각 부서는 자신의 전문 분야에 집중하면서도 필요할 때 다른 부서와 소통합니다.

분산 시스템이 없던 시절에는 어땠을까요? 모든 기능을 하나의 거대한 애플리케이션에 넣어야 했습니다.

작은 기능 하나를 수정해도 전체 시스템을 재배포해야 했습니다. 더 큰 문제는 일부 기능에 트래픽이 몰려도 전체 서버를 늘려야 한다는 점이었습니다.

결제 기능만 부하가 높아도 불필요하게 상품 조회 서버까지 증설해야 했습니다. 바로 이런 문제를 해결하기 위해 분산 시스템이 등장했습니다.

분산 시스템을 사용하면 독립적인 배포가 가능해집니다. 주문 서비스만 수정했다면 주문 서비스만 배포하면 됩니다.

또한 선택적 확장도 얻을 수 있습니다. 결제 서비스에만 트래픽이 몰린다면 결제 서비스만 서버를 증설하면 됩니다.

무엇보다 기술 스택의 자유라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 ProductService를 주입받는 부분을 보면 다른 서비스와 통신할 준비를 하는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 productService.getProduct() 메서드 호출에서는 네트워크를 통해 상품 서비스에 HTTP 요청을 보내는 동작이 일어납니다. 마지막으로 생성된 주문 객체가 데이터베이스에 저장되고 반환됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 쇼핑몰 서비스를 개발한다고 가정해봅시다.

블랙프라이데이 같은 대규모 이벤트 기간에는 결제 서비스에 엄청난 부하가 집중됩니다. 분산 시스템을 활용하면 결제 서비스만 자동으로 스케일 아웃하여 부하를 분산시킬 수 있습니다.

넷플릭스, 아마존, 쿠팡 같은 대형 서비스들이 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 모든 프로젝트를 분산 시스템으로 만들려는 것입니다. 이렇게 하면 불필요한 복잡도가 증가하고 운영 비용이 크게 늘어날 수 있습니다.

따라서 프로젝트의 규모와 요구사항을 고려하여 적절한 시점에 도입해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

팀장님의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 각 팀이 독립적으로 개발하고 배포할 수 있었군요!" 분산 시스템을 제대로 이해하면 더 유연하고 확장 가능한 아키텍처를 설계할 수 있습니다.

하지만 분산 시스템에는 여러 가지 과제가 존재합니다. 지금부터 그 과제들을 하나씩 살펴보겠습니다.

실전 팁

💡 - 작은 프로젝트라면 모놀리식으로 시작하고, 필요할 때 분산 시스템으로 전환하세요

  • 각 서비스의 경계는 비즈니스 도메인을 기준으로 나누는 것이 좋습니다
  • 분산 시스템 도입 전에 팀의 DevOps 역량을 먼저 점검하세요

2. 서비스 간 통신 문제

김개발 씨가 주문 서비스를 테스트하던 중 이상한 에러를 발견했습니다. "상품 서비스에 연결할 수 없습니다"라는 메시지가 떴습니다.

같은 코드가 어제까지는 잘 작동했는데 무슨 일일까요?

서비스 간 통신은 분산 시스템에서 가장 중요하면서도 까다로운 부분입니다. 네트워크를 통해 다른 서비스를 호출할 때 IP 주소가 바뀌거나, 서비스가 다운되거나, 네트워크가 불안정할 수 있습니다.

이런 불확실성을 적절히 다루는 것이 핵심입니다.

다음 코드를 살펴봅시다.

// RestTemplate을 사용한 서비스 간 통신 (문제가 있는 코드)
@Service
public class ProductService {
    private RestTemplate restTemplate = new RestTemplate();

    public Product getProduct(Long productId) {
        // 하드코딩된 URL - 서비스 IP가 바뀌면 동작하지 않음
        String url = "http://192.168.1.100:8080/products/" + productId;

        // 타임아웃, 재시도 로직이 없음 - 서비스가 느리면 무한 대기
        Product product = restTemplate.getForObject(url, Product.class);

        return product;
    }
}

김개발 씨는 당황했습니다. 분명 어제까지는 잘 작동했는데 오늘 갑자기 연결이 안 됩니다.

선배 박시니어 씨를 불러 상황을 설명했습니다. 박시니어 씨가 코드를 보더니 한숨을 쉬었습니다.

"아, IP 주소를 하드코딩했네요. 상품 서비스가 다른 서버로 이동했거든요.

이게 바로 분산 시스템의 첫 번째 과제입니다." 그렇다면 서비스 간 통신 문제란 정확히 무엇일까요? 쉽게 비유하자면, 서비스 간 통신은 마치 친구에게 전화를 거는 것과 같습니다.

친구가 전화번호를 바꿨는데 당신이 모른다면 연결이 안 됩니다. 친구가 전화를 꺼두었다면 통화할 수 없습니다.

전파가 약한 지하철에서는 통화 중에 끊길 수도 있습니다. 이처럼 서비스 간 통신도 다양한 문제 상황에 노출되어 있습니다.

하드코딩된 주소를 사용하던 시절에는 어땠을까요? 운영팀이 서버를 추가하거나 이동할 때마다 개발팀에 연락해야 했습니다.

코드를 수정하고 다시 배포하는 과정을 거쳐야 했습니다. 더 큰 문제는 서비스의 여러 인스턴스가 실행될 때 어느 서버로 요청을 보낼지 결정하기 어려웠다는 점입니다.

로드밸런싱을 위해 복잡한 설정이 필요했습니다. 바로 이런 문제를 해결하기 위해 서비스 디스커버리가 등장했습니다.

서비스 디스커버리를 사용하면 동적 서비스 탐색이 가능해집니다. 각 서비스는 시작할 때 자신의 위치를 등록하고, 다른 서비스는 이름만으로 찾을 수 있습니다.

또한 자동 로드밸런싱도 얻을 수 있습니다. 같은 서비스의 여러 인스턴스 중 하나를 자동으로 선택합니다.

무엇보다 헬스체크라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 하드코딩된 URL을 보면 서비스의 IP와 포트가 고정되어 있는 것을 알 수 있습니다. 이 부분이 문제입니다.

다음으로 restTemplate.getForObject() 호출에서는 타임아웃이나 재시도 설정 없이 무작정 기다리는 동작이 일어납니다. 마지막으로 예외 처리가 없어 서비스가 다운되면 전체 요청이 실패합니다.

실제 현업에서는 어떻게 해결할까요? 예를 들어 넷플릭스는 Eureka라는 서비스 디스커버리를 개발했습니다.

각 마이크로서비스는 Eureka에 자신을 등록하고, 다른 서비스를 호출할 때는 서비스 이름만 사용합니다. "PRODUCT-SERVICE"라는 이름으로 호출하면 Eureka가 실제 IP 주소를 알려줍니다.

쿠팡, 토스 같은 국내 대형 서비스들도 비슷한 패턴을 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 서비스 디스커버리만 도입하면 모든 문제가 해결된다고 생각하는 것입니다. 이렇게 하면 네트워크 타임아웃이나 일시적인 장애에 대한 처리가 여전히 부족할 수 있습니다.

따라서 타임아웃 설정, 재시도 로직, 써킷 브레이커 패턴을 함께 적용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨는 Eureka를 활용한 코드를 보여주었습니다. "이렇게 서비스 이름으로 호출하면 IP가 바뀌어도 문제없어요." 서비스 간 통신 문제를 제대로 이해하면 더 안정적인 분산 시스템을 구축할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Spring Cloud Netflix Eureka나 Consul 같은 검증된 서비스 디스커버리를 사용하세요

  • 모든 외부 서비스 호출에는 반드시 타임아웃을 설정하세요
  • Feign Client를 사용하면 선언적으로 서비스 간 통신을 구현할 수 있습니다

3. 장애 전파와 연쇄 실패

김개발 씨는 월요일 아침 출근하자마자 긴급 호출을 받았습니다. 결제 서비스 하나가 느려졌을 뿐인데 주문, 배송, 심지어 상품 조회까지 모두 다운되었다고 합니다.

대체 무슨 일이 벌어진 걸까요?

장애 전파는 분산 시스템에서 한 서비스의 장애가 다른 서비스로 퍼져나가는 현상입니다. 마치 도미노처럼 한 조각이 넘어지면 전체가 무너지는 것과 같습니다.

이를 방지하기 위해서는 서비스 간 격리와 빠른 실패 전략이 필요합니다.

다음 코드를 살펴봅시다.

// Circuit Breaker 패턴으로 장애 전파 차단
@Service
public class PaymentService {
    @Autowired
    private ExternalPaymentGateway paymentGateway;

    @CircuitBreaker(name = "payment", fallbackMethod = "paymentFallback")
    public PaymentResult processPayment(PaymentRequest request) {
        // 외부 결제 게이트웨이 호출 - 느리거나 실패할 수 있음
        return paymentGateway.charge(request);
    }

    // 장애 발생 시 대체 로직 - 전체 시스템 다운 방지
    private PaymentResult paymentFallback(PaymentRequest request, Exception ex) {
        // 결제 실패를 우아하게 처리하고 대기열에 저장
        return PaymentResult.pending("결제 일시 지연, 곧 처리됩니다");
    }
}

김개발 씨는 모니터링 대시보드를 확인했습니다. 결제 서비스의 응답 시간이 30초를 넘어가고 있었습니다.

그런데 이상한 점은 결제와 전혀 관계없는 상품 조회 서비스까지 먹통이 되었다는 것입니다. 팀장님이 급히 상황실로 모였습니다.

"이게 바로 장애 전파입니다. 결제 서비스가 느려지니까 주문 서비스가 결제 응답을 기다리느라 스레드를 모두 소진했어요.

그 결과 주문 서비스도 다운되었고, 주문 서비스를 호출하던 다른 서비스들도 연쇄적으로 멈췄습니다." 그렇다면 장애 전파란 정확히 무엇일까요? 쉽게 비유하자면, 장애 전파는 마치 교통 체증과 같습니다.

고속도로 한 구간에서 사고가 나면 그 뒤의 차들이 모두 멈춥니다. 시간이 지나면 체증이 점점 뒤로 퍼져 나가고, 결국 진입로까지 막히게 됩니다.

심지어 옆 차선이나 평행한 도로까지 영향을 받습니다. 이처럼 분산 시스템에서도 한 서비스의 문제가 전체 시스템을 마비시킬 수 있습니다.

장애 전파 방지 장치가 없던 시절에는 어땠을까요? 한 서비스가 느려지면 그 서비스를 호출하는 모든 서비스가 대기 상태에 빠졌습니다.

스레드는 응답을 기다리며 묶여있고, 새로운 요청을 처리할 수 없게 되었습니다. 더 큰 문제는 이런 상황에서 시스템 전체가 복구 불가능한 상태로 빠진다는 점이었습니다.

한밤중에 개발자들이 긴급 출동하여 서버를 재시작하는 일이 잦았습니다. 바로 이런 문제를 해결하기 위해 Circuit Breaker 패턴이 등장했습니다.

Circuit Breaker를 사용하면 빠른 실패가 가능해집니다. 문제가 있는 서비스를 계속 호출하지 않고 즉시 실패를 반환합니다.

또한 자동 복구도 얻을 수 있습니다. 일정 시간 후 서비스가 정상화되면 자동으로 호출을 재개합니다.

무엇보다 장애 격리라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 @CircuitBreaker 어노테이션을 보면 결제 서비스 호출을 모니터링하고 있는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 paymentGateway.charge() 호출에서는 실제 외부 결제 처리가 일어나지만, 실패율이 높아지면 Circuit이 열립니다. 마지막으로 fallback 메서드가 호출되어 우아한 실패 처리를 제공합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 넷플릭스는 Hystrix라는 Circuit Breaker 라이브러리를 개발했습니다.

각 서비스 호출을 Hystrix Command로 감싸서 실행하면, 실패율이 임계값을 넘으면 자동으로 Circuit이 열립니다. Circuit이 열리면 더 이상 실제 서비스를 호출하지 않고 즉시 fallback 로직을 실행합니다.

이렇게 하면 결제 서비스가 다운되어도 주문 서비스는 "결제 대기 중" 상태로 주문을 받을 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 모든 메서드에 Circuit Breaker를 적용하는 것입니다. 이렇게 하면 오히려 시스템이 복잡해지고 디버깅이 어려워질 수 있습니다.

따라서 외부 시스템을 호출하거나 장애 가능성이 높은 부분에만 선택적으로 적용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

팀장님의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 Resilience4j 같은 라이브러리가 필요하군요!" 장애 전파를 제대로 이해하면 더 회복력 있는 시스템을 설계할 수 있습니다.

한 서비스의 문제가 전체 시스템을 무너뜨리지 않도록 방어하는 것이 중요합니다.

실전 팁

💡 - Resilience4j는 Hystrix의 후속 라이브러리로 더 가볍고 현대적입니다

  • Circuit Breaker의 임계값은 모니터링 데이터를 기반으로 조정하세요
  • Fallback 메서드는 빠르게 실행되어야 하며, 다른 외부 서비스를 호출하면 안 됩니다

4. 네트워크 지연과 타임아웃

김개발 씨는 사용자로부터 "가끔 주문이 10초 넘게 걸린다"는 불만을 받았습니다. 로그를 확인해보니 결제 서비스 호출에서 대부분의 시간을 소비하고 있었습니다.

어떻게 해결해야 할까요?

네트워크 지연은 분산 시스템에서 피할 수 없는 현실입니다. 서비스 간 통신에는 항상 시간이 걸리고, 때로는 예상보다 훨씬 오래 걸릴 수 있습니다.

적절한 타임아웃 설정과 비동기 처리가 사용자 경험을 크게 개선합니다.

다음 코드를 살펴봅시다.

// 타임아웃 설정과 비동기 처리
@Service
public class OrderService {
    @Autowired
    private WebClient paymentClient;

    public Mono<OrderResponse> createOrderAsync(OrderRequest request) {
        return paymentClient
            .post()
            .uri("/payments")
            .bodyValue(request.getPaymentInfo())
            // 3초 타임아웃 설정 - 무한 대기 방지
            .retrieve()
            .bodyToMono(PaymentResult.class)
            .timeout(Duration.ofSeconds(3))
            // 타임아웃 발생 시 대체 처리
            .onErrorResume(TimeoutException.class, ex ->
                Mono.just(PaymentResult.pending("처리 중입니다")))
            .map(payment -> new OrderResponse(request, payment));
    }
}

김개발 씨는 프로덕션 환경의 로그를 분석했습니다. 평균 응답 시간은 200ms인데, 가끔 20초가 넘는 요청이 있었습니다.

그 시간 동안 사용자는 하얀 화면만 바라보며 기다리고 있었을 것입니다. 선배 박시니어 씨가 코드를 리뷰하더니 고개를 저었습니다.

"타임아웃 설정이 없네요. 결제 서비스가 느려지면 사용자는 무작정 기다려야 합니다.

이게 바로 네트워크 지연 문제입니다." 그렇다면 네트워크 지연 문제란 정확히 무엇일까요? 쉽게 비유하자면, 네트워크 지연은 마치 우편 배달과 같습니다.

같은 도시 내에서는 하루 만에 도착하지만, 섬 지역으로는 일주일이 걸릴 수 있습니다. 때로는 태풍으로 배가 뜨지 못해 더 오래 걸리기도 합니다.

중요한 것은 무작정 기다리는 것이 아니라, 적절한 시간 안에 포기하고 다른 방법을 찾는 것입니다. 이처럼 서비스 호출도 무한정 기다리면 안 됩니다.

타임아웃이 없던 시절에는 어땠을까요? 느린 서비스 하나가 전체 시스템의 응답성을 망쳤습니다.

사용자는 몇 분씩 기다리다가 결국 브라우저를 닫아버렸습니다. 더 큰 문제는 서버의 스레드가 응답을 기다리며 묶여있어 새로운 요청을 처리할 수 없다는 점이었습니다.

결국 정상적인 요청까지 처리하지 못하는 상황이 발생했습니다. 바로 이런 문제를 해결하기 위해 타임아웃과 비동기 처리가 필수가 되었습니다.

타임아웃을 설정하면 예측 가능한 응답 시간이 가능해집니다. 최악의 경우에도 3초 안에는 응답이 돌아옵니다.

또한 리소스 보호도 얻을 수 있습니다. 스레드가 무한정 대기하지 않아 다른 요청을 처리할 수 있습니다.

무엇보다 더 나은 사용자 경험이라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 WebClient를 사용한 부분을 보면 논블로킹 방식으로 HTTP 요청을 보내는 것을 알 수 있습니다. 이 부분이 핵심입니다.

다음으로 timeout(Duration.ofSeconds(3)) 호출에서는 3초가 지나면 자동으로 TimeoutException이 발생하는 동작이 일어납니다. 마지막으로 onErrorResume을 통해 타임아웃 발생 시에도 우아하게 처리합니다.

실제 현업에서는 어떻게 설정할까요? 예를 들어 토스는 모든 외부 API 호출에 엄격한 타임아웃을 적용합니다.

결제 같은 중요한 기능은 3초, 추천이나 개인화 같은 부가 기능은 1초로 설정합니다. 타임아웃이 발생하면 캐시된 데이터를 보여주거나 기본값을 사용합니다.

이렇게 하면 일부 서비스가 느려져도 전체 사용자 경험은 유지됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 타임아웃을 너무 짧게 설정하는 것입니다. 이렇게 하면 정상적인 요청마저 타임아웃으로 실패할 수 있습니다.

따라서 실제 응답 시간을 모니터링하여 P95나 P99 수치를 기반으로 적절한 값을 설정해야 합니다. 또한 재시도 로직과 함께 사용할 때는 전체 타임아웃도 고려해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언을 듣고 타임아웃을 적용한 김개발 씨는 만족스러운 표정을 지었습니다.

"이제 결제 서비스가 느려져도 사용자는 빠르게 피드백을 받을 수 있네요!" 네트워크 지연을 제대로 이해하면 더 반응성 좋은 시스템을 만들 수 있습니다. 타임아웃은 선택이 아닌 필수입니다.

실전 팁

💡 - Connection Timeout과 Read Timeout을 별도로 설정하세요

  • 타임아웃 값은 모니터링 데이터를 기반으로 결정하되, P95 기준으로 설정하는 것이 좋습니다
  • Spring WebFlux나 Reactor를 활용하면 비동기 처리가 더 쉬워집니다

5. 데이터 일관성 문제

김개발 씨는 심각한 버그 리포트를 받았습니다. 사용자가 주문했는데 재고는 차감되었지만 주문 내역이 없다고 합니다.

주문 서비스와 재고 서비스가 각각 다른 데이터베이스를 사용하는데, 어떻게 일관성을 유지할 수 있을까요?

데이터 일관성은 분산 시스템에서 가장 어려운 문제 중 하나입니다. 각 서비스가 독립적인 데이터베이스를 사용하면 트랜잭션을 하나로 묶을 수 없습니다.

Saga 패턴이나 이벤트 소싱 같은 특별한 전략이 필요합니다.

다음 코드를 살펴봅시다.

// Saga 패턴으로 분산 트랜잭션 처리
@Service
public class OrderSagaService {
    @Autowired
    private InventoryService inventoryService;
    @Autowired
    private PaymentService paymentService;
    @Autowired
    private OrderRepository orderRepository;

    public OrderResult createOrder(OrderRequest request) {
        Order order = null;
        try {
            // 1단계: 재고 차감
            inventoryService.decreaseStock(request.getProductId());

            // 2단계: 결제 처리
            paymentService.charge(request.getPaymentInfo());

            // 3단계: 주문 생성
            order = orderRepository.save(new Order(request));

            return OrderResult.success(order);
        } catch (Exception ex) {
            // 보상 트랜잭션: 실패 시 이미 처리된 작업을 되돌림
            if (order != null) orderRepository.delete(order);
            inventoryService.increaseStock(request.getProductId());
            return OrderResult.failed("주문 실패");
        }
    }
}

김개발 씨는 데이터베이스를 직접 확인했습니다. 재고 테이블에는 수량이 줄어들어 있는데, 주문 테이블에는 해당 주문이 없었습니다.

결제는 성공했다는 로그만 남아있었습니다. 이게 어떻게 가능한 일일까요?

팀장님이 화이트보드를 꺼내 설명했습니다. "모놀리식에서는 하나의 트랜잭션으로 모든 테이블을 함께 업데이트할 수 있었죠.

하지만 마이크로서비스는 각자 데이터베이스를 가지고 있어요. 재고 차감은 성공했는데 주문 생성이 실패하면 이런 일이 벌어집니다." 그렇다면 데이터 일관성 문제란 정확히 무엇일까요?

쉽게 비유하자면, 데이터 일관성은 마치 여러 사람이 공동으로 가계부를 쓰는 것과 같습니다. A는 자기 노트에 "10만원 지출"이라고 적고, B는 자기 노트에 "입금 확인"이라고 적습니다.

그런데 C가 "배송 완료"를 적기 전에 연락이 끊기면 어떻게 될까요? 각자의 노트는 일치하지 않고, 전체 거래는 어정쩡한 상태로 남습니다.

이처럼 분산 시스템에서도 데이터가 서로 맞지 않는 상황이 발생할 수 있습니다. 단일 데이터베이스를 사용하던 시절에는 어땠을까요?

BEGIN TRANSACTION으로 시작해서 COMMIT으로 끝나면 모든 변경사항이 함께 적용되었습니다. 중간에 에러가 나면 ROLLBACK으로 전부 취소할 수 있었습니다.

더 중요한 것은 데이터의 일관성이 항상 보장되었다는 점입니다. 재고가 차감되었으면 반드시 주문도 생성되어 있었습니다.

바로 이런 편리함을 잃은 대신 유연성과 확장성을 얻었습니다. 분산 시스템에서 데이터 일관성을 유지하려면 Saga 패턴이 필요합니다.

Saga는 여러 개의 로컬 트랜잭션으로 구성되며, 각 단계가 완료되면 다음 단계를 실행합니다. 만약 중간에 실패하면 보상 트랜잭션을 실행하여 이전 단계들을 되돌립니다.

또한 이벤트 기반 처리를 활용하면 느슨한 결합을 유지하면서도 일관성을 확보할 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 재고 차감을 수행하는 부분을 보면 첫 번째 단계가 실행되는 것을 알 수 있습니다. 다음으로 결제 처리에서는 두 번째 단계가 진행됩니다.

만약 여기서 실패하면 catch 블록이 실행되어 이미 차감된 재고를 다시 증가시키는 보상 트랜잭션이 일어납니다. 마지막으로 모든 단계가 성공하면 주문이 완료됩니다.

실제 현업에서는 어떻게 구현할까요? 예를 들어 배달의민족은 주문 처리에 Saga 패턴을 사용합니다.

주문 접수 → 음식점 확인 → 라이더 배정 → 결제 처리 같은 여러 단계가 있습니다. 각 단계는 독립적인 서비스에서 처리되며, 실패 시 이전 단계를 취소하는 보상 로직이 실행됩니다.

예를 들어 결제가 실패하면 라이더 배정을 취소하고 음식점에 주문 취소를 알립니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 완벽한 일관성을 추구하는 것입니다. 분산 시스템에서는 최종 일관성을 받아들여야 합니다.

짧은 시간 동안은 데이터가 일치하지 않을 수 있지만, 결국에는 일관된 상태에 도달합니다. 따라서 비즈니스 요구사항에 따라 어느 정도의 불일치를 허용할 수 있는지 결정해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 팀장님의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.

"아, 그래서 이벤트 소싱이나 CQRS 같은 패턴이 필요하군요!" 데이터 일관성 문제를 제대로 이해하면 더 신뢰할 수 있는 분산 시스템을 설계할 수 있습니다. 완벽한 일관성 대신 최종 일관성을 받아들이는 것이 현실적인 해결책입니다.

실전 팁

💡 - Saga 패턴에는 Choreography(이벤트 기반)와 Orchestration(중앙 조정) 두 가지 방식이 있습니다

  • 보상 트랜잭션은 반드시 멱등성을 보장해야 합니다
  • Kafka나 RabbitMQ 같은 메시지 큐를 활용하면 이벤트 기반 Saga를 쉽게 구현할 수 있습니다

6. 분산 시스템 설계 원칙

김개발 씨는 지난 몇 주 동안 분산 시스템의 여러 과제를 배웠습니다. 이제 새로운 마이크로서비스를 설계해야 하는데, 어떤 원칙을 따라야 실패하지 않을까요?

분산 시스템 설계 원칙은 수많은 실패와 경험에서 나온 모범 사례입니다. 장애를 전제로 설계하고, 느슨한 결합을 유지하며, 관찰 가능성을 확보하는 것이 핵심입니다.

이런 원칙을 따르면 더 안정적이고 확장 가능한 시스템을 만들 수 있습니다.

다음 코드를 살펴봅시다.

// 분산 시스템 설계 원칙을 반영한 서비스
@Service
public class ResilientOrderService {
    private static final Logger log = LoggerFactory.getLogger(ResilientOrderService.class);

    @Autowired
    private ServiceRegistry serviceRegistry;  // 서비스 디스커버리

    // 1. 장애를 전제로 설계 - Circuit Breaker, Timeout 적용
    @CircuitBreaker(name = "order", fallbackMethod = "fallback")
    @Retry(name = "order", fallbackMethod = "fallback")
    public Mono<OrderResponse> createOrder(OrderRequest request) {
        // 2. 관찰 가능성 - 상세한 로깅과 메트릭
        log.info("Creating order: userId={}, productId={}",
                 request.getUserId(), request.getProductId());

        return inventoryClient
            .checkStock(request.getProductId())
            .timeout(Duration.ofSeconds(2))  // 타임아웃
            .flatMap(stock -> processOrder(request, stock))
            .doOnSuccess(r -> log.info("Order created: {}", r.getOrderId()))
            .doOnError(e -> log.error("Order failed: {}", e.getMessage()));
    }

    // 3. 우아한 성능 저하 - Fallback 제공
    private Mono<OrderResponse> fallback(OrderRequest request, Exception ex) {
        log.warn("Fallback triggered for order: {}", request);
        return Mono.just(OrderResponse.pending("주문 대기 중"));
    }
}

김개발 씨는 지난 몇 주 동안 많은 것을 배웠습니다. 서비스 간 통신 문제, 장애 전파, 네트워크 지연, 데이터 일관성까지.

이제 이 모든 것을 종합하여 새로운 서비스를 설계해야 합니다. 선배 박시니어 씨가 설계 리뷰 미팅에서 조언했습니다.

"분산 시스템을 설계할 때는 몇 가지 중요한 원칙이 있어요. 이 원칙들을 지키면 많은 문제를 미리 예방할 수 있습니다." 그렇다면 분산 시스템 설계 원칙이란 정확히 무엇일까요?

쉽게 비유하자면, 분산 시스템 설계 원칙은 마치 건물을 지을 때 따르는 건축 원칙과 같습니다. 지진에 대비한 내진 설계, 화재에 대비한 방화 구획, 정전에 대비한 비상 발전기처럼 말입니다.

건물은 언젠가 문제가 생길 것을 전제로 설계됩니다. 이처럼 분산 시스템도 장애가 발생할 것을 가정하고 설계해야 합니다.

원칙 없이 설계하던 시절에는 어땠을까요? 개발자들은 모든 것이 항상 정상 작동한다고 가정했습니다.

네트워크는 항상 빠르고, 서비스는 절대 다운되지 않으며, 데이터는 항상 일관되다고 믿었습니다. 그 결과 프로덕션에서 예상치 못한 문제가 터졌을 때 속수무책이었습니다.

한밤중 긴급 상황실이 열리고, 고객들은 불만을 쏟아냈습니다. 바로 이런 경험들이 쌓여 분산 시스템 설계 원칙이 만들어졌습니다.

첫 번째 원칙은 장애를 전제로 설계하기입니다. 모든 서비스 호출은 실패할 수 있습니다.

Circuit Breaker, Timeout, Retry 같은 방어 메커니즘을 기본으로 적용해야 합니다. 두 번째 원칙은 느슨한 결합 유지하기입니다.

서비스 간 의존성을 최소화하고, 메시지 큐나 이벤트를 통해 비동기로 통신합니다. 세 번째 원칙은 관찰 가능성 확보하기입니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 @CircuitBreaker와 @Retry 어노테이션을 보면 장애를 전제로 설계했음을 알 수 있습니다.

이 부분이 핵심입니다. 다음으로 상세한 로깅을 통해 무슨 일이 일어나는지 추적할 수 있습니다.

timeout() 메서드는 네트워크 지연에 대비합니다. 마지막으로 fallback 메서드가 우아한 성능 저하를 제공합니다.

실제 현업에서는 어떻게 적용할까요? 예를 들어 아마존은 모든 것이 항상 실패한다고 가정합니다.

그들의 유명한 원칙 중 하나는 "Everything fails all the time"입니다. 그래서 AWS의 모든 서비스는 다중 가용 영역에 분산되어 있고, 자동 장애 조치 기능이 내장되어 있습니다.

넷플릭스는 Chaos Monkey라는 도구로 프로덕션 환경에서 임의로 서버를 종료시켜 시스템의 회복력을 테스트합니다. 네 번째 원칙은 멱등성 보장하기입니다.

같은 요청을 여러 번 보내도 결과가 같아야 합니다. 네트워크 문제로 재시도가 발생할 수 있기 때문입니다.

다섯 번째 원칙은 우아한 성능 저하입니다. 부분적인 장애가 발생해도 핵심 기능은 계속 작동해야 합니다.

추천 서비스가 다운되어도 상품 구매는 가능해야 합니다. 여섯 번째 원칙은 데이터 지역성입니다.

서비스 간 통신을 최소화하기 위해 필요한 데이터는 로컬에 복제하거나 캐시합니다. 일곱 번째 원칙은 버전 관리입니다.

API는 하위 호환성을 유지하면서 점진적으로 변경되어야 합니다. 한꺼번에 모든 서비스를 업데이트할 수 없기 때문입니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 원칙을 처음부터 완벽하게 적용하려는 것입니다.

이렇게 하면 오히려 개발 속도가 느려지고 복잡도만 증가할 수 있습니다. 따라서 프로젝트의 단계에 맞게 점진적으로 적용해야 합니다.

초기에는 핵심 원칙만 적용하고, 시스템이 성장하면서 필요한 원칙을 추가하는 것이 현실적입니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언을 듣고 설계를 수정한 김개발 씨는 자신감 있는 표정을 지었습니다. "이제 장애가 나도 시스템이 버틸 수 있을 것 같아요!" 분산 시스템 설계 원칙을 제대로 이해하면 더 견고하고 신뢰할 수 있는 시스템을 만들 수 있습니다.

완벽한 시스템은 없지만, 좋은 원칙을 따르면 대부분의 문제를 예방하거나 빠르게 복구할 수 있습니다. 마지막으로 기억해야 할 것은 단순함을 유지하는 것입니다.

분산 시스템은 본질적으로 복잡합니다. 불필요한 복잡도를 추가하면 디버깅과 운영이 더욱 어려워집니다.

정말 필요한 기능만 추가하고, 각 서비스의 책임을 명확히 하며, 표준화된 패턴을 사용하세요. 여러분도 오늘 배운 원칙들을 실제 프로젝트에 적용해 보세요.

처음에는 어렵게 느껴질 수 있지만, 시간이 지나면 자연스럽게 몸에 배게 됩니다.

실전 팁

💡 - 12 Factor App 원칙을 참고하면 클라우드 네이티브 애플리케이션 설계에 도움이 됩니다

  • Observability는 Logging, Metrics, Tracing 세 가지 기둥으로 구성됩니다
  • 처음부터 완벽을 추구하지 말고, 핵심 원칙부터 점진적으로 적용하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Spring Cloud#Microservices#Distributed Systems#Circuit Breaker#Service Discovery

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

Istio 설치와 구성 완벽 가이드

Kubernetes 환경에서 Istio 서비스 메시를 설치하고 구성하는 방법을 초급 개발자도 쉽게 이해할 수 있도록 실무 스토리와 비유로 풀어낸 가이드입니다. istioctl 설치부터 사이드카 주입까지 단계별로 학습합니다.

서비스 메시 완벽 가이드

마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.

Helm 마이크로서비스 패키징 완벽 가이드

Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.

관찰 가능한 마이크로서비스 완벽 가이드

마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.

Prometheus 메트릭 수집 완벽 가이드

Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.