
해당 포스팅은 인프런에 "Spring Cloud로 개발하는 마이크로서비스 애플리케이션(MSA)" 강의를 기반으로 작성됐습니다.
포스팅의 모든 사진 자료는 해당 강의 출처임을 밝힙니다.
지난 포스팅에서는 User Microservice에 대해 구현해봤느데, 구현한 기능들을 보면 다음과 같습니다.
welcome 메시지 & value 또는 environment를 통한 메타 데이터 가져오기
H2 DB 연동
회원 가입 기능 구현
Spring Security를 활용하여 BcrptPasswordEncoder 사용하여 암호화
오늘은 Uesr microservice에 추가로 구현할 부분과 나머지 두 마이크로 서비스를 구현하려고 합니다.
📖 학습목표
- User Microservice part2 구현
- 사용자 조회
- Spring Cloud Gateway 연동- Catalog Microservice 구현
- Order Microservice 구현
어제에 이어서 User Microservice의 추가 기능을 구현하겠습니다.

추가할 기능으로는 다음과 같습니다.

전체 사용자 조회
사용자 정보, 주문 내역 조회

Environment 객체를 활용하여 메타 데이터 중 서버 포트 번호를 가져와 출력해주도록 수정합니다.
다음과 같이 Spring Cloud Gateway에 application.yml 파일에 라우트를 추가해줍니다.
spring:
application:
name: apigateway-service
cloud:
gateway:
default-filters:
- name: GlobalFilter
args:
baseMessage: Spring Cloud Gateway Global Filter
preLogger: true
postLogger: true
routes:
- id : user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
...
위처럼 등록해주면, Api Gateway를 통해서 User Microservice로 접근이 가능합니다.
Eureka-Server, Api Gateway Server, User Microservice를 모두 기동하여 테스트를 진행했습니다.
우선, Eureka Server 상에 게이트웨이와 유저 서비스가 등록됐는지 부터 확인했습니다.

정상적으로 등록된 것을 확인하였고,그 다음으로 User-Service 서버로 요청 시 정상 응답되는 것을 확인했습니다.

하지만, Api Gateway를 통해서 요청한 결과 다음과 같이 404 응답 상태가 발생했습니다.
요청 경로
http://localhost:8000/user-service/health-check
응답결과


즉, UserController 상에 정의된 매핑 Uri와 실제 요청한 Uri가 맞지 않기 때문입니다.
이를 맞춰주기 위해서는 다음과 같이 UserController 상에 @GetMapping() 에 경로를 "/user-service/health-check" 로 수정해주면 됩니다.

이처럼 수정해주면, 다음과 같이 정상 호출되는 것을 확인할 수 있습니다.

위 UserController 측에 매핑 정보 수정은 최상단에 class명 위치에 @RequestMapping()을 통해 전체 매핑 정보의 prefix를 추가해주면 매번 추가해주지 않아도 됩니다.

@Data
@JsonInclude(JsonInclude.Include.NON_NULL) // 추가1
public class ResponseUser {
private String email;
private String name;
private String userId;
private List<ResponseOrder> orders; // 추가2
}
@JsonInclude(JsonInclude.Include.NON_NULL) 은 객체에 null 값인 필드는 json 데이터로 변환 시 변환되지 않도록 해주는 어노테이션입니다.
회원의 주문 목록 정보 관련 List 객체를 추가해줍니다.
@Data
public class ResponseOrder {
private String productId; // 상품 아이디
private Integer qty; // 상품수량
private Integer unitPrice; // 가격
private Integer totalPrice; // 전체가격
private Date createdAt; // 주문일자
private String orderId; // 주문 아이디
}
Order Microservice 로부터 받아올 데이터 정보를 정의해줍니다.
import org.example.userservice.dto.UserDto;
import org.example.userservice.jpa.UserEntity;
public interface UserService {
UserDto createUser(UserDto userDto);
UserDto getUserByUserId(String userId); // 추가 - 회원 상세 조회
Iterable<UserEntity> getUserByAll(); // 추가 - 전체 회원 목록 조회
}
@Data
public class UserDto {
private String email;
private String name;
private String pw;
private String userId;
private Date createdAt;
private String encryptedPw;
private List<ResponseOrder> orders; // 추가
}
// Interface에 추가한 메서드 구현
@Service
public class UserServiceImpl implements UserService{
...
@Override
public UserDto getUserByUserId(String userId) {
UserEntity userEntity = userRepository.findById(userId);
if(userEntity == null)
throw new UsernameNotFoundException("user not found");
UserDto userDto = new ModelMapper().map(userEntity,UserDto.class);
List<ResponseOrder> orders = new ArrayList<>();
userDto.setOrders(orders);
return userDto;
}
@Override
public Iterable<UserEntity> getUserByAll() {
return userRepository.findAll();
}
}
회원 상세 정보 조회 메서드입니다.
userId(String)로 값을 조회하도록 구현했습니다.
회원 전체 목록 조회 메서드입니다.
Spring Data JPA에서 제공하는 findAll() 바로 호출하여 전체 목록 데이터를 조회하도록 구현했습니다.
@RestController
@RequestMapping("/user-service")
public class UserController {
...
// 전체 회원 조회
@GetMapping("/users")
public ResponseEntity<List<ResponseUser>> getUsers(){
Iterable<UserEntity> userList = userService.getUserByAll();
List<ResponseUser> result = new ArrayList<>();
userList.forEach(v->{
result.add(new ModelMapper().map(v, ResponseUser.class));
});
return ResponseEntity.status(HttpStatus.OK).body(result);
}
// 개별 회원 조회 - userId
@GetMapping("/users/${userId}")
public ResponseEntity<ResponseUser> getUser(@PathVariable("userId") String userId){
UserDto userDto = userService.getUserByUserId(userId);
ResponseUser responseUser = new ModelMapper().map(userDto,ResponseUser.class);
return ResponseEntity.ok().body(responseUser);
}
}
UserService를 통해 반환받은 객체를 ModelMapper를 활용하여 ReseponseUser 객체로 매핑 후 ResponseEntity 객체에 응답 상태 코드와 함께 데이터를 전달합니다.
postman을 활용하여 회원 전체 목록과 개별 조회 기능을 API 테스트 해보겠습니다.
우선, 회원 전체 목록 조회입니다.

