본문으로 건너뛰기

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

· 약 17분
Bankware Global Engineering

6자리 영문 대문자와 숫자로 리딤코드를 만들어야 한다고 해보자.

36^6 = 2,176,782,336
log2(36^6) = 약 31.02비트

가능한 문자열은 21억 개가 넘는다. 처음 보면 중복과 추측을 모두 걱정하지 않아도 될 만큼 커 보인다. 하지만 실제 발급기를 설계하면 이 숫자 하나가 서로 다른 네 문제를 가리고 있다는 사실을 만나게 된다.

  • 형식: 코드는 정확히 6자리 ASCII 0-9, A-Z여야 한다.
  • 고유성: 서로 다른 발급 건이 같은 코드를 가져서는 안 된다.
  • 예측 저항: 관측한 코드로 다음 코드를 계산하기 어려워야 한다.
  • 유효성: 문자열이 형식에 맞는다는 것과 실제 발급되어 사용할 수 있다는 것은 다르다.

이 글은 여러 사람이 함께 쓰는 공개 프로모션 코드가 아니라, 발급 건마다 하나씩 만들어져 그 문자열을 가진 사람이 혜택을 청구하는 bearer형 리딤코드를 다룬다. 한 문제를 해결하는 수학이 다른 문제의 답이 되지는 않는다.

중복 없는 생성, 발급 순서 은닉과 유효한 리딤 판정은 서로 다른 요구사항이다.

첫 번째 설계: SecureRandom과 DB UNIQUE

가장 자연스러운 출발점은 암호학적으로 강한 난수 생성기로 여섯 글자를 뽑는 것이다. Java에서 보안 용도의 난수에는 java.util.Random이 아니라 java.security.SecureRandom을 사용한다.

private static final char[] ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();

private static final SecureRandom RNG = new SecureRandom();

static String randomCode() {
char[] code = new char[6];
for (int i = 0; i < code.length; i++) {
code[i] = ALPHABET[RNG.nextInt(ALPHABET.length)];
}
return new String(code);
}

SecureRandom은 암호학적으로 강한 난수 생성기를 제공한다. nextInt(36)0부터 35까지의 값을 고르게 선택한다. 임의의 바이트에 단순히 % 36을 적용하면 modulo bias가 생길 수 있으므로 피한다.

DB가 최종 판정을 내리게 한다

난수 생성만으로 중복이 불가능해지는 것은 아니다. code 컬럼에 UNIQUE 제약조건을 걸고, 충돌하면 새 후보를 만든다.

발급 전에 SELECT로 코드의 존재 여부를 확인하는 방식은 동시성 경쟁을 막지 못한다. 두 트랜잭션이 동시에 “없음”을 본 뒤 같은 코드를 INSERT할 수 있기 때문이다. 애플리케이션의 사전 조회가 아니라 데이터베이스가 원자적으로 최종 판정을 내려야 한다.

PostgreSQL이라면 고유성 예외를 잡아 이미 실패한 트랜잭션 안에서 계속 진행하기보다 ON CONFLICT DO NOTHING을 사용할 수 있다.

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

반환된 행이 없으면 새 난수 후보로 제한된 횟수만큼 재시도한다. MAX_RETRIES를 모두 사용했다면 조용히 무한 반복하지 않고 발급 실패로 처리한다.

for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
String candidate = randomCode();
OptionalLong insertedId = repository.tryInsert(candidate, expiresAt);

if (insertedId.isPresent()) {
return candidate;
}
}

throw new IllegalStateException("redeem-code space is too busy");

누적 충돌과 다음 한 건의 충돌은 다르다

n개의 난수 후보를 누적 생성했을 때 과거에 적어도 한 번 충돌했을 근사 확률은 Birthday Problem으로 계산할 수 있다.

p ≈ 1 - exp(-n(n-1) / (2N))
N = 36^6
누적 후보 수과거에 한 번 이상 충돌했을 확률
10,000약 2.27%
50,000약 43.69%
55,000약 50.08%
100,000약 89.94%

