본문으로 건너뛰기
고급약 45분

보고·로깅 설계 — 실패 하나를 5분 안에 진단 가능하게

자동화 실패 하나를 5분 안에 원인까지 좁히려면 리포트가 무엇을 담아야 하는지 설계한다. 실행 아티팩트(스크린샷·비디오·트레이스·HAR·콘솔·네트워크)의 수집 비용과 보관 정책, 상관 ID로 테스트 실행과 서버 로그를 잇는 방법, 사람이 읽는 단언 메시지, 플래키와 진짜 실패를 리포트에서 가르는 기준, 그리고 보는 사람에 따라 리포트를 층으로 나누는 법을 다룬다.

  • #리포팅
  • #트레이스
  • #상관ID
  • #플래키
  • #아티팩트
  • #Playwright

개념

5분 진단이라는 기준

자동화가 신뢰를 잃는 순간은 테스트가 깨질 때가 아니라 깨진 이유를 알아내는 데 30분이 걸릴 때 다. 그때부터 사람들은 빨간 불을 보고도 열지 않는다. 그래서 리포트 설계의 목표를 숫자로 못 박는다 — 실패 하나를 열어 5분 안에 다음 네 질문에 답할 수 있는가.

  1. 무엇이 실패했는가 — 어떤 사용자 시나리오의 어떤 단계인가
  2. 어디서 멈췄는가 — 어느 단언, 어느 요소, 어느 요청
  3. 누구 책임인가 — 제품 결함 / 테스트 결함 / 환경 문제 / 데이터 문제
  4. 재현되는가 — 같은 커밋·같은 환경에서 다시 터지는가

레슨 2에서 세운 저장소는 이 네 질문의 재료를 모으는 쪽 을 맡는다. 매일 도는 실행을 운영하고 추세를 보는 일은 모듈 59, 파이프라인에서 결과를 모으고 게이트를 거는 일은 모듈 14 레슨 4·6 이다. 결함의 증거로 서버 로그를 읽는 일반 기법은 모듈 61 레슨 4, 운영 모니터링은 모듈 37 레슨 9 에 있다. 여기는 자동화 실행이 자기 실패를 설명하게 만드는 설계 로 좁힌다.

실행 아티팩트 — 비용과 가치를 따져 고른다

아티팩트는 많이 모을수록 좋은 게 아니다. 용량과 실행 시간을 먹고, 민감 정보를 품으며, 보관 기간이 길어지면 돈이 된다.

아티팩트대략 크기주는 답권장 수집 조건
스크린샷수백 KB"화면이 어떤 상태였나"실패 시에만
비디오수 MB"어떤 순서로 일어났나"실패 시 보존
트레이스수~수십 MB위 전부 + DOM 스냅샷 · 네트워크 · 콘솔첫 재시도에만
HAR수 MB"요청·응답 본문이 정확히 무엇이었나"API 의심 케이스만
콘솔·페이지 오류KB"프런트에서 예외가 났나"항상 (값이 싸다)

트레이스가 사실상 상위 집합이다. Playwright 트레이스에는 단계별 DOM 스냅샷, 네트워크 기록, 콘솔, 액션 타임라인이 함께 들어가므로, 트레이스를 켤 수 있다면 스크린샷· 비디오를 따로 켤 이유는 줄어든다. 그런데도 셋을 같이 쓰는 이유는 열어 보는 비용이 다르기 때문 이다 — 스크린샷은 리포트에서 바로 보이고, 트레이스는 뷰어를 띄워야 한다.

// playwright.config.ts (발췌) — 수집 조건은 전부 여기서 결정한다
use: {
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
  trace: 'on-first-retry',          // 모든 실행에서 켜면 실행 시간과 용량이 급증한다
},

trace: 'on-first-retry' 가 기본값으로 쓰이는 이유는 타협점이기 때문이다. 처음 돌 때는 추적을 끄고(빠르게), 실패해서 재시도할 때만 켠다. 다만 한 번만 터지고 재시도에서 통과하는 실패 는 이 설정으로 추적이 남지 않을 수 있다 — 플래키 추적이 목적이라면 retain-on-failure 쪽을 고려한다. 어느 쪽이든 결정과 근거를 설정 파일 주석에 남긴다.

