TL;DR
- 처음부터 완벽한 설계는 없다. 구현하면서 배우고, 부딪히며 개선하자.
- 여러 구현 방식을 몸소 체험하여 나만의 코드 철학을 세우자
- 도메인을 보호할 수 있도록 DIP를 활용하자.
- 값 객체(VO)를 적용할 시기를 고민해보자.
- Application Layer의 역할을 재고해보자.
- 진정한 백엔드 개발자란?
처음부터 완벽한 설계는 없다.
저번주에 만든 문서를 바탕으로 본격적으로 구현을 시작했습니다.
처음부터 완벽한 설계는 없다고 했던가. 구현한지 1시간 만에 클래스, 시퀀스 다이어그램을 수정하게 되었습니다.
이유는 이커머스 도메인에 대한 설계 경험이 부재한 것, 나만의 코드 컨벤션을 명확하게 하지 않은 것입니다.
자잘한 내용을 포함해서 문서의 수정한 내용은 다음과 같습니다.
- 메서드 네이밍 변경
- isLiked -> isLikeByMe
- 클래스 다이어그램의 책임 추가
- VO 추가
- 메서드 추가
- Order에 isOwnedBy()
- Stock에 isAvailable()
- 시퀀스 다이어그램 변경
- participants 추가/삭제
등등
그렇지만, '무엇'을 구현해야 하는지는 다이어그램에 대부분 표시하였습니다. 큰 맥락에서의 구현은 문서의 도움을 톡톡히 받았습니다.
그리고 누락한 다이어그램도 있습니다. 바로 상태 다이어그램과 도메인 모델링입니다.
상태 다이어그램

상태다이어그램은 객체의 생명주기 동안 발생하는 상태 변화와 상태 전이 조건을 시각화한 다이어그램입니다.
특징
- 특정 도메인 객체가 가질 수 있는 모든 상태를 표현
- 상태 간 전이(transition) 조건과 이벤트를 명시
- 비즈니스 규칙과 제약사항을 명확하게 표현
장점은
비즈니스 흐름의 명확한 가시화하여 어떤 조건에서 상태가 전환되는지 명확하게 표현됩니다.
또한, 개발자가 아닌 기획자나 QA도 이해하기 쉽습니다
예외 처리 누락을 방지하고, 테스트 케이스 도출이 쉬워집니다.
상태 다이어그램을 보면 테스트해야 할 시나리오가 명확해집니다:
불가능한 상태 전환을 차단할 수 있습니다.
다이어그램에 없는 전환을 코드에서 명시적으로 막을 수 있습니다.
애그리거트 다이어그램

