본문으로 건너뛰기
전체 글

6자리 리딤코드는 어디까지 안전하게 만들 수 있을까

· 약 12분
Bankware Global Engineering

고객에게 7Q2M9A 같은 6자리 코드를 보내고 한 번만 혜택을 받게 해야 한다고 해보자. 코드는 숫자와 영문 대문자로 만들고, 다른 고객에게 같은 코드가 나가면 안 된다. 사용자가 입력하면 서버는 실제 발급한 코드인지 확인하고 혜택을 지급한 뒤 다시 사용할 수 없게 해야 한다.

이처럼 문자열을 가진 사람이 혜택을 청구하는 방식을 bearer형 리딤코드라고 부른다. 여러 사람이 함께 입력하는 공개 프로모션 코드와는 요구사항이 다르다. 여기서는 개별 발급형을 다룬다.

핵심 질문은 ‘어떤 암호를 쓸까’보다 먼저 있다. 중복 없는 발급, 발급 순서 은닉, 유효한 사용 판정과 추측 방어를 각각 어디서 맡을 것인가?

기본 선택과 코드 한 건의 생애​

대부분의 낮은 점유율 발급기는 보안용 난수와 DB의 UNIQUE 제약조건에서 시작할 수 있다. FF1이라는 암호학적 순열은 고정 길이를 지키며 발급 순서까지 숨겨야 할 때 검토할 선택지다.

필요한 것적합한 출발점
낮은 공간 점유율, DB에서 발급·사용 상태 관리SecureRandom + UNIQUE
내부에서만 쓰는 고유 식별자Sequence + Base36
고정 6자리, 충돌 없는 입력과 발급 순서 은닉FF1 + DB 상태 검증
고가치 혜택 또는 많은 동시 활성 코드길이 확장 + 대상 바인딩 + 시도 제한 우선

한 건의 흐름으로 보면 각 책임이 선명해진다.

단계7Q2M9A 예제에서 일어나는 일
생성보안용 난수 후보를 만든다. FF1을 택했다면 고유 ID를 6자리로 변환한다.
발급 확정DB에 코드와 만료·사용 상태를 저장한다. 충돌하면 다른 후보를 만든다.
사용 요청정확히 6자리 ASCII인지 확인한다.
유효성 판정실제 발급·활성·만료·대상 조건을 확인하면서 사용 횟수를 원자적으로 바꾼다.
혜택 지급같은 트랜잭션 또는 중복 방지 장치로 혜택을 지급한다. 재요청으로 두 번 지급하지 않는다.

형식이 맞는 문자열과 사용할 수 있는 코드는 다르다. 코드를 생성하는 알고리즘만으로 마지막 두 단계를 대신할 수 없다.

난수와 DB 고유성으로 시작하기​

Java의 SecureRandom으로 36개 문자 중 하나를 여섯 번 고른다. nextInt(36)을 사용하면 문자를 균등하게 선택할 수 있다. 임의 바이트에 % 36을 적용하면 일부 문자가 더 자주 나오는 편향이 생길 수 있다.

// RNG는 SecureRandom, ALPHABET은 ASCII "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".
char[] code = new char[6];
for (int i = 0; i < code.length; i++) {
code[i] = ALPHABET[RNG.nextInt(ALPHABET.length)];
}
return new String(code);

설명용 핵심 부분이다. import와 클래스가 포함된 전체 Java 예제는 부록에서 실행할 수 있다. 난수 자체는 중복을 금지하지 않으므로 DB가 최종 고유성을 판정한다.

발급 전 SELECT는 동시 요청을 막지 못한다. 두 요청이 모두 ‘없음’을 확인한 뒤 같은 값을 넣을 수 있다. PostgreSQL에서는 UNIQUE(code)와 다음 INSERT를 사용한다.

INSERT INTO redeem_code (code, method, expires_at)
VALUES (:code, 'RANDOM', :expires_at)
ON CONFLICT (code) DO NOTHING
RETURNING id;

