[LG CNS AM 6기] 38일차 TIL : E-commerce MSA 구성하기 - User·Catalog·Order Microservice와 H2·JPA·BCrypt·API Gateway 연결

윤현일·4일 전

LGCNS

목록 보기
43/43

1. 오늘의 한 줄 요약

E-commerce 애플리케이션을 회원, 상품, 주문이라는 비즈니스 기능으로 분리하고, 각각의 서비스를 H2와 JPA로 구현한 뒤 Eureka와 API Gateway에 연결했다.

실습하면서 JsonInclude.Include.NON_NULL, CrudRepository와 JpaRepository의 차이, BCrypt의 salt 처리 방식, 해시와 암호화의 차이도 새롭게 정리했다.

2. 배운 내용

E-commerce 애플리케이션 구조

오늘 만든 애플리케이션은 다음 세 개의 마이크로서비스로 구성된다.

  • USER-SERVICE: 회원 등록과 회원 정보 조회
  • CATALOG-SERVICE: 상품 목록 조회
  • ORDER-SERVICE: 사용자별 상품 주문과 주문 내역 조회
Client
  │
  ▼
API Gateway : 8000
  ├─ USER-SERVICE
  │    └─ User DB
  │
  ├─ CATALOG-SERVICE
  │    └─ Catalog DB
  │
  └─ ORDER-SERVICE
       └─ Order DB

각 서비스는 하나의 DB를 공유하지 않고 자신이 담당하는 데이터를 별도의 H2 데이터베이스에 저장한다.

모든 서비스는 Eureka에 자신의 이름과 위치를 등록한다. API Gateway는 Eureka에서 서비스의 위치를 찾은 뒤 요청을 전달한다.

API Gateway Route 추가

Gateway에 오늘 만든 세 서비스를 등록했다.

