
POST로 메모 추가, DELETE로 삭제하기 (4/5)

POST로 메모 추가, DELETE로 삭제하기
JavaScript 다음 스텝 - Part 4: 사용자 입력을 서버에 저장하고 삭제하기
Part 3에서 서버의 메모 데이터를 브라우저에 표시하는 데 성공했습니다. fetch()로 GET API를 호출하고, 프로미스 체이닝으로 응답을 처리하고, DOM 조작으로 화면에 그렸죠. 이제 메모 목록이 화면에 보이긴 하는데, 메모를 추가하거나 삭제할 수가 없습니다. 입력 필드에 뭘 적어도, 추가 버튼을 눌러도, 아무 일도 안 일어나요. 서버에 하드코딩된 메모 3개만 볼 수 있는 상태입니다.
이번 편에서는 두 가지 기능을 만듭니다. POST API로 메모를 추가하고, DELETE API로 메모를 삭제합니다. "사용자가 입력한 데이터를 서버에 보내서 저장하고, 필요 없는 데이터를 서버에서 지우는 것"이 이번 편의 핵심입니다. 끝까지 따라하면 메모를 적고 추가 버튼을 누르면 목록에 나타나고, 삭제 버튼을 누르면 사라지는 메모장 앱이 완성됩니다.
전반부에서 POST(추가)를, 후반부에서 DELETE(삭제)를 다룹니다. 각각 서버 API를 먼저 만들고, 브라우저 콘솔에서 테스트한 다음, 프론트엔드 코드를 작성하는 순서로 진행합니다. Part 3에서 했던 것과 같은 패턴입니다. API 만들기 → 확인하기 → 프론트엔드 연결하기.
express.json() 미들웨어
POST 요청은 GET과 다른 점이 하나 있습니다. GET은 "데이터를 달라"는 요청이라 서버에 보낼 데이터가 없지만, POST는 "이 데이터를 추가해줘"라는 요청이라 서버에 데이터를 함께 보내야 합니다. 이 데이터를 **요청 본문(request body)**이라고 합니다.
문제는, Express가 요청 본문을 자동으로 읽지 않는다는 겁니다. 브라우저가 JSON 형식으로 데이터를 보내도, Express는 그걸 그냥 무시합니다. 서버 코드에서 req.body를 찍어보면 undefined가 나옵니다. Express에게 "요청 본문에 JSON이 들어올 수 있으니, 그걸 읽어서 req.body에 넣어줘"라고 명시적으로 알려줘야 합니다.
server.js를 열고, app.use(express.static(...)) 위에 다음 한 줄을 추가합니다.
app.use(express.json());
이 한 줄이 하는 일은, 요청 본문에 들어온 JSON 문자열을 JavaScript 객체로 바꿔서 req.body에 넣어주는 겁니다. 이걸 미들웨어(middleware)라고 부릅니다.
미들웨어란
미들웨어는 요청이 API 핸들러에 도착하기 전에 먼저 실행되는 함수입니다. Part 2에서 쓴 express.static()도 미들웨어였죠. "요청이 오면 public 폴더에서 파일을 찾아봐라"는 미들웨어였고, 이번에 추가한 express.json()은 "요청 본문에 JSON이 있으면 파싱해서 req.body에 넣어라"는 미들웨어입니다.
택배에 비유하면, 미들웨어는 경비실 같은 겁니다. 택배(요청)가 내 방(API 핸들러)에 도착하기 전에 경비실(미들웨어)에서 먼저 한번 처리하는 거죠. express.json() 미들웨어는 "택배 포장을 열어서 내용물을 정리해놓는 경비실"이라고 생각하면 됩니다. 이 경비실이 없으면 내 방에 도착한 택배는 포장이 그대로인 채로 오고, 내용물(req.body)을 꺼낼 수 없습니다.
Express 공식 문서에도 express.json()을 등록해야 요청 본문을 읽을 수 있다고 나와 있습니다. Express 4.16 이전에는 body-parser라는 별도 패키지를 설치해야 했는데, 지금은 Express에 내장되어 있어서 npm install 없이 바로 쓸 수 있습니다.
다음 그림은 express.json() 미들웨어가 요청을 처리하는 흐름을 보여줍니다.

