[프로젝트] 로그인 실패 횟수 트래킹 2편 동시성 이슈 해결하기

조찬영·2023년 9월 24일
0

들어가기 앞서

지난 글에서 로그인 서비스 실패 횟수를 트래킹하고 5회이상 실패시 이메일 인증을 요구하는 로직을 작성해 보았습니다.

저는 싱글 스레드 기반의 Redis를 사용하여 로그인의 실패 횟수를 트래킹하는 행위에 대한 원자성을 보장받으려 했지만 동시성이 일어난다는 것을 알았는데요.
이번 글에서는 구체적으로 동시성이 발생한 시나리오 및 해당 이슈를 해결하기 위해
제가 선택한 최선의 방법과 그 이유등을 알아보겠습니다.



1. 동시성이 발생한 시나리오

다음은 제가 구현한 로그인 서비스의 일부이며 동시성이 발생하는 지점입니다.

LoginService

private void validatePassword(UserLoginRequest userLoginRequest, User user) {
        if (!encoder.matches(userLoginRequest.getPassword(), user.getPassword())) {
            countLoginFailed(user);

            throw new AppException(ErrorCode.HAS_NOT_AUTHENTICATION,
                "Your account requires email verification.");
        }
    }

    private void countLoginFailed(User user) {

        Long attemptCount = incrementFailedCount(user);

        log.info("attemptCount : {}", attemptCount);

        if (attemptCount >= MAX_ATTEMPT_COUNT) {
            accountLockService.lockUserAccount(user);
            loginFailedRepository.delete(user.getUsername()); // 계정 잠금 후 실패 횟수 초기화
        }
    }

    private Long incrementFailedCount(User user) {
        if (loginFailedRepository.getValues(user.getUsername()) == null) {
            loginFailedRepository.setValue(user.getUsername(), INIT_LOGIN_TRIAL_COUNT);
        }
        return loginFailedRepository.increment(user.getUsername());
    }

다음의 코드를 요약하면 이렇습니다.

  • 비밀번호를 검증한다.
  • 비밀번호가 일치하지 않아 로그인이 실패할 경우 카운트한다.
  • 카운트는 RedisTemplate 를 이용한 loginFailedRepository에서 이루어진다.
  • 5회 이상 실패할 경우 계정이 잠금조치되며 해당 계정은 이메일 인증을 해야 한다.
    (이 과정에서 실패 횟수는 초기화된다.)

위의 코드에서 loginFailedRepositoryRedisTemplate 를 이용하여 로그인의 실패횟수를 트래킹하는 repository 인데요.
하지만 문제가 발생한 지점은 바로 이곳이었습니다.

1.1 동시성이 발생한 시나리오 알아보기


기본적으로 Redis 는 원자성을 보장받습니다.
하지만 이는 단일 작업에 대해서만 받을 수 있습니다.

단순히 값을 증가시키는 작업에 대해서는 싱글 스레드 기반으로 돌아가기에 원자성을 보장받을 수 있지만 여러 작업이 하나의 작업처럼 수행 되어야 하는 그룹 작업 같은 경우에는 원자성을 보장 받을 수 없고 동시성 이슈가 발생합니다.

왜 그런걸까요?

다음의 코드에서 동시성이 발생되는 시나리오를 작성해 보겠습니다.

  1. 요청 A 는 최대 시도 횟수를 초과하는지 확인하는 명령을 보내고 최대 횟수를 초과하는 것을 확인
  2. 요청 A는 최대 시도 횟수를 초과를 확인했으므로 실패 횟수를 초기화하고 계정을 잠금 처리 해야 한다.
  3. 요청 A가 실패 횟수를 초기화 하기 전 그 사이를 요청 B가 파고들어 동일한 작업들을 수행한다.
  4. 요청 B가 파고든 시점에서 이미 로그인 실패 횟수 초과치를 넘어선다.

값을 증가시키는 시점(코드의 순서)등을 변경시켜봐도 요청 B를 막지 못한다면 결국 허용 최대치를 넘어서고 맙니다.
즉, 로그인 실패 횟수를 측정하고 조건부에 따라 값이 초기화되는 작업은
하나의 독립적인 작업으로서의 순서를 보장받아야 합니다.

