본문으로 바로가기
검색 (Ctrl+K)

Web Locks API로 같은 작업을 두 탭에서 열지 못하게 잠그기

·10분 읽기·
---
·
개발

웹 잠금

들어가며

저는 WMS(창고 관리 시스템)의 프론트엔드를 만들고 있습니다. WMS를 쓰는 현장 작업자들은 브라우저 탭을 여러 개 열어 두고 일하는 경우가 많아요. 한 탭에는 출고 목록을, 다른 탭에는 패킹 화면을 띄워 두고 오가는 식입니다.

탭을 여러 개 쓰다 보면 같은 작업을 두 탭에서 동시에 여는 일이 생깁니다. 패킹 화면은 진행 상태를 탭마다 따로 기억하기 때문에, 한 탭에서 검수를 끝내고 다음 단계로 넘어가도 다른 탭은 여전히 검수 전 화면에 머물러 있어요. 그 상태에서 두 번째 탭으로 작업을 이어 가면 서버에는 이미 끝난 단계의 요청이 한 번 더 들어갑니다. 실제로 이런 식으로 데이터가 어긋나는 일이 종종 있었습니다.

이런 중복은 결국 서버가 막아야 합니다. 다만 서버 쪽 처리가 준비되기 전에도 프론트엔드에서 할 수 있는 일이 있다고 생각했어요. 같은 브라우저 안이라면 "이 작업은 이미 다른 탭에서 진행 중"이라고 알려 주고, 두 번째 탭에서는 작업 화면을 아예 열지 않는 것입니다. 이 글은 그 기능을 Web Locks API로 만든 과정을 정리한 기록입니다.

Web Locks API란?

Web Locks API는 이름 붙은 잠금(lock)을 비동기로 얻고 반납하는 브라우저 API입니다. 같은 origin의 탭과 워커가 하나의 자원을 두고 순서를 맞출 때 씁니다. 2022년 3월부터 주요 브라우저에서 모두 쓸 수 있고, HTTPS 같은 보안 컨텍스트에서만 동작해요. localhost도 보안 컨텍스트로 취급되기 때문에 개발 서버에서 바로 써 볼 수 있습니다.

API는 navigator.locks로 꺼내 쓰는 LockManager와, 잠금 하나를 나타내는 Lock으로 이뤄져 있습니다. 스펙의 정의를 TypeScript로 옮기면 아래와 같아요.

typescript
type LockMode = "exclusive" | "shared";

interface LockManager {
  request(name: string, callback: LockGrantedCallback): Promise<unknown>;
  request(name: string, options: LockOptions, callback: LockGrantedCallback): Promise<unknown>;
  query(): Promise<LockManagerSnapshot>; // { held, pending }
}

type LockGrantedCallback = (lock: Lock | null) => unknown;

interface Lock {
  readonly name: string;
  readonly mode: LockMode;
}

interface LockOptions {
  mode?: LockMode; // 기본값 "exclusive"
  ifAvailable?: boolean; // 기본값 false
  steal?: boolean; // 기본값 false
  signal?: AbortSignal;
}

request()는 잠금 이름과 콜백을 받고, 필요하면 그 사이에 옵션을 넣습니다. 콜백은 잠금을 얻으면 Lock을, 얻지 못하면 null을 받아요. query()는 지금 잡혀 있는 잠금과 기다리는 요청의 목록을 돌려줍니다.

가장 단순한 모양은 이름과 콜백만 넘기는 거예요.

typescript
await navigator.locks.request("packing:CRT-0001", async (lock) => {
  // 이 콜백이 실행되는 동안 이 탭이 잠금을 쥐고 있다.
  await doSomething();
});
// 콜백이 끝나면 잠금이 반납된다.

첫 번째 인자는 잠금 이름입니다. 브라우저는 이 문자열이 무엇을 뜻하는지 모르고, 같은 이름끼리만 서로 막아요. 콜백이 실행되는 동안 잠금을 쥐고 있다가, 콜백이 반환한 Promise가 끝나면 잠금을 반납합니다. 그래서 잠금을 오래 쥐고 싶으면 콜백이 오래 끝나지 않게 만들면 돼요. request()가 돌려주는 Promise는 잠금이 반납된 뒤에 끝납니다. 이 점은 뒤에서 다시 씁니다.

