Nginx API Gateway 실습 시리즈

1. Spring Boot 마이크로서비스 구성하기 (현재 문서)

2. Nginx API Gateway 설정하기

3. Docker Compose로 전체 시스템 실행하기

API Gateway를 제대로 체감하려면 라우팅할 백엔드 서비스가 있어야 한다. 이번 편에서는 user-service, order-service, payment-service 3개의 Spring Boot 마이크로서비스를 처음부터 만든다. DB 없이 메모리 저장소를 써서 API Gateway 학습에 집중할 수 있는 구조다.

실습 프로젝트 소개

이 시리즈에서 만드는 전체 시스템의 구조를 먼저 파악한다.

nginx-apigateway-practice/
├── docker-compose.yml
├── nginx/
│   ├── Dockerfile
│   ├── conf/api-gateway.conf
│   └── html/               ← 테스트 대시보드 (HTML/CSS/JS)
├── user-service/            ← Spring Boot :8081
├── order-service/           ← Spring Boot :8082
└── payment-service/         ← Spring Boot :8083

3개의 Spring Boot 서비스는 모두 같은 구조를 따른다.

  • Controller : REST API 엔드포인트. 헬스체크 포함.
  • Service : 비즈니스 로직. ConcurrentHashMap으로 데이터 관리.
  • DTO : 요청/응답 데이터 객체. Lombok 사용.

DB를 사용하지 않는 이유는 단순하다. 이 실습의 목적은 API Gateway의 라우팅과 Rate Limiting을 검증하는 것이지, JPA나 데이터베이스를 다루는 것이 아니다. 부수적인 복잡성을 제거하면 핵심에 집중할 수 있다.

왜 서비스를 3개나 만드는가

API Gateway의 가치는 여러 서비스를 하나의 진입점으로 통합하는 데 있다. 서비스가 1개라면 Gateway가 필요 없고, 2개라면 라우팅이 단순해서 실감이 나지 않는다.

3개의 서비스가 있으면 이런 것들을 직접 확인할 수 있다.

  • URL 경로에 따라 다른 서비스로 라우팅되는 것
  • 서비스별로 차등 Rate Limiting이 걸리는 것
  • 하나의 서비스가 죽어도 나머지가 정상 동작하는 것

사전 준비

각 서비스를 Spring Initializr에서 생성한다. 공통 설정은 다음과 같다.

  • Spring Boot : 3.5.x
  • Java : 17
  • 빌드 도구 : Gradle (Groovy)
  • 의존성 : Spring Web, Spring Boot Actuator, Lombok

3개 서비스의 프로젝트를 각각 생성한 뒤, 루트 디렉토리에 나란히 배치한다.

build.gradle 핵심 의존성은 세 서비스 모두 동일하다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Actuator는 헬스체크 엔드포인트를 자동으로 제공한다. Docker에서 컨테이너의 상태를 확인할 때 이 엔드포인트를 쓴다.

user-service 만들기

세 서비스 중 user-service를 가장 먼저, 가장 상세하게 만든다. 나머지 서비스는 같은 패턴이므로 달라지는 부분만 다룬다.

단계 1 — DTO 작성

DTO부터 시작한다. 데이터 구조가 정해져야 Service와 Controller를 만들 수 있다.

package com.codeit.user.dto;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.LocalDateTime;

@Getter @Setter @ToString
public class UserDto {
    private Long id;
    private String name;
    private String email;
    private String phone;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    public UserDto() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }

    public UserDto(Long id, String name, String email, String phone) {
        this();
        this.id = id;
        this.name = name;
        this.email = email;
        this.phone = phone;
    }

    public UserDto(String name, String email, String phone) {
        this();
        this.name = name;
        this.email = email;
        this.phone = phone;
    }
}

생성자를 두 개 만든 이유가 있다. id 없는 생성자는 새 사용자를 생성할 때 쓴다. id는 Service에서 자동 부여하기 때문이다. id 있는 생성자는 샘플 데이터 초기화나 전체 정보를 조립할 때 쓴다.

단계 2 — Service 작성

Service 계층이 데이터 저장과 비즈니스 로직을 담당한다. DB 대신 ConcurrentHashMap을 사용한다.

package com.codeit.user.service;

