본문으로 건너뛰기

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 LOC1,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-300ms80%+
네트워크 왕복9-15회1-3회80%+
월간 비용$70-250 (변동)~$5 (고정)95%+
데이터 정합성Eventually ConsistentStrong Consistent-
Repository 코드~1,350 LOC~300 LOC78%

2. 현황 분석

2.1 마이그레이션 대상 Firestore 컬렉션

도메인Firestore 경로예상 문서 수/사용자주요 쿼리 패턴
QUESTIONNAIREquestionnaire/{userId}/round_{N}/{type}~20userId, roundNumber, type
SLEEPsleep/sleepLog/{userId}/{dayIndex}~365/년userId, dayIndex 범위
JOURNALjournal/{userId}/entries/{id}~100userId, 날짜 범위
CONDITIONcondition/{userId}/reports/{date}~50userId, 날짜 범위
MEDICATIONmedication/{userId}1userId
RELAXATIONrelaxation/{userId}/sessions/{id}~100userId, 통계
LEARNINGlearning/{userId}/lessons/{id}~50userId, progress
USER_CONTEXTuserContext/{userId}1userId
EXTERNAL_HEALTHexternalHealth/{userId}1userId

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

설정 항목 설명

설정타입기본값설명
enableFirestorebooleantrueFirestore 쓰기/읽기 활성화
enablePostgresbooleanfalsePostgreSQL 쓰기/읽기 활성화
primarySource'firestore' | 'postgres''firestore'읽기 작업의 Primary 소스
enableRollbackbooleanfalsePrimary 실패 시 Secondary 롤백 여부
silentSecondarybooleantrueSecondary 실패 시 로깅만 (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;
}
}

마이그레이션 옵션 상세

옵션타입기본값설명
domainsMigrationDomain[]['all']마이그레이션 대상 도메인
batchSizenumber100한번에 처리할 문서 수
delayBetweenBatchesnumber100배치 간 대기 시간 (ms)
userIdsstring[]-특정 사용자만 마이그레이션
continueOnErrorbooleantrue에러 발생 시 계속 진행
overwriteExistingbooleanfalse기존 PostgreSQL 데이터 덮어쓰기
dryRunbooleanfalse실제 저장 없이 테스트
onProgressfunction-진행 상황 콜백

마이그레이션 실행 순서

# 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

PhaseSleep RepositoryQuestionnaire Repository비고
Phase 1CachedTransformedSleepDataRepository (Firestore + Redis)FirestoreTransformedQuestionnaireDataRepository기존 동작
Phase 2-3UnifiedTransformedSleepDataRepository (Dual-Write)UnifiedTransformedQuestionnaireDataRepository양쪽 쓰기, Primary 읽기
Phase 4CachedPrismaTransformedSleepDataRepository (PostgreSQL + Redis)PrismaTransformedQuestionnaireDataRepositoryPostgreSQL 전용

Phase 4 적용 절차

  1. 사전 검증

    # 마이그레이션 완료 확인
    yarn nx run dta-wide-api:migrate --validate --sample-size 1000

    # Phase 3에서 PostgreSQL 읽기 테스트 완료 확인
    # (primarySource: postgres로 1주 이상 운영)
  2. 설정 변경

    # enableFirestore: false로 변경
    agentData:
    dualWrite:
    enableFirestore: false
    enablePostgres: true
    primarySource: postgres
  3. 배포 및 모니터링

    • 애플리케이션 재시작
    • 로그에서 Using CachedPrismaTransformedSleepDataRepository (Phase 4) 확인
    • 응답 시간 및 에러율 모니터링
  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 2PostgreSQL 쓰기 실패율 높음postgres.enabled: false
Phase 3PostgreSQL 읽기 성능 저하primarySource: firestore
Phase 3데이터 불일치 발견재마이그레이션 후 재전환
Phase 4Firestore 의존성 발견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);
}
}

핵심 포인트:

  • AgentDataServiceGetSleepRecordsQuery 객체만 생성
  • 실제 데이터 접근은 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 Controlleragent-data.controller.ts❌ 없음
dta-wide-api Serviceagent-data.service.ts❌ 없음
dta-wide-api Moduleagent-data.module.ts❌ 없음
Shared Contracts@shared-contracts/agent-data❌ 없음
Query Handlersget-sleep-records.handler.ts❌ 없음 (인터페이스 동일)
FeatureAgentDataModule DIfeature-agent-data.module.ts⚠️ Repository 바인딩 변경
Repository 구현체prisma-*.repository.ts (신규)✅ 신규 구현 필요
Firestore Repositoryfirestore-*.repository.ts🗑️ Phase 4에서 삭제

7.5 dta-wide-api 마이그레이션 전략

Phase별 영향:

Phasedta-wide-api 영향조치 사항
Phase 1없음PostgreSQL Repository 구현은 라이브러리 레벨
Phase 2없음이중 쓰기는 FeatureAgentDataModule 내부에서 처리
Phase 3암묵적 전환primarySource: postgres 설정 시 자동 전환
Phase 4없음Firestore 코드 삭제는 라이브러리 레벨

결론: dta-wide-api 코드 수정 없이 마이그레이션 가능

이는 다음 설계 원칙 덕분입니다:

  1. CQRS Pattern: Controller/Service와 데이터 접근 로직 분리
  2. Repository Pattern: 인터페이스와 구현체 분리
  3. 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.02026-01-12claude최초 작성
1.1.02026-01-12claudedta-wide-api 영향 분석 섹션 추가
1.2.02026-01-12claudePhase 2 환경 설정 상세 문서화
1.3.02026-01-12claudePhase 3 마이그레이션 서비스 사용법 문서화
1.4.02026-01-12claudePhase 4 구현 상세 문서화 (DI 전환, Repository 선택 로직)