1. Spring Boot 마이크로서비스 구성하기 (현재 문서)
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();
}
}
ConcurrentHashMap과 AtomicLong을 쓰는 이유가 있다. 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의 전체 코드는 이번 편 최종 전체 코드 섹션에서 확인할 수 있다. 핵심 차이점만 짚으면 헬스체크에 totalOrders와 totalAmount 정보가 추가된다는 점이다.
단계 4 — application.yml
spring:
application:
name: order-service
server:
port: 8082
user-service와 동일한 구조에서 name과 port만 다르다.
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_TRANSFER나 POINT에는 요구하지 않는다. 이런 조건부 검증은 실무에서 자주 나오는 패턴이다.
단계 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를 써도 되지만, 이 실습에서는 생성자가 가장 단순한 방법이다.
서비스 간 공통 패턴 비교
세 서비스의 구조를 나란히 놓으면 패턴이 보인다.
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 레이어가 추가된다.
{"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": ... } 형태로 응답하면 프론트엔드에서 서비스별로 다른 파싱 로직이 필요해진다. 공통 응답 포맷을 정의하고 모든 서비스가 따르게 하는 것이 중요하다.