import com.codeit.user.dto.UserDto;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class UserService {

    private final ConcurrentHashMap<Long, UserDto> userStore = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    public UserService() {
        initializeSampleData();
    }

    private void initializeSampleData() {
        createUser(new UserDto("홍길동", "hong@example.com", "010-1234-5678"));
        createUser(new UserDto("김철수", "kim@example.com", "010-2345-6789"));
        createUser(new UserDto("이영희", "lee@example.com", "010-3456-7890"));
        createUser(new UserDto("박민수", "park@example.com", "010-4567-8901"));
        createUser(new UserDto("최진숙", "choi@example.com", "010-5678-9012"));
    }

    public List<UserDto> getAllUsers() {
        return new ArrayList<>(userStore.values());
    }

    public Optional<UserDto> getUserById(Long id) {
        return Optional.ofNullable(userStore.get(id));
    }

    public UserDto createUser(UserDto userDto) {
        if (getUserByEmail(userDto.getEmail()).isPresent()) {
            throw new IllegalArgumentException(
                "이미 존재하는 이메일입니다: " + userDto.getEmail()
            );
        }

        Long newId = idGenerator.getAndIncrement();
        userDto.setId(newId);
        userDto.setCreatedAt(LocalDateTime.now());
        userDto.setUpdatedAt(LocalDateTime.now());
        userStore.put(newId, userDto);
        return userDto;
    }

    public Optional<UserDto> getUserByEmail(String email) {
        return userStore.values().stream()
                .filter(user -> email.equals(user.getEmail()))
                .findFirst();
    }

    public int getUserCount() {
        return userStore.size();
    }
}

ConcurrentHashMapAtomicLong을 쓰는 이유가 있다. Spring Bean은 기본적으로 싱글톤이다. 여러 요청이 동시에 들어오면 같은 Service 인스턴스에 동시 접근한다. 일반 HashMap을 쓰면 동시성 문제가 발생할 수 있다.

initializeSampleData()는 생성자에서 호출된다. 서비스가 시작되면 자동으로 5명의 샘플 사용자가 들어가므로, API를 호출했을 때 바로 데이터를 확인할 수 있다.

단계 3 — Controller 작성

Controller에서 REST API 엔드포인트를 정의한다. 공통 응답 포맷을 사용해서 모든 응답의 구조를 통일한다.

package com.codeit.user.controller;

import com.codeit.user.dto.UserDto;
import com.codeit.user.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public ResponseEntity<Map<String, Object>> getAllUsers() {
        List<UserDto> users = userService.getAllUsers();
        Map<String, Object> response = createSuccessResponse(
            "사용자 목록 조회 성공", users,
            "총 " + users.size() + "명의 사용자가 조회되었습니다."
        );
        return ResponseEntity.ok(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Map<String, Object>> getUserById(@PathVariable Long id) {
        Optional<UserDto> user = userService.getUserById(id);

        if (user.isPresent()) {
            return ResponseEntity.ok(
                createSuccessResponse("사용자 조회 성공", user.get(),
                    "ID " + id + "번 사용자 정보입니다.")
            );
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
                createErrorResponse("사용자를 찾을 수 없습니다",
                    "ID " + id + "에 해당하는 사용자가 존재하지 않습니다.")
            );
        }
    }

    @PostMapping
    public ResponseEntity<Map<String, Object>> createUser(
            @RequestBody UserDto userDto) {
        try {
            if (userDto.getName() == null || userDto.getName().trim().isEmpty()) {
                return ResponseEntity.badRequest().body(
                    createErrorResponse("잘못된 입력입니다",
                        "이름은 필수 입력 항목입니다.")
                );
            }
            if (userDto.getEmail() == null || userDto.getEmail().trim().isEmpty()) {
                return ResponseEntity.badRequest().body(
                    createErrorResponse("잘못된 입력입니다",
                        "이메일은 필수 입력 항목입니다.")
                );
            }

            UserDto createdUser = userService.createUser(userDto);
            return ResponseEntity.status(HttpStatus.CREATED).body(
                createSuccessResponse("사용자 생성 성공", createdUser,
                    "새로운 사용자가 성공적으로 생성되었습니다.")
            );
        } catch (IllegalArgumentException e) {
            return ResponseEntity.badRequest().body(
                createErrorResponse("사용자 생성 실패", e.getMessage())
            );
        }
    }

    @GetMapping("/health")
    public ResponseEntity<Map<String, Object>> healthCheck() {
        Map<String, Object> healthData = new HashMap<>();
        healthData.put("status", "UP");
        healthData.put("service", "user-service");
        healthData.put("timestamp", LocalDateTime.now());
        healthData.put("totalUsers", userService.getUserCount());
        return ResponseEntity.ok(
            createSuccessResponse("User Service 정상 동작 중", healthData,
                "사용자 서비스가 정상적으로 동작하고 있습니다.")
        );
    }

    private Map<String, Object> createSuccessResponse(
            String message, Object data, String detail) {
        Map<String, Object> response = new HashMap<>();
        response.put("success", true);
        response.put("message", message);
        response.put("data", data);
        response.put("detail", detail);
        response.put("timestamp", LocalDateTime.now());
        response.put("service", "user-service");
        return response;
    }

    private Map<String, Object> createErrorResponse(
            String message, String detail) {
        Map<String, Object> response = new HashMap<>();
        response.put("success", false);
        response.put("message", message);
        response.put("detail", detail);
        response.put("timestamp", LocalDateTime.now());
        response.put("service", "user-service");
        return response;
    }
}

