이커머스 프로젝트에서 브랜드 도메인을 구현하다가 문득 이런 생각이 들었습니다.
" DDD 구현중인데 이 @Transactional, 도메인에 있어도 되는 거 맞아?"
발단: 어노테이션 하나에서 시작된 의문
브랜드 CRUD를 구현하고 나서, 코드를 보고 있었습니다. 겉보기에는 아무 문제 없었습니다. 테스트도 다 통과하고, API도 잘 동작했습니다.
// Domain Service
@Transactional
class BrandService(
private val brandRepository: BrandRepository,
) {
fun createBrand(name: String, description: String?): Brand {
val brand = Brand(name = name, description = description)
return brandRepository.save(brand)
}
fun deleteBrand(brandId: Long) {
val brand = brandRepository.findById(brandId)
?: throw CoreException(ErrorType.NOT_FOUND)
brand.delete()
brandRepository.save(brand)
}
}
근데 하나 걸리는 게 있었습니다. @Transactional. 이건 Spring 프레임워크의 기능입니다. 그리고 BrandService는 도메인 레이어에 있습니다.
"도메인 레이어가 Spring을 알아도 되나?"
이 질문 하나가 아키텍처 전체를 다시 들여다보게 만들었습니다.
DIP가 뭔데?
DIP(Dependency Inversion Principle, 의존성 역전 원칙)는 SOLID 원칙 중 하나입니다. 핵심은 간단합니다.
"상위 모듈이 하위 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다."
말이 어렵습니다. 이커머스 프로젝트에서 실제로 겪은 걸로 설명하겠습니다.
DIP가 없는 구조
// Domain Service가 JPA(인프라 기술)를 직접 의존
class BrandService(
private val brandJpaRepository: BrandJpaRepository // Spring Data JPA
)
BrandService → BrandJpaRepository
(도메인) (인프라)
의존 방향이 도메인 → 인프라입니다. 도메인이라는 상위 모듈이 인프라라는 하위 모듈에 의존하고 있습니다.
이게 왜 문제일까요? 나중에 JPA를 Redis로 바꾸고 싶으면 어떻게 될까요? BrandService의 코드를 전부 수정해야 합니다. 비즈니스 로직은 하나도 안 바뀌었는데, 저장소 기술이 바뀌었다는 이유만으로요.
*멘토링에서 JPA를 Redis로 바꿀 확률이 너무 희박하기 때문에 완벽하기 분리하려는 작업은 오히려 오버엔지니어링 일수 있다. 라는 의견도 들었습니다.
DIP가 적용된 구조
// 도메인 레이어 — 인터페이스 정의
interface BrandRepository {
fun save(brand: Brand): Brand
fun findById(id: Long): Brand?
}
// 도메인 레이어 — 인터페이스에만 의존
class BrandService(
private val brandRepository: BrandRepository // 인터페이스
)
// 인프라 레이어 — 인터페이스를 구현
@Component
class BrandRepositoryImpl(
private val brandJpaRepository: BrandJpaRepository,
) : BrandRepository {
override fun save(brand: Brand) = brandJpaRepository.save(brand)
override fun findById(id: Long) = brandJpaRepository.findById(id).orElse(null)
}
BrandService → BrandRepository (인터페이스, 도메인 레이어)
↑ 구현
BrandRepositoryImpl (인프라 레이어)
의존 방향이 바뀌었습니다. BrandService는 같은 도메인 레이어에 있는 BrandRepository 인터페이스에만 의존합니다. 그리고 인프라 레이어의 BrandRepositoryImpl이 그 인터페이스를 구현합니다.
원래 도메인 → 인프라였던 의존 방향이 인프라 → 도메인으로 뒤집혔습니다. 이게 "역전"입니다.
"역전"의 핵심은 인터페이스의 위치
여기서 중요한 건 인터페이스가 어느 레이어에 있는가입니다.
- 인터페이스가 도메인 레이어에 있으면 → 인프라가 도메인을 향해 의존 → DIP
- 인터페이스가 인프라 레이어에 있으면 → 그냥 추상화 → DIP 아님
단순히 "인터페이스를 만들었다"가 아니라 "인터페이스를 도메인에 뒀다"가 DIP의 핵심이었습니다.
그래서 @Transactional이 왜 문제인데? 원래 Service에 있는거잖아
DIP를 이해하고 나니, 처음 코드가 다시 보였습니다.
@Transactional // ← Spring 프레임워크 (인프라 기술)
class BrandService // ← 도메인 레이어
Repository는 인터페이스로 DIP를 적용해서 도메인을 순수하게 만들었는데, @Transactional 하나 때문에 도메인 레이어가 다시 Spring에 의존하고 있었습니다. 앞문으로는 인프라를 내보내고, 뒷문으로 다시 들인 셈입니다.
@Transactional은 "이 메서드를 하나의 트랜잭션으로 묶어라"라는 기술적 지시입니다. 비즈니스 로직이 아닙니다. "브랜드를 저장해라", "삭제 시 상품도 함께 삭제해라"가 비즈니스 규칙이지, "이걸 하나의 DB 트랜잭션으로 처리해라"는 인프라 관심사입니다.
각 레이어의 역할과 책임
이 문제를 해결하려면 먼저 각 레이어가 무슨 일을 해야 하는지 명확히 해야 했습니다. 코드를 짜다 보면 "이 로직을 어디에 넣지?"라는 고민이 계속 생기는데, 역할이 명확하면 판단 기준이 생깁니다.
Domain 레이어 — "무엇을 해야 하는가"
비즈니스의 중심입니다. 다른 레이어를 몰라야 합니다. Spring도 모르고, JPA도 모르고, Controller도 모릅니다. 오직 비즈니스 규칙만 알고 있습니다.
// Entity — 비즈니스 규칙을 캡슐화
class Product(
var stock: Int,
// ...
) {
fun reserve(quantity: Int): Boolean {
if (quantity <= 0) throw CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다.")
if (!hasEnoughStock(quantity)) return false
this.stock -= quantity
return true
}
private fun hasEnoughStock(quantity: Int): Boolean = this.stock >= quantity
}
// Domain Service — 여러 Entity를 조합하는 순수 비즈니스 로직
class BrandDomainService {
fun deleteBrand(brand: Brand, products: List<Product>) {
brand.delete()
products.forEach { it.delete() }
}
}
Product.reserve()는 "재고가 충분한지 확인하고 차감한다"는 비즈니스 규칙입니다. BrandDomainService.deleteBrand()는 "브랜드를 삭제하면 하위 상품도 함께 삭제한다"는 비즈니스 규칙입니다. 프레임워크 의존이 없고, 순수 Kotlin 코드입니다.
Application 레이어 — "어떤 순서로 해야 하는가"
유스케이스를 조율합니다. 데이터를 가져와서 Domain에 넘기고, 결과를 저장합니다. 트랜잭션 경계도 여기서 잡습니다.
@Component
class BrandService(
private val brandRepository: BrandRepository,
private val productRepository: ProductRepository,
private val brandDomainService: BrandDomainService,
) {
@Transactional
fun deleteBrand(brandId: Long) {
val brand = brandRepository.findById(brandId)
?: throw CoreException(ErrorType.NOT_FOUND)
val products = productRepository.findAllByBrandId(brandId)
brandDomainService.deleteBrand(brand, products) // 비즈니스 로직은 위임
brandRepository.save(brand)
productRepository.saveAll(products)
}
}
Application Service가 하는 일은 "조율"입니다. 브랜드를 가져오고, 상품을 가져오고, Domain Service에게 비즈니스 규칙을 실행시키고, 결과를 저장합니다. "삭제 시 상품도 함께 삭제한다"는 규칙은 BrandDomainService에 있고, Application Service는 그 규칙을 호출만 합니다.
그리고 @Transactional은 여기에 있습니다. "브랜드 삭제와 상품 삭제가 하나의 트랜잭션으로 묶여야 한다"는 건 기술적 관심사이니까요.
의존 방향 정리
Interfaces → Application → Domain ← Infrastructure
Controller Facade/Service Entity RepositoryImpl
@Transactional DomainService JpaRepository
Criteria/Info Repository(I)
Command
화살표 방향을 보면 Domain이 중심입니다. 모든 레이어가 Domain을 향해 의존하고, Domain은 아무것도 의존하지 않습니다. Infrastructure의 화살표가 ← 방향인 게 DIP입니다. 인프라가 도메인의 인터페이스를 구현하면서, 의존 방향이 역전됩니다.
실제로 리팩토링한 과정
개념을 이해하고 나서, 실제 코드를 고쳤습니다. 3단계에 걸쳐 진행했습니다.
1단계: @Transactional을 Application 레이어로 이동
# Before
Domain Service (@Transactional) → Repository
# After
Application Service (@Transactional) → Repository
Domain Service를 모두 Application Service로 변경했습니다.
이제 트랜잭션과 Spring 은 Domain에 없습니다.
2단계: 비즈니스 규칙을 Domain Service로 추출
"브랜드 삭제 시 상품도 cascade 삭제"라는 규칙이 Application Service에 흩어져 있었습니다. 이걸 BrandDomainService로 추출했습니다.
// Before — 비즈니스 규칙이 Application Service에 있음
@Transactional
fun deleteBrand(brandId: Long) {
val brand = brandRepository.findById(brandId)!!
val products = productRepository.findAllByBrandId(brandId)
brand.delete() // 여기와
products.forEach { it.delete() } // 여기가 비즈니스 규칙
brandRepository.save(brand)
productRepository.saveAll(products)
}
// After — 비즈니스 규칙은 Domain Service로, 조율만 Application에
@Transactional
fun deleteBrand(brandId: Long) {
val brand = brandRepository.findById(brandId)!!
val products = productRepository.findAllByBrandId(brandId)
brandDomainService.deleteBrand(brand, products) // 위임
brandRepository.save(brand)
productRepository.saveAll(products)
}
코드 줄 수는 비슷합니다. 하지만 책임이 분리되었습니다. Application Service는 "뭘 가져오고 뭘 저장하는가"(조율)만 알고, "삭제 시 어떤 규칙이 적용되는가"는 Domain Service가 압니다.
3단계: 재고 정책을 Aggregate로 이동
주문 도메인에서도 같은 문제가 있었습니다. OrderDomainService가 재고 검증 + 분류 + Order 조립을 모두 담당하고 있었습니다. 재고는 Product의 상태인데, Order가 관리하고 있었습니다.
// Before — OrderDomainService가 재고까지 관리
class OrderDomainService {
fun classifyAndBuildOrder(userId: Long, products: List<Product>, ...) {
// 재고 확인 (Product의 관심사)
// 성공/실패 분류 (Product의 관심사)
// Order 조립 (Order의 관심사)
}
}
// After — 각자의 책임
class Product {
fun reserve(quantity: Int): Boolean { ... } // 재고는 Product가 관리
}
class ProductService {
fun reserveStock(...): StockReservationResult { ... } // 예약 결과 분류
}
class OrderDomainService {
fun buildOrder(userId: Long, items: List<OrderItemCommand>): Order { ... } // 확정된 아이템만 조립
}
재고 정책을 Product Aggregate로 옮기니까, OrderDomainService는 "확정된 아이템으로 Order를 만든다"는 본래의 책임만 남았습니다. 각 객체가 자기 상태를 자기가 관리하는 구조가 되었습니다.
이렇게 나누면 뭐가 좋은데?
솔직히 처음에는 "코드만 늘어나는 거 아닌가?"라고 생각했습니다. 하지만 실제로 구현해보니 체감이 달랐습니다.
1. 테스트가 쉬워진다
Domain Service에 @Transactional이 없으니까, Spring Context 없이 순수 단위 테스트가 가능합니다.
// Spring 없이 테스트 가능
class BrandDomainServiceTest {
private val brandDomainService = BrandDomainService()
@Test
fun deletesBrandAndProducts_whenCalled() {
val brand = Brand(name = "나이키", description = "스포츠 브랜드")
val products = listOf(Product(brandId = 1L, name = "에어맥스", ...))
brandDomainService.deleteBrand(brand, products)
assertAll(
{ assertThat(brand.deletedAt).isNotNull() },
{ assertThat(products[0].deletedAt).isNotNull() },
)
}
}
어노테이션도 없고, Mock도 없고, DB도 없습니다. 비즈니스 규칙만 검증합니다.
2. 변경 영향이 격리된다
JPA를 다른 기술로 바꿀 때 RepositoryImpl만 새로 만들면 됩니다. Service는 수정 없습니다. 실제로 이 프로젝트에서 BrandRepository의 메서드를 추가할 때도, 인터페이스에 시그니처를 추가하고 BrandRepositoryImpl에 구현만 추가하면 Service 코드는 건드리지 않았습니다.
3. "이 로직 어디에 넣지?"가 명확해진다
가장 큰 장점은 이거였습니다. 레이어별 역할이 명확하니까, 새로운 기능을 추가할 때 고민이 줄었습니다.
| 질문 | 답 |
|---|---|
| "재고가 충분한지 확인"은 어디에? | Domain (Product.reserve()) |
| "여러 상품의 재고를 한번에 예약"은 어디에? | Application (ProductService.reserveStock()) |
| "트랜잭션으로 묶어라"는 어디에? | Application (@Transactional) |
| "JPA로 저장해라"는 어디에? | Infrastructure (RepositoryImpl) |
| "요청을 받아 응답을 만들어라"는 어디에? | Interfaces (Controller) |
단점도 있다
무조건 좋은 구조는 없습니다. 솔직하게 느낀 단점도 적어봅니다.
코드량이 늘어납니다. Repository 하나에 3개 파일, 간단한 CRUD에도 Controller → Service → Domain Service → Repository를 다 거칩니다. 위임만 하는 패스스루 코드가 생깁니다.
프로젝트 규모가 작으면 오버엔지니어링입니다. JPA를 Redis로 바꿀 일이 없는 프로젝트에서 추상화 비용을 지불하는 건 낭비일 수 있습니다.
DTO 변환이 많아집니다. API DTO → Criteria → Command → Info → API DTO. 레이어 간 격리를 위한 건데, 간단한 기능에서는 변환 코드가 본 로직보다 길어질 때도 있었습니다.
결론
@Transactional 하나에서 시작된 의문이 아키텍처 전체를 다시 보게 만들었습니다. 결국 DIP에서 배운 건 기술적인 것이 아니었습니다.
"각 레이어가 자기 역할만 하면, 전체가 깔끔해진다."
- Domain은 비즈니스 규칙만 안다. 프레임워크를 모른다.
- Application은 순서를 조율한다. 뭘 가져와서 누구에게 넘기고 뭘 저장할지 안다.
- Infrastructure는 기술 구현을 담당한다. 도메인이 정한 계약을 지킨다.
이 원칙을 지키면 어노테이션 하나의 위치도 자연스럽게 정해집니다. @Transactional은 기술적 관심사니까 Application에. 비즈니스 규칙은 Domain에. 저장 방법은 Infrastructure에.
"이 코드 어디에 넣지?"라는 질문에 항상 같은 답을 줄 수 있는 구조. 그게 DIP를 적용하면서 얻은 가장 큰 수확이었습니다.
DDD, DIP를 학습 멘토링 시간에 Facade가 꼭 필요한가요? 라는 질문이 있었는데 팀의 컨베션 관점에서 바라봐야 한다고 하신 내용이 기억 남습니다.
또 멘토링 이후 팀원분이 회사 팀장님과 나누신 대화 내용을 slack 에 공유해주셨는데 "어떤 기술에 대해 팀원들에게 전파하려했지만 공감을 얻지는 못하셨다" 라는 내용이였습니다.
어떤 좋은기술도 단점은 존재할수 있습니다. 만약 "왜 이렇게 했는지"를 설명할 수 있고, 팀원의 공감을 얻을 수 있다면 좋은 기술의 단점을 극복할수 있지 않을까요? 팀원 모두가 함께 트레이드오프를 이해하고 선택한것이니깐요!
긴글 읽어주셔서 감사합니다!
'Skill' 카테고리의 다른 글
| [캐시] 10만 건 돌려보니까 보이더라(인덱스, 캐시 실전 적용기) (0) | 2026.03.12 |
|---|---|
| [동시성] 비관적 락 하나로 다 해결한 줄 알았다 (0) | 2026.03.06 |
| [동시성] 재고는 한개인데 주문이 동시에 들어오면?(비관적 락 vs 낙관적 락) (0) | 2026.02.13 |
| 이제 더는 안되겠다 TDD 너 일로 나와 (with claude code) (0) | 2026.02.06 |