앱 출시 직전에는 기능 목록보다 동시에 일어난 두 작업 중 어느 결과를 화면에 반영할지가 더 중요합니다. 검색 요청 A 다음에 B를 보냈는데 A가 늦게 도착하거나, 저장 버튼이 두 번 눌리거나, 응답을 받지 못한 POST를 다시 보낼 때 정책이 없으면 정상 경로 테스트만 통과한 앱에서도 데이터가 뒤집히거나 중복될 수 있습니다.
이 글은 추상적인 체크리스트에 그치지 않도록 세 가지 실패 상황을 작은 상태 모델로 만들고 실제로 실행했습니다. 테스트는 특정 Flutter 화면이나 Android 네트워크 라이브러리의 성능을 측정한 것이 아니라, UI와 데이터 계층 사이에 들어갈 결정 로직이 예상대로 동작하는지 확인한 것입니다.
이번에 직접 확인한 세 가지 실패 조건
| 실패 조건 | 잘못된 결과 | 이번 테스트의 통과 기준 |
|---|---|---|
| A 요청 뒤 B 요청, A가 나중에 완료 | 화면이 다시 A 결과로 돌아감 | B만 반영하고 A는 무시 |
| 같은 저장 작업이 두 번 도착 | 동일 데이터가 두 번 생성됨 | 첫 요청만 쓰고 이후 요청은 기존 결과 반환 |
| GET·POST 요청이 503을 받음 | 모든 요청을 같은 방식으로 자동 재시도 | 요청 의미와 중복 방지 계약에 따라 분기 |
재현 환경은 Windows, Node.js v24.19.0, 내장 node:test입니다. 2026년 9월 18일에 아래 공개 코드와 같은 조건으로 실행했으며, 네트워크 지연은 재현 가능한 짧은 timer로 만들었습니다. 실제 운영 API나 사용자 데이터는 사용하지 않았습니다.

