Skip to main content
Summary

품질은 마지막에 확인하는 것이 아니다

테스트와 디버깅은 개발이 끝난 뒤에 붙이는 부가 작업이 아니다. 기능을 빠르게 만들고, 변경을 두려워하지 않기 위한 안전장치다. 테스트가 없으면 작은 수정도 위험해진다. 방금 고친 코드가 다른 기능을 망가뜨렸는지 알 수 없기 때문이다.

실무에서 버그는 보통 "코드를 몰라서"보다 "변경의 영향을 다 보지 못해서" 생긴다. 주문 취소 로직을 고쳤는데 포인트 환불이 빠지거나, 회원 등급 계산을 수정했는데 관리자 통계가 달라지는 식이다. 테스트는 이런 영향을 사람이 매번 기억하지 않아도 확인하게 해준다.

좋은 테스트의 목적은 모든 버그를 없애는 것이 아니다. 중요한 동작을 반복해서 검증하고, 문제가 생겼을 때 빨리 발견하게 만드는 것이다. 테스트가 잘 갖춰진 코드베이스에서는 리팩터링도 쉬워진다. 내부 구조를 바꿔도 외부 동작이 그대로인지 확인할 수 있기 때문이다.

테스트와 디버깅으로 품질 지키기AI 생성 이미지

테스트 피라미드: 목적과 비용을 구분하기

테스트는 모두 같은 비용과 가치를 가지지 않는다. 보통 단위 테스트, 통합 테스트, E2E 테스트로 나누어 생각한다. 이를 피라미드로 표현하는 이유는 아래로 갈수록 많이 작성하고 빠르게 실행해야 하며, 위로 갈수록 적게 작성하되 실제 사용자 흐름에 가까워지기 때문이다.

단위 테스트는 함수, 클래스, 작은 모듈처럼 좁은 범위를 검증한다. 예를 들어 할인 금액 계산 함수, 비밀번호 정책 검사 함수, 주문 상태 전환 규칙을 테스트한다. 실행이 빠르고 실패 원인을 찾기 쉽다. 대신 데이터베이스, 네트워크, 브라우저까지 포함한 전체 흐름은 확인하지 못한다.

function calculateDiscount(price, rate) {
  if (price < 0) throw new Error('invalid price');
  return Math.floor(price * rate);
}

// 기대: 10000원의 10% 할인은 1000원이다.
expect(calculateDiscount(10000, 0.1)).toBe(1000);

통합 테스트는 여러 모듈이 함께 동작하는지 확인한다. API 서버와 데이터베이스를 함께 실행해 회원 가입 요청이 실제로 저장되는지 검증하는 방식이 대표적이다. 단위 테스트보다 느리고 준비 비용이 들지만, 모듈 사이의 연결 문제를 잡을 수 있다. SQL 쿼리 오류, 트랜잭션 누락, 설정 문제는 단위 테스트만으로 발견하기 어렵다.

E2E 테스트는 사용자의 실제 흐름에 가깝게 검증한다. 브라우저에서 로그인하고, 상품을 장바구니에 담고, 결제 페이지까지 이동하는 테스트가 여기에 해당한다. 가장 신뢰도가 높지만 가장 느리고 불안정해지기 쉽다. 네트워크 지연, 테스트 데이터 상태, 화면 로딩 타이밍 때문에 실패할 수 있다. 그래서 핵심 사용자 흐름 위주로 적게 유지하는 것이 좋다.

테스트 피라미드는 "무조건 단위 테스트만 많이 작성하라"는 뜻이 아니다. 빠른 단위 테스트로 규칙을 넓게 보호하고, 통합 테스트로 연결을 확인하며, E2E 테스트로 중요한 사용자 시나리오를 검증하라는 기준이다.

테스트하기 쉬운 코드는 설계가 분명한 코드다

테스트하기 어려운 코드는 대체로 책임이 섞여 있다. 하나의 함수가 입력 검증, 할인 계산, 데이터베이스 저장, 이메일 발송, 로그 기록을 모두 처리하면 테스트가 복잡해진다. 할인 계산 하나를 확인하려고 데이터베이스와 이메일 서버까지 준비해야 하기 때문이다.

테스트하기 쉬운 코드는 입출력이 명확하다. 같은 입력에 대해 같은 출력을 내는 함수는 테스트하기 쉽다. 이런 함수를 순수 함수라고 한다. 순수 함수는 외부 상태를 바꾸지 않고, 시간·랜덤값·네트워크 같은 외부 요인에 직접 의존하지 않는다.

예를 들어 주문 취소 가능 여부를 판단하는 규칙은 데이터베이스 코드와 분리하는 편이 좋다.

function canCancelOrder(order) {
  return order.status === 'PAID' && !order.shippedAt;
}

이 함수는 주문 객체만 있으면 테스트할 수 있다. 반면 함수 안에서 직접 데이터베이스를 조회하고, 현재 시간을 읽고, 외부 배송 API까지 호출한다면 테스트는 느려지고 깨지기 쉬워진다.

