📌 고정 게시글

📢 공지합니다

이 게시글은 메인 페이지에 항상 고정되어 표시됩니다.

최코딩의 개발

[맞춤형 헤어 컨설팅 예약] 동시성 충돌 연구해보기 본문

개발 팁

[맞춤형 헤어 컨설팅 예약] 동시성 충돌 연구해보기

seung_ho_choi.s 2025. 10. 10. 18:25
728x90

https://balhae.tistory.com/252

 

2025 블레이버스 MVP 개발 해커톤 대회 테크 인사이트상 수상 후기

어느덧 2025년…!!본격적인 취업 준비에 들어가기 전에, 마지막으로 공모전에 참가하고 싶었다.그때 동아리 친구가 블레이버스 개발 해커톤에 나가자고 제안했다.덕분에 동아리 팀원들과 함께

balhae.tistory.com

 

이번 연휴에는 평소에 미뤄뒀던 주제들을 하나씩 정리해보려 한다.
그중 첫 번째로 동시성 충돌(concurrency issue) 에 대해 깊이 다뤄볼 예정이다.

 

당시 해커톤 프로젝트에서는 예약시스템이 있어서 sychronized 키워드를 사용해 스레드 간 동시성 문제를 제어했다.

 

하지만 시간이 지나면서 공정성(fairness) 문제와 무한 대기(deadlock) 가능성이 드러나,

 

보다 세밀한 제어가 가능한 ReentrantLock 으로 개선했다.

 

이로써 코드 단위의 락 해제와 재진입 제어가 유연해졌고, 상황에 따라 타임아웃 설정이나 공정 모드 적용도 가능해졌다.

그러나 여기서 한 가지 중요한 점을 놓치고 있었다. 바로 서버가 로드밸런싱 환경에서 3대의 인스턴스로 동작하고 있었다는 것이다.

 

즉, JVM 수준에서의 락(synchronized, ReentrantLock)은 인스턴스 간에 공유되지 않기 때문에,

 

멀티 인스턴스 환경에서는 여전히 동시성 충돌이 완전히 해결되지 않는다.

 

그래서 이번 실험에서는

  1. ReentrantLock (단일 JVM 기반 락)
  2. 비관적 락(Pessimistic Lock, DB 수준 락)
  3. Redis 분산락 (Redisson 기반, 다중 인스턴스 환경 대응)

이 세 가지 방법을 적용해보고, 각각의 처리 성능, 락 경쟁 상황, 대기 시간 등을 비교 분석해볼 예정이다.

 

기술 도입

낙관적 락도 존재하지만, 동시에 동일 자원에 접근하는 상황이 빈번하게 발생하는 경우에는 비관적 락을 사용하는 것이 더 적절하다.

예를 들어, 항공권 예약이나 영화관 예매처럼 비교적 여유 있는 시스템에서는 낙관적 락을 사용해도 문제가 없지만,
티켓팅이나 미용실 예약처럼 동일한 시간대에 단 한 명만 예약이 가능하고 요청이 동시에 몰리는 환경에서는 비관적 락이 더 안전하다.


🔹 낙관적 락 (Optimistic Lock)

낙관적 락은 충돌이 거의 없을 것이라고 가정하고, 자원에 접근할 때 잠금을 걸지 않는다.
마치 회의실 예약표를 미리 잠그지 않고 작성한 뒤, 나중에 제출할 때 겹치면 다시 써야 하는 방식과 같다.

이 방식은 트래픽이 적을 때는 효율적이지만,
여러 사용자가 동시에 같은 자원을 수정하려 할 경우 충돌 후 재시도와 롤백이 반복되어 성능 저하가 발생할 수 있다.


🔹 비관적 락 (Pessimistic Lock)

반면, 비관적 락은 충돌이 발생할 가능성이 높다고 가정한다.
자원에 접근하는 순간 잠금을 걸어 다른 사용자의 접근을 차단하는데,
이는 회의실 예약표를 직접 들고 가서 다른 사람이 볼 수 없게 하는 방식과 비슷하다.

이 방식은 데이터 정합성을 확실히 보장할 수 있지만,
한 사용자가 락을 잡고 있는 동안 다른 요청이 대기 상태로 전환되어 응답 지연이 발생할 수 있다.


따라서 어느 방식이 "정답"이라고 단정하기는 어렵다.
다만 이번 테스트에서는 DB 수준에서의 락 동작을 직접 확인하기 위해 비관적 락을 적용하여 실험을 진행했다.


🔹 확장 환경에서의 한계와 Redis 분산 락 도입

