pgms

[4주차] - [9기] 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 10회차 - 18

Ejah 2026. 1. 28. 19:36

회원 API 설계

  1. 로그인 : POST /login
  • req : body (id, pwd)
  • res : ${name}님 환영합니다 // => 메인 페이지
  1. 회원 가입 : POST /join
  • req : body (userId, pwd, name)
  • res : ${name}님 환영합니다 // => 로그인 페이지
  1. 회원 개별 “조회” : GET /users/:id
  • req : URL (id)
  • res : userId, name
  1. 회원 개별 “탈퇴” : DELETE /users/:id
  • req : URL (id)
  • res : ${name}님 다음에 또 뵙겠습니다. or 메인 페이지

서비스를 만들다 보면 로그인리소스 CRUD(API) 는 거의 항상 함께 등장합니다.
이번 글에서는 Express로 아래를 한 번에 정리합니다.

  • 로그인 기본 로직 → 예외 처리 → 고도화(토큰/미들웨어)
  • 채널(Channel) API 설계(1단/2단) → 라우트 틀 → CRUD 구현
  • 자바스크립트 Object.keys()로 입력값 검증까지

저장소는 우선 Map()을 DB처럼 사용해서 로직에 집중하고, 나중에 실제 DB로 옮기기 좋게 구성

 

로그인 기본 로직

가장 단순한 로그인은 보통 이런 흐름입니다.

  1. 클라이언트가 POST /auth/login에 email, password를 보냄
  2. 서버는 “사용자 존재 여부 + 비밀번호 일치 여부” 확인
  3. 성공하면 “로그인 됐다고 증명할 정보(세션/토큰)”를 응답

아래 예제는 메모리 유저 DB(Map) 로 로그인 처리합니다.

로그인 예외 처리

로그인 API에서 흔한 예외는 크게 4가지입니다.

  • 요청 바디가 없음 / 형식이 이상함 → 400 Bad Request
  • 필수값 누락 → 400 Bad Request
  • 사용자가 없음 → 404 Not Found 또는 401 Unauthorized
  • 비밀번호 틀림 → 401 Unauthorized

그리고 Express에서는 예외 처리를 한 곳(에러 미들웨어) 로 모아두면 코드가 깔끔해집니다.

자바스크립트 Object.keys()로 입력값 검증하기

Object.keys(obj)는 객체의 키 목록 배열을 돌려줍니다.

  • “요청 바디가 비었는지” 체크: Object.keys(req.body).length === 0
  • “허용되지 않은 필드가 들어왔는지” 체크(화이트리스트 검증)
  • “필수 필드가 모두 존재하는지” 체크

예를 들어 로그인에서 email, password만 받아야 한다면:

  • body에 다른 값(role, isAdmin)이 섞여오면 차단하는 게 안전합니다.

로그인 고도화

로그인을 “성공/실패”로 끝내지 말고, 실제 서비스처럼 고도화하려면 보통 아래 중 하나를 합니다.

  • 세션 기반: 서버가 세션 저장소를 갖고, 쿠키로 세션ID 유지
  • JWT 토큰 기반: 서버는 토큰을 발급하고, 클라이언트는 Authorization: Bearer ...로 요청

여기서는 실습이 편한 JWT 방식 예시를 넣어볼게요.
(실서비스에서는 만료, 리프레시 토큰, 서명키 관리, HTTPS 등도 함께 고려!)

채널 API 설계 1단

우선 “채널”을 간단히 이렇게 정의해볼게요.

  • 채널: { id, name, description, createdAt }

1단에서는 기본 CRUD만 설계합니다.

  • POST /channels : 채널 생성
  • GET /channels/:id : 채널 개별 조회
  • PUT /channels/:id : 채널 수정
  • DELETE /channels/:id : 채널 삭제
  • GET /channels : 채널 전체 조회

채널 API 설계 2단

2단에서는 현실적인 요소를 조금 추가해요.

  • 로그인한 사람만 채널 생성/수정/삭제 가능
  • 입력값 검증 강화(허용 필드만 받기)
  • 공통 에러 포맷 통일
  • 라우트 구조 분리(파일 분리 가능)

채널 API 코드 틀 route

