Skip to content
Published on

동시 로그인 방지 구현 가이드 — IP·세션·JWT·Nginx 계층별 전략과 실전 코드

Authors
  • Name
    Twitter

들어가며

하나의 계정으로 여러 곳에서 동시에 로그인하는 것을 막아야 하는 상황은 생각보다 자주 등장합니다. 금융 서비스, 유료 스트리밍, 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 필요, 복잡도 증가
OpenRestyLua + 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가 바뀌면서 로그아웃됨

대응 방안

  1. IP를 단독 판단 기준에서 제외 — 세션/토큰 기반 검증을 주력으로 사용
  2. Grace Period 적용 — IP 변경 감지 시 즉시 차단하지 않고, 5~10분 유예 기간 부여
  3. 디바이스 ID 우선 — 앱이라면 디바이스 UUID, 웹이라면 localStorage의 device_id를 우선 참조
  4. 사용자 피드백 채널 — "본인이 아닌 경우 신고" + "본인입니다" 재인증 버튼

사례: "회사 VPN 연결/해제 시 매번 로그아웃"

원인: VPN 연결 시 IPVPN 게이트웨이 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. 참고 자료


마무리

동시 로그인 방지는 단순해 보이지만, 어디서 무엇을 체크하느냐에 따라 아키텍처 복잡도와 사용자 경험이 크게 달라집니다.

핵심 원칙을 정리하면:

  1. Nginx는 1차 방어 — Rate limit, IP 차단으로 노이즈를 걸러냅니다.
  2. 정책 판단은 앱/인증 서버 — 세션 수 제한, 토큰 유효성 검증, 강제 로그아웃은 애플리케이션의 역할입니다.
  3. IP는 보조 지표 — 단독 판단 기준으로 사용하면 오탐이 많습니다.
  4. 서버 측 상태는 불가피 — 완전한 Stateless에서는 동시 로그인 제어가 불가능합니다. 최소한 refresh token 저장소는 필요합니다.
  5. UX를 잊지 마세요 — 강제 로그아웃 시 명확한 안내와 재로그인 동선이 없으면, 보안 정책이 사용자 이탈로 이어집니다.

운영 환경의 규모, 보안 요구사항, 사용자 패턴에 맞게 위 전략들을 조합하여 적용하시기 바랍니다.