
JavaScript 다음 스텝: Node.js와 Express로 나만의 메모장 API 만들기 (5/5)

JavaScript 기본 문법을 배웠습니다. 변수, 함수, 객체, 배열을 다룰 수 있게 됐고요. 그런데 막상 "이걸로 뭘 만들지?" 하는 순간이 옵니다. for문 연습 문제를 더 풀어야 하나, 프레임워크를 배워야 하나 고민되는 시점이죠.
주변 주니어 개발자들에게 물어보면, 이 시기가 가장 답답하다고 합니다. 문법은 아는데 그걸로 "진짜 프로그램"을 만드는 방법을 모르겠다는 거죠. 프론트엔드만으로는 한계가 있습니다. 데이터를 저장하고, 다른 사람과 공유하고, 서버에서 처리하는 기능이 필요하거든요. 여기서 Node.js가 빛을 발합니다. 브라우저에서만 돌아가던 JavaScript를 서버에서도 실행할 수 있게 해주는 런타임 환경입니다.
이 글에서는 Node.js와 Express를 사용해 간단한 "메모장" 앱을 처음부터 끝까지 만들어봅니다. 서버에서 API를 제공하고, 같은 서버에서 HTML과 CSS도 서빙하고, 브라우저의 JavaScript로 API를 호출해서 화면에 결과를 표시합니다. 하나의 완결된 예제입니다.
데이터베이스 없이 서버 메모리에 데이터를 저장하기 때문에 서버를 끄면 데이터가 사라집니다. 하지만 웹 개발의 핵심 흐름인 "프론트엔드와 백엔드의 통신"을 이해하기에는 충분합니다.
이 글을 읽기 전에 알아야 할 것
JavaScript의 변수, 함수, 객체, 배열 정도를 알고 있으면 됩니다. 다음 코드가 대략 읽힌다면 준비된 겁니다.
const user = { name: '김개발', age: 25 }; const numbers = [1, 2, 3]; function greet(name) { return '안녕하세요, ' + name + '님!'; }
Node.js는 아직 몰라도 괜찮습니다. 지금부터 하나씩 설명합니다.
Node.js 설치하기
Node.js 공식 사이트(https://nodejs.org)에 가면 두 가지 버전이 있습니다. LTS(Long Term Support)와 Current인데, LTS를 다운로드하세요. 안정적이고 장기 지원을 받는 버전이거든요.
설치 후 터미널(맥은 터미널, 윈도우는 명령 프롬프트나 PowerShell)을 열고 확인합니다.
node --version npm --version
버전 번호가 출력되면 성공입니다. node는 JavaScript를 서버에서 실행하는 프로그램이고, npm은 다른 사람이 만든 패키지(라이브러리)를 설치하는 도구입니다. Node.js를 설치하면 npm도 자동으로 따라오고요.
프로젝트 만들기
먼저 작업할 폴더를 만들고 그 안에서 프로젝트를 초기화합니다.
mkdir memo-app cd memo-app npm init -y
npm init -y는 package.json 파일을 자동으로 만들어줍니다. -y 옵션은 모든 질문에 "yes"로 답하겠다는 뜻이고요. package.json은 이 프로젝트의 설정 파일로, 프로젝트 이름, 버전, 사용하는 패키지 목록 등이 들어갑니다.
다음으로 Express를 설치합니다.
npm install express
Express는 Node.js에서 웹 서버를 쉽게 만들 수 있게 해주는 프레임워크입니다. Node.js만으로도 웹 서버를 만들 수 있지만, Express를 쓰면 코드가 훨씬 간결해지거든요. 이 프로젝트에서 설치하는 외부 패키지는 Express 하나뿐입니다.
설치가 끝나면 node_modules 폴더와 package-lock.json 파일이 생깁니다. node_modules에는 Express와 Express가 의존하는 다른 패키지들이 들어 있는데, 직접 건드릴 일은 없습니다.
첫 번째 서버 띄우기
이제 서버를 만들어봅니다. 프로젝트 폴더에 server.js 파일을 만듭니다.
// server.js const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('안녕하세요! 서버가 동작 중입니다.'); }); app.listen(3000, () => { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
한 줄씩 살펴보겠습니다.
const express = require('express') - 설치한 Express 패키지를 가져옵니다. require는 Node.js에서 다른 파일이나 패키지를 불러오는 함수입니다.
const app = express() - Express 애플리케이션을 만듭니다. 이 app 객체에 "이 주소로 요청이 오면 이렇게 응답해라"는 규칙을 등록합니다.
app.get('/', ...) - 브라우저가 주소 /로 GET 요청을 보낼 때 실행할 함수를 등록합니다. 콜백 함수의 req는 요청(request) 정보, res는 응답(response) 객체입니다. res.send()로 브라우저에 텍스트를 보냅니다.
app.listen(3000, ...) - 3000번 포트에서 요청을 기다립니다. 포트는 컴퓨터의 "문 번호"라고 생각하면 됩니다. 하나의 컴퓨터에서 여러 서버를 동시에 실행할 수 있는데, 포트 번호로 구분합니다.
터미널에서 실행합니다.
node server.js
"서버가 http://localhost:3000 에서 실행 중입니다"라는 메시지가 나오면 성공입니다. 브라우저를 열고 http://localhost:3000에 접속하면 "안녕하세요! 서버가 동작 중입니다."라는 텍스트가 보입니다.
localhost는 "내 컴퓨터"를 가리키는 특별한 주소입니다. 지금은 내 컴퓨터에서 서버를 실행하고, 내 컴퓨터의 브라우저에서 접속하는 겁니다. 서버를 멈추려면 터미널에서 Ctrl + C를 누르면 되고요.
그림은 이 프로젝트에서 만들 메모장 앱의 전체 구조입니다. 왼쪽이 서버(Node.js + Express), 오른쪽이 브라우저(클라이언트)이며, 서버는 API 엔드포인트와 정적 파일 서빙을 담당하고, 브라우저는 HTML/CSS로 화면을 그리고 JavaScript의 fetch()로 API를 호출합니다.

서버의 server.js가 API 엔드포인트(GET, POST, DELETE)를 제공하고, express.static()이 public 폴더의 HTML, CSS, JS 파일을 브라우저에 전달합니다. 브라우저의 app.js는 fetch()로 서버 API를 호출하고, 받은 JSON 데이터로 DOM을 업데이트합니다. 메모 데이터는 서버의 배열(memos[])에 저장됩니다. 지금부터 이 구조를 하나씩 만들어보겠습니다.
HTML 파일 서빙하기
텍스트만 보내는 건 재미없죠. HTML 파일을 서빙해봅니다.
프로젝트 폴더에 public 폴더를 만들고, 그 안에 index.html 파일을 만듭니다.
memo-app/ server.js package.json public/ index.html
먼저 간단한 HTML을 작성합니다.
<!-- 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> </head> <body> <h1>메모장</h1> <p>여기에 메모장 앱을 만들 것입니다.</p> </body> </html>
그리고 server.js를 수정합니다.
// server.js const express = require('express'); const path = require('path'); const app = express(); app.use(express.static(path.join(__dirname, 'public'))); app.listen(3000, () => { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
path는 Node.js에 기본 포함된 모듈로, 파일 경로를 다루는 기능을 제공합니다. path.join(__dirname, 'public')은 현재 파일이 있는 폴더에서 public 폴더의 절대 경로를 만들어줍니다. __dirname은 현재 실행 중인 파일의 폴더 경로를 담고 있는 Node.js의 특별한 변수입니다.
app.use(express.static(...))은 Express에게 "이 폴더의 파일들을 그대로 서빙해라"라고 알려주는 겁니다. public 폴더에 index.html이 있으면 http://localhost:3000/에 접속했을 때 자동으로 이 파일이 보이고요. public/style.css 파일이 있으면 http://localhost:3000/style.css로 접근할 수 있습니다.
이전에 만들었던 app.get('/', ...) 코드는 삭제했습니다. 정적 파일 서빙이 / 경로 요청까지 자동으로 처리하기 때문입니다.
서버를 다시 실행합니다. 서버 코드를 수정했으면 반드시 서버를 껐다가 다시 켜야 합니다.
# Ctrl + C로 기존 서버 중지 후 node server.js
브라우저에서 http://localhost:3000에 접속하면 HTML 페이지가 보입니다.
API란 무엇인가
본격적인 메모장 기능을 만들기 전에, API가 무엇인지 짚고 넘어가겠습니다.
API(Application Programming Interface)는 프로그램과 프로그램 사이의 약속된 통신 방법입니다. 웹 개발에서 말하는 API는 보통 "서버에 이런 주소로 이런 방식으로 요청하면, 이런 형태의 데이터를 돌려준다"는 규칙이죠.
예를 들어 이런 약속입니다.
GET /api/memos- 저장된 메모 목록을 달라POST /api/memos- 새 메모를 추가해줘DELETE /api/memos/3- 3번 메모를 삭제해줘
GET, POST, DELETE는 HTTP 메서드(method)입니다. 같은 주소라도 메서드에 따라 다른 동작을 하죠. 브라우저 주소창에 URL을 입력하면 GET 요청이 보내집니다. 나머지는 JavaScript 코드로 보내야 합니다.
데이터는 JSON(JavaScript Object Notation) 형식으로 주고받습니다. JavaScript 객체와 거의 같은 모양이라 다루기 편하거든요.
{ "id": 1, "text": "첫 번째 메모입니다", "createdAt": "2026-03-04 10:00" }
이제 이 약속에 따라 메모장 API를 만들어봅니다.
메모장 API 만들기
server.js를 수정합니다. 이번에는 메모 데이터를 저장할 배열과 API 엔드포인트를 추가합니다. 엔드포인트(endpoint)는 "API 주소"라고 이해하면 됩니다.
// server.js const express = require('express'); const path = require('path'); const app = express(); // JSON 요청 본문을 파싱하는 미들웨어 app.use(express.json()); // 정적 파일 서빙 app.use(express.static(path.join(__dirname, 'public'))); // ========== 날짜 포매팅 ========== function formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); const h = String(date.getHours()).padStart(2, '0'); const min = String(date.getMinutes()).padStart(2, '0'); return y + '-' + m + '-' + d + ' ' + h + ':' + min; } // ========== 메모 데이터 ========== let memos = [ { id: 1, text: '장보기: 우유, 계란, 식빵', createdAt: '2026-03-04 09:00' }, { id: 2, text: 'Express 공부하기', createdAt: '2026-03-04 09:30' } ]; let nextId = 3; // ========== API 엔드포인트 ========== // 메모 목록 조회 app.get('/api/memos', (req, res) => { res.json(memos); }); // 메모 추가 app.post('/api/memos', (req, res) => { if (!req.body) { res.status(400).json({ error: '요청 형식이 올바르지 않습니다. Content-Type: application/json 헤더를 확인해주세요' }); return; } const text = 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.delete('/api/memos/:id', (req, res) => { const id = Number(req.params.id); 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, () => { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
새로 추가된 코드를 하나씩 설명하겠습니다.
formatDate() 함수는 Date 객체를 "2026-03-04 09:00" 형식의 문자열로 변환합니다. getMonth()는 0부터 시작하기 때문에(1월이 0, 12월이 11) 1을 더해야 하고요. padStart(2, '0')은 한 자리 숫자 앞에 0을 붙여서 두 자리로 만들어줍니다. 예를 들어 3월은 "03", 9시는 "09"가 됩니다. 이렇게 직접 포매팅하면 초기 데이터와 새로 추가되는 메모의 날짜 형식이 항상 일치합니다.
app.use(express.json()) - 이 한 줄이 없으면 POST 요청으로 보낸 JSON 데이터를 서버에서 읽을 수 없습니다. Express에게 "요청 본문이 JSON이면 자동으로 파싱해서 req.body에 넣어줘"라고 알려주는 겁니다. 미들웨어(middleware)라고 하는데, 요청이 API 핸들러에 도착하기 전에 먼저 실행되는 함수라고 보면 됩니다.
let memos = [...] - 메모 데이터를 배열에 저장합니다. 데이터베이스 대신 메모리를 사용합니다. 서버를 껐다 켜면 초기 상태로 돌아가죠. 학습용으로는 충분합니다.
let nextId = 3 - 새 메모를 추가할 때 부여할 ID입니다. 이미 ID 1, 2가 있으니 3부터 시작합니다.
GET /api/memos - 메모 목록 조회
app.get('/api/memos', (req, res) => { res.json(memos); });
가장 간단한 API입니다. res.json()은 JavaScript 배열이나 객체를 JSON 문자열로 변환해서 브라우저에 보내주거든요. 브라우저에서 http://localhost:3000/api/memos에 접속하면 메모 배열이 JSON으로 표시됩니다.
POST /api/memos - 메모 추가
app.post('/api/memos', (req, res) => { if (!req.body) { res.status(400).json({ error: '요청 형식이 올바르지 않습니다. Content-Type: application/json 헤더를 확인해주세요' }); return; } const text = 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.post()는 POST 요청을 처리합니다. 먼저 req.body가 존재하는지 확인합니다. Content-Type: application/json 헤더 없이 요청을 보내면 express.json() 미들웨어가 본문을 파싱하지 않아서 req.body가 undefined가 됩니다. 이 상태에서 req.body.text에 접근하면 TypeError가 발생하고, 서버가 500 에러를 내며 내부 스택 트레이스까지 클라이언트에 노출됩니다. 이런 상황을 방지하기 위해 req.body를 먼저 체크하는 겁니다. 방어적 프로그래밍의 기초죠. 그 다음 req.body.text로 요청 본문에서 text 값을 꺼냅니다. 빈 문자열이면 400(Bad Request) 에러를 응답합니다.
res.status(201)의 201은 "Created"라는 뜻의 HTTP 상태 코드입니다. 새로운 데이터가 성공적으로 생성됐다는 뜻입니다. 자주 쓰이는 상태 코드를 정리하면, 200은 "OK", 201은 "Created", 400은 "Bad Request(잘못된 요청)", 404는 "Not Found(찾을 수 없음)", 500은 "Internal Server Error(서버 오류)"입니다.
text.trim()은 문자열 앞뒤의 공백을 제거합니다. 사용자가 " 메모 "라고 입력하면 "메모"로 저장됩니다.
DELETE /api/memos/:id - 메모 삭제
app.delete('/api/memos/:id', (req, res) => { const id = Number(req.params.id); 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: '삭제되었습니다' }); });
:id는 URL 파라미터입니다. /api/memos/3으로 요청하면 req.params.id에 "3"이 들어오는데, 문자열이기 때문에 Number()로 숫자로 변환합니다.
findIndex()는 배열에서 조건에 맞는 요소의 위치(인덱스)를 찾고, 못 찾으면 -1을 반환합니다. splice(index, 1)은 배열에서 해당 위치의 요소 1개를 제거하는데, 원본 배열을 직접 수정합니다.
API 테스트해보기
서버를 다시 시작하고, 브라우저에서 http://localhost:3000/api/memos에 접속해봅니다. 메모 목록이 JSON으로 표시될 겁니다.
POST와 DELETE는 브라우저 주소창으로 테스트할 수 없습니다. 주소창은 항상 GET 요청만 보내거든요. 나중에 프론트엔드 JavaScript로 테스트하겠지만, 지금 바로 확인하고 싶다면 브라우저의 개발자 도구(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); });
콘솔에 새로 추가된 메모 객체가 출력되면 API가 정상 동작하는 겁니다. http://localhost:3000/api/memos를 새로고침하면 메모가 3개로 늘어나 있을 겁니다.
프론트엔드 만들기: HTML과 CSS
이제 메모장의 화면을 만듭니다. public 폴더에 세 개의 파일을 준비합니다.
memo-app/ server.js package.json public/ index.html style.css app.js
index.html
<!-- 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> <div class="container"> <h1>메모장</h1> <div class="input-area"> <input type="text" id="memoInput" placeholder="메모를 입력하세요"> <button id="addBtn">추가</button> </div> <div class="status" id="status"></div> <ul id="memoList"></ul> </div> <script src="app.js"></script> </body> </html>
HTML 구조는 간단합니다. 메모를 입력할 input과 button, 상태 메시지를 보여줄 div, 메모 목록을 표시할 ul이 전부거든요.
<script src="app.js"></script>를 body 태그가 닫히기 직전에 넣었습니다. HTML이 모두 로드된 후에 JavaScript가 실행되도록 하기 위해서입니다. head에 넣으면 HTML 요소가 아직 만들어지기 전에 JavaScript가 실행되어서, document.getElementById()로 요소를 찾을 수 없는 문제가 생기거든요. 초보 시절에 이것 때문에 한참 헤맨 사람이 꽤 됩니다.
id 속성은 JavaScript에서 해당 요소를 찾기 위한 이름표입니다. 나중에 document.getElementById('memoInput')으로 이 입력 필드를 가져올 수 있습니다.
style.css
/* public/style.css */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, '맑은 고딕', sans-serif; background-color: #f5f5f5; color: #333; } .container { max-width: 600px; margin: 40px auto; padding: 20px; } h1 { text-align: center; margin-bottom: 20px; color: #2c3e50; } .input-area { display: flex; gap: 8px; margin-bottom: 16px; } .input-area input { flex: 1; padding: 10px 14px; border: 2px solid #ddd; border-radius: 6px; font-size: 15px; } .input-area input:focus { outline: none; border-color: #3498db; } .input-area button { padding: 10px 20px; background-color: #3498db; color: white; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; } .input-area button:hover { background-color: #2980b9; } .status { text-align: center; margin-bottom: 12px; font-size: 14px; color: #7f8c8d; min-height: 20px; } ul { list-style: none; } li { background-color: white; padding: 14px 16px; margin-bottom: 8px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } li .memo-text { flex: 1; } li .memo-date { font-size: 12px; color: #95a5a6; margin: 0 12px; white-space: nowrap; } li .delete-btn { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 14px; padding: 4px 8px; } li .delete-btn:hover { background-color: #ffeaea; border-radius: 4px; }
CSS의 핵심만 짚어보겠습니다. .input-area에 display: flex를 주고 input에 flex: 1을 주면, 입력 필드가 버튼을 제외한 나머지 공간을 꽉 채웁니다. li에도 display: flex와 justify-content: space-between을 줘서 메모 텍스트, 날짜, 삭제 버튼이 가로로 배치되게 했고요. flex는 요소를 가로로 나란히 배치할 때 쓰기 좋습니다.
JavaScript로 API 호출하고 화면 그리기
이 글의 핵심입니다. public/app.js 파일에서 서버 API를 호출하고, 받은 데이터로 화면을 업데이트합니다.
전체 코드를 먼저 보여드리고, 그 아래에서 기능별로 쪼개서 설명하겠습니다.
// public/app.js // ========== DOM 요소 가져오기 ========== const memoInput = document.getElementById('memoInput'); const addBtn = document.getElementById('addBtn'); const memoList = document.getElementById('memoList'); const statusDiv = document.getElementById('status'); // ========== 메모 목록 불러오기 ========== function loadMemos() { fetch('/api/memos') .then(function(response) { return response.json(); }) .then(function(memos) { displayMemos(memos); }) .catch(function(error) { showStatus('메모를 불러오는데 실패했습니다', true); }); } // ========== 메모 목록 화면에 표시하기 ========== function displayMemos(memos) { memoList.innerHTML = ''; if (memos.length === 0) { const emptyLi = document.createElement('li'); emptyLi.style.justifyContent = 'center'; emptyLi.style.color = '#999'; emptyLi.textContent = '메모가 없습니다. 첫 메모를 작성해보세요!'; memoList.appendChild(emptyLi); return; } 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.className = 'delete-btn'; deleteBtn.textContent = 'X'; deleteBtn.setAttribute('data-id', memo.id); deleteBtn.addEventListener('click', function() { const id = this.getAttribute('data-id'); deleteMemo(id); }); li.appendChild(textSpan); li.appendChild(dateSpan); li.appendChild(deleteBtn); memoList.appendChild(li); } } // ========== 메모 추가하기 ========== function addMemo() { const text = memoInput.value.trim(); if (text === '') { showStatus('메모 내용을 입력해주세요', true); memoInput.focus(); return; } fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(response) { if (!response.ok) { return response.json().then(function(data) { throw new Error(data.error); }); } return response.json(); }) .then(function(newMemo) { memoInput.value = ''; showStatus('"' + newMemo.text + '" 메모가 추가되었습니다', false); loadMemos(); }) .catch(function(error) { showStatus(error.message, true); }); } // ========== 메모 삭제하기 ========== function deleteMemo(id) { fetch('/api/memos/' + id, { method: 'DELETE' }) .then(function(response) { if (!response.ok) { return response.json().then(function(data) { throw new Error(data.error); }); } return response.json(); }) .then(function(data) { showStatus(data.message, false); loadMemos(); }) .catch(function(error) { showStatus(error.message, true); }); } // ========== 상태 메시지 표시 ========== function showStatus(message, isError) { statusDiv.textContent = message; statusDiv.style.color = isError ? '#e74c3c' : '#27ae60'; setTimeout(function() { statusDiv.textContent = ''; }, 3000); } // ========== 이벤트 연결 ========== addBtn.addEventListener('click', addMemo); memoInput.addEventListener('keydown', function(event) { if (event.key === 'Enter') { addMemo(); } }); // ========== 페이지 로드 시 메모 불러오기 ========== loadMemos();
코드가 길어 보이지만, 각 함수가 하나의 역할만 합니다. 하나씩 뜯어보겠습니다.
DOM 요소 가져오기
const memoInput = document.getElementById('memoInput'); const addBtn = document.getElementById('addBtn'); const memoList = document.getElementById('memoList'); const statusDiv = document.getElementById('status');
document.getElementById()는 HTML에서 해당 id를 가진 요소를 찾아서 JavaScript 변수에 담습니다. 이렇게 한 번 찾아두면 이후에 계속 사용할 수 있습니다. HTML에서 id="memoInput"이라고 적어둔 요소를 JavaScript에서 memoInput 변수로 다루는 것입니다. const로 선언한 이유는 이 변수들이 가리키는 DOM 요소가 바뀔 일이 없기 때문입니다. 값이 바뀌지 않는 변수에는 const를, 바뀌어야 하는 변수에는 let을 씁니다. 서버 코드에서도 같은 규칙을 쓰고 있으니, 프론트엔드와 백엔드 코드의 스타일이 통일되죠.
fetch로 서버에 요청 보내기
function loadMemos() { fetch('/api/memos') .then(function(response) { return response.json(); }) .then(function(memos) { displayMemos(memos); }) .catch(function(error) { showStatus('메모를 불러오는데 실패했습니다', true); }); }
fetch()는 브라우저에 내장된 함수입니다. 서버에 HTTP 요청을 보내고 응답을 받아오는데, 별도 라이브러리를 설치할 필요가 없습니다. 예전에는 XMLHttpRequest라는 복잡한 방식을 썼지만, 지금은 fetch가 표준이죠. 흔히 AJAX(Asynchronous JavaScript and XML)라고 부르는 기술의 현대적 구현체입니다.
fetch('/api/memos')는 현재 서버의 /api/memos 주소에 GET 요청을 보냅니다. 메서드를 지정하지 않으면 기본값이 GET입니다.
fetch()가 반환하는 것은 프로미스(Promise)입니다. 프로미스는 "나중에 결과를 알려줄게"라는 약속 객체입니다. 서버 응답이 언제 올지 모르니까, 응답이 도착했을 때 .then() 안의 함수를 실행하는 거죠.
첫 번째 .then()에서 response.json()을 호출합니다. 서버가 보낸 JSON 문자열을 JavaScript 객체(배열)로 변환하는 과정인데, 이것도 프로미스를 반환하기 때문에 다시 .then()으로 연결합니다.
두 번째 .then()에서 변환된 메모 배열을 받아서 displayMemos() 함수에 넘깁니다.
.catch()는 네트워크 오류 등이 발생했을 때 실행됩니다.
프로미스 체이닝(.then().then().catch())이 처음에는 낯설 수 있습니다. "서버에 요청을 보낸다 -> 응답이 오면 JSON으로 변환한다 -> 변환이 끝나면 화면에 표시한다 -> 중간에 문제가 생기면 에러를 처리한다"라는 순서로 읽으면 됩니다. 각 단계가 끝나야 다음 단계로 넘어가는 구조거든요.
DOM 조작으로 화면 그리기
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.className = 'delete-btn'; deleteBtn.textContent = 'X'; deleteBtn.setAttribute('data-id', memo.id); deleteBtn.addEventListener('click', function() { const id = this.getAttribute('data-id'); deleteMemo(id); }); li.appendChild(textSpan); li.appendChild(dateSpan); li.appendChild(deleteBtn); memoList.appendChild(li); } }
이 함수가 이 글에서 가장 중요한 부분 중 하나입니다. 서버에서 받은 데이터를 HTML 요소로 만들어서 화면에 표시하는 과정, 즉 DOM(Document Object Model) 조작입니다.
DOM은 브라우저가 HTML을 읽고 나서 메모리에 만든 구조입니다. JavaScript는 이 구조를 읽고 수정할 수 있습니다. 요소를 추가하고, 삭제하고, 텍스트를 바꾸고, 스타일을 변경할 수 있습니다.
memoList.innerHTML = ''로 기존 목록을 비웁니다. 매번 전체를 다시 그리는 방식입니다. 성능은 좋지 않지만, 메모 몇 개를 다루는 수준에서는 전혀 문제없습니다. React 같은 프레임워크가 이 부분을 최적화해주지만, 기본 원리를 아는 게 먼저죠.
document.createElement('li')로 새로운 <li> 요소를 만듭니다. 이 시점에서는 아직 화면에 나타나지 않고, 메모리에만 존재하는 요소입니다.
textSpan.className = 'memo-text'로 CSS 클래스를 지정합니다. 이렇게 하면 style.css에서 .memo-text로 정의한 스타일이 적용됩니다.
textSpan.textContent = memo.text로 텍스트를 넣습니다. textContent를 사용하면 HTML 태그가 실행되지 않고 순수 텍스트로 들어갑니다. 만약 사용자가 <script>alert('해킹')</script> 같은 걸 입력해도 그냥 문자열로 표시됩니다. innerHTML을 사용하면 HTML이 실행될 수 있어서 보안상 위험합니다. 사용자 입력을 표시할 때는 항상 textContent를 사용하세요.
deleteBtn.setAttribute('data-id', memo.id)로 삭제 버튼에 메모 ID를 저장합니다. data-로 시작하는 속성은 HTML에서 커스텀 데이터를 저장할 때 사용하는 표준 방법입니다. 나중에 getAttribute('data-id')로 꺼낼 수 있습니다.
deleteBtn.addEventListener('click', function() { ... })로 클릭 이벤트를 등록합니다. 이 버튼이 클릭되면 this.getAttribute('data-id')로 저장해둔 메모 ID를 꺼내서 deleteMemo() 함수를 호출합니다. 여기서 this는 클릭된 버튼 자신을 가리킵니다.
마지막으로 li.appendChild(textSpan)으로 만든 요소들을 li 안에 넣고, memoList.appendChild(li)로 li를 목록(ul)에 추가합니다. appendChild를 호출하는 순간 비로소 화면에 나타나죠. 그 전까지는 JavaScript 메모리 안에서만 존재하는 "보이지 않는" 요소입니다.
POST 요청으로 메모 추가하기
function addMemo() { const text = memoInput.value.trim(); if (text === '') { showStatus('메모 내용을 입력해주세요', true); memoInput.focus(); return; } fetch('/api/memos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text }) }) .then(function(response) { if (!response.ok) { return response.json().then(function(data) { throw new Error(data.error); }); } return response.json(); }) .then(function(newMemo) { memoInput.value = ''; showStatus('"' + newMemo.text + '" 메모가 추가되었습니다', false); loadMemos(); }) .catch(function(error) { showStatus(error.message, true); }); }
memoInput.value로 입력 필드의 현재 값을 가져옵니다. .trim()으로 앞뒤 공백을 제거한 후, 빈 문자열이면 에러 메시지를 보여주고 함수를 종료합니다. memoInput.focus()는 커서를 입력 필드에 다시 놓는 것입니다.
fetch()의 두 번째 인자로 옵션 객체를 전달합니다. GET 요청 때는 이 인자가 필요 없었지만, POST 요청에서는 필수거든요.
method: 'POST'로 POST 요청임을 명시합니다.
headers에 'Content-Type': 'application/json'을 넣어서 "보내는 데이터가 JSON 형식이야"라고 서버에 알려줍니다. 이 헤더가 없으면 서버의 express.json() 미들웨어가 데이터를 파싱하지 못하거든요. 이 부분을 빼먹어서 "왜 req.body가 undefined지?"라고 고민하는 경우가 정말 많습니다.
body: JSON.stringify({ text: text })로 보낼 데이터를 JSON 문자열로 변환합니다. { text: text }는 JavaScript 객체인데, HTTP 요청의 본문에는 문자열만 넣을 수 있기 때문에 JSON.stringify()로 변환합니다. JSON.stringify({ text: '메모 내용' })을 실행하면 '{"text":"메모 내용"}'이라는 문자열이 만들어집니다.
response.ok는 HTTP 상태 코드가 200번대(성공)인지 알려주는 속성입니다. 여기서 fetch()를 처음 사용하는 개발자가 자주 헷갈리는 부분이 있습니다. fetch()는 네트워크 자체가 실패한 경우에만 프로미스를 reject합니다. 서버가 400이나 500 에러를 응답해도 fetch()는 정상적으로 resolve됩니다. 서버가 "요청이 잘못됐어"라고 응답한 것도 어쨌든 "응답을 받긴 했으니까" 성공으로 취급하는 거죠. 그래서 response.ok를 직접 확인해서 HTTP 에러를 감지해야 합니다. 이 코드에서는 response.ok가 false일 때 에러 메시지를 꺼내서 throw new Error()로 던집니다. 이렇게 하면 네트워크 오류와 HTTP 에러 응답이 모두 아래의 .catch()에서 처리됩니다.
성공하면 입력 필드를 비우고(memoInput.value = ''), 상태 메시지를 표시하고, loadMemos()를 다시 호출해서 목록을 갱신합니다.
DELETE 요청으로 메모 삭제하기
function deleteMemo(id) { fetch('/api/memos/' + id, { method: 'DELETE' }) .then(function(response) { if (!response.ok) { return response.json().then(function(data) { throw new Error(data.error); }); } return response.json(); }) .then(function(data) { showStatus(data.message, false); loadMemos(); }) .catch(function(error) { showStatus(error.message, true); }); }
DELETE 요청은 POST보다 간단합니다. 보낼 데이터(body)가 없거든요. URL에 삭제할 메모의 ID를 포함시키는 것으로 충분합니다. /api/memos/3이면 3번 메모를 삭제하라는 뜻입니다.
패턴은 POST와 동일합니다. 응답이 성공이 아니면 에러를 던지고, 성공이면 상태 메시지를 표시하고 목록을 다시 불러옵니다.
이벤트 연결하기
addBtn.addEventListener('click', addMemo); memoInput.addEventListener('keydown', function(event) { if (event.key === 'Enter') { addMemo(); } }); loadMemos();
addEventListener로 이벤트를 등록합니다. "추가" 버튼을 클릭하면 addMemo 함수를 실행합니다. 입력 필드에서 Enter 키를 누르면 역시 addMemo를 실행합니다. event.key는 눌린 키의 이름을 문자열로 알려줍니다.
마지막 줄 loadMemos()는 페이지가 처음 로드될 때 실행됩니다. 서버에서 메모 목록을 가져와서 화면에 표시합니다.
전체 동작 확인
모든 코드가 준비되었습니다. 최종 폴더 구조를 확인합니다.
memo-app/ server.js package.json package-lock.json node_modules/ public/ index.html style.css app.js
서버를 실행합니다.
node server.js
브라우저에서 http://localhost:3000에 접속하면 메모장이 보입니다. 초기 메모 두 개가 표시되어 있습니다.
새 메모를 입력하고 "추가" 버튼을 누르거나 Enter를 치면 메모가 추가됩니다. 각 메모의 "X" 버튼을 누르면 삭제됩니다. 상태 메시지가 3초간 표시되었다가 사라집니다.
한 가지 해볼 만한 것이 있습니다. 브라우저의 개발자 도구(F12)를 열고 Network 탭을 보면, 버튼을 누를 때마다 서버와 주고받는 요청과 응답을 실시간으로 확인할 수 있습니다. 각 요청의 Headers, Payload, Response 탭을 눌러보세요. "프론트엔드가 서버에 이런 데이터를 보냈고, 서버가 이런 데이터로 응답했구나"를 직접 눈으로 확인할 수 있습니다. 이 과정을 직접 보는 것이 프론트엔드와 백엔드의 통신을 이해하는 데 가장 좋은 방법입니다.
전체 흐름 정리
지금까지 만든 것의 동작 흐름을 한번 정리해봅니다. 다음 그림은 메모 조회, 추가, 삭제 세 가지 동작에서 브라우저와 서버가 어떻게 통신하는지 보여줍니다.

세 가지 동작 모두 같은 패턴을 따릅니다. 브라우저가 서버에 요청(Request)을 보내고, 서버가 처리한 후 응답(Response)을 돌려주면, 브라우저가 화면을 업데이트합니다. 메모 추가와 삭제의 경우, 서버 응답을 받은 후 loadMemos()를 다시 호출해서 전체 목록을 새로 불러오는 추가 단계가 있습니다.
사용자가 브라우저에서 http://localhost:3000에 접속하면, Express의 정적 파일 미들웨어가 public/index.html을 보내줍니다. 브라우저는 HTML을 읽으면서 style.css와 app.js를 추가로 요청하고, 서버가 역시 public 폴더에서 찾아서 보내줍니다.
app.js가 로드되면 맨 아래 줄의 loadMemos()가 실행됩니다. 이 함수는 fetch('/api/memos')로 서버에 GET 요청을 보냅니다. 서버의 app.get('/api/memos', ...) 핸들러가 memos 배열을 JSON으로 응답합니다. 브라우저의 JavaScript가 이 JSON을 받아서 displayMemos() 함수로 <li> 요소들을 만들어 화면에 표시합니다.
사용자가 메모를 입력하고 "추가" 버튼을 클릭하면, addMemo() 함수가 fetch('/api/memos', { method: 'POST', ... })로 서버에 POST 요청을 보냅니다. 서버의 app.post('/api/memos', ...) 핸들러가 memos 배열에 새 메모를 추가하고, 추가된 메모를 JSON으로 응답합니다. 브라우저의 JavaScript가 성공 응답을 받으면 loadMemos()를 다시 호출해서 전체 목록을 새로 불러옵니다.
삭제도 같은 흐름입니다. 삭제 버튼을 클릭하면 DELETE 요청을 보내고, 서버가 배열에서 해당 메모를 제거하고 응답하면, 브라우저가 목록을 다시 불러옵니다.
이 흐름이 웹 애플리케이션의 기본 패턴입니다. 프레임워크가 바뀌고 라이브러리가 바뀌어도, "브라우저가 서버에 요청을 보내고, 서버가 처리해서 응답하고, 브라우저가 응답을 받아서 화면을 업데이트한다"는 구조는 같습니다.
자주 하는 실수 모음
직접 코드를 따라 치다 보면 십중팔구 마주치는 문제들이 있습니다. 강의를 하면서 수강생들이 자주 겪는 것들을 정리해봤습니다.
첫 번째는 서버 코드를 수정하고 브라우저만 새로고침하는 것입니다. 서버 코드(server.js)를 수정하면 반드시 서버를 껐다가 다시 켜야 합니다. Ctrl + C로 종료한 후 node server.js를 다시 실행하세요. 프론트엔드 코드(public 폴더의 파일들)는 브라우저 새로고침만으로 반영됩니다. 서버 코드와 프론트엔드 코드의 반영 방식이 다르다는 것을 기억하세요.
두 번째는 Content-Type 헤더를 빼먹는 것입니다. POST 요청에서 headers: { 'Content-Type': 'application/json' }을 빼먹으면 req.body가 undefined가 됩니다. 서버에 JSON을 보낼 때는 반드시 이 헤더를 포함해야 하거든요.
세 번째는 JSON.stringify()를 빼먹는 것입니다. body에 JavaScript 객체를 직접 넣으면 [object Object]라는 문자열이 전송됩니다. 반드시 JSON.stringify()로 변환해야 합니다.
네 번째는 이미 3000번 포트를 사용 중인 경우입니다. "Error: listen EADDRINUSE: address already in use :::3000"이라는 에러가 나오면, 이전에 실행한 서버가 아직 돌아가고 있는 것입니다. 이전 터미널에서 Ctrl + C로 서버를 먼저 종료하세요.
다음으로 해볼 만한 것들
이 예제를 직접 따라 만들어본 후에, 몇 가지를 더 시도해볼 수 있습니다.
메모 수정 기능을 추가해보는 게 자연스러운 다음 단계입니다. 이미 GET, POST, DELETE를 만들어봤으니, PUT도 같은 패턴이고요. 서버 쪽 뼈대 코드는 다음과 같습니다.
app.put('/api/memos/:id', (req, res) => { const id = Number(req.params.id); const index = memos.findIndex(function(memo) { return memo.id === id; }); if (index === -1) { res.status(404).json({ error: '해당 메모를 찾을 수 없습니다' }); return; } // 여기에 text 검증과 업데이트 로직을 직접 작성해보세요 });
DELETE 핸들러와 앞부분이 거의 동일합니다. req.params.id로 수정할 메모를 찾고, req.body.text로 새 내용을 받아서 기존 메모를 업데이트하면 됩니다. 프론트엔드에서는 수정 버튼을 누르면 입력 필드에 기존 내용을 채우고, 수정된 내용을 PUT 요청으로 보내는 방식입니다.
nodemon이라는 도구를 설치하면 서버 코드를 수정할 때마다 자동으로 서버가 재시작됩니다. npm install -g nodemon으로 설치한 후 nodemon server.js로 실행하면 됩니다. 코드를 고칠 때마다 수동으로 서버를 껐다 켜는 번거로움이 사라지죠.
데이터를 영구적으로 저장하고 싶다면, 배열 대신 파일이나 데이터베이스를 사용해야 합니다. Node.js 내장 모듈인 fs로 JSON 파일에 저장하는 것이 가장 간단한 방법이며, 그 다음 단계는 SQLite나 MongoDB 같은 데이터베이스입니다.
마무리
JavaScript 기본 문법을 배운 후 "다음에 뭘 해야 하지?"라는 물음에 대한 하나의 답이 Node.js와 Express입니다. 이미 알고 있는 JavaScript로 서버까지 만들 수 있으니까요.
이 글에서 만든 메모장은 단순하지만, 웹 애플리케이션의 핵심 구조를 담고 있습니다. 서버가 API를 제공하고, 브라우저가 그 API를 호출해서 화면을 그리는 패턴. 이것이 실무에서 접하는 거의 모든 웹 서비스의 기본 골격이거든요. React든 Vue든 Angular든, 결국 이 위에 얹어지는 겁니다.
한 가지 당부를 드리자면, 반드시 직접 타이핑해서 만들어보세요. 코드를 눈으로 읽는 것과 직접 치는 것은 차이가 큽니다. 오타가 나고, 세미콜론을 빠뜨리고, 서버를 재시작하는 걸 잊어서 "왜 안 되지?" 하는 그 과정이 진짜 공부입니다.






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