[SSA] 특강 개설 시 선착순 동시성 이슈 해결

2025. 9. 10. 17:42·SSA/Back

특별 강의 수강 로직에 대한 고민에서 고려했듯이 특강에 대해서 정확히 선착순 인원만큼만 순서대로 처리되도록 하고 이외에 대해서는 실패 처리를 하는 동시성 문제를 해결해 보도록 하겠습니다. 이미 이 같은 해결에 대해서는 많은 내용들이 구글링하면 찾아 볼 수 있기에 당장은 그다지 어렵지 않게 해결해 볼 수 있을 것 같습니다.

 

 

# 문제

## 문제 정의: 선착순 100명, 그러나 1만 명이 동시에 몰린다면 ?

해결해야 할 문제는 간단합니다.

100명 정원의 강의에 10만 명이 동시에 수강 신청 버튼을 누를 때, 정확히 100명만 성공시키고 나머지는 실패 처리해야 합니다.

 

이때 여러 스레드(요청)가 동시에 데이터베이스의 '현재 수강 인원'을 조회하고 수정하려고 하는 경쟁 상태(Race Condition)가 발생합니다. 이 문제를 제대로 해결하지 못하면 다음과 같은 일이 벌어질 수 있습니다.

  • 100명 이상 등록: 여러 쓰레드가 동시에 "아직 자리 있네 !"라고 판단하고 DB에 INSERT를 시도하여 정원을 초과합니다.
  • 데이터 불일치: DB에는 100명이 찼는데, 애플리케이션은 아직 자리가 남았다고 착각하는 등 데이터가 꼬이게 됩니다.

 

이러한 결과가 발생하면 '선착순'이라는 의미가 무색해지고 열심히 신청한 사용자로 하여금 불쾌감을 느낄 수 있게 합니다.

 

 

## 해결 전략 비교 및 선택

이 문제를 해결하기 위해 떠오르는 전략이 다음과 같습니다. 각각의 장단점을 비교하고 우리 상황에 맞는 최적의 방법을 선택해 보겠습니다.

잠금 방식 특징 적합한 환경
Synchronized / ReentrantLock Java 코드 레벨에서 스레드를 제어 단일 서버, 단일 JVM
낙관적 락 (Optimistic Lock) DB에 버전(Version) 컬럼을 두어 데이터 정합성 검증 단일 DB, 데이터 쓰기 작업이 드물 때
비관적 락 (Pessimistic Lock) DB 트랜잭션 동안 Row에 배타적 락(Lock) 설정 단일 DB, 데이터 쓰기 작업이 빈번할 때
네임드 락 (Named Lock) DB 연결 기반으로 특정 이름의 락을 획득 단일 DB, MySQL 환경
분산 락 (Distributed Lock) Redis 등 외부 시스템을 통해 락을 관리 다중 서버, 분산 환경

여기를 확인하시면 락의 설명에 대해 조금 더 자세한 내용을 볼 수 있습니다.

 

모든 상황에 대해서 테스트해 보면 더할 나위 없이 좋겠지만,

Redis 분산 락을 제외한 나머지의 경우는 이론적인 학습만으로도 기본적으로 확장성이 부족하다고 생각했습니다.

또한, Redis를 통해 분산 락을 학습하고자 정한 면도 있기 때문에 ..

 

## 최종 전략 선택: 분산 락(Redis)을 활용한 동시성 제어

각 전략을 비교했을 때, 현재 상황에서는 분산 락(Redis) 방식이 많이 언급되었기도 하고, 다중 분산 환경에서는 합리적이라고 판단했습니다.

  • 이유: DB에 주는 부하를 최소화하여 성능을 확보할 수 있고, 사용자에게 즉각적인 성공/실패 결과를 반환하는 동기 방식이 특별 강의 수강 신청에 더 적합하다고 생각했기 때문입니다. 또한, 특별 강의에 대한 수강신청은 사용자에게 응답이 매우 빨라야 한다고 생각하기 때문에, 지금 해결하려는 문제에는 Redis를 선택하겠습니다.
  • 추후 카프카를 활용한 락 방식의 구현도 가능하다면 학습한 뒤 구현해 보도록 하겠습니다.

 

