비슷한 버그를 몇 번이고 다시 만나면 이상한 기분이 든다. 왜 매번 처음 보는 문제처럼 헤맬까. 여러 프로젝트를 거치며 되풀이해서 본 흔한 예를 들면 이렇다. 외부 API는 요청 형식이 어긋나면 400이나 422를 돌려준다. DB는 변경 순서가 하나만 어긋나도 조용히 깨진다. 오래 도는 백그라운드 작업은 시간 제한 경계에서 다시 시작되곤 한다. 여러 곳의 값을 맞추는 정산 작업은 느린 쪽 하나 때문에 전체가 늘어지곤 한다. 겉보기엔 다 달랐다. 이유가 안 보였다.

기록을 모아 보니 공통점이 하나 있었다. 딱 하나였다. 실패를 숨기려고 처리 범위를 넓히면 다음 장애가 더 커졌다. 반대로 요청 모양, 데이터 구조 순서, 재시도 범위, 테스트 표본(fixture)을 좁히면 고칠 파일이 보였다. 여러 서비스를 거치며 이 패턴을 몇 번 더 겪은 뒤에야 규칙 하나로 정리했다.

오류를 숨기지 말고 재현 가능한 계약으로 줄인다. 이게 전부다. API는 요청 형식을, DB는 변경 재현을, 백그라운드 작업은 진행 기록과 시간 제한 경계를 먼저 본다.

왜 좁히는 쪽이 더 빨리 고쳐지나

여러 외부 서비스와 DB, 백그라운드 작업이 같이 움직이는 서비스에서는 오류 메시지 하나만 보고 코드를 넓히면 위험하다. 특정 외부 서비스 호출이 실패했을 때 "다른 곳으로 넘어가자"라고 처리하면 그 요청이 왜 거부됐는지가 사라진다. 나중에는 같은 잘못된 요청 내용이 다른 외부 서비스에도 퍼진다.

그래서 실패가 나면 먼저 반대 방향을 확인하는 습관을 들였다. 처리 범위를 넓히다 실패했으면 좁혀서 검증한다. 화면 쪽만 만지다 실패했으면 서버 쪽 안전장치를 먼저 둔다. 최신 데이터 구조만 맞추다 실패했으면 예전 변경 순서를 다시 본다. 방향은 매번 반대였다. 원리는 같았다.

이 기준은 성능 문제에서도 통했다. 일정한 간격으로 상태를 확인하는 방식(polling)은 간격을 줄이는 대신 초반만 촘촘히 확인하고 이후 간격을 늘렸다. 값을 맞추는 정산 작업은 외부 조회와 DB 쓰기를 분리했다. 읽기는 병렬로, 쓰기는 순차로. "느리다"는 말만 붙잡지 않고 병목의 성격부터 나눴다.

반복해서 만난 네 가지 실패 유형

여러 프로젝트를 거치며 반복해서 마주친 실패는 크게 네 갈래였다. API 오류, DB 마이그레이션, 백그라운드 재시도, 그리고 폴링과 정산 병목. 겉모습은 다 달랐지만 좁혀서 본 원리는 같았다.

API 400과 422는 계약 위반이다

여러 외부 서비스를 연동하며 자주 본 숫자는 400과 422였다. 400은 요청 형식 자체가 상대 쪽 계약과 맞지 않을 때 나온다. 422는 JSON처럼 보이지만 데이터 구조가 값을 받지 못할 때 자주 나온다. 주소 형식 오류, 입력 길이 제한, 정해진 값 목록(enum) 불일치가 여기 들어간다. 이 셋이 흔했다.

처음엔 대체 경로를 늘리는 쪽이 쉬워 보였다. 실제로는 달랐다. 보내는 값부터 줄이는 편이 빨랐다. 특정 설정값이 남아 있으면 대체 경로를 찾기 전에 요청을 만드는 코드부터 확인한다. 예전에 쓰던 값이 남았는지, 화면 전용 설정이 요청 본문 위쪽에 섞여 들어갔는지도 테스트로 잡는다.

외부 서비스 오류를 원인별로 나누고 허용 목록으로 좁혀 가는 구조를 보여주는 다이어그램
직접 생성한 오리지널 다이어그램.

