본문 바로가기

코드잇 스터디

[Express] JS로 백엔드 개발 시작하기

백엔드의 정의

🖥️ 프론트엔드 (Frontend)
→ 사용자가 보는 화면 (HTML, CSS, JavaScript)
→ 예: 버튼, 폼, 이미지, 텍스트 등

⚙️ 백엔드 (Backend)
→ 눈에 보이지 않는 뒷단에서 데이터를 처리
→ 예: 로그인 처리, 데이터 저장, 계산 등

 

REST API (Application Programming Interface)

API는 프론트엔드와 백엔드가 대화하는 창구이다. 

REST API는 

- 리소스(데이터)를 URL로 표현하고

- 행동은 HTTP 메서드(동사)로 표현한다

=> endpoint는 웬만해서는 명사(데이터)로

예시: 구독(subscription) 데이터를 다룰 때
GET /api/subscriptions → 전체 목록 조회
GET /api/subscriptions/1 → ID가 1인 항목 조회
POST /api/subscriptions → 새 항목 생성
PATCH /api/subscriptions/1 → ID가 1인 항목 수정
DELETE /api/subscriptions/1 → ID가 1인 항목 삭제

 

Express

Node.js: JS 실행기 -> JS를 브라우저 밖에서 실행 가능하게 해주는 환경 (엔진)

Express: Node.js위에서 서버를 쉽게 만들게 해주는 프레임워크

 

Express 없이도 서버를 만들 수 있지만,

Express를 쓰면 훨씬 쉽고 빠르게 만들 수 있다

# 1. 프로젝트 폴더 생성
mkdir my-api
cd my-api

# 2. package.json 생성 (-y: 질문 없이 기본값으로)
npm init -y

# 3. Express 설치
npm install express

# 4. nodemon 설치 (파일 저장할 때마다 서버 자동 재시작)
npm install --save-dev nodemon
{
  "name": "my-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node app.js",
    "dev": "nodemon app.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }
}
import express from 'express';

// 1. Express 앱 만들기
const app = express();

// 2. 포트 번호 설정 (서버가 열릴 문 번호)
const PORT = 3000;

// 3. 기본 라우트 (루트 경로 접속 시)
app.get('/', (req, res) => {
  res.send('서버가 잘 동작하고 있어요! 🎉');
});

// 4. 서버 시작
app.listen(PORT, () => {
  console.log(`서버가 http://localhost:${PORT} 에서 실행 중이에요!`);
});

Route

URL경로HTTP 메서드의 조합

"GET /api/subscriptions → 구독 목록 보여주는 함수"

"POST /api/subscriptions → 구독 추가하는 함수"

 

HTTP 메서드가 달라도 서로 다른 경로이다.

(GET / POST / PUT / PATCH / DELETE)

// 라우트의 기본 형태
app.메서드(경로, 처리함수);

app.get('/api/subscriptions', (req, res) => {
  // 이 경로로 GET 요청이 오면 실행되는 코드
});

JSON으로 응답하기

JSON은 데이터를 표현하는 형식 중 하나이다.

// res.send() → 문자열 그대로 보냄
app.get('/hello', (req, res) => {
  res.send('안녕하세요');  // Content-Type: text/html
});

// res.json() → JSON 형식으로 보냄 (주로 이걸 씁니다!)
app.get('/api/user', (req, res) => {
  res.json({ id: 1, name: '홍길동' });  // Content-Type: application/json
});

상태코드

응답에는 항상 상태코드가 함께온다. 


API 테스트 도구 (REST Client)

API 엔드포인트 하나 만들때마다 잘 동작하는지 확인해줘야한다. 

REST Clinet (Huachao Mao가 만듦)을 사용하도록 하겠

프로젝트에 .http파일을 생성하고 안에 요청 내용을 성한다.

Send Request 버튼을 클릭하면 오른쪽에 응답 결과가 보인다

 

