시작하며
지난 1편에서는 문제 발견과 기술 선택 과정을 다뤘습니다. 이번 2편에서는 본격적인 개발 이야기를 하고자 합니다.
2025.10.26 - [AI] - [시리즈 1/3] 매일 아침 8:30 자동으로 도착하는 AI 뉴스 큐레이션
[시리즈 1/3] 매일 아침 8:30 자동으로 도착하는 AI 뉴스 큐레이션
시작하며안녕하세요. 개발자 stark입니다.이번 시리즈는 저희 회사 휴넷에서 사내 동아리 "AI탐험대" 활동을 하며 만들게 된 "AI 기반 RSS 자동화 플랫폼"을 어떻게 만들게 되었는지 그 과정에 대한
curiousjinan.tistory.com
이번 2편에서는 1개 파이프라인을 3단계로 분리하고, Template Method 패턴을 적용하며, 프롬프트를 v6까지 개선한 과정을 다룹니다.
시작하기 앞서 제가 최초에 설계했던 시스템 아키텍처를 소개드립니다.

3단계 분리 파이프라인
개발 초기, 저희는 서비스가 1 flow로 흘러가도록 구성해서 엄청 간단히 동작하게 만들려고 했습니다.

코드는 다음과 같이 구성했습니다.
- 한 메서드 내에서 "RSS 수집 -> AI 처리 -> Slack 전송"이 모두 처리되고 매일 7시에 Cron으로 동작하도록 했습니다.
// 첫 번째 시도: 모든 걸 한 번에
async function doEverything() {
console.log('시작!');
// Step 1: RSS 수집
const items = await collectRSS(); // 10분 소요
// Step 2: AI 처리
const summaries = await processAI(items); // 20분 소요
// Step 3: Slack 전송
await sendToSlack(summaries); // 5분 소요
console.log('완료! 총 35분');
}
// 매일 아침 7시에 실행
cron.schedule('0 7 * * *', doEverything);
엄청 간단해 보이죠? 하지만 여기에는 치명적인 문제가 있었습니다.
문제 1: AI 처리 중 에러

07:00 RSS 수집 시작
07:10 RSS 수집 완료 (100개 글)
07:10 AI 처리 시작
07:18 Gemini API 타임아웃! (45번째 글에서 실패)
❌ 전체 프로세스 중단
결과:
- 지금까지 처리한 45개: 버려짐
- 남은 55개: 처리 안 됨
- 처음부터 다시 시작해야 함 (RSS 수집부터!)
문제 2: 부분 실패 처리 불가
특정 RSS 피드에서 잘못된 XML 반환
→ 전체 수집 실패
→ 나머지 정상 피드도 수집 못함
→ 그날 뉴스 전송 실패
문제 3: 디버깅 어려움
로그:
"에러 발생!"
어디서?
- RSS 수집?
- AI 처리?
- Slack 전송?
- 알 수 없음...
해결책: 3단계 분리
이 문제를 해결하기 위해 저희는 작업을 각 단계별로 분리해서 독립적으로 만들기로 했습니다.