그렇다면 다음의 문제를 어떻게 해결할 수 있을까요?



2. 루아 스크립트

하나의 독립적인 작업으로서의 순서를 보장받기 위해서는 MULTI/EXEC 등으로 redis 내에서의 트랜잭션을 사용할 수 있습니다.
하지만 MULTI/EXEC는 순차적인 작업에는 용이하지만 조건부가 있다면 사용하기가 까다로웠습니다.

제가 원하는 건 여러 조건부를 고려하고 이에 대한 작업의 순서를 보장받을 수 있어야 하며 성능적으로도 어느 정도의 퍼포먼스를 보여줄 수 있어야 했습니다.
그리고 고민하던 중 저는 Lua scripting를 사용하기로 결정하였습니다.

핵심내용은 이렇습니다.

Lua scripting 를 사용하게 되면 스크립트 자체가 하나의 큰 명령어로 해석되기 때문에 스크립트가 atomic하게 처리된다.

루아 스크립트를 사용하면 스크립트로 작성한 코드에 대해서 하나의 큰 명령어로 해석되기 때문에 다른 스레드가 개입할 틈이 생기지 않습니다.

또한 사용법도 직관적이였습니다.
우리가 작성한 코드를 루아 스크립트 언어로 치환해주고
작업을 수행하기 위한 키 값과 데이터들을 자바코드 안에서 선언해주면 됩니다.

직접 코드로 살펴보겠습니다.

3. 기존 코드 루아 스크립팅하기

먼저 기존의 코드를 다시 살펴보겠습니다.

LoginService

private void validatePassword(UserLoginRequest userLoginRequest, User user) {
        if (!encoder.matches(userLoginRequest.getPassword(), user.getPassword())) {
            countLoginFailed(user);

            throw new AppException(ErrorCode.HAS_NOT_AUTHENTICATION,
                "Your account requires email verification.");
        }
    }

    private void countLoginFailed(User user) {

        Long attemptCount = incrementFailedCount(user);

        log.info("attemptCount : {}", attemptCount);

        if (attemptCount >= MAX_ATTEMPT_COUNT) {
            accountLockService.lockUserAccount(user);
            loginFailedRepository.delete(user.getUsername()); // 계정 잠금 후 실패 횟수 초기화
        }
    }

    private Long incrementFailedCount(User user) {
        if (loginFailedRepository.getValues(user.getUsername()) == null) {
            loginFailedRepository.setValue(user.getUsername(), INIT_LOGIN_TRIAL_COUNT);
        }
        return loginFailedRepository.increment(user.getUsername());
    }

해당 코드를 루아 스크립팅하면 다음과 같습니다.

local count = redis.call('get', KEYS[1])
if count == false then
    redis.call('set', KEYS[1], ARGV[2])
    redis.call('expire', KEYS[1], 86400)
    count = ARGV[2]
else
    count = redis.call('incr', KEYS[1])
end

if tonumber(count) >= tonumber(ARGV[1]) then
    redis.call('del', KEYS[1])
    return 1
else
    return 0
end

스크립트를 설명하면 다음과 같습니다.

  • 주어진 키(KEYS[1])에 대한 현재 값을 redis.call 명령어를 통해 가져옵니다.
  • 만약 해당 키의 값이 존재하지 않으면 (즉, 카운트가 false라면 ), 새로운 값을 설정합니다.
  • 이때 설정되는 값은 ARGV[2]입니다. 그리고 이 키의 만료 시간을 86400초(24시간)으로 설정합니다.
  • 만약 해당 키의 값이 이미 존재하면 (즉, count != false), 카운트를 1만큼 증가시킵니다.
  • 마지막으로, 현재 count 값이 ARGV[1] 보다 크거나 같은지 확인합니다.
    • 만약 크거나 같다면, 해당 키를 삭제하고 1을 반환합니다.
    • 그렇지 않다면, 아무 것도 하지 않고 0을 반환합니다.

여기서 헷갈리거나 생소한 부분은 바로 요녀석들 KEYS[1], ARGV[1],ARGV[2] 일텐데요,
사실 변수와 비슷한 녀석들입니다.
전혀 어려운 부분이 아니며 이 부분은 조금 뒤의 코드를 본다면 아마 단번에
이해되실 겁니다.

