[Harness Engineering] 피드백 루프




에이전트에게 눈과 귀를 달아주다


컨텍스트 엔지니어링은 에이전트에게 규칙을 알려주고, 아키텍처 제약은 규칙 위반을 차단하고, 엔트로피 관리는 축적된 불일치를 정리한다. 그런데 이 세 가지가 갖춰져 있어도 여전히 빠져있는 것이 있다.

에이전트가 코드를 작성했다. 린터도 통과하고, 아키텍처 테스트도 통과했다. 그런데 실제로 브라우저에서 버튼을 클릭하면 아무 일도 일어나지 않는다. 또는 API 응답 시간이 3초나 걸린다. 또는 에러 메시지가 사용자에게 raw stack trace를 그대로 보여준다.

코드 수준에서는 문제가 없지만, 실행 수준에서 문제가 있다. 이 간극을 메우는 것이 피드백 루프다. 에이전트가 자신의 작업 결과를 직접 확인하고, 문제를 발견하면 스스로 수정하는 순환 구조를 만드는 것이다.


브라우저 자동화: 에이전트가 직접 눈으로 확인한다

Anthropic의 장기 실행 에이전트 실험에서 발견된 주요 실패 모드 중 하나는, 에이전트가 기능을 “완료”로 표시하면서 실제로는 제대로 동작하지 않는 것이었다. 단위 테스트는 통과하고, curl로 API를 호출하면 200이 돌아오지만, 실제 UI에서 사용자 흐름을 따라가면 깨지는 경우가 빈번했다.

해결책은 에이전트에게 브라우저를 쥐어주는 것이었다. Puppeteer MCP(Model Context Protocol)를 통해 에이전트가 브라우저를 직접 조작하고, 스크린샷을 찍고, DOM을 탐색할 수 있게 만들었다.

Puppeteer 기반 E2E 테스트 구조

// tests/e2e/auth-flow.test.ts

import puppeteer, { Browser, Page } from 'puppeteer';

describe('Authentication Flow - E2E', () => {
  let browser: Browser;
  let page: Page;

  beforeAll(async () => {
    browser = await puppeteer.launch({
      headless: true,
      args: ['--no-sandbox'],
    });
  });

  beforeEach(async () => {
    page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 720 });
  });

  afterEach(async () => {
    // 실패 시 스크린샷 저장 — 에이전트가 시각적으로 디버깅 가능
    const testName = expect.getState().currentTestName?.replace(/\s/g, '_');
    await page.screenshot({
      path: `reports/screenshots/${testName}.png`,
      fullPage: true,
    });
    await page.close();
  });

  afterAll(async () => {
    await browser.close();
  });

  test('회원가입 → 로그인 → 대시보드 진입 전체 흐름', async () => {
    const testEmail = `test-${Date.now()}@example.com`;
    const testPassword = 'SecureP@ss123';

    // 1. 회원가입 페이지 이동
    await page.goto('http://localhost:3000/signup');
    await page.waitForSelector('[data-testid="signup-form"]');

    // 2. 회원가입 폼 입력
    await page.type('[data-testid="email-input"]', testEmail);
    await page.type('[data-testid="password-input"]', testPassword);
    await page.type('[data-testid="password-confirm-input"]', testPassword);
    await page.click('[data-testid="signup-button"]');

    // 3. 성공 메시지 확인
    await page.waitForSelector('[data-testid="signup-success"]', { timeout: 5000 });
    const successText = await page.$eval(
      '[data-testid="signup-success"]',
      el => el.textContent
    );
    expect(successText).toContain('가입이 완료되었습니다');

    // 4. 로그인 페이지로 이동
    await page.goto('http://localhost:3000/login');
    await page.waitForSelector('[data-testid="login-form"]');

    // 5. 로그인
    await page.type('[data-testid="email-input"]', testEmail);
    await page.type('[data-testid="password-input"]', testPassword);
    await page.click('[data-testid="login-button"]');

    // 6. 대시보드 진입 확인
    await page.waitForSelector('[data-testid="dashboard"]', { timeout: 5000 });
    const url = page.url();
    expect(url).toContain('/dashboard');

    // 7. 사용자 정보 표시 확인
    const userEmail = await page.$eval(
      '[data-testid="user-email"]',
      el => el.textContent
    );
    expect(userEmail).toBe(testEmail);
  });

  test('잘못된 비밀번호로 로그인 시 에러 메시지 표시', async () => {
    await page.goto('http://localhost:3000/login');
    await page.waitForSelector('[data-testid="login-form"]');

    await page.type('[data-testid="email-input"]', 'user@example.com');
    await page.type('[data-testid="password-input"]', 'wrongpassword');
    await page.click('[data-testid="login-button"]');

    // 에러 메시지 확인 (stack trace가 아닌 사용자 친화적 메시지)
    await page.waitForSelector('[data-testid="login-error"]', { timeout: 5000 });
    const errorText = await page.$eval(
      '[data-testid="login-error"]',
      el => el.textContent
    );
    expect(errorText).toContain('이메일 또는 비밀번호가 올바르지 않습니다');
    expect(errorText).not.toContain('Error:');
    expect(errorText).not.toContain('stack');
  });
});

