티스토리 뷰

동시성 이슈는 여러 사용자가 동시에 같은 자원에 접근하거나 동일한 작업을 수행할 때 발생할 수 있습니다. 이러한 상황에서 데이터 무결성이 깨지거나 시스템 오류가 발생할 수 있습니다. 이번 글에서는 동시성 이슈가 발생할 수 있는 시나리오를 포인트 신청 예시를 통해 설명하고, 이를 해결하기 위한 다양한 접근 방법을 살펴보겠습니다.

 

 

1. 포인트 신청 - 동시성 이슈 시나리오


다양한 동시성 이슈가 발생할 수 있는 상황은 다음과 같습니다:

  • 포인트 신청 버튼을 동시에 두 번 누르는 경우
  • Postman을 사용하여 동시에 여러 번 요청을 보내는 경우
  • 악의적으로 여러 개의 웹 페이지를 열어두고 수익금 신청을 동시에 시도하는 경우

 

 

개발환경 : Java 11, SpringBoot 2.7.7

2. 해결 방법


[ 방안 1.  트랜잭션 READ uncommitted 해결하기 ]

트랜잭션을 READ UNCOMMITTED로 설정하면, 처음 데이터가 커밋되지 않았더라도 다른 트랜잭션에서 이를 읽을 수 있습니다. 이 특성을 활용하면, 동시에 트랜잭션 요청이 들어왔을 때 처음 포인트 요청을 커밋하고, 나머지 요청은 예외를 발생시켜 롤백 처리할 수 있습니다.

 

포인트 신청 예제 코드

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public boolean concurrencyInsert(Long accountId, Long point)  {

    // 포인트 저장
    Point point = pointRepository.save(new Point(accountId, point));  	       

    // 회원의 포인트 저장 ID 목록
    List<Long> insertIdList = pointRepository.findByAllPointLong(accountId);

    // 중복 요청 확인
    if (!Collections.min(insertIdList).equals(point.getId())) {
        throw new DuplicateRequestPointException();
    }
}

이 코드에서 트랜잭션 어노테이션의 isolation을 READ UNCOMMITTED로 설정하여 수익금 신청을 저장합니다. 동시에 포인트 저장을 하고 저장된 ID 리스트를 출력한 후, 리스트 중에서 가장 작은 ID만 커밋하고 나머지는 예외를 발생시켜 롤백하게 됩니다. 이렇게 하면 동시에 요청이 오더라도 1개만 포인트 신청이 되고 나머지 요청은 저장이 되지 않습니다.

 

동시성 트랙잭션 흐름

트랜잭션 1과 트랜잭션 2가 동시에 포인트를 insert 요청하고 select하는 과정을 통해 동시성 이슈를 해결하는 흐름을 살펴보겠습니다.

트랜잭션 1의 흐름:

  1. 포인트를 insert 요청하여 ID:1, point:5000 데이터를 저장
  2. 저장된 데이터를 select합니다. 결과는 ID:1, point:5000
  3. 자신이 저장한 ID(1)가 가장 작은 ID임을 확인하고, commit을 진행

트랜잭션 2의 흐름:

  1. 포인트를 insert 요청하여 ID:2, point:10000 데이터를 저장
  2. 저장된 데이터를 select합니다. 결과는 ID:1, point:5000과 ID:2, point:10000
  3. 자신의 ID(2)가 가장 작은 ID가 아님을 확인하고, 오류를 발생시켜 롤백

결과적으로:

  • 트랜잭션 1은 성공적으로 커밋
  • 트랜잭션 2는 오류가 발생하여 롤백

 

 


[ 방안 2.  DB Named Lock 해결하기]

DB Named Lock은 특정 데이터베이스 자원에 대해 이름을 지정하여 락을 걸고 해제하는 방식입니다. 이를 통해 여러 세션이 동시에 접근하는 것을 방지하고, 순차적으로 작업을 처리할 수 있습니다.

 

DB Named Lock 흐름

흐름 설명

  • Session 1: 락을 획득하고 로직을 실행합니다.
  • Session 2: 락을 가질 수 없기 때문에 대기하다가 Session 1에서 락을 해제하면 락을 획득하고 로직을 실행합니다.

 

포인트 신청 예제 코드

PointRepository 인터페이스

public interface PointRepository extends JpaRepository<Point, Long> {
    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    void getLock(String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(String key);
}

메서드 단위의 락을 걸기 위해서 락 획득과 락 해체를 네이티브 쿼리를 통해 작성합니다.

 

NamedLockPointFacade 클래스

public class NamedLockPointFacade {
    private PointRepository pointRepository;
    private PointService pointService;

