본문 바로가기
Web

[JavaScript]자바스크립트로 중복없는 쿠폰번호 생성(1)_아이디어

by 다봄이 2020. 8. 6.

웹 서비스 스타트업에서 인턴으로 백엔드 업무를 수행할 때, 서비스에 필요한 여러 기능을 처리하는 백엔드 API 개발을 주 업무로 맡았었다. 그 중 하나가 쿠폰 번호 생성기이다.

좋은 쿠폰 번호(난수) 생성?

쿠폰 번호 생성이라 하면, 랜덤함수 등을 이용해 난수를 만드는 것을 기본적으로 떠올릴 수 있을 것이다.

그러나 보안적인 관점에서, 아주 작은 확률일지라도 중복되는 번호가 생성될 확률이 있거나, 랜덤 함수에서 사용하는 pseudo-random 주기가 짧을수록 문제가 발생한다.

따라서 쿠폰번호를 랜덤하게 생성하되, 중복에 대한 충돌 검사를 실행한 후 최종 랜덤 번호를 DB에 입력하기로 하였다.

  • 해야 할 일
    • 8자리 랜덤 번호 생성
    • 충돌검사

개발에 앞서 아이디어를 얻기 위해 구글링을 하였는데, 많은 검색 결과가 단순히 널리 알려진 랜덤 함수(C에서의 rand, srand와 같은 함수였는데 아마 math.random이었던 것 같다.)를 사용하여 번호를 생성하고 DB에 저장할 때 중복이 있는지를 검사하여 중복이 있는 경우 빼고 저장하는 로직을 따르고 있었다.

뭔가 맘에 안든다.. 그러다 발견한 것이 xorshift 랜덤 함수이다.

xorshift+

const state = [1212121212, 2323232323]

function xorshift128plus(){

    let s1 = state[0];
    let s0 = state[1];

    state[0] = s0;

    s1 ^= s1 << 23;
    s1 ^= s1 >> 17;
    s1 ^= s0;

    state[1] = s1;

    console.log('현재 상태 -> ', state);

    return state[0] + state[1];
}

console.log('초기 상태 -> ', state);
for(let i=0; i<5; i++){
    console.log('생성된 난수 -> ', xorshift128plus());
}

xorshift에서 약간 변형된 xorshift+의 기본 로직은 위와 같다. 출력을 콘솔로 찍어서 보면 특징을 쉽게 파악할 수 있다. xorshift+ 128bit를 사용하였다.

  • 출력 결과

            초기 상태 ->  [ 1212121212, 2323232323 ]
          현재 상태 ->  [ 2323232323, -62517984 ]
          생성된 난수 ->  2260714339
          현재 상태 ->  [ -62517984, -1472172385 ]
          생성된 난수 ->  -1534690369
          현재 상태 ->  [ -1472172385, -1006217828 ]
          생성된 난수 ->  -2478390213
          현재 상태 ->  [ -1006217828, -600192797 ]
          생성된 난수 ->  -1606410625
          현재 상태 ->  [ -600192797, -700475268 ]
          생성된 난수 ->  -1300668065

    초기 상태가 주어지면, 초기 상태의 두 번째 시드를 받아 인덱스 [0]으로 넘기고, 인덱스 [1]에는 새로운 난수를 할당한 후 두 원소를 더하는 것이 xorshift+에서 난수를 생성하는 방법임을 쉽게 알 수 있다.

여기에 unix timestamp와 선형합동법을 이용한 랜덤함수를 이용해 만든 난수를 각각 초기 상태의 시드로 할당하여 난수를 만들고자 했다.

  • getTime()
    • 자바스크립트에서 getTime 메서드는 총 13자리로 이루어진 표준시를 반환한다. 단위는 millisecond이다
    • 10자리로 이루어진 unix timestamp를 자바스크립트에서 구하는 가장 간단하고 널리 알려진 방법은 getTime 메서드에 나누기 1000을 하는 것이다.

선형합동법

function rand(x) {
    return (((x * a) + c) % m);
}

function getRandomNumbers(count) {

    let init = new Date().getTime();

    let randNums = [init];
    for (let i = 0; i < count; i++) {
        randNums.push(rand(randNums[i]));
    }

    randNums.shift();
    console.log('randomNum list', randNums);

    return randNums;
}

선형합동법은 C의 rand에서도 사용되는 알고리즘이라 한다.

