[Rails] 배포 환경에서 발생하는 Auto loading 문제


레일즈는 소스 코드 파일을 로드하기 위해서 실제로 사용될 때 그제서야 파일을 로드하는 Auto loading1과 서버가 실행될 때 한 번에 모든 파일을 로드하는 Eager loading 두 가지 방식을 사용한다.

며칠 전 까지만 해도 나는 Auto loading 방식만 대충 알고 있었고 Eager loading 방식에 대해서는 모르고 있었는데, 정말 우연한 계기로 두 방식을 상황에 맞게 적절히 사용하지 않으면 개발이나 테스트 환경이 아닌 배포 환경에서 문제가 될 수 있다는 것을 알게 되었다. 이 문제를 통해 레일즈의 Auto loading 방식과 Eager loading 방식에 대해서 좀 더 자세히 알아보자.

1. 어떤 문제가 있었는지?

lib 디렉토리에 존재하는 파일을 읽지 못하는 문제였다. lib 디렉토리에 선언된 클래스를 불러오지 못해서 클래스를 사용하려고 하면 NameError: uninitialized constant 에러가 발생했다.

그런데 이상했던 점은 개발, 테스트 환경에서는 정상적으로 불러오다가 스테이징, 프로덕션 환경에 배포하기만 하면 불러오지 못한다는 것이었다. 테스트도 모두 성공하고 콘솔로 테스트해봐도 제대로 불러오는데 배포환경만 되면 에러를 뿜어대니 돌아버릴 지경이었다.

2. 문제의 원인

오랜 삽질과 검색 끝에 결국 원인을 찾아냈는데, 레일즈 4.2 버전에서 5.0으로 버전 업 될 때 production 환경에서 auto loading 기능을 비활성화 시키는 기능이 추가되었다.2 이게 왜 문제가 되냐면 이전에는 개발 환경이든 배포 환경이든 auto loading을 사용했기 때문에 app 디렉토리 이외의 디렉토리를 로드할 땐 다음과 같이 설정하는게 일반적이었다.

# config/application.rb

...
  config.autoload_paths << Rails.root.join('lib')
...

그런데 배포 환경에서 auto loading이 비활성화 되어버리니 위의 설정 코드가 무용지물이 되어버리고 lib 디렉토리를 참조할 수 없게 된 것이다.

3. 문제의 해결 방법은?

해결 방법은 간단하다. 배포 환경에서는 Eager loading 방식을 사용하므로 배포 환경일 땐 config.eagerload_paths에 경로를 추가하면 된다.

# config/application

...
  if Rails.env.development? || Rails.env.test?
    config.autoload_paths << Rails.root.join('lib')
  else
    config.eager_load_paths << Rails.root.join('lib')
  end
...

그런데 실제로 오토 로딩되는 경로는 autoload_paths + eager_load_paths이기 때문에3 설정 코드를 더 간단히 하기 위해 config.eager_load_paths만 작성해도 된다.

# config/application

...
  config.eager_load_paths << Rails.root.join('lib')
...

참고

  1. https://collectiveidea.com/blog/archives/2016/07/22/solutions-to-potential-upgrade-problems-in-rails-5
  2. http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload/
  1. Lazy loading이라는 용어가 있지만 레일즈에선 auto loading이라고 부른다. Ruby 언어에서 소스 파일을 lazy하게 불러올 때 사용되는 autoload 키워드에서 따온 것 같은데, Ruby의 autoload는 3.0 버전 이후 deprecated 될 수 있다고 한다.

  2. 업그레이드 가이드 문서 참조

  3. Rails Engine 코드 참조


[Rails] 타임 헬퍼로 시간 테스트하기


테스트 코드를 실행할 때 현재 시간을 변경하고 싶을 때가 있다. 예를 들어 모임 시간을 가지는 모임(Meeting) 클래스가 있고 모임 시간이 지났을 때 종료 상태를 반환하는 메서드를 테스트한다고 해보자. 먼저 모임 클래스는 다음과 같다.

class Meeting
  attr_accessor :time

  def initialize(time)
    @time = time
  end

  def finished?
    time < Time.now
  end
end

모임의 시간은 임의로 정할 수 있지만 Time.now가 반환하는 시간은 변경하기 까다롭다. 이럴 때 레일즈에서 제공하는 타임 헬퍼 모듈 (ActiveSupport::Testing::TimeHelpers)을 사용하면 좋다.

# spec/unit/meeting_spec.rb
require 'rails_helper'

