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

Rails의 숨겨진 보석: ActiveRecord Store

Rails는 다양한 상황에서 유용하게 쓸 수 있는 내장 도구를 여럿 갖춘 거대한 프레임워크입니다. 이 시리즈에서는 Rails의 방대한 코드베이스 속에 숨어 있는 잘 알려지지 않은 도구들을 하나씩 살펴봅니다.

이번 글에서는 ActiveRecord의 storestore_accessor 메서드에 집중해 봅니다. 두 메서드 모두 JSON이나 YAML처럼 구조화된 데이터를 데이터베이스 컬럼에 저장하는 용도로 설계되었습니다. store_accessor는 게터 메서드로 모델을 어수선하게 만들지 않고도 데이터에 담긴 값에 접근할 수 있는 편리한 방법을 제공하고, store는 한 단계 더 나아가 선택한 형식으로 데이터를 투명하게 직렬화/역직렬화해 줍니다. 이 기능이 어떤 상황에서 유용한지 이해하기 위해 관계형 데이터베이스에 JSON을 저장하는 옵션과 그 이유도 함께 살펴보겠습니다.

데이터베이스에 JSON 저장하기

먼저 짚고 넘어갈 점이 있습니다. 이 글에서 말하는 '데이터베이스'는 관계형 데이터베이스, 특히 Rails 커뮤니티에서 가장 널리 쓰이는 PostgreSQL과 MySQL을 의미합니다.

"굳이 관계형 데이터베이스에 JSON을 저장해야 할까?"라는 의문이 들 수 있습니다. 실제로 관계형 데이터베이스의 장점을 제대로 활용하려면 데이터를 분해해서 데이터 간 관계를 데이터베이스가 강제하도록 하고(예: 외래 키), 인덱싱으로 쿼리 성능을 높이는 것이 정석입니다.

하지만 관계형 데이터베이스 모델의 단점은 데이터 구조를 미리 알아야 하고, 테이블의 모든 행이 동일한 구조를 가져야 한다는 것입니다. 애플리케이션이 이런 요건을 충족하지 못하는 데이터를 중심으로 설계되었다면 NoSQL 데이터베이스를 검토해 볼 만합니다. 다만 대부분의 웹 애플리케이션에서는 익숙한 관계형 데이터베이스를 계속 사용하면서, 동적인 데이터 구조는 필요한 곳에만 적절히 "뿌려" 넣는 것이 현실적입니다. 이런 경우 JSON 컬럼이 아주 합리적인 선택이 됩니다.

JSON vs. JSONB

PostgreSQL에는 jsonjsonb, 두 종류의 JSON 컬럼이 있습니다. 핵심 차이는 jsonb는 쓰기 시점에 파싱된다는 것입니다. 즉, 데이터베이스가 더 빠르게 쿼리할 수 있는 형식으로 저장됩니다. 대신 이미 파싱된 상태이기 때문에 텍스트로 출력할 때 사용자가 입력한 원본과 정확히 일치하지 않을 수 있습니다. 예를 들어 중복 키가 제거되거나 키 순서가 바뀔 수 있습니다.

PostgreSQL 공식 문서는 특별한 이유가 없다면 대부분의 경우 jsonb를 사용하라고 권장합니다.

MySQL의 json 컬럼은 PostgreSQL의 jsonb와 비슷하게 동작합니다. "사용자가 입력한 그대로"를 출력해야 한다면 varchar 같은 컬럼을 사용해야 할 것입니다.

JSON vs. TEXT

JSON 컬럼은 데이터를 미리 파싱해 줄 뿐만 아니라, 같은 데이터를 TEXT 필드에 저장하는 것과 달리 데이터 자체를 조건으로 삼는 쿼리를 실행할 수 있습니다. 예를 들어 특정 키-값 쌍이 컬럼 안에 존재하는 모든 레코드를 조회할 수 있습니다. 다만 Rails 자체는 JSON 전용 쿼리를 거의(혹은 전혀) 지원하지 않습니다. 이런 기능들은 데이터베이스마다 다르기 때문인데, 따라서 이 기능들을 활용하려면 SQL 쿼리를 직접 작성해야 합니다.

Rails에서 JSON 컬럼 다루기

Rails는 마이그레이션에서 json(PostgreSQL에서는 jsonb) 컬럼 생성을 지원합니다:

class CreateItems < ActiveRecord::Migration[7.0]
  def change
    create_table :items do |t|
      t.jsonb :user_attributes

    ...
    end
  end
end

이 컬럼을 읽으면 Hash가 반환됩니다:

> Item.first.user_attributes
  Item Load (0.6ms)  SELECT "items".* FROM "items" ORDER BY "items"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> {"color"=>"text-red-400"}
> Item.first.update!(user_attributes: {color: "text-blue-400"})
> Item.first.user_attributes.dig(:color)
=> "text-blue-400"

Hash 타입 속성이 생기면 모델에 값을 읽고 쓰는 헬퍼 메서드를 추가하고 싶어질 수 있습니다:

class Item < ApplicationRecord
  def color=(value)
    self.user_attributes["color"] = value
  end

  def color
    user_attributes.dig("color")
  end
end

이런 메서드도 잘 동작하지만, 다룰 JSON 키가 많아지면 금세 관리하기 어려워집니다. 다행히 Rails가 해결책을 마련해 두었습니다.

ActiveRecord의 store와 store_accessor