다음과 같이 Api Gateway를 거쳐 User Microservice에 users에 해당하는 메서드를 호출해줍니다.

위처럼 정상 응답한 것을 알 수 있습니다.
그 다음으로, 개별 회원 조회를 해보겠습니다.


위처럼 정상 응담 및 데이터가 잘 나오는 것을 확인할 수 있습니다.

Catalogs Microservice의 전반적인 구조입니다.

Catalogs Microservice에서는 상품 목록 조회에 대해서만 구현할 예정입니다.
Spring Boot DevTools
Spring Web
Spring Cloud Discovery Client
Spring Data JPA
Validation
H2 Database
ModelStruct
server:
port: 0
spring:
application:
name: catalog-service
h2:
console:
enabled: true
settings:
web-allow-others: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create
show-sql: true
generate-ddl: true
defer-datasource-initialization: true
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password:
sql:
init:
mode: always
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.catalogservice: DEBUG
💡 로깅 레벨

테이블 생성 시 초기 데이터를 넣기 위해 sql 파일에 insert 쿼리를 추가해줍니다.
💡 SQL Script DataSource Initailization 변경
Spring Boot 2.5 버전부터 SQL Script DataSource Initailization 기능이 변경됐습니다.
이로 인해 테이블이 생성되지 않아 insert 구문에서 오류가 발생하게 됩니다.
기본적으로 data.sql 스크립트는 Hibernate가 초기화 되기 전에 실행되어야 하는데, 테이블이 자동으로 생성되지 못하여 insert 구문의 오류가 발생할 수 있습니다.
테이블 생성을 위해서 다음과 같이 defer-datasource-initialization:true 설정을 추가해주면 됩니다.

