본문 바로가기

Errors

[ruby on rails] n+1 해결한 기억나는 경험

다른 SI 업체에 대량의 데이터를 보내주는 작업인데, DB to DB로 보냈었다. 그때, 데이터를 연관관계에 의해서 데이터를 보냈는데, 그 연관관계에 있는 데이터에서 크기가 큰 ids를 추가하다 보니 그때마다 쿼리를 날렸었다. 하지만 이 데이터는 최초 단 한 번 보내는 것이고, 데브에서 실 서버 데이터로 테스트를 했을 땐, 별 문제가 없어서, 실서버 적용했을 때에도 문제가 없을 거라 생각했다.

하지만.. 실서버 배포하는 날 문제가 발생하였고, 1시간이 넘도록 끝까지 전송이 안되었었다. 크기가 큰 ids를 받는 쿼리가 루프에 루프를 타며 디비에서 데이터를 가져오는데 부하가 발생했던 것이다. 그래서, 전송하던 것을 바로 멈추고 코드 리팩토링에 들어갔고, 연관관계에 넣었던 조건식들을 없애고 n+1 문제를 발생하지 않게 리팩토링을 진행하였다. 물론 리팩토링도 내가 직접 하였다. 이때, 문제 발생 후 대략 2시간을 꼬박 써서 해결했었다.

 

1) 왜 테스트할 때는 문제가 없었는데, 실 서버에서 문제가 발생했을까?

로그를 보았을 때, 조건식에 들어가는 id값의 크기가 너무 커서 쿼리문 동작이 너무 느린것처럼 보였다. 그래서 시간이 엄청나게 오래 걸렸다. 또한 테스트 환경과 실서버 환경이 다르고, 실전에서는 예상치 못한 변수도 많다는 것을 인지하지 못한 나의 잘못이었다. 나는 그때 당시 예상치 못한 변수가 최적화되지 못한 쿼리문들로 인해 엄청난 데이터를 전송하는 데 있어서 쿼리가 엄청나게 느려질 수 있다는 것에 크게 위험을 못 느꼈었다.

이때 환경이 다르면 dev에서 문제가 발생하지 않더라도 실서버에서 문제가 발생할 수 있구나..라는 것을 느꼈고, 항상 최선의 쿼리를 작성해야 한다고 느낄 수 있는 뼈아픈 실수였다.

 

2) 그래서 문제를 어떻게 해결했나?

n+1이 발생하는 지점을 없애는 것이었다. 문제점은 가져온 데이터에서 연관관계에 있는 모델에 접근할 때 조건식을 넣는 과정에서 쿼리가 계속 날아갔었다. 엄청 큰 id값으로.. 

mis = Sheet.get_mis_data
gi_ids = mis.pluck('gi.id')
sheets = Sheet.get_data(gi_ids)
sheets.each |sheet|
  sheet.goods.where(id: gi_ids)
  ...
end

이러한 식으로 데이터를 가져오게 되면 가져오는 각 데이터마다 where 절 때문에 쿼리가 발생하게 되고, 엄청 큰 id 크기를 받게 된다. 그래서 이 문제를..

mis = Sheet.get_mis_data
gi_ids = mis.pluck('gi.id')
sheets = Sheet.get_data(gi_ids)
sheets.each |sheet|
  next if sheet.type == 'a'
  sheet.goods
  ...
end

내부 테이블 구조를 알아야 이해할 수 있는 내용인데.. type으로 a, b가 있는데, b인 값들만 받길 원했었다. 그래서 a인 경우는 next로 넘기고 값을 처리하였다.

해결한 내용으로는 별 것이 없지만 기록하는 이유는 id의 크기가 큰 list를 쿼리로 받으면 속도가 엄청 느려질 수가 있다는 것을 기억하기 위함이다. 그래서 이때에는 batches를 사용해서 해결해야 빠르게 검색할 수 있다.

하지만 지금 같은 경우는 batches를 사용해도 n+1문제가 발생하므로, 이렇게 조건문으로 해결하는 것이 좋은 해결책인 것이다.

 

3) 더 좋은 해결책이 있나?

batches를 사용해보고 이 코드를 다시 보았을 때, 고치고 싶은 부분이 하나 있었다.

sheets = Sheet.get_data(gi_ids)

위 부분인데, 그 이유가 ids의 크기가 엄청 크다면, 쿼리를 처리하는 과정에서 에러를 발생시킬 수 있기 때문이다.

ActiveRecord::StatementInvalid: Mysql2::Error::ConnectionError: MySQL server has gone away
from /Users/xxx/.rbenv/versions/2.6.6/lib/ruby/gems/2.6.0/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query'
Caused by Mysql2::Error::ConnectionError: MySQL server has gone away
from /Users/xxx/.rbenv/versions/2.6.6/lib/ruby/gems/2.6.0/gems/mysql2-0.5.3/lib/mysql2/client.rb:131:in `_query'

실제로 꽤 큰 ids를 넘겼을 때, 이러한 에러가 발생하였다.

 

(1) 이 부분을 어떻게 리팩토링하였는가?

batches를 사용해서 해결하였다.

find_in_batches() 함수와 in_batches() 함수는 모든 ids값을 가지고와 limit으로 자른다. 그래서 속도가 느리고, 위와 같은 에러를 발생시킬 여지가 있다.

그래서 each_slice() 함수를 사용해 리팩토링을 하였다.

BATCH_SIZE = 3000
sheets = []
gi_ids.each_slice(BATCH_SIZE) { |gis| sheets += Sheet.get_data(gis) }

이렇게 해주면 3000개의 ids 덩어리를 보내서 처리를 해준다. find_in_batches보다 훨씬 안전한 방법이어서 이 방법을 공부한 이후부터는 이렇게 데이터를 안전하게 가져왔다.

 

쿼리는 이렇게 지정해준 크기만큼 끊어서 날아간다. 아래에 있는 것은 위에 실행결과가 아닌 테스트용으로 batch_size=4로 놓고 실행한 결과이다.

Sheet Load (27.5ms)  SELECT `sheet`.* FROM `sheet` WHERE `sheet`.`id` IN (1, 2, 3, 4)
  Sheet Load (21.9ms)  SELECT `sheet`.* FROM `sheet` WHERE `sheet`.`id` IN (5, 6, 7, 8)
  Sheet Load (68.9ms)  SELECT `sheet`.* FROM `sheet` WHERE `sheet`.`id` IN (9, 10)

 

'Errors' 카테고리의 다른 글

[ruby on rails] n+1 문제 해결 경험  (0) 2021.12.24
에러를 해결하기 위한 마음가짐  (0) 2021.12.17
기억나는 에러들  (0) 2021.11.05