개발 slecs

회귀 방지 시스템으로 서비스 안정성 강화

목차

회귀(regression)는 고쳐놨는데 언제 다시 망가졌는지 모르는 상태다. 배포 직후엔 멀쩡했는데, 두 달 뒤 아무도 건드리지 않은 것처럼 보이는 변경 하나로 특정 플로우가 조용히 죽어있는 것. 그게 회귀다.

이번 작업의 출발점이 거기였다. 기존 기능을 안정화하고 코드 일관성을 확보하면서, 앞으로 같은 실수가 반복되지 않도록 시스템 수준에서 막는 것. 변경 범위가 백엔드 로직, JSP 화면, XML 쿼리, 스타일까지 여러 레이어에 걸쳐있었기 때문에 영향 범위를 먼저 파악하는 데 시간을 꽤 썼다.

Playwright를 붙인 이유

단위 테스트나 통합 테스트만으로는 회귀를 다 잡을 수 없다. 특히 JSP 기반의 멀티레이어 서비스에서 UI까지 연결되는 플로우는, 실제로 브라우저가 페이지를 렌더링하고 버튼을 누르고 결과를 확인하는 방식이 아니면 놓치는 케이스가 생긴다. 백엔드 로직은 맞는데 화면이 깨지거나, 쿼리는 도는데 데이터가 안 뜨는 류의 버그가 그렇다.

Playwright는 실 브라우저 환경에서 사용자 시나리오를 재현하기 때문에 그 부분을 커버한다.

// 핵심 플로우 회귀 검증 예시
test('주요 기능 진입 및 결과 확인', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#userId', 'testuser');
  await page.fill('#password', 'testpw');
  await page.click('button[type=submit]');

  await expect(page).toHaveURL('/dashboard');

  await page.click('[data-testid="main-feature"]');
  await expect(page.locator('.result-panel')).toBeVisible();
  await expect(page.locator('.result-panel')).not.toBeEmpty();
});

테스트 코드에 data-testid를 붙이는 건 처음에 귀찮다. 근데 이게 없으면 CSS 클래스 이름 하나 바꿀 때마다 테스트가 뻑난다. 구조와 동작을 분리하는 가장 단순한 방법이 data-testid고, 나중에 레이아웃 개편할 때 테스트가 살아남으려면 이 습관이 필수다.

정책번과 체크리스트를 함께 쓰는 이유

Playwright 테스트가 통과한다고 무조건 안심할 수 없다. "이 기능은 어떤 정책을 따르는가"가 명문화돼 있지 않으면, 테스트가 맞는 동작을 검증하고 있는지 자체를 보장할 수 없기 때문이다. 정책번과 체크리스트는 그 맥락을 코드에 붙이는 방식이다.

  • 정책번은 해당 기능의 결정 근거(기획 문서, 내부 이슈, 정책 회의 결과)를 추적 가능하게 한다
  • 배포 전 체크리스트는 "이 항목은 내가 직접 눈으로 확인했다"는 기록이 된다
  • 둘을 합치면, 나중에 "이 기능 왜 이렇게 동작해?"를 물어볼 때 문서로 답할 수 있다

레이어가 많을수록 "어디서 뭘 바꿨는지" 흐름이 흐려진다. 정책번으로 변경 단위를 묶어두면 이슈가 생겼을 때 어느 배포와 연결된 변경인지 바로 찾을 수 있다.

이번에 재확인한 원칙들

실제 변경 내용은 중복 코드 제거, 엣지 케이스 보강, 로그·에러 메시지 개선, 불필요한 코드 정리였다. 화려하지 않다. 근데 이 작업들이 나중에 디버깅 시간을 완전히 바꿔놓는다. 새벽 3시에 알람 받고 로그 뒤졌을 때 에러 메시지가 "오류가 발생했습니다"인 것과 "재고 확인 실패: 요청수량=5, 가용재고=0"인 것은 대응 속도가 다르다.

실패 우선 설계. happy path는 대부분 잘 된다. 문제는 에러 케이스다. 입력값이 null이면, 외부 API가 타임아웃 나면, DB 커넥션이 끊기면 - 이 케이스를 먼저 설계하면 구조가 자연스럽게 방어적이 된다.

// 에러 케이스 나중에 추가하는 방식 - 구조가 비틀린다
public Result process(Order order) {
    // 처리 로직 100줄 ...
    if (order == null) throw new Exception("null"); // 맨 아래
}

// 실패 먼저 처리하고 로직 진행 - 읽기 쉽고 테스트 쓰기도 자연스럽다
public Result process(Order order) {
    if (order == null) throw new IllegalArgumentException("order must not be null");
    if (order.getItems().isEmpty()) throw new IllegalStateException("order has no items");
    // 이제 처리 로직
}

작은 커밋. 롤백이 필요할 때 커밋 단위가 크면 같이 롤백되면 안 되는 것까지 딸려온다. 코드 리뷰도 마찬가지고, 변경이 섞여 있으면 리뷰어가 어디에 집중해야 할지 모른다. 커밋 하나에 이유 하나.

문서 동기화. 코드 바꾸면서 주석을 안 바꾸는 건 거짓말하는 것과 같다. 오래된 주석은 없는 것보다 나쁘다. 잘못된 방향으로 읽는 사람을 안내하기 때문에.

원칙 실제로 왜 중요한가
단일 책임 함수 하나가 여러 역할이면 테스트 케이스가 폭발하고, 어디서 깨졌는지도 불명확해짐
명시적 코드 영리한 원라이너는 본인도 한 달 뒤엔 못 읽음. 읽기 쉬운 코드가 결국 유지보수 비용이 낮음
실패 우선 처리 에러 케이스 나중에 붙이면 메인 로직에 방어 코드가 엮여서 구조가 복잡해짐
명확한 에러 메시지 장애 대응 속도는 로그 품질에서 갈림. "오류 발생"은 아무것도 말해주지 않음

이런 작업들이 당장 눈에 보이는 성과가 없는 건 맞다. 기능이 추가된 것도 아니고, 속도가 빨라진 것도 아니다. 근데 코드베이스는 관리받은 만큼 상태가 유지된다. 방치하면 점점 이해하기 어려워지고, 손대기 무서워지고, 결국 "새로 짜는 게 낫겠다"는 말이 나온다.

회귀 방지 시스템을 붙이는 건 그 침식 속도를 늦추는 일이다. 코드를 쓸 때 "6개월 뒤의 나", "처음 보는 개발자", "새벽 3시 장애 상황"을 기준으로 생각하면 자연스럽게 거기로 수렴하게 된다.

댓글 0

첫 댓글 달아줘.