Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비 문자열을 최대 13배 더 빠르게 정리하는 법

생각을 코드로 옮길 때 우리는 대부분 가장 익숙한 메서드를 사용합니다. 떠오르는 대로 손이 먼저 움직이는 메서드들이죠. 정리가 필요한 문자열을 마주하면, 고민할 새 없이 결과를 만들어낼 익숙한 메서드를 입력하고 있습니다.

이렇게 무의식적으로 입력되는 메서드는 대개 가장 범용적인 루비 메서드입니다. 다른 어떤 메서드보다 더 자주 읽고 쓰기 때문입니다. 예컨대 #gsub은 문자열 안의 문자를 치환하는 대표적인 범용 메서드입니다. 하지만 루비에는 표준적인 작업을 위해 더 특화된 편의 메서드가 훨씬 많습니다.

저는 루비의 풍부한 관용 표현을 좋아합니다. 코드를 더 우아하고 읽기 쉽게 만들어 주기 때문입니다. 이 풍부함의 혜택을 누리려면 문자열 정리처럼 단순해 보이는 부분조차 리팩토링하는 데 시간을 들여야 하고, 어휘를 넓히는 데도 약간의 노력이 필요합니다. 그렇다면 이 추가 노력은 과연 가치가 있을까요?

공백 제거의 네 가지 방법

다음은 신용카드 번호를 나타내는 문자열입니다: "055 444 285". 이 문자열을 다루려면 공백을 제거해야 합니다. #gsub으로도 충분히 가능합니다. #gsub이면 무엇이든 원하는 것으로 치환할 수 있으니까요. 하지만 선택지는 이것 말고도 있습니다.

string = "055 444 285"
string.gsub(/ /, '')
string.gsub(' ', '')
string.tr(' ', '')
string.delete(' ')
 
# => "055444285"

편의 메서드에서 가장 마음에 드는 점은 표현력입니다. 마지막 예시가 좋은 사례인데요. "공백 삭제"만큼 명확한 코드는 없습니다. 여러 선택지의 트레이드오프를 저울질할 때 저는 가독성을 최우선으로 두지만, 성능 문제가 생기면 이야기가 달라집니다. 그렇다면 제가 선호하는 해법인 #delete는 실제로 얼마나 큰 성능 손실을 가져오는 걸까요?

위 예제들을 벤치마크해 보았습니다. 이 중 어떤 메서드가 가장 빠를 것 같으신가요?

Benchmark.ips do |x|
  x.config(time: 30, warmup: 2)
 
  x.report('gsub')           { string.gsub(/ /, '') }
  x.report('gsub, no regex') { string.gsub(' ', '') }
  x.report('tr')             { string.tr(' ', '') }
  x.report('delete')         { string.delete(' ') }
 
  x.compare!
end
성능 순위를 먼저 추측해 본 뒤 토글을 열어 결과를 확인하세요
Comparison:
  delete:          2326817.5 i/s
  tr:              2121629.8 i/s   - 1.10x  slower
  gsub, no regex:  868184.1 i/s    - 2.68x  slower
  gsub:            474970.5 i/s    - 4.90x  slower

순위 자체는 예상대로였지만, 속도 차이의 크기는 놀라웠습니다. #gsub은 느릴 뿐만 아니라, 코드를 읽는 사람이 인자를 '해석'하는 데 추가적인 인지 비용도 요구합니다. 이번에는 공백 외의 것들을 정리할 때 이 격차가 어떻게 벌어지는지 살펴보겠습니다.

숫자만 골라내기

다음 전화번호를 봅시다: '(408) 974-2414'. 숫자만 남겨야 한다고 가정해 보겠습니다 => 4089742414. 여기에 #scan도 함께 추가했습니다. 불필요한 것들을 하나씩 지워내기보다는 원하는 것을 찾아낸다는 의도를 더 명확하게 드러내기 때문입니다.

Benchmark.ips do |x|
  x.config(time: 30, warmup: 2)
 
  x.report('gsub')        { string.gsub(/[^0-9]/, '') }
  x.report('tr')          { string.tr("^0-9", "") }
  x.report('delete_chars'){ string.delete("^0-9") }
  x.report('scan')        { string.scan(/[0-9]/).join }
  x.compare!
end
역시 순위를 추측한 후 토글을 열어 정답을 확인하세요
Comparison:
  delete_chars:   2006750.8 i/s
  tr:             1856429.0 i/s   - 1.08x  slower
  gsub:           523174.7 i/s    - 3.84x  slower
  scan:           227717.4 i/s    - 8.81x  slower

정규식을 사용하면 속도가 느려진다는 점은 놀랍지 않습니다. 그리고 #scan의 의도를 드러내는 표현력은 꽤 값비싼 대가를 치르게 됩니다. 하지만 루비의 특화된 메서드들이 문자열을 어떻게 다루는지 보고 나니 더 깊이 파고들고 싶어졌습니다.

