핏토링 회원가입 시 필수로 전화번호 인증을 받는다.
SMS로 멘토링하던 초기 UT 때 멘토에게 장난으로 문자를 보내는 사람들이 꽤 있어서 대응책으로 만들어진 것이다. 확실히 인증 후에는 장난 문자가 사라졌다.

인증 번호는 PhoneVerification 클래스로 관리했다.

프로젝트 초기에는 빠른 기능 구현을 위해 편의상 다른 도메인처럼 엔티티로 관리했지만,
사실 PhoneVerification은 데이터 수명도 짧고 (3분 만료 정책), 다른 엔티티랑 연관 관계도 없어서 굳이 RDB로 관리할 필요가 없었다.
(위의 외딴섬 스키마가 PhoneVerification 이다)
게다가 전화번호:인증코드, 인증코드:인증여부 등 key:val 구조의 조회가 끝인 단순한 로직이다.
또 인증 코드는 만료 시간 체크가 핵심인데 RDB 구조에서는 애플리케이션에서 직접 만료 시간을 넣어주고, 검증해줘야 했다.
인증 코드 데이터의 특성을 정리하면 아래와 같다.
1. 짧은 수명
2. key-val 조회가 대부분
3. 만료 처리
AccessToken 갱신을 위한 RefreshToken 도 만료 시간이 조회가 메인 로직인 점에서 비슷한 특성을 갖고 있다.
이에 조회 성능을 높이고, TTL로 만료 시간을 자연스럽게 다루기 위해 PhoneVerification , RefreshToken 을 Redis로 관리하도록 리팩터링했다.
Key:Val 저장 구조는 아래와 같다.
@Override
public void save(String phoneNumber, String code, long ttlSeconds) {
String key = key(phoneNumber);
redisTemplate.opsForHash().put(key, FIELD_CODE, code);
redisTemplate.opsForHash().put(key, FIELD_VERIFIED, "false");
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
}
하나의 키를 공유하도록 인증 코드, 인증 여부를 해시 필드로 묶었다.
Redis가 이미 Key:Val 해시 구조지만, 이처럼 Val를 해시로 저장하는 것도 지원한다. (Java의 HashMap<String, HashMap<String, String>> 같은 느낌)
이러면 TTL 하나로 동일 전화번호에 대해 인증 코드, 인증 여부 데이터를 함께 관리할 수 있다.
또 기존에는 별도의 재시도 횟수 제한을 두고 있지 않았기 때문에 브루트 포싱으로 전화번호 인증이 뚫리는 구조였다.
그래서 이번 기회에 최대 시도 횟수(MAX_ATTEMPTS)도 추가했다.
인증 시도 횟수는 인증 코드, 인증 여부 와 달리 전화번호 키에 필드로 묶여 있는 게 아니라 별도의 키이다.
따라서 별도로 TTL도 설정해줘야 한다.
code/verified 는 해시이고, attempts는 카운터이기 때문에 역할을 분리했다.인증 시도 횟수 검증은 아래처럼 설계할 수 있다.
RedisPhoneVerificationRepository.findByPhone() 으로 전화번호에 대한 유효한 키(본문 키)가 있으면 가져오고, 없으면 예외 던짐(만료).
이후 incrementAttempts() 로 카운터 값을 증가시키고, 본문 키의 TTL을 attempt 키에 설정해서 TTL을 동기화 한다.
그런데 확률은 낮지만 1에서 유효했던 키가 2에서 만료되면서 attempts 키가 생성만 되고 TTL 설정이 안되는 race condition이 발생할 수 있다.
이러면 Redis에 해당 키가 영구 잔류하게 된다.
따라서 1과 2를 Lua 스크립트를 활용해 묶어주었다.
Redis Lock에도 활용되는 Lua는 스크립트 내용을 단일 명령처럼 원자적으로 처리한다.
INCREMENT_ATTEMPTS_SCRIPT.setScriptText("""
if redis.call('EXISTS', KEYS[1]) == 0 then
return -1
end
local count = redis.call('INCR', KEYS[2])
if count == 1 then
local ttl = redis.call('TTL', KEYS[1])
if ttl > 0 then
redis.call('EXPIRE', KEYS[2], ttl)
end
end
return count
""");
이제 EXISTS로 본문 키를 확인하고, attempts INCR + TTL 동기화 까지 하나의 단위로 처리한다.
사실
attempts도code/verified처럼 TTL을 공유하기 때문에 본문 키에 필드로 묶을 수도 있긴 하지만 그러면 자바 코드 대신에 Lua 스크립트가 길어지고 디버깅이 어려워져서 채택하지 않았다.
@Override
public void save(String tokenValue, Long memberId, long ttlMillis) {
String tokenKey = tokenKey(tokenValue);
String memberKey = memberKey(memberId);
redisTemplate.opsForValue().set(tokenKey, String.valueOf(memberId), ttlMillis, TimeUnit.MILLISECONDS);
redisTemplate.opsForSet().add(memberKey, tokenValue);
redisTemplate.expire(memberKey, ttlMillis, TimeUnit.MILLISECONDS);
}
Refresh Token은 key가 두 개 필요했다. Refresh Token의 기능 요구 사항 때문이다.
- Refresh Token의 토큰 값이 유효할 경우 Access Token과 Refresh Token을 갱신하는
reissue()memberId로 해당 회원의 모든 토큰 값을 만료 시키는logout()
즉, token → memberId, memberId → token 의 양방향 조회가 필요하기 때문에 두 key:val 을 동시에 저장한다.
RDB에서는 WHERE token_value = ? 와 WHERE member_id = ? 이 모두 자연스러운데
Redis 같은 Key:Val 구조는 단 방향 조회만 가능하므로 이런 어색함이 생긴다. RDB와 NoSQL의 트레이드 오프라고 생각한다.
현재 다중 디바이스 로그인을 지원하고 있으므로 member:token은 Set으로 관리한다.
워크트리worktree를 이용해서 기존 MySQL 버전과 Redis 버전의 코드를 동일한 벤치마크 테스트로 비교했다.
워크트리를 이번에 처음 써봤는데, 동일 레포지토리를 서로 다른 체크아웃에서 동시에 열 수 있는 갓 기능이다.
브랜치 2개를 각각 열 수도 있고, 특정 커밋 2개를 각각 띄울 수도 있다고 한다.
나는 작업 전 커밋을 워크트리로 띄워놓고 작업 후 커밋과 동일한 테스트를 직접 붙여 넣고 돌려서 결과를 비교했다.
Redis 전환 전후 성능을 비교하기 위해 PhoneVerification과 RefreshToken 테스트를 아래와 같이 구성했다.
1. 워밍업 + 사전 데이터 추가
2. 연산별 성능 측정
3. 동시성 측정
create(), verify(), check()login(), reissue(), logout()create() → verify()save() → reissue()각 테스트에 대해 총 소요시간, 평균 응답 시간, p95, p99, 처리량을 수집했다.


처리량을 기준으로 결과를 분석해 보자.
먼저 Phone Verification은 확실히 차이를 보였다.
전반적인 처리량이 크게 올랐다. check()의 경우는 무려 350% 증가했다.
그에 비하면 Refresh Token의 개선 폭은 크지 않았다.
원인은 AuthService의 login()과 reissue()에서 여전히 Member 조회를 위해 DB를 호출하기 때문이다.
더 성능을 높이려면 단순히 저장소만 Redis로 돌리는 게 아니라 인증 로직 자체를 개선할 방법을 찾아야 할 것 같다.
일단은 소폭의 성능 향상과 타임아웃 책임을 비즈니스 로직에서 Redis로 옮긴 것에 의의를 두자.