최신 트렌드

AI 콘텐츠 자기 학습 루프 - X 메트릭을 GPT에 피드백해 다음 주 콘텐츠를 자동 개선하는 시스템

백엔드 개발자 김승원 2026. 4. 30. 11:59

들어가며

지난 4편의 영상 자동화 시리즈로 "AI 영상 콘텐츠를 사람 손 없이 매주 자동 게시하는 파이프라인"을 완성했습니다. 그런데 막상 6주 정도 굴려보면 새로운 문제가 보입니다 — 콘텐츠가 점점 비슷해집니다. 같은 톤, 비슷한 BGM, 비슷한 주제. 더 본질적으로는, "이 영상이 잘 됐는지"에 대한 신호가 다음 주 콘텐츠에 반영되지 않습니다.

이 글은 마지막 퍼즐을 다룹니다 — X(트위터) 메트릭을 자동 수집해 GPT에 피드백하고, 다음 주 콘텐츠를 자동 개선하는 자기 학습 루프입니다. "콘텐츠 → 지표 수집 → 분석 → 다음 콘텐츠 개선"이 한 사이클로 도는 진짜 무인 운영의 모습을 코드로 풀어냅니다.

핵심 도구는 세 가지입니다.

  • X API v2 metrics endpoint: 임프레션·좋아요·리트윗·댓글·프로필 클릭을 한 호출로 수집
  • SQLite + 시계열 저장: 외부 DB 없이 GitHub Actions runner에서도 굴러가는 경량 분석 스토어
  • GPT-5.5 with Structured Outputs: 사람이 보는 분석 리포트가 아닌, "다음 주 프롬프트" 형태로 직접 출력

1. 전체 루프 구조

완성된 자기 학습 루프의 흐름입니다.

[월요일 10:00] 영상 게시 (4편 시스템)
      ↓
[24시간 후 - 화요일 10:00] 1차 메트릭 수집
[7일 후 - 다음 월요일 09:00] 최종 메트릭 수집
      ↓
[GPT에 피드백]
  - 지난 4주 모든 게시물의 메트릭
  - 톤·주제·시간대별 성과 분포
  - "무엇이 잘됐는가"
      ↓
[다음 주 콘텐츠 프롬프트 생성]
      ↓
[월요일 10:00] 새 영상 게시 → 다시 루프

핵심은 지표 측정 시점입니다. 게시 직후엔 데이터가 적어 의미 없고, 7일 이상 지나면 다음 콘텐츠 결정에 너무 늦습니다. 보통 24시간 후가 "단기 반응", 7일 후가 "누적 성과"의 갈림길입니다.

2. X API v2로 메트릭 수집

X API v2는 "public metrics"와 "organic metrics" 두 종류를 제공합니다. 자기 콘텐츠 분석에는 organic이 필요하며, OAuth 1.0a 사용자 컨텍스트 인증이 필수입니다.

가져올 수 있는 메트릭

메트릭 의미 유효 시점
impression_count 노출 횟수 실시간 (1시간 내 안정)
like_count 좋아요 실시간
retweet_count 리트윗 실시간
reply_count 댓글 실시간
quote_count 인용 트윗 실시간
bookmark_count 북마크 10분 지연
profile_clicks 프로필 이동 30분 지연
url_link_clicks 외부 링크 클릭 30분 지연
video_view_count 영상 재생 (50% 이상) 1시간 지연

메트릭 가져오는 코드

// src/collect-metrics.js
import { TwitterApi } from 'twitter-api-v2';

const client = new TwitterApi({
  appKey: process.env.X_API_KEY,
  appSecret: process.env.X_API_SECRET,
  accessToken: process.env.X_ACCESS_TOKEN,
  accessSecret: process.env.X_ACCESS_SECRET,
});

export async function fetchMetrics(tweetIds) {
  const tweets = await client.v2.tweets(tweetIds, {
    'tweet.fields': ['organic_metrics', 'created_at', 'text'],
    'media.fields': ['organic_metrics', 'duration_ms'],
    expansions: ['attachments.media_keys']
  });

  return tweets.data.map(t => ({
    id: t.id,
    text: t.text,
    createdAt: t.created_at,
    impressions: t.organic_metrics.impression_count,
    likes: t.organic_metrics.like_count,
    retweets: t.organic_metrics.retweet_count,
    replies: t.organic_metrics.reply_count,
    quotes: t.organic_metrics.quote_count,
    bookmarks: t.organic_metrics.bookmark_count,
    profileClicks: t.organic_metrics.user_profile_clicks,
    urlClicks: t.organic_metrics.url_link_clicks
  }));
}