HAR 는 기본으로 켜지 않는다. 요청·응답 본문을 통째로 담으므로 용량이 크고 토큰· 개인정보가 그대로 들어간다. API 응답을 의심하는 특정 스펙에서만 브라우저 컨텍스트의 recordHar 옵션으로 켠다(본문 포함 수준·마스킹 옵션은 Playwright 문서 원문 확인).

보관 정책 — 리포트는 자산이고 아티팩트는 재고다

대상보관이유
요약 결과(통과·실패·소요·플래키 수)길게 (분기~년)추세 지표의 원천
HTML·JUnit 리포트중간 (수주)릴리스 회고 때 다시 본다
트레이스·비디오·HAR짧게 (7~14일)용량 대비 효용이 급락한다
개인정보·토큰이 담길 수 있는 아티팩트최소 + 접근 제한유출 표면이다

핵심은 요약과 원본을 분리해 수명을 다르게 주는 것 이다. 둘을 한 덩어리로 묶으면 "용량이 터져서 다 지웠더니 추세도 사라졌다" 가 된다. 그리고 아티팩트 저장소는 테스트 데이터를 담는다는 사실을 잊지 않는다 — 실제 고객 데이터로 테스트하는 환경이라면 아티팩트 보관은 개인정보 보관이다.

상관 ID — 테스트 실행과 서버 로그를 잇는다

"UI 는 500 을 보여줬다" 에서 멈추면 QA 는 결함을 넘길 수 없다. 그 요청이 서버에서 무엇을 했는지 를 같은 리포트에서 찾아갈 수 있어야 한다. 방법은 하나다 — 테스트가 만든 식별자를 모든 요청에 붙이고, 그 값으로 서버 로그를 검색한다.

// src/core/ids.ts — 코어. 제품을 모른다
export function newCorrelationId(seed: string): string {
  const slug = seed.replace(/[^a-zA-Z0-9]+/g, '-').slice(0, 24).toLowerCase();
  return `qa-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${slug}`;
}
// src/domain/fixtures/diagnostics.ts — 실패했을 때만 증거를 붙인다
import { test as base, expect } from '@playwright/test';
 
export const test = base.extend<{ diagnostics: void }>({
  diagnostics: [
    async ({ page, correlationId }, use, testInfo) => {
      const console_: string[] = [];
      page.on('console', (m) => console_.push(`[${m.type()}] ${m.text()}`));
      page.on('pageerror', (e) => console_.push(`[pageerror] ${e.message}`));
 
      await use();
 
      if (testInfo.status !== testInfo.expectedStatus) {
        await testInfo.attach('correlation-id', { body: correlationId, contentType: 'text/plain' });
        await testInfo.attach('console.log', {
          body: console_.join('\n'),
          contentType: 'text/plain',
        });
        await testInfo.attach('server-logs', {
          body: `${process.env.LOG_SEARCH_URL}?q=${encodeURIComponent(correlationId)}`,
          contentType: 'text/plain',
        });
      }
    },
    { auto: true },     // 모든 테스트에 자동 적용. 테스트는 이 픽스처를 선언하지 않는다
  ],
});

설계 포인트가 셋이다. auto: true 로 전역 적용한다 — 진단은 테스트가 선택하는 기능이 아니다. 실패했을 때만 첨부한다 — 통과한 수천 건에 로그를 붙이면 리포트가 쓸모 없이 커진다. 로그 검색 URL 을 첨부한다 — 로그 원문을 끌어오지 않고 링크를 남기면 권한 경계를 넘지 않으면서도 클릭 한 번으로 이어진다.

서버가 x-correlation-id 를 받아 로그에 그대로 찍어 주는 것 이 전제다. 그게 안 되면 이 구조는 반쪽이 된다. 그걸 개발팀에 요구하는 방법이 레슨 4의 주제다.

읽히는 실패 메시지 — 단언과 단계를 설계한다

리포트 품질의 절반은 실패 한 줄의 문장 에서 결정된다.

// ✗ 무엇이 왜 틀렸는지 알 수 없다
await expect(page.locator('.badge')).toHaveText('취소완료');
// Error: expected '취소완료', received '배송중'   ← 어떤 주문의 배지인가?
 
