← 뒤로
프로젝트 요약
- 한 줄 요약: 로그인 Brute-Force 방지 기능을 구현하고, E2E 테스트를 통해 동시성 이슈를 발견하여 Lua script로 해결한 뒤, 분산 락과 최적화를 통해 응답시간 P95를 92ms에서 17.95ms로 80% 개선하여 실제 배포까지 완료했습니다.
- 진행/소속: 개인 학습 프로젝트
- 기술 스택: Kotlin, Spring Boot, Redis, MySQL, K6
- 키워드:
Redis, 분산 락, Lua script, 동시성, E2E 테스트, 성능 개선
- 기간: 3일
- 역할: 백엔드
문제(AS-IS)

- 로그인 시도에 제한이 없어, 비밀번호를 무차별 대입하여 알아낼 수 있는 보안 취약점 존재
목표(TO-BE)
- (1) 로그인 실패에 따른 횟수 제한 기능 구현
- (2) E2E 테스트를 통해 로직의 취약점 확인 및 수정
- (3) 로그인 실패에 따른 횟수 제한 기능 성능 최적화
설계/선택(Key decisions)
- 로그인 실패에 따른 횟수 제한 기능 구현
- 기존 로직에 비침습적으로 적용하기 위해 loginfailure 도메인 분리
- email과 ip각각 추적하여 5회 이상 실패시 15분 잠금
- E2E 테스트를 통해 로직의 취약점 확인
- RaceCondition 문제 확인
- Luascript를 사용한 원자 연산으로 개선
- 성능 최적화
- K6로 서버의 응답 속도 P95 측정
- 분산락 도입으로 1차 성능 개선
- 실패 카운트 사전 증가로 2차 성능 개선
- 분산락은 재사용성을 위해 Lock 유틸리티로 컴포넌트화
결과(Impact)