ANSI C 표준에서 m=231 , a =1103515245, c = 12345이다.

  • 출력 예시

        randomNum list [ 16805742822555648,
        758909756151693300,
        1253687209807052800,
        3965235303702069000,
        2168393610381230000,
        4485669341500539000,
        265591981491617800,
        2262039015718912000,
        1595424907007623200,
        1385020162893152300 ]

    count 인자를 10으로 주고 함수를 돌리면 10개의 난수 수열을 반환하는 것을 알 수 있다.

이 함수를 이용해 만든 수열 중 임의의 값 하나를 반환하여 시드 하나로 주고, 위에서 구한 unix time값을 시드 하나로 주어 초기 상태를 할당하여 쿠폰 데이터를 만드려고 했다.

그러나 이 방법에는 문제가 있었다. 우선 비트 연산이 들어가 있기 때문에 모든 데이터는 비트 단위로 다뤄야 한다.

그러나 128비트라는 어마무시한 단위에 계산이 너무 머리 아파진다.(비트 연산 너무 어려워...)

또한, 이 방법으로 생성한 코드가 보안 관점에서 좋은 코드라고 할 수 있을까?하는 의문이 들었다. 그래서 생각해 낸 방법이 해시 알고리즘의 일종인 crc32 encoding이다.

CRC-32 encoding

$ npm install crc-32

우선 crc32를 사용하기 위해서 모듈을 설치하고 가져와야 한다.

그러나 이 모듈을 설치하면 signed 형태로 인코딩되어 대부분의 경우 데이터가 음수로 출력된다.

비트 연산을 통해 음수를 제거하는 방법도 있지만, 다른 모듈을 찾아 이것을 설치하였다.

$ npm install buffer-crc32

이 모듈은 buffer, string의 형태로만 입력을 받아야 한다.

간단한 테스트용 코드

const CRC32 = require('buffer-crc32'); //unsigned crc를 출력하기 위한 모듈.

...
function makeCouponNum(count, amount) {

    let coupons = [];

    for (let i = 0; i < count; i++) {
        let str = new makeCouponStr(getUnixTime(), 'launching', 'alwaysblue15@naver.com');
        coupons.push([str.seed1, str.seed2, str.seed3]);
    }

    let x = coupons.join('-');
    let y = x.split('-');

    for (let i = 0; i < y.length; i++) {
        let buf = Buffer.from(y[i].toString(16));

        let num = CRC32.unsigned(buf);
        console.log('16진수 ', num.toString(16));
    }
}

초기 쿠폰 데이터는 리스트 안에 한 쿠폰에 대한 데이터를 또 리스트 형태로 입력받은 형태를 띠고 있었다.

쿠폰데이터 = [쿠폰1[seed1, seed2, seed3], 쿠폰2[seed1, seed2, seed3], 쿠폰3[seed1, seed2, seed3]]

쿠폰데이터[0]=쿠폰1, 쿠폰데이터[1]=쿠폰2, 쿠폰데이터[3]=쿠폰3

따라서 string으로 변환할 때 다른 쿠폰끼리는 구분할 수 있도록 인덱스 사이에 구분자 '-'을 두기 위해 .join('-')을 사용한 후, 쿠폰 각각의 정보를 인코딩하기 위해 .split('-')으로 파싱하여 저장했다.

let buf = Buffer.from(y[i].toString(16));
CRC32.unsigned(buf);

그러면 이 구문을 통해 쿠폰 각각 정보를 인코딩할 수 있게 된다.

이제 테스트를 위해 1000원짜리 쿠폰 100장을 출력해보면

4e5b50f7
4e5b50f7
4e5b50f7
4e5b50f7
...
4e5b50f7
4e5b50f7

100장이 모두 중복된 데이터이다.

문제가 어디서 발생하는지 보기 위해 원 쿠폰 데이터인 coupons 리스트를 출력해보면

[ [ 1579712248910, 'launching', 'alwaysblue15@naver.com' ],
  [ 1579712248910, 'launching', 'alwaysblue15@naver.com' ],
  [ 1579712248910, 'launching', 'alwaysblue15@naver.com' ],
  [ 1579712248910, 'launching', 'alwaysblue15@naver.com' ],
...
  [ 1579712248910, 'launching', 'alwaysblue15@naver.com' ],
  [ 1579712248910, 'launching', 'alwaysblue15@naver.com' ] ]

쿠폰의 데이터로 사용된 unixtime이 모두 동일하게 출력된 것을 알 수 있다.

컴퓨터의 성능이 쿠폰 100장 정도는 같은 밀리초 시간 안에 찍어낼 수 있는 것이다.

이 문제를 해결하기 위해 쿠폰의 일련번호를 추가하였다.