언어의 특성상 문법은 달라도 의미는 여전히 같으며,
핵심은 스크립트로 선언된 부분이 하나의 명령어처럼 수행된다는 것입니다.

이제 RedisTemplate 를 사용하여 해당 스크립트를 input하는 과정을 살펴보겠습니다.


3.1 RedisTemplate에 루아 스크립트 인풋

다음의 코드를 살펴보겠습니다.

LoginFailedRepository

public class LoginFailedRepository {

   private final RedisTemplate<String, Object> redisTemplate;
   
   private static final String PREFIX_FOR_KEY = "ULF: ";
   private static final Integer INIT_LOGIN_TRIAL_COUNT = 1;
   private static final Integer MAX_ATTEMPT_COUNT = 5;

   @Value("${lua.login-failed}")
   private String luaPath;
   
   private String getKey(String username) {
       return PREFIX_FOR_KEY + username;
   }

   
   public boolean checkLockAccountByKey(String username) {
       String luaScript = LuaScriptLoader.load(luaPath);
       
       Boolean shouldLockAccount = redisTemplate.execute(
           new DefaultRedisScript<>(luaScript, Boolean.class),
           Collections.singletonList(getKey(username)),
           MAX_ATTEMPT_COUNT.toString(), INIT_LOGIN_TRIAL_COUNT.toString());

       return shouldLockAccount;
   }
}
  • luaPath.lua 파일을 관리하는 파일의 경로이며 조금 뒤에 다시 살펴보도록 하겠습니다.

다음 코드의 핵심적인 부분은 이 부분입니다.

 public boolean checkLockAccountByKey(String username) {
        String luaScript = LuaScriptLoader.load(luaPath);
        
        Boolean shouldLockAccount = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Boolean.class),
            Collections.singletonList(getKey(username)),
            MAX_ATTEMPT_COUNT.toString(), INIT_LOGIN_TRIAL_COUNT.toString());

        return shouldLockAccount;
    }
  • redisTemplate.execute(...) 이 부분은 로드된 Lua 스크립트를 Redis 서버에서 실행될 수 있도록 만들어 줍니다.

  • new DefaultRedisScript<>(luaScript, Boolean.class) 는 실행할 Lua 스크립트와 그 결과의 타입을 지정합니다.

  • Collections.singletonList(getKey(username)) Lua 스크립트의 KEYS 배열에 전달될 값입니다.(위에서 보았던 KEYS[1]값은 이 부분에서 생성됩니다.)

  • 이 후 3,4 번째 파라미터의 값은 각각 위에서 보았던 ARGV[1],ARGV[2] 값이 됩니다. 즉 ARGV[1] = 이곳에선 초기화 넘버인 1이 되며 ARGV[2] = 최대 로그인 횟수 넘버인 5가 됩니다.


3.2 lua 파일 관리하기

사실 루아 스크립트를 하드 코딩하여 이런 식으로 String 선언할 수 있습니다.

private final String luaScript ="local count = redis.call('get', KEYS[1])\n"
        + "if count == false then\n"
        + "    redis.call('set', KEYS[1], ARGV[2])\n"
        + "    redis.call('expire', KEYS[1], 86400)\n"
        + "    count = ARGV[2]\n"
        + "else\n"
        + "    count = redis.call('incr', KEYS[1])\n"
        + "end\n"
        + "\n"
        + "if tonumber(count) >= tonumber(ARGV[1]) then\n"
        + "    redis.call('del', KEYS[1])\n"
        + "    return 1\n"
        + "else\n"
        + "    return 0\n"
        + "end\n"
        + "\n";

하지만 매 작업마다 이런식으로 하드 코딩하고 선언해주는 것은 많은 공간을 차지하고 관리의 어려움도 있기에 그다지 좋은 방법은 아니라고 생각했습니다.

그래서 저는 루아 파일들을 따로 관리할 수 있는 디렉토리를 생성해 주었습니다.(인텔리제이)

파일 디렉토리 설정

그리고 디렉토리의 경로를 불러올 수 있는 별도의 유틸 클래스를 생성해 주었습니다.
외부 설정 파일(yaml)에 등록된 값을 바인딩하여 path의 파라미터로 넘겨 줄 수 있습니다.

LuaScriptLoader

