본문으로 바로가기
리옵트 핸드북
리옵트 핸드북
개발자 언러닝

왜 바꿔야 하는가

전문성이 짐이 되는 순간'내가 다 짜야 한다'는 착각

설계와 구현의 전환

사전 설계에서 반복 설계로프롬프트는 설계 언어다AI 시대의 코드 리뷰

실무 습관 리셋

테스팅 전략의 전환디버깅 습관 리셋컨텍스트 관리: 새로운 핵심 역량기술 부채의 재정의

팀과 커리어

팀 워크플로우의 변화절대 언러닝하면 안 되는 것에이전틱 전환 전략

부록

검증 리포트업데이트 내역
핸드북›Developer Unlearning›테스팅 전략의 전환
한국어English

테스팅 전략의 전환

테스트를 AI 구현 위임의 작업 지시서로 삼는 전환과, 동작 명세 테스트·통합 테스트 비중 확대, AI가 테스트를 쓸 때의 함정을 다루는 장

핵심 요약

  • 테스트를 먼저 쓰면 구현을 AI에게 맡길 수 있다. 테스트가 사후 검증이 아니라 사전 명세, 곧 작업 지시서가 된다.
  • 구현에 종속된 테스트 말고 동작을 명세하는 테스트를 써야 AI가 구현을 자유롭게 고르고, 리팩토링해도 깨지지 않는다.
  • AI에게 테스트를 맡기면 자기 코드의 버그를 정답으로 굳히거나, 해피 패스만 만들거나, toBeDefined()로 검증하는 시늉만 하는 함정이 있다.
  • 테스트 피라미드도 다시 짜인다. AI가 모듈 사이 연결을 자주 틀리는 탓에 통합 테스트 비중이 25%에서 40%로 올라간다.
  • 잘 쓴 테스트는 실행해서 검증되는 '살아 있는 기능 명세서'가 되고, 기획 문서보다 믿을 만하다.

TDD는 오래된 개념이다. 하지만 대부분의 개발자는 실제로 "구현 먼저, 테스트 나중에" 순서로 일한다. AI 시대에 이 순서가 뒤집히는 이유는 단순하다. 테스트를 먼저 쓰면 AI에게 구현을 맡길 수 있어서다.

전통적 테스팅과 AI 시대 테스팅의 흐름 차이

대부분의 팀이 실제로 따르는 테스팅 흐름과 AI 시대의 흐름을 나란히 놓으면 구조가 어떻게 다른지 보인다.

전통적 흐름에는 이런 문제가 있다.

문제설명AI 시대 해결 방식
테스트가 구현에 종속구현 방식을 검증하게 됨테스트가 먼저이므로 동작을 검증
테스트 작성 동기 부족동작하는 코드에 테스트는 귀찮은 추가 작업테스트가 AI의 작업 지시서
엣지 케이스 누락구현 시 생각 못한 것을 테스트에서도 놓침테스트 설계 단계에서 엣지 케이스 먼저 도출
리팩토링 부담구현 밀착 테스트는 리팩토링마다 깨짐동작 기반 테스트는 리팩토링에 견고

테스트가 작업 지시서가 되는 구조

전환의 핵심은 테스트의 역할이 사후 검증에서 사전 명세로 바뀐다는 데 있다.

이 구조가 강력한 세 가지 이유

테스트를 먼저 쓰면 세 가지를 한꺼번에 얻는다.

  1. 명확한 스펙: AI에게 모호한 자연어 대신 실행 가능한 명세를 제공
  2. 자동 검증: AI가 생성한 코드를 즉시 검증할 수 있는 기준
  3. 리팩토링 안전망: 나중에 AI에게 리팩토링을 시켜도 테스트가 정상 동작을 보장

구현 종속 테스트와 동작 명세 테스트의 차이

테스트를 AI의 작업 지시서로 쓰려면 구현 방식이 아니라 동작을 명세해야 한다.

구현에 종속된 테스트의 문제

// 내부 구현을 알고 있어야 작성 가능 - AI의 자유도를 제한
test('calculateDiscount가 Map에서 쿠폰을 조회한다', () => {
  const couponMap = new Map([['VIP10', 0.1]]);
  const service = new DiscountService(couponMap);
  expect(service.calculateDiscount('VIP10', 10000)).toBe(9000);
});

동작을 명세하는 테스트

