도입
AI 캐릭터 채팅 플랫폼은 콘텐츠가 곧 상품이다. 스토리 하나를 만들려면 세계관, 캐릭터 4~5명의 페르소나와 말투, 에피소드별 이벤트 체인, 분기 조건을 모두 채워야 한다. 토크룸도 마찬가지다. 테마에 맞는 캐릭터를 배치하고, 인사말과 대화 설정을 작성해야 한다.
수작업으로는 스토리 하나에 반나절이 걸렸다. 콘텐츠 파이프라인의 병목은 "만드는 속도"였다. 해결 방향은 두 가지였다.
- LLM 기반 자동 생성 — 테마 한 줄을 입력하면 Gemini가 스토리 + 캐릭터 + 에피소드를 통째로 생성한다.
- 구조화된 어드민 CMS — 생성된 콘텐츠를 검수하고 편집하는 도메인별 관리 인터페이스.
이 글에서는 어드민 시스템의 백엔드 아키텍처(God Service 리팩토링), AI 생성 파이프라인, 콘텐츠 내보내기/가져오기, 프론트엔드 구조를 기록한다.
핵심 1: God Service에서 14개 도메인 서비스로
문제 — 하나의 서비스가 모든 것을 처리
프로젝트 초기에는 AdminService 하나가 스토리 CRUD, 캐릭터 관리, 세션 조회, 유저 관리, AI 생성까지 전부 담당했다. 200줄을 넘기기 시작하자 문제가 분명해졌다.
- 메서드 하나를 수정하면 관련 없는 코드까지 스크롤해야 한다
- 테스트에서 모킹 대상이 비대해진다
- 코드 리뷰 시 "이 변경이 다른 도메인에 영향을 주는가?"를 매번 확인해야 한다
해결 — 도메인별 서비스 분리
규칙을 하나 세웠다: 하나의 서비스 파일이 200줄을 넘으면 분리한다. 최종적으로 14개 서비스로 나뉘었다.
// admin.module.ts — 14개 도메인 서비스 등록
@Module({
imports: [AuthModule, ContentSafetyModule, GemsModule, SubscriptionsModule],
controllers: [AdminController],
providers: [
AdminService, // 대시보드 통계만
AdminAiGenerationService, // AI 스토리/캐릭터 생성
AdminCharacterService, // 캐릭터 CRUD/승인
AdminContentIoService, // 콘텐츠 Export/Import
AdminContentSafetyService, // 블랙리스트/안전 로그
AdminGemService, // 젬 트랜잭션/잔액
AdminImageService, // 이미지 업로드
AdminSessionService, // 세션 조회/메시지 페이징
AdminStoryService, // 스토리+에피소드+이벤트
AdminSubscriptionService, // 구독/프로모 코드
AdminTalkRoomService, // 토크룸 CRUD/승인
AdminUserLookupService, // Clerk 유저 조회 헬퍼
AdminUserService, // 유저 목록/역할 변경
],
})
export class AdminModule {}

