죽은 세션까지 세면서 자리가 찼다고 막았다
동시 접속을 셋으로 제한하고 나서야 목록에 든 개수와 실제로 살아 있는 세션이 다르다는 것을 알았다. 목록을 들고 세는 대신 저장소의 만료가 세게 바꾼 과정을 적었다.
가맹점 화면과 운영 도구가 같은 인증을 썼고, 계정 하나로 몇 대에서든 동시에 붙을 수 있었다. 동시 접속을 세 개까지만 허용하기로 하고 세션 목록을 Redis에 뒀다. 목록에 든 개수가 셋이면 새 로그인을 막는 규칙이었다.
규칙은 금방 들어갔다. 정작 틀린 것은 규칙이 아니라 세는 대상이었다. 목록에 든 것과 실제로 살아 있는 것이 같지 않았고, 자리가 찼다고 막힌 계정이 열어 둔 자리는 셋보다 적을 수 있었다.
막기 시작하니 개수가 틀린 게 보였다
처음 넣은 것은 막는 규칙이 아니었다. 세 개를 넘으면 가장 오래된 세션을 지우고 새 세션을 넣었다. 로그인은 언제나 성공했고, 개수가 맞는지 확인할 일이 없었다.
밀어낼 자리를 서버가 고르는 게 이상해서 이 부분부터 바꿨다. 자리가 찼으면 실패로 돌려주고, 응답에 살아 있는 세션 목록을 실어 어느 자리를 끊을지 고르게 했다. 막기 시작하자 목록이 이상하다는 게 바로 드러났다. 세 자리 중에 접속 정보가 비어 있는 것이 섞여 있었다.
목록과 정보가 다른 키에 있었다
세션은 두 곳에 나뉘어 있었다. 계정이 들고 있는 세션 ID 집합과, 세션 하나의 접속 정보다.
userSessions:{계정번호} 그 계정의 세션 ID 집합
sessionInfo:{세션ID} 접속 IP, 브라우저, 마지막 접근 시각
둘 다 만료가 걸려 있었지만 키가 다르니 만료도 따로 왔다. 접속 정보가 먼저 사라져도 ID는 집합에 남는다. 개수를 세는 코드는 집합의 크기만 봤다.
쓰는 사람이 있으면 목록은 사라지지 않았다
집합에도 만료가 있으니 언젠가는 통째로 없어진다. 문제는 그 만료가 계속 미뤄진다는 데 있었다. 세션을 추가하거나 접근할 때마다 집합의 만료를 다시 채웠기 때문이다.
한 계정이 자리 하나만 계속 쓰고 있어도 집합은 매번 갱신된다. 같은 집합에 든 나머지 둘은 접속 정보가 만료돼 죽었는데, ID는 갱신되는 집합 안에서 계속 살아남는다. 오래 쓰는 계정일수록 죽은 자리가 오래 남는 구조였다.
flowchart TD A["로그인"] --> B["목록에 세션 ID 추가"] A --> C["접속 정보 키 생성"] B --> D["다른 자리를 쓸 때마다 목록 만료 갱신"] C --> E["하루 뒤 접속 정보 키 만료"] E --> F["세션 ID는 목록에 그대로 남음"] D --> F F --> G["개수에 포함되어 자리를 물고 있음"]
가장 오래된 것을 끊던 규칙이 이걸 가리고 있었다
되짚어 보면 앞의 규칙이 문제를 덮고 있었다. 넘치면 가장 오래된 것을 지웠고, 죽은 세션은 대개 오래된 쪽이라 그냥 밀려났다. 개수가 틀려도 결과는 그럭저럭 맞아 보였다.
막는 쪽으로 바꾸자 덮개가 사라졌다. 사용자에게 목록을 보여 주고 고르라고 하는 순간, 그 목록이 정확해야 할 이유가 생겼다.
역할이 다른데 목록이 같았다
키를 들여다보다 다른 것을 하나 더 봤다. 집합 키에는 계정 번호만 들어 있었다.
운영 도구 계정과 가맹점 계정은 서로 다른 곳에서 발급된 번호를 쓴다. 번호가 겹치면 역할이 다른 두 계정이 같은 목록을 보게 된다. 겹친 기록을 찾아낸 것은 아니고, 겹칠 수 있는 구조라는 게 문제였다. 키에 역할을 넣어 공간을 갈랐다.
userSessions:{역할}:{계정번호}
sessionInfo:{역할}:{세션ID}
토큰도 같은 방식으로 세고 있었다
세션 하나 안에는 토큰이 여러 개 들어간다. 액세스 토큰을 짧게 두고 갱신 토큰으로 다시 받으니 같은 자리에서도 토큰은 계속 새로 나온다. 로그아웃한 자리의 토큰이 계속 통하면 안 되니 세션에 속한 토큰을 알고 있어야 했다.
토큰 ID를 세션마다 집합에 담았다. 넣고, 빼고, 있는지 보는 연산이 전부 상수 시간이라 적당해 보였다.
redis.addToSet(sessionTokensKey, tokenId);
boolean active = redis.isMemberOfSet(sessionTokensKey, tokenId);
redis.removeFromSet(sessionTokensKey, tokenId);
세션 목록에서 본 것과 똑같은 문제가 여기에도 있었다. 이 저장소에서 만료는 키 단위로 걸린다. 집합을 담은 키에 만료를 줄 수는 있어도 안에 든 멤버마다 각각 다른 만료를 줄 수는 없다. 로그아웃으로 빼낸 토큰만 사라지고, 시간이 지나 죽은 토큰은 멤버로 남았다.
세지 말고 만료가 세게 했다
두 자리에서 같은 원인이 나왔으니 고치는 방향도 같았다. 목록을 들고 다니며 직접 지우는 대신, 죽으면 알아서 사라지게 두고 남은 것을 세기로 했다.
토큰 하나를 키 하나로 올렸다. 값에는 활성 표시만 두고 만료는 키에 직접 걸었다.
String tokenKey = sessionKey + ":token:" + tokenId;
redis.set(tokenKey, "active");
redis.expire(tokenKey, SESSION_TTL);
이렇게 두면 만료를 코드가 처리하지 않는다. 시간이 지나면 키가 사라지고, 검증은 키가 있는지 보는 것으로 끝난다. 죽은 토큰이 목록에 남아 있는 상태 자체가 없어졌다.
세는 자리를 검증 경로로 옮겼다
대신 세는 일이 번거로워졌다. 집합일 때는 크기를 물으면 됐는데 키가 흩어지면 셀 대상부터 모아야 한다. 패턴으로 키를 훑는 방법은 운영 중인 저장소에서 쓰고 싶지 않았다.
세션 정보에 개수 필드를 두고 토큰을 만들면 올리고 지우면 내렸다. 그런데 이러면 처음 문제가 그대로 돌아온다. 키는 스스로 만료되는데 필드는 아니다.
따로 도는 정리 작업을 두는 대신 검증할 때 고치게 했다. 토큰 검증은 어차피 요청마다 그 키를 확인한다. 없어졌다는 사실을 그 자리에서 알게 되니, 알게 된 김에 개수를 내렸다.
if (redis.get(tokenKey) == null) {
long left = decreaseTokenCount(sessionKey);
if (left <= 0) {
removeSession(userRole, userId, sessionId);
}
return false;
}
개수가 0으로 내려가면 세션 자체를 지웠다. 접속 정보가 비어 있는 세션도 같은 자리에서 지웠다. 목록에는 뜨는데 눌러도 아무것도 없는 자리를 없앤 것이 이 변경의 실제 효과였다.
| 기준 | ID를 집합에 모아 셈 | 살아 있는 키를 셈 |
|---|---|---|
| 만료 | 멤버 단위로 불가 | 키마다 개별 적용 |
| 죽은 항목 | 명시적으로 빼기 전까지 남음 | 시간이 지나면 사라짐 |
| 유효성 판단 | 포함 여부만으로 부족 | 키 존재 여부로 충분 |
| 개수 세기 | 집합 크기 한 번 | 별도 개수 필드 필요 |
| 정리 주체 | 애플리케이션 코드 | 저장소의 만료 |
일주일은 너무 길었다
세션 만료는 7일이었다. 자리가 셋뿐인 규칙에서 이 값은 그대로 문제가 된다. 잠깐 접속한 자리 하나가 일주일을 물고 있는다.
1일로 줄이고, 쓰고 있는 세션은 접근할 때마다 만료를 다시 채웠다. 계속 쓰는 자리는 사라지지 않고 쓰지 않는 자리는 하루 만에 비워진다. 같은 IP에서 다시 로그인하면 새 세션을 만들지 않고 기존 세션을 재사용하게 해서, 새로 고침만으로 칸이 줄지 않게 했다.
운영 도구는 하나만 남겼다
운영 도구 계정은 셋도 허용하지 않았다. 로그인하면 그 계정의 기존 갱신 토큰을 전부 지우고 새로 발급한다. 언제나 마지막 로그인 하나만 남는다.
가맹점 쪽은 여러 자리에서 함께 쓰는 것이 정상 업무에 가깝지만 운영 도구는 그렇지 않았다. 열려 있는 자리를 줄이는 쪽을 택했다.
열어보지 않으면 고쳐지지 않는다
개수를 검증 경로에서 고치는 방식은 조회가 있어야 동작한다. 로그인해 두고 아무 요청도 보내지 않은 세션은 토큰 키가 만료돼도 개수가 내려가지 않았다. 세션 키 자체의 만료가 오면 결국 사라지지만 그때까지는 실제보다 한 칸 적게 남은 것으로 보였다. 틀리는 방향이 덜 세는 쪽이라 제한이 헐거워지지는 않았고, 그 시점에는 여기서 멈췄다.
같은 IP 재사용에도 대가가 있다. 여러 사람이 같은 출구 주소를 쓰는 환경에서는 한 사람이 두 기기로 붙어도 한 세션으로 묶인다. 기기를 구분하려면 주소 말고 다른 기준이 필요한데 거기까지는 가지 않았다.
제일 오래 남은 것은 개수가 틀렸다는 사실을 그동안 몰랐다는 쪽이다. 넘치면 알아서 밀어내는 동안에는 틀려도 아무 일이 없었다. 막기 시작해서야 드러났다. 조용히 처리해 주는 코드는 문제를 없애는 것이 아니라 문제가 보이는 자리를 없앤다.