B2B 파트너 연동 TDD

Background

근거 PRD: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/B2B 파트너 연동/PRD.md (검수 PASS) 선행 설계: /Users/biuea/Desktop/dpdpdndn/프로젝트/스포츠앱/도메인 경계 재설계/TDD.md + ADR-001~ADR-005

sports-application은 현재 B2C 사용자(모바일)·내부 운영자(web /portal·/admin)만 액터로 갖는다. goods.Product·ticketing.Eventowner_id는 User ID 네임스페이스를 그대로 쓰며(OwnershipGuardImpl.authUserId()UserPrincipal.id로 해석), 등록 API(GoodsSellerApiController·EventHostApiController)는 hasRole('GOODS_SELLER'/'EVENT_HOST')로 보호된다. 외부 협력사(Partner)가 이 등록 유즈케이스를 호출할 인증·권한·감사 체계가 없다.

이 과제는 신규 partner 도메인(지원 도메인)을 도입하되, 상품·티켓 등록 로직은 기존 CreateMyProductUseCase·CreateMyEventUseCase코드 변경 없이 경유한다. 경유의 실제 접점은 인증 계층이다 — 선행 ① [ADR-003](../도메인 경계 재설계/ADR/ADR-003 goods·ticketing 제공 인터페이스 = UseCase 경계.md)이 두 UseCase 시그니처를 계약으로 동결했다.