콜론 인자는 애플리케이션의 바인딩 자리다. 반환 행이 없으면 새 후보로 제한된 횟수만큼 재시도한다. 다른 DB 오류까지 충돌로 취급하거나 무한 반복하지 않는다. 부록의 스키마는 생성 방식을 RANDOM/FF1로 구분하고 RANDOM은 key_version=NULL, FF1은 양의 key_version 필수로 검사하므로 이 INSERT와 맞는다.

‘한 번 충돌했다’와 ‘다음 발급이 어렵다’는 다르다​

6자리 Base36의 전체 후보 수를 NN이라 하자. 숫자 10개와 대문자 26개를 쓰므로:

N=366=2,176,782,336log⁡2N≈31.02비트\begin{aligned} N&=36^6=2{,}176{,}782{,}336\\ \log_2N&\approx31.02\text{비트} \end{aligned}

누적 후보 10만 개를 뽑는 동안 한 번이라도 충돌할 근사 확률은 약 89.94%다. 그러나 DB에서 예약된 코드가 10만 개일 때 다음 후보 하나가 충돌할 확률은 약 0.00459%다. 충돌을 만나면 다시 뽑으면 되므로 낮은 점유율에서 평균 재시도 비용은 작다. 전체 수식과 표는 부록에 둔다.

여기서 ‘예약된 코드’는 활성 코드와 다르다. 만료·사용 완료 행도 DB에 남아 UNIQUE의 대상이면 공간을 점유한다. 과거 코드를 지워 재사용하면 이전 수령인이 같은 문자열을 기억하고 있을 수 있으므로, 만료를 곧바로 안전한 재발급 허용으로 해석해서는 안 된다.

사용은 한 번의 원자적 판정으로 끝낸다​

1회용 코드를 조회한 뒤 따로 상태를 바꾸면, 동시에 들어온 두 요청이 모두 사용 가능하다고 볼 수 있다. 상태·만료·횟수 확인을 조건부 UPDATE와 묶는다.

UPDATE redeem_code
SET used_count = used_count + 1,
status = CASE
WHEN used_count + 1 >= max_uses THEN 'CONSUMED'
ELSE status
END
WHERE code = :code
AND status = 'ACTIVE'
AND expires_at > CURRENT_TIMESTAMP
AND used_count < max_uses
RETURNING id, used_count, max_uses;

반환 행이 있을 때만 사용 상태 변경에 성공했다. max_uses=1인 예제 코드는 첫 요청 후 CONSUMED가 되므로 두 번째 요청은 성공하지 않는다. 계정이나 주문에 묶은 코드라면 그 조건도 같은 WHERE에 포함한다.

혜택 지급도 같은 트랜잭션에 넣는다. 외부 시스템에 지급을 요청해야 한다면 idempotency key로 같은 요청을 한 번만 처리하고, outbox에 지급할 작업을 상태 변경과 함께 기록하는 등의 경계를 설계한다. 사용 상태만 바꾼 뒤 외부 지급에 실패하는 경우도 다뤄야 한다.

코드 없음·만료·이미 사용됨을 오류 응답에서 지나치게 구분하면 유효한 코드를 찾는 단서가 된다. 평문 코드가 애플리케이션·분석 로그에 남지 않게 하는 것도 이 흐름의 일부다.

21억 후보가 있어도 추측은 가능하다​

발급 충돌의 예약 수와 공격자가 맞힐 수 있는 활성 수는 다른 값이다. 이번에는 공격 경로에서 혜택을 청구할 수 있는 활성 코드 수를 mm이라 하자. 공격자가 균등하게 고른 후보 한 개의 성공률은 m/Nm/N이다.

공격 목표한 번의 균등 추측 성공률중복 없이 시도할 때 평균 횟수
특정 활성 코드 1개1/N1/N(N+1)/2(N+1)/2, 약 10.88억 회
활성 mm개 중 아무거나m/Nm/N(N+1)/(m+1)(N+1)/(m+1)
활성 100만 개 중 아무거나약 0.0459%약 2,177회

고정된 활성 집합을 대상으로 중복 없이 시도한다는 가정이다. 계정에 묶었다면 mm은 전체 시스템의 코드 수가 아니라 그 요청으로 청구할 수 있는 집합이다. 실제 허용 시도 횟수와 만료 시간은 이 계산 밖의 운영 정책이다.