// ○ 대상·기대·맥락이 메시지에 들어간다
await test.step(`주문 ${orderNo} 취소 후 상태 확인`, async () => {
  await expect(
    page.getByTestId(`order-${orderNo}-status`),
    `주문 ${orderNo}(결제완료 상태로 시딩)은 취소 후 "취소완료" 가 돼야 한다`,
  ).toHaveText('취소완료');
});

test.step() 이 하는 일은 실패 지점을 좁히는 것 이다. 20줄짜리 테스트가 그냥 "실패" 로 뜨는 것과 "3단계 — 주문 취소 요청" 에서 실패로 뜨는 것은 진단 시간이 다르다. 단계 이름은 사용자 행동의 언어 로 쓴다. 그리고 한 번에 여러 사실을 모아야 할 때는 expect.soft 를 쓴다 — 첫 단언에서 멈추지 않고 끝까지 검사해, "제목도 틀리고 금액도 틀렸다" 를 한 번에 보여준다. 다만 소프트 단언을 남용하면 실패 원인이 여러 개로 흩어져 오히려 읽기 어려워지므로, 같은 화면의 독립적인 필드 검증에만 쓴다.

플래키와 진짜 실패를 리포트에서 가른다

재시도를 켜 둔 스위트의 리포트는 세 종류의 결과를 담는다. 이것을 구분하지 않으면 "어제는 3개 실패, 오늘은 1개 실패" 라는 숫자가 아무 의미도 갖지 않는다.

결과뜻대응
실패(unexpected)재시도까지 전부 실패즉시 트리아지
플래키(flaky)처음 실패, 재시도에서 통과별도 집계. 임계값 넘으면 격리
통과(expected)첫 시도에 통과—

Playwright 는 이 셋을 결과 상태로 구분해 보고하므로, 커스텀 리포터에서 바로 갈라 적을 수 있다.

// src/core/reporters/triage-reporter.ts — 플래키를 따로 센다
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
 
export default class TriageReporter implements Reporter {
  private flaky: string[] = [];
  private failed: string[] = [];
 
  onTestEnd(test: TestCase, result: TestResult) {
    if (test.outcome() === 'flaky') {
      // 재시도별 오류 메시지가 다르면 환경·타이밍 문제일 확률이 높다
      const distinct = new Set(test.results.map((r) => r.error?.message?.slice(0, 120) ?? ''));
      this.flaky.push(`${test.title} (서로 다른 오류 ${distinct.size}종)`);
    } else if (result.status === 'failed' || result.status === 'timedOut') {
      this.failed.push(`${test.title} — ${result.status}`);
    }
  }
 
  onEnd() {
    console.log(`실패 ${this.failed.length}건 / 플래키 ${this.flaky.length}건`);
    this.flaky.forEach((l) => console.log(`  [flaky] ${l}`));
  }
}

distinct.size 를 세는 이유가 핵심이다. 같은 테스트가 재시도마다 다른 메시지로 실패하면 타이밍·환경 쪽, 같은 메시지로 실패하면 제품·테스트 로직 쪽 일 확률이 높다. 이 한 줄이 트리아지의 첫 분기를 자동화한다. 플래키를 실패와 같은 칸에 넣지 않는 것 도, 통과로 숨기지 않는 것 도 둘 다 중요하다 — 전자는 트리아지를 마비시키고 후자는 품질 저하를 안 보이게 만든다.

리포트의 층 — 누가 보는가에 따라 다르게 쓴다

한 리포트로 세 독자를 다 만족시키려 하면 아무도 안 읽는다. 같은 데이터, 세 층.

독자보고 싶은 것전달 형태
릴리스 담당배포해도 되는가한 줄 판정 + 게이트 위반 항목
개발자내 변경이 뭘 깼나실패 목록 + 트레이스·로그 직링크
QA·자동화 담당스위트가 건강한가플래키율·소요 시간·실패 반복 패턴

실무에서는 1층을 PR 코멘트나 채팅 알림으로, 2층을 HTML 리포트로, 3층을 주간 지표로 낸다. 여러 샤드로 나눠 돌렸다면 각 샤드가 blob 리포트를 남기고 끝에서 하나로 합쳐 2층을 만든다 — 샤드별로 리포트가 흩어지면 개발자가 자기 실패를 찾지 못한다.

