[Rails] 직접 만든association으로 N + 1 해결하기

Jinsu Kim·2025년 6월 27일
0

rails

목록 보기
8/8

안녕하세요~🙌
Rails로 백엔드 개발을 담당하고 있는 일본 Rails외노자입니다
이번에는 오랜만에 N + 1를 만나고 Rails의 기능들과 친해질 수 있던 시간이 있었기에 글로 남겨놓고자 합니다

개요

먼저 기간이 매우 빡빡한 일정안에서 개발을 해야 함으로서 테이블 설계가 완벽하지 않았는데, 개발을 하면서 N + 1해결이 쉽게 되지가 않아서 여러가지 방법을 검토하고 삽질해보면서 배웠던 점과 해결 방법에 대해서 작성해보고자 합니다

글을 읽으면 좋은 분

  • Rails preload, eager_load, includes설정으로 쉽게 N + 1이 해결이 되지 않으셨던 분
  • Rails의 N + 1해결에 관심이 많으신 분

📝 글을 읽기에 앞서 사전지식

자세하게 정리한 글들이 많아서 블로그로 대처하겠습니다. 죄송합니다🥲

SQL join과 N + 1 해결을 위한 캐쉬

먼저 joins, preload, includes, eager_load에 대해서 번역해 놓은 글이 있으니 참고 해주시면 감사하겠습니다.

[Rails] joins vs preload vs includes vs eager_load의 차이점

associations

has_one, has_many를 하나하나 설명하지 않아도 가이드에 너무 잘 나와있기 때문에 자세한 내용이 궁금하신분은 아래의 글을 참고해주세요!
Rails Guide Active Record Associations

N + 1을 해결하는 과정

먼저 어떤 문제가 있었는가

Rails에서 보통 SQL을 작성할 때 많이 보이는 케이스라고 하면 아래와 같이 1:1, 1:N, N:M과 같은 관계를 ActiveRecord를 경우하여 테이블을 조인하여 해결을 할 것 같습니다

SELECT * FROM users
INNER JOIN user_items ON users.id = user_items.user_id

이번에 직면 하였던 문제가 테이블과 테이블이 아닌 파생 테이블로 테이블을 하나 만들고 그 테이블을 다시 조인해야 하는 경우가 발생하였습니다.

SELECT * FROM users
LEFT OUTER JOIN(
  SELECT * FROM block_members INNER JOIN members ON members.id = block_members.member_id
  WHERE  members.type = 'xxx'
)
WHERE block_members.id IS NULL;

만약 Rails Code를 작성해야 한다면 scope를 작성하거나 method를 작성하여 불러올 필요가 있는데, 이 때 문제점이, preload, eager_load를 사용하여도 쿼리를 데이터 수만큼 불러온다는 것이 었습니다. 데이터수가 10개도 안된다면 100번 양보하여 N + 1이 발생하도록 냅두는 의사 결정을 할 수 도 있겠지만 이번에 개선했던 부분에 데이터수는 많으면 100개가 넘어가는 레코드를 가저올 수 도 있었고 느려지면 유저경험에 문제가 생겼기에 꼭 해결해야 하는 부분이었습니다.

scope :not_block_members, -> { SQL을 발행하는 코드 }

먼저 어떻게 해결하였는지에 앞서 여러가지 방법을 찾아보았는데요

  1. SQL을 직접 작성하여 다른 테이블의 레코드를 SELECT절에서 가저온 뒤 인스턴스로 만들어주는 방법
  2. ActiveRecord::Associations::Preloader.new를 지정할 때 scope를 작성해주어 각 위치에 정의 해주는 방법
  3. ✅ Rails associations에 직정 정의 하여 preload로 association을 지정하는 방법 ✅

먼저 1번과 2번을 사용하지 않았던 이유를 가볍게 설명한 뒤 3번에 대해 설명하겠습니다

1. SQL을 직접 작성하여 다른 테이블의 레코드를 SELECT절에서 가저온 뒤 인스턴스로 만들어주는 방법

이번 예시는 유저에게서 최신 메시지를 가저오는 쿼리인데, 최신 레코드를 갖는 테이블을 만들고 그 테이블을 유저 테이블과 조인을 합니다. 그 뒤에 가저오고 싶은 칼럼을 정의하여 레코드를 가저옵니다

SELECT 
  users.*, 
  latest_messages.idas as lm_id,
  latest_messages_content as lm_content 
FROM users
JOINS
(SELECT messages.id as user_id, messages.xxx FROM messages
  LEFT OUTER JOIN(
    SELECT * FROM block_members INNER JOIN members ON members.id =   block_members.member_id
    WHERE  members.type = 'xxx'
  )
  WHERE block_members.id IS NULL;
  GROUP BY users.id
) as latest_messages ON users.id == latest_messages.user_id

위에서 가저온 레코드를 아래의 코드처럼 user에 인스턴스 변수를 설정하여 대입을 해줍니다. 여기 코드에서는 많이 생략을 해서 조금 간단해 보일 수 있지만 코드의 길이와 가독성이 떨어지고 Table을 3~4번 Join해야 하는 매우 드는 쿼리를 작성할 필요가 있었기에 좀 더 심플하게 코드를 작성하기 위해 2번 방법을 검토하였습니다