고가치 혜택을 6자리 코드 하나만으로 보호하기에는 공간이 작다. AES 키를 256비트로 바꿔도 외부 후보는 여전히 약 31비트다. 비트 수와 탐색 공간은 지수 관계이므로 긴 키가 짧은 코드의 추측 저항을 대신하지 않는다.

형식을 6자리로 유지해야 한다면 짧은 만료·1회 사용·계정/주문 바인딩을 결합하고, IP 외에도 계정·기기·세션· 캠페인별 시도량을 제한해야 한다. 실패 누적에 따른 지연·차단과 탐지 정책도 필요하다. OWASP ASVS 5.0 V6.6.3의 시도 제한·64비트 고려 사항은 out-of-band 인증에 관한 것으로, 여기서는 비교 기준이지 모든 리딤코드의 일률적 요건은 아니다.

입력 편의를 위해 O/0, I/1 같은 혼동 문자를 줄일 수도 있지만 공간도 작아진다. 32개 문자로 6자리를 만들면 정확히 30비트다. 길이를 바꿀 수 있다면 Base36 13자리는 약 67.2비트다. 사용 편의와 혜택의 가치를 함께 놓고 길이를 정해야 한다.

발급 순서도 숨겨야 한다면​

난수+DB로 요구사항이 충족된다면 여기서 멈출 수 있다. 생성 단계에서 중복을 없애면서 발급 순서도 숨겨야 하는 경우에는 고유 ID를 6자리 코드로 바꾸는 방법을 비교한다.

변환얻는 성질남는 한계
Sequence → Base36범위 안의 ID를 서로 다른 코드로 변환00000Z 다음 000010처럼 순서 노출
Affine modular permutationID를 일대일로 섞음연속 출력의 차이에 선형 구조가 남음
AES 기반 FF1같은 키·tweak의 고정 길이 공간에서 키 기반 순열인증 태그가 아니며 DB 상태·추측 방어 필요

Sequence는 번호가 비어도 된다. rollback·충돌·장애 등으로 소비한 번호는 용량에 포함하며, 0≤id<N0\le id<N 범위를 벗어나면 실패시킨다. modulo로 되감으면 과거 코드가 다시 나타난다. Affine 변환의 큰 곱셈 상수나 소수는 보안을 보장하지 않는다. 증명과 실행 가능한 helper는 부록에 둔다.

FF1은 정해진 문자 수와 길이를 보존하는 암호화(FPE)다. AES 출력을 잘라내거나 modulo로 6자리에 맞추면 서로 다른 값이 같은 짧은 값으로 줄어들 수 있지만, FF1은 도메인 안에서 일대일 대응을 만든다. 이 예제는 radix 36, 길이 6, 버전별 AES 키, 고정 namespace tweak를 쓴다.

NIST SP 800-38G는 FF1을 규정한다. 2026-09-07 확인 기준 Rev.1 두 번째 공개 초안은 최종본이 아니며 최소 도메인 100만 조건을 둔다. 이 코드의 36636^6은 그보다 크지만, 최소 조건 충족이 온라인 리딤의 안전성 보장은 아니다.

FF1 복호화는 발급 확인이 아니다​

모든 형식상 올바른 6자리 Base36 문자열은 어떤 ID로 복호화된다. FF1은 인증 태그나 서명이 아니므로 decode()가 값을 반환했다는 이유로 혜택을 지급하면 안 된다. 실제 발급·활성·만료·대상 조건은 DB에서 확인해야 한다. 이 글의 기본 리딤 경로는 코드로 직접 조회하므로 FF1 복호화가 아예 필요하지 않다.

Tweak는 비밀이 아니어도 되지만 한 코드 공간에서 순열의 고유성을 유지하려면 고정한다. 키나 tweak가 바뀐 두 순열은 출력이 겹칠 수 있으므로 전역 UNIQUE(code)를 유지한다. 충돌이면 같은 ID를 재암호화하지 않고 새 ID를 예약한다. 키 버전별 코드 공간을 실제로 분리하는 설계도 대안이다.

