express-session 설정
HTTP는 무상태(stateless) 프로토콜이다. 서버는 요청이 올 때마다 "이 사람이 누구인지" 알 수 없다. 로그인한 사용자가 다음 페이지로 이동하면, 서버 입장에서는 완전히 새로운 요청이다. 이 문제를 해결하려면 서버가 클라이언트를 식별할 수 있는 방법이 필요하다.
세션(session)은 이 문제에 대한 가장 전통적인 해결책이다. 클라이언트가 처음 접속하면 서버가 고유한 세션 ID를 생성하고, 이 ID를 쿠키에 담아서 브라우저에 보낸다. 이후 브라우저는 매 요청마다 이 쿠키를 자동으로 포함하기 때문에, 서버는 세션 ID를 보고 "아, 이 사람이구나"라고 식별할 수 있다.
express-session은 Express 애플리케이션에서 이 세션 메커니즘을 구현해주는 미들웨어다. 세션 생성, 저장, 조회, 삭제를 알아서 처리하고, 개발자는 req.session 객체에 데이터를 넣고 빼기만 하면 된다.
기본 설정
express-session의 설정은 크게 세 영역으로 나뉜다. 세션 자체의 동작 방식, 쿠키 옵션, 그리고 저장소 설정이다.
const session = require("express-session");
app.use(
session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
},
})
);
이 설정 하나하나가 보안과 성능에 직접적인 영향을 미치기 때문에, 각 옵션이 왜 존재하는지 이해하고 설정해야 한다.
secret
세션 ID 쿠키를 서명(sign)할 때 사용하는 키다. 서명이라는 건 세션 ID가 변조되지 않았음을 검증하는 장치다.
secret: "my-secret-key";
브라우저에 저장되는 쿠키 값은 s:세션ID.서명값 형태다. 서버는 요청이 올 때마다 secret으로 서명을 다시 계산해서 쿠키에 들어있는 서명값과 비교한다. 만약 누군가 세션 ID를 임의로 바꿨다면 서명이 일치하지 않기 때문에 해당 세션은 무효로 처리된다.
// 배열로 여러 키를 지정할 수도 있다
secret: ["new-secret", "old-secret"];
배열로 지정하면 첫 번째 키로 새 세션을 서명하고, 기존 세션 검증 시에는 모든 키를 순서대로 시도한다. 이렇게 하면 secret을 교체할 때 기존 사용자의 세션이 갑자기 무효화되는 것을 방지할 수 있다.
실무에서는 환경변수로 관리하고, 충분히 긴 랜덤 문자열을 사용해야 한다. 짧거나 예측 가능한 secret은 서명 위조 공격에 취약하다.
resave
요청 처리 중에 세션 데이터가 변경되지 않았더라도 세션 저장소에 다시 저장할지를 결정한다.
resave: false;
true로 설정하면 세션에 아무 변경이 없어도 매 요청마다 저장소에 write 연산이 발생한다. 대부분의 경우 불필요한 오버헤드다. 다만 저장소가 세션의 만료 시간을 자체적으로 갱신하지 않는 경우(예: 일부 오래된 저장소 구현)에는 true로 설정해야 세션이 의도치 않게 만료되는 것을 방지할 수 있다.
현대적인 세션 저장소(connect-redis, connect-mongo 등)는 대부분 touch 메서드를 지원하기 때문에 false로 설정하면 된다. touch는 세션 데이터를 다시 쓰지 않고 만료 시간만 갱신하는 경량 연산이다.
saveUninitialized
새로 생성된 세션에 아무 데이터도 저장하지 않았을 때, 이 빈 세션을 저장소에 저장할지를 결정한다.
saveUninitialized: false;
true로 설정하면 사이트에 접속만 해도 빈 세션이 저장소에 쌓인다. 로그인하지 않은 방문자가 100명이면 빈 세션 100개가 생기는 셈이다. 저장소 공간 낭비이고, GDPR 같은 개인정보 규정에서도 사용자 동의 없이 쿠키를 설정하면 문제가 될 수 있다.
false로 설정하면 req.session에 실제로 데이터를 저장할 때만 세션이 생성되고 쿠키가 전송된다. 로그인 시점에 req.session.userId = user.id 같은 코드가 실행될 때 비로소 세션이 만들어진다.
cookie 옵션
세션 ID를 담는 쿠키의 동작을 제어한다. 이 설정이 보안에서 가장 중요한 부분이다.
secure
cookie: {
secure: true;
}
true로 설정하면 HTTPS 연결에서만 쿠키가 전송된다. HTTP 연결에서는 쿠키가 아예 설정되지 않는다. 중간자 공격(MITM)으로 쿠키가 탈취되는 것을 방지하는 가장 기본적인 방어 수단이다.
문제는 개발 환경에서는 보통 HTTP를 사용한다는 점이다. 그래서 환경에 따라 분기하는 것이 일반적이다.
cookie: {
secure: process.env.NODE_ENV === "production";
}
주의할 점은 프록시(nginx, 로드밸런서) 뒤에서 앱이 실행될 때다. 클라이언트는 HTTPS로 접속하지만, 프록시가 SSL을 처리하고 Express에는 HTTP로 요청을 전달하기 때문에 Express는 "이건 HTTP 요청이네" 라고 판단하고 쿠키를 설정하지 않는다. 이 경우 Express에 프록시를 신뢰하라고 알려줘야 한다.
app.set("trust proxy", 1);
httpOnly
cookie: {
httpOnly: true;
}
true로 설정하면 JavaScript에서 document.cookie로 쿠키에 접근할 수 없다. 브라우저가 HTTP 요청을 보낼 때만 쿠키를 포함한다. XSS(Cross-Site Scripting) 공격으로 세션 쿠키가 탈취되는 것을 막는 핵심 방어 수단이다.
공격자가 악성 스크립트를 주입하는 데 성공하더라도, httpOnly 쿠키는 JavaScript로 읽을 수 없기 때문에 세션 하이재킹이 훨씬 어려워진다. 세션 쿠키에 대해서는 거의 항상 true로 설정해야 한다.
maxAge
cookie: {
maxAge: 24 * 60 * 60 * 1000; // 24시간 (밀리초)
}
쿠키의 수명을 밀리초 단위로 설정한다. 위 설정은 24시간 후에 쿠키가 만료된다는 뜻이다. 만료되면 브라우저가 자동으로 쿠키를 삭제하고, 다음 요청부터 세션 ID가 전송되지 않는다.
설정하지 않으면 세션 쿠키(session cookie)가 되어 브라우저를 닫으면 사라진다. "로그인 유지" 기능이 필요하다면 maxAge를 설정하고, 보안이 중요한 서비스라면 짧게 설정하거나 아예 설정하지 않는 것이 좋다.
sameSite
cookie: {
sameSite: "lax"; // 기본값
}
다른 사이트에서 현재 사이트로 요청을 보낼 때 쿠키를 포함할지를 제어한다. CSRF(Cross-Site Request Forgery) 방어에 중요한 옵션이다.
| 값 | 동작 |
|---|---|
"strict" | 다른 사이트에서 오는 모든 요청에 쿠키 미포함 |
"lax" | 다른 사이트에서 링크를 클릭해서 오는 GET 요청에만 쿠키 포함 |
"none" | 모든 크로스 사이트 요청에 쿠키 포함 (secure: true 필수) |
"strict"는 가장 안전하지만, 외부 링크로 사이트에 접속하면 로그인이 풀려 있어서 사용자 경험이 나빠진다. "lax"는 일반적인 탐색(링크 클릭)에서는 쿠키를 보내되, POST 같은 위험한 요청에서는 쿠키를 차단하기 때문에 보안과 사용성의 균형이 좋다.
세션 저장소
기본적으로 express-session은 MemoryStore를 사용한다. 서버 메모리에 세션을 저장하는 방식이다.
Warning: connect.session() MemoryStore is not designed for a production environment,
as it will leak memory, and will not scale past a single process.
이 경고 메시지가 뜨는 이유는 MemoryStore가 프로덕션에 적합하지 않기 때문이다.
- 메모리 누수: 만료된 세션을 자동으로 정리하지 않아서 시간이 지나면 메모리가 계속 증가한다
- 서버 재시작 시 세션 전부 소멸: 배포할 때마다 모든 사용자의 로그인이 풀린다
- 다중 프로세스 불가: 서버를 여러 인스턴스로 스케일링하면 각 프로세스마다 세션이 따로 관리되어 세션이 공유되지 않는다
프로덕션에서는 외부 저장소를 사용해야 한다.
Redis
가장 널리 사용되는 세션 저장소다. 인메모리 데이터베이스라서 읽기/쓰기가 빠르고, TTL(Time To Live)을 지원해서 만료된 세션을 자동으로 정리한다.
Warning: connect.session() MemoryStore is not designed for a production environment,
as it will leak memory, and will not scale past a single process.
데이터베이스
MySQL, PostgreSQL, MongoDB 등 이미 사용 중인 데이터베이스를 세션 저장소로 쓸 수도 있다. Redis처럼 빠르지는 않지만, 별도 인프라를 추가하지 않아도 된다는 장점이 있다.
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");
const redisClient = createClient({ url: "redis://localhost:6379" });
redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
})
);
소규모 서비스나 트래픽이 적은 경우에는 데이터베이스 저장소로 충분하다. 트래픽이 늘어나면 Redis로 전환하면 된다.
req.session 사용
미들웨어를 설정하면 req.session 객체를 통해 세션 데이터를 읽고 쓸 수 있다.
const MySQLStore = require("express-mysql-session")(session);
const store = new MySQLStore({
host: "localhost",
user: "root",
password: "password",
database: "my_database",
});
app.use(session({ store, /* ... */ }));
세션 삭제
// 세션에 데이터 저장
app.post("/login", (req, res) => {
// 인증 로직 후...
req.session.userId = user.id;
req.session.role = "admin";
res.json({ message: "로그인 성공" });
});
// 세션 데이터 읽기
app.get("/profile", (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ message: "로그인 필요" });
}
res.json({ userId: req.session.userId });
});
req.session.destroy()는 서버 저장소에서 세션을 삭제한다. 하지만 클라이언트 브라우저에는 여전히 쿠키가 남아있기 때문에 res.clearCookie()로 쿠키도 함께 지워줘야 깔끔하다. "connect.sid"는 express-session의 기본 쿠키 이름이다.
세션 재생성
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ message: "로그아웃 실패" });
}
res.clearCookie("connect.sid"); // 쿠키도 삭제
res.json({ message: "로그아웃 성공" });
});
});
로그인 시 regenerate()를 호출하면 기존 세션을 파기하고 새 세션 ID를 발급한다. 이는 세션 고정 공격(session fixation attack)을 방지하기 위한 것이다. 공격자가 미리 알고 있는 세션 ID로 피해자를 로그인시키는 공격인데, 로그인 시점에 세션 ID를 바꿔버리면 공격자가 알고 있던 세션 ID는 무효가 된다.
name 옵션
app.post("/login", (req, res) => {
// 인증 성공 후 세션 ID를 새로 발급
req.session.regenerate((err) => {
req.session.userId = user.id;
res.json({ message: "로그인 성공" });
});
});
쿠키 이름을 변경하는 옵션이다. 기본값은 "connect.sid"인데, 이 이름은 Express + express-session을 사용한다는 것을 바로 알 수 있게 해준다. 보안 관점에서 서버의 기술 스택이 노출되는 것은 좋지 않기 때문에, 프로덕션에서는 기본값 대신 의미 없는 이름으로 변경하는 것이 권장된다.
세션 vs JWT
세션 방식 외에 JWT(JSON Web Token)를 사용하는 인증 방식도 있다. 어떤 것이 더 좋다기보다는 상황에 따라 적합한 선택이 다르다.
| 비교 항목 | 세션 | JWT |
|---|---|---|
| 상태 저장 | 서버에 저장 (stateful) | 토큰 자체에 포함 (stateless) |
| 서버 부담 | 저장소 필요 | 저장소 불필요 |
| 무효화 | 서버에서 세션 삭제하면 즉시 무효 | 만료 전까지 무효화 어려움 |
| 확장성 | 저장소 공유 필요 (Redis 등) | 별도 공유 불필요 |
| 용량 | 쿠키에 세션 ID만 전송 (작음) | 토큰에 데이터 포함 (상대적으로 큼) |
세션은 서버가 상태를 관리하기 때문에 "이 사용자의 세션을 강제 종료"같은 제어가 쉽다. 반면 JWT는 서버가 상태를 저장하지 않아서 서버 확장이 자유롭지만, 한 번 발급된 토큰을 만료 전에 무효화하기가 까다롭다(블랙리스트를 관리하면 결국 상태 저장이 필요해진다).
전통적인 웹 애플리케이션에서는 세션 방식이 더 자연스럽고, API 서버나 마이크로서비스 환경에서는 JWT가 유리한 경우가 많다.
정리
- HTTP 무상태를 세션 ID + 쿠키로 해결하며, secret 서명으로 변조를 방지한다
- resave: false, saveUninitialized: false가 기본이고, cookie의 secure/httpOnly/sameSite 조합이 보안의 핵심이다
- MemoryStore는 개발 전용이며, 프로덕션에서는 Redis나 DB 저장소로 교체하고 regenerate()로 세션 고정 공격을 방어한다
관련 문서
- 커스텀 에러 클래스 - AppError 설계
- 에러 처리 미들웨어 - 환경별 에러 응답 분기
- ioredis - Redis 클라이언트와 세션 저장소 연동