이것을 코드로 표현하면 다음과 같습니다.
- 기존 1개의 Cron에서 처리하던 작업을 3개로 분리하여 정해진 시간에 동작하도록 했습니다.
// Step 1: RSS 수집 (07:00)
@Cron('0 7 * * *')
async runStep1CollectionPhase() {
logger.info('Step 1: RSS 수집 시작');
try {
const items = await collectRSS();
await saveToDatabase(items); // DB에 저장!
logger.info(`Step 1 완료: ${items.length}개 수집`);
} catch (error) {
logger.error('Step 1 실패:', error);
// Step 1만 실패, 다른 단계는 영향 없음
}
}
// Step 2: AI 처리 (07:30)
@Cron('30 7 * * *')
async runStep2ProcessingPhase() {
logger.info('Step 2: AI 처리 시작');
try {
const items = await loadFromDatabase(); // DB에서 읽기
const summaries = await processAI(items);
await saveToDatabase(summaries); // DB에 저장!
logger.info(`Step 2 완료: ${summaries.length}개 처리`);
} catch (error) {
logger.error('Step 2 실패:', error);
// Step 1 결과는 보존됨
// 재시도 가능
}
}
// Step 3: Slack 전송 (08:30)
@Cron('30 8 * * *')
async runStep3DeliveryPhase() {
logger.info('Step 3: Slack 전송 시작');
// 주말 체크
const dayOfWeek = new Date().getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) {
logger.info('주말이므로 전송 건너뜀');
return;
}
try {
const summaries = await loadFromDatabase(); // DB에서 읽기
await sendToSlack(summaries);
logger.info('Step 3 완료: Slack 전송 성공');
} catch (error) {
logger.error('Step 3 실패:', error);
// 데이터는 DB에 있으니 재전송 가능
}
}
왜 Step별 간격을 30분으로 설정했을까요?
초기에는 각 Step을 5분 간격으로 설정했습니다. 하지만 실제 운영하면서 문제가 발생할 수 있다는 것을 확인했습니다.
초기 설정 (실패)
- 07:00 - Step 1 시작
- 07:05 - Step 2 시작 (Step 1이 아직 진행 중!)
- 07:10 - Step 3 시작 (Step 2 데이터 없음!)
각 Step의 실제 처리 시간을 측정해 보니 5분으로는 터무니없이 부족했습니다.
실제 측정 결과
- Step 1 (RSS 수집): 평균 1~5초
이 단계에서는 큰 문제가 없었습니다. 3개 카테고리에서 각각 10~30개의 RSS 소스를 수집합니다. 이때 해외 사이트가 포함되어 있어 네트워크 지연을 고려해야 했습니다. - Step 2 (AI 처리): 평균 3분, 최대 10분
일반적으로 약 20~30개의 글을 처리하며, 글당 평균 10~15초가 소요되었습니다. Gemini API자체의 요청에는 각 요청마다 3~15초로 응답 시간이 들쭉날쭉했고, 가끔 타임아웃이 발생하기도 했습니다. - Step 3 (Slack 전송): 평균 10초
슬랙에 전송하는 과정은 비교적 빠르고 안정적으로 처리되었습니다.
30분 간격을 선택한 이유
- 안전 마진 확보
평균 처리 시간의 2~3배 여유를 두어 최악의 경우에도 다음 Step이 시작되기 전에 완료될 수 있도록 했습니다. - 장애 대응 시간 확보
각 Step이 실패했을 때 모니터링을 확인하고 수동으로 재실행할 시간이 필요했습니다. 30분이면 충분한 대응 시간을 확보할 수 있을 것이라고 판단했습니다. - 사용자 경험 최적화
08:00에는 슬랙에서 자체적으로 알림이 울리지 않게 합니다. 그러니 08:30에 전송해서 직원들이 출근 직후 자연스럽게 확인할 수 있도록 했습니다. 08시 이전(너무 이른) 시간에 전송하는 것은 지양하고자 했습니다.
고려했던 다른 옵션들
- 이벤트 기반 (채택 X)
Step 1이 완료되면 즉시 Step 2를 트리거하는 방식입니다. 이 방식은 작업을 가장 빠르게 처리할 수 있지만, 복잡도가 증가하고 디버깅이 어려워진다는 문제가 있었습니다. - 15분 간격 (채택 X)
평균 시간을 기준으로는 충분해 보였지만, 최대 처리 시간을 고려하면 부족했습니다. 특히 Step 2의 최대 처리 시간이 10분이라는 점이 걸림돌이었습니다. - 1시간 간격 (채택 X)
매우 안전하게 동작하지만, 전송 시간이 09:00~10:00로 너무 늦어졌습니다. 사용자들이 출근을 해서 바로 볼 수 있어야 했기 때문에 업무를 시작한 이후에 도착하는 것은 의미가 없었습니다.
최종 결정
30분 간격은 안정성과 사용자 경험의 균형을 맞출 수 있는 최적의 선택이었습니다. 단순히 이론적인 계산이 아닌, 실측 데이터를 기반으로 한 나름 합리적인 결정이었습니다.
3단계 분리의 장점
1. 에러 복구: 3분 → 1분 (66.67% 단축, 대략적인 측정)
- 에러가 발생해도 특정 Step만 재실행하면 되기 때문에 상당히 높은 수준의 성능 개선이 있었습니다.