// AI가 어떤 구현이든 자유롭게 선택할 수 있음
describe('할인 계산', () => {
  test('유효한 쿠폰 코드로 할인이 적용된다', () => {
    const result = applyDiscount({ code: 'VIP10', amount: 10000 });
    expect(result.finalAmount).toBe(9000);
    expect(result.discountApplied).toBe(true);
  });

  test('만료된 쿠폰은 할인이 적용되지 않는다', () => {
    const result = applyDiscount({ code: 'EXPIRED', amount: 10000 });
    expect(result.finalAmount).toBe(10000);
    expect(result.discountApplied).toBe(false);
    expect(result.reason).toBe('쿠폰이 만료되었습니다');
  });

  test('할인 후 금액은 0원 미만이 될 수 없다', () => {
    const result = applyDiscount({ code: 'FULL100', amount: 500 });
    expect(result.finalAmount).toBe(0);
  });

  test('쿠폰 코드가 빈 문자열이면 에러를 반환한다', () => {
    expect(() => applyDiscount({ code: '', amount: 10000 }))
      .toThrow('유효하지 않은 쿠폰 코드');
  });
});

두 번째 예시라면 "이 테스트를 통과하는 applyDiscount 함수를 구현해"라고 AI에게 요청하면 끝이다. 구현 방식은 AI에게 맡기고, 동작이 맞는지는 테스트가 보장한다.

비교 항목구현 종속 테스트동작 명세 테스트
AI 자유도낮음 - 특정 클래스 강제높음 - 구현 방식 자유
리팩토링 내성낮음 - 구현 바뀌면 깨짐높음 - 동작이 같으면 통과
문서 가치낮음 - 구현 세부사항높음 - 비즈니스 규칙 명세
엣지 케이스 포함대개 없음자연스럽게 포함

실전: 테스트 먼저 쓰고 AI에게 구현 위임하기

실제 작업 흐름을 단계별로 보자.

1단계: 비즈니스 규칙을 테스트로 작성

// 사람이 작성: 주문 취소 정책의 비즈니스 규칙
describe('주문 취소 정책', () => {
  test('결제 완료 후 24시간 이내 취소하면 전액 환불', async () => {
    const order = createTestOrder({
      paidAt: hoursAgo(12),
      amount: 50000,
    });
    const result = await cancelOrder(order.id);
    expect(result.refundAmount).toBe(50000);
    expect(result.refundType).toBe('FULL');
  });

  test('결제 완료 후 24시간 초과 시 환불 불가', async () => {
    const order = createTestOrder({
      paidAt: hoursAgo(25),
      amount: 50000,
    });
    const result = await cancelOrder(order.id);
    expect(result.refundAmount).toBe(0);
    expect(result.reason).toBe('24시간 초과');
  });

  test('배송 시작된 주문은 반품 절차 안내', async () => {
    const order = createTestOrder({
      paidAt: hoursAgo(5),
      status: 'SHIPPING',
    });
    const result = await cancelOrder(order.id);
    expect(result.action).toBe('RETURN_PROCESS');
    expect(result.returnGuideUrl).toBeDefined();
  });

  test('이미 취소된 주문은 중복 취소 방지', async () => {
    const order = createTestOrder({ status: 'CANCELLED' });
    await expect(cancelOrder(order.id))
      .rejects.toThrow('이미 취소된 주문');
  });

  test('부분 환불 기간에는 수수료 10%를 제외하고 환불', async () => {
    const order = createTestOrder({
      paidAt: hoursAgo(20),
      amount: 50000,
    });
    // 부분 환불 기간: 12-24시간
    const result = await cancelOrder(order.id, { partial: true });
    expect(result.refundAmount).toBe(45000);
    expect(result.fee).toBe(5000);
  });
});

2단계: AI에게 구현 위임

위 테스트를 모두 통과하는 cancelOrder 함수를 구현해줘.

컨텍스트:
- Prisma ORM 사용, Order 모델에 paidAt, status, amount 필드 있음
- 환불 처리는 refundService.process()로 위임
- 시간 계산은 dayjs 사용, KST 기준

제약:
- 트랜잭션 내에서 상태 변경과 환불 처리를 함께 수행
- 동시에 같은 주문을 취소하는 경우 방지 (낙관적 잠금)

3단계: AI 결과 검증 후 테스트 보강