한편, 단일 DB 환경에서는 비관적 락만으로도 충분하지만,
DB가 여러 대로 확장된 분산 환경에서는 각 서버의 DB 연결 세션이 분리되어 있어 서버 간 락 공유가 불가능하다.

이를 해결하기 위해 Redis 기반의 분산 락(Redis Distributed Lock)을 도입했다.

Redis 분산 락은 건물 전체의 회의실 예약표를 중앙 관리실에서 통합 관리하는 방식과 유사하다.
여러 서버가 동시에 동일 자원에 접근하더라도, 중앙의 Redis가 “누가 먼저 예약했는지”를 관리하여
단 하나의 요청만 자원을 점유하도록 보장한다.


⚙️ Redis 분산 락의 특성과 트레이드오프

하지만 이 과정에서 각 서버가 Redis 서버와 통신해야 하므로,
락의 획득·해제 과정마다 네트워크 왕복(Round Trip)이 발생한다.
또한 SETNX, PEXPIRE, DEL 등의 명령을 통해 락이 관리되기 때문에
비관적 락보다 평균 응답 시간이 길어지는 단점이 존재한다.

결국, Redis 분산 락은 속도보다는 전역 일관성을 보장하는 데 초점을 맞춘 방식이라고 할 수 있다.

 

 

단일 서버 

import http from 'k6/http';
import { check, sleep } from 'k6';

// 테스트 옵션 설정
export const options = {
    vus: 50,                // 가상 사용자 50명
    duration: '30s',        // 테스트 지속 시간 30초
    thresholds: {
        http_req_failed: ['rate<0.05'],   // 실패율 5% 이하
        http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이하
    },
};

// 테스트 대상 URL
const BASE_URL = 'http://localhost:8080/api/consulting';

// 공통 헤더
const params = {
    headers: {
        'Content-Type': 'application/json',
    },
};

export default function () {
    // 각 가상 사용자별로 userId 2~51 부여
    const userId = (__VU + 1).toString();

    // 요청 바디
    const payload = JSON.stringify({
        designerId: "uuid01",
        meet: "offline",
        pay: "카카오페이",
        startTime: "2025-10-09T15:30:00",
        userId: userId
    });

    // POST 요청
    const res = http.post(BASE_URL, payload, params);

    // 응답 검증
    check(res, {
        'status was 200': (r) => r.status === 200,
        'response time < 500ms': (r) => r.timings.duration < 500,
    });

    sleep(0.1); // 사용자 간 약간의 간격
}

 

 

단일 서버에서 테스트를 진행해볼 K6 코드이다.
이 코드를 통해 여러 사용자가 동시에 동일한 API를 호출할 때 발생할 수 있는 동시성 문제(예: 중복 처리, 락 충돌, 트랜잭션 경합 등)시뮬레이션 형태로 검증할 수 있다.

 

다만, K6은 서버 외부에서 부하를 발생시키는 도구이므로, 동시성의 내부 원인(스레드, 락, 트랜잭션 경합) 은 별도의 로그나 모니터링 도구를 통해 분석해야 한다.

 

ReentrantLock

@Component
public class ReentrantLockManager {

    private final ConcurrentHashMap<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, Boolean> reservedMap = new ConcurrentHashMap<>();

    public ReentrantLock getLock(String designerId, LocalDateTime startTime) {
        String lockKey = createKey(designerId, startTime);
        return lockMap.computeIfAbsent(lockKey, key -> new ReentrantLock(true));
    }

    public boolean isReserved(String designerId, LocalDateTime startTime) {
        String lockKey = createKey(designerId, startTime);
        return reservedMap.getOrDefault(lockKey, false);
    }

    public void markAsReserved(String designerId, LocalDateTime startTime) {
        String lockKey = createKey(designerId, startTime);
        reservedMap.put(lockKey, true);
    }

    private String createKey(String designerId, LocalDateTime startTime) {
        return designerId + ":" +
                startTime.truncatedTo(ChronoUnit.MINUTES)
                        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"));
    }
}

 