1층에서 반드시 피해야 할 것은 "테스트 42개 실패" 같은 숫자만 주는 알림 이다. 그 알림을 받은 사람이 할 수 있는 행동이 없다. 최소한 실패를 묶은 범주 하나와 링크 하나 가 있어야 행동이 시작된다 — "로그인 셋업 실패로 38건이 의존성 실패(링크)" 처럼.

실무에서 왜 필요한가

첫째, 진단 시간이 자동화의 실질 비용 이다. 작성 비용은 한 번이지만 진단 비용은 실패할 때마다 든다. 하루 한 번 돌고 주 3회 실패하는 스위트에서 진단이 30분이면 월 6시간이 그냥 사라진다 — 레슨 5의 ROI 계산에 이 항목이 들어간다.

둘째, 리포트가 책임 공방을 숫자로 바꾼다. "자동화가 또 깨졌다" 와 "이 실패는 상관 ID qa-m3x... 의 요청에서 서버가 500 을 반환했고 서버 로그가 여기 있다" 는 다른 대화다. 전자는 QA 가 변명하는 자리, 후자는 개발자가 로그를 여는 자리가 된다.

셋째, 플래키를 숫자로 들고 있어야 격리를 협상할 수 있다. "이 테스트는 좀 불안해요" 로는 아무것도 바꿀 수 없다. "최근 30회 중 7회 플래키, 재시도마다 메시지가 달라 환경 쪽으로 의심" 이면 격리든 수정이든 결정이 난다(모듈 59 레슨 4가 격리 운영을 다룬다).

실습 과제

과제 1 — 실패 리포트 5분 진단 테스트 (30분)

담당 스위트에서 최근 실패 3건 을 고른다. 각각에 대해 타이머를 켜고 리포트만 보며 본문의 네 질문에 답해 본다. 답하지 못한 질문마다 무엇이 리포트에 없어서 막혔는지 를 적는다(예: 단계 이름이 없어서 어디서 멈췄는지 모름 / 서버 로그로 갈 길이 없음). 결과물은 없는 것 목록 이며, 이것이 다음 과제의 백로그가 된다.

과제 2 — 진단 픽스처 붙이기 (40분)

레슨 2에서 만든 저장소에 본문의 diagnostics 픽스처를 auto: true 로 추가하고, 일부러 실패하는 테스트 하나를 만든다. 확인할 것은 셋 — (1) 실패한 테스트의 첨부에 상관 ID·콘솔 로그·로그 검색 링크가 있고, (2) 통과한 테스트에는 첨부가 없고, (3) test.step() 으로 감싼 단계 이름이 리포트에 나타나는지. 그다음 단언 메시지를 본문의 좋은 예 형태로 바꿔, 메시지만 읽고도 원인 가설을 세울 수 있는지 동료에게 확인한다.

과제 3 — 아티팩트 수집·보관 정책 한 장 (25분)

우리 스위트의 아티팩트 정책을 표로 쓴다 — 아티팩트별 수집 조건 / 보관 기간 / 저장 위치 / 접근 권한 / 민감정보 위험. 그다음 두 숫자를 추정한다 — 현재 설정으로 한 달에 쌓이는 용량, 트레이스를 모든 실행에서 켰을 때의 용량. 마지막으로 지금 설정을 바꿀지 말지 를 한 문장으로 결론 낸다(바꾸지 않는 결론도 정답이다).

자가 체크리스트

  • 실패 리포트가 답해야 할 네 질문을 외워 말할 수 있다
  • 트레이스·비디오·스크린샷·HAR 의 수집 비용과 각자가 주는 답을 구분해 설명할 수 있다
  • trace: 'on-first-retry' 가 무엇을 얻고 무엇을 놓치는지 안다
  • 요약 결과와 원본 아티팩트에 서로 다른 보관 기간을 주는 이유를 말할 수 있다
  • 상관 ID 를 생성해 요청 헤더와 리포트 첨부에 함께 남기는 코드를 써 봤다
  • 단언 메시지와 test.step 이름을 사람이 읽을 문장으로 설계할 수 있다
  • 플래키·실패·통과를 리포트에서 구분하고, 재시도별 메시지 차이로 1차 분류할 수 있다
  • 리포트를 릴리스 담당·개발자·QA 세 층으로 나눠 낼 수 있다

흔한 실수

