vfunc.js

따라하기: 할 일 앱 만들기

처음 쓰는 분을 위한 단계별 안내입니다. 파일 세 개로 할 일 앱을 만들고, 목록 저장과 컴포넌트까지 붙인 뒤 인터넷에 올립니다. 설치할 것은 에디터와 파이썬뿐이고 빌드 도구는 없습니다. 한 단계가 10분 정도 걸립니다.

단계마다 완성본 열기 링크가 있습니다. 막히면 완성본과 내 파일을 비교해 보세요. 이 페이지의 코드는 테스트를 통과한 그 파일입니다.

준비

  1. 에디터: Visual Studio Code를 설치합니다. 메모장도 되지만 색 구분이 있어 편합니다.
  2. 파이썬: 파일을 브라우저로 보여 줄 작은 서버에 씁니다. 터미널(Windows는 PowerShell)에 python --version을 입력해 버전이 나오면 준비된 것입니다.
    • 없으면 python.org에서 설치하고, Windows에서는 설치 첫 화면의 Add python.exe to PATH를 체크합니다.
  3. 폴더: 바탕화면 등에 todo 폴더를 만들고 VS Code에서 파일 → 폴더 열기로 엽니다.

서버는 todo 폴더 안에서 실행합니다. VS Code에서 터미널 → 새 터미널을 열고 아래를 입력한 뒤, 브라우저에서 http://localhost:8080/을 엽니다. 끝낼 때는 터미널에서 Ctrl+C를 누릅니다.

python -m http.server 8080

1단계: 첫 화면

todo 폴더에 파일 세 개를 만듭니다. 먼저 index.html입니다. 화면의 뼈대이고, vfunc와 내 코드를 불러옵니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'">
  <title>할 일 — 1단계</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc.tokens.css"
        integrity="sha384-QwsFKKFVXW8Lo7pML80GpQro2b0FlTfTrSjGN+YMCyx868Oa2bXlbYRGuUUbgiVw" crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="todo">
    <h1 class="todo__title">오늘 할 일</h1>
    <div id="app"></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc.js"
        integrity="sha384-HOwph430qtUZpsvBjI0IzL1lkEipwi7yZYM7X7z21/bLJsChFHRF9E5OvWde7RjH" crossorigin="anonymous"></script>
  <script src="./app.js"></script>
</body>
</html>

style.css는 끝까지 이 파일 하나를 씁니다. 복사해 두고 넘어가도 됩니다. 색과 간격은 모두 var(--vf-…) 토큰이라 테마가 바뀌면 함께 바뀝니다.

/* 할 일 앱의 스타일. 색·간격·글꼴은 토큰(var(--vf-*))만 씁니다. */
*, *::before, *::after { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--vf-color-bg);
  color: var(--vf-color-text);
  font-family: var(--vf-font-body);
  font-size: var(--vf-font-size-md);
  line-height: var(--vf-line-height);
}

.todo { max-width: 560px; margin: 0 auto; padding: var(--vf-space-6) var(--vf-space-4); }
.todo__title { margin: 0 0 var(--vf-space-4); font-size: var(--vf-font-size-2xl); }
.todo__label { display: block; margin-bottom: var(--vf-space-1); font-weight: var(--vf-font-weight-strong); }
.todo__greeting { font-size: var(--vf-font-size-lg); }

.todo__form { display: flex; gap: var(--vf-space-2); margin-bottom: var(--vf-space-4); }
.todo__input {
  flex: 1;
  width: 100%;
  padding: var(--vf-space-2) var(--vf-space-3);
  border: 1px solid var(--vf-color-border-strong);
  border-radius: var(--vf-radius-md);
  background: var(--vf-color-surface);
  color: var(--vf-color-text);
  font: inherit;
}
.todo__button {
  padding: var(--vf-space-2) var(--vf-space-4);
  border: 0;
  border-radius: var(--vf-radius-md);
  background: var(--vf-color-primary);
  color: var(--vf-color-on-primary);
  font: inherit;
  cursor: pointer;
}
.todo__button:hover { background: var(--vf-color-primary-hover); }
.todo__input:focus-visible, .todo__button:focus-visible, .todo__remove:focus-visible {
  outline: none;
  box-shadow: var(--vf-focus-ring);
}

