실서비스에서는 한 사용자가 모바일 앱과 웹, 태블릿을 동시에 사용하는 것이 일반적입니다. 그런데 운영 중 “비밀번호를 바꿨는데 다른 기기에서는 계속 로그인되어 있어요”, “폰을 분실했는데 모든 기기에서 로그아웃하고 싶어요”, “어제부터 내 계정이 이상해요” 같은 요청이 들어오기 시작하면, 인증 설계의 빈틈이 바로 드러납니다.
특히 refresh token을 사용하는 구조에서는 토큰 탈취가 발생했을 때 흔적이 남지 않으면 대응이 어렵습니다. 운영에서 중요한 것은 “문제가 생기면 막는다”가 아니라, “의심 신호를 정확히 기록하고, 즉시 차단할 수 있는 통제 지점을 가진다”입니다.
실무에서 바로 적용 가능한 기준으로 다음을 정리합니다.
- 멀티 디바이스에서 기기별 세션을 어떻게 분리할지(세션 모델)
- “모든 기기에서 로그아웃”을 서버 기준으로 즉시 반영하는 방법
- 의심스러운 refresh 재사용을 탐지하고 보안 이벤트로 로깅하는 설계
- 운영에서 자주 터지는 실수와 장애 포인트
개념/배경 설명
멀티 디바이스 세션을 제대로 운영하려면 “토큰”이 아니라 “세션 상태”가 중심이 되어야 합니다. access token(JWT)은 검증이 빠르고 확장에 유리하지만, 이미 발급된 토큰을 즉시 무효화하기 어렵습니다. 반대로 서버가 세션 상태를 들고 있으면, 강제 로그아웃이나 전체 로그아웃 같은 통제 요구사항을 해결할 수 있습니다.
여기서 가장 흔한 오해는 “refresh token만 있으면 세션이다”입니다. refresh token은 단지 재발급 수단일 뿐이고, 운영에서 필요한 것은 “이 로그인 상태가 여전히 유효한가”, “어떤 기기에서 로그인했는가”, “언제 마지막으로 사용했는가”, “차단되었는가” 같은 서버 통제 정보입니다.
잘못 쓰면 생기는 문제는 명확합니다.
- 사용자 단위로 refresh token을 1개만 관리하면, 특정 기기만 로그아웃이 불가능해집니다.
- “모든 기기 로그아웃”을 access token 만료로 처리하면 즉시 반영되지 않습니다.
- refresh token을 평문 저장하거나 재사용을 허용하면, 토큰 탈취 후 장기간 악용될 수 있습니다.
- 보안 이벤트 로그가 없으면, 사고가 나도 무엇이 문제였는지 재현과 추적이 어렵습니다.
설계/전략
1. 기기별 세션 모델: user 기준이 아니라 session 기준으로 관리
실무에서 가장 안전한 기준은 “로그인 1회 = 세션 1개”입니다. 사용자가 같은 계정으로 3개의 기기에서 로그인하면 세션은 3개가 됩니다. 이 구조가 되어야 특정 기기 로그아웃, 전체 로그아웃, 의심 세션만 차단 같은 운영 요구사항이 자연스럽게 해결됩니다.
세션 테이블(또는 컬렉션)은 최소한 다음을 포함하는 것이 좋습니다.
type SessionStatus = 'ACTIVE' | 'REVOKED';
interface SessionEntity {
sessionId: string; // 서버 발급 UUID
userId: string;
deviceId: string; // 기기 식별(앱은 deviceId, 웹은 별도 식별자)
deviceInfo: {
userAgent?: string;
os?: string;
appVersion?: string;
};
refreshTokenHash: string; // 평문 저장 금지
refreshTokenFamilyId: string; // 로테이션 추적용(선택)
status: SessionStatus;
lastUsedAt: Date;
createdAt: Date;
revokedAt?: Date;
revokeReason?: 'USER_LOGOUT' | 'ALL_DEVICES_LOGOUT' | 'SECURITY_INCIDENT' | 'PASSWORD_CHANGE';
}
여기서 핵심은 refresh token을 절대 평문으로 저장하지 않는 것입니다. 운영에서 DB 유출 가능성은 “없다”가 아니라 “언젠가 발생할 수 있다”로 두고 설계하는 편이 안전합니다. 결국 해시 저장을 기본으로 두면, 사고의 피해 범위를 줄일 수 있습니다.
실무 포인트 정리
- 세션은 “사용자 1개”가 아니라 “로그인 1회당 1개”로 분리합니다.
- refresh token은 해시로 저장하고, 평문 저장은 금지합니다.
- deviceInfo는 과도하게 쌓지 말고, 운영에 필요한 최소 정보만 남깁니다.
2. “모든 기기에서 로그아웃”: 즉시 무효화가 가능한 통제 지점 만들기
“모든 기기 로그아웃”은 사용자의 모든 세션을 한 번에 무효화하는 요구사항입니다. 이 요구사항을 제대로 만족시키려면, 서버가 “세션이 살아있는지”를 기준으로 판단할 수 있어야 합니다. 단순히 access token(JWT)만 검증하면 이미 발급된 토큰은 만료 전까지 살아있기 때문에 즉시 로그아웃이 되지 않습니다.
실무에서 많이 쓰는 전략은 다음 중 하나입니다. 팀/환경에 따라 달라질 수 있습니다.
- A안: 매 요청마다 session 상태를 조회(통제력 강함, 비용 큼)
- B안: access token은 JWT로 빠르게 검증하고, 서버에서는 “세션 버전” 또는 “revoked timestamp” 같은 최소한의 통제값만 확인
이번 글에서는 운영에서 균형이 좋은 B안을 기준으로 설명합니다. 방법은 간단합니다.
- JWT payload에 sessionId를 포함합니다.
- 서버에는 sessionId 기준의 “유효/무효” 상태가 존재해야 합니다.
- “모든 기기 로그아웃” 요청 시 해당 사용자의 모든 세션을 REVOKED로 바꾸고, 캐시/인덱스도 함께 무효화합니다.
실무 포인트 정리
- “모든 기기 로그아웃”은 토큰 만료가 아니라 세션 상태를 서버가 통제하는 문제입니다.
- JWT만으로 즉시 무효화가 어렵기 때문에, sessionId 기반 통제 지점이 필요합니다.
- 정확한 즉시 반영을 원하면, 캐시 무효화까지 같이 설계해야 합니다.
3. refresh token 로테이션과 “재사용 탐지”: 탈취 대응의 시작점
refresh token은 원칙적으로 “한 번 쓰면 폐기되고 새 토큰으로 교체되는” 로테이션이 권장됩니다. 정상 흐름에서는 이전 refresh token이 다시 서버로 들어오지 않습니다. 그런데 이미 폐기된 refresh token이 다시 들어온다면, 흔히 다음을 의심해야 합니다.
- 토큰이 유출되었고 공격자가 재사용을 시도했다
- 동일 사용자의 두 기기가 동시에 refresh를 시도했다(경합)
- 클라이언트 구현이 잘못되어 옛 토큰을 재전송했다
실무에서 중요한 것은 “무조건 계정 잠금”이 아니라, 상황을 분류할 수 있을 만큼 로그를 남기고, 정책에 따라 대응하는 것입니다. 서비스 특성상 오탐이 치명적일 수도 있고, 반대로 보안이 최우선일 수도 있습니다. 따라서 강제 조치 여부는 팀/환경에 따라 달라질 수 있음을 전제로 두는 것이 좋습니다.
실무 포인트 정리
- refresh token은 1회성으로 만들고, 로테이션을 기본으로 둡니다.
- 폐기된 refresh token 재사용은 “보안 이벤트”로 취급합니다.
- 오탐 가능성이 있으므로, 정책은 팀/서비스 특성에 맞게 단계화합니다.
코드 예제
아래 예시는 Node.js 백엔드(예: NestJS/Express 계열)에서 바로 적용할 수 있는 형태를 목표로 작성했습니다. 저장소/ORM은 팀마다 다르므로, 핵심 로직과 인터페이스 중심으로 봐주시면 됩니다.
1. 보안 이벤트 로깅 인터페이스
type SecurityEventType =
| 'REFRESH_REUSE_DETECTED'
| 'SESSION_REVOKED'
| 'ALL_SESSIONS_REVOKED'
| 'LOGIN_SUCCESS'
| 'LOGIN_FAILED';
interface SecurityEvent {
eventId: string;
type: SecurityEventType;
userId: string;
sessionId?: string;
deviceId?: string;
ip?: string;
userAgent?: string;
occurredAt: Date;
meta?: Record<string, unknown>;
}
interface SecurityEventLogger {
log(event: SecurityEvent): Promise<void>;
}
운영에서 보안 로그는 일반 애플리케이션 로그와 분리하는 편이 좋습니다. 접근 권한, 보관 기간, 조회 경로가 다르기 때문입니다. 최소한 이벤트 타입, userId, sessionId, IP, userAgent는 남겨야 사후 추적이 가능합니다.
2. refresh 로테이션 + 재사용 탐지 + “모든 기기 로그아웃”까지 이어지는 처리
import crypto from 'crypto';
function sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
interface SessionRepository {
findBySessionId(sessionId: string): Promise<SessionEntity | null>;
findActiveSessionsByUserId(userId: string): Promise<SessionEntity[]>;
updateRefreshToken(sessionId: string, nextRefreshHash: string, lastUsedAt: Date): Promise<void>;
revokeSession(sessionId: string, reason: SessionEntity['revokeReason']): Promise<void>;
revokeAllSessions(userId: string, reason: SessionEntity['revokeReason']): Promise<number>;
}
interface TokenService {
issueAccessToken(payload: { userId: string; sessionId: string }): string;
issueRefreshToken(payload: { userId: string; sessionId: string }): string; // 원문 반환
parseRefreshToken(refreshToken: string): { userId: string; sessionId: string };
}
interface RequestContext {
ip?: string;
userAgent?: string;
}
class AuthService {
constructor(
private readonly sessions: SessionRepository,
private readonly tokens: TokenService,
private readonly securityLog: SecurityEventLogger,
) {}
/**
* refresh 요청 처리:
* - refresh token 해시가 현재 세션에 저장된 값과 일치하면 정상 로테이션
* - 불일치하면 의심스러운 재사용(또는 경합)으로 간주하고 보안 이벤트 로깅
* - 정책에 따라 모든 세션 revoke까지 진행 가능
*/
async refresh(refreshToken: string, ctx: RequestContext) {
const { userId, sessionId } = this.tokens.parseRefreshToken(refreshToken);
const session = await this.sessions.findBySessionId(sessionId);
if (!session || session.status !== 'ACTIVE') {
await this.securityLog.log({
eventId: crypto.randomUUID(),
type: 'REFRESH_REUSE_DETECTED',
userId,
sessionId,
deviceId: session?.deviceId,
ip: ctx.ip,
userAgent: ctx.userAgent,
occurredAt: new Date(),
meta: { reason: 'session_not_found_or_revoked' },
});
throw new Error('Invalid session');
}
const incomingHash = sha256(refreshToken);
const isMatch = incomingHash === session.refreshTokenHash;
if (!isMatch) {
// 의심 상황: 토큰 유출/재사용, 또는 클라이언트 경합 가능성
await this.securityLog.log({
eventId: crypto.randomUUID(),
type: 'REFRESH_REUSE_DETECTED',
userId,
sessionId,
deviceId: session.deviceId,
ip: ctx.ip,
userAgent: ctx.userAgent,
occurredAt: new Date(),
meta: { reason: 'refresh_hash_mismatch' },
});
// 강한 대응이 필요한 서비스라면 전체 세션 revoke
// 팀/환경에 따라 달라질 수 있음(오탐 비용 고려)
const revokedCount = await this.sessions.revokeAllSessions(userId, 'SECURITY_INCIDENT');
await this.securityLog.log({
eventId: crypto.randomUUID(),
type: 'ALL_SESSIONS_REVOKED',
userId,
occurredAt: new Date(),
meta: { reason: 'SECURITY_INCIDENT', revokedCount },
});
throw new Error('Suspicious refresh reuse detected');
}
// 정상 로테이션
const nextAccess = this.tokens.issueAccessToken({ userId, sessionId });
const nextRefresh = this.tokens.issueRefreshToken({ userId, sessionId });
const nextHash = sha256(nextRefresh);
await this.sessions.updateRefreshToken(sessionId, nextHash, new Date());
return { accessToken: nextAccess, refreshToken: nextRefresh };
}
/**
* 사용자의 "모든 기기에서 로그아웃" 요청 처리
*/
async logoutAllDevices(userId: string) {
const revokedCount = await this.sessions.revokeAllSessions(userId, 'ALL_DEVICES_LOGOUT');
await this.securityLog.log({
eventId: crypto.randomUUID(),
type: 'ALL_SESSIONS_REVOKED',
userId,
occurredAt: new Date(),
meta: { reason: 'ALL_DEVICES_LOGOUT', revokedCount },
});
return { revokedCount };
}
}
이 코드에서 실무적으로 중요한 부분은 두 가지입니다. 첫째, refresh token 로테이션은 “다음 refresh 발급 + 기존 refresh 폐기”가 반드시 한 흐름으로 묶여야 합니다. 저장이 분리되면 장애 상황에서 토큰이 꼬이기 쉽습니다. 둘째, refresh 해시 불일치는 무조건 로그인 실패로만 보지 말고 보안 이벤트로 기록해야 합니다. 운영 단계에서는 기록이 있어야 정책을 개선할 수 있고, 실제 사고/오탐을 분리할 수 있습니다.
운영/실무에서 자주 겪는 문제
1. refresh 재사용 탐지가 오탐으로 터지는 경우
모바일 네트워크 환경에서는 동일한 요청이 재전송되거나, 두 요청이 거의 동시에 들어오는 경합이 발생할 수 있습니다. 이때 단순히 “해시 불일치 = 계정 탈취”로 처리하면 정상 사용자가 전부 로그아웃되는 문제가 생길 수 있습니다.
대응 전략은 팀/서비스 성격에 따라 달라질 수 있지만, 운영 난이도를 낮추는 방식은 보통 다음처럼 단계화합니다.
- 1단계: 이벤트 로깅만 하고, 세션은 유지
- 2단계: 해당 세션만 revoke하고 재로그인 유도
- 3단계: 전체 세션 revoke + 추가 인증 요구(필요 시)
2. “모든 기기 로그아웃”이 즉시 반영되지 않는 경우
JWT만 검증하는 구조에서는 access token이 만료될 때까지 요청이 계속 통과할 수 있습니다. 사용자는 분명 로그아웃을 했다고 생각하는데, 다른 기기에서는 계속 동작하는 상황이 발생합니다. 결국 운영에서 가장 많이 접수되는 이슈 중 하나가 됩니다.
해결책은 세션 상태를 서버가 통제하도록 만드는 것입니다. 즉, 세션 revoke가 있으면 access token이 유효하더라도 요청을 차단할 수 있어야 합니다. 이 지점은 성능/비용과 맞바꾸는 부분이라 팀/환경에 따라 구현 수준이 달라질 수 있습니다.
3. 로그가 남지 않아 원인 분석이 불가능한 경우
보안 이슈는 “한 번이라도 이상 징후가 있었는지”, “어느 기기에서 발생했는지”, “어느 IP에서 시도했는지”가 핵심입니다. 이 정보가 없으면 재현도 어렵고, CS 대응도 불가능합니다. 결국 운영팀/개발팀이 같은 질문을 반복하게 됩니다.
실무 권장 체크리스트
- 세션이 사용자 단위가 아니라 로그인 단위(sessionId)로 분리되어 있는가
- refresh token을 평문으로 저장하지 않고 해시로 저장하고 있는가
- 기기별 세션 정보를 운영에 필요한 최소 수준으로 기록하고 있는가
- “모든 기기 로그아웃” 시 서버 기준으로 즉시 세션이 revoke 되는가
- JWT payload에 sessionId를 포함하고, 세션 revoke를 검증 흐름에 반영할 수 있는가
- refresh token 로테이션을 사용하고 있으며, 재사용(해시 불일치) 시 보안 이벤트가 남는가
- 보안 이벤트 로그가 일반 로그와 분리되어 있고, 조회/보관 정책이 준비되어 있는가
- 오탐 가능성을 고려해 대응 정책(로깅만/세션만 revoke/전체 revoke)을 단계화했는가
멀티 디바이스 인증 설계의 핵심은 “토큰을 발급한다”가 아니라 “세션 상태를 서버가 통제한다”입니다.
기기별 세션을 분리하면 특정 기기 로그아웃과 전체 로그아웃이 자연스럽게 해결되고, refresh 재사용 탐지를 통해 탈취 가능성을 운영 중에 조기에 포착할 수 있습니다. 결국 보안, 안정성, 유지보수 관점에서 사고 대응 비용을 줄이고, 사용자 요청을 명확한 기준으로 처리할 수 있게 됩니다.
