Skip to main content
Summary

이번에 만들 서버의 모습

지금까지는 서버의 조각을 하나씩 배웠습니다. 라우팅은 주소별 접수 창구였고, 미들웨어는 중간 도우미였습니다. 정적 파일은 HTML과 CSS를 보여 주는 방법이었고, 폼 처리는 사용자가 입력한 내용을 서버가 받는 방법이었습니다. JSON API는 화면이 아니라 데이터를 돌려주는 창구였습니다.

이번 장에서는 이 조각들을 하나의 작은 서비스로 묶습니다. 만들 것은 문의 접수 서버입니다.

이 서버에는 세 가지 주요 주소가 있습니다.

  • / : 홈페이지를 보여 줍니다.

  • /contact : 문의 작성 페이지를 보여 주고, 문의 제출도 받습니다.

  • /api/inquiries : 접수된 문의 목록을 JSON으로 보여 줍니다.

사용자는 문의 폼에 이름, 이메일, 내용을 입력합니다. 서버는 입력값을 확인한 뒤 문제가 없으면 문의를 저장합니다. 관리자는 JSON API 주소로 접수된 문의 목록을 확인할 수 있습니다. 잘못된 주소로 들어오면 404 안내를 보내고, 서버 내부에서 문제가 생기면 오류 처리 미들웨어가 응답합니다.

큰 서비스는 아니지만, Express 서버의 기본 뼈대를 한 번에 확인하기에는 충분합니다.

작은 문의 접수 서버 완성하기AI 생성 이미지

프로젝트 폴더 정리하기

먼저 무엇을 만들고 있는지 다시 확인하겠습니다. 우리는 HTML 화면, CSS 파일, 라우트 파일, 데이터 파일을 나누어 가진 작은 Express 서버를 만들고 있습니다.

VS Code에서 이전에 사용하던 express-practice 폴더를 열어도 좋고, 새 폴더를 만들어도 좋습니다. 여기서는 새 프로젝트 이름을 contact-server라고 하겠습니다.

터미널에서 작업할 위치로 이동한 뒤 다음 명령을 입력합니다.

mkdir contact-server
cd contact-server
npm init -y
npm install express

성공하면 package.json 파일이 생기고, node_modules 폴더와 package-lock.json 파일도 함께 만들어집니다.

이제 다음 구조로 파일과 폴더를 만듭니다.

contact-server/
├─ app.js
├─ package.json
├─ public/
│  └─ css/
│     └─ style.css
├─ routes/
│  └─ inquiries.js
├─ data/
│  └─ inquiries.json
└─ README.md

각 폴더의 역할은 분명합니다.

public 폴더는 브라우저가 가져갈 수 있는 공개 파일 보관함입니다. CSS, 이미지, 브라우저용 JavaScript를 둘 수 있습니다. routes 폴더는 주소별 처리 코드를 나누어 보관하는 곳입니다. data 폴더는 이번 실습에서 문의 목록을 잠깐 저장할 JSON 파일을 둘 곳입니다.

여기서 한 가지를 기억해 두면 좋습니다. data 폴더는 public 폴더 안에 넣지 않습니다. public 안에 있는 파일은 브라우저가 직접 가져갈 수 있기 때문입니다. 문의 내용처럼 사용자가 남긴 데이터는 공개 파일 보관함 밖에 두어야 합니다.

data/inquiries.json 파일을 열고 처음 내용으로 빈 배열을 적습니다.

[]

이 배열 안에 문의가 하나씩 추가될 예정입니다.

서버의 중심 파일 만들기

이제 app.js 파일을 엽니다. 이 파일은 서버의 입구입니다. Express를 불러오고, 미들웨어를 등록하고, 라우트를 연결하고, 마지막으로 서버를 실행합니다.

먼저 필요한 도구를 불러옵니다.

const express = require('express');
const path = require('path');

express는 서버를 만들기 위한 도구입니다. path는 폴더와 파일 경로를 안전하게 이어 붙일 때 사용합니다. 운영체제마다 경로 표시 방식이 조금 다를 수 있기 때문에 직접 문자열로 경로를 쓰는 것보다 path.join()을 쓰는 편이 안정적입니다.