.todo__list { list-style: none; margin: 0; padding: 0; }
.todo__item {
  display: flex;
  align-items: center;
  gap: var(--vf-space-2);
  padding: var(--vf-space-2) var(--vf-space-3);
  margin-bottom: var(--vf-space-2);
  border: 1px solid var(--vf-color-border);
  border-radius: var(--vf-radius-md);
  background: var(--vf-color-surface);
}
.todo__text { flex: 1; }
.todo__item[data-state="done"] .todo__text { color: var(--vf-color-text-muted); text-decoration: line-through; }
.todo__remove {
  border: 0;
  background: transparent;
  color: var(--vf-color-text-muted);
  font: inherit;
  cursor: pointer;
}
.todo__remove:hover { color: var(--vf-color-danger-text); }
.todo__footer { margin: var(--vf-space-3) 0 0; color: var(--vf-color-text-muted); font-size: var(--vf-font-size-sm); }

app.js가 vfunc 코드입니다.

// 1단계: 컴포넌트 하나. 상태(state)를 바꾸면 화면이 다시 그려집니다.
const hello = vf.vfunc({
  state: { name: '' },
  render: (s) => vf.html`
    <label class="todo__label" for="name">이름</label>
    <input class="todo__input" id="name" data-action="rename" value="${s.name}"
           placeholder="이름을 입력하세요" autocomplete="off">
    <p class="todo__greeting" data-ref="greeting">안녕하세요, ${s.name || '손님'}님!</p>`,
  delegates: [{
    selector: '[data-action="rename"]',
    eventType: 'input',
    onEvent: (e) => { e.sender.name = e.target.value; }
  }]
});

hello.mount('#app');
이름을 입력하면 인사말이 바로 바뀝니다
이름을 입력하면 인사말이 바로 바뀝니다

완성본 열기

2단계: 할 일 추가하기

이제 이름 대신 할 일을 받습니다. app.js를 아래로 바꿉니다. index.html과 style.css는 그대로입니다.

// 2단계: 할 일 추가. 입력한 내용을 목록(state.items)에 넣으면 목록이 다시 그려집니다.
let nextId = 1;

const todo = vf.vfunc({
  state: { items: [] },
  render: (s) => vf.html`
    <form class="todo__form" data-action="add">
      <input class="todo__input" name="text" data-ref="input" aria-label="할 일"
             placeholder="할 일을 입력하세요" autocomplete="off" required>
      <button class="todo__button" type="submit">추가</button>
    </form>
    <ul class="todo__list">
      ${s.items.map((item) => vf.html`<li class="todo__item">${item.text}</li>`)}
    </ul>`,
  delegates: [{
    selector: '[data-action="add"]',
    eventType: 'submit',
    onEvent: (e) => {
      e.event.preventDefault();                   // 폼 제출로 페이지가 새로고침되지 않게 합니다
      const text = e.sender.refs.input.value.trim();
      if (!text) return;
      e.sender.items = e.sender.items.concat({ id: nextId++, text: text });
    }
  }]
});

todo.mount('#app');
할 일 두 개를 추가한 화면
할 일 두 개를 추가한 화면

완성본 열기

3단계: 완료 표시와 삭제

항목마다 체크박스와 삭제 버튼을 붙이고, 남은 일 개수를 보여 줍니다.

// 3단계: 완료 표시와 삭제. 목록의 버튼은 data-action으로 구분하고, 어느 항목인지는 data-id로 찾습니다.
let nextId = 1;

const row = (item) => vf.html`
  <li class="todo__item" data-id="${item.id}" data-state="${item.done ? 'done' : 'open'}">
    <input type="checkbox" id="done-${item.id}" data-action="toggle" ${item.done ? 'checked' : ''}>
    <label class="todo__text" for="done-${item.id}">${item.text}</label>
    <button class="todo__remove" type="button" data-action="remove" aria-label="삭제">✕</button>
  </li>`;

// 이벤트가 일어난 줄의 data-id를 숫자로 돌려줍니다.
const idOf = (e) => Number(e.target.closest('[data-id]').getAttribute('data-id'));

const todo = vf.vfunc({
  state: { items: [] },
  render: (s) => {
    const left = s.items.filter((item) => !item.done).length;
    return vf.html`
      <form class="todo__form" data-action="add">
        <input class="todo__input" name="text" data-ref="input" aria-label="할 일"
               placeholder="할 일을 입력하세요" autocomplete="off" required>
        <button class="todo__button" type="submit">추가</button>
      </form>
      <ul class="todo__list">${s.items.map(row)}</ul>
      <p class="todo__footer">남은 일 <strong data-ref="left">${left}</strong>개</p>`;
  },
  methods: {
    add(text) {
      this.items = this.items.concat({ id: nextId++, text: text, done: false });
    },
    toggle(id) {
      this.items = this.items.map((item) => (item.id === id ? { id: item.id, text: item.text, done: !item.done } : item));
    },
    remove(id) {
      this.items = this.items.filter((item) => item.id !== id);
    }
  },
  delegates: [
    {
      selector: '[data-action="add"]',
      eventType: 'submit',
      onEvent: (e) => {
        e.event.preventDefault();
        const text = e.sender.refs.input.value.trim();
        if (text) e.sender.add(text);
      }
    },
    { selector: '[data-action="toggle"]', eventType: 'change', onEvent: (e) => e.sender.toggle(idOf(e)) },
    { selector: '[data-action="remove"]', eventType: 'click', onEvent: (e) => e.sender.remove(idOf(e)) }
  ]
});