100,000개만 발급해도 90%라는 숫자는 커 보인다. 하지만 이는 지금까지 충돌이 한 번이라도 있었을 확률이다. 현재 n개의 코드가 사용 중일 때 다음 한 건의 충돌 확률은 n/N이다. 100,000개가 점유된 상태에서도 약 0.00459%다.

성공까지 필요한 평균 시도 횟수는 다음과 같다.

1 / (1 - n/N)

따라서 공간 사용량이 낮다면 난수와 UNIQUE 조합은 단순하고 실용적이다. 누적 birthday 확률만 보고 재시도 비용이 이미 높다고 해석해서는 안 된다. 반대로 공간이 가득 찰수록 다음 한 건의 충돌 확률과 재시도 비용은 실제로 커진다.

두 번째 설계: Sequence와 Base36

충돌 자체를 생성 단계에서 없애려면 데이터베이스 sequence나 다른 동시성 안전한 ID 할당기가 만든 값을 Base36으로 바꾸면 된다.

private static final long DOMAIN_SIZE = 2_176_782_336L;

static String base36(long id) {
if (id < 0 || id >= DOMAIN_SIZE) {
throw new IllegalArgumentException("ID outside six-char domain");
}

return String.format("%6s", Long.toString(id, 36))
.replace(' ', '0')
.toUpperCase(Locale.ROOT);
}

결과는 규칙적이다.

0 -> 000000
1 -> 000001
35 -> 00000Z
36 -> 000010

이 방식은 0 <= id < 36^6에서 모든 ID를 서로 다른 코드로 바꾼다. 중복 후보를 조회하거나 충돌을 재시도할 필요도 없다.

여기서 sequence가 반드시 빈틈없이 이어질 필요는 없다. 데이터베이스 sequence는 rollback, cache와 장애 복구 과정에서 번호가 비는 것을 허용할 수 있다. 리딤코드에 필요한 것은 gapless ID가 아니라, 모든 발급 주체가 공유하는 namespace 안에서 중복되지 않고 범위 안에 있는 ID다. 빈 번호도 도메인 용량은 소비하므로 용량 계획에는 포함해야 한다.

Base36은 암호화가 아니다

고유성은 해결했지만 발급 순서는 그대로 드러난다. 0000AZ를 본 사용자는 그다음 값이 0000B0일 수 있음을 바로 알 수 있다. 외부에서 코드를 관측하면 대략적인 발급량도 추정할 수 있다.

중복이 없다는 것과 추측하기 어렵다는 것은 서로 다른 요구사항이다.

입력 범위도 계약으로 다뤄야 한다. 6자리 Base36의 최댓값은 ZZZZZZ이고, 입력 범위는 0부터 36^6 - 1까지다. 범위를 넘어선 ID에 조용히 modulo를 적용하면 이전 코드가 다시 등장한다. 공간 소진 전에 코드 길이, alphabet 또는 분리된 namespace를 확장해야 한다.

세 번째 설계: Modular permutation

Sequence의 고유성을 유지하면서 출력 순서를 흐트러뜨리는 간단한 방법은 affine modular mapping이다.

codeNumber = (id * A + B) mod N
N = 36^6 = 2,176,782,336

다음 조건을 만족하면 이 함수는 전체 도메인의 순열이 된다.

gcd(A, N) = 1

A가 modulo N에서 역원을 가지기 때문이다. f(x) = f(y)라면 다음이 성립한다.

A(x - y) ≡ 0 (mod N)

양변에 A의 역원을 곱하면 x ≡ y (mod N)이고, 두 값이 모두 0..N-1에 있으므로 x = y다.

또한 다음과 같이 인수분해할 수 있다.

36^6 = 2^12 * 3^12

따라서 A가 2 또는 3의 배수가 아니면 gcd(A,N)=1이다. B는 일대일성에 영향을 주지 않는다.

static final long N = 2_176_782_336L;
static final long A = 1_000_000_007L;
static final long B = 123_456_789L;

static String affineCode(long id) {
if (id < 0 || id >= N) {
throw new IllegalArgumentException("id outside domain");
}

long value = (id * A + B) % N;
return toSixDigitBase36(value);
}

