개발 일기

Redis로 다중 서버 환경에서 로그인 정보 불일치 문제 해결하기 본문

Tech/배워서 남주기

Redis로 다중 서버 환경에서 로그인 정보 불일치 문제 해결하기

flow123 2022. 10. 8. 16:51

상황

이커머스 서비스를 구현하면서, 서버 메모리에 세션을 저장하는 방식으로 로그인을 구현했습니다. 

추후 서버가 증설될 경우, 세션이 여러 서버에 걸쳐 공유되어야 하는데, 기존 구조는 서버마다 저장소를 독립적으로 갖는다는 한계가 있었습니다. 이전 포스팅 에서도 살펴 보았듯, 세션의 특성 상 서버 scale-out 시에는 (서버의 대수를 늘리는 것) 별도 처리를 해줘야 하기 때문입니다.  

 

실제로 유저가 몰려서 서버를 증설하는 상황은 아니었습니다. 

하지만, 유저가 늘어나고 이에 따라 scale-out 하는 것이 매우 흔한 상황임을 가정해볼 때, 다중 서버 구성에 대비하는 것이 중요하다고 생각했습니다. 깊이 학습해볼 필요성을 느껴서 Redis로 로그인 중앙화를 구현하였습니다. 

 

이번 포스팅에서는

 

1. 해당 문제를 해결하기 위해 어떤 기술을 고려했는지, 소개해보고자 합니다. 

2. 세션 로그인 방식이 어떻게 이루어지는 지 설명 후,  Redis 구현 방식인 RedisRepository와 RedisTemplate을 모두 사용해서 구현해보겠습니다. 

 


트레이드 오프 비교에 기반한 기술 선택 과정

A. 별도의 세션 저장소 구현

 

서버를 늘릴 경우 (Scale-Out) 서버 끼리 세션을 공유하려면, 저장소 간 동기화가 필요했습니다.

스티키 세션, 세션 클러스터링, 세션 저장소를 고려하였습니다. 가용성, 성능, 트래픽 분산에서의 효율성을 고려하여, 세션 저장소를 선택했습니다.

 

스티키 세션과 세션 클러스터링을  배제한 이유는 다음과 같습니다. 

 

(1) 스티키 세션은 사용자가 자신의 세션이 있는 서버만 이용할 수 있습니다.

따라서 특정 서버에 트래픽이 집중되고, 작업이 없는 서버가 활용되지 않을 가능성이 있습니다.

더불어 하나의 서버에 장애가 발생 시, 해당 서버를 사용한 유저는 세션 정보를 잃습니다. 따라서 가용성과 트래픽 분산에 적합하지 않았습니다.

 

(2) 세션 클러스터링을 primary - secondary 복제 방식으로 구현할 경우,

 

요청이 두 서버가 아닌 프록시 서버로 가게 되면 다시 primary 혹은 secondary로 라우팅이 필요합니다.

가용성과 트래픽 분산은 해결할 수 있었지만, 성능 한계의 문제가 있었습니다. 그래서 위의 문제들을 해결할 수 있는 세션 저장소를 별도로 구현했습니다.

 

B. 인메모리 DB/ Redis 선택 이유

 

(1) 고속 읽기/쓰기에 최적화 된 인 메모리 DB

 

로그인은 읽기/쓰기 작업이 빈번합니다. 따라서 상대적으로 I/O속도가 느린 디스크 기반의 데이터 베이스는 적합하지 않았습니다.

인 메모리 DB는 전원이 끊기면 데이터가 유실 될 위험이 있지만, 로그인 정보는 만료 기간이 정해져 있기 때문에 소멸되더라도 피해가 적습니다.

 

(2) Redis 선택 - 다양한 데이터 구조/이용의 편리성

 

Redis 와 Memcached 중 Redis 를 선택했습니다. Replication 을 통한 failover를 구현할 경우, Memcached에서 replication 을 하려면 별도의 툴을 써야 합니다. 반면, Redis는 primary 저장소를 복제하는 클러스터 기능을 제공하기 때문에 보다 편리하게 이용할 수 있었습니다.

추후 Redis를 캐시 솔루션으로도 활용할 계획이었기 때문에, 다양한 데이터 구조를 활용할 수 있다는 장점도 고려하였습니다(Memcached는 String 형태의 key-value 쌍만 제공합니다)

 


