← 전체 코드 목록

📖 사용 방법

구글 시트를 데이터베이스로 활용하여, 나만의 스티커 메모를 만들고, 이동하고, 편집하며 실시간으로 저장되는 웹 앱을 직접 만들어 보겠습니다.

이런 분께 추천해요

  • 복잡한 프로그램 설치 없이 웹에서 나만의 메모장을 만들고 싶은 분
  • 구글 시트(Google Sheets)를 활용해 데이터를 저장하고 관리하는 방법을 배우고 싶은 분
  • 코딩 지식이 없어도 웹 앱(Web App)을 직접 만들어 보고 싶은 분
  • 아이디어나 할 일 목록을 자유롭게 정리하고 싶은 분

완성하면 이렇게 됩니다

이 가이드를 따라 하면, 웹 브라우저에서 사용할 수 있는 나만의 스티커 메모 보드가 완성됩니다.

  • 새 메모 추가: '+ 새 메모' 버튼을 눌러 새로운 스티커 메모를 만들 수 있습니다.
  • 자유로운 배치: 메모를 드래그(끌어서 옮기기)하여 원하는 위치에 놓을 수 있습니다.
  • 크기 조절: 메모 오른쪽 아래 모서리를 드래그하여 크기를 자유롭게 조절할 수 있습니다.
  • 내용 편집: 메모를 클릭하여 내용을 입력하고 수정할 수 있습니다. 텍스트

💻 코드

Code.gs
const SHEET_NAME = 'Notes'; // 메모 저장용 시트 이름

function getSheet_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) sheet = ss.insertSheet(SHEET_NAME);

  // 헤더: id, x, y, w, h, text(HTML), color, z
  const header = ['id', 'x', 'y', 'w', 'h', 'text', 'color', 'z'];
  const firstRow = sheet.getRange(1, 1, 1, header.length).getValues()[0];
  const isHeaderSet = firstRow.some(v => v);

  if (!isHeaderSet) {
    sheet.getRange(1, 1, 1, header.length).setValues([header]);
  }
  return sheet;
}

/** 웹앱 화면 */
function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('스티커 메모 보드');
}

/** 시트에서 메모 목록 불러오기 */
function getNotes() {
  const sheet = getSheet_();
  const values = sheet.getDataRange().getValues();
  if (values.length < 2) return [];

  const notes = [];
  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    if (!row[0]) continue; // id 없으면 스킵

    const note = {
      id: Number(row[0]),
      x: Number(row[1] || 80),
      y: Number(row[2] || 80),
      w: Number(row[3] || 260),
      h: Number(row[4] || 160),
      // text에는 HTML 그대로 저장
      text: String(row[5] || ''),
      color: String(row[6] || 'yellow'),
      z: Number(row[7] || 1),
    };
    notes.push(note);
  }
  return notes;
}

