Contents
see List왜 중복 탭 제어가 필요한가
업무용 웹 애플리케이션에서 사용자는 같은 문서, 주문, 상담 건을 여러 탭으로 열어 둔 채 작업하는 경우가 많습니다. 이때 자동 저장, 임시 저장, 알림 폴링, 파일 업로드, 결제 상태 확인 같은 작업이 각 탭에서 동시에 실행되면 서버 부하가 늘고 데이터가 뒤섞일 수 있습니다. 특히 마지막 저장 요청이 항상 최신 입력이라고 보장할 수 없기 때문에, 오래된 탭의 자동 저장이 새 탭의 내용을 덮어쓰는 문제가 발생합니다. 단순히 버튼을 비활성화하거나 localStorage 플래그만 두는 방식은 탭 종료, 새로고침, 브라우저 프로세스 분리 상황에서 쉽게 깨집니다.
실무에서는 모든 탭을 완전히 막기보다 역할을 나누는 방식이 안정적입니다. BroadcastChannel은 같은 origin의 탭 사이에서 메시지를 주고받게 해 주고, Web Locks API는 동시에 하나의 탭만 특정 작업을 수행하도록 조율합니다. 두 기능을 함께 쓰면 사용자에게는 자연스럽게 보이면서도, 서버에는 중복 요청이 줄고 저장 충돌 가능성도 낮아집니다.
설계 원칙
먼저 탭마다 고유한 tabId를 만들고 세션 동안 유지합니다. 각 탭은 자신이 살아 있음을 주기적으로 알리고, 중요한 작업은 lock을 잡은 탭만 실행합니다. UI 상태는 BroadcastChannel로 공유하지만, 실제 데이터 정합성은 서버의 버전 번호나 updatedAt, optimistic locking으로 다시 검증해야 합니다. 브라우저 간 통신은 편의 장치이고, 최종 방어선은 API가 되어야 합니다.
- 자동 저장, 알림 폴링, 배치 동기화처럼 반복되는 작업은 대표 탭 하나만 실행합니다.
- 사용자 입력이 있는 탭은 다른 탭에 편집 시작, 저장 완료, 충돌 감지 같은 상태를 전파합니다.
- 네트워크 요청에는 문서 버전 또는 revision 값을 포함해 오래된 저장을 서버가 거절하도록 합니다.
- BroadcastChannel을 지원하지 않는 구형 환경에서는 localStorage 이벤트를 보조 수단으로 둡니다.
기본 구현 예제
다음 코드는 같은 문서를 여러 탭에서 열었을 때 자동 저장 작업이 한 탭에서만 실행되도록 만드는 최소 예시입니다. 핵심은 navigator.locks.request로 문서별 lock 이름을 만들고, BroadcastChannel로 저장 상태를 다른 탭에 알려 주는 것입니다.
const documentId = 'order-1234';
const tabId = crypto.randomUUID();
const channel = new BroadcastChannel(`doc:${documentId}`);
let latestRevision = 12;
let dirtyPayload = null;
channel.onmessage = (event) => {
const message = event.data;
if (message.type === 'saved' && message.tabId !== tabId) {
latestRevision = Math.max(latestRevision, message.revision);
showReadonlyNotice('다른 탭에서 저장되었습니다. 최신 내용을 확인하세요.');
}
};
function markDirty(formValue) {
dirtyPayload = formValue;
channel.postMessage({ type: 'editing', tabId, at: Date.now() });
}
async function saveDraftOnce() {
if (!dirtyPayload) return;
await navigator.locks.request(`autosave:${documentId}`, { ifAvailable: true }, async (lock) => {
if (!lock || !dirtyPayload) return;
const response = await fetch(`/api/documents/${documentId}/draft`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
revision: latestRevision,
payload: dirtyPayload
})
});
if (response.status === 409) {
channel.postMessage({ type: 'conflict', tabId, at: Date.now() });
throw new Error('문서 버전 충돌이 발생했습니다.');
}
const result = await response.json();
latestRevision = result.revision;
dirtyPayload = null;
channel.postMessage({ type: 'saved', tabId, revision: latestRevision });
});
}
setInterval(saveDraftOnce, 5000);서버 API에서 반드시 확인할 것
프론트엔드 lock만으로는 데이터 보호가 완성되지 않습니다. 사용자가 다른 브라우저, 모바일 앱, 사내 관리자 화면에서 같은 데이터를 수정할 수도 있기 때문입니다. 저장 API는 클라이언트가 보낸 revision이 현재 revision과 같은지 확인하고, 다르면 409 Conflict를 반환해야 합니다. 이 응답을 받은 화면은 자동 병합을 시도하거나 사용자에게 최신 데이터로 다시 불러오도록 안내해야 합니다.
-- documents 테이블 예시
UPDATE documents
SET draft_body = $1,
revision = revision + 1,
updated_at = now()
WHERE id = $2
AND revision = $3
RETURNING revision, updated_at;위 SQL의 RETURNING 결과가 없으면 이미 다른 요청이 먼저 저장한 것입니다. 이 경우 서버는 409 상태와 함께 현재 revision, 수정자, 수정 시각을 내려주면 좋습니다. 프론트엔드는 이를 기준으로 사용자에게 어느 탭의 작업을 유지할지 선택하게 만들 수 있습니다.
운영에서 자주 놓치는 부분
첫째, 탭이 background 상태가 되면 타이머가 늦게 실행될 수 있습니다. 자동 저장 간격을 정확한 시계처럼 믿지 말고, visibilitychange 이벤트에서 화면이 다시 보일 때 저장 상태를 점검해야 합니다. 둘째, BroadcastChannel 메시지는 영속 저장소가 아닙니다. 새로 열린 탭은 이전 메시지를 받지 못하므로 초기 진입 시 서버에서 최신 revision을 가져와야 합니다. 셋째, lock 이름은 작업 범위를 너무 넓게 잡지 않는 것이 좋습니다. 전체 사이트에 하나의 lock을 걸면 서로 무관한 문서 작업까지 대기하게 됩니다.
- 문서별 자동 저장: autosave:{documentId}
- 사용자별 알림 폴링: notification-poll:{userId}
- 대용량 업로드 후처리: upload-finalize:{fileId}
점진적 적용 방법
이미 운영 중인 서비스라면 모든 화면에 한 번에 적용하지 말고 충돌 비용이 큰 화면부터 시작합니다. 예를 들어 주문 수정, 견적서 작성, 게시글 임시 저장, 상담 메모처럼 사용자가 긴 내용을 입력하는 화면이 우선순위입니다. 첫 단계에서는 서버 revision 검증과 409 응답을 먼저 넣고, 두 번째 단계에서 BroadcastChannel 알림을 추가합니다. 마지막으로 Web Locks를 적용해 중복 자동 저장을 줄이면 장애를 분리해서 관찰하기 쉽습니다.
관측 지표도 함께 준비해야 합니다. 409 발생 횟수, 자동 저장 성공률, lock을 얻지 못해 건너뛴 횟수, 탭 간 saved 메시지 수를 로그로 남기면 실제로 중복 저장이 줄었는지 판단할 수 있습니다. 사용자가 자주 충돌을 만난다면 UI 경고가 부족한 것이고, lock skip이 너무 많다면 저장 주기나 lock 범위가 과도한 것입니다.
적용 체크리스트
- 탭마다 crypto.randomUUID 기반 tabId를 만든다.
- BroadcastChannel 이름은 origin 안에서 충돌하지 않도록 업무 단위와 식별자를 포함한다.
- 반복 작업은 navigator.locks.request의 ifAvailable 옵션으로 대표 탭만 실행한다.
- 저장 API는 revision 조건으로 갱신하고 실패 시 409 Conflict를 반환한다.
- 새 탭 진입과 화면 재활성화 시 서버에서 최신 상태를 다시 읽는다.
- 충돌, lock skip, 자동 저장 성공률을 운영 로그로 남긴다.