다음으로 Express 앱을 만듭니다.

const app = express();
const PORT = process.env.PORT || 3000;

app은 앞으로 우리가 설정할 서버입니다. PORT는 서버가 기다릴 문 번호입니다. 내 컴퓨터에서는 보통 3000번을 쓰지만, 배포 환경에서는 서비스가 정해 준 process.env.PORT를 사용할 수 있게 해 둡니다.

이제 미들웨어를 등록합니다.

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

첫 줄은 HTML 폼으로 제출된 값을 req.body에서 읽을 수 있게 합니다. 둘째 줄은 JSON 요청 본문을 읽을 수 있게 합니다. 셋째 줄은 public 폴더 안의 CSS 같은 정적 파일을 브라우저가 가져갈 수 있게 합니다.

이제 홈페이지와 문의 작성 페이지를 간단한 HTML 응답으로 만듭니다. 실제 서비스에서는 HTML 파일을 따로 둘 수도 있지만, 이번 장에서는 전체 흐름을 한눈에 보기 위해 app.js 안에 짧게 작성합니다.

app.get('/', (req, res) => {
  res.send(`
    <!doctype html>
    <html lang='ko'>
    <head>
      <meta charset='utf-8'>
      <meta name='viewport' content='width=device-width, initial-scale=1'>
      <title>문의 접수 서버</title>
      <link rel='stylesheet' href='/css/style.css'>
    </head>
    <body>
      <main class='container'>
        <h1>문의 접수 서버</h1>
        <p>궁금한 내용을 남기면 서버가 문의를 접수합니다.</p>
        <a class='button' href='/contact'>문의 작성하기</a>
      </main>
    </body>
    </html>
  `);
});

GET / 요청이 오면 홈페이지 HTML을 응답합니다. HTML 안의 <link>는 /css/style.css를 요청합니다. 이 파일은 public/css/style.css에 둘 예정입니다.

다음으로 문의 작성 페이지를 만듭니다.

app.get('/contact', (req, res) => {
  res.send(`
    <!doctype html>
    <html lang='ko'>
    <head>
      <meta charset='utf-8'>
      <meta name='viewport' content='width=device-width, initial-scale=1'>
      <title>문의 작성</title>
      <link rel='stylesheet' href='/css/style.css'>
    </head>
    <body>
      <main class='container'>
        <h1>문의 작성</h1>
        <form action='/contact' method='POST' class='form'>
          <label>이름
            <input type='text' name='name' maxlength='30' required>
          </label>
          <label>이메일
            <input type='email' name='email' maxlength='80' required>
          </label>
          <label>문의 내용
            <textarea name='message' rows='6' maxlength='1000' required></textarea>
          </label>
          <button type='submit'>문의 보내기</button>
        </form>
        <p><a href='/'>홈으로 돌아가기</a></p>
      </main>
    </body>
    </html>
  `);
});

여기서 중요한 연결은 form 태그입니다. action='/contact'는 문의를 보낼 주소입니다. method='POST'는 사용자가 입력한 내용을 제출하겠다는 뜻입니다. 서버에는 이 요청을 받을 POST /contact 라우트가 필요합니다.

입력칸에 붙인 required는 브라우저에서 빈 값 제출을 한 번 막아 줍니다. 다만 브라우저의 도움만 믿으면 안 됩니다. 서버에서도 다시 확인해야 합니다. 그래서 잠시 뒤 POST /contact 코드에서도 이름, 이메일, 문의 내용이 비어 있는지 검사합니다.

문의 처리 라우트 분리하기

문의 저장과 목록 API는 routes/inquiries.js 파일에 분리해 보겠습니다. app.js 하나에 모든 코드를 넣을 수도 있지만, 파일이 길어지면 찾고 고치기 어려워집니다. 라우트 파일을 나누는 것은 접수 창구별로 담당 직원을 따로 두는 것과 비슷합니다.

routes/inquiries.js 파일을 열고 먼저 필요한 도구를 불러옵니다.

const express = require('express');
const fs = require('fs/promises');
const path = require('path');

const router = express.Router();
const dataPath = path.join(__dirname, '..', 'data', 'inquiries.json');