### 테스트 --> @Transaction vs Lock

  1. @Transaction, Lock 전부 미적용
  2. @Transaction 적용, Lock 미적용
  3. @Transaction, Lock 전부 적용
  4. @Transaction 미적용, Lock 적용

시나리오별 동작 비교

적용 기술 동작 방식 및 결과 테스트 결과
@Transaction, Lock 전부 미적용 💀 (동시성 제어 없음, 원자성 없음)
두 쓰레드가 동시에 재고(정원)를 확인하고, 둘 다 가능하다고 판단 후 등록합니다. 재고가 1개여도 2개가 차감되는 데이터 정합성 문제가 발생합니다.
실패
@Transaction 적용, Lock 미적용 😕 (원자성 보장, 동시성 제어 없음)
트랜잭션은 롤백 등 데이터 무결성을 보장하지만, 여러 스레드가 메서드에 동시에 진입하는 것 자체를 막지는 못합니다. 결국 데이터 정합성 문제가 발생합니다.
실패
@Transaction, Lock 전부 적용 ⚠️ (잘못된 순서로 인한 실패)
개발자가 설정한 Lock의 획득과 해제 시간 때문에 Lock이 Transaction보다 먼저 해제되는 경우입니다. Lock 해제와 트랜잭션 커밋 사이의 미세한 시간차 때문에 다른 쓰레드가 이전 데이터를 읽어가면서 동시성 문제가 발생합니다.
실패
@Transaction 미적용, Lock 적용 🤔 (동시성 제어, 원자성 없음)
Lock이 메서드 동시 실행을 막아주므로, 다음 쓰레드는 이전 실행 쓰레드의 DB 작업(Auto-Commit)이 완료된 후에 진입합니다. 따라서 동시성 문제는 해결됩니다.

주의: save() 호출이 두 번 있을 때 첫 번째 save()만 성공하고 두 번째에서 실패하면, 첫 번째 데이터가 롤백되지 않아 데이터가 불일치 상태가 됩니다. 현재 제 수강신청 로직은 두 번의 save() 호출이 있습니다.
성공
 

동시성 문제는 분산 락이 트랜잭션 커밋(commit)보다 먼저 해제(release)되기 때문에 발생합니다.

이 때문에 데이터 정합성이 깨져서 테스트가 실패합니다.

 

 

# 테스트 기반 공통 사항

  • 데이터베이스

 

 

  • 테스트 스크립트
// k6-scripts/enrollment-test.js
import http from 'k6/http';
import { check } from 'k6';

export const options = {
    scenarios: {
        // 10000명의 사용자가 동시에 테스트를 시작하도록 설정
        contacts: {
            executor: 'per-vu-iterations',
            vus: 10000,
            iterations: 1,
            maxDuration: '1m', // 최대 1분간 테스트 진행
        },
    },
};

export default function () {
    const specialLectureId = 1; // 테스트할 특강 ID

    // __VU는 k6가 제공하는 가상 사용자별 고유 ID입니다.
    // 이를 studentId로 사용하여 모든 요청자가 다른 학생인 것처럼 만듭니다.
    const studentId = __VU;

    const url = `http://host.docker.internal:8080/special-lectures/${specialLectureId}/enrollments?studentId=${studentId}`;

    // POST 요청으로 수강 신청 API 호출
    const res = http.post(url);

    // 응답 코드가 200(성공) 또는 4xx(클라이언트 에러, 예: 마감)인지 확인합니다.
    check(res, {
        'is status 200 or 4xx': (r) => r.status === 200 || (r.status >= 400 && r.status < 500),
    });
}

 

 

 

 

 

 

# 1번 테스트 --> @Transaction, Lock 전부 미적용: 실패

// @RedissonDistributedLock(key = "'specialLecture:' + #command.specialLectureId()")
// @Transactional
public Long enrollSpecialLectureEnrollment(SpecialLectureEnrollmentCreateCommand command) {
    SpecialLecture specialLecture = specialLectureRepository.getById(command.specialLectureId());
    Student student = studentRepository.getById(command.studentId());

    SpecialLectureEnrollment enrollment = specialLecture.enroll(student);

    SpecialLectureEnrollment specialLectureEnrollment = enrollmentRepository.save(enrollment);
    specialLectureRepository.save(specialLecture);
    return specialLectureEnrollment.getId();
}

 

