지그재그 앱을 사용하는 사용자를 분석할 때 사용하기 위해서 최근 업데이트에 나이를 입력받는 화면이 추가되었습니다.

입력은 나이를 받지만 나이는 매년 달라지기 때문에 고정된 값인 태어난 년도로 변환하여 저장하고 있습니다. 그런데 대부분은 정상적인 년도가 들어오는데 일부 사용자의 태어난 년도가 10이하 또는 2500이상인 문제가 있었습니다.

처음에는 사용자가 게임등을 위해 핸드폰의 시간을 변경해서 발생했다고 생각했습니다. 하지만 값이 너무 튀었고, 또 그렇게 값이 다양하지는 않았습니다.

그러던 중에 값이 이상한 경우는 모두 iOS라는 사실을 깨달았습니다. 그래도 짐작가는 것은 없던 차에 값이 2500~2600사이라는 것을 보고 혹시나 해서 ‘ios year 2500’이라는 검색어로 검색을 해봤습니다. 그랬더니 my iPad my set year is 2558 BE?라는 문서가 딱 처음에 나왔습니다.

결론적으로 iOS는 그레고리언 달력 외에도 일본력과 불교력을 지원합니다. 올해가 일본력으로 헤이세이 28년이기 때문에 나이를 20으로 입력하면 태어난 년도가 9가 됩니다. 불기로는 올해가 2560년이고 태어난 20살은 태어난 년도가 2541이 됩니다.

이것을 깨닫고 다시 태어난 년도가 이상한 사용자의 사용언어를 살펴보니 일본력은 일본어 사용자, 불교력은 태국어등 사용자로 나왔습니다. 지극히 한국과 한국어에 특화된 지그재그 서비스라고 생각하고 있었는데 드물지만 외국어 사용자가 있다는 사실이 놀라웠습니다.

기술적으로는

let calendar = Calendar.current // <--
let components = calendar.dateComponents([.year], from: Date())
let currentYear = components.year ?? 2016

로 되어 있던 것을

let calendar = Calendar(identifier: .gregorian)

로 변경했더니 사용자가 설정한 캘린더와 상관없이 기대한 값이 반환되었습니다.

혹시 유사한 증상이 있을 경우 한번 의심해보시면 좋을 것 같습니다.

지그재그에서는 현재 하루에 수천만개의 사용자 로그가 쌓이고 있습니다. 그리고 이 로그를 분석해 사용자가 얼마나 쇼핑몰에 가입을 하는지, 주문을 얼마나 하는지 살피고 있습니다.

그런데 지그재그가 지원하는 수많은 쇼핑몰은 다양한 솔루션을 사용하고 있고, 그에 따라 패턴도 전부 제각각입니다. 따라서 어떤 로그가 가입 페이지인지, 주문 페이지인지 확인하기 위해서 모든 솔루션의 패턴과 대조를 해야 합니다.

오늘은 이러한 대조를 어떻게 하고 있는지 살펴보겠습니다. 분석은 여러가지 언어로 하고 있지만, 여기서는 JavaScript를 살펴보겠습니다.

JavaScript에서 어떤 문자열이 패턴을 포함하고 있는지 검사하는 방법은 다음과 같은 것들이 있습니다. String.prototype.indexOf, String.prototype.includes, String.prototype.match, RegExp.prototype.test.

우선 10만개짜리 로그들을 놓고 간단한 문자열 포함 여부 검사를 각 방법으로 해서 시간을 비교해봤습니다.

fs = require 'fs'
logs = fs.readFileSync('logs', 'utf-8').split('\n')

run = (name, fn) ->
  start = Date.now()
  for i in [0...1000]
    fn()
  console.log "#{name} - #{Date.now()-start}ms"

run 'indexOf', ->
  count = 0
  for log in logs
    if log.indexOf('o') > 0
      count++

run 'includes', ->
  count = 0
  for log in logs
    if log.includes 'o'
      count++

run 'test', ->
  count = 0
  for log in logs
    if /o/.test log
      count++

run 'match', ->
  count = 0
  for log in logs
    if log.match /o/
      count++

다음은 그 결과입니다.

indexOf - 6517ms
includes - 7247ms
test - 13539ms
match - 14369ms

