junyeokk
Blog
Architecture·2024. 11. 17

Session 기반 Admin 인증

웹 서비스에서 관리자 인증을 구현할 때, 가장 먼저 떠오르는 선택지는 JWT다. 클라이언트가 토큰을 들고 다니니 서버가 상태를 안 가져도 되고, 스케일 아웃도 쉽다. 그런데 관리자 인증에는 JWT가 불편한 지점이 있다.

관리자 계정은 일반 사용자와 다르다. 계정 수가 적고, 보안 요구사항이 높고, "지금 당장 이 세션을 강제 종료시키고 싶다"는 요구가 자주 나온다. JWT는 발급하면 만료까지 무효화가 어렵다. 블랙리스트를 두면 되긴 하는데, 그 시점에서 이미 stateless의 이점을 잃은 거다. 관리자가 소수라면 처음부터 서버 사이드 세션이 더 자연스럽다.

세션 인증의 기본 흐름

세션 기반 인증은 아래 과정을 따른다:

  1. 클라이언트가 아이디/비밀번호를 보낸다
  2. 서버가 자격 증명을 검증한다
  3. 검증 성공 시 고유한 세션 ID를 생성하고, 서버 저장소에 세션 정보를 기록한다
  4. 세션 ID를 쿠키에 담아 클라이언트에 내려준다
  5. 이후 요청마다 쿠키가 자동으로 전송되고, 서버는 세션 ID로 인증 상태를 확인한다
클라이언트 서버 저장소 | | | |--- POST /login ---------->| | | {id, password} | | | |-- 비밀번호 검증 ------->| | | | | |-- SET session:uuid --->| | | value: loginId | | | EX: 43200 | |<-- Set-Cookie: sessionId=uuid | | | | |--- GET /admin/data ------>| | | Cookie: sessionId=uuid | | | |-- GET session:uuid --->| | |<-- loginId ------------| |<-- 200 OK | |

핵심은 세션 ID 자체에는 아무 정보도 담기지 않는다는 점이다. UUID처럼 무작위 문자열이고, 의미 있는 데이터는 전부 서버 저장소에 있다. 토큰 자체를 디코딩해서 정보를 뽑아내는 JWT와는 근본적으로 다르다.

왜 Redis인가

세션을 어디에 저장할지가 중요한 결정이다. 선택지를 비교해보자.

메모리 (Map, Object)

typescript
클라이언트                     서버                     저장소
   |                           |                        |
   |--- POST /login ---------->|                        |
   |    {id, password}         |                        |
   |                           |-- 비밀번호 검증 ------->|
   |                           |                        |
   |                           |-- SET session:uuid --->|
   |                           |     value: loginId     |
   |                           |     EX: 43200          |
   |<-- Set-Cookie: sessionId=uuid                      |
   |                           |                        |
   |--- GET /admin/data ------>|                        |
   |    Cookie: sessionId=uuid |                        |
   |                           |-- GET session:uuid --->|
   |                           |<-- loginId ------------|
   |<-- 200 OK                 |                        |

가장 간단하지만, 서버가 재시작되면 모든 세션이 날아간다. 서버 인스턴스가 여러 대면 세션을 공유할 수도 없다. 개발 환경에서는 괜찮지만 프로덕션에서는 쓸 수 없다.

DB (MySQL, PostgreSQL)

sql
const sessions = new Map<string, string>();
sessions.set(sessionId, loginId);

영속성은 있지만, 모든 요청마다 DB 조회가 발생한다. 세션 확인은 인증이 필요한 모든 API에서 일어나므로, 가장 빈번한 쿼리가 된다. 세션처럼 수명이 짧고 빈번하게 조회되는 데이터를 RDBMS에 넣는 건 과하다.

Redis

SET session:550e8400-e29b-41d4-a716-446655440000 "admin01" EX 43200

