Collections.synchronizedSet()
중복 로그인을 차단하기 위해 현재 로그인된 사용자의 ID를 메모리에 보관하는 방식을 많이 사용하는데, 대표적인 구현이 Collections.synchronizedSet()을 사용하는 것이였고, 나 또한 동일하게 사용했다. 다음 코드는 내가 실제로 활용한 코드이다.
@Component
public class LoginUserRegistry {
private final Set<String> loggedInUsers = Collections.synchronizedSet(new HashSet<>());
public boolean isLoggedIn(String userId) {
return loggedInUsers.contains(userId);
}
public void login(String userId) {
loggedInUsers.add(userId);
}
public void logout(String userId) {
loggedInUsers.remove(userId);
}
}
이러한 방식은 단일 서버에서는 잘 동작한다. 하지만 문제는 서버가 2대 이상이 되는 순간 바로 무너진다는 것이다. 이 지점을 이해하기 위해 무중단 배포와 스케일 업/아웃의 개념을 알아보자.
무중단 배포 (Zero Downtime Deployment)
서비스를 중단하지 않고 새 버전을 배포하는 방식이다. 대표적으로 롤링 업데이트, 블루-그린배포, 카나리 배포 등이 있으며 사용자는 배포가 진행중인지 알 수 없다. 새 버전을 배포한다는 말은 내가 수정한 최신 코드(기능)을 실제 서버에 반영해서 사용자들이 사용할 수 있도록 만드는 과정을 뜻한다.
V1-V2 코드 공존 기간
배포 중 네트워크에는 구버전과 신버전 코드가 동시에 존재하고 있다. 이 때 DB 스키마가 변경된다면(컬럼 삭제 등) 구버전 코드가 DB에 접근할 때 에러가 발생할 수 있다. 따라서 DB 스키마는 하위 호환성을 유지하며 점진적으로 변경해야 한다.
세션 공유
배포 중 사용자가 구버전 서버에서 로그인했다가 클릭 시 신버전 서버로 연결될 수 있다. 이때 서버 메모리에 세션을 저장(In-memory(하고 있다면 로그인이 풀린다. 이를 방지하기 위해 Redis 같은 별도의 세션 스토리지를 사용한다.
수직 확장 (스케일 업 / SCALE UP)
CPU, 메모리, 디스크를 더 좋은 것으로 교체하며 서버 1대의 성능을 높이는 방식이다. 이는 한계가 명확하고 비용 부담이 크다.
단일 실패 지점(SPOF)
아무리 성능이 좋아도 서버가 1대이기 때문에, 하드웨어 장애가 발생하면 서비스 전체가 즉시 중단된다
다운타임 발생
CPU나 메모리를 교체하기 위해서는 반드시 장비를 꺼야 하므로, 교체 작업 시간 동안 서비스 중단이 불가피하다.
수평 확장 (스케일 아웃 / SCALE OUT)
서버를 여러 대로 늘리는 방식이다. 트래픽이 증가하면 서버를 추가하고, 줄면 제거한다. 클라우드 환경에서는 Auto Scaling으로 자동화된다. 비용 효율이 높고 유연하지만, 각 서버가 독립된 메모리를 갖는다(무상태성)는 구조적 특성이 있다.
로드 밸런서(Load Balancer)
여러 대의 서버로 트래픽을 분산해주는 L4 / L7 스위치 등의 장치가 필수이다.
무상태성(Stateless)
서버를 늘려도 각 서버가 독립된 메모리를 갖기 때문에, 특정 서버에 데이터를 저장하면 안된다. 모든 서버는 동일한 상태를 유지해야 하므로, 상태(Session, Cache 등)는 반드시 외부 공용 저장소(DB, Redis)에 두어야 한다.
일관성 문제
여러 서버가 동시에 동일한 DB 데이터를 수정하려 할 때 데이터 충돌이 생길 수 있다. 이를 위해 분산 락(Distributed Lock)이나 낙관적/비관적 락(Locking) 개념이 중요하다.
Collections.synchronizedSet() - 문제 발생(스케일 아웃 환경)
Collections.synchronizedSet()은 JVM 힙 메모리 안에 존재한다. 서버가 2대로 늘어나면 각 서버가 별도의 메모리를 가지기 때문에 Set도 서버마다 따로 존재하게 된다. 즉, 무상태성(Stateless)이 깨진다.
예시 상황
1. 사용자 A가 서버 1에 로그인
2. 서버 1의 Set에 사용자 A추가
3. 사용자 A가 다른 기기에서 다시 로그인 요청
4. 서버 2로 라우팅
5. 서버 2의 Set에는 A가 없음
6. 중복 로그인 차단 동작x
결론적으로 메모리 기반 방식은 단일 서버에서만 유효하다. 실무 환경에서 스케일 아웃을 고려한다면 반드시 서버 외부에 공유 저장소를 두어야 한다. 그러면 현재의 방식 외에 중복 로그인을 차단하는 방법은 또 어떤 방식이 있고, 어떤 방식을 사용하는게 좋을까?
중복 로그인 차단 방법
Collections.synchronizedSet()
JVM 메모리에 로그인 사용자 Set을 보관한다. 스레드간 공유는 가능하지만, 서버간 공유가 불가능하다.
장점 : 외부 의존성이 없으며, 구현이 단순하다.
단점 : 서버가 2대 이상이면 동작하지 않는다. 서버 재시작시 데이터가 소멸된다.
DB 기반의 로그인 상태 테이블
로그인 상태를 DB 테이블에 저장한다. 서버가 몇 대든 동일한 DB를 참조하기 때문에 공유가 가능하다.
장점 : 별도 인프라 없이 구현이 가능하며 데이터가 영속된다.
단점 : 로그인 요청마다 DB I/O발생 -> 트래픽이 많으면 병목 현상 발생
Redis - 외부 공유 저장소
Redis는 메모리 기반의 Key-Value 저장소로, 모든 서버가 동일한 Redis를 바라보게 구성한다. 로그인 상태를 Redis에 저장하면 스케일 아웃 환경에서도 정확하게 중복을 감지할 수 있다.
장점 : 메모리 기반으로 속도가 빠르다. TTL로 자동 만료 처리가 가능하며, 스케일 아웃을 완벽하게 지원한다.
단점 : Redis 서버를 별도로 운영해야 한다.
✔️ 메모리 기반 : 하드디스크가 아닌 메모리(RAM)에 데이터를 저장해서 DB보다 빠르다.
✔️ TTL(자동삭제) : 데이터 유효기간을 설정하면 자동 삭제 됨.(로그인 세션 유지 시간에 적합)
✔️ 스케일 아웃 지원 : 서버를 늘려도 모든 서버가 Redis 하나만 참조하면 된다.
Spring Session + Redis
Spring Session은 HttpSession을 Redis에 위임해서 관리한다. 세션 자체가 Redis에 저장되기 때문에 어느 서버로 요청이 들어오든 같은 세션을 읽을 수 있다. 중복 로그인 차단은 세션에 로그인 플래그를 저장하고 Redis에서 확인하는 방식으로 구현한다.
장점 : 세션 관리 시스템 자체를 공유 저장소에 쓰기에 세션 공유와 중복 로그인 차단 로직을 따로 구현할 필요가 없으며, 의존성을 추가하면 Spring Boot에서 자동 구성을 지원한다.
단점 : Redis 의존성을 추가해야 하고, Redis의 구조 이해가 필요하다.
정리
실무 환경을 고려한다면, Redis + Spring Session 조합이 가장 좋은 방식이다. 세션 공유와 중복 로그인 차단 문제를 동시에 해결하고, 이후 스케일 아웃을 해도 코드 변경없이 동작이 가능하기 때문이다.
Redis 사용 이유
1. 처리 속도의 물리적 한계 돌파(In-Memory)
일반적인 RDB(MySQL 등)는 데이터를 하드 디스크(SSD/HDD)에 저장한다. 아무리 처리 속도가 빨라도 물리적인 디스크 I/O가 발생한다. 하지만, Redis는 모든 데이터를 RAM(메모리)에 상주시키기에 탐색 및 읽기/쓰기 속도가 압도적으로 빠르다.
2. 싱글 스레드 기반의 원자성(Atomicity) 보장
여러대의 서버에서 동시에 사용자의 로그인 여부를 요청할 때, Redis는 내부적으로 싱글 스레드로 동작하며 요청을 순차적으로 처리한다. 이 덕분에 락(Lock) 메커니즘을 직접 구현하지 않아도, 데이터의 일관성을 보장한다.
✔ Java의 synchronizedSet은 단일 JVM 내부의 스레드 간 동기화만 가능하지만, Redis는 서버의 수량과 상관 없이 시스템 전체의 데이터 일관성을 보장한다.
3. TTL(세션 관리 최적화)
데이터마다 유효기간(TTl, Time To Live)을 설정할 수 있다. 세션의 만료 시간이 지나면 Redis가 알아서 데이터를 삭제해주기에, 별도의 로직을 구현할 필요가 없다.
'Spring' 카테고리의 다른 글
| [Spring] Spring Security의 기초 이해 (0) | 2026.05.19 |
|---|---|
| [Spring Boot] JPA, Spring Data JPA, QueryDSL의 이해 (0) | 2026.05.16 |
| [Spring Boot] 테스트의 이해와 @Transactional 동작 원리 (0) | 2026.05.12 |
| [Spring] - AOP(관점 지향 프로그래밍)의 이해 (0) | 2026.04.28 |
| [Spring] - BCryptPasswordEncoder를 이용한 비밀번호 암호화와 보안 원리 (1) | 2026.04.16 |