🙈 1. 문제 파악
현재 시스템은 계정당 로그인 시도 횟수 제한이 없어, 무차별 대입 공격(Brute Force Attack) 에 취약한 상태이다.
로그인 실패에 대한 제어 장치가 부재할 경우, 장시간에 걸친 자동화된 공격이 가능해짐에 따라 공격자는 사용자 비밀번호를 탈취할 수 있으며, 이는 계정 탈취 및 정보 유출로 이어질 수 있다.
최악의 경우, 관리자 게정이 탈취되어 서비스가 중단될 수 있다.
따라서, 현재 로그인 시도 횟수 제한을 위한 방어 메커니즘 도입이 시급하다.
💡 2. 해결 계획
방어 메커니즘을 위해 필요한 기능은 다음과 같다.
(1) 로그인 실패 카운트 기능
계정별로 로그인 실패 횟수를 기록해야 한다.
기존 테이블에 로그인 시도횟수를 기록하는 방법을 생각해 볼 수 있다.
이를 위해,
- 기존 데이터베이스 스키마에 COLUMN을 추가하는 방법
(2) 계정 잠금 기능
실패횟수가 임계횟수에 도달했을때, 해당 계정을 잠구는 기능이 필요하다.
- 실패횟수가 임계 횟수가 되는 순간, 해당 계정을 잠금하도록 한다.
- 일정 시간동안 잠금이 되며, 이후 자동으로 잠금이 풀리도록 구현한다.
(3) 잠금 해제 기능
정상적인 사용자가 로그인을 시도하기 위해서, 잠금을 해제하는 기능이 필요하다.
- 계정 사용자 본인이 잠금해제 기능
- 관리자가 특정 사용자를 잠금해제 기능
✍️ 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 횟수를 줄일 수 있는 여지가 있다.
- Redis
하지만, Redis는 트랜잭션을 지원하지 않아, 동시성 문제가 발생할 수 있다는 문제점이 있었다 - JPQL
지연쓰기 대신, JPQL의 벌크연산으로 해당 row를 update하도록 하는것도 좋은 방법일 것 같다는 생각이 들었다.
이 방법으로 수행할 시 로그인 성공, 실패 모두 2개의 쿼리로 줄어들 수 있을것이다.
'프로젝트 일기 > 한편의 수학 학원' 카테고리의 다른 글
데코레이터 패턴을 통한 비동기 처리의 안정적 도입 (0) | 2025.04.14 |
---|---|
이미지 업로드 API에서의 트랜잭션 병목과 비동기 처리 전략 (0) | 2025.04.13 |
[Refactor] Bean Validation Duplicated (0) | 2025.04.09 |
벌크연산을 통한 쿼리 최적화 (0) | 2025.04.04 |