의존성 분리도 중요하다. 결제 취소 로직이 특정 결제 SDK를 직접 호출하면 테스트에서 실제 결제 시스템을 건드릴 위험이 있다. 이럴 때는 "결제를 취소할 수 있는 역할"을 인터페이스나 추상 객체로 분리한다. 실제 운영에서는 외부 결제 어댑터를 사용하고, 테스트에서는 가짜 구현을 넣는다. 앞 장에서 다룬 의존성 역전은 테스트 가능성과도 바로 연결된다.

테스트하기 쉬운 코드를 만들기 위한 기준은 간단하다.

  • 계산 규칙과 외부 I/O를 분리한다.

  • 함수의 입력과 출력을 명확히 한다.

  • 전역 상태와 숨은 의존성을 줄인다.

  • 시간이 필요한 코드는 현재 시간을 직접 읽기보다 주입받는다.

  • 데이터베이스, 파일, 네트워크 호출은 경계 밖으로 밀어낸다.

이 기준은 테스트만을 위한 기교가 아니다. 코드의 책임을 분명하게 만들고, 변경 범위를 줄이는 설계 습관이다.

Mock, Stub, Fixture는 목적을 알고 써야 한다

테스트에서는 실제 객체 대신 가짜 객체를 쓰는 경우가 많다. 이때 자주 나오는 용어가 Mock, Stub, Fixture다.

Stub은 정해진 값을 돌려주는 대체물이다. 예를 들어 배송비 계산 테스트에서 외부 주소 API가 항상 "서울"을 반환하게 만들 수 있다. 목적은 테스트에 필요한 조건을 안정적으로 만드는 것이다.

Mock은 호출 여부나 호출 방식을 검증하는 대체물이다. 예를 들어 주문 취소가 성공했을 때 refundPayment가 한 번 호출되었는지 확인할 수 있다. 목적은 어떤 협력 객체와 상호작용했는지 검증하는 것이다.

Fixture는 테스트에 필요한 데이터 묶음이다. 회원, 주문, 결제 내역처럼 테스트 전에 준비하는 기본 데이터를 말한다. Fixture가 잘 정리되어 있으면 테스트를 읽기 쉽다.

다만 테스트 대역을 과하게 쓰면 문제가 생긴다. 테스트가 실제 동작이 아니라 구현 세부사항에 묶인다. 예를 들어 "이 메서드가 내부에서 A 함수를 호출한 뒤 B 함수를 호출한다"는 식으로 검증하면, 결과는 같아도 내부 구조를 조금 바꾸는 순간 테스트가 깨진다. 이런 테스트는 리팩터링을 돕지 않고 방해한다.

좋은 테스트는 가능하면 결과와 관찰 가능한 동작을 검증한다. "주문 취소 후 상태가 CANCELED가 된다", "환불 기록이 저장된다", "이미 배송된 주문은 취소할 수 없다"처럼 요구사항에 가까운 표현이 좋다. Mock은 외부 결제 API 호출처럼 실제 실행하면 위험하거나 느린 경계에서 신중하게 사용하는 편이 낫다.

Fixture도 너무 거대하면 테스트를 이해하기 어렵다. 모든 테스트에서 같은 대형 회원·주문 데이터를 재사용하면, 어떤 필드가 테스트에 중요한지 보이지 않는다. 테스트마다 필요한 데이터만 간결하게 만들고, 반복되는 생성 코드는 팩토리 함수로 정리하는 것이 좋다.

디버깅은 추측이 아니라 좁혀 가는 과정이다

디버깅은 버그를 고치는 행위만을 뜻하지 않는다. 증상을 재현하고, 원인을 좁히고, 수정이 실제로 문제를 해결했는지 검증하는 과정이다. 경험 많은 개발자도 한 번에 원인을 맞히지 못한다. 중요한 것은 추측을 근거로 바꾸는 절차다.

첫 단계는 재현이다. 버그가 언제, 어떤 입력에서, 어떤 환경에서 발생하는지 확인한다. "가끔 안 됩니다"라는 제보만으로는 고치기 어렵다. 사용자 계정, 요청 값, 브라우저, 서버 버전, 데이터 상태, 발생 시간 같은 정보를 모아야 한다.

두 번째는 범위 축소다. 프론트엔드 문제인지, API 문제인지, 데이터 문제인지 나눈다. API 응답이 이미 잘못되었다면 화면 코드를 볼 필요가 줄어든다. 데이터베이스에는 정상 값이 있는데 화면만 틀리다면 렌더링이나 상태 관리 문제일 가능성이 높다.

세 번째는 가설 수립과 검증이다. 예를 들어 "쿠폰 할인 계산에서 소수점 처리가 잘못되었을 것이다"라는 가설을 세웠다면, 해당 입력으로 작은 테스트를 만들거나 로그를 추가해 확인한다. 가설이 틀렸다면 버리고 다음 가능성으로 이동한다.

