
프로젝트를 개발 서버에 배포 후 부하 테스트를 진행하던 중, 예상치 못한 동시성 이슈로 일부 데이터가 누락되는 문제가 발생했습니다.
해당 글에서는 해당 문제를 어떻게 트러블슈팅했는지 과정을 공유합니다.
프로젝트 요구사항
Client가 프로그램 다운로드 시 등록 API를 호출한다.
- 서버에서는 License Count를 1 증가시킨다.
- 이미 설치 이력이 있다면 Count는 증가하지 않고, 대신 해당 사용자의 최근 접속 시간을 갱신한다.
이후 정기적으로(6시간 주기) Update API를 호출하여 유효한 라이센스인지 검증하기 위해 최근 접속 시간을 업데이트한다.
문제점
CREATE나 UPDATE 작업 시 트래픽이 몰렸을 때 서버가 과부하로 뻗은 경험이 있었습니다. 따라서 아래와 같이 구현했습니다.위 문제를 해결하기 위해 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));
}
});
}
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());
}
}
agentSaveMap에 모아둔 데이터를 한꺼번에 DB에 저장.Timer 1000
우선, Thread 수 1000개, LoopCount 10회, Timer 1000ms 환경에서 테스트를 진행했습니다.
결과적으로 특별한 오류 없이 모든 요청이 정상적으로 처리되었으며, 아래 DB 스크린샷처럼 데이터도 정확히 들어와 있음을 확인할 수 있었습니다.

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

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

총 100,000개의 요청을 보냈음에도, 99,966건만 저장되어 약 34건의 데이터가 누락된 것을 확인할 수 있었습니다.
위 테스트를 통해 Timer를 짧게 하여 동시 요청 사이 간격을 좁히면, 동시성 이슈가 발생 한다는 걸 파악했습니다.
ConcurrentHashMap은 개별적인 원소 접근(put, get, compute 등)에 대해서만 스레드 안전을 보장.
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();
**
create()를 통해 agentSaveMap에 데이터를 추가할 경우batch List에는 들어가지 못하고 agentSaveMap에만 존재.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로 가지 못함
│ │
▼ ▼
(처리 종료) (대기/종료)
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()하는 연산이 원자적으로 보장되어 데이터 누락이 발생하지 않는다.
Timer 200개선됨을 확인하기 위해 위에서의 조건 그대로 부하 테스트를 진행했습니다.

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

실제로 DB에서도 이전과 달리 100,000건 전부 잘 Count 되어 있는 걸 확인할 수 있습니다.
현재는 단일 서버 환경에서 단순 로직을 사용하므로,
synchronized만으로도 동시성 문제를 충분히 해결할 수 있었습니다. 하지만 만약 트래픽이 폭발적으로 늘어나거나, 확장이 필요한 상황이 온다면 아래 사항들을 함께 고려해볼 필요가 있을 것 같습니다:
ReadWriteLock, StampedLock 등의 대안도 검토해볼 수 있습니다.ConcurrentHashMap을 사용하는 것만으로는 멀티 이벤트(여러 메서드)가 동시에 접근할 때의 동시성 이슈를 해결할 수 없습니다..
간단한 해결책은 동기화 블록(synchronized)를 통해 원자적 연산을 보장하는 것입니다.
감사합니다.