온라인 쇼핑몰 시스템
실제 운영 가능한 수준의 온라인 쇼핑몰을 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");
주문 서비스
transaction:
port:
OrderService:
meta: dbms @datasource("main") @expose @secure
methods:
# 주문 생성
createOrder:
params:
userId: long
request: OrderCreateRequest
return: OrderResponse
transaction: REQUIRED
logic: |
// 장바구니 조회
Cart cart = cartRepository.findByUserId(userId)
.orElseThrow(() -> new CartNotFoundException());
if (cart.getItems().isEmpty()) {
throw new EmptyCartException();
}
// 재고 확인
for (CartItem item : cart.getItems()) {
if (!productService.checkStock(item.getProductId(), item.getQuantity())) {
throw new InsufficientStockException(item.getProductId(), item.getQuantity());
}
}
// 주문 생성
Order order = Order.builder()
.orderNumber(orderNumberGenerator.generate())
.userId(userId)
.status(OrderStatus.PENDING)
.build();
// 주문 항목 생성
List<OrderItem> orderItems = new ArrayList<>();
BigDecimal subtotal = BigDecimal.ZERO;
for (CartItem item : cart.getItems()) {
Product product = productRepository.findById(item.getProductId()).get();
OrderItem orderItem = OrderItem.builder()
.productId(product.getId())
.productCode(product.getCode())
.productName(product.getName())
.quantity(item.getQuantity())
.unitPrice(product.getPrice())
.totalPrice(product.getPrice().multiply(new BigDecimal(item.getQuantity())))
.build();
orderItems.add(orderItem);
subtotal = subtotal.add(orderItem.getTotalPrice());
}
// 할인 계산
User user = userRepository.findById(userId).get();
BigDecimal discountAmount = discountService.calculateDiscount(user, subtotal);
// 배송비 계산
BigDecimal shippingFee = shippingService.calculateShippingFee(
request.getShippingMethod(), subtotal.subtract(discountAmount));
// 최종 금액 계산
BigDecimal totalAmount = subtotal.subtract(discountAmount).add(shippingFee);
order.setSubtotal(subtotal);
order.setDiscountAmount(discountAmount);
order.setShippingFee(shippingFee);
order.setTotalAmount(totalAmount);
// 배송 정보 설정
order.setShippingMethod(request.getShippingMethod());
order.setRecipientName(request.getRecipientName());
order.setRecipientPhone(request.getRecipientPhone());
order.setShippingAddress(request.getShippingAddress());
order = orderRepository.save(order);
// 주문 항목 저장
for (OrderItem item : orderItems) {
item.setOrderId(order.getId());
orderItemRepository.save(item);
}
// 재고 차감
for (CartItem item : cart.getItems()) {
productService.reserveStock(item.getProductId(), item.getQuantity());
}
// 장바구니 비우기
cartItemRepository.deleteByCartId(cart.getId());
// 주문 생성 이벤트 발행
eventPublisher.publish(new OrderCreatedEvent(order.getId()));
return mapper.toResponse(order);
# 결제 처리
processPayment:
params:
orderId: long
paymentRequest: PaymentRequest
return: PaymentResponse
transaction: REQUIRED
logic: |
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (order.getStatus() != OrderStatus.PENDING) {
throw new InvalidOrderStatusException(order.getStatus());
}
// 외부 결제 서비스 호출
PaymentResponse paymentResponse = paymentClient.processPayment(
PaymentServiceRequest.builder()
.orderNumber(order.getOrderNumber())
.amount(order.getTotalAmount())
.paymentMethod(paymentRequest.getPaymentMethod())
.build()
);
if (paymentResponse.isSuccess()) {
order.setStatus(OrderStatus.PAID);
order.setPaymentStatus(PaymentStatus.COMPLETED);
order.setPaymentDate(LocalDateTime.now());
orderRepository.save(order);
// 결제 완료 이벤트 발행
eventPublisher.publish(new PaymentCompletedEvent(order.getId()));
} else {
order.setPaymentStatus(PaymentStatus.FAILED);
orderRepository.save(order);
}
return paymentResponse;
외부 연동 Port
결제 시스템 연동
http:
port:
PaymentClient:
meta: client @baseUrl("${payment.api.url}") @timeout(30000)
methods:
processPayment:
path: POST /payments
params:
request: PaymentServiceRequest @requestBody
return: PaymentResponse
headers:
Authorization: "Bearer ${payment.api.key}"
Content-Type: application/json
retry: 3
circuitBreaker: true
getPaymentStatus:
path: GET /payments/{transactionId}
params:
transactionId: string @pathVariable
return: PaymentStatusResponse
refundPayment:
path: POST /payments/{transactionId}/refund
params:
transactionId: string @pathVariable
request: RefundRequest @requestBody
return: RefundResponse
배송 서비스 연동
http:
port:
ShippingClient:
meta: client @baseUrl("${shipping.api.url}")
methods:
createShipment:
path: POST /shipments
params:
request: ShipmentRequest @requestBody
return: ShipmentResponse
trackShipment:
path: GET /shipments/{trackingNumber}/track
params:
trackingNumber: string @pathVariable
return: TrackingResponse
이메일 알림
message:
port:
EmailNotificationHandler:
meta: consumer @queue("email.notifications")
methods:
sendOrderConfirmation:
params:
event: OrderCreatedEvent
return: void
async: true
logic: |
Order order = orderRepository.findById(event.getOrderId()).get();
User user = userRepository.findById(order.getUserId()).get();
EmailTemplate template = emailTemplateService.getTemplate("ORDER_CONFIRMATION");
Map<String, Object> variables = Map.of(
"user", user,
"order", order,
"orderItems", order.getItems()
);
emailService.send(EmailRequest.builder()
.to(user.getEmail())
.subject(template.getSubject())
.body(template.render(variables))
.build());
sendShippingNotification:
params:
event: ShippingStartedEvent
return: void
logic: |
// 배송 시작 알림 발송 로직
관리자 API (GraphQL)
graphql:
port:
AdminResolver:
meta: resolver
methods:
# 상품 관리
products:
type: query
params:
filter: ProductFilter
pagination: PaginationInput
return: ProductConnection
security: "@hasRole('ADMIN')"
createProduct:
type: mutation
params:
input: ProductInput!
return: ProductPayload
security: "@hasRole('ADMIN')"
updateProduct:
type: mutation
params:
id: ID!
input: ProductUpdateInput!
return: ProductPayload
security: "@hasRole('ADMIN')"
# 주문 관리
orders:
type: query
params:
filter: OrderFilter
pagination: PaginationInput
return: OrderConnection
security: "@hasRole('ADMIN')"
updateOrderStatus:
type: mutation
params:
id: ID!
status: OrderStatus!
return: OrderPayload
security: "@hasRole('ADMIN')"
# 대시보드 통계
dashboardStats:
type: query
return: DashboardStats
security: "@hasRole('ADMIN')"
logic: |
return DashboardStats.builder()
.totalUsers(userRepository.count())
.totalOrders(orderRepository.count())
.totalRevenue(orderRepository.sumTotalAmount())
.todayOrders(orderRepository.countTodayOrders())
.build();
설정 파일
application.yml
spring:
application:
name: online-shopping-mall
datasource:
url: jdbc:postgresql://localhost:5432/shopping_mall
username: ${DB_USERNAME:shop_user}
password: ${DB_PASSWORD:shop_pass}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
security:
jwt:
secret: ${JWT_SECRET:your-secret-key}
expiration: 86400000 # 24시간
elasticore:
dsl-location: classpath:elasticore
code-generation:
package-base: com.example.shopping
features:
rest-api: true
graphql: true
search: true
audit: true
cache: true
security: true
# 외부 서비스 설정
payment:
api:
url: ${PAYMENT_API_URL:https://api.payment.com}
key: ${PAYMENT_API_KEY:your-payment-key}
shipping:
api:
url: ${SHIPPING_API_URL:https://api.shipping.com}
key: ${SHIPPING_API_KEY:your-shipping-key}
email:
smtp:
host: ${SMTP_HOST:smtp.gmail.com}
port: ${SMTP_PORT:587}
username: ${SMTP_USERNAME:your-email}
password: ${SMTP_PASSWORD:your-password}
이 예제는 실제 운영 가능한 수준의 온라인 쇼핑몰 시스템을 보여줍니다. ElastiCORE의 다양한 기능을 활용하여:
- 완전한 도메인 모델: 사용자, 상품, 주문, 결제 등
- 비즈니스 로직: 재고 관리, 할인 계산, 주문 처리
- 외부 연동: 결제, 배송, 이메일 서비스
- 관리자 기능: GraphQL 기반 관리 API
- 보안: JWT 인증, 역할 기반 접근제어
- 성능: 캐싱, 검색, 페이징
- 감사: 주문 및 결제 추적
모든 코드가 DSL 정의로부터 자동 생성되어 일관성 있고 유지보수가 용이한 시스템을 구축할 수 있습니다.