실전 프로젝트 — FP 패턴 통합
지금까지 배운 패턴을 하나의 주문 서비스 예제로 통합한다. 실제 프로젝트에서 어떤 레이어에 어떤 패턴을 적용하는지, 그리고 팀에 FP를 도입할 때의 현실적인 전략을 다룬다.
프로젝트 구조
src/main/kotlin/com/example/order/
├── domain/
│ ├── model/ ← 불변 data class (도메인 모델)
│ │ ├── Order.kt
│ │ ├── OrderItem.kt
│ │ ├── Money.kt
│ │ └── OrderId.kt (value class)
│ ├── error/ ← sealed class 에러 타입
│ │ └── OrderError.kt
│ └── service/ ← 순수 함수 (object)
│ └── OrderPricingService.kt
├── application/
│ └── OrderApplicationService.kt ← @Service, 부수 효과 조율
├── infrastructure/
│ ├── persistence/
│ │ ├── OrderEntity.kt ← JPA 엔티티
│ │ └── OrderRepositoryImpl.kt
│ └── external/
│ └── PaymentApiClient.kt
└── presentation/
├── OrderController.kt ← @RestController 또는 RouterFunction
└── dto/
├── CreateOrderRequest.kt
└── OrderResponse.kt
Domain Layer — 불변 모델 + 에러 타입
// domain/model/Order.kt
data class Order(
val id: OrderId,
val userId: UserId,
val status: OrderStatus,
val items: List<OrderItem>,
val pricing: OrderPricing,
val shippingAddress: Address,
val createdAt: Instant,
) {
companion object {
fun create(
userId: UserId,
items: List<OrderItem>,
pricing: OrderPricing,
address: Address,
): Order = Order(
id = OrderId.UNASSIGNED,
userId = userId,
status = OrderStatus.Pending,
items = items,
pricing = pricing,
shippingAddress = address,
createdAt = Instant.now(),
)
}
fun confirm(): Either<OrderError, Order> =
if (status == OrderStatus.Pending) copy(status = OrderStatus.Confirmed).right()
else OrderError.InvalidStatusTransition(status, OrderStatus.Confirmed).left()
fun cancel(reason: String): Either<OrderError, Order> =
if (status is OrderStatus.Cancelled) OrderError.AlreadyCancelled(id).left()
else copy(status = OrderStatus.Cancelled(reason)).right()
}
// domain/error/OrderError.kt
sealed class OrderError {
data class NotFound(val id: OrderId) : OrderError()
data class UserNotFound(val userId: UserId) : OrderError()
data class InsufficientStock(
val productId: ProductId,
val requested: Int,
val available: Int,
) : OrderError()
data class InvalidStatusTransition(
val current: OrderStatus,
val target: OrderStatus,
) : OrderError()
data class AlreadyCancelled(val id: OrderId) : OrderError()
object PaymentFailed : OrderError()
}Domain Service — 순수 비즈니스 로직
// domain/service/OrderPricingService.kt
object OrderPricingService {
fun calculatePricing(
items: List<OrderItemCommand>,
products: List<ProductInfo>,
coupon: Coupon?,
address: Address,
): Either<OrderError, OrderPricing> = either {
// 상품 정보 매핑 (순수 — DB 없음)
val orderItems = items.map { item ->
val product = products.find { it.id == item.productId }
?: raise(OrderError.ProductNotFound(item.productId))
OrderItem(product.id, product.name, item.quantity, product.price)
}
val itemTotal = orderItems.fold(Money.ZERO) { acc, item -> acc + item.subtotal }
// 할인 계산
val discount = coupon?.let { c ->
ensure(c.isValid()) { OrderError.InvalidCoupon(c.code) }
when {
c.discountRate != null -> itemTotal * c.discountRate
c.discountAmount != null -> minOf(c.discountAmount, itemTotal)
else -> Money.ZERO
}
} ?: Money.ZERO
// 배송비 계산
val discounted = itemTotal - discount
val shipping = when {
discounted >= Money.of(50_000) -> Money.ZERO
address.isRemote -> Money.of(5_000)
else -> Money.of(3_000)
}
OrderPricing(itemTotal, discount, shipping, discounted + shipping)
}
}Application Service — 부수 효과 조율
// application/OrderApplicationService.kt
@Service
@Transactional
class OrderApplicationService(
private val userRepository: UserRepository,
private val productRepository: ProductRepository,
private val couponRepository: CouponRepository,
private val orderRepository: OrderRepository,
private val paymentClient: PaymentApiClient,
private val eventPublisher: ApplicationEventPublisher,
) {
suspend fun placeOrder(command: PlaceOrderCommand): Either<OrderError, OrderResult> = either {
// 1. 데이터 수집 (부수 효과)
val user = userRepository.findById(command.userId).bind()
val products = productRepository.findAllByIds(command.items.map { it.productId }).bind()
val coupon = command.couponCode?.let {
couponRepository.findByCode(it).bind()
}
// 2. 순수 함수로 비즈니스 로직 처리
val pricing = OrderPricingService.calculatePricing(
items = command.items,
products = products,
coupon = coupon,
address = command.shippingAddress,
).bind()
// 3. 도메인 모델 생성 (순수)
val order = Order.create(
userId = user.id,
items = pricing.items,
pricing = pricing,
address = command.shippingAddress,
)
// 4. 저장 (부수 효과)
val saved = orderRepository.save(order).bind()
// 5. 결제 (부수 효과 — 트랜잭션 외부)
val paymentResult = paymentClient.charge(saved.id, saved.pricing.total).bind()
// 6. 이벤트 발행 (부수 효과)
eventPublisher.publishEvent(OrderPlacedEvent(saved.id, saved.pricing.total, user.email))
OrderResult(saved, paymentResult.transactionId)
}
suspend fun cancelOrder(
orderId: OrderId,
reason: String,
): Either<OrderError, Order> = either {
val order = orderRepository.findById(orderId).bind()
val cancelled = order.cancel(reason).bind()
orderRepository.save(cancelled).bind()
}
}Presentation Layer — Either → HTTP 응답
// presentation/OrderController.kt
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderApplicationService) {
@PostMapping
suspend fun placeOrder(
@RequestBody @Valid request: PlaceOrderRequest,
): ResponseEntity<*> =
orderService.placeOrder(request.toCommand())
.map { OrderResponse.from(it) }
.toResponseEntity(HttpStatus.CREATED)
@DeleteMapping("/{id}")
suspend fun cancelOrder(
@PathVariable id: Long,
@RequestBody request: CancelOrderRequest,
): ResponseEntity<*> =
orderService.cancelOrder(OrderId(id), request.reason)
.map { OrderResponse.from(it) }
.toResponseEntity()
}
// 공통 확장 함수
fun <E : OrderError, T> Either<E, T>.toResponseEntity(
successStatus: HttpStatus = HttpStatus.OK
): ResponseEntity<*> = fold(
ifLeft = { error ->
val (status, message) = when (error) {
is OrderError.NotFound -> HttpStatus.NOT_FOUND to "주문을 찾을 수 없습니다"
is OrderError.UserNotFound -> HttpStatus.NOT_FOUND to "사용자를 찾을 수 없습니다"
is OrderError.InsufficientStock -> HttpStatus.UNPROCESSABLE_ENTITY to
"재고 부족 (요청: ${error.requested}, 가용: ${error.available})"
is OrderError.AlreadyCancelled -> HttpStatus.CONFLICT to "이미 취소된 주문입니다"
OrderError.PaymentFailed -> HttpStatus.BAD_GATEWAY to "결제 처리 실패"
else -> HttpStatus.INTERNAL_SERVER_ERROR to "서버 오류"
}
ResponseEntity.status(status).body(ErrorResponse(message))
},
ifRight = { ResponseEntity.status(successStatus).body(it) }
)레이어별 FP 적용 수준
모든 레이어에 FP를 동시에 도입할 필요는 없다. 효과 대비 비용을 고려해 단계적으로 적용하는 것이 현실적이다.
레이어 FP 적용 수준 기대 효과
──────────────────────────────────────────────────
도메인 모델 ★★★★★ (필수) 불변성, 타입 안전, 상태 추적 용이
도메인 서비스 ★★★★★ (필수) Mock 없는 단위 테스트, 재사용성
Application ★★★★☆ Either로 에러 흐름 명시
Repository ★★★☆☆ Either 반환, 변환 함수
Controller ★★☆☆☆ fold()로 HTTP 응답 변환
팀에 FP 도입 전략
1단계: 값 객체 + 불변 모델 (충격 없음)
// 이것만 해도 큰 효과
data class Order(val id: OrderId, val status: OrderStatus, ...)
@JvmInline value class OrderId(val value: Long)코드 스타일만 바뀌므로 팀 저항이 거의 없다.
2단계: sealed class 에러 타입
sealed class OrderError { ... }
// @ExceptionHandler 방식과 병행 가능3단계: 비즈니스 로직 순수 함수 분리
object OrderPricingService {
fun calculate(...): OrderPricing // 순수 함수
}테스트 속도가 빨라지는 효과가 즉시 보인다. 팀 설득에 효과적.
4단계: Either 도입 (학습 비용 있음)
fun createOrder(...): Either<OrderError, Order>Arrow-kt 도입이 필요하다. 팀 학습이 필요한 단계.
5단계: Coroutines / Flow (선택)
성능 요구사항이나 스트리밍이 필요한 경우에만.
정리 — 패턴 한눈에
Request
│
▼ [Presentation]
Controller / Handler
- @Valid 형식 검증
- DTO → Command 변환
- Either → ResponseEntity 변환
│
▼ [Application]
ApplicationService (@Service)
- 데이터 수집 (Repository, 외부 API)
- 순수 함수 호출
- 이벤트 발행
- either { bind() } 파이프라인
│
▼ [Domain]
Domain Service (object) Domain Model (data class)
- 순수 비즈니스 로직 - val 프로퍼티
- Either<DomainError, Value> - copy()로 상태 전이
- Mock 없이 단위 테스트 - sealed class 에러 타입
│
▼ [Infrastructure]
Repository (@Repository)
- JPA Entity ↔ Domain Model 변환
- Either<Error, Model> 반환
- 영속성 세부사항 캡슐화
FP는 “올바른 방식”이 아니라 예측 가능하고 테스트하기 쉬운 코드를 만드는 도구다. 팀 상황과 도메인 복잡도에 맞춰 선택적으로 적용하는 것이 가장 현실적이다.