사용자 동시 접근 요청을 제어하는 3가지 방법

FrogRat·2021년 1월 22일
1

배경

서비스 특성상 이벤트를 많이 구현했었는데 몇몇 사악한 사용자들이 이벤트 참여가 한 번만 가능함에도 불구하고 서로 다른 PC에서 이벤트 참여 버튼을 동시 클릭해서 이벤트에 참여하는 케이스가 간혹 발생했습니다. 뿐만 아니라 참여량이 상당한 경품 이벤트의 경우, 동시 접속자가 많아지면 초반에 제한한 당첨자 수 보다 초과해서 당첨되는 문제가 있었습니다.

이벤트 참여 프로세스 내에 기본적인 응모권 개수 체크 및 당첨 수량 체크가 있음에도 불구하고 사용자가 악의적으로 이벤트 참여 버튼을 동시에 클릭하거나, 너무 많은 유저들이 한꺼번에 당첨 수량 데이터를 변경할 때에는 기본적인 유효성 체크만으로는 방어가 불가능합니다. 한정된 수량이 초과했음에도 불구하고 사용자 요청이 성공하는 케이스를 방어하기 위해 어떤 방법들을 사용했었는지 정리해보려고 합니다.

해결

1. SELECT ~ FOR UPDATE 구문

먼저 Oracle, MySQL DB에서 제공하는 구문을 활용할 수 있습니다. 해당 구문은 SELECT를 통해 조회된 Row 가 동일 세션을 통해 다시 SELECT 혹은 UPDATE 될 때까지 다른 세션에서 해당 Row에 DML 쿼리를 수행하지 못하도록 Row 단위 Lock을 걸어두게 됩니다.

-- InBottle 계정에 대한 Row 에 대해 Lock 
SELECT *
FROM event_user
WHERE memberid = 'InBottle'
FOR UPDATE;

다만 해당 구문은 SELECT 한 Row의 Lock을 획득하기 위해 무한정 기다리는 이슈가 있어 서비스 장애 상황을 야기할 수 있습니다. 이를 위한 해결책으로 오라클의 경우 9i 버전 이후부터는 WAIT라는 옵션 키워드도 같이 제공합니다.

이는 WAIT 뒤에 나오는 숫자 값만큼 기다려서 해당 Row에 대한 Lock을 획득하지 못할 경우 더 이상 Lock을 기다리지 않고 바로 에러가 발생합니다. WAIT 키워드 외에 NOWAIT도 있는데, 해당 키워드는 Lock을 획득하기 위한 대기 시간 없이 바로 에러가 발생합니다.

SELECT name, limit_count
FROM event_gift
WHERE eventcode = 12345
FOR UPDATE WAIT 3;

하지만 팀 내부적으로는 실제 서비스 구현 시 몇몇 케이스에서는 SELECT ~ FOR UPDATE 구문 사용을 지양하였습니다. 서비스 특성상 Lock을 걸어야 하는 Row가 모든 유저들이 동시에 SELECT/UPDATE가 필요한 Row 거나(Master 성 데이터), 특정 유저에 대한 Row이긴 하지만 여러 서비스에서 접근하는 경우에는 해당 구문 사용을 피하는 것이 좋습니다.

조회와 데이터 변경을 위한 접근 횟수가 많은 Row에 Lock을 수시로 걸고, 해제할 경우 Lock을 건 세션을 제외한 대부분의 요청사항이 실패하는 이슈가 발생할 수 있기 때문입니다.

2. 트랜잭션 종료 전 변경 데이터 재확인

Oracle DB를 사용하는 서비스에서는 트랜잭션 내부에서 변경된 데이터를 DB 트랜잭션이 종료되기 직전에 원하는 값으로 변경이 잘 되었는지 다시 한번 확인하는 프로세스를 넣어 사용자 동시 요청에 대한 방어를 할 수 있습니다.

간단한 예시로 자판기에서 음료수를 뽑을 때의 프로세스를 코드 형태로 간단하게 작성하였다. 사용자가 뽑고 싶어 하는 음료수가 원하는 개수(wantedDrinkCounts)만큼 있는지 확인 후, 음료 값만큼 동전 데이터를 증가시키고(increaseCoin), 자판기에서 들고 있는 음료 개수를 사용자가 뽑아 먹을 개수만큼 감소하는 작업(decreaseDrinkCounts)을 트랜잭션 내부에서 수행해줍니다.

