3 minute read

방문차량 QR 출입을 붙이고 테스트하러 지하주차장에 내려갔다. 리더기가 QR을 읽고 멈췄다. 서버 응답을 기다리다 타임아웃이 났다. 지하 2층에는 신호가 거의 없었다.

배경

공동주택 관리 앱을 만들고 있었다. 입주민이 방문차량을 등록하면 차단기가 자동으로 열리는 기능이다.

기존 절차는 이랬다.

  1. 입주민이 관리실에 전화해서 방문차량 번호를 알려준다
  2. 관리실 직원이 주차관제 프로그램에 수동 입력한다
  3. 관리실이 비는 시간(야간·주말)에는 입주민이 내려와서 직접 열어준다

입주민 앱에서 등록하면 바로 통과되게 만드는 게 목표였다.

문제

설계는 단순해 보였다. 입주민이 차량번호와 방문 시간을 등록하면 서버에 저장하고, 차단기 쪽 리더기가 QR을 읽어 서버에 물어본다. 유효하면 열린다.

실제로는 두 가지가 걸렸다.

첫째, 지하주차장에서 통신이 불안정했다. 리더기가 유선으로 묶여 있지 않은 동도 있었다. 서버 조회가 2~3초씩 걸리거나 아예 실패했다. 차단기 앞에서 3초는 뒤차가 경적을 울리는 시간이다.

둘째, QR 안에 뭘 담을지가 안 정해져 있었다. 처음엔 등록 ID를 그대로 넣었다. 테스트 중에 숫자를 하나 바꿔서 찍어봤더니 다른 사람의 방문 등록으로 통과됐다.

원인

두 문제의 원인이 사실 하나였다. 검증을 전부 서버에 의존하는 구조였다.

QR이 단순 식별자면 그 자체로는 아무것도 증명하지 못한다. 그래서 매번 서버에 물어봐야 하고, 서버에 못 물어보면 아무 판단도 못 한다. 통신이 끊기면 전부 멈추고, QR은 추측 가능한 숫자라 위조도 쉽다.

해결

QR 자체가 유효성을 증명하게

QR payload에 필요한 정보를 넣고 서버 키로 서명했다. 리더기는 서명만 검증해도 “이건 우리 서버가 발급한 것”을 알 수 있다.

import hmac, hashlib, base64, json, time

def issue_pass(visit_id: int, plate: str, valid_from: int, valid_to: int) -> str:
    payload = {
        "v": 1,              # 버전 — 나중에 형식을 바꿀 여지
        "id": visit_id,
        "p": plate,
        "f": valid_from,     # epoch seconds
        "t": valid_to,
    }
    body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    sig = hmac.new(SECRET_KEY, body, hashlib.sha256).digest()[:16]
    return base64.urlsafe_b64encode(body + b"." + sig).decode()

sort_keys=True가 중요하다. 검증하는 쪽에서 같은 바이트열을 만들어야 서명이 맞는다. dict 순서에 의존하면 파이썬 버전이나 직렬화 경로가 달라질 때 조용히 깨진다.

서명은 32바이트 전체가 아니라 앞 16바이트만 썼다. QR 용량 때문이다. 128비트면 위조 시도에 충분히 버틴다.

서버가 안 되면 서명으로, 되면 서버로

판단 순서를 둘로 나눴다.

def verify(token: str, now: int) -> Decision:
    payload = check_signature(token)          # 1) 로컬 — 네트워크 불필요
    if payload is None:
        return Decision.DENY                  # 위조 또는 손상
    if not (payload["f"] <= now <= payload["t"]):
        return Decision.DENY                  # 기간 만료

    try:
        state = fetch_state(payload["id"], timeout=0.8)   # 2) 서버 — 취소 여부
    except (Timeout, ConnectionError):
        return Decision.ALLOW_OFFLINE         # 서명·기간은 통과 → 열고 로그만 남김

    return Decision.ALLOW if state.active else Decision.DENY

취소된 방문 등록을 걸러내는 건 서버만 할 수 있다. 하지만 서버 조회 실패가 곧 거부가 되면 안 됐다. 서명과 유효기간은 네트워크 없이도 검증되니까, 그 둘을 통과했으면 열어주고 ALLOW_OFFLINE으로 기록을 남겼다.

타임아웃을 0.8초로 잡았다. 차단기 앞에서 체감되지 않는 한계치를 기준으로 정했다.

유효기간을 입주민이 정하게

처음엔 24시간 고정이었다. 요구사항을 다시 들어보니 방문 유형이 달랐다. 손님은 몇 시간, 인테리어 공사 차량은 2주, 가족은 반복 방문이었다.

그래서 등록 시 시작·종료 시각을 직접 넣게 했고, 기간이 길어질수록 동 대표 승인을 거치게 했다. 긴 기간을 기술로 막는 게 아니라 절차로 통제하는 쪽이 맞았다.

결과

  • 지하주차장에서 서버 응답이 없어도 차단기가 열렸다
  • QR 숫자를 바꿔 찍는 위조가 서명 검증 단계에서 막혔다
  • 야간·주말 방문차량 처리에 관리실 개입이 필요 없어졌다

배운 것

1. “서버에 물어본다”가 항상 가능한 전제가 아니다. 웹 서비스를 만들 때는 네트워크를 공기처럼 여긴다. 차단기·키오스크·현장 리더기처럼 물리 공간에 놓인 장치를 상대하면 그 전제가 깨진다. 통신 실패를 예외가 아니라 정상 경로의 하나로 설계해야 했다.

2. 식별자와 증명은 다르다. ID는 “누구인가”만 말하고, 서명은 “우리가 발급했는가”를 말한다. QR·링크·쿠폰처럼 사용자 손을 거치는 값은 식별자만 담으면 반드시 추측 공격을 받는다.

3. 보안 판단과 운영 판단을 분리했다. 서명 위조는 무조건 거부, 서버 조회 실패는 허용 후 기록. 전부 똑같이 거부하면 안전해 보이지만 실제로는 못 쓰는 시스템이 된다.

남은 한계

ALLOW_OFFLINE으로 통과한 차량 중에 그 사이에 취소된 등록이 섞일 수 있다. 서버에 못 물어봤으니 알 방법이 없다. 유효기간을 짧게 쓰도록 유도하는 것 외에는 구조적으로 못 막는다. 취소 목록을 리더기에 주기적으로 내려보내는 방식(CRL과 같은 개념)을 검토했지만, 당시 리더기 쪽 저장 공간과 동기화 주기를 통제할 수 없어서 넣지 못했다.