적용 

목적 

 

-sessionId로 조회 시, LoginUser 객체를 반환합니다 

-sessionId에는 LoginUser 객체의 role, userId 등이 저장되어 있기 때문에, 추후 셀러가 자신의 상품 정보를 수정할 때 

인증/인가 처리를 하는 데도 사용됩니다. 

 

참고

  • 추후 기능 개발 시, @Transactional을 사용할 예정이어서, 저는 RedisTemplate을 사용하기로 결정했습니다. 하지만 RedisRepository도 어떻게 구현되는지 간단히 살펴보고 싶어서, 두 방식 모두 구현해보겠습니다.
  • 로그인 세션 객체는 user 도메인 중에서도 email, role 등 로그인/추후 인가에 필요한 정보만 저장할 것입니다. RedisRepository는 RedisHashLoginUser라는 객체를, RedisTemplate은 LoginUser라는 객체를 저장합니다.
  • 두 구현 방식 외의 코드는 변경점을 최대한 줄이고자, 두 방식의 구현체가  SessionManager 라는 인터페이스를 implements 합니다.

 

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

참고로, HttpSession을 사용했다면 아래 의존성도 추가해야 합니다.

 'org.springframework.session:spring-session-data

서블릿이 제공하는 HttpSession 을 사용하면, 제공하는 기능도 많고 레디스도 보다 편리하게 사용할 수 있습니다.

저는 Session이 어떻게 동작하는지 보다 이해하고, 개발 의도에 맞게 구현하는 연습을 하고 싶어서 직접 Session을 만들었습니다. 


로그인 과정 

 

RedisSessionTemplate 을 사용한 코드로, 세션 로그인 방식을 간단히 설명드리겠습니다.  

 

1. 유저가 로그인할 때,  로그인 세션 데이터를(LoginUser) 생성합니다.  이는 서버 측 세션 저장소 (ex. Redis) 에 저장됩니다.

- 세션ID는 쿠키에 저장될 것이므로, 추정 불가능한 랜덤 값으로 생성합니다. 

- java.util.UUID 를 사용하면, 고유한 128bit 식별자 생성이 가능합니다

- cookie.setPath("/") 설정 시, 웹서버의 특정 URL에 대해서만 쿠키를 전송 할 수 있습니다. 모든 URL에 전송 가능하도록 합니다. 

@Override
public void createSession(LoginUser loginUser, HttpServletResponse response) {

  String sessionId = UUID.randomUUID().toString();
  loginUser.setSessionId(sessionId); // sessionTemplate만 사용한다면 굳이 필요하지 않습니다
  sessionRedisTemplate.save(loginUser);
  log.info("redisSessionManager 동작 중 - @ID가 자동으로 생성한 ID 확인:" + loginUser.getId());
  Cookie cookie = new Cookie(SESSION_COOKIE_NAME, loginUser.getSessionId());
  cookie.setMaxAge(2 * 60 * 60); //2시간.
  cookie.setPath("/");
  response.addCookie(cookie);
}

*참고로, sessionId는 sessionTemplate 방식만 사용한다면 필요 없는 값이기 때문에 삭제하셔도 좋습니다. 

실습 시에는, 두 방식을 모두 시도해보는 과정에서 코드 변경점을 줄이고 싶어서, RedisHashLoginUser(RedisRepository 방식)와 LoginUser (RedisTemplate 방식)가 같은 필드로 구성되도록 설정했습니다. 

 

이때 (Key, Value) 형식으로 저장됩니다. Key에 sessionId를 설정하고, 값에는 User가 입력됩니다. sessionId로 

유저 정보를 검색합니다. 

출처: 김영한님 mvc 강의 2편

 

3. 이후 유저가 로그인 정보가 필요한 API 를 수행할 때, 세션 ID를 쿠키에서 가져옵니다. 

 

@Override
public LoginUser getSession(HttpServletRequest request) {
  Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
  if (sessionCookie == null) {
    return null;
  }
  log.info("sessionCookie 값 입니다" + sessionCookie.getValue());
  log.info(
      "redisTemplate에서 찾아온 값" + sessionRedisTemplate.findBySessionId(sessionCookie.getValue()));

  return sessionRedisTemplate.findBySessionId(sessionCookie.getValue());
}

 