public class LuaScriptLoader {

    public static String load(String path) {
        try (InputStream in = LuaScriptLoader.class.getResourceAsStream(path);
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(in))) {
        
            return reader.lines().collect(Collectors.joining("\n"));

        } catch (IOException e) {
            throw new AppException(ErrorCode.NOT_SUPPORT_FORMAT);
        }
    }
}

이렇게 한다면 루아 스크립트들을 관리하기 용이해지며 더 깔끔한 코드를 구현할 수 있습니다.


4. 루아 스크립트의 한계

이후 기능의 로그인 실패에 대한 트래킹 및 카운트가 일정 횟수를 넘긴다면 계정이 잠금되고 이메일 인증을 요구하는 기능의 동작까지 확인했습니다.

그리고 이러한 동작들을 루아 스크립트를 이용하여 하나의 명령어로 실행되게 만들어 주었으므로 동시성에서도 안전할 것입니다.

하지만 하나의 명령어로 실행된다는 것은 자칫 양날의 검이 될 수도 있습니다.
왜냐하면 기본적으로 Redis 는 싱글 스레드 입니다.

즉, 루아 스크립트가 실행된다면 Redis의 다른 작업들은 루아 스크립트의 작업이 끝날때까지 대기해야 하는 시간이 생깁니다.
그런데 이때 하나의 작업이 너무 긴 시간을 차지하고 있으면 이는 곧 성능을 저해시키는 요소가 될 수 있습니다.

물론 하나의 작업을 여러 작업으로 분리하여 스크립트를 작성할 수 있고
Redis에 타임 아웃 을 선언하여 일정 시간이 경과한다면 해당 작업을 취소해 볼 수도 있겠습니다.

그럼에도 너무 복잡하거나 시간 소요가 많은 작업에 대해서는 도입에 신중할 필요성이 있다고 느꼈습니다.



5. 다른 방법에는 무엇이 있을까? 🤔

사실 이번 로그인 실패 처리에 대한 로직은 금방 끝날거라 생각했습니다.
왜냐하면 Redis가 당연히 카운트에 대한 원자성을 보장해줄거라 생각했기 때문입니다.

하지만 행운이라고 할 수 있을까요?
저는 동시성이라는 녀석과 이번 계기로 더 친해지게 되었습니다. ( 예감상 더 친해질 것 같다...)

그리고 동시성에 대해 알아보다가 우연히 이 녀석들도 알게 되었습니다.

RabbitMQ
Kafka
Google Cloud Pub/Sub:

바로 메시지 큐잉 이라는 기술인데요.
자료구조 Queue의 특성인 FIFO(First In First Out)를 이용하여 대규모적인 요청에도 순서를 보장하여 작업을 처리할 수 있다고 합니다.

사실 요즘 많이 언급되는 키워드라 이름정도는 알고 있었지만 동시성과 루아 스크립트의 한계성을 경험해본 시점에서 왜 사용하는지 그리고 왜 대규모라는 키워드와 항상 붙어다니는지 조금은 알 것 같다는 생각이 들었습니다.

물론 지금 당장 공부하기에는 아직 넘어야 할 몇 개의 산들이 남았기에
우선 지금은 " 왜 사용하는지 조금은 알 것 같다"라는 선에서 넘어가고 이후에 공부하려고 합니다.

(넘고 싶은 산이 또 생겼다...)



6. 새로운 문제에 직면하다.

이제 최종적으로 로그인에 대한 로직을 살펴보겠습니다.

UserService