핵심 원칙은 컨트롤러는 하나, 서비스는 도메인별로 분리다. AdminController가 라우팅과 가드를 일괄 처리하고, 각 서비스는 자기 도메인의 비즈니스 로직에만 집중한다. 예를 들어 원본 AdminService는 대시보드 통계만 남겼다.
// admin.service.ts — 통계만 담당 (17줄)
@Injectable()
export class AdminService {
constructor(private prisma: PrismaService) {}
async getStats() {
const [users, stories, sessions, messages] = await Promise.all([
this.prisma.user.count(),
this.prisma.story.count(),
this.prisma.playSession.count(),
this.prisma.chatMessage.count(),
]);
return { users, stories, sessions, messages };
}
}
서비스 간 의존이 필요한 경우도 있다. AdminSessionService는 세션에 닉네임을 붙여야 하므로 AdminUserLookupService를 주입받는다. 이때 룰은 "공유 헬퍼만 주입, 도메인 서비스 간에는 직접 의존하지 않는다"이다.
핵심 2: Gemini 기반 AI 콘텐츠 생성
아키타입 힌트 시스템
공개 커뮤니티 데이터 분석 결과, 높은 인게이지먼트가 검증된 캐릭터 유형을 10개 아키타입으로 정리했다. 각 아키타입은 감정 변화 패턴을 가진다.
// admin-ai-generation.service.ts — 아키타입 힌트
const ARCHETYPE_HINTS: Record<
string,
{ label: string; emotionPattern: string; description: string }
> = {
'cold-aloof': {
label: '까칠/무뚝뚝',
emotionPattern: 'neutral→confused→tender→longing',
description:
'겉은 차갑지만 관심 있으면 은근히 챙김. 짧은 문장·단답 후 긴 독백 전환.',
},
tsundere: {
label: '츤데레',
emotionPattern: 'angry→confused→shy→happy',
description:
'호감을 감추려 거칠게 대하지만 결정적 순간에 솔직. 부정 뒤 소곤("…아닌데") 패턴',
},
obsessive: {
label: '집착형',
emotionPattern: 'angry→jealous→longing→tender',
description:
'강한 독점욕, 소유격 대사("넌 내 거야"), 질투 독백, 과잉 보호 행동',
},
// ... cheerful, mysterious, healing-mentor, childhood-friend 등 총 10개
};
아키타입이 지정되면 감정 패턴과 성격 특성이 시스템 프롬프트에 주입된다. "츤데레"를 선택하면 AI가 angry→confused→shy→happy 패턴을 personaPrompt와 speechStyle에 자동으로 반영한다.
스토리 전체 생성 플로우
POST /api/v1/admin/contents/stories/generate에 테마 한 줄을 보내면, Gemini가 스토리 메타데이터 + 캐릭터 + 에피소드를 통째로 JSON으로 반환한다.
// admin-ai-generation.service.ts — 스토리 생성 진입점
async generateStory(dto: GenerateStoryDto) {
const characterCount = dto.characterCount ?? STORY_DEFAULTS.CHARACTER_COUNT;
const episodeCount = dto.episodeCount ?? STORY_DEFAULTS.EPISODE_COUNT;
const presetHint = dto.preset ? STORY_PRESET_HINTS[dto.preset] : undefined;
const systemPrompt = `너는 AI 스토리·토크 플랫폼 "플릿(Plit)"의 스토리 작가 AI이다.
...
## 생성 규칙
- 캐릭터 수: ${characterCount}명
- 에피소드 수: ${episodeCount}개
${PERSONA_QUALITY_RULES}
...`;
const result = await this.callGeminiJson(
systemPrompt,
userMessage,
MAX_OUTPUT_TOKENS.STORY, // 65,536 토큰
'스토리',
);
return result;
}
프롬프트 설계에서 신경 쓴 부분은 세 가지다.
- 출력 JSON 스키마를 명시적으로 정의 — Gemini의
responseMimeType: 'application/json'을 사용하되, 스키마 자체를 시스템 프롬프트에 포함시켜 구조를 강제한다. - 공유 프롬프트 블록 분리 —
PERSONA_QUALITY_RULES,SPEECH_STYLE_SCHEMA,CHARACTER_BASE_SCHEMA등 3개 생성 메서드(스토리, 스토리 캐릭터, 토크 캐릭터)가 공유하는 품질 규칙을 상수로 추출했다. - 프리셋 힌트 —
school-romance,isekai,mystery등 7개 프리셋을 제공해 장르별 구체적인 연출 지시를 주입한다.
캐릭터 단독 생성
스토리에 캐릭터를 추가로 생성하는 경우, 기존 캐릭터들의 컨텍스트를 프롬프트에 포함시켜 차별화를 유도한다.
// admin-ai-generation.service.ts — 기존 캐릭터 컨텍스트 주입
async generateCharacter(storyId: string, dto: { archetype?: string; ... }) {
const story = await this.prisma.story.findUniqueOrThrow({
where: { id: storyId },
include: { characters: { include: { character: true } } },
});
// 기존 캐릭터 요약을 프롬프트에 포함
const existingChars = story.characters
.map((sc) =>
`- ${sc.character.name} (${sc.role}, ${sc.character.gender}): ${(sc.character.personaPrompt ?? '').slice(0, 100)}...`
)
.join('\n');
const systemPrompt = `...
## 기존 캐릭터
${existingChars || '(없음)'}
## 생성 규칙
- 기존 캐릭터들과 차별화된 개성을 가져야 함
${this.buildArchetypePrompt(dto.archetype)}
...`;
}
DB에 바로 저장하지 않고 미리보기 JSON을 반환하는 것이 핵심이다. 관리자가 결과를 검토하고, 필요하면 수정한 뒤 저장 버튼을 누르는 2단계 워크플로우다.
핵심 3: 콘텐츠 내보내기/가져오기
스토리 하나를 내보내면 연결된 캐릭터, 에피소드, 이벤트가 모두 포함된 JSON이 생성된다. ID와 타임스탬프는 제거하고, 이름 기반으로 충돌을 해결한다.
// admin-content-io.service.ts — 스토리 Export (관계 데이터 포함)
async exportStories(ids?: string[]) {
const stories = await this.prisma.story.findMany({
where: ids?.length ? { id: { in: ids } } : {},
include: {
characters: {
include: { character: true },
orderBy: { sortOrder: 'asc' },
},
episodes: {
include: { events: { orderBy: { sortOrder: 'asc' } } },
orderBy: { episodeNumber: 'asc' },
},
},
});
return {
exportVersion: '1.0',
type: 'stories',
// ID, 타임스탬프 제거 — 이름 기반 충돌 해결
data: stories.map(({ id, createdAt, updatedAt, characters, episodes, ...rest }) => ({
...rest,
characters: characters.map(({ character, ...link }) => ({ ...link, character })),
episodes: episodes.map(({ events, ...ep }) => ({ ...ep, events })),
})),
};
}
Import 시에는 title(스토리)이나 name(캐릭터)으로 기존 데이터를 찾아 있으면 업데이트, 없으면 새로 생성한다. 스토리 Import는 캐릭터 → 스토리-캐릭터 연결 → 에피소드 → 이벤트 순서로 트랜잭션 안에서 처리된다. 타임아웃은 60초로 넉넉하게 잡았다.
이 기능은 주로 두 가지 용도로 사용된다.
- 환경 간 콘텐츠 이동 — 로컬에서 만든 스토리를 스테이징/프로덕션으로 옮기기
- 백업 —
make dump-seed이전의 수동 백업 수단
핵심 4: 어드민 프론트엔드
프론트엔드 어드민은 Next.js 16 App Router의 (admin) 라우트 그룹 아래 12개 페이지로 구성된다.
/admin # 대시보드 (통계)
/admin/contents # 콘텐츠 목록 (스토리 + 토크룸)
/admin/contents/new # 새 콘텐츠 생성 (위저드)
/admin/contents/story/[id] # 스토리 상세 편집 (탭 구조)
/admin/contents/talk/[id] # 토크룸 상세 편집
/admin/sessions # 세션 목록
/admin/sessions/[id] # 세션 상세 (메시지 뷰어)
/admin/users # 유저 관리
/admin/gems # 젬 트랜잭션
/admin/subscriptions # 구독 관리
/admin/feedback # 피드백
/admin/images # 이미지 관리