쿠키 저장소에 이미 mySessionId = "sessionId 값" 형태로 데이터가 존재합니다. 

따라서, 쿠키에 저장된 sessionId로, 서버에서 User 객체를 가져올 수 있습니다. 

이때 argumentResolver를 사용하면 로그인이 필요한 Controller 호출 직전에 호출함으로써, 

세션 객체를 가져올 수 있습니다. 로그인이라는 공통 작업을 보다 편리하게 처리할 수 있습니다. 

이에 대해서는 해당 포스팅에서 다루고 있습니다. 

 


Redis 적용하기 

 

Redis와 Spring을 쓸 때, IoC 컨테이너를 통해서 저장소에 연결합니다. .

이때, 1) 적합한  Connector를 설정해서, connector를 클래스에 주입해야 합니다.  Redis 와 연결하여 connections 객체를 가져올 수 있는 인터페이스들이 필요합니다. spring-boot-starter-data-redis' 내부 라이브러리 하의  org.springframework.data.spring-data-redis를 살펴보시면, connection package 가 있습니다.

참고: https://docs.spring.io/spring-data/data-redis/docs/current/reference/html/#reference

 

아래 Config 파일을 통해 빈을 주입해줍시다. 

 

Spring Boot 2.0 부터 Jedis 가 기본 클라이언트에서 deprecated 되고 Lettuce 가 탑재되었습니다. 

 

@Configuration
@EnableRedisRepositories
public class RedisConfig {

  @Value("${spring.redis.host}")
  private String host;
  @Value("${spring.redis.port}")
  private int port;

  @Bean
  public RedisConnectionFactory redisConnectionFactory() {
    return new LettuceConnectionFactory(host, port);
  }

  @Bean
  public SessionManager sessionManager(SessionRedisRepository redisRepository) {
    return new RedisSessionManager(redisRepository);
  }

//  @Bean
//  public SessionManager sessionManager() {
//    return new InMemorySessionManager();
//  }


//  @Bean
//  public SessionManager sessionManager(SessionRedisTemplate sessionRedisTemplate) {
//    return new RedisSessionTemplateManager(sessionRedisTemplate);
//  }

  @Bean
  public SessionRedisTemplate sessionRedisTemplate(RedisObjectMapper redisObjectMapper) {
    return new SessionRedisTemplate(redisObjectMapper);
  }

  @Bean
  public RedisObjectMapper redisObjectMapper(RedisTemplate redisTemplate) {
    return new RedisObjectMapper(redisTemplate);
  }

  @Bean
  public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(connectionFactory);
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new StringRedisSerializer());
    return redisTemplate;
  }

}

 

1. RedisSessionTemplate 구현 방식

 

*CRUD Repository와 SessionTemplate의 차이

 

(1) SessionTemplate 방식에서는 Key/Value Serializer를 별도로 설정해야 합니다. 

 

Performs automatic serialization/deserialization between the given objects and the underlying binary data in the Redis store. By default, it uses Java serialization for its objects (through JdkSerializationRedisSerializer ). - 공식 문서 

위의 공식문서에서도 볼 수 있듯, org.springframework.data.redis.core 라이브러리에서 제공되는 RedisTemplate 은  Jdk 직렬화 방식을 사용합니다. 동작에는 문제가 없지만 redis-cli를 통해 직접 데이터를 보려고 할 때 알아볼수 없는 형태로 출력되기 때문에 Serializer를 변경합니다.

 

(2) RedisRepository는 트랜젝션을  지원하지 않습니다. 만약 트랜잭션을 적용하고 싶다면 RedisTemplate 을 사용해야 합니다.

 

(3) RedisRepository는 key는 저장되는 객체에서 @RedisHash 어노테이션의 value + @Id가 붙어있는 맴버변수 를 사용합니다. 

Redis DB에서 조회할 때, 고유값 확인이 필요해서 @Id 를 붙이면 RedisRepository가 Id를 생성줍니다. 

sessionId 를 기준으로 검색하기위해, 해당 필드에 @Indexed 도 붙여줍니다. 

 

