[Spring Boot] 단일 서버 동시성 이슈: synchronized 활용 트러블 슈팅

동재·2025년 3월 24일
post-thumbnail

프로젝트를 개발 서버에 배포 후 부하 테스트를 진행하던 중, 예상치 못한 동시성 이슈로 일부 데이터가 누락되는 문제가 발생했습니다.

해당 글에서는 해당 문제를 어떻게 트러블슈팅했는지 과정을 공유합니다.

1. 📝개요

프로젝트 요구사항

  • Client가 프로그램 다운로드 시 등록 API를 호출한다.
    - 서버에서는 License Count를 1 증가시킨다.
    - 이미 설치 이력이 있다면 Count는 증가하지 않고, 대신 해당 사용자의 최근 접속 시간을 갱신한다.

  • 이후 정기적으로(6시간 주기) Update API를 호출하여 유효한 라이센스인지 검증하기 위해 최근 접속 시간을 업데이트한다.

문제점

  • 과거 API 호출마다 DB Connection을 생성해 CREATEUPDATE 작업 시 트래픽이 몰렸을 때 서버가 과부하로 뻗은 경험이 있었습니다. 따라서 아래와 같이 구현했습니다.

2. 구현

위 문제를 해결하기 위해 ConcurrentHashMap을 활용했습니다:

1. ConcurrentHashMap에 임시 저장

private final Map<String, Agent> agentSaveMap = new ConcurrentHashMap<>();

public void create(String id, long eduOfficeId, String agentType) {
        agentSaveMap.compute(id, (key, existingAgent) -> {
            if (existingAgent != null) {
                existingAgent.updateEduOfficeId(eduOfficeId);
                return existingAgent;
            } else {
                return agentRepository.findById(id)
                        .map(agent -> {
                            agent.updateEduOfficeId(eduOfficeId);
                            return agent;
                        })
                        .orElseGet(() -> Agent.createNew(id, eduOfficeId, agentType));
            }
        });
    }
  • API 요청이 들어올 때마다, DB에 직접 접근하는 대신 agentSaveMap에 데이터를 저장.

2. 배치 프로세스 (스케줄러)로 DB 반영

@Scheduled(fixedDelay = 3000)
@Transactional
public void processCreateQueue() {
    if (!agentSaveMap.isEmpty()) {
        log.info("processCreateQueue not Empty 실행 size {} ", agentSaveMap.size());

        List<Agent> batch = new ArrayList<>(agentSaveMap.values());
        agentSaveMap.clear();
        agentRepository.saveAll(batch);

        log.info("Bulk Inserted/Updated {} agents", batch.size());
    }
}
  • 3초마다 agentSaveMap에 모아둔 데이터를 한꺼번에 DB에 저장.
  • 이를 통해, API 호출마다 DB Connection을 맺지 않아 서버 부담을 줄였습니다.

3. 🔥이슈 감지: 부하 테스트

1. Thread 1000, LoopCount 10, Timer 1000

우선, Thread 수 1000개, LoopCount 10회, Timer 1000ms 환경에서 테스트를 진행했습니다.

결과적으로 특별한 오류 없이 모든 요청이 정상적으로 처리되었으며, 아래 DB 스크린샷처럼 데이터도 정확히 들어와 있음을 확인할 수 있었습니다.

2. Thread 1000, LoopCount 100, Timer 200

이번에는 더 하드한 환경을 만들기 위해 LoopCount 100회로 늘리고, Timer 200ms로 단축했습니다.

JMeter 상으로는 요청이 모두 성공한 것처럼 보이지만, 실제로 DB를 조회해보니 소실된 데이터가 있었습니다.

총 100,000개의 요청을 보냈음에도, 99,966건만 저장되어 약 34건의 데이터가 누락된 것을 확인할 수 있었습니다.

위 테스트를 통해 Timer를 짧게 하여 동시 요청 사이 간격을 좁히면, 동시성 이슈가 발생 한다는 걸 파악했습니다.


4. 🔎원인 파악

ConcurrentHashMap의 동시성 한계 :

ConcurrentHashMap은 개별적인 원소 접근(put, get, compute 등)에 대해서만 스레드 안전을 보장.

문제 상황:

  1. 스케줄러(processCreateQueue)가 agentSaveMap에 있는 데이터를 batch List로 옮기는 과정, 이후 agentSaveMap.clear() 완료 이전에,
@Scheduled(fixedDelay = 3000)
@Transactional
public void processCreateQueue() {
    if (!agentSaveMap.isEmpty()) {
        log.info("processCreateQueue not Empty 실행 size {} ", agentSaveMap.size());

        List<Agent> batch = new ArrayList<>(agentSaveMap.values());
        agentSaveMap.clear();
        agentRepository.saveAll(batch);

        log.info("Bulk Inserted/Updated {} agents", batch.size());
    }
}

** 이부분
List<Agent> batch = new ArrayList<>(agentSaveMap.values());
agentSaveMap.clear();
**
  1. 다른 스레드가 create()를 통해 agentSaveMap에 데이터를 추가할 경우
  • 이 시점에 추가된 데이터가 batch List에는 들어가지 못하고 agentSaveMap에만 존재.
  • DB에는 batch List만 저장되고, agentSaveMap은 clear되어 소실되는 데이터가 존재하게 된다.

