- Authors
- Name
들어가며
하나의 계정으로 여러 곳에서 동시에 로그인하는 것을 막아야 하는 상황은 생각보다 자주 등장합니다. 금융 서비스, 유료 스트리밍, B2B SaaS, 사내 어드민 등 "한 계정 = 한 세션" 정책이 필수인 도메인이 많기 때문입니다.
이 글에서는 동시 로그인 방지(Concurrent Session Control) 를 구현하기 위한 네 가지 접근 방식을 아키텍처 관점에서 비교하고, 각 계층에서 무엇을 체크해야 하는지, 그리고 실전 코드와 운영 노하우를 한 편에 정리합니다.
1. 접근 방식 비교
1-1. IP 기반 제한
가장 단순한 방법은 "같은 계정이 서로 다른 IP에서 접속하면 차단"하는 것입니다.
장점
- 구현이 매우 단순합니다 — 로그인 시 IP를 기록하고, 요청 IP와 비교하면 됩니다.
- 별도 저장소 없이도 동작할 수 있습니다.
단점 — 오탐(false positive)이 심각합니다
| 시나리오 | 결과 |
|---|---|
| 기업 NAT 환경 — 수백 명이 같은 공인 IP 사용 | 다른 사용자를 같은 사용자로 오판 |
| 모바일망 전환 (Wi-Fi → LTE) | 정상 사용자가 IP 변경으로 강제 로그아웃 |
| 프록시·VPN 사용자 | 동일 IP로 다수 사용자가 접속, 또는 한 사용자가 IP가 자주 변동 |
| CGNAT(통신사 공유 IP) | 전혀 무관한 사용자들이 같은 IP |
결론: IP 기반 제한은 보조 지표(risk signal) 로만 사용하고, 단독 정책으로는 부적합합니다. "같은 IP에서 짧은 시간에 여러 계정 로그인 시도" 같은 브루트포스 탐지에는 유효합니다.
1-2. 세션 기반 단일 로그인
서버 세션 저장소(보통 Redis)에서 사용자별 활성 세션을 관리하는 방식입니다.
동작 흐름
1. 로그인 → 세션 생성 → Redis에 user:{userId}:sessions = {sessionId}
2. 새 로그인 발생 → 기존 세션 ID 조회 → 기존 세션 무효화(삭제)
3. 기존 기기에서 요청 → 세션 없음 → 401 + "다른 곳에서 로그인됨" 메시지
장점
- 서버가 세션 생명주기를 완전히 통제합니다.
- 즉시 강제 로그아웃이 가능합니다.
- 최대 동시 세션 수를 N개로 유연하게 설정할 수 있습니다.
단점
- 서버 사이드 세션 저장소(Redis 등)가 필수입니다.
- Stateless 아키텍처와 충돌할 수 있습니다.
- 세션 저장소 장애 시 전체 인증에 영향을 줍니다.
1-3. JWT 기반 단일 로그인
Stateless JWT를 사용하면서 동시 로그인을 제어하려면 서버 측 상태를 최소한으로 유지하는 전략이 필요합니다.
핵심 기법들
| 기법 | 설명 |
|---|---|
jti (JWT ID) 기반 블랙리스트 | 로그인 시 이전 토큰의 jti를 블랙리스트에 추가. 검증 시 블랙리스트 확인 |
session_version | 사용자 테이블에 session_version 컬럼 → 로그인 시 증가 → JWT 클레임과 비교 |
device_id 바인딩 | 디바이스별 고유 ID를 JWT에 포함, 서버에서 허용 디바이스 목록 관리 |
| Refresh Token Rotation | 새 로그인 시 기존 refresh token family 폐기 → access token 갱신 불가 |
Refresh Token Rotation 흐름
1. 로그인 → access_token + refresh_token 발급
2. refresh_token을 서버 DB/Redis에 저장 (user_id → token_family)
3. 새 기기 로그인 → 새 token_family 발급 + 기존 family 전체 폐기
4. 기존 기기가 refresh 시도 → family가 폐기됨 → 401 + 재로그인 유도
포인트: JWT를 사용하더라도 refresh token의 서버 측 저장은 거의 필수입니다. 완전한 Stateless에서는 동시 로그인 제어가 불가능합니다.
1-4. 디바이스 바인딩 / 위험 기반 인증 (Risk-Based)
User-Agent, IP, 브라우저 핑거프린트 등을 조합하여 디바이스 프로파일을 만들고, 알려지지 않은 디바이스에서의 로그인에 추가 인증을 요구하는 방식입니다.
- 활용 가능 시그널: User-Agent, IP 대역, 화면 해상도, 타임존, 설치된 폰트, Canvas/WebGL 핑거프린트
- 주의사항: 브라우저 핑거프린팅은 개인정보 침해 소지가 있으므로, 동의 기반으로만 수집해야 합니다. GDPR, 개인정보보호법 등 법적 요건을 반드시 확인하세요.
- 권장 패턴: 강제 차단보다는 "새 디바이스에서 로그인 시 이메일/SMS 2차 인증" 형태로 사용합니다.
2. 접근 방식 종합 비교
| 기준 | IP 기반 | 세션 기반 | JWT + Refresh Rotation | 디바이스 바인딩 |
|---|---|---|---|---|
| 정확도 | ❌ 낮음 (NAT/VPN 오탐) | ✅ 높음 | ✅ 높음 | ⚠️ 중간 (핑거프린트 변동) |
| 즉시 강제 로그아웃 | ❌ 불가 | ✅ 가능 | ⚠️ Access 만료까지 지연 | ⚠️ 정책에 따라 다름 |
| 서버 상태 필요 | 최소 | Redis/DB 필수 | Refresh 저장소 필요 | 디바이스 DB 필요 |
| 멀티 디바이스 제어 | ❌ 어려움 | ✅ N개 제한 가능 | ✅ Family별 관리 | ✅ 디바이스별 관리 |
| 구현 난이도 | 쉬움 | 중간 | 중간~높음 | 높음 |
| 개인정보 리스크 | 낮음 | 낮음 | 낮음 | ⚠️ 높음 (핑거프린트) |
실무 권장 조합: 세션 기반 또는 JWT + Refresh Token Rotation을 메인으로, IP와 디바이스 정보는 보조 risk signal로 활용합니다.
3. Nginx 레벨 체크 — 가능한 것과 한계
3-1. Nginx가 잘하는 것
# Rate Limiting — 동일 IP에서의 과도한 요청 제한
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
server {
location /api/auth/login {
limit_req zone=login burst=10 nodelay;
proxy_pass http://backend;
}
}
# IP 기반 접근 제어
geo $blocked {
default 0;
10.0.0.0/8 0; # 내부망 허용
192.168.0.0/16 0; # 내부망 허용
# 특정 악성 IP 차단
203.0.113.50 1;
}
server {
if ($blocked) {
return 403;
}
}
# 국가별 차단 (GeoIP2 모듈)
geoip2 /etc/nginx/GeoLite2-Country.mmdb {
$geoip2_country_code country iso_code;
}
map $geoip2_country_code $allowed_country {
KR 1;
US 1;
JP 1;
default 0;
}
3-2. Nginx의 한계
Nginx는 L7 리버스 프록시이므로 다음을 할 수 없습니다.
- 사용자 세션의 의미 해석: "이 요청이 userId=123의 두 번째 세션인지" 판단 불가
- JWT 클레임 기반 고급 로직: 토큰 파싱은 가능하나, DB/Redis 조회 기반의 세션 유효성 검증은 불가
- 실시간 세션 무효화: 특정 사용자의 세션을 즉시 끊는 동작은 앱 계층의 역할
3-3. njs / Lua / OpenResty 확장
| 확장 방식 | 장점 | 단점 |
|---|---|---|
| njs (Nginx JavaScript) | Nginx 공식 지원, 가벼움 | Redis 연동 제한, 비동기 IO 부족 |
| Lua (lua-nginx-module) | 매우 유연, Redis 직접 연동 | OpenResty 필요, 복잡도 증가 |
| OpenResty | Lua + Nginx 통합, 고성능 | 별도 빌드/관리, 표준 Nginx와 호환성 |
-- OpenResty + Lua 예시: JWT에서 user_id 추출 후 Redis 세션 검증
local jwt = require "resty.jwt"
local redis = require "resty.redis"
local token = ngx.var.http_authorization:sub(8) -- "Bearer " 제거
local jwt_obj = jwt:load_jwt(token)
if jwt_obj.valid then
local red = redis:new()
red:connect("127.0.0.1", 6379)
local active_session = red:get("user:" .. jwt_obj.payload.sub .. ":session")
if active_session ~= jwt_obj.payload.jti then
ngx.status = 401
ngx.say('{"error": "session_expired", "message": "다른 곳에서 로그인되었습니다"}')
return ngx.exit(401)
end
end
최종 권장: 정책 판단은 애플리케이션/인증 서버에서 수행하고, Nginx는 1차 방어(rate limit, IP 차단, 기본 gating) 역할에 집중합니다. OpenResty/Lua 확장은 인증 서버 앞단의 캐시/경량 검증 용도로만 사용하세요.
4. 구현 예시 코드
4-1. Spring Boot — Concurrent Session Control
Spring Security는 동시 세션 제어를 기본으로 지원합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // 최대 1개 세션
.maxSessionsPreventsLogin(false) // false: 기존 세션 만료 (기본)
// true: 새 로그인 차단
.expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"session_expired\",\"message\":\"다른 곳에서 로그인되었습니다\"}"
);
})
);
return http.build();
}
// 분산 환경에서는 Spring Session + Redis 사용
@Bean
public SessionRegistry sessionRegistry() {
return new SpringSessionBackedSessionRegistry<>(this.sessionRepository);
}
@Autowired
private FindByIndexNameSessionRepository<?> sessionRepository;
}
분산 환경 필수 의존성:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
4-2. Django — 세션 테이블 + Redis 미들웨어
# middleware.py
import redis
from django.conf import settings
from django.contrib.auth import logout
r = redis.Redis(host=settings.REDIS_HOST, port=6379, db=0, decode_responses=True)
ACTIVE_SESSION_PREFIX = "user:active_session:"
class SingleSessionMiddleware:
"""한 계정에 하나의 세션만 허용하는 미들웨어"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
key = f"{ACTIVE_SESSION_PREFIX}{request.user.id}"
current_session = request.session.session_key
active_session = r.get(key)
if active_session and active_session != current_session:
# 현재 세션이 활성 세션이 아님 → 강제 로그아웃
logout(request)
from django.http import JsonResponse
return JsonResponse(
{"error": "session_expired",
"message": "다른 곳에서 로그인되었습니다"},
status=401
)
return self.get_response(request)
# signals.py — 로그인 시 활성 세션 등록
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
@receiver(user_logged_in)
def register_active_session(sender, request, user, **kwargs):
key = f"user:active_session:{user.id}"
# 기존 세션 삭제 (Django session store에서)
old_session_key = r.get(key)
if old_session_key:
from django.contrib.sessions.models import Session
Session.objects.filter(session_key=old_session_key).delete()
# 새 세션 등록
r.set(key, request.session.session_key, ex=settings.SESSION_COOKIE_AGE)
4-3. JWT — Refresh Token Rotation 구현
// Node.js / Express 예시
const redis = require('ioredis')
const jwt = require('jsonwebtoken')
const crypto = require('crypto')
const client = new redis()
async function login(userId, deviceId) {
const tokenFamily = crypto.randomUUID()
const jti = crypto.randomUUID()
// 기존 token family 폐기
const oldFamily = await client.get(`user:${userId}:token_family`)
if (oldFamily) {
await client.del(`token_family:${oldFamily}`)
}
// 새 token family 등록
await client.set(`user:${userId}:token_family`, tokenFamily, 'EX', 86400 * 7)
await client.set(
`token_family:${tokenFamily}`,
JSON.stringify({
userId,
deviceId,
jti,
rotationCount: 0,
}),
'EX',
86400 * 7
)
const accessToken = jwt.sign({ sub: userId, jti, device_id: deviceId }, process.env.JWT_SECRET, {
expiresIn: '15m',
})
const refreshToken = jwt.sign(
{ sub: userId, family: tokenFamily, jti: crypto.randomUUID() },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
)
return { accessToken, refreshToken }
}
async function refresh(refreshToken) {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)
const familyData = await client.get(`token_family:${decoded.family}`)
if (!familyData) {
// Token family가 폐기됨 → 탈취 의심 → 모든 세션 무효화
await client.del(`user:${decoded.sub}:token_family`)
throw new Error('TOKEN_FAMILY_REVOKED')
}
const family = JSON.parse(familyData)
// Rotation: 새 토큰 발급 + 기존 refresh 무효화
const newJti = crypto.randomUUID()
family.jti = newJti
family.rotationCount += 1
await client.set(`token_family:${decoded.family}`, JSON.stringify(family), 'EX', 86400 * 7)
const newAccessToken = jwt.sign(
{ sub: decoded.sub, jti: newJti, device_id: family.deviceId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
const newRefreshToken = jwt.sign(
{ sub: decoded.sub, family: decoded.family, jti: crypto.randomUUID() },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
4-4. Nginx 설정 — Rate Limit + 기본 방어
# /etc/nginx/conf.d/security.conf
# 로그인 엔드포인트 Rate Limit
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/s;
# 전역 연결 제한
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# 의심 IP 맵
map $remote_addr $is_suspicious {
default 0;
~^192\.168\.100\. 1; # 예시: 내부 테스트 대역 차단
}
server {
listen 443 ssl;
server_name api.example.com;
# 로그인 엔드포인트
location /api/auth/login {
limit_req zone=auth_limit burst=10 nodelay;
limit_conn conn_limit 10;
if ($is_suspicious) {
return 403;
}
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 인증 필요 API — 앱에서 세션/토큰 검증
location /api/ {
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
5. 아키텍처 정리 — 무엇을 어디서 체크할 것인가
┌────────────────────────────────────────────────┐
│ 클라이언트 │
│ (브라우저/앱 — device_id, fingerprint 생성) │
└─────────────────────┬──────────────────────────┘
│
┌─────────────────────▼──────────────────────────┐
│ Nginx / L7 Proxy │
│ ✅ Rate Limit (login brute-force 방어) │
│ ✅ IP 차단 (geo, deny) │
│ ✅ 기본 헤더 검증 │
│ ❌ 세션 유효성 판단 (앱 역할) │
└─────────────────────┬──────────────────────────┘
│
┌─────────────────────▼──────────────────────────┐
│ 인증 서버 / API Gateway │
│ ✅ JWT 검증 (서명, 만료, jti 블랙리스트) │
│ ✅ session_version 비교 │
│ ✅ Refresh Token Rotation 관리 │
│ ✅ 동시 세션 수 정책 판단 │
└─────────────────────┬──────────────────────────┘
│
┌─────────────────────▼──────────────────────────┐
│ 세션/토큰 저장소 (Redis) │
│ - user:{id}:sessions → Set<sessionId> │
│ - user:{id}:token_family → familyId │
│ - token_family:{id} → {userId, deviceId, jti} │
│ - blacklist:jti:{jti} → TTL │
└──────────────────────────────────────────────────┘
6. 운영 체크리스트
6-1. 강제 로그아웃 UX
- 명확한 안내 메시지: "다른 기기에서 로그인하여 현재 세션이 종료되었습니다"
- 재로그인 유도: 로그인 페이지로 리다이렉트 + 이전 페이지 URL 보존
- 실시간 알림: WebSocket 또는 SSE로 즉시 세션 만료 통지 (polling 대비 UX 우수)
6-2. 알림과 감사 로그
- 새 디바이스 로그인 시 이메일/푸시 알림 발송
- 감사 로그 필수 기록 항목:
timestamp,user_id,action(login/logout/force_logout),ip,user_agent,device_id,session_id - 로그 보존 기간은 컴플라이언스 요건에 맞춤 (금융: 5년, 일반: 1~3년)
6-3. 관리자 기능
- 관리자가 특정 사용자의 모든 세션 강제 해제 가능하도록 구현
- 계정 잠금/해제 기능과 연동
- 관리자 조작도 감사 로그에 기록
6-4. 보안 및 개인정보 고려
- 디바이스 핑거프린트 수집 시 명시적 동의 필요 (개인정보보호법, GDPR)
- IP 주소는 개인정보에 해당 — 로그 보관 및 처리 시 법적 근거 확보
- 세션 ID/토큰을 로그에 평문으로 남기지 않기 (해시 처리)
Secure,HttpOnly,SameSite쿠키 속성 필수 적용
7. 트러블슈팅
7-1. 정상 사용자 오탐으로 강제 로그아웃
문제 상황: 모바일 사용자가 Wi-Fi ↔ LTE 전환 시 IP가 바뀌면서 로그아웃됨
대응 방안
- IP를 단독 판단 기준에서 제외 — 세션/토큰 기반 검증을 주력으로 사용
- Grace Period 적용 — IP 변경 감지 시 즉시 차단하지 않고, 5~10분 유예 기간 부여
- 디바이스 ID 우선 — 앱이라면 디바이스 UUID, 웹이라면 localStorage의 device_id를 우선 참조
- 사용자 피드백 채널 — "본인이 아닌 경우 신고" + "본인입니다" 재인증 버튼
사례: "회사 VPN 연결/해제 시 매번 로그아웃"
원인: VPN 연결 시 IP가 VPN 게이트웨이 IP로 변경
해결: IP 변경만으로는 세션을 끊지 않고, refresh token이 유효하면 세션 유지
+ IP 변경 이벤트를 감사 로그에만 기록
7-2. 멀티 디바이스 허용 정책 설계
모든 서비스가 "단일 세션"을 요구하지는 않습니다. 디바이스 유형별 차등 정책이 더 현실적입니다.
설계 패턴: 디바이스 유형별 슬롯
{
"concurrent_session_policy": {
"max_total": 5,
"per_device_type": {
"web_browser": 2,
"mobile_app": 2,
"tablet_app": 1
},
"on_exceed": "expire_oldest",
"admin_override": true
}
}
구현 로직
# 예시: 디바이스 유형별 슬롯 관리
def check_session_limit(user_id, device_type, new_session_id):
key = f"user:{user_id}:sessions:{device_type}"
sessions = redis.lrange(key, 0, -1)
max_slots = POLICY['per_device_type'].get(device_type, 1)
if len(sessions) >= max_slots:
if POLICY['on_exceed'] == 'expire_oldest':
oldest = redis.lpop(key)
invalidate_session(oldest) # 가장 오래된 세션 만료
elif POLICY['on_exceed'] == 'deny_new':
raise SessionLimitExceeded()
redis.rpush(key, new_session_id)
redis.expire(key, SESSION_TTL)
정책 결정 포인트
| 결정 항목 | 선택지 |
|---|---|
| 초과 시 동작 | 기존 세션 만료 vs 새 로그인 차단 |
| 디바이스 유형 구분 | User-Agent 파싱 vs 클라이언트 명시 전달 |
| 관리자 예외 | 관리자 계정은 무제한 vs 동일 정책 |
| VIP/플랜별 차등 | Free: 1대, Pro: 3대, Enterprise: 무제한 |
8. 참고 자료
- Spring Security - Session Management — 공식 세션 관리 가이드
- Spring Session + Redis — 분산 세션 저장소 구성
- Django Sessions Documentation — Django 세션 공식 문서
- RFC 7519 - JSON Web Token (JWT) — JWT 표준 명세
- Auth0 - Refresh Token Rotation — Refresh Token Rotation 구현 가이드
- Nginx Rate Limiting — Nginx 공식 Rate Limit 가이드
- OpenResty Best Practices — OpenResty 공식 사이트
- OWASP Session Management Cheat Sheet — OWASP 세션 관리 권장사항
- njs Scripting Language — Nginx JavaScript 모듈 공식 문서
마무리
동시 로그인 방지는 단순해 보이지만, 어디서 무엇을 체크하느냐에 따라 아키텍처 복잡도와 사용자 경험이 크게 달라집니다.
핵심 원칙을 정리하면:
- Nginx는 1차 방어 — Rate limit, IP 차단으로 노이즈를 걸러냅니다.
- 정책 판단은 앱/인증 서버 — 세션 수 제한, 토큰 유효성 검증, 강제 로그아웃은 애플리케이션의 역할입니다.
- IP는 보조 지표 — 단독 판단 기준으로 사용하면 오탐이 많습니다.
- 서버 측 상태는 불가피 — 완전한 Stateless에서는 동시 로그인 제어가 불가능합니다. 최소한 refresh token 저장소는 필요합니다.
- UX를 잊지 마세요 — 강제 로그아웃 시 명확한 안내와 재로그인 동선이 없으면, 보안 정책이 사용자 이탈로 이어집니다.
운영 환경의 규모, 보안 요구사항, 사용자 패턴에 맞게 위 전략들을 조합하여 적용하시기 바랍니다.