안녕하세요~🙌
Rails로 백엔드 개발을 담당하고 있는 일본 Rails외노자입니다
이번에는 오랜만에 N + 1를 만나고 Rails의 기능들과 친해질 수 있던 시간이 있었기에 글로 남겨놓고자 합니다
먼저 기간이 매우 빡빡한 일정안에서 개발을 해야 함으로서 테이블 설계가 완벽하지 않았는데, 개발을 하면서 N + 1해결이 쉽게 되지가 않아서 여러가지 방법을 검토하고 삽질해보면서 배웠던 점과 해결 방법에 대해서 작성해보고자 합니다
자세하게 정리한 글들이 많아서 블로그로 대처하겠습니다. 죄송합니다🥲
먼저 joins, preload, includes, eager_load에 대해서 번역해 놓은 글이 있으니 참고 해주시면 감사하겠습니다.
[Rails] joins vs preload vs includes vs eager_load의 차이점
has_one, has_many를 하나하나 설명하지 않아도 가이드에 너무 잘 나와있기 때문에 자세한 내용이 궁금하신분은 아래의 글을 참고해주세요!
Rails Guide Active Record Associations
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번과 2번을 사용하지 않았던 이유를 가볍게 설명한 뒤 3번에 대해 설명하겠습니다
이번 예시는 유저에게서 최신 메시지를 가저오는 쿼리인데, 최신 레코드를 갖는 테이블을 만들고 그 테이블을 유저 테이블과 조인을 합니다. 그 뒤에 가저오고 싶은 칼럼을 정의하여 레코드를 가저옵니다
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
코드를 더 심플하고 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번의 해결책을 작성하였고 문제를 해결 하였습니다
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을 사용하여 해당 유저의 가장 최신 메시지가 위로 올 수 있도록 하는 것입니다.
id | user_id | content | created_at |
---|---|---|---|
1 | 1 | 잘지내? | 2025-06-28 12:00 |
2 | 1 | 자니? | 2025-06-28 11:30 |
3 | 2 | 축구 고? | 2025-06-28 12:30 |
4 | 2 | 내일 | 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를 해결 하지 못한 분이 계신다면 이 글이 조금이라도 참고가 되었으면 좋겠습니다!
긴글 읽어주셔서 감사합니다! 틀린 부분이나 궁금하신 내용이 있으시다면 언제든 코멘트 환영입니다~