이때 사용하는 도구가 로그, 브레이크포인트, 프로파일러다.

로그는 실행 흐름과 상태를 남긴다. 운영 환경에서 특히 중요하다. 단, 로그는 많이 찍는다고 좋은 것이 아니다. 요청 ID, 사용자 ID, 주문 ID, 처리 단계, 오류 메시지처럼 원인을 연결할 수 있는 정보를 남겨야 한다. 비밀번호, 토큰, 개인정보는 로그에 남기면 안 된다.

브레이크포인트는 코드 실행을 멈추고 변수 값을 직접 확인하게 해준다. 로컬 개발 환경에서 조건문 분기, 반복문 상태, 함수 호출 순서를 볼 때 유용하다. 다만 운영 서버에서 사용할 수 없으므로, 운영 문제는 로그와 모니터링 정보가 더 중요하다.

프로파일러는 성능 문제를 찾을 때 사용한다. 어떤 함수가 시간을 많이 쓰는지, 메모리가 어디서 증가하는지, 데이터베이스 쿼리가 얼마나 자주 실행되는지 확인한다. 느린 코드를 감으로 고치면 엉뚱한 부분을 최적화하기 쉽다. 먼저 측정하고, 병목을 확인한 뒤 수정해야 한다.

버그를 고친 뒤에는 재발 방지 테스트를 추가하는 것이 좋다. 이미 한 번 발생한 버그는 다시 발생할 가능성이 있다. 버그를 재현하는 테스트를 먼저 만들고, 수정 후 테스트가 통과하는지 확인하면 같은 실수를 줄일 수 있다.

CI는 품질 기준을 코드베이스에 내장한다

개발자가 로컬에서 테스트를 실행하는 것만으로는 부족하다. 바쁜 일정에서는 테스트를 깜빡할 수 있고, 사람마다 실행 환경이 다르다. 그래서 CI, 즉 지속적 통합 환경에서 검사를 자동화한다.

CI는 보통 코드가 Pull Request로 올라오거나 메인 브랜치에 합쳐질 때 실행된다. 여기에는 단위 테스트, 통합 테스트, 린트, 타입 검사, 빌드, 보안 검사가 포함될 수 있다.

  • 테스트: 핵심 동작이 깨지지 않았는지 확인한다.

  • 린트: 코드 스타일과 잠재적 오류 패턴을 검사한다.

  • 타입 검사: 잘못된 함수 호출이나 값의 형태 문제를 실행 전에 찾는다.

  • 빌드 검사: 실제 배포 가능한 결과물이 만들어지는지 확인한다.

  • 보안 검사: 알려진 취약점이 있는 의존성이나 위험한 설정을 찾는다.

CI의 장점은 품질 기준을 개인의 기억에 맡기지 않는다는 점이다. "머지하기 전에 테스트를 돌려 주세요"라는 말보다, 테스트가 실패하면 머지할 수 없게 만드는 규칙이 더 안정적이다. 팀의 약속을 자동화된 절차로 바꾸는 것이다.

다만 CI가 너무 느리면 개발 흐름을 막는다. 빠른 검사는 PR마다 실행하고, 오래 걸리는 E2E 테스트나 전체 보안 스캔은 일정 주기나 배포 전 단계에서 실행하는 식으로 나눌 수 있다. 중요한 것은 빠른 피드백이다. 오류는 작성 직후 발견할수록 고치기 쉽다.

테스트와 디버깅을 개발 습관으로 만들기

좋은 품질은 한 번의 대규모 정리로 만들어지지 않는다. 작은 테스트를 추가하고, 버그를 재현 가능하게 만들고, 실패 원인을 기록하고, CI로 반복 검사를 자동화하는 습관에서 나온다.

기능을 만들 때는 먼저 중요한 규칙을 찾는다. 할인 계산, 권한 판단, 상태 전환, 결제 처리처럼 틀리면 큰 문제가 되는 부분부터 테스트한다. 모든 줄을 테스트하려고 하기보다, 변경이 잦고 실패 비용이 큰 곳을 우선 보호한다.

디버깅할 때는 증상만 보고 바로 코드를 고치지 않는다. 재현 조건을 찾고, 원인을 나누고, 가설을 검증한다. 수정 후에는 테스트를 추가해 같은 버그가 다시 들어오지 못하게 한다.

결국 테스트와 디버깅은 개발 속도를 늦추는 일이 아니다. 무작정 빨리 만드는 속도는 오래가지 못한다. 변경해도 깨지지 않는다는 신뢰가 있을 때 팀은 더 빠르게 움직일 수 있다. 품질은 속도의 반대편에 있는 것이 아니라, 지속 가능한 속도를 만드는 기반이다.

책 목록

개발자에게 필요한 필수 지식 정리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기