StaleObjectStateException 동시성 이슈 해결기

JHLee·2025년 12월 19일

트러블슈팅

목록 보기
1/1
post-thumbnail

팀 프로젝트에서 로컬 개발 환경을 세팅하던 중 동시성 이슈(StaleObjectStateException)로 애플리케이션 기동이 실패했습니다.

처음엔 단순 트러블슈팅처럼 보였지만, 해결 과정에서 자연스럽게 “실제 서비스라면 어떻게 설계해야 할까?”까지 고민이 확장되어 정리해보았습니다.


🚨 사건의 발단 : 애플리케이션 기동 실패

로컬에서 애플리케이션을 실행하자마자 아래 오류가 발생하며 기동이 실패했습니다.

StaleObjectStateException: Row was updated or deleted by another transaction

원인을 추적해보니 초기 데이터 자동 입력을 담당하는 데이터 시더(Seeder)가 문제였습니다.

무엇이 문제였나?

Comment(댓글) 데이터를 넣는 과정에서, 부모 엔티티인 Article(기사)commentCount를 업데이트하는 로직이 함께 실행되고 있었는데요.
이 과정에서 같은 Article 레코드를 여러 트랜잭션이 동시에 수정하려고 하면서 충돌이 발생했습니다.

즉, “로컬 실행 → 시딩 → 동일 Row 업데이트 경합 → 예외 → 애플리케이션 종료” 흐름으로 개발 환경 세팅 자체가 막혀버렸습니다.

JPA에서는 동일 엔티티를 여러 트랜잭션이 동시에 갱신할 경우, 먼저 커밋된 변경으로 인해 이후 트랜잭션의 update가 실패하면서 StaleObjectStateException이 발생할 수 있습니다.


💡 해결책 : “실행 순서”를 명확히 하자

핵심 원인은 시더 실행 순서가 보장되지 않는 상태에서, 동일 레코드를 건드리는 작업이 겹쳤다는 점이었습니다.

그래서 모든 시더를 한 곳에서 통제하는 AllDataSeederRunner를 도입해 순차 실행을 강제했습니다.

@Profile("dev")
@Component
@RequiredArgsConstructor
public class AllDataSeederRunner {
    private final List<DataSeeder> seeders;

    @PostConstruct
    public void runAllSeeders() {
        seeders.stream()
            .sorted(Comparator.comparingInt(this::getOrder))
            .forEach(DataSeeder::seed);
    }

    private int getOrder(DataSeeder seeder) {
        if (seeder instanceof UserDataSeeder) return 1;
        if (seeder instanceof InterestDataSeeder) return 2;
        if (seeder instanceof ArticleDataSeeder) return 3;
        if (seeder instanceof CommentDataSeeder) return 4;
        if (seeder instanceof CommentLikeDataSeeder) return 5;
        return 99;
    }
}

이렇게 실행 순서를 명확히 하니 충돌 없이 데이터가 정상 입력되었고, 애플리케이션도 정상 기동되었습니다. 😎


✅ 결과: 일관된 개발 환경 보장

순차 실행을 보장함으로써 동일 레코드에 대한 경합을 원천 차단할 수 있었습니다.
덕분에 팀원 모두가 로컬에서 매번 동일하고 안정적인 초기 데이터를 기준으로 개발을 진행할 수 있게 되었습니다.


🤔 만약 "실제 서비스"에서 동시성 이슈가 발생한다면?

시더 문제는 순차 실행으로 해결했지만, "수만 명의 유저가 사용하는 실제 서비스라면?" 이야기가 완전히 달라질 수 있습니다.

예를 들어 회원가입/결제처럼 중복 요청이 들어오면 치명적인 도메인에서는, 단순한 if (exists) 체크만으로 동시성을 막을 수 없습니다.

💭 예시: 회원가입 요청이 중복으로 들어온다면?

유저가 가입 버튼을 여러 번 누르거나 네트워크 문제로 요청이 중복 전송되는 상황을 상상해보겠습니다.

  • 요청 A: “홍길동 아이디 있나요?” → 서버: “없네요, 가입 진행!” (처리 중)
  • 요청 B: (A 완료 직전) “홍길동 있나요?” → 서버: “아직 없네요, 가입 진행!”

-> 결과: 동일 아이디 유저가 중복 생성될 수 있음 😱


🛠️ 동시성 대응: 어떤 선택지가 있을까?

동시성 이슈를 막는 방법은 상황에 따라 달라지지만, 크게 아래 3가지 축으로 정리할 수 있습니다.

1) DB 제약 조건(Unique Index) 활용

  • DB 레벨에서 UNIQUE를 걸어두면, 마지막 커밋 순간에 DB가 중복을 막아줍니다.
  • 애플리케이션은 중복 키 예외를 잡아서 적절한 에러 응답/재시도 정책을 설계하면 됩니다.
  • 실무에서는 보통 가장 기본이자 반드시 필요한 안전장치입니다.

2) 애플리케이션 레벨 락(Lock)

  • 낙관적 락(Optimistic Lock)
    @Version으로 버전을 비교해, 누군가 먼저 수정했다면 “충돌 → 재시도/실패 처리”로 대응합니다. (충돌이 드물고 재시도 가능한 경우 유리)
  • 비관적 락(Pessimistic Lock)
    SELECT ... FOR UPDATE처럼 조회 시점부터 락을 걸어 다른 트랜잭션 접근을 막습니다.
    (정합성이 극도로 중요한 구간에서 사용하지만, 성능/대기시간 비용이 큼)

3) 분산 락 (Distributed Lock)

  • 서버가 여러 대인 분산 환경에서는 DB 락만으로 부족할 수 있어 Redis 같은 외부 저장소로 락을 관리합니다.
  • “특정 키(예: 회원가입 아이디, 주문번호)에 대해 한 번에 한 요청만 처리” 같은 제어가 가능합니다.

✨ 마치며: 결국 핵심은 “멱등성”

이번 경험을 통해 가장 크게 느낀 건,

여러 번 호출돼도 결과는 같아야 한다(멱등성)
라는 원칙이 시스템 안정성의 핵심이라는 점이었습니다.

예를 들어 결제 시스템이라면,

  • 주문 번호를 유니크하게 관리하고
  • 요청마다 Request-ID 같은 중복 방지 키를 두며
  • 중복 요청이 와도 “한 번 처리된 요청이면 같은 결과를 반환”하도록 설계해야 합니다.

단순한 시더 오류 해결에서 시작된 고민이었지만, 동시성은 결국 시스템의 신뢰성을 결정짓는 방어선이라는 점을 깊이 체감했습니다.
앞으로도 “기능이 돌아가는 코드”를 넘어, 유저의 예측 불가능한 행동과 네트워크 불확실성까지 고려하는 방어적 설계를 습관화해야 할 것 같습니다.🙌

profile
개발자로 성장하기

0개의 댓글