AI가 구현을 내놓으면 테스트를 돌려보고, 통과하지 못하는 케이스가 있으면 프롬프트를 다듬는다. 전부 통과하더라도 빠진 엣지 케이스가 없는지 반드시 점검한다.

// 3단계에서 추가하는 엣지 케이스
test('주문이 존재하지 않으면 NotFoundError', async () => {
  await expect(cancelOrder('non-existent-id'))
    .rejects.toThrow('주문을 찾을 수 없습니다');
});

test('동시 취소 요청 시 하나만 성공한다', async () => {
  const order = createTestOrder({ paidAt: hoursAgo(1) });
  const results = await Promise.allSettled([
    cancelOrder(order.id),
    cancelOrder(order.id),
  ]);
  const fulfilled = results.filter(r => r.status === 'fulfilled');
  const rejected = results.filter(r => r.status === 'rejected');
  expect(fulfilled).toHaveLength(1);
  expect(rejected).toHaveLength(1);
});

AI가 테스트를 작성할 때의 함정

가장 위험한 패턴: 자기 코드를 검증하는 테스트

AI에게 "이 함수의 테스트를 작성해"라고 시키면 AI는 자기가 이해한 동작을 검증하는 테스트를 만든다. 코드에 버그가 있으면 테스트도 같은 버그를 정답으로 받아들인다.

함정 1: 구현을 그대로 복제하는 테스트

// AI가 구현한 함수
function formatUserName(first: string, last: string): string {
  return `${first} ${last}`;  // 한국어에서는 성+이름 순서가 맞음
}

// 같은 AI가 작성한 테스트 - 같은 가정을 공유
test('이름을 포매팅한다', () => {
  expect(formatUserName('길동', '홍')).toBe('길동 홍');
  // 한국어 이름 순서 오류를 테스트도 함께 놓침
  // 올바른 기대값: '홍길동' 또는 '홍 길동'
});

AI가 만든 구현과 테스트가 같은 가정을 공유하는 맹점을 잘 보여주는 사례다.

함정 2: 해피 패스만 테스트

AI는 정상 동작 테스트는 잘 만들지만 실패 시나리오와 경계 조건을 자주 빠뜨린다.

AI가 잘 만드는 테스트AI가 놓치는 테스트
정상 입력에 대한 정상 출력네트워크 실패 시 재시도 로직
기본 CRUD 동작동시 수정 충돌 처리
단일 사용자 시나리오다중 사용자 동시 접근
유효한 데이터악의적 입력
소량 데이터 처리대량 데이터 성능

함정 3: 실제 검증 없는 형식적 테스트

AI가 만든 테스트에서 자주 보이는 문제 패턴이다.

// 함정: toBeDefined()만으로 검증 - 어떤 값이든 통과
test('사용자를 조회한다', async () => {
  const user = await getUser('user-1');
  expect(user).toBeDefined();          // null만 아니면 통과
  expect(user.name).toBeDefined();     // 이름이 뭐든 통과
  expect(user.email).toBeDefined();    // 이메일 형식 검증 없음
});

// 올바른 검증: 구체적 값과 형식을 확인
test('사용자를 조회한다', async () => {
  const user = await getUser('user-1');
  expect(user.name).toBe('홍길동');
  expect(user.email).toMatch(/^[^@]+@[^@]+\.[^@]+$/);
  expect(user.role).toBe('MEMBER');
  expect(user.createdAt).toBeInstanceOf(Date);
});

toBeDefined()만 쓰는 테스트는 "존재하기만 하면 통과"라서 사실상 검증이 아니다.

함정 4: 과도하게 많은 테스트

AI에게 "테스트를 철저하게 작성해"라고 하면 의미 없는 테스트를 수십 개 찍어내 유지보수 부담만 늘리기도 한다.

// AI가 만들 수 있는 의미 없는 테스트들
test('빈 문자열을 전달하면 빈 문자열을 반환한다', () => ...);
test('문자열 "a"를 전달하면 "a"를 반환한다', () => ...);
test('문자열 "ab"를 전달하면 "ab"를 반환한다', () => ...);
test('문자열 "abc"를 전달하면 "abc"를 반환한다', () => ...);
// 이런 패턴이 20개 이상 반복...

테스트의 가치는 개수가 아니라 각 테스트가 검증하는 비즈니스 규칙의 고유성에서 나온다.

테스트 피라미드의 재구성

AI 시대에는 전통적 테스트 피라미드의 비율이 달라진다.