/** 메모 전체를 시트에 저장 (덮어쓰기) */
function saveNotes(notes) {
  const sheet = getSheet_();
  if (!Array.isArray(notes)) notes = [];

  // 기존 내용 삭제
  sheet.clearContents();

  // 헤더 다시 쓰기
  const header = ['id', 'x', 'y', 'w', 'h', 'text', 'color', 'z'];
  sheet.getRange(1, 1, 1, header.length).setValues([header]);

  if (!notes.length) return 'OK';

  const rows = notes.map(n => [
    n.id || new Date().getTime(),
    n.x || 80,
    n.y || 80,
    n.w || 260,
    n.h || 160,
    n.text || '',
    n.color || 'yellow',
    n.z || 1,
  ]);

  sheet.getRange(2, 1, rows.length, header.length).setValues(rows);
  return 'OK';
}
HTML
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <base target="_top">
  <title>스티커 메모 보드</title>

  <style>
    * { box-sizing: border-box; }

    body {
      margin: 0;
      height: 100vh;
      overflow: hidden;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f3f5fb;
    }

    #toolbar {
      position: fixed;
      top: 10px;
      left: 10px;
      z-index: 1000;
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 8px 12px;
      background: rgba(255, 255, 255, 0.9);
      border-radius: 8px;
      box-shadow: 0 2px 6px rgba(0,0,0,0.15);
      font-size: 13px;
    }

    #toolbar button {
      border: none;
      padding: 6px 10px;
      border-radius: 4px;
      cursor: pointer;
      background: #3b82f6;
      color: #fff;
      font-size: 12px;
    }

    #toolbar button:hover {
      opacity: 0.9;
    }

    #status {
      font-size: 11px;
      color: #555;
    }

    /* 메모 카드 */
    .note {
      position: absolute;
      width: 260px;
      min-width: 160px;
      min-height: 160px;
      border-radius: 8px;                /* ↔ 크기조절 핸들과 동일 라운딩 */
      box-shadow: 0 10px 25px rgba(0,0,0,0.2);
      overflow: hidden;
    }

    .note-header {
      position: absolute;
      top: 0;
      left: 0;
      right: 0;
      height: 26px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 6px;
      font-size: 11px;
      cursor: move;
      user-select: none;
      opacity: 0.9;
    }

    .note-header .title {
      flex: 1;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .note-header .actions {
      display: flex;
      align-items: center;
      gap: 4px;
    }

    .note-header select {
      font-size: 10px;
    }

    .note-header button {
      border: none;
      background: transparent;
      cursor: pointer;
      font-size: 14px;
      line-height: 1;
    }

    /* 상단 서식 툴바 */
    .note-toolbar {
      position: absolute;
      top: 26px;
      left: 0;
      right: 0;
      height: 26px;
      display: flex;
      align-items: center;
      gap: 4px;
      padding: 0 6px;
      font-size: 11px;
      background: rgba(255,255,255,0.4);
      user-select: none;
    }

    .note-toolbar button {
      border: none;
      background: transparent;
      cursor: pointer;
      padding: 2px 4px;
      border-radius: 4px;
      font-size: 12px;
    }
    .note-toolbar button:hover {
      background: rgba(0,0,0,0.08);
    }

    /* 내용 영역: 헤더(26) + 툴바(26) 위,
       아래쪽은 크기조절 핸들(14px) + 여유 4px = 18px 만큼 비워둠 */
    .note-content {
      position: absolute;
      top: 52px;           /* 26 + 26 */
      left: 0;
      right: 0;
      bottom: 18px;        /* ↔ 핸들 바로 위까지만 편집 영역 */
      padding: 8px 10px 4px 10px;
      font-size: 13px;
      line-height: 1.4;
      outline: none;
      white-space: pre-wrap;
      overflow: auto;
    }

    /* 리스트 행간/여백 */
    .note-content ul {
      margin: 2px 0 4px 1.1em;
      padding-left: 0.9em;
    }
    .note-content li {
      margin: 0;
      line-height: 1.35;
    }

    /* 링크 스타일 */
    .note-content a {
      color: #1d4ed8;
      text-decoration: underline;
      word-break: break-all;
    }

    /* 파스텔톤 색상 */
    .note.yellow { background: #fff7c2; }
    .note.yellow .note-header { background: #ffe28a; }

    .note.pink { background: #ffdce7; }
    .note.pink .note-header { background: #ffb6cf; }

    .note.blue { background: #dcecff; }
    .note.blue .note-header { background: #afc8ff; }

    .note.green { background: #e2f7e9; }
    .note.green .note-header { background: #bfe8cd; }

    /* 크기 조절 핸들 */
    .note-resize {
      position: absolute;
      right: 4px;
      bottom: 4px;
      width: 14px;
      height: 14px;
      cursor: se-resize;
      background: rgba(0,0,0,0.18);
      border-radius: 8px;        /* ↔ note 라운딩과 맞춤 */
    }

    @media (max-width: 768px) {
      body { overflow: auto; }
    }
  </style>
</head>
<body>
  <div id="toolbar">
    <button id="addNoteBtn">+ 새 메모</button>
    <span id="status">불러오는 중...</span>
  </div>

  <script>
    let notes = [];
    let dragState = { id: null, offsetX: 0, offsetY: 0 };
    let resizeState = { id: null, startX: 0, startY: 0, startW: 0, startH: 0 };
    let maxZ = 1;
    let saveTimer = null;

    const statusEl = () => document.getElementById('status');

    function setStatus(msg) {
      if (!statusEl()) return;
      statusEl().textContent = msg || '';
    }

    function scheduleSave() {
      if (saveTimer) clearTimeout(saveTimer);
      setStatus('저장 중...');
      saveTimer = setTimeout(() => {
        google.script.run
          .withSuccessHandler(() => {
            setStatus('저장 완료');
            setTimeout(() => setStatus(''), 800);
          })
          .withFailureHandler(err => {
            setStatus('저장 실패: ' + err.message);
          })
          .saveNotes(notes);
      }, 400);
    }

    function getNote(id) {
      return notes.find(n => n.id === id);
    }

    function bringToFront(note) {
      maxZ += 1;
      note.z = maxZ;
      const el = document.querySelector(`.note[data-id="${note.id}"]`);
      if (el) el.style.zIndex = note.z;
      scheduleSave();
    }

    // 첫 줄을 제목으로
    function updateTitleFromContent(note, contentEl, titleEl) {
      if (!contentEl || !titleEl) return;
      const text = contentEl.innerText || '';
      const firstLine =
        (text.split(/r?n/).find(line => line.trim() !== '') || '메모')
          .trim();
      titleEl.textContent = firstLine.length > 30
        ? firstLine.slice(0, 30) + '...'
        : firstLine;
    }

    // URL 자동 링크
    function autoLink(root) {
      const urlRegex = /(https?://[^s]+)/g;
      const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
      const targets = [];
      let node;

      while ((node = walker.nextNode())) {
        if (!node.nodeValue || !urlRegex.test(node.nodeValue)) continue;
        if (node.parentNode && node.parentNode.closest('a')) continue; // 이미 링크이면 패스
        targets.push(node);
        urlRegex.lastIndex = 0;
      }

      targets.forEach(textNode => {
        const text = textNode.nodeValue;
        let lastIndex = 0;
        let match;
        const frag = document.createDocumentFragment();

        while ((match = urlRegex.exec(text))) {
          const url = match[0];
          if (match.index > lastIndex) {
            frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
          }
          const a = document.createElement('a');
          a.href = url;
          a.textContent = url;
          a.target = '_blank';
          a.rel = 'noopener noreferrer';
          a.contentEditable = 'false';      // 링크는 클릭 가능하게, 편집은 불가
          frag.appendChild(a);
          lastIndex = match.index + url.length;
        }

        if (lastIndex < text.length) {
          frag.appendChild(document.createTextNode(text.slice(lastIndex)));
        }

        textNode.parentNode.replaceChild(frag, textNode);
      });
    }

    function createToolbar(content, note, titleEl) {
      const toolbar = document.createElement('div');
      toolbar.className = 'note-toolbar';

      const buttons = [
        { cmd: 'bold', label: 'B' },
        { cmd: 'italic', label: 'I' },
        { cmd: 'underline', label: 'U' },
        { cmd: 'insertUnorderedList', label: '•' },
      ];

      buttons.forEach(b => {
        const btn = document.createElement('button');
        btn.textContent = b.label;
        btn.title = b.cmd;
        btn.addEventListener('click', (e) => {
          e.preventDefault();
          content.focus();
          document.execCommand(b.cmd, false, null);
          const n = getNote(note.id);
          if (!n) return;
          autoLink(content);
          n.text = content.innerHTML;
          updateTitleFromContent(n, content, titleEl);
          scheduleSave();
        });
        toolbar.appendChild(btn);
      });

      return toolbar;
    }

    function createNoteElement(note) {
      const el = document.createElement('div');
      el.className = 'note ' + (note.color || 'yellow');
      el.style.left = (note.x || 80) + 'px';
      el.style.top = (note.y || 80) + 'px';
      el.style.width = (note.w || 260) + 'px';
      el.style.height = (note.h || 160) + 'px';
      el.style.zIndex = note.z || 1;
      el.dataset.id = note.id;

      const header = document.createElement('div');
      header.className = 'note-header';

      const title = document.createElement('div');
      title.className = 'title';
      title.textContent = '메모';

      const actions = document.createElement('div');
      actions.className = 'actions';

      const colorSelect = document.createElement('select');
      ['yellow','pink','blue','green'].forEach(c => {
        const opt = document.createElement('option');
        opt.value = c;
        opt.textContent = c;
        if (c === note.color) opt.selected = true;
        colorSelect.appendChild(opt);
      });

      const closeBtn = document.createElement('button');
      closeBtn.innerHTML = '✕';
      closeBtn.title = '삭제';

      actions.appendChild(colorSelect);
      actions.appendChild(closeBtn);

      header.appendChild(title);
      header.appendChild(actions);

      const content = document.createElement('div');
      content.className = 'note-content';
      content.contentEditable = 'true';
      content.spellcheck = false;   // 빨간 물결 끄기
      content.innerHTML = note.text || '';
      autoLink(content);            // 기존 내용도 링크 변환

      const toolbar = createToolbar(content, note, title);

      const resizeHandle = document.createElement('div');
      resizeHandle.className = 'note-resize';

      el.appendChild(header);
      el.appendChild(toolbar);
      el.appendChild(content);
      el.appendChild(resizeHandle);
      document.body.appendChild(el);

      // 제목 초기화
      updateTitleFromContent(note, content, title);

      // 내용 입력 시 저장 + 링크 처리 + 제목 갱신
      content.addEventListener('input', () => {
        const n = getNote(note.id);
        if (!n) return;
        autoLink(content);
        n.text = content.innerHTML;
        updateTitleFromContent(n, content, title);
        scheduleSave();
      });

      // 색상 변경
      colorSelect.addEventListener('change', () => {
        const n = getNote(note.id);
        if (!n) return;
        n.color = colorSelect.value;

        el.classList.remove('yellow','pink','blue','green');
        el.classList.add(n.color);
        scheduleSave();
      });

      // 삭제
      closeBtn.addEventListener('click', () => {
        notes = notes.filter(n => n.id !== note.id);
        el.remove();
        scheduleSave();
      });

      // 드래그 시작
      header.addEventListener('mousedown', (e) => {
        const n = getNote(note.id);
        if (!n) return;
        dragState.id = note.id;
        dragState.offsetX = e.clientX - (n.x || 0);
        dragState.offsetY = e.clientY - (n.y || 0);
        bringToFront(n);
      });

      // 클릭 시 맨 앞으로
      el.addEventListener('mousedown', () => {
        const n = getNote(note.id);
        if (n) bringToFront(n);
      });

      // 크기 조절 시작
      resizeHandle.addEventListener('mousedown', (e) => {
        e.stopPropagation();
        const n = getNote(note.id);
        if (!n) return;
        resizeState.id = note.id;
        resizeState.startX = e.clientX;
        resizeState.startY = e.clientY;
        resizeState.startW = el.offsetWidth;
        resizeState.startH = el.offsetHeight;
        bringToFront(n);
      });
    }

    function renderAll() {
      document.querySelectorAll('.note').forEach(el => el.remove());
      notes.forEach(n => createNoteElement(n));
    }

    document.addEventListener('mousemove', (e) => {
      // 이동
      if (dragState.id) {
        const note = getNote(dragState.id);
        if (!note) return;
        note.x = e.clientX - dragState.offsetX;
        note.y = e.clientY - dragState.offsetY;

        const el = document.querySelector(`.note[data-id="${note.id}"]`);
        if (el) {
          el.style.left = note.x + 'px';
          el.style.top = note.y + 'px';
        }
        scheduleSave();
      }

      // 크기 조절
      if (resizeState.id) {
        const note = getNote(resizeState.id);
        if (!note) return;
        const el = document.querySelector(`.note[data-id="${note.id}"]`);
        if (!el) return;

        const newW = Math.max(160, resizeState.startW + (e.clientX - resizeState.startX));
        const newH = Math.max(140, resizeState.startH + (e.clientY - resizeState.startY));

        note.w = newW;
        note.h = newH;

        el.style.width = newW + 'px';
        el.style.height = newH + 'px';
        scheduleSave();
      }
    });

    document.addEventListener('mouseup', () => {
      if (dragState.id || resizeState.id) {
        scheduleSave();
      }
      dragState.id = null;
      resizeState.id = null;
    });

    function init() {
      setStatus('불러오는 중...');
      google.script.run
        .withSuccessHandler(serverNotes => {
          if (Array.isArray(serverNotes) && serverNotes.length > 0) {
            notes = serverNotes;
          } else {
            notes = [{
              id: Date.now(),
              x: 80,
              y: 80,
              w: 260,
              h: 160,
              text: '여기에 메모를 적어 보세요.',
              color: 'yellow',
              z: 1,
            }];
          }
          maxZ = notes.reduce((m, n) => Math.max(m, n.z || 1), 1);
          renderAll();
          setStatus('');
        })
        .withFailureHandler(err => {
          setStatus('불러오기 실패: ' + err.message);
        })
        .getNotes();

      document.getElementById('addNoteBtn').addEventListener('click', () => {
        const id = Date.now();
        const newNote = {
          id,
          x: 100 + Math.random() * 80,
          y: 100 + Math.random() * 80,
          w: 260,
          h: 160,
          text: '',
          color: 'yellow',
          z: ++maxZ,
        };
        notes.push(newNote);
        createNoteElement(newNote);
        scheduleSave();
      });
    }

    window.addEventListener('load', init);
  </script>
</body>
</html>

🤖 사용한 AI 프롬프트

구글 시트와 앱스 스크립트를 사용해서 '스티커 메모 보드'를 만들고 싶어. 아래에 작성한 기본 기능을 참고해서 GS 코드와 HTML 코드를 만들어줘. 
* Google Apps Script 웹앱이 HTML(스티커 메모 보드)을 띄움
* 메모 정보는 Google Sheet에 저장/불러오기
* 메모를 움직이거나 수정할 때마다 자동으로 Sheet에 저장(백업)
* 작성 시간, 수정 시간 기록
* 상단에 서식 툴바 추가(볼드, 기울기, 삭제, 밑줄, 글머리, 폰트 선택, 폰트 크기 선택)
* 메모 오른쪽 아래 작은 크기 조절 핸들 추가, 드래그 → 폭·높이 변경
* 첫 줄을 자동으로 제목으로 지정, 자동으로 볼드가 되도록 지정(엔터로 제목을 구분해줘)
* 메모지는 파스텔 톤 컬러로 지정하고, 기본 메모지는 노란색으로 해줘. 
* 모바일에서는 메모지가 한줄로 보이도록 해줘. 
* 기본 폰트 크기는 12로 해줘. 기본 폰트는 noto sans로 지정해줘. 
* 저장은 실시간으로 바로 반영되도록 해줘. 

🎬 관련 영상