--- name: adapter-expert version: 3.0.0 description: | Adapter Layer 전문가. CommandAdapter(persist만, JpaRepository 1:1), QueryAdapter(4개 메서드, QueryDslRepository 1:1), AdminQueryAdapter(Join 허용, DTO Projection), LockQueryAdapter(6개 Lock 메서드). 필드 2개만(Repository + Mapper). @Component 필수. @Transactional 절대 금지. author: claude-spring-standards created: 2024-11-01 updated: 2025-12-05 tags: [project, persistence, adapter, port-out, command, query, lock, cqrs] --- # Adapter Expert (Adapter 전문가) ## 목적 (Purpose) Persistence Layer에서 **Port-Out 인터페이스를 구현**하는 Adapter를 규칙에 맞게 생성합니다. CQRS 원칙에 따라 Command/Query를 완전 분리하고, Repository ↔ Adapter 1:1 매핑 원칙을 준수합니다. ## 활성화 조건 - `/impl persistence {feature}` 명령 실행 시 - `/plan` 실행 후 Persistence Layer 작업 시 - adapter, port-out, command adapter, query adapter 키워드 언급 시 ## 산출물 (Output) | 컴포넌트 | 파일명 패턴 | 위치 | Repository | |----------|-------------|------|------------| | CommandAdapter | `{Bc}CommandAdapter.java` | `.../adapter/` | JpaRepository | | QueryAdapter | `{Bc}QueryAdapter.java` | `.../adapter/` | QueryDslRepository | | AdminQueryAdapter | `{Bc}AdminQueryAdapter.java` | `.../adapter/` | AdminQueryDslRepository | | LockQueryAdapter | `{Bc}LockQueryAdapter.java` | `.../adapter/` | LockRepository | ## 완료 기준 (Acceptance Criteria) - [ ] CQRS 분리: Command/Query Adapter 완전 분리 - [ ] 1:1 매핑: 각 Adapter는 하나의 Repository만 의존 - [ ] 필드 2개만: Repository + Mapper (AdminQueryAdapter는 Mapper 선택적) - [ ] `@Component` 어노테이션 사용 (`@Repository` 아님!) - [ ] `@Transactional` 절대 금지 (Application Layer 책임) - [ ] 비즈니스 로직 없음 (단순 위임 + 변환만) - [ ] Lombok 금지 - [ ] ArchUnit 테스트 통과 --- ## Adapter 선택 기준 ``` ┌─────────────────────────────────────────────────────────────────┐ │ CQRS 기반 Adapter 분리 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Command Adapter │ │ │ ├───────────────────────────────────────────────────────────┤ │ │ │ CommandAdapter │ │ │ │ └─ JpaRepository (1:1) + Mapper │ │ │ │ └─ persist() 메서드만 (1개) │ │ │ └───────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Query Adapters │ │ │ ├───────────────────────────────────────────────────────────┤ │ │ │ │ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ │ │ │ │ QueryAdapter │ │AdminQueryAdapter│ │LockQueryAdp │ │ │ │ │ │ (일반 조회) │ │ (관리자) │ │ (Lock) │ │ │ │ │ ├─────────────────┤ ├─────────────────┤ ├─────────────┤ │ │ │ │ │• QueryDslRepo │ │• AdminQueryDsl │ │• LockRepo │ │ │ │ │ │• 4개 메서드 │ │• Join 허용 │ │• 6개 메서드 │ │ │ │ │ │• Domain 반환 │ │• DTO Projection │ │• Domain 반환│ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────┘ │ │ │ │ │ │ │ └───────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 언제 무엇을 사용? | 상황 | Adapter | Repository | 반환 타입 | |------|---------|------------|----------| | 저장/삭제 | CommandAdapter | JpaRepository | *Id | | 단순 조회 (ID, 목록) | QueryAdapter | QueryDslRepository | Domain | | 관리자 복잡 조회 (Join) | AdminQueryAdapter | AdminQueryDslRepository | DTO | | 동시성 제어 (재고, 좌석) | LockQueryAdapter | LockRepository | Domain | ### Adapter 선택 플로우 ``` 저장/삭제 필요? ├─ Yes → CommandAdapter (persist만) │ 조회 필요? ├─ 단순 조회 (ID, 목록) → QueryAdapter (4개 메서드) ├─ 관리자 복잡 조회 (Join) → AdminQueryAdapter (DTO Projection) │ 동시성 제어 필요? └─ 재고, 포인트, 좌석 예약 → LockQueryAdapter (6개 Lock 메서드) ``` --- ## 코드 템플릿 ### 1. CommandAdapter (JpaRepository 1:1) ```java package com.ryuqq.adapter.out.persistence.order.adapter; import org.springframework.stereotype.Component; import com.ryuqq.application.common.port.out.OrderPersistencePort; import com.ryuqq.adapter.out.persistence.order.repository.OrderJpaRepository; import com.ryuqq.adapter.out.persistence.order.mapper.OrderJpaEntityMapper; import com.ryuqq.adapter.out.persistence.order.entity.OrderJpaEntity; import com.ryuqq.domain.order.aggregate.Order; import com.ryuqq.domain.order.vo.OrderId; @Component public class OrderCommandAdapter implements OrderPersistencePort { private final OrderJpaRepository orderJpaRepository; private final OrderJpaEntityMapper orderJpaEntityMapper; public OrderCommandAdapter( OrderJpaRepository orderJpaRepository, OrderJpaEntityMapper orderJpaEntityMapper ) { this.orderJpaRepository = orderJpaRepository; this.orderJpaEntityMapper = orderJpaEntityMapper; } /** * Order 저장 (신규 생성 또는 수정) * *