routes:
  - id: user-service
    uri: lb://USER-SERVICE
    predicates:
      - Path=/user-service/**

  - id: catalog-service
    uri: lb://CATALOG-SERVICE
    predicates:
      - Path=/catalog-service/**

  - id: order-service
    uri: lb://ORDER-SERVICE
    predicates:
      - Path=/order-service/**

클라이언트는 각 서비스의 랜덤 포트를 알 필요 없이 Gateway의 8000번 포트로 요청하면 된다.

/user-service/**    → USER-SERVICE
/catalog-service/** → CATALOG-SERVICE
/order-service/**   → ORDER-SERVICE

3. 실습 / 적용

User Microservice

User Service에서는 회원 등록과 조회 API를 구현했다.

기능HTTP요청 경로
회원 등록POST/user-service/users
회원 전체 조회GET/user-service/users
회원 상세 조회GET/user-service/users/{userId}
상태 확인GET/user-service/health-check

회원가입 요청은 다음 흐름으로 처리된다.

RequestUser
→ UserController
→ UserDto
→ UserService
→ UserEntity
→ UserRepository
→ H2 Database

클라이언트의 요청 객체를 곧바로 Entity로 사용하지 않고, RequestUser, UserDto, UserEntity, ResponseUser로 역할을 나눴다.

회원의 외부 식별자인 userId는 UUID로 생성하고, 비밀번호는 평문이 아닌 BCrypt 결과로 저장했다.

userDto.setUserId(UUID.randomUUID().toString());

userEntity.setEncryptedPwd(
    passwordEncoder.encode(userDto.getPwd())
);

userRepository.save(userEntity);

Catalog Microservice

Catalog Service에서는 상품 목록 조회 기능을 구현했다.

GET /catalog-service/catalogs

CatalogEntity에는 다음과 같은 상품 정보가 저장된다.

  • 상품 ID
  • 상품 이름
  • 재고
  • 단가
  • 등록 시간

Controller는 Repository의 Entity를 직접 반환하지 않고 ResponseCatalog로 변환해 응답한다.

CatalogController
→ CatalogService
→ CatalogRepository
→ CatalogEntity
→ ResponseCatalog

Order Microservice

Order Service에서는 주문 등록과 사용자별 주문 조회 기능을 구현했다.

기능HTTP요청 경로
주문 등록POST/order-service/{userId}/orders
주문 내역 조회GET/order-service/{userId}/orders

주문을 등록할 때 주문 ID를 UUID로 만들고, 수량과 단가를 이용해 총금액을 계산한다.

orderDto.setOrderId(UUID.randomUUID().toString());

orderDto.setTotalPrice(
    orderDto.getQty() * orderDto.getUnitPrice()
);

주문 데이터에는 userId가 포함되기 때문에 특정 사용자의 주문 목록을 조회할 수 있다.

Iterable<OrderEntity> findByUserId(String userId);

4. 새롭게 알게 된 점

JsonInclude.Include.NON_NULL

응답 객체에는 다음 애노테이션이 사용되었다.

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseUser {
    private String email;
    private String name;
    private String userId;
    private List<ResponseOrder> orders;
}

NON_NULL을 적용하면 값이 null인 필드는 JSON 응답에 포함되지 않는다.

예를 들어 orders가 아직 설정되지 않았다면 다음과 같이 응답한다.

{
  "email": "test@example.com",
  "name": "홍길동",
  "userId": "USER-ID"
}

"orders": null을 내려주지 않기 때문에 응답에서 불필요한 필드를 줄일 수 있다. 다만 빈 배열인 []은 null이 아니므로 그대로 포함될 수 있다.

공식 문서: Jackson — JsonInclude.Include.NON_NULL

왜 JpaRepository가 아니라 CrudRepository를 사용했을까?

오늘 작성한 Repository는 모두 CrudRepository를 상속한다.

public interface UserRepository
        extends CrudRepository<UserEntity, Long> {

    UserEntity findByUserId(String userId);
}

CrudRepository는 다음과 같은 기본 데이터 처리 기능을 제공한다.

  • save()
  • findById()
  • findAll()
  • count()
  • delete()
  • deleteById()

오늘 구현한 기능은 저장과 단순 조회가 중심이므로 CrudRepository만으로 충분하다. 또한 현재 코드도 findAll()의 결과를 Iterable로 받아 처리하고 있다.

JpaRepository를 사용해도 동작하지만 다음과 같은 더 넓은 JPA 전용 기능까지 노출된다.

  • 페이징과 정렬
  • flush()
  • saveAndFlush()
  • 일괄 삭제
  • 목록 형태의 조회 결과

따라서 CrudRepository를 선택한 이유는 JPA를 사용할 수 없어서가 아니라, 현재 서비스에 필요한 최소한의 Repository 기능만 사용하기 위해서라고 이해할 수 있다.

공식 문서: Spring Data JPA — JpaRepository

BCrypt 결과에는 salt 정보가 함께 저장된다

BCrypt는 같은 비밀번호를 여러 번 encode()해도 매번 다른 결과를 만든다.

password
→ $2a$10$서로 다른 결과...

password
→ $2a$10$또 다른 결과...

비밀번호가 같은데 결과가 달라지는 이유는 BCrypt가 매번 랜덤한 salt를 생성해 사용하기 때문이다.

BCrypt가 만든 인코딩 문자열에는 알고리즘 버전, cost, salt와 해시 결과를 검증하는 데 필요한 정보가 함께 들어 있다. salt는 비밀번호와 달리 비밀값일 필요가 없다.

로그인할 때는 저장된 값을 복호화하지 않는다.

passwordEncoder.matches(
    입력받은_평문_비밀번호,
    DB에_저장된_BCrypt_문자열
);

matches()는 저장된 BCrypt 문자열의 설정과 salt를 이용해 입력된 비밀번호를 다시 계산하고, 결과가 일치하는지 확인한다.

공식 문서: Spring Security — Password Storage

해시와 암호화의 차이

해시와 암호화는 데이터를 알아볼 수 없는 형태로 바꾼다는 점은 비슷하지만 목적과 복구 가능 여부가 다르다.

구분해시암호화
방향단방향양방향
원본 복구불가능키가 있으면 가능
키 사용일반적인 해시는 사용하지 않음암호화 키 사용
주요 목적무결성 확인, 비밀번호 검증데이터 기밀성 보호
예시BCrypt, SHA-256AES, RSA

비밀번호는 원래 값을 다시 알아낼 필요가 없기 때문에 복호화 가능한 암호화보다 단방향 해시를 사용한다.

암호화는 다시 다음 두 종류로 구분할 수 있다.

  • 대칭키 암호화: 암호화와 복호화에 같은 키를 사용한다. 대표적으로 AES가 있다.
  • 비대칭키 암호화: 공개키와 개인키처럼 서로 다른 키를 사용한다. 대표적으로 RSA가 있다.

참고: OWASP — 해시와 암호화의 차이

5. 문제와 해결

Gateway 경로와 Controller 경로 맞추기

Gateway는 /user-service/** 요청을 User Service에 전달한다.

그런데 Controller가 /users만 처리하도록 구성되어 있다면 Gateway가 전달한 /user-service/users와 경로가 맞지 않아 404 Not Found가 발생할 수 있다.

이를 맞추기 위해 UserController의 루트 경로를 다음과 같이 구성했다.

@RestController
@RequestMapping("/user-service")
public class UserController {
}

Gateway의 Predicate와 실제 Controller의 @RequestMapping이 서로 일치해야 한다는 점을 확인했다.

아직 서비스 간 직접 통신은 연결되지 않았다

현재 회원 상세 조회에서는 주문 목록을 빈 배열로 생성해 DTO에 넣는다.

List<ResponseOrder> orderList = new ArrayList<>();
userDto.setOrders(orderList);

따라서 User, Catalog, Order Service가 각각 실행되고 Gateway에 등록되었지만, User Service가 Order Service를 호출해 실제 주문 목록을 가져오는 단계까지 구현된 것은 아니다.

오늘은 각 마이크로서비스를 독립적으로 구성하는 단계였고, 서비스 간 통신은 이후 실습에서 연결할 예정이다.

6. 다음에 할 일

  • User Service에서 Order Service를 호출해 실제 주문 내역 가져오기
  • 주문이 생성되면 Catalog Service의 재고를 감소시키는 흐름 연결하기
  • BCrypt의 matches()를 이용한 실제 로그인 인증 구현하기
  • 각 마이크로서비스가 독립된 DB를 사용할 때 데이터 일관성을 유지하는 방법 알아보기
profile
개발자

0개의 댓글