같은 이름의 잠금을 다른 탭이 이미 쥐고 있으면 기본적으로는 기다립니다. 앞 탭이 잠금을 반납할 때까지 요청이 대기열에 머물러요. 기다리지 않고 바로 결과를 알고 싶다면 ifAvailable 옵션을 줍니다. 잠금을 바로 얻을 수 없으면 콜백이 null을 받아요. 다만 동기적으로 실행되지는 않습니다. 스펙도 이 확인에 프로세스 간 통신이 필요할 수 있다고 적고 있어요.

기본 요청과 ifAvailable 요청
typescript
await navigator.locks.request("packing:CRT-0001", { ifAvailable: true }, async (lock) => {
  if (lock === null) {
    // 다른 탭이 쥐고 있다.
    return;
  }
  // 이 탭이 쥐었다.
});

LockOptions의 나머지 옵션도 간단히 보겠습니다. mode: "shared"로 요청하면 여러 탭이 같은 잠금을 함께 쥘 수 있고, 기본값인 "exclusive"는 한 탭만 쥘 수 있어요. steal은 쥐고 있는 쪽의 잠금을 강제로 빼앗고, signal로는 대기 중인 요청을 취소합니다. 현재 잡혀 있는 잠금은 navigator.locks.query()로 확인할 수 있어요.

typescript
await navigator.locks.query();
// { held: [{ name: "packing:CRT-0001", mode: "exclusive", clientId: "..." }], pending: [] }

이 잠금은 협조적으로 동작합니다. 잠금을 요청하지 않는 코드는 잠금이 있든 없든 그대로 실행돼요. 그래서 잠금을 쓰려면 관련 코드가 모두 같은 이름으로 요청해야 합니다.

잠금은 어디까지 공유될까?

이 기능을 쓰기 전에 가장 궁금했던 부분입니다. 작업자가 탭을 어떻게 열든 잠금이 걸리는지, 반대로 어디서부터는 걸리지 않는지 알아야 안내 문구도 정할 수 있었어요.

스펙은 잠금 관리자(lock manager)가 storage bucket마다 하나씩 있다고 정의합니다. storage bucket은 localStorage나 IndexedDB가 저장되는 단위라고 생각하면 됩니다. 그래서 잠금이 공유되는 범위는 이 저장소들이 공유되는 범위와 같아요. sessionStorage는 예외입니다. sessionStorage는 탭마다 따로 있지만, 잠금은 탭끼리 공유됩니다.

잠금이 공유되는 범위

같은 origin이면 탭과 워커가 하나의 잠금 관리자를 씁니다. origin은 프로토콜, 호스트, 포트를 합친 것이라서 포트만 달라도 다른 origin이에요. 저희 서비스도 웹과 PDA 앱이 같은 호스트에서 포트만 다르게 떠 있는데, 이 둘 사이에서는 잠금이 공유되지 않습니다.

iframe은 조금 다릅니다. 같은 origin의 페이지라도 다른 사이트에 iframe으로 들어가 있으면 잠금을 따로 써요. 스펙은 저장소를 나누는 브라우저라면 잠금도 같은 단위로 나눠야 한다고 정해 두었고, Chrome은 115부터 third-party 컨텍스트의 저장소와 함께 Web Locks도 나눕니다.

브라우저 프로필이 다르거나 시크릿 모드면 공유되지 않습니다. 스펙은 다른 프로필과 시크릿 모드 세션을 각각 별도의 user agent로 봐요. 그래서 일반 창과 시크릿 창은 서로 막지 않습니다. 다른 브라우저나 다른 기기와도 공유되지 않아요. 크롬과 엣지에서 같은 작업을 열거나 두 대의 PC에서 같은 작업을 여는 경우는 이 잠금으로 막을 수 없고, 이 부분은 처음부터 서버의 몫으로 남겨 두었습니다.

