[BASIC #7] Microservice 간 통신 전략

2025. 9. 20. 19:22·MSA/MSA 기본

0. 들어가며

마이크로서비스 아키텍처에서는 하나의 비즈니스 기능을 완성하기 위해 여러 서비스가 협력해야 한다. 예를 들어, 사용자의 주문 요청을 처리하려면 Order Service가 User Service에서 사용자 정보를 조회하고, Catalog Service에서 상품 재고를 확인한 후, 재고를 차감하고 주문을 생성하는 일련의 과정이 필요하다. 이처럼 분산된 서비스들이 서로 통신하는 것은 필수적이며, 어떻게 통신하느냐에 따라 시스템의 성능, 안정성, 확장성이 크게 달라진다.

 

마이크로서비스 간 통신 방식은 크게 동기식(Synchronous)과 비동기식(Asynchronous)으로 나눌 수 있다. 각 방식은 장단점이 명확하며, 상황에 따라 적절한 방식을 선택하거나 혼합하여 사용해야 한다.

 

이번 글에서는 Spring Cloud 환경에서 마이크로서비스 간 통신을 구현하는 다양한 방법을 살펴본다. RestTemplate을 이용한 전통적인 방식부터, 선언적 REST 클라이언트인 Feign Client, 그리고 비동기 메시징을 위한 Kafka까지 단계적으로 알아볼 예정이다. 또한 분산 환경에서 발생할 수 있는 데이터 동기화 문제와 해결 방안도 함께 다룬다.


1. Communication Types

1.1. 동기식 통신 (Synchronous Communication)

동기식 통신은 서비스 A가 서비스 B를 호출하고, 응답을 받을 때까지 대기하는 방식이다. HTTP/REST, gRPC 등의 프로토콜을 사용한다.

장점:

  • 구현이 직관적이고 단순하다.
  • 요청에 대한 결과를 즉시 확인할 수 있다.
  • 요청-응답 패턴에 적합하다.

단점:

  • 호출된 서비스의 응답을 기다리는 동안 스레드가 블로킹된다.
  • 서비스 간 결합도가 높아진다.
  • 장애가 연쇄적으로 전파될 수 있다.

1.2. 비동기식 통신 (Asynchronous Communication)

비동기식 통신은 서비스 A가 메시지를 발행하고, 서비스 B가 이를 처리하는 방식이다. 메시지 큐(RabbitMQ, Kafka)나 이벤트 스트리밍 플랫폼을 사용한다.

장점:

  • 서비스 간 결합도가 낮아진다.
  • 장애 격리가 용이하다.
  • 높은 확장성과 탄력성을 제공한다.

단점:

  • 구현이 복잡하다.
  • 최종 일관성(Eventual Consistency)만 보장할 수 있다.
  • 메시지 추적과 디버깅이 어렵다.

1.3. 통신 방식 선택 기준

상황  동기식 통신  비동기식 통신
즉각적인 응답이 필요한 경우 ✅ ❌
긴 처리 시간이 필요한 경우 ❌ ✅
서비스 간 강한 결합이 허용되는 경우 ✅ ❌
장애 격리가 중요한 경우 ❌ ✅
트랜잭션 일관성이 중요한 경우 ✅ ❌
높은 확장성이 필요한 경우 ❌ ✅

2. RestTemplate 사용

2.1. RestTemplate란?

RestTemplate은 Spring에서 제공하는 HTTP 클라이언트로, 동기식 HTTP 통신을 위한 다양한 메서드를 제공한다. Spring 5.0부터는 WebClient 사용을 권장하지만, 여전히 많은 레거시 시스템에서 사용되고 있다.

2.2. 의존성 추가

build.gradle (order-service)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // 기존 의존성...
}

2.3. RestTemplate 빈 등록

OrderServiceApplication.java

package com.example.orderservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }

    @Bean
    @LoadBalanced  // Eureka와 연동하여 로드 밸런싱 지원
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

@LoadBalanced 어노테이션을 추가하면 RestTemplate이 Eureka와 연동되어 서비스 이름으로 호출할 수 있고, 클라이언트 사이드 로드 밸런싱이 적용된다.