describe Meeting do
  include ActiveSupport::Testing::TimeHelpers

  describe "#finished?" do
    let(:meeting_time) { Time.now }
    let(:meeting) { Meeting.new(meeting_time) }

    context "모임 시간이 지났을 때" do
      before do
        # 현재 시간을 하루 뒤로 설정한다.
        travel(1.day)
      end

      it { expect(meeting.finished?).to eq(true) }
    end

    context "모임 시간이 지나지 않았을 때" do
      before do
        # 현재 시간을 모임 시간 하루 전으로 설정한다.
        travel_to(meeting_time - 1.day)
      end

      it { expect(meeting.finished?).to eq(false) }
    end
  end
end

나는 타임 헬퍼를 시간으로 정렬되는 것을 테스트할 때 사용하고 있다. 데이터베이스에 2개의 레코드를 생성한 뒤 정렬 결과를 확인하는 방식인데, 테스트 코드가 너무 빨리 실행되서인지 레코드의 created_at이 같아지는 바람에 정렬 테스트의 결과가 실행할 때 마다 달라지는 문제가 있었다.

it "최신순으로 정렬되어야 함" do
  old_meeting = create(:meeting)
  new_meeting = create(:meeting)

  # old_meeting과 new_meeting의 created_at이 같음!
  expect(sorting code).to eq([new_meeting, old_meeting])
end

이 때 현재 시간을 1초 정도 딜레이 시켜주면 정상적으로 테스트할 수 있다. 물론 created_at 값을 직접 설정해도 되지만 코드를 읽을 때 타임 헬퍼를 사용하는 것이 좀 더 직관적으로 보인다.


# created_at 변경
it "최신순으로 정렬되어야 함" do
  old_meeting = create(:meeting, created_at: Time.now)
  new_meeting = create(:meeting, created_at: Time.now + 1.second)

  # old_meeting과 new_meeting의 created_at이 1초 차이남
  expect(sorting code).to eq([new_meeting, old_meeting])
end

# 타임 헬퍼 사용
it "최신순으로 정렬되어야 함" do
  old_meeting = create(:meeting)
  travel 1.second
  new_meeting = create(:meeting)

  # old_meeting과 new_meeting의 created_at이 1초 차이남
  expect(sorting code).to eq([new_meeting, old_meeting])
end

이 때 주의할 점은 테스트가 끝나도 현재 시간이 초기화되지 않는다는 것이다. 전체 테스트에서 travel 1.second 코드를 300번 사용하면 현재 시간이 5분이 밀리게 된다. 이것 때문에 뜻밖의 오류가 발생할 수 있으니 반드시 테스트 종료 후에 travel 메서드로 변경한 시간을 초기화 시키는 travel_back 메서드를 실행시켜 주어야 한다. 이것은 rails_helper.rb 설정 파일에 작성할 수 있다. (RSpec 기준)

# spec/rails_helper.rb

...

RSpec.configure do |config|
  ...
  config.include ActiveSupport::Testing::TimeHelpers
  ...

  config.after(:each) do
    travel_back
  end
end

rails_helper.rb 파일에서 타임 헬퍼를 include 하면 모든 테스트 코드에서 사용할 수 있기 때문에 이제 meeting_spec.rb에서 include 코드를 작성할 필요는 없다.

# spec/unit/meeting_spec.rb
require 'rails_helper'

describe Meeting do
  # include ActiveSupport::Testing::TimeHelpers

  describe "#finished?" do
    ...
  end
end

logrotate로 리눅스 로그 관리


웹 서비스를 운영할 때 쉽게 놓칠 수 있는 것 중 하나가 로그 관리다. 로그를 관리하지 않아도 운영하는데는 문제가 없으며 운영 초반에는 로그를 관리해야할 정도로 트래픽이 발생하는 경우가 드물기 때문이다.

하지만 시간이 점점 지나고 트래픽이 늘어나면 로그는 고스란히 디스크에 쌓이게 되고, 이를 그대로 방치해두면 엄청난 디스크 용량을 낭비하게 된다.

끝도 없이 늘어나는 로그 파일을 관리하기 위해 리눅스에서는 logrotate라는 프로그램을 사용하면 좋다. logrotate는 정해진 시간마다 로그 파일을 백업시켜주는데, 로그 파일이 무작정 늘어나는 것을 방지하기 위해 로그 파일의 최대 개수를 정해놓으면 최대 개수를 초과했을 때 가장 오래된 로그 파일을 삭제하고 새로운 로그 파일을 생성하면서 rotating 해주는 툴이다.

시스템에 설치되어있지 않다면 설치해준다.

sudo yum install -y logrotate

/etc/logrotate.conf 파일에 어떤 로그를 로테이팅할지 작성할 수 있다. 파일을 열어보면 기본으로 작성된 코드가 있을 것이다.

# see "man logrotate" for details
# rotate log files weekly
weekly

# keep 4 weeks worth of backlogs
rotate 4