todo.mount('#app');
첫 항목을 완료하면 줄이 그어지고 남은 일이 2개가 됩니다
첫 항목을 완료하면 줄이 그어지고 남은 일이 2개가 됩니다

완성본 열기

4단계: 저장하기

지금은 새로고침하면 목록이 사라집니다. 브라우저 저장소(localStorage)에 저장합니다.

// 4단계: 저장. 목록이 바뀔 때마다 브라우저(localStorage)에 저장하고, 페이지를 열 때 불러옵니다.
const KEY = 'vfunc-tutorial-todo';

// 저장된 값은 바깥 데이터이므로 필요한 필드만 골라 씁니다.
function load() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY)) || [];
    return saved.map((item) => ({ id: Number(item.id), text: String(item.text), done: item.done === true }));
  } catch (err) {
    return [];
  }
}

const saved = load();
let nextId = saved.reduce((max, item) => Math.max(max, item.id), 0) + 1;

const row = (item) => vf.html`
  <li class="todo__item" data-id="${item.id}" data-state="${item.done ? 'done' : 'open'}">
    <input type="checkbox" id="done-${item.id}" data-action="toggle" ${item.done ? 'checked' : ''}>
    <label class="todo__text" for="done-${item.id}">${item.text}</label>
    <button class="todo__remove" type="button" data-action="remove" aria-label="삭제">✕</button>
  </li>`;

const idOf = (e) => Number(e.target.closest('[data-id]').getAttribute('data-id'));

const todo = vf.vfunc({
  state: { items: saved },
  render: (s) => {
    const left = s.items.filter((item) => !item.done).length;
    return vf.html`
      <form class="todo__form" data-action="add">
        <input class="todo__input" name="text" data-ref="input" aria-label="할 일"
               placeholder="할 일을 입력하세요" autocomplete="off" required>
        <button class="todo__button" type="submit">추가</button>
      </form>
      <ul class="todo__list">${s.items.map(row)}</ul>
      <p class="todo__footer">남은 일 <strong data-ref="left">${left}</strong>개</p>`;
  },
  // 다시 그릴 때마다 호출됩니다. 여기서 저장합니다.
  onUpdate: (inst) => {
    localStorage.setItem(KEY, JSON.stringify(inst.items));
  },
  methods: {
    add(text) {
      this.items = this.items.concat({ id: nextId++, text: text, done: false });
    },
    toggle(id) {
      this.items = this.items.map((item) => (item.id === id ? { id: item.id, text: item.text, done: !item.done } : item));
    },
    remove(id) {
      this.items = this.items.filter((item) => item.id !== id);
    }
  },
  delegates: [
    {
      selector: '[data-action="add"]',
      eventType: 'submit',
      onEvent: (e) => {
        e.event.preventDefault();
        const text = e.sender.refs.input.value.trim();
        if (text) e.sender.add(text);
      }
    },
    { selector: '[data-action="toggle"]', eventType: 'change', onEvent: (e) => e.sender.toggle(idOf(e)) },
    { selector: '[data-action="remove"]', eventType: 'click', onEvent: (e) => e.sender.remove(idOf(e)) }
  ]
});

todo.mount('#app');
새로고침해도 목록과 완료 표시가 남아 있습니다
새로고침해도 목록과 완료 표시가 남아 있습니다

완성본 열기

5단계: 컴포넌트 쓰기