인메모리라 조회가 빠르고(O(1)), TTL을 지원해서 만료를 별도로 관리할 필요가 없다. 서버가 여러 대여도 Redis 하나를 바라보면 세션 공유가 된다. 재시작해도 RDB/AOF로 복구 가능하다. 세션 저장소로는 거의 정답에 가깝다.

세션 ID 생성

세션 ID는 추측 불가능해야 한다. 순차 번호나 타임스탬프 기반 ID를 쓰면 다른 세션을 예측해서 탈취할 수 있다(Session Fixation 공격).

typescript
CREATE TABLE sessions (
  session_id VARCHAR(36) PRIMARY KEY,
  login_id VARCHAR(255),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  expires_at TIMESTAMP
);

UUID v4는 122비트의 랜덤 값을 가진다. 충돌 확률이 사실상 0이고, 외부에서 다음 값을 예측할 수 없다. 세션 ID로 적합하다.

만약 더 강한 보안이 필요하다면 crypto.randomBytes를 쓸 수도 있다:

typescript
SET session:550e8400-e29b-41d4-a716-446655440000 "admin01" EX 43200

로그인 구현

로그인 로직에서 중요한 부분은 기존 세션 처리다. 같은 계정으로 중복 로그인을 허용할지, 이전 세션을 무효화할지 결정해야 한다. 관리자 계정이라면 보통 단일 세션만 허용하는 게 안전하다.

typescript
import { v4 as uuidv4 } from 'uuid';

const sessionId = uuidv4();
// "550e8400-e29b-41d4-a716-446655440000"

3번 단계에서 기존 세션을 찾아 삭제하는 부분이 흥미롭다. Redis에는 "값으로 키를 검색"하는 명령이 없으므로 SCAN을 사용한다:

typescript
import { randomBytes } from 'crypto';

const sessionId = randomBytes(32).toString('hex');
// 256비트 랜덤 문자열

SCAN은 커서 기반 반복자다. KEYS * 패턴과 달리 Redis를 블로킹하지 않는다. 한 번에 100개씩 키를 가져오면서 해당 loginId를 가진 세션을 찾고, 발견하면 삭제한다. 관리자 수가 적을 때는 이 방식이 충분히 효율적이다.

다만 관리자 수가 많아지면 역방향 인덱스를 두는 게 낫다:

typescript
@Injectable()
export class AuthService {
  constructor(
    private readonly adminRepository: AdminRepository,
    private readonly redisService: RedisService,
  ) {}

  async login(loginId: string, password: string, req: Request, res: Response) {
    // 1. 자격 증명 검증
    const admin = await this.adminRepository.findOne({
      where: { loginId },
    });

    if (!admin || !(await bcrypt.compare(password, admin.password))) {
      throw new UnauthorizedException('아이디 혹은 비밀번호가 잘못되었습니다.');
    }

    // 2. 기존 쿠키의 세션 삭제
    const existingCookie = req.cookies['sessionId'];
    if (existingCookie) {
      await this.redisService.del(`session:auth:${existingCookie}`);
    }

    // 3. 같은 계정의 다른 세션 찾아서 삭제 (단일 세션 정책)
    await this.invalidateExistingSessions(loginId);

    // 4. 새 세션 생성
    const sessionId = uuidv4();
    await this.redisService.set(
      `session:auth:${sessionId}`,
      admin.loginId,
      'EX',
      SESSION_TTL,  // 예: 12시간 (43200초)
    );

    // 5. 쿠키에 세션 ID 설정
    res.cookie('sessionId', sessionId, {
      httpOnly: true,
      secure: true,
      sameSite: 'none',
      path: '/',
    });
  }
}

쿠키 설정의 보안 속성

세션 ID를 쿠키에 담을 때 보안 관련 옵션이 중요하다.

typescript
private async invalidateExistingSessions(loginId: string) {
  let cursor = '0';
  do {
    const [newCursor, keys] = await this.redisService.scan(
      cursor,
      'session:auth:*',
      100,
    );
    cursor = newCursor;

    if (!keys.length) break;

    const values = await this.redisService.mget(...keys);
    for (let i = 0; i < keys.length; i++) {
      if (values[i] === loginId) {
        await this.redisService.del(keys[i]);
        return; // 관리자는 한 명당 하나의 세션만 있으므로
      }
    }
  } while (cursor !== '0');
}