- 로그인 시도 횟수에 따른 제한 로직 적용/테스트/배포 완료
- BF 공격시 응답시간 P95를 92ms -> 20.8ms -> 17.95ms 로 감소
- Lock 컴포넌트 재사용성 확보
아래에서 해당 코드 확인 가능
(Github) citron0137/exp-message
구현 상세
목차
- 로그인 실패에 따른 횟수 제한 기능 구현
- E2E 테스트를 통한 동시성 이슈 발견 및 해결
- 2-1) E2E 테스트 구현
- 2-2) 이슈 수정 1 - 실패 횟수 증가 누락 문제 해결
- 2-3) 이슈 수정 2 - 로그인 실패 카운트 이전에 시작된 로그인 시작 문제 해결
- 성능 최적화
- 3-1) 분산락 도입으로 1차 성능 개선
- 3-2) 실패 카운트 사전 증가로 2차 성능 개선
1) 로그인 실패에 따른 횟수 제한 기능 구현
loginfailure 도메인에 아래와 같은 기능 구현
- (로그인 시작시) email과 ip를 입력받아 실패 횟수가 제한을 넘겼는지 확인하는 로직 추가
- (로그인 실패시) email과 ip에 대해 실패 횟수를 1씩 추가하는 로직 추가 (TTL 15분)
- (로그인 성공시) email과 ip에 대해 실패 횟수를 초기화해즈는 로직 추가
레포지터리는 아래와 같은 이유로 Redis 선택
- TTL 기능을 적극 활용하기 위해
- 데이터의 영속성이 중요하지 않음
2-1) E2E 테스트 구현
아래 E2E 테스트를 Kotlin으로 구현
- (Given) 동일 Email로 4개의 실패 요청 전송
- (When) 1번과 동일 Email로 20개의 실패 요청 전송
- (Then) 2번의 응답 코드와 Redis 실패 카운트 조회
테스트 결과
- HTTP 응답: USER_001(로그인 실패) 20개, LOCKED(계정 잠김) 0개
- 정상적으로 작동시 USER_001은 1개, LOCKED는 19개
- Redis 실패 카운트: 9
- 정상적인 값 N의 범위는
USER_001 카운트 <= N <= 24
아래 2가지 이슈로 인해 정상적으로 작동하지 않는 것 확인
- 로그인 실패시 Read And Write로 인해 실패 횟수 증가가 누락되는 동시성 이슈 발생
- 로그인 실패 카운트 이전에 여러 쓰레드가 로그인을 시작한 경우 동시성 이슈 발생
2-2) 이슈 수정 1 - 실패 횟수 증가 누락 문제 해결
로그인 실패시 count 로직의 Read-Modify-Write를 Luascript를 사용하여 원자 연산으로 변경
테스트 결과
- HTTP 응답: USER_001(로그인 실패) 20개, LOCKED(계정 잠김) 0개
- 정상적으로 작동시 USER_001은 1개, LOCKED는 19개
- Redis 실패 카운트: 24 (정상)
2-3) 이슈 수정 2 - 로그인 실패 카운트 이전에 시작된 로그인 시작 문제 해결
마지막에 한 번 더 실패 횟수가 제한을 넘겼는지 확인 로직 추가
테스트 결과
- HTTP 응답: USER_001(로그인 실패) 1개 (가끔 0개), LOCKED(계정 잠김) 19개(가끔 20개)
- Redis 실패 카운트: 24 (정상)
해당 로직이 비효율적이라는 것을 알았기 때문에 바로 성능 최적화 시작
3-1) 분산락 도입으로 1차 성능 개선
동일 Email로 20개의 실패 요청을 동시에 보내는 E2E 테스트를 K6로 구현
=> 응답시간 P95 92.05ms
응답 시간이 느린 편은 아니나,
모든 요청이 Mysql을 거치는 부분을 개선하여 성능 개선이 가능할 것으로 예상됨
따라서 락을 통해 아래와 같은 성능 개선을 기대함
동시성 이슈 상황에서 차단 로직 분석
AS-IS:
- Redis loginFailCount 조회
- Mysql User 조회
- Redis loginFailCount++ 및 재조회
- LOCKED 반환
TO-BE(락 적용):
- Redis loginFailCount 조회
- Redis Set Lock or Throw “LOCKED” // 대부분이 이때 LOCKED 반환
- Redis loginFailCount 재조회 // 나머지가 이때 LOCKED 반환
- (참고) Mysql User 조회
- (참고) Redis loginFailCount++ 및 재조회
- (참고) Redis Unset Lock
동시성 이슈 발생 상황에서
기존의 경우 Redis 연산 2회와 Mysql연산 1회가 있는데 반해
새로운 경우 Redis 연산 2회 만 있을 것으로 예상됨 (Mysql 연산 1회 감소)
다만 2번의 Waiting에 대한 Redis연산이 더 있을 수 있기 때문에, Waiting은 과감히 없앰
이로 인해 동시성 이슈 발생 시 무조건 1개 요청은 LOCKED 응답받음
구현 후 위와 동일한 테스트 실행시
=> 응답시간 P95 20.8ms (77% 감소)
3-2) 실패 카운트 사전 증가로 2차 성능 개선
아래와 같이 한 번 더 개선이 가능하다고 생각됨
AS-IS(락 적용):
- Redis loginFailCount 조회
- Redis Set Lock or Throw “LOCKED” // 대부분이 이때 LOCKED 반환
- Redis loginFailCount 재조회 // 나머지가 이때 LOCKED 반환
- (참고) Mysql User 조회
- (참고) Redis loginFailCount++ 및 재조회
- (참고) Redis Unset Lock
TO-BE(락 미 적용):
- Redis increaseLoginFailCountOrThrow // 5미만일 경우 증가 아니면 LOCKED 반환
- (참고) Mysql User 조회
- (참고) (성공시에만) Redis resetLoginFailureCount
이 경우 Redis 연산을 기존 2회에서 1회로 감소가 가능하다고 생각됨
구현 후 위와 동일한 테스트 실행시
=> 응답시간 P95 17.95ms (10% 감소)
트레이드오프와 남은 과제
받아들인 트레이드오프
- Lua script 의 복잡도: 조회·검증·증가를 원자적으로 묶으면서 로직이 Redis 스크립트 안으로 들어갔다. 애플리케이션 코드만 읽어서는 흐름이 한눈에 보이지 않는다.
- 원자성(정확성)을 얻는 대신 가독성을 내준 선택이며, 명확한 함수명과 스크립트 단위 캡슐화로 비용을 상쇄했다.
- 분산 락을 유지하는 대안은 Redis 왕복이 2배였고, P95 를 20.8ms 아래로 내리지 못했다.
남은 과제
- Redis 장애 시 폴백 부재: 현재 구조는 Redis 에 전적으로 의존한다. Redis 가 죽으면 BF 방지도 함께 죽는다. fail-open(공격 노출)과 fail-closed(로그인 차단) 중 로그인 도메인에 맞는 쪽을 결정하고, 서킷 브레이커로 Redis 타임아웃이 로그인 응답을 물고 늘어지지 않게 해야 한다.
- IP 기반 우회: 계정 단위 카운터만으로는 여러 IP 에서 다수 계정을 두드리는 공격을 막지 못한다.