데이터베이스에 JSON을 저장하는 일에는 직렬화와 접근이라는 두 가지 측면이 있습니다. 데이터베이스에서 json 타입 컬럼을 사용한다면 직렬화는 신경 쓸 필요가 없습니다. Rails와 데이터베이스 어댑터가 알아서 처리해 주기 때문입니다(바로 store_accessor로 넘어가도 됩니다). 반면 데이터를 TEXT 컬럼에 저장한다면 ActiveRecord의 store 메서드가 필요합니다. 이 메서드는 컬럼에 쓰는 데이터가 선택한 형식으로 직렬화되도록 보장해 줍니다.

ActiveRecord의 store

ActiveRecord에는 컬럼에 읽거나 쓰는 데이터를 자동으로 직렬화해 주는 store 메서드가 있습니다:

class Item < ApplicationRecord
  store :user_attributes, accessors: [:color], coder: JSON
end

여기서 :user_attributes는 사용할 컬럼이고, accessors는 접근하고 싶은 키 목록(여기서는 color 하나)이며, 마지막으로 데이터를 어떻게 인코딩할지 지정합니다. JSON을 사용했지만 YAML이나 커스텀 인코더 등 무엇이든 지정할 수 있습니다. 이 메서드는 선택한 coder로 직렬화를 처리하고 내부적으로 store_accessor를 호출합니다.

ActiveRecord의 store_accessor

store_accessor를 사용하면 모델에 get/set 메서드를 만들 수 있습니다:

class Item < ApplicationRecord
  store_accessor :user_attributes, :color
  store_accessor :user_attributes, :name, prefix: true
  store_accessor :user_attributes, :location, prefix: 'primary'
end

마찬가지로 user_attributes는 사용할 데이터베이스 컬럼이고, 그 뒤에 JSON 데이터에서 사용할 키를 지정하며, 마지막으로 prefix(또는 suffix) 옵션을 줄 수 있습니다. 참고로 store_accessor는 중첩된 데이터는 지원하지 않으며 최상위 키-값 쌍만 다룹니다. prefixsuffix 옵션은 불리언, 문자열, 심볼을 받을 수 있으며, 불리언 true를 전달하면 컬럼 이름이 접두사/접미사로 사용됩니다.

=>item = Item.create!(color: 'red', user_attributes_name: 'Jonathan', primary_location: 'New Zealand')
>#<Item:0x000055d63f4f0360
 id: 4,
 user_attributes: {"color"=>"red", "name"=>"Jonathan", "location"=>"New Zealand"}>
=>item.color
>"red"
=> item.user_attributes_name
>"Jonathan"
=> item.name
>NoMethodError: undefined method `name'...
=> item.primary_location
>"New Zealand"

실전 활용 사례

필자 역시 미리 정의된 전형적인 관계형 데이터베이스 스키마에서 벗어나야 했던 경우는 드물었지만, 그 몇 번의 경험 덕분에 이런 옵션 없이는 만들기 어려웠을 깔끔하고 단순한 데이터베이스 구조를 얻을 수 있었습니다.

실제로 겪은 예로, 사용자가 자신의 계정을 연결하는 여러 API를 지원해야 하는 경우가 있습니다. API마다 인증 방식이 다르면 문제가 복잡해집니다. 어떤 API는 사용자명+비밀번호를 쓰고, 어떤 API는 API 키를, 또 어떤 API는 API 키, 시크릿, 머천트 ID까지 요구합니다. 한 가지 접근법은 테이블에 컬럼을 계속 추가하는 것이지만, 그러면 대부분의 프로바이더에서 null로 남는 컬럼이 많아집니다. 반면 json 컬럼을 사용하면 해당 API에 필요한 값만 저장할 수 있습니다.

필자가 진행 중인 사이드 프로젝트에서도 JSON 저장소를 활용해 사용자가 아이템에 임의의 속성(사용자 정의 속성 포함)을 설정할 수 있게 했습니다. 유동적이고 예측하기 어려운 이런 데이터에는 JSON 저장소(알려진 속성에는 store_accessor 적용)가 자연스럽게 어울립니다.

마치며

데이터와 데이터 구조가 자주 바뀌거나 미리 알 수 없을 때 JSON 데이터(그리고 ActiveRecord의 헬퍼들)는 매우 유용합니다. 물론 이런 저장 방식도 대부분의 기술 선택과 마찬가지로 트레이드오프가 있습니다. 특정 레코드의 데이터 구조에서 큰 유연성을 얻는 대신, 데이터베이스 제약조건이 제공하는 데이터 무결성 일부를 포기해야 합니다. 또한 일반적인 ActiveRecord 쿼리, 조인 등으로 레코드 간 조회 능력도 떨어집니다.

경험칙을 몇 가지 소개하면, 다음에 해당한다면:

  1. 모든 행에서 JSON 키가 동일하다는 것을 알고 있거나,
  2. 다른 데이터베이스 테이블의 ID(기본 키)를 저장하고 있거나,
  3. JSON 안에 다른 테이블의 레코드를 조회하는 데 쓰이는 값을 저장하고 있다면

새 테이블을 만들어 데이터베이스가 데이터 무결성을 강제하도록 하는 편이 좋습니다. 반면, 다른 테이블과 직접 연관되지 않는 행 고유의 데이터를 저장하는 것이라면 JSON이 데이터베이스 구조를 단순화하는 데 도움이 될 수 있습니다.