본문 바로가기
TYLER SONGBlog
← Blog index
CS7 min read

Session: 상태 없는 HTTP 위에서 사용자 상태를 유지하는 방법

세션(session)은 여러 HTTP 요청을 하나의 사용자 흐름으로 연결하는 애플리케이션 계층의 상태 관리 방식이다. 서버는 세션 데이터를 저장하고, 클라이언트는 세션 식별자만 쿠키(cookie)로 전달한다. 안전한 세션 구현에는 예측 불가능한 식별자, 만료 처리, 로그인 시 식별자 재발급, 쿠키 보안 속성이 필요하다.

By 송민성

1. 개념

HTTP는 각 요청을 독립적으로 처리하는 상태 비저장(stateless) 프로토콜이다. 서버는 별도 상태를 저장하지 않으면 두 요청이 같은 사용자에게서 왔는지 알 수 없다.

세션(session)은 여러 요청 사이에서 사용자 상태를 유지하는 방법이다. 일반적인 서버 측 세션(server-side session)은 다음 두 요소로 구성된다.

  • 세션 식별자(session ID): 세션을 찾기 위한 예측 불가능한 문자열
  • 세션 저장소(session store): 사용자 식별 정보, 만료 시각 같은 데이터를 보관하는 공간

브라우저는 세션 식별자를 쿠키(cookie)에 저장하고 요청마다 서버로 전송한다.

text
브라우저
  Cookie: sid=무작위_세션_식별자
          │
          ▼
웹 서버 ── 세션 식별자로 조회 ──▶ 세션 저장소
                                  {
                                    "userId": 42,
                                    "expiresAt": "..."
                                  }

쿠키와 세션은 같은 개념이 아니다.

  • 쿠키: 브라우저가 저장하고 HTTP 요청에 첨부하는 데이터
  • 세션: 서버가 여러 요청을 하나의 상태로 연결하는 방식
  • 세션 쿠키: 세션 식별자를 담은 쿠키

2. 왜 사용하는가

로그인 후 사용자가 페이지를 이동할 때마다 자격 증명을 다시 입력하게 할 수는 없다. 서버는 세션을 이용해 이미 인증된 사용자인지 확인한다.

세션에 저장하기 적합한 정보는 다음과 같다.

  • 로그인한 사용자 식별자
  • 인증 시각과 만료 시각
  • 다단계 인증 완료 여부
  • 장바구니 식별자
  • 일회성 알림 메시지

비밀번호, 카드 번호처럼 유출 피해가 큰 원문 데이터는 세션에 저장하지 않는다. 필요한 최소 정보만 보관한다.

서버 측 세션은 클라이언트가 세션 데이터를 직접 수정할 수 없다는 장점이 있다. 다만 세션 식별자를 탈취한 공격자는 해당 세션의 사용자로 가장할 수 있으므로 식별자 자체를 자격 증명처럼 보호해야 한다.

3. 동작 원리

세션 생성

사용자가 로그인에 성공하면 서버는 암호학적으로 안전한 난수 생성기(cryptographically secure random number generator)로 세션 식별자를 만든다.

text
1. 클라이언트가 자격 증명 전송
2. 서버가 자격 증명 검증
3. 서버가 새 세션 식별자 생성
4. 서버가 세션 저장소에 데이터 기록
5. 서버가 Set-Cookie 응답 헤더 전송

응답 예시는 다음과 같다.

text
Set-Cookie: sid=abc123...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800

세션 조회

이후 브라우저는 같은 출처로 요청할 때 쿠키를 자동으로 첨부한다.

text
Cookie: sid=abc123...

서버는 다음 순서로 요청을 처리한다.

  1. 쿠키에서 세션 식별자를 읽는다.
  2. 세션 저장소에서 해당 식별자를 조회한다.
  3. 세션이 존재하고 만료되지 않았는지 확인한다.
  4. 세션의 사용자 식별자를 기준으로 요청을 처리한다.

세션 식별자가 없거나 유효하지 않으면 인증되지 않은 요청으로 처리한다.

세션 만료

세션 만료 방식은 두 가지로 나뉜다.

  • 절대 만료(absolute expiration): 생성 후 정해진 시간이 지나면 사용 중이어도 종료
  • 유휴 만료(idle expiration): 마지막 활동 후 정해진 시간 동안 요청이 없으면 종료

유휴 만료를 사용할 때는 요청마다 만료 시각을 연장하는 슬라이딩 만료(sliding expiration)를 적용할 수 있다. 보안이 중요한 서비스는 절대 만료와 유휴 만료를 함께 사용한다.