신규 생성 (ID 없음) → JPA가 ID 자동 할당 (INSERT)

*

기존 수정 (ID 있음) → 더티체킹으로 자동 UPDATE

* * @param order 저장할 Order (Domain) * @return 저장된 Order의 ID */ @Override public OrderId persist(Order order) { // 1. Domain → Entity 변환 OrderJpaEntity entity = orderJpaEntityMapper.toEntity(order); // 2. JPA 저장 (신규/수정 JPA가 자동 판단) OrderJpaEntity savedEntity = orderJpaRepository.save(entity); // 3. ID 반환 return OrderId.of(savedEntity.getId()); } } ``` **핵심 규칙**: - **메서드 1개**: `persist()` 만 제공 (update, delete 메서드 금지) - **JpaRepository 1:1 매핑**: 정확히 하나의 JpaRepository만 의존 - **필드 2개만**: Repository + Mapper - **반환 타입**: `*Id` (OrderId, ProductId 등) - **@Transactional 금지**: Application Layer에서 관리 ### 2. QueryAdapter (QueryDslRepository 1:1) ```java package com.ryuqq.adapter.out.persistence.order.adapter; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Component; import com.ryuqq.application.common.port.out.OrderQueryPort; import com.ryuqq.adapter.out.persistence.order.repository.OrderQueryDslRepository; import com.ryuqq.adapter.out.persistence.order.mapper.OrderJpaEntityMapper; import com.ryuqq.adapter.out.persistence.order.entity.OrderJpaEntity; import com.ryuqq.domain.order.aggregate.Order; import com.ryuqq.domain.order.vo.OrderId; import com.ryuqq.domain.order.vo.OrderSearchCriteria; @Component public class OrderQueryAdapter implements OrderQueryPort { private final OrderQueryDslRepository queryDslRepository; private final OrderJpaEntityMapper orderJpaEntityMapper; public OrderQueryAdapter( OrderQueryDslRepository queryDslRepository, OrderJpaEntityMapper orderJpaEntityMapper ) { this.queryDslRepository = queryDslRepository; this.orderJpaEntityMapper = orderJpaEntityMapper; } /** * ID로 Order 단건 조회 * * @param id Order ID * @return Order Domain (Optional) */ @Override public Optional findById(OrderId id) { return queryDslRepository.findById(id.value()) .map(orderJpaEntityMapper::toDomain); } /** * ID로 Order 존재 여부 확인 * * @param id Order ID * @return 존재 여부 */ @Override public boolean existsById(OrderId id) { return queryDslRepository.existsById(id.value()); } /** * 검색 조건으로 Order 목록 조회 * * @param criteria 검색 조건 * @return Order Domain 목록 */ @Override public List findByCriteria(OrderSearchCriteria criteria) { List entities = queryDslRepository.findByCriteria(criteria); return entities.stream() .map(orderJpaEntityMapper::toDomain) .toList(); } /** * 검색 조건으로 Order 개수 조회 * * @param criteria 검색 조건 * @return Order 개수 */ @Override public long countByCriteria(OrderSearchCriteria criteria) { return queryDslRepository.countByCriteria(criteria); } } ``` **핵심 규칙**: - **메서드 4개 고정**: `findById`, `existsById`, `findByCriteria`, `countByCriteria` - **QueryDslRepository 1:1 매핑**: 정확히 하나의 QueryDslRepository만 의존 - **필드 2개만**: Repository + Mapper - **Domain 반환**: Entity → Domain 변환 (DTO 반환 금지) - **JPAQueryFactory 직접 사용 금지**: QueryDslRepository에 위임 ### 3. AdminQueryAdapter (AdminQueryDslRepository 1:1) ```java package com.ryuqq.adapter.out.persistence.order.adapter; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import com.ryuqq.application.common.port.out.OrderAdminQueryPort; import com.ryuqq.adapter.out.persistence.order.repository.OrderAdminQueryDslRepository; import com.ryuqq.application.order.dto.AdminOrderListResponse; import com.ryuqq.application.order.dto.AdminOrderDetailResponse; import com.ryuqq.application.order.dto.AdminOrderSearchCriteria; import com.ryuqq.domain.order.vo.OrderId; @Component public class OrderAdminQueryAdapter implements OrderAdminQueryPort { private final OrderAdminQueryDslRepository adminQueryDslRepository; public OrderAdminQueryAdapter(OrderAdminQueryDslRepository adminQueryDslRepository) { this.adminQueryDslRepository = adminQueryDslRepository; } /** * 관리자 목록 조회 (Join 허용) * * @param criteria 검색 조건 * @return 관리자 목록 Response (DTO Projection) */ @Override public List findList(AdminOrderSearchCriteria criteria) { return adminQueryDslRepository.findList(criteria); } /** * 관리자 상세 조회 (Join 허용) * * @param id Order ID * @return 관리자 상세 Response (DTO Projection) */ @Override public Optional findDetail(OrderId id) { return adminQueryDslRepository.findDetail(id.value()); } /** * 관리자 페이징 조회 * * @param criteria 검색 조건 * @param pageable 페이징 정보 * @return 페이징된 목록 */ @Override public Page findPage( AdminOrderSearchCriteria criteria, Pageable pageable ) { return adminQueryDslRepository.findPage(criteria, pageable); } } ``` **핵심 규칙**: - **메서드 자유**: 고정된 메서드 개수 없음 - **필드 1~2개**: AdminQueryDslRepository + (선택적) ResponseMapper - **DTO Projection 직접 반환**: Domain이 아닌 DTO 반환 - **Join 허용**: AdminQueryDslRepository에서 Long FK 기반 Join ### 4. LockQueryAdapter (LockRepository 1:1) ```java package com.ryuqq.adapter.out.persistence.order.adapter; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Component; import com.ryuqq.application.common.port.out.OrderLockQueryPort; import com.ryuqq.adapter.out.persistence.order.repository.OrderLockRepository; import com.ryuqq.adapter.out.persistence.order.mapper.OrderJpaEntityMapper; import com.ryuqq.adapter.out.persistence.order.entity.OrderJpaEntity; import com.ryuqq.domain.order.aggregate.Order; import com.ryuqq.domain.order.vo.OrderId; import com.ryuqq.domain.order.vo.OrderSearchCriteria; @Component public class OrderLockQueryAdapter implements OrderLockQueryPort { private final OrderLockRepository lockRepository; private final OrderJpaEntityMapper orderJpaEntityMapper; public OrderLockQueryAdapter( OrderLockRepository lockRepository, OrderJpaEntityMapper orderJpaEntityMapper ) { this.lockRepository = lockRepository; this.orderJpaEntityMapper = orderJpaEntityMapper; } // ======================================================================== // Pessimistic Write Lock (FOR UPDATE) // ======================================================================== /** * ID로 Order 단건 조회 (FOR UPDATE) * * @param id Order ID * @return Order Domain (Optional) * @throws PessimisticLockException Lock 획득 실패 시 * @throws LockTimeoutException Lock 타임아웃 시 */ @Override public Optional findByIdForUpdate(OrderId id) { return lockRepository.findByIdForUpdate(id.value()) .map(orderJpaEntityMapper::toDomain); } /** * Criteria로 Order 목록 조회 (FOR UPDATE) * * @param criteria 검색 조건 * @return Order Domain 목록 * @throws PessimisticLockException Lock 획득 실패 시 * @throws LockTimeoutException Lock 타임아웃 시 */ @Override public List findByCriteriaForUpdate(OrderSearchCriteria criteria) { List entities = lockRepository.findByCriteriaForUpdate(criteria); return entities.stream() .map(orderJpaEntityMapper::toDomain) .toList(); } // ======================================================================== // Pessimistic Read Lock (FOR SHARE) // ======================================================================== /** * ID로 Order 단건 조회 (FOR SHARE) * * @param id Order ID * @return Order Domain (Optional) * @throws PessimisticLockException Lock 획득 실패 시 * @throws LockTimeoutException Lock 타임아웃 시 */ @Override public Optional findByIdForShare(OrderId id) { return lockRepository.findByIdForShare(id.value()) .map(orderJpaEntityMapper::toDomain); } /** * Criteria로 Order 목록 조회 (FOR SHARE) * * @param criteria 검색 조건 * @return Order Domain 목록 * @throws PessimisticLockException Lock 획득 실패 시 * @throws LockTimeoutException Lock 타임아웃 시 */ @Override public List findByCriteriaForShare(OrderSearchCriteria criteria) { List entities = lockRepository.findByCriteriaForShare(criteria); return entities.stream() .map(orderJpaEntityMapper::toDomain) .toList(); } // ======================================================================== // Optimistic Lock (@Version) // ======================================================================== /** * ID로 Order 단건 조회 (Optimistic Lock) * *

조회 시 Lock을 걸지 않으며, 업데이트 시 OptimisticLockException 발생 가능

* * @param id Order ID * @return Order Domain (Optional) */ @Override public Optional findByIdWithOptimisticLock(OrderId id) { return lockRepository.findByIdWithOptimisticLock(id.value()) .map(orderJpaEntityMapper::toDomain); } /** * Criteria로 Order 목록 조회 (Optimistic Lock) * *

조회 시 Lock을 걸지 않으며, 업데이트 시 OptimisticLockException 발생 가능

* * @param criteria 검색 조건 * @return Order Domain 목록 */ @Override public List findByCriteriaWithOptimisticLock(OrderSearchCriteria criteria) { List entities = lockRepository.findByCriteriaWithOptimisticLock(criteria); return entities.stream() .map(orderJpaEntityMapper::toDomain) .toList(); } } ``` **핵심 규칙**: - **메서드 6개 고정**: ForUpdate(2), ForShare(2), OptimisticLock(2) - **LockRepository 1:1 매핑**: 정확히 하나의 LockRepository만 의존 - **필드 2개만**: Repository + Mapper - **Domain 반환**: Entity → Domain 변환 - **예외 명시**: JavaDoc에 `@throws` 명시 (catch 하지 않음) - **예외 처리는 Application Layer**: Adapter는 위임만 --- ## N+1 해결 전략 ### Application Layer에서 해결 (1:1 매핑 원칙 유지) ``` ❌ Adapter에서 N+1 해결 (금지) ────────────────────────────── OrderQueryAdapter ├─ orderQueryDslRepository ├─ customerQueryDslRepository ← 금지! 1:1 위반 └─ mapper ✅ Application Layer에서 해결 (권장) ────────────────────────────── OrderQueryUseCase (Application Layer) ├─ orderQueryPort.findByCriteria() → 주문 목록 ├─ customerQueryPort.findByIds() → 고객 목록 (IN 절) └─ 조합 및 Response 생성 → Application에서 처리 ``` **N+1 해결 패턴**: ```java // Application Layer (UseCase) @Component public class OrderQueryUseCase { private final OrderQueryPort orderQueryPort; private final CustomerQueryPort customerQueryPort; @Transactional(readOnly = true) public List findOrdersWithCustomer( OrderSearchCriteria criteria) { // 1. 주문 조회 List orders = orderQueryPort.findByCriteria(criteria); // 2. 고객 ID 수집 Set customerIds = orders.stream() .map(Order::getCustomerId) .collect(Collectors.toSet()); // 3. 고객 일괄 조회 (IN 절로 N+1 해결) Map customerMap = customerQueryPort.findByIds(customerIds) .stream() .collect(Collectors.toMap(c -> c.getId().getValue(), c -> c)); // 4. 조합 return orders.stream() .map(order -> new OrderWithCustomerResponse( order, customerMap.get(order.getCustomerId()) )) .toList(); } } ``` --- ## Zero-Tolerance 규칙 ### ✅ MANDATORY (필수) | 규칙 | 설명 | |------|------| | `@Component` | 모든 Adapter에 적용 | | 1:1 매핑 | 각 Adapter는 정확히 하나의 Repository만 의존 | | 필드 2개만 | Repository + Mapper (AdminQueryAdapter는 Mapper 선택적) | | Domain 반환 | QueryAdapter, LockQueryAdapter는 Domain 반환 | | DTO 반환 | AdminQueryAdapter만 DTO Projection 반환 | | persist() | CommandAdapter 유일한 메서드 | | 4개 메서드 | QueryAdapter: findById, existsById, findByCriteria, countByCriteria | | 6개 메서드 | LockQueryAdapter: ForUpdate(2), ForShare(2), OptimisticLock(2) | ### ❌ PROHIBITED (금지) | 항목 | 이유 | |------|------| | `@Repository` | `@Component` 사용 | | `@Transactional` | Application Layer에서 관리 | | 비즈니스 로직 | Domain Layer 책임 | | 다른 Repository 주입 | 1:1 매핑 위반 | | JPAQueryFactory 직접 사용 | QueryDslRepository에 위임 | | update(), delete() 메서드 | persist()로 통합 (더티체킹 활용) | | Query 메서드 in CommandAdapter | CQRS 분리 | | Command 메서드 in QueryAdapter | CQRS 분리 | | Lombok | Plain Java 사용 | --- ## Adapter 유형별 비교 | 항목 | CommandAdapter | QueryAdapter | AdminQueryAdapter | LockQueryAdapter | |------|---------------|--------------|-------------------|------------------| | **Repository** | JpaRepository | QueryDslRepo | AdminQueryDslRepo | LockRepository | | **메서드 수** | 1개 (persist) | 4개 고정 | 자유 | 6개 고정 | | **필드 수** | 2개 | 2개 | 1~2개 | 2개 | | **Mapper** | 필수 | 필수 | 선택적 | 필수 | | **반환 타입** | *Id | Domain | DTO | Domain | | **Join** | N/A | ❌ 금지 | ✅ 허용 | N/A | --- ## 패키지 구조 ``` adapter-out/persistence-mysql/ └─ src/main/java/ └─ com/company/adapter/out/persistence/ └─ order/ ├─ entity/ │ └─ OrderJpaEntity.java │ ├─ repository/ │ ├─ OrderJpaRepository.java (JPA - Command) │ ├─ OrderQueryDslRepository.java (QueryDSL - 일반) │ ├─ OrderAdminQueryDslRepository.java (QueryDSL - 관리자) │ └─ OrderLockRepository.java (Lock) │ ├─ mapper/ │ └─ OrderJpaEntityMapper.java │ └─ adapter/ ├─ OrderCommandAdapter.java (JpaRepository 1:1) ├─ OrderQueryAdapter.java (QueryDslRepository 1:1) ├─ OrderAdminQueryAdapter.java (AdminQueryDslRepository 1:1) └─ OrderLockQueryAdapter.java (LockRepository 1:1) ``` --- ## 체크리스트 (Output Checklist) ### CommandAdapter - [ ] `@Component` 어노테이션 - [ ] `*PersistencePort` 구현 - [ ] JpaRepository 의존성 주입 - [ ] Mapper 의존성 주입 - [ ] **필드 2개만** - [ ] `persist()` 메서드만 (1개) - [ ] **반환 타입 `*Id`** - [ ] `@Transactional` 금지 - [ ] Query 메서드 없음 - [ ] 비즈니스 로직 없음 ### QueryAdapter - [ ] `@Component` 어노테이션 - [ ] `*QueryPort` 구현 - [ ] QueryDslRepository 의존성 주입 - [ ] Mapper 의존성 주입 - [ ] **필드 2개만** - [ ] `findById()` 메서드 - Optional - [ ] `existsById()` 메서드 - boolean - [ ] `findByCriteria()` 메서드 - List - [ ] `countByCriteria()` 메서드 - long - [ ] **메서드 4개만** - [ ] `@Transactional` 금지 - [ ] Command 메서드 없음 - [ ] JPAQueryFactory 직접 사용 금지 ### AdminQueryAdapter - [ ] `@Component` 어노테이션 - [ ] `*AdminQueryPort` 구현 - [ ] AdminQueryDslRepository 의존성 주입 - [ ] **필드 1~2개** (Mapper 선택적) - [ ] **DTO Projection 직접 반환** - [ ] `@Transactional` 금지 - [ ] 비즈니스 로직 없음 ### LockQueryAdapter - [ ] `@Component` 어노테이션 - [ ] `*LockQueryPort` 구현 - [ ] LockRepository 의존성 주입 - [ ] Mapper 의존성 주입 - [ ] **필드 2개만** - [ ] `findByIdForUpdate()` - FOR UPDATE 단건 - [ ] `findByCriteriaForUpdate()` - FOR UPDATE 목록 - [ ] `findByIdForShare()` - FOR SHARE 단건 - [ ] `findByCriteriaForShare()` - FOR SHARE 목록 - [ ] `findByIdWithOptimisticLock()` - Optimistic 단건 - [ ] `findByCriteriaWithOptimisticLock()` - Optimistic 목록 - [ ] **메서드 6개만** - [ ] JavaDoc `@throws` 명시 (예외 catch 금지) - [ ] `@Transactional` 금지 - [ ] Domain 반환 --- ## 테스트 체크리스트 ### Adapter 단위 테스트 - [ ] Repository 위임 검증 - [ ] Mapper 변환 검증 - [ ] 반환 타입 검증 ### ArchUnit 테스트 - [ ] `@Component` 어노테이션 검증 - [ ] `@Transactional` 금지 검증 - [ ] 필드 개수 검증 (2개) - [ ] 메서드 개수/이름 검증 - [ ] 1:1 매핑 검증 --- ## 참조 문서 - **Adapter Guide**: `docs/coding_convention/04-persistence-layer/mysql/adapter/adapter-guide.md` - **Command Adapter Guide**: `docs/coding_convention/04-persistence-layer/mysql/adapter/command/command-adapter-guide.md` - **Query Adapter Guide**: `docs/coding_convention/04-persistence-layer/mysql/adapter/query/general/query-adapter-guide.md` - **Admin Query Adapter Guide**: `docs/coding_convention/04-persistence-layer/mysql/adapter/query/admin/admin-query-adapter-guide.md` - **Lock Query Adapter Guide**: `docs/coding_convention/04-persistence-layer/mysql/adapter/query/lock/lock-query-adapter-guide.md` - **Command Adapter ArchUnit**: `docs/coding_convention/04-persistence-layer/mysql/adapter/command/command-adapter-archunit.md` - **Query Adapter ArchUnit**: `docs/coding_convention/04-persistence-layer/mysql/adapter/query/general/query-adapter-archunit.md`