들어가며
안녕하세요! 플러피라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 시험을 출제할 때, 응시자가 시험을 한 번만 제출할 수 있게 하거나 여러 번 제출할 수 있게 하는 옵션이 있습니다. 한 번만 제출하게 하기 위해서는 동시성 문제를 해결해야 합니다. 이번 시간에는 분산락을 이용하여 중복 생성 문제를 해결하는 방법에 대해서 알아보겠습니다.
문제 상황
제출에 관한 서비스 코드는 다음과 같습니다. 편한 이해를 위해 부분 생략했습니다. 한 번 제출과 여러 번 제출 옵션이 있기 때문에 분기 처리를 합니다. 한 번만 제출 가능한 경우 이미 제출 했을 시 예외를 발생시킵니다.
@Service
public class SubmissionService {
// 생략 ...
@Transactional
public void submit(Submission submission) {
if (시험을 한 번만 제출 가능한가?) {
if (이미 시험을 제출 했는가?) {
throw new BadRequestException("이미 제출한 시험입니다.");
}
submissionRepository.save(submission);
return;
}
// 여러 번 제출 가능한 경우
submissionRepository.save(submission);
}
}
한 번만 제출할 수 있는 경우에 대해서 설명하겠습니다. 시험 응시자가 시험 제출 요청을 동시에 여러 번 보낼 경우, 여러 스레드에서 submit 메서드를 실행하게 됩니다. 트랜잭션 내에서 시험 제출을 생성해도 트랜잭션이 커밋되기 전에는 DB에 반영되지 않습니다. 그렇기 때문에 다른 스레드의 트랜잭션에서 시험 제출이 생성됐음을 알 수 없습니다. 결국 여러 스레드에서 중복된 시험 제출이 생성될 수 있습니다.
개발 환경 및 상황
해결 방법을 알아보기에 앞서, 플러피의 개발 환경과 상황을 알아보겠습니다. 플러피는 고가용성을 위해 서버가 여러 대인 분산 환경입니다. 또한, 시험 조회 성능을 높이기 위해 Redis와 호환되는 오픈소스인 Valkey를 통해 캐시 저장소 사용하고 있습니다. 시험은 한 번 또는 여러 번 제출할 수 있는 옵션이 있습니다.
- Spring Boot 3
- Spring Data JPA
- PostgreSQL 16
- Valkey 8
낙관적 락(Optimistic Lock)
먼저, 낙관적 락은 존재하지 않는 것에 대한 중복 생성 문제를 해결할 수 없습니다. 낙관적 락은 데이터를 수정할 때 주로 사용됩니다. 데이터를 수정하기 전에 버전 번호나 타임 스탬프를 비교하여 데이터가 변경되었는지 확인합니다. 존재하지 않는 데이터는 버전 관리나 타임스탬프를 확인할 수 없기 때문에 중복 생성 문제를 처리하기 어렵습니다.
비관적 락(Pessimistic Lock)
MySQL의 경우
비관적 락은 데이터를 읽거나 수정하기 전에 먼저 락을 거는 방식입니다. 이 방식은 다른 트랜잭션이 해당 데이터를 수정하거나 접근하는 것을 강제로 막을 수 있기 때문에 동시성 문제가 발생할 가능성을 낮춰줍니다.
MySQL의 InnoDB에서는 넥스트 키 락(Next-Key Lock)을 사용하여 범위 조회 시 데이터를 잠그는 방식을 지원합니다. 넥스트 키 락은 특정 행을 잠그는 레코드 락과 레코드 사이의 공간을 잠그는 갭 락을 조합한 것입니다. 즉, 특정 행과 앞뒤 공간을 모두 잠그기 때문에 새로운 데이터가 삽입되는 것을 방지할 수 있습니다.
MySQL에서 비관적 락은 주로 FOR UPDATE와 FOR SHARE로 사용할 수 있습니다. FOR UPDATE는 읽으면서 동시에 수정하려는 경우에 사용됩니다. 해당 행에 대해 수정을 차단하고, 다른 트랜잭션이 수정하거나 삭제하려고 할 때, 해당 트랜잭션이 완료될 때까지 대기합니다. FOR SHARE는 읽기 전용 트랜잭션에서 사용되며, 해당 행을 읽을 때 다른 트랜잭션이 수정하거나 삭제하는 것을 방지합니다. 다른 트랜잭션이 해당 행을 수정하거나 삭제하는 것을 방지하지만, 다른 트랜잭션이 해당 행을 읽는 것을 허용합니다. 수정이나 삭제가 아닌 삽입이기 때문에 FOR UPDATE, FOR SHARE 모두 동일하게 동작합니다.
한 번만 제출 가능한 시험에 대해서 비관적 락을 적용하면 다음과 같이 실행될 수 있습니다.
-- 트랜잭션 시작
begin;
-- 이미 시험을 제출했는지 확인
select * from submission where exam_id = 1 and member_id = 1 for update;
-- 시험을 제출한 적이 없다면 시험 제출을 생성
insert into submission (exam_id, member_id);
-- 트랜잭션 커밋
commit;
여러 스레드의 경우 다음과 같이 실행될 때 중복 생성을 막을 수 있습니다.
-- 스레드 1 시작
begin;
-- 스레드 2 시작
begin;
-- 스레드 1
select * from submission where exam_id = 1 and member_id = 1 for update;
insert into submission (exam_id, member_id);
-- 스레드 2: 스레드 1이 커밋되기 전까지 대기
select * from submission where exam_id = 1 and member_id = 1 for update;
-- 스레드 1 커밋
commit;
-- 스레드 2의 select가 실행되고, 이미 시험을 제출했기에 insert를 하지 않음.
-- 스레드 2 커밋
commit;
비관적 락의 경우 넥스트 키 락을 통해 해당 행과 앞 뒤의 행들을 잠그게 됩니다. 이로 인해 select한 해당 행 이외의 조회에 대해서도 대기 상태에 빠지게 하는 성능 저하를 일으킬 수 있습니다. 또한 다음과 같은 상황에서 데드락이 발생할 수 있다. 두 스레드가 select한 후 각각 insert를 하려고 할 때, 넥스트 키 락에 의해 대기를 하게 됩니다.
-- 스레드 1 시작
begin;
-- 스레드 2 시작
begin;
-- 스레드 1
select * from submission where exam_id = 1 and member_id = 1 for update;
-- 스레드 2
select * from submission where exam_id = 1 and member_id = 1 for update;
-- 스레드 1 대기
insert into submission (exam_id, member_id);
-- 스레드 2 데드락 발생
insert into submission (exam_id, member_id);
참고로, JPA에서는 다음과 같이 사용할 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
boolean existsByExamIdAndMemberId(Long examId, Long memberId);
PostgreSQL의 경우
플러피의 경우 PostgreSQL을 사용 중입니다. PostgreSQL의 경우 MySQL과 다르게 갭 락이 없습니다. 따라서 비관적 락으로 존재하지 않는 것에 대해 중복 생성 문제를 해결할 수 없습니다.
유니크 제약 조건(Unique Constraint)
데이터베이스에서 제공하는 유니크 제약 조건을 사용하여 중복 생성을 방지할 수 있습니다. 유니크 제약 조건은 테이블에 있는 컬럼의 값이 중복되지 않도록 제약을 걸어주는 기능입니다. 중복된 값이 삽입될 경우 데이터베이스에서 예외를 발생시킵니다.
다음과 같이 사용할 수 있습니다.
ALTER TABLE submission ADD CONSTRAINT unique_submission UNIQUE (exam_id, member_id);
하지만, 플러피의 경우 시험 응시자가 동일한 시험에 대해서 여러 번 제출할 수도 있고, 한 번만 제출할 수도 있는 옵션이 있기 때문에 유니크 제약 조건을 사용할 수 없습니다.
네임드 락(pg_advisory_lock)
일반적으로 네임드 락은 레코드 락이나 테이블 락과 같이 특정한 행이 아닌, 논리적인 식별자(이름)에 대해 락을 설정할 수 있습니다. 이미 동일한 락이 설정된 경우, 다른 트랜잭션에서 해당 락을 설정하려고 하면 대기하게 됩니다. 이를 이용하여 중복 생성을 방지할 수 있습니다.
PostgreSQL에서 네임드 락은 pg_advisory_lock()을 사용하여 특정 정수 값을 가진 락을 설정할 수 있습니다.
SELECT pg_advisory_lock(1);
-- hashtext로 텍스트를 정수로 변환
SELECT pg_advisory_lock(hashtext('exam_1_member_1'));
Spring JPA에서 사용하는 방법을 간단하게 알아보겠습니다.
public interface LockRepository extends JpaRepository<Submission, Long> {
@Query(value = "SELECT pg_advisory_lock(hashtext(:lockKey))", nativeQuery = true)
void acquireLock(@Param("lockKey") String lockKey);
@Query(value = "SELECT pg_advisory_unlock(hashtext(:lockKey))", nativeQuery = true)
void releaseLock(@Param("lockKey") String lockKey);
}
네임드 락을 사용할 때 주의할 점이 있습니다. 트랜잭션 내부에서 락을 얻고, 락을 해제할 경우 문제가 생길 수 있습니다. 일반적인 락의 경우 트랜잭션이 롤백될 경우 락이 해제되지만 네임드 락의 경우에는 아닙니다. 트랜잭션 내부에서 락을 해제할 경우 커밋되기 이전이라 다른 트랜잭션이 시작되어 이전과 동일하게 중복 생성 문제가 발생할 수 있습니다. 이를 예방하기 위해서 아래와 같이 트랜잭션 밖에서 락을 얻고 해제해주도록 합니다.
public class SubmissionFacade {
private final SubmissionService submissionService;
private final LockRepository lockRepository;
public void submit(Submission submission) {
String lockKey = "submit:%d:%d".formatted(submission.getExamId(), submission.getUserId());
lockRepository.acquireLock(lockKey);
try {
submissionService.submit(submission);
} finally {
lockRepository.releaseLock(lockKey);
}
}
}
public class SubmissionService {
@Transactional
public void submit(Submission submission) {
if (이미 제출한 시험이 있는가?) {
throw new AlreadySubmittedException();
}
submissionRepository.save(submission);
}
}
분산락(Distributed Lock)
지금까지 설명한 방법들은 데이터베이스 레벨에서 중복 생성을 방지하는 방법들이었습니다. 동일한 자원에 대해 락을 시도할 때, 다른 트랜잭션들은 락을 얻기 위해 대기하게 됩니다. 이로 인해 트랜잭션 대기 시간이 길어지고, 이는 데이터베이스 커넥션을 오래 점유하게 되어 다른 트랜잭션들의 처리 속도가 느려질 수 있습니다. 이러한 문제를 해결하기 위해 분산 락을 사용할 수 있습니다.
플러피 서비스는 기존에 Valkey를 사용하여 캐시 서버를 운영하고 있습니다. Valkey는 Redis와 호환되는 오픈소스로 SETNX 명령어를 사용하여 분산 락을 구현할 수 있습니다. SETNX 명령어는 키가 존재하지 않을 때만 값을 설정하고, 키가 이미 존재할 경우 값을 설정하지 않습니다. 이를 이용하여 중복 생성을 방지할 수 있습니다.
보통 분산락을 구현하기 위해서 Lettuce와 Redisson을 사용하게 됩니다. Lettuce는 일반적으로 스핀락(Spin Lock)방식으로 분산락을 구현할 수 있습니다. 이 방식은 락을 요청한 후, 락을 획득할 때까지 반복적으로 확인하는 방식입니다. 이때, 락을 얻지 못하면 잠시 대기한 후 다시 시도하는 방식이 됩니다. 락을 얻지 못했을 때 대기 시간을 처리하고, 일정 시간 동안 시도할 횟수를 제한하는 등의 추가적인 처리가 필요합니다.
Lettuce와 같이 스핀락 방식을 사용하는 경우, 레디스에 부하를 줄 수 있습니다. 반면에, Redisson의 경우 Pub/Sub 방식을 사용하여 레디스에 부하를 줄일 수 있습니다. Pub/Sub 방식은 레디스의 메시지 브로커를 사용하여 락을 획득하거나 해제하는 방식입니다. 락이 해제되면 이를 구독하고 있는 곳에서 락을 획득할 수 있습니다. 또한 RLock을 사용하여 타임아웃을 지정하거나 락을 획득할 수 있습니다.
위에서 설명했던 네임드 락과 같이 트랜잭션 내부에서 락을 획득하고 해제할 경우 트랜잭션이 커밋되기 전에 다른 트랜잭션이 락을 획득하거나 롤백된 후 락이 해제되지 않을 수 있습니다. 락 획득과 해제를 트랜잭션 외부에서 처리하는 것 이외에도 락을 획득하기 위한 대기 시간, 타임아웃, 락 키 등등을 설정해야 합니다. 분산락이 필요할 때마다 처리하기에는 번거로울 수 있습니다. 저는 어노테이션으로 분산락을 구현하는 방법 - 컬리 블로그를 참고하여 AOP로 분산락을 구현했습니다. 자세한 구현은 이 블로그를 참고해주세요.
아래는 분산락 AOP를 사용한 코드입니다. @DistributedLock을 통해서 바깥에서 락을 획득한 후, 트랜잭션을 시작하고, 트랜잭션 커밋 후 락을 해제합니다. 또한 Lock Key, Timeout등을 지정할 수 있습니다.
@Service
@RequiredArgsConstructor
public class SubmissionService {
// 생략
private final SubmissionLockService submissionLockService;
@Transactional
public void submit(SubmissionAppRequest request) {
// 생략
if (exam.isSingleAttempt()) {
String lockName = "submit:%d:%d".formatted(exam.getId(), member.getId());
submissionLockService.submitWithLock(request, exam, member, lockName);
return;
}
// 생략
}
}
@Service
@RequiredArgsConstructor
public class SubmissionLockService {
private final SubmissionRepository submissionRepository;
private final SubmissionMapper submissionMapper;
@DistributedLock(key = "#lockName") // 분산락 AOP
public void submitWithLock(SubmissionAppRequest request, Exam exam, Member member, String lockName) {
if (submissionRepository.existsByExamIdAndMemberId(exam.getId(), member.getId())) {
throw new BadRequestException("한 번만 제출 가능합니다.");
}
Submission submission = submissionMapper.toSubmission(exam, member.getId(), request);
submissionRepository.save(submission);
}
}
중복 생성 문제를 테스트하는 방법
중복 생성 문제가 해결되었는지 테스트 코드를 작성해보겠습니다.
class SubmissionServiceTest extends AbstractIntegrationTest {
// 생략
@Test
@DisplayName("한 번만 제출할 수 있는 시험에서 제출을 여러 번 할 수 없다.")
void submit() throws InterruptedException {
// given
Member member = 시험 응시자;
memberRepository.save(member);
Exam exam = 한 번만 제출할 수 있는 시험;
examRepository.save(exam);
// when
SubmissionAppRequest request = new SubmissionAppRequest(...);
try (ExecutorService executorService = newFixedThreadPool(2)) {
CountDownLatch countDownLatch = new CountDownLatch(2);
for (int i = 0; i < 2; i++) {
executorService.execute(() -> {
try {
submissionService.submit(request);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
}
// then
List<Submission> submissions = submissionRepository.findAll();
assertThat(submissions).hasSize(1);
}
}
ExecutorService를 사용하여 2개의 스레드로 구성된 풀을 생성합니다. 이는 2개의 스레드가 동시에 실행될 수 있도록 설정한 것입니다.
CountDownLatch는 동기화 용도로 사용됩니다. 이 객체는 주어진 숫자만큼 countDown() 메서드가 호출될 때까지 기다리는 역할을 합니다. 두 스레드가 모두 완료될 때까지 await()에서 블로킹되며, 대기하고 있습니다. 스레드가 작업을 마친 후 countDown()으로 CountDownLatch를 하나씩 감소시키고, 카운트가 0이 되면 await()에서 블로킹이 풀리고 모든 스레드가 다음 작업을 수행할 수 있습니다.
executorService.execute(() -> { ... }); 이 부분에서는 스레드가 동시에 실행되도록 합니다. 각 스레드는 submissionService.submit(request)를 호출하여 시험 제출을 시도합니다.
마지막에는 시험 제출이 한 번만 되었는지 확인합니다.
마치며
이번 시간에는 존재하지 않는 것에 대해서 중복 생성 문제를 해결하는 방법에 대해서 알아보았습니다. 이미 캐시 저장소를 사용하고 있었기에 분산락을 도입하는데 어려움이 없었습니다. 캐시를 아직 도입하지 않았거나 도입할 예정이 없고, 간단하게 구현하고 싶다면 네임드 락을 사용하는 것도 나쁘지 않은 것 같습니다.
플러피 서버에서 적용된 분산락 코드는 아래에서 확인할 수 있습니다.
fluffy/server/src/main/java/com/fluffy/global/redis at 50136c2f3bc3b21ac083133c2007baa20708d03d · alstn113/fluffy
온라인 시험 문제 제작 및 관리 서비스 - 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
읽어주셔서 감사합니다. 좋은 하루 보내세요~
'서버' 카테고리의 다른 글
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장 (0) | 2025.03.03 |
---|---|
Testcontainers로 실제 서비스와 유사한 환경에서 테스트하기 (1) | 2025.03.02 |
로컬에서 AWS Private Subnet에 있는 인스턴스에 접속하는 방법 (0) | 2025.02.13 |
AWS ElastiCache로 Redis(or Valkey) 운영하기 (0) | 2025.02.10 |
스프링에서 AWS S3를 이용한 이미지 업로드 방법 (0) | 2025.02.03 |