Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

증가하는 사용자 테이블을 길들이는 실전 리팩토링 가이드

클라이언트는 어떻게 이런 상태에 이르게 되었을까?

세부적인 내용으로 들어가기 전에, 애플리케이션이 어떻게 이런 상태에 도달하게 되는지 이해해 보자. 우리는 간단한 users 테이블에서 출발한다. 몇 주 후에는 마지막 로그인 시간을 알아야 해서 users.last_sign_in_at 컬럼을 추가한다. 그다음엔 사용자의 이름이 필요해진다. first_namelast_name을 추가한다. 트위터 핸들은? 또 다른 컬럼이다. GitHub 프로필은? 전화번호는? 몇 달이 지나면 테이블은 감당하기 어려울 정도로 비대해진다.

이 구조의 문제점은 무엇일까?

이렇게 거대해진 테이블은 여러 가지 문제를 시사한다.

  1. User 모델이 서로 관련 없는 여러 책임을 떠안게 된다. 이는 코드를 이해하고, 수정하고, 테스트하기 더 어렵게 만든다.
  2. 앱과 데이터베이스 간에 데이터를 주고받을 때 불필요한 대역폭이 추가로 소모된다.
  3. 비대해진 모델을 담기 위해 앱은 더 많은 메모리를 필요로 한다.

앱은 인증과 권한 부여를 위해 매 요청마다 User를 조회하지만, 실제로는 소수의 컬럼만 사용하는 경우가 대부분이었다. 이 문제를 해결하면 설계와 성능 양쪽 모두 개선할 수 있다.

테이블 추출하기

자주 사용되지 않는 컬럼들을 새로운 테이블(또는 테이블들)로 추출하면 문제를 해결할 수 있다. 예를 들어 프로필 정보(first_name 등)를 다음 단계를 거쳐 profiles 테이블로 추출할 수 있다.

  1. users의 프로필 관련 컬럼들을 복제하는 컬럼들로 profiles 테이블을 생성한다.
  2. usersprofile_id를 추가한다. 일단은 NULL로 둔다.
  3. users의 각 행에 대해 프로필 관련 컬럼 값을 복제한 행을 profiles에 삽입한다.
  4. users의 해당 행에 있는 profile_id가 3번에서 삽입한 행을 가리키도록 한다.
  5. 절대로 users.profile_id를 NOT NULL로 만들지 않는다. 아직 앱이 이 컬럼의 존재를 알지 못하므로 오류가 발생할 것이다.

이제 users.first_name에 대한 참조를 profiles.first_name 등으로 교체해야 한다. 추출하는 컬럼이 몇 개 안 되고 참조 지점도 적다면 수동으로 작업하는 것을 권장한다. 하지만 "아, 이건 최악의 작업이야!"라고 생각하는 순간이 온다면 다른 방법을 찾아야 한다.

문제를 방치해서는 안 된다. 모두가 피하는 코드는 더욱 악화되고, 결국 더 큰 방치를 초래한다. 이 악순환을 끊는 가장 쉬운 방법은 작게 시작하는 것이다.

클라이언트가 이 문제를 어떻게 해결했는지 궁금하다면 계속 읽어보자.

한 번에 한 줄씩 코드 고치기

가장 점진적인 접근 방식은 기존 컬럼에 대한 참조를 한 번에 하나씩 수정하는 것이다. first_nameusers에서 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로의 참조가 바람직하다.

데이터베이스 구조가 준비되면 Userfirst_nameProfile에 위임할 수 있다. 클라이언트는 몇 가지 요구 사항을 제시했다.

  1. 접근자는 연관된 Profile을 사용해야 한다. 또한 폐기 예정인(deprecated) 접근자가 어디서 호출되었는지 로그로 남겨야 한다.
  2. 폐기 예정인 접근자를 사용하는 코드가 깨지지 않도록, User를 저장할 때 Profile도 자동으로 저장되어야 한다.
  3. 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
end

User에 추가했던 코드도 더 이상 필요 없으므로 함께 제거해야 한다.

이 방법의 한계

이 방법이 당신의 경우에도 적용될 수 있지만, 몇 가지 한계를 염두에 두자.

  • User.update_all 같은 벌크(bulk) 쿼리는 처리하지 못한다.
  • Raw SQL 쿼리는 처리하지 못한다.
  • 몽키패치(monkey-patch)가 깨질 수 있다(의존성이 몽키패치를 도입할 수도 있음을 기억하자).
  • profiles.first_name만 갱신되고 users.first_name이 갱신되지 않으면 UserProfile이 서로 동기화되지 않을 수 있다.

일부 한계는 충분히 극복할 수 있다. 예를 들어 서비스 객체나 Profile의 콜백으로 모델 간 동기화를 유지할 수 있다. PostgreSQL을 사용한다면 과도기적으로 머티리얼라이즈드 뷰(materialized view)를 활용하는 것도 고려할 만하다.

마무리하며

이 글의 가장 중요한 교훈은 악취 나는 코드를 회피하지 말고 정면으로 마주하라는 것이다. 작업이 너무 방대하다면 정기적인 일정에 따라 반복적으로 진행하자. 이 글은 테이블 추출이 어려울 때 고려할 수 있는 하나의 방법을 소개했다. 적용하기 어렵다면 다른 방법을 찾으면 된다. 방법이 떠오르지 않는다면 언제든 연락하기 바란다. 도움을 주겠다. 당신의 비트(bit)가 썩도록 내버려 두지 말자.