// 영상 메트릭 (재생수)는 별도 처리
export async function fetchVideoMetrics(mediaKey) {
  const res = await client.v2.get(`media/${mediaKey}`, {
    'media.fields': ['organic_metrics']
  });
  return {
    views: res.data.organic_metrics.view_count,
    durationMs: res.data.duration_ms
  };
}

한 번에 100개까지 배치 처리

X API v2의 GET /tweets endpoint는 ids 파라미터로 최대 100개를 한 번에 가져올 수 있습니다. 100개 게시물 메트릭 수집이 1번의 API 호출로 끝나므로 rate limit 부담이 거의 없습니다.

// 4주분(28개)도 한 번에 수집 가능
const recentIds = await db.all('SELECT tweet_id FROM posts ORDER BY published_at DESC LIMIT 100');
const metrics = await fetchMetrics(recentIds.map(r => r.tweet_id));

3. SQLite로 시계열 저장

"자기 학습"의 핵심은 메트릭의 변화 추적입니다. 단일 시점 데이터로는 "이 시간대가 좋다"를 절대 알 수 없습니다. 24시간 후, 7일 후를 각각 저장해야 합니다.

스키마

-- posts 테이블 (게시 기록)
CREATE TABLE posts (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  tweet_id      TEXT UNIQUE NOT NULL,
  text          TEXT NOT NULL,
  topic         TEXT NOT NULL,           -- 'productivity', 'tech_news' 등
  tone          TEXT NOT NULL,           -- 'casual', 'expert', 'humorous'
  bgm_style     TEXT NOT NULL,           -- 'upbeat', 'chill', 'dramatic'
  posted_at     TIMESTAMP NOT NULL,
  posted_hour   INTEGER NOT NULL,        -- 0~23 (KST)
  posted_dow    INTEGER NOT NULL         -- 0=일~6=토
);

-- metrics_snapshots 테이블 (시점별 스냅샷)
CREATE TABLE metrics_snapshots (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id         INTEGER NOT NULL,
  collected_at    TIMESTAMP NOT NULL,
  hours_after     INTEGER NOT NULL,      -- 24, 168 같은 단위
  impressions     INTEGER, likes INTEGER, retweets INTEGER,
  replies         INTEGER, quotes INTEGER, bookmarks INTEGER,
  profile_clicks  INTEGER, url_clicks INTEGER, video_views INTEGER,
  FOREIGN KEY (post_id) REFERENCES posts(id),
  UNIQUE(post_id, hours_after)
);

CREATE INDEX idx_snapshots_post ON metrics_snapshots(post_id);

스냅샷 업서트

// src/save-snapshot.js
import Database from 'better-sqlite3';
const db = new Database('./data/blog.db');

export function saveSnapshot(postId, hoursAfter, m) {
  db.prepare(`
    INSERT INTO metrics_snapshots
      (post_id, collected_at, hours_after, impressions, likes, retweets,
       replies, quotes, bookmarks, profile_clicks, url_clicks, video_views)
    VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(post_id, hours_after) DO UPDATE SET
      collected_at = excluded.collected_at,
      impressions = excluded.impressions,
      likes = excluded.likes,
      retweets = excluded.retweets,
      replies = excluded.replies,
      quotes = excluded.quotes,
      bookmarks = excluded.bookmarks,
      profile_clicks = excluded.profile_clicks,
      url_clicks = excluded.url_clicks,
      video_views = excluded.video_views
  `).run(
    postId, hoursAfter,
    m.impressions, m.likes, m.retweets, m.replies, m.quotes,
    m.bookmarks, m.profileClicks, m.urlClicks, m.videoViews
  );
}

왜 PostgreSQL이 아닌 SQLite인가?