express.Router()는 작은 라우터를 만드는 도구입니다. 이 라우터를 나중에 app.js에 연결하면, 문의와 관련된 주소를 따로 관리할 수 있습니다.

이제 파일에서 문의 목록을 읽는 함수를 만듭니다.

async function readInquiries() {
  const text = await fs.readFile(dataPath, 'utf-8');
  return JSON.parse(text);
}

파일 읽기는 시간이 조금 걸릴 수 있는 작업입니다. 그래서 async와 await를 사용합니다. 어렵게 느껴진다면 "파일을 다 읽을 때까지 기다렸다가 다음 줄로 간다" 정도로 이해해도 충분합니다.

다음은 문의 목록을 파일에 저장하는 함수입니다.

async function saveInquiries(inquiries) {
  const text = JSON.stringify(inquiries, null, 2);
  await fs.writeFile(dataPath, text, 'utf-8');
}

배열을 파일에 바로 넣을 수는 없습니다. 먼저 JSON 문자열로 바꾼 뒤 저장합니다. null, 2는 파일을 사람이 읽기 좋게 줄바꿈해 주는 설정입니다.

이제 폼으로 들어온 문의를 받을 라우트를 만듭니다.

router.post('/contact', async (req, res, next) => {
  try {
    const name = String(req.body.name || '').trim();
    const email = String(req.body.email || '').trim();
    const message = String(req.body.message || '').trim();

    if (!name || !email || !message) {
      return res.status(400).send('이름, 이메일, 문의 내용을 모두 입력해 주세요.');
    }

    const inquiries = await readInquiries();

    const newInquiry = {
      id: Date.now(),
      name,
      email,
      message,
      createdAt: new Date().toISOString()
    };

    inquiries.push(newInquiry);
    await saveInquiries(inquiries);

    res.status(201).send(`
      <!doctype html>
      <html lang='ko'>
      <head>
        <meta charset='utf-8'>
        <meta name='viewport' content='width=device-width, initial-scale=1'>
        <title>문의 접수 완료</title>
        <link rel='stylesheet' href='/css/style.css'>
      </head>
      <body>
        <main class='container'>
          <h1>문의가 접수되었습니다</h1>
          <p>문의를 남겨 주셔서 감사합니다.</p>
          <p><a href='/'>홈으로 돌아가기</a></p>
        </main>
      </body>
      </html>
    `);
  } catch (err) {
    next(err);
  }
});

이 코드는 길어 보이지만 흐름은 단순합니다. 먼저 req.body에서 이름, 이메일, 문의 내용을 꺼냅니다. String()과 trim()은 값이 비어 있거나 앞뒤에 공백이 있을 때를 대비한 처리입니다. 그다음 세 값 중 하나라도 없으면 400 Bad Request로 안내합니다.

입력값이 모두 있으면 기존 문의 목록을 읽고, 새 문의 객체를 만든 뒤 배열에 추가합니다. 마지막으로 파일에 저장하고 접수 완료 HTML을 응답합니다.

여기서는 사용자가 입력한 이름이나 문의 내용을 완료 화면에 그대로 끼워 넣지 않았습니다. 사용자가 쓴 문장을 다시 HTML로 보여 줄 때는 조심해야 하기 때문입니다. 지금 단계에서는 "접수되었습니다"라고만 알려 주는 방식이 더 안전하고 단순합니다.

try와 catch는 서버 처리 중 문제가 생겼을 때 서버가 갑자기 멈추지 않도록 돕습니다. 파일을 읽을 수 없거나 JSON 형식이 깨져 있으면 catch로 넘어가고, next(err)가 오류 처리 미들웨어로 문제를 전달합니다.

이제 관리자용 문의 목록 API를 만듭니다.

router.get('/api/inquiries', async (req, res, next) => {
  try {
    const inquiries = await readInquiries();
    res.json({
      count: inquiries.length,
      items: inquiries
    });
  } catch (err) {
    next(err);
  }
});

이 주소는 HTML 화면이 아니라 JSON 데이터를 응답합니다. 브라우저에서 http://localhost:3000/api/inquiries로 접속하면 접수된 문의 목록을 데이터로 볼 수 있습니다.

