세션 관리를 하나의 서버에서만 한다면 추후 서버를 스케일 아웃할 때 세션 불일치 문제가 발생할 수 있다. 이를 해결하기 하려면 session clustering, sticky session, redis session 방식이 있는데 여기서는 session clustering 이나 sticky session 은 장점보단 단점이 극명해서 redis session 방식으로 진행하고자 한다.
아래 링크는 분산 서버일 때 세션 관리 방법들을 정리한 글이다.
실제 프로젝트를 NCP나 AWS를 이용해 배포한 상황이라면 redis cloud server를 사용하는 것이 가장 좋지만 중요한 건 비용이 든다. NCP의 크레딧도 거의 다 쓴 상황이라 로컬에 redis를 설치하는 방식을 선택했다.
로컬 redis 설치 방법: redis.io
윈도우의 WSL 에서 ping 명령어를 입력했을 때 pong 이 나오면 정상 설치가 된 것이다.
스프링 부트는 spring-data-redis를 통해 Lettuce, Jedis라는 두 가지 오픈소스 라이브러리를 사용할 수 있다. Lettuce는 별도의 설정 없이 사용할 수 있지만 Jedis는 별도의 설정이 필요하다.
build.gradle:
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
application.yml:
session:
store-type: redis
스프링 부트의 auto-configuration 덕분에 yml 혹은 properties 파일에 위처럼 설정만 해주면 redis 설정이 끝난다. 즉 아래처럼 @EnableRedisHttpSession 이 있는 Config 클래스를 생성할 필요가 없다.
@EnableRedisHttpSession
public class Config {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory();
}
}
이후 yml 파일에 로컬 redis의 URL과 포트번호를 지정해준다.
redis:
host: localhost
password:
port: 6379
필터를 통해 session 타입을 바꿔줘야 하므로 원래라면 설정 단계가 더 있다.
/* AbstractHttpSessionApplicationInitializer는 2번 단계를
쉽게 설정하기 위한 Spring Session이 지원하는 유틸리티 클래스이다.
또한 이 클래스는 Spring이 Config를 로드하도록 보장하는 메커니즘을 가지고 있다.*/
public class Initializer extends AbstractHttpSessionApplicationInitializer {
public Initializer() {
super(Config.class);
}
}
하지만 스프링 부트는 이러한 두 단계도 자동으로 설정해주기 때문에 위와 같은 추가 코드 작성은 필요없다.
아래는 로그인 컨트롤러에서 세션을 설정하는 부분이다.
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@Validated @RequestBody LoginRequest loginForm,
@NotNull HttpSession session) {
User loginUser = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
session.setAttribute(SessionConst.LOGIN_SESSION, loginUser.getLoginId());
return new ResponseEntity<>(LoginResponse.from(loginUser), HttpStatus.OK);
}
redis를 사용했을 때와 안했을 때의 차이점은 아래와 같다. (응답 헤더를 보기 위해 포스트맨 프로그램을 사용했다.)
기존의 HttpSession 방식인 경우 JSESSIONID를 쿠키에 담아 클라이언트로 전송한다. WSL 에서 keys * 를 입력하면 아무런 값도 입력되지 않았다. 이렇게 되면 서버가 여러대일 때 세션 불일치 문제가 발생할 수 있다.
이후 Redis가 적용된 후를 보면 SESSION 이라는 이름의 새로운 쿠키가 생성된 것이 보인다.
keys * 를 입력하면 redis 에 세 가지 값이 저장되었다. 위에서 부터 string, set, hash 타입이다.
session은 map을 저장소로 사용한다. 따라서 hash 타입의 spring:session:sessions 키를 통해 값을 조회할 수 있다. hkeys "키값" 을 입력하면 sessionAttr:loginId 가 저장된 것을 볼 수 있다. loginId가 어디서 나온 거냐면 위 코드에서 session.setAttribute(SessionConst.LOGIN_SESSION, loginUser.getLoginId());
부분을 보면 SessionConst.LOGIN_SESSION 이 있는데 이것은 현재 내 프로젝트에서 public static final String LOGIN_SESSION = "loginId";
으로 할당해놓은 상태이다.
나머지 자동 저장되는 1번과 2번은 다음과 같다.
1) "spring:session:sessions:expires:69fae4e6-4baf-4436-8af9-6f3280692786"
2) "spring:session:expirations:1659611100000"
세션 만료 시간이나 세션 네임스페이스를 따로 설정해주지 않았으므로 디폴트 값이 들어갔는데 application.properties 또는 yml 에서 해당 부분 설정도 가능하다.
server.servlet.session.timeout= # 세션 만료 시간. 기간 suffix가 없으면 초가 사용된다.
spring.session.redis.flush-mode=on_save # 세션 플러시 모드
spring.session.redis.namespace=spring:session # 세션을 저장하기 위해 사용하는 키의 네임 스페이스
로컬에 redis 를 설치하는 방식은 치명적인 단점이 있다. 내 컴퓨터에만 redis 가 있다보니 협업하는 사람이 git clone 했을 때 redis가 없는채로 프로젝트를 실행하면 당연히 Fail to ApplicationContext 에러가 발생한다.
따라서 테스트할 때 embedded h2 DB를 사용하는 것처럼 redis도 embedded redis를 사용하는 것이 훨씬 좋다.
내장 redis 를 사용하기 위해 gradle에 아래 종속성을 추가해준다. 글 쓴 시점을 기준으로 가장 최신 버전은 0.7.3 인데 이 버전에는 @Slf4j 종속성이 포함되어 있어 코드를 실행시킬 때 중복 종속성에 관한 에러가 발생한다. 0.7.3 버전을 쓰려면 gradle의 export를 써서 로그에 관련된 종속성을 제거해야 한다.
implementation 'it.ozimov:embedded-redis:0.7.2'
윈도우 환경에서의 EmbeddemRedisConfig 클래스 :
아래는 yml 파일의 project=local
일 때만 실행되는 설정 클래스이다.
@Slf4j
@Profile("local")
@Configuration
public class EmbeddedRedisConfig {
@Value("${spring.redis.port}")
private int redisPort;
private RedisServer redisServer;
@PostConstruct
public void redisServer() throws IOException {
int port = isRedisRunning() ? findAvailablePort() : redisPort;
log.info("current redis port={}", port);
redisServer = new RedisServer(port);
redisServer.start();
}
@PreDestroy
public void stopRedis() {
if (redisServer != null) {
redisServer.stop();
}
}
/**
* 현재 PC/서버에서 사용가능한 포트 조회
*/
public int findAvailablePort() throws IOException {
for (int port = 10000; port <= 65535; port++) {
Process process = executeGrepProcessCommand(port);
if (!isRunning(process)) {
return port;
}
}
throw new IllegalArgumentException("Not Found Available port: 10000 ~ 65535");
}
/**
* Embedded Redis가 현재 실행중인지 확인
*/
private boolean isRedisRunning() throws IOException {
return isRunning(executeGrepProcessCommand(redisPort));
}
/**
* 해당 port를 사용중인 프로세스 확인하는 sh 실행(윈도우 환경일 때)
*/
private Process executeGrepProcessCommand(int port) throws IOException {
String command = String.format("netstat -nao | find \"LISTEN\" | find \"%d\"", port);
String[] shell = {"cmd.exe", "/y", "/c", command};
return Runtime.getRuntime().exec(shell);
}
/**
* 해당 Process가 현재 실행중인지 확인
*/
private boolean isRunning(Process process) {
String line;
StringBuilder pidInfo = new StringBuilder();
try (BufferedReader input = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
while ((line = input.readLine()) != null) {
pidInfo.append(line);
}
} catch (Exception e) {
log.error(e.getMessage());
}
return !StringUtils.isEmpty(pidInfo.toString());
}
}
application.yml:
spring:
redis:
host: localhost
port: 6379
password:
profiles:
active: local
통합 테스트를 하다가 실패한 부분이 있는데 바로 아래 코드이다.
@SpringBootTest
@AutoConfigureMockMvc
@SpringJUnitWebConfig
@Transactional
class UserControllerTest {
@Autowired
MockMvc mvc;
@Autowired
UserService userService;
@Autowired
LoginService loginService;
@Autowired
MockHttpSession session;
...
@Test
@DisplayName("회원 아이디로 회원 정보 찾기")
void findUserId() throws Exception {
//given
UserSaveRequest userSaveRequest = createUserInfo();
User user = userService.addUser(userSaveRequest);
LoginRequest loginRequest = new LoginRequest(user.getLoginId(), "testPassword");
User loginUser = loginService.login(loginRequest, session);
//when
mvc.perform(get("/users/" + loginUser.getLoginId())
.session(session))
//then
.andDo(print())
.andExpect(jsonPath("$.loginId").value(loginUser.getLoginId()))
.andExpect(jsonPath("$.name").value(loginUser.getName()))
.andExpect(jsonPath("$.role").value(String.valueOf(loginUser.getRole())))
.andExpect(jsonPath("$.phoneNumber").value(loginUser.getPhoneNumber()))
.andExpect(jsonPath("$.email").value(loginUser.getEmail()));
}
...
}
사용자가 로그인을 하면 해당 세션 정보를 가지고 회원 정보를 조회하는 기능을 테스트하는 코드이다. 이 때 세션이 필요한 테스트를 할 때 주로 사용하는 MockHttpSession 값을 넘겨주는데, 스프링 부트의 redis 자동 설정때문에 MockHttpSession 값을 제대로 비교하지 못하는 현상이 발생했다.
stackoverflow 에도 해당 문제로 올라온 질문이 몇 개 있긴 했는데 단순히 테스트를 하기 위해 설정해야하는 코드만 많아지고 내 경우엔 잘 되지도 않았다.
그래서 테스트 환경에서만 사용하는 application.yml 에 아래 코드를 추가해줬다.
말 그대로 spring session을 테스트할 때만 비활성화 시키는 것이다. 이렇게 하면 실제 애플리케이션을 수행할 때는 내장 redis를 통해 세션 값을 가져올 수 있고 테스트 할 때는 MockHttpSession 을 통해 테스트 코드를 간단하게 작성할 수 있다.
spring:
redis:
host: localhost
port: 6379
password:
session:
store-type: none
https://docs.spring.io/spring-session/reference/guides/boot-redis.html#boot-sample
https://docs.spring.io/spring-session/reference/guides/java-redis.html
https://redis.io/docs/getting-started/installation/install-redis-on-windows/)
https://docs.spring.io/spring-boot/docs/2.0.x/reference/html/boot-features-session.html