주의점:

- 변수는 @varName = value 방식으로 정의한다

- Header내용과 data사이에 한줄 띄워줘야 돌아간ㄷ

- 돌리고 싶은 부분을 항이라이트하고 cntrl + alt + r을 해준다

### 파일 상단에 변수 명
@baseUrl = https://panda-market-api.vercel.app

GET {{baseUrl}}/articles?pageSize=10&orderBy=recent

POST {{baseUrl}}/articles
Content-Type: application/json

{
  "image": "https://example.com/...",
  "content": "게시글 내용입니다.",
  "title": "게시글 제목입니다."
}

PATCH {{baseUrl}}/articles/1
Content-Type: application/json

{
  "image": "https://example.com/...",
  "content": "게시글 내용입니다.",
  "title": "게시글 제목입니다."
}

DELETE {{baseUrl}}/articles/1

 

사용방법

1. http 확장자의 파일을 작성한다 (HTTPMETHOD url)

2. 서버를 킨다

3. cntrl + alt + r을 누른다


CRUD API 만들기

GET 요청

인메모리 데이터로 get 요청을 해보도록 하겠다

1) 기본적인 구조

import express from 'express';

const app = express();

// 임시 데이터 (나중에 DB로 교체할 거예요)
const subscriptions = [
  { id: 1, service: 'Netflix', price: 9900, cycle: 'monthly' },
  { id: 2, service: 'YouTube Premium', price: 14900, cycle: 'monthly' },
  { id: 3, service: 'Spotify', price: 10900, cycle: 'monthly' },
];

// 전체 목록 조회
app.get('/subscriptions', (req, res) => {
  res.json(subscriptions);
});

app.listen(3000, () => console.log('서버 실행 중!'));

 

2) URL 파라미터로 특정 항목 조회하기

req.params.paramName 방식으로 URL 파라미터를 꺼내낸다. 

앞에 endpoint에 있는 :id를 보고 req.params.id가 인식되는 것이다. 

app.get('/subscriptions/:id', (req, res) => {
  // req.params.id 로 URL에서 id 값을 가져옴
  const id = Number(req.params.id);  // 문자열로 오니까 숫자로 변환!
  //아니면 const {param1, param2, param3} = req.params식으로 하는게 깔끔할수 있다

  const subscription = subscriptions.find(sub => sub.id === id);

  if (!subscription) {
    // 못 찾으면 404 에러
    return res.status(404).json({ message: `ID ${id}인 구독을 찾을 수 없어요.` });
  }

  res.json(subscription);
});

 

3) URL QueryString으로 필터링하기

req.query.queryName방식으로 URL QueryString 값을 꺼낸다. 

app.get('/subscriptions', (req, res) => {
  // req.query 로 쿼리스트링 값을 가져옴
  const { cycle, minPrice } = req.query;

  let results = subscriptions;

  // cycle 파라미터가 있으면 필터링
  if (cycle) {
    results = results.filter(sub => sub.cycle === cycle);
  }

  // minPrice 파라미터가 있으면 필터링
  if (minPrice) {
    results = results.filter(sub => sub.price >= Number(minPrice));
  }

  res.json({
    count: results.length,
    data: results
  });
});

 

이때 응답을 줄때 res.send()모다는 res.json()하는 것이 좋다

sementic하기 때문에 실무 컨벤션으로 잡혀 있다.

항상 Content-Type:application/json으로 헤더를 지정해놓을 수 있어서 편하다

re.send는 전달하는 값의 타입에 따라 Content-Type이 달라져서 문제가 생긴다

 

POST 요청

POST 요청으로 데이터를 보낼때에는

URL이 아닌 요청 본문 (Body)에 데이터를 담아서 보낸다.

GET → URL에 정보 표시 (누구나 볼 수 있음)

POST → Body에 정보 숨김 (보안상 더 안전)

 

1) 필요 미들웨어