보통 Express 프로젝트에서는 다음처럼 라우트를 나눕니다.

  • /auth/* : 로그인/회원 관련
  • /channels/* : 채널 리소스 CRUD
// app.js
const express = require("express");
const jwt = require("jsonwebtoken");

const app = express();
app.use(express.json());

// ====== In-memory DB (Map) ======
const usersByEmail = new Map();
// 실습용 유저 1명
usersByEmail.set("test@demo.com", {
  id: 1,
  email: "test@demo.com",
  password: "1234", // 실서비스에선 절대 평문 저장 X (bcrypt 등 해시 사용)
  name: "테스트유저",
});

const channels = new Map();
let channelIdSeq = 1;

const JWT_SECRET = "dev-secret"; // 실서비스에선 env로 관리

// ====== utils ======
function assertBodyNotEmpty(req) {
  if (!req.body || Object.keys(req.body).length === 0) {
    const err = new Error("요청 바디가 비어있습니다.");
    err.status = 400;
    throw err;
  }
}

function allowOnlyKeys(obj, allowedKeys) {
  const keys = Object.keys(obj);
  const invalid = keys.filter((k) => !allowedKeys.includes(k));
  if (invalid.length > 0) {
    const err = new Error(`허용되지 않은 필드가 포함됨: ${invalid.join(", ")}`);
    err.status = 400;
    throw err;
  }
}

function requireKeys(obj, requiredKeys) {
  const missing = requiredKeys.filter((k) => obj[k] === undefined || obj[k] === "");
  if (missing.length > 0) {
    const err = new Error(`필수 값 누락: ${missing.join(", ")}`);
    err.status = 400;
    throw err;
  }
}

function authMiddleware(req, res, next) {
  try {
    const auth = req.headers.authorization;
    if (!auth || !auth.startsWith("Bearer ")) {
      const err = new Error("인증이 필요합니다. (Bearer 토큰 없음)");
      err.status = 401;
      throw err;
    }

    const token = auth.slice("Bearer ".length);
    const payload = jwt.verify(token, JWT_SECRET); // 유효성/만료 검증
    req.user = payload; // { userId, email, name }
    next();
  } catch (e) {
    e.status = e.status || 401;
    next(e);
  }
}

// ====== Auth Routes ======
app.post("/auth/login", (req, res, next) => {
  try {
    assertBodyNotEmpty(req);

    allowOnlyKeys(req.body, ["email", "password"]);
    requireKeys(req.body, ["email", "password"]);

    const { email, password } = req.body;

    const user = usersByEmail.get(email);
    if (!user) {
      const err = new Error("존재하지 않는 사용자입니다.");
      err.status = 404;
      throw err;
    }

    if (user.password !== password) {
      const err = new Error("비밀번호가 올바르지 않습니다.");
      err.status = 401;
      throw err;
    }

    const token = jwt.sign(
      { userId: user.id, email: user.email, name: user.name },
      JWT_SECRET,
      { expiresIn: "1h" }
    );

    res.status(200).json({
      message: "로그인 성공",
      token,
      user: { id: user.id, email: user.email, name: user.name },
    });
  } catch (e) {
    next(e);
  }
});

// ====== Channel Routes ======

// 채널 전체 조회 (로그인 없이도 가능하게 해도 됨)
app.get("/channels", (req, res) => {
  const list = Array.from(channels.entries()).map(([id, ch]) => ({
    id,
    ...ch,
  }));
  res.json({ count: list.length, channels: list });
});

// 채널 개별 조회
app.get("/channels/:id", (req, res, next) => {
  try {
    const id = Number(req.params.id);
    if (!channels.has(id)) {
      const err = new Error("채널을 찾을 수 없습니다.");
      err.status = 404;
      throw err;
    }
    res.json({ id, ...channels.get(id) });
  } catch (e) {
    next(e);
  }
});

// 채널 생성 (로그인 필요)
app.post("/channels", authMiddleware, (req, res, next) => {
  try {
    assertBodyNotEmpty(req);

    allowOnlyKeys(req.body, ["name", "description"]);
    requireKeys(req.body, ["name"]);

    const now = new Date().toISOString();
    const newChannel = {
      name: req.body.name,
      description: req.body.description || "",
      createdAt: now,
      createdBy: req.user.userId,
    };

    const id = channelIdSeq++;
    channels.set(id, newChannel);

    res.status(201).json({ message: "채널 생성 완료", id, ...newChannel });
  } catch (e) {
    next(e);
  }
});

// 채널 수정 (로그인 필요)
app.put("/channels/:id", authMiddleware, (req, res, next) => {
  try {
    assertBodyNotEmpty(req);

    allowOnlyKeys(req.body, ["name", "description"]);
    const id = Number(req.params.id);

    if (!channels.has(id)) {
      const err = new Error("수정할 채널이 없습니다.");
      err.status = 404;
      throw err;
    }

    const prev = channels.get(id);
    const updated = {
      ...prev,
      name: req.body.name ?? prev.name,
      description: req.body.description ?? prev.description,
      updatedAt: new Date().toISOString(),
      updatedBy: req.user.userId,
    };

    channels.set(id, updated);
    res.json({ message: "채널 수정 완료", id, ...updated });
  } catch (e) {
    next(e);
  }
});

// 채널 삭제 (로그인 필요)
app.delete("/channels/:id", authMiddleware, (req, res, next) => {
  try {
    const id = Number(req.params.id);

    if (!channels.has(id)) {
      const err = new Error("삭제할 채널이 없습니다.");
      err.status = 404;
      throw err;
    }

    channels.delete(id);
    res.status(204).send(); // No Content
  } catch (e) {
    next(e);
  }
});

// ====== Error Handler (중요!) ======
app.use((err, req, res, next) => {
  const status = err.status || 500;

  res.status(status).json({
    error: {
      message: err.message || "서버 오류",
      status,
    },
  });
});

app.listen(1234, () => {
  console.log("Server running on http://localhost:1234");
});

로그인해서 토큰 받기

POST /auth/login
Content-Type: application/json

{
  "email": "test@demo.com",
  "password": "1234"
}

응답의 token을 복사해두고,

POST /channels
Authorization: Bearer <토큰>
Content-Type: application/json

{
  "name": "공지",
  "description": "서비스 공지 채널"
}

3) 채널 개별 조회

GET /channels/1

수정

PUT /channels/1
Authorization: Bearer <토큰>
Content-Type: application/json

{
  "description": "공지/업데이트 내용을 공유합니다."
}

삭제

DELETE /channels/1
Authorization: Bearer <토큰>

채널 전체 조회

GET /channels