들어가며
Google Authenticator가 보여주는 “847 291”이라는 6자리 숫자. 30초 후에는 완전히 다른 숫자가 됩니다. 이 숫자는 인터넷에 연결되지 않은, 완전히 오프라인인 스마트폰에서 생성됩니다. 비행기 안에서, 지하 철도역에서, 통신이 차단된 에어갭 망에서도 계정에 들어갈 수 있는 이 6자리는 대체 어디서 오는 걸까요? 놀랍게도 서버와 스마트폰은 서로에게 단 한 번도 신호를 보내지 않은 채, 같은 숫자를 동시에 만들어냅니다. 이 불가능해 보이는 일주행을 가능하게 하는 두 가지 재료가 바로 “같은 비밀키”와 “같은 시간”입니다. 이 글은 그 6자리가 탄생하는 전 과정을, 인증의 기초부터 알고리즘의 한 줄까지 따라갑니다.
인증의 3요소: 한 가지로는 부족하다
TOTP를 이해하려면 먼저 “왜 6자리 숫자가 필요한지”부터 물어야 합니다. 비밀번호가 있는데도 말입니다. 답은 간단합니다 — 비밀번호 하나로는 충분하지 않기 때문입니다. 비밀번호는 유출되고, 재사용되고, 추측당합니다. 누군가 비밀번호를 알아냈다면, 그 사람이 진짜 사용자인지 구분할 방법이 비밀번호 하나에는 없습니다. 그래서 보안은 서로 다른 성격의 증거를 겹쳐 쌓는 방향으로 발전했고, 이를 인증의 3요소라고 부릅니다.
아래 그림은 이 3요소를 한눈에 보여줍니다. 세 상자의 차이에 주목해 주세요 — 각각 “아는 것”, “가진 것”, “그 자체인 것”이라는 서로 완전히 다른 종류의 증거입니다. MFA는 이 중 서로 다른 종류의 요소를 2개 이상 조합합니다. “비밀번호 + 비밀번호 확인 질문”은 둘 다 “아는 것”이므로 진짜 MFA가 아님을 그림이 말해줍니다.
┌──────────────────────────────────────────────────────────────┐
│ MFA = 2가지 이상 조합 │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Something │ │ Something │ │ Something │ │
│ │ You KNOW │ │ You HAVE │ │ You ARE │ │
│ │ │ │ │ │ │ │
│ │ · 비밀번호 │ │ · 스마트폰 │ │ · 지문 │ │
│ │ · PIN 번호 │ │ · OTP 토큰 │ │ · 얼굴 │ │
│ │ · 패턴 │ │ · 스마트카드│ │ · 홍채 │ │
│ │ │ │ · 보안 키 │ │ · 음성 │ │
│ │ ← 1차 인증 │ │ ← 2차 인증 │ │ ← 생체 인증 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ 일반적 MFA: KNOW(비밀번호) + HAVE(OTP) 조합 │
└──────────────────────────────────────────────────────────────┘
핵심 통찰: MFA의 본질은 “증거를 더 많이 요구하는 것”이 아니라 “증거의 종류를 다르게 하는 것”입니다. 공격자가 비밀번호(“아는 것”)를 훔쳤더라도, “가진 것”인 스마트폰까지 함께 훔치지 않으면 통과할 수 없습니다. 공격 비용이 한 차원 뛰는 순간입니다.
3요소가 무엇인지 정리했으니, 이제 가장 널리 쓰이는 “가진 것” 요소 — 30초마다 바뀌는 그 6자리 — 가 어떻게 만들어지는지 TOTP의 핵심 아이디어로 들어가겠습니다.
TOTP의 핵심 아이디어: 같은 비밀키, 같은 시간
TOTP(Time-based One-Time Password)가 풀려는 문제는 묘합니다. 서버와 스마트폰은 통신하지 않고도 같은 숫자를 내야 합니다. 어떻게? 두 가지만 약속하면 됩니다. 첫째, 처음 한 번 비밀키 K를 똑같이 공유합니다. 둘째, 둘 다 같은 시계를 봅니다. 그러면 같은 비밀키와 같은 시간을 같은 알고리즘에 넣었을 때, 결과는 수학적으로 동일할 수밖에 없습니다. 통신이 필요 없는 이유가 바로 이것입니다 — 둘이 볼 “재료”가 같으니까요.
아래 다이어그램은 이 원리를 보여줍니다. 화살표가 없다는 점을 주의 깊게 보세요. 서버와 스마트폰 사이에는 어떤 데이터도 오가지 않습니다. 대신 양쪽이 각자 “비밀키 K”와 “현재 시간 T”를 TOTP 함수에 넣어 독립적으로 같은 코드를 만들어냅니다. 가운데 “같은 6자리”라는 결과가 두 흐름의 교차점입니다.
┌──────────┐ ┌──────────┐
│ 서버 │ │ 스마트폰 │
│ │ │ (Auth) │
│ 비밀키 K │ ← 같은 비밀키 K → │ 비밀키 K │
│ 시간 T │ ← 같은 시간 T → │ 시간 T │
└────┬─────┘ └────┬─────┘
│ │
│ TOTP(K, T) = 6자리 코드 │ TOTP(K, T) = 6자리 코드
▼ ▼
┌──────────┐ ┌──────────┐
│ 847 291 │ ← 같은 6자리 → │ 847 291 │
└──────────┘ └──────────┘
핵심: 같은 비밀키 + 같은 시간 = 같은 코드
30초마다 코드가 변경됨
이 아이디어가 왜 안전한지를 몇 가지로 쪼개어 보면, TOTP의 매력이 선명해집니다. 비밀번호가 유출되어도 OTP 코드가 없으면 로그인이 막힙니다. OTP 코드는 30초마다 바뀌어 가로채도 금방 쓸모가 없어집니다. 비밀키는 오직 스마트폰 안에만 저장되어, 서버가 해킹당해도 키가 빠져나가지 않습니다. 그리고 무엇보다, 이 모든 것이 인터넷 연결 없이 작동합니다.
핵심 통찰: TOTP는 “사전 합의(pre-shared)” 모델입니다. 두 당사자가 미리 같은 비밀을 나눠 가졌기 때문에, 이후에는 검증을 위해 어떤 신호도 주고받을 필요가 없습니다. 이것이 TOTP를 오프라인·에어갭 환경에서도 작동시키는 근본 이유입니다.
핵심 아이디어는 명확해졌습니다. 그렇다면 진짜 질문이 하나 남습니다 — “같은 비밀키와 같은 시간”이 구체적으로 어떤 계산을 거쳐 저 6자리가 되는지. 그 메커니즘을 RFC 6238 알고리즘으로 들여다보겠습니다.
TOTP 알고리즘: RFC 6238의 4단계
“비밀키 + 시간 → 6자리”라는 한 줄짜리 아이디어를 실제로 계산하려면 네 단계가 필요합니다. 시간을 숫자로 바꾸고, 그 숫자로 해시를 만들고, 해시에서 특정 부분을 잘라내어, 마지막으로 6자리로 압축합니다. 이 과정을 한 줄 한 줄 코드로 보면, “마법”이 단순한 산술의 반복이었음을 알게 됩니다.
아래는 RFC 6238의 TOTP 알고리즘을 파이썬으로 그대로 옮긴 것입니다. 읽으면서 각 단계의 주석이 무엇을 하는지를 따라가 주세요 — 특히 3단계(HMAC-SHA1)와 4단계(동적 자르기)가 “20바이트짜리 무작위 해시”를 “사람이 읽을 수 있는 6자리”로 어떻게 변환하는지가 이 알고리즘의 정수입니다.
TOTP 알고리즘 전체 코드 (펼쳐보기)
import hmac, hashlib, struct, time
def totp(secret: bytes, interval: int = 30, digits: int = 6) -> str:
"""TOTP 코드 생성"""
# 1. 현재 시간을 30초 단위로 나눔
counter = int(time.time()) // interval
# 2. 카운터를 8바이트 빅엔디안으로 변환
msg = struct.pack(">Q", counter)
# 3. HMAC-SHA1 해시 계산
h = hmac.new(secret, msg, hashlib.sha1).digest()
# 4. 동적 자르기(dynamic truncation) → 6자리 숫자
offset = h[-1] & 0x0F
code = struct.unpack(">I", h[offset:offset+4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
# 사용 예:
secret = b"JBSWY3DPEHPK3PXP" # Base32 디코딩된 비밀키
code = totp(secret)
print(f"현재 OTP 코드: {code}") # 예: 847291
4단계를 풀어 설명하면 이렇습니다. 1단계에서 현재 타임스탬프를 30으로 나누어, 30초마다 1씩 증가하는 카운터를 만듭니다 — 즉 같은 30초 안에서는 카운터가 변하지 않고, 그래서 코드도 변하지 않습니다. 2단계에서 이 카운터를 HMAC이 받아들일 수 있는 8바이트 메시지로 포장합니다. 3단계에서 비밀키로 이 메시지에 서명하여 20바이트짜리 해시를 얻습니다 — 이 해시는 비밀키를 모르면 절대 재현할 수 없는 값입니다. 4단계가 가장 영리한 부분으로, 20바이트 중 한 바이트를 “포인터” 삼아 4바이트를 잘라내고, 그것을 6자리 숫자로 압축합니다.
왜 하필 HMAC-SHA1인가
여기서 자연스러운 의문이 하나 듭니다. SHA1은 충돌 공격이 알려져 해시 용도로는 이미 깨졌다고 들었는데, 왜 TOTP는 아직도 SHA1을 씁니다? 이는 HMAC의 보안이 충돌 내성에 의존하지 않기 때문입니다. HMAC은 해시 함수를 “의사난수 함수(PRF)”로 쓰며, 충돌을 찾는 능력과 HMAC을 위조하는 능력은 전혀 다른 문제입니다. SHA1의 충돌은 깨졌어도 HMAC-SHA1은 여전히 안전합니다.
그리고 더 결정적인 이유는 상호운용성입니다. TOTP의 바탕이 되는 HOTP(RFC 4226)가 SHA1을 기본으로 정했고, Google Authenticator·Microsoft Authenticator를 비롯한 모든 앱이 SHA1으로 구현되었습니다. RFC 6238은 SHA256/SHA512도 허용하지만, 기본값은 SHA1입니다. SHA256으로 바꾸는 순간 대부분의 인증자 앱과 호환되지 않습니다. 그래서 “더 강한 알고리즘”보다 “모두가 같은 알고리즘”을 선택한 것입니다 — 표준의 힘은 강도보다 일관성에서 올 때가 있습니다.
핵심 통찰: TOTP에서 SHA1이 살아남은 이유는 “충분히 안전해서”가 아니라 “안전하면서도 모두와 호환되기 때문”입니다. 보안에서 가끔 가장 중요한 결정은 “가장 강한 것”이 아니라 “모두가 쓰는 것”을 고르는 일입니다. 단, 그 선택이 진짜로 안전할 때만요.
알고리즘의 모든 단계를 짚었지만, 한 가지 숫자가 계속 눈에 걸립니다 — 30초. 왜 하필 30초일까요? 10초면 더 안전할 테고, 5분이면 더 편할 텐데 말입니다. 이 단 하나의 숫자에 숨겨진 설계의 이유를 다음에서 살펴봅니다.
왜 30초인가: 시간 창의 설계
TOTP의 모든 파라미터 중에서 가장 사람의 일상에 닿아 있는 것이 바로 이 30초입니다. 사용자가 코드를 보고 입력하는 동안 숫자가 바뀌지 않아야 하고, 동시에 공격자가 훔친 코드를 쓸 수 있는 시간은 짧아야 합니다. 이 두 요구는 서로 정반대로 당깁니다 — 창이 길면 편하지만 위험하고, 짧으면 안전하지만 짜증납니다. RFC 6238의 기본값 30초는 이 줄다리기의 균형점입니다.
왜 더 짧지 않은지부터 보겠습니다. 창이 5초라면 사용자가 6자리를 읽고 타이핑할 동안 코드가 바뀝니다. 게다가 서버와 스마트폰의 시계가 단 몇 초만 어긋나도 인증이 실패합니다. NTP 동기화가 완벽하지 않은 환경에서는 로그인이 자꾸 튕기게 됩니다. 반대로 창이 5분이라면, 공격자가 중간에서 코드를 가로채는 피싱·릴레이 공격에 취약해집니다. 훔친 코드가 5분 동안이나 유효하다는 것은, 그 시간 안에 써먹으면 그만이라는 뜻입니다. 30초는 “사람이 여유 있게 읽고 치기에 충분”하면서 “공격자가 가로채 쓰기엔 빠듯한” 경계에 자리 잡았습니다.
핵심 통찰: 30초라는 숫자는 수학이 아니라 인간 공학이 만들어낸 값입니다. 알고리즘 자체는 어떤 창에서도 돌아가지만, “보안”과 “사용성”이라는 두 축이 만나는 지점을 찾은 결과가 바로 이 값입니다. 좋은 보안 설계는 항상 사람의 행동 양식을 품고 있습니다.
시간 창의 의미까지 이해했으니, 이제 모든 조각을 하나로 묶을 때입니다. 사용자가 아이디를 치는 순간부터 6자리가 검증되어 로그인이 끝나는 순간까지, 실제 MFA 로그인 플로우가 어떻게 흘러가는지 보겠습니다.
MFA 로그인 플로우: 비밀번호에서 6자리까지
지금까지는 TOTP를 “숫자를 만드는 기계”로만 바라봤습니다. 하지만 실제 서비스에서 이 기계는 더 큰 흐름의 한 조각입니다. 사용자가 비밀번호를 통과한 뒤에야 6자리를 요구하고, 그 6자리가 맞아야 비로소 JWT가 발급됩니다. 이 흐름을 보면 TOTP가 어디에 끼어드는지, 그리고 비밀번호 인증과 MFA 인증이 어떻게 단계적으로 쌓이는지가 보입니다.
아래 시퀀스 다이어그램은 전체 로그인 과정을 단계별로 보여줍니다. 읽을 때 두 갈래길에 주목해 주세요 — 3단계의 “MFA 필요?” 판단에서 갈라지는, 미등록 사용자는 QR 등록으로, 등록 사용자는 OTP 입력으로 향하는 두 경로입니다. 그리고 마지막 7단계에서 두 경로가 다시 만나 mfa_verified: true JWT로 수렴합니다.
MFA 로그인 전체 시퀀스 (펼쳐보기)
사용자 서버
│ │
│ 1. 아이디/비밀번호 │
│───────────────────▶│
│ │
│ │ 2. LDAP bind 인증
│ │ (비밀번호 검증)
│ │
│ ┌─────▼─────┐
│ │ 3. MFA 필요?│
│ └─────┬─────┘
│ ┌────────┴────────┐
│ ▼ ▼
│ MFA 미등록 MFA 등록됨
│ │ │
│ 4a. 등록 화면 (QR) 4b. OTP 입력 화면
│◀──────────│─────────────────│
│ │ │
│ 5a. 코드 입력 등록 5b. 6자리 코드 입력
│──────────▶│────────────────▶│
│ │ │
│ │ 6. TOTP 검증 │
│ │ pyotp.verify()│
│ │ ┌─────▼─────┐
│ │ │ 검증 결과 │
│ │ └─────┬─────┘
│ │ │
│ 7. JWT 발급 (mfa_verified: true)
│◀──────────│─────────────────│
│ │
│ 로그인 완료! │
흐름에서 짚고 넘어갈 점이 있습니다. 1~2단계는 우리가 잘 아는 비밀번호 인증입니다. 여기까지만 통과한 사용자는 아직 “반쪽짜리 신원”입니다 — 비밀번호가 맞다는 것은 알지만, 그것이 주인 본인인지는 증명되지 않았습니다. 그 반쪽을 채우는 것이 3단계 이후의 MFA입니다. 서버는 비밀번호 통과 즉시 로그인을 끝내지 않고, 6자리라는 두 번째 증거를 요구합니다. 두 증거가 모두 모여야 7단계의 JWT에 mfa_verified: true라는 도장이 찍힙니다.
QR 코드 등록: 비밀키를 전달하는 한 번의 악수
흐름의 4a단계, 처음 MFA를 등록할 때 비밀키가 어떻게 스마트폰에 들어갈까요? 서버가 비밀키를 생성하고, 이를 otpauth:// URI 형태로 QR 코드에 담아 보여줍니다. 사용자가 Google Authenticator로 스캔하면 비밀키가 앱에 저장됩니다. 이 한 번의 스캔이 “같은 비밀키”를 공유하는 유일한 순간이며, 이후에는 다시는 키를 주고받지 않습니다. 아래는 그 URI의 구조입니다 — secret이 비밀키, period가 30초 창임을 확인해 주세요.
otpauth://totp/AD%20Manager:user@example.local
?secret=JBSWY3DPEHPK3PXP
&issuer=AD%20Manager
&digits=6
&period=30
이 플로우는 TOTP를 기반으로 훌륭하게 작동합니다. 하지만 잠깐 — 우리는 왜 TOTP를 골랐을까요? SMS로 코드를 보내거나, 푸시 알림을 띄우는 방식도 있지 않았을까요? 그 대안들과 TOTP를 나란히 놓고 비교해보면, 이 선택이 단순한 취향이 아니었음이 드러납니다.
왜 TOTP인가: SMS, Push, WebAuthn과의 비교
2차 인증 방식은 TOTP만 있는 것이 아닙니다. SMS로 코드를 문자 메시지로 보낼 수도 있고, 앱에 푸시 알림을 띄워 “승인/거절”을 누르게 할 수도 있고, WebAuthn(FIDO2) 보안 키를 꽂게 할 수도 있습니다. 각기 장단점이 있습니다. 그런데 왜 많은 시스템, 특히 에어갭 환경에서는 TOTP를 선택할까요? 답은 “가장 안전해서”가 아니라 “주어진 제약 안에서 가장 합리적”이기 때문입니다.
아래 표는 주요 MFA 방식을 네 가지 기준 — 보안성, 편의성, 비용, 오프라인 작동 여부 — 으로 비교합니다. 가장 중요한 열은 맨 오른쪽 “오프라인”입니다. 인터넷이 차단된 환경에서 작동하는 방식이 TOTP와 하드웨어 기반 두 가지뿐임을, 그리고 그중 TOTP가 유일하게 “무료 + 소프트웨어만으로” 가능한 선택임을 표가 말해줍니다.
| 방식 | 보안성 | 편의성 | 비용 | 오프라인 |
|---|---|---|---|---|
| TOTP (Google Auth) | 높음 | 높음 | 무료 | O |
| SMS OTP | 중간 (SIM 스와핑 위험) | 높음 | 유료 (통신비) | X |
| 이메일 OTP | 중간 | 중간 | 무료 | X |
| Push 알림 | 높음 | 높음 | 유료 (앱 백엔드) | X |
| WebAuthn (FIDO2) | 최고 | 중간 | 하드웨어 필요 | O |
| 인증서 (Smart Card) | 최고 | 낮음 | 하드웨어 필요 | O |
표의 두 번째 행, SMS OTP를 주의 깊게 보세요. 편의성은 높지만 보안성이 “중간”으로 떨어져 있습니다. SMS는 통신망(SS7)을 통해 가로채일 수 있고, SIM 스와핑 공격으로 공격자가 피해자의 번호를 자기 폰으로 빼앗을 수도 있습니다. 2018년 Reddit 공동창업자의 계정 탈취도 SIM 스와핑으로 일어났습니다. 게다가 SMS는 건당 통신비가 들고, 무엇보다 이동통신망 자체가 없으면 작동하지 않습니다. 인터넷은 물론 휴대전화 신호조차 없는 환경에서 SMS는 쓸모가 없습니다.
핵심 통찰: 인터넷이 단절된 에어갭 망에서 MFA를 구현해야 한다면, 실질적인 선택지는 TOTP 하나뿐입니다. SMS·Push·이메일은 모두 외부 통신이 필요하고, WebAuthn·스마트카드는 하드웨어 예산과 배포 부담이 따릅니다. TOTP는 “소프트웨어만으로, 무료로, 오프라인에서” 작동하는 유일한 MFA입니다 — 그래서 TOTP를 선택한 것이 아니라, 제약이 TOTP를 선택하게 했다는 표현이 더 정확합니다.
TOTP가 왜 합리적인 선택인지, 그리고 다른 방식들이 왜 이 환경에서는 답이 될 수 없는지까지 확인했습니다. 이제 이 모든 관찰을 돌아보며 마무리하겠습니다.
마치며
이 글을 쓰면서 다시 한번 놀랐던 것은, TOTP의 우아함이 “복잡한 암호학”이 아니라 “같은 것을 보는 두 당사자”라는 단순한 관찰에서 비롯된다는 점입니다. 서버와 스마트폰이 서로에게 말을 건네지 않아도 같은 숫자를 내는 까닭은, 둘이 같은 비밀키를 공유하고 같은 시계를 보기 때문입니다. 통신을 생략한 채 신뢰를 유지하는 이 “사전 합의” 모델은, 다음 글에서 다룰 Kerberos가 비밀번호를 네트워크에 노출시키지 않는 방식과도 깊이 맞닿아 있습니다 — 보안의 혁신은 종종 “더 강한 암호”가 아니라 “정보를 아예 주고받지 않는 설계”에서 옵니다.
그리고 30초 창과 HMAC-SHA1이라는 두 결정은, 좋은 보안 설계가 결국 사람과 현실에 대한 이해라는 사실을 일깨워줍니다. 30초는 “사람이 읽고 치기에 충분하면서 공격자에게는 빠듯한” 인간 공학의 균형점이고, SHA1은 “가장 강한 알고리즘”이 아니라 “모두와 호환되는 알고리즘”을 택한 상호운용성의 선택이었습니다. SHA1의 충돌은 깨졌어도 HMAC으로서는 여전히 안전하다는 사실은, “안전하다”는 말이 용도에 따라 다른 뜻을 지님을 보여줍니다. 같은 도구도 쓰임에 따라 안전할 수도 위험할 수도 있다는 것을 잊지 않기를 바랍니다.
마지막으로, TOTP가 에어갭 환경에서 사실상 유일한 MFA라는 사실은 현장의 무게를 담고 있습니다. SMS와 푸시는 통신을 전제하지만, 인터넷이 단절된 인프라에서는 그 전제 자체가 성립하지 않습니다. 비밀키 분실이 곧 영구 잠금으로 이어지는 위험을 백업 코드로 완벽히 없앨 수는 없지만, 시스템이 살아있는 한 — 그리고 NTP로 시계가 맞춰져 있는 한 — TOTP는 묵묵히 30초마다 새 숫자를 만들어냅니다. 신호를 보내지 않아도, 연결이 없어도, 계정을 지키는 6자리의 조용한 수호자. 그 조용함이야말로 TOTP가 가진 가장 강력한 힘일 것입니다.
이전 글: (3) OAuth2 & OIDC 다음 글: (5) Kerberos & SSO
게이트웨이 On-promise 제품 팀에서 시스템 모니터링 및 관리를 쉽게 다가갈 수 있도록 하기 위한 업무를 하고 있습니다.
Contact: lhjnano@gmail.com