Before (한 번에 처리):
- AI 처리 실패 → 전체 재실행 (3분)
After (3단계 분리):
- AI 처리 실패 → Step 2만 재실행 (1분)
- 에러 복구: 3분 → 1분 (66.67% 단축)
2. 디버깅 용이성
- 사실 이것은 1개의 메서드를 호출할 때도 내부에서 기능을 잘 분리해 뒀다면 로깅이 가능합니다.
// 로그가 명확해짐
2024-10-15 07:00:00 Step 1: RSS 수집 시작
2024-10-15 07:08:32 Step 1 완료: 98개 수집
2024-10-15 07:30:00 Step 2: AI 처리 시작
2024-10-15 07:32:15 Step 2 실패: Gemini API timeout
→ 정확히 어느 단계인지 즉시 파악!
2024-10-15 08:30:00 Step 3: Slack 전송 시작
2024-10-15 08:30:45 Step 3 실패: 처리된 요약 없음
→ Step 2 재실행 필요
3. 유연한 운영
- 운영 개발자가 언제든지 원하는 Step을 호출해서 동작시키고 테스트할 수 있었습니다.

# 특정 단계만 수동 실행 가능
# RSS만 다시 수집
$ curl -X POST http://localhost:3000/api/schedule/step1
# AI만 다시 처리
$ curl -X POST http://localhost:3000/api/schedule/step2
# Slack만 다시 전송
$ curl -X POST http://localhost:3000/api/schedule/step3
4. 단계별 독립 테스트
- 이 부분이 정말 중요했습니다. 이전에는 통합으로 테스트를 진행해야 했지만 이제 분리된 각 Step별로 테스트를 할 수 있었기에 좀 더 빠르고 안전하게 검증하며 개발을 진행할 수 있었습니다.

