Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

하나의 계정으로 여러 곳에서 동시에 로그인하는 것을 막아야 하는 상황은 생각보다 자주 등장합니다. 금융 서비스, 유료 스트리밍, 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;

}

**분산 환경 필수 의존성**:

4-2. Django — 세션 테이블 + Redis 미들웨어

middleware.py

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 연결 시 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](https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html) — 공식 세션 관리 가이드

- [Spring Session + Redis](https://docs.spring.io/spring-session/reference/guides/boot-redis.html) — 분산 세션 저장소 구성

- [Django Sessions Documentation](https://docs.djangoproject.com/en/5.0/topics/http/sessions/) — Django 세션 공식 문서

- [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) — JWT 표준 명세

- [Auth0 - Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation) — Refresh Token Rotation 구현 가이드

- [Nginx Rate Limiting](https://www.nginx.com/blog/rate-limiting-nginx/) — Nginx 공식 Rate Limit 가이드

- [OpenResty Best Practices](https://openresty.org/en/) — OpenResty 공식 사이트

- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — OWASP 세션 관리 권장사항

- [njs Scripting Language](https://nginx.org/en/docs/njs/) — Nginx JavaScript 모듈 공식 문서

마무리

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

**핵심 원칙을 정리하면**:

1. **Nginx는 1차 방어** — Rate limit, IP 차단으로 노이즈를 걸러냅니다.

2. **정책 판단은 앱/인증 서버** — 세션 수 제한, 토큰 유효성 검증, 강제 로그아웃은 애플리케이션의 역할입니다.

3. **IP는 보조 지표** — 단독 판단 기준으로 사용하면 오탐이 많습니다.

4. **서버 측 상태는 불가피** — 완전한 Stateless에서는 동시 로그인 제어가 불가능합니다. 최소한 refresh token 저장소는 필요합니다.

5. **UX를 잊지 마세요** — 강제 로그아웃 시 명확한 안내와 재로그인 동선이 없으면, 보안 정책이 사용자 이탈로 이어집니다.

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

현재 단락 (1/354)

하나의 계정으로 여러 곳에서 동시에 로그인하는 것을 막아야 하는 상황은 생각보다 자주 등장합니다. 금융 서비스, 유료 스트리밍, B2B SaaS, 사내 어드민 등 "한 계정 = 한...

작성 글자: 0원문 글자: 12,719작성 단락: 0/354