public ConsultingResponseDTO executeV1(ConsultingRequestDTO req, String userId) {

    . . . . 객체 불러오기
    
    ReentrantLock lock = reentrantLockManager.getLock(req.designerId(), req.startTime());
    boolean acquired = false;

    try {
        acquired = lock.tryLock(5, TimeUnit.SECONDS);
        if (!acquired) {
            throw new BlaybusException(HttpStatus.CONFLICT, "현재 예약 요청이 많습니다. 잠시 후 다시 시도해주세요.");
        }

        // 락 안에서 메모리 캐시로 중복 체크
        if (reentrantLockManager.isReserved(req.designerId(), req.startTime())) {
            throw new BlaybusException(HttpStatus.CONFLICT, "이미 해당 시간에 예약 시간이 존재합니다.");
        }

        // DB에서도 한 번 더 체크 (혹시 모를 경우 대비)
        if (consultingRepository.existsByDesignerAndStartTime(designer, req.startTime())) {
            throw new BlaybusException(HttpStatus.CONFLICT, "이미 해당 시간에 예약 시간이 존재합니다.");
        }

        	. . . . . . . 저장 로직

        return ConsultingResponseDTO.from(consulting);
        
        // 저장 성공 후 메모리에 마킹
        reentrantLockManager.markReserved(req.designerId(), req.startTime());

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BlaybusException(HttpStatus.INTERNAL_SERVER_ERROR, "락 획득 중 인터럽트 발생");
    } finally {
        if (acquired) {
            lock.unlock();
        }
    }
}


여기서 락은 finally 블록에서 즉시 해제되지만, 실제 DB 커밋은 @Transactional 메서드가 완전히 종료된 뒤에 이루어진다.

 

즉, 락이 풀린 순간 다른 스레드가 같은 자원에 대한 락을 획득해 DB에 접근할 수 있는 여지가 생긴다는 뜻이다.

 

이러한 타이밍 이슈를 방지하기 위해, 락 해제 직전에 메모리 캐시에 예약 상태를 미리 반영하여 다른 스레드가 동일한 시간대에 접근하지 못하도록 한 것이다.

 

비관적 락

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Consulting c WHERE c.designer = :designer AND c.startTime = :startTime")
Optional<Consulting> findWithPessimisticLock(@Param("designer") Designer designer,
                                             @Param("startTime") LocalDateTime startTime);
public ConsultingResponseDTO executeV2(ConsultingRequestDTO req, String userId) {
  
    . . . . 객체 불러오기

    // 동일 (designer, startTime) 조합에 대해 DB가 직접 Lock 잡음
    boolean exists = consultingRepository.findWithPessimisticLock(designer, req.startTime()).isPresent();
    if (exists) {
        throw new BlaybusException(HttpStatus.CONFLICT, "이미 해당 시간에 예약 시간이 존재합니다.");
    }
  
  			. . . . . . . 저장 되는 코드
  
   return new ConsultingResponseDTO(
                consulting.getId(),
                consulting.getUser().getId(),
                consulting.getDesigner().getId(),
                consulting.getMeeting(),
                type,
                ConsultingStatus.fromString(status),
                consulting.getPay(),
                consulting.getStartTime()
        );
    }

 

DB 수준에서 미리 락을 걸어, 동시에 접근하는 다른 트랜잭션을 사전에 차단하였다.

 

 

