락이 레포마다 다른 방식으로 걸려 있었다
세 서비스에 제각각 구현돼 있던 분산락을 하나로 통일하면서 비정상 종료 회복 시간을 다섯 시간에서 30초로 줄이고, 락 실패 정책을 진입 주체 기준으로 정리한 과정을 적었다.
배치를 사용자 단위로 쪼개면서 락을 걸다가, 우리 서비스에 락이 몇 군데나 걸려 있는지 아무도 모른다는 것을 알게 됐다. 락을 새로 하나 추가하기 전에 지금 있는 것부터 세어 보기로 했다.
세 서비스에서 서른 곳 넘게 나왔는데 구현이 전부 달랐다. 같은 회사 코드인데 같은 문제를 세 가지 방식으로 풀고 있었다.
왜 락이 필요한 구조인가
회원관리 서비스는 파드 두 개로 떠 있고 Redis는 단일 노드다. 인스턴스가 하나면 프로세스 안의 잠금으로 충분하지만, 둘이면 같은 메서드가 서로 다른 JVM에서 동시에 돈다. 그걸 막을 수 있는 건 밖에 있는 Redis뿐이다.
락이 걸린 자리를 모아 보니 우연히 흩어져 있지 않았다. 심사와 한도, 자금 요청과 상환, 자동 정산, 지급 네 군데에 몰려 있었다. 공통점은 같은 대상에 대한 중복 외부 호출이 이중 송금이나 중복 생성 같은 되돌리기 어려운 사고로 이어진다는 점이었다.
반대로 락이 하나도 없는 서비스도 있었는데, 그쪽은 단일 인스턴스이거나 동시에 돌아도 무해한 작업이었다. 락의 유무가 아니라 그 판단이 기록돼 있지 않은 것이 문제였다.
세 가지 구현이 있었다
| 서비스 | 구현 | 키를 정하는 방식 |
|---|---|---|
| 회원관리 | 어노테이션 + AOP, TTL 5시간 고정 | 메서드 매개변수 이름 |
| 배치 | 람다를 넘기는 헬퍼 메서드 | 호출부에서 문자열 조립 |
| API | Redis 클라이언트 직접 호출 | 호출부에서 문자열 조립 |
바닥은 셋 다 같아서, 키가 없으면 넣고 있으면 실패하는 연산 하나에 기대고 있었다. 다른 건 그 위에 씌운 껍데기의 두께뿐이었다.
TTL 다섯 시간이 무슨 뜻인지 뒤늦게 봤다
어노테이션 방식의 만료 시간이 다섯 시간으로 고정돼 있었는데, 정상적으로 끝나면 해제하니 평소에는 보이지 않는 값이다.
문제는 해제 코드가 실행되지 못하는 경우다. 프로세스가 강제 종료되면 해제가 돌지 않고, 그 키는 다섯 시간 동안 잠긴 채로 남는다. 그동안 그 사용자에 대한 처리는 전부 막힌다.
정상 락 획득 -> 처리 -> 해제
비정상 락 획득 -> 프로세스 강제 종료 -> 다섯 시간 대기
다섯 시간은 아무 근거 없는 숫자는 아니었을 것이고, 가장 오래 걸리는 처리보다 넉넉하게 잡은 값으로 보였다. 다만 그 넉넉함이 그대로 장애 복구 시간이 된다는 것이 계산에 들어 있지 않았다.
키가 조용히 비는 버그가 있었다
더 나쁜 것을 하나 찾았다. 어노테이션이 키를 뽑는 방식이 메서드 매개변수의 이름에 의존하고 있었다.
이름이 일치할 때만 값이 들어간다. 리팩토링으로 매개변수 이름을 바꾸면 키가 빈 문자열이 되고, 그러면 사용자마다 달라야 할 락이 전부 같은 키를 쓰게 된다. 사용자 단위 락이 서비스 전체를 직렬화하는 전역 락으로 바뀌는 것이다.
컴파일도 테스트도 통과하고, 락이 걸리기는 하니 동시성 사고도 나지 않는다. 처리가 느려질 뿐이라 원인을 찾기가 아주 어렵다.
하나로 통일했다
세 구현을 하나의 어노테이션으로 모았다. Redis 클라이언트가 제공하는 락을 쓰고, 만료는 고정값 대신 자동 연장에 맡기고, 키는 표현식으로 명시하게 했다.
flowchart LR A["어노테이션 · TTL 5시간 · 매개변수명 키"] --> D["통일된 어노테이션"] B["람다 헬퍼"] --> D C["클라이언트 직접 호출"] --> D D --> E["자동 연장 · 표현식 키 · 실패 정책"]
자동 연장은 락을 쥔 프로세스가 살아 있는 동안 주기적으로 만료를 미룬다. 프로세스가 죽으면 연장이 멈추므로 30초 안에 저절로 풀린다. 다섯 시간이 30초가 됐다.
키는 표현식으로 쓰게 해서 무엇이 키가 되는지 코드에 드러나게 했다. 매개변수 이름을 바꿔도 표현식이 그대로면 키는 그대로여서, 이름 변경이 락 동작을 바꾸는 경로가 사라졌다.
접두어는 한 곳에 남겨 뒀다
통일하면서 기존의 접두어 목록을 없앨지 고민했는데, 표현식으로 전체 키를 쓸 수 있으니 필요 없어 보였다.
남기기로 했다. 서른 곳에 접두어를 문자열로 직접 적으면 오타 하나가 락을 무력화한다. 접두어는 한 곳에서만 정의하고 표현식은 뒤쪽만 담당하게 나눴다. 최종 키의 형태는 이전과 같게 유지해서 배포 시점에 기존 락과 섞여도 문제가 없게 했다.
실패했을 때의 동작은 진입 주체가 정한다
통일하면서 가장 오래 걸린 결정이 이것이다. 락을 못 얻었을 때 조용히 넘어갈지 예외를 던질지를 어디서 정할 것인가. 처음에는 락마다 정하려 했는데, 같은 자원인데 들어오는 경로에 따라 답이 달랐다. 그래서 호출하는 쪽이 정하게 열어 뒀다.
| 진입 주체 | 실패 시 | 이유 |
|---|---|---|
| 배치·이벤트 | 건너뛴다 | 중복 발행이 정상 흐름의 일부다 |
| 사용자·운영자 액션 | 예외를 던진다 | 이미 처리 중이라고 알려야 한다 |
이 구분이 옳다는 증거를 재크롤링 기능에서 봤다. 완전히 같은 키인데 운영자가 버튼을 한 번 누르는 경로는 예외를 던지고, 대량으로 도는 경로는 건너뛴다. 같은 자원, 다른 진입점, 다른 정책이 한 키 위에 공존한다.
경합 실패와 Redis 장애를 갈랐다
정책을 정하고 나서 빈틈이 하나 남은 것을 발견했다. 락을 못 얻은 것과 Redis 자체가 죽은 것이 같은 경로로 처리되고 있었다.
둘은 다르다. 앞의 것은 남이 쓰고 있다는 뜻이라 기다리거나 넘어가면 되지만, 뒤의 것은 판단할 근거 자체가 없다는 뜻이다. 그런데 신청처럼 돈을 만드는 입구는 Redis가 죽었다고 막히면 안 된다.
그래서 축을 하나 더 뒀다. 연결 장애일 때 락 없이 진행할지 여부를 따로 고르게 했고, 기본값은 진행하지 않는 쪽으로 뒀다. 기존에 걸려 있던 락들의 동작은 그대로 유지됐다.
어노테이션을 붙이기 전에 호출 그래프를 봤다
통일 과정에서 반복해서 걸린 함정이 자기 호출이었다. 어노테이션은 프록시로 동작하니, 같은 객체 안에서 자기 메서드를 직접 부르면 프록시를 거치지 않아 락이 걸리지 않는다.
붙였는데 안 걸리는 상태가 가장 위험한데, 코드를 읽으면 보호되는 것처럼 보이기 때문이다. 세 군데에서 각각 다른 방식으로 나타났고 해법도 달랐다.
람다가 같은 빈을 부름 -> 자기 자신을 주입받아 프록시를 거치게 한다
같은 빈 안에서 직접 호출 -> 임계 구간을 별도 빈으로 분리한다
외부에서만 호출됨 -> 조치 없음. 대신 그 사실을 기록으로 남긴다
세 번째를 기록으로 남긴 것이 나중에 도움이 됐다. 안전하다는 판단을 적어 두지 않으면 다음 사람이 같은 검토를 처음부터 다시 한다.
락은 진입점이 아니라 자원에 건다
작업을 마치고 남은 원칙은 이 한 줄인데, 정반대로 생긴 두 사례가 같은 결론을 가리켰다.
한쪽은 발행하는 입구에 락이 걸려 있었다. 발행을 동시에 부르는 것은 막았지만 소비하는 쪽이 동시에 처리하는 것은 막지 못했다. 지급 흐름이 전부 모이는 지점을 찾아 거기에 걸자 입구 쪽 락 두 개가 필요 없어졌다.
다른 쪽은 반대였다. 대량 처리 경로에만 락이 있고 같은 작업을 부르는 단건 경로는 무방비였다. 단건에 락을 새로 다는 것만으로는 부족했고, 둘이 같은 키를 쓰게 만들어야 서로를 막을 수 있었다.
이미 걸려 있는 락만 셌다
재진입 동작이 바뀐 것을 완전히 검증하지는 못했다. 기존 방식은 같은 스레드가 같은 락을 다시 잡을 수 없었는데 새 방식은 잡힌다. 의도적으로 재진입을 막고 있던 코드가 있었다면 동작이 달라진다. 눈으로 확인한 범위에서는 없었지만 전수는 아니다.
세 서비스의 표준이 완전히 같아지지도 않았다. 실패 정책 하나는 한 서비스에만 있고, 연결 장애 축도 다른 한 서비스에만 있다. 필요한 곳에서 먼저 만들어졌고 나머지로 옮기는 작업은 미뤘다.
그리고 통일했다고 락이 필요한 자리를 다 찾은 것은 아니다. 이번에 센 것은 이미 걸려 있는 락이고, 걸려 있어야 하는데 없는 자리는 세지 못했다. 재크롤링에서 나온 비대칭이 그 예였고, 같은 형태가 더 있는지는 모른다.