
Express로 웹 페이지 띄우기 (2/5)

JavaScript 다음 스텝 - Part 2: HTML 파일을 서버에서 서빙하기
Part 1에서 Express로 텍스트를 응답하는 서버를 만들었습니다. 브라우저에 "안녕하세요! 메모 앱 서버가 동작 중입니다."라는 메시지가 출력되는 걸 확인했죠. 동작은 하는데, 솔직히 이걸 "웹사이트"라고 부르기엔 좀 민망합니다. 실제 웹사이트는 HTML과 CSS로 이루어져 있으니까요.
이번 편에서는 서버가 텍스트 대신 HTML 파일을 보내도록 바꿉니다. HTML로 메모장 화면을 만들고, CSS로 꾸미고, Express가 이 파일들을 브라우저에 서빙하는 것까지요. 끝까지 따라하면 브라우저에 메모장 모양의 UI가 뜹니다. 아직 메모를 추가하거나 삭제하는 기능은 없지만, "내가 만든 서버에서 내가 만든 웹 페이지가 뜬다"는 걸 직접 확인하게 됩니다.

정적 파일이란
Part 1에서 res.send()로 텍스트를 직접 보냈습니다. 이번에는 미리 만들어둔 HTML 파일을 보내려고 하는데, 그 전에 "정적 파일"이라는 개념을 짚고 넘어가겠습니다.
정적 파일(Static File)은 서버가 가공 없이 그대로 브라우저에 보내주는 파일입니다. HTML, CSS, JavaScript, 이미지 파일이 여기에 해당하죠. 서버가 파일 내용을 바꾸지 않고 "이 파일 그대로 가져가"라고 응답하는 겁니다.
반대 개념은 동적 응답입니다. Part 1에서 app.get('/', function(req, res) { res.send('안녕하세요!'); }) 같은 코드가 동적 응답이었죠. 서버가 요청을 받을 때마다 코드를 실행해서 응답을 만들어냅니다. 나중에 데이터베이스에서 메모 목록을 꺼내서 JSON으로 보내주는 것도 동적 응답이고요.
지금 만들 메모장 앱에서는 두 가지가 다 필요합니다. HTML, CSS, 클라이언트 JavaScript 파일은 정적 파일로 서빙하고, 메모 데이터를 주고받는 API는 동적 응답으로 처리하는 식이죠. 이번 편에서는 정적 파일 서빙만 다룹니다.
public 폴더 만들기
Express에서 정적 파일을 서빙하려면, 파일을 모아둘 폴더가 필요합니다. 보통 public이라는 이름을 쓰는데, Express 공식 문서에서도 권장하며 대부분의 프로젝트가 따르는 관례입니다.
Part 1에서 만든 memo-app 폴더 안에서 public 폴더를 만듭니다.
mkdir public
그리고 public 폴더 안에 index.html 파일을 만듭니다. 우선 가장 간단한 HTML부터 시작하겠습니다. 정적 파일 서빙이 되는지 확인하기 위한 임시 파일이고, 동작을 확인한 뒤 메모장 UI로 교체할 거니까 그 흐름을 염두에 두고 따라오면 됩니다.
<!-- 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>Express 서버에서 HTML 파일을 서빙하고 있습니다.</p> </body> </html>
HTML을 처음 보는 분은 거의 없겠지만, 간단히 짚어보겠습니다. <!DOCTYPE html>은 "이 문서는 HTML5입니다"라는 선언이고, <html lang="ko">는 한국어 문서라는 표시입니다. <head> 안에는 문서 설정(문자 인코딩, 제목 등)이 들어가고, <body> 안에 실제 화면에 보이는 내용이 들어갑니다. <meta charset="UTF-8">은 한글이 깨지지 않게 하는 설정이고, <meta name="viewport" ...>는 모바일에서도 화면이 적절한 크기로 보이게 하는 설정입니다.
이 파일은 아직 서버에 연결되지 않은 상태입니다. server.js를 수정해야 합니다.
server.js 수정하기 - express.static()
Part 1의 server.js는 이런 모양이었습니다.
// server.js (Part 1 버전) const express = require('express'); const app = express(); app.get('/', function(req, res) { res.send('안녕하세요! 메모 앱 서버가 동작 중입니다.'); }); app.listen(3000, function() { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
이걸 다음과 같이 수정합니다.
// server.js (Part 2 버전) const express = require('express'); const path = require('path'); const app = express(); app.use(express.static(path.join(__dirname, 'public'))); app.listen(3000, function() { console.log('서버가 http://localhost:3000 에서 실행 중입니다'); });
코드가 오히려 짧아졌습니다. app.get('/', ...) 부분이 사라지고, app.use(express.static(...)) 한 줄이 들어왔죠. Part 1에서 배운 app.get()을 삭제해도 되는 이유는, express.static()이 public/index.html을 자동으로 찾아서 서빙하기 때문입니다. /로 접속하면 index.html이 응답되므로 app.get('/', ...)이 할 일이 없어진 거죠. 둘을 동시에 넣으면 먼저 등록된 쪽이 우선합니다. Part 3에서 API 경로를 추가할 때 express.static()과 app.get()을 함께 사용하게 되니, 그때 자세히 다루겠습니다.
이 한 줄이 public 폴더의 모든 파일을 자동으로 서빙합니다. 새로운 문법이 좀 나왔으니 하나씩 뜯어보겠습니다.
require('path')
const path = require('path');
path는 Node.js 내장 모듈입니다. Part 1에서 require('express')를 봤는데, Express는 npm install로 설치해야 썼잖아요. path는 다릅니다. Node.js를 설치하면 기본으로 들어 있어서 따로 설치할 필요가 없습니다. npm install path 같은 명령은 안 써도 됩니다.
Node.js에는 이런 내장 모듈이 꽤 있습니다. 파일을 읽고 쓰는 fs, 네트워크 서버를 만드는 http, 운영체제 정보를 가져오는 os 같은 것들이죠. path는 파일 경로를 다루는 모듈로, 경로를 합치거나 확장자를 추출하거나 절대 경로로 변환하는 기능을 제공합니다.
path.join()
path.join(__dirname, 'public')
path.join()은 여러 경로 조각을 하나로 합치는 함수입니다. 왜 단순히 문자열을 +로 합치지 않고 이 함수를 쓸까요?
운영체제마다 경로 구분자가 다르기 때문입니다. 맥과 리눅스는 /(슬래시)를 쓰고, 윈도우는 \(백슬래시)를 씁니다. path.join()은 현재 운영체제에 맞는 구분자를 자동으로 사용합니다.
// 맥/리눅스에서 실행하면 path.join('/Users/dev', 'memo-app', 'public') // 결과: '/Users/dev/memo-app/public' // 윈도우에서 실행하면 path.join('C:\\Users\\dev', 'memo-app', 'public') // 결과: 'C:\\Users\\dev\\memo-app\\public'
직접 '/Users/dev' + '/' + 'public'처럼 쓰면 맥에서는 되지만 윈도우에서는 문제가 생길 수 있습니다. path.join()을 쓰면 어떤 운영체제에서든 올바른 경로가 만들어집니다.
__dirname
__dirname은 Node.js가 제공하는 특별한 변수입니다. 현재 실행 중인 파일이 위치한 폴더의 절대 경로를 담고 있습니다. 브라우저 JavaScript에는 없는 변수입니다.
예를 들어 server.js가 /Users/dev/memo-app/server.js에 있다면, __dirname의 값은 /Users/dev/memo-app입니다. 파일명(server.js)은 포함되지 않고 폴더 경로만 들어갑니다.
path.join(__dirname, 'public') // __dirname이 '/Users/dev/memo-app'이면 // 결과: '/Users/dev/memo-app/public'
왜 __dirname을 쓸까요? express.static('public')처럼 상대 경로만 쓰면, Node.js를 어디서 실행하느냐에 따라 경로가 달라질 수 있거든요. memo-app 폴더 안에서 node server.js를 실행하면 문제가 없지만, 상위 폴더에서 node memo-app/server.js를 실행하면 public 폴더를 찾지 못합니다. __dirname을 쓰면 server.js 파일의 위치 기준으로 절대 경로를 만들기 때문에, 어디서 실행하든 같은 결과가 나옵니다. Express 공식 문서에서도 이 방식을 권장하고요.
app.use()
app.use(express.static(path.join(__dirname, 'public')));
app.use()는 Express가 제공하는 메서드로, 미들웨어를 등록하는 역할을 합니다.
미들웨어라는 용어가 처음이면 이렇게 생각하면 됩니다. 브라우저가 서버에 요청을 보내면, 그 요청이 최종 처리되기 전에 거쳐야 하는 "중간 단계"가 있거든요. 편의점 택배 발송을 떠올려보세요. 택배를 보내기 전에 박스 포장하고, 운송장 붙이고, 무게 재는 단계를 거치잖아요. 각 단계가 미들웨어인 셈입니다. 요청이 들어올 때마다 등록된 미들웨어가 순서대로 실행됩니다.
Part 1에서는 app.get('/', ...)으로 특정 경로에 대한 응답만 등록했죠. app.use()는 모든 요청에 대해 동작합니다. 어떤 URL로 요청이 오든, app.use()로 등록한 미들웨어가 먼저 실행됩니다.
express.static()
express.static()은 Express가 기본 제공하는 미들웨어입니다. "이 폴더의 파일들을 정적 파일로 서빙해라"라고 설정하는 거죠.
app.use(express.static(path.join(__dirname, 'public')));
풀어쓰면 이런 뜻입니다. "어떤 요청이 들어오면, 먼저 public 폴더에서 해당 파일이 있는지 찾아봐라. 파일이 있으면 그 파일을 응답으로 보내고, 없으면 다음 단계로 넘겨라."
브라우저가 http://localhost:3000/index.html을 요청하면 Express는 public/index.html 파일이 있는지 확인하고, 있으면 보냅니다. http://localhost:3000/style.css를 요청하면 public/style.css를 찾아서 보내고요.
한 가지 주의할 점은, URL에 public이 포함되지 않는다는 겁니다. http://localhost:3000/public/index.html이 아니라 http://localhost:3000/index.html이에요. express.static()이 public 폴더를 "루트"로 설정하기 때문에, 그 안의 파일은 마치 최상위에 있는 것처럼 접근됩니다.
다음 그림은 express.static()이 브라우저 요청 URL을 public 폴더의 파일로 매핑하는 과정을 보여줍니다. 그림에서 Browser Requests는 브라우저가 보내는 GET 요청을, public/ Folder는 서버의 정적 파일 폴더를 나타냅니다.