# create new (empty) log files after rotating old ones
create

# use date as a suffix of the rotated file
dateext

# uncomment this if you want your log files compressed
#compress

# RPM packages drop log rotation information into this directory
include /etc/logrotate.d

# no packages own wtmp and btmp -- we'll rotate them here
/var/log/wtmp {
    monthly
    create 0664 root utmp
        minsize 1M
    rotate 1
}

/var/log/btmp {
    missingok
    monthly
    create 0600 root utmp
    rotate 1
}

# system-specific logs may be also be configured here.

중간에 include /etc/logrotate.d를 보면 /etc/logrotate.d 디렉토리의 파일들을 전부 불러오는 것을 알 수 있다. 그럼 이제 /etc/logrotate.d 디렉토리에 새로운 설정 파일을 생성하고 레일즈 프로젝트의 로그를 관리하도록 작성해보자.

# /etc/logrotate.d/myproject

/home/ec2-user/myproject/log/production.log {
  weekly
  rotate 4
  missingok
  dateext
  postrotate
    touch /home/ec2-user/myproject/tmp/restart.txt
  endscript
}

이렇게 설정한 뒤에 한 번 실행해보자.

sudo /usr/sbin/logrotate /etc/logrotate.d/myproject

처음 실행했을 때는 아무 일도 일어나지 않는다. 왜냐면 logrotate는 실행될 때 /var/lib/logrotate.status 파일을 통해 정해진 기간이 지났는지 확인하는데 방금은 아무 정보가 없었기 때문이다. /var/lib/logrotate.status 파일에 현재 날짜만 기록하고 로테이팅이 실행되지는 않았다.

# /var/lib/logrotate.status

logrotate state -- version 2
"/var/log/yum.log" 2016-1-1
"/home/ec2-user/myproject/log/production.log" 2016-12-10
"/var/log/dracut.log" 2016-1-1
"/var/log/wtmp" 2015-12-1
"/var/log/spooler" 2016-12-4
"/var/log/btmp" 2016-12-1
"/var/log/maillog" 2016-12-4
"/var/log/secure" 2016-12-4
"/var/log/messages" 2016-12-4
"/var/account/pacct" 2015-12-1
"/var/log/cron" 2016-12-4

production.log 파일이 오늘 날짜(2016-12-10)로 실행되었다는 것이 저장되었다. 이제 12월 17일이 되어야 1주일이 지났다는 것을 인식하고 로테이팅이 실행될 것이다.

지금 당장 확인해보려면 2016-12-10을 일주일 전인 2016-12-03으로 바꾼 뒤 sudo /usr/sbin/logrotate /etc/logrotate.d/myproject를 한번 더 실행해보면 된다.


블로그 주소 저장


전에 작성한 “mvc 웹 프레임워크의 문제점” 포스트에서 밥 아저씨의 Clean Architecture 강의 영상으로 다음 포스트를 쓴다고 했는데, 내가 쓰려는 포스트와 거의 비슷한 블로그 포스트를 찾음

읽어보고 도움도 많이 되었고 좋은 글이라 공유해둠

https://medium.com/@fbzga/clean-architecture-in-ruby-7eb3cd0fc145


MVC 웹 프레임워크의 문제점


나는 2014년 10월에 레일즈를 처음 접했고 2년 동안 거의 모든 웹 프로젝트에 레일즈를 사용했다.

그리고 레일즈를 배우면서 루비를 배웠다. 그 때의 나에게 루비는 “파이썬과 비슷한 동적 스크립트 언어” 그 이상도 이하도 아니었다. C나 Java와는 달리 컴파일도 필요없고 변수에 타입도 없다. 딱 이 수준이었다.

C, Java, Python을 조금씩 해봤기 때문에 변수, 반복문, 조건문 이런 기본적인 개념은 금방 익혔고 이 정도만 알아도 레일즈로 개발하는데는 큰 문제가 없었다. 웬만한 필요한 기능은 Gem으로 만들어져 있었고 막히는 부분은 가이드 문서나 튜토리얼 아니면 스택오버플로우를 찾아 봤다. 시간이 좀 지나고나니 웬만한건 레일즈로 만들어낼 수 있게 되었다.

프로젝트의 규모가 조금 커지자 문제가 발생했다. 기존의 기능을 수정하거나 새로운 기능을 추가하는 등 시스템에 변화가 생길 때마다 코드를 짜기가 너무 힘들어졌다. 비즈니스 로직들은 한 눈에 알아보기도 힘들 정도로 여기저기 흩어져 있어서 이 코드가 무슨 코드였는지 파악하기 위해 전체 코드들을 다 살펴봐야 했다. 대체 뭐가 문제였을까?

MVC

