온라인 쇼핑몰 시스템
실제 운영 가능한 수준의 온라인 쇼핑몰을 ElastiCORE로 구현하는 예제입니다.
시스템 개요
구현할 기능:
- 👤 사용자 관리 (회원가입, 로그인, 프로필)
- 🛍️ 상품 관리 (카테고리, 상품, 재고)
- 🛒 장바구니 및 주문 처리
- 💳 결제 시스템 연동
- 📦 배송 관리
- 📊 관리자 대시보드
Domain Model
사용자 관리
# 사용자 엔티티
entity:
User:
meta: entity @expose @audit @secure
fields:
id: long! @id @sequence
email: string(255)! @unique @email @search(eq)
username: string(50)! @unique @search(like)
password: string(255)! @encrypt
firstName: string(50)! @search(like)
lastName: string(50)! @search(like)
phone: string(20) @pattern("^[0-9-+()\\s]+$")
birthDate: date
gender: Gender
memberGrade: MemberGrade = BRONZE
active: boolean = true @index
emailVerified: boolean = false
phoneVerified: boolean = false
# 감사 필드
createdAt: datetime @createdDate @index
updatedAt: datetime @lastModifiedDate
lastLoginAt: datetime
# 관계
addresses: Address[] @oneToMany(mappedBy="userId")
orders: Order[] @oneToMany(mappedBy="userId")
cart: Cart @oneToOne(mappedBy="userId")
# 사용자 주소
Address:
meta: entity
fields:
id: long! @id @sequence
userId: long! @ref(User.id) @index
name: string(100)! # 주소별칭 (집, 회사 등)
recipientName: string(100)!
phone: string(20)!
zipCode: string(10)!
address: string(200)!
addressDetail: string(200)
isDefault: boolean = false
# 관계
user: User @manyToOne(join="userId")
# 사용자 관련 DTO
dto:
UserRegistrationRequest:
meta: dto @validation
fields:
email: string(255)! @email @notBlank
username: string(50)! @notBlank @size(min=3,max=50)
password: string(255)! @notBlank @size(min=8,max=255)
confirmPassword: string(255)! @notBlank
firstName: string(50)! @notBlank
lastName: string(50)! @notBlank
phone: string(20) @pattern("^[0-9-+()\\s]+$")
birthDate: date @past
gender: Gender
agreeTerms: boolean! @assertTrue
UserResponse:
meta: dto @template(User)
fields:
id: long
email: string
username: string
firstName: string
lastName: string
phone: string
memberGrade: MemberGrade
active: boolean
emailVerified: boolean
createdAt: datetime
AddressRequest:
meta: dto @validation
fields:
name: string(100)! @notBlank
recipientName: string(100)! @notBlank
phone: string(20)! @pattern("^[0-9-+()\\s]+$")
zipCode: string(10)! @notBlank
address: string(200)! @notBlank
addressDetail: string(200)
isDefault: boolean = false
상품 관리
# 상품 카테고리
entity:
Category:
meta: entity @expose @cache(ttl=3600)
fields:
id: long! @id @sequence
name: string(100)! @unique @search(like)
code: string(20)! @unique @businessKey
description: string(500)
parentId: long @ref(Category.id)
level: int = 1
path: string(500) # /electronics/computers/laptops
displayOrder: int = 0
active: boolean = true
imageUrl: string(500)
# 관계
parent: Category @manyToOne(join="parentId")
children: Category[] @oneToMany(mappedBy="parentId")
products: Product[] @oneToMany(mappedBy="categoryId")
# 상품
entity:
Product:
meta: entity @expose @audit @searchable
fields:
id: long! @id @sequence
code: string(50)! @unique @businessKey @search(eq)
name: string(200)! @search(like) @notBlank
description: text @search(fulltext)
shortDescription: string(500)
price: decimal(12,2)! @positive
salePrice: decimal(12,2) @positive
costPrice: decimal(12,2) @positive
categoryId: long! @ref(Category.id) @index
brandId: long @ref(Brand.id) @index
status: ProductStatus = DRAFT
featured: boolean = false @index
tags: string[] @search(in)
weight: decimal(8,3) # kg
dimensions: string(100) # 가로x세로x높이
# SEO
metaTitle: string(200)
metaDescription: string(500)
slug: string(200) @unique
# 재고
stockQuantity: int = 0
minStockLevel: int = 0
trackInventory: boolean = true
# 평점
averageRating: decimal(3,2) = 0.00
reviewCount: int = 0
# 관계
category: Category @manyToOne(join="categoryId")
brand: Brand @manyToOne(join="brandId")
images: ProductImage[] @oneToMany(mappedBy="productId")
attributes: ProductAttribute[] @oneToMany(mappedBy="productId")
reviews: ProductReview[] @oneToMany(mappedBy="productId")
# 상품 이미지
entity:
ProductImage:
meta: entity
fields:
id: long! @id @sequence
productId: long! @ref(Product.id) @index
imageUrl: string(500)! @notBlank
altText: string(200)
displayOrder: int = 0
isPrimary: boolean = false
# 상품 속성 (색상, 크기 등)
entity:
ProductAttribute:
meta: entity
fields:
id: long! @id @sequence
productId: long! @ref(Product.id) @index
attributeName: string(100)! # 색상, 크기, 소재 등
attributeValue: string(200)! # 빨강, XL, 면 등
additionalPrice: decimal(8,2) = 0.00
# 상품 리뷰
entity:
ProductReview:
meta: entity @audit
fields:
id: long! @id @sequence
productId: long! @ref(Product.id) @index
userId: long! @ref(User.id) @index
orderId: long @ref(Order.id) # 구매 확인용
rating: int! @min(1) @max(5)
title: string(200)
content: text! @notBlank @size(min=10,max=2000)
helpful: int = 0 # 도움이 됨 카운트
verified: boolean = false # 구매 인증 여부
createdAt: datetime @createdDate @index
updatedAt: datetime @lastModifiedDate
# 브랜드
entity:
Brand:
meta: entity @expose @cache
fields:
id: long! @id @sequence
name: string(100)! @unique @search(like)
code: string(20)! @unique
description: text
logoUrl: string(500)
websiteUrl: string(500)
active: boolean = true
# 상품 관련 열거형
enumeration:
ProductStatus:
meta: enum @db(code) @json(name)
fields:
code: string(10)
name: string(50)
enum:
DRAFT: DRAFT,임시저장
PENDING: PENDING,검토중
ACTIVE: ACTIVE,판매중
INACTIVE: INACTIVE,판매중단
SOLD_OUT: SOLD_OUT,품절
DISCONTINUED: DISCONTINUED,단종
Gender:
meta: enum
enum:
MALE: 남성
FEMALE: 여성
UNISEX: 공용
MemberGrade:
meta: enum @db(code) @json(name)
fields:
code: string(10)
name: string(30)
discountRate: decimal(5,2)
pointRate: decimal(5,2)
enum:
BRONZE: BRONZE,브론즈,0.00,1.00
SILVER: SILVER,실버,2.00,1.50
GOLD: GOLD,골드,5.00,2.00
PLATINUM: PLATINUM,플래티넘,7.00,3.00
주문 및 결제
# 장바구니
entity:
Cart:
meta: entity
fields:
id: long! @id @sequence
userId: long! @ref(User.id) @unique
createdAt: datetime @createdDate
updatedAt: datetime @lastModifiedDate
# 관계
user: User @oneToOne(join="userId")
items: CartItem[] @oneToMany(mappedBy="cartId")
entity:
CartItem:
meta: entity
fields:
id: long! @id @sequence
cartId: long! @ref(Cart.id) @index
productId: long! @ref(Product.id) @index
quantity: int! @min(1) @max(999)
addedAt: datetime @createdDate
# 관계
cart: Cart @manyToOne(join="cartId")
product: Product @manyToOne(join="productId")
# 주문
entity:
Order:
meta: entity @expose @audit
fields:
id: long! @id @sequence
orderNumber: string(20)! @unique @businessKey @search(eq)
userId: long! @ref(User.id) @index
status: OrderStatus = PENDING @index
# 주문 금액
subtotal: decimal(12,2)! # 상품 총액
discountAmount: decimal(12,2) = 0.00 # 할인 금액
shippingFee: decimal(8,2) = 0.00 # 배송비
taxAmount: decimal(12,2) = 0.00 # 세금
totalAmount: decimal(12,2)! # 최종 결제 금액
# 배송 정보
shippingMethod: ShippingMethod
recipientName: string(100)!
recipientPhone: string(20)!
shippingAddress: string(500)!
shippingMemo: string(200)
# 결제 정보
paymentMethod: PaymentMethod
paymentStatus: PaymentStatus = PENDING
# 일시
orderDate: datetime @createdDate @index
paymentDate: datetime
shippingDate: datetime
deliveryDate: datetime
# 관계
user: User @manyToOne(join="userId")
items: OrderItem[] @oneToMany(mappedBy="orderId")
entity:
OrderItem:
meta: entity
fields:
id: long! @id @sequence
orderId: long! @ref(Order.id) @index
productId: long! @ref(Product.id) @index
productCode: string(50)! # 주문 시점의 상품 코드
productName: string(200)! # 주문 시점의 상품명
quantity: int! @min(1)
unitPrice: decimal(12,2)! # 주문 시점의 단가
totalPrice: decimal(12,2)! # 수량 * 단가
# 관계
order: Order @manyToOne(join="orderId")
product: Product @manyToOne(join="productId")
# 주문 관련 열거형
enumeration:
OrderStatus:
meta: enum @db(code) @json(name) @workflow
fields:
code: string(3)
name: string(50)
description: string(200)
allowCancel: boolean
enum:
PEN: PEN,주문접수,새로운 주문이 접수됨,true
CFM: CFM,주문확인,주문 내용이 확인됨,true
PAY: PAY,결제완료,결제가 완료됨,false
PCK: PCK,상품준비,상품 포장 준비중,false
SHP: SHP,배송시작,배송이 시작됨,false
DLV: DLV,배송완료,배송이 완료됨,false
CAN: CAN,주문취소,주문이 취소됨,false
RTN: RTN,반품완료,반품이 완료됨,false
PaymentMethod:
meta: enum @db(code) @json(name)
fields:
code: string(10)
name: string(50)
feeRate: decimal(5,4)
enum:
CARD: CARD,신용카드,0.0250
BANK: BANK,계좌이체,0.0050
MOBILE: MOBILE,모바일페이,0.0300
VIRTUAL: VIRTUAL,가상계좌,0.0080
PaymentStatus:
meta: enum
enum:
PENDING: 결제대기
COMPLETED: 결제완료
FAILED: 결제실패
CANCELLED: 결제취소
REFUNDED: 환불완료
ShippingMethod:
meta: enum @db(code) @json(name)
fields:
code: string(10)
name: string(50)
baseFee: decimal(8,2)
freeThreshold: decimal(10,2)
enum:
STD: STD,일반배송,3000,50000
FAST: FAST,빠른배송,5000,100000
FREE: FREE,무료배송,0,0
서비스 Port 정의
사용자 서비스
transaction:
port:
UserService:
meta: dbms @datasource("main") @expose @secure
methods:
# 회원가입
registerUser:
params:
request: UserRegistrationRequest
return: UserResponse
logic: |
// 이메일 중복 체크
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException();
}
// 사용자명 중복 체크
if (userRepository.existsByUsername(request.getUsername())) {
throw new DuplicateUsernameException();
}
// 비밀번호 확인
if (!request.getPassword().equals(request.getConfirmPassword())) {
throw new PasswordMismatchException();
}
User user = mapper.toEntity(request);
user.setPassword(passwordEncoder.encode(user.getPassword()));
user = userRepository.save(user);
// 이메일 인증 메일 발송
emailService.sendVerificationEmail(user.getEmail());
return mapper.toResponse(user);
# 로그인
authenticate:
params:
email: string!
password: string!
return: AuthResponse
logic: |
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new InvalidCredentialsException());
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new InvalidCredentialsException();
}
if (!user.isActive()) {
throw new InactiveUserException();
}
// 로그인 시간 업데이트
user.setLastLoginAt(LocalDateTime.now());
userRepository.save(user);
// JWT 토큰 생성
String token = jwtTokenProvider.createToken(user);
return AuthResponse.builder()
.token(token)
.user(mapper.toResponse(user))
.build();
# 주소 관리
addAddress:
params:
userId: long
request: AddressRequest
return: AddressResponse
logic: |
// 기본 주소 설정 시 기존 기본 주소 해제
if (request.isDefault()) {
addressRepository.clearDefaultAddress(userId);
}
Address address = mapper.toEntity(request);
address.setUserId(userId);
address = addressRepository.save(address);
return mapper.toResponse(address);
상품 서비스
transaction:
port:
ProductService:
meta: dbms @datasource("main") @expose @cache
methods:
# 상품 검색
searchProducts:
params:
request: ProductSearchRequest
pageable: Pageable
return: Page<ProductResponse>
cache: 300
logic: |
Specification<Product> spec = ProductSpecification.build(request);
Page<Product> products = productRepository.findAll(spec, pageable);
return products.map(mapper::toResponse);
# 상품 상세 조회
getProduct:
params:
id: long
return: ProductDetailResponse
cache: 600
logic: |
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// 조회수 증가 (비동기)
productViewService.incrementViewCount(id);
ProductDetailResponse response = mapper.toDetailResponse(product);
// 연관 상품 조회
List<Product> relatedProducts = productRepository
.findRelatedProducts(product.getCategoryId(), id, 6);
response.setRelatedProducts(relatedProducts.stream()
.map(mapper::toResponse)
.collect(Collectors.toList()));
return response;
# 재고 확인
checkStock:
params:
productId: long
quantity: int
return: boolean
logic: |
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
if (!product.isTrackInventory()) {
return true;
}
return product.getStockQuantity() >= quantity;
# 재고 차감
reserveStock:
params:
productId: long
quantity: int
return: void
transaction: REQUIRED
logic: |
Product product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
if (product.getStockQuantity() < quantity) {
throw new InsufficientStockException(productId, quantity);
}
product.setStockQuantity(product.getStockQuantity() - quantity);
productRepository.save(product);
// 재고 히스토리 기록
stockHistoryService.recordStockChange(productId, -quantity, "ORDER_RESERVED");