개념간의 벽을 만들때 라이프 사이클과 개념관의 관심사를 고려해서 만들었습니다.
- User-Point, Order-OrderDetail, Product-Stock는 하나의 라이프 사이클로 관리합니다.
- "User가 생성되면 Point도 생성하고, User가 삭제되면 Point도 함께 삭제합니다.
- Point는 User 없이 독립적으로 존재할 수 없기 때문에 같은 라이프 사이클입니다." (Order-OrderDetail / Product - Stock도 마찬가지입니다.)
- 상품에서 '좋아요' 개념을 분리합니다.
- 상품와 좋아요의 관심사는 다릅니다.
- 좋아요는 '사용자 행동 데이터'이고 Product는 '판매 상품 데이터'로 생명주기와 변경 빈도가 다릅니다.
- 상품에 좋아요를 포함하면, 좋아요 추가/취소 시마다 Product 전체에 락이 걸려 상품 가격 수정 같은 핵심 비즈니스 로직이 blocking됩니다."
- 상품와 좋아요의 관심사는 다릅니다.
의존성 역전
Controller (Presentation)
⬇️
Facade (Application)
⬇️
Domain ←───────┐
⬆️ │ (의존성 역전)
Infra ─────────┘
레이어는 위와 같이 구현하였습니다.
주목해야 할 부분은 domain과 infra에 DIP(Dependency Inversion Principle)를 적용하였습니다.
코드는 다음과 같습니다.
// Domain Layer
interface ProductRepository {
fun findById(id: Long): Product?
fun save(product: Product): Product
}
// Infra Layer
@Repository
class ProductRepositoryImpl(
private val jpaRepository: ProductJpaRepository
) : ProductRepository {
override fun findById(id: Long): Product? = ...
override fun save(product: Product): Product = ...
}
장점
1. 도메인의 안정성 유지
- Domain Layer가 JPA, Hibernate, Spring 같은 기술에 무지함
- 비즈니스 로직에만 집중 가능
- 프레임워크 변경이 도메인에 영향을 주지 않음
- 의존성 방향이 항상 Domain을 향함
2. 테스트 용이성
- 실제 DB 없이 빠른 단위 테스트
- XxxRepository를 Mock 혹은 Fake 객체로 테스트 가능
3. 구현 기술 교체 가능
- DB를 MySQL → PostgreSQL로 변경해도 Domain 수정 불필요
- ORM을 JPA → MyBatis로 변경해도 Domain 수정 불필요
단점
1. 초기 구조가 복잡해 보임
- 인터페이스 + 구현체로 파일 수 증가
2. 작은 프로젝트에서는 과도할 수 있음
- 단순 CRUD만 있는 경우 오버엔지니어링
3. 러닝 커브
- DIP 개념 이해 필요
작은 프로젝트라도 도메인의 안정성을 유지하는걸 선호하여 대부분 프로젝트에서는 위와 같은 구조를 토대로 개발하고 있습니다.
응용 계층에서 조합하기 / 묻지말고 시켜라
응용 계층에서 조합하기
이번 구현에서는 application 계층과 domain 계층의 코드 구현 방식을 평소와 다르게 진행해보았습니다.
평소 방식
// Facade (Application Layer)
class ProductFacade(
private val productService: ProductService
) {
fun register(userId: Long, request: RegisterProductRequest): ProductResponse {
return productService.register(userId, request)
}
}
// Domain Layer
class ProductService(
private val userReader: UserReader,
private val productReader: ProductReader,
private val productWriter: ProductWriter
) {
fun register(userId: Long, request: RegisterProductRequest): Product {
val user = userReader.findById(userId) ?: throw UserNotFoundException()
val product = Product.create(request.name, request.price, request.brandId)
return productWriter.save(product)
}
}
interface UserReader {
fun findById(id: Long): User?
}
interface ProductReader {
fun findById(id: Long): Product?
fun findAll(pageable: Pageable): Page<Product>
}
interface ProductWriter {
fun save(product: Product): Product
}
Facade 계층에 XxxFacade를 만듭니다.
domain 계층에 XxxService, XxxReaer, XxxWriter 인터페이스를 만듭니다.
XxxReader는 조회 역할, XxxWriter는 쓰기 역할 입니다. XxxService에선 필요한 XxxReader, XxxStore를 참조하여 구현합니다.
장점은
읽기와 쓰기를 분리하여 CQRS 전환에 유리합니다.
트랜잭션 범위를 명확하게 지정할 수 있고, 역할과 책임을 분리하였기 때문에 테스트 구현이 편리합니다.
단점은
파일 수가 많아집니다.
구현을 위해 필요한 클래스를 찾아야 하는 비용이 증가합니다.
XxxFacade, XxxService 는 단순 위임만 하는 경우가 생길 경우 큰 이점을 누리지 못합니다.
시도한 방식
// Facade (Application Layer) - 유스케이스 조합
class ProductLikeFacade(
private val productLikeService: ProductLikeService,
private val productService: ProductService,
private val userService: UserService,
) {
@Transactional
fun like(productId: Long, userId: String) {
val user = userService.getMyInfo(userId)
val product = productService.getProduct(productId)
?: throw CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다")
productLikeService.like(product, user)
}
}
// Domain Layer - 단일 도메인 로직
class ProductLikeService(
private val productLikeRepository: ProductLikeRepository,
) {
fun like(product: Product, user: User) {
productLikeRepository.findBy(product.id, user.id)
?: productLikeRepository.save(ProductLike.create(product.id, user.id))
}
}
// Domain Layer - 복잡한 검증 로직 분리
class StockValidator(
private val productRepository: ProductRepository,
) {
fun validate(orderItems: List<OrderItem>) {
orderItems.forEach { item ->
val product = productRepository.findById(item.productId)
?: throw CoreException(ErrorType.NOT_FOUND, "상품 없음")
if (!product.hasEnoughStock(item.quantity)) {
throw CoreException(ErrorType.INSUFFICIENT_STOCK, "재고 부족")
}
}
}
}
동일하게 Facade 계층에 XxxFacade를 만들었습니다.
도메인 계층에 XxxReader, XxxWriter를 만들지 않고, XxxService에선 XxxRepository만 주입한다.
Service의 메서드가 복잡해지면 별도의 클래스로 추출하였습니다.
예를 들어, 주문 요청시 상품 존재 여부, 재고 수 체크 로직은 StockValidator 클래스로 추출하여 이관하였습니다.
장점은
XxxFacade의 역할에 충실합니다. 여러 서비스를 조합하여 호출하고, 유스케이스의 흐름을 한눈에 파악할 수 있습니다.
XxxService가
단점은
아직은 모르겠습니다.
구현을 해보면서 몸소 체험해보겠습니다.
객체에게 묻지말고 시켜라
객체지향에서 Tell, Dont't Ask 원칙(TDA)이 있습니다. 객체의 상태를 외부에서 조회해 판단하지 말고, 객체에게 직접 행동을 지시하라는 의미입니다. 이는 객체가 자신의 데이터와 행동을 스스로 관리하도록 하는 캡슐화의 핵심입니다. 상남자 답게 시키세요.
안티패턴 묻기 시키기 (Ask, Then Tell)
// PointService.kt
@Transactional
fun use(amount: Long, userId: Long) {
val point = pointRepository.getBy(userId)
?: throw CoreException(ErrorType.NOT_FOUND)
// 1. 먼저 물어본다
if(point.isAvailablePoint(amount)) {
// 2. 그 다음 시킨다
point.use(amount)
}
}
// Point.kt
fun isAvailablePoint(useAmount: Long): Boolean {
val amount = Amount(useAmount)
return !this.amount.isZero() && !this.amount.isLessThan(amount)
}
fun use(amount: Long) {
require(amount > 0) { "사용 금액은 0보다 커야 합니다." }
val useAmount = Amount(amount)
this.amount -= useAmount
}
문제점
- 책임의 분산: 포인트 사용 가능 여부 판단 로직이 Point 객체와 PointService 양쪽에 흩어져 있습니다.
- 캡슐화 위반: 서비스 레이어가 도메인 객체의 내부 상태(amount)를 기반으로 비즈니스 로직을 판단합니다.
- 변경에 취약: 포인트 사용 조건이 변경되면 여러 곳을 수정해야 합니다.
권장 패턴: 시키기 (Tell)
// PointService.kt
@Transactional
fun use(amount: Long, userId: Long) {
val point = pointRepository.getBy(userId)
?: throw CoreException(ErrorType.NOT_FOUND)
// 그냥 시킨다
point.use(amount)
}
// Point.kt
fun use(amount: Long) {
require(amount > 0) { "사용 금액은 0보다 커야 합니다." }
val useAmount = Amount(amount)
if (this.amount.isZero() || this.amount.isLessThan(useAmount)) {
throw CoreException(ErrorType.INSUFFICIENT_BALANCE)
}
this.amount -= useAmount
}
장점
- 단일 책임: 포인트 사용과 관련된 모든 로직이 Point 객체 안에 응집되어 있습니다.
- 캡슐화 강화: 서비스 레이어는 도메인 로직을 알 필요가 없고, 단지 "포인트를 사용해줘"라고 요청만 합니다.
- 변경 용이: 포인트 사용 규칙이 바뀌어도 Point.use() 메서드만 수정하면 됩니다.
- 원자성 보장: 검증과 실행이 하나의 메서드 안에서 원자적으로 처리됩니다.
- 가독성 향상: 코드를 읽는 사람이 비즈니스 의도를 명확하게 파악할 수 있습니다.
TDA를 지키면 좋은 이유
1. 도메인 로직의 중앙화
비즈니스 규칙은 도메인 객체에 존재해야 합니다. 서비스 레이어에 도메인 로직이 누수되면, 동일한 로직이 여러 서비스에 중복되거나 불일치가 발생합니다.
2. 테스트 용이성
class PointTest {
@Test
fun `포인트 부족시 예외 발생`() {
// given
val point = Point(userId = 1L, amount = Amount(1000))
// when & then
assertThatThrownBy { point.use(2000) }
.isInstanceOf(CoreException::class.java)
.hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_BALANCE)
}
}
도메인 로직이 객체 내부에 있으면 단위 테스트가 간결하고 명확해집니다.
결론
개발 업계에서 저명한 사람의 의견을 들어봐도 제각각입니다. 연차가 쌓여가면서 의견이 달라질 수도 있구요.
도메인 복잡도 등 상황마다 각자의 장단점이 있습니다. 사람마다 장단점을 받아들이는 것이 다르기 때문에 의견이 다른 것 같습니다.
어떤 방식이든 가독성을 지키고, Fat한 클래스를 지양, private 클래스 최소화 하기 위해 고군분투 하고 있습니다.
private 클래스를 최소화 하는 이유는 도메인 모델의 응집도를 높이기 위해 private 메서드에 구현한 로직을 domain 모델에서 구현할 수 없는지 한번 쯤 생각하기 위함입니다.
개발을 진행해가면서 나만의 개발 컨벤션을 정립해 나가야 겠습니다.
도메인 엔티티와 JPA 엔티티 분리
이번 프로젝트에서는 시간 관계상 도메인 엔티티와 JPA 엔티티를 동일하게 사용했습니다. 사실 분리할지 말지 계속 고민하고 있었는데, 구현 중 명확한 신호를 발견했습니다.
@Embeddable
data class UserId(
@Column(name = "user_id", unique = true, nullable = false)
val value: String,
) {
// User 테이블: 컬럼명이 "id"
@Entity
class User(
// db 필드명 user_id
val id: UserId
)
// Order 테이블: 컬럼명이 "ref_user_id"
@Entity
class Order(
// db 필드명 ref_user_id를 원했으나 UserId에서 @Column(name = "user_id")로 정의
val userId: UserId
)
User에선 db 필드명을 user_id를 사용하지만, Order에선 db 필드명을 ref_user_id를 사용하기로 원했습니다. UserId에 @Column(name = "user_id')로 정의해서 Order에서 @Column을 오버라이딩을 해야했습니다.
처음엔 복잡해 보여서 통합 사용했지만, UserId처럼 기술(JPA)이 도메인 설계를 제약하는 순간 분리의 필요성을 느꼈습니다.
다음 프로젝트에서는 처음부터 분리해서 시작할 것 같습니다. 초기 비용은 있지만, 도메인 순수성과 유연성을 지킬 수 있으니까요.
단점으로 매핑 로직 누락시 데이터 정합성 문제, 필드 누락과 같은 장애로 이어질 수 있지만 테스트 코드로 방어가 가능합니다.
진정한 의미의 '백엔드' 개발자는 스프링이나 JPA 없이도 성립할 수 있는 애플리케이션을 만들 수 있어야 합니다.
그러기 위해, 순수 자바 코드로 객체지향 적인 애플리케이션을 먼저 만들 수 있는 능력을 키워야 합니다.
즉, 도메인을 파악하고, 도메인 모델을 구성할 줄 알아야 합니다.
각 도메인의 책임

제가 작성한 클래스 다이어그램입니다.
Stock (재고)
+ isAvailable(quantity): Boolean
+ decrease(quantity): void
isAvailable(quantity) - 재고 가용성 검증
- 책임: 요청 수량만큼 재고가 충분한지 확인
- 도메인 규칙: 현재 재고량 ≥ 요청 수량
- 사용 시점: 주문 생성 전 검증 단계
- 반환: 가능 여부 (true/false)
decrease(quantity) - 재고 차감
- 책임: 주문 확정 시 재고를 실제로 감소
- 도메인 규칙:
- 음수 방지 (재고는 0 이하로 내려갈 수 없음)
- isAvailable() 검증 후에만 실행되어야 함
- 사용 시점: 주문 트랜잭션 내부
- 예외: 재고 부족 시 예외 발생
Order (주문)
+ validateOwner(userId): void
+ cancel(): void
+ complete(): void
validateOwner(userId) - 주문 소유자 검증
- 책임: 해당 주문이 요청한 사용자의 것인지 확인
- 도메인 규칙: 다른 사용자의 주문 접근 차단
- 사용 시점: 주문 조회/취소/상세 조회 전
- 예외: 소유자 불일치 시 403 Forbidden
cancel() - 주문 취소
- 책임: 주문 상태를 CANCELLED로 전환
- 도메인 규칙:
- PENDING 상태에서만 취소 가능
- COMPLETED/CANCELLED 상태에서는 불가
- 사용 시점: 사용자의 취소 요청 또는 시스템 실패 시
- 부가 효과: 재고 복구, 포인트 환불 (보상 트랜잭션)
complete() - 주문 완료
- 책임: 주문 상태를 COMPLETED로 전환
- 도메인 규칙:
- PENDING 상태에서만 완료 가능
- 재고 차감, 포인트 차감 성공 후에만 호출
- 사용 시점: 주문 처리 성공 시
- 불변성: 한 번 완료되면 되돌릴 수 없음
Point (포인트)
+ charge(amount): void
+ use(amount): void
charge(amount) - 포인트 충전
- 책임: 사용자 포인트 잔액 증가
- 도메인 규칙:
- 충전 금액은 양수여야 함
- 최대 보유 한도 검증 (선택적)
- 사용 시점: 사용자의 충전 요청 시
- 부가 효과: 충전 이력 기록 (향후 확장)
use(amount) - 포인트 사용
- 책임: 주문 결제 시 포인트 차감
- 도메인 규칙:
- 사용 금액은 양수여야 함
- 현재 잔액 ≥ 사용 금액
- 사용 시점: 주문 트랜잭션 내부
- 예외: 잔액 부족 시 예외 발생
각 객체에서 모두 간접 참조를 하고 있어, 각 객체의 메서드는 XxxService에서 호출하고 있습니다.
같은 애그리거트에 있는 객체이며 라이프 사이클이 같은 경우인 User-Point 관계에서 직접 참조를 사용할 수도 있는데요.
User에서 Point 객체를 가지고 있고, User를 통해서 Point를 조작할 수 있도록 구현할 수도 있습니다.
Value Object (값 객체) 언제써야 할까?
값 객체는 식별자가 없고, 값 자체로 동등성을 판단하는 불변 객체입니다.
@Embeddable
data class Email(
@Column(name = "email", nullable = false)
val value: String,
) {
init {
validate(value)
}
private fun validate(email: String) {
require(email.isNotBlank()) { ERROR_MESSAGE_BLANK }
require(!email.contains(" ")) { ERROR_MESSAGE_WHITESPACE }
require(email.matches(EMAIL_PATTERN)) { ERROR_MESSAGE_FORMAT }
}
override fun toString(): String = value
companion object {
private val EMAIL_PATTERN = Regex("^[^@]+@[^@]+\\.[^@]+$")
private const val ERROR_MESSAGE_BLANK = "email은 필수입니다"
private const val ERROR_MESSAGE_WHITESPACE = "email에 공백이 포함될 수 없습니다"
private const val ERROR_MESSAGE_FORMAT = "email은 xx@yy.zz 형식이어야 합니다"
}
}
val email1 = Email("test@test.com")
val email2 = Email("test@test.com")
println(email1 == email2) // true (값이 같으면 동일)
특징은
- 값이 같으면 동일한 객체
- 식별자 없음
- 불변 (생성 후 변경 불가)
장점은
비즈니스 규칙을 캡슐화할 수 있습니다.
'금액은 0 이상이어야 한다'는 규칙을 여러 곳에서 검증하는 대신 Money 생성 시점에 한 번만 검증하여 중복을 제거할 수 있습니다.
도메인 개념을 명확하게 표현할 수 있습니다.
예를 들어 Long 타입으로 가격을 표현하면 음수가 들어갈 수도 있고, 다른 Long 값과 혼동될 수 있지만, Money라는 값 객체로 표현하면 타입 안정성이 보장됩니다.
불변성으로 인한 안정성입니다.
값 객체는 생성 후 변경할 수 없어서 멀티스레드 환경에서도 안전하고 디버깅이 쉽습니다.
단점은 객체 관리 비용이 생깁니다.
Stock 객체에서 수량(quantity)을 관리하고 있습니다.
@Entity
@Table(name = "stock")
class Stock(
@Column(nullable = false)
var quantity: Long,
@Column(name = "ref_product_id", nullable = false)
val productId: Long,
) : BaseEntity() {
companion object {
fun create(quantity: Long, productId: Long): Stock {
return Stock(
quantity = quantity,
productId = productId,
)
}
}
fun isAvailable(quantity: Long): Boolean {
return this.quantity >= quantity
}
fun decrease(quantity: Long) {
require(quantity > 0) { "차감 수량은 0보다 커야 합니다." }
if (isInsufficientStock(quantity)) {
throw CoreException(ErrorType.INSUFFICIENT_STOCK)
}
this.quantity -= quantity
}
private fun isInsufficientStock(quantity: Long): Boolean {
return this.quantity < quantity
}
}
quantity를 값 객체로 만들 수 있지만 저는 만들지 않았습니다.
현재 재고 수량의 정책이 복잡한 검증을 하지 않고, Stock 객체에서만 사용 중입니다. 따라서, VO로 만들 필요성을 크게 느끼지 못했습니다. 만약, 정책들의 관리가 중요하고 범용성이 필요한 시점에 VO로 추출하여 별도 테스트 코드를 작성하여 안정성을 향상 시킬려고 합니다.
마무리: 학습 후 느낀 점
불과 한달전까지만 해도 "문서 작성 시간에 코드나 짜자"라고 생각했습니다. 하지만 이번 과정에서 구현 1시간 만에 다이어그램을 수정하면서 깨달았습니다. 다이어그램이 없었다면 코드를 10번 고쳤을 것입니다. 문서 덕분에 조금 더 원활하게 구현할 수 있었습니다.
"문서를 치열하게 고민하여 만들었으면 구현은 기계처럼 작성한다"는 말이 조금 와닿았습니다.
개발 방법론에 대한 많은 철학적 이야기를 듣고 구현하면서 "나만의 개발 철학을 만드는 여정"이었습니다.
평소 방식을 버리고 새로운 구조를 시도했고, 실패하고 수정하면서 체득했습니다.
내가 선호하는 코드 컨벤션이 무엇인지 한 걸음 다가갈 수 있었습니다.
진정한 백엔드 개발자는 스프링 개발자가 아닌 도메인 개발자가 되어야 합니다. JPA, 스프링은 도메인을 해결하기 위한 도구이자 수단일 뿐이며, 중요한 것은 비즈니스 가치를 전달하기 위한 도메인 분석과 설계가 뒷받침 되어야 한다는 것을 깨달았습니다.
루프백 3기 오픈
https://www.loopers.im/education
교육 과정 | Loop:Pak
Loop:Pak 부트캠프 교육 과정 상세 정보. NextNode Backend Edition을 포함한 전문 개발자 양성 커리큘럼과 현업 멘토링 프로그램을 만나보세요.
www.loopers.im
루퍼스 3기가 오픈되었습니다.
추천인 코드를 입력하면 할인가로 수강할 수 있습니다. 다만 결코 작은 금액은 아니기에, 신청 전 적지 않은 고민을 했는데요. AI 기술의 빠른 발전과 함께 연차가 쌓이면서, 스스로의 실력에 대한 의구심이 들던 시기였습니다.
며칠간 고민한 끝에 이번 기회를 마지막이라 생각하고 수강을 결정했습니다. 지금 돌이켜보면, 후회 없는 선택이었다고 자신 있게 말할 수 있습니다.
만약 저와 비슷한 고민을 하고 있거나, 루퍼스 과정에 대해 궁금한 점이 있다면 언제든지 편하게 연락 주셔도 좋습니다.
LinkedIn 또는 jikimee64@gmail.com으로 언제든 문의 부탁 드립니다. 감사합니다.
추천인 코드
TZFI0
'외부활동 > 루퍼스 2기' 카테고리의 다른 글
| [루퍼스/루프백 2기] 이커머스 동시성 제어 및 낙관적 락과 비관적 락 선택 기준 (0) | 2025.11.17 |
|---|---|
| [Loop:PAK] 3주차 WIL (0) | 2025.11.14 |
| [Loop:PAK] 2주차 WIL (0) | 2025.11.06 |
| [루퍼스/루프백 2기] 문서를 만들고 개발을 진행하시나요? (2) | 2025.11.01 |
| [Loop:PAK] 1주차 WIL (0) | 2025.10.30 |