이번 포스팅에서는 회원가입 시 문자 인증을 도입하고, 관련된 부가 기능을 도입한 내용에 대해 작성할 예정이다. 문자 인증은 외부 API(coolsms), 인증 번호 기억을 위한 로컬 캐시(Caffeine Cache), 요청 횟수 제한을 위한 Rate Limit 도입에 대한 이야기이다.
NHN cloud, Naver cloud, Kakao 등 여러 플랫폼이 문자(카톡) 전송 클라우드 서비스를 지원하고 있다. 하지만 사업자로 등록한 경우에만 사용할 수 있게 되어있어 개인 프로젝트를 진행 중인 나에게는 맞지 않았다. 따라서 가격이 저렴하면서 개인이 쓰기에 제약이 없는 coolsms를 사용하기로 했다.
coolsms는 단문 문자 20원, 장문 문자 50원으로 책정되어 있다.
인증 번호는 유효 기간이 필요하다. "10분 이내에 등록해야 한다"라는 조건이 필요하다. 이는 휘발성 데이터로 DB가 아닌 캐시를 사용하기로 했다. 인증 번호 뿐만 아니라 회원가입 하려는 전화번호가 인증되었는지 정보도 같이 캐시에 저장하여, 회원가입 시 인증된 전화번호인지 확인해야한다.
캐시 사용 이유 및 캐시 유지 기간
고려한 캐시는 3가지가 있다. 그 중 많은 기능을 지원하는 Caffeine Cache를 사용하기로 했다. Caffeine Cache는 특히 셋 중 성능이 가장 우수하다. 또한 커뮤니티와 문서화도 잘 되어 있어 적합하다고 판단했다.
위에서 설명했듯이 인증 번호 담은 캐시, 인증된 전화번호 담은 캐시 2개가 필요하다.
나는 인증 번호 유효기간(TTL)을 10분, 인증된 전화번호 유효기간(TTL)을 60분으로 설정하였다.
- TTL(Time To Live): 넣은 순간부터 설정된 시간이 지나면 항목을 무조건 만료
- TTL(Time To Idle): 마지막 접근 시점부터 설정된 시간 동안 접근이 없으면 만료
인증번호 담은 캐시: verficationCodeCache
[ <전화번호1: 인증번호>, <전화번호2: 인증번호> ....]
인증된 전화번호를 담은 캐시: verifiedPhoneNumberCache
[ <전화번호1: true>, <전화번호2: true> ]
- 처음 인증 문자 요청
- 캐시에 <전화번호1: 인증번호1> 저장 후 문자 발송
- 같은 전화번호로 인증 문자 반복 요청
- 새롭게 생성된 인증번호2를 사용하여 <전화번호1: 인증번호2> 변경
- 기존 인증번호1 삭제되어 동일한 전화번호, 인증번호1을 사용하여 인증 시 실패
핸드폰 화면을 캡쳐하여 화질이 좋지 못하지만, 동작 테스트 완료..!!
위와 같이 요청당 서버에서 비용을 지불해야하는 API가 있다면, 사용자의 불필요하거나 남용되는 요청을 제한할 필요가 있다. 이 때 사용자마다 요청 횟수를 제한하는 기능을 Rate Limit이라고 한다.
Bucket4j + Caffeine 캐시 사용
- 왜 라이브러리를 쓰는 방법을 선택했나?
- 웹서버를 쓴다면, IP Rate Limit 정책이 변경되거나 특정 API를 개발하고 엔드포인트에 맞게 세세한 Rate Limit을 적용하기 위해서는 수정하고 재배포하는 일을 반복하게 된다. 이보다는 서버 내에서 적용하여 수정하면 빠르게 적용할 수 있다. 또한 이번 기능을 위해 Nginx를 추가하고, 설정하기에는 리소스가 크다고 생각했다.
- Redis는 단일 발생 장애 지점이 될 수 있고, 비용도 발생하기에, 현재 Redis를 도입하는 것은 "굳이" 라는 생각이 들어 다른 방법을 택하게 되었다.
- 그럼 Bucket4j만 사용하면 되는데 왜 Caffeine Cache와 함께 사용했나?
- Bucket4j는 토큰 버킷 알고리즘을 쉽게 사용하라고 Bucket과 그에 맞는 알고리즘, 설정 등을 제공한다. 그럼 우리는 Bucket을 어딘가에 저장해야 한다. 여러 쓰레드에서 접근하기에 ConcurrentHashMap을 많이 사용하게 된다.
- 하지만 키, 값이 영구적으로 남아있게 되고 메모리를 계속 차지할 가능성이 있다. 또한 추후 Redis로의 변경을 위해 Caffeine Cache를 도입하여 통계정보까지 확인하고자 한다.
- TIL을 설정하고, Caffeine Cache에서 제공하는 스케줄러를 통해 만료된 데이터를 제거한다. TimerWheel이 만료 노드만 스캔해 제거하여 오버헤드가 매우 적다.
컨트롤러별 Rate Limit 다르게 적용하기 with 커스텀 어노테이션
- 디도스 제어: 전체 API 대상, soft하게 적용, 1초 10번 ➡️ DEFAULT
- 비용 발생 제어: 문자 인증 API 대상, 10분 5번 ➡️ SMS_SEND
- 동일 IP 요청 제어: Guestbook 등록 API 대상, 1분 5번 ➡️ GUESTBOOK_WRITE
|
|---|
|
|---|
k6를 통해 GUESTBOOK_WRITE 적용된 컨트롤러에 1분 내로 10번의 요청을 시도했다.
구현 의도에 맞게 같은 IP로는 5번의 요청까지만 적용되고, 이후 요청에서 Error가 반환되는 것을 볼 수 있다.
‼️ 전화번호가 겹치면 안되기에 0번부터 9번까지 iteration 번호를 적용해 다르게 요청하였다.
만약 공용 IP를 쓰는 곳이라면 100명의 유저가 동시에 접속할 때, 정상적인 접근이지만 Rate Limit에서 차단하게 된다. 만약 jwt 토큰 또는 사용자 id를 기준으로 잡을 수 있는 API Rate Limit을 사용한다면 괜찮지만, 현재 로그인하지 않은 사용자가 등록을 할 수 있도록 하기 때문에 그럴 수 없다.
그럼 어떻게 해야할까? 나는 Device Id를 추가하기로 했다.
현재: PLAN 이름 + IP
개선: PLAN 이름 + IP + Device Id
Device Id는 클라이언트에서 저장해놓고 매 요청마다 헤더에 추가하여 전송한다.
이 때 Device Id를 JWT 토큰으로 생성하고, 헤더로 받은 JWT 토큰을 파싱하여 uuid를 얻는다.
key를 기준으로 변화된 내용을 정리
1. IP만 사용 ➡️ API마다 다른 기준을 적용 불가 ❌
2. PLAN + IP 사용 ➡️ API마다 다른 기준 적용 ✅, 공용 IP에서 유저 개별 적용 ❌
3. PLAN + IP + DeviceId ➡️ API마다 다른 기준 적용 ✅, 공용 IP에서 유저 개별 적용 ✅