모든 실행에서 모든 아티팩트를 켠다. 트레이스와 비디오를 상시 켜면 실행 시간이 늘고 저장 용량이 터진다. 더 나쁜 건 리포트가 무거워져 아무도 안 여는 것 이다. 아티팩트는 실패 시 수집이 기본이고, 상시 수집은 값이 싼 콘솔 로그 정도다.

로그 원문을 리포트에 복사해 넣는다. 서버 로그를 끌어와 첨부하면 용량도 문제지만 권한 경계를 넘고 민감 정보가 리포트로 새어 나온다. 상관 ID 와 검색 링크만 남기고, 원문은 원래 있던 곳에서 권한대로 보게 한다.

플래키를 통과로 집계한다. 재시도로 통과한 것을 성공 칸에 넣으면 통과율이 좋아 보이고 플래키 부채는 쌓인다. 반대로 플래키를 실패와 같이 세면 매일 빨간 불이 떠서 트리아지가 마비된다. 별도 칸 이 답이다.

단언 메시지를 비워 두고 스크린샷으로 때운다. 스크린샷은 "무엇이 보였나" 를 말하지만 무엇을 기대했는지 는 말하지 않는다. 기대값과 맥락은 메시지로만 전달된다.

실패 알림에 숫자만 보낸다. "42건 실패" 알림은 행동을 만들지 않는다. 묶은 범주와 직링크를 함께 보내야 한다.

리포트를 한 종류만 만든다. 릴리스 담당에게 트레이스 뷰어를, 개발자에게 통과율 그래프를 주면 둘 다 아무것도 못 한다. 같은 실행에서 층을 나눠 낸다.

참고 자료

  • Playwright 공식 문서 — Trace viewer, Videos, Screenshots, Reporters(html·blob·junit), Test annotations 와 testInfo.attach, Reporter API(onTestEnd·TestCase.outcome). 옵션 이름과 기본값은 버전마다 달라 원문 확인 이 필요하다
  • ISTQB, Certified Tester Test Automation Engineering (CT-TAE) Syllabus v2.0, 2024 — 테스트 실행 역량의 로깅·리포팅 컴포넌트, 자동화 실행 결과 보고
  • 모듈 61 레슨 4 — 로그를 결함 증거로 쓰는 일반 기법. 상관 ID 로 이어간 뒤의 작업이 그쪽이다
  • 모듈 14 레슨 4·6 — 품질 게이트와 실패 패턴 추적. 리포트를 파이프라인 판단에 쓰는 단계
  • 모듈 13 레슨 6, 모듈 59 레슨 4 — 플래키 원인 진단과 격리 운영
  • 레슨 2(저장소 구조), 레슨 4(테스트 용이성 협상 — 서버가 상관 ID 를 로그에 남기게 하기)

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

KB — 킬로바이트
데이터 양의 단위. 1,000 킬로바이트가 대략 1 메가바이트다.
MB — 메가바이트
데이터 양의 단위. 사진 한 장이 대략 2~5 메가바이트쯤 된다.
DOM — 문서 객체 모델
웹 화면을 프로그램이 다룰 수 있게 나무 구조로 바꿔 놓은 것. 화면의 현재 상태를 담고 있다.
HAR — 통신 기록 파일
브라우저가 주고받은 요청과 응답을 전부 담아 저장한 파일.
API — 응용 프로그램 인터페이스
프로그램끼리 주고받는 창구. 사람이 화면 버튼을 누르는 대신, 프로그램이 정해진 주소로 요청을 보내 답을 받는다.
HTML — 하이퍼텍스트 표시 언어
웹 화면의 뼈대를 적는 글자 형식. 제목·문단·버튼 같은 역할을 꺾쇠 괄호로 표시한다.
JUnit — 제이유닛
자바로 만든 프로그램의 단위 시험을 쓰고 돌리는 가장 널리 쓰이는 도구.
ID — 식별자
무엇 하나를 다른 것과 구별하기 위해 붙인 이름이나 번호.
UI — 사용자 인터페이스
사람이 직접 보고 누르는 부분 — 화면, 버튼, 입력창.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
URL — 인터넷 주소
웹에서 무언가의 위치를 가리키는 주소 한 줄.
PR — 풀 리퀘스트
"내가 고친 코드를 본체에 합쳐 달라"고 올리는 요청. 합치기 전에 사람들이 보고 의견을 단다.

이 레슨에 나온 나머지 3개를 포함해 용어집 전체 보기