세션 종료

로그아웃할 때는 브라우저의 쿠키만 지우지 말고 서버 저장소의 세션도 삭제해야 한다.

text
Set-Cookie: sid=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0

서버의 세션을 남겨 두면 기존 식별자를 가진 요청이 계속 인증될 수 있다.

4. 코드 예제

다음 예제는 Node.js 표준 라이브러리로 구현한 메모리 기반 세션 서버다. 학습용이므로 서버를 재시작하면 모든 세션이 사라진다.

typescript
import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { randomBytes, timingSafeEqual } from "node:crypto"; type Session = { userId: number; expiresAt: number; }; const sessions = new Map<string, Session>(); const SESSION_TTL_MS = 30 * 60 * 1000; function parseCookies(request: IncomingMessage): Record<string, string> { const header = request.headers.cookie; if (!header) return {}; return Object.fromEntries( header.split(";").map((item) => { const [name, ...value] = item.trim().split("="); return [name, decodeURIComponent(value.join("="))]; }), ); } function createSession(userId: number): string { const sessionId = randomBytes(32).toString("base64url"); sessions.set(sessionId, { userId, expiresAt: Date.now() + SESSION_TTL_MS, }); return sessionId; } function getSession(request: IncomingMessage): Session | undefined { const sessionId = parseCookies(request).sid; if (!sessionId) return undefined; const session = sessions.get(sessionId); if (!session) return undefined; if (session.expiresAt <= Date.now()) { sessions.delete(sessionId); return undefined; } return session; } function verifyPassword(received: string, expected: string): boolean { const receivedBuffer = Buffer.from(received); const expectedBuffer = Buffer.from(expected); return ( receivedBuffer.length === expectedBuffer.length && timingSafeEqual(receivedBuffer, expectedBuffer) ); } function sendJson( response: ServerResponse, statusCode: number, body: unknown, ): void { response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", }); response.end(JSON.stringify(body)); } async function readJson(request: IncomingMessage): Promise<unknown> { const chunks: Buffer[] = []; for await (const chunk of request) { chunks.push(Buffer.from(chunk)); } return JSON.parse(Buffer.concat(chunks).toString("utf8")); } const server = createServer(async (request, response) => { if (request.method === "POST" && request.url === "/login") { try { const body = (await readJson(request)) as { username?: string; password?: string; }; const validUser = body.username === "developer" && typeof body.password === "string" && verifyPassword(body.password, "study-session"); if (!validUser) { sendJson(response, 401, { message: "인증 실패" }); return; } const oldSessionId = parseCookies(request).sid; if (oldSessionId) sessions.delete(oldSessionId); const sessionId = createSession(42); response.setHeader( "Set-Cookie", `sid=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800`, ); sendJson(response, 200, { message: "로그인 성공" }); } catch { sendJson(response, 400, { message: "잘못된 요청" }); } return; } if (request.method === "GET" && request.url === "/profile") { const session = getSession(request); if (!session) { sendJson(response, 401, { message: "로그인 필요" }); return; } sendJson(response, 200, { userId: session.userId, message: "인증된 요청", }); return; } if (request.method === "POST" && request.url === "/logout") { const sessionId = parseCookies(request).sid; if (sessionId) sessions.delete(sessionId); response.setHeader( "Set-Cookie", "sid=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0", ); sendJson(response, 200, { message: "로그아웃 성공" }); return; } sendJson(response, 404, { message: "경로 없음" }); }); server.listen(3000, () => { console.log("https://localhost:3000에서 서버 실행"); });

세션 식별자는 randomBytes(32)로 생성한 256비트 난수다. 저장소에는 식별자의 의미를 추측할 수 있는 사용자 이름이나 순차 번호를 사용하지 않는다.

운영 환경에서는 전송 계층 보안(TLS)을 적용한 HTTPS가 필요하다. 로컬에서 HTTP로 시험하려면 학습 목적으로만 Secure 속성을 제거해야 한다.

5. 시간 복잡도 또는 성능 특성

해시 테이블(hash table) 기반 메모리 저장소를 기준으로 세션 연산의 평균 시간 복잡도는 다음과 같다.

연산평균 시간 복잡도
세션 생성O(1)
세션 조회O(1)
세션 갱신O(1)
세션 삭제O(1)

세션 수를 S라고 하면 저장 공간은 O(S)다. 실제 처리 시간은 해시 조회보다 세션 저장소까지의 네트워크 왕복 시간과 직렬화 비용의 영향을 크게 받는다.

만료된 세션을 전체 순회해 정리하면 한 번의 정리 작업은 O(S)다. Redis 같은 저장소는 키별 만료 시간(time to live, TTL)을 설정해 만료 처리를 위임할 수 있다.

여러 서버가 하나의 세션 저장소를 공유하면 요청이 어느 서버로 전달돼도 같은 세션을 조회할 수 있다. 대신 요청마다 외부 저장소 조회가 추가된다.

6. 실무 사용 사례

웹 로그인

로그인 성공 후 세션에 사용자 식별자와 인증 상태를 저장한다. 요청마다 세션을 조회하고 필요한 사용자 정보를 데이터베이스에서 읽는다.

다중 서버 환경

서버별 메모리에 세션을 저장하면 다른 서버로 전달된 요청에서 세션을 찾지 못한다. 일반적으로 모든 서버가 Redis 같은 중앙 세션 저장소를 공유한다.

text
로드 밸런서
  ├─ 웹 서버 1 ─┐
  ├─ 웹 서버 2 ─┼─▶ 공용 세션 저장소
  └─ 웹 서버 3 ─┘

고정 세션(sticky session)으로 같은 사용자를 특정 서버에 계속 전달할 수도 있지만, 서버 장애와 확장 시 상태 이동 문제가 생긴다.

권한 변경 반영

세션에는 사용자 식별자만 저장하고 최신 권한은 별도 저장소에서 확인할 수 있다. 권한을 세션에 저장한다면 관리자 권한 회수, 계정 잠금 같은 변경 시 관련 세션을 무효화해야 한다.

동시 로그인 제어

사용자별 활성 세션 목록을 관리하면 모든 기기에서 로그아웃하거나 최대 로그인 기기 수를 제한할 수 있다. 각 기기에는 서로 다른 세션 식별자를 발급한다.

7. 주의할 점

세션 고정 공격

세션 고정(session fixation)은 공격자가 알고 있는 세션 식별자를 피해자가 로그인 후에도 사용하게 만드는 공격이다. 로그인 성공, 권한 상승, 비밀번호 변경 뒤에는 기존 세션을 폐기하고 새 식별자를 발급한다.

세션 탈취

세션 하이재킹(session hijacking)은 유출된 세션 식별자로 사용자를 가장하는 공격이다.

  • Secure: HTTPS 요청에서만 쿠키 전송
  • HttpOnly: 브라우저의 자바스크립트에서 쿠키 접근 차단
  • SameSite: 교차 사이트 요청에서 쿠키 전송 제한
  • 짧은 만료 시간: 탈취된 세션의 사용 가능 시간 제한

HttpOnly는 사이트 간 스크립팅(XSS) 공격 자체를 막지 않는다. 자바스크립트가 쿠키 값을 읽는 경로만 제한한다.

사이트 간 요청 위조

사이트 간 요청 위조(CSRF)는 사용자의 브라우저가 세션 쿠키를 자동 전송한다는 성질을 악용한다. 상태를 변경하는 요청에는 SameSite 설정과 사이트 간 요청 위조 방지 토큰(CSRF token)을 함께 검토한다.

세션 데이터 과다 저장

세션에 큰 객체를 저장하면 메모리 사용량, 직렬화 비용, 네트워크 트래픽이 증가한다. 사용자 전체 객체 대신 사용자 식별자처럼 재조회 가능한 최소 정보만 저장한다.

만료와 삭제 불일치

쿠키 만료와 서버 세션 만료는 별도로 관리된다. 둘 중 하나만 만료되면 불필요한 데이터가 남거나 사용자가 예상보다 일찍 로그아웃될 수 있다. 서버는 쿠키의 만료 여부와 무관하게 저장된 만료 시각을 직접 검증해야 한다.

8. 핵심 정리

  • 세션(session)은 상태 없는 HTTP 요청들을 하나의 사용자 흐름으로 연결한다.
  • 브라우저는 세션 식별자를 쿠키에 저장하고, 실제 세션 데이터는 서버가 관리한다.
  • 로그인과 권한 변경 시 세션 식별자를 재발급해 세션 고정 공격을 막는다.
  • Secure, HttpOnly, SameSite, 만료 정책으로 세션 탈취와 오용 위험을 줄인다.
  • 다중 서버 환경에서는 모든 서버가 접근할 수 있는 공용 세션 저장소를 사용한다.