관점 SQLite PostgreSQL
운영 비용 $0 (파일 1개) 월 $7~ (관리형)
GitHub Actions 호환 완벽 (workflow에 commit) 외부 connect 필요
4주분 데이터 크기 <100KB same
동시성 읽기 동시 OK, 쓰기 1개 훨씬 우수
분석 쿼리 성능 10만 행까지 무난 훨씬 우수

1인 콘텐츠 운영 규모(주 1게시 = 연 52건)에서는 SQLite가 압도적으로 유리합니다. data/blog.db 파일을 워크플로우 끝에 git commit·push하면 자동으로 "분석 데이터 백업"이 됩니다.

4. GPT-5.5에 분석 위임 - Structured Outputs로 직접 프롬프트 받기

옛날 방식은 "GPT에게 분석 리포트를 받아서 사람이 읽고 다음 주 프롬프트를 짠다"였습니다. 우리는 사람을 빼는 게 목표이므로, GPT가 다음 주 프롬프트를 직접 출력하게 만듭니다.

분석용 데이터 집계 쿼리

// 톤·주제·시간대별 7일 임프레션 평균
const summary = db.prepare(`
  SELECT
    p.topic, p.tone, p.bgm_style, p.posted_hour, p.posted_dow,
    AVG(s.impressions) AS avg_impressions,
    AVG(s.likes) AS avg_likes,
    AVG(CAST(s.likes AS REAL) / NULLIF(s.impressions, 0)) AS engagement_rate,
    COUNT(*) AS sample_size
  FROM posts p
  JOIN metrics_snapshots s ON s.post_id = p.id AND s.hours_after = 168
  WHERE p.posted_at >= datetime('now', '-28 days')
  GROUP BY p.topic, p.tone, p.bgm_style, p.posted_hour, p.posted_dow
  HAVING sample_size >= 1
  ORDER BY engagement_rate DESC
`).all();

GPT-5.5 호출 - JSON Schema로 결과 강제

// src/optimize-prompts.js
import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const schema = {
  type: 'object',
  properties: {
    diagnosis: {
      type: 'object',
      properties: {
        winning_topics: { type: 'array', items: { type: 'string' } },
        underperforming_topics: { type: 'array', items: { type: 'string' } },
        best_hour: { type: 'integer', minimum: 0, maximum: 23 },
        best_tone: { type: 'string' },
        engagement_trend: { type: 'string', enum: ['rising', 'flat', 'declining'] }
      },
      required: ['winning_topics', 'underperforming_topics', 'best_hour', 'best_tone', 'engagement_trend']
    },
    next_week: {
      type: 'object',
      properties: {
        topic: { type: 'string' },
        tone: { type: 'string' },
        bgm_style: { type: 'string' },
        publish_hour: { type: 'integer' },
        rationale: { type: 'string' },
        ab_variant: {
          type: 'object',
          properties: { topic: { type: 'string' }, tone: { type: 'string' } }
        }
      },
      required: ['topic', 'tone', 'bgm_style', 'publish_hour', 'rationale']
    }
  },
  required: ['diagnosis', 'next_week']
};

export async function optimizeNextWeek(summary) {
  const response = await openai.chat.completions.create({
    model: 'gpt-5.5',
    response_format: {
      type: 'json_schema',
      json_schema: { name: 'optimization', schema, strict: true }
    },
    messages: [
      {
        role: 'system',
        content: '당신은 소셜미디어 콘텐츠 분석가입니다. 데이터에 기반한 결정만 내리세요. 표본이 적으면 보수적으로 판단하세요.'
      },
      {
        role: 'user',
        content: `최근 4주 콘텐츠 성과 데이터(7일 누적):\n\n${JSON.stringify(summary, null, 2)}\n\n다음 주 콘텐츠 전략을 제안하세요. \"engagement_rate\"가 높은 조합을 우선하되, 표본이 1~2개인 조합은 신뢰하지 마세요. 다양성도 유지해야 합니다.`
      }
    ]
  });

  return JSON.parse(response.choices[0].message.content);
}

실제 GPT 응답 예시 (운영 6주차 데이터)