서버 기동 시 테이블 생성과 동시에 데이터가 들어오는 것을 확인할 수 있습니다.
@Data
@Entity
@Table(name="catalog")
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 Date createdAt;
}
createdAt 필드에 @Column(nullable=false, updatable=false, insertable=false) 설정을 통해서 필드 저장 및 수정 방지했습니다.
insertable : Entity 저장 시 필드도 함께 저장하는 속성으로 false 설정 시 읽기 전용으로 설정됩니다.
updatable : Entity 수정 시 필드도 함께 수정하는 속성으로 false 설정 시 읽기 전용으로 설정됩니다.
public interface CatalogRepository extends CrudRepository<CatalogEntity,Long> {
CatalogEntity findByProductId(String productId);
}
@Data
public class CatalogDto implements Serializable {
private String productId;
private Integer qty;
private Integer unitPrice;
private Integer totalPrice;
private String orderId;
private String userId;
}
계층 간에 데이터를 전달하기 위한 DTO 정의합니다.
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseCatalog {
private String productId;
private String productName;
private Integer unitPrice;
private Integer stock;
private Date createdAt;
}
요청에 따른 상품 데이터를 응답하기 위한 VO를 정의합니다.
@RestController
@RequestMapping("/catalog-service")
public class CatalogController {
Environment env;
CatalogService catalogService;
public CatalogController(Environment env, CatalogService catalogService) {
this.env = env;
this.catalogService = catalogService;
}
@GetMapping("/health-check")
public String status() {
return String.format("It's Working in catalog-Service on PORT %s",
env.getProperty("local.server.port"));
}
// 전체 상품 조회
@GetMapping("/catalogs")
public ResponseEntity<List<ResponseCatalog>> getUsers(){
Iterable<CatalogEntity> userList = catalogService.getAllCatalogs();
List<ResponseCatalog> result = new ArrayList<>();
userList.forEach(v->{
result.add(CatalogMapper.INSTANCE.entityToDto(v));
});
return ResponseEntity.status(HttpStatus.OK).body(result);
}
}
User-service에서는 POJO 간에 매핑을 위해 ModelMapper 라이브러리를 사용했지만, 금번에는 성능이 상대적으로 좋은 MapStruct 라는 라이브러리를 사용하여 구현했습니다.
자세한 내용은 아래 포스팅에서 설명하겠습니다.
@Mapper
public interface CatalogMapper {
CatalogMapper INSTANCE = Mappers.getMapper(CatalogMapper.class);
@Mapping(target = "productId", source = "productId")
@Mapping(target = "productName", source = "productName")
@Mapping(target = "unitPrice", source = "unitPrice")
@Mapping(target = "stock", source="stock")
@Mapping(target = "createdAt", source="createdAt")
ResponseCatalog entityToDto(CatalogEntity catalogEntity);
}
postman을 통해서 전체 상품 조회를 테스트 해보겠습니다.


정상적으로 응답한것을 확인할 수 있습니다.

Orders Microservice는 상품을 주문할 때, 주문 정보를 관리하기 위한 서비스 입니다.
해당 마이크로 서비스에서는 사용자별 상품 주문과 사용자 별 주문 내역 조회 기능을 추가하겠습니다.