Redis 분산락

 public ConsultingResponseDTO execute(ConsultingRequestDTO req, String userId) {

      	. . . . 객체 불러오기 
        
        String normalizedTime = req.startTime()
                .truncatedTo(ChronoUnit.MINUTES)
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"));

        String lockKey = "lock:consulting:" + req.designerId() + ":" + normalizedTime;
        String reservedKey = "reserved:consulting:" + req.designerId() + ":" + normalizedTime;

        RLock lock = redissonClient.getLock(lockKey);

        boolean isLocked = false;
        try {
            // waitTime=5s, leaseTime=10s
            isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new BlaybusException(HttpStatus.CONFLICT, "현재 예약 요청이 많습니다. 잠시 후 다시 시도해주세요.");
            }

            // Redis 캐시에서 먼저 체크
            RBucket<String> reservedBucket = redissonClient.getBucket(reservedKey);
            if (reservedBucket.isExists()) {
                throw new BlaybusException(HttpStatus.CONFLICT, "이미 해당 시간에 예약 시간이 존재합니다.");
            }

            // DB에서도 체크
            if (consultingRepository.existsByDesignerAndStartTime(designer, req.startTime())) {
                throw new BlaybusException(HttpStatus.CONFLICT, "이미 해당 시간에 예약 시간이 존재합니다.");
            }

            . . . . . . . 저장되는 코드

            // Redis에 예약 완료 마킹 (TTL 1시간)
            reservedBucket.set("reserved", 1, TimeUnit.HOURS);

            return new ConsultingResponseDTO(
                    consulting.getId(),
                    consulting.getUser().getId(),
                    consulting.getDesigner().getId(),
                    consulting.getMeeting(),
                    type,
                    ConsultingStatus.fromString(status),
                    consulting.getPay(),
                    consulting.getStartTime()
            );

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BlaybusException(HttpStatus.INTERNAL_SERVER_ERROR, "락 획득 중 인터럽트 발생");
        } finally {
            if (isLocked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

 

앞서 명시적 락을 사용한 경우와 동일한 상황이다. 이쪽 역시 별도의 캐시를 두어 예약 상태를 관리하였다.

 

예약 완료 마킹을 1시간으로 설정한 이유는,
해당 시간대의 예약이 끝난 이후에는 동일한 시간 슬롯에 대한 중복 요청이 더 이상 의미가 없기 때문이다.

 

즉, 예약 시작 시점부터 1시간이 지나면 자연스럽게 해당 시간대의 예약 가능 여부가 초기화되도록 하여,
불필요한 캐시 데이터가 오래 남지 않게 하고 메모리 효율성을 유지하기 위함이다.

 

 

앞서 코드를 기반으로 K6 부하 테스트를 진행했다.

단순히 테스트 코드로 검증할 수도 있었지만, 응답 지연(ms) 을 함께 확인하기 위해 K6를 활용했다.
왼쪽부터 순서대로 ReentrantLock, 비관적 락, Redis 분산 락 결과이다.

구분 락 종류 평균 응답 시간 최대 응답 시간 95% 응답 시간 요청 수 HTTP 실패율 동시성 관점 분석
ReentrantLock (JVM 내부 락) 9.11 ms 565.87 ms 12.32 ms ≈ 455 req/s 99.99 % 단일 인스턴스 내에서만 동기화되므로 처리 속도는 빠르지만, 외부 요청 충돌 시 일부 스레드가 락을 획득하지 못해 요청 실패율이 매우 높음. 서버 내부에서는 동시성 제어가 잘 되지만, 분산 환경에는 부적합.
비관적 락 (Pessimistic Lock / DB 레벨) 11.36 ms 322.23 ms 17.2 ms ≈ 443 req/s 99.99 % 트랜잭션 단위의 DB 락으로 인해 정합성은 확보되지만, 동시에 다수의 요청이 들어오면 DB 락 대기로 인해 응답이 지연됨. 락 경합이 심화될수록 병목 발생. 즉 시스템 전체 성능 마비로 이어짐.
Redis 분산 락 (Redisson) 105.07 ms 594.73 ms 259.48 ms ≈ 242 req/s 99.98 % 네트워크 기반 분산 락으로 인해 응답 시간이 크게 증가했으며, 락 획득·해제 과정의 네트워크 오버헤드가 명확히 드러남. 그러나 여러 인스턴스 간의 일관성 유지에는 가장 적합함.
 
표를 정리하면 위와 같다.
단일 서버 환경에서 테스트를 진행했기 때문에, 모든 경우에서 성공은 1건, 나머지는 모두 실패한 것은 당연한 결과다.
또한 Redis의 응답 지연 시간이 가장 긴 이유 역시 네트워크를 통한 통신이 필요하기 때문이다.
표만 놓고 보면, 비관적 락과 ReentrantLock 모두 안정적으로 동작한 것으로 보인다.
 

다중 서버 

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    vus: 50,
    duration: '30s',
    thresholds: {
        http_req_failed: ['rate<0.05'],
        http_req_duration: ['p(95)<5000'],
    },
};

// 공통 헤더
const params = {
    headers: {
        'Content-Type': 'application/json',
    },
};

const ports = [8080, 8081, 8082];

export default function () {
    // 매 요청마다 랜덤 포트 선택
    const port = ports[Math.floor(Math.random() * ports.length)];
    const BASE_URL = `http://localhost:${port}/api/consulting/create`;

    const userId = (__VU + 1).toString();

    const payload = JSON.stringify({
        designerId: "uuid01",
        meet: "offline",
        pay: "카카오페이",
        startTime: "2025-10-09T15:30:00",
        userId: userId
    });

    const res = http.post(BASE_URL, payload, params);

    check(res, {
        'status was 200': (r) => r.status === 200,
        'response time < 5000ms': (r) => r.timings.duration < 5000,
    });

    sleep(0.1);
}

 

 

요청 시 포트 번호를 랜덤하게 선택하도록 구성하여, 여러 인스턴스에 분산되도록 설정했다.

 

 

# ================================
# Blaybus 다중 인스턴스 실행 스크립트
# ================================

# 프로젝트 루트 경로
$PROJECT_ROOT = "C:\Users\chltm\Github\Blaybus-Haertz-Server"

# JAR 파일 경로
$JAR_PATH = "$PROJECT_ROOT\build\libs\Blaybus-0.0.1-SNAPSHOT.jar"

# 환경변수 파일 경로
$ENV_PATH = "$PROJECT_ROOT\env\prod.env"

# 복사 대상 경로 (JAR 실행 위치)
$TARGET_ENV_DIR = "$PROJECT_ROOT\build\libs\env"

# env 폴더가 없으면 생성하고 prod.env 복사
if (-Not (Test-Path $TARGET_ENV_DIR)) {
    New-Item -ItemType Directory -Path $TARGET_ENV_DIR | Out-Null
    Copy-Item $ENV_PATH -Destination $TARGET_ENV_DIR
    Write-Host "env/prod.env 복사 완료"
}

# 실행 포트 목록
$ports = @(8080, 8081, 8082)

foreach ($port in $ports) {
    Write-Host "🚀 포트 $port 인스턴스 실행 중..."
    Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$($PROJECT_ROOT)\build\libs'; java -jar Blaybus-0.0.1-SNAPSHOT.jar --server.port=$port"
    Start-Sleep -Seconds 2
}

Write-Host ""
Write-Host "=========================================="
Write-Host "모든 인스턴스 실행 완료!"
Write-Host "8080 / 8081 / 8082 포트에서 서버 확인 가능"
Write-Host "=========================================="

 

 

위와 같이 .ps1 스크립트 파일을 작성해, 여러 인스턴스를 동시에 실행하도록 설정했다.

 

 진행한 결과다. 순서도 아까랑 동일하다. 

 

구분 락 종류 평균 응답 시간 최대 응답 시간 95% 응답시간 요청 수  실패율  생성 결과
ReentrantLock(JVM 내부 락) 13.26 ms 1.09 s 20.62 ms ≈ 438 req/s 99.97% 3건 생성
비관적 락(DB 기반) 19.2 ms 1.05 s 53.87 ms ≈ 415 req/s 99.99% 1건 생성
Redis 분산 락(Redisson) 87.38 ms 1.17 s 331.9 ms ≈ 265 req/s 99.98% 1건 생성

 

정리하면 위 표와 같다.

 

다중 서버 환경으로 설정하니 응답 시간이 확연히 감소했다.
특히 ReentrantLock의 경우 총 3건이 생성되었는데, 직접 테스트해보며 JVM 수준의 락으로는 다중 서버 간 동시 접근을 막을 수 없다는 점을 확인할 수 있었다.

 

반면 비관적 락과 Redis 분산 락은 모두 1건으로 동일한 결과를 보였으며,
단 비관적 락은 DB 인스턴스가 3대라면 각각에서 동일하게 3건이 생성될 가능성이 있다.

 

무엇보다 비관적 락(Pessimistic Lock) 은 대용량 트래픽 환경에서 DB 커넥션 고갈을 유발해 시스템 전체를 마비시킬 수 있으며, 동시에 데드락(Deadlock) 이 발생할 위험도 존재한다.


비관적 락은 일종의 “회의실 문을 잠그고 들어가는 방식”으로,
예를 들어 너는 회의실 A를 점유 중이고 나는 회의실 B를 점유 중인데,
서로 상대방 방의 열쇠가 필요하다면 둘 다 움직이지 못한 채 대기하게 된다.
이것이 바로 데드락이다.

 

반면 낙관적 락(Optimistic Lock) 은 회의실 문을 잠그지 않고, 나중에 겹쳤는지만 확인하는 방식이다.
따라서 충돌이 발생해도 단순히 “다시 예약해” 하고 끝나므로 대기나 데드락이 발생하지 않는다.
다만 대규모 요청이 동시에 몰리는 상황에서는 충돌 후 재시도 횟수가 폭증해 시스템 부하로 이어질 수 있다.

 

이러한 이유로, Redis 분산락은 DB 트랜잭션과 별개로 애플리케이션 레벨에서 락을 관리하기 때문에
DB 커넥션을 점유하지 않으면서도 자원 접근 순서를 제어할 수 있다.
즉, 비관적 락의 병목과 데드락 문제를 완화하고,
낙관적 락의 과도한 재시도 문제도 방지할 수 있는 균형 잡힌 락 제어 방식이다.

 

최종

이번 테스트를 통해 얻은 인사이트들은 단순한 성능 비교를 넘어,
실제 서비스 환경에서 발생할 수 있는 동시성 문제의 본질을 깊이 이해하는 계기가 되었다.
이 경험을 바탕으로, 새로운 사이드 프로젝트에서는 더 안정적이고 확장 가능한 구조를 설계하고자 한다.
나아가, 현장에서 마주칠 수 있는 다양한 문제 상황을 직접 연구하고 개선하는 개발자로 성장하고 싶다.

728x90