{
  "diagnosis": {
    "winning_topics": ["productivity_tools", "ai_news"],
    "underperforming_topics": ["general_tech"],
    "best_hour": 19,
    "best_tone": "casual",
    "engagement_trend": "rising"
  },
  "next_week": {
    "topic": "productivity_tools",
    "tone": "casual",
    "bgm_style": "upbeat",
    "publish_hour": 19,
    "rationale": "productivity_tools에서 평균 engagement 4.2%를 기록 (전체 평균 2.1%의 2배). 19시 게시는 10시 대비 임프레션 1.8배. casual 톤이 expert 대비 좋아요 1.3배.",
    "ab_variant": {
      "topic": "ai_news",
      "tone": "casual"
    }
  }
}

5. 다음 주 콘텐츠 자동 적용

GPT가 출력한 결정을 4편 시스템에 그대로 주입합니다. 기존 generate-script.js를 약간 수정하면 됩니다.

// pipeline.js (수정본)
import { optimizeNextWeek } from './src/optimize-prompts.js';
import { collectAndSaveMetrics } from './src/collect-metrics.js';

async function main() {
  // [0] 지난 게시물들 메트릭 갱신
  await collectAndSaveMetrics();

  // [1] GPT가 다음 주 전략 결정
  const summary = aggregateLastFourWeeks();
  const decision = await optimizeNextWeek(summary);
  console.log('이번 주 결정:', decision.next_week);

  // [2] 결정된 톤·주제·BGM으로 스크립트 생성
  const script = await generateScript({
    topic: decision.next_week.topic,
    tone: decision.next_week.tone
  });

  // [3] 영상 생성 (4편과 동일)
  const clips = await generateClips(script.shots);

  // [4] BGM 선택을 GPT 결정에 따라
  const bgmFile = `assets/bgm/${decision.next_week.bgm_style}.mp3`;
  const finalVideo = await builder.build({
    clips, bgm: bgmFile, logo: 'assets/logo.png',
    outputPath: 'work/final.mp4'
  });

  // [5] 게시 + DB 기록
  const tweetId = await publishToX({ videoPath: finalVideo, text: script.tweet });
  db.prepare(`
    INSERT INTO posts (tweet_id, text, topic, tone, bgm_style, posted_at, posted_hour, posted_dow)
    VALUES (?, ?, ?, ?, ?, datetime('now'), ?, ?)
  `).run(
    tweetId, script.tweet, decision.next_week.topic,
    decision.next_week.tone, decision.next_week.bgm_style,
    new Date().getHours(), new Date().getDay()
  );
}

6. A/B 테스트로 결정 검증

위 GPT 결정만 무한 반복하면 "성과 좋은 한 가지 패턴"에만 갇힙니다(echo chamber). 매 4주에 1번은 GPT가 제안한 ab_variant를 의도적으로 게시해서 다양성을 확보합니다.

// 4번에 1번은 변형 시도
const weekIndex = Math.floor((Date.now() - START_DATE) / (7 * 86400 * 1000));
const useVariant = weekIndex % 4 === 3 && decision.next_week.ab_variant;

const chosen = useVariant
  ? { ...decision.next_week, ...decision.next_week.ab_variant }
  : decision.next_week;

console.log(useVariant ? '🧪 A/B 변형 게시' : '✓ 메인 전략 게시');

4주마다 1주를 "실험"에 쓰면 매년 13번의 새 데이터 포인트가 생깁니다. 이게 없으면 알고리즘이 변할 때 같이 못 따라갑니다.

7. 부정적 피드백 안전장치

자기 학습 루프의 가장 큰 위험은 "숨은 실패"입니다. 메트릭만 보다가 사람들이 싫어하는 콘텐츠를 계속 만들 수 있습니다.

(1) 댓글 감성 분석

좋아요는 많은데 댓글 톤이 부정적이면 위험 신호입니다. GPT-5.5에 댓글을 넘겨 sentiment를 측정합니다.

const replies = await client.v2.search(`conversation_id:${tweetId}`, {
  max_results: 100,
  'tweet.fields': ['text', 'public_metrics']
});

