기술 스택 대안 선택 - 기복
게시 됨: 2022-03-11웹 애플리케이션이 충분히 크고 오래되었다면 더 작고 격리된 부분으로 분해하고 서비스를 추출해야 할 때가 올 수 있습니다. 그 중 일부는 다른 것보다 더 독립적입니다. 이러한 결정을 내릴 수 있는 몇 가지 이유는 다음과 같습니다. 테스트 실행 시간 단축, 앱의 다른 부분을 독립적으로 배포할 수 있음, 하위 시스템 간 경계 적용. 서비스 추출을 위해서는 소프트웨어 엔지니어가 많은 중요한 결정을 내려야 하며, 그 중 하나는 새로운 서비스에 사용할 기술 스택입니다.
이 게시물에서 우리는 모놀리식 애플리케이션인 Toptal Platform 에서 새로운 서비스를 추출하는 이야기를 공유합니다. 우리는 우리가 선택한 기술 스택과 그 이유를 설명하고 서비스 구현 중에 발생한 몇 가지 문제를 간략하게 설명합니다.
Toptal의 연대기 서비스는 Toptal 플랫폼에서 수행되는 모든 사용자 작업을 처리하는 앱입니다. 작업은 기본적으로 로그 항목입니다. 사용자가 어떤 작업(예: 블로그 게시물 게시, 작업 승인 등)을 수행하면 새 로그 항목이 생성됩니다.
플랫폼에서 추출했지만 기본적으로 플랫폼에 의존하지 않으며 다른 앱과 함께 사용할 수 있습니다. 이것이 우리가 프로세스에 대한 자세한 설명을 게시하고 엔지니어링 팀이 새 스택으로 전환하는 동안 극복해야 했던 여러 문제에 대해 논의하는 이유입니다.
서비스를 추출하고 스택을 개선하기로 한 결정 뒤에는 여러 가지 이유가 있습니다.
- 우리는 다른 서비스가 다른 곳에서 표시되고 사용될 수 있는 이벤트를 기록할 수 있기를 원했습니다.
- 히스토리 레코드를 저장하는 데이터베이스 테이블의 크기가 비선형적으로 빠르게 증가하여 높은 운영 비용이 발생했습니다.
- 우리는 기존의 구현이 기술적인 부채에 의해 부담이 된다고 판단했습니다.
언뜻 보기에는 단순한 계획처럼 보였습니다. 그러나 대체 기술 스택을 처리하면 예기치 않은 단점이 생기는 경향이 있으며 이것이 오늘 기사에서 해결하려는 것입니다.
아키텍처 개요
Chronicles 앱은 다소 독립적일 수 있고 별도의 Docker 컨테이너에서 실행되는 세 부분으로 구성됩니다.
- Kafka 소비자 는 항목 생성 메시지의 매우 얇은 Karafka 기반 Kafka 소비자입니다. 수신된 모든 메시지를 Sidekiq에 추가합니다.
- Sidekiq 작업자 는 Kafka 메시지를 처리하고 데이터베이스 테이블에 항목을 생성하는 작업자입니다.
- GraphQL 끝점:
- 공개 끝점 은 다양한 플랫폼 기능에 사용되는 항목 검색 API를 노출합니다(예: 스크리닝 버튼에 대한 주석 툴팁을 렌더링하거나 작업 변경 이력을 표시하기 위해).
- 내부 끝점 은 데이터 마이그레이션에서 태그 규칙 및 템플릿을 생성하는 기능을 제공합니다.
두 개의 서로 다른 데이터베이스에 연결하는 데 사용되는 연대기:
- 자체 데이터베이스(태그 규칙 및 템플릿을 저장하는 위치)
- 플랫폼 데이터베이스(사용자가 수행한 작업과 해당 태그 및 태깅을 저장하는 곳)
앱을 추출하는 과정에서 Platform 데이터베이스에서 데이터를 마이그레이션하고 Platform 연결을 종료했습니다.
초기 계획
처음에 우리는 Hanami와 기본적으로 제공하는 모든 생태계(ROM.rb, dry-rb, hanami-newrelic 등으로 뒷받침되는 하나미 모델)를 사용하기로 결정했습니다. 작업을 수행하는 "표준" 방식을 따르면 마찰이 적고 구현 속도가 빠르며 직면할 수 있는 모든 문제에 대한 "구글 가능성"이 매우 뛰어납니다. 또한 하나미 생태계는 성숙하고 대중적이며 라이브러리는 Ruby 커뮤니티의 존경받는 구성원에 의해 신중하게 유지 관리됩니다.
또한 시스템의 많은 부분이 이미 플랫폼 측에서 구현되었기 때문에(예: GraphQL 항목 검색 엔드포인트 및 CreateEntry 작업) 많은 코드를 변경하지 않고 그대로 Platform에서 Chronicles로 복사할 계획이었습니다. 이것은 또한 Elixir에서 허용하지 않았기 때문에 우리가 Elixir를 사용하지 않은 주요 이유 중 하나였습니다.
우리는 Rails를 사용하지 않기로 결정했습니다. 특히 ActiveSupport와 같은 소규모 프로젝트에는 너무 많은 작업이 필요하기 때문에 실질적인 이점을 많이 제공하지 못할 것이기 때문입니다.
계획이 남쪽으로 갈 때
우리는 계획을 지키기 위해 최선을 다했지만 여러 가지 이유로 곧 탈선했습니다. 하나는 선택한 스택에 대한 경험이 부족하고 스택 자체에 대한 진정한 문제가 있었고 비표준 설정(2개의 데이터베이스)이 있었습니다. 결국 우리는 hanami-model
을 없애고 하나미 자체를 Sinatra로 교체하기로 결정했습니다.
Sinatra는 12년 전에 생성된 활발하게 유지 관리되는 라이브러리이고 가장 인기 있는 라이브러리 중 하나이기 때문에 팀의 모든 구성원이 충분한 실습 경험을 가지고 있기 때문에 Sinatra를 선택했습니다.
호환되지 않는 종속성
연대기 추출은 2019년 6월에 시작되었으며 당시 Hanami는 최신 버전의 dry-rb gem과 호환되지 않았습니다. 즉, 당시 Hanami의 최신 버전(1.3.1)은 dry-validation 0.12만 지원했고 우리는 dry-validation 1.0.0을 원했습니다. 우리는 1.0.0에서만 도입된 건식 검증의 계약을 사용할 계획이었습니다.
또한 Kafka 1.2는 Dry gem과 호환되지 않으므로 저장소 버전을 사용했습니다. 현재 우리는 최신 드라이 젬에 의존하는 1.3.0.rc1을 사용하고 있습니다.
불필요한 종속성
또한 Hanami gem에는 hanami-cli
, hanami-assets
, hanami-mailer
, hanami-view
, hanami-controller
와 같이 사용하지 않으려는 종속성이 너무 많이 포함되어 있습니다. 또한 hanami-model readme를 보면 기본적으로 하나의 데이터베이스만 지원한다는 것이 분명해졌습니다. 반면에 hanami-model
의 기반이 되는 ROM.rb는 기본적으로 다중 데이터베이스 구성을 지원합니다.
대체로 하나미 전체, 특히 hanami-model
은 불필요한 추상화 수준으로 보였습니다.
그래서 연대기에 의미 있는 첫 홍보를 하고 10일 만에 하나미를 시나트라로 완전히 바꿨습니다. 복잡한 라우팅이 필요하지 않기 때문에 순수한 Rack도 사용할 수 있었지만(GraphQL 엔드포인트 2개, /ping 엔드포인트 및 sidekiq 웹 인터페이스의 4가지 "정적" 엔드포인트가 있음) 너무 하드코어하지 않기로 결정했습니다. Sinatra는 우리에게 딱 맞았습니다. 더 자세히 알고 싶다면 Sinatra 및 Sequel 튜토리얼을 확인하십시오.
Dry-Schema 및 Dry-validation 오해
건식 검증을 올바르게 "요리"하는 방법을 알아내는 데 시간과 많은 시행착오가 필요했습니다.
params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end
위의 스니펫에서 url
매개변수는 몇 가지 약간 다른 방식으로 정의됩니다. 일부 정의는 동일하고 다른 정의는 의미가 없습니다. 처음에는 모든 정의를 완전히 이해하지 못했기 때문에 이러한 모든 정의의 차이점을 구분할 수 없었습니다. 결과적으로 우리 계약의 첫 번째 버전은 상당히 엉망이었습니다. 시간이 지남에 따라 DRY 계약을 올바르게 읽고 작성하는 방법을 배웠고 이제 일관성 있고 우아해 보입니다. 사실 우아할 뿐만 아니라 아름답습니다. 계약을 통해 애플리케이션 구성도 검증합니다.
ROM.rb 및 Sequel 관련 문제
ROM.rb 및 Sequel은 ActiveRecord와 다릅니다. 플랫폼에서 대부분의 코드를 복사하여 붙여넣을 수 있다는 초기 아이디어는 실패했습니다. 문제는 플랫폼 부분이 AR이 많이 사용되었기 때문에 거의 모든 것이 ROM/Sequel로 다시 작성되어야 한다는 것입니다. 프레임워크에 독립적인 코드의 작은 부분만 복사할 수 있었습니다. 그 과정에서 우리는 몇 가지 실망스러운 문제와 몇 가지 버그에 직면했습니다.
하위 쿼리로 필터링
예를 들어, ROM.rb/Sequel에서 하위 쿼리를 만드는 방법을 알아내는 데 몇 시간이 걸렸습니다. 이것은 내가 Rails: scope.where(sequence_code: subquery
)에서 깨어나지 않고도 작성할 수 있는 것입니다. 그러나 Sequel에서는 쉽지 않은 것으로 나타났습니다.
def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end
따라서 base_query.where(sequence_code: bild_subquery(params))
와 같은 간단한 한 줄 대신 사소한 코드, 원시 SQL 조각 및 이 불행한 경우를 일으킨 원인을 설명하는 여러 줄 주석이 포함된 수십 줄을 가져야 합니다. 고창증.
중요하지 않은 조인 필드와의 연결
entry
관계( performed_actions
테이블)에는 기본 id
필드가 있습니다. 단, *taggings
테이블과 조인하기 위해서는 sequence_code
컬럼을 사용한다. ActiveRecord에서는 다음과 같이 간단하게 표현됩니다.
class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end
ROM에도 동일하게 쓸 수 있습니다.
module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end
그런데 작은 문제가 있었습니다. 컴파일은 잘 되지만 실제로 사용하려고 하면 런타임에 실패합니다.