큰 multiplier는 보안 매개변수가 아니다

여기서 빠지기 쉬운 오해가 있다. A에 10억 이상의 큰 수나 큰 소수를 쓰면 예측 가능성이 크게 낮아진다고 생각하는 것이다.

순열에 필요한 조건은 크기가 아니라 gcd(A,N)=1이다. 큰 소수라고 출력이 더 균등해지는 것도 아니다. 선형 구조는 다음 식에 그대로 남아 있다.

f(id + 1) - f(id) ≡ A (mod N)

연속 입력의 출력 차이는 항상 같다. AB를 숨겨도 연속 샘플이나 입력과 출력의 대응을 관찰할 수 있다면 구조를 추론할 수 있다. 상수를 비밀로 두는 것과 검토된 키 기반 암호를 사용하는 것은 다르다.

Modular permutation은 데이터를 고르게 흩거나 보안 경계 밖의 ID를 섞는 데는 유용하다. 외부에 노출되는 bearer형 리딤 자격 증명의 예측 저항을 맡기기에는 부족하다.

네 번째 설계: 키 기반 암호학적 순열

발급 ID와 코드를 일대일로 대응시키면서, 키를 모르는 관찰자에게는 무작위 순열처럼 보이게 하려면 PRP(Pseudorandom Permutation)가 필요하다.

0 <= sequential ID < 36^6
|
v
AES 기반 FF1, radix 36, length 6, 고정 namespace
|
v
정확히 6자리인 Base36 리딤코드

AES 결과를 잘라 쓰면 안 되는 이유

AES의 블록 크기는 128비트지만 리딤코드 도메인은 36^6이다. 서로 다른 ID의 AES 결과를 잘라내거나 modulo로 줄여 6자리 코드에 넣으면 서로 다른 암호문이 같은 짧은 값으로 축약될 수 있다. 충돌이 다시 생기는 것이다.

FF1 같은 format-preserving encryption은 지정된 radix와 길이 안에서 순열을 만든다. NIST SP 800-38G 최종본은 FF1을 승인된 블록 암호 위에서 동작하는 FPE 방식으로 규정한다.

이 글에서는 다음 조건을 사용한다.

  • radix: 36
  • length: 6
  • domain size: 36^6 = 2,176,782,336
  • key: 암호학적으로 안전하게 생성하고 버전별로 관리하는 AES 키
  • tweak: 동일 코드 공간에서 고정된 namespace

NIST가 2025년에 공개한 SP 800-38G Rev.1 두 번째 공개 초안은 FF1의 최소 도메인을 1,000,000으로 강화했다. 36^6은 이 조건보다 크다. 다만 Rev.1은 이 글을 쓰는 시점에도 최종본이 아니라 공개 초안이다.

Tweak와 키를 바꾸면 새 순열이 된다

Tweak는 비밀일 필요가 없다. 그러나 하나의 전역 코드 공간에서 순열의 일대일성을 유지하려면 발급마다 바꾸지 않고 namespace별로 고정해야 한다.

키 또는 tweak가 달라지면 완전히 새로운 순열이 생긴다. 새 순열의 출력은 이전 순열에서 이미 발급한 코드와 충돌할 수 있다. 따라서 키 회전에는 다음 상태와 정책이 필요하다.

  • 발급 행에 key_version을 기록한다.
  • 이전 코드를 처리해야 하는 기간 동안 옛 키를 안전하게 유지한다.
  • 전역 UNIQUE(code)를 마지막 안전장치로 둔다.
  • 충돌이 발생하면 같은 ID를 다시 암호화하지 말고 새 ID를 할당하거나, 버전별로 겹치지 않는 코드 namespace를 설계한다.
  • 코드만으로 stateless decode가 필요하다면 어떤 키 버전을 적용할지 별도로 표현한다.

키 회전을 문서에 한 줄 적는 것으로 전역 고유성이 자동으로 유지되지는 않는다.

가장 중요한 경계: FF1은 코드를 인증하지 않는다