핵심은 에이전트가 코드만 보는 것이 아니라, 코드가 실행된 결과를 사용자 관점에서 확인한다는 점이다. 스크린샷은 실패 시 자동 저장되므로, 에이전트는 시각적으로 무엇이 잘못되었는지 파악하고 수정할 수 있다.

스냅샷 비교를 통한 회귀 감지

새 기능을 추가할 때 기존 UI가 깨지지 않았는지 확인하는 시각적 회귀 테스트도 피드백 루프의 일부다.

// tests/e2e/visual-regression.test.ts

import { toMatchImageSnapshot } from 'jest-image-snapshot';

expect.extend({ toMatchImageSnapshot });

test('대시보드 레이아웃이 변경되지 않았다', async () => {
  await page.goto('http://localhost:3000/dashboard');
  await page.waitForSelector('[data-testid="dashboard"]');

  // 동적 콘텐츠 마스킹 (날짜, 숫자 등)
  await page.evaluate(() => {
    document.querySelectorAll('[data-testid="dynamic-content"]').forEach(el => {
      (el as HTMLElement).style.visibility = 'hidden';
    });
  });

  const screenshot = await page.screenshot({ fullPage: true });

  expect(screenshot).toMatchImageSnapshot({
    failureThreshold: 0.05,        // 5% 이내 차이는 허용
    failureThresholdType: 'percent',
    customSnapshotsDir: 'tests/e2e/__snapshots__',
  });
});

관찰 가능성: 에이전트가 로그와 메트릭을 직접 쿼리한다

OpenAI의 Codex 실험에서 인상적이었던 부분은, 에이전트에게 관찰 가능성 도구를 직접 연결한 것이다. Chrome DevTools Protocol로 브라우저 상태를 보는 것에 더해, 로그와 메트릭을 직접 쿼리할 수 있는 환경을 구축했다.

개발 환경용 관찰 스택

# docker-compose.observability.yml

version: '3.8'
services:
  # 로그 수집 및 쿼리
  loki:
    image: grafana/loki:2.9.0
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml

  # 메트릭 수집 및 쿼리
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  # 대시보드 (선택적 — 인간 확인용)
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3001:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

에이전트가 사용하는 관찰 스크립트

// scripts/observability/check-performance.ts

interface HealthCheckResult {
  service: string;
  status: 'healthy' | 'degraded' | 'unhealthy';
  responseTimeMs: number;
  details: Record<string, any>;
}