[ [ 0, 1579712553892, 'launching', 'alwaysblue15@naver.com' ],
  [ 1, 1579712553892, 'launching', 'alwaysblue15@naver.com' ],
  [ 2, 1579712553892, 'launching', 'alwaysblue15@naver.com' ],
  [ 3, 1579712553892, 'launching', 'alwaysblue15@naver.com' ],
  [ 4, 1579712553892, 'launching', 'alwaysblue15@naver.com' ],
...

이렇게 for문을 도는 동안 일련번호를 갖도록 하면 절대 같은 데이터는 나올 수 없게 될 것이다.

여기에 해시테이블을 이용하여 정보를 저장함으로써 중복 검사를 할 수 있도록 하였다.

HashMap(java)와 Map(JS)

처음에는 자바스크립트에 Map이라는 이름으로 해시 테이블을 제공하고 있는 것을 몰랐기 때문에, 자바의 HashMap 구조를 자바스크립트에서 사용하기 위해 함수를 선언하였다.

//hashmap 선언하기
function HashMap() {
    this.map = new Object();
}

//hashmap으로써의 기능 추가
HashMap.prototype = {
    put: function (key, value) {
        this.map[key] = value;
    },
    get: function (key) {
        return this.map[key];
    },
    getAll: function () {
        return this.map;
    },
    getKeys: function () {
        let keys = new Array();
        for (i in this.map) {
            keys.push(i);
        }
        return keys;
    },
    clear: function () {
        this.map = new Object();
    },
    remove: function (key) {
        delete this.map[key];
    },
    containsKey: function (key) {
        for (i in this.map) {
            if (i == key) {
                return true;
            }
        }
        return false;
    },
    isEmpty: function () {
        return (this.map, size() == 0);
    }
}

처음에는 사람들이 array를 이용하여 해시맵을 구현하는 것을 발견하여 array로 구현하였다. 그러나 해시맵의 key에는 interger가 들어갈 수 없다고 한다. 따라서 해시맵을 object로 선언하였다.

해시 테이블 자체가 key의 중복이 발생할 경우 해당 key의 value를 덮어 쓰기 때문에 일단 중복이 발생한 경우에는 콘솔이 출력되도록 하였다.

이제 중복 발생이 몇 번 출력 되었는지를 계산하여 중복 발생을 뺀 만큼 쿠폰의 매수를 DB에 등록하면 될 것 같다.

[e7ed0a04: 1000,
  da5ce6d8: 1000,
  9c8ed3bc: 1000,
  a13f3f60: 1000,
  112ab974: 1000,
  2c9b55a8: 1000,
...]

쿠폰번호를 key, 금액을 value로 하는 object가 생성되었다.

microtime 반환하기

같은 시각에 동시에 100장의 쿠폰 데이터를 찍어내는 것이 사용한 시간의 단위가 너무 짧아서인가? 하는 의문이 들었다. stack overflow를 뒤진 결과 microsecond 단위로 시간을 구할 수 있는 방법이 있었다.

$ npm install microtime
const microTime = require('microtime');

function getUnixTime() {

    //let init = new Date().getTime(); //13자리
    let init = microTime.now();

    return init;
}

터미널에서 npm microtime 모듈을 설치하고 require를 통해 불러온 후, getUnixTime 함수 내부의 리턴값을 13

자리 getTime 메서드 값이 아닌 microTime값으로 바꿔주었다.

[ [ 0, 1579746153306836, 'launching', 'alwaysblue15@naver.com' ],
  [ 1, 1579746153308339, 'launching', 'alwaysblue15@naver.com' ],
  [ 2, 1579746153308359, 'launching', 'alwaysblue15@naver.com' ],
  [ 3, 1579746153308363, 'launching', 'alwaysblue15@naver.com' ],
  [ 4, 1579746153308365, 'launching', 'alwaysblue15@naver.com' ],
  [ 5, 1579746153308369, 'launching', 'alwaysblue15@naver.com' ],
  [ 6, 1579746153308370, 'launching', 'alwaysblue15@naver.com' ],
  [ 7, 1579746153308373, 'launching', 'alwaysblue15@naver.com' ],
  [ 8, 1579746153308375, 'launching', 'alwaysblue15@naver.com' ],
  [ 9, 1579746153308378, 'launching', 'alwaysblue15@naver.com' ],
  [ 10, 1579746153308380, 'launching', 'alwaysblue15@naver.com' ],
 ...

milisecond 단위로 시간을 반환했을 때와 달리 시간이 하나도 겹치지 않는 것을 확인할 수 있다.

댓글