| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |
- 합정포케
- 양꼬치
- 회고
- 무이네 사막투어
- 무이네투어
- 니코호텔
- 한빛미디어
- Redis
- 밤리단길 맛집
- 기술부채
- 호치민
- 혼공학습단12기
- 포케맛집
- 혼공네트
- 합정 맛집
- 혼공컴운
- 자료구조
- 합정맛집
- OS
- 베트남여행
- 연어포케
- 호치민 맛집
- 호치민 여행
- 핑크성당
- 호치민여행
- 혼공컴운11기
- 호치민 무이네
- 혼공학습단
- Docker
- cs
- Today
- Total
경험은 나의 것
[Spring] Spring PSA의 편리함과 세션 동시성 문제 본문
Spring PSA가 주는 편리함에 간과한 본질을 마주했다.
‘설정 한 줄’로 끝났던 세션이, 동시성이라는 점 때문에 어떻게 변하는지 기록하려고 한다.
1. 편한 PSA & 착각
서비스를 확장하며 WAS 간 세션 정합성 문제를 해결해야 했다.
Spring은 spring.session.store-type=redis라는 설정 한 줄로 복잡한 과정을 추상화 해주었다.
이러한 설정이 스프링이 말하는 PSA의 기능이겠지만, 솔직히 그때는 '참 편하다' 하고 대수롭지 않게 넘겼다.
하지만 운영 환경에서는 예상치 못한 동작을 확인할 수 있었다.
비즈니스 로직을 전혀 수정하지 않아도 모든 것이 해결되었다고 생각했고, 차후에 설계 결과로 인해 데이터가 유실되는 현상을 볼 수 있었다.
2. 분석: Redis Hash와 Spring의 ‘Delta Updates’ 전략
핵심은 다음 한 문장으로 요약된다.
> 인프라의 원자성과 애플리케이션의 원자성은 다르다.
Spring Session은 성능과 정합성 사이의 절충안으로 Redis의 Hash 데이터 구조를 활용한다.
스프링 세션의 최상위 인터페이스인 SessionRepository는 설계 단계에서부터 ‘부분 업데이트’의 가능성을 열어두고 있다.
[Interface Specification: SessionRepository]
/**
* ...
* Creates a new Session that is capable of being persisted by this SessionRepository.
* This allows for optimizations and customizations of how the Session is persisted.
* For example, the returned implementation might keep track of changes to ensure
* that only the delta is kept on save.
*/
S createSession();
Delta Update 전략
- 전략의 핵심: 세션 전체를 매번 다시 저장하지 않는다. 내부적으로 변경된 속성만 추적(keep track of changes)하고, 저장 시점에 **변경분(delta)**만 반영한다.
- 인프라의 역할: Redis의 HSET 명령을 사용해 변경된 필드만 업데이트한다. 필드 단위 업데이트이므로 서로 다른 필드를 수정할 때는 문제가 없다.
- 결정적 한계: HSET 자체는 원자적이지만, 세션 속성을 구성하는 Read–Modify–Write 사이클은 애플리케이션 레벨에서 분리되어 수행된다. 이로 인해 동일 속성을 동시에 수정할 경우 **갱신 손실(Lost Update)**이 발생한다.
3. 검증: Lost Update 재현
서비스의 최근 본 상품 목록 시나리오를 예제로 문제를 재현했다.
@RestController
public class SessionConcurrencyController {
private static final String VIEWED_PRODUCTS = "viewedProducts";
@GetMapping("/view-product-race")
public String viewProductRace(HttpSession session) throws InterruptedException {
int threadCount = 2;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// 두 개의 스레드가 동시에 세션의 동일 리스트에 상품을 추가
executorService.submit(() -> { updateSessionList(session, "상품B"); latch.countDown(); });
executorService.submit(() -> { updateSessionList(session, "상품C"); latch.countDown(); });
latch.await(5, TimeUnit.SECONDS);
executorService.shutdown();
List<String> result = (List<String>) session.getAttribute(VIEWED_PRODUCTS);
return "최종 결과: " + result + (result.size() < 3 ? " -> [유실 발생!]" : " -> [정상]");
}
private void updateSessionList(HttpSession session, String newProduct) {
List<String> original = (List<String>) session.getAttribute(VIEWED_PRODUCTS);
List<String> products = new ArrayList<>(original != null ? original : new ArrayList<>());
try { Thread.sleep(500); } catch (InterruptedException ignored) {}
products.add(newProduct);
session.setAttribute(VIEWED_PRODUCTS, products);
}
}
실행 결과 및 타임라인 분석
실행 결과 및 타임라인 분석 로그에는 두 상품 모두 추가되었다고 찍히지만, 최종 결과는 하나가 누락된 [상품A, 상품C]다.
| Read | [상품A] 조회 | [상품A] 조회 | [상품A] |
| Modify | [상품A, 상품B] 생성 | [상품A, 상품C] 생성 | [상품A] |
| Write (A) | set [상품A, 상품B] | - | [상품A, 상품B] |
| Write (B) | - | set [상품A, 상품C] | [상품A, 상품C] (유실) |
4. 회고 및 대안
단순히 세션 데이터를 저장할때 주의해서 사용하자 라는 말로는 부족하다, 실제로 이 문제를 마주쳤을 때 다음과 같은 점을 고려해야 할 것 같다.
| 대안 | 장점 | 단점 | 추천 상황 |
| 저장소 분리 | 정합성이 완벽 | 인프라 비용 및 복잡도 증가 | 동시 수정이 빈번한 핵심 데이터 |
| 분산 락 (Redlock) | 데이터 유실 방지 | 성능 저하 (Lock 오버헤드) | 세션 정합성이 반드시 필요한 경우 |
| 비즈니스 로직 변경 | 세션 의존도 낮춤 | 구현 난이도 상승 | 최근 본 상품 등 부가 서비스 |
선택과 교훈
이번 케이스에서는 최근 본 상품이 세션에 머물 필요가 없다고 판단하여 별도 DB 저장소로 분리하고 비동기로 처리하는 방식을 택했다.
세션은 최대한 가볍고 불변에 가까운 데이터만 담는 것이 안전하다.
- 추상화의 비용
PSA는 생산성을 높여주지만, 데이터 정합성까지 책임지지는 않는다. - 현실적인 대응 전략
- 세션에 변경 가능한 컬렉션을 직접 저장하지 않는다
- 동시 수정 데이터는 외부 저장소로 분리한다
- 필요 시 분산 락을 도입하되 비용을 인지한다
혹시 내용 중 틀린 점이나 보완할 부분이 있다면 가감 없이 지적 부탁드립니다.
참고 자료
- https://github.com/spring-projects/spring-session
- https://docs.spring.io/spring-session/reference/configuration/redis.html
- https://redis.io/docs/latest/develop/data-types/hashes/
'Dev' 카테고리의 다른 글
| 더이상 MSA를 공부할 필요가 없는 이유 (0) | 2026.01.22 |
|---|---|
| Git Push HTTP 400 에러 해결: "unexpected disconnect while reading sideband packet" (0) | 2026.01.03 |
| 2025년 회고: 정면으로 마주하기 (0) | 2025.12.30 |
| [Spring] Filter의 요청 가로채기 (0) | 2025.12.28 |
| [Spring] Redis의 세션 Store 휩쓸기 (0) | 2025.12.26 |