Express는 Body를 자동으로 파싱해주지 않기 때문에 express.json() 미들웨어를 꼭 추가해야한다. 

app.use(express.json())을 꼭 추가해야지 req.body를 쓸 수 있다.

import express from 'express';
import { nanoid } from 'nanoid';

const app = express();

// ✅ 이 줄을 꼭 추가해야 req.body를 쓸 수 있어요!
// req.body = payload;
app.use(express.json()); 

let subscriptions = [
  { id: nanoid(), service: 'Netflix', price: 9900, cycle: 'monthly' },
];

app.post('/subscriptions', (req, res) => {
  const { service, price, cycle } = req.body;

  const newSubscription = {
    id: nanoid(),  
    service,
    price,
    cycle,
  };

  subscriptions.push(newSubscription);
  res.status(201).json(newSubscription);
});

 

(+) 데이터 추가하기 - 아직 DB 작성안해서 데이터를 직접 만들어야는데

id를 무엇으로 생성해줘야할까 -> nanoid

💡 ID를 어떻게 만들까?
숫자를 직접 계산해서 만들 수도 있지만, nanoid 라이브러리를 쓰면 한 줄로 끝나요!
nanoid가 만들어주는 ID 예시: "V1StGXR8", "abc123xy"
→ 짧고, 겹칠 일 없고, 실무에서도 실제로 쓰여요.

 

2) body 검증하기

- 클라이언트가 필수 데이터를 빠뜨리고 보낼 수도 있다

- 그래서 저장하기 전에 데이터가 올바른지 먼저 확인해야한다.

app.post('/subscriptions', (req, res) => {
  const { service, price, cycle } = req.body;

  // ✅ 필수 필드 확인
  if (!service || !price || !cycle) {
    return res.status(400).json({
      message: 'service, price, cycle은 필수 항목이에요.',
    });
  }

  // ✅ 가격이 숫자인지 확인
  if (typeof price !== 'number' || price <= 0) {
    return res.status(400).json({
      message: '가격은 0보다 큰 숫자여야 해요.',
    });
  }

  // ✅ 구독 주기 값 확인
  const validCycles = ['daily', 'weekly', 'monthly', 'yearly'];
  if (!validCycles.includes(cycle)) {
    return res.status(400).json({
      message: `구독 주기는 ${validCycles.join(', ')} 중 하나여야 해요.`,
    });
  }

  // 검증 통과 → 저장
  const newId = subscriptions.length > 0
    ? Math.max(...subscriptions.map(s => s.id)) + 1
    : 1;

  const newSubscription = { id: newId, service, price, cycle };
  subscriptions.push(newSubscription);

  res.status(201).json(newSubscription);
});

 

PATCH랑 DELETE

app.patch('/subscriptions/:id', (req, res) => {
  const { id } = req.params;  // nanoid는 문자열이라 변환 필요 없어요!

  const index = subscriptions.findIndex(sub => sub.id === id);

  if (index === -1) {
    return res.status(404).json({ message: '구독을 찾을 수 없어요.' });
  }

  subscriptions[index] = {
    ...subscriptions[index],
    ...req.body,
  };

  res.json(subscriptions[index]);
});
app.delete('/subscriptions/:id', (req, res) => {
  const { id } = req.params;

  const index = subscriptions.findIndex(sub => sub.id === id);

  if (index === -1) {
    return res.status(404).json({ message: '구독을 찾을 수 없어요.' });
  }

  const deleted = subscriptions.splice(index, 1)[0];

  res.json({ message: '삭제되었어요.', data: deleted });
});

DB 사용하기

데이터를 JS 파일 (메모리)안에 저장하면 문제가 생긴다.
서버를 재시작하면 데이터가 사라져요

서버가 여러 대면 데이터가 서로 달라요

데이터가 많아지면 검색이 느려져요

 

이를 해결하는 방법이 바로 데이터베이스를 사용하는 것이다.

 