async function checkServiceHealth(url: string): Promise<HealthCheckResult> {
  const start = performance.now();
  try {
    const response = await fetch(url);
    const elapsed = performance.now() - start;
    const body = await response.json();

    return {
      service: url,
      status: elapsed < 500 ? 'healthy' : elapsed < 2000 ? 'degraded' : 'unhealthy',
      responseTimeMs: Math.round(elapsed),
      details: body,
    };
  } catch (error: any) {
    return {
      service: url,
      status: 'unhealthy',
      responseTimeMs: Math.round(performance.now() - start),
      details: { error: error.message },
    };
  }
}

async function queryMetrics(query: string): Promise<any> {
  const response = await fetch(
    `http://localhost:9090/api/v1/query?query=${encodeURIComponent(query)}`
  );
  return response.json();
}

async function queryLogs(query: string, since: string = '1h'): Promise<string[]> {
  const response = await fetch(
    `http://localhost:3100/loki/api/v1/query_range?query=${encodeURIComponent(query)}&since=${since}`
  );
  const data = await response.json();
  return data.data?.result?.flatMap(
    (stream: any) => stream.values.map((v: any) => v[1])
  ) || [];
}

// 실행 예시
async function main() {
  // 1. 서비스 헬스 체크
  console.log('🏥 서비스 헬스 체크');
  const health = await checkServiceHealth('http://localhost:3000/health');
  console.log(`  상태: ${health.status} (${health.responseTimeMs}ms)`);

  // 2. 응답 시간 메트릭 확인
  console.log('\n📊 응답 시간 메트릭 (최근 5분)');
  const latency = await queryMetrics(
    'histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))'
  );
  console.log(`  P95 응답 시간: ${latency.data?.result?.[0]?.value?.[1]}s`);

  // 3. 에러 로그 확인
  console.log('\n🔴 최근 에러 로그');
  const errors = await queryLogs('{level="error"}', '30m');
  if (errors.length > 0) {
    console.log(`  ${errors.length}건의 에러 발견:`);
    errors.slice(0, 5).forEach(log => console.log(`  - ${log}`));
  } else {
    console.log('  에러 없음');
  }

  // 4. 성능 임계값 확인
  if (health.responseTimeMs > 800) {
    console.log(`\n⚠️  응답 시간이 ${health.responseTimeMs}ms로 임계값(800ms)을 초과합니다.`);
    process.exit(1);
  }
}

main();

이 스크립트가 존재하면, “서비스 시작 시간을 800ms 이내로 만들어줘” 같은 프롬프트가 실행 가능한 작업이 된다. 에이전트가 현재 응답 시간을 측정하고, 코드를 수정하고, 다시 측정해서 목표를 달성할 때까지 반복할 수 있기 때문이다.

OpenAI의 실험에서는 단일 Codex 실행이 하나의 작업에 6시간 이상 집중하는 경우도 빈번했다. 에이전트에게 충분한 관찰 도구와 피드백 채널이 주어지면, 복잡한 성능 최적화 같은 작업도 자율적으로 수행할 수 있다.


CI 파이프라인: 모든 피드백의 통합 지점

개별 피드백 도구들을 하나로 엮는 것이 CI 파이프라인이다. 에이전트가 PR을 열면, 파이프라인이 자동으로 실행되면서 모든 검증을 한 번에 수행한다.

# .github/workflows/agent-pr-check.yml

name: Agent PR Check
on:
  pull_request:
    branches: [main]