[4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...
id와 sequence_code
의 유형이 다르기 때문에 PG는 유형 오류를 던집니다. 유형이 같다면 내가 이것을 디버깅하는 데 몇 시간을 소비할지 누가 알겠습니까?
따라서 entries.join(:access_taggings)
이 작동하지 않습니다. 조인 조건을 명시적으로 지정하면 어떻게 될까요? 공식 문서에서 제안하는 것처럼 entries.join(:access_taggings, performed_action_sequence_code: :sequence_code)
에서처럼.
[8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist
이제 :access_taggings
이 어떤 이유로 테이블 이름이라고 생각합니다. 좋아, 실제 테이블 이름으로 바꿔보자.
[10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]
마지막으로, 추상화 누수로 끝났지만 무언가를 반환하고 실패하지 않았습니다. 테이블 이름이 애플리케이션 코드로 누출되어서는 안 됩니다.
SQL 매개변수 보간
연대기 검색에는 사용자가 페이로드로 검색할 수 있는 기능이 있습니다. 쿼리는 다음과 같습니다. {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"}
, 여기서 path
는 항상 문자열의 배열이고 값은 유효한 JSON 값입니다.
ActiveRecord에서는 다음과 같이 보입니다.
@scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)
Sequel에서는 :path
보간을 제대로 하지 못했기 때문에 이에 의존해야 했습니다.
base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))
운 좋게도 여기의 path
는 영숫자 문자만 포함하도록 적절하게 검증되었지만 이 코드는 여전히 재미있습니다.
ROM-팩토리의 사일런트 매직
테스트에서 모델 생성을 단순화하기 위해 rom-factory
gem을 사용했습니다. 그러나 여러 번 코드가 예상대로 작동하지 않았습니다. 이 테스트의 문제점이 무엇인지 짐작할 수 있습니까?
action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)
아니요, 기대는 빗나간 것이 아니라, 그 기대는 괜찮습니다.
문제는 두 번째 줄이 고유 제약 조건 유효성 검사 오류로 실패한다는 것입니다. 그 이유는 action
이 Action
모델이 가지고 있는 속성이 아니기 때문입니다. 실제 이름은 action_name
이므로 작업을 생성하는 올바른 방법은 다음과 같아야 합니다.
RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']
잘못 입력된 속성이 무시되었기 때문에 팩토리에 지정된 기본 속성( action_name { 'created' }
)으로 폴백하고 두 개의 동일한 작업을 생성하려고 하기 때문에 고유한 제약 조건 위반이 발생합니다. 우리는 이 문제를 여러 번 처리해야 했으며 이는 부담이 되었습니다.
다행히 0.9.0에서 수정되었습니다. Dependabot은 테스트에서 잘못 입력된 몇 가지 속성을 수정한 후 병합한 라이브러리 업데이트와 함께 pull 요청을 자동으로 보냈습니다.
일반 인체 공학
이것은 모든 것을 말합니다:
# ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_
그리고 그 차이는 더 복잡한 예에서 훨씬 더 큽니다.
좋은 부품
고통과 땀과 눈물이 전부는 아니었다. 우리의 여정에는 많은 좋은 것들이 있었고 그것들은 새로운 스택의 부정적인 측면보다 훨씬 더 큽니다. 그렇지 않았다면 우리는 처음부터 그것을 하지 않았을 것입니다.
테스트 속도
전체 테스트 스위트를 로컬에서 실행하는 데는 5-10초가 소요되며 RuboCop의 경우에도 마찬가지입니다. CI 시간은 훨씬 더 길지만(3-4분) 어쨌든 모든 것을 로컬에서 실행할 수 있기 때문에 문제가 적습니다. 덕분에 CI에서 실패할 가능성이 훨씬 적습니다.
가드 젬을 다시 사용할 수 있게 되었습니다. 코드를 작성하고 저장할 때마다 테스트를 실행하여 매우 빠른 피드백을 제공할 수 있다고 상상해 보십시오. 이것은 플랫폼으로 작업할 때 상상하기 매우 어렵습니다.
배포 시간
추출한 연대기 앱을 배포하는 데 걸리는 시간은 단 2분입니다. 번개처럼 빠르지는 않지만 그래도 나쁘지는 않습니다. 우리는 매우 자주 배포하므로 사소한 개선으로도 상당한 비용을 절감할 수 있습니다.
애플리케이션 성능
연대기에서 가장 성능 집약적인 부분은 항목 검색입니다. 현재로서는 연대기에서 기록 항목을 가져오는 플랫폼 백엔드에 약 20곳이 있습니다. 이것은 연대기의 응답 시간이 플랫폼의 응답 시간에 대한 60초 예산에 기여한다는 것을 의미하므로 연대기는 빨라야 합니다.
작업 로그의 거대한 크기(3천만 행 및 증가)에도 불구하고 평균 응답 시간은 100ms 미만입니다. 이 아름다운 차트를 보십시오.
평균적으로 앱 시간의 80-90%가 데이터베이스에서 소비됩니다. 이것이 적절한 성능 차트의 모습이어야 합니다.
우리는 여전히 수십 초가 걸릴 수 있는 느린 쿼리가 있지만 추출된 앱이 훨씬 더 빨라지도록 이를 제거하는 방법을 이미 계획하고 있습니다.
구조
우리의 목적을 위해 건식 검증은 매우 강력하고 유연한 도구입니다. 우리는 계약을 통해 외부 세계의 모든 입력을 전달하고 입력 매개변수가 항상 잘 형성되고 유형이 잘 정의되어 있다는 확신을 줍니다.
모든 데이터가 정리되고 앱 경계에서 유형 변환되기 때문에 더 이상 애플리케이션 코드에서 .to_s.to_sym.to_i
를 호출할 필요가 없습니다. 어떤 의미에서 이것은 역동적인 Ruby 세계에 강력한 유형의 온전함을 가져다줍니다. 나는 그것을 충분히 추천할 수 없다.
마지막 단어
비표준 스택을 선택하는 것은 처음에 보이는 것처럼 간단하지 않았습니다. 우리는 새로운 서비스에 사용할 프레임워크와 라이브러리를 선택할 때 모놀리식 애플리케이션의 현재 기술 스택, 새 스택에 대한 팀의 친숙도, 선택한 스택을 유지 관리하는 방법 등 많은 측면을 고려했습니다.
처음부터 매우 신중하고 계산된 결정을 내리려고 노력했지만(표준 Hanami 스택을 사용하기로 선택했습니다) 프로젝트의 비표준 기술 요구 사항으로 인해 도중에 스택을 재고해야 했습니다. 우리는 Sinatra와 DRY 기반 스택으로 끝났습니다.
새로운 앱을 추출한다면 Hanami를 다시 선택하시겠습니까? 아마 그렇습니다. 이제 우리는 라이브러리와 그 장단점에 대해 더 많이 알고 있으므로 새로운 프로젝트를 시작할 때부터 더 많은 정보에 입각한 결정을 내릴 수 있습니다. 그러나 일반 Sinatra/DRY.rb 앱을 사용하는 것도 진지하게 고려할 것입니다.
대체로 새로운 프레임워크, 패러다임 또는 프로그래밍 언어를 배우는 데 투자한 시간은 현재 기술 스택에 대한 새로운 관점을 제공합니다. 도구 상자를 풍부하게 하기 위해 사용할 수 있는 것이 무엇인지 항상 아는 것이 좋습니다. 각 도구에는 고유한 사용 사례가 있습니다. 따라서 도구에 대해 더 잘 안다는 것은 더 많은 도구를 마음대로 사용할 수 있고 응용 프로그램에 더 적합하도록 바꾸는 것을 의미합니다.