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.Event의 owner_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 Key | Partner 인증 수단. partner_<keyId>_<random> 형식. 해시만 저장(평문은 발급 시 1회 반환) |
| 인증 계층 pass-through | Partner 필터가 SecurityContext를 채우면 기존 컨트롤러가 코드 변경 없이 동작하는 경유 방식 |
| 제공 UseCase 경계 | goods=CreateMyProductUseCase, ticketing=CreateMyEventUseCase (ADR-003 동결) |
| PartnerAuditLog | Partner 요청의 감사 기록. 필터 레벨에서 비동기로 적재 |
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.createEvent가ownershipGuard.authUserId()로 얻은 id를request.toCommand(authUserId)로 채워 전달(EventHostApiController.kt).
소유권 검증: OwnershipGuardImpl(OwnershipGuardImpl.kt)
authUserId()—SecurityContextHolder ... principal as? UserPrincipal의id반환, 미인증 시UnauthorizedException(401).requireOwned(ownerUserId, authUserId)— 불일치 시ResourceNotFoundException(404, 존재 은닉). Partner 재사용 대상, 코드 변경 없음.
인증 필터 패턴 (파트너 필터의 원형): McpTokenAuthenticationFilter(McpTokenAuthenticationFilter.kt)
Authorization: Bearer mcp_<id>_<random>→parseTokenId로 id 파싱 →findById→passwordEncoder.matches(plain, tokenHash)→requireActive/requireNotExpired→SecurityContextHolder에UsernamePasswordAuthenticationToken주입 → 인증 성공 시mcpTokenDomainService.recordUsage(id)직접 호출.mcp_접두사가 아니면 pass-through.SecurityConfig가addFilterBefore(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— 유니크 이메일 검증 +USERrole 부여.assignRole(adminId, userId, roleName)— 지정 role 부여.getRolesForUser(userId): List<Role>— 필터가 principal roles 로딩 시 사용.- user 도메인 코드 변경 불필요 — 위 3개 기존 메서드 조합으로 연동 계정 생성·role 부여·역할 조회 전부 가능.
Role enum: UserRoleName에 GOODS_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 인계 필수 항목)
문제점:
- Partner 신원 타입·API Key 인증 필터·라이프사이클이 전무.
- Partner 활동 감사 로그 없음(
mcp패턴만 존재, 테이블 공유 불가 — 도메인 격리). - 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/products가 CreateMyProductUseCase 호출) | 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), linkedUserId | suspend()·activate()·validateActive() | common |
PartnerApiKey (Entity, domain) | 인증 키 라이프사이클 | id, partnerId, keyHash, status(ACTIVE/REVOKED), revokedAt, lastUsedAt | revoke()·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 / findByKeyId | PartnerRepository·PartnerApiKeyRepository·ApiKeyGenerator |
PartnerAuditLogDomainService (domain) | 감사 기록 저장·조회 | PartnerAuditLogRepository 조율 | record / listBy | PartnerAuditLogRepository(+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 | 상품 등록 | CreateMyProductUseCase | API 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 | 이벤트(티켓) 등록 | CreateMyEventUseCase | API 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&size | — | 200 Page<PartnerAuditLogResponse> | 감사 조회(빈 목록 가능) |
실패 경로·동시성·멱등 (시니어 관점 1급 시민)
| 관심사 | 설계 |
|---|---|
| 잘못된 요청(무효 카테고리 등) | 기존 @Valid·enum 역직렬화가 400 반환(코드 무변경). 파트너도 동일 |
| 미인증/무효 키 | 필터: 키 없음·비파트너 prefix → pass-through(이후 authenticated()가 401). id 파싱 실패·matches 불일치·키 REVOKED → 401 |
| SUSPENDED 파트너 | 키는 유효하나 파트너 상태 SUSPENDED → 403(신원은 확인됨, 접근만 거부). FR-9 |
| 타 파트너 리소스 접근 | 기존 OwnershipGuard.requireOwned → 404(존재 은닉). 코드 무변경, FR-7 |
| 감사 적재 실패 | @Async + try/catch → 요청 실패시키지 않고 WARN 로그(mcp 패턴). 등록 트랜잭션과 분리 |
| 재발급 동시성 | reissueKey: 구 ACTIVE 키 조회→revoke→신규 save를 UseCase @Transactional로 묶음. 파트너당 관리 호출은 저빈도 → 낙관적 충돌 시 재시도 안내(현 규모 락 불필요) |
| 키 폐기 즉시 반영 | 필터가 요청마다 DB status 확인(캐시 없음) → 폐기 후 즉시(<1분 NFR 충족) 차단. 캐시 도입 시 TTL≤60s |
| 멱등 | 등록은 코어 UseCase 정책을 그대로 따름(장바구니 병합 등 기존 동작). 관리 API 재발급은 매 호출 신규 키 생성(비멱등, 의도적) |
상태 전이 표
Partner.status
| 현재 × 이벤트 | 다음 | 거부 |
|---|---|---|
| ACTIVE × suspend | SUSPENDED | — |
| SUSPENDED × activate | ACTIVE | — |
| ACTIVE × activate | (무변경) | 이미 ACTIVE |
| SUSPENDED × 등록요청 | — | 403 거부(FR-9) |
PartnerApiKey.status
| 현재 × 이벤트 | 다음 | 거부 |
|---|---|---|
| ACTIVE × revoke | REVOKED | — |
| 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_hashUNIQUE — 해시 유일성 보장(조회는 PK id로, 스캔 아님).partner.linked_user_idINDEX — 역조회 대비(현재 필수 쿼리는 없으나 운영 조회 대비, 미사용이면 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
| 레벨 | 대상 | 범위 |
|---|---|---|
| domain | Partner·PartnerApiKey 엔티티, PartnerDomainService | 상태 전이(suspend/activate/revoke), 재발급 시 구 키 revoke, 키 발급 해시, authenticate 실패 케이스 (Kotest BehaviorSpec + MockK) |
| application | CreatePartner·Reissue·Revoke·ChangeStatus·ListAuditLogs UseCase | DomainService 모킹, 연동 User 생성+role 부여 오케스트레이션, execute ≤10줄 |
| infrastructure | Partner*RepositoryImpl, AsyncPartnerActivityRecorder, ApiKeyGeneratorImpl | TestContainers(MySQL). 저장·조회, 감사 적재 실패 무시 |
| presentation | PartnerAdminApiController, PartnerApiKeyAuthenticationFilter | MockMvc + TestContainers. 401/403/404 분기, principal 주입 후 기존 엔드포인트 통과 |
| scenario | E2E | 파트너 등록 → 키 발급 → 키로 /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, 스키마 먼저):
- DB expand —
V38 partner/V39 partner_api_key/V40 partner_audit_log신규 테이블 추가(기존 무영향). - 코드 배포 (필터 플래그 OFF) —
partner.auth.enabled=false.PartnerApiKeyAuthenticationFilter빈은 로드되나 SecurityConfig 등록을 플래그로 게이트 → 휴면. 관리 API(/api/admin/partners/**)는 배포되나 파트너 인증 경로 미개통. - 플래그 ON —
partner.auth.enabled=true→ 필터 활성. 운영자가 첫 Partner 등록·키 발급 후 실트래픽 개시.
- DB expand —
- 전환 조건: 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역순) — 데이터 없을 때만.
- 3→2:
- 필터는 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) |