jobs:
  # 1단계: 빠른 검증 (1~2분)
  quick-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: 타입 검사
        run: npx tsc --noEmit

      - name: 린터
        run: npx eslint src/ --max-warnings 0

      - name: 아키텍처 테스트
        run: npm run test:architecture

      - name: 순환 의존성 검사
        run: npx madge --circular --extensions ts src/

  # 2단계: 단위/통합 테스트 (3~5분)
  unit-tests:
    needs: quick-checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: 단위 테스트
        run: npm run test:unit -- --coverage

      - name: 커버리지 임계값 확인
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "❌ 테스트 커버리지 ${COVERAGE}%로 임계값(80%) 미달"
            exit 1
          fi
          echo "✅ 테스트 커버리지: ${COVERAGE}%"

      - name: 통합 테스트
        run: npm run test:integration

  # 3단계: E2E 테스트 (5~10분)
  e2e-tests:
    needs: unit-tests
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: 개발 서버 기동
        run: npm run dev &
        env:
          DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb

      - name: 서버 준비 대기
        run: npx wait-on http://localhost:3000/health --timeout 30000

      - name: E2E 테스트
        run: npm run test:e2e

      - name: 성능 확인
        run: npx ts-node scripts/observability/check-performance.ts

      - name: 스크린샷 아티팩트 저장
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-screenshots
          path: reports/screenshots/

  # 4단계: 엔트로피 체크 (2~3분)
  entropy-check:
    needs: quick-checks
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: 중복 코드 임계값 확인
        run: npx jscpd src/ --threshold 5 --exitCode 1

      - name: 죽은 코드 확인
        run: npx knip --no-exit-code
        continue-on-error: true

      - name: 문서-코드 일관성
        run: npx ts-node scripts/verify-doc-code-consistency.ts

이 파이프라인의 구조는 의도적으로 단계별로 나뉘어 있다.

**1단계(빠른 검증)**는 1~2분 안에 완료된다. 타입 에러, 린터 위반, 아키텍처 규칙 위반 같은 명백한 문제를 빠르게 잡아낸다. 여기서 실패하면 이후 단계는 실행되지 않는다. 에이전트는 빠른 피드백을 받고 즉시 수정할 수 있다.

**2단계(테스트)**는 기능적 정확성을 검증한다. 커버리지 임계값을 설정해서, 새 코드가 테스트 없이 머지되는 것을 방지한다.

**3단계(E2E)**는 실제 사용자 관점에서의 동작을 확인한다. 데이터베이스를 포함한 전체 스택을 기동하고, 브라우저 테스트를 실행한다. 실패 시 스크린샷이 아티팩트로 저장되어, 에이전트가 시각적으로 디버깅할 수 있다.

**4단계(엔트로피 체크)**는 2단계와 병렬로 실행되면서, PR이 코드베이스의 건강도를 악화시키지 않는지 확인한다.


피드백 루프의 순환 구조

지금까지 다룬 도구들을 연결하면, 에이전트의 작업 흐름은 다음과 같은 순환 구조를 형성한다.

1. 작업 시작
   └→ AGENTS.md 읽기 (컨텍스트)
   └→ progress.md 읽기 (진행 상태)
   └→ feature-list.json에서 다음 작업 선택

2. 코드 작성
   └→ 아키텍처 제약 범위 안에서 구현

3. 로컬 검증
   └→ 린터 실행 → 위반 시 수정
   └→ 단위 테스트 실행 → 실패 시 수정
   └→ 브라우저 테스트 실행 → 실패 시 수정
   └→ 성능 메트릭 확인 → 임계값 초과 시 최적화

4. 커밋 시도
   └→ pre-commit 훅 → 실패 시 3번으로 복귀

5. PR 생성
   └→ CI 파이프라인 실행
   └→ 모든 단계 통과 시 리뷰 요청
   └→ 실패 시 에러 로그 확인 → 3번으로 복귀

6. 세션 마무리
   └→ progress.md 업데이트
   └→ feature-list.json의 passes 필드 변경
   └→ git commit with descriptive message

이 순환이 핵심이다. 에이전트는 “코드를 쓰고 끝”이 아니라, “코드를 쓰고, 결과를 확인하고, 문제가 있으면 고치고, 다시 확인하는” 루프를 돈다. 피드백이 빠르고 자동화될수록, 이 루프는 더 빠르게 수렴한다.

OpenAI 팀이 관찰한 바로는, 피드백 루프가 잘 갖춰진 환경에서 에이전트의 작업 품질이 극적으로 향상되었다. 코드만 보고 “됐다”고 판단하는 것과, 브라우저에서 직접 확인하고 로그를 쿼리해서 검증하는 것은 완전히 다른 차원의 결과를 만든다.




댓글 남기기