// Step 1 테스트
describe('RSS Collection', () => {
it('should collect items from all sources', async () => {
const items = await collectRSS();
expect(items.length).toBeGreaterThan(0);
});
});
// Step 2 테스트 (Step 1 없이 가능!)
describe('AI Processing', () => {
it('should process items from database', async () => {
// DB에 테스트 데이터 넣기
await seedTestData();
// Step 2만 테스트
const summaries = await processAI();
expect(summaries[0]).toHaveProperty('translatedTitle');
});
});
확장 가능한 아키텍처 (Template Method 패턴)
Template Method 패턴
- 처음에는 3개의 RSS 카테고리로 시작했지만, 앞으로 10개 이상으로 확장할 계획이었습니다. 여기서 발견된 문제점이 있었는데 바로 카테고리마다 코드 중복이 발생할 수 있다는 것이었습니다.
// 나쁜 예: 카테고리마다 복사-붙여넣기
class LndBatch {
async collect() { /* RSS 수집 */ }
async process() { /* AI 처리 */ }
async deliver() { /* Slack 전송 */ }
}
class PolicyBatch {
async collect() { /* RSS 수집 - 거의 동일한 코드! */ }
async process() { /* AI 처리 - 거의 동일한 코드! */ }
async deliver() { /* Slack 전송 - 거의 동일한 코드! */ }
}
class TechBatch {
// 또 복사-붙여넣기...
}
저희는 이것을 해결하기 위해 Template Method 패턴을 도입했습니다.
// 좋은 예: 공통 로직 추상화
abstract class BaseBatch {
// 공통 흐름 (템플릿 메서드)
async executeCollectionPhase(hoursAgo: number): Promise<void> {
logger.info(`[${this.batchName}] Step 1 시작`);
// 1. RSS 수집 (서브클래스 구현)
const items = await this.fetchRssFeeds(hoursAgo);
// 2. 중복 제거 (공통 로직)
const uniqueItems = this.removeDuplicates(items);
// 3. DB 저장 (공통 로직)
await this.saveItems(uniqueItems);
logger.info(`[${this.batchName}] Step 1 완료: ${uniqueItems.length}개`);
}
async executeProcessingPhase(): Promise<void> {
logger.info(`[${this.batchName}] Step 2 시작`);
// 1. DB에서 읽기 (공통 로직)
const items = await this.loadUnprocessedItems();
// 2. AI 처리 (서브클래스 구현)
const summaries = await this.processWithAI(items);
// 3. DB 저장 (공통 로직)
await this.saveSummaries(summaries);
logger.info(`[${this.batchName}] Step 2 완료: ${summaries.length}개`);
}
// 서브클래스가 구현할 메서드
protected abstract fetchRssFeeds(hoursAgo: number): Promise<RssItem[]>;
protected abstract processWithAI(items: RssItem[]): Promise<Summary[]>;
// 공통 메서드
protected removeDuplicates(items: RssItem[]): RssItem[] {
const seen = new Set<string>();
return items.filter(item => {
if (seen.has(item.link)) return false;
seen.add(item.link);
return true;
});
}
}
공통 로직을 추상화시킨 이후 각 카테고리만 잘 구현해 주면 됩니다.
// L&D 카테고리
class LndBatch extends BaseBatch {
protected batchName = 'L&D';
protected async fetchRssFeeds(hoursAgo: number): Promise<rssitem[]> {
const sources = [
'https://joshbersin.com/feed/',
'https://www.atd.org/feed',
'https://elearningindustry.com/feed',
];
// L&D 특화 수집 로직
return await this.rssService.fetchMultiple(sources, hoursAgo);
}
protected async processWithAI(items: RssItem[]): Promise<summary[]> {
// L&D 특화 프롬프트
const prompt = this.buildLndPrompt(items);
return await this.aiService.process(prompt);
}
}
// 정부 정책 카테고리
class PolicyBatch extends BaseBatch {
protected batchName = '정부정책';
protected async fetchRssFeeds(hoursAgo: number): Promise<rssitem[]> {
const sources = [
'https://www.moel.go.kr/rss/news.xml',
'https://www.moe.go.kr/boardCnts/list.do?type=rss',
];
// 정책 특화 수집 로직 (한글 인코딩 처리 등)
return await this.rssService.fetchMultiple(sources, hoursAgo);
}
protected async processWithAI(items: RssItem[]): Promise<summary[]> {
// 정책 특화 프롬프트 (시사점 강조)
const prompt = this.buildPolicyPrompt(items);
return await this.aiService.process(prompt);
}
}
</summary[]></rssitem[]></summary[]></rssitem[]>
이렇게 구성한 Template Method 패턴의 장점은 다음과 같습니다.
코드 중복 제거
- 공통 로직 한 곳에서 관리
- 버그 수정 한 번이면 모든 카테고리 적용
새 카테고리 추가 쉬움
- 2개 메서드만 구현하면 됨
- 5분이면 새 카테고리 추가 가능
일관된 에러 처리
- 공통 로직에서 처리
- 모든 카테고리 동일한 방식
확장 가능한 아키텍처 (멀티배치 오케스트레이터)
멀티배치 오케스트레이터
- 여러 카테고리를 동시에 관리하는 오케스트레이터를 설계했습니다.
@Injectable()
export class MultiBatchOrchestrator {
private batches: BaseBatch[];
constructor(
private lndBatch: LndBatch,
private policyBatch: PolicyBatch,
private techBatch: TechBatch,
) {
this.batches = [lndBatch, policyBatch, techBatch];
}
// Step 1: 모든 배치 수집
async executeCollectionPhase(hoursAgo: number): Promise<void> {
logger.info('전체 Step 1 시작');
// 병렬 실행으로 시간 단축
await Promise.all(
this.batches.map(batch =>
batch.executeCollectionPhase(hoursAgo)
)
);
logger.info('전체 Step 1 완료');
}
// Step 2: 모든 배치 처리
async executeProcessingPhase(): Promise<void> {
logger.info('전체 Step 2 시작');
// 순차 실행 (Gemini API 제한 고려)
for (const batch of this.batches) {
await batch.executeProcessingPhase();
await this.sleep(5000); // 5초 대기
}
logger.info('전체 Step 2 완료');
}
// Step 3: 모든 배치 전송
async executeDeliveryPhase(): Promise<void> {
logger.info('📨 전체 Step 3 시작');
await Promise.all(
this.batches.map(batch =>
batch.executeDeliveryPhase()
)
);
logger.info('전체 Step 3 완료');
}
}
프롬프트 엔지니어링 (Slack 결과물 확인)
측정 기준은 다음과 같았습니다.
측정: 샘플 RSS 20개, Gemini 2.5 Flash
- 성공 기준: JSON 파싱 성공 + 필수 필드 3개 존재 + 키워드 3~5개 + summary 길이 50~200자
- 실패 케이스: JSON 형식 오류 15%, 키워드 개수 틀림 6%"
첫 번째 프롬프트 (일반 요청)
- v1: 단순 요청 (요청했을 때 제대로 된 응답은 절반도 안되었습니다.)
const v1Prompt = `
다음 글을 요약해주세요:
제목: ${item.title}
내용: ${item.content}
`;
// 결과:
// - 형식이 제각각
// - 때론 영어, 때론 한국어
// - 키워드가 없거나 너무 많음
// - 일관성 전혀 없음
문제점:
- 응답 형식 불명확
- 언어 혼재
- 출력 길이 제어 불가
제가 원했던 결과와는 달리 영문으로 출력되는 현상이 지속적으로 발생했습니다.