MongoDB

문서(Document)기반 NoSQL 데이터베이스

- 데이터를 JSON과 비슷한 형태로 저장

- 스키마(테이블구조)가 유연해서 필드 추가가 쉬움)

- JS랑 잘 어울림 (JSON형태 그대로 저장)

(+) model: 특정 collection 내에 CRUD를 가능케하는 인터페이스 객체 (콜렉션을 추상적으로 가리키는 객체)

Username: quothraven1122
Password: 1122

Connection String: mongodb+srv://quothraven1122:1122@cluster0.vf4m04h.mongodb.net/?appName=Cluster0

 

Mongoose로 MongoseDB 연결하기

Mongoose는 Node.js에서 MongoDB를 편하게 쓰게 해주는 라이브러리이다. 

MongoDB를 직접 사용하면:
- 복잡한 쿼리를 직접 작성해야 함
- 데이터 형식을 직접 검증해야 함
- 코드가 복잡해짐

Mongoose를 쓰면:
✅스키마로 데이터 구조를 정의할 수 있음
✅간단한 메서드로 CRUD 가능 (create, find, findById...)
✅자동 유효성 검사

 

1. npm install mongoose dotevn

2. 코드로 환경 세팅

 

환경변수로 안전하게 관리하기

1) .env 파일을 생성한다

MONGODB_URI=mongodb+srv://사용자이름:비밀번호@cluster0.xxxxx.mongodb.net/my-api
PORT=3000

 

2) env 파일을 .gitignore한다

node_modules/
.env

 

3) app.js에서 dotenv.config()를 호출한다.

import dotenv from 'dotenv';
dotenv.config();  // .env 파일 로드 (맨 먼저!)

 

4) .env 파일을 process.env.ENVVAR 형태로 꺼낸다


스키마와 모델 만들기

스키마: 데이터 설계도

테이블 속성들의 타입과 유효성 검사를 지정해준다

(서비스는 3중 유효성 검사 - Broswer, API, DB)

예: 구독 데이터 스키마
- service: 문자열, 필수
- price: 숫자, 필수, 0 이상
- cycle: 문자열, monthly/yearly/weekly/daily 중 하나
- startDate: 날짜

이 설계도에 맞지 않는 데이터는 저장 거부! → 데이터 일관성을 유지할 수 있어요.

- required: 필수일때 추가

- trim: 자동으로 앞뒤 공백 제거

- min: 최소 값 지정

- enum: 도메인 설정 (이것중에 하나 골라)

- default: 디폴트값 지정

import mongoose from 'mongoose';

// 1. 스키마 정의 (데이터 구조 설계도)
const subscriptionSchema = new mongoose.Schema(
  {
    service: {
      type: String,
      required: [true, '서비스 이름은 필수예요.'],
      trim: true,  // 앞뒤 공백 자동 제거
    },
    price: {
      type: Number,
      required: [true, '가격은 필수예요.'],
      min: [0, '가격은 0 이상이어야 해요.'],
    },
    cycle: {
      type: String,
      required: [true, '구독 주기는 필수예요.'],
      enum: {
        values: ['daily', 'weekly', 'monthly', 'yearly'],
        message: '올바른 구독 주기가 아니에요.',
      },
    },
    startDate: {
      type: Date,
      default: Date.now,  // 기본값: 현재 날짜
    },
  },
  {
    timestamps: true,  // createdAt, updatedAt 자동 생성
  }
);

// 2. 모델 생성 (실제 DB와 연결되는 객체)
const Subscription = mongoose.model('Subscription', subscriptionSchema);

export default Subscription;

시드 데이터 넣기

시드 데이터: 개발/ 테스트용으로 DB에 미리 넣어두는 샘플 데이터