반면 RedisTemplate은 id 자동 생성이 되지 않습니다. 고유값인 sessionId 키, value 형태로 넣어주기 때문에  굳이 id가 필요하지 않습니다. sessionId가 Key이기 때문에, @indexed 도 필요치 않습니다. 

 

RedisSessionTemplateManager

 

전체코드는 아래와 같습니다. 

*SessionRedisTemplate은 레디스 저장소 클래스 입니다.  레디스 구현에 대한 포스트에서 자세히 다루겠습니다. 

@Slf4j
public class RedisSessionTemplateManager implements SessionManager {

  public static final String SESSION_COOKIE_NAME = "MySessionId";
  private SessionRedisTemplate sessionRedisTemplate;

  public RedisSessionTemplateManager(
      SessionRedisTemplate sessionRedisTemplate) {
    this.sessionRedisTemplate = sessionRedisTemplate;
  }

  @Override
  public void createSession(LoginUser loginUser, HttpServletResponse response) {

    String sessionId = UUID.randomUUID().toString();
    loginUser.setSessionId(sessionId);
    sessionRedisTemplate.save(loginUser);
    log.info("redisSessionManager 동작 중 - @ID가 자동으로 생성한 ID 확인:" + loginUser.getId());
    Cookie cookie = new Cookie(SESSION_COOKIE_NAME, loginUser.getSessionId());
    cookie.setMaxAge(2 * 60 * 60); //2시간.
    cookie.setPath("/");
    response.addCookie(cookie);
  }

  private Cookie findCookie(HttpServletRequest request, String cookieName) {
    final Cookie[] cookies = request.getCookies();
    if (cookies == null) {
      return null;
    }
    return Arrays.stream(cookies)
        .filter(c -> (c.getName().equals(cookieName)) && (c.getMaxAge() != 0))
        .findFirst()
        .get();
  }

  @Override
  public LoginUser getSession(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
      return null;
    }
    log.info("sessionCookie 값 입니다" + sessionCookie.getValue());
    log.info("redisTemplate에서 찾아온 값" + sessionRedisTemplate.findBySessionId(sessionCookie.getValue()));

    return sessionRedisTemplate.findBySessionId(sessionCookie.getValue());
  }

  @Override
  public void expireSession(HttpServletResponse response) {
    Cookie cookie = new Cookie(SESSION_COOKIE_NAME, null);
    cookie.setMaxAge(0);
    cookie.setPath("/");
    response.addCookie(cookie);
    log.info("세션이 만료되었습니다");
  }

 

2. RedisRepository 구현 방식 

 

RedisHashLoginUser는 RedisRepository에 저장되는 객체입니다. 

 

@Getter
@RedisHash(value = "LoginUser", timeToLive = 120)
public class RedisHashLoginUser {

  @Id
  private String id;
  @Indexed
  private String sessionId;
  private Long userId;
  private String email;
  private Role role;
  private LocalDateTime createdAt;


  public RedisHashLoginUser(String sessionId, Long userId, String email,
      Role role, LocalDateTime createdAt) {
    this.sessionId = sessionId;
    this.userId = userId;
    this.email = email;
    this.role = role;
    this.createdAt = createdAt;
  }

  public static RedisHashLoginUser of(String sessionId, Long userId, String email, Role role,
      LocalDateTime createdAt) {
    return new RedisHashLoginUser(sessionId, userId, email, role, createdAt);
  }
}

RedisRepository는 CRUD Repository를 상속 받습니다. 

public interface SessionRedisRepository extends CrudRepository<RedisHashLoginUser, String> {

  @Override
  RedisHashLoginUser save(RedisHashLoginUser user);

  @Override
  List<RedisHashLoginUser> findAll();

  RedisHashLoginUser findBySessionId(String value);
}

RedisSessionManager

@Slf4j
public class RedisSessionManager implements SessionManager {

  public static final String SESSION_COOKIE_NAME = "MySessionId";
  private SessionRedisRepository redisRepository;

  public RedisSessionManager(
      SessionRedisRepository redisRepository) {
    this.redisRepository = redisRepository;
  }