AI 요약 서비스가 동작하지 않아서 이런 식으로 원본 데이터의 일부를 표현하게 된 경우도 있었습니다.

두 번째 시도 (JSON 규격 제공)
- v2: JSON 요청 (이 경우에는 오히려 JSON 규격을 맞추지 못해 실패가 더 늘었습니다.)
const v2Prompt = `
다음 글을 JSON 형식으로 요약:
{
"title": "번역된 제목",
"summary": "요약",
"keywords": ["키워드"]
}
글:
${item.content}
`;
// 결과:
// ```json
// {
// "title": "AI in Education" // 번역 안 됨!
// "summary": "This article..." // 영어로 응답!
// "keywords": ["AI", "Education", "Technology", "Learning", "Future"]
// // 너무 많음!
// }
// ```
- "더 자세히" ≠ "더 좋음" 이것은 다른 것이었습니다. JSON모양으로 응답을 요청했다고 자동으로 잘 되는 게 아니었습니다.
세 번째 시도 (명확한 규칙 제공)
- v3: 명확한 규칙 (규칙을 제공하니 이전보다 많은 성공을 한다는 것을 확인했습니다.)
const v3Prompt = `
다음 규칙에 따라 요약해주세요:
규칙:
1. 제목을 한국어로 번역하세요
2. 요약은 3문장으로 작성하세요
3. 키워드는 정확히 3-5개를 추출하세요
4. JSON 형식으로 응답하세요
입력:
제목: ${item.title}
내용: ${item.content}
출력:
{
"translatedTitle": "...",
"summary": "...",
"keywords": [...]
}
`;
// 결과: 조금 나아짐
// 하지만 여전히 40%는 실패
문제:
- 규칙만으론 부족
- AI가 "3 문장"을 다르게 해석
- 키워드 개수 틀림
이전보다는 성공률이 많이 높아졌지만 아직도 키워드를 제대로 추출하지 못하는 현상이 발생했습니다.