왜 필요한가요?
- DB를 처음 연결하면 데이터가 텅 비어있어요
- GET 요청을 테스트하려면 조회할 데이터가 있어야 해요
- 매번 POST로 하나씩 추가하기엔 번거로워요
→ 시드 스크립트를 한 번 실행하면 샘플 데이터가 한꺼번에 들어가요!
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import Subscription from './models/Subscription.js';

dotenv.config();

const seedData = [
  { service: 'Netflix', price: 9900, cycle: 'monthly' },
  { service: 'YouTube Premium', price: 14900, cycle: 'monthly' },
  { service: 'Spotify', price: 10900, cycle: 'monthly' },
  { service: 'iCloud 50GB', price: 1100, cycle: 'monthly' },
  { service: 'Adobe CC', price: 61600, cycle: 'yearly' },
];

async function seed() {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log('✅ DB 연결 성공');

  // 기존 데이터 전체 삭제 후 새로 삽입
  await Subscription.deleteMany({});
  console.log('🗑️ 기존 데이터 삭제 완료');

  await Subscription.insertMany(seedData);
  console.log(`🌱 시드 데이터 ${seedData.length}개 삽입 완료`);

  await mongoose.disconnect();
  console.log('👋 DB 연결 종료');
}

seed();

이러고 package.json에 seed라는 스크립트 추가하면 좋다

node see.js를 실행하면 바로 시드 데이터가 설정된다


CRUD 데이터 다루기

1. Create - 데이터 생성

import Subscription from '../models/Subscription.js';

const newSub = await Subscription.create({
  service: 'Netflix',
  price: 9900,
  cycle: 'monthly',
});

 

Controller: req, res를 받아서 처리하는 함수

export const createSubscription = async (req, res) => {
  try {
    const subscription = await Subscription.create(req.body);
    res.status(201).json(subscription);
  } catch (error) {
    // Mongoose 유효성 검사 실패 시
    res.status(400).json({ message: error.message });
  }
};

 

2. Read - 데이터 조회

// 전체 조회
const subscriptions = await Subscription.find();

// 조건으로 조회
const monthlySubs = await Subscription.find({ cycle: 'monthly' });

// 특정 ID로 조회
const subscription = await Subscription.findById(id);

// 정렬 + 제한
const sorted = await Subscription.find().sort({ price: -1 }).limit(10);
//                                              ↑ -1: 내림차순, 1: 오름차순
// 전체 목록 조회
export const getSubscriptions = async (req, res) => {
  try {
    const subscriptions = await Subscription.find();
    res.json(subscriptions);
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
};

// 특정 항목 조회
export const getSubscriptionById = async (req, res) => {
  try {
    const subscription = await Subscription.findById(req.params.id);

    if (!subscription) {
      return res.status(404).json({ message: '구독을 찾을 수 없어요.' });
    }

    res.json(subscription);
  } catch (error) {
    // 잘못된 ID 형식일 때 (예: 숫자로 요청)
    res.status(400).json({ message: '유효하지 않은 ID 형식이에요.' });
  }
};

 

3. Update 데이터 수정

const updated = await Subscription.findByIdAndUpdate(
  id,        // 수정할 문서의 ID
  req.body,  // 수정할 내용
  {
    new: true,          // 수정된 문서를 반환 (기본은 수정 전 문서)
    runValidators: true // 스키마 유효성 검사 실행
  }
);
export const updateSubscription = async (req, res) => {
  try {
    const subscription = await Subscription.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );

    if (!subscription) {
      return res.status(404).json({ message: '구독을 찾을 수 없어요.' });
    }

    res.json(subscription);
  } catch (error) {
    res.status(400).json({ message: error.message });
  }
};

 

4. delete - 데이터 삭제

const deleted = await Subscription.findByIdAndDelete(id);

컨트롤러 적용 예시