@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {

    private final UserRepository userRepository;
    private final LoginFailedRepository loginFailedRepository;
    private final BCryptPasswordEncoder encoder;
    private final StorageService storageService;
    private final AccountLockService accountLockService;
    
   ...(생략)
   
    @Transactional
    public AccessToken login(UserLoginRequest userLoginRequest) {
        /*
        로그인 기능
            - username 등록되어 있지 않다면 에러 반환
            - 이메일 인증이 되지 않았다면 로그인 불가능
            - username 이 password 와 일치하지 않는다면 에러 반환
            - 로그인 5회이상 실패시 계정 잠금 (이메일 인증 필요)
         */

        UserEntity userEntity = userRepository.findByUsername(userLoginRequest.getUsername())
            .orElseThrow(
                () -> new AppException(ErrorCode.USER_NOT_FOUND));
        User user = User.fromEntity(userEntity);

        validateEmailVerify(userEntity);
        validatePassword(userLoginRequest, user);
        return jwtTokenGenerator.generateAccessToken(user.getUsername());
    }

    private static void validateEmailVerify(UserEntity userEntity) {
        if (userEntity.getEmailVerified() == EmailVerified.UNVERIFIED) {
            throw new AppException(ErrorCode.HAS_NOT_AUTHENTICATION, "이메일 인증을 부탁드립니다.");
        }
    }

    private void validatePassword(UserLoginRequest userLoginRequest, User user) {
        if (!encoder.matches(userLoginRequest.getPassword(), user.getPassword())) {

            countLoginFailed(user);

            throw new AppException(ErrorCode.INVALID_PASSWORD);
        }
    }

    private void countLoginFailed(User user) {
        boolean shouldLockAccount = loginFailedRepository.checkLockAccountByKey(user.getUsername());

        if (shouldLockAccount) {
            accountLockService.lockUserAccount(user);
        }
    }
  • username 등록되어 있지 않다면 에러 반환
  • 이메일 인증이 되지 않았다면 로그인 불가능
  • username 이 password 와 일치하지 않는다면 에러 반환
  • 로그인 5회이상 실패시 계정 잠금 (이메일 인증 필요)
  • 이메일로 본인 인증을 한다면 계정은 다시 원복됩니다.

이로서 유저 로그인에 대한 실패 처리 로직을 구현하였지만,
코드에서 아쉬운 부분이 발견되었습니다.

계정이 잠금되었다는 것은 유저의 상태가 잠금 상태로 update 되었다는 것인데
문제는 잠금 상태에서 원래 상태로 복원될 때 원래 상태를 알 수 없다는 것이였습니다.

코드로 살펴보겠습니다.

유저 이메일 인증

@Transactional
    public void verifiedCode(String email, String userCode) {
        String securityCode = emailCertificationRepository.getValues(email)
            .orElseThrow(() ->
            new AppException(ErrorCode.EXPIRED_VERIFICATION)
        );

        validateSecurityCode(userCode, securityCode);

        UserEntity userEntity = getUserEntityByEmail(email);
        userEntity.toVerified();

        if (userEntity.getUserActivity() == UserActivity.LOCKED) {
            userEntity.changeActivity(UserActivity.NORMAL);
        }
    }

다음 코드에서 문제가 되는 부분은 이 부분입니다.

if (userEntity.getUserActivity() == UserActivity.LOCKED) {
            userEntity.changeActivity(UserActivity.NORMAL);
        }

유저가 잠금 상태에서 이메일 인증이 된다면 NORMAL 상태로 돌아가게 됩니다.
하지만 현재 존재하는 유저의 상태는 이렇습니다.

UserActivity

@Getter
@AllArgsConstructor
public enum UserActivity {

    NORMAL("일반 유저"),
    FLAGGED("신고받은 유저"),
    LOCKED("일시정지 "),
    BAN("이용 제한 유저");

    private final String description;
}

물론 BAN 상태는 api에 접근할 수 없기때문에 애당초 문제가 생기지는 않습니다.
하지만 FLAGGED 상태로 많은 유저의 신고를 받은 유저가 있다면
이메일 인증으로 NORMAL 유저가 되는 유저 세탁이 일어나게 되고 맙니다.

그래서 User 가 update 된다면 update에 대한 상태를 저장하고 추척할 수 있게 만드는 데이터가 필요하다고 생각되었습니다.

그렇다면 이러한 상황에서 User의 이전 데이터를 확인하여 값을 변경할 수 있고 더 나아가서 User가 username 을 변경하거나 password를 변경하는 사항에 대한 데이터도 저장되기에 서비스의 안정성을 더해주는 데이터가 될 것 이라고 생각했습니다.

그래서 다음의 프로젝트 목표는 User 변경 사항 관리 및 추적하기가 될 것 같습니다.


끝마치며

긴 글 읽어주셔서 감사합니다 :)
더 개선되야 할 부분이나 더 좋은 방법들이 있다면 공유해주세요!

profile
보안/응용 소프트웨어 개발자

0개의 댓글