🎯 한 사람에 대한 여러 요청을 어떻게 제한할 수 있을까?
예약 상품 프로젝트는 특정 시간에 상품이 오픈되고, 많은 사용자가 짧은 시간에 주문 요청을 할 것이라고 가정한다.
위의 그림에서 고려사항1이 생겼다.
한명의 사용자가 여러 PC에서 로그인하여 동시 요청하는것을 방지하려면 어떻게해야할까?
고민해본 해결방법은 아래와 같다.
1. 로그인할 수 있는 기기 개수에 제한을 건다.
2. Gateway에서 RateLimit을 구현하여 많은 요청을 막는다.
🎯 [프로젝트에 적용] 로그인 기기 개수에 제한 걸기
로그인 할 수 있는 기기 개수에 제한을 두어 많은 PC에서 같은 ID로 주문 요청 하는것에 제한을 두려고 한다.
현재 로그아웃 구현 상태는?
현재 로그아웃 방식을 활용하기 위하여 redis를 활용하고 있다.
로그인할 때 refresh토큰을 redis에 저장하고, 사용자가 로그아웃할 때 redis에 refresh토큰을 제거한다. 또한 redis 자료구조에 만료기한을 지정해놓고 일정 기간이 지난후에는 refresh토큰을 만료시켜 재발급 하도록 구현해놓은 상태이다.
현재는 jwt방식을 통해서 로그인 시 access토큰과 refresh토큰을 발급해주는 방식으로 동작한다.
서버에서 상태를 갖지 않도록 하기위해 jwt를 선택했지만, 현재 로그인한 사용자의 기기 정보를 보관하기 위해서는 별도의 데이터베이스를 사용해야한다.
사용자의 로그인 기기 개수 제한을 두기위해 어떤 방식을 사용해야할까?
기기 개수 제한을 두기 위한 방안으로도 redis를 활용해보면 좋겠다는 생각이 들었다.
중복된 기기를 갖지 않아야 하고, 로그인한 사용자의 기기정보들은 특정 기간내에 사라질 정보들이기 때문에 MySQL과 같은 DB에 데이터를 축적하는 것 보다는 redis를 통해 일정 기간동안 보관하는 방식이 좋아보였다.
또한 jwt의 장점인 분산된 서버 환경에서 redis를 활용하여 로그인 기기 관리를 공통적으로 쉽게 할 수 있을 것이라고 생각하였다.
생각해낸 구현 방법
1. 로그인 시 3종류의 데이터를 redis에 저장한다.
로그인 할 때 아래와같이 3개의 데이터를 redis에 저장
redis Set = { memberId - deviceUUID" : 임의의 문자 }
redis Set = { "deviceUUID" : "refreshToken" }
redis hash = {"memberId" : {"memberId" : "deviceUUID1", "memberId" : "deviceUUID1" . . .} }
2. 로그아웃할 때 해당 memberId와 해당기기 uuid조합과 refresh토큰을 삭제한다.
3. 모든 기기에서 로그아웃 할 때 memberId에 해당하는 모든 uuid조합을 삭제한다.
set 자료구조를 활용하여 memberId와 deviceUUID 조합을 저장하여 한명의 사용자가 한개의 기기에서만 로그인할 수 있도록 한다.
또한 set 자료구조를 활용하여 deviceUUID의 refreshToken을 관리하여 로그인/로그아웃 상태를 관리한다.
마지막으로 hash 자료구조를 활용하여 memberId당 로그인 되어있는 deviceUUID를 관리할 수 있고, 개수에 제한을 둬서 많은 기기에서 로그인 하지 못하도록 할 수 있다.
위의 아이디어대로 구현한 코드는 아래와 같다.
로그인 코드
/**
* 로그인 -> redis에저장
* device = {memberId1 + : +uuid1} - 자동만료가능
* deviceToken = {uuid1 : refreshToken1} - 자동만료가능
* memberLogins = {memberId : {uuid1, uuid2, . . .}} -> 위에가 자동만료되었을 때 어떻게 할 것인가?
*/
@Transactional
public LoginResponse login(final LoginInfo loginInfo, final String deviceUUID) {
final String email = loginInfo.getEmail();
final String password = loginInfo.getPassword();
final Member member = findExistMember(email);
checkPassword(password, member);
final String accessToken = jwtTokenProvider.generate(member.getId(), member.getEmail(), TokenType.ACCESS);
final String refreshToken = jwtTokenProvider.generate(member.getId(), member.getEmail(), TokenType.REFRESH);
final long duration = jwtTokenProvider.getExpiredTime(refreshToken, TokenType.REFRESH);
final String memberId = String.valueOf(member.getId());
final String device = redisRefreshRepository.findByValue(memberId + deviceUUID);
// 이미 존재 하면 덮어쓰운다.
redisRefreshRepository.save(memberId + "-" + deviceUUID, String.valueOf(member.getId()), duration);
redisRefreshRepository.save(deviceUUID, refreshToken, duration);
if (device == null) {
redisRefreshRepository.addToHash(memberId, deviceUUID, "NULL");
}
return LoginResponse.from(accessToken, refreshToken);
}
로그아웃 코드
/**
* 현재 기기에서 로그아웃
*/
@Transactional
public void logout(final LogoutInfo logoutInfo, final String principalEmail, final String deviceUUID) {
final String refreshToken = logoutInfo.getRefreshToken();
final String email = jwtTokenProvider.getEmail(refreshToken, TokenType.REFRESH);
checkAuthorized(email, principalEmail);
final Member member = memberRepository.findByEmail(email).orElseThrow(() ->
new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] User not found"));
// 존재하는 리프레쉬 토큰 확인 후 제거
final String memberId = String.valueOf(member.getId());
final String device = redisRefreshRepository.findByValue(memberId + "-" + deviceUUID);
if (device == null) {
throw new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] ] not found refresh token");
}
redisRefreshRepository.delete(memberId + "-" + deviceUUID);
redisRefreshRepository.delete(deviceUUID);
redisRefreshRepository.removeFromHash(memberId, deviceUUID);
}
모든 기기에서 로그아웃 코드
/**
* 모든 기기에서 로그아웃
*/
public void logoutAll(final LogoutInfo logoutInfo, final String principalEmail) {
String refreshToken = logoutInfo.getRefreshToken();
String email = jwtTokenProvider.getEmail(refreshToken, TokenType.REFRESH);
checkAuthorized(email, principalEmail);
Member member = memberRepository.findByEmail(email).orElseThrow(() ->
new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] User not found"));
// 존재하는 모든 리프레쉬 토큰 확인 후 제거
String memberId = String.valueOf(member.getId());
// 존재하는 모든 uuid - refreshToken 가져오기
Map<String, String> allDevice = redisRefreshRepository.getAllFromHash(memberId);
for (String uuid : allDevice.keySet()) {
redisRefreshRepository.delete(memberId + "-" + uuid);
redisRefreshRepository.delete(uuid);
redisRefreshRepository.removeFromHash(memberId, uuid);
}
}
🎯 [추가 학습] RateLimit
사용자의 로그인 기기개수를 제한하는 것 말고도, API Gateway단에서 요청 개수의 최대치를 제한할 수 있는 RateLimit기법도 존재한다.
한명의 사용자는 10초 내에 최대 1번만 주문요청을 한다거나, 10초에 최대 3번까지 주문 요청을 할 수 있도록 하는 방법이다.
또한 절대적으로 API Gateway로 들어오는 최대 요청수를 제한해 버림으로써 뒷단에 있는 서버들의 부하를 줄일 수 있다는 장점이있다.
(대규모 트래픽을 다루는 기업들은 모두 Rate Limiter를 사용하고 있다)
보통 API Gateway에 구현하거나, 별도의 RateLimiter서버에 구현하고 API Gateway가 RateLimiter를 사용하도록 구현할 수 있다.
또한 동시 요청수가 100만개 이상이 되면 RateLimiter도 응답시간 지연에 영향을 미칠 수 있으므로 여러대의 RateLimiter를 운영한다고 한다.
여러대의 RateLimiter를 구현한다고 했을 때 공통으로 접근할 수 있는 Redis데이터베이스를 많이 활용한다고 한다. 빠르게 접속중인 요청개수를 카운트할 수 있고 업데이트할 수 있다. (단, 여기서도 redis상에서 발생할 수 있는 동시성 문제에 대해서 고민해봐야 한다)
구현할 수 있는 알고리즘도 아래와 같이 다양하고, 각각의 장/단점이 있기 때문에 구현하거나 라이브러리를 이용할 때 서비스 특징에 맞게 선택해야 할 것 같다.
[Rate Limit 구현 알고리즘]
- 토큰 버킷(token buchet)
- 누출 버킷(leaky buchet)
- 고정 윈도우 카운터(fixed window counter)
- 슬라이딩 윈도우 로그(sliding window log)
- 고정 슬라이딩 카운터(sliding window counter)
나의 프로젝트의 경우에 짧은 시간대에 많은 트래픽이 몰릴 것으로 예상되기 때문에, 토큰 버킷 알고리즘 혹은 고정 슬라이딩 카운터 알고리즘을 활용한 Rate Limiter를 적용시켜보면 좋을 것 같다.
Spring Cloud Gateway의 경우 Redis를 활용한 RateLimiter 기능을 제공하기 때문에 시간이 부족할 경우에 빠르게 라이브러리를 이용할 수 있으면 좋을 것 같다. (Spring Cloud Gateway Ratelimiter 문서)
참고자료
'프로젝트 > 예약상품' 카테고리의 다른 글
[회고] 예약 상품 프로젝트 (0) | 2024.03.04 |
---|---|
[프로젝트] 회복탄력성(CircuitBreaker와 Retry) (0) | 2024.03.02 |
[프로젝트] redis로 동시성 문제를 접근해보자 (0) | 2024.02.26 |
[프로젝트] 재고정보를 판매오픈전 캐싱할 때 고려사항 (0) | 2024.02.26 |
[프로젝트] synchronized 영역 최소화하기 (0) | 2024.02.24 |