2.4. User Service 클라이언트 구현

UserServiceClient.java

package com.example.orderservice.client;

import com.example.orderservice.dto.UserDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class UserServiceClient {

    private final RestTemplate restTemplate;

    private static final String USER_SERVICE_URL = "<http://user-service>";  // Eureka 서비스 이름

    /**
     * 사용자 ID로 사용자 정보 조회
     */
    public UserDto getUserById(String userId) {
        String url = UriComponentsBuilder.fromHttpUrl(USER_SERVICE_URL)
                .path("/users/{userId}")
                .buildAndExpand(userId)
                .toUriString();

        try {
            ResponseEntity<UserDto> response = restTemplate.getForEntity(url, UserDto.class);
            return response.getBody();
        } catch (Exception e) {
            log.error("Failed to get user from user-service. userId: {}, error: {}", userId, e.getMessage());
            throw new RuntimeException("User service call failed", e);
        }
    }

    /**
     * 사용자 존재 여부 확인
     */
    public boolean checkUserExists(String userId) {
        String url = UriComponentsBuilder.fromHttpUrl(USER_SERVICE_URL)
                .path("/users/{userId}/exists")
                .buildAndExpand(userId)
                .toUriString();

        try {
            ResponseEntity<Boolean> response = restTemplate.getForEntity(url, Boolean.class);
            return Boolean.TRUE.equals(response.getBody());
        } catch (Exception e) {
            log.error("Failed to check user existence. userId: {}, error: {}", userId, e.getMessage());
            return false;
        }
    }

    /**
     * 사용자 정보 업데이트 (POST 요청 예시)
     */
    public void updateUser(String userId, UserDto userDto) {
        String url = UriComponentsBuilder.fromHttpUrl(USER_SERVICE_URL)
                .path("/users/{userId}")
                .buildAndExpand(userId)
                .toUriString();

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<UserDto> requestEntity = new HttpEntity<>(userDto, headers);

        try {
            restTemplate.exchange(url, HttpMethod.PUT, requestEntity, Void.class);
            log.info("User updated successfully. userId: {}", userId);
        } catch (Exception e) {
            log.error("Failed to update user. userId: {}, error: {}", userId, e.getMessage());
            throw new RuntimeException("User update failed", e);
        }
    }

    /**
     * 주문 내역 추가 (다른 서비스에 데이터 전달)
     */
    public void addOrderToUser(String userId, Map<String, Object> orderInfo) {
        String url = UriComponentsBuilder.fromHttpUrl(USER_SERVICE_URL)
                .path("/users/{userId}/orders")
                .buildAndExpand(userId)
                .toUriString();

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(orderInfo, headers);

        try {
            restTemplate.postForEntity(url, requestEntity, Void.class);
            log.info("Order added to user successfully. userId: {}", userId);
        } catch (Exception e) {
            log.error("Failed to add order to user. userId: {}, error: {}", userId, e.getMessage());
            throw new RuntimeException("Add order to user failed", e);
        }
    }
}

2.5. UserDto 클래스

UserDto.java (order-service)

package com.example.orderservice.dto;

import lombok.Data;

import java.time.LocalDateTime;
import java.util.List;

@Data
public class UserDto {
    private String email;
    private String name;
    private String userId;
    private LocalDateTime createdAt;
    private List<OrderDto> orders;
}

2.6. Order Service에서 사용

OrderServiceImpl.java (RestTemplate 버전)