해결 기준은 허용 목록(allowlist)이었다. 서비스별 요청 조립기가 보내도 되는 항목만 담게 한다. 화면이 들고 있는 설정 전체를 그대로 요청에 밀어 넣지 않는다. 비어 있는 선택 항목도 남기지 않는다. 문제가 난 뒤에는 실패를 흉내 낸 테스트부터 만든다. 같은 400을 다시 안 본다. 최소한의 울타리다.

422도 같은 방식으로 좁혔다. 앱 내부에서만 쓰는 상대 경로는 외부 서비스 입장에서 유효한 주소가 아니다. 외부로 보내는 값을 만들 때는 완전한 주소로 바꾸거나 데이터를 그대로 담아 보낸다. 입력 길이와 정해진 값 목록은 원본 기준을 두고 자른다. "상대가 까다롭다"가 아니라 "내가 만든 요청이 계약을 지키는가"로 질문을 바꾸면 고칠 위치가 좁아진다.

이 원리는 특정 서비스 하나에만 해당하지 않았다. 결제 API나 알림 수신처럼 다른 API에서도 마찬가지였다. 처음 보는 400·422는 상대 쪽 잘못이 아니라 내가 보낸 계약 위반일 확률이 더 높았다. 오류 응답에 있는 항목 이름 하나, 상태 코드 하나를 그대로 기록해 두고 그 값으로 검증 테스트를 만들면 다음에 같은 오류가 나도 원인을 훨씬 빨리 좁힐 수 있었다.

DB 마이그레이션은 순서 문제로 본다

DB 쪽 실패는 더 조용했다. 로컬 미리보기나 테스트 재현에서 갑자기 없는 항목을 찾다 멈춘다. 어떤 환경에서는 이미 있는 항목을 또 만들려다 걸린다. 겉보기엔 데이터베이스 엔진 특성처럼 보이지만 실제 원인은 대개 순서였다.

새 변경 사항을 넣을 때는 원본 파일, 실행 시점 정의, 변경 이력, 관련 테스트를 같이 본다. 새 DB에서 재현이 통과해야 하고 기존 데이터가 있는 DB에서도 추가 변경이 안전해야 한다. 값이 비어 있으면 안 되는 항목을 기본값 없이 추가하면 기존 데이터가 있는 DB에서 바로 실패한다. 새 항목의 색인을 예전 변경보다 먼저 만들면 미리보기 환경에서 그 항목을 못 찾는다.

읽기 전용 경로도 예외가 아니었다. 공개 화면을 불러오는 코드가 구조 복구 로직을 건드리면 사용자가 페이지를 보는 순간 DB 보정이 섞인다. 그래서 읽기 담당 코드는 필요하면 구조 복구를 끈다. 새 항목 때문에 실패하면 기존 데이터를 버리지 않는 대체 조회를 둔다. 그다음에 회귀 테스트를 붙인다.

마이그레이션 실패를 그냥 감싸서 넘긴다. 화면은 당장 뜬다. 대신 다음 배포에서 어떤 구조가 진짜인지 아무도 모른다. 실패가 보이면 순서와 재현 테스트로 다시 돌려보냈다. 귀찮아 보였다. 그래도 그쪽이 더 빨랐다.

백그라운드 재시도는 중복 실행을 부른다

방패 아이콘이 붉은 에러 스파크를 막아내고, 워크플로우를 나타내는 화살표가 장애물을 우회해 흘러가는 복원력 개념 이미지
직접 생성한 오리지널 이미지.

여러 단계로 나뉜 백그라운드 작업(수집, 변환, 저장 같은 순서)에서 한 단계가 시간 제한 경계에서 다시 시작되면 단순 재시도가 아니다. 이미 끝낸 단계가 있는데 외부 호출을 처음부터 다시 보내면 비용과 시간이 같이 늘어난다. 이 문제를 몇 번 되풀이하고 나서야 재시도 자체를 의심하기 시작했다. 의심부터 했다.