파일 마지막에는 라우터를 밖으로 내보냅니다.

module.exports = router;

라우트와 오류 처리를 연결하기

다시 app.js 파일을 엽니다. 위쪽 불러오기 코드 아래에 문의 라우터를 추가합니다.

const inquiryRoutes = require('./routes/inquiries');

그리고 홈페이지와 문의 작성 페이지 라우트 아래에 문의 라우터를 연결합니다.

app.use(inquiryRoutes);

이제 routes/inquiries.js 안에 있던 POST /contact와 GET /api/inquiries가 실제 서버에 붙습니다.

마지막으로 404 처리와 오류 처리 미들웨어를 추가합니다. 이 코드는 모든 정상 라우트보다 아래에 있어야 합니다.

app.use((req, res) => {
  res.status(404).send('요청하신 주소를 찾을 수 없습니다.');
});

app.use((err, req, res, next) => {
  console.error(err.message);
  res.status(500).send('서버에서 문의를 처리하는 중 문제가 생겼습니다. 잠시 후 다시 시도해 주세요.');
});

404는 "그 주소의 접수 창구가 없다"는 뜻입니다. 500은 "주소는 찾았지만 서버 안에서 처리하다가 문제가 생겼다"는 뜻입니다. 둘을 나누어 두면 문제를 확인할 때 범위를 좁히기 좋습니다.

마지막으로 서버 실행 코드를 둡니다.

app.listen(PORT, () => {
  console.log(`문의 접수 서버가 ${PORT}번 포트에서 실행 중입니다.`);
});

app.js는 순서가 중요합니다. 정리하면 대략 이런 흐름입니다.

  1. 필요한 도구를 불러옵니다.

  2. Express 앱과 포트 번호를 준비합니다.

  3. express.urlencoded(), express.json(), express.static() 미들웨어를 등록합니다.

  4. GET /, GET /contact 라우트를 만듭니다.

  5. app.use(inquiryRoutes)로 문의 라우터를 연결합니다.

  6. 404 처리와 오류 처리 미들웨어를 둡니다.

  7. app.listen()으로 서버를 실행합니다.

특히 app.use(inquiryRoutes)는 404 처리보다 위에 있어야 합니다. 404 처리가 먼저 나오면 Express는 아직 뒤쪽 라우트를 만나기 전에 "없는 주소"라고 응답해 버릴 수 있습니다.

CSS로 화면 정리하기

이제 public/css/style.css 파일을 엽니다. 화면을 보기 편하게 만드는 정도만 작성하겠습니다.

body {
  font-family: Arial, sans-serif;
  background: #f6f7f9;
  margin: 0;
}

.container {
  max-width: 640px;
  margin: 60px auto;
  padding: 32px;
  background: white;
  border-radius: 12px;
}

.form {
  display: grid;
  gap: 16px;
}

label {
  display: grid;
  gap: 6px;
}

input,
textarea,
button,
.button {
  font-size: 16px;
  padding: 10px;
}

button,
.button {
  background: #2563eb;
  color: white;
  border: 0;
  border-radius: 8px;
  text-decoration: none;
  display: inline-block;
}

CSS는 서버의 핵심 기능은 아니지만, 정적 파일 제공이 제대로 되는지 확인하는 좋은 기준입니다. 홈페이지에 파란 버튼과 흰색 카드 모양이 보이면 /css/style.css 요청이 성공한 것입니다.

실행하고 확인하기

무엇을 만들고 있는지 다시 정리하겠습니다. 지금 우리는 홈페이지, 문의 폼, 문의 저장, JSON 목록 API, 404와 500 처리를 가진 작은 서버를 완성하고 있습니다.

터미널에서 프로젝트 폴더가 맞는지 확인한 뒤 서버를 실행합니다.

node app.js

성공하면 터미널에 다음과 비슷한 문장이 보입니다.

문의 접수 서버가 3000번 포트에서 실행 중입니다.

브라우저에서 다음 주소를 차례로 확인합니다.

http://localhost:3000

