Agent Data PostgreSQL JSONB 마이그레이션 계획
Version: 1.0.0 Last Updated: 2026-01-12 Status: Planning
1. 마이그레이션 개요
1.1 배경
현재 Agent Data 도메인은 **Firestore (genai 데이터베이스)**를 사용하여 9개 서브도메인의 변환된 데이터를 저장하고 있습니다. 운영 과정에서 다음과 같은 문제점이 발견되었습니다:
| 문제 영역 | 현재 상황 | 영향 |
|---|---|---|
| 성능 | user_profile_summary 응답 1-5초 | MCP Tool 타임아웃 위험 |
| N+1 쿼리 | 9개 도메인 × 서브컬렉션 조회 | 네트워크 왕복 15회 이상 |
| 비용 | 사용량 기반 과금 | 월 $70-250 (예측 불가) |
| 정합성 | PostgreSQL 원본과 별도 DB | 데이터 불일치 가능 |
| 유지보수 | 9개 Repository × 150 LOC | 1,350줄 중복 코드 |
1.2 목표
Before:
[PostgreSQL 원본] → [Event] → [Firestore genai DB] → [MCP Tools]
↑
별도 DB, 네트워크 지연
After:
[PostgreSQL 원본] → [Event] → [PostgreSQL agent_data] → [MCP Tools]
↑
동일 DB, JOIN 가능, 트랜잭션 지원
1.3 기대 효과
| 메트릭 | Before (Firestore) | After (PostgreSQL) | 개선율 |
|---|---|---|---|
user_profile_summary 응답 | 1-5초 | 100-300ms | 80%+ |
| 네트워크 왕복 | 9-15회 | 1-3회 | 80%+ |
| 월간 비용 | $70-250 (변동) | ~$5 (고정) | 95%+ |
| 데이터 정합성 | Eventually Consistent | Strong Consistent | - |
| Repository 코드 | ~1,350 LOC | ~300 LOC | 78% |
2. 현황 분석
2.1 마이그레이션 대상 Firestore 컬렉션
| 도메인 | Firestore 경로 | 예상 문서 수/사용자 | 주요 쿼리 패턴 |
|---|---|---|---|
| QUESTIONNAIRE | questionnaire/{userId}/round_{N}/{type} | ~20 | userId, roundNumber, type |
| SLEEP | sleep/sleepLog/{userId}/{dayIndex} | ~365/년 | userId, dayIndex 범위 |
| JOURNAL | journal/{userId}/entries/{id} | ~100 | userId, 날짜 범위 |
| CONDITION | condition/{userId}/reports/{date} | ~50 | userId, 날짜 범위 |
| MEDICATION | medication/{userId} | 1 | userId |
| RELAXATION | relaxation/{userId}/sessions/{id} | ~100 | userId, 통계 |
| LEARNING | learning/{userId}/lessons/{id} | ~50 | userId, progress |
| USER_CONTEXT | userContext/{userId} | 1 | userId |
| EXTERNAL_HEALTH | externalHealth/{userId} | 1 | userId |
2.2 현재 Repository 인터페이스
각 도메인별 Repository는 다음과 같은 공통 패턴을 가지고 있습니다:
// 공통 패턴
interface CommonRepositoryMethods<T> {
findByUserId(userId: string, options?: QueryOptions): Promise<T[]>;
findRecentByUser(userId: string, days: number): Promise<T[]>;
save(data: T): Promise<string>;
delete(userId: string, ...keys: string[]): Promise<void>;
}
2.3 현재 문제점 상세
2.3.1 성능 문제
// user_profile_summary 호출 시 발생하는 쿼리
async getUserProfileSummary(userId: string) {
// 9개의 순차적 Firestore 호출 (각각 100-500ms)
const [sleep, questionnaire, journal, condition, medication,
relaxation, learning, userContext, externalHealth] = await Promise.all([
this.sleepRepo.findRecentByUser(userId, 30), // ~200ms
this.questionnaireRepo.findByUserId(userId), // ~300ms
this.journalRepo.findRecentByUserId(userId, 30), // ~200ms
this.conditionRepo.findRecentByUserId(userId, 30), // ~200ms
this.medicationRepo.findActiveByUserId(userId), // ~100ms
this.relaxationRepo.findRecentByUserId(userId, 30),// ~200ms
this.learningRepo.findByUserId(userId), // ~150ms
this.userContextRepo.findByUserId(userId), // ~100ms
this.externalHealthRepo.findRecentByUserId(userId),// ~150ms
]);
// 총 소요 시간: ~1.6초 (병렬 실행 시), 네트워크 오버헤드 추가
}
2.3.2 코드 중복
// 9개 Repository에서 반복되는 패턴
@Injectable()
export class FirestoreXxxDataRepository implements XxxDataRepository {
private readonly DATABASE_ID = 'genai';
private readonly ROOT_COLLECTION = 'xxx';
private readonly genaiFirestore: Firestore;
constructor(
private readonly configService: ConfigService,
private readonly logger: LoggerService,
) {
// 동일한 초기화 코드 반복...
}
private cleanUndefinedValues(obj: any): any {
// 동일한 유틸 함수 반복...
}
}
3. 목표 아키텍처
3.1 PostgreSQL 스키마 설계
-- 단일 통합 테이블 (이미 Prisma 스키마에 추가됨)
CREATE TABLE agent_data.agent_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
domain agent_data."AgentDataDomain" NOT NULL,
data_type VARCHAR(50) NOT NULL,
reference_key VARCHAR(255) DEFAULT '',
data JSONB NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_user_domain_type_key
UNIQUE (user_id, domain, data_type, reference_key)
);
-- 인덱스
CREATE INDEX idx_agent_data_user_domain ON agent_data.agent_data(user_id, domain);
CREATE INDEX idx_agent_data_user_domain_type ON agent_data.agent_data(user_id, domain, data_type);
CREATE INDEX idx_agent_data_created ON agent_data.agent_data(created_at DESC);
CREATE INDEX idx_agent_data_jsonb ON agent_data.agent_data USING GIN (data);
3.2 Prisma 스키마 (구현됨)
enum AgentDataDomain {
QUESTIONNAIRE
SLEEP
JOURNAL
CONDITION
MEDICATION
RELAXATION
LEARNING
USER_CONTEXT
EXTERNAL_HEALTH
@@schema("agent_data")
}
model AgentData {
id String @id @default(uuid())
userId String @map("user_id")
domain AgentDataDomain
dataType String @map("data_type") @db.VarChar(50)
referenceKey String @default("") @map("reference_key") @db.VarChar(255)
data Json
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, domain, dataType, referenceKey], name: "unique_user_domain_type_key")
@@index([userId, domain])
@@index([userId, domain, dataType])
@@index([createdAt(sort: Desc)])
@@map("agent_data")
@@schema("agent_data")
}
3.3 데이터 매핑
| Firestore 경로 | PostgreSQL 컬럼 매핑 |
|---|---|
questionnaire/{userId}/round_{N}/{type} | domain='QUESTIONNAIRE', data_type='response', reference_key='{N}_{type}' |
sleep/sleepLog/{userId}/{dayIndex} | domain='SLEEP', data_type='sleep_log', reference_key='{dayIndex}' |
sleep/sleepGoal/{userId}/{dayIndex} | domain='SLEEP', data_type='sleep_goal', reference_key='goal_{dayIndex}' |
journal/{userId}/entries/{id} | domain='JOURNAL', data_type='entry', reference_key='{id}' |
condition/{userId}/reports/{date} | domain='CONDITION', data_type='report', reference_key='{date}' |
medication/{userId} | domain='MEDICATION', data_type='profile', reference_key='' |
relaxation/{userId}/sessions/{id} | domain='RELAXATION', data_type='session', reference_key='{id}' |
learning/{userId}/lessons/{id} | domain='LEARNING', data_type='progress', reference_key='{id}' |
userContext/{userId} | domain='USER_CONTEXT', data_type='context', reference_key='' |
externalHealth/{userId} | domain='EXTERNAL_HEALTH', data_type='summary', reference_key='' |
4. 구현 계획
4.1 Phase 0: 도메인 문서화 (완료)
- 기존 Firestore Repository 분석
- 데이터 인터페이스 분석
- 마이그레이션 계획 문서화
- domain-model.md 업데이트
4.2 Phase 1: 스키마 생성 및 Repository 구현
Day 1-2: Prisma 스키마 및 마이그레이션
# 마이그레이션 생성
yarn nx run core-database:prisma-migrate --name add_agent_data_schema
Day 3-4: Base Repository 구현
// libs/feature/agent-data/src/lib/infrastructure/repositories/base-agent-data.repository.ts
@Injectable()
export class BaseAgentDataRepository {
constructor(
protected readonly prisma: PrismaService,
protected readonly logger: LoggerService,
) {}
async findByUserAndDomain<T>(
userId: string,
domain: AgentDataDomain,
dataType?: string,
options?: AgentDataQueryOptions,
): Promise<T[]> {
const where: Prisma.AgentDataWhereInput = { userId, domain };
if (dataType) where.dataType = dataType;
// ... 필터링 로직
const results = await this.prisma.agentData.findMany({
where,
orderBy: { createdAt: options?.sortOrder || 'desc' },
take: options?.limit,
skip: options?.offset,
});
return results.map(r => r.data as T);
}
async findOne<T>(
userId: string,
domain: AgentDataDomain,
dataType: string,
referenceKey?: string,
): Promise<T | null> {
const result = await this.prisma.agentData.findUnique({
where: {
unique_user_domain_type_key: {
userId,
domain,
dataType,
referenceKey: referenceKey ?? '',
},
},
});
return result?.data as T | null;
}
async upsert<T extends object>(
userId: string,
domain: AgentDataDomain,
dataType: string,
referenceKey: string | null,
data: T,
metadata?: Record<string, any>,
): Promise<void> {
await this.prisma.agentData.upsert({
where: {
unique_user_domain_type_key: {
userId,
domain,
dataType,
referenceKey: referenceKey ?? '',
},
},
create: { userId, domain, dataType, referenceKey: referenceKey ?? '', data, metadata: metadata ?? {} },
update: { data, metadata: metadata ?? {}, updatedAt: new Date() },
});
}
}
Day 5: 도메인별 Repository 구현
// libs/feature/agent-data/src/lib/infrastructure/repositories/prisma-transformed-questionnaire-data.repository.ts
@Injectable()
export class PrismaTransformedQuestionnaireDataRepository
extends BaseAgentDataRepository
implements TransformedQuestionnaireDataRepository {
private readonly DOMAIN = AgentDataDomain.QUESTIONNAIRE;
private readonly DATA_TYPE = 'response';
async save(data: TransformedQuestionnaireData): Promise<string> {
const referenceKey = `${data.roundNumber}_${data.questionnaireType}`;
await this.upsert(data.userId, this.DOMAIN, this.DATA_TYPE, referenceKey, data);
return referenceKey;
}
async findByRound(
userId: string,
questionnaireType: QuestionnaireType,
roundNumber: number,
): Promise<TransformedQuestionnaireData | null> {
const referenceKey = `${roundNumber}_${questionnaireType}`;
return this.findOne(userId, this.DOMAIN, this.DATA_TYPE, referenceKey);
}
// ... 기타 메서드
}
4.3 Phase 2: 이중 쓰기 (Dual Write)
Week 2-3: DualWriteService 구현
// libs/feature/agent-data/src/lib/infrastructure/services/dual-write.service.ts
export interface DualWriteConfig {
enableFirestore: boolean;
enablePostgres: boolean;
primarySource: 'firestore' | 'postgres';
}
@Injectable()
export class DualWriteService {
private config: DualWriteConfig;
constructor(private readonly configService: ConfigService) {
this.config = {
enableFirestore: configService.get('agentData.firestore.enabled', true),
enablePostgres: configService.get('agentData.postgres.enabled', true),
primarySource: configService.get('agentData.primarySource', 'firestore'),
};
}
async write<T>(
firestoreWrite: () => Promise<T>,
postgresWrite: () => Promise<T>,
): Promise<T> {
// Primary 먼저 실행, Secondary 비동기 실행
if (this.config.primarySource === 'firestore') {
const result = await firestoreWrite();
if (this.config.enablePostgres) {
postgresWrite().catch(err => this.logger.warn('PostgreSQL secondary write failed', err));
}
return result;
} else {
const result = await postgresWrite();
if (this.config.enableFirestore) {
firestoreWrite().catch(err => this.logger.warn('Firestore secondary write failed', err));
}
return result;
}
}
}
환경 설정
# config/development.yaml - Phase 1 (기본값, Firestore만 사용)
agentData:
dualWrite:
enableFirestore: true
enablePostgres: false
primarySource: firestore
enableRollback: false
silentSecondary: true
# config/development.yaml - Phase 2 (Dual-Write 활성화)
agentData:
dualWrite:
enableFirestore: true
enablePostgres: true # PostgreSQL 쓰기 활성화
primarySource: firestore # Firestore가 읽기/쓰기 Primary
enableRollback: false
silentSecondary: true # Secondary 실패 시 로깅만
# config/staging.yaml - Phase 3 (PostgreSQL Primary 전환)
agentData:
dualWrite:
enableFirestore: true # Firestore는 백업으로 유지
enablePostgres: true
primarySource: postgres # PostgreSQL이 읽기/쓰기 Primary
enableRollback: false
silentSecondary: true
# config/production.yaml - Phase 4 (PostgreSQL Only)
agentData:
dualWrite:
enableFirestore: false # Firestore 완전 비활성화
enablePostgres: true
primarySource: postgres
enableRollback: false
silentSecondary: true
설정 항목 설명
| 설정 | 타입 | 기본값 | 설명 |
|---|---|---|---|
enableFirestore | boolean | true | Firestore 쓰기/읽기 활성화 |
enablePostgres | boolean | false | PostgreSQL 쓰기/읽기 활성화 |
primarySource | 'firestore' | 'postgres' | 'firestore' | 읽기 작업의 Primary 소스 |
enableRollback | boolean | false | Primary 실패 시 Secondary 롤백 여부 |
silentSecondary | boolean | true | Secondary 실패 시 로깅만 (true) / 에러 throw (false) |
4.4 Phase 3: 데이터 마이그레이션 및 읽기 전환
마이그레이션 서비스 사용법
// 마이그레이션 서비스 주입
import { AgentDataMigrationService } from '@feature/agent-data';
@Injectable()
export class MigrationCommandService {
constructor(
private readonly migrationService: AgentDataMigrationService
) {}
// 1. 전체 마이그레이션 실행
async runFullMigration() {
const result = await this.migrationService.runMigration({
domains: ['all'], // 'sleep' | 'questionnaire' | 'all'
batchSize: 100, // 한번에 처리할 문서 수
delayBetweenBatches: 100, // 배치 간 대기 시간 (ms)
continueOnError: true, // 에러 발생 시 계속 진행
overwriteExisting: false, // 기존 데이터 덮어쓰기
dryRun: false, // Dry-run 모드
onProgress: (progress) => {
console.log(`${progress.domain}: ${progress.percentage}%`);
},
});
console.log(`Migrated: ${result.totalMigrated}, Failed: ${result.totalFailed}`);
return result;
}
// 2. Dry-run (실제 저장 없이 테스트)
async testMigration() {
return this.migrationService.dryRun(['all']);
}
// 3. 특정 사용자만 마이그레이션
async migrateSpecificUsers(userIds: string[]) {
return this.migrationService.migrateUsers(userIds, ['sleep']);
}
// 4. 데이터 검증
async validateMigration() {
const validation = await this.migrationService.validateMigration(
['all'], // 검증할 도메인
100 // 샘플 크기
);
if (!validation.isValid) {
console.error('Validation failed!');
console.log('Sleep mismatches:', validation.sleep?.mismatches);
console.log('Questionnaire mismatches:', validation.questionnaire?.mismatches);
}
return validation;
}
}
마이그레이션 옵션 상세
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
domains | MigrationDomain[] | ['all'] | 마이그레이션 대상 도메인 |
batchSize | number | 100 | 한번에 처리할 문서 수 |
delayBetweenBatches | number | 100 | 배치 간 대기 시간 (ms) |
userIds | string[] | - | 특정 사용자만 마이그레이션 |
continueOnError | boolean | true | 에러 발생 시 계속 진행 |
overwriteExisting | boolean | false | 기존 PostgreSQL 데이터 덮어쓰기 |
dryRun | boolean | false | 실제 저장 없이 테스트 |
onProgress | function | - | 진행 상황 콜백 |
마이그레이션 실행 순서
# 1. Dry-run으로 마이그레이션 테스트
yarn nx run dta-wide-api:migrate --dry-run
# 2. Sleep 도메인만 먼저 마이그레이션 (위험 분산)
yarn nx run dta-wide-api:migrate --domains sleep
# 3. 데이터 검증
yarn nx run dta-wide-api:migrate --validate
# 4. Questionnaire 도메인 마이그레이션
yarn nx run dta-wide-api:migrate --domains questionnaire
# 5. 전체 검증
yarn nx run dta-wide-api:migrate --validate --sample-size 500
마이그레이션 결과 예시
========================================
MIGRATION SUMMARY
========================================
Status: SUCCESS
Duration: 45230ms
Total Migrated: 15420
Total Skipped: 230
Total Failed: 5
----------------------------------------
[SLEEP/sleep_log]
Total: 12500
Migrated: 12300
Skipped: 195
Failed: 5
Duration: 32100ms
[SLEEP/sleep_goal]
Total: 1200
Migrated: 1195
Skipped: 5
Failed: 0
Duration: 5100ms
[QUESTIONNAIRE/initial_response]
Total: 1925
Migrated: 1925
Skipped: 30
Failed: 0
Duration: 8030ms
========================================
4.5 Phase 4: Firestore 제거
Phase 4 환경 설정
# config/production.yaml - Phase 4 (PostgreSQL Only)
agentData:
dualWrite:
enableFirestore: false # Firestore 완전 비활성화
enablePostgres: true
primarySource: postgres
enableRollback: false
silentSecondary: true
Phase 4 모듈 DI 전환 (자동)
feature-agent-data.module.ts의 DI Factory는 설정에 따라 자동으로 적절한 Repository를 선택합니다:
// Repository DI - Phase별 자동 전환
{
provide: TRANSFORMED_SLEEP_DATA_REPOSITORY,
useFactory: (firestoreRepo, prismaRepo, dualWriteService, cacheService, logger) => {
const firestoreEnabled = dualWriteService.isFirestoreEnabled();
const postgresEnabled = dualWriteService.isPostgresEnabled();
// Phase 4: PostgreSQL만 사용 (Firestore 비활성화)
if (!firestoreEnabled && postgresEnabled) {
logger.info('Using CachedPrismaTransformedSleepDataRepository (Phase 4)');
return new CachedPrismaTransformedSleepDataRepository(
prismaRepo,
cacheService,
logger,
);
}
// Phase 2-3: Dual-Write (양쪽 활성화)
if (postgresEnabled && firestoreEnabled) {
logger.info('Using UnifiedTransformedSleepDataRepository (Dual-Write)');
return new UnifiedTransformedSleepDataRepository(
firestoreRepo, prismaRepo, dualWriteService, logger,
);
}
// Phase 1: Firestore만 사용 (기본값)
logger.info('Using CachedTransformedSleepDataRepository (Firestore only)');
return new CachedTransformedSleepDataRepository(
firestoreRepo, cacheService, logger,
);
},
inject: [...],
}
Phase별 사용되는 Repository
| Phase | Sleep Repository | Questionnaire Repository | 비고 |
|---|---|---|---|
| Phase 1 | CachedTransformedSleepDataRepository (Firestore + Redis) | FirestoreTransformedQuestionnaireDataRepository | 기존 동작 |
| Phase 2-3 | UnifiedTransformedSleepDataRepository (Dual-Write) | UnifiedTransformedQuestionnaireDataRepository | 양쪽 쓰기, Primary 읽기 |
| Phase 4 | CachedPrismaTransformedSleepDataRepository (PostgreSQL + Redis) | PrismaTransformedQuestionnaireDataRepository | PostgreSQL 전용 |
Phase 4 적용 절차
-
사전 검증
# 마이그레이션 완료 확인
yarn nx run dta-wide-api:migrate --validate --sample-size 1000
# Phase 3에서 PostgreSQL 읽기 테스트 완료 확인
# (primarySource: postgres로 1주 이상 운영) -
설정 변경
# enableFirestore: false로 변경
agentData:
dualWrite:
enableFirestore: false
enablePostgres: true
primarySource: postgres -
배포 및 모니터링
- 애플리케이션 재시작
- 로그에서
Using CachedPrismaTransformedSleepDataRepository (Phase 4)확인 - 응답 시간 및 에러율 모니터링
-
Firestore 코드 정리 (선택)
- 롤백 가능성이 없어지면 Firestore 관련 코드 삭제
- Firestore genai 데이터베이스 아카이브 또는 삭제
제거 대상 파일 (Firestore 코드 정리 시)
libs/feature/agent-data/src/lib/infrastructure/repositories/
├── firestore-transformed-questionnaire-data.repository.ts // 삭제
├── firestore-transformed-sleep-data.repository.ts // 삭제
├── firestore-condition-data.repository.ts // 삭제
├── firestore-external-health-data.repository.ts // 삭제
├── firestore-journal-data.repository.ts // 삭제
├── firestore-learning-data.repository.ts // 삭제
├── firestore-medication-data.repository.ts // 삭제
├── firestore-relaxation-data.repository.ts // 삭제
├── firestore-user-context.repository.ts // 삭제
├── cached-transformed-sleep-data.repository.ts // 삭제 (Firestore 캐시)
├── unified-transformed-sleep-data.repository.ts // 삭제 (이중쓰기)
└── unified-transformed-questionnaire-data.repository.ts // 삭제 (이중쓰기)
Note: Firestore 코드 삭제는 롤백 가능성이 완전히 없어진 후 진행하는 것을 권장합니다. Phase 4 설정만으로 Firestore 접근이 완전히 비활성화되므로 코드 삭제는 선택사항입니다.
5. 위험 관리 및 롤백 계획
5.1 롤백 시나리오
| Phase | 문제 상황 | 롤백 방법 |
|---|---|---|
| Phase 2 | PostgreSQL 쓰기 실패율 높음 | postgres.enabled: false |
| Phase 3 | PostgreSQL 읽기 성능 저하 | primarySource: firestore |
| Phase 3 | 데이터 불일치 발견 | 재마이그레이션 후 재전환 |
| Phase 4 | Firestore 의존성 발견 | Git revert + Firestore 재활성화 |
5.2 모니터링 메트릭
const metrics = {
// 성능
'agent_data.read.latency_ms': Histogram,
'agent_data.write.latency_ms': Histogram,
// 에러율
'agent_data.read.errors': Counter,
'agent_data.write.errors': Counter,
// 처리량
'agent_data.read.count': Counter,
'agent_data.write.count': Counter,
// 데이터 소스별
'agent_data.source.firestore': Counter,
'agent_data.source.postgres': Counter,
};
6. 타임라인
Phase 0: 도메인 문서화 [완료]
└── 분석 및 계획 수립
Phase 1: 스키마 및 Repository [1주]
├── Day 1-2: Prisma 스키마, 마이그레이션
├── Day 3-4: Base Repository 구현
└── Day 5: 도메인별 Repository 구현
Phase 2: 이중 쓰기 [2주]
├── Day 1-3: DualWriteService 구현
├── Day 4-7: 통합 Repository 구현
├── Day 8-10: 테스트 및 스테이징 배포
└── Day 11-14: 프로덕션 배포 및 모니터링
Phase 3: 마이그레이션 및 전환 [1주]
├── Day 1-2: 마이그레이션 스크립트 실행
├── Day 3: 데이터 검증
├── Day 4-5: 읽기 전환 (primarySource: postgres)
└── Day 6-7: 모니터링
Phase 4: Firestore 제거 [1주]
├── Day 1-2: Firestore 코드 제거
├── Day 3: 데이터 아카이브
└── Day 4-5: 최종 테스트 및 문서화
총 예상 기간: 5주 (버퍼 포함 6-8주)
7. dta-wide-api 영향 분석
7.1 현재 의존성 구조
dta-wide-api
└── apps/dta-wide-api/src/app/agent-data/
├── agent-data.module.ts → imports FeatureAgentDataModule
├── agent-data.controller.ts → REST API: GET /agent-data/users/:userId/sleep/records
└── agent-data.service.ts → QueryBus.execute(GetSleepRecordsQuery)
↓
libs/feature/agent-data
└── FeatureAgentDataModule
├── GetSleepRecordsHandler → @Inject(TRANSFORMED_SLEEP_DATA_REPOSITORY)
└── TRANSFORMED_SLEEP_DATA_REPOSITORY → FirestoreTransformedSleepDataRepository (현재)
→ PrismaTransformedSleepDataRepository (마이그레이션 후)
7.2 CQRS 패턴에 의한 격리
dta-wide-api의 AgentDataService는 CQRS QueryBus를 통해 쿼리를 실행합니다:
// apps/dta-wide-api/src/app/agent-data/agent-data.service.ts
@Injectable()
export class AgentDataService {
constructor(private readonly queryBus: QueryBus) {}
async getSleepRecords(userId, limit, sortOrder, includeInvalidData, fromDayIndex?, toDayIndex?) {
const query = new GetSleepRecordsQuery(userId, limit, sortOrder, includeInvalidData, fromDayIndex, toDayIndex);
return this.queryBus.execute<GetSleepRecordsQuery, GetSleepRecordsResult>(query);
}
}
핵심 포인트:
AgentDataService는GetSleepRecordsQuery객체만 생성- 실제 데이터 접근은
GetSleepRecordsHandler에서 처리 - Handler는 Repository 인터페이스에만 의존 (Dependency Inversion)
7.3 Repository DI 구조
// libs/feature/agent-data/src/lib/feature-agent-data.module.ts
@Module({
providers: [
// Repository Token에 구현체 바인딩 (DI Container)
{
provide: TRANSFORMED_SLEEP_DATA_REPOSITORY,
useFactory: (firestoreRepo, cacheService, logger) => {
return new CachedTransformedSleepDataRepository(firestoreRepo, cacheService, logger);
},
inject: [FirestoreTransformedSleepDataRepository, ...],
},
// ... 9개 도메인 Repository
],
})
export class FeatureAgentDataModule {}
마이그레이션 시 변경 지점:
// Phase 3 이후
{
provide: TRANSFORMED_SLEEP_DATA_REPOSITORY,
useFactory: (prismaRepo, cacheService, logger) => {
return new CachedTransformedSleepDataRepository(prismaRepo, cacheService, logger);
},
inject: [PrismaTransformedSleepDataRepository, ...], // ← Firestore → Prisma 교체
}
7.4 영향도 결론
| 레이어 | 파일 | 코드 변경 필요 |
|---|---|---|
| dta-wide-api Controller | agent-data.controller.ts | ❌ 없음 |
| dta-wide-api Service | agent-data.service.ts | ❌ 없음 |
| dta-wide-api Module | agent-data.module.ts | ❌ 없음 |
| Shared Contracts | @shared-contracts/agent-data | ❌ 없음 |
| Query Handlers | get-sleep-records.handler.ts 등 | ❌ 없음 (인터페이스 동일) |
| FeatureAgentDataModule DI | feature-agent-data.module.ts | ⚠️ Repository 바인딩 변경 |
| Repository 구현체 | prisma-*.repository.ts (신규) | ✅ 신규 구현 필요 |
| Firestore Repository | firestore-*.repository.ts | 🗑️ Phase 4에서 삭제 |
7.5 dta-wide-api 마이그레이션 전략
Phase별 영향:
| Phase | dta-wide-api 영향 | 조치 사항 |
|---|---|---|
| Phase 1 | 없음 | PostgreSQL Repository 구현은 라이브러리 레벨 |
| Phase 2 | 없음 | 이중 쓰기는 FeatureAgentDataModule 내부에서 처리 |
| Phase 3 | 암묵적 전환 | primarySource: postgres 설정 시 자동 전환 |
| Phase 4 | 없음 | Firestore 코드 삭제는 라이브러리 레벨 |
결론: dta-wide-api 코드 수정 없이 마이그레이션 가능
이는 다음 설계 원칙 덕분입니다:
- CQRS Pattern: Controller/Service와 데이터 접근 로직 분리
- Repository Pattern: 인터페이스와 구현체 분리
- Dependency Injection: 구현체 교체가 모듈 설정에서만 발생
7.6 검증 계획
마이그레이션 각 Phase에서 dta-wide-api 엔드포인트 동작 검증:
# 1. Sleep Records API 테스트
curl -X GET "http://localhost:3000/agent-data/users/{userId}/sleep/records?limit=10&sortOrder=DESC"
# 2. 응답 검증 항목
# - 응답 스키마 동일성
# - 데이터 값 일치성 (Firestore vs PostgreSQL)
# - 응답 시간 개선 확인
테스트 체크리스트:
- Phase 2: 이중 쓰기 후 양쪽 데이터 일치 확인
- Phase 3: PostgreSQL 전환 후 API 응답 동일성 확인
- Phase 3: 응답 시간 80%+ 개선 확인
- Phase 4: Firestore 제거 후 정상 동작 확인
8. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 1.0.0 | 2026-01-12 | claude | 최초 작성 |
| 1.1.0 | 2026-01-12 | claude | dta-wide-api 영향 분석 섹션 추가 |
| 1.2.0 | 2026-01-12 | claude | Phase 2 환경 설정 상세 문서화 |
| 1.3.0 | 2026-01-12 | claude | Phase 3 마이그레이션 서비스 사용법 문서화 |
| 1.4.0 | 2026-01-12 | claude | Phase 4 구현 상세 문서화 (DI 전환, Repository 선택 로직) |