네 번째 시도 (예시 제공)
- v4: 예시 제공 (예시를 제공했더니 상당히 퀄리티가 높아졌습니다.)
const v4Prompt = `
다음 예시처럼 정확히 응답하세요.
예시 입력:
제목: "How AI is Transforming Corporate Learning"
내용: "Artificial intelligence is revolutionizing..."
예시 출력:
{
"translatedTitle": "AI가 기업 학습을 혁신하는 방법",
"summary": "AI 기반 학습 플랫폼이 기업 교육의 효율성을 30% 향상시키고 있습니다. 개인화된 학습 경로 제공으로 직원 만족도가 증가하고 있으며, 실시간 피드백 시스템이 학습 효과를 극대화합니다.",
"keywords": ["AI", "기업교육", "학습혁신"]
}
실제 입력:
제목: "${item.title}"
내용: "${item.content}"
출력:
`;
// 결과: 대폭 개선!
// AI는 설명보다 예시를 더 잘 따라함
- 예시를 제공하는 게 최고다..!!
prompt에 응답 예시를 제공했더니 좀 더 완벽한 응답을 반환해주었습니다.

다섯 번째 시도 (전처리 추가)
- v5: 입력 전처리 (전처리를 했더니 거의 모든 작업에 성공했고 퀄리티도 높아졌습니다.)
// 전처리 함수
function preprocessContent(content: string): string {
return content
.replace(/<[^>]*>/g, '') // HTML 태그 제거
.replace(/\s+/g, ' ') // 공백 정리
.replace(/[^\w\sㄱ-힣.,!?-]/g, '') // 특수문자 제거 추가
.slice(0, 2000); // 2000자 제한 근거?
// → Gemini context window 고려
// → 요약 품질과 비용 밸런스
}
const v5Prompt = `
[시스템 설정]
- 역할: 전문 L&D 큐레이터
- 언어: 모든 응답은 반드시 한국어
- 형식: 엄격한 JSON
[예시]
입력: "How AI is Transforming Corporate Learning"
출력:
{
"translatedTitle": "AI가 기업 학습을 혁신하는 방법",
"summary": "AI 기반 학습 플랫폼이 효율성을 30% 향상. 개인화된 학습 경로로 만족도 증가. 실시간 피드백으로 효과 극대화.",
"keywords": ["AI", "기업교육", "학습혁신"],
"insight": "휴넷도 AI 개인화 학습 시스템 도입 검토 필요."
}
[규칙]
1. summary: 마침표로 구분된 3문장, 각 30자 내외
2. keywords: 3-5개, 각 10자 이하
3. insight: 1문장, "휴넷"으로 시작
[입력]
${preprocessContent(item.content)}
[출력]
`;
// 결과: 전처리가 핵심이었다!
// "쓰레기를 넣으면 쓰레기가 나온다" (GIGO 원칙)
- 전처리가 핵심이었습니다. 쓰레기를 넣으면 쓰레기가 나온다는 GIGO(Garbage In, Garbage Out) 원칙이 떠올랐습니다.
이번 버전에서는 전처리도 하고 시사점을 받을 수 있도록 프롬프트도 추가했더니 좀 더 완벽한 응답이 되었습니다.