트랜잭션을 종료 처리하기 전에 음료수 개수가 트랜잭션 시작 전에 조회한 음료 개수(beforeDrinkCounts) 기준으로 사용자가 뽑아가려고 하는 개수(wantedDrinkCouns)만큼만 줄었는지 확인하는 코드를 추가해줍니다. (Line 16 ~ 21)

public void pickDrink(Drink drink, int wantedDrinkCounts) {
	int beforeDrinkCounts = getDrinkCounts(drink);
    
    if (beforeDrinkCounts < wantedDrinkCounts) {
    	throw new Exception("lack of drinks");
    }
    
    Transaction transaction = new Transaction();
    
    try {
    	transaction.start();
        
        increaseCoin(200);
        decreaseDrinkCounts(wantedDrinkCounts);
        
        int afterDrinkCounts = getDrinkCounts(drink);
        
        // drink 갯수가 원하는 만큼(wantedDrinkCounts 만큼) 감소되었는지 확인
        if (beforeDrinkCounts - wantedDrinkCounts != afterDrinkCounts) {
        	throw new Exception("drink counts concurrency issue!!!");
        }
        
        transaction.commit();
    } catch(Exception e) {
    	transaction.rollback();
    } finally {
    	transaction.end();
    }
}

이와 같이 트랜잭션 종료 전에 음료 개수 재확인 작업을 통해 다른 동시 요청으로 음료를 이미 뽑아간 경우 현재 진행 중인 프로세스에서 한번 더 음료를 뽑아 갈 수 있는 케이스를 어느 정도 방어할 수 있습니다. 하지만 이와 같은 방법 또한 100% 동시 요청 방어를 해줄 수 없어서 다음에 소개할 3번의 방법과 같이 혼용하여 처리를 하면 보다 완벽하게 동시 요청에 대한 방어를 할 수 있습니다.

다만 한 가지 제약 사항이 존재하는데 바로 DB 트랜잭션 격리 수준(Isolation Level)이 READ COMMITTED 이하일 경우에만 사용할 수 있다는 점입니다. Isolation Level이 READ COMMITTED 일 경우, 다른 트랜잭션에서 COMMIT 한 데이터를 READ 시 변경된 데이터로 읽어올 수 있기 때문에 위와 같은 동시 요청 방어 방법이 유의미하게 됩니다.

하지만 격리 수준이 REPEATABLE READ 이상 일 경우 사실상 의미가 없는 방법입니다. 1번 트랜잭션이 이미 시작하고 나서 2번 트랜잭션에서 음료 개수에 대한 정보를 UPDATE 해버릴 경우, 1번 트랜잭션 시작 이후에 자판기 내에 음료 개수를 조회해도 2번 트랜잭션에서 COMMIT 한 데이터를 읽어오지 못하기 때문입니다.

  • Oracle 디폴트 격리 수준: READ COMMITTED
  • MYSQL innoDB 디폴트 격리 수준: REPEATABLE READ

3. 사용자 요청에 대한 고유키 관리

해당 방법은 한 명의 사용자가 악의적으로 동시에 한 가지 작업을 여러 번 요청할 때 방어할 수 있는 방법입니다.

사용자가 이벤트 응모하기를 클릭하고 관련 응모 작업을 시작하기 전에 해당 작업에 대한 고유 번호(이벤트 고유번호) + 유저 계정을 유니크 Key로 잡고 있는 DB 테이블(abusing_defence)에 INSERT 해줍니다. (workcode: 12345, id: bottle)

workcodeid
12345bottle
12346can

이때 사용자가 악의적으로 동일 시점에 한번 더 요청을 할 경우, 이미 abusing_defence 테이블에 동일한 workcode와 id가 있기 때문에 중복 유니크 Key를 INSERT 하려는 시도로 인해 INSERT가 실패하고 해당 이벤트 응모에 실패하게 됩니다.

INSERT에 성공하여 프로세스를 정상적으로 탄 후에는 abusing_defence 테이블 내에서 고유키를 삭제해줍니다.

workcodeid
12345bottle
12346can

고유키를 삭제할 필요가 없는 케이스로는 해당 프로세스가 유저별로 서비스 기간 중에 1번만 수행이 가능할 경우입니다. 이때는 프로세스 전/후로 INSERT/DELETE 해줄 필요 없이 이벤트 참여 로그 형태로 데이터를 남겨 어뷰징 유저 이슈 체크를 프로세스 내부 로직으로 녹여낼 수 있습니다.

참조

링크

profile
병 안에 쥐개구리

0개의 댓글