결과:

등록 수강생 수가 100명이라고 되어 있긴 합니다만 ..

 

실제 수강생 수를 찾아 보면 265명이라고 되어 있습니다.

 

애플리케이션 로그에도 수강 정원이 초과되었다고 로그가 남았습니다
그러나, 데이터베이스에는 실제 100명을 초과한 인원이 등록되어 있다고 볼 수 있습니다.
정합성이 깨졌습니다.

 

 

 

 

 

 

# 2번 테스트 --> @Transaction 적용, Lock 미적용: 실패

// @RedissonDistributedLock(key = "'specialLecture:' + #command.specialLectureId()")
@Transactional
public Long enrollSpecialLectureEnrollment(SpecialLectureEnrollmentCreateCommand command) {
    SpecialLecture specialLecture = specialLectureRepository.getById(command.specialLectureId());
    Student student = studentRepository.getById(command.studentId());

    SpecialLectureEnrollment enrollment = specialLecture.enroll(student);

    SpecialLectureEnrollment specialLectureEnrollment = enrollmentRepository.save(enrollment);
    specialLectureRepository.save(specialLecture);
    return specialLectureEnrollment.getId();
}

 

결과:

똑같이 등록 수강생 수가 100명이라고 되어 있긴 합니다만 ..

 

실제 수강생 수를 찾아 보면 199명이라고 되어 있습니다.

 

@Transaction 어노테이션을 켜서 그런지 트랜잭션 롤백 로그도 남아 있습니다.

 

 

 

 

 

 

# 3번 테스트 --> @Transaction, Lock 전부 적용: 실패

@RedissonDistributedLock(key = "'specialLecture:' + #command.specialLectureId()")
@Transactional
public Long enrollSpecialLectureEnrollment(SpecialLectureEnrollmentCreateCommand command) {
    SpecialLecture specialLecture = specialLectureRepository.getById(command.specialLectureId());
    Student student = studentRepository.getById(command.studentId());

    SpecialLectureEnrollment enrollment = specialLecture.enroll(student);

    SpecialLectureEnrollment specialLectureEnrollment = enrollmentRepository.save(enrollment);
    specialLectureRepository.save(specialLecture);
    return specialLectureEnrollment.getId();
}

 

결과:

이번엔 등록 수강생 수가 100명도 안 되었습니다 ..

 

실제로 62명만 쓰여 있습니다.

 

이러면 그래도 괜찮은 거 아니냐고 할 수 있는데

애초에 1만 명이 신청했으니 그중에서 먼저 신청한 대로 100명까지 전부 쓰기 작업이 발생했어야 합니다.

따라서 이번도 결국 실패한 것입니다.

 

 

 

 

 

 

# 4번 테스트 --> @Transaction 미적용, Lock 적용: 성공

@RedissonDistributedLock(key = "'specialLecture:' + #command.specialLectureId()")
// @Transactional
public Long enrollSpecialLectureEnrollment(SpecialLectureEnrollmentCreateCommand command) {
    SpecialLecture specialLecture = specialLectureRepository.getById(command.specialLectureId());
    Student student = studentRepository.getById(command.studentId());

    SpecialLectureEnrollment enrollment = specialLecture.enroll(student);

    SpecialLectureEnrollment specialLectureEnrollment = enrollmentRepository.save(enrollment);
    specialLectureRepository.save(specialLecture);
    return specialLectureEnrollment.getId();
}

 

결과:

이번에도 등록 수강생 수가 100명으로 잘 되어 있습니다.

 

총 등록 수강생 수도 100명으로 정확히 성공했습니다.

 

 

 

 

# 왜 @Transactional을 붙이면 실패할까요 ? 🧐

@Transactional과 @RedissonDistributedLock 어노테이션은 모두 AOP(Aspect Oriented Programming)로 동작합니다.

즉, 메서드 실행 전후에 프록시(Proxy) 객체가 특정 로직을 끼워 넣는 방식으로 작동합니다.

문제는 어떤 프록시가 먼저 실행되느냐에 따라 결과가 달라진다는 점입니다. 일반적으로 별도 설정을 안 하면 순서를 보장할 수 없지만, 대부분의 경우 다음과 같이 동작합니다.