서로 다른 키가 같은 한 입력을 우연히 같은 출력에 대응시킬 수도 있다. 따라서 ‘키가 바뀌면 모든 출력이 달라져야 한다’를 테스트 보장으로 삼지 않는다. 이는 한 키·고정 tweak 안에서의 일대일성과 모순되지 않는다.

직접 코드 조회만 한다면 이전 코드 사용을 위해 옛 FF1 키가 반드시 필요한 것은 아니다. 옛 키의 보관 기간은 코드 재생성·복호화·감사 요구에 따라 정한다. 복호화 기반 경로를 택한다면 사용할 키 버전을 코드나 별도 요청 문맥에서 어떻게 식별할지부터 설계해야 한다.

6자리 설계의 선택 기준은 이 역할 분담이다. 생성 고유성에는 수학과 DB를, 순서 은닉에는 필요한 경우 검토된 순열을, 사용 자격에는 발급 상태를, 추측 방어에는 길이·대상·시도 제한을 배치한다.

부록 A: 실행 예제와 실제 검증 범위​

Java 전체 코드, ID 예약과 DB 스키마

예제 파일은 FF1 변환 클래스, 기본 생성 함수와 검증, JDBC 발급 경로, 스키마, SQL 검증로 나눴다. 검증 클래스의 공개 고정 키는 테스트 전용이다.

공통 스키마는 method로 RANDOM과 FF1을 구분한다. RANDOM 행의 key_version은 NULL이고, FF1은 양의 버전을 넣어야 한다. VARCHAR(6)·ASCII 형식·사용 상태·횟수와 전역 고유성을 DB에서도 검사한다. 코드 원문 대신 별도 키의 HMAC을 조회 키로 저장할 수도 있다. 이때 FF1 키와 HMAC 키를 분리해야 하며, 무키 hash만으로는 31비트 공간의 오프라인 열거를 막을 수 없다.

FF1은 ID를 먼저 받아야 하므로 identity 자동 INSERT에 맡기지 않고 명시적 sequence를 예약한다.

-- 1. 원자적인 ID 예약; rollback으로 번호를 되돌리지 않는다.
SELECT nextval('redeem_id_seq') AS id;

-- 2. 애플리케이션에서 code = codec.encode(id).
-- 3. 같은 id, 해당 key_version과 함께 저장한다.
INSERT INTO redeem_code (id, code, method, key_version, expires_at)
VALUES (:id, :code, 'FF1', :key_version, :expires_at)
ON CONFLICT (code) DO NOTHING
RETURNING id;

반환 행이 없으면 1번부터 새 ID로 재시도하며 32번에서 종료한다. JDBC 예제는 이 세 단계를 연결한다. 호출자가 트랜잭션 commit/rollback을 책임지고, 고객에게 코드를 보내는 것은 commit 성공 뒤다. 예제는 만료된 행도 보관하므로 예약 수는 활성 수와 같지 않다.

FF1 엔진 입력은 ASCII 바이트가 아니라 0~35의 자리값 배열이다. Java의 Character.digit은 일부 비ASCII 입력도 받아들이므로 예제는 ASCII 범위를 직접 검사한다. 소문자 허용은 모든 진입점에서 같은 정규화 정책을 적용할 때만 추가한다. 엔진은 호출마다 새로 생성하며, 재사용 최적화는 thread-safety 확인과 측정이 필요하다.

이 lightweight API는 프로세스 안의 raw AES 키를 사용한다. 비반출 KMS/HSM 키가 자동 연결되는 것은 아니다. FF1을 직접 지원하는 provider 또는 메모리에서 데이터 키를 다루는 명시적 경계가 필요하다.

저장소 루트에서 다음과 같이 임시 폴더로 검증할 수 있다. 의존성 다운로드는 Maven Central을 사용한다.

