애플리케이션을 배포했을 때 비정상적으로 종료될 수 있기 때문에 사수님께서 Nginx를 사용하여 무중단 배포를 구축해보라고 하셨었습니다. 그래서 Nginx의 LB(Load Balancing) 기능을 사용하여 위 "구성도(이때 당시는 Redis가 없음)"처럼 배포하였습니다.
개발 중이던 Spring 애플리케이션은 Spring Security의 Form 인증(Session/Cookie) 방식을 사용하고 있었습니다. 별도로 Session을 관리하는 저장소(Redis) 없이 2개의 Spring 애플리케이션을 배포하고 테스트해본 결과 8080에서 로그인하여 여러 번 요청(Nginx에 weight를 설정했습니다.)을 보내서 8081 포트의 Spring으로 요청했을 때 8081 기준으로는 인증되지 않은 요청이기 때문에 (8080, 8081 각각 별도로 Session을 관리하고 있음) 로그인이 해제된 것을 알 수 있었습니다.
이와 같은 문제를 직면했었고 쿠키, 세션에 대해 좀 더 생각할 수 있었습니다. 문제를 해결하기 위해 사수님과 주위 개발자 지인분들에게 물어보았고 세션 클러스터링(Session Clustering)이란 단어를 알 수 있었습니다. 그래서 이번 포스팅에서는 Spring Security의 Form 인증 방식 + Spring에서 별도로 관리하는 Session Repository를 Redis로 이관 + Nginx를 사용하여 다중 서버 환경에서의 로그인을 해결한 경험에 대해서 정리해보도록 하겠습니다.
이 글은 기존에 작성했던 Form Login 인증 프로젝트에 살을 붙이는 형태로 진행되기 때문에 Form 인증 방식을 확인하고 싶으시면 다음 글을 참고해주세요.
전체 소스 코드는 git의 feature/session-clustering 브랜치에 있습니다.
https://github.com/DahamLeee/Spring-Security-Tistory
① 편에서는 Spring Security, Spring Data Redis 연동 후, local에 있는 Redis에 Session 값이 정상적으로 저장되고 보관되는 확인 해보도록 하겠습니다.
※ Redis 설치는 따로 다루지 않겠습니다. Local PC에 바로 설치하셔도 되고, Docker에 있는 Redis Image를 가져오셔서 host container의 port를 6379:6379로 설정해주시면 됩니다.
프로젝트 버전
- 개발 도구: IntelliJ Ultimate
- Spring Boot: 2.5.9
- Java 11
- h2 Database
- Thymeleaf
- Spring Security, Spring Web, Spring Data JPA, Lombok
- [추가 됨] Spring Session, Spring Data Redis
build.gradle
// ...
dependencies {
// ... 다른 라이브러리는 Form 인증을 참고해주세요
// spring session data redis 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
// ...
}
application.yml
spring:
profiles:
active: local
datasource:
url: jdbc:h2:tcp://localhost/~/h2/tistory
username: sa
password: sa
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 1000
session:
store-type: redis
timeout: 30m
application.yml에 redis의 포트나 다른 부가 정보를 설정해주지 않으면 local에 있는 redis 6379 포트(기본 포트)를 바라봅니다.
RedisConfig
@Configuration
@EnableRedisHttpSession
public class RedisConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory();
}
}
- 이 코드는 없어도 되지만, spring data redis에서 알아서 해주는 것인지, application.yml에서 설정을 해줘서 그런지는 확인해봐야 함.
SecurityConfig
@Configuration
@RequiredArgsConstructor
public class SecurityConfig<S extends Session> extends WebSecurityConfigurerAdapter {
// .. 다른 설정 코드는 Form 인증을 확인해주세요.
// Redis 설정을 위한 추가
private final FindByIndexNameSessionRepository<S> sessionRepository;
private final SecuritySessionExpiredStrategy securitySessionExpiredStrategy;
private final CustomUserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http // 1.
.sessionManagement(s -> s
.maximumSessions(1)
.sessionRegistry(sessionRegistry())
.maxSessionsPreventsLogin(false)
.expiredSessionStrategy(securitySessionExpiredStrategy))
// ..
}
// sessionRegistry 추가
@Bean
public SpringSessionBackedSessionRegistry<S> sessionRegistry() {
return new SpringSessionBackedSessionRegistry<>(this.sessionRepository);
}
}
- maximumSession[int] : 동일 세션 개수 제한 => 1개로 설정하여 중복 로그인 방지
- sessionRegistry[SessionRegistry] : 정보 조사
- maxSessionPreventsLogin[true/false]
- true : 먼저 사용 중인 사용자의 세션이 유지되며, 새로 접속 한 사람은 로그인이 되지 않음
- false : 먼저 사용 중인 사용자가 로그아웃 되며, 새로 접속 한 사람이 서비스를 이용
- expiredSessionStrategy[SessionInformationExpiredStrategy] : Session 만료됐을 때 가져갈 전략 설정
SecuritySessionExpiredStrategy
@Slf4j
@Component
public class SecuritySessionExpiredStrategy implements SessionInformationExpiredStrategy {
private static final String defaultUrl = "/login";
@Override
public void onExpiredSessionDetected(
SessionInformationExpiredEvent event) throws IOException, ServletException {
HttpServletRequest request = event.getRequest();
HttpServletResponse response = event.getResponse();
String ajaxHeader = request.getHeader("X-Requested-With");
boolean isAjax = "XMLHttpRequest".equals(ajaxHeader);
if (isAjax) { // session 이 만료된 상태에서 Ajax 요청 시 Response 보내기
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setCharacterEncoding("UTF-8");
response.getWriter().write("세션이 만료되었습니다. 다시 로그인 해주세요.");
} else { // session 이 만료된 상태에서 Web 요청 시 Login Page 로 이동
response.sendRedirect(defaultUrl);
}
}
}
위처럼 설정을 마친 후에 Spring Application을 실행하여 localhost:8080으로 접속하면 다음과 같은 로그인 화면(localhost:8080/login)으로 이동한 것을 확인할 수 있습니다.
이 화면에서 URL을 바꿔서 이동하여도 다시 /login으로 리다이렉트 되실 겁니다. 왜냐하면 SecurityConfig에서 모든 http 요청에 인증을 걸어두었기 때문입니다.
그러면 InitData Class에서 애플리케이션이 실행됨과 동시에 Member 엔티티에 넣어준 값으로 로그인을 해보도록 하겠습니다.
그러면 로그인하기 전에는 볼 수 없었던 Main 화면을 볼 수 있습니다.
설치한 Redis에 정상적으로 세션이 저장되었는지도 확인해보도록 하겠습니다. 저는 Redis를 Docker Container로 띄웠기 때문에 "docker exec -it redis /bin/bash"를 통해 Redis Container로 접속 후에, "redis-cli" 명령어를 쳐주면 redis-server에 접속하게 됩니다. 그 이후에 "keys *" 명령어를 쳐주면 밑에 사진과 같이 저장된 세션을 확인할 수 있습니다.
선명한 사진을 위해 4), 5)를 자르긴 했지만 4번에는 value가 null, 5번에는 FormAuthenticationProvider 클래스에서 작성한 SessionMember의 내용이 담겨 있습니다.
※ Spring Boot 2.5.6 버전에서는 4)번의 SessionRepository의 value가 null로 저장되는 case를 보지 못했는데, 2.5.9로 하니깐 로그인한 후에 null이 같이 저장되더라고용... 왜 그런지는 잘 모르겠습니다ㅠ
※ 세션을 모두 지우고 싶다면 flushall 명령어를 사용하면 됩니다. flushall 이후에 로그인 되어있는 브라우저에 가서 새로고침을 해보면 로그인 화면으로 이동하는 것을 확인할 수 있습니다.
※ maximumSessions의 값을 1로 설정, maxSessionsPreventsLogin을 false로 설정하였기 때문에 로그인 되어있는 브라우저 이외에(chrome을 사용했다면 시크릿 모드 혹은 Edge를 사용) 브라우저에서 같은 계정으로 로그인 했을 경우 기존에 로그인되어 있는 브라우저느 자동으로 로그아웃 되며 로그인 화면으로 이동합니다.
이번 포스팅에서는 Spring Application의 세션 정보를 외부에 있는 Redis에 저장하는 것을 알아보았습니다. ② 편에서는 이것을 토대로 Docker(CentOS 7) + Nginx + Spring Application 2개 + Redis를 사용하여 Session Clustering과 무중단 배포에 대해 정리하도록 하겠습니다.
'Spring > Spring Security' 카테고리의 다른 글
Spring Security - Nginx LB + 세션 클러스터링(Session Clustering) ③ (0) | 2022.02.21 |
---|---|
Spring Security - Nginx LB + 세션 클러스터링(Session Clustering) ② (0) | 2022.02.17 |
Spring Security - Form 인증 로그인 (0) | 2022.02.06 |