[MSA] e-commerce API; 프로젝트 흐름
[MSA] e-commerce API; 구현1 - 설정(config-server, eureka-server, gateway)
[MSA] e-commerce API; 구현2 - customer, product, payment, order, notification
(앞선 게시글에 작성한 내용은 생략할 예정입니다)
작성한 ERD를 참조하여 구현합니다.
Order
e-commerce API 의 중심이 되는 도메인입니다.
고객(Customer)이 제품(Product)을 주문(Order)하고 구매(Payment)합니다.
주문에는 하위 항목(OrderLine)이 존재하며, 각 항목별로 결제(Purchase)가 요청됩니다.
order는 HttpClient를 통해 다른 서비스에게 요청하고 응답을 받아옵니다.
HttpClient의 종류는 다양합니다.
아주 기본적인 HttpURLConnection부터 ApacheHttpClient, RestTemplate, OpenFeign, WebClient, RestClient가 있습니다.
이 프로젝트에서는 Spring Cloud에서 주로 쓰이는 OpenFiegn과,
Spring이 가장 최근에 도입한 RestClient를 사용합니다.
중요한 것은 일관성을 유지하여 유지보수를 편하게 하는 것이지만,
학습을 위해 두 Http Client 모두 작성해보도록 하겠습니다.
일관성이 중요한 데이터이므로 RDBMS에 저장합니다.
(RDBMS는 postgreSQL을 사용했습니다)
아래는 order프로젝트의 의존성입니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'io.micrometer:micrometer-tracing-bridge-brave'
implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
implementation 'org.springframework.cloud:spring-cloud-starter-config'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.kafka:spring-kafka'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
주요 의존성은 openfeign으로, spring cloud에 특화된 HTTP Client입니다.
복잡한 기능을 구현하는 데에 제약이 있으나,
인터페이스를 활용하여 HTTP 통신을 선언형으로 간단하게 구현할 수 있습니다.
order의 설정파일인 application.yml은 아래와 같습니다.
spring:
application:
name: order-service
config:
import: optional:configserver:http://localhost:8888
config-server의 product-service.yml은 아래와 같습니다.
server:
port: 8070
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/order
username: ride
password: password
jpa:
hibernate:
ddl-auto: update
database: postgresql
database-platform: org.hibernate.dialect.PostgreSQLDialect
kafka:
producer:
bootstrap-servers: localhost:9092
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
properties:
spring.json.type.mapping: orderConfirmation:com.ride.ecommerce.kafka.OrderConfirmation
application:
config:
customer-url: http://localhost:8222/api/v1/customers
payment-url: http://localhost:8222/api/v1/payments
product-url: http://localhost:8222/api/v1/products
application.config 설정을 통해 각 서비스의 url을 등록했습니다.order 프로젝트에서 이를 참조할 수 있습니다.
아래는 order 도메인이 사용하는 DB입니다.
OpenFeign을 사용하기 위해 main class에 @EnableFeignClients 를 추가합니다.
JpaAuditing을 사용하기 위해 @EnableJpaAuditing 을 추가합니다.
package com.ride.ecommerce;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableFeignClients
@EnableJpaAuditing
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
1.0 디렉토리 구조
order의 디렉토리 구조는 아래와 같습니다.
config에 RestClient를,
customer, payment, product 디렉토리에 각각 HttpClient관련 파일 및 DTO를 생성합니다.
order, orderLine 디렉토리에 order, order-line에대한 코드를 작성합니다.
1.1 config
1.1.1 RestClientConfig
RestClient, WebClient, RestTemplate의 HttpClient을 Spring Bean으로 관리하지 않으면 필요하지 않습니다.
package com.ride.ecommerce.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient() {
return RestClient.create();
}
}
현재 인터셉터 설정이 따로 필요하지 않기 때문에 Spring Bean으로 등록하는 것 이외에 따로 작성한 코드는 없습니다.
1.2 order
1.2.1 Order class, PaymentMethod enum
package com.ride.ecommerce.order;
import com.ride.ecommerce.orderLine.OrderLine;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static jakarta.persistence.EnumType.STRING;
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "customer_order")
public class Order {
@Id
@GeneratedValue
private Integer id;
private String reference;
private BigDecimal totalAmount;
@Enumerated(STRING)
private PaymentMethod paymentMethod;
private String customerId;
@OneToMany(mappedBy = "order")
private List<OrderLine> orderLines;
@CreatedDate
@Column(updatable = false, nullable = false)
private LocalDateTime createdDate;
@LastModifiedDate
@Column(insertable = false)
private LocalDateTime lastModifiedDate;
}
package com.ride.ecommerce.order;
public enum PaymentMethod {
PAYPAL,
CREDIT_CARD,
VISA,
MASTER_CARD,
BITCOIN
}
1.2.2 OrderController class
package com.ride.ecommerce.order;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* REST controller for mapping orders
*/
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService service;
/**
* POST /api/v1/orders : Create a new Order.
* @param request the order request containing order details
* @return the ResponseRntity with status 200 (OK) and the ID of the created order
*/
@PostMapping
public ResponseEntity<Integer> createOrder(
@RequestBody @Valid OrderRequest request
) {
return ResponseEntity.ok(service.createOrder(request));
}
/**
* GET /api/v1/orders
*
* @return the ResponseEntity with status 200 (OK) and the list of orders
*/
@GetMapping
public ResponseEntity<List<OrderResponse>> findAll() {
return ResponseEntity.ok(service.findAll());
}
/**
* GET /api/v1/orders/{order-id} : Get an order by ID.
*
* @param orderId the ID of the order to retrieve
* @return the ResponseEntity with status 200 (OK) and the order details
*/
@GetMapping("/{order-id}")
public ResponseEntity<OrderResponse> findById(
@PathVariable("order-id") Integer orderId
) {
return ResponseEntity.ok(service.findById(orderId));
}
}
1.2.3 OrderRequest, OrderResponse record
package com.ride.ecommerce.order;
import com.ride.ecommerce.product.PurchaseRequest;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.util.List;
public record OrderRequest(
Integer id,
String reference,
@Positive(message = "Order amount should be positive")
BigDecimal amount,
@NotNull(message = "Payment method should be precised")
PaymentMethod paymentMethod,
@NotNull(message = "Customer should be present")
@NotEmpty(message = "Customer should be present")
@NotBlank(message = "Customer should be present")
String customerId,
@NotEmpty(message = "You should at least purchase one product")
List<PurchaseRequest> products
) {
}
package com.ride.ecommerce.order;
import java.math.BigDecimal;
public record OrderResponse(
Integer id,
String reference,
BigDecimal amount,
PaymentMethod paymentMethod,
String customerId
) {
}
1.2.4 OrderService, OrderMapper class
package com.ride.ecommerce.order;
import com.ride.ecommerce.customer.CustomerClient;
import com.ride.ecommerce.exeption.BusinessException;
import com.ride.ecommerce.orderLine.OrderLineRequest;
import com.ride.ecommerce.orderLine.OrderLineService;
import com.ride.ecommerce.payment.PaymentClient;
import com.ride.ecommerce.payment.PaymentRequest;
import com.ride.ecommerce.product.ProductClient;
import com.ride.ecommerce.product.PurchaseRequest;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* Service class for managing orders
*/
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final CustomerClient customerClient;
private final ProductClient productClient;
private final OrderMapper mapper;
private final OrderLineService orderLineService;
private final PaymentClient paymentClient;
/**
* Creates a new Order.
* @param request the order request containing order details
* @return the ID of the created order
*/
public Integer createOrder(OrderRequest request) {
// check the customer --> openFeign
var customer = this.customerClient.findCustomerById(request.customerId())
.orElseThrow(() -> new BusinessException("Cannot create order:: No Customer exists with the provided ID"));
// purchase the products --> product-ms (RestTemplate, RestClient)
var purchasedProducts = this.productClient.purchaseProducts(request.products());
// persist order
var order = this.repository.save(mapper.toOrder(request));
//persist order lines
for (PurchaseRequest purchaseRequest : request.products()) {
orderLineService.saveOrderLine(
new OrderLineRequest(
null,
order.getId(),
purchaseRequest.productId(),
purchaseRequest.quantity()
)
);
}
// start payment process
var paymentRequest = new PaymentRequest(
request.amount(),
request.paymentMethod(),
order.getId(),
order.getReference(),
customer
);
paymentClient.requestOrderPayment(paymentRequest);
// send the order confirmation --> notification-ms (kafka)
return order.getId();
}
/**
* Finds all orders.
*
* @return the list of order responses containing details of all orders
*/
public List<OrderResponse> findAll() {
return repository.findAll()
.stream()
.map(mapper::fromOrder)
.collect(Collectors.toList());
}
/**
* Finds an order by its ID.
*
* @param orderId the ID of the order to retrieve
* @return the order response containing order details
* @throws EntityNotFoundException if no order is found with the given ID
*/
public OrderResponse findById(Integer orderId) {
return repository.findById(orderId)
.map(mapper::fromOrder)
.orElseThrow(() -> new EntityNotFoundException(String.format("No order found with the provided ID: %d", orderId)));
}
}
package com.ride.ecommerce.order;
import org.springframework.stereotype.Service;
/**
* Mapper class for converting between OrderRequest/OrderResponse DTOs and Order entity.
*/
@Service
public class OrderMapper {
/**
* Converts an OrderRequest DTO to an Order entity
*
* @param request the order request with customer details
* @return the Order entity
*/
public Order toOrder(OrderRequest request) {
return Order.builder()
.id(request.id())
.customerId(request.customerId())
.reference(request.reference())
.totalAmount(request.amount())
.paymentMethod(request.paymentMethod())
.build();
}
/**
* Converts an Order entity to an OrderResponse DTO.
*
* @param order the Order entity
* @return the OrderResponse DTO
*/
public OrderResponse fromOrder(Order order) {
return new OrderResponse(
order.getId(),
order.getReference(),
order.getTotalAmount(),
order.getPaymentMethod(),
order.getCustomerId()
);
}
}
1.2.5 OrderRepository interface
package com.ride.ecommerce.order;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Integer> {
}
1.3 orderLine
1.3.1 OrderLine class
package com.ride.ecommerce.orderLine;
import com.ride.ecommerce.order.Order;
import jakarta.persistence.*;
import lombok.*;
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@Entity
public class OrderLine {
@Id
@GeneratedValue
private Integer id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
private Integer productId;
private double quantity;
}
Order와 다대일 관계로, OrderLine이 연관관계의 주인입니다.
1.3.2 OrderLineController class
package com.ride.ecommerce.orderLine;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* REST controller for managing order lines.
*/
@RestController
@RequestMapping("/api/v1/order-line")
@RequiredArgsConstructor
public class OrderLineController {
private final OrderLineService service;
/**
* GET /api/v1/order-line/order/{order-id} : Get all order lines by order ID.
*
* @param orderId the ID of the order to retrieve order lines for
* @return the ResponseEntity with status 200 (OK) and the list of order lines
*/
@GetMapping("/order/{order-id}")
public ResponseEntity<List<OrderLineResponse>> findByOrderId(
@PathVariable("order-id") Integer orderId
) {
return ResponseEntity.ok(service.findAllByOrderId(orderId));
}
}
1.3.3 OrderLineRequest, OrderLineResponse record
package com.ride.ecommerce.orderLine;
public record OrderLineRequest(
Integer id,
Integer orderId,
Integer productId,
double quantity
) {
}
package com.ride.ecommerce.orderLine;
public record OrderLineResponse(
Integer id,
double quantity
) {
}
1.3.4 OrderLineService, OrderLineMapper class
package com.ride.ecommerce.orderLine;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import static java.util.stream.Collectors.toList;
/**
* Service class for managing order lines.
*/
@Service
@RequiredArgsConstructor
public class OrderLineService {
private final OrderLineRepository repository;
private final OrderLineMapper mapper;
/**
* Saves a new order line.
*
* @param request the order line request containing order line details
* @return the ID of the saved order line
*/
public Integer saveOrderLine(OrderLineRequest request) {
var order = mapper.toOrderLine(request);
return repository.save(order).getId();
}
/**
* Finds all order lines by order ID.
*
* @param orderId the ID of the order to retrieve order lines for
* @return the list of order line responses
*/
public List<OrderLineResponse> findAllByOrderId(Integer orderId) {
return repository.findAllByOrderId(orderId)
.stream()
.map(mapper::toOrderLineResponse)
.collect(toList());
}
}
package com.ride.ecommerce.orderLine;
import com.ride.ecommerce.order.Order;
import org.springframework.stereotype.Service;
/**
* Mapper class for converting between OrderLineRequest/OrderLineResponse DTOs and OrderLine entity.
*/
@Service
public class OrderLineMapper {
/**
* Converts an OrderLineRequest DTO to and OrderLine entity.
*
* @param request request the order line request containing order line details
* @return the OrderLine entity
*/
public OrderLine toOrderLine(OrderLineRequest request) {
return OrderLine.builder()
.id(request.id())
.order(
Order.builder()
.id(request.orderId())
.build())
.productId(request.productId())
.quantity(request.quantity())
.build();
}
/**
* Converts an OrderLine entity to and OrderLineResponse DTO.
*
* @param orderLine the OrderLine entity
* @return the OrderLineResponse DTO
*/
public OrderLineResponse toOrderLineResponse(OrderLine orderLine) {
return new OrderLineResponse(
orderLine.getId(),
orderLine.getQuantity());
}
}
1.3.5 OrderLineRepository interface
package com.ride.ecommerce.customer;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface CustomerRepository extends MongoRepository<Customer, String > {
}
1.4 customer
1.4.1 CustomerResponse record
package com.ride.ecommerce.customer;
public record CustomerResponse(
String id,
String firstname,
String lastname,
String email
) {
}
1.4.2 CustomerClient interface
package com.ride.ecommerce.customer;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.Optional;
@FeignClient(
name = "customer-service",
url = "${application.config.customer-url}"
)
public interface CustomerClient {
@GetMapping("/{customer-id}")
Optional<CustomerResponse> findCustomerById(@PathVariable("customer-id") String customerId);
}
OpenFeign을 활용하여 HTTP 통신합니다.
@FeignClient 어노테이션을 입력합니다.
config-server의 order-service.yml에 설정한 데이터를 참조할 수 있도록 합니다.
Optional<CustomerResponse> 형식으로 반환받습니다.
아래는 CustomerClient가 통신할 customer-service의 Get 메서드입니다.
/**
* GET /api/v1/customers/{customer-id} : Get a customer by id.
*
* @param customerId the id of the customer to retrieve
* @return the ResponseEntity with status 200 (OK) and the customer details
*/
@GetMapping("/{customer-id}")
public ResponseEntity<CustomerResponse> findById(
@PathVariable("customer-id") String customerId
) {
return ResponseEntity.ok(service.findById(customerId));
}
1.5 product
1.5.1 PurchaseRequest, PurchaseResponse record
package com.ride.ecommerce.product;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record PurchaseRequest(
@NotNull(message = "Product is mandatory")
Integer productId,
@Positive(message = "Quantity is mandatory")
double quantity
) {
}
package com.ride.ecommerce.product;
import java.math.BigDecimal;
public record PurchaseResponse(
Integer productId,
String name,
String description,
BigDecimal price,
double quantity
) {
}
1.5.2 ProductClient class(OpenFeign 구현 시 interface)
위의 customerClient와 다르게 RestClient를 적용했습니다.
product-service와 통신하여 List<PurchaseResponse> 객체를 받아옵니다.
HttpInterface를 활용하여 선언형으로 사용하는 것은 최적화 포스팅에서 다루겠습니다.
package com.ride.ecommerce.product;
import com.ride.ecommerce.exeption.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.List;
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductClient {
@Value("${application.config.product-url}")
private String productUrl;
private final RestClient restClient;
public List<PurchaseResponse> purchaseProducts(List<PurchaseRequest> requestBody) {
HttpHeaders headers = new HttpHeaders();
headers.set(CONTENT_TYPE, APPLICATION_JSON_VALUE);
ParameterizedTypeReference<List<PurchaseResponse>> responseType =
new ParameterizedTypeReference<>() {
};
ResponseEntity<List<PurchaseResponse>> entity = restClient.post()
.uri(productUrl + "/purchase")
.headers(httpHeaders -> httpHeaders.addAll(headers))
.body(requestBody)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
log.info("request uri : {}", request.getURI());
throw new BusinessException("An error occurred while processing the products purchase: " + response.getStatusCode());
})
.toEntity(responseType);
return entity.getBody();
}
}
1.6 payment
1.6.1 PaymentRequest record
package com.ride.ecommerce.payment;
import com.ride.ecommerce.customer.CustomerResponse;
import com.ride.ecommerce.order.PaymentMethod;
import java.math.BigDecimal;
public record PaymentRequest(
BigDecimal amount,
PaymentMethod paymentMethod,
Integer orderId,
String orderReference,
CustomerResponse customer
) {
}
1.6.2 PaymentClient interface
package com.ride.ecommerce.payment;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@FeignClient(
name = "product-service",
url = "${application.config.payment-url}"
)
public interface PaymentClient {
@PostMapping
Integer requestOrderPayment(@RequestBody PaymentRequest request);
}
1.7 exception
1.7.1 BusinessException class
package com.ride.ecommerce.exeption;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Custom exception class for handling business logic related exceptions.
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class BusinessException extends RuntimeException {
/**
* The message describing the exception.
*/
private final String msg;
}
1.8 handler
1.8.1 GlobalExeptionHandler class
package com.ride.ecommerce.handler;
import com.ride.ecommerce.exeption.BusinessException;
import jakarta.persistence.EntityNotFoundException;
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 static org.springframework.http.HttpStatus.BAD_REQUEST;
/**
* Global exception handler for handling various exceptions across the application.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<String> handle(BusinessException exp) {
return ResponseEntity
.status(BAD_REQUEST)
.body(exp.getMsg());
}
/**
* Handles BusinessException and returns a 400 BAD REQUEST response.
*
* @param exp the BusinessException instance
* @return a ResponseEntity with a 400 status and the exception message
*/
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<String> handle(EntityNotFoundException exp) {
return ResponseEntity
.status(BAD_REQUEST)
.body(exp.getMessage());
}
/**
* Handles EntityNotFoundException and returns a 400 BAD REQUEST response.
*
* @param exp the EntityNotFoundException instance
* @return a ResponseEntity with a 400 status and the exception message
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handle(MethodArgumentNotValidException exp) {
var errors = new HashMap<String, String>();
exp.getBindingResult().getAllErrors()
.forEach(error -> {
var fieldName = ((FieldError)error).getField();
var errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity
.status(BAD_REQUEST)
.body(new ErrorResponse(errors));
}
}
1.8.1 ErrorResponse record
package com.ride.ecommerce.handler;
import java.util.Map;
public record ErrorResponse(
Map<String, String> errors
) {
}
'Java' 카테고리의 다른 글
[JAVA, JDK, Tomcat, Eclipse, JSP] JDK, Tomcat 및 이클립스 환경 구축, JSP 프로젝트 생성 (0) | 2024.07.19 |
---|---|
[MSA] e-commerce API; 구현2.5 - notification(kafka, email) (0) | 2024.06.21 |
[MSA] e-commerce API; 구현2.3 - payment (1) | 2024.06.19 |
[MSA] e-commerce API; 구현2.2 - product (0) | 2024.06.19 |
[MSA] e-commerce API; 구현2.1 - customer (0) | 2024.06.18 |