vfunc에는 버튼, 빈 화면, 알림, 확인 창 같은 컴포넌트(vfunc-ui)가 있습니다. 필요한 곳에만 골라 씁니다. index.html에 파일 세 줄을 더합니다(vfunc-ui.css, vfunc-ui.js, 한국어 문구 vfunc-ui.locale.ko.js).

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'">
  <title>할 일 — 5단계</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc.tokens.css"
        integrity="sha384-QwsFKKFVXW8Lo7pML80GpQro2b0FlTfTrSjGN+YMCyx868Oa2bXlbYRGuUUbgiVw" crossorigin="anonymous">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc-ui.css"
        integrity="sha384-lb4q+RHHylsEIsyBQuDJbSZAAocK3fj1H4bBQFf5S7UoMLfN2j3L/G9L5AYE6uIA" crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="todo">
    <h1 class="todo__title">오늘 할 일</h1>
    <div id="app"></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc.js"
        integrity="sha384-HOwph430qtUZpsvBjI0IzL1lkEipwi7yZYM7X7z21/bLJsChFHRF9E5OvWde7RjH" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc-ui.js"
        integrity="sha384-I+GaVCl8OOTjHk4zMfuH/l8vwopR07O0vlZUgVvqmwSJwKjtG6+u8Yc3KkZBUm3G" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc-ui.locale.ko.js"
        integrity="sha384-mFqQlhYgsg+ftO7b2N4njBVTehRpPziqz+7qYTVLypq9bmsar1dnoYPVro/wSl4Z" crossorigin="anonymous"></script>
  <script src="./app.js"></script>
</body>
</html>

app.js:

// 5단계: 컴포넌트 쓰기(vfunc-ui). 버튼·빈 화면은 vs*(마크업), 알림·확인 창은 vf*(인스턴스)입니다.
const KEY = 'vfunc-tutorial-todo';

function load() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY)) || [];
    return saved.map((item) => ({ id: Number(item.id), text: String(item.text), done: item.done === true }));
  } catch (err) {
    return [];
  }
}

const saved = load();
let nextId = saved.reduce((max, item) => Math.max(max, item.id), 0) + 1;
let toast;   // 알림 영역. 페이지에 하나만 만듭니다(아래 맨 끝).

const row = (item) => vf.html`
  <li class="todo__item" data-id="${item.id}" data-state="${item.done ? 'done' : 'open'}">
    <input type="checkbox" id="done-${item.id}" data-action="toggle" ${item.done ? 'checked' : ''}>
    <label class="todo__text" for="done-${item.id}">${item.text}</label>
    ${vf.vsButton({ label: '삭제', variant: 'ghost', size: 'sm', action: 'remove' })}
  </li>`;

const idOf = (e) => Number(e.target.closest('[data-id]').getAttribute('data-id'));

const todo = vf.vfunc({
  state: { items: saved },
  render: (s) => {
    const left = s.items.filter((item) => !item.done).length;
    return vf.html`
      <form class="todo__form" data-action="add">
        <input class="todo__input" name="text" data-ref="input" aria-label="할 일"
               placeholder="할 일을 입력하세요" autocomplete="off" required>
        ${vf.vsButton({ label: '추가', type: 'submit', variant: 'primary' })}
      </form>
      ${s.items.length
        ? vf.html`<ul class="todo__list">${s.items.map(row)}</ul>
                  <p class="todo__footer">남은 일 <strong data-ref="left">${left}</strong>개</p>`
        : vf.vsEmptyState({ title: '할 일이 없습니다', description: '위 입력칸에 할 일을 적고 추가를 누르세요.' })}`;
  },
  onUpdate: (inst) => {
    localStorage.setItem(KEY, JSON.stringify(inst.items));
  },
  methods: {
    add(text) {
      this.items = this.items.concat({ id: nextId++, text: text, done: false });
      toast.show({ message: '추가했습니다: ' + text, variant: 'success' });
    },
    toggle(id) {
      this.items = this.items.map((item) => (item.id === id ? { id: item.id, text: item.text, done: !item.done } : item));
    },
    // 지우기 전에 한 번 묻습니다. open()은 사용자의 답(true/false)을 기다립니다.
    async remove(id) {
      const item = this.items.find((x) => x.id === id);
      const dialog = vf.vfConfirm({ title: '삭제할까요?', message: item.text, variant: 'danger', confirmLabel: '삭제' });
      const ok = await dialog.open();
      dialog.destroy();
      if (!ok) return;
      this.items = this.items.filter((x) => x.id !== id);
      toast.show({ message: '삭제했습니다' });
    }
  },
  delegates: [
    {
      selector: '[data-action="add"]',
      eventType: 'submit',
      onEvent: (e) => {
        e.event.preventDefault();
        const text = e.sender.refs.input.value.trim();
        if (text) e.sender.add(text);
      }
    },
    { selector: '[data-action="toggle"]', eventType: 'change', onEvent: (e) => e.sender.toggle(idOf(e)) },
    { selector: '[data-action="remove"]', eventType: 'click', onEvent: (e) => e.sender.remove(idOf(e)) }
  ]
});