REDEEM_CHECK_DIR=$(mktemp -d)
REDEEM_EXAMPLES=web/apps/loonshots-blog/static/examples/six-character-redeem-code
curl -fL -o "$REDEEM_CHECK_DIR/bcprov.jar" \
https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/1.85.2/bcprov-jdk18on-1.85.2.jar
javac -cp "$REDEEM_CHECK_DIR/bcprov.jar" -d "$REDEEM_CHECK_DIR" \
"$REDEEM_EXAMPLES/RedeemCodeFf1.java" \
"$REDEEM_EXAMPLES/VerifyCodes.java" "$REDEEM_EXAMPLES/IssueCode.java"
java -cp "$REDEEM_CHECK_DIR:$REDEEM_CHECK_DIR/bcprov.jar" VerifyCodes
cp "$REDEEM_EXAMPLES/schema.sql" "$REDEEM_EXAMPLES/verify-sql.mjs" "$REDEEM_CHECK_DIR/"
npm install --prefix "$REDEEM_CHECK_DIR" --ignore-scripts --no-audit --no-fund @electric-sql/pglite@0.5.8
node "$REDEEM_CHECK_DIR/verify-sql.mjs"

2026-09-07에 JDK 21.0.7·Bouncy Castle 1.85.2로 컴파일하고 NIST 공식 FF1 sample #1, 경계값·잘못된 ASCII·결정성·10만 건 고유성과 round trip을 확인했다. 이는 전체 공식 벡터 검증이나 운영 보안 평가가 아니다.

SQL은 PGlite 0.5.8의 PostgreSQL 18.3/WASM 엔진에서 스키마, 난수 INSERT·중복, ID 예약·버전 간 중복 처리, 반복 사용 거부·만료를 확인했다. SQL 테스트의 FF1 코드는 저장 흐름 검증용 고정 문자열이다. Java/JDBC와 실제 PostgreSQL 서버를 연결한 통합 시험, 다중 세션 동시 리딤, 외부 혜택 지급·키 관리·성능은 검증하지 않았다. 운영 적용 전에는 해당 경계를 별도로 시험해야 한다.

부록 B: 충돌 확률과 순열의 조건​

확률의 분모, 근호와 affine 변환 증명

kk는 누적 난수 후보 수, nn은 DB가 아직 예약한 코드 수, mm은 공격 경로의 활성 코드 수다. 세 기호는 다른 질문에 사용된다. 균등·독립 후보를 생성할 때 누적 충돌 확률의 근사는:

Pcollision≈1−exp⁡ ⁣(−k(k−1)2N)P_{\mathrm{collision}}\approx1-\exp\!\left(-\frac{k(k-1)}{2N}\right)
누적 후보 kk한 번 이상 충돌했을 근사 확률
10,0002.27%
50,00043.69%
55,00050.08%
100,00089.94%

k(k−1)k(k-1)을 k2k^2로 근사하면 50% 지점은 다음 근호식으로 읽을 수 있다.

k50≈2Nln⁡2≈54,933k_{50}\approx\sqrt{2N\ln 2}\approx54{,}933

그러나 다음 후보의 충돌과 성공까지의 평균 시도 횟수는 예약 수 nn을 사용한다.

Pnext=nNE[attempts]=11−n/N\begin{aligned} P_{\mathrm{next}}&=\frac{n}{N}\\ \mathbb{E}[\mathrm{attempts}]&=\frac{1}{1-n/N} \end{aligned}

공간이 가득 차면 발급이 불가능해진다. 무한 재시도 대신 점유율·sequence 소진을 관찰하고 길이 또는 실제로 분리되는 namespace를 확장해야 한다.

Affine 변환은 f(x)=(Ax+B) mod Nf(x)=(Ax+B)\bmod N이다. gcd⁡(A,N)=1\gcd(A,N)=1이면 역원이 있으므로 f(x)=f(y)f(x)=f(y)에서 x≡y(modN)x\equiv y\pmod N, 범위 안에서는 x=yx=y를 얻는다. 366=21231236^6=2^{12}3^{12}이므로 AA가 2와 3의 배수가 아니면 된다. 하지만 f(x+1)−f(x)≡A(modN)f(x+1)-f(x)\equiv A\pmod N이라는 선형 구조는 남는다. 큰 상수나 비밀 상수로 이 구조가 사라지지 않으므로 외부 bearer 코드의 보안 경계로 쓰지 않는다.

VerifyCodes.java의 affineCode는 같은 파일의 base36 helper를 호출한다. 상수를 바꾸면 Java long 곱셈 범위도 다시 점검해야 한다.