Overview

  • 무엇을: 신규 partner 도메인(Partner·PartnerApiKey·PartnerAuditLog Aggregate), API Key 기반 인증 필터, API Key 발급·재발급·폐기 라이프사이클, 파트너 활동 감사 로그를 도입한다.
  • : PRD의 “하루 1,000건 이상 B2B 등록 트래픽” 시나리오를 실제로 발생시킬 대상 API·인증·권한·감사가 없다.
  • 어떻게: Partner 인증 필터가 API Key를 검증하면 연동 전용 User 계정(로그인 불가, ROLE_GOODS_SELLER·ROLE_EVENT_HOST 보유)으로 SecurityContext를 채운다. 이후 기존 등록 엔드포인트(/api/goods-seller/products·/api/event-host/events)가 코드 변경 없이 그대로 동작한다. Partner 도메인은 코어(goods/ticketing)를 동기 호출하지 않는다 — 경유는 전적으로 인증 계층 pass-through로 실현된다(ADR-002 rule #4 준수).

Terminology

용어정의
Partner상품·티켓을 등록하는 외부 협력사(B2B). 신규 지원 도메인의 Aggregate Root
연동 전용 User 계정Partner 가입 시 생성하는 로그인 불가 User. owner_id 네임스페이스에 편입되어 소유자로 해석됨
API KeyPartner 인증 수단. partner_<keyId>_<random> 형식. 해시만 저장(평문은 발급 시 1회 반환)
인증 계층 pass-throughPartner 필터가 SecurityContext를 채우면 기존 컨트롤러가 코드 변경 없이 동작하는 경유 방식
제공 UseCase 경계goods=CreateMyProductUseCase, ticketing=CreateMyEventUseCase (ADR-003 동결)
PartnerAuditLogPartner 요청의 감사 기록. 필터 레벨에서 비동기로 적재

Define Problem

AS-IS

실제 코드(backend/src/main/kotlin/com/sportsapp/)를 읽어 확인한 현재 구조.

등록 UseCase 시그니처 (동결 대상, ADR-003):

  • CreateMyProductUseCase.execute(command: CreateMyProductCommand): ProductWithStock — 소유자를 ownershipGuard.authUserId()로 SecurityContext에서 해석(CreateMyProductUseCase.kt:18).
  • CreateMyEventUseCase.execute(command: CreateMyEventCommand): CreateMyEventResult — 소유자를 command.ownerUserId로 받음. EventHostApiController.createEventownershipGuard.authUserId()로 얻은 id를 request.toCommand(authUserId)로 채워 전달(EventHostApiController.kt).

소유권 검증: OwnershipGuardImpl(OwnershipGuardImpl.kt)

  • authUserId()SecurityContextHolder ... principal as? UserPrincipalid 반환, 미인증 시 UnauthorizedException(401).
  • requireOwned(ownerUserId, authUserId) — 불일치 시 ResourceNotFoundException(404, 존재 은닉). Partner 재사용 대상, 코드 변경 없음.

인증 필터 패턴 (파트너 필터의 원형): McpTokenAuthenticationFilter(McpTokenAuthenticationFilter.kt)

  • Authorization: Bearer mcp_<id>_<random>parseTokenId로 id 파싱 → findByIdpasswordEncoder.matches(plain, tokenHash)requireActive/requireNotExpiredSecurityContextHolderUsernamePasswordAuthenticationToken 주입 → 인증 성공 시 mcpTokenDomainService.recordUsage(id) 직접 호출.
  • mcp_ 접두사가 아니면 pass-through. SecurityConfigaddFilterBefore(mcpFilter, UsernamePasswordAuthenticationFilter) + JWT 필터도 그 앞에 등록(SecurityConfig.kt:47-52).

감사 로그 패턴: McpAuditLogAsyncRecorder(McpAuditLogAsyncRecorder.kt)

  • @Async("mcpAuditExecutor") record(...)RecordToolInvocationUseCase.execute 호출. 적재 실패는 요청을 실패시키지 않고 WARN 로그만(McpAuditLogAsyncRecorder.kt:53). McpAuditLog 엔티티(McpAuditLog.kt)는 별도 테이블 mcp_audit_logs.

연동 User 생성 수단(기존 재사용 가능): UserDomainService(UserDomainService.kt)

  • register(email, rawPassword): User — 유니크 이메일 검증 + USER role 부여.
  • assignRole(adminId, userId, roleName) — 지정 role 부여.
  • getRolesForUser(userId): List<Role> — 필터가 principal roles 로딩 시 사용.
  • user 도메인 코드 변경 불필요 — 위 3개 기존 메서드 조합으로 연동 계정 생성·role 부여·역할 조회 전부 가능.

Role enum: UserRoleNameGOODS_SELLER·EVENT_HOST 존재(UserRoleName.kt).

SecurityConfig 현황: /api/goods-seller/**·/api/event-host/**는 이미 authenticated()(SecurityConfig.kt:62-64) — Partner 필터가 principal을 주입하면 재사용 엔드포인트는 SecurityConfig 변경 없이 동작. 신규 /api/admin/partners/** matcher만 추가 필요.

마이그레이션 컨벤션: 레포는 V{정수}__{설명}.sql 순차 정수 방식(최신 V37). private-db-schema-convention의 타임스탬프 형식이 아님 — 신규는 V38~ 부터. (DBA 인계 필수 항목)

문제점:

  1. Partner 신원 타입·API Key 인증 필터·라이프사이클이 전무.
  2. Partner 활동 감사 로그 없음(mcp 패턴만 존재, 테이블 공유 불가 — 도메인 격리).
  3. B2B 트래픽을 발생시킬 대상 인증 경로가 없음.

TO-BE

  • 신규 partner 지원 도메인: Partner·PartnerApiKey·PartnerAuditLog Aggregate + 도메인 서비스 + Repository/Gateway interface.
  • PartnerApiKeyAuthenticationFilter(infrastructure/security): API Key → 연동 User principal 주입 → 기존 등록 엔드포인트 코드 무변경 경유.
  • 운영자용 관리 API(/api/admin/partners/**, hasRole('ADMIN')): Partner 등록·API Key 발급/재발급/폐기·상태 변경·감사 로그 조회.
  • 필터 레벨 비동기 감사 적재 → 별도 partner_audit_log 테이블(mcp 미공유).

Architecture Benchmarking (의무)

제품/사례해결 방식참고할 패턴미참고 사유
Stripe API Keys (stripe.com/docs/keys, Best practices for API keys)서버는 secret key의 해시/salted 저장, 평문은 발급 시 1회 노출. roll(재발급) 시 구 키 즉시 무효화, roll 후 만료 유예 옵션. prefix로 키 종류 식별① 해시만 저장·평문 1회 노출 채택(BCrypt PasswordEncoder 재사용). ② prefix(partner_)로 키 종류 식별해 필터 pass-through. ③ 재발급 시 구 키 즉시 REVOKED 채택만료 유예(grace period) 롤은 지금 규모(1000건/일)에 불필요 → 즉시 무효화만(단순함 우선)
GitHub Apps / PAT (docs.github.com — authenticating)외부 통합 주체를 별도 신원(App/Bot 계정) 으로 두고, 그 신원에 권한(scope/role) 부여. 리소스 소유는 그 신원 id로 귀속외부 통합 = 사람 User와 구분되는 별도 신원 개념 채택. 단, 본 과제는 신원을 연동 전용 User 계정에 매핑해 기존 owner_id·role 게이트를 재사용(PRD 확정)GitHub는 owner-type을 분리한 신원 테이블 — 본 과제는 owner-type 컬럼 미도입(기존 네임스페이스 유지)
쿠팡/카페24 Open API (PRD 벤치마킹)셀러가 API Key 발급 후 플랫폼 코어 상품 로직을 그대로 사용, 카테고리 등 필수값을 API 계약으로 강제파트너가 코어 등록 로직을 중복 구현 없이 그대로 경유하는 구조 채택 → 인증 계층 pass-through로 실현셀러 셀프서비스 발급 콘솔은 Non-Goals(전용 UI 없음) → 운영자 관리 API로 대체

Possible Solutions

방안 비교

방안설명왜 채택 / 미채택
A. 인증 계층 pass-through + 기존 엔드포인트 재사용Partner 필터가 연동 User principal을 주입하면 기존 /api/goods-seller/products·/api/event-host/events가 코드 변경 없이 동작채택 — PRD FR-4/FR-5 “코드 변경 없이 그대로 호출”의 가장 단순·정확한 실현. Partner 도메인이 코어를 동기 호출하지 않아 ADR-002 rule #4 준수. 신규 컨트롤러/UseCase 0개
B. 파트너 전용 등록 컨트롤러 신설 (/api/partner/productsCreateMyProductUseCase 호출)presentation/partner가 application/goods.UseCase를 오케스트레이션미채택 — 신규 컨트롤러 코드 추가(무변경 원칙 약화), presentation/partner→application/goods 교차 결합 신설. A 대비 이득 없음
C. 파트너 전용 등록 UseCase 신설Partner 도메인이 등록 로직을 자체 구현미채택 — 등록 로직 중복(PRD·ADR-003 명시 금지)
D. API Key를 owner-type 컬럼 + 신규 신원 테이블로Partner를 User와 별도 신원 네임스페이스로미채택owner_id·role 게이트·OwnershipGuardImpl 전부 재작성 유발(PRD가 연동 User 방식으로 확정)
E. 감사 로그를 AOP(@Around)로 등록 API에 부착컨트롤러 메서드를 AOP로 가로채 감사미채택 — 등록 API는 여러 컨트롤러에 분산, 파트너/비파트너 구분 로직 필요. 필터는 모든 파트너 요청의 단일 통과점 → 100% 커버리지·구분 불필요. mcp도 AOP 회피(CGLIB 우회 이슈)
F. 감사 로그를 mcp_audit_logs 공유기존 테이블 재사용미채택 — 도메인 격리 원칙(ADR-001) 위반. PRD FR-8 별도 PartnerAuditLog 명시

단순함 우선 결론: A(인증 pass-through 재사용) + 필터 레벨 비동기 감사(별도 테이블). 신규 등록 컨트롤러·UseCase 0개, user/goods/ticketing 도메인 코드 변경 0건.

Detail Design

도메인 바운디드 컨텍스트 경계 판단 (신규 vs 합류)

결정: 신규 partner 도메인을 지원(Supporting) 분류로 분리한다.

후보판단근거
기존 user에 합류미채택User는 사람 신원·인증의 기반 코어. Partner는 외부 조직 신원 + 독립 자격증명(API Key) 라이프사이클 → 변경 주기·데이터 소유가 다름. user에 넣으면 코어 오염
기존 mcp에 합류미채택mcp는 AI 에이전트 토큰·이상탐지 서브시스템. 의미·소비자·라이프사이클이 무관. 테이블/코드 공유는 우연적 유사성일 뿐
신규 partner 도메인 (지원)채택독립 라이프사이클(발급·재발급·폐기), 독립 데이터 소유(Partner·PartnerApiKey·PartnerAuditLog), 코어와 다른 변경 주기. ADR-001의 “무조건 분리” 3요건 충족. 코어를 동기 호출하지 않고 인증 계층에서만 접점 → ADR-002 지원 도메인 규칙 준수

컨텍스트 맵 갱신(선행 ① TDD FR-8 절차): 분류=지원, 관계=Partner(Customer, Conformist) → goods/ticketing(Supplier, 인증 pass-through), user(연동 계정 ID 참조·Long), 도메인 간 참조는 linkedUserId: Long만.

서버 토폴로지 설계

후보 서버 형태이 과제 적합성판단
API 서버 (요청-응답)등록·발급·폐기·조회 전부 동기 요청-응답채택 — 단일 API 서버
워커 (비동기 큐 소비)감사 로그 적재만 비동기별도 워커 서버 불필요 — 프로세스 내 @Async 스레드풀로 충분(1000건/일 ≈ 0.01 rps)
스케줄러 (주기 실행)만료 키 정리 등현재 불필요 — 재발급이 즉시 무효화, 만료 정책 없음(Non-Goals)
소켓 서버 (실시간 양방향)해당 없음미채택

결론: 기존 단일 모놀리스 API 서버에 배치. 감사 적재만 @Async 전용 스레드풀(partnerAuditExecutor)로 분리 — 등록 요청의 P95(300ms)에 감사 I/O가 끼어들지 않게 한다. 별도 물리 서버 분리는 지금 규모에 과함(단순함 우선).

시스템 역할 경계 (의무)

단위역할소유 데이터/책임노출 인터페이스의존
Partner (Entity, domain)협력사 신원·상태id, name, status(ACTIVE/SUSPENDED), linkedUserIdsuspend()·activate()·validateActive()common
PartnerApiKey (Entity, domain)인증 키 라이프사이클id, partnerId, keyHash, status(ACTIVE/REVOKED), revokedAt, lastUsedAtrevoke()·recordUsage()·isActive()common
PartnerAuditLog (Entity, domain)파트너 활동 감사 기록partnerId, userId, method, path, statusCode, latencyMs, calledAt 등불변 기록(팩토리 of)common
PartnerDomainService (domain)Partner 생성·키 발급/재발급/폐기·상태 전이 오케스트레이션위 3 Repository·ApiKeyGenerator 조율createPartner / issueKey / reissueKey / revokeKey / changeStatus / findByKeyIdPartnerRepository·PartnerApiKeyRepository·ApiKeyGenerator
PartnerAuditLogDomainService (domain)감사 기록 저장·조회PartnerAuditLogRepository 조율record / listByPartnerAuditLogRepository(+Custom)
PartnerApiKeyAuthenticationFilter (infra/security)API Key 검증 → 연동 User principal 주입 → 파트너 요청 단일 통과점(감사 트리거)요청당 인증·감사Servlet Filter (SecurityConfig 등록)PartnerApiKeyRepository·PartnerDomainService·UserDomainService·PartnerActivityRecorder
PartnerActivityRecorder (interface, domain) / AsyncPartnerActivityRecorder (infra)감사 비동기 적재(실패 무시·WARN)@Async("partnerAuditExecutor")record(…)PartnerAuditLogDomainService
CreatePartnerUseCase 외 (application)관리 API 오케스트레이션연동 User 생성+role 부여+Partner 생성+최초 키 발급execute(command)PartnerDomainService·UserDomainService
PartnerAdminApiController (presentation)운영자 관리 API 라우팅/api/admin/partners/**REST 계약(아래)위 UseCase들
goods/ticketing UseCase (기존)등록 로직(변경 없음)ADR-003 동결 시그니처(변경 없음)

인터페이스 시그니처 (구현자 간 해석 차이 제거)

// domain/partner/repository/PartnerRepository.kt
interface PartnerRepository {
    fun save(partner: Partner): Partner
    fun findById(partnerId: Long): Partner?
    fun findByLinkedUserId(linkedUserId: Long): Partner?
}

// domain/partner/repository/PartnerApiKeyRepository.kt
interface PartnerApiKeyRepository {
    fun save(apiKey: PartnerApiKey): PartnerApiKey
    fun findById(keyId: Long): PartnerApiKey?
    fun findActiveByPartnerId(partnerId: Long): PartnerApiKey?   // 재발급 시 구 키 조회
}

// domain/partner/gateway/ApiKeyGenerator.kt  (외부 시스템 아님 — 랜덤/해시 생성 유틸 계약)
interface ApiKeyGenerator {
    fun generateRandomPart(): String            // 256-bit URL-safe
    fun hash(plainKey: String): String          // BCrypt (PasswordEncoder 위임)
    fun matches(plainKey: String, keyHash: String): Boolean
}

// domain/partner/repository/PartnerAuditLogRepository.kt  (+ Custom for QueryDSL 조회)
interface PartnerAuditLogRepository {
    fun save(auditLog: PartnerAuditLog): PartnerAuditLog
}
interface PartnerAuditLogCustomRepository {
    fun findBy(partnerId: Long, from: ZonedDateTime, to: ZonedDateTime, pageable: Pageable): Page<PartnerAuditLog>
}

// domain/partner/gateway/PartnerActivityRecorder.kt
interface PartnerActivityRecorder {
    fun record(partnerId: Long, userId: Long, method: String, path: String,
               statusCode: Int, latencyMs: Int, ipAddr: String?, userAgent: String?, calledAt: ZonedDateTime)
}

// domain/partner/service/PartnerDomainService.kt  (반환에 평문 키 포함 — 발급 1회 노출)
data class IssuedApiKey(val plainKey: String, val apiKey: PartnerApiKey)
fun createPartner(name: String, linkedUserId: Long): Pair<Partner, IssuedApiKey>
fun reissueKey(partnerId: Long): IssuedApiKey     // 구 ACTIVE 키 즉시 revoke + 신규 발급
fun revokeKey(partnerId: Long, keyId: Long)
fun changeStatus(partnerId: Long, active: Boolean)
fun authenticate(keyId: Long, plainKey: String): AuthenticatedPartner  // 필터용: 키·파트너 검증 후 (partnerId, linkedUserId) 반환, 실패 시 도메인 예외

API 계약 (Partner용 엔드포인트 — 후속 참조용)

A. Partner가 사용하는 등록/조회 엔드포인트 (기존 재사용, 코드 무변경). 인증: Authorization: Bearer partner_<keyId>_<random>

메서드경로용도경유 UseCase인증/인가
POST/api/goods-seller/products상품 등록CreateMyProductUseCaseAPI Key → ROLE_GOODS_SELLER
GET/api/goods-seller/products등록 이력(신규 파트너 빈 목록)ListMyProductsUseCase동일
PATCH/PUT/api/goods-seller/products/{id}수정UpdateMyProductUseCase동일 + OwnershipGuard.requireOwned(타 파트너 404)
POST/api/event-host/events이벤트(티켓) 등록CreateMyEventUseCaseAPI Key → ROLE_EVENT_HOST
GET/api/event-host/events이벤트 이력ListMyEventsUseCase동일

B. 운영자 관리 엔드포인트 (신규, hasRole('ADMIN')). Open Question 해소: Partner 전용 UI 없음(Non-Goals) → 백엔드 관리 API로 제공, 기존 web/portal 화면 확장은 별도 과제.

메서드경로요청응답비고
POST/api/admin/partners{ name }201 { partnerId, name, status, plainApiKey }연동 User 생성+role 부여+Partner 생성+최초 키. plainApiKey는 1회 노출
POST/api/admin/partners/{partnerId}/api-keys{}201 { keyId, plainApiKey }재발급 — 구 ACTIVE 키 즉시 무효화
DELETE/api/admin/partners/{partnerId}/api-keys/{keyId}204폐기
PATCH/api/admin/partners/{partnerId}/status{ status: "ACTIVE"|"SUSPENDED" }200 { partnerId, status }SUSPEND/ACTIVATE
GET/api/admin/partners/{partnerId}/audit-logs?from&to&page&size200 Page<PartnerAuditLogResponse>감사 조회(빈 목록 가능)

실패 경로·동시성·멱등 (시니어 관점 1급 시민)

관심사설계
잘못된 요청(무효 카테고리 등)기존 @Valid·enum 역직렬화가 400 반환(코드 무변경). 파트너도 동일
미인증/무효 키필터: 키 없음·비파트너 prefix → pass-through(이후 authenticated()가 401). id 파싱 실패·matches 불일치·키 REVOKED → 401
SUSPENDED 파트너키는 유효하나 파트너 상태 SUSPENDED → 403(신원은 확인됨, 접근만 거부). FR-9
타 파트너 리소스 접근기존 OwnershipGuard.requireOwned404(존재 은닉). 코드 무변경, FR-7
감사 적재 실패@Async + try/catch → 요청 실패시키지 않고 WARN 로그(mcp 패턴). 등록 트랜잭션과 분리
재발급 동시성reissueKey: 구 ACTIVE 키 조회→revoke→신규 save를 UseCase @Transactional로 묶음. 파트너당 관리 호출은 저빈도 → 낙관적 충돌 시 재시도 안내(현 규모 락 불필요)
키 폐기 즉시 반영필터가 요청마다 DB status 확인(캐시 없음) → 폐기 후 즉시(<1분 NFR 충족) 차단. 캐시 도입 시 TTL≤60s
멱등등록은 코어 UseCase 정책을 그대로 따름(장바구니 병합 등 기존 동작). 관리 API 재발급은 매 호출 신규 키 생성(비멱등, 의도적)

상태 전이 표

Partner.status

현재 × 이벤트다음거부
ACTIVE × suspendSUSPENDED
SUSPENDED × activateACTIVE
ACTIVE × activate(무변경)이미 ACTIVE
SUSPENDED × 등록요청403 거부(FR-9)

PartnerApiKey.status

현재 × 이벤트다음거부
ACTIVE × revokeREVOKED
ACTIVE × 재발급(타 키 발급)REVOKED(구 키) + 신규 ACTIVE
REVOKED × 인증시도401 거부
REVOKED × revoke(무변경)이미 REVOKED

Component Diagram (Mermaid flowchart LR)

flowchart LR
    Client["Partner Client"]
    subgraph Infra["Infrastructure/Security"]
        Filter["PartnerApiKeyAuthenticationFilter"]
    end
    subgraph Presentation
        Reused["기존 GoodsSeller / EventHost Controller"]
        Admin["PartnerAdminApiController"]
    end
    subgraph Application
        CreateUC["Create/Reissue/Revoke UseCase"]
        CoreUC["CreateMyProduct / CreateMyEvent (기존)"]
    end
    subgraph Domain["Domain/partner"]
        PDS["PartnerDomainService"]
        Recorder["PartnerActivityRecorder"]
    end
    Client --> Filter
    Filter --> Reused
    Filter --> Recorder
    Reused --> CoreUC
    Client --> Admin
    Admin --> CreateUC
    CreateUC --> PDS

Sequence Diagram — 상품 등록 경유 (동기 + 비동기 감사)

sequenceDiagram
    participant C as Partner Client
    participant F as PartnerApiKeyFilter
    participant Ctrl as GoodsSellerController(기존)
    participant UC as CreateMyProductUseCase(기존)
    participant R as AsyncActivityRecorder
    C->>F: POST /api/goods-seller/products (Bearer partner_..)
    F->>F: parse keyId, matches(hash), partner ACTIVE?
    F->>F: inject SecurityContext(연동 User principal)
    F->>Ctrl: doFilter (코드 무변경)
    Ctrl->>UC: execute(command)
    UC-->>Ctrl: ProductWithStock (owner=연동 User)
    Ctrl-->>F: 201
    F->>R: record(partnerId,.., 201, latency)  (@Async)
    F-->>C: 201

ERD

DDL 전문은 senior-dba가 후속 설계(V38~ 순차 정수 마이그레이션). 아래는 도메인 모델·필요 컬럼·인덱스 요구.

erDiagram
    PARTNER ||--o{ PARTNER_API_KEY : issues
    PARTNER ||--o{ PARTNER_AUDIT_LOG : records
    USER ||--|| PARTNER : "linked (id only)"
    PARTNER {
        bigint id PK
        varchar name "NOT NULL"
        varchar status "ACTIVE|SUSPENDED, NOT NULL"
        bigint linked_user_id "User ID 네임스페이스, NOT NULL, INDEX"
        datetime6 created_at
        datetime6 updated_at
    }
    PARTNER_API_KEY {
        bigint id PK "partner_<id>_<random>의 id"
        bigint partner_id "NOT NULL, INDEX"
        varchar key_hash "BCrypt, NOT NULL, UNIQUE"
        varchar status "ACTIVE|REVOKED, NOT NULL"
        datetime6 revoked_at "NULL"
        datetime6 last_used_at "NULL"
        datetime6 created_at
    }
    PARTNER_AUDIT_LOG {
        bigint id PK
        bigint partner_id "NOT NULL"
        bigint user_id "연동 User, NOT NULL"
        varchar http_method "NOT NULL"
        varchar request_path "NOT NULL"
        varchar target_resource "NULL"
        int status_code "NOT NULL"
        int latency_ms "NOT NULL"
        varchar ip_addr "len 45, NULL"
        varchar client_user_agent "len 500, NULL"
        datetime6 called_at "NOT NULL"
        datetime6 created_at
    }

DBA 인계 — 인덱스 요구(쿼리 근거 병기):

  • partner_api_key(partner_id, status) — 재발급 시 findActiveByPartnerId (partner_id + status=ACTIVE 조회).
  • partner_api_key.key_hash UNIQUE — 해시 유일성 보장(조회는 PK id로, 스캔 아님).
  • partner.linked_user_id INDEX — 역조회 대비(현재 필수 쿼리는 없으나 운영 조회 대비, 미사용이면 DBA 판단으로 보류 가능).
  • partner_audit_log(partner_id, called_at) — 감사 조회 findBy(partnerId, from~to, paged)의 범위 스캔 + 정렬.
  • 컨벤션: FK 금지(일반 컬럼), ENUM 금지(VARCHAR), BOOLEAN 금지(status는 VARCHAR), DATETIME(6), 전 컬럼 COMMENT. 파일명 순차 정수 V38__create_partner.sql 등(레포 관례, 타임스탬프 아님).

Testing Plan

레벨대상범위
domainPartner·PartnerApiKey 엔티티, PartnerDomainService상태 전이(suspend/activate/revoke), 재발급 시 구 키 revoke, 키 발급 해시, authenticate 실패 케이스 (Kotest BehaviorSpec + MockK)
applicationCreatePartner·Reissue·Revoke·ChangeStatus·ListAuditLogs UseCaseDomainService 모킹, 연동 User 생성+role 부여 오케스트레이션, execute ≤10줄
infrastructurePartner*RepositoryImpl, AsyncPartnerActivityRecorder, ApiKeyGeneratorImplTestContainers(MySQL). 저장·조회, 감사 적재 실패 무시
presentationPartnerAdminApiController, PartnerApiKeyAuthenticationFilterMockMvc + TestContainers. 401/403/404 분기, principal 주입 후 기존 엔드포인트 통과
scenarioE2E파트너 등록 → 키 발급 → 키로 /api/goods-seller/products 등록 → owner=연동 User 확인 → 타 파트너 404 → 폐기 키 401 → SUSPENDED 403 → 감사 로그 적재 확인

핵심 실패 경로 시나리오:

  • 폐기된 키로 등록 요청 → 401, 등록 안 됨.
  • SUSPENDED 파트너 키로 등록 → 403.
  • 파트너 A가 B 상품 수정 → 404(기존 OwnershipGuard).
  • 감사 DB 순단 → 등록은 201 성공, 감사만 WARN(요청 비실패).
  • 재발급 후 구 키 요청 → 401, 신규 키 요청 → 201.

Observability (신규 기능·상태 전이·인증 진입점 → 의무)

  • 지표(PRD Operations 연계): partnerId별 등록 요청 수·성공/실패율(status_code 집계), API Key 인증 실패 수(401/403), 감사 적재 실패 수(WARN 카운트). 소스 태그 partner.
  • 알람: partnerId별 등록 실패율 급증 → 지능형 장애 알림 채널(PRD Operations). 감사 적재 실패율 > 0 지속 → 커버리지 위험 경보.
  • 커버리지 측정(Success Metric): 등록 API 응답 건수(access log) vs partner_audit_log 적재 건수 대조 → 누락 0건. 필터가 모든 파트너 요청의 단일 통과점이므로 구조적으로 100% 커버.

Release Scenario — 무중단 배포 (의무)

전부 additive(신규 테이블·신규 코드·신규 필터). 기존 트래픽(B2C JWT·mcp)은 필터가 partner_ prefix가 아니면 pass-through하므로 무영향.

  • 배포 순서 (expand-contract, 스키마 먼저):
    1. DB expandV38 partner / V39 partner_api_key / V40 partner_audit_log 신규 테이블 추가(기존 무영향).
    2. 코드 배포 (필터 플래그 OFF)partner.auth.enabled=false. PartnerApiKeyAuthenticationFilter 빈은 로드되나 SecurityConfig 등록을 플래그로 게이트 → 휴면. 관리 API(/api/admin/partners/**)는 배포되나 파트너 인증 경로 미개통.
    3. 플래그 ONpartner.auth.enabled=true → 필터 활성. 운영자가 첫 Partner 등록·키 발급 후 실트래픽 개시.
  • 전환 조건: 2단계에서 관리 API로 테스트 파트너 등록·키 발급이 정상, 기존 B2C 회귀 0건 확인 → 3단계.
  • 롤백 (단계별):
    • 3→2: partner.auth.enabled=false (플래그 OFF) → 필터 휴면, 파트너 요청은 401(키 인증 경로 없음). B2C/JWT/mcp 무영향.
    • 2→1: 코드 롤백(관리 API 제거). 테이블은 잔존(무해).
    • 1: 테이블 역방향 DDL(DROP TABLE partner_audit_log, partner_api_key, partner 역순) — 데이터 없을 때만.
  • 필터는 prefix 미매칭 시 즉시 pass-through라 플래그 OFF가 아니어도 기존 트래픽 안전 — 플래그는 belt-and-suspenders.

Open Questions

  • (PRD Open Q) 운영자 Partner 등록 경로 → 백엔드 관리 API(/api/admin/partners/**, ADMIN)로 확정. web/portal 최소 화면 추가는 별도 FE 과제로 분리(본 과제 Non-Goals 유지).
  • 연동 User의 로그인 차단 방식 → 랜덤 256-bit 비밀번호(미노출) 로 실질 로그인 불가 실현. UserStatus에 신규 상태 추가는 user 코어 변경이라 미채택. 향후 명시적 차단이 필요하면 별도 과제.
  • 인증 성능 캐시 → 현재 미도입(요청마다 DB 확인, 폐기 즉시 반영). 트래픽 증가 시 TTL≤60s 캐시 검토.

Document History

날짜변경 내용
2026-07-03최초 작성 — AS-IS 실측(두 UseCase 시그니처·mcp 필터/감사 패턴·UserDomainService 재사용), 신규 partner 지원 도메인 분리 판단, 인증 pass-through 재사용 방안, API 계약(재사용+관리), 실패 경로·상태 전이, 필터 레벨 비동기 감사, 무중단 배포(플래그+expand-contract)