[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를 참조하여 구현합니다.
Customer
고객(Customer)의 데이터는 MongoDB에 저장하여,
고객의 자료형 또는 고객 데이터의 변화에 유연하게 대처할 수 있게 합니다.
아래는 customer프로젝트의 의존성입니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
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.boot:spring-boot-starter-data-mongodb'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
http통신을 위한 spring-boot-starter-web,
자료형 검증을 위한 spring-boot-starter-validation
개발 편의성을 위한 lombok,
mongodb용 의존성이 추가되었습니다.
customer의 설정파일인 application.yml은 아래와 같습니다.
spring:
application:
name: customer-service
config:
import: optional:configserver:http://localhost:8888
이제 config-server에 customer-service.yml을 작성합니다.
server:
port: 8090
spring:
data:
mongodb:
username: ride
password: password
host: localhost
port: 27017
database: customer
authentication-database: admin
gateway가 customer와 통신한다면 8090포트를 거칠 것입니다.
mongoDB에 customer를 생성하여 사용할 것이므로
관련 설정 또한 작성합니다.
아래는 mongoDB의 database목록이며 customer 도메인은 customer db를 사용합니다.
1.0 디렉토리 구조
customer의 디렉토리 구조는 아래와 같습니다.
exception 디렉토리에 Customer에 대한 Exception 클래스(CustomerNotFoundException)를 생성할 것이며,
handler 디렉토리에 Exception 전역처리 설정을 위한 GlobalExceptionHandler를 구성합니다.
customer 디렉토리에 customer 도메인과 연관된 파일 및 코드를 구성합니다.
1.1 customer
1.1.1 Customer class
mongoDB에 저장할 고객(Customer)의 자료형을 정의합니다.
package com.ride.ecommerce.customer;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@Document
public class Customer {
@Id
private String id;
private String firstname;
private String lastname;
private String email;
private Address address;
}
MongoDB를 사용하므로 JPA의 @Entity가 아닌 @Document를 사용합니다.
1.1.2 Address class
Address는 Customer의 하위 항목입니다.
package com.ride.ecommerce.customer;
import lombok.*;
import org.springframework.validation.annotation.Validated;
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@Validated
public class Address {
private String street;
private String houseNumber;
private String zipCode;
}
@Validated를 사용하여, 객체 생성 시 유효성 검사가 진행되도록 합니다.
@NotNull, @Email 등의 어노테이션과 함께 쓰이고,
매개변수에 @Valid를 기재하여 활성화합니다.
아래는 이를 기반으로 생성된 mongoDB입니다.
1.1.3 CustomerController class
다른 서비스에서 customer에 보낸 요청을 처리하고 응답을 반환합니다.
package com.ride.ecommerce.customer;
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 managing customers.
*/
@RestController
@RequestMapping("/api/v1/customers")
@RequiredArgsConstructor
public class CustomerController {
private final CustomerService service;
/**
* POST /api/v1/customers : Create a new customer.
*
* @param request the customer request with customer details
* @return the ResponseEntity with status 200 (OK) and the result of the creation
*/
@PostMapping
public ResponseEntity<String> createCustomer(
@RequestBody @Valid CustomerRequest request
) {
return ResponseEntity.ok(service.createCustomer(request));
}
/**
* PUT /api/v1/customers : Update an existing customer.
*
* @param request the customer request with updated customer details
* @return the ResponseEntity with status 202 (Accepted)
*/
@PutMapping
public ResponseEntity<Void> updateCustomer(
@RequestBody @Valid CustomerRequest request
) {
service.updateCustomer(request);
return ResponseEntity.accepted().build();
}
/**
* GET /api/v1/customers : Get all customers.
*
* @return the ResponseEntity with status 200 (OK) and the list of customers
*/
@GetMapping
public ResponseEntity<List<CustomerResponse>> findAll() {
return ResponseEntity.ok(service.findAllCustomers());
}
/**
* GET /api/v1/customers/exits/{customer-id} : Check if a customer exists by id.
*
* @param customerId the id of the customer to check
* @return the ResponseEntity with status 200 (OK) and a boolean indicating if the customer exists
*/
@GetMapping("/exits/{customer-id}")
public ResponseEntity<Boolean> existsById(
@PathVariable("customer-id") String customerId
) {
return ResponseEntity.ok(service.existsById(customerId));
}
/**
* 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));
}
/**
* DELETE /api/v1/customers/{customer-id} : Delete a customer by id.
*
* @param customerId the id of the customer to delete
* @return the ResponseEntity with status 202 (Accepted)
*/
@DeleteMapping("/{customer-id}")
public ResponseEntity<Void> deleteById(
@PathVariable("customer-id") String customerId
) {
service.deleteCustomer(customerId);
return ResponseEntity.accepted().build();
}
}
Controller의 주요 메서드를 살펴보겠습니다.
@RestController
RestController는 Controller + ResponseBody 를 합한 것으로,
@RestController 어노테이션을 통해 Spring Bean으로 관리하도록 합니다.
@RequestMapping("/api/v1/customers")
@RequestMapping을 통해 클라이언트가 보낸 http 요청을 처리합니다.
public class CustomerController {
private final CustomerService service;
@RequiredArgsConstructor는 롬복의 어노테이션이며,
해당 클래스에 생성한 불변객체의 생성자를 만들고, 의존성을 자동으로 주입해줍니다.
/**
* POST /api/v1/customers : Create a new customer.
*
* @param request the customer request with customer details
* @return the ResponseEntity with status 200 (OK) and the result of the creation
*/
@PostMapping
public ResponseEntity<String> createCustomer(
@RequestBody @Valid CustomerRequest request
) {
return ResponseEntity.ok(service.createCustomer(request));
}
@PostMapping은 RequestMapping의 method를 POST로 설정한 것으로 POST 요청을 처리합니다.
@RequestMapping(
method = {RequestMethod.POST}
)
ResponseEntity 객체의 body에 String 자료형을 담아 반환합니다.
@RequestBody @Valid CustomerRequest request
클라이언트가 post 요청 시 Body에 담겨있는 데이터를 CustomerRequest DTO로 변환합니다.
@Valid를 통해 유효성을 검증합니다.
public ResponseEntity<String> createCustomer(
return ResponseEntity.ok(service.createCustomer(request));
ResponseEntity는 HttpEntity를 확장한 것으로 Http Status를 추가한 것입니다.
HttpEntity는 Header와 Body로 구성되었으며, HTTP 요청 또는 응답 엔티티를 나타냅니다.
CustomerRequest 객체를 매개변수로 service의 createCustomer 메서드를 호출합니다.
예외가 발생하지 않으면, HTTP SATUS의 OK(200)와 String 자료를 반환합니다.
/**
* PUT /api/v1/customers : Update an existing customer.
*
* @param request the customer request with updated customer details
* @return the ResponseEntity with status 202 (Accepted)
*/
@PutMapping
public ResponseEntity<Void> updateCustomer(
@RequestBody @Valid CustomerRequest request
) {
service.updateCustomer(request);
return ResponseEntity.accepted().build();
}
리소스 수정에 PUT, PATCH를 활용합니다.
PUT은 리소스 전체를 대체하는 방식이고, PATCH는 부분적으로 적용합니다.
따라서 PUT요청 리소스에 빈 값이 있으면 null로 적용됩니다.
네트워크 문제로 인해 클라이언트가 동일한 요청을 여러 번 재전송할 수 있습니다.
PUT은 동일한 요청을 여러 번 보내도 동일한 리소스를 덮어쓰기 때문에 동일한 결과를 반환하지만,
POST는 동일한 요청이 오면 여러 번 처리하여 여러 명의 사용자가 생성될 수 있습니다.
return ResponseEntity.accepted().build();
ACCEPTED는 성공 시 반환하는 상태 코드 중 하나로 202를 의미합니다.
/**
* GET /api/v1/customers : Get all customers.
*
* @return the ResponseEntity with status 200 (OK) and the list of customers
*/
@GetMapping
public ResponseEntity<List<CustomerResponse>> findAll() {
return ResponseEntity.ok(service.findAllCustomers());
}
@GetMapping를 통해 리소스를 조회합니다.
데이터 반환 시 CustomerResponse DTO에 담아 반환합니다.
/**
* DELETE /api/v1/customers/{customer-id} : Delete a customer by id.
*
* @param customerId the id of the customer to delete
* @return the ResponseEntity with status 202 (Accepted)
*/
@DeleteMapping("/{customer-id}")
public ResponseEntity<Void> deleteById(
@PathVariable("customer-id") String customerId
) {
service.deleteCustomer(customerId);
return ResponseEntity.accepted().build();
}
리소스 삭제에 @DeleteMapping을 사용합니다.
@PathVariable("customer-id") String customerId
uri의 {customer-id} 를 String customerId에 담아 사용합니다.
1.1.4 CustomerRequest, CustomerResponse record
요청받은 값 또는 응답할 값을 변환할 DTO(Data Transfer Object) 를 생성합니다.
record는 JAVA14부터 도입된 것으로 불변 객체를 간편하게 정의할 수 있으며,
DTO에 작성할 필드, 생성자, getter, toString 등의 메서드를 자동으로 생성해줍니다.
package com.ride.ecommerce.customer;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
public record CustomerRequest(
String id,
@NotNull(message = "Customer firstname is required")
String firstname,
@NotNull(message = "Customer lastname is required")
String lastname,
@NotNull(message = "Customer email is required")
@Email(message = "Customer email is not a valid email address")
String email,
Address address
) {
}
@NotNull, @Email 어노테이션은 @Valid, @Validated와 사용되며 객체 생성 시 유효성을 검증합니다.
유효성 검증에 예외가 발생하면 message를 통해 정보를 제공합니다.
package com.ride.ecommerce.customer;
public record CustomerResponse(
String id,
String firstname,
String lastname,
String email,
Address address
) {
}
Server가 Response의 생명주기를 관리하기 때문에 @NotNull 등의 어노테이션을 사용하지 않아도 됩니다.
1.1.5 CustomerService, CustomerMapper class
Service는 Controller와 DA계층(Repository)의 중간 계층으로, 비즈니스로직을 포함합니다.
package com.ride.ecommerce.customer;
import com.ride.ecommerce.exception.CustomerNotFoundException;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
import static java.lang.String.format;
/**
* Service class for managing customer operations.
*/
@Service
@RequiredArgsConstructor
public class CustomerService {
private final CustomerRepository repository;
private final CustomerMapper mapper;
/**
* Create a new customer.
*
* @param request the customer request with customer details
* @return the ID of the created customer
*/
public String createCustomer(CustomerRequest request) {
var customer = repository.save(mapper.toCustomer(request));
return customer.getId();
}
/**
* Update an existing customer.
*
* @param request the customer request with updated customer details
*/
public void updateCustomer(CustomerRequest request) {
var customer = repository.findById(request.id())
.orElseThrow(() -> new CustomerNotFoundException(
format("Cannot update customer:: No customer found with the provided ID:: %s", request.id())
));
mergerCustomer(customer, request);
repository.save(customer);
}
/**
* Merge the provided customer details with the existing customer.
*
* @param customer the existing customer
* @param request the customer request with updated details
*/
private void mergerCustomer(Customer customer, CustomerRequest request) {
if (StringUtils.isNotBlank(request.firstname())) {
customer.setFirstname(request.firstname());
}
if (StringUtils.isNotBlank(request.lastname())) {
customer.setLastname(request.lastname());
}
if (StringUtils.isNotBlank(request.email())) {
customer.setEmail(request.email());
}
if (request.address() != null) {
customer.setAddress(request.address());
}
}
/**
* Find all customers.
*
* @return the list of all customer responses
*/
public List<CustomerResponse> findAllCustomers() {
return repository.findAll()
.stream()
.map(mapper::fromCustomer)
.collect(Collectors.toList());
}
/**
* Check if a customer exists by ID.
*
* @param customerId the ID of the customer to check
* @return true if the customer exists, false otherwise
*/
public Boolean existsById(String customerId) {
return repository.findById(customerId)
.isPresent();
}
/**
* Find a customer by ID.
*
* @param customerId the ID of the customer to find
* @return the customer response
* @throws CustomerNotFoundException if no customer is found with the provided ID
*/
public CustomerResponse findById(String customerId) {
return repository.findById(customerId)
.map(mapper::fromCustomer)
.orElseThrow(() -> new CustomerNotFoundException(format("No customer found with provided ID:: %s", customerId)));
}
/**
* Delete a customer by ID.
*
* @param customerId the ID of the customer to delete
*/
public void deleteCustomer(String customerId) {
repository.deleteById(customerId);
}
}
@Service 어노테이션을 사용하여 해당 클래스를 스프링 컨테이너로 관리하도록 하고,
Service임을 선언합니다.
private final CustomerRepository repository;
private final CustomerMapper mapper;
repository는 DB에 Access하여 CRUD 작업을 합니다.
mapper는 Entity를 DTO로 변환하거나 DTO를 Entity로 변환하는 로직을 담고 있습니다.
/**
* Create a new customer.
*
* @param request the customer request with customer details
* @return the ID of the created customer
*/
public String createCustomer(CustomerRequest request) {
var customer = repository.save(mapper.toCustomer(request));
return customer.getId();
}
클라이언트가 요청한 CustomerRequest 객체를 Customer Entity로 변환하여 DB에 저장합니다.
DB에 생성된 Customer Column의 id를 반환합니다.
/**
* Update an existing customer.
*
* @param request the customer request with updated customer details
*/
public void updateCustomer(CustomerRequest request) {
var customer = repository.findById(request.id())
.orElseThrow(() -> new CustomerNotFoundException(
format("Cannot update customer:: No customer found with the provided ID:: %s", request.id())
));
mergerCustomer(customer, request);
repository.save(customer);
}
/**
* Merge the provided customer details with the existing customer.
*
* @param customer the existing customer
* @param request the customer request with updated details
*/
private void mergerCustomer(Customer customer, CustomerRequest request) {
if (StringUtils.isNotBlank(request.firstname())) {
customer.setFirstname(request.firstname());
}
if (StringUtils.isNotBlank(request.lastname())) {
customer.setLastname(request.lastname());
}
if (StringUtils.isNotBlank(request.email())) {
customer.setEmail(request.email());
}
if (request.address() != null) {
customer.setAddress(request.address());
}
}
repository를 통해 DB에 접근하여 customer를 id로 조회합니다.
customer가 존재하지 않을 시 예외를 발생시킵니다.
(예외 클래스는 아래에서 다루겠습니다)
조회된 customer 객체와 request 객체를 mergerCustomer 메서드를 통해 병합하고,
customer를 update합니다.
/**
* Find all customers.
*
* @return the list of all customer responses
*/
public List<CustomerResponse> findAllCustomers() {
return repository.findAll()
.stream()
.map(mapper::fromCustomer)
.collect(Collectors.toList());
}
DB에 조회한 List<Customer>를 stream과 mapper를 통해 List<CustomerResponse>로 변환합니다.
package com.ride.ecommerce.customer;
import org.springframework.stereotype.Service;
/**
* Mapper class for converting between CustomerRequest/CustomerResponse DTOs and Customer entity.
*/
@Service
public class CustomerMapper {
/**
* Converts a CustomerRequest DTO to a Customer entity.
*
* @param request the customer request with customer details
* @return the Customer entity
*/
public Customer toCustomer(CustomerRequest request) {
if (request == null) {
return null;
}
return Customer.builder()
.id(request.id())
.firstname(request.firstname())
.lastname(request.lastname())
.email(request.email())
.address(request.address())
.build();
}
/**
* Converts a Customer entity to a CustomerResponse DTO.
*
* @param customer the Customer entity
* @return the CustomerResponse DTO
*/
public CustomerResponse fromCustomer(Customer customer) {
return new CustomerResponse(
customer.getId(),
customer.getFirstname(),
customer.getLastname(),
customer.getEmail(),
customer.getAddress()
);
}
}
1.1.6 CustomerRepository interface
MongoDB의 CURD를 담당합니다.
package com.ride.ecommerce.customer;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface CustomerRepository extends MongoRepository<Customer, String > {
}
제네릭에는 <자료형, PK> 형식으로 기재합니다.
PK가 Integer나 Long일 수 있으며, 중요한 것은 PK의 자료형을 기재하는 것입니다.
1.2 exception
1.2.1 CustomerNotFoundException class
Customer를 조회하지 못했을 때 발생시키는 사용자 정의 예외입니다.
package com.ride.ecommerce.exception;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Exception thrown when a customer is not found.
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class CustomerNotFoundException extends RuntimeException {
/**
* The message describing the exception.
*/
private final String msg;
}
@EqulasAndHashCode는 부모 클래스(RuntimeException)로부터 메소드를 상속받아 생성합니다.
두 객체가 같은지 비교하고 해시 코드를 생성하는 메서드를 자동으로 생성합니다.
callSuper의 default는 false지만,
true 설정 시, 부모 클래스(RuntimeException)에 정의된 필드 값들도 동일한지 체크합니다.
@Data는 getter, setter, toString, equlas, hashCode 등의 메서드를 자동으로 생성합니다.
1.3 handler
1.3.1 GlobalExeptionHandler class
애플리케이션 전반에 걸쳐 발생하는 예외를 처리합니다.
package com.ride.ecommerce.handler;
import com.ride.ecommerce.exception.CustomerNotFoundException;
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.*;
/**
* Global exception handler for handling various exceptions across the application.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Handles CustomerNotFoundException and returns a 404 NOT FOUND response.
*
* @param exp the CustomerNotFoundException instance
* @return a ResponseEntity with a 404 status and the exception message
*/
@ExceptionHandler(CustomerNotFoundException.class)
public ResponseEntity<String> handle(CustomerNotFoundException exp) {
return ResponseEntity
.status(NOT_FOUND)
.body(exp.getMsg());
}
/**
* Handles MethodArgumentNotValidException and returns a 400 BAD REQUEST response with validation errors.
*
* @param exp the MethodArgumentNotValidException instance
* @return a ResponseEntity with a 400 status and the validation errors
*/
@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));
}
}
@ExceptionHandler를 통해 예외를 특정합니다.
HTTP 상태 코드와 Body를 커스텀하고 ResponseEntity로 반환합니다.
1.3.2 ErrorResponse record
더 자세한 정보를 담고 있는 객체입니다.
package com.ride.ecommerce.handler;
import java.util.Map;
public record ErrorResponse(
Map<String, String> errors
) {
}
'Java' 카테고리의 다른 글
[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; 구현1 - 설정(docker, config-server, eureka-server, gateway) (0) | 2024.06.18 |
[MSA] e-commerce API; 프로젝트 흐름 (0) | 2024.06.18 |
[HTTP Client] 예제; HttpURLConnection, Apache HttpClient, RestTemplate, OpenFeign, WebClient, RestClient (0) | 2024.06.14 |