  public void createSession(LoginUser loginUser, HttpServletResponse response) {

    String sessionId = UUID.randomUUID().toString();
    loginUser.setSessionId(sessionId);
    RedisHashLoginUser hashLoginUser = RedisHashLoginUser.of(loginUser.getSessionId(),
        loginUser.getUserId(), loginUser.getEmail(), loginUser.getRole(), loginUser.getCreatedAt());
    redisRepository.save(hashLoginUser);
    log.info("@ID가 자동으로 생성한 ID 확인:" + hashLoginUser.getId());

    Cookie cookie = new Cookie(SESSION_COOKIE_NAME, loginUser.getSessionId());
    cookie.setMaxAge(2 * 60 * 60); //2시간.
    cookie.setPath("/");
    response.addCookie(cookie);
    log.info("redis session manager 동작 중입니다");
  }

  /*
  (1) 쿠키가 아예 없다면 null 처리를 하고,
  (2) request 의 쿠키가 Session 저장소에 저장되었는지 조회하고 반환.
  (Session Id를 키로 일치하는 지 확인 가능)
  */
  private Cookie findCookie(HttpServletRequest request, String cookieName) {
    final Cookie[] cookies = request.getCookies();
    if (cookies == null) {
      return null;
    }
    return Arrays.stream(cookies)
        .filter(c -> (c.getName().equals(cookieName)) && (c.getMaxAge() != 0))
        .findFirst()
        .get();
  }

  /*
  세션 조회.
  (1) findCookie의 조회 결과가 없다면 null 처리를 하고,
  (2) 일치할 경우 cookie 의 값과(sessionId) 일치하는 세션을 꺼내온다.
  (3) Controller 에서 Redis 도입 후에도 LoginUser의 형태로 쓸 수 있도록, 꺼내온 RedisHashLoginUser의 타입을 바꿔준다.
  */
  public LoginUser getSession(HttpServletRequest request) {
    Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
    if (sessionCookie == null) {
      return null;
    }
    log.info("sessionCookie 값 입니다" + sessionCookie.getValue());

    RedisHashLoginUser user = redisRepository.findBySessionId(sessionCookie.getValue());
    if (user == null) {
      log.info("RedisSessionManager - 유저 정보가 없어서 반환이 불가합니다");
      throw new UnauthorizedRequestException();
    }

    LoginUser loginUser = LoginUser.of(user.getSessionId(), user.getUserId(), user.getEmail(),
        user.getRole(), user.getCreatedAt());

    return loginUser;
  }

  /*
  세션 만료
  클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
  Session을 꺼내올 때, 쿠키를 key로 가져오기 때문에, 세션을 delete 할 필요 없이 (어차피 TTL 이 정해져 있다)
  쿠키를 지우자 (MaxAge를 0으로 해서 더하면 된다)
   */

  public void expireSession(HttpServletResponse response) {
    Cookie cookie = new Cookie(SESSION_COOKIE_NAME, null);
    cookie.setMaxAge(0);
    cookie.setPath("/");
    response.addCookie(cookie);
    log.info("세션이 만료되었습니다");
  }
}

해결

Redis 를 적용하여 세션 저장소를 구현함으로써 가용성을 확보할 수 있었습니다.

이로써, 애플리케이션 서버가 장애가 나도, 별도의 세션 저장소가 존재하기 때문에 인증/인가 기능을 지장 없이 운영하게 되었습니다.

Serializer 등 자세한 구현 방식은 별도 포스팅에서 다루도록 하겠습니다.  코드는 아래 참고 부탁드립니다. 

 

https://github.com/f-lab-edu/FreshCart/tree/main/src/main/java/com/example/freshcart/redis       


참고 자료 

 

참고:  스프링 MVC 2편 김영한님 강의 

https://do-study.tistory.com/111

https://www.baeldung.com/memcached-vs-redis

https://hyuntaeknote.tistory.com/8

https://hyuntaeknote.tistory.com/6

 

다중 서버 환경에서 Session은 어떻게 공유하고 관리할까? - 2편(Sticky Session, Session Clustering, Session St

개요 지난 시간에 서버의 성능을 업그레이드하는 두 가지 방법을 알아보았습니다. 그중 스케일 아웃을 하면 여러 대의 서버가 각각 세션 저장소를 독립적으로 갖게 되므로 정합성 이슈가 발생

hyuntaeknote.tistory.com

 

Comments