각 옵션의 역할:

httpOnly: true

document.cookie로 세션 ID를 읽을 수 없게 한다. XSS 공격으로 악성 스크립트가 주입되더라도 쿠키를 탈취할 수 없다. 세션 쿠키에는 반드시 설정해야 한다.

javascript
// 로그인 시 양방향 매핑
await this.redisService.set(`session:auth:${sessionId}`, loginId, 'EX', SESSION_TTL);
await this.redisService.set(`session:reverse:${loginId}`, sessionId, 'EX', SESSION_TTL);

// 기존 세션 삭제 시
const oldSession = await this.redisService.get(`session:reverse:${loginId}`);
if (oldSession) {
  await this.redisService.del(`session:auth:${oldSession}`);
}

secure: true

HTTPS 연결에서만 쿠키가 전송된다. HTTP에서는 네트워크 패킷을 가로채면 쿠키가 평문으로 노출되는데, secure 플래그가 이를 방지한다. 프로덕션에서는 필수다.

sameSite

CSRF(Cross-Site Request Forgery) 방어와 관련된 옵션이다:

동작사용 사례
strict같은 사이트 요청에서만 쿠키 전송가장 안전하지만, 외부 링크로 진입 시 로그인 풀림
laxGET 요청은 크로스 사이트도 허용일반적인 웹사이트에 적합
none모든 크로스 사이트 요청 허용 (secure 필수)API 서버와 프론트엔드 도메인이 다를 때

프론트엔드와 API 서버 도메인이 다르면(예: app.example.comapi.example.com) sameSite: 'none'을 써야 한다. 대신 CSRF 토큰 같은 추가 방어가 필요하다.

환경별 분기

개발 환경에서는 HTTPS를 쓰지 않는 경우가 많으므로 환경에 따라 설정을 분기한다:

typescript
const cookieOptions = {
  httpOnly: true,    // JavaScript에서 접근 불가
  secure: true,      // HTTPS에서만 전송
  sameSite: 'none',  // 크로스 사이트 요청에서도 전송
  path: '/',         // 모든 경로에서 유효
};

Guard로 인증 검증

NestJS에서는 Guard를 사용해 인증 로직을 컨트롤러에서 분리한다. 요청이 컨트롤러에 도달하기 전에 Guard가 먼저 실행되어 인증 여부를 판단한다.

typescript
// httpOnly: true이면 이게 동작하지 않음
console.log(document.cookie); // sessionId가 보이지 않음

Guard의 canActivatetrue를 반환하면 요청을 통과시키고, 예외를 던지면 요청을 거부한다. 쿠키에서 세션 ID를 꺼내고, Redis에서 해당 세션이 유효한지 확인하는 게 전부다.

request['user']에 사용자 정보를 주입하면 컨트롤러에서 인증된 사용자 정보에 접근할 수 있다:

typescript
export const cookieConfig = {
  production: {
    httpOnly: true,
    secure: true,
    sameSite: 'none' as const,
    path: '/',
  },
  development: {
    httpOnly: true,
    secure: false,
    sameSite: 'lax' as const,
    path: '/',
  },
};

// 사용 시
res.cookie('sessionId', sessionId, cookieConfig[process.env.NODE_ENV]);

전역으로 적용하려면 특정 경로에만 Guard를 걸 수도 있다:

typescript
@Injectable()
export class AdminAuthGuard implements CanActivate {
  constructor(private readonly redisService: RedisService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const sid = request.cookies['sessionId'];

    if (!sid) {
      throw new UnauthorizedException('세션 ID가 없습니다.');
    }

    const loginId = await this.redisService.get(`session:auth:${sid}`);

    if (!loginId) {
      throw new UnauthorizedException('인증되지 않은 요청입니다.');
    }

    // 인증된 사용자 정보를 request에 주입
    request['user'] = { loginId };

    return true;
  }
}