최종 버전 (완성)
- v6: 안전한 폴백 (폴백까지 넣으니 서비스중단 한번 없이 거의 모든 작업에 성공했습니다.)
async function processWithAI(item: RssItem): Promise<Summary> {
const prompt = buildPrompt(item);
try {
// 1차 시도
const response = await gemini.generate(prompt);
const parsed = JSON.parse(response);
// 검증
if (validate(parsed)) {
return parsed;
}
// 2차 시도 (재생성)
const response2 = await gemini.generate(prompt);
const parsed2 = JSON.parse(response2);
if (validate(parsed2)) {
return parsed2;
}
// 폴백
return createFallback(item);
} catch (error) {
logger.error('AI 처리 실패:', error);
// 폴백: 기본 요약
return createFallback(item);
}
}
function createFallback(item: RssItem): Summary {
return {
translatedTitle: item.title.slice(0, 100),
summary: `${item.content.slice(0, 150)}...`,
keywords: extractKeywords(item.content),
insight: '상세 내용은 원문을 참조하세요.',
};
}
폴백으로는 L&D 단어 사전을 추가했습니다.

마지막으로 그날 요약된 데이터를 읽기 좋게 분석해 주는 '종합 분석'을 추가하였습니다.
- 저희 회사인 휴넷에 특화된 설명을 하도록 prompt를 주입해서 분석을 진행했습니다.

