s00jin 님의 블로그

[동시성 문제] 더블 클릭과 네트워크 문제로 인한 동시성 문제 해결 본문

프로젝트/트러블슈팅

[동시성 문제] 더블 클릭과 네트워크 문제로 인한 동시성 문제 해결

s00jin 2025. 9. 18. 15:59

문제

해커톤 운영진으로 활동하던 당시 굉장히 좋은 백엔드 멘토님을 알게 되었다.

해당 백엔드 멘토님은 해커톤 진행 당시 밤을 새며 참가자들을 멘토링 해주셨는데, 쉬는 시간에도 쉬지 않고, 운영진들에게도 취업 멘토링을 해주셨다.

 

그래서 내 프로젝트와 포트폴리오도 멘토링 해주셨는데, 그때 해주신 질문이 스노우볼 효과로 여기까지 왔다.

 

멘토님이 해주신 질문은 이 프로젝트에 동시성 문제는 어떻게 해결했냐? 였다.

더 자세하게 말하자면, 프로젝트 모집 공고에 동시에 지원하거나 동시에 수락하면 동시성 문제는 어떻게 되냐?였다.

 

해당 프로젝트 아래와 같은 특징이 있다

  • 단일 서버환경으로 구축
  • 프로젝트 모집 공고에 지원자 수 제한이 없음
  • 지원자 수락 또한 모집 공고를 올린 사용자 단 한명만 가능

이러한 특징 때문에 동시성이 발생하지 않을 거라 생각했다. 

 

그래서 멘토님에게 내 생각과 함께 질문을 드렸다. 

단일 서버 환경에서도 더블클릭이나 네트워크 문제로 동시성 문제가 발생할 수 있어요

생각지도 못한 부분의 문제였다.

더블클릭과 네트워크 문제로 인한 동시성 문제가 발생할거라곤 생각도 못했다.

 

자바는 단일 서버 환경이라고 해서 단일 스레드 환경이 아니다.
단일 서버에서도 멀티 스레도 환경으로 동작하기 때문에 위 같은 문제가 발생할 수 있던 것이다.

 


동시성 문제 확인

동시성 문제가 발생하는지 확인하기 위해 더미 데이터와 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. 동시성 문제 해결

우선 동시성 문제를 어떻게 해결할까 고민해서 나온 세 가지 방안이 있다.

  1. synchronized를 사용해서 동시성 문제 해결
  2. 데이터 베이스 원자적 쿼리로 변경
  3. 비관적 락 사용
    1. 데이터 수정 전 미리 해당 데이터에 접근 제한
    2. 락으로 인한 성능 저하 발생 가능
  4. 낙관적 락 사용
    1. 트랜잭션 충돌이 발생하지 않을거라고 낙관적으로 가정
    2. 여러 트랜잭션의 동시 접근 허용 → 미리 락 X, 충돌이 발생하면 그때 처리
    3. @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());
    }

 

추가해주고 다시 테스트 하니 동시성 문제가 해결되었다.