const sentiment = await openai.chat.completions.create({
  model: 'gpt-5.5',
  response_format: {
    type: 'json_schema',
    json_schema: {
      name: 'sentiment',
      schema: {
        type: 'object',
        properties: {
          positive_ratio: { type: 'number' },
          negative_ratio: { type: 'number' },
          flagged_themes: { type: 'array', items: { type: 'string' } }
        }
      },
      strict: true
    }
  },
  messages: [{
    role: 'user',
    content: `다음 댓글들의 감성을 분석하세요:\n${replies.data.map(r => r.text).join('\n---\n')}`
  }]
});

// negative_ratio > 0.4면 해당 주제 일시 차단
if (parsed.negative_ratio > 0.4) {
  await flagTopicForReview(currentTopic, parsed.flagged_themes);
}

(2) 메트릭 급락 알림

지난 4주 평균 대비 50% 미만 임프레션이면 즉시 사람을 호출합니다. 알고리즘 변동·계정 제재·콘텐츠 문제 어느 쪽이든 사람 판단이 필요합니다.

const recent = db.prepare(`
  SELECT AVG(impressions) FROM metrics_snapshots
  WHERE hours_after = 24 AND post_id IN (
    SELECT id FROM posts WHERE posted_at >= datetime('now','-7 days')
  )
`).get();

const baseline = db.prepare(`
  SELECT AVG(impressions) FROM metrics_snapshots
  WHERE hours_after = 24 AND post_id IN (
    SELECT id FROM posts WHERE posted_at BETWEEN datetime('now','-35 days') AND datetime('now','-7 days')
  )
`).get();

if (recent < baseline * 0.5) {
  await alertSlack(`⚠️ 임프레션 급락: ${recent} (baseline: ${baseline}). 사람 점검 필요.`);
  process.exit(2);  // workflow 중단, 다음 주는 게시 금지
}

(3) X 정책 위반 자동 감지

X는 공식 API로 "쉐도우밴 여부"를 알려주지 않습니다. 우회로는 "평소보다 노출이 0에 수렴하면 의심"입니다. 1주일 내 임프레션이 50 미만이면 거의 100% 정책 이슈입니다.

8. 운영 6주차 - 실제 학습 결과

이 시스템을 6주 굴리면 보통 이런 패턴이 나옵니다(샘플 데이터).

주차 평균 임프레션 engagement GPT 결정 변화
1주차 820 1.4% (초기 추측 - tech_news, expert tone)
2주차 900 1.6% 유지
3주차 1,100 1.9% productivity_tools 추가
4주차 1,400 2.4% tone을 casual로 전환
5주차 (A/B) 1,300/1,800 2.1%/3.0% ai_news 변형이 더 좋음
6주차 1,950 3.2% ai_news + casual + 19시로 수렴

6주 만에 임프레션 2.4배, engagement 2.3배. 사람이 매주 데이터를 보고 손으로 조정해도 비슷한 결과는 가능하지만, 사람의 시간이 0이라는 점이 결정적 차이입니다.

9. 흔한 함정과 해결법

(1) "표본 1~2개로 GPT가 잘못 결정해요"

운영 초기 2~3주는 데이터가 너무 적어 잘못된 결론에 도달하기 쉽습니다. 시스템 프롬프트에 "sample_size < 3이면 결정 보류"를 명시하고, 첫 4주는 수동 톤·주제 풀로 다양성 강제 주입을 권장합니다.

(2) "GPT가 매주 같은 결정만 내려요"

winning topic이 굳어지면 GPT는 그것만 추천합니다. 시스템 프롬프트에 "4주에 1번은 의도적으로 다른 조합을 시도해서 데이터를 넓혀라"를 추가하거나, 코드 레벨에서 ab_variant 로직을 강제합니다.

(3) "engagement_rate 분모가 0이라 NaN이 떠요"

새 게시물의 임프레션이 0인 시점에 24시간 메트릭을 수집하면 division by zero가 발생합니다. SQL에서 NULLIF(impressions, 0)로 보호하고, 분석 단에서도 impressions >= 100 같은 임계값으로 필터링하세요.

(4) "db 파일이 git에 커밋되니 conflict가 생겨요"

