경험은 나의 것

[Spring] Spring PSA의 편리함과 세션 동시성 문제 본문

Dev

[Spring] Spring PSA의 편리함과 세션 동시성 문제

sangkins 2026. 1. 2. 00:42

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 전략

  1. 전략의 핵심: 세션 전체를 매번 다시 저장하지 않는다. 내부적으로 변경된 속성만 추적(keep track of changes)하고, 저장 시점에 **변경분(delta)**만 반영한다.
  2. 인프라의 역할: Redis의 HSET 명령을 사용해 변경된 필드만 업데이트한다. 필드 단위 업데이트이므로 서로 다른 필드를 수정할 때는 문제가 없다.
  3. 결정적 한계: 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는 생산성을 높여주지만, 데이터 정합성까지 책임지지는 않는다.
  • 현실적인 대응 전략
    1. 세션에 변경 가능한 컬렉션을 직접 저장하지 않는다
    2. 동시 수정 데이터는 외부 저장소로 분리한다
    3. 필요 시 분산 락을 도입하되 비용을 인지한다

혹시 내용 중 틀린 점이나 보완할 부분이 있다면 가감 없이 지적 부탁드립니다.

참고 자료