FF1이 전체 6자리 도메인의 순열이라는 말에는 놓치기 쉬운 반대 면이 있다.

모든 6자리 Base36 문자열은 어떤 ID로든 정상적으로 복호화된다.

따라서 다음 등식은 성립하지 않는다.

FF1 decode 성공
!= 실제 발급된 코드
!= 현재 활성 코드
!= 리딤 성공

FF1은 인증 태그나 서명이 아니다. 발급 순서를 숨기는 가역 변환이다. 서버가 decode(code)의 성공만 보고 혜택을 지급한다면 공격자가 입력한 임의의 형식상 올바른 코드도 모두 어떤 ID를 얻게 된다.

리딤 서버는 해당 코드 또는 복호화된 ID가 실제 발급됐는지 확인하고, 상태·만료·사용 횟수·대상 계정이나 주문까지 검증해야 한다. DB에서 코드로 행을 직접 조회하는 구조라면 리딤 경로에 decode가 필요하지 않을 수도 있다.

6자리 형식을 그대로 유지하면서 완전히 stateless한 진위 판정까지 넣기는 어렵다. 별도의 인증 태그를 추가하면 길이가 늘어나고, 발급 상태를 서버에 저장하면 더 이상 stateless가 아니다. 이 trade-off를 숨기지 않아야 한다.

Java 구현: Bouncy Castle FF1

다음 예제는 Bouncy Castle bcprov-jdk18on:1.85.2FPEFF1Engine을 사용한다.

implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
import java.nio.charset.StandardCharsets;
import java.util.Objects;

import org.bouncycastle.crypto.fpe.FPEFF1Engine;
import org.bouncycastle.crypto.params.FPEParameters;
import org.bouncycastle.crypto.params.KeyParameter;

public final class RedeemCodeFf1 {
private static final char[] ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();

private static final int RADIX = 36;
private static final int LENGTH = 6;

public static final long DOMAIN_SIZE = 2_176_782_336L;

private final byte[] aesKey;
private final byte[] tweak;

public RedeemCodeFf1(byte[] aesKey, String namespace) {
Objects.requireNonNull(aesKey, "aesKey");
Objects.requireNonNull(namespace, "namespace");

if (aesKey.length != 16
&& aesKey.length != 24
&& aesKey.length != 32) {
throw new IllegalArgumentException("invalid AES key length");
}
if (namespace.isEmpty()) {
throw new IllegalArgumentException("namespace must not be empty");
}

this.aesKey = aesKey.clone();
this.tweak = namespace.getBytes(StandardCharsets.UTF_8);
}

public String encode(long id) {
requireInDomain(id);

byte[] input = toDigits(id);
byte[] output = new byte[LENGTH];

FPEFF1Engine ff1 = new FPEFF1Engine();
ff1.init(true, new FPEParameters(
new KeyParameter(aesKey), RADIX, tweak));
ff1.processBlock(input, 0, input.length, output, 0);

return toCode(output);
}

public long decode(String code) {
byte[] input = parseAsciiCode(code);
byte[] output = new byte[LENGTH];

FPEFF1Engine ff1 = new FPEFF1Engine();
ff1.init(false, new FPEParameters(
new KeyParameter(aesKey), RADIX, tweak));
ff1.processBlock(input, 0, input.length, output, 0);

return fromDigits(output);
}

private static byte[] toDigits(long value) {
byte[] digits = new byte[LENGTH];

for (int i = LENGTH - 1; i >= 0; i--) {
digits[i] = (byte) (value % RADIX);
value /= RADIX;
}
return digits;
}

private static long fromDigits(byte[] digits) {
long value = 0;

for (byte digit : digits) {
value = value * RADIX + (digit & 0xff);
}
return value;
}

private static String toCode(byte[] digits) {
char[] chars = new char[LENGTH];

for (int i = 0; i < LENGTH; i++) {
chars[i] = ALPHABET[digits[i] & 0xff];
}
return new String(chars);
}

private static byte[] parseAsciiCode(String code) {
if (code == null || code.length() != LENGTH) {
throw new IllegalArgumentException(
"code must be exactly 6 ASCII characters");
}

byte[] digits = new byte[LENGTH];

for (int i = 0; i < LENGTH; i++) {
char c = code.charAt(i);
int digit;

if (c >= '0' && c <= '9') {
digit = c - '0';
} else if (c >= 'A' && c <= 'Z') {
digit = c - 'A' + 10;
} else {
throw new IllegalArgumentException(
"code must contain only ASCII 0-9 and A-Z");
}

digits[i] = (byte) digit;
}
return digits;
}

private static void requireInDomain(long id) {
if (id < 0 || id >= DOMAIN_SIZE) {
throw new IllegalArgumentException("id outside domain");
}
}
}