모든 응답이 { success, message, data, detail, timestamp, service } 구조를 따른다. 나중에 테스트 대시보드에서 이 포맷을 파싱해서 결과를 표시한다. service 필드가 있어서 어느 서비스에서 온 응답인지 구분할 수 있다.

/health 엔드포인트는 Actuator와 별개로 직접 만든 것이다. Gateway의 테스트 대시보드에서 각 서비스의 상태를 확인할 때 사용한다.

단계 4 — application.yml 설정

spring:
  application:
    name: user-service
  profiles:
    active: docker

server:
  port: 8081

logging:
  level:
    com.codeit.user: INFO
    org.springframework.web: INFO

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics
  endpoint:
    health:
      show-details: always

---
spring:
  config:
    activate:
      on-profile: docker

server:
  port: 8081

logging:
  level:
    root: INFO
    com.codeit.user: DEBUG

---로 구분된 아래 블록은 docker 프로파일 설정이다. Docker 환경에서는 로그 레벨을 DEBUG로 올려서 디버깅을 용이하게 한다. management.endpoints는 Actuator가 노출할 엔드포인트를 지정하고, show-details: always는 헬스체크 상세 정보를 포함시킨다.

order-service 만들기

order-service는 user-service와 같은 패턴이지만 도메인이 다르다. 주문 상태를 enum으로 관리하고, 총 금액 계산 로직이 추가된다.

단계 1 — OrderDto

user-service와 달리 OrderStatus enum이 내부에 정의된다. 주문은 상태 전이가 중요한 도메인이기 때문이다.

package com.codeit.order.dto;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Getter @Setter @ToString
public class OrderDto {

    private Long id;
    private Long userId;
    private String productName;
    private Integer quantity;
    private BigDecimal unitPrice;
    private BigDecimal totalAmount;
    private OrderStatus status;
    private String deliveryAddress;
    private LocalDateTime orderDate;
    private LocalDateTime updatedAt;

    public enum OrderStatus {
        PENDING("주문 대기"),
        CONFIRMED("주문 확인"),
        PREPARING("상품 준비중"),
        SHIPPED("배송중"),
        DELIVERED("배송 완료"),
        CANCELLED("주문 취소");

        private final String description;

        OrderStatus(String description) {
            this.description = description;
        }

        public String getDescription() {
            return description;
        }
    }

    public OrderDto() {
        this.orderDate = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
        this.status = OrderStatus.PENDING;
    }

    public OrderDto(Long userId, String productName, Integer quantity,
                    BigDecimal unitPrice, String deliveryAddress) {
        this();
        this.userId = userId;
        this.productName = productName;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
        this.deliveryAddress = deliveryAddress;
        calculateTotalAmount();
    }

    private void calculateTotalAmount() {
        if (quantity != null && unitPrice != null) {
            this.totalAmount = unitPrice.multiply(BigDecimal.valueOf(quantity));
        }
    }
}

금액은 BigDecimal을 쓴다. double이나 float로 금액을 다루면 부동소수점 오차로 인해 1500000원이 1499999.99원이 되는 문제가 생길 수 있다. 돈을 다루는 코드에서 BigDecimal은 필수다.

단계 2 — OrderService

package com.codeit.order.service;

