'반딧불이' 프로젝트를 진행하면서 팀원들과 사용자 인증 방식에 대한 많은 고민을 했다.
인증 방식 중 세션 방식, 토큰 방식이 있었는데 세션 방식을 사용하기로 했다.
그 이유는, 인증이 필요한 사용자의 수가 적다는 점 때문이다.
반딧불이의 모바일 사용자(시각장애인)는 별도의 로그인 없이 서비스를 이용할 수 있다. 그리고 관리자(역무원)은 역무원의 수가 지하철 역마다 1명이라고 한정했었기 때문에 세션을 유지할 사용자의 수가 매우 적었다. 그래서 충분히 가지고있는 서버만으로 감당 가능하다는 생각을 했고, 세션 방식을 채택하게 되었다.
그리고 세션 저장소를 Redis를 사용하게 되었는데, 그 이유는 세션 스토리지 방식을 위함이다. 세션 스토리지가 무엇인지와 왜 사용하게 되었는지 알아보자.
세션 스토리지는 모든 WAS가 같은 Session 저장소를 바라보게 하는 방법이다.
WAS에 세션을 저장하는게 아닌, 외부로 세션 저장소를 분리한 것이다.
그럼 왜 세션 저장소를 외부로 분리했을까?
그 이유는 무중단 배포를 도입한 아키텍처 때문인데, 서비스를 지속적으로 운영하기 위해 Blue-Green 방식의 무중단 배포를 진행하였다.
두 대의 WAS를 사용하기 때문에 세션 불일치 문제가 발생한다고 생각했고,
외부로 세션 저장소를 분리하게 되었다.
그럼 세션을 어디다가 저장해야할까?
MySQL, postgreSQL 처럼 Disk를 사용하는 데이터베이스에 저장하는 방법이 있을 수 있다.
하지만, 로그인한 사용자의 정보는 영구적인 저장이 필요하지 않다.
또한, Disk에 접근하는 I/O 시간이 오래 걸린다는 문제점이 있다고 판단했다.
그래서 In-memory 방식의 Redis를 세션 저장소로 채택했다.
추가적으로 외부의 세션 저장소를 사용하기 위해 Spring Session을 사용했다.
Spring Session은 스프링 기반 애플리케이션에서 세션 관리를 효과적으로 처리하기 위한 기술이다. 세션 데이터를 서버의 메모리에 저장하는 대신, 외부 스토리지에 저장하고 관리가 가능하다.
dependencies {
// Spring Boot에서 Redis를 사용하기 위한 의존성입니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Spring Session을 Redis에 저장하기 위한 의존성입니다.
implementation 'org.springframework.session:spring-session-data-redis'
}
redis를 띄운 호스트 주소와 포트번호, 패스워드를 입력해주었다.
@Configuration
@EnableRedisHttpSession
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
final RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setDefaultSerializer(new StringRedisSerializer());
return template;
}
}
Redis를 외부 세션 저장소로 사용하기 위해서는 @EnableRedisHttpSession 어노테이션이 필요하다.
@RequiredArgsConstructor
@Slf4j
public class UserAuthenticationFilter extends OncePerRequestFilter {
private final StationRepository stationRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("userAuthenticationFilter 호출");
HttpSession session = request.getSession(false); // 세션을 새로 생성하지 않음
if (session != null) {
System.out.println(session);
StationSessionDto user = (StationSessionDto) session.getAttribute("user");
Station station = stationRepository.findById(user.getId()).orElseThrow(() -> new EntityNotFoundException("유저 정보가 없습니다."));
if (station != null) {
log.info("유저 정보 : " + station);
GrantedAuthority authority = new SimpleGrantedAuthority("USER");
Authentication authentication = new UsernamePasswordAuthenticationToken(station, null, Collections.singleton(authority));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request,response);
}
}
해당 필터에서, 사용자의 요청이 들어오게되면 쿠키의 세션id를 꺼내서 세션을 조회하고, 유저 정보를 가져온다.
스프링 시큐리티의 인가 처리를 위해, 유저 정보를 ContextHolder에 저장하였다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsConfig corsConfig;
private final StationRepository stationRepository;
@Bean
public BCryptPasswordEncoder encodePwd(){
return new BCryptPasswordEncoder();
}
private static final String[] PERMIT_URL_ARRAY = {
"/api/login","/api/signup", "/socket/**", "/api/navigation/**", "/api/beacon/info/**", "/api/sos/**", "/api/stationinfo/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.addFilterBefore(corsConfig.corsFilter(), UsernamePasswordAuthenticationFilter.class) // CORS 필터를 가장 먼저 적용
.addFilterBefore(new UserAuthenticationFilter(stationRepository), UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(PERMIT_URL_ARRAY).permitAll()
.anyRequest().authenticated())
.csrf(AbstractHttpConfigurer::disable)
.build();
}
}
로그인이 필요없는 요청들은 PERMIT_URL_ARRAY에 등록하여 인가처리를 하지 않게 처리했다.
public StationInfoDto login(LoginDto dto, HttpSession session) {
Station station = stationRepository.findByLoginId(dto.getLoginId());
if (station == null) {
throw new EntityNotFoundException("사용자가 존재하지 않습니다.");
}
if (!bCryptPasswordEncoder.matches(dto.getPassword(), station.getPassword())) {
throw new PasswordIncorrectException("패스워드가 일치하지 않습니다.");
}
System.out.println();
// 세션에 엔티티를 바로 넣게되면 JPA의 LazyLoading issue 같은 프록시 객체 초기화 관련 문제가 발생하므로 dto를 넣어주자
StationSessionDto user = new StationSessionDto(station.getId(), station.getName(), station.getLoginId(), station.getLine(), station.getRole());
session.setAttribute("user", user);
return new StationInfoDto(station.getLine(), station.getName());
}
로그인 시, session 저장소에 유저 정보를 저장하게 구현하였다.
로그인을 진행하면 응답의 Cookies에 SESSION ID가 담겨오는것을 확인할 수 있다.
redis 에도 저장 완료!