스토리 편집 페이지가 가장 복잡하다. 기본정보, 캐릭터, 에피소드 세 개 탭으로 나뉘고, 각 탭에서 모달 기반 CRUD를 수행한다. 커스텀 훅 5개(useCharacterEditor, useEpisodeEditor, useEventEditor, useStoryCharacterEditor, useApproval)가 상태와 뮤테이션을 캡슐화한다.
AI 캐릭터 생성은 다이얼로그로 제공된다. 아키타입 그리드에서 원하는 유형을 선택하고, 성별과 추가 설명을 입력하면 Gemini가 캐릭터를 생성한다. 결과가 폼 상태로 주입되어 관리자가 수정 후 저장할 수 있다.
// AiCharacterGenerateDialog.tsx — 아키타입 선택 UI
<div className="grid grid-cols-2 gap-2">
{CHARACTER_ARCHETYPES.map((arch) => (
<button
key={arch.key}
onClick={() => setSelectedArchetype(arch.key)}
className={`rounded-lg border p-2 text-left ${
selectedArchetype === arch.key ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="text-sm font-medium">{arch.label}</div>
<div className="text-xs text-muted-foreground">{arch.description}</div>
<div className="mt-1 text-xs text-muted-foreground/60">{arch.emotionPattern}</div>
</button>
))}
</div>
핵심 5: Gemini JSON 모드와 에러 핸들링
AI 생성에서 가장 까다로운 부분은 "LLM이 올바른 JSON을 반환하도록 강제하는 것"이다. callGeminiJson은 모든 생성 메서드가 공유하는 공통 호출 레이어다.
// admin-ai-generation.service.ts — Gemini JSON 호출 공통 레이어
private async callGeminiJson(
systemPrompt: string,
userMessage: string,
maxOutputTokens: number,
errorContext: string,
): Promise<Record<string, unknown>> {
const response = await this.gemini.models.generateContent({
model: GENERATE_MODEL, // 'gemini-3.1-flash-lite-preview'
contents: userMessage,
config: {
systemInstruction: systemPrompt,
responseMimeType: 'application/json', // JSON 모드 강제
maxOutputTokens,
},
});
const text = response.text ?? '';
if (!text) {
const reason = response.candidates?.[0]?.finishReason ?? 'unknown';
throw new InternalServerErrorException(
'AI가 빈 응답을 반환했습니다. 다시 시도해주세요.',
);
}
return JSON.parse(text); // 파싱 실패 시 재시도 유도
}
세 가지 방어 계층을 둔다.
responseMimeType: 'application/json'— Gemini에게 JSON 형식을 명시적으로 요청한다.- 빈 응답 검사 —
finishReason이MAX_TOKENS인 경우를 잡아 사용자에게 재시도를 유도한다. - JSON 파싱 실패 시 한국어 에러 메시지 — 관리자가 바로 이해할 수 있는 메시지로 래핑한다.
모델은 gemini-3.1-flash-lite-preview를 사용한다. 스토리 전체 생성에 maxOutputTokens: 65536, 캐릭터 단독 생성에 8192를 할당한다. Flash Lite를 선택한 이유는 비용 대비 JSON 구조 준수율이 충분했기 때문이다.
정리 — 어드민 시스템 설계 핵심 판단
-
200줄 규칙은 생각보다 효과적이다. 서비스 파일이 200줄을 넘으면 반드시 분리한다. 물리적 크기 제한이 도메인 경계를 자연스럽게 만들어준다.
-
God Service의 신호는 "스크롤 피로"다. 메서드 하나를 찾기 위해 파일을 위아래로 스크롤한다면, 이미 분리 시점을 놓친 것이다. 컨트롤러는 하나로 유지하되 서비스를 쪼개는 것이 NestJS에서는 가장 자연스러운 패턴이다.
-
AI 생성은 "미리보기 → 검수 → 저장" 2단계로 설계한다. LLM 출력을 DB에 바로 쓰지 않는다. 관리자가 검토하고 수정할 수 있는 중간 단계가 필수다.
-
프롬프트의 공유 블록을 상수로 추출하라.
PERSONA_QUALITY_RULES,SPEECH_STYLE_SCHEMA같은 품질 규칙을 여러 생성 메서드에서 공유하면, 규칙 변경 시 한 곳만 수정하면 된다. 프롬프트도 코드와 같은 원칙으로 관리할 수 있다. -
아키타입 힌트는 "구조화된 창의성"이다. LLM에게 "캐릭터를 만들어라"가 아니라 "츤데레 아키타입,
angry→confused→shy→happy감정 패턴으로 만들어라"고 지시하면 결과의 일관성과 품질이 모두 올라간다. -
Export/Import는 초기부터 만들어두면 좋다. 환경 간 데이터 이동, 백업, 시드 데이터 갱신에 반복적으로 사용된다. "이름 기반 upsert" 전략이 간단하면서도 실용적이다.
-
프론트엔드 어드민은 커스텀 훅으로 복잡성을 격리한다. 스토리 편집 페이지처럼 CRUD가 여러 엔티티에 걸치는 경우, 엔티티별 커스텀 훅(
useCharacterEditor,useEpisodeEditor등)으로 상태와 뮤테이션을 캡슐화하면 페이지 컴포넌트가 깨끗해진다.