TLDR: 세션 정보를 저장하는 레디스에는 다른 데이터가 섞여있지 않아야 합니다.


connect-redis는 Express 앱에서 Redis 스토리지를 통해 세션을 효과적으로 관리해주는 미들웨어입니다. 본 글에서는 제가 해당 모듈을 사용하면서 겪었던 TypeError: Cannot read property 'toString' of null 에러 현상과 함께 이를 해결하는 방법을 알아보고자 합니다. 아래는 당시 모듈 버전 정보입니다.

  • express-session: 1.14.2
  • connect-redis: 3.1.0
  • redis: 2.6.3

현상

세션 정보와 몇 가지 추가적인 데이터가 섞여서 레디스에 저장되어있다고 가정하겠습니다. 이때, 존재하는 모든 세션 정보를 읽어오기 위해서는 req.sessionStore.all 이라는 세션 스토어 API를 사용해야 합니다. 코드는 아래와 같습니다.

async getSessions(req, res) {
  try {
    const sessions = await new Promise((resolve, reject) => {
      // all API를 사용해서 모든 세션 정보 읽어오기
      return req.sessionStore.all((err, sessions) => {
        if (err) reject(err);
        resolve(sessions);
      })
    })
    // ...
  } catch (err) {
    // ...
  }
}

위 코드를 수행했을 때, 정상적인 케이스라면 sessions 변수에 레디스에 존재하는 모든 세션 정보가 할당되어야 합니다.
하지만 저의 경우 TypeError: Cannot read property 'toString' of null 라는 메세지와 함께 에러가 발생했습니다. API 내부적인 버그라고 추측했지만, 이와 관련된 내용을 구글링 해봐도 원인을 찾기가 쉽지 않았습니다. 결국 connect-redis의 내부 구현을 확인하여 문제점을 파악하게 되었습니다.

원인 및 해결 방안

원인은 레디스에 세션 정보가 아닌 다른 타입 혹은 포맷의 데이터가 함께 섞여있을 경우, 이를 세션 정보와 구분하지 못하면서 발생하는 현상이었습니다.

KEY TYPE VALUE
my-app-1:1c0Y8QWkpEM-_EHVYXp2dKHgYGWfdnG7 STR '{"cookie":{"originalMaxAge":3600000,"expires":"2021-10-09T10:46:18.338Z","secure":false,"httpOnly":true,"path":"/"}}'
my-app-1:wW6KPKGGQt7D8LsQhisvKLi6LK5v8nU- STR '{"cookie":{"originalMaxAge":3600000,"expires":"2021-10-09T10:47:18.338Z","secure":false,"httpOnly":true,"path":"/"}}'
my-app-2:7Py1Qq9PheeAQqJwAw5Wmxi25HfvZkfo STR '{"cookie":{"originalMaxAge":3600000,"expires":"2021-10-09T10:48:18.338Z","secure":false,"httpOnly":true,"path":"/"}}'
my-app-1:1000:query-history LIST first row of list
second row of list
third row of list

위의 예시에서 마지막 줄을 제외한 3개의 row는 세션 정보입니다. 포맷은 아래와 같습니다.

  • key: prefix + sid(세션 아이디)
  • type: STRING
  • value: JSON 포맷의 문자열