import com.codeit.order.dto.OrderDto;
import com.codeit.order.dto.OrderDto.OrderStatus;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class OrderService {

    private final ConcurrentHashMap<Long, OrderDto> orderStore =
        new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    public OrderService() {
        initializeSampleData();
    }

    private void initializeSampleData() {
        createOrder(new OrderDto(1L, "노트북", 1,
            new BigDecimal("1500000"), "서울시 강남구 역삼동"));
        createOrder(new OrderDto(2L, "무선 마우스", 2,
            new BigDecimal("35000"), "서울시 서초구 반포동"));
        createOrder(new OrderDto(1L, "모니터", 1,
            new BigDecimal("300000"), "서울시 강남구 역삼동"));
        createOrder(new OrderDto(3L, "키보드", 1,
            new BigDecimal("120000"), "경기도 성남시 분당구"));
        createOrder(new OrderDto(2L, "헤드셋", 1,
            new BigDecimal("80000"), "서울시 서초구 반포동"));

        updateOrderStatus(2L, OrderStatus.CONFIRMED);
        updateOrderStatus(3L, OrderStatus.PREPARING);
        updateOrderStatus(4L, OrderStatus.SHIPPED);
    }

    public List<OrderDto> getAllOrders() {
        return new ArrayList<>(orderStore.values());
    }

    public Optional<OrderDto> getOrderById(Long id) {
        return Optional.ofNullable(orderStore.get(id));
    }

    public OrderDto createOrder(OrderDto orderDto) {
        validateOrderForCreate(orderDto);

        Long newId = idGenerator.getAndIncrement();
        orderDto.setId(newId);
        orderDto.setOrderDate(LocalDateTime.now());
        orderDto.setUpdatedAt(LocalDateTime.now());
        orderDto.setStatus(OrderStatus.PENDING);

        if (orderDto.getQuantity() != null && orderDto.getUnitPrice() != null) {
            BigDecimal totalAmount = orderDto.getUnitPrice()
                .multiply(BigDecimal.valueOf(orderDto.getQuantity()));
            orderDto.setTotalAmount(totalAmount);
        }

        orderStore.put(newId, orderDto);
        return orderDto;
    }

    public int getOrderCount() {
        return orderStore.size();
    }

    public BigDecimal getTotalOrderAmount() {
        return orderStore.values().stream()
            .filter(order -> order.getStatus() != OrderStatus.CANCELLED)
            .map(OrderDto::getTotalAmount)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    private void updateOrderStatus(Long id, OrderStatus status) {
        OrderDto order = orderStore.get(id);
        if (order != null) {
            order.setStatus(status);
            order.setUpdatedAt(LocalDateTime.now());
        }
    }

    private void validateOrderForCreate(OrderDto orderDto) {
        if (orderDto.getUserId() == null)
            throw new IllegalArgumentException("사용자 ID는 필수입니다.");
        if (orderDto.getProductName() == null
                || orderDto.getProductName().trim().isEmpty())
            throw new IllegalArgumentException("상품명은 필수입니다.");
        if (orderDto.getQuantity() == null || orderDto.getQuantity() <= 0)
            throw new IllegalArgumentException("수량은 1개 이상이어야 합니다.");
        if (orderDto.getUnitPrice() == null
                || orderDto.getUnitPrice().compareTo(BigDecimal.ZERO) <= 0)
            throw new IllegalArgumentException("단가는 0보다 커야 합니다.");
        if (orderDto.getDeliveryAddress() == null
                || orderDto.getDeliveryAddress().trim().isEmpty())
            throw new IllegalArgumentException("배송 주소는 필수입니다.");
    }
}

getTotalOrderAmount()는 헬스체크에서 쓴다. 취소된 주문을 필터링한 뒤 총 금액을 합산한다. Stream API로 간결하게 처리하는 패턴이다.

단계 3 — OrderController

user-service의 Controller와 구조가 같다. @RequestMapping("/api/orders")로 경로만 바꾸고, Service 호출을 OrderService로 교체한다.

Controller의 전체 코드는 이번 편 최종 전체 코드 섹션에서 확인할 수 있다. 핵심 차이점만 짚으면 헬스체크에 totalOrderstotalAmount 정보가 추가된다는 점이다.

단계 4 — application.yml

spring:
  application:
    name: order-service

server:
  port: 8082

user-service와 동일한 구조에서 nameport만 다르다.

payment-service 만들기

payment-service는 가장 복잡한 도메인이다. 결제 수단과 결제 상태를 각각 enum으로 관리하고, 카드 결제 시 추가 검증이 들어간다.

단계 1 — PaymentDto

package com.codeit.payment.dto;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Getter @Setter @ToString
public class PaymentDto {

    private Long id;
    private Long orderId;
    private Long userId;
    private BigDecimal amount;
    private PaymentMethod method;
    private PaymentStatus status;
    private String transactionId;
    private String cardLastFour;
    private String failureReason;
    private LocalDateTime paymentDate;
    private LocalDateTime updatedAt;

    public enum PaymentMethod {
        CARD("신용카드"),
        ACCOUNT_TRANSFER("계좌이체"),
        MOBILE_PAYMENT("모바일 결제"),
        POINT("포인트"),
        COUPON("쿠폰");

        private final String description;
        PaymentMethod(String description) { this.description = description; }
        public String getDescription() { return description; }
    }

    public enum PaymentStatus {
        PENDING("결제 대기"),
        PROCESSING("결제 처리중"),
        SUCCESS("결제 성공"),
        FAILED("결제 실패"),
        CANCELLED("결제 취소"),
        REFUNDED("환불 완료");

        private final String description;
        PaymentStatus(String description) { this.description = description; }
        public String getDescription() { return description; }
    }

    public PaymentDto() {
        this.paymentDate = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
        this.status = PaymentStatus.PENDING;
    }

    public PaymentDto(Long orderId, Long userId, BigDecimal amount,
                      PaymentMethod method, String cardLastFour) {
        this();
        this.orderId = orderId;
        this.userId = userId;
        this.amount = amount;
        this.method = method;
        this.cardLastFour = cardLastFour;
        generateTransactionId();
    }

    private void generateTransactionId() {
        this.transactionId = "TXN" + System.currentTimeMillis()
            + String.format("%04d", (int)(Math.random() * 10000));
    }
}

transactionId를 자동 생성하는 점이 다른 DTO와 다르다. 실제 결제 시스템에서 거래 추적 ID는 필수다. 여기서는 타임스탬프 + 랜덤 4자리 조합으로 간단히 만든다.

cardLastFour는 카드 뒷 4자리만 저장한다. 카드 전체 번호를 저장하면 PCI-DSS 규정 위반이다. 실습이지만 보안 관행은 익혀두는 것이 좋다.

단계 2 — PaymentService

user-service와 같은 패턴이되, validatePaymentForCreate()에서 결제 수단별 검증이 추가된다. 카드 결제인 경우 카드 정보가 필수라는 조건이 있다.

private void validatePaymentForCreate(PaymentDto paymentDto) {
    if (paymentDto.getOrderId() == null)
        throw new IllegalArgumentException("주문 ID는 필수입니다.");
    if (paymentDto.getUserId() == null)
        throw new IllegalArgumentException("사용자 ID는 필수입니다.");
    if (paymentDto.getAmount() == null
            || paymentDto.getAmount().compareTo(BigDecimal.ZERO) <= 0)
        throw new IllegalArgumentException("결제 금액은 0보다 커야 합니다.");
    if (paymentDto.getMethod() == null)
        throw new IllegalArgumentException("결제 수단은 필수입니다.");
    if (paymentDto.getMethod() == PaymentMethod.CARD
            && (paymentDto.getCardLastFour() == null
                || paymentDto.getCardLastFour().trim().isEmpty()))
        throw new IllegalArgumentException(
            "카드 결제 시 카드 정보는 필수입니다.");
}

결제 수단이 CARD일 때만 카드 정보를 요구하고, ACCOUNT_TRANSFERPOINT에는 요구하지 않는다. 이런 조건부 검증은 실무에서 자주 나오는 패턴이다.

단계 3 — PaymentController와 application.yml

Controller는 같은 구조로 @RequestMapping("/api/payments")를 사용한다. application.yml의 포트는 8083이다.

코드 뜯어보기

createSuccessResponse — 통일된 응답 포맷

private Map<String, Object> createSuccessResponse(
        String message, Object data, String detail) {
    Map<String, Object> response = new HashMap<>();
    response.put("success", true);
    response.put("message", message);
    response.put("data", data);
    response.put("detail", detail);
    response.put("timestamp", LocalDateTime.now());
    response.put("service", "user-service");
    return response;
}

세 서비스 모두 이 포맷을 따른다. success 필드로 성공/실패를 판단하고, service 필드로 어느 서비스의 응답인지 식별한다. 테스트 대시보드의 JavaScript가 이 구조에 의존해서 결과를 파싱한다.

Map<String, Object>를 쓰는 이유는 유연성이다. 전용 Response DTO를 만들어도 되지만, 이 실습에서는 서비스마다 data에 들어가는 타입이 다르기 때문에 Map이 더 간편하다.

initializeSampleData — 서비스 시작 시 데이터 주입

public UserService() {
    initializeSampleData();
}

생성자에서 샘플 데이터를 넣는다. Spring이 Bean을 생성하는 시점에 자동으로 실행되므로, 서비스가 뜨자마자 API를 호출해 데이터를 확인할 수 있다. CommandLineRunner나 @PostConstruct를 써도 되지만, 이 실습에서는 생성자가 가장 단순한 방법이다.

서비스 간 공통 패턴 비교

세 서비스의 구조를 나란히 놓으면 패턴이 보인다.

block-beta columns 3 block:user["user-service :8081"]:1 columns 1 UD["UserDto"] US2["UserService
ConcurrentHashMap"] UC["UserController
/api/users"] end block:order["order-service :8082"]:1 columns 1 OD["OrderDto
+ OrderStatus enum"] OS2["OrderService
+ 금액 계산"] OC["OrderController
/api/orders"] end block:payment["payment-service :8083"]:1 columns 1 PD["PaymentDto
+ Method/Status enum"] PS2["PaymentService
+ 조건부 검증"] PC["PaymentController
/api/payments"] end style UD fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style US2 fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style UC fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style OD fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style OS2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style OC fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style PD fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style PS2 fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style PC fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style user fill:#f0f8f0,stroke:#4CAF50 style order fill:#fff8f0,stroke:#FF9800 style payment fill:#faf0fa,stroke:#9C27B0

세 서비스 모두 DTO → Service → Controller 구조를 따르지만, 도메인의 복잡도에 따라 검증 로직과 enum이 추가된다.

요청 흐름 다이어그램

사용자 생성 요청이 들어왔을 때의 전체 흐름이다. 다음 편에서 Nginx를 앞에 두면 이 흐름 앞에 Gateway 레이어가 추가된다.

sequenceDiagram autonumber participant C as 클라이언트 participant Ctrl as UserController participant Svc as UserService participant Store as ConcurrentHashMap C->>Ctrl: POST /api/users
{"name":"테스트","email":"test@ex.com"} rect rgb(255, 243, 224) Note over Ctrl: 입력 검증 (name, email 필수) end Ctrl->>Svc: createUser(userDto) rect rgb(240, 248, 255) Note over Svc: 이메일 중복 검사 Svc->>Store: getUserByEmail("test@ex.com") Store-->>Svc: Optional.empty() end rect rgb(232, 248, 232) Note over Svc: ID 부여, 타임스탬프 설정 Svc->>Store: put(id, userDto) Store-->>Svc: 저장 완료 end Svc-->>Ctrl: UserDto (id 포함) Ctrl-->>C: 201 Created
{"success":true, "data":{...}}

자주 하는 실수

모든 서비스에서 같은 포트를 사용하기

세 서비스를 로컬에서 동시에 실행하면 포트 충돌이 발생한다. user-service는 8081, order-service는 8082, payment-service는 8083으로 반드시 다른 포트를 지정해야 한다. Docker 환경에서는 컨테이너가 격리되므로 같은 포트를 써도 되지만, application.yml의 포트와 Docker Compose의 환경 변수가 일치해야 한다.

[!DANGER] HashMap으로 동시성 문제 일으키기

Spring Bean은 싱글톤이다. 여러 요청이 동시에 Service에 접근하면 HashMap은 데이터 손상이 발생할 수 있다. 반드시 ConcurrentHashMap을 사용해야 한다. 같은 이유로 long 대신 AtomicLong을 써서 ID 생성의 원자성을 보장한다.

[!DANGER] 응답 포맷을 서비스마다 다르게 만들기

한 서비스는 { "data": ... }, 다른 서비스는 { "result": ... } 형태로 응답하면 프론트엔드에서 서비스별로 다른 파싱 로직이 필요해진다. 공통 응답 포맷을 정의하고 모든 서비스가 따르게 하는 것이 중요하다.