    @Transactional
    public void concurrencyInsert(Long accountId, Long point) {
        try {
            pointRepository.getLock(accountId.toString());
            pointService.concurrencyInsert(accountId, point);
        } finally {
            pointRepository.releaseLock(accountId.toString());
        }
    }
}

세션에서 락을 획득하고 로직을 실행한 후 락을 해제하는 로직을 작성합니다. 세션 1에서 락을 획득한 후 서비스를 로직을 실행하고 나머지 세션은 점유 대기로 유지합니다. 세션 1에서 락을 해체하면 두 번째 세션이 진행하게 됩니다.

 

PointService 클래스

@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean concurrencyInsert(Long accountId, Long point)  {

    // 포인트 저장
    Point point = pointRepository.save(new Point(accountId, point));  	       

    // 회원의 포인트 저장 ID 목록
    List<Long> insertIdList = pointRepository.findByAllPointLong(accountId);

    // 중복 요청 확인
    if (!Collections.min(insertIdList).equals(point.getId())) {
        throw new DuplicateRequestPointException();
    }
}

propagation = Propagation.REQUIRES_NEW를 설정한 이유는 자식 트랜잭션이 커밋되기 전에 락이 풀리는 현상을 방지하기 위함입니다. 별도의 트랜잭션으로 분리하여 데이터베이스에 정상적으로 커밋된 이후에 락을 해체합니다.

 

 

장단점

장점으로는 순차적 실행을 보장하여 동시성 이슈를 해결할 수 있지만, 단점으로는 세션 관리와 코드 관리가 추가적으로 발생하고 데이터 소스가 부족할 수 있으며, 이를 해결하기 위해서 데이터 소스를 늘리거나 Hikari의 pool을 넉넉하게 설정해야 하는 큰 단점이 있다.

 

 

 

 


[ 방안 3.  Redis Lock 해결하기]

Redis Lock은 Redis 데이터베이스를 사용하여 분산 락을 구현하는 방식입니다. 이를 통해 여러 세션이 동시에 접근하는 것을 방지하고, 순차적으로 작업을 처리할 수 있습니다. Redis를 활용하면 세션 관리가 필요 없으며, 데이터베이스에 직접적인 영향을 미치지 않아 성능 면에서도 유리할 수 있습니다.

 

포인트 신청 예제 코드

RedisLockRepository 클래스

public class RedisLockRepository {
    private RedisTemplate<String, String> redisTemplate;

    public Boolean lock(Long key) {
        return redisTemplate
                .opsForValue()
                .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
    }

    public Boolean unlock(Long key) {
        return redisTemplate.delete(generateKey(key));
    }

    private String generateKey(Long key) {
        return key.toString();
    }
}

Redis에 락 생성 및 해제를 위한 메서드를 구현합니다.

  • 락 생성: setIfAbsent 메서드를 사용하여 Redis에 키가 존재하지 않을 때만 락을 생성합니다. 만약 이미 락이 존재한다면 setIfAbsent은 false를 반환합니다.
  • 락 해제: delete 메서드를 사용하여 Redis에서 락을 해제합니다.

 

RedisLockPointFacade 클래스

public class RedisLockPointFacade {
    private RedisLockRepository redisLockRepository;
    private PointService pointService;

    @Transactional
    public void concurrencyInsert(Long accountId, Long point) {
        // 락 요청
        Boolean lock = redisLockRepository.lock(accountId);     
        
        // 락을 얻지 못하면 에러
        if (!lock) {
            throw new DuplicateRequestPointException();         
        }

        try {
            // 포인트 신청
            pointService.concurrencyInsert(accountId, point);  
        } finally {
            // 락 해체
            redisLockRepository.unlock(accountId);             
        }
    }
}

사용자가 동시에 여러 번 요청하면 락을 획득한 요청만 로직을 실행하고 락을 획득하지 못한 요청은 예외를 던지게 된다. 

 

 

DB Named Lock vs Redis Lock 매커니즘 비교

DB Named Lock:

  • 데이터베이스 내에서 특정 이름을 가진 락을 사용하여 동시성 문제를 해결
  • 세션 관리와 데이터베이스 자원을 사용하며, 데이터베이스의 락을 획득한 후 로직을 실행

Redis Lock:

  • Redis를 사용하여 분산 락을 구현합니다. Redis에 직접 락을 걸고 해제하는 방식
  • 데이터베이스와는 별도로 락을 관리합니다. Redis 락은 데이터베이스와 독립적으로 작동하며, 세션 관리가 필요 없고 성능이 더 좋음

 

 


[ 마무리하며 ]

READ uncommitted 방식이 코드가 깔끔하고 나머지 락 방식에 비해 성능이 우수하다. 하지만 트랜잭션 isolation 지킬 수 없어서 trade-off 생각이 든다. RDB Lock 방식(비관적, 낙관적, 명시적) 중에 명시적 락을 통해 진행하였다. 명시적 락은 세션 관리와 코드가 복잡해지는 단점이 있어서 DB Replication 통해 Datasource 분리를 해주어야 한다. Redis Lock 방식은 RDB < Redis 성능이 좋지만 별도의 인프라를 구축해야 하기 때문에 비용이 발생한다. 장점으로는 세션 관리를 별도로 해줄 필요가 없다.