export const deleteSubscription = async (req, res) => {
  try {
    const subscription = await Subscription.findByIdAndDelete(req.params.id);

    if (!subscription) {
      return res.status(404).json({ message: '구독을 찾을 수 없어요.' });
    }

    // 삭제 성공 → 204 No Content
    res.status(204).send();
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
};

비동기 에러 처리 패턴

모든 라우트에 try-catch를 반복 작성하면 코드가 지저분해져요.
→ asyncHandler 안에서 에러를 직접 처리하면 한 번에 해결!

MongoDB에서 자주 발생하는 에러 두 가지:
- ValidationError → 스키마 유효성 검사 실패 (예: 필수 필드 누락)
    → 400 Bad Request
- CastError → 잘못된 ID 형식으로 조회 시 발생
    → 404 Not Found
- 그 외 → 예상치 못한 서버 오류
    → 500 Internal Server Error

 

asyncHandler.js

export const asyncHandler = (fn) => {
  return async (req, res) => {
    try {
      await fn(req, res);
    } catch (e) {
      if (e.name === 'ValidationError') {
        // 스키마 유효성 검사 실패 (필수 필드 누락, enum 불일치 등)
        res.status(400).send({ message: e.message });
      } else if (e.name === 'CastError') {
        // 올바르지 않은 ID 형식으로 조회했을 때
        res.status(404).send({ message: 'Cannot find given id.' });
      } else {
        // 그 외 예상치 못한 서버 오류
        res.status(500).send({ message: e.message });
      }
    }
  };
};

import { asyncHandler } from '../utils/asyncHandler.js';
// try-catch 없이 깔끔하게! 에러는 asyncHandler 내부에서 처리됨
export const getTodos = asyncHandler(async (req, res) => {
  const todos = await Todo.find();
  res.json(todos);
});

배포 전에 CORS 설정하기

1. 브라우저의 기본 규칙 SOP (Same-Origin Policy)

서버는 기본적으로 다른 도메인에서 오는 요청을 막는다.

"같은 출처끼리만 자유롭게 통신해. 다른 출처는 기본적으로 막을게."

모든 브라우저의 기본값입니다. 우리가 켠 적 없는데 원래 켜져 있어요.

 

출처 = 프로토콜 + 도메인 + 포트

 

2. CORS (Cross-Origin) - SOP의 예외를 허용하는 규칙

현실에서는 다른 출처랑 통신할 일이 너무 많다. 

요즘 웹은 거의다 프론트/백 분리 구조인데, SOP 그대로 두면 아무것도 못한다. 

그래서 안전하게 다른 출처와 통신할 수 있게 해주는 규칙이 필요해졌다.

그것이 바로 CORS이다. 

 

CORS = Cross-Origin Resource Sharing

한 줄 정의: "다른 출처여도 서버가 허락하면 통신해도 돼" 라고 알려주는 약속

SOP와 CORS 모두 브라우저에 강제하는 보안 규칙이다.

 

🚨 CORS 에러는 "서버가 응답을 안 줘서"가 아니라 "브라우저가 받은 응답을 자바스크립트에게 안 넘겨줘서" 발생

즉, 서버 입장에선 요청 받고 처리하고 응답까지 다 보냈어요. 다만 브라우저가 그 응답을 코드에 전달하지 않고 차단하는 것뿐이에요. 
💡 그래서 REST Client/Postman으로 테스트할 때는 CORS가 전혀 문제없었던 거예요! CORS가 필요해지는 순간은 
실제 프론트엔드 코드에서 브라우저로 fetch()를 호출할 때예요.

 

3. CORS 세팅 방법

npm install cors

import cors from 'cors';

// 개발할 때 (모든 도메인 허용 - 개발 편의상)
app.use(cors());

// 배포할 때 (특정 도메인만 허용 - 보안상 좋음)
app.use(cors({
  origin: [
    'http://localhost:5173',           // 개발용 프론트엔드
    'https://my-app.vercel.app',       // 배포된 프론트엔드
  ],
}));

근데 도메인들도 환경변수로 관리하는것이 좋다