요약

create()processCreateQueue()라는 두 이벤트가 동시에 agentSaveMap에 접근할 때, 단순히 ConcurrentHashMap만으로는 전체 연산(복사 후 초기화)을 원자적으로 보장하지 못해 해당 이슈 발생.

[Sequence Diagram]

┌────────────────────────────────────────┐        ┌─────────────────────────────┐
│ Thread A (processCreateQueue)         │        │ Thread B (create)           │
└────────────────────────────────────────┘        └─────────────────────────────┘
              │                                        │
              │ 1) batch = new ArrayList<>(agentSaveMap.values())  
              │────────────────────────────────────────>│
              │   // 현재 시점의 agentSaveMap 데이터를 
              │   // batch 리스트로 복사
              │                                        │
              │                                        │ 2) create() 호출 
              │                                        │    → agentSaveMap에 새 데이터 추가
              │                                        │    // (1) 이후에 들어온 데이터는
              │                                        │    // batch 리스트에는 포함되지 않음
              │                                        │
              │ 3) agentSaveMap.clear()                │
              │────────────────────────────────────────>│
              │   // (2)에서 새로 추가된 데이터도 함께
              │   // Map에서 지워져버림
              │                                        │
              │ 4) agentRepository.saveAll(batch)      │
              │────────────────────────────────────────>│
              │   // batch에는 (1) 시점의 데이터만 있으므로
              │   // (2)에서 추가된 데이터는 DB로 가지 못함
              │                                        │
              ▼                                        ▼
         (처리 종료)                              (대기/종료)

5. 💡해결 방법: synchronized로 원자적 연산 보장

빠른 해결책은 다음과 같이 아래와 같이 동기화 블록을 사용하는 것입니다.

public void create(String id, long eduOfficeId, String agentType) {
    
    synchronized (agentSaveMap) {
    
        agentSaveMap.compute(id, (key, existingAgent) -> {
            if (existingAgent != null) {
                existingAgent.updateEduOfficeId(eduOfficeId);
                return existingAgent;
            } else {
                return agentRepository.findById(id)
                        .map(agent -> {
                            agent.updateEduOfficeId(eduOfficeId);
                            return agent;
                        })
                        .orElseGet(() -> Agent.createNew(id, eduOfficeId, agentType));
            }
        });
    }
}

@Scheduled(fixedDelay = 3000)
@Transactional
public void processCreateQueue() {
    Map<String, Agent> currentBatch;
    
    synchronized (agentSaveMap) {
        if (agentSaveMap.isEmpty()) {
            return;
        }
        currentBatch = new HashMap<>(agentSaveMap);
        agentSaveMap.clear();
    }
    

    List<Agent> batch = new ArrayList<>(currentBatch.values());
    agentRepository.saveAll(batch);
    log.info("Bulk Inserted/Updated {} agents", batch.size());
}
  • synchronized (agentSaveMap)를 통해 같은 객체에 대한 단일 락을 획득.

  • create()processCreateQueue() 어느 쪽이든, 락을 얻기 전까지 대기하게 되므로 동시에 agentSaveMap을 변경할 수 없다.

  • 따라서, agentSaveMap에서 데이터를 꺼내 List로 복사하고 clear()하는 연산이 원자적으로 보장되어 데이터 누락이 발생하지 않는다.

✅Test: Thread 1000, LoopCount 100, Timer 200

개선됨을 확인하기 위해 위에서의 조건 그대로 부하 테스트를 진행했습니다.

JMeter 상으로도 요청이 모두 성공한 것으로 보이고,

실제로 DB에서도 이전과 달리 100,000건 전부 잘 Count 되어 있는 걸 확인할 수 있습니다.


6. 마무리 및 추가 고려사항

현재는 단일 서버 환경에서 단순 로직을 사용하므로, synchronized 만으로도 동시성 문제를 충분히 해결할 수 있었습니다. 하지만 만약 트래픽이 폭발적으로 늘어나거나, 확장이 필요한 상황이 온다면 아래 사항들을 함께 고려해볼 필요가 있을 것 같습니다:

동기화 범위 최소화

  • 한 번에 락을 크게 잡지 않고, 필요한 부분에만 세밀하게 적용해야 잠금 경쟁을 줄일 수 있습니다.
  • ReadWriteLock, StampedLock 등의 대안도 검토해볼 수 있습니다.

배치 주기 최적화

  • 3초 주기가 정답은 아니며, 실제 트래픽 규모와 서버 성능에 따라 적절히 조절해야 할 것입니다.
  • 너무 짧으면 잦은 락 경쟁이 일어나고, 너무 길면 DB 반영 시점이 늦어질 수 있습니다.

7. 결론

  • ConcurrentHashMap을 사용하는 것만으로는 멀티 이벤트(여러 메서드)가 동시에 접근할 때의 동시성 이슈를 해결할 수 없습니다..

  • 간단한 해결책은 동기화 블록(synchronized)를 통해 원자적 연산을 보장하는 것입니다.

감사합니다.

profile
Backend Developer

0개의 댓글