로그인 시도 횟수 제한 기능 구현하기

2025. 4. 3. 13:39·프로젝트 일기/한편의 수학 학원

🙈 1. 문제 파악

현재 시스템은 계정당 로그인 시도 횟수 제한이 없어, 무차별 대입 공격(Brute Force Attack) 에 취약한 상태이다.
로그인 실패에 대한 제어 장치가 부재할 경우, 장시간에 걸친 자동화된 공격이 가능해짐에 따라 공격자는 사용자 비밀번호를 탈취할 수 있으며, 이는 계정 탈취 및 정보 유출로 이어질 수 있다.

최악의 경우, 관리자 게정이 탈취되어 서비스가 중단될 수 있다.

따라서, 현재 로그인 시도 횟수 제한을 위한 방어 메커니즘 도입이 시급하다.


💡 2. 해결 계획

방어 메커니즘을 위해 필요한 기능은 다음과 같다.

(1) 로그인 실패 카운트 기능

계정별로 로그인 실패 횟수를 기록해야 한다.

기존 테이블에 로그인 시도횟수를 기록하는 방법을 생각해 볼 수 있다.
이를 위해,

    1. 기존 데이터베이스 스키마에 COLUMN을 추가하는 방법

(2) 계정 잠금 기능

실패횟수가 임계횟수에 도달했을때, 해당 계정을 잠구는 기능이 필요하다.

    1. 실패횟수가 임계 횟수가 되는 순간, 해당 계정을 잠금하도록 한다.
    1. 일정 시간동안 잠금이 되며, 이후 자동으로 잠금이 풀리도록 구현한다.

(3) 잠금 해제 기능

정상적인 사용자가 로그인을 시도하기 위해서, 잠금을 해제하는 기능이 필요하다.

    1. 계정 사용자 본인이 잠금해제 기능
    1. 관리자가 특정 사용자를 잠금해제 기능

✍️ 3. 해결 과정

(1) <로그인 실패 카운트> 구현

계정 스키마에 login_try_count 칼럼을 추가하여 구현한다.

기존의 구현 상, 비밀번호가 일치하지 않을 시 예외를 던져 트랜잭션이 롤백되고 있다.

로그인 실패 횟수를 카운트하기 위해 새로운 트랜잭션을 실행하도록 하여, login_try_count가 롤백되지 않도록 구성했다.

    /**
     * @param account     로그인 하고자 하는 계정
     * @param currentTime 로그인 시도한 시간. 
     * 로그인 실패 시, 호출해야하는 메소드입니다. 로그인 시도 횟수가 일정 횟수에 도달했다면, 해당 계정을 잠금설정 합니다.
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateLoginFailedInfo(final Account account, final LocalDateTime currentTime) {
        final Member member = findMember(account.getId());
        if (member.isOverMaxLoginTryCount(this.maxTryCount)) {
            member.lock(currentTime);
            log.info("Lock account {}, lock started at {}", account.getId(), currentTime);
        } else {
            member.increaseLoginTryCount();
            log.info("account {}, Increase login try count to {}", account.getId(), member.getLoginTryCount());
        }
    }

(2) <계정 잠금> 구현

계정 스키마에 locked 칼럼을 추가한다
계정 스키마에 locked_date_time 을 추가한다.
로그인 시도 횟수가 일정 횟수에 도달했다면, 해당 계정을 잠금설정 합니다.
잠금 시에, 현재 로그인 시도 시간을 기록하도록 구현했습니다.

locked_date_time을 기준으로 일정시간이 지난 후, 재시도할 수 있도록 구성했다.

    /**
     * 로그인 실패 시, 호출해야하는 메소드입니다.
     * 
     * @param account     로그인 하고자 하는 계정
     * @param currentTime 로그인 시도한 시간.
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateLoginFailedInfo(final Account account, final LocalDateTime currentTime) {
        final Member member = findMember(account.getId());

        member.increaseLoginTryCount();
        log.info("account {}, Increase login try count to {}", account.getId(), member.getLoginTryCount());

        if (member.isOverMaxLoginTryCount(this.maxTryCount)) {
            member.lock(currentTime);
            log.info("Lock account {}, lock started at {}", account.getId(), currentTime);
        }
    }

(3) <잠금 확인> 구현

로그인 하고자 하는 계정이 잠겨있는지 확인하는 기능입니다.
잠금상태라면, 현재 로그인 시도 시간을 기준으로 일정 시간이 지났다면, 시도할 수 있도록 합니다.

    /**
     * @param account 로그인 하고자 하는 대상입니다.
     * @return 로그인을 시도할 수 있다면 true, 아니라면 false
     */
    @Transactional(propagation = Propagation.MANDATORY)
    public boolean checkAllowedToLogin(final Account account, final LocalDateTime currentTime) {
        final Member member = findMember(account.getId());
        return member.canLoginAt(currentTime, this.lockTimeMinutes);
    }
     /// Member Entiy...

     public boolean canLoginAt(final LocalDateTime currentTime, final Long lockTimeMinutes) {
        if (!getLocked()) {
            return true;
        }
        return currentTime.isAfter(getLockedStartTime().plusMinutes(lockTimeMinutes));
    }

(4) <잠금 해제> 구현

로그인 시도가 locked_date_time 이후라면, locked 칼럼을 false로 변경한다.

(5) 총 코드


@Service
@Slf4j
public class AccountLockService {
    private final MemberRepository memberRepository;
    private final Integer maxTryCount;
    private final Long lockTimeMinutes;