그래서 단계를 순수 계산과 전체 흐름 조정(orchestration)으로 나눴다. 작업 대기열 메시지에는 작업 번호와 처리 내용 중심 정보만 담고 긴 입력값이나 파일 같은 큰 값은 넣지 않는다. 병렬로 처리해도 되는 하위 작업은 동시 처리 개수를 제한해 묶어서 처리했다. 순서를 요구하는 경우가 아니면 단계마다 순차 대기를 추가하지 않는다.

수집, 변환, 저장, 후처리 단계별로 진행 기록(체크포인트)을 남기고, 재시작 시 완료된 단계는 건너뛰고 실패 지점부터 재시도하는 재실행 안전성 흐름
직접 생성한 오리지널 다이어그램.

재시작 문제는 재실행 안전성으로 막는다. 같은 요청을 몇 번을 다시 보내도 결과가 달라지지 않게 만드는 성질이다. 이미 완료된 단계는 건너뛴다. 그냥 건너뛴다. 진행 기록(checkpoint)이 남아 있으면 같은 외부 호출을 다시 보내지 않는다. 재사용 여부는 입력값의 스냅샷과 확인값을 같이 보고 판단한다. 문자열 하나만 비교하면 미묘하게 달라진 입력을 같은 것으로 착각할 수 있다.

실패 대응도 짧게 남긴다. 하위 작업이 상위 시간 제한 경계에서 재시작되면 하위 작업의 제한 시간을 상위보다 여유 있게 둔다. 일부 후처리 단계만 실패하면 전체 실패로 내리지 않는다. 대체 가능한 경로가 있는지 먼저 확인한다. 나중에 읽는 사람에게 필요한 건 긴 설명보다 이런 분기점이다.

이 습관은 특정 서비스나 특정 대기열 방식에만 해당하지 않는다. 이메일 발송, 알림 배치, 보고서 생성처럼 재시도가 걸리는 어떤 백그라운드 작업이든 같은 질문을 던질 수 있다. 이미 끝난 일을 다시 하고 있지는 않은가, 재시도 판단 기준이 요청 자체에 있는가 아니면 바깥 상태에 있는가. 이 두 질문만 먼저 물어도 상당수가 걸러졌다.

주기적 확인 간격과 정산 병목은 다른 문제다

성능 쪽 실패는 한 가지였을까. 아니다, 두 갈래였다. 한쪽은 일정한 간격으로 상태를 확인하는 방식이다. 예를 들어 짧은 간격을 고정해 두면 길게 도는 작업일수록 상태를 수백 번씩 두드리게 된다. 고친 방식은 짧은 간격에서 시작해 점점 늘려 가는 방식에 약간의 무작위 지연을 더했다.

여기서 포인트는 "천천히 보자"가 아니다. 초반엔 빠르게 보고 길어질수록 덜 자주 확인한다. 짧은 작업은 여전히 빠르게 끝난다. 긴 작업은 상태 확인 창구를 계속 두드리지 않는다. 사용자는 기다림을 덜 느낀다. 서버 쪽은 불필요한 확인을 덜 받는다. 둘 다 편해졌다.

다른 한쪽은 여러 곳의 값을 맞추는 정산 작업이었다. 서비스별 청구 조회와 DB 쓰기를 분리했다. 서로 다른 서비스의 청구 조회는 독립적이라 순서대로 하나씩 처리하면 느린 곳 하나가 전체를 붙잡는다. 이 부분은 동시 처리로 바꿨다.

반대로 있으면 갱신하고 없으면 추가하는 DB 쓰기와 사용량 맞춤은 순서대로 남겼다. 쓰기를 무작정 동시에 처리하면 다른 병목을 만든다. 같은 정산 로직 안에서도 읽기와 쓰기를 다르게 봤다. 외부 응답 대기는 동시 처리로 하고 저장소 변경은 안전한 순서로 둔다. 이 둘을 같은 방식으로 처리하면 둘 다 손해였다. 다르게 봐야 맞다.

테스트는 실패 지점 옆에 붙인다

테스트를 많이 붙인다고 항상 좋아지지는 않았다. 실패가 난 위치와 너무 멀리 떨어진 테스트는 원인을 흐린다. API 400이면 요청 조립 코드 옆에, DB 마이그레이션이면 재현 테스트 옆에, 주기적 확인이면 관련 코드 옆에 붙이는 식으로 범위를 줄였다.