@Service
@Slf4j
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;
    private final UserServiceClient userServiceClient;

    @Override
    public ResponseOrder createOrder(String userId, RequestOrder requestOrder) {
        // 1. 사용자 존재 여부 확인
        if (!userServiceClient.checkUserExists(userId)) {
            throw new RuntimeException("User not found: " + userId);
        }

        // 2. 주문 생성
        String orderId = UUID.randomUUID().toString();
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setProductId(requestOrder.getProductId());
        orderEntity.setQuantity(requestOrder.getQuantity());
        orderEntity.setUnitPrice(requestOrder.getUnitPrice());
        orderEntity.setTotalPrice(requestOrder.getQuantity() * requestOrder.getUnitPrice());
        orderEntity.setUserId(userId);
        orderEntity.setOrderId(orderId);

        orderRepository.save(orderEntity);

        // 3. 사용자 서비스에 주문 정보 전달 (선택사항)
        Map<String, Object> orderInfo = new HashMap<>();
        orderInfo.put("orderId", orderId);
        orderInfo.put("productId", requestOrder.getProductId());
        orderInfo.put("quantity", requestOrder.getQuantity());
        orderInfo.put("totalPrice", orderEntity.getTotalPrice());
        orderInfo.put("createdAt", orderEntity.getCreatedAt());

        try {
            userServiceClient.addOrderToUser(userId, orderInfo);
        } catch (Exception e) {
            log.warn("Failed to notify user service about order, but order created. orderId: {}", orderId);
            // 사용자 서비스 알림 실패가 주문 생성 자체를 실패하게 하지는 않음
        }

        return mapToResponseOrder(orderEntity);
    }
}

2.7. RestTemplate의 한계

  • 복잡한 API 호출 코드: URL 구성, 헤더 설정, 예외 처리 등을 매번 작성해야 한다.
  • 타입 안정성 부족: 응답을 매핑할 때 타입 안정성이 떨어진다.
  • 선언적이지 않음: 어떤 서비스를 호출하는지 인터페이스만으로 파악하기 어렵다.

3. Feign Client 사용

3.1. Feign Client란?

Feign은 Netflix에서 개발한 선언적 HTTP 클라이언트다. 인터페이스와 어노테이션만으로 REST 클라이언트를 정의할 수 있어, 복잡한 HTTP 통신 코드를 작성할 필요가 없다. Spring Cloud OpenFeign은 Spring MVC 어노테이션을 지원하여 더욱 편리하게 사용할 수 있다.

3.2. 의존성 추가

build.gradle (order-service)

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
    // 기존 의존성...
}

3.3. Feign Client 활성화

OrderServiceApplication.java

package com.example.orderservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients  // Feign Client 활성화
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

3.4. Feign Client 인터페이스 생성

UserServiceFeignClient.java

package com.example.orderservice.client;

import com.example.orderservice.dto.UserDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import java.util.Map;

@FeignClient(name = "user-service")  // Eureka에 등록된 서비스 이름
public interface UserServiceFeignClient {

    @GetMapping("/users/{userId}")
    UserDto getUserById(@PathVariable("userId") String userId);

    @GetMapping("/users/{userId}/exists")
    Boolean checkUserExists(@PathVariable("userId") String userId);

    @PostMapping("/users/{userId}/orders")
    void addOrderToUser(@PathVariable("userId") String userId, @RequestBody Map<String, Object> orderInfo);
}

3.5. Feign Client 설정

application.yml (order-service)

feign:
  client:
    config:
      default:
        connectTimeout: 5000
        readTimeout: 5000
        loggerLevel: full
      user-service:
        connectTimeout: 3000
        readTimeout: 3000
        loggerLevel: basic

logging:
  level:
    com.example.orderservice.client: DEBUG

3.6. Order Service에서 Feign Client 사용

OrderServiceImpl.java (Feign Client 버전)

@Service
@Slf4j
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final OrderRepository orderRepository;
    private final UserServiceFeignClient userServiceFeignClient;

    @Override
    public ResponseOrder createOrder(String userId, RequestOrder requestOrder) {
        // 1. 사용자 존재 여부 확인 (Feign Client 사용)
        Boolean userExists = userServiceFeignClient.checkUserExists(userId);
        if (!Boolean.TRUE.equals(userExists)) {
            throw new RuntimeException("User not found: " + userId);
        }

        // 2. 주문 생성
        String orderId = UUID.randomUUID().toString();
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setProductId(requestOrder.getProductId());
        orderEntity.setQuantity(requestOrder.getQuantity());
        orderEntity.setUnitPrice(requestOrder.getUnitPrice());
        orderEntity.setTotalPrice(requestOrder.getQuantity() * requestOrder.getUnitPrice());
        orderEntity.setUserId(userId);
        orderEntity.setOrderId(orderId);

        orderRepository.save(orderEntity);

        // 3. 사용자 서비스에 주문 정보 전달 (Feign Client 사용)
        Map<String, Object> orderInfo = new HashMap<>();
        orderInfo.put("orderId", orderId);
        orderInfo.put("productId", requestOrder.getProductId());
        orderInfo.put("quantity", requestOrder.getQuantity());
        orderInfo.put("totalPrice", orderEntity.getTotalPrice());
        orderInfo.put("createdAt", orderEntity.getCreatedAt());

        try {
            userServiceFeignClient.addOrderToUser(userId, orderInfo);
        } catch (Exception e) {
            log.warn("Failed to notify user service about order, but order created. orderId: {}", orderId);
        }

        return mapToResponseOrder(orderEntity);
    }
}

