Ruby on Rails를 사용해 본 적이 있다면 '컨선(Concern)'이라는 개념을 한 번쯤은 접했을 겁니다. 새로운 Rails 프로젝트를 만들 때마다 app/controllers/concerns와 app/models/concerns 디렉터리가 기본으로 생성되니까요. 그런데 컨선은 대체 무엇일까요? 그리고 Rails 커뮤니티에서는 왜 종종 컨선에 대해 비판적인 이야기가 오갈까요?
컨선 한눈에 보기
Rails 컨선이란 ActiveSupport::Concern 모듈을 확장(extend)하는 모듈을 말합니다. "그럼 일반 Ruby 모듈과 뭐가 다른데?"라고 물으실 수 있겠죠. 핵심 차이는 Rails 컨선이 약간의 '마법'을 제공한다는 점입니다:
# app/models/concerns/trashable.rb
module Trashable
extend ActiveSupport::Concern
included do
scope :existing, -> { where(trashed: false) }
scope :trashed, -> { where(trashed: true) }
end
def trash
update_attribute :trashed, true
end
end여기서 눈길을 끄는 것이 바로 included라는 단어입니다. 이것은 Ruby 모듈에 뿌려진 일종의 Rails 문법 설탕(syntactic sugar)입니다. ActiveSupport::Concern은 included 블록 안에 넣은 코드가 해당 컨선이 포함되는 시점에 평가되도록 해줍니다.
예를 들어, 모델에서 휴지통(trashing) 로직을 분리하고 싶다고 해보죠. included 블록을 활용하면 위처럼 스코프를 정의한 뒤, 모델에서 아래와 같이 컨선을 포함시키면 됩니다:
class Song < ApplicationRecord
include Trashable
has_many :authors
# ...
end꽤 편리하고 단순해 보이죠? Song 모델은 살짝 가벼워졌고, 휴지통 로직은 이제 다른 모델에서도 재사용할 수 있게 됐습니다. 하지만 세상 일은 그렇게 단순하지 않습니다. 지금부터 본격적으로 파헤쳐 보겠습니다.
믹스인(Mixin)의 전형적인 예
컨선의 깊은 곳으로 들어가기 전에, 개념을 조금 더 짚고 넘어가겠습니다. 코드에서 include SomeModule이나 extend AnotherModule을 본 적이 있다면, 그것이 바로 믹스인(mixin)입니다. 믹스인은 다른 클래스에 추가할 수 있는 코드 묶음을 뜻하며, Ruby 공식 문서에 따르면 모듈은 메서드와 상수들의 집합입니다. 즉, 우리가 하는 일은 메서드와 상수를 담은 모듈을 여러 클래스에 포함시켜 해당 클래스들이 그 기능을 사용할 수 있게 하는 것입니다.
앞서 본 Trashable 컨선이 정확히 그런 사례입니다. 모델 객체를 휴지통으로 보내는 공통 로직을 하나의 모듈로 추출했고, 이 모듈은 이후 어디서든 재사용할 수 있습니다. 참고로 믹스인은 Ruby나 Rails에만 국한된 디자인 패턴이 아니라 여러 언어에서 쓰이는 범용 패턴입니다. 흥미로운 점은, 믹스인을 접한 사람들은 극명하게 갈린다는 것입니다. 좋아하는 사람들은 유용한 도구로 여기지만, 싫어하는 사람들은 금방 통제 불능 상태로 빠질 수 있다고 경계하죠.
이해를 돕기 위해 컨선 사용의 장단점을 몇 가지 살펴보겠습니다. 이를 통해 언제, 그리고 과연 컨선을 사용해야 하는지 판단 기준을 잡을 수 있기를 바랍니다.
'모든 것'을 손안에 — 강력하지만 위험한 권력
Trashable처럼 어떤 코드를 컨선으로 추출하기로 결정했다면, 그 컨선이 포함되는 곳의 모든 기능에 접근할 수 있게 됩니다. 막강한 힘이죠. 하지만 Richard Schneeman이 관련 블로그 포스트에서 말했듯이, "막강한 힘에는 복잡한 코드를 만들 능력이 따른다"고 했습니다. 그의 의도는 이렇습니다: 당신이 의존하고 있는 코드를, 원래 컨선에 있어야 한다고 생각했던 코드를 오히려 복잡하게 만들어 버릴 수 있다는 것입니다.
다시 Trashable을 살펴볼까요?
module Trashable
extend ActiveSupport::Concern
included do
scope :existing, -> { where(trashed: false) }
scope :trashed, -> { where(trashed: true) }
end
def trash
update_attribute :trashed, true
end
end이 컨선의 로직은 컨선이 포함되는 모든 곳에 trashed 필드가 존재한다는 사실에 의존합니다. 물론 큰 문제는 아닙니다. 우리가 원한 것이 바로 그거니까요. 하지만 실제 현장에서 자주 목격되는 함정이 있습니다. 사람들은 모델에 있는 다른 코드들을 컨선으로 끌어당기고 싶은 유혹을 받곤 합니다.
어떤 일이 벌어지는지 그림으로 그려볼까요. Song 모델에 featured_authors라는 메서드가 하나 더 있다고 상상해 봅시다:
class Song < ApplicationRecord
include Trashable
has_many :authors
def featured_authors
authors.where(featured: true)
end
# ...
end
class Album < ApplicationRecord
include Trashable
has_many :authors
def featured_authors
authors.where(featured: true)
end
# ...
end설명을 위해 Trashable을 포함하는 Album 모델도 추가했습니다. 이제 노래와 앨범이 휴지통으로 이동할 때 피처드 작가들에게 알림을 보내고 싶다고 해봅시다. 사람들은 이 로직을 컨선 안에 밀어 넣고 싶은 유혹을 받게 됩니다:
module Trashable
extend ActiveSupport::Concern
included do
scope :existing, -> { where(trashed: false) }
scope :trashed, -> { where(trashed: true) }
end
def trash
update_attribute :trashed, true
notify(featured_authors)
end
def notify(authors)
# ...
end
end바로 이 지점부터 일이 복잡해지기 시작합니다. 휴지통 로직이 이미 Song 모델 밖으로 나가 있으니, 알림 로직도 Trashable 컨선에 넣으면 되겠다는 생각이 드는 거죠. 그리고 그 안에서 뭔가 '어긋난' 일이 벌어집니다. featured_authors가 Song 모델에서 끌려온 것입니다. 뭐, PR 리뷰와 CI 검사를 통과한다 칩시다.
그러나 몇 달 뒤, 새로운 요구사항이 등장합니다. 노래의 featured_authors 표현 방식을 바꿔야 한다는 것입니다. 예컨대 유럽 지역의 피처드 작가만 보여달라는 요구사항이죠. 자연스럽게 개발자는 피처드 작가가 정의된 곳을 찾아 수정합니다:
class Song < ApplicationRecord
include Trashable
has_many :authors
def featured_authors
authors.where(featured: true).where(region: 'Europe')
end
# ...
end
class Album < ApplicationRecord
include Trashable
has_many :authors
# ...
end작가를 표시하는 곳에서는 잘 동작합니다. 하지만 프로덕션에 배포하고 나면, 다른 지역의 사용자들은 더 이상 자신의 노래에 대한 알림을 받지 못하게 됩니다. 이렇듯 컨선을 사용할 때는 실수하기 쉽습니다. 위 예제는 단순하고 인위적이지만, 실무에서 마주치는 사례는 훨씬 교묘합니다.
여기서 위험한 점은 무엇일까요? 컨선(믹스인)이 자신이 포함되는 모델에 대해 너무 많이 알고 있다는 것, 즉 순환 의존성(circular dependency)이 생긴다는 점입니다. Song과 Album은 휴지통 기능 때문에 Trashable에 의존하고, Trashable은 featured_authors 정의 때문에 두 모델 모두에 의존합니다. trashed 필드가 두 모델에 존재해야 컨선이 동작한다는 사실도 마찬가지입니다.
이것이 반컨선 진영이 반대하고, 친컨선 진영이 찬성하는 이유입니다. 제 개인적으로는 첫 번째 버전의 Trashable을 내 코드베이스에 두고 싶습니다. 그렇다면 알림 기능이 들어간 두 번째 버전은 어떻게 개선할 수 있을까요?
너희는 어디서 왔니? — 출처를 알 수 없는 메서드
알림 로직이 섞인 Trashable을 돌아봅시다. 뭔가 조치가 필요합니다. 컨선을 사용할 때 또 하나 저지르기 쉬운 실수가 바로 과도한 DRY화입니다. 데모를 위해 기존 모델들에 대해 DRY 원칙을 적용해 또 다른 컨선을 만들어 보겠습니다 (조금만 따라와 주세요):
module Authorable
has_many :authors
def featured_authors
authors.where(featured: true)
end
end그러면 Song과 Album은 이렇게 변합니다:
class Song < ApplicationRecord
include Trashable
include Authorable
# ...
end
class Album < ApplicationRecord
include Trashable
include Authorable
# ...
end모든 것을 DRY하게 만들었지만, 이제 유럽 피처드 작가 요구사항은 충족되지 않습니다. 설상가상으로 Trashable 컨선과 모델들이 Authorable에 의존하게 됩니다. "이게 대체 뭐 하는 짓이야?" — 제가 얼마 전 컨선을 다루면서 던졌던 정확히 같은 질문입니다. 어떤 메서드가 어디서 왔는지 추적하기가 정말 어렵습니다.
이 문제에 대한 저의 해결책은 featured_authors를 모델에 최대한 가깝게 두는 것입니다. notify 메서드는 절대로 Trashable 컨선에 속해서는 안 됩니다. 각 모델이 자신의 알림 로직을 스스로 처리해야 하며, 특히 서로 다른 대상 그룹에게 알림을 보내는 경우라면 더욱 그렇습니다. 덜 아픈 방법으로 구현해 보겠습니다:
# Concerns
module Trashable
extend ActiveSupport::Concern
included do
scope :existing, -> { where(trashed: false) }
scope :trashed, -> { where(trashed: true) }
end
def trash
update_attribute :trashed, true
end
end
module Authorable
has_many :authors
# 여러 모델에 걸쳐 공통으로 유용한 작가 관련 메서드.
# 없다면 차라리 컨선을 버리는 게 낫다.
end
# Models
class Song < ApplicationRecord
include Trashable
include Authorable
def featured_authors
authors.where(featured: true).where(region: 'Europe')
end
# ...
end
class Album < ApplicationRecord
include Trashable
include Authorable
def featured_authors
authors.where(featured: true)
end
# ...
end이 정도의 컨선이라면 관리 가능하고 그리 복잡하지 않습니다. 앞서 언급한 notify 기능은 다룰 내용이 많아 이번 글에서는 생략했습니다.
최종 보스: 컨선이 컨선을 부르는 세계
Rails의 창시자인 Basecamp에게는 컨선이 다른 컨선을 참조하는 구조가 전혀 문제가 아닌 듯합니다. DHH(David Heinemeier Hansson)가 얼마 전 트윗에서 보여준 코드가 그 증거입니다.
그 코드 스크린샷을 보면, 감탄사가 나오든 경악이 나오든 둘 중 하나입니다. 중간지대란 없는 것 같습니다. 제가 이 코드를 수정할 기회가 생긴다면 '최종 컨선 보스전(Final Concern Boss Fight)'이라고 부르고 싶네요. 농담은 접어두고, 여기서 흥미로운 점은 어떤 컨선이 어떤 컨선에 의존하는지 주석으로 남겨놨다는 사실입니다. 직접 확인해 보세요:
# ...
include Subscribable # Depends on Readable
include Eventable # Depends on Recordables
# ...이런 주석은 도움이 될 수 있습니다. 하지만 여전히 뭔가 수상한 일이 벌어질 준비가 된 구조입니다. 특히 코드베이스가 익숙하지 않은 신입 입장에서는 코드에 숨어 있는 갖가지 '함정(gotcha)'을 파악하기 어렵고, 컨선의 나선형 늪에 빠져들기 쉽습니다.
DHH는 토론 스레드의 댓글에서도 비슷한 구조의 코드를 공유한 바 있습니다. 그 안에서 누군가 "이 코드베이스를 다루는 사람들은 이런 컨선과 어떻게 상호작용하라고?"라고 물었고, DHH는 문서화된 내용이 많지 않지만 채용을 자주 하지 않아 팀원들이 코드에 매우 익숙하다고 답했습니다.
하지만 '경험 많은 팀이 코드베이스를 잘 안다'는 것을 컨선 사용의 근거로 삼는 건 어색하고 설득력도 약합니다. 결국 컨선 사용 여부는 감각의 영역에 가깝습니다. 모듈이 제공하는 사실상의 다중 상속이 편안한가요, 아니면 조합(composition)을 선호하나요? 선택은 여러분의 몫입니다.
결론
지금까지 살펴본 것처럼, 컨선은 코드를 추출하고 DRY하게 유지할 수 있는 유용한 문법 설탕을 제공하는 모듈일 뿐입니다. 만약 더 나은 도구들이 손에 익숙하다면, 컨선에 성급하게 손을 뻗지 않는 것이 좋습니다. 파일 첨부 처리나 예제에서 보여준 휴지통 로직처럼 독립적이고 자족적인 동작은 모듈(컨선)로 추출하기에 좋은 후보입니다.
이 글을 통해 컨선과 모듈을 다룰 때의 좋은 면과 나쁜 면을 모두 엿보셨기를 바랍니다. 완벽한 코드란 존재하지 않는다는 점도 기억하세요. 결국 시도해 보고 실패하거나 성공해 봐야 무엇이 자신에게 좋고 나쁜지 배울 수 있는 법이니까요.
완벽한 해결책은 없습니다. 이 글을 통해 Rails 컨선 방식의 사고와 실무 노하우를 이해하셨기를 바랍니다. 늘 그렇듯 여러분의 판단을 믿고 장단점을 인식한 채 활용하시기 바랍니다.
다음 글에서 만나요. 즐코딩 되세요!
P.S. Ruby Magic의 글을 발행 즉시 읽고 싶다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!