마무리하며: Prompt 엔지니어링을 통해 배운 점
이번 포스팅의 마무리는 제가 Prompt 엔지니어링을 통해 배운 점을 정리해 봤습니다.
1. "JSON으로 응답해 주세요" 만으론 안 된다.
프롬프트에 "JSON 형식으로 응답해 주세요"라고 요청하면 잘 작동할 거라 생각했습니다. 하지만 결과는 참담했습니다. 오히려 성공률이 떨어졌습니다. AI는 JSON 형식은 맞췄지만, 필드명을 제멋대로 바꾸거나 배열에 예상보다 훨씬 많은 요소를 넣는 식으로 응답했습니다.
그렇기에 단순히 형식만 요청하는 것이 아니라, 각 필드의 정확한 이름과 데이터 타입, 그리고 구체적인 제약사항까지 명시해야 했습니다. "keywords는 3~5개의 문자열 배열"처럼 구체적으로 작성하니 비로소 원하는 결과를 얻을 수 있었습니다.
2. 예시가 설명보다 강력하다
규칙을 아무리 자세히 적어도 AI는 종종 엉뚱하게 해석했습니다. 하지만 예시를 하나 추가하자 성능이 극적으로 개선되었습니다. "이렇게 해주세요"라고 백 번 설명하는 것보다 "이런 예시처럼 해주세요"라고 한 번 보여주는 것이 훨씬 효과적이었습니다.
특히 입력과 출력을 쌍으로 제시한 예시가 가장 좋았습니다. AI는 패턴을 학습하는 데 탁월하기 때문에, 좋은 예시 하나가 수십 줄의 규칙보다 강력했습니다.
3. 입력 데이터 전처리가 생각보다 중요하다
"쓰레기를 넣으면 쓰레기가 나온다"는 말을 실감했습니다. RSS 피드에는 HTML 태그, 이상한 공백, 특수문자 등이 잔뜩 섞여 있었습니다. 이런 데이터를 그대로 AI에게 넘기면 요약 품질이 형편없었습니다.
전처리 단계를 추가해서 HTML 태그를 제거하고, 공백을 정리하고, 적절한 길이로 잘라낸 후 AI에게 전달하니 결과가 확연히 달라졌습니다. 프롬프트를 개선하는 것만큼이나, 입력 데이터를 정제하는 것이 중요했습니다.
4. 폴백 전략은 필수다
아무리 프롬프트를 개선해도 100% 성공은 불가능했습니다. API 타임아웃, JSON 파싱 에러, 예상치 못한 응답 등 다양한 이유로 실패가 발생했습니다. 처음에는 실패하면 그냥 건너뛰었는데, 사용자가 받은 Slack 메시지에는 뉴스가 빠진 것처럼 보여 좋지 않았습니다.
폴백 로직을 구현해서, AI 처리가 실패하면 지정한 데이터(오늘의 단어사전) 같은 내용을 보여주도록 했습니다. 완벽하진 않지만 "아무것도 없는 것"보다는 훨씬 나았습니다. 폴백의 품질이 곧 사용자 경험을 좌우한다는 것을 배웠습니다.
5. 엣지 케이스는 실전에서만 나타난다
테스트 데이터로는 아무리 열심히 테스트해도 실제 운영에서 만나는 문제를 다 발견할 수 없었습니다. 수학 수식(Σ, ∫)이 포함된 글, 이모지가 잔뜩 들어간 제목, HTML 태그가 완전히 제거되지 않은 내용 등 예상치 못한 케이스들이 계속 나타났습니다.
결국 운영 데이터를 모니터링하면서 실패 케이스를 하나씩 수집하고, 그에 맞춰 프롬프트와 전처리 로직을 개선해 나가는 수밖에 없었습니다. 완벽한 프롬프트는 없으며, 지속적인 개선이 필요하다는 것을 깨달았습니다.
Q&A
Q1. 프롬프트 테스트를 어떻게 자동화했나요?
A. 샘플 RSS 데이터 20개를 준비하고, 각 버전의 프롬프트로 처리 후 결과를 자동 검증하는 스크립트를 만들었습니다. JSON 파싱 성공 여부, 필수 필드 존재 여부, 길이 제약 등을 자동으로 체크했습니다.
Q2. Template Method 패턴이 과하지 않나요?
A. 카테고리가 3개뿐이라면 과할 수 있습니다. 하지만 우리는 10개 이상 확장 계획이 있었고, 실제로 지금 5개까지 늘리기 위해 카테고리를 고민 중인 상태입니다. 초기 투자는 컸지만, 장기적으론 훨씬 효율적이라고 볼 수 있습니다.
Q3. Gemini 무료 티어 제한은 어떻게 관리하나요?
A. 3달 동안 300달러 크레딧을 무료로 제공하기 때문에 여유롭게 작업할 수 있었습니다. 그렇다 하더라도 Google AI Studio에서 지속적으로 API 비용 체크가 필요합니다. 언제 토큰이 훅 날아갈지 모르기 때문이죠.. 처음 제가 무료 크레딧 제공이 있는지 모르고 무료 API를 사용했을 때는 테스트 중 로직 오류가 발생하면 배치로 수십 번의 요청이 날아가서 API호출 제한에 걸리곤 했습니다.
Q4. 프롬프트 v6까지 만드는 데 얼마나 걸렸나요?
A. 약 10일입니다. 주중 저녁과 주말에 하루 종일 테스트하고 개선했습니다. 지루했지만, 완벽해지는 응답을 보며 "해낼 만했다"라고 생각했습니다.
재밌게 보셨다면 다음편도 읽어주세요!
[시리즈 3/3] 매일 아침 AI가 뉴스를 큐레이션하는 법 - 성능 최적화와 운영 노하우
시작하며안녕하세요. 개발자 stark입니다!지난 1편에서는 문제 발견과 기술 선택을, 2편에서는 아키텍처 설계와 프롬프트 엔지니어링을 다뤘습니다. [시리즈 1/3] 매일 아침 8:30 자동으로 도착하는
curiousjinan.tistory.com
'AI' 카테고리의 다른 글
| [시리즈 3/3] 매일 아침 AI가 뉴스를 큐레이션하는 법 - 성능 최적화와 운영 노하우 (0) | 2025.10.28 |
|---|---|
| [시리즈 1/3] 매일 아침 8:30 자동으로 도착하는 AI 뉴스 큐레이션 (문제 정의 및 기술 선택) (0) | 2025.10.26 |
| [바이브 코딩] #6: Monkeys 프로젝트 최종 보고 (4) | 2025.08.24 |
| [바이브 코딩] #5: 실전 Day 2-3 - 백엔드 완성 그리고 배포 (3) | 2025.08.24 |
| [바이브 코딩] #4: 실전 Day 1 - 아이디어부터 화면까지 (4) | 2025.08.24 |