3.7. Feign Client의 장점

  • 선언적 프로그래밍: 인터페이스와 어노테이션만으로 클라이언트 정의 가능
  • 코드 간결성: 반복적인 HTTP 통신 코드 제거
  • Spring MVC 어노테이션 지원: 기존 컨트롤러와 동일한 방식으로 작성 가능
  • 로드 밸런싱 통합: Eureka와 자동 연동
  • 히스트릭스 통합: 회로 차단기 패턴 적용 가능 (선택적)

4. Feign Client 예외 처리

4.1. 기본 예외 처리

Feign Client 호출 시 발생하는 예외는 FeignException으로 래핑된다.

try {
    UserDto user = userServiceFeignClient.getUserById(userId);
} catch (FeignException.NotFound e) {
    log.error("User not found: {}", userId);
    throw new ResourceNotFoundException("User not found: " + userId);
} catch (FeignException e) {
    log.error("Feign client error: {}", e.getMessage());
    throw new ServiceCallException("Failed to call user service", e);
}

4.2. ErrorDecoder를 이용한 예외 처리

Feign은 ErrorDecoder 인터페이스를 제공하여 HTTP 오류 응답을 커스텀 예외로 변환할 수 있다.

FeignErrorDecoder.java

package com.example.orderservice.client;

import feign.Response;
import feign.codec.ErrorDecoder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Component
public class FeignErrorDecoder implements ErrorDecoder {

    private final ErrorDecoder defaultErrorDecoder = new Default();

    @Override
    public Exception decode(String methodKey, Response response) {
        String errorContent = "";

        // 응답 바디 읽기
        if (response.body() != null) {
            try {
                errorContent = new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8);
            } catch (IOException e) {
                log.error("Failed to read response body", e);
            }
        }

        log.error("Feign client error. methodKey: {}, status: {}, reason: {}, body: {}",
                methodKey, response.status(), response.reason(), errorContent);

        HttpStatus status = HttpStatus.valueOf(response.status());

        // HTTP 상태 코드에 따른 예외 처리
        if (status.is4xxClientError()) {
            if (status == HttpStatus.NOT_FOUND) {
                return new ResourceNotFoundException("Resource not found: " + errorContent);
            } else if (status == HttpStatus.BAD_REQUEST) {
                return new BadRequestException("Bad request: " + errorContent);
            } else if (status == HttpStatus.UNAUTHORIZED) {
                return new UnauthorizedException("Unauthorized: " + errorContent);
            }
            return new ClientException(status.value(), "Client error: " + errorContent);
        } else if (status.is5xxServerError()) {
            return new ServerException(status.value(), "Server error: " + errorContent);
        }

        return defaultErrorDecoder.decode(methodKey, response);
    }
}

4.3. 커스텀 예외 클래스

ResourceNotFoundException.java

package com.example.orderservice.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

BadRequestException.java

package com.example.orderservice.exception;

public class BadRequestException extends RuntimeException {
    public BadRequestException(String message) {
        super(message);
    }
}

ClientException.java

package com.example.orderservice.exception;

public class ClientException extends RuntimeException {
    private final int statusCode;

    public ClientException(int statusCode, String message) {
        super(message);
        this.statusCode = statusCode;
    }

    public int getStatusCode() {
        return statusCode;
    }
}

ServerException.java

package com.example.orderservice.exception;

public class ServerException extends RuntimeException {
    private final int statusCode;

    public ServerException(int statusCode, String message) {
        super(message);
        this.statusCode = statusCode;
    }

