로그인 요청은 200이었음. 그런데 사용자는 계속 로그인 화면으로 돌아갔음. 부가 기능의 작은 실패가 로그아웃으로 번진 과정과, 그 밑에서 풀리지 않고 남아 있던 SQLite 잠금 이야기.
로그인은 성공하고 있었음
어느 날 운영 서버에서 "로그인이 안 된다"는 증상이 났음. 서버 로그를 보니 로그인 요청은 200, 성공이었고 토큰도 정상으로 나갔음.
문제는 그다음 요청이었음. 로그인 직후 화면은 "내 정보"(GET /api/me)를 불러오는데, 이게 500을 냈음.
화면은 내 정보를 못 불러오면 토큰을 지우고 로그인 화면으로 돌려보내게 되어 있었음. 사용자 눈에는
로그인이 안 되는 걸로 보였지만, 실제로는 로그인한 직후에 화면이 스스로 로그아웃하고 있었음.
500은 접속 시각 기록에서 났음
내 정보를 불러올 때 서버는 그 김에 "마지막 접속 시각"을 갱신함. 그 쓰기가 데이터베이스 잠금에 걸려 실패했고, 그 때문에 요청 전체가 500이 됐음.
정리하면, 있으면 좋은 정도의 부가 기능(접속 시각 기록)이 실패했고, 그게 요청을 실패시켰고, 화면은 그걸 "세션이 끝났다"로 받아들였음. 서버의 사소한 오류 하나가 로그아웃으로 이어진 것임.
잠금이 안 풀린 이유
잠금 자체는 흔한 일임. SQLite는 쓰기를 한 번에 하나씩만 받아서, 동시에 쓰려는 요청이 있으면 잠깐 기다림. 그런데 이번 잠금은 서버를 다시 켜기 전까지 풀리지 않았음.
원인은 파이썬 sqlite3 모듈의 기본값에 있었음.
- 기본 설정(
isolation_level="")에서는 쓰기 문장 앞에 트랜잭션이 자동으로 열림. - 잠금 대기 때문에
commit()이 실패하면 그 트랜잭션은 열린 채 남음. - 데이터베이스 연결은 스레드마다 만들어 두고 계속 다시 씀. 그래서 열린 트랜잭션을 들고 있는 연결이 그대로 재사용됨.
그 결과 한 번의 일시적인 경합이 잠금을 쥔 채 굳어 버렸음. 다른 요청들은 그 잠금을 기다리다 실패했음.
고친 곳 세 군데
-
근본 원인: 연결을 만들 때
isolation_level=None, 즉 자동 커밋으로 바꿨음. 자동으로 열리는 트랜잭션이 없으니 열린 채 남을 것도 없음. 여러 문장을 꼭 한 번에 묶어야 하는 곳(표를 다시 만드는 작업)만BEGIN; … COMMIT;으로 따로 묶었음. - 부가 기능 실패: 접속 시각 갱신이 실패해도 내 정보 요청은 그대로 성공하게 감쌌음. 접속 시각이 한 번 안 찍혀도 문제 될 건 없음.
- 화면의 로그아웃 조건: 401 · 403일 때만 로그아웃하게 바꿨음. 500은 "서버가 아프다"는 뜻이고 "세션이 끝났다"는 뜻이 아님. 둘을 구분하지 않으면 서버 오류가 전부 로그아웃으로 이어짐.
"로그인이 안 된다"를 로그인 코드에서만 찾았으면 한참 헤맸을 듯함. SQLite는 이 뒤에도 한동안 잘 썼음. 쓰기가 많아질 걸 대비해 PostgreSQL로 옮긴 건 열흘쯤 뒤의 일임.