회원 API 설계
- 로그인 : POST /login
- req : body (id, pwd)
- res : ${name}님 환영합니다 // => 메인 페이지
- 회원 가입 : POST /join
- req : body (userId, pwd, name)
- res : ${name}님 환영합니다 // => 로그인 페이지
- 회원 개별 “조회” : GET /users/:id
- req : URL (id)
- res : userId, name
- 회원 개별 “탈퇴” : DELETE /users/:id
- req : URL (id)
- res : ${name}님 다음에 또 뵙겠습니다. or 메인 페이지
서비스를 만들다 보면 로그인과 리소스 CRUD(API) 는 거의 항상 함께 등장합니다.
이번 글에서는 Express로 아래를 한 번에 정리합니다.
- 로그인 기본 로직 → 예외 처리 → 고도화(토큰/미들웨어)
- 채널(Channel) API 설계(1단/2단) → 라우트 틀 → CRUD 구현
- 자바스크립트 Object.keys()로 입력값 검증까지
저장소는 우선 Map()을 DB처럼 사용해서 로직에 집중하고, 나중에 실제 DB로 옮기기 좋게 구성

로그인 기본 로직
가장 단순한 로그인은 보통 이런 흐름입니다.
- 클라이언트가 POST /auth/login에 email, password를 보냄
- 서버는 “사용자 존재 여부 + 비밀번호 일치 여부” 확인
- 성공하면 “로그인 됐다고 증명할 정보(세션/토큰)”를 응답
아래 예제는 메모리 유저 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'pgms' 카테고리의 다른 글
| [4주차] - [9기] 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 10회차 - 20 (0) | 2026.01.30 |
|---|---|
| [4주차] - [9기] 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 10회차 - 19 (0) | 2026.01.29 |
| [4주차] - [9기] 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 10회차 - 17 (1) | 2026.01.27 |
| [4주차] - [9기] 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 10회차 - 16 (0) | 2026.01.26 |
| [3주차] - [9기] 타입스크립트로 함께하는 웹 풀 사이클 개발(React, Node.js) 10회차 - 15 (0) | 2026.01.25 |