잠금이 걸린 회원의 로그인을 허용하는 변경이 배포됐다. 검증하다가 잠금 규모가 궁금해졌다.
Table of contents
Open Table of contents
증상 — 잠금이 수천 계정
먼저 규모를 셌다.
SELECT COUNT(*), COUNT(DISTINCT lock_reason) FROM members WHERE is_locked = 1;
-- 수천 계정 / lock_reason 은 대부분 NULL
is_locked 가 켜진 것이 수천 계정이었고 lock_reason 은 대부분 비어 있었다.
이 is_locked 가 무엇인지 처음에는 블랙리스트 차단으로 봤다. 봄에 수사 협조로 그런 기능을 붙인 것을 알고 있어서 그쪽으로 읽었는데 지적을 받았다.
원인 — 두 기능이 같은 컬럼을 썼다
실제 감사 로그 문구를 대조했다.
SELECT action, COUNT(*) FROM audit_logs
WHERE target_type = 'member' AND target_id IN (...) GROUP BY action;
action 이 두 종류로 나왔다.
'출금 잠금 설정'
'블랙리스트 등록'
같은 is_locked 인데 audit_logs 에 남은 문구가 다르다.
코드를 보니 서로 다른 두 어드민 기능이 같은 컬럼을 쓰고 있었다.
// 블랙리스트 등록 (수사 협조·침해 시도 대응)
POST /admin/members/blacklist
→ $member->is_locked = 1;
// 출금 잠금 (의심 입금·본인 요청)
POST /admin/members/withdraw-lock-batch
→ $member->is_locked = 1;
blacklist 와 withdraw-lock-batch 가 같은 자리에 값을 쓴다.
blacklist 화면에서 차단하면 값이 1이 되고 withdraw-lock-batch 에서 잠금을 풀면 그 값이 0이 된다. 두 기능을 쓰는 사람이 다르면 서로 모른 채로 상대의 상태를 밟는다.
영향 범위 — 게이트가 넓었다
이 잠금이 무엇을 막는지 지점을 전수로 찾았다.
로그인 (2곳: 웹·API 키 인증)
출금 생성
주문 생성
API 키 발급
출금 생성 만 막는 것이 아니라 로그인 과 주문 생성 까지 막고 있었다.
그러면 출금만 잠그려던 회원이 문제가 된다. 로그인을 해도 튕기고 주문도 못 넣으니 사실상 계정 동결이다.
검증 — 위험 조합 수백 계정
잠긴 것 전부가 위험한 것은 아니었다.
잠금 상태
+ 최근 30일 로그인 시도
+ 잔액 보유
이미 탈퇴한 것과 잔액이 0 인 것은 동결이어도 영향이 없다.
셋이 겹치는 것만 세었다.
잠금만 수천 계정
+ 최근 시도 + 잔액 보유 수백 계정
최근 시도 와 잔액 조건으로 좁히니 실제 대상이 수천에서 수백으로 갈렸다.
숫자를 세는 것이 이 조사에서 결정적이었다. 잠긴 계정이 좀 있다는 말과 수백 명이 잔액을 둔 채 로그인을 못 하고 있다는 말은 같은 사실인데 무게가 다르다.
결과 — 절반의 해소와 반대쪽의 개방
배포된 변경을 다시 봤다.
차단 경로 4개 중
로그인 2개 → 열림 (출금 잠금 회원, 블랙리스트 회원 둘 다)
주문·API 키 발급 → 그대로
네 경로 중 로그인 둘만 열렸고 주문 생성 과 API 키 발급 은 그대로였다.
열린 쪽이 더 문제였는데, 게이트가 is_locked 하나를 보니 출금 잠금 회원을 위해 연 문으로 블랙리스트 회원도 들어온다. 그 자리에서 되돌리는 것 대신 로그인 게이트에 lock_reason 조건을 덧대는 쪽을 골랐다. 이유는 되돌리면 수백 계정이 다시 동결되고 조건을 덧대면 두 문제가 같이 닫혀서다.
수백 계정은 여전히 주문을 못 넣는다는 뜻이라 그것을 별도로 보고했다. 부분 수정이면 무엇이 남았는지를 따로 적어야 다음 사람이 다 된 것으로 읽지 않는다.
대응 — 컬럼을 나누는 제안
이 컬럼을 다루는 작업에는 규칙을 남겼다.
두 기능이 같은 플래그를 공유한다
한 화면의 토글이 다른 화면의 상태를 뒤집는다
한쪽을 위해 게이트를 열면 다른 쪽도 열린다
값만 보면 어느 기능이 켠 것인지 알 수 없어서 audit_logs 의 문구를 봐야 한다.
근본은 컬럼을 나누는 것이었다.
is_blacklisted 블랙리스트 차단
is_withdraw_locked 출금 잠금
is_blacklisted 와 is_withdraw_locked 로 갈라 두면 서로를 뒤집을 자리가 없어진다.
컬럼을 더하는 것 자체는 무겁지 않다 — MySQL 5.7 참고 매뉴얼의 온라인 DDL 표는 컬럼 추가를 in-place 로, 동시 DML 허용으로 적는다(자동 증가 컬럼만 예외). 무거운 것은 is_locked 를 읽는 자리를 전부 고치는 쪽이라 별도 제안으로 적었다. 감사 로그가 남아 있어 지금은 구분이 되지만 로그가 없었으면 어느 기능이 켠 값인지 영영 몰랐을 것이다.
정리
- 두 기능이 같은 플래그를 공유할 수 있다
- 한 화면의 조작이 다른 화면의 상태를 뒤집는다
- 플래그 값만 보면 어느 기능이 켠 것인지 모른다
- 감사 로그 문구를 대조해야 구분된다
- 게이트가 생각보다 넓을 수 있으니 지점을 전수로 찾는다
- 출금만이 아니라 로그인과 주문까지 막고 있었다
- 조합으로 좁혀야 진짜 위험 대상이 나온다
- 규모를 세면 같은 사실도 무게가 달라진다
- 공유 플래그의 게이트를 한쪽을 위해 열면 다른 쪽도 열린다
- 부분 수정이면 남은 것이 무엇인지 따로 보고한다
- 근본은 컬럼을 나누는 것이고 크면 별도 제안으로 남긴다