이번 편은 1차 연재의 마지막 편이다. 여기까지 오면서 우리는 바이브코딩으로 체크리스트 앱을 만들 때, 단순히 화면을 만드는 것을 넘어 저장, 수정, 삭제, 재정렬, 자동 저장, 서버 응답 반영, 임시 항목, retry 같은 흐름까지 조금씩 다뤄왔다. 마지막에 이 이야기를 오프라인과 동기화로 묶는 이유는 단순하다. 앞에서 배운 개념들이 결국 여기에서 한 번 더 만나기 때문이다.
입문자 입장에서는 "오프라인"이라는 말이 갑자기 크게 느껴질 수 있다. 하지만 조금 더 현실적으로 보면, 꼭 비행기 모드나 지하철 안 같은 극단적인 상황만 뜻하는 것은 아니다. 네트워크가 잠깐 흔들리거나, 서버 응답이 느려지거나, 저장이 바로 안 되는 순간도 모두 비슷한 감각으로 이어진다. 즉, 오프라인과 동기화는 특별한 고급 기능이라기보다 앱이 흔들릴 때 무엇을 지금 처리하고 무엇을 나중으로 미룰지 정하는 문제에 가깝다.
이번 글에서는 마지막 정리라는 마음으로, 지금까지 만들었던 흐름이 왜 큐와 동기화라는 개념으로 모이게 되는지, "지금 화면에 먼저 반영할 것"과 "나중에 서버에 보낼 것"은 어떻게 나눠서 봐야 하는지, 그리고 초보자라면 가장 단순한 첫 큐 구조를 어떻게 잡는 편이 좋은지를 차근차근 정리해보겠다.
| 겉으로 보이는 문제 | 실제로 일어나는 일 | 가장 먼저 봐야 할 방향 |
|---|---|---|
| 인터넷이 잠깐 끊기면 저장이 전부 멈춘 것처럼 느껴진다 | 지금 처리할 로컬 변경과 나중에 보낼 서버 작업을 분리하지 않았다 | 즉시 반영할 것과 보류할 것을 먼저 나눈다 |
| 네트워크가 돌아오면 무엇부터 다시 보내야 할지 헷갈린다 | 실패한 작업 목록을 따로 들고 있지 않다 | 큐라는 개념으로 미뤄진 작업을 관리하는지 본다 |
| 다시 연결되자마자 상태가 더 꼬인다 | 큐를 순서 없이 보내거나, 이미 처리한 작업과 새 작업을 구분하지 못한다 | 첫 버전은 순서를 지키는 단순한 큐부터 시작한다 |
| 오프라인 대응을 붙였더니 코드가 갑자기 무거워졌다 | todo 데이터, UI 상태, 대기 작업을 한 덩어리로 보고 있다 | 데이터, 화면 상태, 큐를 서로 다른 층위로 분리해서 본다 |
이번 글의 핵심 한 줄
오프라인 대응의 핵심은 "인터넷이 없어도 전부 다 하겠다"가 아니라, "지금 할 일"과 "나중에 동기화할 일"을 구분해서 앱이 덜 흔들리게 만드는 데 있다.
왜 마지막 단계에서 오프라인과 동기화를 봐야 할까
지금까지 연재를 따라왔다면 이미 여러 번 비슷한 문제를 봤다. 저장 중 상태를 잠가야 했고, 실패 시 rollback도 생각해야 했고, 오래된 응답이 최신 화면을 덮어쓰지 못하게 requestId도 봤고, 낙관적 생성에서는 tmp id와 진짜 id를 연결해야 했다. 이 모든 문제는 겉으로는 조금씩 달라 보여도 공통 질문이 하나 있다. "지금 이 변화는 이미 확정된 것인가, 아니면 아직 어딘가에 전달되어야 하는가?"
오프라인과 동기화는 바로 이 질문을 더 분명하게 보게 만든다. 인터넷이 불안정하거나 잠깐 끊기면, 어떤 변화는 지금 화면에 먼저 반영할 수 있고 어떤 변화는 서버에 나중에 보내야 한다. 이 구분이 없으면 사용자는 방금 체크한 항목이 정말 저장된 것인지, 잠깐 화면에서만 바뀐 것인지 알 수 없고, 개발자도 무엇을 먼저 보내야 할지 금방 헷갈리게 된다.
| 앞에서 다룬 개념 | 오프라인 단계에서 다시 중요한 이유 |
|---|---|
| 낙관적 업데이트 | 지금 화면에는 먼저 보여주되, 나중에 서버와 맞춰야 한다 |
| retry | 실패한 작업을 그냥 버리지 않고 다시 보낼지 정해야 한다 |
| requestId와 최신 응답 | 뒤늦게 온 결과가 현재 상태를 망치지 않게 해야 한다 |
| 임시 항목과 tmp id | 아직 서버가 모르는 항목을 화면에 먼저 둘 수 있기 때문이다 |
즉, 오프라인 대응은 완전히 새로운 주제가 아니라, 지금까지 쌓인 개념이 한 번 더 모여서 "지금 반영할 것"과 "나중에 맞출 것"을 구분하는 단계라고 보는 편이 훨씬 정확하다.
핵심 포인트
오프라인 대응은 거창한 별도 기능이라기보다, 지금까지 만든 저장 흐름에 "나중에 동기화"라는 시간이 하나 더 붙는 단계에 가깝다.
"지금 반영할 것"과 "나중에 보낼 것"을 나누는 감각
입문자에게 가장 중요한 출발점은 이것이다. 앱 안에는 서로 다른 종류의 정보가 있다. 어떤 것은 지금 당장 화면에 보여주기 위해 반영해도 되고, 어떤 것은 서버가 살아나면 그때 보내도 된다. 이 둘을 구분하지 않으면 오프라인 대응은 거의 항상 과하게 복잡해진다.
체크리스트 앱으로 보면 보통 아래처럼 생각할 수 있다.
| 지금 반영할 수 있는 것 | 나중에 서버로 보내도 되는 것 |
|---|---|
| 체크 박스가 눌린 화면 상태 | done 변경 요청 |
| 목록에 임시 항목을 먼저 보여주기 | 새 항목 생성 요청 |
| 재정렬된 화면 순서 | order 변경 요청 |
여기서 중요한 건 "화면에 보인 것 = 서버도 이미 안다"라고 가정하지 않는 것이다. 예를 들어 사용자가 체크를 바꾸는 즉시 UI에서 완료 표시를 바꿔줄 수는 있다. 하지만 네트워크가 끊겨 있으면 서버는 아직 그 사실을 모른다. 그래서 앱은 "지금 화면엔 바뀌어 있지만, 아직 서버로는 못 보낸 작업"을 따로 기억하고 있어야 한다.
이 감각이 생기면 오프라인 대응도 갑자기 덜 막막해진다. 모든 걸 완벽하게 실시간으로 맞추려 하기보다, 로컬에서 먼저 반영하고 나중에 서버로 보낼 작업을 모아두는 구조로 보이기 때문이다.
핵심 정리
오프라인 대응의 첫걸음은 "모든 걸 지금 서버와 맞추겠다"가 아니라 "로컬 상태와 서버로 보낼 작업을 분리해서 들고 가겠다"에 가깝다.
큐는 정확히 무엇이고 왜 필요한가
이제 여기서 큐라는 개념이 등장한다. 큐는 어렵게 말하면 "나중에 처리할 작업을 순서대로 모아두는 줄"에 가깝다. 지금은 바로 서버로 보내지 못하거나, 보내도 확정까지 기다려야 하는 작업들을 잠시 쌓아두는 곳이라고 보면 된다.
예를 들어 오프라인 상태에서 사용자가 할 일을 하나 추가하고, 기존 항목을 완료 처리하고, 또 다른 항목의 text를 수정했다고 해보자. 이 세 동작은 모두 화면에는 반영될 수 있지만, 서버는 아직 모를 수 있다. 그러면 앱은 이 동작들을 큐에 순서대로 넣어두고, 연결이 돌아왔을 때 하나씩 보낼 수 있어야 한다.
{
id: "q-1710000000001",
type: "create",
todoId: "tmp-1710000000001",
payload: {
text: "주말 장보기"
},
createdAt: 1710000000001
}
이런 구조를 보면 감이 온다. 큐 항목은 "현재 어떤 작업이 대기 중인가"를 설명하는 데이터다. todo 자체와는 다르다. todo는 화면에 보여줄 실제 항목이고, queue item은 나중에 서버로 보낼 일감이다.
| 구분 | todo | queue item |
|---|---|---|
| 의미 | 화면과 로컬 상태에 존재하는 실제 항목 | 나중에 서버에 보낼 작업 정보 |
| 예시 | text, done, order | type, todoId, payload |
| 왜 필요한가 | 사용자에게 지금 상태를 보여주기 위해 | 네트워크 복구 후 어떤 작업부터 보낼지 기억하기 위해 |
즉, 큐는 "실패한 저장을 다시 시도하는 버튼"보다 한 단계 더 넓은 개념이다. 실패한 항목을 남겨두는 것도 결국 큐와 닿아 있다. 왜냐하면 retry도 결국 "이 작업을 나중에 다시 보낸다"는 뜻이기 때문이다.
핵심 포인트
큐는 데이터를 또 하나 복제하는 곳이 아니라, 서버로 보내지 못한 작업을 순서대로 기억해두는 목록에 가깝다.
가장 단순한 큐 구조와 상태 설계
입문자에게는 큐를 너무 거창하게 시작할 필요가 없다. 처음에는 아래 정도 구조만 있어도 충분한 경우가 많다.
let appState = {
todos: [],
queue: [],
isSyncing: false,
syncError: ""
};
그리고 queue item은 보통 이런 정도면 시작할 수 있다.
function createQueueItem(type, todoId, payload) {
return {
id: "q-" + Date.now() + "-" + Math.random().toString(16).slice(2),
type,
todoId,
payload,
createdAt: Date.now()
};
}
여기서 type에는 "create", "update", "delete" 같은 값이 들어갈 수 있고, payload에는 text나 done 같은 필요한 데이터가 들어갈 수 있다. todoId는 어떤 항목과 연결된 작업인지 알게 해준다. 생성의 경우에는 tmp id를 들고 갈 수도 있다.
| 상태 | 무슨 뜻인가 |
|---|---|
| queue | 아직 서버에 못 보냈거나 다시 보내야 할 작업 목록 |
| isSyncing | 지금 큐를 서버와 맞추는 중인지 |
| syncError | 동기화 전체에 대한 실패 안내 |
중요한 건 queue를 todos 안에 섞지 않는 것이다. todo는 사용자에게 보여줄 현재 상태이고, queue는 아직 끝나지 않은 네트워크 작업 목록이다. 이 둘을 한 객체 안에 전부 밀어 넣기 시작하면 설명도 디버깅도 훨씬 어려워진다.
핵심 정리
처음 버전의 큐는 거대한 동기화 엔진이 아니라, "나중에 서버로 보낼 일감 목록" 정도로 이해해도 충분하다.
재연결 후 동기화는 어떤 순서로 흘러야 할까
이제 마지막으로 가장 중요한 흐름을 보자. 인터넷이 다시 살아났다고 해서 queue 안의 작업을 무작정 한꺼번에 보내기 시작하면 오히려 더 꼬일 수 있다. 초보자에게는 처음 버전에서는 순서대로 하나씩 보내는 구조가 가장 설명하기 쉽고 안정적이다.
- 현재 queue에 작업이 있는지 본다.
- 이미 동기화 중이면 새로 시작하지 않는다.
- 가장 앞의 작업 하나를 서버로 보낸다.
- 성공하면 queue에서 제거하고, 필요한 로컬 교체를 반영한다.
- 실패하면 멈추고 syncError를 남긴다.
- 성공한 경우 다음 작업으로 넘어간다.
async function processQueue() {
if (appState.isSyncing) return;
if (appState.queue.length === 0) return;
appState.isSyncing = true;
appState.syncError = "";
try {
while (appState.queue.length > 0) {
const job = appState.queue[0];
const result = await sendQueueItem(job);
applyServerResult(job, result);
appState.queue.shift();
persistLocalState();
renderApp();
}
} catch (error) {
appState.syncError = "동기화에 실패했습니다. 다시 시도해 주세요.";
} finally {
appState.isSyncing = false;
renderApp();
}
}
이 흐름이 첫 버전에서 좋은 이유는 명확하다. 무엇을 먼저 보냈는지, 어디서 멈췄는지, 실패하면 어떤 작업이 아직 남아 있는지 설명하기 쉽기 때문이다. 반대로 처음부터 여러 작업을 병렬로 한꺼번에 보내기 시작하면, 응답 순서와 상태 반영이 한꺼번에 복잡해진다.
| 순차 처리 | 왜 입문자에게 유리한가 |
|---|---|
| 한 번에 하나만 보냄 | 응답 순서가 단순해서 디버깅이 쉽다 |
| 실패 시 바로 멈춤 | 어느 작업에서 막혔는지 설명하기 쉽다 |
| 성공할 때마다 큐 제거 | 남은 작업과 끝난 작업이 분명하게 나뉜다 |
이 단계에서 한 가지를 더 기억하면 좋다. 동기화는 로컬 todos를 없애고 서버에서 다시 다 받는 것과는 조금 다르다. 처음에는 "큐에 남아 있던 작업을 서버에 전달하고, 그 결과로 필요한 로컬 보정을 한다" 정도로 생각해도 충분하다. 특히 생성 작업은 tmp id를 서버 id로 교체하는 식의 반영이 여기서 다시 중요해진다.
핵심 정리
재연결 후 동기화의 첫 버전은 "하나씩, 순서대로, 실패하면 멈춘다"만으로도 충분히 의미가 있다. 복잡한 병렬 처리보다 설명 가능한 흐름이 먼저다.
초보자가 자주 하는 실수 6가지
오프라인과 큐를 붙이기 시작하면 구조가 풍부해지는 대신, 몇 가지 실수도 아주 자주 반복된다. 대부분은 todo, uiState, queue를 같은 층위로 보거나, 큐 순서를 너무 가볍게 볼 때 생긴다.
| 실수 | 겉으로 보이는 증상 | 실제로 문제인 부분 | 어떻게 보는 게 좋은가 |
|---|---|---|---|
| queue 없이 todos만 믿고 나중에 다시 저장하려 한다 | 무엇을 서버에 보내야 하는지 기준이 흐려진다 | 미뤄진 작업과 현재 데이터 상태를 분리하지 않았다 | 나중에 보낼 일은 queue로 따로 들고 가는 편이 낫다 |
| queue item 안에 UI 상태까지 다 넣는다 | 구조가 갑자기 너무 무거워진다 | 데이터, 화면 상태, 동기화 작업을 한데 섞었다 | queue는 서버에 보낼 작업 정보에 집중시키는 편이 좋다 |
| 재연결되자마자 모든 큐를 병렬로 보낸다 | 응답 순서가 꼬여 설명이 어려워진다 | 첫 버전에서 너무 복잡한 동기화를 시도했다 | 입문자에게는 순차 처리부터가 훨씬 낫다 |
| 실패한 queue item을 조용히 버린다 | 사용자는 왜 값이 안 맞는지 알 수 없다 | 실패를 설명하거나 재시도할 기준이 없다 | 첫 버전이라도 실패 시 queue를 유지하고 syncError를 남기는 편이 좋다 |
| tmp id를 서버 id로 바꾸는 단계를 동기화에서 빼먹는다 | 오프라인 중 생성된 항목이 나중에도 수정, 삭제에서 꼬인다 | 생성 응답 반영 규칙이 빠졌다 | 동기화 단계에서도 tmp -> server 교체가 필요하다는 점을 잊지 않는다 |
| queue를 메모리에서만 들고 있다 | 새로고침하면 미뤄진 작업이 사라진다 | 로컬 상태 보존을 빼먹었다 | 작은 연습 앱이라도 queue 자체를 로컬에 저장할지 고민하는 편이 좋다 |
특히 마지막 실수는 생각보다 중요하다. 오프라인 대응을 붙였는데 queue를 메모리에만 두면, 앱을 새로고침하는 순간 "나중에 보내려던 일"이 전부 사라질 수 있기 때문이다. 그래서 작은 연습 앱이라도 개념을 이해하는 목적이라면 queue 자체도 localStorage 같은 곳에 저장해볼 가치가 있다. 물론 규모가 커지면 다른 저장소도 고민할 수 있지만, 입문 단계에서는 개념을 몸에 익히는 것이 먼저다.
실전에서 가장 많이 흔들리는 부분
오프라인 대응은 "네트워크가 없을 때도 되게 하자"보다 "지금 하지 못한 일을 어디에 안전하게 보관하고, 나중에 어떤 순서로 처리할지"를 분명히 하는 데 더 가깝다.
'바이브코딩 > 이론' 카테고리의 다른 글
| 29. 저장 실패한 항목을 왜 바로 없애면 아쉬울까: retry 가능한 임시 항목 설계 (0) | 2026.05.19 |
|---|---|
| 28. 새 항목은 보이는데 왜 수정과 삭제가 꼬일까: 임시 id와 진짜 id 연결하기 (0) | 2026.05.14 |
| 27. 저장은 성공했는데 왜 화면 값이 또 바뀔까: 서버 응답 반영 기준 (1) | 2026.05.12 |
| 26. 지금 저장됐는지 어떻게 보여줄까: "저장 중", "저장됨", "실패" 상태 설계하기 (0) | 2026.05.07 |
| 25. 입력할 때마다 저장하면 왜 더 불편할까: 바이브코딩 debounce 입문 (0) | 2026.05.05 |
