Spring 프로젝트에 redis 연동하기

김세준·2022년 8월 4일
0

project-issue

목록 보기
2/3

1. redis 방식을 채택한 이유

세션 관리를 하나의 서버에서만 한다면 추후 서버를 스케일 아웃할 때 세션 불일치 문제가 발생할 수 있다. 이를 해결하기 하려면 session clustering, sticky session, redis session 방식이 있는데 여기서는 session clustering 이나 sticky session 은 장점보단 단점이 극명해서 redis session 방식으로 진행하고자 한다.

아래 링크는 분산 서버일 때 세션 관리 방법들을 정리한 글이다.

2. redis 설치

실제 프로젝트를 NCP나 AWS를 이용해 배포한 상황이라면 redis cloud server를 사용하는 것이 가장 좋지만 중요한 건 비용이 든다. NCP의 크레딧도 거의 다 쓴 상황이라 로컬에 redis를 설치하는 방식을 선택했다.

로컬 redis 설치 방법: redis.io

윈도우의 WSL 에서 ping 명령어를 입력했을 때 pong 이 나오면 정상 설치가 된 것이다.

3. gradle 및 yml 설정

스프링 부트는 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 타입을 바꿔줘야 하므로 원래라면 설정 단계가 더 있다.

  1. springSessionRepositoryFilter 빈을 등록한다.
  2. 서블릿 컨테이너가 모든 요청에 대해 springSessionRepositoryFilter 를 사용해야 하므로 아래와 같은 코드가 필요하다.
/* AbstractHttpSessionApplicationInitializer는 2번 단계를 
쉽게 설정하기 위한 Spring Session이 지원하는 유틸리티 클래스이다.

또한 이 클래스는 Spring이 Config를 로드하도록 보장하는 메커니즘을 가지고 있다.*/
public class Initializer extends AbstractHttpSessionApplicationInitializer { 

	public Initializer() {
		super(Config.class); 
	}

}

하지만 스프링 부트는 이러한 두 단계도 자동으로 설정해주기 때문에 위와 같은 추가 코드 작성은 필요없다.

4. 실행

아래는 로그인 컨트롤러에서 세션을 설정하는 부분이다.

	@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. 해당 세션의 만료 키로 사용
  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 # 세션을 저장하기 위해 사용하는 키의 네임 스페이스

5. 테스트

로컬에 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

5.1 MockHttpSession 문제점

통합 테스트를 하다가 실패한 부분이 있는데 바로 아래 코드이다.

@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

6. 참고

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://jojoldu.tistory.com/297

https://stackoverflow.com/questions/33008798/spring-session-with-redis-how-to-mock-it-in-integration-tests

https://docs.spring.io/spring-boot/docs/2.0.x/reference/html/boot-features-session.html

0개의 댓글