FPEFF1Engine의 입력은 ASCII 문자 자체가 아니라 0..35 범위의 radix digit 배열이다. 변환 계층에서 이 경계를 명시적으로 관리해야 한다.

Character.digit(c, 36)을 그대로 쓰면 소문자뿐 아니라 일부 전각 문자와 비ASCII 숫자도 받아들인다. DB가 ^[0-9A-Z]{6}$만 허용하는데 Java가 더 넓은 문자를 허용하면 입력 계층과 저장 계층의 계약이 달라진다. 위 예제는 ASCII 범위를 직접 검사한다. 소문자를 허용하고 싶다면 모든 진입점에서 먼저 대문자로 정규화한다는 별도의 정책을 세워야 한다.

그리고 다시 강조하면 decode()가 값을 반환했다는 사실은 코드가 발급되었거나 활성 상태라는 뜻이 아니다.

검증해야 할 성질

  • 경계값 0, 1, N-2, N-1을 정상 처리한다.
  • -1N은 거부한다.
  • 여러 표본에서 decode(encode(id)) == id가 성립한다.
  • 현실적인 대규모 표본에서 중복이 없다.
  • 키 또는 namespace가 달라지면 출력도 달라진다.
  • 소문자, 전각 문자, 비ASCII 숫자와 잘못된 길이를 거부한다.
  • 공식 FF1 테스트 벡터와 라이브러리 버전별 회귀 테스트를 CI에 둔다.
  • 임의의 형식상 올바른 코드를 decode해도 발급 상태 검증 전에는 리딤되지 않는다.

이 초안의 예제는 JDK 21과 Bouncy Castle 1.85.2로 컴파일했고, 0부터 99,999까지 10만 건에 대해 중복 없음과 round trip을 확인했다. 이는 예제의 기본 성질을 확인한 것이지 성능 benchmark나 운영 보안 검증은 아니다.

예제는 설명을 위해 호출마다 새 엔진을 만든다. 고성능 구현은 라이브러리의 thread-safety 계약을 확인하고 측정한 뒤 안전한 인스턴스 관리 방식을 정해야 한다.

키 관리 표현에도 주의가 필요하다. 이 lightweight API는 애플리케이션 프로세스 안에서 raw AES key bytes를 사용한다. 일반적인 비반출 KMS/HSM 키가 이 코드에 자동으로 연결되는 것은 아니다. FF1을 직접 지원하는 provider/HSM을 사용하거나, secret manager 또는 envelope encryption으로 전달된 data key를 메모리에서 다루는 경계를 별도로 설계해야 한다. 테스트의 고정 키를 운영 키로 재사용해서는 안 된다.

DB UNIQUE는 FF1을 사용해도 남긴다

한 키와 고정 tweak 아래에서 FF1이 이론적으로 일대일이라고 해서 데이터베이스 안전장치를 제거할 이유는 없다. 다음 문제는 수학 밖에서 발생한다.

  • 잘못된 key_version
  • namespace 혼용
  • 범위 검사 버그
  • 마이그레이션 실수
  • 키 회전 중 이전 순열과의 충돌

UNIQUE(code)는 이런 결함을 빠르게 드러내는 마지막 방어선이다.