잠금을 쥔 탭을 닫거나 다른 페이지로 이동하면 브라우저가 그 탭의 잠금을 모두 반납합니다. localStorage에 "진행 중" 표시를 남기는 방식은 탭이 닫힐 때 표시를 지우지 못하면 그대로 남는 문제가 있어요. Web Locks는 탭이 사라지면 잠금도 같이 풀립니다.

이 중 일부는 Playwright로 Chromium 153에서 직접 확인했습니다. 같은 브라우저 컨텍스트의 두 탭은 잠금을 공유했고, 다른 컨텍스트끼리는 공유하지 않았어요. Playwright의 컨텍스트는 시크릿 창처럼 서로 격리된 환경이라 이런 결과가 나옵니다. 잠금을 쥔 탭을 닫거나 강제로 크래시시키면, 1초 안에 다른 탭이 잠금을 얻는 것도 확인했어요. 크래시처럼 비정상적으로 끝나는 경우는 스펙이 아직 명확하게 정하지 않았기 때문에, 다른 브라우저에서도 같다고 단정하지는 않으려고 합니다. 나머지 범위는 스펙을 근거로 정리한 내용입니다.

useWebLock 만들기

이제 화면에 붙일 React 훅을 만들어 보겠습니다. 훅의 요구사항은 아래와 같아요.

  • 화면이 떠 있는 동안 잠금을 계속 쥔다.
  • 다른 탭이 쥐고 있으면 기다리지 않고 바로 알려 준다.
  • 잠글 대상이 바뀌면 이전 잠금을 놓고 새로 잠근다.
  • Web Locks를 지원하지 않는 브라우저에서는 막지 않는다.

두 번째 항목 때문에 기본 동작 대신 ifAvailable을 골랐습니다. 기다리는 방식이면 두 번째 탭은 아무 안내 없이 멈춘 화면으로 남아 있다가, 첫 번째 탭이 닫히는 순간 갑자기 작업 화면이 열려요. 작업자 입장에서는 왜 멈췄는지, 왜 갑자기 열렸는지 알기 어렵습니다. 그래서 바로 안내를 보여 주고, 다시 열지는 작업자가 정하게 했어요. "여기서 이어서 하기" 버튼을 두고 steal로 잠금을 가져오는 방법도 있지만, 이번에는 넣지 않았습니다.

처음 떠올린 모양

첫 번째 항목은 콜백이 끝나지 않는 Promise를 기다리게 해서 해결합니다. 그 Promise는 컴포넌트가 사라질 때 풀어 줘요.

typescript
useEffect(() => {
  let release: () => void;
  const held = new Promise<void>((resolve) => (release = resolve));

  navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
    if (!lock) {
      setStatus("busy");
      return;
    }
    setStatus("acquired");
    await held; // 언마운트될 때까지 잠금을 쥔다.
  });

  return () => release();
}, [name]);

이 코드로도 기본 동작은 됩니다. 그런데 같은 이름을 곧바로 다시 잠그는 경우에 문제가 있었어요.

StrictMode에서 두 요청이 순서대로 처리된다

같은 이름을 곧바로 다시 잠글 때

작업을 초기화하면 잠금 이름이 null이 되고, 같은 작업 ID를 바로 다시 스캔하면 같은 이름으로 돌아옵니다. 이때는 이전 요청이 실제로 잠금을 쥐고 있다가 반납하는 중이에요.

스펙을 보면 콜백이 끝난 뒤의 반납도 대기열에 들어가는 작업입니다. 그래서 놓자마자 다시 요청하면 아직 반납되지 않은 잠금에 막힐 수 있어요. 잠금을 얻은 것을 확인한 뒤 놓고 다시 요청하는 실험을 시점별로 100번씩 돌려 봤습니다.

다시 요청하는 시점null을 받은 횟수
놓자마자 바로100번 중 100번
setTimeout(0) 뒤100번 중 0번
이전 request()의 Promise가 끝난 뒤100번 중 0번

