개발 slecs

결제 플랫폼 관리자 로그인·2FA 화면 데이터 정합성 확보

목차

admin-login 영역에 기능을 추가했다. 이커머스 PG 플랫폼 SaaS 톤으로 로그인과 2FA 페이지를 리디자인했고, 변경 파일은 뷰와 스타일 각 1개씩이다. 파일 수만 보면 작은 작업인데, 실제로는 SQL 매퍼 쿼리부터 내부 클래스 메서드까지 건드렸다. 화면 2개 고치는 게 왜 그렇게 길어지냐고 하면-결제 플랫폼 관리자 도메인에서 단순 UI 패치로 끝나는 경우가 거의 없기 때문이다.

왜 쿼리 레벨부터 설계했나

기존 화면과 API에서 제공하지 않던 데이터와 동작이 필요해졌다. 로그인·2FA 관리자 화면에서 "2FA 설정 여부"만 보여주는 걸 넘어, 설정 상태의 세부 구분이 필요했다. 한 번도 설정하지 않은 계정, 설정했지만 인증을 완료하지 않은 계정, 정상 인증 완료된 계정, 실패 누적으로 잠긴 계정-이 네 가지를 하나의 "미설정"이나 "활성화 여부"로 뭉개면 관리자가 화면만 보고 잘못된 판단을 내릴 수 있다.

그래서 접근 방향을 뒤집었다. 화면에서 필요한 게 무엇인지 먼저 정의하고, 그걸 만족하는 쿼리를 SQL 매퍼에 작성한 다음, 내부 클래스에 메서드를 추가하고, 마지막에 화면 렌더링을 연동했다. 일반적으로 UI 레이아웃부터 잡고 API를 맞추다 보면 쿼리가 화면 구조에 끌려다니는 형태가 되는데, 그게 누적되면 같은 데이터를 쓰는 다른 화면에서 숫자가 미묘하게 달라지는 원인이 된다. 정합성 문제는 대부분 이 지점에서 시작한다.

PG 플랫폼처럼 트랜잭션 기록, 정산 상태, 사용자 권한이 얽혀 있는 도메인에서는 특히 그렇다. 관리자 화면의 집계 수치가 실제 DB 상태와 1건이라도 다르면 운영팀에서 신뢰를 잃는다. 그리고 한번 의심받기 시작하면 "이 숫자 맞아요?"라는 질문이 계속 들어온다.

설계에서 선택해야 했던 것들

구현하면서 결정해야 할 분기점이 여러 개 있었다.

실시간 갱신 방식. 2FA 상태값은 사용자가 직접 액션을 취할 때만 바뀐다. 폴링이나 웹소켓을 붙이면 복잡도와 서버 부하만 올라간다. 이 화면에서는 요청 시점 조회로 충분하다고 판단했다. 실시간성이 진짜 필요한지 여부를 먼저 따지는 게 맞다. 항상 실시간이 더 좋은 건 아니다.

상태 구분의 세분화. 앞서 언급한 네 가지 상태를 SQL에서부터 명확하게 구분해뒀다. 아래는 방향을 보여주는 예시다(실제 컬럼명과는 다름).

SELECT
  u.id,
  CASE
    WHEN t.registered_at IS NULL THEN 'not_enrolled'
    WHEN t.verified_at   IS NULL THEN 'enrolled_unverified'
    WHEN t.locked_until  > NOW() THEN 'locked'
    ELSE                              'active'
  END AS tfa_status,
  t.failed_attempts,
  t.locked_until
FROM users u
LEFT JOIN tfa_meta t ON t.user_id = u.id
WHERE u.id = #{userId}

쿼리 레벨에서 상태를 구분해두면 뷰 레이어의 분기가 깔끔해지고, 나중에 상태 종류가 늘어날 때도 CASE 문만 수정하면 된다. 반대로 뷰에서 null 체크와 날짜 비교를 섞어서 처리하면 같은 로직이 화면마다 흩어지고, 어느 순간 판단 기준이 화면별로 달라진다.

NULL과 빈 데이터 방어. 신규 계정, 2FA 미설정 계정, 잠금 이력 없는 계정 등 "아직 데이터가 없는" 경우가 여러 종류다. LEFT JOIN 결과의 NULL을 그냥 빈 값으로 내려보내면 프론트에서 타입 오류가 나거나, 관리자가 보기에 "설정 안 됨"인지 "데이터 없음"인지 구분이 안 된다. 각 케이스를 아래처럼 명시적으로 처리했다.

케이스 처리 방식
2FA 미설정 not_enrolled 상태로 명시, 별도 안내 문구 노출
설정 후 미인증 enrolled_unverified, 인증 유도 액션 표시
잠금 상태 locked + 잠금 해제 예정 시각 표시
정상 활성 active + 마지막 인증 시각 표시
실패 횟수 NULL 0으로 처리 (LEFT JOIN 방어)

쿼리 실행 계획 사전 확인. 관리자 화면이라 트래픽이 많지 않지만, LEFT JOIN이 들어간 쿼리는 인덱스를 타는지 미리 확인해두는 게 습관이다. 데이터가 쌓이면 JOIN 누락이나 풀스캔이 나중에 문제가 된다. 실행 계획 보고 이상 없는 것 확인 후 진행했다.

검증하고 다시 체감한 것들

구현 후 직접 화면에서 동작을 확인했다. 각 상태별 케이스를 만들어서 화면이 의도대로 나오는지 봤고, 기존 데이터가 깨지지 않았는지, 관련 화면의 숫자가 일치하는지 cross-check했다. 변경 전에 기준 수치를 메모해두고, 변경 후 같은 케이스로 다시 확인하는 방식이다. 이게 귀찮아 보여도 금융/결제 도메인에서는 이 단계를 생략할 수 없다.

커밋은 논리적 단위로 쪼갰다. 쿼리 추가, 클래스 메서드 추가, 뷰 연동을 별도 커밋으로 분리하면 나중에 문제가 생겼을 때 어느 변경에서 깨졌는지 찾기 훨씬 쉽다. 커밋 메시지는 "무엇을"보다 "왜"를 담으려고 노력한다. "2FA 상태 쿼리 추가"보다 "관리자가 계정별 인증 단계를 구분할 수 있도록 상태 세분화"가 나중에 읽었을 때 훨씬 쓸모 있다.

이번 작업에서도 다시 확인한 건 하나다. 기능 하나가 버튼 하나 추가로 끝나지 않는다. SQL 집계, 상태 구분, 예외 처리, 화면 렌더링, 권한 체크가 모두 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 상태의 사용자에게 이상한 화면이 나온다. 금융/결제 도메인에서는 숫자 하나가 틀리면 신뢰가 흔들린다. "대충 맞는 것 같다"로 넘어가면 반드시 다시 돌아온다.

댓글 0

첫 댓글 달아줘.