|
| 1 | +--- |
| 2 | +layout: "post" |
| 3 | +title: "LCG를 활용한 ID 셔플링" |
| 4 | +categories: |
| 5 | + - "개발" |
| 6 | +tags: |
| 7 | + - "LCG" |
| 8 | + - "Linear Congruential Generator" |
| 9 | + - "ID 셔플링" |
| 10 | + - "Base34" |
| 11 | + - "의사난수" |
| 12 | + - "ID 난독화" |
| 13 | + - "단축 URL" |
| 14 | +date: "2026-02-21 00:00:00" |
| 15 | +toc: true |
| 16 | +--- |
| 17 | + |
| 18 | +단일 데이터베이스를 쓸 경우 무난하게 사용할 수 있는 ID 생성 방식은 순차적으로 오르는 ID 를 사용하는 것이다. 이 방식의 아쉬운 점은 이전, 이후 id 가 유추가 가능하다는 것이다. |
| 19 | + |
| 20 | +이럴 때, 오늘 알아볼 LCG를 활용한 ID 셔플링을 활용하면 간단한 알고리즘으로 좀 더 데이터를 은닉할 수 있게 된다. (아래에서 설명하겠지만 완벽한 보안을 보장하는 것이 아니다.) |
| 21 | + |
| 22 | +## LCG란 무엇인가? |
| 23 | + |
| 24 | +**LCG(Linear Congruential Generator, 선형 합동 생성기)**는 의사난수(pseudo-random number)를 생성하는 알고리즘 중 하나이다. 가장 오래되고 널리 알려진 PRNG(Pseudo-Random Number Generator) 중 하나이다. |
| 25 | + |
| 26 | +"의사난수"라는 이름에서 알 수 있듯이, LCG는 진정한 난수가 아닌 결정론적(deterministic) 방식으로 난수처럼 보이는 수열을 생성한다. 동일한 시드(seed) 값을 사용하면 항상 동일한 수열이 생성된다. |
| 27 | + |
| 28 | +## LCG의 수학적 원리 |
| 29 | + |
| 30 | +원리는 간단한 편이다. |
| 31 | + |
| 32 | +LCG는 다음과 같은 점화식(recurrence relation)을 사용한다: |
| 33 | + |
| 34 | +$$ |
| 35 | +X_{n+1} = (a \cdot X_n + c) \mod m |
| 36 | +$$ |
| 37 | + |
| 38 | +각 변수의 의미는 다음과 같다: |
| 39 | + |
| 40 | +| 변수 | 이름 | 설명 | |
| 41 | +| --------- | ---------------- | ------------------------- | |
| 42 | +| $X_n$ | 현재 값 | 현재 난수 값 | |
| 43 | +| $X_{n+1}$ | 다음 값 | 생성될 다음 난수 값 | |
| 44 | +| $a$ | 승수(multiplier) | 곱해지는 상수 | |
| 45 | +| $c$ | 증분(increment) | 더해지는 상수 | |
| 46 | +| $m$ | 모듈러(modulus) | 나머지 연산에 사용되는 값 | |
| 47 | +| $X_0$ | 시드(seed) | 초기값 | |
| 48 | + |
| 49 | +### 작동 예시 |
| 50 | + |
| 51 | +간단한 예시로 LCG가 어떻게 동작하는지 살펴보자. |
| 52 | + |
| 53 | +- $a = 5$, $c = 3$, $m = 16$, $X_0 = 7$ 로 설정하면: |
| 54 | + |
| 55 | +| n | 계산 과정 | $X_n$ | |
| 56 | +| --- | --------------------------------------- | ----- | |
| 57 | +| 0 | 초기값 | 7 | |
| 58 | +| 1 | $(5 \times 7 + 3) \mod 16 = 38 \mod 16$ | 6 | |
| 59 | +| 2 | $(5 \times 6 + 3) \mod 16 = 33 \mod 16$ | 1 | |
| 60 | +| 3 | $(5 \times 1 + 3) \mod 16 = 8 \mod 16$ | 8 | |
| 61 | +| 4 | $(5 \times 8 + 3) \mod 16 = 43 \mod 16$ | 11 | |
| 62 | + |
| 63 | +이런 식으로 연속적인 난수 수열이 생성된다. |
| 64 | + |
| 65 | +## 좋은 파라미터 선택 기준 |
| 66 | + |
| 67 | +LCG 를 사용할 때에는 적절한 파라미터(a, c, m) 선택이 중요하다. |
| 68 | + |
| 69 | +### 주기(Period)란? |
| 70 | + |
| 71 | +LCG는 모듈러 연산을 사용하기 때문에 생성되는 수열은 결국 반복된다. **주기(period)**란 수열이 반복되기 전까지 생성되는 서로 다른 값의 개수를 말한다. |
| 72 | + |
| 73 | +예를 들어, 위의 예시($a = 5$, $c = 3$, $m = 16$)에서 수열을 계속 생성하면 어느 순간 이전에 나왔던 값이 다시 나타나고, 그 이후로는 같은 패턴이 반복된다. |
| 74 | + |
| 75 | +### 최대 주기(full period) |
| 76 | + |
| 77 | +최대 주기는 가능한 모든 값(0부터 $m-1$까지)이 한 번씩 나타나는 것을 의미한다. 즉, 주기가 $m$과 같을 때 최대 주기를 갖는다고 한다. 최대 주기를 가지면 생성되는 난수가 가장 균등하게 분포되므로, 좋은 LCG는 최대 주기를 갖도록 파라미터를 선택해야 한다. |
| 78 | + |
| 79 | +#### 최대 주기를 위한 조건(Hull-Dobell 정리) |
| 80 | + |
| 81 | +최대 주기를 얻기 위해서는 다음 조건을 모두 만족해야 한다: |
| 82 | + |
| 83 | +1. $c$와 $m$은 서로소(coprime)이어야 한다 |
| 84 | +2. $a - 1$은 $m$의 모든 소인수로 나누어 떨어져야 한다 |
| 85 | +3. $m$이 4의 배수이면, $a - 1$도 4의 배수여야 한다 |
| 86 | + |
| 87 | +### 실제 사용되는 파라미터 예시 |
| 88 | + |
| 89 | +| 사용처 | $m$ | $a$ | $c$ | |
| 90 | +| -------------------- | ------------ | ---------- | ------- | |
| 91 | +| glibc | $2^{31}$ | 1103515245 | 12345 | |
| 92 | +| Microsoft Visual C++ | $2^{32}$ | 214013 | 2531011 | |
| 93 | +| MINSTD | $2^{31} - 1$ | 48271 | 0 | |
| 94 | + |
| 95 | +더 많은 파라미터 예시는 [LCG#Parameters in common use](https://en.wikipedia.org/wiki/Linear_congruential_generator#Parameters_in_common_use) 에서 확인해볼 수 있다. |
| 96 | + |
| 97 | +## ID 셔플 함수로 활용하기 |
| 98 | + |
| 99 | +LCG 에 대해 알아보았으니 본격적으로 오늘 다뤄보고자 한 ID 셔플링에 대해 설명해보겠다. |
| 100 | + |
| 101 | +원래 LCG는 이전 결과값을 다음 입력으로 사용하여 수열을 만드는 용도이다. 하지만 LCG의 수식을 빌려와 순차적 ID를 셔플(shuffle)하는 함수로 활용할 수도 있다. |
| 102 | + |
| 103 | +$$f(id) = (a \cdot id + c) \mod m$$ |
| 104 | + |
| 105 | +이 방식은 순차적으로 증가하는 ID($0, 1, 2, 3, \ldots$)를 입력으로 넣어 난수처럼 보이는 값으로 변환한다: |
| 106 | + |
| 107 | +$$f(0), f(1), f(2), f(3), \ldots$$ |
| 108 | + |
| 109 | +> 참고: 이는 LCG 수열의 n번째 항을 직접 계산하는 것과는 다르다. 여기서는 단순히 LCG의 선형 변환 수식을 ID 셔플 용도로 활용하는 것이다. |
| 110 | +
|
| 111 | +## 구현 예시 |
| 112 | + |
| 113 | +### Java |
| 114 | + |
| 115 | +```java |
| 116 | +public class LCG { |
| 117 | + private final long a; |
| 118 | + private final long c; |
| 119 | + private final long m; |
| 120 | + |
| 121 | + public LCG(long a, long c, long m) { |
| 122 | + this.a = a; |
| 123 | + this.c = c; |
| 124 | + this.m = m; |
| 125 | + } |
| 126 | + |
| 127 | + // 직접 접근 방식: ID를 입력받아 변환된 값을 반환 |
| 128 | + public long transform(long id) { |
| 129 | + return (a * id + c) % m; |
| 130 | + } |
| 131 | + |
| 132 | + public static void main(String[] args) { |
| 133 | + // glibc 파라미터 사용 |
| 134 | + LCG lcg = new LCG(1103515245, 12345, (1L << 31)); |
| 135 | + |
| 136 | + // 순차 ID(0, 1, 2, ...)를 난수처럼 보이는 값으로 변환 |
| 137 | + for (int i = 0; i < 10; i++) { |
| 138 | + System.out.printf("ID %d -> %d%n", i, lcg.transform(i)); |
| 139 | + } |
| 140 | + } |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +#### 실행 결과 |
| 145 | + |
| 146 | +``` |
| 147 | +ID 0 -> 12345 |
| 148 | +ID 1 -> 1103527590 |
| 149 | +ID 2 -> 59559187 |
| 150 | +ID 3 -> 1163074432 |
| 151 | +ID 4 -> 119106029 |
| 152 | +ID 5 -> 1222621274 |
| 153 | +ID 6 -> 178652871 |
| 154 | +ID 7 -> 1282168116 |
| 155 | +ID 8 -> 238199713 |
| 156 | +ID 9 -> 1341714958 |
| 157 | +``` |
| 158 | + |
| 159 | +## Base34 변환 |
| 160 | + |
| 161 | +여기서 조금 더 난수처럼 보이고 싶다면 Base34 인코딩을 활용할 수 있다. Base34는 0-9(10개)와 A-Z에서 I, O를 제외한 24개 문자를 사용하여 총 34개의 문자로 숫자를 표현하는 방식이다. |
| 162 | + |
| 163 | +> Base34 에서 `I`는 숫자 `1`과, `O`는 숫자 `0`과 혼동되기 쉬워 제외한다. |
| 164 | +
|
| 165 | +### 변환 원리 |
| 166 | + |
| 167 | +10진수를 34진법으로 변환하는 과정은 다음과 같다: |
| 168 | + |
| 169 | +1. 숫자를 34로 나눈 나머지를 구한다 |
| 170 | +2. 나머지에 해당하는 문자를 기록한다 |
| 171 | +3. 몫이 0이 될 때까지 반복한다 |
| 172 | +4. 기록한 문자를 역순으로 나열한다 |
| 173 | + |
| 174 | +### 구현 예시 |
| 175 | + |
| 176 | +#### Java |
| 177 | + |
| 178 | +```java |
| 179 | +public class Base34 { |
| 180 | + private static final String CHARACTERS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"; |
| 181 | + // I, O 제외 (혼동 방지) |
| 182 | + |
| 183 | + public static String encode(long number) { |
| 184 | + if (number == 0) return "0"; |
| 185 | + |
| 186 | + StringBuilder result = new StringBuilder(); |
| 187 | + while (number > 0) { |
| 188 | + int remainder = (int) (number % 34); |
| 189 | + result.append(CHARACTERS.charAt(remainder)); |
| 190 | + number /= 34; |
| 191 | + } |
| 192 | + return result.reverse().toString(); |
| 193 | + } |
| 194 | + |
| 195 | + public static long decode(String encoded) { |
| 196 | + long result = 0; |
| 197 | + for (char c : encoded.toCharArray()) { |
| 198 | + result = result * 34 + CHARACTERS.indexOf(c); |
| 199 | + } |
| 200 | + return result; |
| 201 | + } |
| 202 | + |
| 203 | + public static void main(String[] args) { |
| 204 | + LCG lcg = new LCG(1103515245, 12345, (1L << 31)); |
| 205 | + |
| 206 | + // 순차 ID를 LCG로 변환 후 Base34 인코딩 |
| 207 | + for (int i = 0; i < 10; i++) { |
| 208 | + long value = lcg.transform(i); |
| 209 | + String encoded = encode(value); |
| 210 | + System.out.printf("ID %d -> %d -> %s%n", i, value, encoded); |
| 211 | + } |
| 212 | + } |
| 213 | +} |
| 214 | +``` |
| 215 | + |
| 216 | +### 출력 예시 |
| 217 | + |
| 218 | +``` |
| 219 | +ID 0 -> 12345 -> AP3 |
| 220 | +ID 1 -> 1103527590 -> Q9SQMU |
| 221 | +ID 2 -> 59559187 -> 1AKBST |
| 222 | +ID 3 -> 1163074432 -> RLBRRJ |
| 223 | +ID 4 -> 119106029 -> 2M4CWH |
| 224 | +ID 5 -> 1222621274 -> SWWSV8 |
| 225 | +ID 6 -> 178652871 -> 3XPE07 |
| 226 | +ID 7 -> 1282168116 -> U7FTYY |
| 227 | +ID 8 -> 238199713 -> 588F3X |
| 228 | +ID 9 -> 1341714958 -> VJ0V2N |
| 229 | +``` |
| 230 | + |
| 231 | +### 활용 예시 |
| 232 | + |
| 233 | +Base34로 인코딩된 LCG 출력은 다음과 같은 용도로 활용할 수 있다: |
| 234 | + |
| 235 | +- **단축 URL 생성**: 긴 숫자 ID를 짧은 문자열로 변환 |
| 236 | +- **고유 코드 생성**: 쿠폰 코드, 초대 코드 등 |
| 237 | +- **파일명 생성**: 임시 파일이나 업로드 파일의 고유 이름 |
| 238 | + |
| 239 | +## LCG의 장점과 단점 |
| 240 | + |
| 241 | +### 장점 |
| 242 | + |
| 243 | +- **빠른 속도**: 단순한 산술 연산만 사용하므로 매우 빠르다 |
| 244 | +- **적은 메모리**: 현재 상태 값 하나만 저장하면 된다 |
| 245 | +- **재현 가능성**: 동일한 시드를 사용하면 동일한 수열을 재현할 수 있다 |
| 246 | +- **구현 용이**: 알고리즘이 단순하여 구현하기 쉽다 |
| 247 | + |
| 248 | +### 단점 |
| 249 | + |
| 250 | +- **예측 가능성**: 연속된 몇 개의 값만 알면 전체 수열을 예측할 수 있다 |
| 251 | +- **낮은 차원에서의 상관관계**: 연속된 값들을 n차원 공간에 점으로 찍으면 초평면(hyperplane) 위에 놓이는 특성이 있다 (Marsaglia의 정리) |
| 252 | +- **제한된 주기**: 최대 주기가 $m$으로 제한된다 |
| 253 | + |
| 254 | +## 사용 시 주의사항 |
| 255 | + |
| 256 | +### 보안 목적으로 사용하면 안 된다 |
| 257 | + |
| 258 | +LCG는 **암호학적으로 안전하지 않다(cryptographically insecure)**. 단순히 숫자가 커졌다 작아졌다 하는 착시를 줄 뿐, 패턴 분석에 매우 취약하다. |
| 259 | + |
| 260 | +LCG는 선형성이 강하기 때문에, $a$와 $c$ 값을 모르더라도 몇 개의 (입력, 출력) 쌍만 알면 간단한 연립방정식으로 파라미터를 알아낼 수 있다. **실제 보안이 필요한 곳에서는 절대 사용하면 안 된다.** |
| 261 | + |
| 262 | +보안이 필요한 경우 CSPRNG(Cryptographically Secure PRNG)를 사용해야 한다. Java에서는 `SecureRandom`이 이를 제공한다. |
| 263 | + |
| 264 | +## 마무리 |
| 265 | + |
| 266 | +LCG는 단순하면서도 효과적인 의사난수 생성 알고리즘이다. 알아두면 유용하게 사용할 수 있을 것이다. |
| 267 | + |
| 268 | +## 참고 |
| 269 | + |
| 270 | +### 최대 주기 실제로 확인해보기 |
| 271 | + |
| 272 | +간단한 예시로 최대 주기가 실제로 어떻게 만들어지는지 확인해보겠다. |
| 273 | + |
| 274 | +$m = 8$, $a = 5$, $c = 1$로 설정하면 위에서 설명한 Hull-Dobell 조건을 모두 만족한다: |
| 275 | + |
| 276 | +- $c = 1$과 $m = 8$은 서로소 ✓ |
| 277 | +- $a - 1 = 4$는 $m$의 소인수 2로 나누어 떨어짐 ✓ |
| 278 | +- $m = 8$이 4의 배수이고, $a - 1 = 4$도 4의 배수 ✓ |
| 279 | + |
| 280 | +$X_0 = 0$부터 시작하여 수열을 생성하면 다음과 같이 나온다. |
| 281 | + |
| 282 | +| n | 계산 과정 | $X_n$ | |
| 283 | +| --- | ------------------------------------- | ----- | |
| 284 | +| 0 | 초기값 | 0 | |
| 285 | +| 1 | $(5 \times 0 + 1) \mod 8 = 1$ | 1 | |
| 286 | +| 2 | $(5 \times 1 + 1) \mod 8 = 6$ | 6 | |
| 287 | +| 3 | $(5 \times 6 + 1) \mod 8 = 31 \mod 8$ | 7 | |
| 288 | +| 4 | $(5 \times 7 + 1) \mod 8 = 36 \mod 8$ | 4 | |
| 289 | +| 5 | $(5 \times 4 + 1) \mod 8 = 21 \mod 8$ | 5 | |
| 290 | +| 6 | $(5 \times 5 + 1) \mod 8 = 26 \mod 8$ | 2 | |
| 291 | +| 7 | $(5 \times 2 + 1) \mod 8 = 11 \mod 8$ | 3 | |
| 292 | +| 8 | $(5 \times 3 + 1) \mod 8 = 16 \mod 8$ | **0** | |
| 293 | + |
| 294 | +8번째 단계에서 다시 0으로 돌아왔다. 생성된 수열 **0 → 1 → 6 → 7 → 4 → 5 → 2 → 3 → 0**을 보면, 0부터 7까지 모든 값이 정확히 한 번씩 나타난 후 다시 처음으로 돌아온다. |
| 295 | + |
| 296 | + |
| 297 | + |
| 298 | +이것이 바로 최대 주기($m = 8$)를 갖는 LCG의 특성이다. |
0 commit comments