@Transactional이 있을 때의 동작 순서 (실패하는 시나리오):

  1. 사용자가 특강 수강 신청 API를 요청합니다.
  2. @RedissonDistributedLock AOP가 먼저 작동해서 락(Lock)을 획득합니다. (테스트로 specialLecture:10001)
  3. @Transactional AOP가 작동해서 데이터베이스 트랜잭션을 시작합니다.
  4. enrollSpecialLectureEnrollment 메서드 실행:
    • specialLecture 엔티티를 조회합니다. (정원: 0/100)
    • 여러 번의 실행 후 마지막 즈음 enrollSpecialLectureEnrollment 메서드에서 정원이 꽉 찼는지 확인합니다. (아직은 99/100 이라 통과)
    • 메모리상에서 specialLecture 객체의 등록 인원을 1 증가시킵니다. (메모리에서는 100/100이 됨)
    • save()를 호출하지만, 아직 트랜잭션이 끝나지 않았으므로 DB에 커밋(Commit)되지 않고 애플리케이션의 트랜잭션 내부에만 변경사항이 기록됩니다. --> 쓰기 지연(Transactional Write-Behind)
  5. enrollSpecialLectureEnrollment 메서드가 종료됩니다.
  6. @RedissonDistributedLock AOP는 메서드가 끝났으므로 락을 해제합니다.
  7. @Transactional AOP가 작동해서 트랜잭션을 데이터베이스에 커밋합니다.

문제 상황 (Race Condition):

  • A 쓰레드가 6번 단계에서 락을 해제한 직후, 아직 7번 단계(커밋)가 완료되지 않았다고 가정해 보겠습니다.
  • 이때 B 쓰레드가 동일한 강의(specialLecture:10001)에 대한 수강 신청을 시도합니다.
  • B는 해당 강의에 대한 락이 해제되어 있는 걸 확인 후 2번 단계에서 락을 성공적으로 획득합니다.
  • B가 4번 단계에서 specialLecture 엔티티를 DB에서 조회합니다. 하지만 A의 트랜잭션은 아직 커밋 전이므로, DB에는 여전히 정원이 99/100인 상태로 보입니다.
  • B 쓰레드 역시 수강 신청이 가능하다고 판단하고 로직을 실행합니다.
  • 결과적으로 정원(100명)을 초과하는 101번째 학생(그 이상)이 등록되는 데이터 정합성 문제가 발생합니다.

 

 

 

 

 

# 왜 @Transactional을 빼면 성공할까요 ? ✅

@Transactional 어노테이션이 없으면 JPA는 기본적으로 각 save() 호출을 즉시 DB에 커밋(Auto-Commit)합니다. (Repository.save()에 자체적으로 @Transactional이 붙어 있긴 합니다 --> 따로 메서드에 또 붙이는 이유는 트랜잭션이 필요한 비즈니스 로직에 따라서 여러 save()를 하나로 또 다시 묶기 위해서 ..)

@Transactional이 없을 때의 동작 순서:

  1. 사용자가 특강 수강 신청 API를 요청합니다.
  2. @RedissonDistributedLock AOP가 작동해서 락을 획득합니다.
  3. enrollSpecialLectureEnrollment 메서드를 실행합니다
    • enrollmentRepository.save() 호출 -> 즉시 DB에 INSERT 커밋 (수강생으로 등록)
    • specialLectureRepository.save() 호출 -> 즉시 DB에 UPDATE 커밋 (등록 인원 1 증가)
  4. enrollSpecialLectureEnrollment 메서드가 종료됩니다.
  5. @RedissonDistributedLock AOP가 락을 해제합니다.

이 경우, 락이 해제되는 시점에는 이미 모든 DB 변경사항이 커밋되어 있습니다. 따라서 다음 쓰레드가 락을 획득하고 DB를 조회하면, 이미 정원이 꽉 찬 상태를 정확하게 읽을 수 있으므로 정합성 문제가 발생하지 않습니다.

 

그런데 만약 specialLectureEnrollmentRepository.save()을 통해 수강생 등록에는 성공했는데,

그 직후 specialLectureRepository.save()에서 해당 강의에 대한 수정/저장 에러가 발생하면 어떻게 될까요?

첫 번째 INSERT는 롤백되지 않아 데이터가 불일치 상태로 남게 됩니다. 즉, 원자성(Atomicity)이 깨집니다.

 

 

 