    public int getStatusCode() {
        return statusCode;
    }
}

4.4. ErrorDecoder 적용

ErrorDecoder를 빈으로 등록하면 Feign Client에서 자동으로 사용한다. 별도의 설정 클래스를 만들어 적용할 수도 있다.

FeignConfig.java

package com.example.orderservice.config;

import com.example.orderservice.client.FeignErrorDecoder;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FeignConfig {

    @Bean
    public ErrorDecoder errorDecoder() {
        return new FeignErrorDecoder();
    }
}

4.5. Fallback 처리

Feign Client는 Hystrix 또는 Resilience4j와 연동하여 Fallback을 구현할 수 있다.

UserServiceFeignClientWithFallback.java

package com.example.orderservice.client;

import com.example.orderservice.dto.UserDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import java.util.HashMap;
import java.util.Map;

@FeignClient(name = "user-service", fallback = UserServiceFallback.class)
public interface UserServiceFeignClientWithFallback {

    @GetMapping("/users/{userId}")
    UserDto getUserById(@PathVariable("userId") String userId);

    @GetMapping("/users/{userId}/exists")
    Boolean checkUserExists(@PathVariable("userId") String userId);
}

@Component
class UserServiceFallback implements UserServiceFeignClientWithFallback {

    @Override
    public UserDto getUserById(String userId) {
        // Fallback 응답 반환
        UserDto fallbackUser = new UserDto();
        fallbackUser.setUserId(userId);
        fallbackUser.setName("Unknown User");
        fallbackUser.setEmail("unknown@example.com");
        return fallbackUser;
    }

    @Override
    public Boolean checkUserExists(String userId) {
        // 안전하게 false 반환
        return false;
    }
}

5. 데이터 동기화 문제

5.1. 분산 환경에서의 데이터 일관성 문제

마이크로서비스 아키텍처에서는 각 서비스가 독립적인 데이터베이스를 가진다. 이로 인해 다음과 같은 데이터 일관성 문제가 발생할 수 있다.

문제 상황 예시: 주문 생성 프로세스

  1. Order Service에서 주문 생성
  2. Catalog Service에서 재고 차감
  3. User Service에서 주문 내역 업데이트
  4. Payment Service에서 결제 처리

만약 2번 단계에서 실패하면 어떻게 될까? 주문은 생성되었지만 재고는 차감되지 않은 상태가 된다. 이러한 상황에서 데이터 일관성을 유지하는 것이 분산 시스템의 핵심 과제다.

5.2. 데이터 동기화 문제 해결 패턴

1. 분산 트랜잭션 (2PC - Two Phase Commit)

  • 장점: 강한 일관성 보장
  • 단점: 성능 저하, 가용성 감소, 모든 리소스가 2PC를 지원해야 함
  • 마이크로서비스 환경에서는 잘 사용되지 않음

2. 사가 패턴 (Saga Pattern)

  • 장점: 최종 일관성 보장, 높은 가용성
  • 단점: 보상 트랜잭션 구현 복잡
  • 코레오그래피(Choreography)와 오케스트레이션(Orchestration) 방식으로 구현

3. 이벤트 소싱 (Event Sourcing)

  • 장점: 상태 변경 이력 보존, 감사 추적 용이
  • 단점: 학습 곡선, 구현 복잡성

4. CQRS (Command Query Responsibility Segregation)

  • 장점: 읽기/쓰기 최적화, 확장성 향상
  • 단점: 복잡성 증가, 최종 일관성

5.3. 사가 패턴 구현 예시 (코레오그래피 방식)

사가 패턴은 분산 트랜잭션을 여러 개의 로컬 트랜잭션으로 분할하고, 각 단계가 실패하면 보상 트랜잭션을 실행하여 이전 상태로 되돌리는 패턴이다.

주문 생성 사가 이벤트 흐름:

Order Service: 주문 생성 (PENDING 상태)
    ↓ 이벤트 발행: "OrderCreated"
Catalog Service: 재고 확인 및 차감
    ↓ 이벤트 발행: "StockDeducted" (성공) 또는 "StockFailed" (실패)
