| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- crud
- mysql
- Java
- 로그인
- 커밋 메시지
- Dockerfile
- spring boot
- 해외봉사
- 회고
- 프로그래머스
- 네팔
- springboot
- 우테코
- cors
- 알고리즘
- 쿠키로그인
- fastapi
- 세션로그인
- 부트스트랩
- llm
- 코딩테스트
- Lv.2
- 프로젝트
- docker
- OOM
- Spring
- openAI
- 게시판
- LV2
- 서버 꺼짐
- Today
- Total
s00jin 님의 블로그
[동시성 문제] 더블 클릭과 네트워크 문제로 인한 동시성 문제 해결 본문
문제
해커톤 운영진으로 활동하던 당시 굉장히 좋은 백엔드 멘토님을 알게 되었다.
해당 백엔드 멘토님은 해커톤 진행 당시 밤을 새며 참가자들을 멘토링 해주셨는데, 쉬는 시간에도 쉬지 않고, 운영진들에게도 취업 멘토링을 해주셨다.
그래서 내 프로젝트와 포트폴리오도 멘토링 해주셨는데, 그때 해주신 질문이 스노우볼 효과로 여기까지 왔다.
멘토님이 해주신 질문은 이 프로젝트에 동시성 문제는 어떻게 해결했냐? 였다.
더 자세하게 말하자면, 프로젝트 모집 공고에 동시에 지원하거나 동시에 수락하면 동시성 문제는 어떻게 되냐?였다.
해당 프로젝트 아래와 같은 특징이 있다
- 단일 서버환경으로 구축
- 프로젝트 모집 공고에 지원자 수 제한이 없음
- 지원자 수락 또한 모집 공고를 올린 사용자 단 한명만 가능
이러한 특징 때문에 동시성이 발생하지 않을 거라 생각했다.
그래서 멘토님에게 내 생각과 함께 질문을 드렸다.
단일 서버 환경에서도 더블클릭이나 네트워크 문제로 동시성 문제가 발생할 수 있어요
생각지도 못한 부분의 문제였다.
더블클릭과 네트워크 문제로 인한 동시성 문제가 발생할거라곤 생각도 못했다.
자바는 단일 서버 환경이라고 해서 단일 스레드 환경이 아니다.
단일 서버에서도 멀티 스레도 환경으로 동작하기 때문에 위 같은 문제가 발생할 수 있던 것이다.
동시성 문제 확인
동시성 문제가 발생하는지 확인하기 위해 더미 데이터와 junit으로 멀티 쓰레드 환경을 만들어 테스트 해봤다.
첫 시도
정직하게 쓰레드를 2로 설정해서 테스트 했다.
2로 설정하여 테스트 하려고 하니 문제 상황을 재현하기 힘들었다.
보완
그래서 스레드 수를 20으로 늘려 극단적인 상황을 시뮬레이션했다.
@SpringBootTest
public class DoubleClickTest {
@Autowired
private ApplicationService applicationService;
@Autowired
private FieldRepository fieldRepository;
@Test
void doubleClickTest throws InterruptedException {
Long applicationId = 4001L; // userA 지원서
Integer status = 2; // 2 = 지원서 수락
int threadCount = 20; // 더블클릭 상황을 극단적으로 표현 → 요청 20번
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
// 일부러 동시에 부딪히게 지연
Thread.sleep(100);
applicationService.acceptApplication(applicationId, status);
} catch (Exception e) {
System.out.println("예외 발생: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
// 최종 Field 상태 확인
// 1이 나와야 문제 없는 상황
Field field = fieldRepository.findById(3000L).orElseThrow();
System.out.println("최종 수락 인원(accept_member): " + field.getAcceptMember() +
" / 정원(range_value): " + field.getRange());
}
}
그랬더니 최종 수락 인원이 1도 아닌 20도 아닌 5가 나와버렸다.

테스트 후 기존 코드들을 다시 살펴봤다.
문제가 많아도 너무 많았다...
- 현재 로직에서는 정원을 넘으면 모집 상태가 모집 종료로 변하면서 프론트 딴에서만 지원 화면에 접근할 수 없는 상태이다
- 그래서 정상적인 사용자 흐름에서는 프론트 단에서 해결 가능하지만, 백엔드 관점에서는 문제가 될 수 있던거다.
- 그리고 transactional 애너테이션이 누락되어 있었다.
문제 해결 1. 정원 초과 검증 로직 추가
정원 초과에 대한 검증을 백엔드에서 처리하지 않던 기존 코드이다.
// 필드 조회
List<Field> fields = fieldRepository.findByProject_ProjectId(project.getProjectId());
fields.forEach(field -> {
// 수락되면 필드의 현재 수락된 멤버 수 증가
if (field.getDepartment().equals(projectMember.getDepartment())) { // 지원한 분야랑 같은 분야 확인
field.setAcceptMember(field.getAcceptMember() + 1); // 같으면 지원 수락 된 인원 1 증가
// 모집 인원보다 모집된 인원이 크거나 같으면 분야 모집 상태 2(모집 완료)로 수정
if (field.getAcceptMember() >= field.getRange()) {
field.setDepartmentMemberStatus(2);
}
}
});
아래는 정원 초과 검증 로직을 추가한 코드이다.
// 필드 조회
List<Field> fields = fieldRepository.findByProject_ProjectId(project.getProjectId());
fields.forEach(field -> {
// 수락되면 필드의 현재 수락된 멤버 수 증가
if (field.getDepartment().equals(projectMember.getDepartment())) { // 지원한 분야랑 같은 분야 확인
// 정원 초과 검증
if (field.getAcceptMember() >= field.getRange()) {
throw new IllegalStateException("이미 정원이 가득 찼습니다.");
}
field.setAcceptMember(field.getAcceptMember() + 1); // 같으면 지원 수락 된 인원 1 증가
// 모집 인원보다 모집된 인원이 크거나 같으면 분야 모집 상태 2(모집 완료)로 수정
if (field.getAcceptMember() >= field.getRange()) {
field.setDepartmentMemberStatus(2);
}
}
});
문제 해결 2. 동시성 문제 해결
우선 동시성 문제를 어떻게 해결할까 고민해서 나온 세 가지 방안이 있다.
- synchronized를 사용해서 동시성 문제 해결
- 데이터 베이스 원자적 쿼리로 변경
- 비관적 락 사용
- 데이터 수정 전 미리 해당 데이터에 접근 제한
- 락으로 인한 성능 저하 발생 가능
- 낙관적 락 사용
- 트랜잭션 충돌이 발생하지 않을거라고 낙관적으로 가정
- 여러 트랜잭션의 동시 접근 허용 → 미리 락 X, 충돌이 발생하면 그때 처리
- @Version을 사용하여 확인
간단하게 1번으로 할까 했지만, synchronized는 단일 서버 환경에서만 보장이 된다.
해당 프로젝트는 추후 멀티 서버 환경을 구현할 수도 있는 프로젝트라 후보에서 제외했다.
그리고 3, 4번 중 고민 후 비관적 락 방식을 선택했다.
선택 이유
- 정원 초과 같은 실패하면 안되는 상황에서는 충돌 자체를 차단하는 게 맞다고 생각했다.
- 낙관적 락은 재시도 로직까지 추가해야 해서 복잡도가 올라갈 것 같았다.
비관적 락 적용
package com.kakaotrack.pin.project.repository;
import com.kakaotrack.pin.domain.Field;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface FieldRepository extends JpaRepository<Field, Long> {
// ㅂㅣ관적 락 처리
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT f FROM Field f WHERE f.project.projectId = :projectId")
List<Field> findByProject_ProjectId(Long projectId);
}
비관적 락 처리를 위한 @Lock 과 @Query를 추가해줬다.
그리고 다시 위에서 똑같은 설정으로 테스트 해줬다.

당연히 1이 나올 줄 알았지만, 0이 나왔다?!?!
아까 위에서 말한 @transactional 애너테이션 누락을 추가 안해줬다 ㅎㅎㅎ
// 지원서 수락
@Transactional
public void acceptApplication(Long applicationId, Integer status){
// 지원서 조회
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new IllegalArgumentException("Application not found: " + applicationId));
// 지원서 상태 변경 (2 = 수락)
application.setStatus(status);
// DB 저장
applicationRepository.save(application);
// 프로젝트 멤버 테이블 추가
addProjectMember(application.getAppProject(), application.getAppMember(), application.getDepartment());
}
추가해주고 다시 테스트 하니 동시성 문제가 해결되었다.

'프로젝트 > 트러블슈팅' 카테고리의 다른 글
| [CORS 오류] 프론트에서 api 호출 시 CORS 오류 발생 (1) | 2025.09.15 |
|---|---|
| [Docker/EC2] 배포한 서버가 수정된 코드로 반영되지 않던 이유 (0) | 2025.09.15 |
| [EC2/MySQL] EC2 서버가 몇 시간 만에 꺼지는 이유와 해결 - OOM 문제 (0) | 2025.09.10 |