    public AccountLockService(
            MemberRepository memberRepository,
            @Value("${login.lock.maxTryCount}") Integer maxTryCount,
            @Value("${login.lock.minutes}") final Long lockTimeMinutes
    ) {
        this.memberRepository = memberRepository;
        this.maxTryCount = maxTryCount;
        this.lockTimeMinutes = lockTimeMinutes;
    }

    /**
     * @param account     로그인 하고자 하는 계정
     * @param currentTime 로그인 시도한 시간 로그인 실패 시, 호출해야하는 메소드입니다.
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateLoginFailedInfo(final Account account, final LocalDateTime currentTime) {
        final Member member = findMember(account.getId());
        if (member.isOverMaxLoginTryCount(this.maxTryCount)) {
            member.lock(currentTime);
            log.info("Lock account {}, lock started at {}", account.getId(), currentTime);
        } else {
            member.increaseLoginTryCount();
            log.info("account {}, Increase login try count to {}", account.getId(), member.getLoginTryCount());
        }
    }

    /**
     * @param account 로그인 하고자 하는 대상입니다.
     * @return 로그인을 시도할 수 있다면 true, 아니라면 false
     */
    @Transactional(propagation = Propagation.MANDATORY)
    public boolean checkAllowedToLogin(final Account account, final LocalDateTime currentTime) {
        final Member member = findMember(account.getId());
        return member.canLoginAt(currentTime, this.lockTimeMinutes);
    }

    @Transactional
    public void unlock(final Account account, final LocalDateTime currentTime) {
        final Member member = findMember(account.getId());
        unlockMember(member, currentTime);
    }

    private void unlockMember(final Member member, final LocalDateTime currentTime) {
        member.unlock();
        log.info("Check account {}, unlocked at {}", member.getId(), currentTime);
    }

    private Member findMember(final Long memberId) {
        return memberRepository.findMemberByIdAndRemovedIsFalse(memberId)
                .orElseThrow(() -> new NoSuchMemberException(ErrorCode.NO_SUCH_MEMBER));
    }
}

⭐️ 4. 깨달은 점

이전의 한번의 조회에 비해 해당 기능을 구현하며, 쿼리 횟수가 굉장히 증가했다.

로그인 실패

이전 쿼리 : 계정 SELECT 1 -> 총 1회
현재 쿼리 : 계정 SELECT 2, UPDATE 1 -> 총 3회

로그인 성공

이전 쿼리 : 계정 SELECT 1 -> 총 1회
현재 쿼리 : 계정 SELECT 2, UPDATE 1 -> 총 3회

이는login_try_count업데이트를 위해 별도의 트랜잭션을 시작함에 따라 발생하게 된 문제이다.

로그인 처리 시 보안성과 기록의 신뢰성을 위해 login_try_count 업데이트를 트랜잭션 분리 처리했으나, 그에 따른 쿼리 증가가 발생하였다.
향후 Redis 기반 캐싱 또는 JPQL 최적화로 SELECT 횟수를 줄일 수 있는 여지가 있다.

  1. Redis
    하지만, Redis는 트랜잭션을 지원하지 않아, 동시성 문제가 발생할 수 있다는 문제점이 있었다
  2. JPQL
    지연쓰기 대신, JPQL의 벌크연산으로 해당 row를 update하도록 하는것도 좋은 방법일 것 같다는 생각이 들었다.
    이 방법으로 수행할 시 로그인 성공, 실패 모두 2개의 쿼리로 줄어들 수 있을것이다.

'프로젝트 일기 > 한편의 수학 학원' 카테고리의 다른 글

API 테스트 하는데... 인증까지 매번..?  (0) 2025.04.22
데코레이터 패턴을 통한 비동기 처리의 안정적 도입  (0) 2025.04.14
이미지 업로드 API에서의 트랜잭션 병목과 비동기 처리 전략  (0) 2025.04.13
[Refactor] Bean Validation Duplicated  (0) 2025.04.09
벌크연산을 통한 쿼리 최적화  (0) 2025.04.04
'프로젝트 일기/한편의 수학 학원' 카테고리의 다른 글
  • 데코레이터 패턴을 통한 비동기 처리의 안정적 도입
  • 이미지 업로드 API에서의 트랜잭션 병목과 비동기 처리 전략
  • [Refactor] Bean Validation Duplicated
  • 벌크연산을 통한 쿼리 최적화
윤희종
윤희종
호기심을 잃지 말자 지적, 질문은 언제나 환영합니다 ;)
  • 윤희종
    서버견문록
    윤희종
  • 전체
    오늘
    어제
    • 분류 전체보기 (36)
      • 데일리 플랜 (1)
      • 이것저것 (4)
      • Java (6)
      • Spring (12)
        • SpringBoot (10)
        • Spring MVC (0)
      • Computer Science (4)
        • Network (1)
        • Operating System (0)
        • Data Structure (0)
        • Algorithm (2)
        • Database (0)
      • IOS (0)
      • 프로그래머스 문제풀이 (2)
      • 프로젝트 일기 (7)
        • 한편의 수학 학원 (7)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    스프링 시큐리티 구성
    캐시
    스프링
    read through
    SecurityFilterChain
    Spring
    인증 테스트
    성능 개선
    mysql 쿼리 최적화
    알고리즘
    servlet
    스프링 시큐리티 사용법
    springboot
    스프링 부트 인증 우회
    비동기 처리 유의점
    제네릭
    인증 우회 테스트
    스프링 부트
    SecurityFilterChain 구성
    cache write back
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
윤희종
로그인 시도 횟수 제한 기능 구현하기
상단으로

티스토리툴바