이 상태에서 sessionStore.all 함수를 사용할 경우, connect-redis는 정규표현식으로 prefix* 에 해당하는 모든 key 값을 가져오며 타입 상관 없이 key에 해당하는 값을 MGET으로 읽어온 뒤 반환된 값에 대해서 toString으로 형변환을 시도합니다. 그리고 이 과정에서 LIST 타입으로 존재하던 값(예시에서 네 번째 줄)은 null로써 반환되며, 이에 대해 toString을 수행하면서 결과적으로 에러를 만들어낸 것입니다. (LIST 타입의 값을 읽어오는 오퍼레이션은 GET이 아니라 LRANGE입니다.)
이러한 상황에 대해 해결 방법은 아래와 같습니다.

  1. 별도의 값을 저장해야 할 경우 세션 저장소와 db를 분리한다. (config 활용)
  2. 세션 prefix와 별도 저장 시 사용할 prefix를 구분한다.
  3. 버전을 4.0.4 이상으로 업그레이드한다. (https://github.com/tj/connect-redis/pull/299에서 null 예외 처리에 대한 패치가 추가됨)

이 중 버전 업그레이드는 해당 현상의 일부분을 해소해줄 수 있지만, 근본적인 해결 방법이 아닙니다. 여전히 코드 내에 존재하는 로직은 정규표현식에 따라 prefix*를 통해 세션 목록을 읽어오고 있으며, 이것이 개발자가 원하는 세션 정보만을 반환해주리라고는 보장하지 않습니다. 따라서, 세션이 저장되는 레디스 저장소에는 세션이 아닌 다른 값이 저장되지 않도록 하는 것이 좋습니다.
저는 아래와 같은 코드를 통해 레디스 저장소를 db 단위로 분리했습니다.

// ...
const sessionStore = new RedisStore(Object.assign({
  socket_keepalive: true,
  no_ready_check: true,
  db: config.session.db, // 세션 정보만을 저장하기 위한 db 번호를 명시적으로 설정
}, config.redis))

app.use(session(Object.assign({
  store: sessionStore
}, config.session)))

코드 분석

connect-redis의 코드를 조금 더 세부적으로 확인해보겠습니다. (v3.1.0 기준) all 함수의 흐름은 아래와 같습니다.

  1. SCAN을 통해 모든 key를 찾는다 (비동기)
  2. 가져온 모든 key 목록에 대해 MGET을 수행해 결과값을 읽어온다
  3. 결과 목록을 순회하면서 파싱을 수행한다

SCAN - 모든 key 찾기

function allKeys (store, cb) {
  var keysObj = {};
  var pattern = store.prefix + '*'; // 패턴을 보면 prefix*를 사용하는 것을 볼 수 있다.
  var scanCount = store.scanCount;
  debug('SCAN "%s"', pattern);
  (function nextBatch (cursorId) {
    // SCAN 오퍼레이션은 패턴에 매칭되는 모든 key 목록을 반환한다.
    // 이 때 SCAN은 비동기적으로 수행된다.
    store.client.scan(cursorId, 'match', pattern, 'count', scanCount, function (err, result) {
      if (err) return cb(err);
      var nextCursorId = result[0];
      var keys = result[1];
      debug('SCAN complete (next cursor = "%s")', nextCursorId);
      keys.forEach(function (key) {
        keysObj[key] = 1;
      });
      if (nextCursorId != 0) {
        return nextBatch(nextCursorId);
      }
      return cb(null, Object.keys(keysObj));
    });
  })(0);
}

MGET - key에 대한 결과값 반환하기

RedisStore.prototype.all = function (fn) {
  var store = this;
  var prefixLength = store.prefix.length;
  if (!fn) fn = noop;
  // SCAN을 통해 패턴 매칭된 모든 키를 가져온다
  allKeys(store, function (err, keys) {
    if (err) return fn(err);
    if (keys.length === 0) return fn(null,[]);
    // MGET을 통해 모든 키에 대한 반환값을 가져온다.
    // 여기서 제공된 키가 실제로 존재하지 않다면 null을 반환한다.
    // GET을 지원하지 않는 자료형에 대해서도 null을 반환한다.
    store.client.mget(keys, function (err, sessions) {
      if (err) return fn(err);
      var result;
      try {
        result = sessions.map(function (data, index) {
          data = data.toString();
          data = store.serializer.parse(data);
          data.id = keys[index].substr(prefixLength);
          return data;
        });
      } catch (e) {
        err = e;
      }
      return fn(err, result);
    });
  });
};