CREATE TABLE redeem_code (
id BIGINT GENERATED BY DEFAULT AS IDENTITY
(START WITH 0 MINVALUE 0 MAXVALUE 2176782335 NO CYCLE)
PRIMARY KEY,
code VARCHAR(6) NOT NULL,
key_version SMALLINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
expires_at TIMESTAMPTZ NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,

CONSTRAINT uq_redeem_code UNIQUE (code),

CONSTRAINT ck_id_domain CHECK (
id >= 0 AND id < 2176782336
),

CONSTRAINT ck_code_format CHECK (
code ~ '^[0-9A-Z]{6}$'
),

CONSTRAINT ck_status CHECK (
status IN ('ACTIVE', 'CONSUMED', 'REVOKED')
),

CONSTRAINT ck_usage CHECK (
max_uses > 0
AND used_count >= 0
AND used_count <= max_uses
)
);

PostgreSQL의 UNIQUE 제약조건은 고유 B-tree 인덱스를 자동으로 만든다. codeNOT NULL로 두고 ASCII 형식을 DB에서도 확인한다. 고정 길이 blank-padding 의미가 필요한 값이 아니므로 여기서는 CHAR(6)보다 VARCHAR(6)을 사용했다.

원문 코드를 DB에 저장하면 DB 유출 시 아직 활성 상태인 코드도 함께 노출된다. 위협 모델상 이를 피해야 한다면 별도 키로 계산한 HMAC을 lookup 값으로 저장하는 방안을 검토할 수 있다. HMAC 키와 FF1 키는 분리하고, 31비트 공간 때문에 단순한 무키 hash만으로는 오프라인 전수조사를 막을 수 없다는 점도 함께 고려해야 한다.

사용 횟수는 원자적으로 바꾼다

1회용 코드를 먼저 SELECT하고 나중에 UPDATE하면 동시 요청이 모두 성공할 수 있다. 조건부 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;

조건을 만족해 반환된 행이 있을 때만 리딤 상태 변경에 성공한 것이다. 실제 혜택 지급도 같은 트랜잭션에 넣거나, 외부 시스템 호출이 필요하다면 idempotency key와 outbox 같은 명시적인 경계로 중복 지급을 막아야 한다.

대상 계정이나 주문에 코드를 묶는다면 해당 조건도 같은 원자적 판정에 포함한다. 코드 존재 여부, 만료, 이미 사용됨을 외부 오류 메시지에서 지나치게 구분하면 공격자에게 유효성 oracle을 제공할 수 있으므로 응답 정책도 함께 설계한다.

31비트 코드 공간의 brute-force 한계

FF1은 관측한 코드에서 다음 코드를 계산하기 어렵게 만든다. 하지만 공격자는 다음 코드를 계산하지 않고 6자리 후보를 직접 시도할 수 있다.

암호 키의 강도와 온라인 코드 공간의 크기를 혼동하면 안 된다. AES 키가 128비트 또는 256비트여도 출력 후보는 36^6, 즉 약 31.02비트다.

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

특정 코드 하나를 중복 없이 전수 탐색할 때는 평균 약 10.88억 개의 후보가 필요하다. 하지만 공격자가 아무 활성 코드나 맞히면 되는 경우에는 동시에 활성화된 코드가 많을수록 공격이 훨씬 쉬워진다.

따라서 고가치 보상에 6자리 Base36 코드 하나만을 독립된 비밀로 사용하는 것은 위험할 수 있다.

온라인 방어가 필요한 이유

인증용 out-of-band 코드에 관한 OWASP ASVS 5.0 V6.6.3은 rate limiting을 요구하고 64비트 이상의 엔트로피도 고려하도록 한다. 모든 리딤코드에 그대로 적용되는 요건은 아니지만, 고가치 bearer 코드의 위험을 비교하는 데 유용하다. 6자리 Base36은 그 절반에도 미치지 못하는 약 31비트다.