브라우저가 GET /style.css를 요청하면 express.static()이 public/style.css 파일을 찾아서 응답합니다. URL에는 public/이 포함되지 않지만, 서버 내부에서는 public 폴더를 기준으로 파일을 찾는 구조입니다.
서버 실행하고 HTML 확인하기
코드를 수정했으니 실행해봅시다. Part 1에서 서버가 아직 실행 중이라면 터미널에서 Ctrl + C로 먼저 종료합니다. 서버 코드를 수정하면 반드시 껐다 켜야 하거든요. 실행 중인 서버는 수정 전의 코드를 메모리에 올려놓고 있기 때문에, 파일을 저장해도 자동으로 반영되지 않습니다.
node server.js
서버가 http://localhost:3000 에서 실행 중입니다
브라우저에서 http://localhost:3000을 열어보면, 아까 만든 HTML 페이지가 보입니다. "메모장"이라는 제목과 "Express 서버에서 HTML 파일을 서빙하고 있습니다."라는 문구가 뜨죠.
Part 1에서는 res.send()로 텍스트를 직접 보냈는데, 이번에는 HTML 파일을 보낸 겁니다. 브라우저 개발자도구(F12)를 열고 Network 탭을 보면 index.html 파일이 서버에서 전송된 것을 확인할 수 있습니다.
왜 index.html이 자동으로 보이는가
http://localhost:3000에 접속했을 뿐인데 index.html이 보입니다. 주소에 /index.html을 붙이지 않았는데요. 이건 express.static()의 기본 동작입니다. 폴더의 루트(/)에 접속하면 자동으로 index.html을 찾아서 보내주거든요. 웹 서버의 오래된 관례이기도 하고, Apache나 Nginx 같은 서버도 마찬가지입니다.
http://localhost:3000과 http://localhost:3000/index.html은 같은 결과를 보여줍니다. 직접 확인해보세요.
CSS 파일 연결하기
HTML만으로는 밋밋합니다. CSS를 추가해보겠습니다. public 폴더 안에 style.css 파일을 만듭니다.
/* 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; } h1 { text-align: center; padding: 20px; background-color: #4a90d9; color: white; } p { text-align: center; padding: 20px; font-size: 18px; }
그리고 index.html의 <head> 안에 CSS 파일을 연결합니다.
<!-- 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> <h1>메모장</h1> <p>Express 서버에서 HTML 파일을 서빙하고 있습니다.</p> </body> </html>
<link rel="stylesheet" href="style.css">가 핵심입니다. 브라우저는 이 태그를 만나면 style.css 파일을 서버에 요청합니다. 즉 브라우저가 http://localhost:3000/style.css로 GET 요청을 보내는 거죠. express.static()이 public/style.css 파일을 찾아서 응답하고요.
여기서 한 가지. CSS 파일은 public 폴더 안에 있는데, HTML에서는 href="style.css"라고 썼습니다. href="public/style.css"가 아니에요. 앞서 설명한 대로 express.static()이 public 폴더를 루트로 설정했기 때문에, public 없이 바로 파일명을 쓰면 됩니다.
이제 브라우저를 새로고침합니다. 서버를 재시작할 필요는 없어요. 왜냐고요? CSS 파일은 public 폴더 안의 정적 파일이니까요. 서버 코드(server.js)를 수정한 게 아니라 서빙되는 파일(style.css)을 수정한 것이므로, 브라우저 새로고침만으로 바로 반영됩니다.
파란색 배경의 제목과 가운데 정렬된 텍스트가 보이면 CSS가 정상적으로 적용된 것입니다.
서버 코드 수정 vs 프론트엔드 코드 수정
이 차이를 모르면 나중에 꼭 한 번은 헤맵니다. 강의할 때 "코드를 수정했는데 왜 반영이 안 돼요?"라고 질문하는 분이 정말 많거든요.
서버 코드(server.js)를 수정하면 서버를 껐다 켜야 합니다. Ctrl + C로 종료하고 node server.js로 다시 실행하는 거죠. server.js는 Node.js가 처음 실행할 때 한 번 읽어들이고, 이후로는 메모리에 올라간 코드를 쓰기 때문입니다. 파일을 아무리 수정해도 이미 실행 중인 서버는 수정 전 코드를 계속 사용합니다.
프론트엔드 코드(public/ 안의 파일)를 수정하면 브라우저에서 새로고침(F5)만 하면 됩니다. express.static()은 요청이 올 때마다 public 폴더에서 파일을 읽어서 보내기 때문에, 파일을 수정하면 다음 요청에서 바로 반영되거든요.
정리하면 이렇고요.
| 수정한 파일 | 반영 방법 |
|---|---|
server.js | 서버 종료(Ctrl + C) 후 재시작(node server.js) |
public/index.html | 브라우저 새로고침(F5) |
public/style.css | 브라우저 새로고침(F5) |
public/app.js | 브라우저 새로고침(F5) |
이 차이가 왜 생기는지 흐름을 따라가보면 납득이 됩니다. server.js는 Node.js 프로세스가 시작할 때 한 번 읽고 끝이에요. 반면 public/ 안의 파일은 브라우저가 요청할 때마다 디스크에서 읽어서 보내주는 것이거든요.
나중에 실무에서는 nodemon 같은 도구를 써서 서버 코드가 바뀌면 자동으로 재시작하게 만듭니다. 하지만 지금 단계에서는 수동으로 껐다 켜는 습관을 들이는 게 좋습니다. 그래야 서버 코드와 프론트엔드 코드를 구분하는 감각이 생기거든요.
메모장 UI 만들기
이제 진짜 메모장 UI를 만들어봅시다. 간단한 HTML과 CSS를 확인했으니, 본격적으로 메모장 화면을 구성합니다. 입력 필드, 추가 버튼, 메모 목록 영역이 필요합니다.
index.html 수정
public/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> <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>
HTML 요소들을 하나씩 봅시다.
<header>는 페이지 상단 영역을 묶는 태그입니다. 제목을 여기에 넣었고요.
<main>은 페이지의 주요 콘텐츠를 담는 태그입니다. <header>, <main>, <footer> 같은 태그를 시맨틱 태그라고 부르는데, <div>로 해도 동작은 같지만 태그 이름만 보고 역할을 알 수 있어서 코드 읽기가 편해집니다.
<div class="input-area">는 입력 필드와 버튼을 감싸는 컨테이너입니다. 나중에 CSS로 이 두 요소를 가로로 나란히 배치할 겁니다.
<input type="text" id="memoInput" placeholder="메모를 입력하세요">는 텍스트 입력 필드입니다. id="memoInput"은 나중에 JavaScript에서 이 요소를 찾기 위한 고유 식별자이고, placeholder는 입력 전에 연한 글씨로 보이는 안내 문구죠.
<button id="addBtn">추가</button>는 클릭할 수 있는 버튼입니다. 마찬가지로 id를 지정해서 JavaScript에서 찾을 수 있게 했고요.
<div id="status"></div>는 "메모가 추가되었습니다", "메모가 삭제되었습니다" 같은 상태 메시지를 표시할 영역입니다. 지금은 비어 있지만 나중에 JavaScript로 내용을 채울 겁니다.
<ul id="memoList"></ul>는 메모 목록이 표시될 영역입니다. <ul>은 순서 없는 목록(unordered list)이고, 각 메모는 <li> 태그로 들어가게 됩니다. 지금은 비어 있고, Part 3에서 JavaScript로 메모 데이터를 가져와서 여기에 채웁니다.
<script src="app.js"></script>는 JavaScript 파일을 연결하는 태그입니다. CSS를 연결할 때 <link>를 썼던 것처럼, JavaScript는 <script src="...">로 연결하죠. </body> 바로 위에 넣은 이유가 있는데요. HTML 요소가 모두 로드된 후에 JavaScript가 실행되어야 document.getElementById() 같은 코드가 제대로 동작하기 때문입니다. <head>에 넣으면 HTML 요소가 아직 만들어지기 전에 JavaScript가 실행돼서 오류가 날 수 있습니다.
style.css 수정
public/style.css를 다음과 같이 수정합니다. CSS가 좀 길지만, 한 번에 전체를 작성하고 설명하겠습니다.
/* 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 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; }
CSS가 좀 길어 보이지만, 각 부분이 하는 일은 단순합니다. 생소할 수 있는 속성 몇 가지만 짚어보겠습니다.
맨 위의 * { box-sizing: border-box; }는 모든 요소의 크기 계산 방식을 바꾸는 설정입니다. 이게 없으면 width: 100px에 padding: 10px을 주었을 때 실제 크기가 120px이 되거든요. border-box로 바꾸면 padding을 포함해서 100px이 되므로 레이아웃을 예측하기 쉬워집니다. 거의 모든 CSS 리셋에 포함되는 설정이고요.
.input-area에 적용한 display: flex는 자식 요소를 가로로 나란히 배치하는 레이아웃 방식입니다. 이게 없으면 입력 필드와 버튼이 각각 한 줄씩 세로로 쌓이죠. gap: 8px은 두 요소 사이의 간격이고, 입력 필드에 적용한 flex: 1은 "버튼을 제외한 나머지 공간을 전부 차지하라"는 뜻입니다. 화면이 넓으면 입력 필드가 길어지고, 좁으면 짧아집니다.
CSS 각 속성에 대한 자세한 설명은 참고 자료의 MDN CSS Flexbox 문서를 참고하세요.
app.js 파일 생성
마지막으로 public/app.js 파일을 만듭니다. 지금은 빈 파일이지만, Part 3에서 여기에 코드를 작성합니다.
// public/app.js // 이 파일에 JavaScript 코드를 작성합니다. // Part 3에서 서버 데이터를 가져와 화면에 표시하는 코드를 추가합니다. console.log('app.js 로드 완료');
console.log('app.js 로드 완료')를 넣어둔 이유가 있습니다. 브라우저 개발자도구(F12) 콘솔에 이 메시지가 보이면 JavaScript 파일이 정상 로드된 거고, 안 보이면 파일 경로가 틀리거나 <script> 태그에 오타가 있는 겁니다. 연결 확인용으로 쓸만하죠.
브라우저에서 확인하기
현재 프로젝트 구조를 보겠습니다.
memo-app/ node_modules/ public/ index.html style.css app.js server.js package.json package-lock.json
public/ 폴더 안에 세 파일이 들어갔습니다. 브라우저에서 확인해봅시다. 서버가 실행 중이면 새로고침만 하면 되고, 종료했다면 node server.js로 다시 실행하면 됩니다.
http://localhost:3000에 접속하면 메모장 UI가 보입니다. 파란색 헤더에 "메모장"이라는 제목, 그 아래에 입력 필드와 "추가" 버튼이 가로로 나란히 놓여 있죠. 입력 필드를 클릭하면 테두리가 파란색으로 바뀌고, 버튼에 마우스를 올리면 색이 살짝 어두워집니다.
아직 버튼을 눌러도 아무 일도 일어나지 않습니다. 메모 목록도 비어 있고요. 이건 Part 3에서 JavaScript로 기능을 추가합니다. 지금은 화면(껍데기)만 완성한 상태입니다.
브라우저 개발자도구(F12)를 열어서 두 가지를 확인해보세요.
첫째, Console 탭에 "app.js 로드 완료"라는 메시지가 보이는지 확인합니다. 보이면 JavaScript 파일이 정상 연결된 겁니다.
둘째, Network 탭을 열고 페이지를 새로고침(F5)합니다. 서버에서 가져온 파일 목록이 보이는데, index.html, style.css, app.js 세 파일이 각각 별도 요청으로 전송된 걸 확인할 수 있습니다. 브라우저가 index.html을 먼저 받고, 그 안의 <link>와 <script> 태그를 보고 style.css와 app.js를 추가로 요청한 거죠.
정적 파일 서빙의 전체 흐름이 이겁니다. HTML 페이지 하나를 로드하는 과정에서 여러 번의 HTTP 요청이 오갑니다.
정적 파일 서빙의 흐름
지금까지의 흐름을 순서대로 보면 이렇습니다.
- 브라우저가
http://localhost:3000으로 GET 요청을 보냅니다. express.static()이public/index.html을 찾아서 응답합니다.- 브라우저가
index.html을 받아서 파싱합니다. <link href="style.css">를 만나면http://localhost:3000/style.css를 요청합니다.express.static()이public/style.css를 찾아서 응답합니다.<script src="app.js">를 만나면http://localhost:3000/app.js를 요청합니다.express.static()이public/app.js를 찾아서 응답합니다.- 모든 파일이 로드되면 페이지가 완성됩니다.
다음 그림은 브라우저가 페이지 하나를 로드할 때 서버와 주고받는 요청/응답의 전체 흐름을 보여줍니다. 그림에서 Browser는 웹 브라우저를, Express Server는 Node.js Express 서버를 나타냅니다.

HTML 파일을 받은 브라우저가 <link>와 <script> 태그를 발견하면 CSS와 JavaScript 파일을 추가로 요청합니다. 페이지 하나를 완성하기 위해 여러 번의 HTTP 요청이 오가는 것이죠. 서버는 각 파일의 확장자를 보고 Content-Type 헤더를 자동으로 설정해서 응답합니다.
브라우저는 HTML 파일 하나를 받기 위해 한 번만 요청하는 게 아닙니다. HTML 안에서 참조하는 CSS, JavaScript, 이미지 파일마다 추가 요청을 보내죠. 개발자도구 Network 탭에서 이걸 직접 눈으로 확인해보면 웹이 어떻게 동작하는지 감이 잡히실 겁니다.
한 가지 더 알아두면 좋은 게 있습니다. 브라우저가 style.css를 받았을 때 CSS로 처리하고, app.js를 받았을 때 JavaScript로 실행하는 이유는, 서버가 응답 헤더에 Content-Type을 포함하기 때문입니다. express.static()은 파일 확장자를 보고 Content-Type 헤더를 자동으로 설정하거든요. .html은 text/html, .css는 text/css, .js는 application/javascript로 보냅니다. 브라우저는 이 헤더를 보고 파일을 어떻게 처리할지 결정하죠. 개발자도구 Network 탭에서 각 파일의 응답 헤더를 클릭해보면 Content-Type이 다르게 설정된 걸 확인할 수 있습니다.
파일을 추가할 때
public 폴더에 파일을 추가하면 따로 설정할 것 없이 자동으로 서빙됩니다. public/about.html이라는 파일을 만들면 http://localhost:3000/about.html로 바로 접속할 수 있고, server.js를 수정할 필요도 없습니다. express.static()이 public 폴더를 서빙 대상으로 등록해두었으니까요.
하위 폴더도 됩니다. public/images/logo.png를 넣으면 http://localhost:3000/images/logo.png로 접근할 수 있죠. express.static() 한 줄이면 폴더 구조 전체가 그대로 서빙되는 겁니다.
직접 해보기
여기까지 따라왔다면 두 가지를 시도해보세요.
1. 메모장 스타일 커스터마이즈
style.css에서 헤더 배경색을 바꿔보세요. #4a90d9(파란색) 대신 #2ecc71(초록색), #e74c3c(빨간색), #9b59b6(보라색) 등 원하는 색을 넣어보세요. 브라우저 새로고침만으로 바로 반영됩니다.
header { background-color: #2ecc71; /* 초록색으로 변경 */ }
2. 이미지 파일 서빙해보기
public 폴더에 아무 이미지 파일(예: photo.jpg)을 넣고, index.html에 <img> 태그를 추가해보세요.
<main> <img src="photo.jpg" alt="내 사진" style="max-width: 200px;"> <!-- 나머지 코드 --> </main>
이미지 파일도 정적 파일이므로, express.static()이 자동으로 서빙합니다. server.js를 수정하지 않아도 됩니다. 이미지가 보이면 성공입니다.
정리
이번 편에서는 Express로 HTML, CSS, JavaScript 파일을 서빙하는 방법을 다뤘습니다.
public 폴더를 만들고 express.static() 미들웨어로 자동 서빙을 설정했고, path.join()과 __dirname으로 운영체제에 상관없이 올바른 경로를 만드는 법도 봤습니다. HTML에서 CSS와 JavaScript를 연결하면 브라우저가 각 파일을 별도로 요청한다는 것도 확인했고요.
이번 편에서 새로 나온 문법들을 정리하면 이렇습니다.
Node.js 내장 모듈/변수 (설치 없이 사용 가능): require('path'), path.join(), __dirname
Express가 제공하는 기능 (npm install express 후 사용 가능): app.use(), express.static()
그리고 서버 코드를 수정하면 서버를 껐다 켜야 하지만, public/ 안의 파일을 수정하면 브라우저 새로고침만으로 반영된다는 것. 이 구분이 이번 편에서 가장 중요한 포인트입니다.
지금은 메모장의 "껍데기"만 있는 상태입니다. 입력 필드와 버튼이 있지만 아무 동작도 하지 않죠. 다음 편(Part 3)에서는 서버에 GET API를 만들고, 브라우저에서 fetch()로 데이터를 가져와서 화면에 표시합니다. 서버와 브라우저가 데이터를 주고받는 "진짜 웹 앱"이 시작되는 거죠.
참고 자료
- Express 공식 문서 - 정적 파일 서빙: https://expressjs.com/en/starter/static-files.html
- Express 공식 문서 - express.static() API: https://expressjs.com/en/5x/api.html#express.static
- Node.js 공식 문서 - path 모듈: https://nodejs.org/api/path.html
- MDN - HTML 기초: https://developer.mozilla.org/ko/docs/Learn/Getting_started_with_the_web/HTML_basics
- MDN - CSS Flexbox: https://developer.mozilla.org/ko/docs/Web/CSS/CSS_flexible_box_layout






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