로그아웃

로그아웃은 간단하다. Redis에서 세션을 삭제하고 쿠키를 지우면 된다.

typescript
@Controller('admin')
export class AdminController {
  @Get('dashboard')
  @UseGuards(AdminAuthGuard)
  getDashboard(@Req() req: Request) {
    const { loginId } = req['user'];
    return { message: `Welcome, ${loginId}` };
  }
}

clearCookie는 클라이언트에 만료된 쿠키를 설정해서 브라우저가 쿠키를 삭제하게 만든다. Redis에서 먼저 삭제하기 때문에, 혹시 쿠키 삭제가 실패하더라도 세션 ID로는 아무것도 할 수 없다.

세션 TTL과 보안 고려사항

세션의 유효 시간은 보안과 편의성 사이의 트레이드오프다.

typescript
// 특정 컨트롤러 전체에 적용
@Controller('admin')
@UseGuards(AdminAuthGuard)
export class AdminController {
  // 이 컨트롤러의 모든 핸들러에 Guard 적용
}

// 전역 적용 (main.ts)
app.useGlobalGuards(new AdminAuthGuard(redisService));

12시간이면 업무 시간 동안 재로그인 없이 사용할 수 있다. 더 민감한 시스템이라면 1~2시간으로 줄이고, 활동이 있을 때마다 TTL을 갱신하는 방식(슬라이딩 세션)을 쓴다:

typescript
async logout(req: Request, res: Response) {
  const sid = req.cookies['sessionId'];

  if (sid) {
    await this.redisService.del(`session:auth:${sid}`);
  }

  res.clearCookie('sessionId');
}

이러면 사용자가 활동하는 동안은 세션이 만료되지 않고, 비활동 상태가 TTL만큼 지속되면 자동으로 만료된다.

JWT와 세션의 비교

어떤 상황에서 어떤 방식이 유리한지 정리하면:

기준세션 기반JWT 기반
상태 관리서버에 세션 저장 (Stateful)토큰에 정보 포함 (Stateless)
즉시 무효화Redis에서 삭제하면 즉시 적용만료 전까지 무효화 어려움
스케일 아웃공유 저장소(Redis) 필요저장소 불필요
서버 부하매 요청 저장소 조회토큰 서명 검증만 (CPU)
보안 사고 대응세션 삭제로 즉시 차단블랙리스트 별도 구현 필요
적합한 상황관리자, 소수 사용자, 높은 보안대규모 사용자, MSA, 모바일

관리자 인증처럼 사용자 수가 적고 보안이 중요한 경우, 세션 기반이 더 단순하고 안전하다. Redis 의존성이 추가되지만, 이미 캐시나 메시지 큐로 Redis를 쓰고 있다면 추가 비용이 거의 없다.

반면 일반 사용자 인증은 JWT가 유리한 경우가 많다. 세션 저장소 없이 토큰 자체로 인증이 완결되므로, 수평 확장이 쉽고 마이크로서비스 간 인증 전파도 간단하다.

실무에서는 하이브리드도 흔하다. 일반 사용자는 JWT(Access + Refresh Token), 관리자는 세션 기반으로 분리하는 식이다. 각각의 특성에 맞는 인증 전략을 선택하는 것이 핵심이다.

정리

  • 관리자처럼 소수+높은 보안이 필요한 경우, JWT보다 서버 사이드 세션이 즉시 무효화와 강제 로그아웃 면에서 유리하다
  • Redis를 세션 저장소로 쓰면 O(1) 조회, TTL 자동 만료, 다중 인스턴스 세션 공유를 한꺼번에 해결할 수 있다
  • httpOnly+secure+sameSite 쿠키 설정과 슬라이딩 세션 TTL 갱신을 조합해서 탈취 방지와 사용성을 균형 잡는다

관련 문서