Lombok
Spring Boot DevTools
Spring Web
Spring Data JPA
H2 Database
Eureka Discovery Client
MapStruct
server:
port: 0
spring:
application:
name: order-service
h2:
console:
enabled: true
settings:
web-allow-others: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
show_sql: true
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:testdb
username: sa
password:
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.orderservice: DEBUG
@Data
@Entity
@Table(name = "orders")
public class OrderEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable=false, length=120)
private String productId;
@Column(nullable=false)
private Integer qty;
@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;
@Column(nullable=false, updatable = false, insertable = false)
@ColumnDefault(value = "CURRENT_TIMESTAMP")
private Date createdAt;
}
주문 테이블과 매핑하기 위한 엔티티를 정의합니다.
public interface OrderRepository extends CrudRepository<OrderEntity,Long> {
OrderEntity findByOrderId(String orderId);
Iterable<OrderEntity> findByUserId(String userId);
}
@Data
public class OrderDto implements Serializable {
private String productId;
private Integer qty;
private Integer unitPrice;
private Integer totalPrice;
private String userId;
private String orderId;
private String createdAt;
}
@Data
public class RequestOrder {
private String productId;
private Integer qty;
private Integer unitPrice;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseOrder {
private String productId;
private Integer qty;
private Integer unitPrice;
private Integer TotalPrice;
private Date createdAt;
private String orderId;
}
@Mapper
public interface OrderMapper {
OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);
// target : entity, source : dto - db 입력 데이터
@Mapping(target = "productId" ,source ="productId" )
@Mapping(target = "qty" ,source ="qty" )
@Mapping(target = "unitPrice" ,source ="unitPrice" )
@Mapping(target = "totalPrice" ,source ="totalPrice" )
@Mapping(target = "orderId" ,source ="orderId" )
@Mapping(target = "userId" ,source ="userId" )
OrderEntity dtoToEntity(OrderDto orderDto);
// target : dto, source : entity - db 데이터 반환
@Mapping(target = "productId" ,source = "productId" )
@Mapping(target = "qty" ,source = "qty" )
@Mapping(target = "unitPrice" ,source = "unitPrice" )
@Mapping(target = "totalPrice" ,source = "totalPrice" )
@Mapping(target = "orderId" ,source = "orderId" )
@Mapping(target = "createdAt", source = "createdAt")
OrderDto entityToDto(OrderEntity orderEntity);
// target : dto, source : vo - 입력 데이터
@Mapping(target = "productId" ,source = "productId" )
@Mapping(target = "qty" ,source = "qty" )
@Mapping(target = "unitPrice" ,source = "unitPrice" )
OrderDto voToDto(RequestOrder requestOrder);
// target : vo, source : dto - 출력 데이터
@Mapping(target = "productId" ,source = "productId" )
@Mapping(target = "qty" ,source = "qty" )
@Mapping(target = "unitPrice" ,source = "unitPrice" )
@Mapping(target = "totalPrice" ,source = "totalPrice" )
@Mapping(target = "orderId" ,source = "orderId" )
@Mapping(target = "createdAt", source = "createdAt")
ResponseOrder dtoToVo(OrderDto orderDto);
// target : vo, source : entity
@Mapping(target = "productId" ,source = "productId" )
@Mapping(target = "qty" ,source = "qty" )
@Mapping(target = "unitPrice" ,source = "unitPrice" )
@Mapping(target = "totalPrice" ,source = "totalPrice" )
@Mapping(target = "orderId" ,source = "orderId" )
@Mapping(target = "createdAt", source = "createdAt")
ResponseOrder entityToVo(OrderEntity orderEntity);
}
public interface OrderService {
// 주문 생성
OrderDto createOrder(OrderDto orderDetails);
// 주문 정보 조회
OrderDto getOrderByOrderId(String orderId);
// 회원 주문 목록 조회
Iterable<OrderEntity> getOrdersByUserId(String userId);
}
@Service
public class OrderServiceImpl implements OrderService {
private OrderRepository orderRepository;
public OrderServiceImpl(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public OrderDto createOrder(OrderDto orderDetails) {
orderDetails.setOrderId(UUID.randomUUID().toString());
orderDetails.setTotalPrice(orderDetails.getUnitPrice() * orderDetails.getQty()); // 수량 * 단가
OrderEntity orderEntity = OrderMapper.INSTANCE.dtoToEntity(orderDetails);
orderEntity = orderRepository.save(orderEntity);
return OrderMapper.INSTANCE.entityToDto(orderEntity);
}
@Override
public OrderDto getOrderByOrderId(String orderId) {
OrderEntity orderEntity = orderRepository.findByOrderId(orderId);
return OrderMapper.INSTANCE.entityToDto(orderEntity);
}
@Override
public Iterable<OrderEntity> getOrdersByUserId(String userId) {
return orderRepository.findByUserId(userId);
}
}
@RestController
@RequestMapping("/order-service") // prefix 설정
public class OrderController {
Environment env;
OrderService orderService;
public OrderController(Environment env, OrderService orderService) {
this.env = env;
this.orderService = orderService;
}
@GetMapping("/health-check")
public String status(){
return String.format("It's Working in Order Service on PORT %s",
env.getProperty("local.server.port"));
}
@PostMapping("/{userId}/orders")
public ResponseEntity<ResponseOrder> createOrder(@PathVariable("userId") String userId,
@RequestBody RequestOrder orderDetails){
OrderDto orderDto = OrderMapper.INSTANCE.voToDto(orderDetails);
orderDto.setUserId(userId);
OrderDto createdOrder = orderService.createOrder(orderDto);
ResponseOrder responseUser = OrderMapper.INSTANCE.dtoToVo(createdOrder);
return ResponseEntity.status(HttpStatus.CREATED).body(responseUser);
}
@GetMapping("/{orderId}/orders")
public ResponseEntity<ResponseOrder> getOrderByOrderId(@PathVariable("orderId") String orderId){
OrderDto orderDto = orderService.getOrderByOrderId(orderId);
ResponseOrder responseOrder = OrderMapper.INSTANCE.dtoToVo(orderDto);
return ResponseEntity.status(HttpStatus.OK).body(responseOrder);
}
@GetMapping("/{userId}/orders")
public ResponseEntity<List<ResponseOrder>> getOrderByUserId(@PathVariable("userId") String userId){
Iterable<OrderEntity> orderList = orderService.getOrdersByUserId(userId);
List<ResponseOrder> result = new ArrayList<>();
orderList.forEach(entity -> result.add(OrderMapper.INSTANCE.entityToVo(entity)));
return ResponseEntity.status(HttpStatus.OK).body(result);
}
}

테스트를 위해서 전서버 기동 중 H2 DB 접속 시 Lock 오류가 발생하면서 기동이 되지 않는 현상이 발생했습니다.
간단히 설명하면, TCP/IP 설정을 하지 않아 발생한 오류입며, 자세한 설명은 아래 포스팅에서 확인하실 수 있습니다.
⚠️ H2 DB 접속 오류

위처럼 모든 마이크로 서비스가 잘 등록된 것을 확인할 수 있습니다.
테스트 진행을 위해 회원가입을 수행하였으며, 상품 구매(주문 등록) 및 주문 목록 조회 기능을 테스트하겠습니다.