형식을 반드시 6자리로 유지해야 한다면 다음 방어를 결합해야 한다.

  • IP뿐 아니라 계정, 디바이스, 세션, 캠페인과 대상 주문별 시도 한도
  • 실패 누적에 따른 점진적 지연, CAPTCHA와 임시 차단
  • 업무상 필요한 만큼의 짧은 만료 시간
  • 기본 1회 사용과 원자적인 사용 횟수 처리
  • 특정 계정, 주문, 캠페인 또는 상품에 대한 바인딩
  • 존재 여부를 과도하게 구분하지 않는 오류 응답
  • 원문 코드가 남지 않는 애플리케이션·분석 로그
  • 실패율, 고유 IP·디바이스 수, 계정·캠페인별 시도량과 반복 패턴에 대한 경보

사용자가 입력하는 과정에서 O/0, I/1 혼동을 줄이기 위해 alphabet을 줄일 수도 있다. 다만 alphabet이 작아지면 전체 공간도 함께 줄어든다. 예를 들어 32개 문자의 6자리 코드는 정확히 30비트다.

제품 요구사항을 바꿀 수 있다면 길이를 늘리는 것이 brute force 저항을 높이는 가장 직접적인 방법이다. 64비트에 가까운 공간이 필요하다면 Base36으로는 13자리부터 약 67.2비트가 된다. 사용성, 값의 크기와 위협 모델을 함께 놓고 결정해야 한다.

무엇을 선택할 것인가

항상 FF1이 정답은 아니다.

조건적합한 출발점
발급량이 작고 DB 기반 상태 검증이 가능함SecureRandom + UNIQUE
외부에 노출되지 않는 내부 식별자Sequence + Base36
보안 경계가 아니라 순서를 골고루 흩는 것이 목적Affine modular permutation
고정 6자리, 대량 발급, 순서 은닉이 함께 필요함FF1 + DB 발급 상태 검증
고가치 보상 또는 동시 활성 코드가 많음길이 확장 + 대상 바인딩 + 다축 rate limit

FF1의 실질적인 장점은 sequence가 주는 충돌 없는 입력을 유지하면서 발급 순서를 숨긴다는 데 있다. DB 행과 리딤 상태가 어차피 필요하고 공간 점유율이 낮다면, SecureRandom + UNIQUE가 더 단순하고 충분할 수 있다. 암호 구성과 키 회전의 복잡성을 도입할 이유가 실제 요구사항에 있는지 먼저 확인해야 한다.

결론

6자리 리딤코드 설계의 핵심은 하나의 알고리즘을 고르는 일이 아니라 요구사항을 분리하는 일이다.

  • SecureRandom은 예측하기 어려운 후보를 만들지만 중복을 원천적으로 없애지는 않는다.
  • Sequence는 중복을 없애지만 발급 순서를 숨기지 않는다.
  • Modular permutation은 완벽한 일대일 매핑을 만들지만 선형 구조를 제거하지 못한다.
  • FF1은 키 기반 순열을 제공하지만 코드의 진위를 인증하지 않는다.
  • DB 상태 검증은 실제 발급·만료·사용 가능 여부를 판정한다.
  • 길이, 대상 바인딩과 rate limit은 31비트 공간의 온라인 공격을 방어한다.

큰 multiplier가 보안을 만들지 않고, 긴 AES 키가 6자리 출력 공간을 넓혀주지도 않는다. 검토된 암호 구성도 유효성 상태와 운영 정책을 대신하지 않는다.

한 문장으로 정리하면 다음과 같다.

중복 없는 발급은 수학과 데이터베이스로, 순서 은닉은 검토된 키 기반 순열로, 유효성은 발급 상태로, brute force 방어는 길이와 운영 정책으로 완성한다.

참고 자료

  1. NIST SP 800-38G — Methods for Format-Preserving Encryption
  2. NIST SP 800-38G Rev.1 — Second Public Draft
  3. Bouncy Castle Java FPE API
  4. Bouncy Castle Java releases
  5. OWASP ASVS 5.0 — Code-based mechanism brute-force protection

이 글은 설계와 학습을 위한 기술 자료다. 실제 결제, 자산, 인증 시스템에서는 위협 모델, 규제, 장애 복구, 키 관리와 사고 대응 정책을 포함한 별도의 보안 검토가 필요하다.