금액에서 통화 기호 제거하기

이번에는 문자열 "€ 300"에서 부분 문자열 "€ "를 제거하는 여러 가지 방법을 시도해 보겠습니다. 일부 해법은 정확한 부분 문자열 "€ "를 지정하고, 일부는 모든 통화 기호 또는 모든 비숫자 문자를 제거합니다.

Benchmark.ips do |x|
  x.config(time: 30, warmup: 2)
 
  x.report('delete specific chars')  { string.delete("€ ") }
  x.report('delete non-numericals')  { string.delete("^0-9") }
  x.report('delete prefix')          { string.delete_prefix("€ ") }
  x.report('delete prefix, strip')   { string.delete_prefix("€").strip }
 
  x.report('gsub')                   { string.gsub(/€ /, '') }
  x.report('gsub-non-nums')          { string.gsub(/[^0-9]/, '') }
  x.report('tr')                     { string.tr("€ ", "") }
  x.report('slice array')            { string.chars.slice(2..-1).join }
  x.report('split')                  { string.split.last }
  x.report('scan nums')              { string.scan(/\d/).join }
  x.compare!
end

승자가 #delete 계열 중 하나일 것이라고 예상하신다면 정답입니다. 그런데 #delete 변형 중 어떤 것이 가장 빠를까요? 게다가 다른 메서드 중 하나가 일부 #delete 변형보다 빠르기도 하는데, 과연 어떤 메서드일까요?

추측해 본 후 토글을 열어 확인하세요
Comparison:
        delete prefix:   4236218.6 i/s
 delete prefix, strip:   3116439.6 i/s - 1.36x  slower
                split:   2139602.2 i/s - 1.98x  slower
delete non-numericals:   1949754.0 i/s - 2.17x  slower
delete specific chars:   1045651.9 i/s - 4.05x  slower
                   tr:   951352.0 i/s  - 4.45x  slower
          slice array:   681196.2 i/s  - 6.22x  slower
                 gsub:   548588.3 i/s  - 7.72x  slower
        gsub-non-nums:   489744.8 i/s  - 8.65x  slower
            scan nums:   418978.8 i/s  - 10.11x  slower

배열을 잘라내는 조작조차 #gsub보다 빠르다는 사실이 의외였고, #split이 얼마나 빠른지 확인할 때마다 기분이 좋습니다. 또 하나 주목할 점은, 모든 비숫자 문자를 제거하는 것이 특정 부분 문자열을 제거하는 것보다 오히려 빠르다는 것입니다.

숫자 뒤의 통화 단위 제거하기

이번에는 숫자 뒤에 붙은 통화 단위를 제거해 보겠습니다. (느린 #gsub 변형들은 생략했습니다.)

Benchmark.ips do |x|
  x.config(time: 30, warmup: 2)
 
  x.report('tr')            { string.tr(" USD", "") }
  x.report('delete_chars')  { string.delete("^0-9") }
  x.report('delete_suffix') { string.delete_suffix(" USD") }
  x.report('to_i.to_s')     { string.to_i.to_s }
  x.report('split')         { string.split.first }
  x.compare!
end

이번에는 승자 간에 동률이 나왔습니다. 어떤 두 메서드가 최고 속도를 다퉜을 것 같으신가요?

덧붙여, 이번 실험에서 #gsub이 얼마나 느린지도 추측해 본 후 열어 보세요
Comparison:
delete_suffix: 4354205.4 i/s
to_i.to_s: 4307614.6 i/s - same-ish: difference falls within error
split: 2870187.8 i/s - 1.52x slower
delete_chars: 1989566.1 i/s - 2.19x slower
tr: 1853957.1 i/s - 2.35x slower
gsub: 524080.6 i/s - 13.22x slower

물론 항상 필요에 딱 맞는 특화 메서드가 존재하지는 않습니다. 선행 "0"을 유지해야 한다면 #to_i를 사용할 수 없고, #delete_suffix는 통화 단위가 반드시 미국 달러(USD)라는 가정에 크게 의존합니다.

특화 메서드는 정밀 도구와 같습니다. 특정 맥락에서 특정 작업에 적합하죠. 그래서 언제든 #gsub이 정확히 필요한 도구가 되는 경우도 있습니다. #gsub은 다재다능하고 항상 먼저 떠오르는 메서드이니까요. 하지만 처리하기가 다소 까다롭고 종종 느립니다. 심지어 제 예상보다 더 느리기까지 했습니다. 저에게 루비의 풍부함은 함께 작업하는 즐거움의 원천이며, 덤으로 얻는 속도 향상은 훌륭한 보너스입니다.