user_with_latest_messages = # 위에 SQL을 실행하는 메소드

user_with_latest_messages.each do |user|
  user.message = Message.new(id: lm_id, content, lm_content)
end

2. ActiveRecord::Associations::Preloader.new를 지정할 때 scope를 작성해주어 각 위치에 정의 해주는 방법

코드를 더 심플하고 Rails를 사용하여 해결 할 수 없을까 찾아보던 중 Preloader에 scope를 직접 지정할 수 있다는 정보를 얻고 시험해보았습니다. 메소드 사용방법을 보면 preload_scope을 지정할 수 있는데 여기에 자신이 원하는 스콥을 지정하면 association에 scope을 오버라이드 할 수 있습니다.

preload(records, associations, preload_scope = nil) public

참조: https://apidock.com/rails/ActiveRecord/Associations/Preloader/preload

예시와 같이 User모델에 최신 메시지를 가저오면 has_one association과 Message는 최신 메시지가 가장 먼저있는 코드를 작성한다 Controller에서 직접 설정할 수 있습니다.
(has_one작성해놓면 Rails가 알아서 가장 첫번째 있는 레코드를 취득해준다. 너무 편리💪)

class User
  has_one :latest_message
end

class Message
  scope :user_latest_message -> { # code }
end

class xxxController
  users = User.all.preload(:latest_message)
  
  ActiveRecord::Associations::Preloader.new.preload(
    reocrds: users,
    associations: latest_message,
    preload_scope: Message.user_latest_message
  end
  
  json { users }
end

1번에 Join문으로 가저오는 것과 비교하면 SQL Join이 2번정도로 끝낼 수 있고 코드도 매우 간단하게 작성할 수 있어서 이 해결책으로 끝낼 까 하였지만...
단점으로는 이 preload_scope사용해야 하는 걸 모르는 유저가 이 코드를 만젔을 때 의도치 않은 에러를 발생시킬 수 있다는 문제점과 latest_message의 N + 1를 해결 할 때마다 Preloader로직을 불러올 필요가 있다는 점이었다. 이런 문제를 해결 하기 위해 3번의 해결책을 작성하였고 문제를 해결 하였습니다

Rails associations에 직정 정의 하여 preload로 association을 지정하는 방법

class User
  has_one :a_type_latest_message () -> {
    user_latest_message(a_type)
  }, class_name: Message
  has_one :b_type_latest_message () -> {
    user_latest_message(b_type)
  }, class_name: Message
end

class Message
  scope :user_latest_message ->(type) { # type에 해당하는 유저의 최신 메시지 획득 }
end

class xxxAController
  users = User.all.preload(:a_type_latest_message)
    
  json { users }
end

class xxxBController
  users = User.all.preload(:b_type_latest_message)
    
  json { users }
end

결론적으로는 User에서 레코드를 가저올 수 있도록 지정한뒤 has_one안에서 Message모델에 scope을 불러오도록 하였다.
이 때 수정 포인트는 order을 사용하여 해당 유저의 가장 최신 메시지가 위로 올 수 있도록 하는 것입니다.

iduser_idcontentcreated_at
11잘지내?2025-06-28 12:00
21자니?2025-06-28 11:30
32축구 고?2025-06-28 12:30
42내일2025-06-28 11:00

위에 레코드 처럼 정렬해놓으면 Rails의 has_one association이 아래와 같이 해당 유저의 가장 최신 레코드를 가저와 준다
다시 한번 너무 편리...

# user_id: 1
puts user1.latest_message
id: 1user_id: 1, content: 잘지내?

# user_id:2
puts user2.latest_message
id:3, user_id: 2, content: 축구 고?

추가적으로 has_one에 preload를 해놓면 예를 들어 company.users.preload(:latest_message)를 한다면 company테이블에 대해서도 rails에서 자동으로 로직도 추가해주니 더더욱 심플하게 작성할 수 있다.

SELECT * FROM users
INNER JOIN companies ON company.id = users.company_id
xxx
WHERE company_id = 1

이렇게 Rails와의 다른 테이블과의도 Join도 매우 편리해지고 코드가 심플해지는 점, 사양을 모르는 개발자가 있어도 괜찮은 점들이 해결되었기에 3번에 방법으로 개발을 하여 해결하였습니다!!!

마무리

지금 까지 preload, eager_load에 테이블을 정의하면 해결되는 문제가 대부분이였는데, 이번 테이블 설계로 Rails의 preload에 대해서 더 깊게 알아볼 수 있는 시간이었습니다. 테이블 설계를 잘 하면 애초에 이런 문제를 안 만날 가능성이 크지만, 사용자와 개발일정은 언제나 저희를 기다려주지 않기에... 여러가지 문제로 인해 N + 1를 해결 하지 못한 분이 계신다면 이 글이 조금이라도 참고가 되었으면 좋겠습니다!
긴글 읽어주셔서 감사합니다! 틀린 부분이나 궁금하신 내용이 있으시다면 언제든 코멘트 환영입니다~

profile
Ruby와 js로 첫 커리어를 시작하였고 4년차 엔진니어입니다! 현재 Rails, vim에 관심이 많습니다!

0개의 댓글