Payment Service: 결제 처리
    ↓ 이벤트 발행: "PaymentProcessed" (성공) 또는 "PaymentFailed" (실패)
Order Service: 주문 상태 업데이트 (COMPLETED 또는 FAILED)

실패 시 보상 트랜잭션:

  • 재고 차감 실패: Order Service에서 주문 상태를 FAILED로 변경
  • 결제 실패: Catalog Service에서 재고 복원

5.4. Kafka를 활용한 이벤트 기반 동기화

Kafka와 같은 메시지 브로커를 활용하면 이벤트 기반 아키텍처를 구현할 수 있다.

Order Service - 이벤트 발행

@Service
@Slf4j
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @Transactional
    public ResponseOrder createOrder(String userId, RequestOrder requestOrder) {
        // 1. 주문 생성 (PENDING 상태)
        String orderId = UUID.randomUUID().toString();
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setProductId(requestOrder.getProductId());
        orderEntity.setQuantity(requestOrder.getQuantity());
        orderEntity.setUnitPrice(requestOrder.getUnitPrice());
        orderEntity.setTotalPrice(requestOrder.getQuantity() * requestOrder.getUnitPrice());
        orderEntity.setUserId(userId);
        orderEntity.setOrderId(orderId);
        orderEntity.setStatus("PENDING");

        orderRepository.save(orderEntity);

        // 2. 이벤트 발행: 재고 차감 요청
        OrderCreatedEvent event = new OrderCreatedEvent();
        event.setOrderId(orderId);
        event.setUserId(userId);
        event.setProductId(requestOrder.getProductId());
        event.setQuantity(requestOrder.getQuantity());

        kafkaTemplate.send("order-created-topic", orderId, event);
        log.info("Order created event published: {}", orderId);

        return mapToResponseOrder(orderEntity);
    }

    // 주문 완료 처리 (Catalog Service의 응답 수신)
    @KafkaListener(topics = "stock-deducted-topic")
    public void handleStockDeducted(StockDeductedEvent event) {
        OrderEntity order = orderRepository.findByOrderId(event.getOrderId())
                .orElseThrow(() -> new RuntimeException("Order not found"));

        order.setStatus("COMPLETED");
        orderRepository.save(order);
        log.info("Order completed: {}", event.getOrderId());
    }

    // 재고 차감 실패 처리 (보상 트랜잭션)
    @KafkaListener(topics = "stock-failed-topic")
    public void handleStockFailed(StockFailedEvent event) {
        OrderEntity order = orderRepository.findByOrderId(event.getOrderId())
                .orElseThrow(() -> new RuntimeException("Order not found"));

        order.setStatus("FAILED");
        orderRepository.save(order);
        log.info("Order failed due to stock issue: {}", event.getOrderId());
    }
}

Catalog Service - 이벤트 소비 및 재차발행

@Service
@Slf4j
@RequiredArgsConstructor
public class CatalogService {

    private final CatalogRepository catalogRepository;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @KafkaListener(topics = "order-created-topic")
    public void handleOrderCreated(OrderCreatedEvent event) {
        log.info("Received order created event: {}", event.getOrderId());

        try {
            // 재고 확인 및 차감
            CatalogEntity catalog = catalogRepository.findByProductId(event.getProductId())
                    .orElseThrow(() -> new RuntimeException("Product not found"));

            if (catalog.getStock() < event.getQuantity()) {
                throw new RuntimeException("Insufficient stock");
            }

            catalog.setStock(catalog.getStock() - event.getQuantity());
            catalogRepository.save(catalog);

            // 성공 이벤트 발행
            StockDeductedEvent successEvent = new StockDeductedEvent();
            successEvent.setOrderId(event.getOrderId());
            successEvent.setProductId(event.getProductId());
            successEvent.setQuantity(event.getQuantity());

            kafkaTemplate.send("stock-deducted-topic", event.getOrderId(), successEvent);
            log.info("Stock deducted successfully: {}", event.getOrderId());

        } catch (Exception e) {
            // 실패 이벤트 발행
            StockFailedEvent failedEvent = new StockFailedEvent();
            failedEvent.setOrderId(event.getOrderId());
            failedEvent.setProductId(event.getProductId());
            failedEvent.setReason(e.getMessage());

            kafkaTemplate.send("stock-failed-topic", event.getOrderId(), failedEvent);
            log.error("Stock deduction failed: {}", event.getOrderId(), e);
        }
    }
}