실행한 코드: 상태를 바꾸는 규칙을 UI 밖으로 꺼내기
다음 코드는 프레임워크 없이 세 정책을 검증합니다. 화면 위젯이나 Activity 안에 조건을 흩어 놓지 않고, 입력과 기대 결과를 assertion으로 고정하는 데 목적이 있습니다.
import assert from 'node:assert/strict';
import test from 'node:test';
const wait = (milliseconds, value) =>
new Promise((resolve) => setTimeout(() => resolve(value), milliseconds));
test('늦게 도착한 이전 응답은 최신 화면을 덮지 않는다', async () => {
let latestRequest = 0;
let visibleResult = null;
const events = [];
async function search(query, delay) {
const requestId = ++latestRequest;
const result = await wait(delay, `${query} 결과`);
if (requestId !== latestRequest) {
events.push(`${query}:ignored`);
return;
}
visibleResult = result;
events.push(`${query}:applied`);
}
await Promise.all([search('A', 60), search('B', 10)]);
assert.equal(visibleResult, 'B 결과');
assert.deepEqual(events, ['B:applied', 'A:ignored']);
});
test('같은 작업 키로 들어온 중복 저장은 한 번만 반영한다', () => {
const stored = new Map();
let writes = 0;
function saveOnce(key, payload) {
if (stored.has(key)) {
return { status: 'duplicate', value: stored.get(key) };
}
writes += 1;
stored.set(key, payload);
return { status: 'created', value: payload };
}
const first = saveOnce('save-20260918-001', { memo: 'release note' });
const second = saveOnce('save-20260918-001', { memo: 'release note' });
assert.equal(first.status, 'created');
assert.equal(second.status, 'duplicate');
assert.equal(writes, 1);
});
test('자동 재시도는 요청 의미와 실패 종류를 함께 본다', () => {
function retryDecision({ method, status, hasIdempotencyKey = false }) {
if ([400, 401, 403, 404].includes(status)) return 'do-not-retry';
if (![408, 429, 500, 502, 503, 504].includes(status)) return 'do-not-retry';
const idempotentMethod = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']
.includes(method.toUpperCase());
return idempotentMethod || hasIdempotencyKey
? 'bounded-backoff'
: 'confirm-before-retry';
}
assert.deepEqual([
retryDecision({ method: 'GET', status: 503 }),
retryDecision({ method: 'POST', status: 503 }),
retryDecision({ method: 'POST', status: 503, hasIdempotencyKey: true }),
retryDecision({ method: 'GET', status: 401 }),
], [
'bounded-backoff',
'confirm-before-retry',
'bounded-backoff',
'do-not-retry',
]);
});
실행 명령은 node --test 2026-09-18-release-flow-test.mjs이며 결과는 다음과 같았습니다. 실행 시간은 환경에 따라 달라지므로 판정에 필요한 안정적인 값만 남깁니다.
stale-response | B:applied -> A:ignored | visible=B 결과
duplicate-request | created -> duplicate | writes=1
retry-policy | GET503=backoff | POST503=confirm | keyedPOST503=backoff | GET401=stop
tests 3
pass 3
fail 0
결과 1: 요청 취소와 결과 무시는 서로 다른 문제다
A 요청을 실제로 취소할 수 있다면 데이터 사용량과 서버 부하를 줄일 수 있습니다. 그러나 취소 신호가 이미 처리 중인 서버 작업까지 항상 멈춘다고 가정하면 안 됩니다. 클라이언트에는 취소 여부와 별개로 현재 요청 번호와 완료된 요청 번호를 비교하는 마지막 방어선이 필요합니다.
이번 테스트에서는 B가 먼저 완료되어 화면에 반영되고, 50ms 뒤 도착한 A는 requestId !== latestRequest 조건에서 버려졌습니다. Flutter에서는 이 정책을 검색 ViewModel이나 repository의 상태 전환에 두고, 화면이 닫혔는지 확인하는 lifecycle 처리와 함께 사용해야 합니다. 단순히 mounted만 검사하면 “화면은 살아 있지만 더 오래된 응답”인 상황은 막지 못합니다.
비동기 작업이 끝난 뒤 이미 닫힌 화면을 갱신하는 문제는 Flutter setState() called after dispose 점검에서 lifecycle·취소·mounted의 역할을 별도로 확인할 수 있습니다.
결과 2: 버튼 잠금은 중복 저장의 최종 방어선이 아니다
버튼을 누른 직후 비활성화하면 연속 탭은 줄어듭니다. 하지만 모바일 네트워크 재전송, 앱 재시작 뒤 queue 복원, 프록시나 SDK의 재시도처럼 UI를 거치지 않는 중복은 남습니다. 중복 방지는 서버 또는 영속 저장소가 같은 작업 키를 기억하고 동일한 결과를 돌려줄 때 완성됩니다.
테스트의 Map은 이 계약을 최소 형태로 표현합니다. 같은 키를 두 번 전달했을 때 첫 결과는 created, 두 번째 결과는 duplicate였고 실제 쓰기 횟수는 1이었습니다. 운영 환경에서는 메모리 Map이 아니라 다음 조건이 필요합니다.
- 사용자·작업 범위가 포함된 예측 불가능한 idempotency key
- 키와 요청 내용 hash, 처리 상태, 최종 응답의 원자적 저장
- 같은 키에 다른 요청 내용이 오면 재사용을 거절하는 규칙
- 동시에 들어온 요청 둘이 모두 생성 단계에 진입하지 않도록 하는 고유 제약 또는 lock
- 키 만료 시간과 오래 실행되는 작업의 복구 정책
헤더에 키 문자열만 붙였다고 POST가 자동으로 안전해지는 것은 아닙니다. 서버가 위 계약을 실제로 집행해야 합니다. 결제나 주문처럼 외부 시스템이 연결된 작업은 공급자 응답 조회와 보상 처리까지 별도 설계가 필요합니다.
결과 3: 상태 코드만 보고 모든 실패를 재시도하지 않는다
RFC 9110은 GET·HEAD 같은 safe method와 PUT·DELETE를 idempotent method로 정의하지만, 이는 요청한 의도에 관한 성질입니다. 서버 로그나 부수 효과가 완전히 같다는 뜻은 아닙니다. 비멱등 요청은 원래 작업이 적용되지 않았음을 알거나 애플리케이션이 멱등성을 보장할 수 있을 때만 자동 재시도를 고려해야 합니다.
| 상황 | 기본 행동 | 추가 확인 |
|---|---|---|
| GET + 503 | 횟수 제한과 backoff를 둔 재시도 후보 | Retry-After, 전체 시간 제한, 사용자 취소 |
| POST + 503 | 자동 반복 전에 처리 여부 확인 | 서버 idempotency 계약과 작업 조회 API |
| 키가 있는 POST + 503 | 서버가 키를 집행할 때만 제한 재시도 | 동일 payload, 키 보존 기간, 응답 replay |
| 401·403 | 같은 요청의 반복 중단 | 인증 갱신은 한 번만 조정하고 실패 시 로그인 안내 |
| 429 | 서버가 준 대기 조건 우선 | Retry-After, 사용자·키별 제한, 동시성 |
429를 500과 같은 고정 delay로 다루면 제한을 더 악화시킬 수 있습니다. 웹 API 429 재시도 설계에서 Retry-After와 jitter·동시성 조건을 더 자세히 다룹니다.
화면 상태를 한 줄짜리 Boolean 여러 개로 쪼개지 않기
isLoading, hasError, isSaved를 서로 독립적으로 변경하면 동시에 참이 될 수 없는 조합이 생깁니다. 화면이 표현할 수 있는 상태를 하나의 sealed state나 명시적인 record로 만들고, event가 어떤 전이를 허용하는지 표로 고정하는 편이 테스트하기 쉽습니다.
| 현재 상태 | 이벤트 | 다음 상태 | 금지할 전이 |
|---|---|---|---|
| Idle | Submit | Saving(requestKey) | 검증 실패 입력으로 Saving 진입 |
| Saving | 두 번째 Submit | 기존 작업 유지 또는 명시적 교체 | 키가 다른 저장을 조용히 병렬 실행 |
| Saving | Success | Saved(result) | 오래된 request의 Success 반영 |
| Saving | RetryableFailure | RetryWaiting(attempt) | 무제한 즉시 재시도 |
| Saving | AuthFailure | NeedsLogin | 같은 token으로 반복 |
| 어느 상태든 | Logout | SignedOut | 민감한 결과·queue 유지 |
Android 공식 아키텍처 문서는 화면 구성 요소가 OS에 의해 재생성될 수 있으므로 UI 컴포넌트를 데이터의 소유자로 두지 않고, 단일 진실 공급원과 단방향 데이터 흐름을 사용하도록 안내합니다. Flutter에서도 View가 직접 데이터 작업을 소유하기보다 ViewModel과 repository 경계를 분리해야 상태 전이를 독립적으로 테스트할 수 있습니다.
실제 앱에서 추가해야 할 실패 주입 테스트
이번 Node 테스트는 정책을 빠르게 검증한 단위 테스트입니다. 출시 판단에는 프레임워크와 실제 저장소를 통과하는 다음 테스트가 더 필요합니다.
- 화면 이동: 요청 중 뒤로 가기 후 응답이 도착해도 사라진 화면과 새 화면을 갱신하지 않는지 확인합니다.
- 프로세스 재생성: 입력 중 회전·백그라운드 종료 뒤 복원할 임시 상태와 서버에서 다시 읽을 데이터를 구분합니다.
- 두 기기의 동시 수정: 마지막 쓰기 우선, version conflict, merge 중 제품 규칙과 실제 API 동작이 일치하는지 봅니다.
- 응답 유실: 서버 저장은 성공했지만 클라이언트가 응답을 못 받은 조건에서 같은 key 조회가 같은 결과를 반환하는지 확인합니다.
- 부분 실패: 파일 업로드 성공·DB 기록 실패처럼 단계가 나뉜 작업의 정리 또는 재개 위치를 검증합니다.
- 업데이트 설치: 이전 앱의 queue·로컬 DB·token을 새 버전이 읽을 때 migration과 폐기 규칙을 확인합니다.
앱 설치 자체가 막히는 경우는 상태 모델과 다른 층이므로 Android·iPhone 설치 오류 진단표에서 스토어·저장 공간·계정·기기 조건부터 분리합니다.
로그는 재현에 필요한 값만 남기기
각 상태 전이에 request_id, operation_key의 안전한 hash, 앱 버전, 이전·다음 상태, 실패 분류, attempt를 남기면 “오류가 났다”보다 원인을 찾기 쉽습니다. 반대로 access token, session ID 원문, 비밀번호, 결제 정보, 사용자가 입력한 민감한 본문은 일반 로그에 남기지 않습니다.
OWASP는 session 식별자, access token, 비밀번호, 연결 문자열과 암호화 키 같은 값을 직접 기록하지 말고 제거·마스킹·hash·암호화를 검토하도록 안내합니다. 지원용 오류 코드는 내부 stack trace 전체가 아니라 서버 로그의 안전한 event ID와 연결합니다.
출시를 멈춰야 하는 기준
다음 중 하나가 남아 있다면 정상 경로가 성공해도 출시 완료로 보지 않는 편이 안전합니다.
- 오래된 응답을 구분할 request 또는 version 기준이 없다.
- 중복 저장을 UI 버튼 잠금만으로 막고 서버 계약이 없다.
- POST timeout 뒤 원래 작업의 처리 여부를 조회할 방법이 없다.
- 401·403·429·5xx가 모두 같은 재시도 loop로 들어간다.
- 화면 종료 뒤 비동기 결과가 누구의 상태를 바꾸는지 설명할 수 없다.
- 업데이트 실패 시 이전 앱과 서버 API의 호환 범위가 정해져 있지 않다.
- 장애 로그 없이는 사용자의 말만으로 요청 하나를 추적할 수 없다.
반대로 상태 소유자와 허용 전이가 문서화돼 있고, 늦은 응답·중복 요청·응답 유실을 자동 테스트로 재현하며, 실제 배포 빌드의 lifecycle과 저장소까지 통합 테스트했다면 출시 판단에 사용할 근거가 생깁니다. 체크 표시의 개수보다 실패 조건을 다시 만들 수 있는 코드와 관찰 결과가 중요합니다.
확인한 환경과 원문
직접 실행 범위는 이 글에 공개한 순수 상태 결정 코드이며 실제 Flutter 엔진, Android 기기, 결제 서버 동작을 대신하지 않습니다. 제품 적용 시 사용하는 프레임워크와 API의 통합 테스트가 별도로 필요합니다.
- Android Developers: Guide to app architecture
- Android Developers: UI layer and unidirectional data flow
- Android Developers: Activity lifecycle
- Flutter: Guide to app architecture
- RFC 9110 §9.2.2: Idempotent Methods
- OWASP: Logging Cheat Sheet