37장. Kotlin으로 주문 시스템 만들기
드디어 실전입니다.
지금까지 배운 문법과 구조를 모두 모아
하나의 작은 주문 시스템을 완성해 봅니다.
웹 서버나 데이터베이스는 쓰지 않습니다.
순수 코틀린만으로 백엔드 핵심 로직을 완성합니다.
이 장은 새로운 문법을 배우는 장이 아닙니다.
배운 것을 “엮는” 장입니다.
코드를 눈으로만 읽지 말고
직접 따라 쳐 보길 권합니다.
37.1 프로젝트 요구사항
만들 기능은 다음과 같습니다.
회원 등록
상품 등록
상품 조회
주문 생성
주문 조회
주문 취소
작지만 백엔드의 핵심 흐름이
모두 담긴 요구사항입니다.
37.2 프로젝트 구조 만들기
32장에서 배운 대로 역할별로 나눕니다.
domain
├─ User
├─ Product
├─ Order
└─ OrderItem
repository
├─ UserRepository
├─ ProductRepository
└─ OrderRepository
service
├─ UserService
├─ ProductService
└─ OrderService
dto
├─ request
└─ response
이 구조를 머릿속에 그린 채
아래로 내려갑시다.
37.3 회원 기능 구현하기
먼저 도메인입니다. (34장)
data class User(
val id: Long,
val name: String,
val email: String
) {
init {
require(name.isNotBlank()) { "이름은 비어 있을 수 없습니다" }
require(email.contains("@")) { "올바른 이메일이 아닙니다: $email" }
}
}
Repository 인터페이스와 메모리 구현입니다. (35장)
interface UserRepository {
fun save(user: User): User
fun findById(id: Long): User?
}
class MemoryUserRepository : UserRepository {
private val storage = mutableMapOf<Long, User>()
private var sequence = 0L
override fun save(user: User): User {
val id = if (user.id == 0L) ++sequence else user.id
val saved = user.copy(id = id)
storage[id] = saved
return saved
}
override fun findById(id: Long): User? = storage[id]
}
DTO와 변환 함수입니다. (33장)
data class CreateUserRequest(val name: String, val email: String)
data class UserResponse(val id: Long, val name: String)
fun User.toResponse() = UserResponse(id = id, name = name)
Service입니다. (36장)
class UserService(
private val userRepository: UserRepository
) {
fun register(request: CreateUserRequest): UserResponse {
val user = User(id = 0, name = request.name, email = request.email)
return userRepository.save(user).toResponse()
}
}
37.4 상품 기능 구현하기
상품도 같은 패턴을 따릅니다.
data class Product(
val id: Long,
val name: String,
val price: Int
) {
init {
require(price >= 0) { "가격은 음수일 수 없습니다: $price" }
}
}
interface ProductRepository {
fun save(product: Product): Product
fun findById(id: Long): Product?
fun findAll(): List<Product>
}
Service에는 등록과 조회를 둡니다.
class ProductService(
private val productRepository: ProductRepository
) {
fun register(name: String, price: Int): Product =
productRepository.save(Product(id = 0, name = name, price = price))
fun findAll(): List<Product> = productRepository.findAll()
}
37.5 주문 기능 구현하기
주문은 여러 도메인을 엮으므로 조금 더 복잡합니다.
data class OrderItem(val productId: Long, val quantity: Int)
enum class OrderStatus { CREATED, CANCELED }
data class Order(
val id: Long,
val userId: Long,
val items: List<OrderItem>,
val totalPrice: Int,
val status: OrderStatus = OrderStatus.CREATED
) {
fun cancel(): Order {
check(status == OrderStatus.CREATED) { "이미 취소된 주문입니다" }
return copy(status = OrderStatus.CANCELED)
}
}
주문 Service는
회원과 상품이 실제로 있는지 확인하고,
금액을 계산해 주문을 만듭니다.
class OrderService(
private val orderRepository: OrderRepository,
private val userRepository: UserRepository,
private val productRepository: ProductRepository
) {
fun create(userId: Long, items: List<OrderItem>): Order {
userRepository.findById(userId)
?: throw IllegalArgumentException("회원 없음: $userId")
val total = items.sumOf { item ->
val product = productRepository.findById(item.productId)
?: throw IllegalArgumentException("상품 없음: ${item.productId}")
product.price * item.quantity
}
val order = Order(id = 0, userId = userId, items = items, totalPrice = total)
return orderRepository.save(order)
}
fun cancel(orderId: Long): Order {
val order = orderRepository.findById(orderId)
?: throw IllegalArgumentException("주문 없음: $orderId")
return orderRepository.save(order.cancel())
}
}
37.6 Collection을 활용한 주문 데이터 처리
주문 여러 건을 다룰 때
13장에서 배운 컬렉션 함수가 빛을 발합니다.
val orders: List<Order> = orderRepository.findAll()
// 회원별 주문 그룹화
val byUser = orders.groupBy { it.userId }
// 취소되지 않은 주문의 총 매출
val revenue = orders
.filter { it.status == OrderStatus.CREATED }
.sumOf { it.totalPrice }
// 금액이 큰 순으로 정렬
val topOrders = orders.sortedByDescending { it.totalPrice }
반복문 대신groupBy, filter, sumOf로
의도가 한눈에 드러납니다.
37.7 오류와 예외 처리
실패 상황을 어떻게 다룰지도 설계합니다. (23·24장)
없는 회원이나 상품은
명확한 예외로 알립니다.
userRepository.findById(userId)
?: throw IllegalArgumentException("회원 없음: $userId")
메시지에 원인이 된 값($userId)을 담아
나중에 추적하기 쉽게 만듭니다.
37.8 DTO 변환
마지막으로 도메인을 응답으로 바꿉니다. (33장)
data class OrderResponse(
val id: Long,
val userId: Long,
val totalPrice: Int,
val status: String
)
fun Order.toResponse() = OrderResponse(
id = id,
userId = userId,
totalPrice = totalPrice,
status = status.name
)
도메인은 안에서만 쓰고,
바깥에는 응답 DTO만 내보냅니다.
37.9 테스트 작성
Fake와 DI 덕분에
테스트가 쉽습니다. (31·36장)
@Test
fun `주문을 생성하면 총액이 계산된다`() {
// Given
val users = MemoryUserRepository().apply { save(User(0, "홍길동", "a@b.com")) }
val products = MemoryProductRepository().apply { save(Product(0, "노트북", 1000)) }
val orders = MemoryOrderRepository()
val orderService = OrderService(orders, users, products)
// When
val order = orderService.create(
userId = 1,
items = listOf(OrderItem(productId = 1, quantity = 2))
)
// Then
assertEquals(2000, order.totalPrice)
}
apply로 저장소를 만들며 바로 채웠습니다. (15장 스코프 함수)
37.10 전체 코드 리팩터링
동작하는 코드를 만든 뒤에는
“더 코틀린답게” 다듬습니다. (다음 38장의 예고이기도 합니다)
- 불필요한
var를val로 바꾸기 (2장) - 반복문을 컬렉션 함수로 바꾸기 (13장)
- 변환 로직을 확장 함수로 모으기 (17·33장)
!!대신 엘비스 연산자로 실패를 명확히 (5장)
마지막으로 main에서 전체를 조립해
실제로 흐름을 실행해 봅니다.
fun main() {
val userRepository = MemoryUserRepository()
val productRepository = MemoryProductRepository()
val orderRepository = MemoryOrderRepository()
val userService = UserService(userRepository)
val productService = ProductService(productRepository)
val orderService = OrderService(orderRepository, userRepository, productRepository)
val user = userService.register(CreateUserRequest("홍길동", "hong@test.com"))
val product = productService.register("노트북", 1000)
val order = orderService.create(
userId = user.id,
items = listOf(OrderItem(product.id, 2))
)
println("주문 생성됨: ${order.toResponse()}")
val canceled = orderService.cancel(order.id)
println("주문 취소됨: ${canceled.toResponse()}")
}
웹 서버도 데이터베이스도 없지만,
백엔드의 핵심 로직이 완성되었습니다.
여기까지 스스로 만들었다면,
스프링을 배울 준비가 충분히 된 것입니다.
37장을 마치며
이 장에서 우리는 다음을 해냈습니다.
- 회원, 상품, 주문 기능을 순수 코틀린으로 구현
- 도메인·DTO·Repository·Service를 엮어 하나의 흐름 완성
- 컬렉션 함수로 데이터 가공
- Fake와 DI로 테스트 작성
- 조립(
main)으로 전체 실행
이제 마지막 13부에서는
이 코드를 더 코틀린답게 다듬는 법을 정리합니다.