// 컴포넌트의 기본 문구(취소, 닫기 등)를 한국어로 바꾼 뒤 시작합니다.
vf.i18n.set('ko').then(() => {
  toast = vf.vfToast();
  todo.mount('#app');
});
목록이 비었을 때는 안내가 보입니다
목록이 비었을 때는 안내가 보입니다
추가하면 오른쪽 아래에 알림이 나타납니다
추가하면 오른쪽 아래에 알림이 나타납니다
삭제 전에 확인 창이 뜹니다. Esc나 취소를 누르면 지우지 않습니다
삭제 전에 확인 창이 뜹니다. Esc나 취소를 누르면 지우지 않습니다
컴퓨터를 다크 모드로 바꾸면 토큰이 다크 색으로 바뀝니다. 코드는 그대로입니다
컴퓨터를 다크 모드로 바꾸면 토큰이 다크 색으로 바뀝니다. 코드는 그대로입니다

완성본 열기

6단계: 인터넷에 올리기

운영용 파일로 바꿉니다. 개발용 vfunc.js는 경고 문구가 들어 있어 큽니다. 올리기 전에 index.html의 스크립트를 min 파일로 바꿉니다. 아래는 5단계 index.html의 운영용입니다(integrity도 파일에 맞게 바뀌었습니다).

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; object-src 'none'; base-uri 'none'">
  <title>할 일 — 5단계</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc.tokens.css"
        integrity="sha384-QwsFKKFVXW8Lo7pML80GpQro2b0FlTfTrSjGN+YMCyx868Oa2bXlbYRGuUUbgiVw" crossorigin="anonymous">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/css/vfunc-ui.css"
        integrity="sha384-lb4q+RHHylsEIsyBQuDJbSZAAocK3fj1H4bBQFf5S7UoMLfN2j3L/G9L5AYE6uIA" crossorigin="anonymous">
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <main class="todo">
    <h1 class="todo__title">오늘 할 일</h1>
    <div id="app"></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc.min.js"
        integrity="sha384-yTR0o8US3nk+D1XIBAsv1O/lMFA5tV3V021xPo8yxyqnBNSxi1rqINVvBOykAEkK" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc-ui.min.js"
        integrity="sha384-bjDcJm9zuGIIvCwh/qrHrukBRpbwhH5dnnDDM7Z0vByWS9N8bQWs3RfqseMNajiy" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/vfunc@1.2.0/dist/vfunc-ui.locale.ko.js"
        integrity="sha384-mFqQlhYgsg+ftO7b2N4njBVTehRpPziqz+7qYTVLypq9bmsar1dnoYPVro/wSl4Z" crossorigin="anonymous"></script>
  <script src="./app.js"></script>
</body>
</html>

GitHub Pages로 올리기 (무료, 계정만 있으면 됩니다):

  1. github.com에 가입하고 오른쪽 위 + → New repository를 누릅니다. 이름(예: todo)을 적고 Public으로 만듭니다.
  2. 저장소 화면에서 Add file → Upload files를 누르고 index.html, style.css, app.js를 끌어다 놓은 뒤 Commit changes를 누릅니다.
  3. Settings → Pages에서 Source를 Deploy from a branch, Branch를 main과 / (root)로 고르고 Save를 누릅니다.
  4. 1~2분 뒤 같은 화면 위쪽에 주소(https://<아이디>.github.io/todo/)가 나타납니다. 그 주소를 다른 사람에게 보내면 됩니다.

막힐 때

증상확인할 것
화면이 비어 있음F12 → Console의 빨간 글. vf is not defined면 index.html의 vfunc 주소나 <script> 순서(vfunc가 app.js보다 먼저)
주소가 file://로 시작함파일을 더블클릭해서 열었습니다. 서버를 켜고 http://localhost:8080/으로 엽니다
Refused to load 또는 Content-Security-Policy 오류index.html의 보안 줄에 https://cdn.jsdelivr.net이 있는지, 파일 주소가 맞는지
integrity 오류버전과 integrity 값이 짝이 맞지 않습니다. 이 페이지의 코드를 다시 복사합니다
추가를 누르면 페이지가 새로 고쳐짐e.event.preventDefault()가 빠졌습니다
onclick="…"이 동작하지 않음보안 설정이 막습니다. data-action과 delegates를 씁니다
새로고침하면 목록이 사라짐시크릿 창이거나 브라우저가 저장소를 막고 있습니다. 4단계 코드의 onUpdate도 확인합니다

그래도 안 되면 FAQ를 보거나 GitHub Discussions에 질문을 남겨 주세요. 콘솔 오류 문구를 함께 적으면 빨리 답할 수 있습니다.

다음으로