0. 들어가며
지금까지 우리는 마이크로서비스 아키텍처의 이론적 배경과 핵심 컴포넌트인 서비스 디스커버리(Eureka), API 게이트웨이(Spring Cloud Gateway)를 살펴보았다. 이제 이론에서 한 걸음 더 나아가 실제 동작하는 이커머스(E-commerce) 애플리케이션을 구축해보려 한다.
이번 글에서는 실제 프로젝트의 구조를 설계하고, 사용자 관리(User), 상품 카탈로그(Catalog), 주문(Order)이라는 세 가지 핵심 마이크로서비스를 구현할 것이다. 또한 각 서비스와 H2 인메모리 데이터베이스를 연동하고, JPA를 활용한 기본적인 CRUD 기능을 구현한다. 특히 Spring Security를 활용한 사용자 인증과 BCrypt 암호화를 적용하여 실무에 가까운 보안 설정도 함께 다룬다.
1. E-commerce 애플리케이션 개요
1.1. 프로젝트의 목표
우리가 구축할 이커머스 애플리케이션의 목표는 다음과 같다.
- 마이크로서비스 기반 설계: 각 기능을 독립적인 서비스로 분리하여 개발하고 배포한다.
- 실무에 가까운 구현: 단순한 예제를 넘어 실제 서비스에서 필요한 인증, 암호화, 비즈니스 로직을 포함한다.
- 확장 가능한 구조: 새로운 기능 추가나 기존 기능 확장이 용이한 구조로 설계한다.
- Spring Cloud와의 통합: 앞서 배운 Eureka, Gateway 등을 실제 서비스와 연동한다.
1.2. 주요 기능
이커머스 애플리케이션의 핵심 기능은 다음과 같다.
사용자 관리(User Service):
- 회원 가입: 새로운 사용자를 등록한다.
- 사용자 정보 조회: 개별 사용자 정보 또는 전체 사용자 목록을 조회한다.
- 로그인: 사용자 인증을 처리한다.
상품 카탈로그 관리(Catalog Service):
- 상품 목록 조회: 판매 중인 전체 상품 목록을 제공한다.
- 상품 상세 정보 조회: 개별 상품의 상세 정보를 제공한다.
- 재고 관리: 주문 발생 시 상품 재고를 차감한다.
주문 관리(Order Service):
- 주문 생성: 사용자가 상품을 주문한다.
- 주문 내역 조회: 사용자별 주문 이력을 확인한다.
- 주문 상태 관리: 주문의 상태(결제 대기, 배송 중, 완료 등)를 관리한다.
1.3. 시스템 아키텍처
전체 시스템 아키텍처는 다음과 같이 구성된다.
[클라이언트] → [API Gateway (포트 8000)] → [Eureka Discovery Server (포트 8761)]
├→ [User Service (랜덤 포트)]
├→ [Catalog Service (랜덤 포트)]
└→ [Order Service (랜덤 포트)]
각 서비스는 Eureka에 등록되어 서로를 발견할 수 있으며, 모든 클라이언트 요청은 API Gateway를 통해 해당 서비스로 라우팅된다.
2. E-commerce 애플리케이션 구성
2.1. 서비스별 책임과 경계
User Service:
- 사용자 정보 저장 및 관리 (user_id, email, name, encrypted_password)
- 사용자 인증 및 토큰 발급
- 주문 서비스에 사용자 정보 제공
Catalog Service:
- 상품 정보 저장 및 관리 (product_id, product_name, stock, unit_price)
- 상품 재고 관리
- 주문 발생 시 재고 차감 처리
Order Service:
- 주문 정보 저장 및 관리 (order_id, user_id, product_id, quantity, total_price, status)
- 사용자별 주문 내역 관리
- 주문 생성 시 Catalog Service와 통신하여 재고 확인 및 차감
2.2. 기술 스택
- Spring Boot 3.2.0: 애플리케이션 프레임워크
- Spring Cloud 2023.0.0: 마이크로서비스 패턴 지원
- Spring Data JPA: 데이터 접근 계층
- H2 Database: 개발 단계에서 사용할 인메모리 데이터베이스
- Spring Security: 인증 및 보안
- Lombok: 반복 코드 제거
- Eureka Discovery Client: 서비스 디스커버리
- Spring Cloud Gateway: API 게이트웨이
2.3. 프로젝트 구조
전체 프로젝트는 멀티 모듈 구조로 구성된다.
ecommerce-msa/
├── discovery-service/ # Eureka Server
├── apigateway-service/ # Spring Cloud Gateway
├── user-service/ # 사용자 관리 서비스
├── catalog-service/ # 상품 카탈로그 서비스
└── order-service/ # 주문 관리 서비스
3. Users Microservice - 프로젝트 생성
3.1. 프로젝트 생성 및 의존성 추가
build.gradle (user-service)
plugins {
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', "2023.0.0")
}
dependencies {
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Spring Cloud
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
// Database
runtimeOnly 'com.h2database:h2'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Development
developmentOnly 'org.springframework.boot:spring-boot-devtools'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
3.2. 메인 애플리케이션 클래스
UserServiceApplication.java
package com.example.userservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
3.3. 기본 설정 파일
application.yml
server:
port: 0 # 랜덤 포트 사용
spring:
application:
name: user-service
# H2 Database 설정
h2:
console:
enabled: true
settings:
web-allow-others: true
path: /h2-console
# Datasource 설정
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:userdb
username: sa
password: 1234
# JPA 설정
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
generate-ddl: true
defer-datasource-initialization: true
properties:
hibernate:
format_sql: true
# Eureka Client 설정
eureka:
instance:
instance-id: ${spring.application.name}:${spring.application.instance_id:${random.value}}
client:
register-with-eureka: true
fetch-registry: true
service-url:
defaultZone: <http://127.0.0.1:8761/eureka>
# 로깅 설정
logging:
level:
com.example.userservice: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE
4. Users Microservice - H2 데이터베이스 연동
4.1. 엔티티 클래스 생성
UserEntity.java
package com.example.userservice.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50, unique = true)
private String email;
@Column(nullable = false, length = 50)
private String name;
@Column(nullable = false, unique = true)
private String userId;
@Column(nullable = false)
private String encryptedPwd;
@CreationTimestamp
private LocalDateTime createdAt;
}
4.2. 리포지토리 인터페이스 생성
UserRepository.java
package com.example.userservice.repository;
import com.example.userservice.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByUserId(String userId);
Optional<UserEntity> findByEmail(String email);
}
5. Users Microservice - 사용자 추가 (JPA)
5.1. DTO 클래스 생성
RequestUser.java (회원 가입 요청 DTO)
package com.example.userservice.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RequestUser {
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;
@NotBlank(message = "이름은 필수 입력값입니다.")
@Size(min = 2, message = "이름은 최소 2자 이상이어야 합니다.")
private String name;
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
@Size(min = 4, message = "비밀번호는 최소 4자 이상이어야 합니다.")
private String pwd;
}
ResponseUser.java (사용자 정보 응답 DTO)
package com.example.userservice.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ResponseUser {
private String email;
private String name;
private String userId;
private LocalDateTime createdAt;
}
5.2. 서비스 계층 구현
UserService.java (인터페이스)
package com.example.userservice.service;
import com.example.userservice.dto.RequestUser;
import com.example.userservice.dto.ResponseUser;
import java.util.List;
public interface UserService {
ResponseUser createUser(RequestUser user);
ResponseUser getUserByUserId(String userId);
List<ResponseUser> getAllUsers();
}
UserServiceImpl.java (구현체)
package com.example.userservice.service;
import com.example.userservice.dto.RequestUser;
import com.example.userservice.dto.ResponseUser;
import com.example.userservice.entity.UserEntity;
import com.example.userservice.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Override
public ResponseUser createUser(RequestUser requestUser) {
// UUID를 사용하여 고유한 사용자 ID 생성
String userId = UUID.randomUUID().toString();
UserEntity userEntity = new UserEntity();
userEntity.setEmail(requestUser.getEmail());
userEntity.setName(requestUser.getName());
userEntity.setUserId(userId);
// 암호화는 아직 적용하지 않음 (다음 단계에서 구현)
userEntity.setEncryptedPwd(requestUser.getPwd());
userRepository.save(userEntity);
log.info("User created: {}", userId);
return mapToResponseUser(userEntity);
}
@Override
public ResponseUser getUserByUserId(String userId) {
UserEntity userEntity = userRepository.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
return mapToResponseUser(userEntity);
}
@Override
public List<ResponseUser> getAllUsers() {
List<UserEntity> userEntities = userRepository.findAll();
List<ResponseUser> result = new ArrayList<>();
userEntities.forEach(userEntity ->
result.add(mapToResponseUser(userEntity))
);
return result;
}
private ResponseUser mapToResponseUser(UserEntity userEntity) {
ResponseUser responseUser = new ResponseUser();
responseUser.setEmail(userEntity.getEmail());
responseUser.setName(userEntity.getName());
responseUser.setUserId(userEntity.getUserId());
responseUser.setCreatedAt(userEntity.getCreatedAt());
return responseUser;
}
}
5.3. 컨트롤러 구현
UserController.java
package com.example.userservice.controller;
import com.example.userservice.dto.RequestUser;
import com.example.userservice.dto.ResponseUser;
import com.example.userservice.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<ResponseUser> createUser(@Valid @RequestBody RequestUser requestUser) {
ResponseUser createdUser = userService.createUser(requestUser);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
@GetMapping("/{userId}")
public ResponseEntity<ResponseUser> getUser(@PathVariable String userId) {
ResponseUser user = userService.getUserByUserId(userId);
return ResponseEntity.ok(user);
}
@GetMapping
public ResponseEntity<List<ResponseUser>> getUsers() {
List<ResponseUser> users = userService.getAllUsers();
return ResponseEntity.ok(users);
}
@GetMapping("/health")
public String health() {
return "User Service is running...";
}
}
5.4. H2 콘솔 확인
애플리케이션 실행 후 브라우저에서 http://localhost:{랜덤포트}/h2-console에 접속하면 H2 데이터베이스 콘솔을 확인할 수 있다.
- JDBC URL: jdbc:h2:mem:userdb
- Username: sa
- Password: 1234
6. Users Microservice - 반환값과 응답코드
6.1. 일관된 응답 형식
API 응답의 일관성을 위해 공통 응답 클래스를 생성한다.
ResponseMessage.java
package com.example.userservice.dto.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponseMessage<T> {
private boolean success;
private String message;
private T data;
public static <T> ResponseMessage<T> success(T data) {
return new ResponseMessage<>(true, "Success", data);
}
public static <T> ResponseMessage<T> success(String message, T data) {
return new ResponseMessage<>(true, message, data);
}
public static <T> ResponseMessage<T> error(String message) {
return new ResponseMessage<>(false, message, null);
}
}
6.2. 글로벌 예외 처리
GlobalExceptionHandler.java
package com.example.userservice.exception;
import com.example.userservice.dto.common.ResponseMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ResponseMessage<Map<String, String>>> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
log.error("Validation error: {}", errors);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ResponseMessage.error("입력값 검증에 실패했습니다."));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ResponseMessage<Void>> handleRuntimeException(RuntimeException ex) {
log.error("Runtime exception: {}", ex.getMessage());
HttpStatus status = ex.getMessage().contains("찾을 수 없습니다")
? HttpStatus.NOT_FOUND
: HttpStatus.INTERNAL_SERVER_ERROR;
return ResponseEntity
.status(status)
.body(ResponseMessage.error(ex.getMessage()));
}
}
6.3. 수정된 컨트롤러
@PostMapping
public ResponseEntity<ResponseMessage<ResponseUser>> createUser(@Valid @RequestBody RequestUser requestUser) {
ResponseUser createdUser = userService.createUser(requestUser);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ResponseMessage.success("사용자가 성공적으로 생성되었습니다.", createdUser));
}
@GetMapping("/{userId}")
public ResponseEntity<ResponseMessage<ResponseUser>> getUser(@PathVariable String userId) {
ResponseUser user = userService.getUserByUserId(userId);
return ResponseEntity.ok(ResponseMessage.success(user));
}
@GetMapping
public ResponseEntity<ResponseMessage<List<ResponseUser>>> getUsers() {
List<ResponseUser> users = userService.getAllUsers();
return ResponseEntity.ok(ResponseMessage.success(users));
}
7. Users Microservice - Spring Security 연동
7.1. Security 의존성 확인
이미 build.gradle에 추가한 spring-boot-starter-security 의존성이 있는지 확인한다.
7.2. Security 기본 설정
WebSecurityConfig.java
package com.example.userservice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/users/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.disable())
);
return http.build();
}
}
이 설정은:
- CSRF 보호를 비활성화한다 (REST API에서는 일반적으로 비활성화).
- /users/**와 /h2-console/** 경로는 인증 없이 접근을 허용한다.
- 세션을 사용하지 않는 STATELESS 정책을 사용한다.
- H2 콘솔 사용을 위해 frameOptions를 비활성화한다.
8. Users Microservice - BCryptPasswordEncoder
8.1. PasswordEncoder 빈 등록
WebSecurityConfig.java에 PasswordEncoder 빈을 추가한다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
8.2. 서비스 계층 수정
UserServiceImpl.java 수정
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public ResponseUser createUser(RequestUser requestUser) {
String userId = UUID.randomUUID().toString();
UserEntity userEntity = new UserEntity();
userEntity.setEmail(requestUser.getEmail());
userEntity.setName(requestUser.getName());
userEntity.setUserId(userId);
// 비밀번호 암호화 적용
userEntity.setEncryptedPwd(passwordEncoder.encode(requestUser.getPwd()));
userRepository.save(userEntity);
log.info("User created: {}", userId);
return mapToResponseUser(userEntity);
}
// 비밀번호 검증 메서드 추가
public boolean validatePassword(String rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
// ...
}
8.3. 로그인 기능 추가 (테스트용)
AuthController.java (임시 로그인 컨트롤러)
package com.example.userservice.controller;
import com.example.userservice.dto.common.ResponseMessage;
import com.example.userservice.entity.UserEntity;
import com.example.userservice.repository.UserRepository;
import com.example.userservice.service.UserServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final UserServiceImpl userService;
@PostMapping("/login")
public ResponseMessage<?> login(@RequestBody Map<String, String> loginRequest) {
String email = loginRequest.get("email");
String password = loginRequest.get("password");
UserEntity user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
if (userService.validatePassword(password, user.getEncryptedPwd())) {
log.info("Login successful: {}", email);
return ResponseMessage.success("로그인 성공", Map.of("userId", user.getUserId()));
} else {
log.warn("Login failed: {}", email);
return ResponseMessage.error("비밀번호가 일치하지 않습니다.");
}
}
}
9. Catalogs Microservice
9.1. 프로젝트 생성 및 의존성 추가
build.gradle (catalog-service) - user-service와 유사하게 구성
9.2. 엔티티 클래스
CatalogEntity.java
package com.example.catalogservice.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.ColumnDefault;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "catalog")
@Data
public class CatalogEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120, unique = true)
private String productId;
@Column(nullable = false)
private String productName;
@Column(nullable = false)
private Integer stock;
@Column(nullable = false)
private Integer unitPrice;
@Column(nullable = false, updatable = false, insertable = false)
@ColumnDefault(value = "CURRENT_TIMESTAMP")
private LocalDateTime createdAt;
}
9.3. 리포지토리
CatalogRepository.java
package com.example.catalogservice.repository;
import com.example.catalogservice.entity.CatalogEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface CatalogRepository extends JpaRepository<CatalogEntity, Long> {
Optional<CatalogEntity> findByProductId(String productId);
}
9.4. 데이터 초기화
application.yml에 데이터 초기화 설정 추가
spring:
sql:
init:
data-locations: classpath:data.sql
mode: always
resources/data.sql
INSERT INTO catalog(product_id, product_name, stock, unit_price, created_at)
VALUES ('CATALOG-001', 'Berlin', 100, 1500, NOW());
INSERT INTO catalog(product_id, product_name, stock, unit_price, created_at)
VALUES ('CATALOG-002', 'Tokyo', 200, 2500, NOW());
INSERT INTO catalog(product_id, product_name, stock, unit_price, created_at)
VALUES ('CATALOG-003', 'Seoul', 150, 1000, NOW());
9.5. 서비스 계층
CatalogService.java (인터페이스)
package com.example.catalogservice.service;
import com.example.catalogservice.dto.CatalogDto;
import java.util.List;
public interface CatalogService {
List<CatalogDto> getAllCatalogs();
CatalogDto getCatalogByProductId(String productId);
}
CatalogDto.java
package com.example.catalogservice.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CatalogDto {
private String productId;
private String productName;
private Integer stock;
private Integer unitPrice;
private LocalDateTime createdAt;
}
CatalogServiceImpl.java
package com.example.catalogservice.service;
import com.example.catalogservice.dto.CatalogDto;
import com.example.catalogservice.entity.CatalogEntity;
import com.example.catalogservice.repository.CatalogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class CatalogServiceImpl implements CatalogService {
private final CatalogRepository catalogRepository;
@Override
public List<CatalogDto> getAllCatalogs() {
List<CatalogEntity> catalogEntities = catalogRepository.findAll();
List<CatalogDto> result = new ArrayList<>();
catalogEntities.forEach(entity ->
result.add(mapToDto(entity))
);
return result;
}
@Override
public CatalogDto getCatalogByProductId(String productId) {
CatalogEntity catalogEntity = catalogRepository.findByProductId(productId)
.orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다."));
return mapToDto(catalogEntity);
}
private CatalogDto mapToDto(CatalogEntity entity) {
CatalogDto dto = new CatalogDto();
dto.setProductId(entity.getProductId());
dto.setProductName(entity.getProductName());
dto.setStock(entity.getStock());
dto.setUnitPrice(entity.getUnitPrice());
dto.setCreatedAt(entity.getCreatedAt());
return dto;
}
}
9.6. 컨트롤러
CatalogController.java
package com.example.catalogservice.controller;
import com.example.catalogservice.dto.CatalogDto;
import com.example.catalogservice.dto.common.ResponseMessage;
import com.example.catalogservice.service.CatalogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/catalogs")
@RequiredArgsConstructor
public class CatalogController {
private final CatalogService catalogService;
private final Environment env;
@GetMapping("/health")
public String health() {
return String.format("Catalog Service is running at port %s",
env.getProperty("local.server.port"));
}
@GetMapping
public ResponseEntity<ResponseMessage<List<CatalogDto>>> getCatalogs() {
List<CatalogDto> catalogs = catalogService.getAllCatalogs();
return ResponseEntity.ok(ResponseMessage.success(catalogs));
}
@GetMapping("/{productId}")
public ResponseEntity<ResponseMessage<CatalogDto>> getCatalog(@PathVariable String productId) {
CatalogDto catalog = catalogService.getCatalogByProductId(productId);
return ResponseEntity.ok(ResponseMessage.success(catalog));
}
}
10. Orders Microservice
10.1. 프로젝트 생성 및 의존성 추가
build.gradle (order-service) - user-service와 유사하게 구성
10.2. 엔티티 클래스
OrderEntity.java
package com.example.orderservice.entity;
import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "orders")
@Data
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String productId;
@Column(nullable = false)
private Integer quantity;
@Column(nullable = false)
private Integer unitPrice;
@Column(nullable = false)
private Integer totalPrice;
@Column(nullable = false)
private String userId;
@Column(nullable = false, unique = true)
private String orderId;
@CreationTimestamp
private LocalDateTime createdAt;
}
10.3. 리포지토리
OrderRepository.java
package com.example.orderservice.repository;
import com.example.orderservice.entity.OrderEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
Optional<OrderEntity> findByOrderId(String orderId);
List<OrderEntity> findByUserId(String userId);
}
10.4. DTO 클래스
RequestOrder.java
package com.example.orderservice.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class RequestOrder {
@NotBlank(message = "상품 ID는 필수입니다.")
private String productId;
@NotNull(message = "수량은 필수입니다.")
@Min(value = 1, message = "수량은 최소 1 이상이어야 합니다.")
private Integer quantity;
@NotNull(message = "단가는 필수입니다.")
@Min(value = 0, message = "단가는 0 이상이어야 합니다.")
private Integer unitPrice;
}
ResponseOrder.java
package com.example.orderservice.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ResponseOrder {
private String productId;
private Integer quantity;
private Integer unitPrice;
private Integer totalPrice;
private String orderId;
private String userId;
private LocalDateTime createdAt;
}
10.5. 서비스 계층
OrderService.java (인터페이스)
package com.example.orderservice.service;
import com.example.orderservice.dto.RequestOrder;
import com.example.orderservice.dto.ResponseOrder;
import java.util.List;
public interface OrderService {
ResponseOrder createOrder(String userId, RequestOrder requestOrder);
ResponseOrder getOrderByOrderId(String orderId);
List<ResponseOrder> getOrdersByUserId(String userId);
}
OrderServiceImpl.java
package com.example.orderservice.service;
import com.example.orderservice.dto.RequestOrder;
import com.example.orderservice.dto.ResponseOrder;
import com.example.orderservice.entity.OrderEntity;
import com.example.orderservice.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
@Override
public ResponseOrder createOrder(String userId, RequestOrder requestOrder) {
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);
log.info("Order created: {} for user: {}", orderId, userId);
return mapToResponseOrder(orderEntity);
}
@Override
public ResponseOrder getOrderByOrderId(String orderId) {
OrderEntity orderEntity = orderRepository.findByOrderId(orderId)
.orElseThrow(() -> new RuntimeException("주문을 찾을 수 없습니다."));
return mapToResponseOrder(orderEntity);
}
@Override
public List<ResponseOrder> getOrdersByUserId(String userId) {
List<OrderEntity> orderEntities = orderRepository.findByUserId(userId);
List<ResponseOrder> result = new ArrayList<>();
orderEntities.forEach(entity ->
result.add(mapToResponseOrder(entity))
);
return result;
}
private ResponseOrder mapToResponseOrder(OrderEntity entity) {
ResponseOrder responseOrder = new ResponseOrder();
responseOrder.setProductId(entity.getProductId());
responseOrder.setQuantity(entity.getQuantity());
responseOrder.setUnitPrice(entity.getUnitPrice());
responseOrder.setTotalPrice(entity.getTotalPrice());
responseOrder.setOrderId(entity.getOrderId());
responseOrder.setUserId(entity.getUserId());
responseOrder.setCreatedAt(entity.getCreatedAt());
return responseOrder;
}
}
10.6. 컨트롤러
OrderController.java
package com.example.orderservice.controller;
import com.example.orderservice.dto.RequestOrder;
import com.example.orderservice.dto.ResponseOrder;
import com.example.orderservice.dto.common.ResponseMessage;
import com.example.orderservice.service.OrderService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final Environment env;
@GetMapping("/health")
public String health() {
return String.format("Order Service is running at port %s",
env.getProperty("local.server.port"));
}
@PostMapping("/{userId}")
public ResponseEntity<ResponseMessage<ResponseOrder>> createOrder(
@PathVariable String userId,
@Valid @RequestBody RequestOrder requestOrder) {
ResponseOrder createdOrder = orderService.createOrder(userId, requestOrder);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ResponseMessage.success("주문이 성공적으로 생성되었습니다.", createdOrder));
}
@GetMapping("/{orderId}")
public ResponseEntity<ResponseMessage<ResponseOrder>> getOrder(@PathVariable String orderId) {
ResponseOrder order = orderService.getOrderByOrderId(orderId);
return ResponseEntity.ok(ResponseMessage.success(order));
}
@GetMapping("/users/{userId}")
public ResponseEntity<ResponseMessage<List<ResponseOrder>>> getOrdersByUser(
@PathVariable String userId) {
List<ResponseOrder> orders = orderService.getOrdersByUserId(userId);
return ResponseEntity.ok(ResponseMessage.success(orders));
}
}
11. 전체 서비스 실행 및 테스트
11.1. 실행 순서
- Eureka Server 실행 (포트 8761)
- http://localhost:8761에서 대시보드 확인
- User Service 실행
- 랜덤 포트 할당
- Eureka 대시보드에서 등록 확인
- Catalog Service 실행
- 랜덤 포트 할당
- Eureka 대시보드에서 등록 확인
- Order Service 실행
- 랜덤 포트 할당
- Eureka 대시보드에서 등록 확인
- API Gateway 실행 (포트 8000)
- Eureka 대시보드에서 등록 확인
11.2. 테스트 시나리오
1. 사용자 생성
POST <http://localhost:8000/users>
Content-Type: application/json
{
"email": "test@example.com",
"name": "홍길동",
"pwd": "password123"
}
2. 사용자 목록 조회
GET <http://localhost:8000/users>
3. 상품 목록 조회
GET <http://localhost:8000/catalogs>
4. 주문 생성
POST <http://localhost:8000/orders/{userId}>
Content-Type: application/json
{
"productId": "CATALOG-001",
"quantity": 2,
"unitPrice": 1500
}
5. 사용자별 주문 내역 조회
GET <http://localhost:8000/orders/users/{userId}>
12. 결론 및 다음 단계
이번 글에서는 실제 이커머스 애플리케이션의 기본 구조를 설계하고 세 가지 핵심 마이크로서비스를 구현했다. 각 서비스는 독립적인 데이터베이스를 가지며, JPA를 통해 데이터를 관리한다. 또한 Spring Security와 BCryptPasswordEncoder를 활용하여 사용자 비밀번호를 안전하게 암호화했다.
현재까지 구현된 내용:
- ✅ User Service: 회원 가입, 사용자 정보 조회, 비밀번호 암호화
- ✅ Catalog Service: 상품 목록 조회, 상품 상세 정보
- ✅ Order Service: 주문 생성, 주문 내역 조회
- ✅ 모든 서비스 Eureka에 등록
- ✅ API Gateway를 통한 라우팅
다음 글에서는 현재까지 구현한 서비스에 인증 처리를 추가할 예정이다. JWT(JSON Web Token)를 활용하여 사용자 로그인 기능을 구현하고, API Gateway에서 토큰을 검증하는 AuthorizationFilter를 추가할 것이다. 또한 사용자 서비스에 주문 내역을 포함하는 기능도 함께 구현할 예정이다. 이를 통해 진정한 의미의 마이크로서비스 기반 이커머스 애플리케이션을 완성해나갈 것이다.
'MSA > MSA 기본' 카테고리의 다른 글
| [BASIC #6] Configuration Service와 중앙 설정 관리 (0) | 2025.09.20 |
|---|---|
| [BASIC #5] 인증 처리와 JWT 기반 보안 (0) | 2025.09.20 |
| [BASIC #3] API Gateway (0) | 2025.09.20 |
| [BASIC #2] Service Discovery (0) | 2025.09.20 |
| [BASIC #1] Microservice와 Spring Cloud의 소개 (0) | 2025.09.20 |