위 차트에서 첫 번째 막대는 전통적 비율, 두 번째 막대는 AI 시대 비율이다.

테스트 레벨전통 비중AI 시대 비중변화 이유
단위 테스트40%20%AI가 생성한 코드와 함께 작성 가능
통합 테스트25%40%AI가 모듈 간 연결을 틀리기 쉬움
E2E 테스트25%25%최종 사용자 관점은 여전히 중요
계약 테스트10%15%AI가 API 계약을 임의로 바꾸지 않았는지 확인

통합 테스트 비중이 높아지는 이유

AI는 함수나 모듈 하나는 잘 만들지만 모듈끼리 주고받는 지점에서 자주 틀린다. 인증 - 데이터 조회 - 응답 변환의 흐름에서 단계마다는 멀쩡한데 이어 붙이면 타입 불일치, 빠진 변환, 뒤바뀐 순서가 튀어나오는 식이다.

통합 테스트 작성 예시

AI에게 구현을 맡길 때 쓰는 통합 테스트는 이런 구조다.

// 통합 테스트: API 엔드포인트
describe('POST /api/orders', () => {
  // 사전 조건 설정
  beforeEach(async () => {
    await seedTestData({
      users: [testUser],
      products: [testProduct],
    });
  });

  // 정상 시나리오
  test('인증된 사용자가 유효한 상품을 주문하면 성공', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${testToken}`)
      .send({ productId: testProduct.id, quantity: 1 });

    expect(res.status).toBe(201);
    expect(res.body.order.status).toBe('PENDING');

    // DB 상태 검증
    const savedOrder = await db.order.findFirst({
      where: { userId: testUser.id },
    });
    expect(savedOrder).toBeTruthy();
    expect(savedOrder.productId).toBe(testProduct.id);
  });

  // 인증 검증
  test('인증 없이 요청하면 401', async () => {
    const res = await request(app)
      .post('/api/orders')
      .send({ productId: testProduct.id, quantity: 1 });
    expect(res.status).toBe(401);
  });

  // 비즈니스 규칙 검증
  test('재고가 부족하면 409 에러', async () => {
    const res = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${testToken}`)
      .send({ productId: testProduct.id, quantity: 9999 });
    expect(res.status).toBe(409);
    expect(res.body.error).toContain('재고 부족');
  });

  // 데이터 정합성 검증
  test('주문 생성 시 재고가 차감된다', async () => {
    const before = await db.product.findUnique({
      where: { id: testProduct.id },
    });
    await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${testToken}`)
      .send({ productId: testProduct.id, quantity: 2 });
    const after = await db.product.findUnique({
      where: { id: testProduct.id },
    });
    expect(after.stock).toBe(before.stock - 2);
  });
});

이 테스트를 AI에게 건네면 AI는 모든 시나리오를 통과하는 구현을 만들어야 한다. 자연어로 풀어 설명하는 것보다 훨씬 정확한 명세다.

AI 위임용 테스트에서 사람과 AI의 역할 분담

사람이 테스트를 설계하고 AI가 구현과 보일러플레이트를 맡는 구조가 대체로 잘 굴러간다.

사람의 역할AI의 역할
비즈니스 규칙 테스트 설계테스트 코드 구현
엣지 케이스 정의테스트 데이터 생성
실패 조건 명시보일러플레이트 작성
통합 시나리오 설계커버리지 보완

테스트 시나리오 작성의 구조

AI에게 구현을 맡길 때 시나리오를 어떻게 짜면 좋은지 보자.

## 기능: 쿠폰 적용

### 정상 시나리오
- 유효한 쿠폰 코드 입력 시 할인 적용
- 퍼센트 할인과 정액 할인 모두 지원
- 할인 후 최소 결제금액 0원

### 실패 시나리오
- 만료된 쿠폰: 에러 메시지와 함께 거부
- 이미 사용된 쿠폰: 중복 사용 방지
- 존재하지 않는 쿠폰: 명확한 에러

### 경계 조건
- 할인이 주문 금액보다 큰 경우: 0원 처리
- 동시에 같은 쿠폰 사용 시도: 하나만 성공
- 쿠폰 만료 시각 경계: KST 자정 기준

### 데이터 조건
- 쿠폰 테이블 스키마: code, type, value, expiresAt, usedAt
- 시간 계산: dayjs, KST 기준

정상-실패-경계-데이터 순서로 시나리오를 짜면 놓치기 쉬운 엣지 케이스가 자연스럽게 따라 나온다.

테스트가 살아있는 문서가 되는 효과

테스트를 먼저 잘 써두면 그게 곧 살아 있는 기능 명세서가 되는 덤이 따라온다.

describe('주문 취소 정책', () => {
  test('결제 완료 후 24시간 이내: 전액 환불', () => { /* ... */ });
  test('결제 완료 후 24시간 초과: 환불 불가', () => { /* ... */ });
  test('배송 시작 후: 반품 절차 안내', () => { /* ... */ });
  test('이미 취소된 주문: 중복 취소 방지', () => { /* ... */ });
  test('부분 환불 기간: 수수료 10% 제외', () => { /* ... */ });
});

이 테스트 파일만 읽어도 주문 취소 정책의 비즈니스 규칙이 바로 잡힌다. 별도 기획 문서보다 믿을 만한 문서이기도 하다. 실행해서 검증되니까.

문서 유형최신성신뢰도엣지 케이스검증
기획 문서구현 후 갱신 안 됨낮음대개 누락수동 확인
API 명세Swagger로 자동 가능중간일부 포함실행 가능
테스트 코드항상 최신높음자연스럽게 포함자동 실행

기획 문서는 시간이 지나면 현실과 어긋나지만, 테스트 코드는 CI에서 매번 실행되니 코드와 자동으로 발맞춘다. 이 차이는 프로젝트가 커질수록 더 벌어진다.


다음 장 미리보기

테스팅 전략을 바꿨다면 다음은 디버깅 습관 리셋이다. console.log를 여기저기 심어 추적하던 방식 대신, AI에게 증상을 설명하고 가설-검증으로 디버깅하는 새로운 접근법을 다룬다.


참고 자료

참고 자료 안내

이 장의 관점과 프레임워크를 뒷받침하는 참고 자료입니다. 본문의 모든 주장이 아래 자료에서 직접 인용된 것은 아니며, 실무 경험과 커뮤니티 사례를 종합한 해석이 포함되어 있습니다.

  • Fowler, M. (2012). "TestPyramid." https://martinfowler.com/bliki/TestPyramid.html

관련 문서

Ch8. 테스트 전략

엔터프라이즈 프로젝트 설계 · 모노레포 환경 테스트 피라미드, Vitest 유닛, Playwright e2e

에이전틱 테스트 환경 엔지니어링

브라우저 러너를 넘어 agent·API·workflow·observability까지 묶어 테스트 환경을 설계하는 고급 핸드북

테스트 하네스 엔지니어링

에이전틱 테스트 환경 엔지니어링 · 테스트 코드를 넘어 환경 제어, 증거 수집, 재현성, agent repair boundary를 묶는 테스트 하네스 설계 원칙과 패키지 구조

기술 부채의 재정의

AI 코딩으로 비용 구조가 역전되어 보일러플레이트는 싸지고 과도한 추상화·메타프로그래밍은 비싸진 변화를 코드 사례와 DRY 재고로 짚는 장

AI 시대의 코드 리뷰

직접 작성한 코드 리뷰에서 AI 생성 코드를 검증하고 판단하는 리뷰로의 전환

디버깅 습관 리셋

console.log·디버거의 수동 추적에서 벗어나, 증상을 구조화해 AI에게 확률 순 가설을 받고 검증하는 디버깅으로 전환하는 방법과 실전 케이스

On this page

전통적 테스팅과 AI 시대 테스팅의 흐름 차이테스트가 작업 지시서가 되는 구조구현 종속 테스트와 동작 명세 테스트의 차이구현에 종속된 테스트의 문제동작을 명세하는 테스트실전: 테스트 먼저 쓰고 AI에게 구현 위임하기1단계: 비즈니스 규칙을 테스트로 작성2단계: AI에게 구현 위임3단계: AI 결과 검증 후 테스트 보강AI가 테스트를 작성할 때의 함정함정 1: 구현을 그대로 복제하는 테스트함정 2: 해피 패스만 테스트함정 3: 실제 검증 없는 형식적 테스트함정 4: 과도하게 많은 테스트테스트 피라미드의 재구성통합 테스트 작성 예시AI 위임용 테스트에서 사람과 AI의 역할 분담테스트 시나리오 작성의 구조테스트가 살아있는 문서가 되는 효과다음 장 미리보기참고 자료