성공적으로 사내 카페 POS 서버를 운영하게 되고, 다음 프로젝트로 바로 Spring을 도입하여 mobile pos 서버를 개발하게 되었다. 아키텍처는 일단 NodeJS로 개발했던 것을 그대로 따라 어느정도 동작하는 MVP 개발을 마치고, 기능 추가 및 리팩토링을 진행하자는 방향이었다. 중간에 다른 Spring 프로젝트를 진행하느라 원래 맡았던 주문 서비스를 인수인계한 이후에 다시 팀에 복귀 했을 때, 로그인 서비스를 담당하게 되었다.
기존에 했던 POS 서버는 monolithic 구조였는데, 여기서 크게 로그인, 주문, 상품, 결제, 알림 서비스로 나누어 MSA 구조로 전환하게 되었다. MSA로 전환하게 된 가장 주된 이유는 각 서비스만의 문제가 전체 서비스의 다운으로 이어지지 않게 하기 위함이었다.
추가로 MSA 전환으로 의사결정에 크게 기여한 부분은 이미 이전 프로젝트에서 리팩토링을 통하여 각 서비스간의 의존성을 최대한 덜어내는 과정에서 필요 정보 조회 함수콜이 거의 Controller 레벨에서 이루어져 함수콜을 HTTP Request로 바꿔도 큰 수정이 없어 MSA로의 전환이 용이했다는 점이다.
이번 프로젝트부터 MSA를 지향하는 만큼 초반 기획상 Session 클러스터링을 고려할 필요가 없는 확장성이 있는 JWT로 진행하기로 결정하였다.
문제는 로그인 관련 비즈니스 로직이 계속 추가되었다는 것이다.
자동 로그인, 로그인 실패 횟수, 중복 로그인 방지, 비밀번호 변경에 따른 자동 로그인된 디바이스에서 로그아웃 시키기 등이 개발 도중에 계속 추가되었다.
추가되는 기능 중에서 중복 로그인 방지와 자동 로그인된 디바이스에서 로그아웃을 시키기위해서는 유저가 발급받은 JWT(refresh token)를 보유하고 있는 수 밖에 없었다.
어라? 결국 서버에 로그인중인 유저 정보를 저장해야되네..?
어차피 유저의 로그인 상태를 서버에서 제어하는 로직이 있는 이상 JWT든, 세션이든 저장해서 들고 있어야하는 상황에 JWT의 장점이 많이 퇴색되었다고 생각했다. 다만 중복 로그인 방지 로직과 로그인된 디바이스에서 로그아웃 시키는 로직이 추가되어야 했을 때, 굳이 JWT를 세션으로 교체할 이유 또한 없었다. 그래서 Spring Security 프레임워크와 JWT, Redis를 이용하여 해당 로직들을 구현하였다.
JWT에 Access Token과 Refresh Token을 이용하여 아래 로직들을 구현했었다. 간단하게 어떻게 구현하였는지 설명하려고 한다. Redis에 담았던 유저 로그인 정보 객체는 아래와 같다. JWT에서 로그인 로직들을 구현하기 위해서 필요한 것은 해당 아이디의 현재 로그인 상태들을 알아야한다는 것이다. 이 것을 알기 위하여 Redis에 유저 아이디와 Access Token, Refresh Token을 저장하였다.
@RedisHash(value = "login_user")
public class UserLoginInfo {
@Id
String username;
@Builder.Default
int maxLoginDeviceCount = 1;
Set<String> accessTokens;
Set<String> refreshTokens;
}
커스텀으로 구현한 로그인 필터는 UsernamePasswordAuthenticationFilter
를 상속받아 구현하였다.
public class JWTLoginFilter extends UsernamePasswordAuthenticationFilter{
...
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
userLoginDto.getEmail(), userLoginDto.getPassword(), null
);
// user details
setDetails(request, token);
return getAuthenticationManager().authenticate(token);
...
}
로그인 시에 Access Token과 Refresh Token을 새로 발급해주면서 로그인한 유저 아이디와 발급한 토큰들을 Redis에 저장한다. 중복 로그인에 대한 판단은 서버에서 가지고 있는 Refresh Token으로 하였다. 새로 로그인 시도를 하여 Refresh Token을 발급해주려고 하는데 이미 발급된 Refresh Token이 있다면 로그인 실패를 하도록 하였다. 다만 사용자 인증 이후 로그인하면서 다른 디바이스에 전부 강제 로그아웃을 할 수 있는 로직도 구현하였다.
...
// Redis에서 로그인 기록 조회
UserLoginInfo userLoginInfo = userLoginInfoRepository.findById(userLoginDto.getEmail()).orElse(null);
// 로그인 기록이 있다면
if (userLoginInfo != null) {
// 강제 로그인일 경우 모든 토큰을 날린다
if (userLoginDto.isForceLogin()) {
if (userLoginInfo.getAccessTokens() != null)
userLoginInfo.getAccessTokens().clear();
if (userLoginInfo.getRefreshTokens() != null)
userLoginInfo.getRefreshTokens().clear();
} else {
// 로그인 시도 직전에 만료된 토큰 삭제
userLoginInfo.removeExpiredRefreshToken();
userLoginInfo.removeExpiredAccessToken();
}
userLoginInfoRepository.save(userLoginInfo);
// 유효한 Refresh Token이 있다면 이미 다른 디바이스에 로그인된 상태
if (userLoginInfo.getRefreshTokens() != null
&& userLoginInfo.getMaxLoginDeviceCount() <= userLoginInfo.getRefreshTokens().size()) {
log.error(AuthErrorCode.ALREADY_LOGIN_OTHER_DEVICES.getMessage());
request.setAttribute("exception", AuthErrorCode.ALREADY_LOGIN_OTHER_DEVICES);
throw new AuthenticationException(AuthErrorCode.ALREADY_LOGIN_OTHER_DEVICES.getMessage()) {
};
}
}
...
처음으로 Spring Security 프레임워크를 접하고 사용하였던 프로젝트이다. JWT 발급 로직 위치 고민과 같은 새롭게 생각하고 시야가 넓어질 고민을 할 수 있었다.
JWT를 사용한 로그인 로직으로 Refresh Token으로 로그인 상태를 관리하는 서비스 개발은 새로운 시도였고 JWT부터 Spring Security 프레임워크에 대한 학습은 즐거운 경험이었다. 다음으로 잃어버린 아이디와 비밀번호를 찾기 위한 사용자 인증에 대한 소프트웨어 설계를 했던 경험으로 포스트를 작성하려고 한다.