TL;DR
4개 레이어(interfaces, application, domain, infrastructure)로 구성된 레이어드 아키텍처를 적용했다.
의존 방향은 interfaces → application → domain ← infrastructure 단방향으로 강제하고,
DIP(의존성 역전 원칙)로 domain이 Spring/JPA/BCrypt 같은 인프라 기술을 모르는 구조를 만들었다.
Introduction & Goals
커머스 백엔드 API를 설계할 때 두 가지 문제를 피하고 싶었다.
- 기술 의존 전파: Spring Security, JPA 같은 인프라 기술이 비즈니스 로직에 직접 침투하는 것
- 책임 불명확: "이 코드는 어느 레이어에 있어야 하나?"라는 질문에 명확히 답할 수 없는 구조
레이어드 아키텍처 + DIP로 두 문제를 해결하고, MSA 이행 시에도 레이어 구조 변경 없이 호출 방식만 교체할 수 있는 설계를 목표로 했다.
Goals:
interfaces → application → domain ← infrastructure의존 방향 단방향 강제domain이 Spring, JPA, BCrypt를 import하지 않도록 유지- 경계 위반 경로를 코드 구조 자체로 없앰 (문서 규칙이 아닌 컴파일 에러로 강제)
System Architecture
클라이언트
↕ HTTP (JSON)
┌─────────────────────────────────────────┐
│ interfaces / api │
│ Controller · DTO · ApiControllerAdvice │
│ LoginInterceptor · WebMvcConfig │
└─────────────────────────────────────────┘
↕ Info (record)
┌─────────────────────────────────────────┐
│ application │
│ Facade · Service · Info │
└─────────────────────────────────────────┘
↕ Model
┌─────────────────────────────────────────┐
│ domain │
│ Model · VO · Repository 인터페이스 │
│ PasswordHasher 인터페이스 │
└──────────────↑──────────────────────────┘
│ 구현 (DIP)
┌─────────────────────────────────────────┐
│ infrastructure │
│ XJpaRepository · XRepositoryImpl │
│ BCryptPasswordHasher │
└─────────────────────────────────────────┘
↕ DB (MySQL)의존 규칙:
① interfaces → application : Controller는 Facade만 호출. Model import 금지.
② application → domain : Facade·Service는 Model·Repository 인터페이스 사용.
③ domain ← infrastructure : domain이 정의한 인터페이스를 infrastructure가 구현(DIP).패키지 구조
src/main/java/com/loopers/
├── interfaces/api/
│ ├── {domain}/ XController.java, XDto.java
│ ├── common/ ApiControllerAdvice.java, ApiResponse.java
│ └── config/ LoginInterceptor.java, WebMvcConfig.java
├── application/
│ └── {domain}/ XFacade.java, XService.java, XInfo.java
├── domain/
│ ├── {domain}/ XModel.java, XRepository.java
│ │ └── vo/ LoginId.java, Email.java, PlainPassword.java ...
│ └── common/ CoreException.java, ErrorType.java, BaseEntity.java
└── infrastructure/
└── {domain}/ XJpaRepository.java, XRepositoryImpl.javaLayer 1. interfaces / api
책임: HTTP 요청을 파싱하고 application에 위임한 뒤, 결과를 HTTP 응답으로 변환한다.
비즈니스 로직이 없다. 도메인 객체(XModel)를 직접 생성하거나 반환하지 않는다.
| 컴포넌트 | 책임 |
|---|---|
XController |
HTTP 엔드포인트 정의. Facade 호출, Info → Response 변환 |
XDto |
HTTP 입출력 형태 정의. 로직 없음 |
ApiControllerAdvice |
CoreException → HTTP 상태코드 변환 (전역 예외 처리) |
LoginInterceptor |
헤더에서 인증 정보 파싱, 인증 검증, userId를 request attribute에 저장 |
WebMvcConfig |
인터셉터 등록. blacklist 방식 — 전체 적용 후 공개 경로만 제외 |
설계 포인트:
- Controller는
XModel을 import하지 않는다.BrandInfo info = brandFacade.create(...)처럼 Info만 받아 DTO로 변환한다. LoginInterceptor의 책임은 인증만이다. 데이터를 Controller에 전달하면 인증 + 데이터 전달 두 책임이 생기고 Controller가 Interceptor 구조에 결합된다.catch(CoreException e)—Exception이 아닌CoreException만 잡아 401로 변환한다. DB 장애 같은 시스템 오류는 그대로 전파해 500이 된다.- WebMvcConfig는
new LoginInterceptor(...)아닌 Bean 주입으로 받는다.new로 생성하면 Spring이 관리하는 Bean과 별개 인스턴스가 두 개 생긴다. - whitelist(인증 경로를 하나씩 추가) 대신 blacklist를 채택했다. 새 API 추가 시 인증 누락 위험이 없다.
Layer 2. application
책임: 도메인 Service를 조합해 유스케이스를 완성한다. Model → Info 변환으로 도메인 객체가 Controller까지 나가지 못하게 막는다.
| 컴포넌트 | 책임 | Repository 의존 |
|---|---|---|
XFacade |
여러 Service 조합, 유스케이스 진입점, Model → Info 변환 | ❌ |
XService |
단일 도메인 유스케이스, 트랜잭션 관리 | ✅ 하나만 |
XInfo |
Facade 출력 DTO. 서버 내부 계약. Record로 구현 | — |
설계 포인트:
- Service가 Repository 하나에만 의존하는 이유: 여러 도메인을 조율하는 로직은 Facade가 담당한다. Service가 여러 Repository를 의존하면 Facade의 존재 이유가 없어진다.
- Facade가 얇아 보여도 자리를 잡아두는 이유:
BrandFacade는 지금 위임 + Info 변환뿐이지만,ProductFacade처럼 Brand 존재 확인 → Product 저장 → Stock 생성을 한 흐름에서 조율하는 Facade가 있어야 Controller가 여러 Service를 직접 건드리지 않아도 된다. - DTO(외부 계약) vs Info(내부 계약) 분리: 지금은
BrandInfo(id, name)와BrandResponse(id, name)이 같아 보여도 나중에 갈라진다. Info에productCount가 추가돼도 DTO는 그대로라 클라이언트에 영향이 없다. - OrderFacade의 핵심 흐름: 확인을 먼저 전체 통과시킨 후 차감해야 부분 차감 없이 All-or-Nothing이 보장된다.
1. 상품 목록 조회 2. productId 오름차순 정렬 ← 모든 트랜잭션이 같은 순서로 락 → 데드락 방지 3. 재고 SELECT FOR UPDATE ← 비관적 락 4. isAvailable() 전체 선검사 ← 하나라도 부족하면 여기서 중단, DB 변경 없음 5. Order + OrderItem 저장 ← 가격·상품명 스냅샷 6. 재고 일괄 차감 ← 더티 체킹으로 save() 없이 반영 - LikeFacade:
likeService.create()와productService.increaseLikeCount()를 같은@Transactional안에서 호출한다. 분리하면 Like는 있는데 likeCount가 안 오르는 데이터 불일치가 생긴다.
Layer 3. domain
책임: 비즈니스 규칙을 캡슐화한다. Spring/JPA에 의존하지 않는다.
단, @Entity, @Embeddable 같은 JPA 매핑 어노테이션은 사용한다.
| 컴포넌트 | 책임 | Spring 의존 |
|---|---|---|
XModel (Entity) |
도메인 상태 보유, 비즈니스 불변식 보호, 상태 변경 메서드 | @Entity (매핑만) |
VO |
값 타입, 불변, 생성자에서 즉시 검증 | @Embeddable (매핑만) |
XRepository (인터페이스) |
영속성 인터페이스 정의. JPA를 import하지 않음 | ❌ |
PasswordHasher (인터페이스) |
비밀번호 해싱/검증 포트. BCrypt를 import하지 않음 | ❌ |
CoreException / ErrorType |
도메인 예외. ErrorType이 HTTP 상태코드와 묶임 |
❌ |
BaseEntity |
id, createdAt, updatedAt, deletedAt 공통 필드 |
@MappedSuperclass |
설계 포인트:
Model 생성자에서 VO 즉시 생성:
this.loginId = new LoginId(loginId)— VO 생성자가 검증을 담당하므로 Model 생성자는 유효성 검사를 직접 하지 않는다.VO는 Record 불가:
@Embeddable+ Record 조합은 QueryDSL APT가 컴파일 타임에ElementKind.RECORD를 처리하지 못해 컴파일 에러가 난다. JPA 명세도 no-arg 생성자를 요구하는데 Record는 이를 지원하지 않는다. 일반 클래스 +@NoArgsConstructor(PROTECTED)+@EqualsAndHashCode로 구현했다.@Embeddable @EqualsAndHashCode @NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 명세: no-arg 생성자 필요 public class LoginId { private String value; public LoginId(String value) { if (!value.matches("^[a-zA-Z0-9]+$")) throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 영문과 숫자만 사용 가능합니다."); this.value = value; } public String value() { return value; } }VO 배치 기준: 값 하나의 유효성 → VO 생성자. 객체 자신의 상태 변경 → Entity 메서드. 여러 객체 사이의 정책 → Domain Service (현재 미도입). DB 조회 필요 → Application Service.
PlainPassword 생성자 오버로딩: 비밀번호 변경 시 형식만 검증하는 생성자와, 회원가입 시 형식 + 생년월일 포함 여부를 검증하는 생성자를 분리했다.
ProductModel에
@ManyToOne BrandModel대신Long brandId:@ManyToOne이면product.getBrand().update()가 컴파일된다 — 코드 구조 자체로 타 도메인 상태 변경 경로를 막는다.// ❌ @ManyToOne → 타 도메인 상태 변경 경로가 열림 product.getBrand().update("새 브랜드명"); // ✅ Long brandId → 이 코드 자체가 컴파일 안 됨 private Long brandId;OrderItemModel에 스냅샷 저장:
productId만 저장하면 이후 상품 가격이 바뀔 때 과거 주문 금액이 소급 변경된다. 주문 시점 상품명·가격을 함께 저장해 상품 수정·삭제와 무관하게 주문 데이터를 보존한다.PasswordHasher인터페이스 (DIP 포트):UserModel과UserService는 이 인터페이스에 의존한다. BCrypt가 뭔지 모른다. domain 레이어에spring-securityimport가 없다.// domain — spring-security import 없음 public interface PasswordHasher { String hash(String raw); boolean matches(String raw, String encoded); } // infrastructure — BCryptPasswordEncoder는 여기서만 등장 @Component public class BCryptPasswordHasher implements PasswordHasher { ... }Repository 인터페이스: JPA를 import하지 않는 순수 인터페이스. 파라미터와 반환 타입만 정의한다.
findByProductIdForUpdate처럼 비관적 락 의도를 메서드 이름에 명시한다.CoreException → HTTP 변환:
domain은CoreException(ErrorType.BAD_REQUEST, "...")만 던진다.ApiControllerAdvice가ErrorType을 보고 HTTP 상태코드로 변환한다. domain은 HTTP를 모른다.
Layer 4. infrastructure
책임: domain이 정의한 Repository 인터페이스와 PasswordHasher를 기술로 구현한다. 기술 선택(JPA, Redis, BCrypt 등)은 이 레이어에서만 이루어진다.
| 컴포넌트 | 책임 |
|---|---|
XJpaRepository |
Spring Data JPA 인터페이스. @Lock, @Query 등 JPA 어노테이션 사용 |
XRepositoryImpl |
domain의 XRepository 구현체. XJpaRepository에 위임 |
BCryptPasswordHasher |
PasswordHasher 구현체. BCryptPasswordEncoder에 위임 |
설계 포인트:
- 비관적 락은 JpaRepository에서: "어떻게 DB에서 읽어올 것인가"는 인프라 관심사다. StockModel은 DB를 모르는 순수 도메인 객체여야 한다.
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT s FROM StockModel s WHERE s.productId = :productId AND s.deletedAt IS NULL") Optional<StockModel> findByProductIdForUpdate(@Param("productId") Long productId); @Embedded필드 경로 탐색:findByLoginIdValue(String)—loginId가LoginIdVO이고 내부 필드가value이므로, Spring Data JPA가loginId.value를 파싱해WHERE login_id = ?쿼리를 생성한다.BCryptPasswordHasher:PasswordHasher인터페이스를 구현해 DIP를 완성한다. Spring Security 의존이 이 클래스에서만 발생한다.
Cross-cutting Concerns
Soft Delete
모든 엔티티는 BaseEntity를 상속해 deletedAt 컬럼을 갖는다.
삭제 시 deletedAt을 현재 시각으로 설정하고, @SQLRestriction("deleted_at IS NULL")이 조회 쿼리에 자동으로 필터를 추가한다.
Soft Delete + Unique 제약 (MySQL)
활성 계정 간 loginId, email 중복 불허 + 탈퇴 후 재가입 허용을 충족해야 했다.
MySQL은 WHERE deleted_at IS NULL 조건의 Filtered Index를 지원하지 않아 Generated Column으로 우회했다.
ALTER TABLE users
ADD COLUMN login_id_unique_key VARCHAR(20)
GENERATED ALWAYS AS (IF(deleted_at IS NULL, login_id, NULL)) VIRTUAL;
CREATE UNIQUE INDEX uk_users_login_id_active ON users (login_id_unique_key);
활성 계정은 login_id 값을, 탈퇴 계정은 NULL을 갖는다. MySQL Unique 제약은 NULL을 중복으로 보지 않으므로 탈퇴 계정이 아무리 많아도 unique 위반이 없다.
애그리거트 경계
같은 애그리거트 → @OneToMany 직접 참조 허용
Order + OrderItem: 항상 함께 생성·삭제, 같은 트랜잭션
다른 애그리거트 → Long ID로만 참조, FK 제약 없음
Product → Brand: Long brandId
Stock → Product: Long productId
Like, Order → Member: Long memberId
읽기(Query) → JOIN 허용
쓰기(Command) → 경계 엄격히 유지FK 없이 애플리케이션에서 검증하는 이유: MSA 전환 시 다른 서비스 DB에 FK를 걸 수 없다. 모놀리스부터 같은 구조를 써두면 코드 변경 없이 Service 간 호출 방식(직접 → HTTP API)만 교체하면 된다.
Alternatives Considered
| 항목 | 대안 | 선택 | 이유 |
|---|---|---|---|
| Service 위치 | domain 레이어 |
application 레이어 |
Repository 의존 Service는 인프라를 간접 필요로 함 |
| Brand-Product 참조 | @ManyToOne BrandModel |
Long brandId |
컴파일 레벨에서 타 도메인 상태 변경 경로 차단 |
| 비밀번호 해싱 | UserModel에서 직접 BCrypt |
PasswordHasher 포트 |
DIP — domain이 인프라 기술을 import하지 않음 |
| Facade 유무 | Controller에서 Service 직접 조합 | Facade 경유 | Controller가 여러 Service를 조율하면 레이어 위반 |
| VO 구현 | Java Record + @Embeddable |
일반 클래스 + Lombok | QueryDSL APT 컴파일 에러, JPA no-arg 생성자 스펙 위반 |
| WebMvcConfig 경로 | whitelist | blacklist | 새 API 추가 시 인증 누락 위험 제거 |
Lessons Learned
1. Repository 인터페이스가 domain에 있다고 Service도 domain에 있는 게 아니다
Repository 인터페이스가 domain에 있는 이유는 DIP다. Service가 그 인터페이스를 사용한다는 사실은 변하지 않고, Service는 infrastructure를 간접 필요로 한다. 그 책임은 application 레이어에 있다.
2. 컴파일 레벨에서 경계를 강제하라
Long brandId를 쓰면 product.getBrand().update()가 컴파일 자체가 안 된다. 경계를 문서나 팀 규약으로 강제하는 것보다 코드 구조로 위반 경로를 없애는 게 더 강력하다.
3. Service가 Repository 하나에만 의존하는 것이 Facade를 의미 있게 만든다
이 원칙이 없으면 Facade는 그냥 위임 클래스일 뿐이다. Service의 단일 의존 제약이 있어야 Facade가 "여러 도메인을 조율하는 레이어"라는 의미를 갖는다.
4. VO는 "이 값이 존재하기 위한 조건"을, Entity 메서드는 "이 객체의 상태 변경"을 담당한다
경계가 애매할 때 판단 기준: "이 로직이 없어도 이 값/객체는 존재할 수 있는가?" 없으면 안 된다 → 그 안에 넣는다. 외부 조건이다 → 바깥으로 꺼낸다.
5. VO를 Record로 구현하면 QueryDSL APT와 충돌한다
Hibernate 6이 런타임에서 Record를 우회 처리해줘도, QueryDSL APT는 컴파일 타임에 ElementKind.CLASS만 처리한다. "런타임에서 동작하는가"와 "생태계 도구 전체가 지원하는가"는 별개의 질문이다.