클라이언트는 어떻게 이런 상태에 이르게 되었을까?
세부적인 내용으로 들어가기 전에, 애플리케이션이 어떻게 이런 상태에 도달하게 되는지 이해해 보자. 우리는 간단한 users 테이블에서 출발한다. 몇 주 후에는 마지막 로그인 시간을 알아야 해서 users.last_sign_in_at 컬럼을 추가한다. 그다음엔 사용자의 이름이 필요해진다. first_name과 last_name을 추가한다. 트위터 핸들은? 또 다른 컬럼이다. GitHub 프로필은? 전화번호는? 몇 달이 지나면 테이블은 감당하기 어려울 정도로 비대해진다.
이 구조의 문제점은 무엇일까?
이렇게 거대해진 테이블은 여러 가지 문제를 시사한다.
User모델이 서로 관련 없는 여러 책임을 떠안게 된다. 이는 코드를 이해하고, 수정하고, 테스트하기 더 어렵게 만든다.- 앱과 데이터베이스 간에 데이터를 주고받을 때 불필요한 대역폭이 추가로 소모된다.
- 비대해진 모델을 담기 위해 앱은 더 많은 메모리를 필요로 한다.
앱은 인증과 권한 부여를 위해 매 요청마다 User를 조회하지만, 실제로는 소수의 컬럼만 사용하는 경우가 대부분이었다. 이 문제를 해결하면 설계와 성능 양쪽 모두 개선할 수 있다.
테이블 추출하기
자주 사용되지 않는 컬럼들을 새로운 테이블(또는 테이블들)로 추출하면 문제를 해결할 수 있다. 예를 들어 프로필 정보(first_name 등)를 다음 단계를 거쳐 profiles 테이블로 추출할 수 있다.
users의 프로필 관련 컬럼들을 복제하는 컬럼들로profiles테이블을 생성한다.users에profile_id를 추가한다. 일단은NULL로 둔다.users의 각 행에 대해 프로필 관련 컬럼 값을 복제한 행을profiles에 삽입한다.users의 해당 행에 있는profile_id가 3번에서 삽입한 행을 가리키도록 한다.- 절대로
users.profile_id를 NOT NULL로 만들지 않는다. 아직 앱이 이 컬럼의 존재를 알지 못하므로 오류가 발생할 것이다.
이제 users.first_name에 대한 참조를 profiles.first_name 등으로 교체해야 한다. 추출하는 컬럼이 몇 개 안 되고 참조 지점도 적다면 수동으로 작업하는 것을 권장한다. 하지만 "아, 이건 최악의 작업이야!"라고 생각하는 순간이 온다면 다른 방법을 찾아야 한다.
문제를 방치해서는 안 된다. 모두가 피하는 코드는 더욱 악화되고, 결국 더 큰 방치를 초래한다. 이 악순환을 끊는 가장 쉬운 방법은 작게 시작하는 것이다.
클라이언트가 이 문제를 어떻게 해결했는지 궁금하다면 계속 읽어보자.
한 번에 한 줄씩 코드 고치기
가장 점진적인 접근 방식은 기존 컬럼에 대한 참조를 한 번에 하나씩 수정하는 것이다. first_name을 users에서 profiles로 옮기는 작업에 집중해 보자.
먼저 다음과 같이 Profile 모델을 생성한다.
rails generate model Profile first_name:string그다음 users에서 profiles로의 참조를 추가하고 users.first_name 데이터를 profiles로 복사한다.
class ExtractUsersFirstNameToProfiles < ActiveRecord::Migration
# Redefine the models to break dependency on production code. We need
# vanilla models without callbacks, etc. Also, removing a model in the future
# might break the migration.
class User < ActiveRecord::Base; end
class Profile < ActiveRecord::Base; end
def up
add_reference :users, :profile, index: true, unique: true, foreign_key: true
User.find_each do |user|
profile = Profile.create!(first_name: user.first_name)
user.update!(profile_id: profile.id)
end
change_column_null :users, :profile_id, false
end
def down
remove_reference :users, :profile
end
end각 사용자가 정확히 하나의 프로필을 갖도록 강제하기 때문에, 반대 방향의 참조보다 users에서 profiles로의 참조가 바람직하다.
데이터베이스 구조가 준비되면 User의 first_name을 Profile에 위임할 수 있다. 클라이언트는 몇 가지 요구 사항을 제시했다.
- 접근자는 연관된
Profile을 사용해야 한다. 또한 폐기 예정인(deprecated) 접근자가 어디서 호출되었는지 로그로 남겨야 한다. - 폐기 예정인 접근자를 사용하는 코드가 깨지지 않도록,
User를 저장할 때Profile도 자동으로 저장되어야 한다. User#first_name_changed?등ActiveModel::Dirty계열 메서드들이 여전히 정상 동작해야 한다.
즉, User는 다음과 같은 모습이어야 한다.
class User < ActiveRecord::Base
# We need autosave as the client code might be unaware of
# Profile#first_name and still reference User#first_name.
belongs_to :profile, autosave: true
def first_name
log_backtrace(:first_name)
profile.first_name
end
def first_name=(new_first_name)
log_backtrace(:first_name)
# Call super so that User#first_name_changed? and similar still work as
# expected.
super
profile.first_name = new_first_name
end
private
def log_backtrace(name)
filtered_backtrace = caller.select do |item|
item.start_with?(Rails.root.to_s)
end
Rails.logger.warn(<<-END)
A reference to an obsolete attribute #{name} at:
#{filtered_backtrace.join("\n")}
END
end
end이 변경 후에도 앱은 동일하게 동작하지만, Profile에 대한 추가 참조 때문에 약간 느려질 수 있다(성능이 문제가 된다면 AppSignal 같은 도구를 활용하면 된다). 이 코드는 레거시 속성에 대한 모든 참조를 로그로 남긴다. 심지어 grep으로 찾을 수 없는 형태의 참조(예: user[attr] = ... 또는 user.send("#{attr}=" , ...))까지 기록하므로, grep이 무용지물일 때도 모든 참조 지점을 찾아낼 수 있다.
이러한 기반이 마련되면 정기적인 일정에 따라 users.first_name에 대한 참조를 하나씩 수정하겠다고 약속할 수 있다. 예를 들어 매일 아침(하루를 빠른 성취감으로 시작하기 위해)이나 정오 무렵(집중적인 오전 업무 후 가벼운 작업을 하기 위해)이 좋다. 이러한 약속이 필수적이다. 목표는 문제 해결에 대한 심리적 장벽을 낮추는 것이기 때문이다. 위 코드를 그대로 두고 아무 조치도 취하지 않으면 앱은 더욱 낙후될 뿐이다.
모든 폐기 예정 참조를 제거하고(grep과 로그로 확인한 후) 드디어 users.first_name 컬럼을 삭제할 수 있다.
class RemoveUsersFirstName < ActiveRecord::Migration
def change
remove_column :users, :first_name, :string
end
endUser에 추가했던 코드도 더 이상 필요 없으므로 함께 제거해야 한다.
이 방법의 한계
이 방법이 당신의 경우에도 적용될 수 있지만, 몇 가지 한계를 염두에 두자.
User.update_all같은 벌크(bulk) 쿼리는 처리하지 못한다.- Raw SQL 쿼리는 처리하지 못한다.
- 몽키패치(monkey-patch)가 깨질 수 있다(의존성이 몽키패치를 도입할 수도 있음을 기억하자).
profiles.first_name만 갱신되고users.first_name이 갱신되지 않으면User와Profile이 서로 동기화되지 않을 수 있다.
일부 한계는 충분히 극복할 수 있다. 예를 들어 서비스 객체나 Profile의 콜백으로 모델 간 동기화를 유지할 수 있다. PostgreSQL을 사용한다면 과도기적으로 머티리얼라이즈드 뷰(materialized view)를 활용하는 것도 고려할 만하다.
마무리하며
이 글의 가장 중요한 교훈은 악취 나는 코드를 회피하지 말고 정면으로 마주하라는 것이다. 작업이 너무 방대하다면 정기적인 일정에 따라 반복적으로 진행하자. 이 글은 테이블 추출이 어려울 때 고려할 수 있는 하나의 방법을 소개했다. 적용하기 어렵다면 다른 방법을 찾으면 된다. 방법이 떠오르지 않는다면 언제든 연락하기 바란다. 도움을 주겠다. 당신의 비트(bit)가 썩도록 내버려 두지 말자.