CTF(Capture The Flag) 플랫폼을 개발하면서 심각한 버그가 발견됐다. 백엔드 팀에서 테스트한 결과, 50개 팀이 동시에 같은 문제를 제출했을 때, 절반 정도의 팀만 정상적으로 처리되고 나머지는 처리되지 않았다.
예상: 50개 모두 성공
실제: 약 25개만 성공, 나머지는 타임아웃 에러
모든 팀이 첫 번째 문제를 동시에 제출하는 테스트 상황에서, 절반의 팀이 실패하는 것은 치명적인 문제였다. 사용자들은 정답을 제출했음에도 "나중에 다시 시도하세요"라는 메시지를 받게 되는 것이다.
@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의 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 분산 락을 사용한 이유는 다음과 같다:
하지만 모든 작업을 락 안에서 처리하는 것이 문제였다. 사용자 응답과 관계없는 무거운 작업(점수 계산, 알림 전송)까지 락을 점유한 채로 처리했기 때문에 처리량이 극도로 낮아진 것이다.
나 혼자만의 힘으로는 너무나도 어려운 부분이었다. 이럴 때 필요한 게 AI 형님들이다.
옵션 1: 락 타임아웃 늘리기
lock.tryLock(300, 300, TimeUnit.SECONDS); // 5분으로 증가
옵션 2: 락 없이 처리
옵션 3: 비동기 처리 아키텍처 ⚠️
처음에는 비동기 처리로 문제를 해결하려고 시도했다:
기존: [락 획득] → 모든 작업 수행 → [락 해제] → 응답
시도: [락 획득] → 필수 작업만 수행 → [락 해제] → 응답 → (백그라운드) 무거운 작업
필수 작업 (동기):
백그라운드 작업 (비동기):
이렇게 하면 사용자는 약 150ms만에 응답을 받고, 무거운 작업은 백그라운드에서 처리될 것으로 기대했다.
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;
}
}
무거운 작업을 백그라운드에서 처리하는 비동기 프로세서를 구현했다:
@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);
}
}
// ... 기타 메서드들
}
@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";
}
이렇게 구현하고 테스트를 돌렸더니... 여전히 문제가 발생했다!
예상: challenge.solvers = 50
실제: challenge.solvers = 47
비동기 처리로 응답 시간은 개선되었지만, 새로운 Race Condition이 발생했다.
// 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만 증가
별도의 락을 사용했지만, 여러 비동기 스레드가 동시에 퍼스트 블러드를 체크하면서 중복 판정이 발생하기도 했다.
다이나믹 스코어링과 팀 점수 계산에서도 간헐적으로 불일치가 발생했다.
"Redisson 분산 락이 있는데 왜 이런 문제가?"라고 의문이 들 수 있다.
하지만 Redis Lock은 메인 트랜잭션(submit 메서드)만 보호한다. 비동기 작업(processCorrectSubmissionAsync)은:
@Transactional(propagation = REQUIRES_NEW))Main Thread (Redis Lock으로 보호됨):
[락 획득] → 히스토리 저장 → 비동기 위임 → [락 해제] → 응답
Async Thread A (보호받지 않음):
Challenge 읽기 → solvers + 1 → 저장
Async Thread B (보호받지 않음):
Challenge 읽기 → solvers + 1 → 저장 ← Race Condition!
비동기 처리는 성능은 개선하지만, 동시성 제어가 더 복잡해진다:
특히 여러 비동기 작업이 같은 데이터를 수정하는 경우, 락을 아무리 세밀하게 나눠도 Race Condition을 완전히 방지하기 어렵다.
역시 팀 협업을 하는 이유는 다 있는거다!
- 비동기 처리로 해결하려던 시도가 실패하자, 백엔드 팀에서 다른 접근 방식을 채택했다.
"비동기로 처리할 수 있는 것과 없는 것을 명확히 구분하자"
데이터 정합성이 중요한 핵심 작업들은:
비동기는:
비관적 락(Pessimistic Lock)은 데이터를 읽는 시점에 데이터베이스 레벨의 Lock을 획득하여, 트랜잭션이 완료될 때까지 다른 트랜잭션의 접근을 차단하는 방식이다.
-- 비관적 락을 사용하면 다음과 같은 SQL이 실행됨
SELECT * FROM challenge WHERE challenge_id = ? FOR UPDATE;
FOR UPDATE 절이 추가되어 해당 행(row)에 배타적 락이 걸린다. 다른 트랜잭션은:
JPA는 세 가지 주요 Lock 모드를 제공한다:
| Lock 모드 | SQL | 다른 트랜잭션 Read | 다른 트랜잭션 Write | 충돌 시 동작 |
|---|---|---|---|---|
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE | 차단 | 차단 | 대기 |
PESSIMISTIC_READ | SELECT ... FOR SHARE | 허용 | 차단 | 대기 |
OPTIMISTIC | SELECT (일반) | 허용 | 허용 | 예외 발생 |
이 프로젝트에서는 PESSIMISTIC_WRITE를 사용했다. 배타적 락(Exclusive Lock)을 걸어 다른 트랜잭션이 해당 행을 읽거나 수정하는 것을 완전히 차단한다.
@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);
}
핵심 작업들을 모두 동기로 처리하고, 비관적 락으로 보호:
@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) {
// 전체 팀의 점수를 재계산하는 로직
// 구현 생략
}
}
비동기는 외부 알림만 담당하도록 대폭 축소:
@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 문제가 발생하지 않는다.
Redis Lock + JPA 비관적 락 이중 보호
모든 핵심 작업을 하나의 트랜잭션 안에서 처리
→ 원자성(Atomicity) 보장
비동기는 부가 기능만
초기에는 "무거운 작업을 비동기로"라는 아이디어가 매력적이었지만:
문제점:
결론:
비동기 처리 환경에서 비관적 락이 효과적인 이유:
비관적 락의 단점인 "대기 시간"은 이미 비동기 처리로 인해 사용자 응답과 분리되어 있어 문제가 되지 않는다.
낙관적 락(Optimistic Lock)도 고려했지만 선택하지 않은 이유:
낙관적 락의 동작:
@Version
private Long version; // Entity에 버전 필드 추가
// 충돌 발생 시 OptimisticLockException 발생
// → 재시도 로직 필요
문제점:
1. 높은 동시성 환경에서 재시도가 과도하게 발생
2. 재시도 로직 구현 복잡도 증가
3. 비동기 환경에서 재시도 실패 시 데이터 손실 가능성
비관적 락은 충돌 시 자동으로 대기하므로 안정성이 높다.
비관적 락으로 데이터 정합성이 보장되지만, 추가적인 안전장치를 마련했다.
다음과 같은 상황에서 DB의 solvers 값이 부정확할 수 있다:
따라서 History 테이블을 Source of Truth로 삼아 실시간으로 계산하는 로직을 추가했다.
@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);
}
}
@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 반환 (자동 보정!)
이렇게 하면:
실시간 계산이 성능에 미치는 영향:
-- 인덱스 추가로 빠른 조회 가능
CREATE INDEX idx_history_challenge_correct
ON history(challenge_id, is_correct, login_id);
성능 측정 결과:
조회 시 20ms 추가는 충분히 감수할 만한 수준이다.
최종적으로 세 가지 레벨의 동시성 제어를 갖추게 되었다:
┌─────────────────────────────────────────┐
│ 1단계: Redisson 분산 락 │
│ (애플리케이션 레벨) │
│ │
│ • 메인 트랜잭션 순차 처리 보장 │
│ • 중복 제출 방지 │
│ • 분산 환경 대비 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2단계: JPA 비관적 락 │
│ (데이터베이스 레벨) │
│ │
│ • Read-Modify-Write 패턴 보호 │
│ • solvers 카운트 정확성 보장 │
│ • 트랜잭션 내 데이터 무결성 완벽 보장 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 3단계: 실시간 계산 │
│ (검증 레벨) │
│ │
│ • History 테이블 기반 정확한 값 계산 │
│ • DB 불일치 시 자동 보정 │
│ • Single Source of Truth 확립 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 4단계: 간소화된 비동기 처리 │
│ (부가 기능) │
│ │
│ • 외부 API 알림만 비동기 처리 │
│ • 핵심 기능과 독립적 │
│ • 실패해도 전체 프로세스에 영향 없음 │
└─────────────────────────────────────────┘
1단계 - Redisson 분산 락:
2단계 - JPA 비관적 락:
3단계 - 실시간 계산:
4단계 - 간소화된 비동기 처리:
초기에는 "무거운 작업은 모두 비동기로"라는 접근을 시도했지만, 실패했다.
비동기 처리가 적합한 작업:
비동기 처리가 부적합한 작업:
결론: 비동기는 성능을 위한 도구이지만, 데이터 정합성을 희생해서는 안 된다.
| 단계 | 평균 응답 시간 | P95 | P99 |
|---|---|---|---|
| 개선 전 (동기만) | 5,000ms | 5,200ms | 5,500ms |
| 최종 (비관적 락) | 200ms | 350ms | 500ms |
비관적 락 적용으로 25배 개선되었다.
| 단계 | 10초간 처리 가능 요청 수 | 50개 요청 처리 시간 |
|---|---|---|
| 개선 전 (동기만) | 약 2개 | 약 127초 |
| 최종 (비관적 락) | 약 30~50개 | 약 10초 |
비관적 락 덕분에 12배 향상되었다.
| 단계 | 성공률 | 데이터 정합성 |
|---|---|---|
| 개선 전 (동기만) | 50% (25/50) | 처리된 요청만 보장 |
| 최종 (비관적 락) | 100% (50/50) | 완벽하게 보장 |
최종 버전은 성능과 정합성 모두 달성했다.
초기 가정: "무거운 작업을 비동기로 처리하면 모든 문제가 해결될 것이다"
현실: 비동기 처리는 새로운 동시성 문제를 만들었다
교훈:
예상: "비관적 락은 느리고 데드락 위험이 있다"
현실: 이 프로젝트에서는 매우 효과적이었다
교훈:
초기 시도: 성능을 위해 모든 것을 비동기로
최종 해결: 핵심 작업은 동기 + 비관적 락, 부가 기능만 비동기
교훈:
// 위험한 패턴
entity.setValue(entity.getValue() + 1);
이 간단해 보이는 코드가 동시성 환경에서는 치명적인 버그를 만든다.
다음 패턴을 발견하면 항상 동시성 제어를 고려해야 한다:
해결책:
UPDATE ... SET count = count + 1)애플리케이션 레벨: Redisson 같은 분산 락으로 거시적인 순서 보장
데이터베이스 레벨: JPA 비관적 락으로 미시적인 데이터 정합성 보장
검증 레벨: 실시간 계산으로 최종 안전망 구축
각 레벨마다 적합한 동시성 제어 기법이 다르다.
일반적인 단위 테스트로는 동시성 문제를 발견하기 어렵다.
다음과 같은 전용 테스트가 필수적이다:
교훈:
이 해결 패턴은 다양한 시나리오에 적용할 수 있다:
// 문제 패턴
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)가 동시성 환경에서 얼마나 복잡한 문제를 일으킬 수 있는지 경험했다.
"무거운 작업은 비동기로"라는 단순한 접근은 실패했다.
진짜 중요한 것은:
여담으로 함께 작업할 팀이 있다는 건 굉장히 좋은 거 같다.
쉽지않네요