속도 차이가 많아보입니다.

이번에는 검색 패턴을 좀더 실제에 가깝게 해봤습니다.

run 'indexOf', ->
  count = 0
  for log in logs
    if log.indexOf('join.html') > 0
      count++

run 'includes', ->
  count = 0
  for log in logs
    if log.includes 'join.html'
      count++

run 'test', ->
  count = 0
  for log in logs
    if /join\.html/.test log
      count++

run 'match', ->
  count = 0
  for log in logs
    if log.match /join\.html/
      count++

다음은 그 결과입니다.

indexOf - 6812ms
includes - 7947ms
test - 10420ms
match - 10943ms

indexOf, includes에 대해서 시간이 증가하는 것은 예상된 것이지만, 정규식을 쓰는 경우 오히려 시간이 감소했습니다. 혹시 이유를 아시는 분은 알려주시면 감사하겠습니다.

그래도 indexOf가 가장 빠르지만, 우리가 원하는 패턴은 하나가 아닙니다. 패턴을 두개로 늘려봤습니다.

run 'indexOf', ->
  count = 0
  for log in logs
    if log.indexOf('join.html') > 0 or log.indexOf('join_contract.html') > 0
      count++

run 'includes', ->
  count = 0
  for log in logs
    if log.includes('join.html') or log.includes('join_contract.html')
      count++

run 'test', ->
  count = 0
  for log in logs
    if /join\.html|join_contract\.html/.test log
      count++

run 'match', ->
  count = 0
  for log in logs
    if log.match /join\.html|join_contract\.html/
      count++

다음은 그 결과입니다.

indexOf - 12686ms
includes - 13864ms
test - 11690ms
match - 12089ms

패턴이 두개만 되도 정규식이 빠릅니다. 심지어 저희는 패턴이 일단 10개는 넘습니다. 그래서 정규식의 test를 쓰는 것으로 결정을 했습니다. 그리고 알려진 패턴을 추가했습니다.

PATTERN = /join\.html|join_contract\.html|member\/register|register_form\.php|..../

10개미만일때는 그래도 괜찮은데 길어지니까 어디까지가 하나의 패턴인지 눈에 안 들어옵니다. 거기에 일일이 기억하기 어려워서 각 패턴이 어떤 솔루션의 것인지 주석을 달고 싶어졌습니다. 그래서 정규식을 여러 줄로 나눠서 쓸 수 있는지 찾아봤습니다. 몇몇언어(예. Perl)는 정규식 자체를 여러 줄로 나눌 수 있지만 JavaScript는 그런 문법은 없는 것 같습니다.

방법을 찾아보니 regex - How to split a long regular expression into multiple lines in JavaScript? - Stack Overflow를 찾을 수 있었습니다. 그 중에서도 단순히 문자열을 합친 후 RegExp 생성자를 이용하는 것은 문자열 escape에 신경을 써야 해서, 최종적으로는 두번째 답변의 방법을 이용하기로 했습니다.

PATTERNS = [
    /join\.html/, # 솔루션 A
    /join_contract\.html/, # 솔루션 B
    /member\/register/, # 솔루션 C
    /register_form\.php/, # 솔루션 D
    ...
]
PATTERN_RE = new RegExp PATTERNS.map((p) -> p.source).join('|')

이상 패턴 일치 여부 검사 방법이였습니다. (원래는 JavaScript에서 정규식을 여러 줄로 쓰는 방법에 대해서 쓰려던 건데 사족이 붙어서 길어졌네요)

C
const char *str = "2016-12-05";
regex_t rx;
if (regcomp(&rx, "([[:digit:]]{4})-([[:digit:]]{2})-([[:digit:]]{2})", REG_EXTENDED)==0) {
  regmatch_t m[4];
  if (regexec(&rx, str, 4, m, 0)==0) {
    int year = str2int(str+m[1].rm_so, m[1].rm_eo - m[1].rm_so);
    int month = str2int(str+m[2].rm_so, m[2].rm_eo - m[2].rm_so);
    int day = str2int(str+m[3].rm_so, m[3].rm_eo - m[3].rm_so);
  }
  regfree(&rx);
}
C++
string str("2016-12-05");
regex rx("(\\d{4})-(\\d{2})-(\\d{2})");
smatch m;
if (regex_match(str, m, rx)) {
  int year = stoi(m.str(1));
  int month = stoi(m.str(2));
  int day = stoi(m.str(3));
}
CoffeeScript
str = '2016-12-05'
if /(\d{4})-(\d{2})-(\d{2})/.test str
  year = Number RegExp.$1
  month = Number RegExp.$2
  day = Number RegExp.$3