레일즈는 MVC 패턴을 적용한 웹 프레임워크이며 View는 사용자와, Model은 데이터베이스와 상호작용하며 Controller는 View와 Model 사이에서 다리 역할을 한다.

레일즈를 처음 접했을 때 한 번씩은 읽어봤을법한 MVC 패턴 설명이다. 레일즈는 완벽한 MVC 프레임워크이다. 모델은 ActiveRecord를 상속받아서 데이터베이스와 쉽게 통신할 수 있고, 뷰는 HTML으로 이루어져있으며 컨트롤러는 모델 객체를 활용하여 특정 기능을 수행한 뒤 사용자에게 뷰를 렌더한다.

그렇다면 우리가 개발할 앱의 비즈니스 로직은 대체 어디에 들어가야할까? Controller일까? 아니면 Model일까? (View는 당연히 아니다)

1. Controller

다음은 Rails Tutorial에 작성된 로그인 코드다. 유저를 데이터베이스에서 찾아서 비밀번호를 검사하는 간단한 비즈니스 로직이 컨트롤러에 들어가있는 것을 볼 수 있다.

class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # Log the user in and redirect to the user's show page.
    else
      # Create an error message.
      render 'new'
    end
  end

  def destroy
  end
end

단일 책임 원칙(Single Responsibility Principle)이라는 객체지향 원칙이 있다. 이 원칙을 지키려면 “코드를 변경하는 이유”가 딱 한가지만 있으면 된다. 위 코드는 크게 2가지 이유로 변경될 수 있다.

  1. 로그인 처리 후 렌더링하는 페이지가 바뀔 경우
  2. 로그인 방식이 바뀔 경우(ex. 이메일이 아니라 이름으로 로그인)

컨트롤러에는 고유의 책임이 이미 주어져 있다. 유저로부터 요청을 받아서 적당한 응답을 던져주는 것이 바로 컨트롤러의 책임이다. 이미 책임이 부여된 컨트롤러에 비즈니스 로직을 작성하는 것은 당연히 단일 책임 원칙을 위배하며 따라서 컨트롤러에 비즈니스 로직을 넣는 것은 좋은 방법이 아니다.

2. Model

Skinny controllers, Fat models

레일즈 개발자라면 한 번쯤은 들어봤을만한 문장이다. 컨트롤러는 최대한 가볍게 유지하고, 웬만한 코드는 모델에 넣으라는 뜻이다.

하지만 생각해보면 이것도 틀렸다는 것을 알 수 있다. 컨트롤러는 고유의 책임이 이미 존재해서 비즈니스 로직을 넣을 수 없었다. 그렇다면 모델은 주어진 책임이 없을까? 모델은 ActiveRecord를 상속받음으로써 데이터베이스와 통신할 수 있게되며 이 때 상속받는 메서드만 200개가 훨씬 넘는다고 한다.

즉, 모델은 ActiveRecord를 상속받는 순간 Persistence layer의 책임을 갖게된다. 위의 로그인 코드에서 User.find_by(...)가 바로 모델이 해야하는 일이다. 결국 모델도 비즈니스 로직을 담을만한 곳은 아니다.

문제점?

레일즈에 비즈니스 로직을 넣을만한 곳이 없는 것이 문제일까? 레일즈는 프레임워크다. 프레임워크는 대부분의 일반적인 상황에서 사용할 수 있는 코드를 제공한다. 반면에 비즈니스 로직은 우리가 개발하는 애플리케이션에 specific하다. 따라서 프레임워크에 비즈니스 로직을 넣을 곳이 없는 것은 문제가 되지 않는다. 없으면 만들면 되기 때문이다.

문제는 바로 프레임워크 위에 모든 코드를 작성하게 하는 가이드와 수많은 튜토리얼들, 가장 큰 문제는 그것을 이상하다고 여기지 않고 그대로 받아들였던 나 자신이다. 아마 프레임워크를 통해 언어를 배웠기 때문에 기본기가 부족해서 그러지 않았나 싶다. 뭐든지 기본기가 탄탄해야 한다는 것을 다시 한 번 배우게 되었다.

해결 방법

몇 달 전부터 비즈니스 로직을 프레임워크로부터 분리시키는 방법을 찾으려고 했다. 이전에 번역한 [[번역] 액티브레코드 모델을 리팩토링하는 7가지 방법] 도 그런 의도에서 작성한 것이었다. 몇 가지 종류의 Plain Old Ruby Objects를 작성해서 비즈니스 로직을 분리시킬 수 있다.

그리고 좋은 해결책이 될 수 있는 밥 아저씨의 Clean Architecture 강의 영상을 찾았는데 다음 포스트에서는 이 영상을 다뤄봐야겠다.


Pagination