setTimeout(0) 뒤에 다시 요청한 경우는 3,000번을 더 돌려도 실패하지 않았습니다. 하지만 반납이 한 틱 안에 끝난다는 보장은 스펙 어디에도 없어요. 반면 request()가 돌려주는 Promise는 스펙에서 "잠금이 반납되면 끝나는 Promise"로 정의돼 있습니다. 그래서 측정 결과보다 스펙이 보장하는 쪽을 택했어요.

반납을 기다린 뒤 요청하면 잠금을 얻는다

훅이 직전에 보낸 요청의 Promise를 ref에 담아 두고, 새로 요청하기 전에 그 Promise가 끝나길 기다리게 했습니다.

잠글 대상이 바뀌는 순간

패킹 화면은 작업자가 바코드를 스캔하기 전까지는 잠글 대상이 없습니다. 그래서 이름에 null을 넘길 수 있게 하고, 스캔한 뒤에 이름이 생기면 그때부터 잠그도록 했어요.

이름이 바뀌면 상태도 바로 바뀌어야 합니다. effect 안에서 상태를 초기화하면 effect가 실행되기 전 한 번의 렌더 동안 이전 이름의 상태가 그대로 보여요. 그래서 React 문서의 "props가 바뀔 때 state 조정하기"에 나오는 방식대로, 렌더 중에 이전 이름과 비교해서 상태를 맞췄습니다.

typescript
const [trackedName, setTrackedName] = useState(name);
if (name !== trackedName) {
  setTrackedName(name);
  setStatus(initialStatus(name));
}

완성한 훅

위 내용을 모두 합친 훅입니다.

typescript
export type WebLockStatus = "pending" | "acquired" | "busy" | "unsupported";

const initialStatus = (name: string | null): WebLockStatus => {
  if (name === null) return "acquired";
  return typeof navigator !== "undefined" && !navigator.locks ? "unsupported" : "pending";
};

export const useWebLock = (name: string | null): WebLockStatus => {
  const [status, setStatus] = useState<WebLockStatus>(() => initialStatus(name));
  const [trackedName, setTrackedName] = useState(name);

  if (name !== trackedName) {
    setTrackedName(name);
    setStatus(initialStatus(name));
  }

  const pending = useRef<Promise<void> | null>(null);

  useEffect(() => {
    if (name === null) return;
    const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
    if (!locks) return;

    let cancelled = false;
    let fired = false;
    let release: (() => void) | undefined;
    const held = new Promise<void>((resolve) => (release = resolve));

    const previous = pending.current;
    let resolveAttempt: () => void;
    const attempt = new Promise<void>((resolve) => (resolveAttempt = resolve));
    pending.current = attempt;

    // StrictMode의 첫 번째 실행은 이 타이머가 돌기 전에 정리된다.
    const timer = setTimeout(async () => {
      fired = true;
      // 직전 요청이 잠금을 완전히 반납할 때까지 기다린다.
      if (previous) await previous.catch(() => {});
      if (!cancelled) {
        await locks.request(name, { ifAvailable: true }, async (lock) => {
          if (cancelled) return;
          if (!lock) {
            setStatus("busy");
            return;
          }
          setStatus("acquired");
          await held;
        });
      }
      resolveAttempt();
    }, 0);

    return () => {
      cancelled = true;
      clearTimeout(timer);
      release?.();
      // 타이머가 돌기 전에 정리됐다면 요청 자체가 없었으니 바로 끝낸다.
      if (!fired) resolveAttempt();
    };
  }, [name]);

  return status;
};

요청을 setTimeout(0)으로 한 틱 미룬 건 개발 모드의 StrictMode 때문입니다. StrictMode는 effect를 실행하고 정리한 뒤 곧바로 다시 실행하는데, 첫 번째 실행이 보낸 요청이 잠금을 먼저 가져가면 두 번째 실행의 요청은 null을 받아요. 요청을 미뤄 두면 첫 번째 실행은 타이머가 돌기 전에 정리돼서 요청을 보내지 않습니다.