주기적 확인은 대기 간격이 늘어나는 경로를 직접 확인했다. 정산 로직은 조회 순서와 일부만 실패했을 때 처리를 잡았다. 무관한 화면 갈래까지는 넓히지 않았다. 필요한 만큼만 봤다. 의도한 경로가 먼저 실패로 뜨고 수정 뒤 통과로 바뀌는 테스트가 가장 쓸모 있었다.

테스트 명령 자체도 실패 지식에 들어간다. 어떤 환경에서는 패키지 관리 도구가 단순 테스트 실행 이상을 하며 잠금 파일이나 작업 영역 설정을 건드릴 수 있다. 그럴 땐 그 도구를 계속 밀어붙이지 말고 실행 파일을 직접 돌린다. 의도치 않은 변경은 바로 되돌린다. 검증 명령도 제품 코드만큼 재현 가능해야 한다.

전체 흐름을 처음부터 끝까지 도는 테스트도 같은 기준을 따른다. 최근 배포 결과 하나만으로 운영 환경을 검증했다고 믿지 않는다. 화면 하나가 떴다고 해서 확인 조건을 약하게 만들면 실제 흐름은 여전히 깨져 있을 수 있다. 준비 데이터나 초기 설정이 어긋났는지 먼저 확인한다. 실패한 확인 조건을 억지로 맞추는 게 아니라 사용자가 실제로 지나가는 경로를 다시 세운다.

테스트를 실패 지점 옆에 붙이는 습관에는 부작용도 있었다. 파일마다 테스트가 흩어지니 전체 커버리지를 한눈에 보기가 불편해졌다. 그래도 감수할 만했다. 커버리지 숫자가 예뻐지는 것보다 버그가 났을 때 어느 파일을 열어야 하는지 바로 아는 쪽이 더 아꼈다. 숫자는 리포트에서나 의미가 있다. 실제로 고칠 때는 달랐다. 위치가 전부였다.

다음 버그에 바로 쓰는 체크리스트

아래 표는 내가 겪은 범위에서 반복해서 쓴 판단 순서다. 여러 외부 서비스와 DB 마이그레이션, 백그라운드 작업이 얽힌 서비스라면 시작점으로 삼을 만하다.

증상먼저 의심할 것안전한 대응
API 400화면 전용 값, 예전 값, 비어 있는 상위 항목서비스별 허용 목록 조립기와 요청 형식 테스트
API 422주소 형식, 정해진 값 목록, 입력 길이, 오래된 설정값기준 구조에 맞춘 정리, 주소 정규화, 가까운 표본 재사용
DB 미리보기 실패변경 순서, 기존 데이터, 색인 생성 시점새 DB 재현과 기존 데이터 변경 테스트를 같이 실행
백그라운드 재시작시간 제한, 진행 기록 누락, 재실행 안전성완료 단계 건너뛰기, 단위 진행 기록, 제한 시간 여유 조정
긴 작업 부하고정 간격 확인, 상태 확인 과다 호출초기엔 촘촘히, 이후엔 간격을 점점 늘리고 무작위 지연 추가
정산 지연외부 조회와 DB 쓰기를 같은 방식으로 처리조회는 동시 처리, 쓰기·맞춤은 순서 유지

실패가 나면 더 똑똑한 대체 처리부터 찾지 않는다. 실패가 다시 재현되는 가장 작은 문을 만든다. 그 문이 테스트가 된다. 테스트가 수정 범위를 줄인다. 고친 뒤에는 같은 판단을 다음에 볼 수 있는 자리에 남긴다. 기록만 남기면 된다.

비슷한 개발 기록은 프로그래밍 카테고리에 모아 둔다. 같은 증상이 나오면 먼저 그 기록을 뒤져 본다. 새 기능보다 오래 남는 건 대개 이런 작은 판단 순서였다.

아직 못 정리한 것도 있다. 증상마다 "먼저 의심할 것" 목록은 계속 늘어난다. 그 목록을 언제 체크리스트에서 빼도 되는지 기준이 없다. 다음 실험은 오래된 항목 중 최근 몇 달간 한 번도 안 걸린 것부터 솎아내는 일이다. 그게 다음 숙제다.