Claude-skill-registry adapter-expert
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/adapter-expert" ~/.claude/skills/majiayu000-claude-skill-registry-adapter-expert && rm -rf "$T"
manifest:
skills/data/adapter-expert/SKILL.mdsource content
Adapter Expert (Adapter 전문가)
목적 (Purpose)
Persistence Layer에서 Port-Out 인터페이스를 구현하는 Adapter를 규칙에 맞게 생성합니다. CQRS 원칙에 따라 Command/Query를 완전 분리하고, Repository ↔ Adapter 1:1 매핑 원칙을 준수합니다.
활성화 조건
명령 실행 시/impl persistence {feature}
실행 후 Persistence Layer 작업 시/plan- adapter, port-out, command adapter, query adapter 키워드 언급 시
산출물 (Output)
| 컴포넌트 | 파일명 패턴 | 위치 | Repository |
|---|---|---|---|
| CommandAdapter | | | JpaRepository |
| QueryAdapter | | | QueryDslRepository |
| AdminQueryAdapter | | | AdminQueryDslRepository |
| LockQueryAdapter | | | LockRepository |
완료 기준 (Acceptance Criteria)
- CQRS 분리: Command/Query Adapter 완전 분리
- 1:1 매핑: 각 Adapter는 하나의 Repository만 의존
- 필드 2개만: Repository + Mapper (AdminQueryAdapter는 Mapper 선택적)
-
어노테이션 사용 (@Component
아님!)@Repository -
절대 금지 (Application Layer 책임)@Transactional - 비즈니스 로직 없음 (단순 위임 + 변환만)
- 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)
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 저장 (신규 생성 또는 수정) * * <p>신규 생성 (ID 없음) → JPA가 ID 자동 할당 (INSERT)</p> * <p>기존 수정 (ID 있음) → 더티체킹으로 자동 UPDATE</p> * * @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개:
만 제공 (update, delete 메서드 금지)persist() - JpaRepository 1:1 매핑: 정확히 하나의 JpaRepository만 의존
- 필드 2개만: Repository + Mapper
- 반환 타입:
(OrderId, ProductId 등)*Id - @Transactional 금지: Application Layer에서 관리
2. QueryAdapter (QueryDslRepository 1:1)
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<Order> 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<Order> findByCriteria(OrderSearchCriteria criteria) { List<OrderJpaEntity> 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
,findByCriteriacountByCriteria - QueryDslRepository 1:1 매핑: 정확히 하나의 QueryDslRepository만 의존
- 필드 2개만: Repository + Mapper
- Domain 반환: Entity → Domain 변환 (DTO 반환 금지)
- JPAQueryFactory 직접 사용 금지: QueryDslRepository에 위임
3. AdminQueryAdapter (AdminQueryDslRepository 1:1)
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<AdminOrderListResponse> findList(AdminOrderSearchCriteria criteria) { return adminQueryDslRepository.findList(criteria); } /** * 관리자 상세 조회 (Join 허용) * * @param id Order ID * @return 관리자 상세 Response (DTO Projection) */ @Override public Optional<AdminOrderDetailResponse> findDetail(OrderId id) { return adminQueryDslRepository.findDetail(id.value()); } /** * 관리자 페이징 조회 * * @param criteria 검색 조건 * @param pageable 페이징 정보 * @return 페이징된 목록 */ @Override public Page<AdminOrderListResponse> 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)
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<Order> 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<Order> findByCriteriaForUpdate(OrderSearchCriteria criteria) { List<OrderJpaEntity> 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<Order> 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<Order> findByCriteriaForShare(OrderSearchCriteria criteria) { List<OrderJpaEntity> entities = lockRepository.findByCriteriaForShare(criteria); return entities.stream() .map(orderJpaEntityMapper::toDomain) .toList(); } // ======================================================================== // Optimistic Lock (@Version) // ======================================================================== /** * ID로 Order 단건 조회 (Optimistic Lock) * * <p>조회 시 Lock을 걸지 않으며, 업데이트 시 OptimisticLockException 발생 가능</p> * * @param id Order ID * @return Order Domain (Optional) */ @Override public Optional<Order> findByIdWithOptimisticLock(OrderId id) { return lockRepository.findByIdWithOptimisticLock(id.value()) .map(orderJpaEntityMapper::toDomain); } /** * Criteria로 Order 목록 조회 (Optimistic Lock) * * <p>조회 시 Lock을 걸지 않으며, 업데이트 시 OptimisticLockException 발생 가능</p> * * @param criteria 검색 조건 * @return Order Domain 목록 */ @Override public List<Order> findByCriteriaWithOptimisticLock(OrderSearchCriteria criteria) { List<OrderJpaEntity> 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에
명시 (catch 하지 않음)@throws - 예외 처리는 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 해결 패턴:
// Application Layer (UseCase) @Component public class OrderQueryUseCase { private final OrderQueryPort orderQueryPort; private final CustomerQueryPort customerQueryPort; @Transactional(readOnly = true) public List<OrderWithCustomerResponse> findOrdersWithCustomer( OrderSearchCriteria criteria) { // 1. 주문 조회 List<Order> orders = orderQueryPort.findByCriteria(criteria); // 2. 고객 ID 수집 Set<Long> customerIds = orders.stream() .map(Order::getCustomerId) .collect(Collectors.toSet()); // 3. 고객 일괄 조회 (IN 절로 N+1 해결) Map<Long, Customer> 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 (필수)
| 규칙 | 설명 |
|---|---|
| 모든 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 (금지)
| 항목 | 이유 |
|---|---|
| 사용 |
| 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개만
-
메서드만 (1개)persist() - 반환 타입
*Id -
금지@Transactional - Query 메서드 없음
- 비즈니스 로직 없음
QueryAdapter
-
어노테이션@Component -
구현*QueryPort - QueryDslRepository 의존성 주입
- Mapper 의존성 주입
- 필드 2개만
-
메서드 - Optional<Domain>findById() -
메서드 - booleanexistsById() -
메서드 - List<Domain>findByCriteria() -
메서드 - longcountByCriteria() - 메서드 4개만
-
금지@Transactional - Command 메서드 없음
- JPAQueryFactory 직접 사용 금지
AdminQueryAdapter
-
어노테이션@Component -
구현*AdminQueryPort - AdminQueryDslRepository 의존성 주입
- 필드 1~2개 (Mapper 선택적)
- DTO Projection 직접 반환
-
금지@Transactional - 비즈니스 로직 없음
LockQueryAdapter
-
어노테이션@Component -
구현*LockQueryPort - LockRepository 의존성 주입
- Mapper 의존성 주입
- 필드 2개만
-
- FOR UPDATE 단건findByIdForUpdate() -
- FOR UPDATE 목록findByCriteriaForUpdate() -
- FOR SHARE 단건findByIdForShare() -
- FOR SHARE 목록findByCriteriaForShare() -
- Optimistic 단건findByIdWithOptimisticLock() -
- Optimistic 목록findByCriteriaWithOptimisticLock() - 메서드 6개만
- JavaDoc
명시 (예외 catch 금지)@throws -
금지@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