5.5. 데이터 동기화 문제 해결을 위한 Best Practice

1. 비즈니스 트랜잭션 경계 명확히 하기

  • 하나의 비즈니스 트랜잭션이 여러 서비스를 거쳐야 하는지 검토
  • 가능하다면 서비스 경계를 재조정하여 트랜잭션 범위 축소

2. 최종 일관성(Eventual Consistency) 수용

  • 강한 일관성이 반드시 필요한 경우가 아니면 최종 일관성 채택
  • 사용자 경험을 고려한 UI 설계 (예: "처리 중" 상태 표시)

3. 멱등성(Idempotency) 보장

  • 동일한 이벤트가 여러 번 처리되어도 결과가 동일하도록 설계
  • 멱등성 키를 사용하여 중복 처리 방지
@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"eventId"}))
public class ProcessedEvent {
    @Id
    private String eventId;
    private LocalDateTime processedAt;
}

4. 보상 트랜잭션(Compensating Transaction) 구현

  • 실패 시 이전 상태로 복구하는 로직 구현
  • 보상 트랜잭션도 멱등성을 보장해야 함

5. 데드 레터 큐(Dead Letter Queue) 활용

  • 처리 실패한 메시지를 별도 큐에 저장
  • 추후 재처리 또는 수동 개입 가능하도록 설계

6. 분산 트레이싱(Distributed Tracing) 적용

  • 여러 서비스를 거치는 요청의 흐름 추적
  • 장애 발생 시 원인 파악 용이

6. 결론

마이크로서비스 간 통신은 분산 시스템의 핵심 요소다. RestTemplate은 단순하고 직관적이지만 코드가 장황해지는 단점이 있다. Feign Client는 선언적 프로그래밍 모델을 제공하여 생산성을 크게 향상시킨다. 비동기 메시징은 서비스 간 결합도를 낮추고 시스템의 탄력성을 높이는 데 효과적이다.

 

데이터 동기화 문제는 마이크로서비스 아키텍처에서 피할 수 없는 과제다. 강한 일관성이 필요한 경우와 최종 일관성으로 충분한 경우를 구분하고, 사가 패턴과 같은 적절한 해결 방안을 적용해야 한다.

 

실무에서는 상황에 따라 동기식 통신과 비동기식 통신을 적절히 혼합하여 사용한다. 즉각적인 응답이 필요한 조회성 API는 동기식으로, 오래 걸리거나 여러 서비스에 영향을 미치는 작업은 비동기식으로 구현하는 것이 일반적이다.

 

다음 글에서는 Apache Kafka를 활용한 데이터 동기화에 대해 더 깊이 다룰 예정이다. Kafka의 핵심 개념부터 실제 구현, 그리고 데이터 동기화 문제를 해결하는 다양한 패턴을 살펴보겠다.

'MSA > MSA 기본' 카테고리의 다른 글

[BASIC #9] 장애 처리와 모니터링  (0) 2025.09.20
[BASIC #8] Kafka 기반 데이터 동기화  (0) 2025.09.20
[BASIC #6] Configuration Service와 중앙 설정 관리  (0) 2025.09.20
[BASIC #5] 인증 처리와 JWT 기반 보안  (0) 2025.09.20
[BASIC #4][실습] E-commerce MSA 프로젝트 구조 설계  (0) 2025.09.20
'MSA/MSA 기본' 카테고리의 다른 글
  • [BASIC #9] 장애 처리와 모니터링
  • [BASIC #8] Kafka 기반 데이터 동기화
  • [BASIC #6] Configuration Service와 중앙 설정 관리
  • [BASIC #5] 인증 처리와 JWT 기반 보안
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (241) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (16)
        • MSA 기본 (11)
        • MSA 아키텍처 (5)
      • Kafka (30) N
        • Core (18) N
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[BASIC #7] Microservice 간 통신 전략
상단으로

티스토리툴바