상태는 네 가지입니다. pending은 요청 결과를 기다리는 중, acquired는 이 탭이 쥔 상태, busy는 다른 탭이 쥔 상태, unsupported는 Web Locks를 지원하지 않는 브라우저예요. 화면은 busy일 때만 막고 나머지는 모두 통과시킵니다.

pending까지 막으면 정상적인 탭도 스캔할 때마다 안내 화면이 잠깐 깜빡여요. 그래서 pending은 통과시켰고, 그 대신 두 번째 탭은 잠금 결과가 오기 전까지 아주 짧게 작업 화면을 그립니다. unsupported를 통과시킨 것도 비슷한 판단입니다. 다만 보안 컨텍스트가 아니면 navigator.locks가 아예 없어서 이 경우도 unsupported가 돼요. HTTP로 배포된 환경이라면 이 기능은 조용히 꺼진다는 뜻이라, 배포 주소가 HTTPS인지 먼저 확인해야 합니다.

패킹 화면에 붙이기

패킹 화면에서는 스캔한 바코드를 잠금 이름에 넣었습니다.

typescript
const lock = useWebLock(barcode ? `b2c-packing:${barcode}` : null);
const locked = lock === "busy";

locked가 true면 작업 화면 대신 "이 작업은 다른 탭에서 이미 진행 중입니다." 안내와 목록으로 돌아가는 버튼만 보여 줍니다.

하나 더 챙긴 부분이 있어요. 패킹 화면은 새로고침해도 이어서 작업할 수 있도록 진행 상태를 sessionStorage에 저장합니다. 그런데 탭 복제나 window.open으로 연 탭은 원래 탭의 sessionStorage를 그대로 복사해 가요. 그래서 안내 화면에서 목록으로 나갈 때 그 탭의 저장 상태를 비우도록 했습니다. 비우지 않으면 복제된 탭이 나중에 다시 들어왔을 때 이미 끝난 작업을 복원하게 됩니다.

두 탭으로 확인하기

개발 서버에서 확인한 모습입니다. 탭 A에서 작업 ID를 스캔해 작업을 열고, 탭 B에서 같은 작업 ID를 스캔했어요. 탭 B에는 안내가 뜨고, 탭 A를 닫은 뒤 탭 B를 새로고침하면 작업 화면이 열립니다.

두 탭에서 같은 작업번호를 스캔한 모습

이 잠금이 막지 못하는 것

먼저 잠금 이름을 스캔한 값으로 정했기 때문에, 같은 작업을 서로 다른 바코드로 열면 서로 막지 못합니다. 서버가 돌려준 작업 ID로 잠그면 더 정확하겠지만, 그러려면 서버 응답을 받기 전까지는 잠글 수가 없어요. 이번에는 스캔한 값으로 잠그는 쪽을 택했습니다.

두 번째 탭도 스캔하는 순간 서버 요청은 나갑니다. 잠금 확인이 스캔 응답을 받은 뒤에 일어나기 때문입니다. 저희 서버는 이미 시작된 작업을 다시 스캔하면 그 작업을 그대로 돌려줘서 문제가 없었어요. 스캔할 때마다 서버 상태가 새로 바뀌는 구조였다면 잠금을 확인하는 순서부터 다시 생각해야 했을 거예요.

그리고 앞에서 말한 대로 다른 브라우저, 다른 프로필, 다른 기기는 막을 수 없습니다.

마무리

데이터 정합성은 결국 서버가 지켜야 합니다. 다른 브라우저나 다른 기기에서 들어오는 요청까지 막을 수 있는 곳은 서버뿐이에요.

같은 브라우저에서 같은 작업을 여는 경우가 대부분이라면, 브라우저가 기본으로 제공하는 잠금만으로도 그 경우를 화면에서 미리 막을 수 있습니다. 작업자는 잘못된 화면에서 작업을 이어 가기 전에 안내를 보게 되고, 서버에 들어가는 중복 요청도 그만큼 줄어들어요.

Web Locks API는 생각보다 작은 API였습니다. 그래도 잠금이 언제 실제로 반납되는지는 스펙을 읽고 직접 돌려 보고 나서야 알 수 있었어요.

참고