Java
String str = "2016-12-05";
Pattern rx = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = rx.matcher(str);
if (m.matches()) {
  int year = Integer.parseInt(m.group(1));
  int month = Integer.parseInt(m.group(2));
  int day = Integer.parseInt(m.group(3));
}
JavaScript
var str = '2016-12-05';
if (/(\d{4})-(\d{2})-(\d{2})/.test(str)) {
  var year = Number(RegExp.$1);
  var month = Number(RegExp.$2);
  var day = Number(RegExp.$3);
}
Kotlin
val str = "2016-12-05"
val rx = "(\\d{4})-(\\d{2})-(\\d{2})".toRegex()
val m = rx.matchEntire(str)
if (m!=null) {
  val year = m.groups[1]!!.value.toInt()
  val month = m.groups[2]!!.value.toInt()
  val day = m.groups[3]!!.value.toInt()
}
Lua
str = '2016-12-05'
rx = '(%d%d%d%d)-(%d%d)-(%d%d)'
year, month, day = string.match(str, rx)
year = tonumber(year)
month = tonumber(month)
day = tonumber(day)
Objective-C
NSString *str = @"2016-12-05";
NSRegularExpression *rx = [NSRegularExpression regularExpressionWithPattern:@"(\\d{4})-(\\d{2})-(\\d{2})" options:0 error:nil];
NSTextCheckingResult *m = [rx firstMatchInString:str options:0 range:NSMakeRange(0, [str length])];
if (m!=nil) {
  NSInteger year = [[str substringWithRange:[m rangeAtIndex:1]] integerValue];
  NSInteger month = [[str substringWithRange:[m rangeAtIndex:2]] integerValue];
  NSInteger day = [[str substringWithRange:[m rangeAtIndex:3]] integerValue];
}
Perl
$str = '2016-12-05';
$rx = qr/(\d{4})-(\d{2})-(\d{2})/;
if ($str =~ $rx) {
  ($year, $month, $day) = (int $1, int $2, int $3);
}
PHP
$str = '2016-12-05';
$rx = '/(\d{4})-(\d{2})-(\d{2})/';
if (preg_match($rx, $str, $m)) {
  $year = (int)$m[1];
  $month = (int)$m[2];
  $day = (int)$m[3];
  // if you don't need integers
  list($_, $year, $month, $day) = $m;
}
Python
str = '2016-12-05'
rx = '(\\d{4})-(\\d{2})-(\\d{2})'
m = re.search(rx, str)
if m:
  year = int(m.group(1))
  month = int(m.group(2))
  day = int(m.group(3))
  // if you don't need integers
  year, month, day = m.groups()
Ruby
// using method
str = '2016-12-05'
rx = /(\d{4})-(\d{2})-(\d{2})/
m = rx.match(str)
if m
  year = m[1].to_i
  month = m[2].to_i
  day = m[3].to_i
  // if you don't need integers
  year, month, day = m[1..3]
end

// using pattern-matching operator
str = '2016-12-05'
rx = /(\d{4})-(\d{2})-(\d{2})/
if rx =~ str
  year = $~[1].to_i
  month = $~[2].to_i
  day = $~[3].to_i
  // if you don't need integers
  year, month, day = $~[1..3]
end
Swift
let str = "2016-12-05"
let rx = try! NSRegularExpression(pattern"(\\d{4})-(\\d{2})-(\\d{2})"options: [])
if let m = rx.firstMatch(in: str, options: [], rangeNSRange(location0length: (str as NSString).length)) {
  let year = Int((str as NSString).substring(with: m.rangeAt(1)))
  let month = Int((str as NSString).substring(with: m.rangeAt(2)))
  let day = Int((str as NSString).substring(with: m.rangeAt(3)))
}