[Ruby on rails] 서버 속도 (+MYSQL)를 개선해보자!
최근에 한 프로젝트가 엄청나게 느린 퍼포먼스를 보였습니다.
한땀한땀 속도를 늘리기 위해 수정 된 내용을 공유해봅니다.
일단 퍼포먼스의 측정은 Newrelic을 사용했습니다.
Newrelic 에서 이전 데이터를 제공하지 않아, 데이터는 첨부가 불가능하지만, 유저가 사용하는 부분에서 1초를 넘는 것이 허다했습니다.
Newrelic 에서 Application 단의 Transactions 의 "Slowest average response time"을 보면 어떤 부분에서 오랜 시간이 걸리는지 확인이 가능합니다.
위 사진을 보면, 어떤 Controller 에서 Action 별로 시간이 오래 걸리는 순으로 나타납니다.
전반적으로 10ms 이하의 속도여야 하지만, 현실은 ... 5초를 넘어가는 경우도 있었습니다.
위의 Transaction을 클릭하면 상세화면을 통해 어떤 곳에서 시간이 많이 걸렸으며 어떤 함수를 몇번 호출했는지를 보며 속도개선을 위한 작업을 할 수 있습니다.
시간을 빠르게 하기 위해서는
1. Controller 단을 확인하자! - 혹시 main thread 에 sleep을 걸진 않으셨나요?
부끄러운 이야기지만, 메세지 모델을 Create하는 과정에서 순서가 중요한 케이스가 있었습니다. 메세지들은 각 모델의 after_save 에서 특정 모델이 추가되면 그에 따른 메세지를 생성하도록 해놓았는데 이 때 모델별로 데이터베이스에 들어가는 시간이 다르다보니, 어쩔때에는 정상적인 순서로 출력이 되나 어쩔때에는 순서가 뒤바껴 나오는 경우가 있었습니다. 아웃풋을 내는데에 급급했기에 일단 Sleep 1 로 막아버린 경우가 있었습니다. 해당 코드를 탈 때 무조건 1초 이상 걸리게 되겠죠.
이 부분은 전부 Sidekiq 의 Background active job 으로 변경했습니다. Sleep은 ActiveJob의 delay_for로 변경을 했습니다. main thread에서는 불필요하게 시간을 잡아놓는 Sleep은 제거되어야 합니다.
2. Controller 단을 확인하자! - for문을 자주 돌고 있는 건 아닌가요?
특정 채팅방에 있는 유저들을 가져와 채팅방내의 메세지와 유저간의 모델의 특정 컬럼을 업데이트 해야 합니다.
예를 들어 단순하게 for문을 돌게 되면,
chat.users.each do |user|
chat.message.each do |message|
message_reader = MessageReader.where(message: message).where(user: user)
message_reader.read = true
message_reader.save
end
end
로 생각할 수 있습니다. 채팅방의 유저를 for문을 돌며 안에서 채팅방의 메세지마다 for문을 돌며 MessageReader 모델을 가지고와서 컬럼을 업데이트하고 저장합니다.
하나만 돌아도 끔찍하지만 만약 채팅방 여러개에 대해 해당 for문을 돈다면 ...
다행히 Rails에는 Relation에 array로 쿼리를 할 수 있습니다.
위의 for문을 여러번 도는 것들을,
MessageReader.where(user: chat.users).where(message: chat.message).update_all(read: true)
채팅방의 메세지와 유저가 많아질 수록 쿼리의 숫자가 기하급수적으로 늘어날텐데, 단 한번의 쿼리로 원하는 동일한 결과를 얻어낼 수 있습니다.
아주 시간이 적게 걸리는 간단한 쿼리여도 여러번 반복하게 되면 쌓이고 쌓여 재앙을 초래할 수 있습니다.
간단하게 특정 모델들의 컬럼을 업데이트 하는 것이 아닌 특정 모델들을 찾아내어야 하는 경우에도,
specific_users_ids = User.where(company: "mintech").all.pluck(:id)
pluck(:id)를 통하여 특정 유저들의 id값을 가진 array를 생성한 후,
Message.where(user_id: specific_user_ids)
해당 유저들이 쓴 메세지들을 가져올 수 있습니다.
(위의 예가 적절친 않지만, Message.where(user: User.where(company: "mintech")) 으로도 바로 가져올 수 있지만, 바로 가지고 오지 못하는 경우더라도 for문을 쓰기보단 되도록 쿼리로 처리하는 것이 좋습니다)
조금 어렵더라도 join 쿼리등을 활용하여 Controller 단에서 for each를 돌며 처리를 하는 방식보다는 데이터베이스 쿼리문으로 요청들을 처리하는 것이 시간개선이나 리소스 측면에서 훨씬 탁월합니다.
3. Controller 단을 확인하자! - 시간이 오래 걸리는 작업들은 무조건 Background 잡으로 처리하자!
회원가입을 할 경우 환영 메일을 보내게 됩니다. 이 때 환영메일부터 환영 메세지를 채팅방까지 추가하는 작업이 굳이 main thread의 action에서 처리 될 필요는 없습니다. 유저는 회원가입을 하고 그 다음 단계로 진행을 바로 하면 됩니다. 이메일이나 환영 메세지등이 꼭 회원가입 프로세스에서 순차적으로 일어나지 않아도 되는 작업들입니다. 이런 작업들은 최대한 Sidekiq 을 활용하여 Background Job으로 처리합니다.
당연한 이야기지만 정신없이 구현을 하다보면, 코드 내 생각보다 많은 곳에서 부가적인 작업들이 액션의 시간을 늦추고 있습니다.
또 다른 예로는, 채팅방 내의 메세지들을 불러올 때 메세지들마다 "내"가 읽었는지 안읽었는지에 대한 여부를 특정 테이블에 기록을 하게 됩니다. 메세지와 채팅 방 내의 유저가 많을 수록 이 작업이
4. 필요한 것만 내보내자. 남용의 나쁜 예
너무 당연한 이야기겠지만, 필요한 것만 필요할 때 요청의 단위로 나누는 것이 좋습니다.
상황은 이랬습니다.
유저에 대한 Database 컬럼이 아닌 부가적으로 다른 테이블과의 Join된 값 등이 필요해졌다. 이를테면, 내 친구로 등록 된 유저의 별명도 정보를 받아야 하는 상황이 되었다.
데이터 베이스의 구조는 이렇다.
User - Friendship - Friend (class User)
User 와 Friend 모델 사이에 Friendship이라는 through 테이블이 존재하며 해당 테이블에 내가 상대방의 별명을 저장해놓은 nickname 이라는 필드가 존재합니다.
클라이언트 단에서 유저의 정보를 보여줄 때 정해놓은 별명을 표시해야 할 일이 생겼다! 유저정보를 요청하여 처리한 뒤 별명을 불러오는 것이 번거롭다고 생각하여 User 모델에 to_api_hash라는 함수를 만들고 to_api_hash를 요청하면 아래와 같이 내려보내도록 구현했습니다.
User.rb
def to_api_hash (me: nil)
{
id: id,
name: name,
nickname: get_nickname(me: me)
}
end
def get_nickname(me: nil)
return Friendship.where(user: me).where(friend: self).nickname;
end
자, 이제 유저만 요청하면 to_api_hash를 리턴해주면 친구의 닉네임을 클라이언트에 표시해줄 수 있습니다. 여기까지는 아무런 문제가 없습니다.
이번엔 채팅방을 요청하면 채팅방에 참여한 유저의 목록을 보여주어야 할 차례입니다.
Chat.rb
def to_api_hash(me:nil)
{
id: id,
users: users.map(&:to_api_hash(me: me))
}
end
를 통해 채팅방에도 역시 부가적인 정보들을 hash형태로 리턴해주는 to_api_hash를 만든 뒤 유저들의 to_api_hash를 돌아 users로 리턴하게끔 만들어주었습니다.
문제는, 유저의 닉네임을 표기를 안하는 곳에서도 채팅방목록을 요청하면 유저와 나의 Friendship에 가서 별명을 호출한다는 점입니다. 유저뿐만 아니라 채팅방에 속한 여러가지 모델들 목록을 돌며 hash로 만들어서 내려보내 주도록 구현을 했는데... 문제는, 정보가 필요없는 경우에도 모든 정보를 읽어오느라 수없이 많은 데이터베이스 쿼리를 던지고 처리하게 된다는 점입니다. 전체적인 구조를 변경해야 하는 점이라, 일단은 is_detail이라는 파라미터를 두어 정보가 필요 없는 구간에서는
User.rb
def to_api_hash (me: nil, is_detail: false)
{
id: id,
name: name,
nickname: get_nickname(me: me, is_detail: is_detail)
}
end
def get_nickname(me: nil, is_detail: is_detail)
return "" if !is_detail
return Friendship.where(user: me).where(friend: self).nickname;
end
임시방편으로 추가 정보가 필요한 경우에만 is_detail 을 true로 넘기도록 하여 돌도록 수정했습니다.
근본적으로는, 추가적인 정보는 따로 요청을 하는 방식이 로드를 줄일 수 있는 방법입니다. to_api_hash에는 데이터베이스 값들을 내려보내주고 유저의 닉네임이 필요할 경우 닉네임을 요청하는 액션을 따로 생성하는 것이 좋습니다.
5. Rails의 Cache를 이용하자!
Rails 에서는 다양한 캐쉬를 지원한다. 자세한 내용은 이곳에 잘 정리되어 있습니다.
http://guides.rubyonrails.org/caching_with_rails.html
environments/production.rb 에서
config.action_controller.perform_caching = true
액션에 대한 캐시도 지원되며, 위에서 이야기했던 to_api_hash의 반복적인 문제도 Low-level cache 를 적용했습니다.
def to_api_hash
cache_timestamp = self.updated_at
cache_key = "user|#{id}|#{cache_timestamp}"
Rails.cache.fetch(cache_key, expires_in: 12.hours) do
{
id: id,
...
}
end
end
물론 캐싱을 너무 남용해도 예기치 못한 지옥으로 빠져들 수 있기 때문에 신중하게 사용을 해야 합니다.
4. Database 에 index 걸기
데이터베이스 튜닝에는 수없이 많은 방법이 있지만 가능한한 최소한의 비용으로 최대의 효과를 얻을 수 있는 것이 INDEX이다. 왠만큼 데이터가 많지 않다면 위의 방법으로 코드 상에서 걸리는 시간을 최소화 하는 것이 가장 효과적입니다.
Index를 걸게 되면, Message 모델에 user_id 가 있다고 가정해봅니다.
Message가 10만개인데 그 중 2번 User의 메세지가 천개가 존재합니다.
Message 에 user_id로 Index를 걸게 되면, user_id로 그룹핑하여 Indexing을 하게 됩니다.
위와 같이 인덱싱을 하게 되면 26000개의 데이터를 각 id별로 그룹핑하여 컬럼에 대한 쿼리를 요청했을 때 각 로우의 열까지 탐색하지 않고 빠르게 리턴을 하게 된다. 물론 Index도 남용하게 되면 오히려 속도가 저하되니 자주 호출되는 쿼리의 Interger값에만 걸어봅시다! (Text의 경우 FULLTEXT 등의 인덱싱 방법이 있는데 이 부분은 추후 적용을 해보면 업데이트를)
아직도 속도 개선의 여지가 많이 남아있어 여러 원인을 찾고 해결 되면 문서는 업데이트하도록 하겠습니다.
속도가 느려진 주요 이유가 특별한 이유가 아닌, 대부분 소스가 엉망으로 짜여진 이유였기 때문에 당연한 이야기일 수도 있지만 같은 실수를 반복하지 않기 위해 애써야겠습니다 :)