# 해결 방법 💡

이 문제를 해결하려면 트랜잭션이 락의 범위 안에 완전히 포함되어야 합니다.

즉, 락을 획득하고 -> 트랜잭션을 시작하고 -> 로직을 실행하고 -> 트랜잭션을 커밋한 후 -> 락을 해제해야 합니다.

 

AOP의 '프록시 객체'라는 것을 잘 이해하면 좋습니다.

핵심은 RedissonDistributedLockAop가 AopForTransaction을 호출하는 구조에 있습니다.

  1. enrollSpecialLectureEnrollment 메서드 호출
    • 클라이언트가 이 메서드를 호출합니다.
  2. RedissonDistributedLockAop 동작 (Lock 획득)
    • @Around 어노테이션에 의해 RedissonDistributedLockAop가 가장 먼저 요청을 가로챕니다.
    • 메서드에 진입하여 lock.tryLock(...)을 통해 분산 락을 획득합니다.
  3. AopForTransaction 호출 (트랜잭션 시작)
    • 락을 성공적으로 획득한 후, AOP는 joinPoint.proceed()를 직접 호출하지 않고 aopForTransaction.proceed(joinPoint)를 호출합니다.
    • 이때 AopForTransaction은 별개의 빈이므로, 해당 빈의 프록시가 동작하며 @Transactional(propagation = Propagation.REQUIRES_NEW) 어노테이션을 인식합니다.
    • 새로운 트랜잭션이 시작됩니다.
  4. 핵심 비즈니스 로직 실행
    • AopForTransaction 내부에서 joinPoint.proceed()가 호출되면서, enrollSpecialLectureEnrollment 메서드 로직이 실행됩니다.
    • 이 로직은 이미 락이 걸려 있고, 트랜잭션이 시작된 상태에서 실행됩니다.
  5. 트랜잭션 커밋
    • enrollSpecialLectureEnrollment 메서드가 성공적으로 끝나면, AopForTransaction.proceed() 메서드의 실행이 종료됩니다.
    • 메서드가 종료되면서 @Transactional 어노테이션에 의해 트랜잭션이 DB에 커밋됩니다.
  6. RedissonDistributedLockAop로 복귀 (Lock 해제)
    • aopForTransaction.proceed() 호출이 끝나고 RedissonDistributedLockAop로 돌아옵니다.
    • try-finally 구문의 finally 블록이 실행되면서 락을 해제합니다.

 

 

 

 

 

 


### 추가 전략: Message Queue

  • 방법: 모든 수강 신청 요청을 일단 API 서버에서 받은 뒤, 즉시 Kafka의 단일 파티션 토픽으로 보냅니다. 그리고 이 토픽을 구독하는 단 하나의 컨슈머가 메시지를 순서대로 처리하며 100명을 채우는 방식입니다.
  • 의문: 이 방법이 실제로 동작할지는 아직 잘 모르겠습니다.

 

다음은 가정입니다.

  • 장점: 높은 처리량(TPS)을 보여 줄 수 있을 것 같습니다. API 서버는 요청을 큐에 넣고 바로 응답하므로 사용자 입장에서 지연이 거의 없게 느껴질 것 같습니다. 또한, 요청 순서를 보장하도록 구현하면 공정성 문제도 해결할 수 있을 것 같습니다.
  • 단점: 구현이 매우 복잡할 것 같습니다 .. 사용자에게 "신청되었습니다"가 아닌 "신청이 접수되었습니다"라고 응답해야 하는 비동기 처리 방식으로 처리하기 때문에 내부적으로 정말 순서대로 처리되도록 보장하도록 구현해야 합니다. Kafka를 이용한 방식은 확장성 면에서 이점이 있을 것 같습니다만, 아직 카프카를 활용한 락 방식의 구현에 대한 학습이 부족합니다.

 

 

### 추가 고민

레디스의 락 종류에는 Lettuce의 스핀 락도 있습니다.

근데 스핀 락을 쓰면 크게 두 가지 문제가 발생합니다.

 

첫 번째는 부하가 증가할 수도 있다는 점입니다.

  • 락을 획득하려고 시도하는데 획득 못 하면 일정 시간 뒤에 다시 SETNX를 날립니다. 또 실패하면 또 날립니다.
  • Redis에 부하를 많이 주게 될 겁니다.

 

