
E-commerce 애플리케이션을 회원, 상품, 주문이라는 비즈니스 기능으로 분리하고, 각각의 서비스를 H2와 JPA로 구현한 뒤 Eureka와 API Gateway에 연결했다.
실습하면서 JsonInclude.Include.NON_NULL, CrudRepository와 JpaRepository의 차이, BCrypt의 salt 처리 방식, 해시와 암호화의 차이도 새롭게 정리했다.
오늘 만든 애플리케이션은 다음 세 개의 마이크로서비스로 구성된다.
Client
│
▼
API Gateway : 8000
├─ USER-SERVICE
│ └─ User DB
│
├─ CATALOG-SERVICE
│ └─ Catalog DB
│
└─ ORDER-SERVICE
└─ Order DB
각 서비스는 하나의 DB를 공유하지 않고 자신이 담당하는 데이터를 별도의 H2 데이터베이스에 저장한다.
모든 서비스는 Eureka에 자신의 이름과 위치를 등록한다. API Gateway는 Eureka에서 서비스의 위치를 찾은 뒤 요청을 전달한다.
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
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 Service에서는 상품 목록 조회 기능을 구현했다.
GET /catalog-service/catalogs
CatalogEntity에는 다음과 같은 상품 정보가 저장된다.
Controller는 Repository의 Entity를 직접 반환하지 않고 ResponseCatalog로 변환해 응답한다.
CatalogController
→ CatalogService
→ CatalogRepository
→ CatalogEntity
→ ResponseCatalog
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);
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이 아니므로 그대로 포함될 수 있다.
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 기능만 사용하기 위해서라고 이해할 수 있다.
BCrypt는 같은 비밀번호를 여러 번 encode()해도 매번 다른 결과를 만든다.
password
→ $2a$10$서로 다른 결과...
password
→ $2a$10$또 다른 결과...
비밀번호가 같은데 결과가 달라지는 이유는 BCrypt가 매번 랜덤한 salt를 생성해 사용하기 때문이다.
BCrypt가 만든 인코딩 문자열에는 알고리즘 버전, cost, salt와 해시 결과를 검증하는 데 필요한 정보가 함께 들어 있다. salt는 비밀번호와 달리 비밀값일 필요가 없다.
로그인할 때는 저장된 값을 복호화하지 않는다.
passwordEncoder.matches(
입력받은_평문_비밀번호,
DB에_저장된_BCrypt_문자열
);
matches()는 저장된 BCrypt 문자열의 설정과 salt를 이용해 입력된 비밀번호를 다시 계산하고, 결과가 일치하는지 확인한다.
해시와 암호화는 데이터를 알아볼 수 없는 형태로 바꾼다는 점은 비슷하지만 목적과 복구 가능 여부가 다르다.
| 구분 | 해시 | 암호화 |
|---|---|---|
| 방향 | 단방향 | 양방향 |
| 원본 복구 | 불가능 | 키가 있으면 가능 |
| 키 사용 | 일반적인 해시는 사용하지 않음 | 암호화 키 사용 |
| 주요 목적 | 무결성 확인, 비밀번호 검증 | 데이터 기밀성 보호 |
| 예시 | BCrypt, SHA-256 | AES, RSA |
비밀번호는 원래 값을 다시 알아낼 필요가 없기 때문에 복호화 가능한 암호화보다 단방향 해시를 사용한다.
암호화는 다시 다음 두 종류로 구분할 수 있다.
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를 호출해 실제 주문 목록을 가져오는 단계까지 구현된 것은 아니다.
오늘은 각 마이크로서비스를 독립적으로 구성하는 단계였고, 서비스 간 통신은 이후 실습에서 연결할 예정이다.
matches()를 이용한 실제 로그인 인증 구현하기