SQLite는 바이너리 포맷이라 머지 충돌이 풀리지 않습니다. 두 가지 옵션이 있습니다.

  • 옵션 A: 메인 브랜치에서만 자동화 워크플로우를 돌리고, 사람은 다른 브랜치에서 작업 (db 파일 보호)
  • 옵션 B: 매 워크플로우 끝에 db를 SQL dump(.dump)해서 텍스트로 커밋. 머지 시 충돌 해결 가능. 다음 워크플로우 시작 시 dump를 db로 복원.

(5) "GitHub Actions에서 db 파일 push가 권한 에러"

기본 GITHUB_TOKEN은 같은 워크플로우에서 push할 수 있지만, 같은 워크플로우를 다시 트리거하지는 못합니다. 이 글의 자기 학습 루프는 schedule cron으로 새로 트리거되므로 문제 없습니다. 단, push 시 persist-credentials: true (default)와 적절한 permissions 블록이 필요합니다.

permissions:
  contents: write   # db 파일 push 위해 필수

10. 다음 단계 - 외부 신호 통합

지금까지의 학습 루프는 자기 콘텐츠 데이터만 봅니다. 다음 단계는 외부 신호 통합입니다.

  • 경쟁 계정 트래킹: 같은 분야 인기 계정의 최근 인기 트윗 주제·톤 분석
  • 구글 트렌드 통합: 주간 검색 급상승 키워드를 GPT 분석에 추가
  • X 트렌드 API: 한국 트렌딩 토픽을 콘텐츠 결정 입력으로
  • 외부 RSS: 기술 뉴스 사이트 RSS를 매일 수집해 "오늘의 핫토픽" 풀 자동 갱신

이렇게 "자기 데이터 + 외부 데이터" 두 축이 합쳐지면 진짜 시장 변화에 적응하는 시스템이 됩니다. 한 가지만 보면 echo chamber, 둘 다 보면 진짜 학습입니다.

마치며

5편 시리즈로 "AI 영상 콘텐츠 자동화"의 마지막 단계까지 다뤘습니다.

  • 1편: 영상 AI 시장 정리 - 어떤 도구를 쓸 것인가
  • 2편: Grok Imagine API 통합 - 영상 생성
  • 3편: FFmpeg + Whisper 후처리 - 자막·BGM·합성
  • 4편: GitHub Actions 무인 운영 - 스케줄·시크릿·실패 대응
  • 5편 (이 글): 자기 학습 루프 - 메트릭 → GPT → 다음 주 자동 개선

핵심 정리:

  • X API v2 organic_metrics + SQLite 시계열 저장이면 시장 분석 인프라가 완성됩니다. 추가 비용 없음.
  • GPT Structured Outputs로 "분석 리포트가 아닌 다음 주 프롬프트"를 직접 출력받으면 사람 개입 0이 가능합니다.
  • A/B 테스트(4주에 1주)로 echo chamber를 방지하세요. 그렇지 않으면 시장 변화에 못 따라갑니다.
  • 안전장치 3가지: 댓글 감성 분석, 메트릭 급락 알림, 정책 위반 자동 감지. 무인 운영의 보호선입니다.
  • 운영 6주면 임프레션 2배, engagement 2배가 평균입니다. 그 이상은 외부 신호(트렌드·경쟁) 통합이 필요합니다.

5편 시리즈가 끝났습니다. 처음 "영상 한 편 만드는 데 2시간"이었던 작업이 마지막엔 "GPT가 다음 주를 알아서 결정하고 사람은 가끔 들여다본다"로 바뀌었습니다. 도구는 Grok Imagine + Whisper + FFmpeg + X API + GPT-5.5 + GitHub Actions + SQLite 7가지뿐입니다. 모두 합쳐 월 운영비 $10 이하입니다.

중요한 건 "100% 자동화"가 아니라 "레버리지 + 사람의 판단력"의 균형입니다. 자기 학습 루프는 90%를 자동화하지만, 마지막 10% — 콘텐츠 톤의 진정성, 정책 변화 대응, 새 도구 도입 결정 — 은 여전히 사람의 영역입니다. 다음 시리즈에서는 이 시스템에서 모은 데이터를 발판으로, 유료 광고(X Ads) 자동 집행까지 확장하는 방법을 다룰 예정입니다. "오가닉 데이터로 ROI를 알 수 있는 콘텐츠만 골라 광고비를 태우는" 의사결정을 시스템화하는 이야기입니다.