본문 바로가기

소프트웨어 이야기/장고와 루비온레일즈

[Ruby]루비의 메모리 이슈

루비 메모리 이슈 관련 포스팅 해석 및 따라해보기 

블로그 : https://www.toptal.com/ruby/hunting-ruby-memory-issues

예제 코드 : https://github.com/lingceng/momery_demo




준비물

1. 예제 코드를 다운 받는다 


2. Gemfile을 만들어서, 테스트용 스크립트를 돌릴 때 필요한 gem들을 설치한다 


예제 코드에서는 Gemfile이 따로 없어서, 나는 아래의 Gemfile을 따로 만들어서 예제 코드를 돌려봤다.

이 예제 코드 안에 아래의 Gemfile을 만들고, bundle install을 해주면, Gem들이 설치된다.

source 'http://rubygems.org'

gem "activerecord"
gem "activesupport"
gem "get_process_mem"
gem "memory_profiler"
gem "sqlite3"

get_process_mem은 현재 돌고있는 루비 프로세스의 메모리 샤용량을 쉽게 가져올 수 있게 하는 gem이다. 


이렇게 gemfile을 만들어준 다음에, bundle install을 해줘서 gem을 설치해줬다 




메모리 이슈가 항상 메모리 릭은 아니다! 

메모리 이슈가 발생하면, 우리는 대부분 메모리릭이 발생한거라고 생각한다.
예를 들어, 웹 어플리케이션에서 같은 API를 찌르게 되면, 요청할때마다 메모리 사용량이 올라가는 상황이 발생한다고 생각해보자.
이 경우, 우리는 메모리 릭이 발생해서 그렇구나.. 라고 생각하게 된다.
이런 경우, 메모리 릭일수도 있지만, 대부분의 경우에는 메모리 릭이 아니다. 

이러한 상황을 테스트해볼 수 있는 스크립트가 build_arrays.rb 에 있다.  

common.rb - 이 코드는 메모리 사용량을 쉽게 확인할 수 있도록 블로그 작성자가 만들어놓은 공통함수가 있는 스크립트다. 

# common.rb
require "active_record"
require "active_support/all"
require "get_process_mem"
require "sqlite3"

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: "people.sqlite3"
)

class Person < ActiveRecord::Base; end

def print_usage(description)
  mb = GetProcessMem.new.mb
  puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }"
end

def print_usage_before_and_after
  print_usage("Before")
  yield
  print_usage("After")
end

def random_name
  (0...20).map { (97 + rand(26)).chr }.join
end


# build_arrays.rb
require_relative "./common"

ARRAY_SIZE = 1_000_000

times = ARGV.first.to_i

print_usage(0)
(1..times).each do |n|
  foo = []
  ARRAY_SIZE.times { foo << {some: "stuff"} }

  print_usage(n)
end


이 코드는 아래의 명령어로 실행하면 된다

$ bundle exec ruby build_arrays.rb 10


위의 명령문은 한 배열에 백만개의 해시를 밀어넣는 걸 10번 반복시킨다. 



그러면, 이 잡이 10번 실행될 때마다, 메모리를 얼마나 썼는지가 터미널에 뜬다. 

스크립트를 실행할 때, times의 값을 몇으로 설정하느냐에 따라 루프를 몇번 반복할지 달라진다.


그런데 times 값을 무조건 늘린다고 메모리 사용량이 늘어나는건 아니다.

늘어날 때도 있고, 줄어들 때도 있다.

때문에 이러한 상황은 메모리릭이 발생하고 있다고 단정하기 어려운 상황이다. 


첫번째 루프가 돈 직후에는 급 메모리 사용량이 올라가서 

메모리 릭이 발생한 것처럼 보일 수 있지만, 여러번 루프가 반복될 때에는 첫번째 루프같은 상황이 재현되지는 않는다.


build_array.rb 스크립트는 루프가 돌 때마다 이전에 만들었던 배열을 메모리에서 지우고, 다시 새로운 배열을 하나 만들어낸다. 

그런데 이전에 만들었던, 더이상 사용하지 않는 배열을 garbage collection에서 처리하기도 전에

새로운 배열을 메모리에 할당하다 보니, 메모리 사용량이 늘어나는걸로 보인 거였다. 


애플리케이션의 메모리 사용량이 갑자기 증가한다고 해서, 메모리릭이라는 패닉에 빠지지 않아도 된다 ㅎㅎ 

메모리릭 말고도 이러저러한 문제로 메모리가 부족할수 있다 ㅎ ㅎ 


( 위의 스크립트에서 배열의 크기를 줄이면, 메모리 사용량도 같이 줄어든다. 한번에 너무 많은 객체를 처리하지 않고, 나눠서 처리하면 메모리를 좀더 효율적으로 사용할 수 있다 ) 




문제를 해결하는 방법

일반적으로 실제 코드는 위의 예제 ( build_arrays.rb)처럼 단순하지 않다.

'대충.. 어림짐작으로 이거때문에 문제가 생긴거네~~' 라는 느낌으로 코드를 파헤치고, 고치는건, 문제를 파악하기 어렵게 만든다 ㅎㅎ


그래서 문제를 분석할 때는 아래의 두가지 접근 방법을 사용해서 분석해나가야 한다.


1. 문제가 되는 코드를 profiler으로 감싸서, 메모리 사용량이 얼마나 되는지 확인하고,

2. 핵심 코드들을 주석처리해가면서, 어디서 문제가 발생한건지 문제의 폭을 줄여나가야한다. 


메모리 이슈 트래킹 방법 따라해보기

메모리 이슈의 원인을 분석하는 방법에 대한 예시를 이어서 설명하고자 한다.


people.rb 파일에는 아래의 코드가 들어있다.