브라우저가 JSON 본문을 담아 POST 요청을 보내면, express.json() 미들웨어가 먼저 이를 가로채서 JSON 문자열을 JavaScript 객체로 변환하고 req.body에 넣어줍니다. 그래야 라우트 핸들러에서 req.body.text로 데이터를 꺼낼 수 있습니다. 미들웨어가 라우트보다 뒤에 있으면(WRONG) req.body는 undefined가 됩니다.
주의할 점이 하나 있습니다. app.use(express.json())은 반드시 라우트(app.get(), app.post() 등)보다 위에 써야 합니다. Express는 코드를 위에서 아래로 실행하므로, 미들웨어가 라우트보다 아래에 있으면 요청이 미들웨어를 거치지 않고 바로 라우트로 갑니다. 그러면 req.body가 여전히 undefined입니다. 초보자가 Express에서 가장 많이 하는 실수 중 하나가 이 순서를 잘못 놓는 겁니다.
POST API 만들기
이제 메모를 추가하는 POST API를 만듭니다. server.js를 다음과 같이 수정합니다.
// server.js (Part 4 버전) const express = require('express'); const path = require('path'); const app = express(); // 메모 데이터 (서버 메모리에 저장) const memos = [ { id: 1, text: '장보기: 우유, 계란, 식빵', createdAt: '2026-03-04 09:00' }, { id: 2, text: 'Express 공부하기', createdAt: '2026-03-04 09:30' }, { id: 3, text: '점심 약속 장소 확인', createdAt: '2026-03-04 10:15' } ]; let nextId = 4; // 날짜 포맷 함수 function formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes; } // JSON 요청 본문 파싱 미들웨어 app.use(express.json()); // 정적 파일 서빙 app.use(express.static(path.join(__dirname, 'public'))); // GET /api/memos - 메모 목록 조회 app.get('/api/memos', function(req, res) { res.json(memos); }); // POST /api/memos - 메모 추가 app.post('/api/memos', function(req, res) { const text = req.body && req.body.text; if (!text || text.trim() === '') { res.status(400).json({ error: '메모 내용을 입력해주세요' }); return; } const memo = { id: nextId, text: text.trim(), createdAt: formatDate(new Date()) }; nextId = nextId + 1; memos.push(memo); res.status(201).json(memo); }); app.listen(3000, function() { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
Part 3 코드와 비교해 바뀐 부분이 꽤 많습니다. express.json() 미들웨어, nextId 변수, formatDate() 함수, 그리고 app.post() 라우트가 추가되었습니다. 하나씩 보겠습니다.
변수 선언: const와 let
Part 3에서는 변수 선언을 깊이 다루지 않았는데, 이번 편부터는 const와 let을 구분해서 씁니다.
const memos = [...]; let nextId = 4;
const는 한번 값을 할당하면 다른 값으로 바꿀 수 없는 변수입니다. let은 나중에 다른 값으로 바꿀 수 있습니다. memos는 const로 선언했는데, "배열 내용이 바뀌는데 const를 써도 되나?"라는 의문이 들 수 있습니다.
const가 막는 건 변수 자체의 재할당입니다. memos = 다른배열 이렇게 바꾸는 건 안 됩니다. 하지만 배열 안에 항목을 추가(push)하거나 삭제(splice)하는 건 배열 내부를 수정하는 거지 변수를 다른 값으로 바꾸는 게 아닙니다. 그래서 const로 선언해도 문제가 없습니다. 실제로 대부분의 JavaScript 코드에서 배열과 객체는 const로 선언합니다.
반면 nextId는 let으로 선언했습니다. 메모가 추가될 때마다 nextId = nextId + 1로 값 자체가 바뀌거든요. 4에서 5로, 5에서 6으로 숫자가 달라지니까 재할당이 필요하고, 그래서 let입니다.
정리하면 이렇습니다. 나중에 =로 다른 값을 넣어야 하면 let, 그렇지 않으면 const. 이 기준만 기억하면 됩니다.
nextId 변수
let nextId = 4;
메모를 추가할 때마다 고유한 id를 붙여야 합니다. 기존 메모가 1, 2, 3번이니까 다음 메모는 4번부터 시작합니다. 메모가 추가될 때마다 nextId를 1씩 올려서 중복되지 않게 합니다.
데이터베이스를 쓴다면 이런 작업을 데이터베이스가 알아서 합니다. 지금은 배열에 저장하고 있으니까 직접 관리하는 거죠.
formatDate() 함수
function formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes; }
새 메모를 만들 때 createdAt에 현재 시간을 넣어야 합니다. new Date()로 현재 시간을 구할 수 있는데, 이걸 그대로 문자열로 바꾸면 "Wed Mar 04 2026 15:30:00 GMT+0900" 같은 형식이 나옵니다. 기존 메모와 형식을 맞추려면 "2026-03-04 15:30" 형태로 변환해야 하죠.
getFullYear()는 연도(2026), getMonth()는 월(0~11, 0부터 시작하므로 1을 더해야 합니다), getDate()는 일, getHours()는 시, getMinutes()는 분을 반환합니다. 이것들은 JavaScript의 Date 객체가 제공하는 메서드입니다.
String()은 숫자를 문자열로 바꾸고, .padStart(2, '0')은 문자열 길이가 2보다 짧으면 앞에 '0'을 채웁니다. 3월이면 '3'을 '03'으로 만드는 거죠. 이 두 메서드 모두 JavaScript 표준 문법입니다.
app.post()
app.post('/api/memos', function(req, res) { // ... });
app.post()는 POST 요청을 처리하는 Express 메서드입니다. Part 3에서 쓴 app.get()과 사용법이 같습니다. 첫 번째 인자는 URL 경로, 두 번째 인자는 콜백 함수. 차이점은 HTTP 메서드뿐입니다.
여기서 중요한 포인트가 있습니다. app.get('/api/memos', ...)와 app.post('/api/memos', ...)의 URL 경로가 똑같습니다. 같은 주소인데 어떻게 다르게 동작할까요? HTTP 메서드가 다르기 때문입니다. 브라우저가 GET 방식으로 /api/memos를 요청하면 app.get()이 처리하고, POST 방식으로 요청하면 app.post()가 처리합니다. 이것이 REST API의 핵심 원칙입니다. 같은 자원(memos)에 대해 메서드로 행위를 구분하는 겁니다.
req.body
const text = req.body && req.body.text;
req.body는 아까 등록한 express.json() 미들웨어가 파싱한 요청 본문입니다. 브라우저가 { "text": "새 메모 내용" } 형태의 JSON을 보내면, req.body에 { text: "새 메모 내용" }이라는 JavaScript 객체가 들어갑니다. 거기서 .text 속성을 꺼내서 변수에 담는 거죠.
req.body && req.body.text라고 쓴 이유가 있습니다. req.body가 undefined인 상황이 실제로 발생할 수 있기 때문입니다. express.json() 미들웨어를 등록하지 않았거나, 요청에 Content-Type: application/json 헤더가 빠져 있으면 req.body는 undefined가 됩니다. undefined.text를 읽으려 하면 TypeError: Cannot read properties of undefined라는 에러가 나고, 서버가 500 에러를 응답합니다.
req.body && req.body.text는 "먼저 req.body가 존재하는지 확인하고, 존재하면 .text를 꺼내라"는 뜻입니다. req.body가 undefined이면 && 뒤를 실행하지 않고 바로 undefined를 반환하므로, TypeError가 발생하지 않습니다. 이렇게 하면 req.body가 없는 경우에도 에러 없이 유효성 검사에서 걸러집니다.
POST API를 만들 때 이 방어 코드를 넣는 습관을 들여두면 좋습니다. express.json() 미들웨어의 순서를 잘못 놓거나, 클라이언트가 Content-Type 헤더를 빼먹는 경우에도 서버가 크래시하지 않습니다.
유효성 검사
if (!text || text.trim() === '') { res.status(400).json({ error: '메모 내용을 입력해주세요' }); return; }
사용자가 빈 문자열을 보내거나, text 속성 자체를 빼먹고 보내는 경우를 처리합니다. !text는 text가 undefined이거나 null이거나 빈 문자열일 때 true가 됩니다. text.trim() === ''은 공백만 있는 경우(예: " ")를 잡습니다.
문제가 있으면 res.status(400).json()으로 에러 응답을 보냅니다. 400은 HTTP 상태 코드(Status Code)로, "잘못된 요청(Bad Request)"이라는 뜻입니다. 클라이언트(브라우저)가 보낸 데이터에 문제가 있다는 의미죠.
return을 빼먹으면 안 됩니다. 에러 응답을 보낸 뒤에도 함수는 계속 실행됩니다. return이 없으면 에러 응답을 보내놓고 아래에서 메모를 또 추가하려 하고, 두 번째 응답을 보내려다가 "headers already sent" 에러가 나죠. return으로 함수 실행을 여기서 멈춰야 합니다.
메모 생성과 응답
const memo = { id: nextId, text: text.trim(), createdAt: formatDate(new Date()) }; nextId = nextId + 1; memos.push(memo); res.status(201).json(memo);
유효성 검사를 통과하면 새 메모 객체를 만듭니다. id는 nextId를 쓰고, text는 .trim()으로 앞뒤 공백을 제거하고, createdAt은 현재 시간을 포맷한 값을 넣습니다.
memos.push(memo)는 배열 끝에 새 요소를 추가하는 JavaScript 메서드입니다. 이 코드 한 줄로 서버의 메모 목록에 새 메모가 들어갑니다.
res.status(201).json(memo)는 HTTP 201 상태 코드와 함께 생성된 메모를 응답으로 보냅니다. 201은 "Created", 새 데이터가 잘 만들어졌다는 뜻이죠. 200(OK)으로 보내도 동작하지만, REST API에서는 새 자원을 만들었을 때 201로 응답하는 게 관례입니다.
생성된 메모를 응답으로 보내는 이유가 있습니다. 브라우저가 서버에서 실제로 만들어진 메모를 확인할 수 있어야 하거든요. id와 createdAt은 서버에서 생성한 값이니까, 브라우저가 이걸 받아야 화면에 제대로 표시할 수 있습니다.
브라우저 콘솔에서 POST 테스트하기
서버 코드를 수정했으니 서버를 재시작합니다. Ctrl + C로 기존 서버를 종료하고 다시 실행합니다.
node server.js
참고로, 서버를 재시작하면 메모리에 저장된 데이터가 초기 상태(3개)로 돌아갑니다. 지금은 배열에 저장하고 있으니 당연한 동작입니다. 서버가 시작될 때마다 server.js 맨 위의 const memos = [...] 코드가 다시 실행되면서 초기 데이터로 세팅되니까요. 앞으로 서버를 재시작할 때마다 이전에 추가한 메모가 사라지는데, 버그가 아니라 메모리 저장 방식의 한계입니다.
프론트엔드 코드를 건드리기 전에, 먼저 API가 제대로 동작하는지 확인합니다. Part 3에서도 이 순서를 따랐죠. API 먼저 확인하고, 그 다음에 프론트엔드 코드를 작성하는 겁니다. 문제가 생겼을 때 원인을 빨리 찾을 수 있는 습관입니다.
브라우저에서 http://localhost:3000을 열고, 개발자도구(F12)의 Console 탭에서 다음 코드를 입력합니다.
fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: '콘솔에서 추가한 메모' }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log(data); });
콘솔에 이런 결과가 나오면 성공입니다.
{id: 4, text: "콘솔에서 추가한 메모", createdAt: "2026-03-05 14:30"}
id가 4이고, createdAt에 현재 시간이 들어가 있는 걸 확인할 수 있습니다. 이제 브라우저에서 http://localhost:3000/api/memos를 열어보면 메모가 4개로 늘어난 걸 볼 수 있죠.
이 fetch() 코드에서 새로 나온 부분을 짚어보겠습니다.
method: 'POST'
fetch('/api/memos', { method: 'POST', // ... })
Part 3에서 fetch('/api/memos')만 썼을 때는 기본값인 GET 요청이었습니다. POST 요청을 보내려면 두 번째 인자로 옵션 객체를 전달하고, method에 'POST'를 지정해야 합니다.
headers와 Content-Type
headers: { 'Content-Type': 'application/json' }
이것은 "내가 보내는 데이터가 JSON 형식이야"라고 서버에 알려주는 HTTP 헤더입니다. 이 한 줄을 빼먹으면 req.body가 undefined가 됩니다. express.json() 미들웨어는 Content-Type이 application/json인 요청만 파싱하거든요. 헤더가 없으면 "이건 JSON이 아니네" 하고 지나쳐 버립니다.
초보자가 POST 요청에서 가장 많이 하는 실수가 바로 이 Content-Type 헤더를 빼먹는 겁니다. 서버 코드는 맞는데 req.body가 계속 undefined라서 헤매다가, 알고 보니 프론트엔드에서 헤더를 안 보낸 것이었다는 경우가 정말 흔합니다.
JSON.stringify()
body: JSON.stringify({ text: '콘솔에서 추가한 메모' })
JSON.stringify()는 JavaScript 객체를 JSON 문자열로 변환하는 함수입니다. { text: '콘솔에서 추가한 메모' }라는 JavaScript 객체를 '{"text":"콘솔에서 추가한 메모"}'라는 문자열로 바꿔줍니다.
왜 변환이 필요할까요? HTTP 요청의 본문(body)에는 문자열만 넣을 수 있기 때문입니다. JavaScript 객체는 메모리에 있는 데이터 구조라서 네트워크로 전송할 수 없어요. 그래서 JSON 문자열로 바꿔서 보내고, 서버에서 express.json() 미들웨어가 다시 JavaScript 객체로 변환하는 겁니다.
Part 3에서 서버가 res.json(memos)로 보낸 JSON을 브라우저에서 response.json()으로 파싱했던 것과 방향만 반대입니다. 이번에는 브라우저가 JSON을 만들어서 서버에 보내고, 서버가 파싱하는 거죠.
브라우저 → JSON.stringify() → JSON 문자열 → 네트워크 → express.json() → req.body 서버 → res.json() → JSON 문자열 → 네트워크 → response.json() → JavaScript 객체
POST fetch의 3가지 필수 설정
정리하면, fetch()로 POST 요청을 보낼 때는 3가지를 반드시 설정해야 합니다.
method: 'POST'- GET이 아닌 POST 요청임을 명시headers: { 'Content-Type': 'application/json' }- 보내는 데이터가 JSON임을 알림body: JSON.stringify(데이터)- JavaScript 객체를 JSON 문자열로 변환해서 전송
하나라도 빠지면 서버에서 데이터를 제대로 받지 못합니다. 이 세 가지는 POST 요청의 공식처럼 외워두면 편합니다.
프론트엔드에서 메모 추가 기능 구현하기
API가 동작하는 걸 확인했으니, 이제 프론트엔드 코드를 작성합니다. public/app.js에 메모 추가 기능을 구현합니다.
// public/app.js const memoList = document.getElementById('memoList'); const memoInput = document.getElementById('memoInput'); const addBtn = document.getElementById('addBtn'); function displayMemos(memos) { memoList.innerHTML = ''; for (let i = 0; i < memos.length; i++) { const memo = memos[i]; const li = document.createElement('li'); const textSpan = document.createElement('span'); textSpan.className = 'memo-text'; textSpan.textContent = memo.text; const dateSpan = document.createElement('span'); dateSpan.className = 'memo-date'; dateSpan.textContent = memo.createdAt; li.appendChild(textSpan); li.appendChild(dateSpan); memoList.appendChild(li); } } function loadMemos() { fetch('/api/memos') .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(memos) { displayMemos(memos); }) .catch(function(error) { console.log('에러 발생:', error); }); } function addMemo() { const text = memoInput.value.trim(); if (text === '') return; fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(newMemo) { memoInput.value = ''; loadMemos(); }) .catch(function(error) { console.log('추가 실패:', error); }); } addBtn.addEventListener('click', addMemo); loadMemos();
Part 3의 코드에서 세 가지가 추가되었습니다. memoInput과 addBtn 요소를 가져오는 코드, addMemo() 함수, 그리고 addEventListener(). 하나씩 보겠습니다.
코드에 response.ok를 확인하는 부분이 보입니다. 지금은 "이런 코드가 있구나" 정도로 넘어가세요. response.ok가 무엇인지, 왜 필요한지는 이번 편 후반부 "response.ok로 에러 처리하기" 섹션에서 자세히 설명합니다. Part 3에서 "fetch()는 서버가 에러를 응답해도 catch로 빠지지 않는다"고 예고했는데, 그 문제를 해결하는 코드입니다.
memoInput.value
const text = memoInput.value.trim(); if (text === '') return;
memoInput은 document.getElementById('memoInput')으로 가져온 <input> 요소입니다. .value는 입력 필드에 현재 입력된 텍스트를 가져오는 속성입니다. 사용자가 "저녁 메뉴 정하기"라고 적었으면 memoInput.value는 '저녁 메뉴 정하기'가 됩니다.
.trim()으로 앞뒤 공백을 제거하고, 빈 문자열이면 return으로 함수를 즉시 종료합니다. 아무것도 안 적고 추가 버튼을 누르면 아무 일도 안 일어나게 하는 거죠.
addMemo() 함수
function addMemo() { // 입력값 가져오기 const text = memoInput.value.trim(); if (text === '') return; // 서버에 POST 요청 fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(response) { if (!response.ok) { // 에러 응답 확인 throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(newMemo) { memoInput.value = ''; // 입력 필드 비우기 loadMemos(); // 목록 새로고침 }) .catch(function(error) { console.log('추가 실패:', error); }); }
콘솔에서 테스트했던 fetch() 코드와 거의 같습니다. 차이점은 두 번째 .then() 안에서 두 가지 일을 한다는 겁니다. memoInput.value = ''로 입력 필드를 비우고, loadMemos()를 호출해서 메모 목록을 다시 가져옵니다. response.ok 확인과 throw new Error()는 이번 편 후반부에서 자세히 다룹니다.
loadMemos()는 Part 3에서 만든 함수죠. 서버에서 전체 메모 목록을 GET으로 가져와서 displayMemos()로 화면에 그리는 함수입니다. 메모를 추가한 뒤에 이 함수가 실행되면 방금 추가한 메모까지 포함된 최신 목록으로 화면이 갱신됩니다.
"방금 추가한 메모만 화면에 추가하면 더 효율적이지 않나?"라고 생각할 수 있습니다. 맞는 말입니다. 하지만 지금은 메모가 몇 개 안 되는 상태이고, 전체 목록을 다시 가져오는 방식이 코드가 더 단순합니다. 서버의 데이터와 화면이 항상 일치한다는 보장도 되고요. 성능 최적화는 필요해지면 그때 해도 늦지 않습니다.
addEventListener()
addBtn.addEventListener('click', addMemo);
addEventListener()는 "이 요소에서 이 이벤트가 발생하면 이 함수를 실행해라"라고 등록하는 메서드입니다. 첫 번째 인자 'click'은 이벤트 종류, 두 번째 인자 addMemo는 실행할 함수입니다. addBtn(추가 버튼)을 클릭하면 addMemo 함수가 실행되는 거죠.
주의할 점은 addMemo 뒤에 ()를 붙이지 않는다는 겁니다. addMemo()라고 쓰면 "지금 바로 실행해라"는 뜻이고, addMemo만 쓰면 "이 함수를 기억해놨다가 나중에 클릭할 때 실행해라"는 뜻입니다. addEventListener()에 전달할 때는 함수를 나중에 호출할 수 있도록 참조만 넘겨야 하므로 ()를 붙이지 않습니다.
Enter 키로 메모 추가하기
버튼 클릭으로 메모를 추가할 수 있게 되었지만, 메모를 적고 매번 마우스로 버튼을 누르는 건 불편합니다. 입력 필드에서 Enter 키를 치면 바로 추가되게 만들겠습니다.
addBtn.addEventListener('click', addMemo); 아래에 다음 코드를 추가합니다.
memoInput.addEventListener('keydown', function(event) { if (event.key === 'Enter') { addMemo(); } });
keydown 이벤트
'keydown'은 키보드를 누르는 순간 발생하는 이벤트입니다. memoInput에 포커스가 있는 상태(커서가 깜빡이는 상태)에서 키보드를 누르면 이 이벤트가 발생합니다.
event 매개변수
function(event) { if (event.key === 'Enter') { addMemo(); } }
이벤트 리스너 함수의 매개변수 event에는 브라우저가 이벤트 정보를 담아서 넘겨줍니다. 어떤 키를 눌렀는지, 마우스가 어디에 있는지 같은 정보가 들어 있죠.
event.key는 눌린 키의 이름을 문자열로 알려줍니다. Enter 키를 누르면 'Enter', Escape 키를 누르면 'Escape'가 들어옵니다. MDN 공식 문서에 모든 키 이름이 정리되어 있으니, 다른 키를 감지하고 싶으면 참고하면 됩니다.
event.key === 'Enter'일 때만 addMemo()를 호출하므로, 다른 키를 눌러서 글자를 입력하는 건 영향을 받지 않습니다. Enter 키를 누르면 메모가 추가되고, 나머지 키는 평소대로 입력됩니다.
브라우저에서 추가 기능 확인하기
app.js는 public/ 안의 파일이므로 서버 재시작 없이 브라우저 새로고침만 하면 됩니다.
http://localhost:3000을 새로고침하고, 입력 필드에 "저녁 메뉴 정하기"라고 적은 뒤 추가 버튼을 클릭합니다. 메모 목록에 새 메모가 나타나면 성공입니다. Enter 키를 눌러서도 추가되는지 확인해보세요.
만약 메모가 추가되지 않으면 개발자도구(F12) Console 탭에 에러가 있는지 확인합니다. Network 탭에서 /api/memos로의 POST 요청이 201로 응답하는지도 확인해보세요. 상태 코드가 400이면 빈 문자열을 보내고 있을 가능성이 있고, req.body가 undefined라는 에러가 뜨면 express.json() 미들웨어를 확인합니다.
DELETE API 만들기
메모 추가가 되니, 이제 삭제 차례입니다. server.js에 DELETE API를 추가합니다. app.post() 아래에 다음 코드를 넣습니다.
// DELETE /api/memos/:id - 메모 삭제 app.delete('/api/memos/:id', function(req, res) { const id = parseInt(req.params.id, 10); const index = memos.findIndex(function(memo) { return memo.id === id; }); if (index === -1) { res.status(404).json({ error: '해당 메모를 찾을 수 없습니다' }); return; } memos.splice(index, 1); res.json({ message: '삭제되었습니다' }); });
새로운 문법이 여러 개 나옵니다. 하나씩 살펴보겠습니다.
URL 파라미터 :id
app.delete('/api/memos/:id', function(req, res) {
':id'는 URL의 일부를 변수로 받는 Express 기능입니다. /api/memos/3이면 3이, /api/memos/7이면 7이 req.params.id에 들어갑니다. 이것을 URL 파라미터(route parameter)라고 합니다.
왜 이런 게 필요할까요? 삭제 요청을 보낼 때는 "몇 번 메모를 삭제할지" 구체적인 대상을 지정해야 합니다. /api/memos만으로는 어떤 메모를 삭제할지 알 수 없죠. /api/memos/3이면 "3번 메모를 삭제해줘"라는 뜻이 되고, :id 부분이 3을 받아서 req.params.id에 넣어줍니다.
GET /api/memos는 전체 목록을 가져오는 거라 특정 대상을 지정할 필요가 없었고, POST /api/memos는 새 메모를 추가하는 거라 요청 본문에 데이터를 담았습니다. DELETE는 특정 메모를 지정해야 하니까 URL에 ID를 포함하는 방식을 씁니다.
parseInt()
const id = parseInt(req.params.id, 10);
req.params.id는 항상 문자열입니다. URL에서 추출한 값이니까요. /api/memos/3이면 숫자 3이 아니라 문자열 '3'이 들어옵니다. 메모의 id는 숫자이므로, 비교하려면 문자열을 숫자로 변환해야 합니다.
parseInt()는 문자열을 정수로 변환하는 JavaScript 함수입니다. 두 번째 인자 10은 10진수라는 뜻입니다. parseInt('3', 10)은 숫자 3을 반환합니다. 두 번째 인자를 생략해도 대부분 10진수로 동작하지만, 명시적으로 10을 쓰는 것이 안전한 습관입니다.
findIndex()
const index = memos.findIndex(function(memo) { return memo.id === id; });
findIndex()는 배열에서 조건에 맞는 요소의 위치(인덱스)를 찾는 JavaScript 메서드입니다. 배열의 각 요소에 대해 함수를 실행하고, 함수가 true를 반환하는 첫 번째 요소의 인덱스를 돌려줍니다.
memos 배열에서 id가 일치하는 메모를 찾는 겁니다. id가 3인 메모가 배열의 인덱스 2에 있으면 2를 반환합니다. 만약 해당 id를 가진 메모가 없으면 -1을 반환합니다.
인덱스가 왜 필요할까요? 이 뒤에 나오는 splice()로 배열에서 요소를 삭제하려면 해당 요소가 몇 번째에 있는지 알아야 하기 때문입니다.
404 Not Found
if (index === -1) { res.status(404).json({ error: '해당 메모를 찾을 수 없습니다' }); return; }
findIndex()가 -1을 반환했다는 건 해당 id의 메모가 없다는 뜻입니다. 404는 HTTP 상태 코드로 "찾을 수 없음(Not Found)"이라는 의미입니다. 존재하지 않는 메모를 삭제하려 했으니, "그런 메모는 없다"고 알려주는 거죠.
POST API에서 봤던 것처럼, 에러 응답 뒤에 return을 붙여서 함수 실행을 멈춥니다.
splice()
memos.splice(index, 1);
splice()는 배열에서 요소를 제거하는 JavaScript 메서드입니다. 첫 번째 인자는 시작 위치, 두 번째 인자는 제거할 개수입니다. splice(2, 1)이면 "인덱스 2에서 1개를 제거해라"는 뜻이죠.
push()는 끝에 추가하고, splice()는 특정 위치에서 제거합니다. 둘 다 원본 배열을 직접 수정합니다. const로 선언한 배열이라도 이 메서드들로 내용을 바꿀 수 있다는 점, 앞에서 설명한 이유와 같습니다.
삭제 후에는 res.json({ message: '삭제되었습니다' })로 성공 메시지를 응답합니다. REST API에서 DELETE 성공 시 204(No Content)를 쓰기도 하는데, 204는 응답 본문이 없어서 response.json()이 에러를 내므로 초보자에게 혼란스럽습니다. 200에 메시지를 담아 보내는 방식이 더 직관적이라 여기서는 이 방식을 씁니다.
프론트엔드에서 삭제 기능 구현하기
DELETE API가 준비되었으니 프론트엔드 코드를 수정합니다. 두 가지를 해야 합니다. displayMemos()에 삭제 버튼을 추가하고, 삭제 버튼을 클릭하면 DELETE 요청을 보내는 deleteMemo() 함수를 만드는 것입니다.
public/app.js의 displayMemos() 함수를 수정합니다.
function displayMemos(memos) { memoList.innerHTML = ''; for (let i = 0; i < memos.length; i++) { const memo = memos[i]; const li = document.createElement('li'); const textSpan = document.createElement('span'); textSpan.className = 'memo-text'; textSpan.textContent = memo.text; const dateSpan = document.createElement('span'); dateSpan.className = 'memo-date'; dateSpan.textContent = memo.createdAt; const deleteBtn = document.createElement('button'); deleteBtn.textContent = '삭제'; deleteBtn.addEventListener('click', function() { deleteMemo(memo.id); }); li.appendChild(textSpan); li.appendChild(dateSpan); li.appendChild(deleteBtn); memoList.appendChild(li); } }
그리고 addMemo() 함수 위에 deleteMemo() 함수를 추가합니다.
function deleteMemo(id) { fetch('/api/memos/' + id, { method: 'DELETE' }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(data) { loadMemos(); }) .catch(function(error) { console.log('삭제 실패:', error); }); }
삭제 버튼 만들기
const deleteBtn = document.createElement('button'); deleteBtn.textContent = '삭제';
createElement('button')으로 버튼 요소를 만들고, textContent로 "삭제"라는 글자를 넣습니다. 삭제할 메모의 id는 이벤트 리스너에서 클로저를 통해 memo.id를 직접 참조하므로, 버튼 요소에 별도로 저장할 필요가 없습니다.
이벤트 리스너와 클로저
deleteBtn.addEventListener('click', function() { deleteMemo(memo.id); });
삭제 버튼에 클릭 이벤트 리스너를 등록합니다. 버튼을 클릭하면 deleteMemo(memo.id)가 실행되어 해당 메모의 id를 서버에 보내서 삭제합니다.
여기서 memo.id가 어떻게 올바른 값을 가리킬 수 있는지 궁금할 수 있습니다. for 루프의 { } 블록 안에서 const memo = memos[i]로 선언했기 때문에, 각 반복마다 별도의 memo 변수가 만들어집니다. 이벤트 리스너 함수는 자신이 만들어진 블록의 memo를 기억합니다. 이것을 클로저(closure)라고 부릅니다. 함수가 자신이 만들어질 때의 주변 변수를 기억하는 JavaScript의 기능입니다.
지금 당장 클로저를 깊이 이해할 필요는 없습니다. "각 삭제 버튼이 자기가 담당하는 메모의 id를 기억하고 있다"는 정도만 알면 됩니다.
deleteMemo() 함수
function deleteMemo(id) { fetch('/api/memos/' + id, { method: 'DELETE' }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(data) { loadMemos(); }) .catch(function(error) { console.log('삭제 실패:', error); }); }
fetch('/api/memos/' + id, { method: 'DELETE' })로 DELETE 요청을 보냅니다. id가 3이면 /api/memos/3으로 요청이 갑니다. POST와 달리 DELETE 요청에는 headers나 body가 없습니다. 삭제할 대상은 URL에 이미 포함되어 있으니, 추가로 보낼 데이터가 없기 때문입니다.
서버에서 삭제가 완료되면 loadMemos()를 호출해서 목록을 새로고침합니다. 메모 추가 때와 같은 패턴이죠. 서버에 변경을 요청하고, 성공하면 전체 목록을 다시 가져와서 화면을 갱신합니다.
다음 그림은 이번 편에서 만든 POST와 DELETE 요청의 전체 데이터 흐름을 정리한 것입니다.

POST 요청은 JSON.stringify()로 변환한 데이터를 Content-Type 헤더와 함께 서버에 보내고, 서버는 express.json()으로 파싱한 뒤 배열에 추가하고 201 응답을 반환합니다. DELETE 요청은 URL에 삭제 대상 ID를 포함해서 보내고, 서버는 findIndex()로 찾아 splice()로 제거한 뒤 200 응답을 반환합니다. POST는 body에 데이터를 담고, DELETE는 URL에 대상을 지정하는 점이 핵심 차이입니다.
상태 메시지 표시하기
지금까지 에러가 발생하면 console.log()로 콘솔에만 출력했습니다. 개발자도구를 열지 않으면 사용자는 에러가 났는지조차 모릅니다. 메모가 추가되었는지, 삭제되었는지도 화면에서 알 수 없죠. index.html에 <div id="status"></div>라는 영역이 있는데, 여기에 상태 메시지를 표시하겠습니다.
public/app.js 맨 위에서 status 요소를 가져오고, showStatus() 함수를 만듭니다.
const statusDiv = document.getElementById('status'); function showStatus(message, isError) { statusDiv.textContent = message; statusDiv.style.color = isError ? '#e74c3c' : '#27ae60'; setTimeout(function() { statusDiv.textContent = ''; }, 3000); }
showStatus() 함수
showStatus()는 메시지를 화면에 표시하고, 3초 뒤에 자동으로 사라지게 합니다. isError가 true이면 빨간색, false이면 초록색으로 표시합니다. statusDiv.textContent에 메시지를 넣고, statusDiv.style.color로 색상을 설정한 뒤, setTimeout()으로 3초 후에 메시지를 비우는 흐름입니다.
statusDiv.style.color는 요소의 글자 색상을 JavaScript로 설정하는 방법입니다. CSS 파일에서 스타일을 정의하는 대신, JavaScript에서 직접 인라인 스타일을 바꾸는 거죠. 색상 설정 부분에 isError ? '#e74c3c' : '#27ae60'이라는 낯선 문법이 보이는데, 이것은 삼항 연산자입니다.
참고: 삼항 연산자
조건 ? 참일때값 : 거짓일때값은 삼항 연산자(ternary operator)라는 JavaScript 문법입니다. if/else를 한 줄로 쓸 수 있습니다. isError가 true이면 빨간색('#e74c3c'), false이면 초록색('#27ae60')이 되는 것이죠. if(isError)이면 빨간색, 아니면 초록색과 같은 뜻입니다.
삼항 연산자를 처음 보면 읽기 어렵게 느껴질 수 있습니다. 무리해서 쓸 필요는 없고, 조건이 간단할 때 한 줄로 깔끔하게 쓸 수 있는 방법 정도로 알아두면 됩니다.
setTimeout()
setTimeout(function() { statusDiv.textContent = ''; }, 3000);
setTimeout()은 지정한 시간이 지난 뒤에 함수를 실행하는 브라우저 내장 함수입니다. 첫 번째 인자는 실행할 함수, 두 번째 인자는 대기 시간(밀리초)입니다. 3000은 3000밀리초, 즉 3초입니다. 3초 뒤에 statusDiv.textContent = ''가 실행되어 메시지가 사라집니다.
setTimeout()도 fetch()처럼 비동기로 동작합니다. setTimeout()을 호출한 뒤 바로 다음 코드로 넘어가고, 3초가 지나면 등록한 함수가 실행됩니다. JavaScript가 3초 동안 멈추는 게 아닙니다.
addMemo()와 deleteMemo()에 상태 메시지 추가
이제 앞에서 작성한 addMemo()와 deleteMemo()에서 console.log() 대신 showStatus()를 호출하도록 수정합니다. 두 번째 .then()의 성공 처리 부분과 .catch()의 에러 처리 부분을 바꾸면 됩니다.
addMemo()에서는 memoInput.value = '' 아래에 showStatus('메모가 추가되었습니다.', false)를, .catch() 안에는 showStatus('메모 추가에 실패했습니다.', true)를 넣습니다. deleteMemo()도 같은 패턴입니다. loadMemos() 위에 showStatus('메모가 삭제되었습니다.', false)를, .catch() 안에 showStatus('메모 삭제에 실패했습니다.', true)를 넣습니다.
수정 후 addMemo() 함수는 다음과 같습니다.
function addMemo() { const text = memoInput.value.trim(); if (text === '') return; fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(newMemo) { memoInput.value = ''; showStatus('메모가 추가되었습니다.', false); loadMemos(); }) .catch(function(error) { showStatus('메모 추가에 실패했습니다.', true); }); }
deleteMemo()도 동일한 패턴으로 수정합니다.
function deleteMemo(id) { fetch('/api/memos/' + id, { method: 'DELETE' }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(data) { showStatus('메모가 삭제되었습니다.', false); loadMemos(); }) .catch(function(error) { showStatus('메모 삭제에 실패했습니다.', true); }); }
바뀐 부분은 console.log() 대신 showStatus()를 호출한 것뿐입니다. response.ok 확인과 throw new Error()는 이미 앞에서 작성한 코드 그대로입니다.
response.ok로 에러 처리하기
Part 3에서 예고했던 내용입니다. fetch()는 서버가 400(Bad Request)이나 404(Not Found) 같은 에러 상태 코드를 응답해도 .catch()로 빠지지 않습니다. 네트워크 통신 자체는 성공했기 때문이죠. "서버에 요청을 보내서 응답을 받았다"는 사실 자체가 성공이고, 서버가 에러 상태를 응답했는지는 개발자가 직접 확인해야 합니다.
.then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); })
response.ok는 HTTP 상태 코드가 200~299 범위일 때 true, 그 외에는 false인 속성입니다. 201(Created)도 true고, 400(Bad Request)이나 404(Not Found)는 false입니다.
response.ok가 false이면 throw new Error()로 에러를 발생시킵니다. throw는 에러를 의도적으로 발생시켜 "던지는" 키워드입니다. 프로미스 체이닝에서 throw를 하면, 그 이후의 .then()을 건너뛰고 .catch()로 직접 이동합니다. 이렇게 해야 에러 상태 코드가 왔을 때도 .catch()에서 에러 메시지를 표시할 수 있습니다.
이 패턴을 정리하면 이렇습니다.
fetch() 호출 ↓ 서버 응답 수신 ↓ response.ok 확인 ├── true → response.json() → 두 번째 .then()에서 성공 처리 └── false → throw Error → .catch()에서 에러 처리 네트워크 에러 (서버 꺼짐 등) → .catch()에서 에러 처리
fetch()의 에러 처리는 처음엔 헷갈리지만, 한번 이 패턴을 익히면 모든 API 호출에 똑같이 적용할 수 있습니다. "response.ok 확인 -> 실패 시 throw -> catch에서 처리"가 공식입니다.
이 패턴은 loadMemos()에도 동일하게 적용합니다. 앞에서 작성한 loadMemos()를 보면 이미 response.ok 확인 코드가 들어가 있습니다. .catch() 안에서 showStatus('메모를 불러올 수 없습니다.', true)를 호출하도록 수정하면, 서버가 꺼져 있거나 에러를 응답할 때 사용자에게 메시지를 보여줄 수 있습니다.
function loadMemos() { fetch('/api/memos') .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(memos) { displayMemos(memos); }) .catch(function(error) { showStatus('메모를 불러올 수 없습니다.', true); }); }
addMemo(), deleteMemo(), loadMemos() 세 함수 모두 같은 에러 처리 패턴을 사용합니다. 첫 번째 .then()에서 response.ok를 확인하고, 실패하면 throw로 .catch()로 보내는 구조입니다.
지금까지의 코드 전체
파일이 많이 수정되었으니, 현재 상태를 한 번에 정리하겠습니다.
server.js
// server.js const express = require('express'); const path = require('path'); const app = express(); // 메모 데이터 (서버 메모리에 저장) const memos = [ { id: 1, text: '장보기: 우유, 계란, 식빵', createdAt: '2026-03-04 09:00' }, { id: 2, text: 'Express 공부하기', createdAt: '2026-03-04 09:30' }, { id: 3, text: '점심 약속 장소 확인', createdAt: '2026-03-04 10:15' } ]; let nextId = 4; // 날짜 포맷 함수 function formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return year + '-' + month + '-' + day + ' ' + hours + ':' + minutes; } // JSON 요청 본문 파싱 미들웨어 app.use(express.json()); // 정적 파일 서빙 app.use(express.static(path.join(__dirname, 'public'))); // GET /api/memos - 메모 목록 조회 app.get('/api/memos', function(req, res) { res.json(memos); }); // POST /api/memos - 메모 추가 app.post('/api/memos', function(req, res) { const text = req.body && req.body.text; if (!text || text.trim() === '') { res.status(400).json({ error: '메모 내용을 입력해주세요' }); return; } const memo = { id: nextId, text: text.trim(), createdAt: formatDate(new Date()) }; nextId = nextId + 1; memos.push(memo); res.status(201).json(memo); }); // DELETE /api/memos/:id - 메모 삭제 app.delete('/api/memos/:id', function(req, res) { const id = parseInt(req.params.id, 10); const index = memos.findIndex(function(memo) { return memo.id === id; }); if (index === -1) { res.status(404).json({ error: '해당 메모를 찾을 수 없습니다' }); return; } memos.splice(index, 1); res.json({ message: '삭제되었습니다' }); }); app.listen(3000, function() { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
public/index.html
Part 3과 동일합니다. 변경사항 없습니다.
<!-- public/index.html --> <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>메모장</title> <link rel="stylesheet" href="style.css"> </head> <body> <header> <h1>메모장</h1> </header> <main> <div class="input-area"> <input type="text" id="memoInput" placeholder="메모를 입력하세요"> <button id="addBtn">추가</button> </div> <div id="status"></div> <ul id="memoList"> <!-- 메모 항목이 여기에 표시됩니다 --> </ul> </main> <script src="app.js"></script> </body> </html>
public/style.css
Part 3과 동일합니다. 삭제 버튼 스타일은 Part 2에서 이미 만들어뒀습니다.
/* public/style.css */ * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, '맑은 고딕', sans-serif; background-color: #f5f5f5; color: #333; line-height: 1.6; } header { background-color: #4a90d9; color: white; padding: 16px; text-align: center; } header h1 { font-size: 24px; font-weight: 600; } main { max-width: 600px; margin: 30px auto; padding: 0 16px; } .input-area { display: flex; gap: 8px; margin-bottom: 20px; } .input-area input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; } .input-area input:focus { outline: none; border-color: #4a90d9; } .input-area button { padding: 12px 24px; background-color: #4a90d9; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; } .input-area button:hover { background-color: #357abd; } #status { text-align: center; padding: 8px; margin-bottom: 12px; font-size: 14px; color: #666; } #memoList { list-style: none; } #memoList li { background-color: white; padding: 16px; margin-bottom: 8px; border-radius: 6px; border: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; } #memoList li .memo-text { flex: 1; } #memoList li .memo-date { font-size: 12px; color: #999; margin-left: 12px; white-space: nowrap; } #memoList li button { padding: 6px 12px; background-color: #e74c3c; color: white; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; margin-left: 12px; } #memoList li button:hover { background-color: #c0392b; }
public/app.js
// public/app.js const memoList = document.getElementById('memoList'); const memoInput = document.getElementById('memoInput'); const addBtn = document.getElementById('addBtn'); const statusDiv = document.getElementById('status'); function showStatus(message, isError) { statusDiv.textContent = message; statusDiv.style.color = isError ? '#e74c3c' : '#27ae60'; setTimeout(function() { statusDiv.textContent = ''; }, 3000); } function displayMemos(memos) { memoList.innerHTML = ''; for (let i = 0; i < memos.length; i++) { const memo = memos[i]; const li = document.createElement('li'); const textSpan = document.createElement('span'); textSpan.className = 'memo-text'; textSpan.textContent = memo.text; const dateSpan = document.createElement('span'); dateSpan.className = 'memo-date'; dateSpan.textContent = memo.createdAt; const deleteBtn = document.createElement('button'); deleteBtn.textContent = '삭제'; deleteBtn.addEventListener('click', function() { deleteMemo(memo.id); }); li.appendChild(textSpan); li.appendChild(dateSpan); li.appendChild(deleteBtn); memoList.appendChild(li); } } function loadMemos() { fetch('/api/memos') .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(memos) { displayMemos(memos); }) .catch(function(error) { showStatus('메모를 불러올 수 없습니다.', true); }); } function deleteMemo(id) { fetch('/api/memos/' + id, { method: 'DELETE' }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(data) { showStatus('메모가 삭제되었습니다.', false); loadMemos(); }) .catch(function(error) { showStatus('메모 삭제에 실패했습니다.', true); }); } function addMemo() { const text = memoInput.value.trim(); if (text === '') return; fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(response) { if (!response.ok) { throw new Error('서버 에러: ' + response.status); } return response.json(); }) .then(function(newMemo) { memoInput.value = ''; showStatus('메모가 추가되었습니다.', false); loadMemos(); }) .catch(function(error) { showStatus('메모 추가에 실패했습니다.', true); }); } addBtn.addEventListener('click', addMemo); memoInput.addEventListener('keydown', function(event) { if (event.key === 'Enter') { addMemo(); } }); loadMemos();
프로젝트 폴더 구조
memo-app/ node_modules/ public/ index.html style.css app.js server.js package.json package-lock.json
Part 3과 폴더 구조가 동일합니다. 파일 내용만 바뀌었습니다. server.js에 POST, DELETE API가 추가되었고, app.js에 추가/삭제/상태 표시 기능이 들어갔습니다.
직접 해보기
여기까지 따라왔다면 세 가지를 시도해보세요.
1. 메모 수정(PUT) API 만들기
지금 메모를 추가하고 삭제하는 기능이 있습니다. 수정 기능도 만들어보세요. 서버 쪽 뼈대 코드를 드리겠습니다.
// PUT /api/memos/:id - 메모 수정 app.put('/api/memos/:id', function(req, res) { const id = parseInt(req.params.id, 10); const text = req.body && req.body.text; // 여기에 코드를 채워보세요 // 1. text 유효성 검사 (빈 문자열 체크) // 2. findIndex()로 해당 id의 메모 찾기 // 3. 못 찾으면 404 응답 // 4. 찾으면 text를 수정하고 200 응답 });
app.put()은 app.post()나 app.delete()와 사용법이 같습니다. PUT은 "기존 데이터를 수정해줘"라는 HTTP 메서드입니다. DELETE API에서 findIndex()로 메모를 찾고, POST API에서 유효성 검사를 했던 것을 조합하면 됩니다.
2. 삭제 전 확인 대화상자 추가하기
지금은 삭제 버튼을 누르면 바로 삭제됩니다. 실수로 누를 수도 있으니, confirm() 함수로 확인 대화상자를 띄워보세요.
deleteBtn.addEventListener('click', function() { if (confirm('정말 삭제하시겠습니까?')) { deleteMemo(memo.id); } });
confirm()은 브라우저가 제공하는 내장 함수로, "확인/취소" 대화상자를 띄웁니다. 확인을 누르면 true, 취소를 누르면 false를 반환합니다.
3. data-id 속성으로 삭제 버튼 만들기
지금은 클로저를 이용해서 삭제 버튼이 memo.id를 기억하게 했습니다. 다른 방법도 있습니다. HTML의 data- 속성을 활용하는 방법입니다.
const deleteBtn = document.createElement('button'); deleteBtn.textContent = '삭제'; deleteBtn.setAttribute('data-id', memo.id);
data-로 시작하는 속성은 HTML 표준에서 허용하는 커스텀 데이터 속성입니다. data-id="3"이면 이 버튼이 3번 메모와 연결되어 있다는 정보를 HTML 요소에 직접 저장하는 것입니다. 이벤트 리스너에서 getAttribute('data-id')로 값을 꺼내 쓸 수 있습니다. 이 방식으로 displayMemos() 함수를 수정해보세요. 개발자도구 Elements 탭에서 각 삭제 버튼의 data-id 값을 확인할 수 있어서 디버깅에도 유용합니다.
정리
이번 편에서는 POST API로 메모를 추가하고, DELETE API로 메모를 삭제하는 기능을 만들었습니다. 메모장 앱이 비로소 제대로 된 기능을 갖추게 되었죠. 입력 필드에 메모를 적고 추가 버튼을 누르면 서버에 저장되고, 삭제 버튼을 누르면 서버에서 지워집니다.
서버 쪽에서는 express.json() 미들웨어로 JSON 요청 본문을 파싱하고, app.post()로 POST 요청을, app.delete()로 DELETE 요청을 처리했습니다. URL 파라미터(:id)로 특정 메모를 지정하는 것, HTTP 상태 코드(201, 400, 404)로 결과를 구분하는 것도 다뤘고요.
브라우저 쪽에서는 fetch()에 method, headers, body 옵션을 줘서 POST 요청을 보내고, URL에 ID를 포함해서 DELETE 요청을 보내는 법을 익혔습니다. addEventListener()로 클릭과 키보드 이벤트를 처리하고, response.ok로 HTTP 에러를 감지하는 패턴도 다뤘습니다.
이번 편에서 반복적으로 등장한 패턴이 있습니다. "API 만들기 → 콘솔에서 테스트 → 프론트엔드 연결"이라는 흐름입니다. GET, POST, DELETE를 만들 때마다 같은 순서를 밟았죠. 이 습관을 들이면 문제가 생겼을 때 원인이 서버인지 브라우저인지 바로 구분할 수 있습니다. 앞으로 다른 API를 만들 때도 이 순서를 따르면 됩니다.
앞에서 언급했듯이, 지금 만든 메모장은 서버 메모리에 데이터를 저장하고 있어서 서버를 재시작하면 데이터가 사라집니다. 실제 서비스에서는 데이터베이스를 연결해서 데이터를 영구 저장합니다. 하지만 그건 별도의 주제이고, 이 시리즈에서 만든 메모장 앱은 서버와 브라우저가 데이터를 주고받는 구조를 이해하기 위한 것입니다. 이 구조만 잡히면, 나중에 데이터베이스를 연결하는 건 "저장소를 바꾸는 것"일 뿐입니다.
다음 편(Part 5)은 이 시리즈의 마지막입니다. Part 1부터 4까지 조각조각 만든 메모장을 처음부터 끝까지 한 번에 다시 만들어봅니다. 각 편에서 배운 개념이 전체 구조에서 어떻게 맞물리는지 확인하는 시간이죠. 조각을 하나씩 이해한 상태에서 만드니까, 처음부터 만들 때보다 훨씬 수월할 겁니다.
참고 자료
- Express 공식 문서 - express.json() 미들웨어: https://expressjs.com/en/5x/api.html#express.json
- Express 공식 문서 - 미들웨어 사용하기: https://expressjs.com/en/guide/using-middleware.html
- MDN - Fetch API POST 요청: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
- MDN - EventTarget.addEventListener(): https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
- MDN - Array.prototype.findIndex(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex
- MDN - Array.prototype.splice(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice
- MDN - JSON.stringify(): https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
- web.dev - Fetch API 에러 처리: https://web.dev/articles/fetch-api-error-handling
- MDN - HTML data 속성: https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/Use_data_attributes
- MDN - Window.setTimeout(): https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout






댓글
댓글을 작성하려면 이 필요합니다.