Spring Boot : 동시성 문제 트러블 슈팅

JYC·2025년 11월 16일
post-thumbnail

문제 발견

CTF(Capture The Flag) 플랫폼을 개발하면서 심각한 버그가 발견됐다. 백엔드 팀에서 테스트한 결과, 50개 팀이 동시에 같은 문제를 제출했을 때, 절반 정도의 팀만 정상적으로 처리되고 나머지는 처리되지 않았다.

예상: 50개 모두 성공
실제: 약 25개만 성공, 나머지는 타임아웃 에러

모든 팀이 첫 번째 문제를 동시에 제출하는 테스트 상황에서, 절반의 팀이 실패하는 것은 치명적인 문제였다. 사용자들은 정답을 제출했음에도 "나중에 다시 시도하세요"라는 메시지를 받게 되는 것이다.

기술 스택

  • Backend: Spring Boot, JPA/Hibernate
  • Database: MySQL
  • Concurrency: Redis, Redisson
  • Async: Spring @Async
  • Test: Python (동시성 테스트)

원인 분석: Redisson 분산 락 타임아웃

기존 코드 구조

@Transactional
public String submit(String loginId, Long challengeId, String flag, String clientIP) {
    RLock lock = redissonClient.getLock("challengeLock:" + challengeId);
    
    try {
        // 락 획득 시도: 대기 시간 10초, 점유 시간 10초
        boolean acquired = lock.tryLock(10, 10, TimeUnit.SECONDS);
        
        if (!acquired) {
            return "Try again later";  // ← 여기서 실패!
        }
        
        // 1. 플래그 검증 (약 100ms)
        ChallengeEntity challenge = challengeRepository.findById(challengeId)
            .orElseThrow(() -> new RestApiException(ErrorCode.CHALLENGE_NOT_FOUND));
        
        if (!challenge.getFlag().equals(flag)) {
            return "오답입니다";
        }
        
        // 2. 히스토리 저장 (약 50ms)
        historyRepository.save(createCorrectAnswerHistory(loginId, challengeId, clientIP));
        
        // 3. 점수 계산 및 업데이트 (약 3초) ← 병목!
        calculateAndUpdateScores(loginId, challengeId);
        
        // 4. 팀원들에게 알림 전송 (약 2초) ← 병목!
        sendNotificationToTeamMembers(loginId, challengeId);
        
        return "정답입니다";
        
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RestApiException(ErrorCode.LOCK_ACQUISITION_FAILED);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

Redisson 락 파라미터 분석

Redisson의 tryLock(waitTime, leaseTime, TimeUnit) 메서드는 세 가지 파라미터를 받는다:

lock.tryLock(10, 10, TimeUnit.SECONDS);
//           ↑   ↑
//           │   └─ leaseTime: 락을 점유할 수 있는 최대 시간 (10초)
//           └───── waitTime: 락 획득을 기다리는 최대 시간 (10초)

waitTime (대기 시간): 다른 스레드가 락을 점유하고 있을 때, 해당 락이 해제되기를 기다리는 최대 시간. 이 시간 내에 락을 획득하지 못하면 false를 반환한다.

leaseTime (점유 시간): 락을 획득한 후 자동으로 해제되기까지의 시간. 이는 데드락을 방지하기 위한 안전장치다.

처리 시간 분석

각 요청당 실제 소요 시간을 측정한 결과:

플래그 검증:        100ms
히스토리 저장:       50ms
점수 계산:        3,000ms  ← 병목 지점
알림 전송:        2,000ms  ← 병목 지점
──────────────────────────
총 처리 시간:     약 5초

점수 계산과 알림 전송이 전체 처리 시간의 대부분(약 5초 중 5초)을 차지했다.

동시성 계산

50개의 요청이 동시에 들어온 경우를 계산해보자:

1개 요청 처리 시간: 약 5초
락 대기 시간(waitTime): 10초
락 점유 시간(leaseTime): 10초

이론적으로 10초 동안 처리 가능한 요청 수:
10초 ÷ 5초 = 약 2개

실제로는 네트워크 지연, 락 경쟁 등으로
10초 동안 약 2~2.5개 정도만 처리됨

50개 요청 ÷ 2개/10초 = 약 250초 필요
→ 10초 대기 시간 내에 락을 획득하지 못한 요청들은 실패!

이것이 바로 약 25개만 성공하고 나머지는 실패하는 이유였다.

왜 Redisson 분산 락을 사용했는가?

이 프로젝트에서 Redisson 분산 락을 사용한 이유는 다음과 같다:

  1. 중복 제출 방지: 같은 사용자가 동시에 여러 번 제출하는 것을 막기 위해
  2. 순차 처리 보장: 같은 문제에 대한 제출을 순서대로 처리하여 데이터 정합성 확보

하지만 모든 작업을 락 안에서 처리하는 것이 문제였다. 사용자 응답과 관계없는 무거운 작업(점수 계산, 알림 전송)까지 락을 점유한 채로 처리했기 때문에 처리량이 극도로 낮아진 것이다.

해결 방안 검토 With Claude

나 혼자만의 힘으로는 너무나도 어려운 부분이었다. 이럴 때 필요한 게 AI 형님들이다.

고려한 옵션들

옵션 1: 락 타임아웃 늘리기

lock.tryLock(300, 300, TimeUnit.SECONDS);  // 5분으로 증가
  • 장점: 간단한 수정
  • 단점: 근본적인 해결책이 아니며, 사용자는 여전히 긴 대기 시간 경험

옵션 2: 락 없이 처리

  • 장점: 빠른 처리 속도
  • 단점: 중복 제출, Race Condition 등 데이터 정합성 문제 발생

옵션 3: 비동기 처리 아키텍처 ⚠️

  • 장점: 사용자에게 즉시 응답, 높은 처리량
  • 단점: 구현 복잡도 증가, 새로운 Race Condition 발생 가능

첫 번째 시도: 비동기 처리 아키텍처

처음에는 비동기 처리로 문제를 해결하려고 시도했다:

기존: [락 획득] → 모든 작업 수행 → [락 해제] → 응답
시도: [락 획득] → 필수 작업만 수행 → [락 해제] → 응답 → (백그라운드) 무거운 작업

필수 작업 (동기):

  • 플래그 검증
  • 히스토리 저장
  • 중복 제출 체크

백그라운드 작업 (비동기):

  • 퍼스트 블러드 판정
  • 점수 계산 및 업데이트
  • solvers 카운트 증가
  • 다이나믹 스코어링
  • 팀 점수 재계산
  • 알림 전송

이렇게 하면 사용자는 약 150ms만에 응답을 받고, 무거운 작업은 백그라운드에서 처리될 것으로 기대했다.

3단계 비동기 처리 시도와 실패

1. AsyncConfig 설정

Spring의 @Async를 사용하기 위한 스레드 풀 설정:

@Configuration
@EnableAsync
public class AsyncConfig {
    
    @Bean(name = "submissionAsyncExecutor")
    public Executor submissionAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);           // 기본 스레드 수
        executor.setMaxPoolSize(20);            // 최대 스레드 수
        executor.setQueueCapacity(100);         // 큐 용량
        executor.setThreadNamePrefix("submission-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

2. AsyncSubmissionProcessor 첫 번째 버전 (실패)

무거운 작업을 백그라운드에서 처리하는 비동기 프로세서를 구현했다:

@Service
@Slf4j
@RequiredArgsConstructor
public class AsyncSubmissionProcessor {
    
    private final TeamService teamService;
    private final ChallengeRepository challengeRepository;
    private final UserRepository userRepository;
    private final HistoryRepository historyRepository;
    private final RedissonClient redissonClient;
    
    @Async("submissionAsyncExecutor")
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processCorrectSubmissionAsync(Long userId, Long challengeId, String loginId) {
        long startTime = System.currentTimeMillis();
        
        try {
            log.info("[비동기 처리 시작] loginId={}, challengeId={}", loginId, challengeId);
            
            // 1. 사용자 및 문제 정보 조회
            UserEntity user = userRepository.findById(userId)
                    .orElseThrow(() -> new RuntimeException("User not found"));
            
            ChallengeEntity challenge = challengeRepository.findById(challengeId)
                    .orElseThrow(() -> new RuntimeException("Challenge not found"));
            
            boolean isSignature = challenge.getCategory() == ChallengeCategory.SIGNATURE;
            
            // 2. 퍼스트 블러드 판정 (별도 락 사용)
            boolean isFirstBlood = checkAndProcessFirstBlood(challengeId, user, challenge, isSignature);
            
            // 3. 팀 점수 및 마일리지 업데이트
            updateTeamScoreAndMileage(user, challenge, isFirstBlood, isSignature, challengeId);
            
            // 4. 다이나믹 스코어링 적용
            if (!isSignature) {
                updateChallengeScore(challenge);
            }
            
            // 5. solvers 카운트 증가 ← 여기서 Race Condition 발생!
            challenge.setSolvers(challenge.getSolvers() + 1);
            challengeRepository.save(challenge);
            
            // 6. 전체 팀 점수 재계산
            updateAllTeamTotalPoints();
            
            long duration = System.currentTimeMillis() - startTime;
            log.info("[비동기 처리 완료] loginId={}, challengeId={}, duration={}ms",
                    loginId, challengeId, duration);
            
        } catch (Exception e) {
            log.error("[비동기 처리 실패] loginId={}, challengeId={}, error={}",
                    loginId, challengeId, e.getMessage(), e);
        }
    }
    
    // ... 기타 메서드들
}

3. ChallengeService 수정 (첫 번째 시도)

@Service
@Slf4j
public class ChallengeService {
    
    private final ChallengeRepository challengeRepository;
    private final HistoryRepository historyRepository;
    private final AsyncSubmissionProcessor asyncProcessor;
    private final RedissonClient redissonClient;
    
    @Transactional
    public String submit(String loginId, Long challengeId, String flag, String clientIP) {
    	//... 초기 단계 스킵 (기본 유저 검증, 플래그 검증, 문제 검증 등, 오답 처리 등)
        
        String lockKey = "challengeLock:" + challengeId;
        RLock lock = redissonClient.getLock(lockKey);
       
        
        try {
            // 락 획득 (5초 대기, 10초 보유)
            // 기존: tryLock(10, 10) → 변경: tryLock(5, 10)
            // 대기 시간을 줄여서 빠르게 실패하도록 함
            locked = lock.tryLock(5, 10, TimeUnit.SECONDS);

            if (!locked) {
                log.warn("[락 획득 실패] loginId={}, challengeId={}", loginId, challengeId);
                return "Try again later";
            }

            // 락 획득 후 다시 한 번 중복 체크 (동시 요청 방지)
            if (historyRepository.existsByLoginIdAndChallengeId(loginId, challengeId)) {
                return "Submitted";
            }

            // 정답 제출 기록 (공격 감지 방지)
            threatDetectionService.recordFlagAttempt(clientIP, true, challengeId, user.getUserId(), loginId, isInternalIP);

            // HistoryEntity 저장 (가장 중요한 작업만 락 안에서 수행)
            HistoryEntity history = HistoryEntity.builder()
                    .loginId(user.getLoginId())
                    .challengeId(challenge.getChallengeId())
                    .solvedTime(LocalDateTime.now())
                    .univ(user.getUniv())
                    .build();
            historyRepository.save(history);

            // TeamHistory 저장
            if (team.isPresent()) {
                TeamHistoryEntity teamHistory = TeamHistoryEntity.builder()
                        .teamName(team.get().getTeamName())
                        .challengeId(challenge.getChallengeId())
                        .solvedTime(LocalDateTime.now())
                        .build();
                teamHistoryRepository.save(teamHistory);
            }

            // 기존 제출 기록 삭제 (오답 시도 기록)
            Optional<SubmissionEntity> existingOpt =
                    submissionRepository.findByLoginIdAndChallengeId(loginId, challengeId);
            existingOpt.ifPresent(submissionRepository::delete);

            long lockDuration = System.currentTimeMillis() - startTime;
            log.info("[락 내부 처리 완료] loginId={}, challengeId={}, 소요시간={}ms",
                    loginId, challengeId, lockDuration);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("[제출 처리 중단] loginId={}, challengeId={}, error={}",
                    loginId, challengeId, e.getMessage());
            return "Error while processing";
        } finally {
            // 락 해제
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

        // 무거운 작업은 비동기로 처리 (락 밖에서 실행)
        try {
            // AsyncSubmissionProcessor를 통해 비동기 처리
            // 이 메서드는 즉시 반환되고, 실제 작업은 백그라운드에서 실행됨
            asyncSubmissionProcessor.processCorrectSubmissionAsync(
                    user.getUserId(),
                    challengeId,
                    loginId
            );
        } catch (Exception e) {
            // 비동기 작업 스케줄링 실패 시 로그만 남기고 계속 진행
            // 사용자에게는 정답 처리된 것으로 표시됨
            log.error("[비동기 작업 스케줄링 실패] loginId={}, challengeId={}, error={}",
                    loginId, challengeId, e.getMessage(), e);
        }

        long totalDuration = System.currentTimeMillis() - startTime;
        log.info("[정답 처리 완료] loginId={}, challengeId={}, 전체소요시간={}ms (비동기 작업 제외)",
                loginId, challengeId, totalDuration);

        // 즉시 정답 응답 반환 (점수 계산 등은 백그라운드에서 처리 중)
        return "Correct";
    }

비동기 처리 첫 번째 시도의 문제점

이렇게 구현하고 테스트를 돌렸더니... 여전히 문제가 발생했다!

문제 1: solvers 카운트 불일치

예상: challenge.solvers = 50
실제: challenge.solvers = 47

비동기 처리로 응답 시간은 개선되었지만, 새로운 Race Condition이 발생했다.

원인: Read-Modify-Write in 비동기 환경

// AsyncSubmissionProcessor에서
challenge.setSolvers(challenge.getSolvers() + 1);  // ← Race Condition!

여러 비동기 스레드가 동시에 같은 Challenge를 읽고 수정하면서 Lost Update 문제 발생:

시간축:

T1: Async Thread A - challenge 조회, solvers = 0 읽기
T2: Async Thread B - challenge 조회, solvers = 0 읽기 ← 동시 읽기!
T3: Thread A - solvers + 1 = 1 계산 후 저장
T4: Thread B - solvers + 1 = 1 계산 후 저장 ← A의 변경 덮어쓰기!

결과: 2번 제출했지만 solvers는 1만 증가

문제 2: 퍼스트 블러드 중복 판정

별도의 락을 사용했지만, 여러 비동기 스레드가 동시에 퍼스트 블러드를 체크하면서 중복 판정이 발생하기도 했다.

문제 3: 점수 계산 불일치

다이나믹 스코어링과 팀 점수 계산에서도 간헐적으로 불일치가 발생했다.

왜 Redis Lock으로 보호되지 않았는가?

"Redisson 분산 락이 있는데 왜 이런 문제가?"라고 의문이 들 수 있다.

하지만 Redis Lock은 메인 트랜잭션(submit 메서드)만 보호한다. 비동기 작업(processCorrectSubmissionAsync)은:

  • 별도의 트랜잭션으로 실행 (@Transactional(propagation = REQUIRES_NEW))
  • Redis Lock 범위 밖에서 동작
  • 여러 비동기 스레드가 동시에 같은 데이터에 접근 가능
Main Thread (Redis Lock으로 보호됨):
  [락 획득] → 히스토리 저장 → 비동기 위임 → [락 해제] → 응답

Async Thread A (보호받지 않음):
  Challenge 읽기 → solvers + 1 → 저장

Async Thread B (보호받지 않음):  
  Challenge 읽기 → solvers + 1 → 저장  ← Race Condition!

비동기 처리의 딜레마

비동기 처리는 성능은 개선하지만, 동시성 제어가 더 복잡해진다:

  1. 각 비동기 스레드마다 별도의 락 필요 → 락 관리 복잡도 증가
  2. 트랜잭션 경계 관리 어려움 → 데이터 정합성 보장 어려움
  3. 에러 처리 복잡 → 실패 시 재시도, 롤백 전략 필요

특히 여러 비동기 작업이 같은 데이터를 수정하는 경우, 락을 아무리 세밀하게 나눠도 Race Condition을 완전히 방지하기 어렵다.

해결책: 핵심 작업은 동기로, 비관적 락 추가 (다른 백엔드가 작업)

역시 팀 협업을 하는 이유는 다 있는거다!

  • 비동기 처리로 해결하려던 시도가 실패하자, 백엔드 팀에서 다른 접근 방식을 채택했다.

핵심 아이디어

"비동기로 처리할 수 있는 것과 없는 것을 명확히 구분하자"

데이터 정합성이 중요한 핵심 작업들은:

  • 동기 처리로 되돌림 (ChallengeService의 락 안에서 처리)
  • 비관적 락 추가로 Race Condition 완전 차단

비동기는:

  • 데이터 무결성과 무관한 작업만 담당 (외부 알림 등)

해결 방안: 비관적 락이란?

비관적 락(Pessimistic Lock)은 데이터를 읽는 시점에 데이터베이스 레벨의 Lock을 획득하여, 트랜잭션이 완료될 때까지 다른 트랜잭션의 접근을 차단하는 방식이다.

-- 비관적 락을 사용하면 다음과 같은 SQL이 실행됨
SELECT * FROM challenge WHERE challenge_id = ? FOR UPDATE;

FOR UPDATE 절이 추가되어 해당 행(row)에 배타적 락이 걸린다. 다른 트랜잭션은:

  • 이 행을 읽을 수 없음 (SELECT도 대기)
  • 이 행을 수정할 수 없음 (UPDATE도 대기)
  • 락이 해제될 때까지 대기(blocking)

JPA Lock 모드

JPA는 세 가지 주요 Lock 모드를 제공한다:

Lock 모드SQL다른 트랜잭션 Read다른 트랜잭션 Write충돌 시 동작
PESSIMISTIC_WRITESELECT ... FOR UPDATE차단차단대기
PESSIMISTIC_READSELECT ... FOR SHARE허용차단대기
OPTIMISTICSELECT (일반)허용허용예외 발생

이 프로젝트에서는 PESSIMISTIC_WRITE를 사용했다. 배타적 락(Exclusive Lock)을 걸어 다른 트랜잭션이 해당 행을 읽거나 수정하는 것을 완전히 차단한다.

구현 1: ChallengeRepository 수정

@Repository
public interface ChallengeRepository extends JpaRepository<ChallengeEntity, Long> {
    
    // 일반 조회 (기존)
    Optional<ChallengeEntity> findById(Long challengeId);
    
    // 비관적 락을 사용한 조회 (추가)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM ChallengeEntity c WHERE c.challengeId = :challengeId")
    Optional<ChallengeEntity> findByIdWithLock(@Param("challengeId") Long challengeId);
}

구현 2: ChallengeService 수정 (최종 버전)

핵심 작업들을 모두 동기로 처리하고, 비관적 락으로 보호:

@Service
@Slf4j
@RequiredArgsConstructor
public class ChallengeService {
    
    private final ChallengeRepository challengeRepository;
    private final HistoryRepository historyRepository;
    private final TeamService teamService;
    private final UserRepository userRepository;
    private final AsyncSubmissionProcessor asyncProcessor;
    private final RedissonClient redissonClient;
    
    @Transactional
    public String submit(String loginId, Long challengeId, String flag, String clientIP) {
        
        RLock lock = redissonClient.getLock("challengeLock:" + challengeId);
        long startTime = System.currentTimeMillis();
        
        try {
            // Redis 분산 락 획득
            boolean acquired = lock.tryLock(10, 10, TimeUnit.SECONDS);
            if (!acquired) {
                log.warn("[락 획득 실패] loginId={}, challengeId={}", loginId, challengeId);
                return "Try again later";
            }
            
            // 1. 비관적 락으로 Challenge 조회 (FOR UPDATE)
            ChallengeEntity challenge = challengeRepository
                .findByIdWithLock(challengeId)  // ← 여기가 핵심!
                .orElseThrow(() -> new RestApiException(ErrorCode.CHALLENGE_NOT_FOUND));
            
            // 2. 플래그 검증
            if (!challenge.getFlag().equals(flag)) {
                historyRepository.save(createWrongAnswerHistory(loginId, challengeId, clientIP));
                return "오답입니다";
            }
            
            // 3. 중복 제출 체크
            if (historyRepository.existsByLoginIdAndChallengeId(loginId, challengeId)) {
                throw new RestApiException(ErrorCode.ALREADY_SUBMITTED);
            }
            
            // 4. 히스토리 저장
            historyRepository.save(createCorrectAnswerHistory(loginId, challengeId, clientIP));
            
            // 5. 사용자 조회
            UserEntity user = userRepository.findByLoginId(loginId)
                    .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_FOUND));
            
            boolean isSignature = challenge.getCategory() == ChallengeCategory.SIGNATURE;
            
            // 6. 퍼스트 블러드 판정 (동기 처리)
            boolean isFirstBlood = checkFirstBlood(challengeId);
            
            // 7. solvers 증가 (비관적 락으로 보호됨)
            challenge.setSolvers(challenge.getSolvers() + 1);
            challengeRepository.save(challenge);
            
            // 8. 다이나믹 스코어링 (동기 처리)
            int newPoints = challenge.getPoints();
            if (!isSignature) {
                newPoints = calculateDynamicScore(challenge, challenge.getSolvers());
                challenge.setPoints(newPoints);
                challengeRepository.save(challenge);
            }
            
            // 9. 팀 점수/마일리지 업데이트 (동기 처리)
            if (user.getCurrentTeamId() != null) {
                int baseMileage = challenge.getMileage();
                int fbBonus = (isFirstBlood && baseMileage > 0) 
                    ? (int) Math.ceil(baseMileage * 0.30) : 0;
                int finalMileage = baseMileage + fbBonus;
                int awardedPoints = isSignature ? 0 : newPoints;
                
                teamService.recordTeamSolution(
                    user.getUserId(),
                    challengeId,
                    awardedPoints,
                    finalMileage
                );
            }
            
            // 10. 전체 팀 점수 재계산 (동기 처리)
            recalculateTeamsByChallenge(challengeId);
            
            // 11. 비동기 작업: 퍼스트 블러드 알림만 전송
            asyncProcessor.processCorrectSubmissionAsync(
                user.getUserId(),
                challengeId,
                loginId,
                isFirstBlood,
                newPoints
            );
            
            long duration = System.currentTimeMillis() - startTime;
            log.info("[제출 처리 완료] loginId={}, challengeId={}, duration={}ms, isFirstBlood={}",
                    loginId, challengeId, duration, isFirstBlood);
            
            return "정답입니다";
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RestApiException(ErrorCode.LOCK_ACQUISITION_FAILED);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    private boolean checkFirstBlood(Long challengeId) {
        long solvedCount = historyRepository.countDistinctByChallengeId(challengeId);
        return solvedCount == 1;
    }
    
    private int calculateDynamicScore(ChallengeEntity challenge, int solvers) {
        int initialPoints = challenge.getInitialPoints();
        int minPoints = challenge.getMinPoints();
        int decay = 50;
        
        double newPoints = (((double)(minPoints - initialPoints) / (decay * decay)) 
            * (solvers * solvers)) + initialPoints;
        newPoints = Math.max(newPoints, minPoints);
        
        return (int) Math.ceil(newPoints);
    }
    
    private void recalculateTeamsByChallenge(Long challengeId) {
        // 전체 팀의 점수를 재계산하는 로직
        // 구현 생략
    }
}

구현 3: AsyncSubmissionProcessor 최종 버전 (간소화)

비동기는 외부 알림만 담당하도록 대폭 축소:

@Service
@Slf4j
@RequiredArgsConstructor
public class AsyncSubmissionProcessor {
    
    private final ChallengeRepository challengeRepository;
    private final UserRepository userRepository;
    private final RedissonClient redissonClient;
    
    @Value("${api.key}")
    private String apiKey;
    
    @Value("${api.url}")
    private String apiUrl;
    
    /**
     * 비동기 처리: 퍼스트 블러드 알림만 전송
     * 
     * 중요: 팀 점수/마일리지/solvers 업데이트는 이미 ChallengeService의 락 안에서 완료됨
     *         비동기에서는 데이터 무결성과 무관한 외부 알림만 전송
     */
    @Async("submissionAsyncExecutor")
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processCorrectSubmissionAsync(Long userId, Long challengeId, String loginId, 
                                             boolean isFirstBlood, int calculatedPoints) {
        long startTime = System.currentTimeMillis();
        
        try {
            UserEntity user = userRepository.findById(userId)
                    .orElseThrow(() -> new IllegalStateException("User not found: " + userId));
            
            ChallengeEntity challenge = challengeRepository.findById(challengeId)
                    .orElseThrow(() -> new IllegalStateException("Challenge not found: " + challengeId));
            
            boolean isSignature = challenge.getCategory() == ChallengeCategory.SIGNATURE;
            
            // 퍼스트 블러드 알림 전송 (일반 문제만)
            if (isFirstBlood && !isSignature) {
                try {
                    sendFirstBloodNotification(challenge, user);
                    log.info("[퍼블 알림 전송] challengeId={}, by={}", challengeId, user.getLoginId());
                } catch (Exception e) {
                    log.warn("[퍼블 알림 실패] challengeId={}, err={}", challengeId, e.getMessage());
                    // 알림 실패는 전체 프로세스에 영향 없음
                }
            }
            
            long duration = System.currentTimeMillis() - startTime;
            log.info("[비동기 처리 완료] loginId={}, challengeId={}, duration={}ms, isFB={}",
                    loginId, challengeId, duration, isFirstBlood);
            
        } catch (Exception e) {
            long duration = System.currentTimeMillis() - startTime;
            log.error("[비동기 처리 실패] challengeId={}, loginId={}, duration={}ms, err={}",
                    challengeId, loginId, duration, e.getMessage(), e);
        }
    }
    
    private void sendFirstBloodNotification(ChallengeEntity challenge, UserEntity user) {
        try {
            RestTemplate restTemplate = new RestTemplate();
            HttpHeaders headers = new HttpHeaders();
            headers.set("Content-Type", "application/json");
            headers.set("X-API-Key", apiKey);
            
            Map<String, Object> body = new HashMap<>();
            body.put("first_blood_problem", challenge.getTitle());
            body.put("first_blood_person", user.getLoginId());
            body.put("first_blood_school", user.getUniv());
            
            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
            ResponseEntity<String> response = restTemplate.exchange(
                apiUrl, HttpMethod.POST, entity, String.class);
            
            if (response.getStatusCode().is2xxSuccessful()) {
                log.info("[퍼블 알림 성공] challengeId={}, loginId={}", 
                        challenge.getChallengeId(), user.getLoginId());
            } else {
                log.error("[퍼블 알림 실패] challengeId={}, statusCode={}", 
                        challenge.getChallengeId(), response.getStatusCode());
            }
        } catch (Exception e) {
            log.error("[퍼블 알림 오류] challengeId={}, err={}", 
                    challenge.getChallengeId(), e.getMessage());
        }
    }
}

비관적 락 동작 과정

Thread A와 Thread B가 동시에 제출하는 경우:

T1: Thread A - Redis Lock 획득
T2: Thread A - findByIdWithLock() 호출
    → DB에 FOR UPDATE Lock 획득
    → solvers = 0 읽기
T3: Thread B - Redis Lock 대기 중...

T4: Thread A - solvers를 1로 증가
T5: Thread A - 다이나믹 스코어링 계산
T6: Thread A - 팀 점수 업데이트
T7: Thread A - 전체 팀 재계산
T8: Thread A - save() → DB 저장
T9: Thread A - 트랜잭션 커밋 → DB Lock 해제
T10: Thread A - Redis Lock 해제
T11: Thread A - 비동기 알림 위임

T12: Thread B - Redis Lock 획득
T13: Thread B - findByIdWithLock() 호출
     → DB에 FOR UPDATE Lock 획득
     → solvers = 1 읽기 (A의 변경사항 반영!)
T14: Thread B - solvers를 2로 증가
T15: Thread B - 다이나믹 스코어링 계산
T16: Thread B - 팀 점수 업데이트
T17: Thread B - 전체 팀 재계산
T18: Thread B - save() → DB 저장
T19: Thread B - 트랜잭션 커밋 → DB Lock 해제
T20: Thread B - Redis Lock 해제
T21: Thread B - 비동기 알림 위임

결과: solvers = 2 (정확함!)

핵심: Thread B는 Thread A가 커밋한 최신 값(1)을 읽는다. Lost Update 문제가 발생하지 않는다.

왜 이 방식이 효과적인가?

  1. Redis Lock + JPA 비관적 락 이중 보호

    • Redis Lock: 분산 환경에서 서버 간 동기화
    • JPA 비관적 락: 데이터베이스 레벨에서 행 단위 보호
  2. 모든 핵심 작업을 하나의 트랜잭션 안에서 처리

    • 퍼스트 블러드 판정
    • solvers 증가
    • 다이나믹 스코어링
    • 팀 점수 업데이트
    • 전체 팀 재계산

    → 원자성(Atomicity) 보장

  3. 비동기는 부가 기능만

    • 외부 API 알림만 담당
    • 실패해도 핵심 기능에 영향 없음

왜 비동기 처리를 포기했는가?

초기에는 "무거운 작업을 비동기로"라는 아이디어가 매력적이었지만:

문제점:

  • 여러 비동기 스레드가 같은 데이터 수정 → Race Condition 불가피
  • 각 작업마다 별도 락 필요 → 복잡도 기하급수적 증가
  • 트랜잭션 경계 불명확 → 부분 실패 시 롤백 어려움

결론:

  • 데이터 정합성이 중요한 작업은 비동기 부적합
  • 무거운 작업이어도 동기로 처리하되 락으로 보호하는 것이 더 안전
  • 비동기는 데이터 무결성과 무관한 부가 기능에만 사용

왜 비관적 락이 효과적인가?

비동기 처리 환경에서 비관적 락이 효과적인 이유:

  1. Read-Modify-Write 패턴 보호: 읽기부터 쓰기까지 원자적으로 보호
  2. 자동 대기: 충돌 시 자동으로 대기하므로 재시도 로직 불필요
  3. 데이터 정합성 완벽 보장: 동시성 환경에서도 항상 정확한 값 유지

비관적 락의 단점인 "대기 시간"은 이미 비동기 처리로 인해 사용자 응답과 분리되어 있어 문제가 되지 않는다.

왜 낙관적 락을 사용하지 않았는가?

낙관적 락(Optimistic Lock)도 고려했지만 선택하지 않은 이유:

낙관적 락의 동작:

@Version
private Long version;  // Entity에 버전 필드 추가

// 충돌 발생 시 OptimisticLockException 발생
// → 재시도 로직 필요

문제점:
1. 높은 동시성 환경에서 재시도가 과도하게 발생
2. 재시도 로직 구현 복잡도 증가
3. 비동기 환경에서 재시도 실패 시 데이터 손실 가능성

비관적 락은 충돌 시 자동으로 대기하므로 안정성이 높다.

이중 안전장치: 실시간 계산

비관적 락으로 데이터 정합성이 보장되지만, 추가적인 안전장치를 마련했다.

왜 추가 안전장치가 필요한가?

다음과 같은 상황에서 DB의 solvers 값이 부정확할 수 있다:

  1. 과거의 버그로 인한 누적 오류
  2. 관리자의 직접 수정
  3. 데이터 마이그레이션 중 오류
  4. 예상치 못한 트랜잭션 롤백

따라서 History 테이블을 Source of Truth로 삼아 실시간으로 계산하는 로직을 추가했다.

구현: 실시간 solvers 계산

@Service
public class ChallengeService {
    
    private final ChallengeRepository challengeRepository;
    private final HistoryRepository historyRepository;
    
    public ChallengeDto.Detail getDetailChallenge(Long challengeId) {
        // Challenge 조회
        ChallengeEntity challenge = challengeRepository.findById(challengeId)
                .orElseThrow(() -> new RestApiException(ErrorCode.CHALLENGE_NOT_FOUND));
        
        // 실시간으로 solvers 카운트 계산
        long actualSolvers = historyRepository.countDistinctSolversByChallengeId(challengeId);
        
        // DB 값 대신 실시간 계산 값 사용
        challenge.setSolvers((int) actualSolvers);
        
        return ChallengeDto.Detail.fromEntity(challenge);
    }
}

HistoryRepository에 메서드 추가

@Repository
public interface HistoryRepository extends JpaRepository<HistoryEntity, Long> {
    
    /**
     * 특정 Challenge를 정답으로 제출한 고유 사용자 수 계산
     */
    @Query("SELECT COUNT(DISTINCT h.loginId) FROM HistoryEntity h " +
           "WHERE h.challengeId = :challengeId AND h.isCorrect = true")
    long countDistinctSolversByChallengeId(@Param("challengeId") Long challengeId);
}

자동 보정 효과

시나리오:
  DB 저장값: challenge.solvers = 47 (비관적 락 적용 전 누적 오류)
  실제 History: 50개 팀이 정답 제출
  조회 결과: 50 반환 (자동 보정!)

이렇게 하면:

  • 과거의 데이터 불일치도 자동으로 수정
  • DB 값에 관계없이 항상 정확한 값 제공
  • History 테이블이 단일 진실 공급원(Single Source of Truth) 역할

성능 고려사항

실시간 계산이 성능에 미치는 영향:

-- 인덱스 추가로 빠른 조회 가능
CREATE INDEX idx_history_challenge_correct 
ON history(challenge_id, is_correct, login_id);

성능 측정 결과:

  • 인덱스 없음: 평균 500ms
  • 인덱스 있음: 평균 20ms

조회 시 20ms 추가는 충분히 감수할 만한 수준이다.

최종 아키텍처: 다층 방어 체계

최종적으로 세 가지 레벨의 동시성 제어를 갖추게 되었다:

┌─────────────────────────────────────────┐
│  1단계: Redisson 분산 락                  │
│  (애플리케이션 레벨)                       │
│                                          │
│  • 메인 트랜잭션 순차 처리 보장            │
│  • 중복 제출 방지                         │
│  • 분산 환경 대비                         │
└─────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────┐
│  2단계: JPA 비관적 락                     │
│  (데이터베이스 레벨)                       │
│                                          │
│  • Read-Modify-Write 패턴 보호           │
│  • solvers 카운트 정확성 보장             │
│  • 트랜잭션 내 데이터 무결성 완벽 보장     │
└─────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────┐
│  3단계: 실시간 계산                       │
│  (검증 레벨)                              │
│                                          │
│  • History 테이블 기반 정확한 값 계산     │
│  • DB 불일치 시 자동 보정                 │
│  • Single Source of Truth 확립          │
└─────────────────────────────────────────┘
              ↓
┌─────────────────────────────────────────┐
│  4단계: 간소화된 비동기 처리               │
│  (부가 기능)                              │
│                                          │
│  • 외부 API 알림만 비동기 처리            │
│  • 핵심 기능과 독립적                     │
│  • 실패해도 전체 프로세스에 영향 없음      │
└─────────────────────────────────────────┘

각 레벨의 역할

1단계 - Redisson 분산 락:

  • 같은 Challenge에 대한 제출을 순차적으로 처리
  • 여러 서버 인스턴스 간 동기화
  • 중복 제출 1차 방어

2단계 - JPA 비관적 락:

  • 핵심 해결책: Read-Modify-Write 패턴의 Race Condition 완전 차단
  • solvers, points 등 카운터 필드의 정확성 보장
  • 데이터베이스 레벨의 확실한 보호

3단계 - 실시간 계산:

  • 과거 버그나 예외 상황에 대한 자동 복구
  • 데이터 신뢰성 최종 보장
  • 모니터링 및 검증 용도

4단계 - 간소화된 비동기 처리:

  • 외부 API 알림만 담당 (퍼스트 블러드 알림 등)
  • 핵심 데이터 처리와 완전히 분리
  • 실패해도 사용자 경험에 영향 없음

핵심 교훈: 무엇을 비동기로 처리할 것인가?

초기에는 "무거운 작업은 모두 비동기로"라는 접근을 시도했지만, 실패했다.

비동기 처리가 적합한 작업:

  • 외부 API 호출 (알림, 웹훅 등)
  • 로깅, 모니터링
  • 캐시 갱신
  • 이메일/SMS 전송

비동기 처리가 부적합한 작업:

  • 데이터 무결성이 중요한 DB 업데이트
  • 다른 작업이 의존하는 계산
  • 트랜잭션 롤백이 필요할 수 있는 작업
  • 여러 스레드가 같은 데이터를 수정하는 작업

결론: 비동기는 성능을 위한 도구이지만, 데이터 정합성을 희생해서는 안 된다.

성능 비교: 최종 결과

응답 시간

단계평균 응답 시간P95P99
개선 전 (동기만)5,000ms5,200ms5,500ms
최종 (비관적 락)200ms350ms500ms

비관적 락 적용으로 25배 개선되었다.

처리량

단계10초간 처리 가능 요청 수50개 요청 처리 시간
개선 전 (동기만)약 2개약 127초
최종 (비관적 락)약 30~50개약 10초

비관적 락 덕분에 12배 향상되었다.

성공률

단계성공률데이터 정합성
개선 전 (동기만)50% (25/50)처리된 요청만 보장
최종 (비관적 락)100% (50/50)완벽하게 보장

최종 버전은 성능과 정합성 모두 달성했다.

배운 교훈

비동기 처리는 만능이 아니다

초기 가정: "무거운 작업을 비동기로 처리하면 모든 문제가 해결될 것이다"

현실: 비동기 처리는 새로운 동시성 문제를 만들었다

  • 여러 비동기 스레드가 같은 데이터 수정 → Race Condition
  • 트랜잭션 경계 불명확 → 부분 실패 시 롤백 어려움
  • 각 작업마다 별도 락 필요 → 복잡도 기하급수적 증가

교훈:

  • 데이터 무결성이 중요한 작업은 비동기 부적합
  • 비동기는 핵심 로직과 독립적인 부가 기능에만 사용
  • 외부 API 호출, 알림, 로깅 등에 적합

비관적 락의 효과

예상: "비관적 락은 느리고 데드락 위험이 있다"

현실: 이 프로젝트에서는 매우 효과적이었다

  • Redis Lock과 결합하여 순차 처리 → 데드락 위험 최소화
  • FOR UPDATE는 매우 빠름 (수 ms)
  • 자동 대기로 재시도 로직 불필요

교훈:

  • 비관적 락은 "동시 쓰기가 빈번한" 상황에 적합
  • 특히 카운터 증가 같은 Read-Modify-Write 패턴에 효과적
  • 락 대기 시간보다 데이터 정합성이 더 중요한 경우 선택

성능과 정합성의 균형

초기 시도: 성능을 위해 모든 것을 비동기로

최종 해결: 핵심 작업은 동기 + 비관적 락, 부가 기능만 비동기

교훈:

  • "성능 vs 정합성"은 이분법이 아니다
  • 적절한 아키텍처 설계로 둘 다 달성 가능
  • 병목을 정확히 파악하여 필요한 부분만 최적화

Read-Modify-Write 패턴을 경계하라

// 위험한 패턴
entity.setValue(entity.getValue() + 1);

이 간단해 보이는 코드가 동시성 환경에서는 치명적인 버그를 만든다.

다음 패턴을 발견하면 항상 동시성 제어를 고려해야 한다:

  • 카운터 증가/감소 (solvers, likes, views)
  • 재고 수정 (stock -= quantity)
  • 잔액 업데이트 (balance += amount)
  • 포인트 적립 (points += reward)

해결책:

  • JPA 비관적 락
  • 원자적 연산 (AtomicInteger 등)
  • DB의 UPDATE 쿼리로 직접 증가 (UPDATE ... SET count = count + 1)

동시성 제어는 레이어마다 다르다

애플리케이션 레벨: Redisson 같은 분산 락으로 거시적인 순서 보장
데이터베이스 레벨: JPA 비관적 락으로 미시적인 데이터 정합성 보장
검증 레벨: 실시간 계산으로 최종 안전망 구축

각 레벨마다 적합한 동시성 제어 기법이 다르다.

테스트의 중요성

일반적인 단위 테스트로는 동시성 문제를 발견하기 어렵다.

다음과 같은 전용 테스트가 필수적이다:

  • 멀티스레드 환경 시뮬레이션 (Python asyncio)
  • 실제 부하 수준의 동시 요청 (50개 팀 동시 제출)
  • 성능 지표 측정 (응답 시간, 처리량, P95/P99)
  • 데이터 정합성 검증 (solvers 카운트 정확성)

교훈:

  • 동시성 버그는 프로덕션에서 발견하기 전에 반드시 테스트로 잡아야 함
  • 부하 테스트 도구 투자는 필수

적용 가능한 다른 상황

이 해결 패턴은 다양한 시나리오에 적용할 수 있다:

좋아요/조회수 시스템

// 문제 패턴
post.setLikeCount(post.getLikeCount() + 1);

// 해결책
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Post> findByIdWithLock(Long postId);

재고 관리

// 문제 패턴
product.setStock(product.getStock() - quantity);

// 해결책: 비관적 락 + 비동기 재고 동기화

티켓 예매

// 문제 패턴
seat.setReservedCount(seat.getReservedCount() + 1);

// 해결책: 비관적 락 + 비동기 알림 처리

투표 시스템

// 문제 패턴
poll.setVoteCount(poll.getVoteCount() + 1);

// 해결책: 비관적 락 + 실시간 카운트 계산

공통 패턴: Read-Modify-Write + 무거운 후처리 작업

공통 해결책: 비관적 락 (필수) + 간소화된 비동기 (선택) + 실시간 검증 (권장)

결론

간단해 보이는 카운터 증가 코드(solvers + 1)가 동시성 환경에서 얼마나 복잡한 문제를 일으킬 수 있는지 경험했다.

문제 해결 여정

  1. 문제 발견: 50개 중 25개만 성공 → Redisson 타임아웃
  2. 첫 번째 시도: 비동기 처리 아키텍처
    • 무거운 작업(점수 계산, 알림)을 백그라운드로 분리
    • 결과: 성능 개선 but 새로운 Race Condition 발생 (solvers 부정확)
  3. 문제 재발견: solvers 카운트 불일치 (50 예상 → 47 실제)
  4. 최종 해결
    • 핵심 작업은 동기로 되돌림 (ChallengeService의 락 안에서)
    • 비관적 락 추가로 Read-Modify-Write 패턴 보호
    • 비동기는 외부 알림만 담당하도록 축소
    • 결과: 성능 + 정합성 모두 확보

핵심 원칙

  1. 비동기는 만능이 아니다: 데이터 무결성이 중요한 작업은 동기로 처리
  2. 비관적 락의 재평가: 적절한 상황에서는 매우 효과적
  3. Read-Modify-Write는 항상 보호: 카운터 증가는 반드시 락으로 보호
  4. 각 레벨에 맞는 동시성 제어: 애플리케이션, DB, 검증 각각 다른 기법
  5. 협업의 힘: 서로 다른 관점이 더 나은 해결책을 만듦

최종 교훈

"무거운 작업은 비동기로"라는 단순한 접근은 실패했다.

진짜 중요한 것은:

  • 무엇을 비동기로 처리할 것인가? (핵심 vs 부가 기능)
  • 데이터 정합성을 어떻게 보장할 것인가? (비관적 락)
  • 검증 체계를 어떻게 구축할 것인가? (실시간 계산)

여담으로 함께 작업할 팀이 있다는 건 굉장히 좋은 거 같다.

참고 자료

Spring & JPA 공식 문서

Concurrency 관련

Redis & Redisson

profile
열심히 하기 1일차

2개의 댓글

comment-user-thumbnail
2025년 12월 7일

쉽지않네요

1개의 답글