common.rb 파일에서 Persion이라는 모델을 미리 정의해놨었다. 그리고 이름을 랜덤하게 만들어주는 함수인 random_name도 만들어뒀었다.

아래의 함수는 메모리를 많이 잡아먹는다. 어느 부분에서 메모리를 가장 많이 잡아먹는지는 차차 분석할거다. 

# people.rb
require_relative "./common"

def run(number)
  Person.delete_all

  names = number.times.map { random_name }

  names.each do |name|
    Person.create(name: name)
  end

  records = Person.all.to_a

  File.open("people.txt", "w") { |out| out << records.to_json }
end


before_and_after.rb

아래의 스크립트는 Person 데이터를 만드는 스크립트인 run 함수를 호출하는 스크립트이다. 

print_usage_before_and_after 함수가 run 함수가 호출되기 전/후의 메모리 사용량을 계산해서, 터미널로 보여준다.

# before_and_after.rb
require_relative "./people"

print_usage_before_and_after do
  run(ARGV.shift.to_i)
end


위의 스크립트를 돌려보니 아래와 같은 결과가 나왔다.

run 함수를 호출하고 난 후, 메모리 사용량이 늘어났다.

run 함수에는 메모리 이슈를 발생시킬 것 같은 후보군들이 여럿있다.


후보군 1. 대량의 String을 배열로 만드는 부분! 

  names = number.times.map { random_name }


후보군 2. Active Record Object들을 배열로 만드는 부분! 

  records = Person.all.to_a

후보군 3. Active Record Object들을 직렬화하는 부분! 

  File.open("people.txt", "w") { |out| out << records.to_json }


어느 부분에서 메모리 할당 ( memory allocation )이 발생하고 있는건지 확인하기 위하여, memory_profiler gem을 사용해서 

분석을 해봤다. ( profile.rb )

# profile.rb
require "memory_profiler"
require_relative "./people"

report = MemoryProfiler.report do
  run(1000)
end
report.pretty_print(to_file: "profile.txt")


profile.rb 스크립트를 돌려본 후, 분석 결과를 확인해봤다. ( 메모리가 얼마나 할당되었었고, 얼마가 남아있는지도 뜬다 ! )


분석결과를 보면, active record에서 가장 많은 메모리 할당이 발생했다. 그렇다면 문제의 후보군은 아래의 두가지 케이스로 좁혀진다. 

- Person 객체 (active record) 들을 array으로  바꿔주는 코드

- Persion 객체 (active record)를 json으로 변환해서, File에 쓰는 코드 ( 직렬화 단계 )


profiler으로 어느 부분에서 메모리를 많이 사용하는지 확인했으니, 이제 의심이 가는 로직을 주석처리 해보면서 

어느 부분으 메모리를 많이 썼는지 확인해볼 단계이다. 


우선 people.rb 스크립트에서, to_json을 하는 로직을 주석처리해본 후 메모리 사용량을 확인해보려한다.

  # File.open("people.txt", "w") { |out| out << records.to_json }

이렇게 주석처리를 한 후, 스크립트를 돌려봤다. 주석처리하기 전에는 After Memory 사용량이 75MB였다. 위의 로직을 주석처리하면서, 26mb가 감소했다.


그 다음으로 의심되는 코드인, active record 리스트를 array으로 변경하는 로직에서, to_a를 지워보았다.

  # records = Person.all.to_a
  records = Person.all

  # File.open("people.txt", "w") { |out| out << records.to_json }


to_json 코드를 주석처리할 때는 26mb가 감소했는데, 이번에는 2mb만 감소했다. 



이걸 봤을 때, 메모리 사용량에는 to_json이라는 코드가 많은 영향을 미쳤음을 알 수 있다.

이제 이 로직을 최적화할 수 있는 방법을 찾아봐야한다.


이렇게 프로파일러로 코드를 살펴보는 방식이 실제로 문제가 되는 지점을 명확하게 찝어주진 않는다. 

그런데 프로파일러는 어느 부분을 주석 처리해가며, 메모리 사용량을 비교해봐야하는지 가이드를 주기도 하고,

어느 부분을 튜닝해야할지에 대한 감을 주기도 한다. 




Serialization

person.rb 스크립트의 메모리 이슈를 분석해본 결과,  serialization ( to_json 함수 )부분이 메모리를 많이 사용하고 있었다. 

persion 데이터를 sqlite3에 100,000개를 만들어놓고, to_json.rb 파일을 돌려보니, 아래같은 결과가 나왔다

to_json 함수는 모든 레코드들을 한번에 JSON으로 인코딩한다. 

그런데 하나의 레코드를 JSON으로 만들고 난 다음에, 다음 레코드를 JSON으로 만들어주는 방식으로.. 순차적으로 처리하면, 

한번에 메모리를 사용할 때보다 메모리 사용량이 줄어든다 ( 대신 속도는 느려질것 같단 생각이 든다 )

그런데 루비 JSON 라이브러리에서 이러한 기능을 지원하지 않는다.

대신 json-write-stream을 사용하면 된다.



Deserialization

일반적인 메모리 이슈는 XML이나 JSON 포맷처럼 직렬화되어있는 큰 데이터를 Deserializing할 때 발생한다. 

JSON.parse / Active support의 Hash.from_xml 같은 함수들은 간편하게 쓸 수 있다는 장점이 있지만, 

읽어들여야하는 데이터가 커지면, 문제가 발생하기 쉽다.


만약 데이터의 크기를 제어할 수 있는 상황이라면, 데이터의 크기를 작게 쪼개서 처리하면 된다. 

그런데 우리가 데이터의 크기를 제어할 수 없다면, streaming deserializer를 사용하면 된다.


XML에는 Ox라는 gem이 있고, JSON에서는 yajl-ruby라는 gem을 사용하면 된다