두 번째는 순서가 보장되지 않을 수도 있다는 점입니다.

  • 문제 상황:
    1. 학생 1: 락 획득 성공
    2. 학생 2: SETNX 실패 → 100ms 뒤에 다시 시도하려고 한 뒤 Sleep
    3. 학생 1: 락 해제
    4. 학생 3: SETNX 시도 (학생 2는 Sleep)
  • 결과적으로 학생 3이 락을 획득합니다. 학생 2는 100ms 뒤에 깨어나서 다시 시도합니다.

이처럼 스핀 락은 선착순을 보장하지 않습니다. 운이 좋아서 락이 풀리는 타이밍에 딱 맞춰 요청을 날린 사람이 락을 가져갑니다.

 

따라서, 스핀 락은 선택하지 않았습니다.

 

 

 

 

## 참고 ##

https://ttl-blog.tistory.com/1581

https://helloworld.kurly.com/blog/distributed-redisson-lock/

 

'SSA > Back' 카테고리의 다른 글

[SSA] 카프카 메시지를 발행할 때 ZERO-PAYLOAD ? 아니면 Event-Carried State Transfer ?  (0) 2025.12.11
[SSA] Redis의 특징을 활용한 동시성 제어 (Redis 분산 락 아님)  (0) 2025.09.13
[SSA] 수강 로직에 대한 간단한 고민 ..  (0) 2025.09.09
[SSA] 락의 종류와 활용 가능한 상황  (0) 2025.09.09
[SSA] Kafka Consumer가 멱등성을 가질 수 있도록 설계  (0) 2025.09.09
'SSA/Back' 카테고리의 다른 글
  • [SSA] 카프카 메시지를 발행할 때 ZERO-PAYLOAD ? 아니면 Event-Carried State Transfer ?
  • [SSA] Redis의 특징을 활용한 동시성 제어 (Redis 분산 락 아님)
  • [SSA] 수강 로직에 대한 간단한 고민 ..
  • [SSA] 락의 종류와 활용 가능한 상황
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (128) N
      • Computer Science (23)
        • 운영체제 (7)
        • 데이터통신 (6)
        • 자료구조 (4)
        • 논리회로 (0)
        • 확률 및 통계 (0)
        • 데이터베이스 (2)
        • AI소프트웨어 (3)
        • 컴퓨터네트워크 (1)
      • Design (5) N
        • OOP - 객체 지향 프로그래밍 (3) N
        • DDD - 도메인 주도 개발 (데이터베이스 주도 .. (0)
        • EDA - 이벤트 기반 아키텍처 (1)
        • MSA - 마이크로서비스 아키텍처 (0)
        • ADD - AI 주도 개발 (1)
      • Language (2)
        • Java (0)
        • TypeScript (2)
      • Framework (12)
        • Spring (9)
        • NestJS (3)
      • Engine (3)
        • Elasticsearch (1)
        • GraphQL + Apollo Federation (2)
      • Plugin - Extension (1)
        • VS Code (1)
        • IntelliJ (0)
      • Tips (2)
        • 터미널 명령어 (1)
        • 우분투 명령어 에러 (1)
      • SSA (26)
        • Front (1)
        • Back (23)
        • DB (1)
        • 기획 (1)
      • CNU SW 아카데미 (42)
        • 1주차 (5)
        • 2주차 (5)
        • 3주차 (2)
        • 4주차 (1)
        • 5주차 (3)
        • 6주차 (2)
        • 7주차 (0)
        • 8주차 (1)
        • 9주차 (14)
        • 10주차 (0)
        • 11주차 (1)
        • 12주차 (0)
        • 13주차 (2)
        • 14주차 (2)
        • 15주차(최종 프로젝트) (3)
        • 최종 프로젝트 이후 (1)
      • 모각코 (6)
        • 2023 동계 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    ci/cd
    Husky
    릴리스엔지니어링
    lint-staged
    DX(DeveloperExperience)
    ESLint
    Typescript
    생산성
    개발자경험(DX)
    아키텍처
    프론트엔드/백엔드
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[SSA] 특강 개설 시 선착순 동시성 이슈 해결
상단으로

티스토리툴바