홈페이지가 보이면 성공입니다. 문의 작성 버튼을 눌러 /contact로 이동합니다. 이름, 이메일, 문의 내용을 입력하고 제출합니다. 성공하면 "문의가 접수되었습니다"라는 문장이 보입니다.

이제 문의 목록 API를 확인합니다.

http://localhost:3000/api/inquiries

성공하면 다음과 비슷한 JSON이 보입니다.

{
  "count": 1,
  "items": [
    {
      "id": 1782432000000,
      "name": "민지",
      "email": "[email protected]",
      "message": "수업 일정이 궁금합니다.",
      "createdAt": "2026-06-26T00:00:00.000Z"
    }
  ]
}

id와 createdAt 값은 문의를 제출한 시간에 따라 달라집니다. 예시와 숫자가 달라도 괜찮습니다.

일부러 잘못된 주소도 열어 봅니다.

http://localhost:3000/not-found

"요청하신 주소를 찾을 수 없습니다."라는 문장이 보이면 404 처리도 연결된 것입니다.

오류가 난다면 실패한 것이 아니라 점검할 단서가 생긴 것입니다. 먼저 터미널의 오류 메시지에서 파일명과 줄 번호를 봅니다. 그다음 data/inquiries.json이 빈 배열 []로 시작하는지, routes/inquiries.js 파일 이름이 정확한지, app.use(inquiryRoutes)가 404 처리보다 위에 있는지 확인합니다.

README로 프로젝트 설명 남기기

마지막으로 README.md 파일을 엽니다. README는 미래의 나와 다른 사람을 위한 사용 설명서입니다. 코드를 모두 기억하지 않아도 프로젝트를 다시 실행할 수 있게 도와줍니다.

다음처럼 적어 둡니다.

# 문의 접수 서버

Express로 만든 작은 문의 접수 서버입니다.

## 실행 방법

```bash
npm install
node app.js
```

브라우저에서 `http://localhost:3000`으로 접속합니다.

## 주요 주소

- `GET /` : 홈페이지
- `GET /contact` : 문의 작성 페이지
- `POST /contact` : 문의 제출
- `GET /api/inquiries` : 문의 목록 JSON API

## 데이터 저장 위치

문의 데이터는 `data/inquiries.json` 파일에 저장됩니다.

## 수정해 볼 부분

- 문의 항목에 전화번호 추가하기
- 관리자 API에서 이메일을 숨기기
- 문의 완료 화면을 별도 HTML 파일로 나누기
- 실제 데이터베이스로 저장 방식 바꾸기

README까지 작성하면 이 프로젝트는 단순한 예제가 아니라 다시 꺼내 쓸 수 있는 출발점이 됩니다.

여기까지 연결된 것들

이 작은 문의 접수 서버 안에는 앞에서 배운 내용이 모두 들어 있습니다.

주소마다 다른 일을 시키기 위해 라우팅을 사용했습니다. 폼 제출을 읽기 위해 express.urlencoded() 미들웨어를 등록했습니다. CSS 파일을 보여 주기 위해 express.static()을 사용했습니다. 문의 관련 처리는 express.Router()로 분리했습니다. 접수된 문의 목록은 JSON API로 응답했습니다. 문의 데이터는 파일에 저장했고, 처리 중 문제가 생기면 오류 처리 미들웨어로 넘겼습니다.

아직 실제 서비스라고 부르기에는 부족한 점도 있습니다. 관리자 인증이 없고, 파일 저장은 동시에 많은 요청을 처리하기에 약합니다. 개인정보를 더 안전하게 다루는 장치도 필요합니다. 사용자가 입력한 내용을 화면에 다시 보여 줄 때는 HTML로 안전하게 바꾸는 처리도 필요합니다. 하지만 서버의 기본 흐름은 이미 갖추었습니다.

이제 Express 서버를 볼 때 낯선 덩어리로 보지 않아도 됩니다. 요청이 들어오고, 미들웨어를 지나고, 라우트에서 처리되고, 필요한 데이터를 저장하거나 꺼낸 뒤, 응답으로 돌아갑니다. 이 흐름을 이해했다면 다음 학습은 훨씬 수월해집니다.

책 목록

비개발자를 위한 Node.js Express 서버 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기