Ruby DSL 만들기: 고급 메타프로그래밍 가이드
게시 됨: 2022-03-11DSL(도메인 특정 언어)은 복잡한 시스템을 쉽게 프로그래밍하거나 구성할 수 있도록 해주는 매우 강력한 도구입니다. 그들은 또한 어디에나 있습니다. 소프트웨어 엔지니어로서 당신은 매일 여러 가지 다른 DSL을 사용하고 있을 것입니다.
이 기사에서는 도메인 특정 언어가 무엇인지, 언제 사용해야 하는지, 마지막으로 고급 메타프로그래밍 기술을 사용하여 Ruby에서 고유한 DSL을 만드는 방법을 배우게 됩니다.
이 기사는 Toptal 블로그에도 게시된 Nikola Todorovic의 Ruby 메타프로그래밍 소개를 기반으로 합니다. 따라서 메타프로그래밍이 처음이라면 먼저 읽어보십시오.
도메인 특정 언어란 무엇입니까?
DSL의 일반적인 정의는 DSL이 특정 응용 프로그램 도메인 또는 사용 사례에 특화된 언어라는 것입니다. 즉, 특정 용도로만 사용할 수 있으며 범용 소프트웨어 개발에는 적합하지 않습니다. 그것이 광범위하게 들린다면 그것은 DSL이 다양한 모양과 크기로 제공되기 때문입니다. 다음은 몇 가지 중요한 범주입니다.
- HTML 및 CSS와 같은 마크업 언어는 웹 페이지의 구조, 콘텐츠 및 스타일과 같은 특정 사항을 설명하기 위해 설계되었습니다. 임의의 알고리즘을 작성할 수 없으므로 DSL의 설명에 맞습니다.
- 매크로 및 쿼리 언어(예: SQL)는 특정 시스템이나 다른 프로그래밍 언어 위에 위치하며 일반적으로 수행할 수 있는 작업이 제한됩니다. 따라서 그들은 분명히 도메인 특정 언어로 자격이 있습니다.
- 많은 DSL에는 고유한 구문이 없습니다. 대신 별도의 미니 언어를 사용하는 것처럼 느껴지는 영리한 방식으로 기존 프로그래밍 언어의 구문을 사용합니다.
이 마지막 범주는 내부 DSL 이라고 하며 곧 예제로 만들 것 중 하나입니다. 그러나 그 내용을 다루기 전에 내부 DSL의 잘 알려진 몇 가지 예를 살펴보겠습니다. Rails의 경로 정의 구문은 다음 중 하나입니다.
Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end
이것은 Ruby 코드이지만 깨끗하고 사용하기 쉬운 인터페이스를 가능하게 하는 다양한 메타프로그래밍 기술 덕분에 사용자 정의 경로 정의 언어처럼 느껴집니다. DSL의 구조는 Ruby 블록을 사용하여 구현되며 이 미니 언어의 키워드를 정의하는 데 get
및 resources
와 같은 메서드 호출이 사용됩니다.
메타프로그래밍은 RSpec 테스트 라이브러리에서 훨씬 더 많이 사용됩니다.
describe UsersController, type: :controller do before do allow(controller).to receive(:current_user).and_return(nil) end describe "GET #new" do subject { get :new } it "returns success" do expect(subject).to be_success end end end
이 코드 조각에는 또한 선언을 일반 영어 문장으로 소리내어 읽을 수 있는 유창한 인터페이스 에 대한 예제가 포함되어 있어 코드가 수행하는 작업을 훨씬 더 쉽게 이해할 수 있습니다.
# Stubs the `current_user` method on `controller` to always return `nil` allow(controller).to receive(:current_user).and_return(nil) # Asserts that `subject.success?` is truthy expect(subject).to be_success
유창한 인터페이스의 또 다른 예는 복잡한 SQL 쿼리를 작성하기 위해 내부적으로 추상 구문 트리를 사용하는 ActiveRecord 및 Arel의 쿼리 인터페이스입니다.
Post. # => select([ # SELECT Post[Arel.star], # `posts`.*, Comment[:id].count. # COUNT(`comments`.`id`) as("num_comments"), # AS num_comments ]). # FROM `posts` joins(:comments). # INNER JOIN `comments` # ON `comments`.`post_id` = `posts`.`id` where.not(status: :draft). # WHERE `posts`.`status` <> 'draft' where( # AND Post[:created_at].lte(Time.now) # `posts`.`created_at` <= ). # '2017-07-01 14:52:30' group(Post[:id]) # GROUP BY `posts`.`id`
Ruby의 깨끗하고 표현력 있는 구문은 메타프로그래밍 기능과 함께 특정 도메인 언어를 구축하는 데 고유하게 적합하지만 DSL은 다른 언어에도 존재합니다. 다음은 Jasmine 프레임워크를 사용하는 JavaScript 테스트의 예입니다.
describe("Helper functions", function() { beforeEach(function() { this.helpers = window.helpers; }); describe("log error", function() { it("logs error message to console", function() { spyOn(console, "log").and.returnValue(true); this.helpers.log_error("oops!"); expect(console.log).toHaveBeenCalledWith("ERROR: oops!"); }); }); });
이 구문은 Ruby 예제만큼 깨끗하지 않을 수 있지만 구문을 영리하게 명명하고 창의적으로 사용하면 거의 모든 언어를 사용하여 내부 DSL을 생성할 수 있음을 보여줍니다.
내부 DSL의 이점은 제대로 구현하기 어려운 것으로 악명 높은 별도의 파서가 필요하지 않다는 것입니다. 또한 구현된 언어의 구문을 사용하기 때문에 나머지 코드베이스와도 원활하게 통합됩니다.
그 대가로 포기해야 하는 것은 구문상의 자유입니다. 내부 DSL은 구현 언어에서 구문적으로 유효해야 합니다. 이와 관련하여 어느 정도 타협해야 하는지는 주로 선택한 언어에 따라 다릅니다. Java 및 VB.NET과 같은 장황하고 정적으로 유형이 지정된 언어는 스펙트럼의 한쪽 끝에 있고 Ruby와 같은 광범위한 메타프로그래밍 기능이 있는 동적 언어는 다른 쪽 끝에 있습니다. 끝.
자체 구축 - 클래스 구성을 위한 Ruby DSL
Ruby에서 빌드할 예제 DSL은 매우 간단한 구문을 사용하여 Ruby 클래스의 구성 속성을 지정하기 위한 재사용 가능한 구성 엔진입니다. 클래스에 구성 기능을 추가하는 것은 Ruby 세계에서 매우 일반적인 요구 사항입니다. 특히 외부 gem 및 API 클라이언트를 구성할 때 그렇습니다. 일반적인 솔루션은 다음과 같은 인터페이스입니다.
MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end
먼저 이 인터페이스를 구현한 다음 시작점으로 사용하여 더 많은 기능을 추가하고 구문을 정리하고 작업을 재사용할 수 있도록 하여 단계적으로 개선할 수 있습니다.
이 인터페이스가 작동하려면 무엇이 필요합니까? MyApp
클래스에는 구성 값을 읽고 쓰기 위한 접근자 메서드가 있는 구성 개체를 전달하여 블록을 가져온 다음 해당 블록에 양보하여 해당 블록을 실행하는 configure
클래스 메서드가 있어야 합니다.
class MyApp # ... class << self def config @config ||= Configuration.new end def configure yield config end end class Configuration attr_accessor :app_id, :title, :cookie_name end end
구성 블록이 실행되면 값에 쉽게 액세스하고 수정할 수 있습니다.
MyApp.config => #<MyApp::Configuration:0x2c6c5e0 @app_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"
지금까지 이 구현은 DSL로 간주될 만큼 사용자 지정 언어처럼 느껴지지 않습니다. 하지만 한 번에 한 걸음씩 나아가자. 다음으로 MyApp
클래스에서 구성 기능을 분리하고 다양한 사용 사례에서 사용할 수 있을 만큼 충분히 일반적으로 만들 것입니다.
재사용 가능하게 만들기
지금 당장 다른 클래스에 유사한 구성 기능을 추가하려면 Configuration
클래스와 관련 설정 메소드를 다른 클래스에 복사하고 attr_accessor
목록을 편집하여 허용되는 구성 속성을 변경해야 합니다. 이를 피하기 위해 구성 기능을 Configurable
이라는 별도의 모듈로 이동하겠습니다. 이를 통해 MyApp
클래스는 다음과 같이 보일 것입니다.
class MyApp #BOLD include Configurable #BOLDEND # ... end
구성과 관련된 모든 것이 Configurable
가능 모듈로 이동되었습니다.
#BOLD module Configurable def self.included(host_class) host_class.extend ClassMethods end module ClassMethods #BOLDEND def config @config ||= Configuration.new end def configure yield config end #BOLD end #BOLDEND class Configuration attr_accessor :app_id, :title, :cookie_name end #BOLD end #BOLDEND
새로운 self.included
메소드를 제외하고 여기에서 많이 변경되지 않았습니다. 모듈을 포함하는 것은 인스턴스 메소드에서만 혼합되므로 이 메소드가 필요합니다. 따라서 config
및 configure
클래스 메소드는 기본적으로 호스트 클래스에 추가되지 않습니다. 그러나 모듈에 included
특수 메서드를 정의하면 Ruby는 해당 모듈이 클래스에 포함될 때마다 해당 메서드를 호출합니다. 거기에서 ClassMethods
의 메소드를 사용하여 호스트 클래스를 수동으로 확장할 수 있습니다.
def self.included(host_class) # called when we include the module in `MyApp` host_class.extend ClassMethods # adds our class methods to `MyApp` end
아직 완료되지 않았습니다. 다음 단계는 Configurable
모듈을 포함하는 호스트 클래스에서 지원되는 속성을 지정할 수 있도록 하는 것입니다. 다음과 같은 솔루션이 좋을 것입니다.
class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end
아마도 다소 놀랍게도 위의 코드는 구문상 정확합니다. include
는 키워드가 아니라 단순히 Module
객체를 매개변수로 기대하는 일반 메소드입니다. Module
을 반환하는 표현식을 전달하는 한 행복하게 포함됩니다. 따라서 Configurable
을 직접 포함하는 대신 지정된 속성으로 사용자 정의된 새 모듈을 생성하는 이름 with
있는 메소드가 필요합니다.
module Configurable #BOLD def self.with(*attrs) #BOLDEND # Define anonymous class with the configuration attributes #BOLD config_class = Class.new do attr_accessor *attrs end #BOLDEND # Define anonymous module for the class methods to be "mixed in" #BOLD class_methods = Module.new do define_method :config do @config ||= config_class.new end #BOLDEND def configure yield config end #BOLD end #BOLDEND # Create and return new module #BOLD Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end #BOLDEND end
여기에서 풀어야 할 것이 많습니다. 전체 Configurable
모듈은 이제 단일 with
메소드로 구성되며 모든 것이 해당 메소드 내에서 발생합니다. 먼저, 속성 접근자 메서드를 유지하기 위해 Class.new
를 사용하여 새로운 익명 클래스를 만듭니다. Class.new
는 클래스 정의를 블록으로 사용하고 블록은 외부 변수에 액세스할 수 있으므로 문제 없이 attrs
변수를 attr_accessor
에 전달할 수 있습니다.
def self.with(*attrs) # `attrs` is created here # ... config_class = Class.new do # class definition passed in as a block attr_accessor *attrs # we have access to `attrs` here end
Ruby의 블록이 외부 변수에 액세스할 수 있다는 사실은 블록이 정의된 외부 환경을 포함하거나 "닫기" 때문에 closures 라고도 불리는 이유이기도 합니다. "defined in"이라는 문구를 사용했음을 주목하세요. "실행"되지 않습니다. 맞습니다. 우리의 define_method
블록이 언제 어디서 실행되는지에 관계없이 with
메소드가 실행을 마치고 반환 된 후에도 항상 config_class
및 class_methods
변수에 액세스할 수 있습니다. 다음 예에서는 이 동작을 보여줍니다.
def create_block foo = "hello" # define local variable return Proc.new { foo } # return a new block that returns `foo` end block = create_block # call `create_block` to retrieve the block block.call # even though `create_block` has already returned, => "hello" # the block can still return `foo` to us
블록의 이 깔끔한 동작에 대해 알았으므로 생성된 모듈이 포함될 때 호스트 클래스에 추가될 클래스 메서드에 대해 class_methods
에 익명 모듈을 정의할 수 있습니다. 메서드 내에서 외부 config_class
변수에 액세스해야 하기 때문에 여기에서 config
메서드를 정의하기 위해 define_method
를 사용해야 합니다. def
키워드를 사용하여 메서드를 정의하면 def
를 사용한 일반 메서드 정의가 클로저가 아니기 때문에 액세스 권한이 부여되지 않습니다. 그러나 define_method
는 블록을 사용하므로 다음과 같이 작동합니다.

config_class = # ... # `config_class` is defined here # ... class_methods = Module.new do # define new module using a block define_method :config do # method definition with a block @config ||= config_class.new # even two blocks deep, we can still end # access `config_class`
마지막으로 Module.new
를 호출하여 반환할 모듈을 생성합니다. 여기서 self.included
메서드를 정의해야 하지만 불행히도 메서드는 외부 class_methods
변수에 액세스해야 하므로 def
키워드로 그렇게 할 수 없습니다. 따라서 블록과 함께 define_method
를 다시 사용해야 하지만 이번에는 모듈 인스턴스 자체에서 메서드를 정의하기 때문에 모듈의 싱글톤 클래스에서 사용합니다. 아, 그리고 define_method
는 싱글톤 클래스의 private 메소드이기 때문에 직접 호출하는 대신 send
를 사용하여 호출해야 합니다.
class_methods = # ... # ... Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods # the block has access to `class_methods` end end
휴, 이미 꽤 하드코어한 메타프로그래밍이었습니다. 그러나 추가된 복잡성이 그만한 가치가 있었습니까? 사용이 얼마나 쉬운지 살펴보고 스스로 결정하십시오.
class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"
하지만 우리는 더 잘할 수 있습니다. 다음 단계에서는 configure
블록의 구문을 약간 정리하여 모듈을 더욱 편리하게 사용할 수 있도록 합니다.
구문 정리
현재 구현에서 여전히 저를 괴롭히는 마지막 한 가지가 있습니다. 구성 블록의 모든 단일 라인에서 config
을 반복해야 합니다. 적절한 DSL은 configure
블록 내의 모든 것이 구성 개체의 컨텍스트에서 실행되어야 한다는 것을 알고 다음과 같이 동일한 작업을 수행할 수 있습니다.
MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end
구현해 볼까요? 그런 면에서 우리는 두 가지가 필요합니다. 먼저 구성 개체의 컨텍스트에서 configure
에 전달된 블록을 실행하여 블록 내의 메서드 호출이 해당 개체로 이동하도록 하는 방법이 필요합니다. 둘째, 인수가 제공되면 값을 쓰고 인수 없이 호출될 때 다시 읽도록 접근자 메서드를 변경해야 합니다. 가능한 구현은 다음과 같습니다.
module Configurable def self.with(*attrs) #BOLD not_provided = Object.new #BOLDEND config_class = Class.new do #BOLD attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end attr_writer *attrs #BOLDEND end class_methods = Module.new do # ... def configure(&block) #BOLD config.instance_eval(&block) #BOLDEND end end # Create and return new module # ... end end
여기서 더 간단한 변경은 구성 개체의 컨텍스트에서 configure
블록을 실행하는 것입니다. 객체에 대해 Ruby의 instance_eval
메서드를 호출하면 해당 객체 내에서 실행 중인 것처럼 임의의 코드 블록을 실행할 수 있습니다. 즉, 구성 블록이 첫 번째 줄에서 app_id
메서드를 호출하면 해당 호출이 구성 클래스 인스턴스로 이동합니다.
config_class
에서 속성 접근자 메서드를 변경하는 것은 조금 더 복잡합니다. 그것을 이해하려면 먼저 attr_accessor
가 배후에서 정확히 무엇을 하고 있었는지 이해해야 합니다. 예를 들어 다음 attr_accessor
호출을 사용하십시오.
class SomeClass attr_accessor :foo, :bar end
이것은 지정된 각 속성에 대해 판독기 및 기록기 메서드를 정의하는 것과 같습니다.
class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end
따라서 원래 코드에서 attr_accessor *attrs
app_id=
app_id
때 Ruby title=
attrs
의 모든 속성에 대해 속성 판독기 및 작성기 메서드를 정의 title
. 켜짐. 새 버전에서는 다음과 같은 할당이 여전히 제대로 작동하도록 표준 작성기 메서드를 유지하려고 합니다.
MyApp.config.app_ => "not_my_app"
attr_writer *attrs
를 호출하여 라이터 메소드를 계속 자동 생성할 수 있습니다. 그러나 이 새로운 구문을 지원하기 위해 속성을 쓸 수 있어야 하기 때문에 더 이상 표준 판독기 메서드를 사용할 수 없습니다.
MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end
독자 메서드를 직접 생성하려면 attrs
배열을 반복하고 새 값이 제공되지 않으면 일치하는 인스턴스 변수의 현재 값을 반환하고 지정된 경우 새 값을 쓰는 각 속성에 대한 메서드를 정의합니다.
not_provided = Object.new # ... attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end
여기서 우리는 Ruby의 instance_variable_get
메소드를 사용하여 임의의 이름을 가진 인스턴스 변수를 읽고 instance_variable_set
에 새 값을 할당합니다. 불행히도 변수 이름은 두 경우 모두 "@" 기호를 접두어로 해야 하므로 문자열 보간법이 적용됩니다.
"제공되지 않음"에 대한 기본값으로 빈 객체를 사용해야 하는 이유와 그 용도로 단순히 nil
을 사용할 수 없는 이유가 궁금할 것입니다. 이유는 간단합니다. nil
은 누군가가 구성 속성에 대해 설정하기를 원하는 유효한 값입니다. nil
에 대해 테스트했다면 이 두 시나리오를 구분할 수 없었을 것입니다.
MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end
not_provided
에 저장된 그 빈 객체는 항상 자기 자신과 같을 것이므로 아무도 그것을 우리 메소드에 전달하지 않고 쓰기 대신 의도하지 않은 읽기를 일으키지 않을 것이라고 확신할 수 있습니다.
참조 지원 추가
모듈을 더욱 다재다능하게 만들기 위해 추가할 수 있는 기능이 하나 더 있습니다. 바로 다른 구성 속성을 참조하는 기능입니다.
MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"
여기에서 cookie_name
의 참조를 app_id
속성에 추가했습니다. 참조를 포함하는 표현식은 블록으로 전달됩니다. 이는 속성 값의 지연된 평가를 지원하기 위해 필요합니다. 아이디어는 나중에 속성을 읽을 때만 블록을 평가하고 정의할 때는 평가하지 않는 것입니다. 그렇지 않으면 속성을 "잘못된" 순서로 정의하면 재미있는 일이 발생할 수 있습니다.
SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny
표현식이 블록으로 래핑되면 즉시 평가되지 않습니다. 대신에 속성 값이 검색될 때 나중에 실행되도록 블록을 저장할 수 있습니다.
SomeClass.configure do foo { "#{bar}_baz" } # stores block, does not evaluate it yet bar "hello" end SomeClass.config.foo # `foo` evaluated here => "hello_baz" # correct!
블록을 사용한 지연 평가에 대한 지원을 추가하기 위해 Configurable
가능 모듈을 크게 변경할 필요는 없습니다. 사실, 속성 메소드 정의만 변경하면 됩니다.
define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end
속성을 설정할 때 block || value
block || value
표현식은 블록이 전달되면 블록을 저장하고 그렇지 않으면 값을 저장합니다. 그런 다음 나중에 속성을 읽을 때 블록인지 확인하고 블록이면 instance_eval
을 사용하여 평가하고 블록이 아니면 이전처럼 반환합니다.
물론 지원 참조에는 자체 주의 사항과 예외적인 경우가 있습니다. 예를 들어 이 구성의 속성을 읽으면 어떤 일이 발생하는지 알 수 있습니다.
SomeClass.configure do foo { bar } bar { foo } end
완성된 모듈
결국 우리는 임의의 클래스를 구성 가능하게 만든 다음 깨끗하고 간단한 DSL을 사용하여 해당 구성 값을 지정하기 위한 꽤 깔끔한 모듈을 얻었습니다.
class MyApp include Configurable.with(:app_id, :title, :cookie_name) # ... end SomeClass.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } end
총 36줄의 DSL을 구현하는 모듈의 최종 버전은 다음과 같습니다.
module Configurable def self.with(*attrs) not_provided = Object.new config_class = Class.new do attrs.each do |attr| define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end end attr_writer *attrs end class_methods = Module.new do define_method :config do @config ||= config_class.new end def configure(&block) config.instance_eval(&block) end end Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end end
거의 읽을 수 없고 따라서 유지 관리가 매우 어려운 코드 조각에서 이 모든 Ruby 마법을 보면 도메인 특정 언어를 조금 더 멋지게 만드는 데 이 모든 노력이 가치가 있는지 궁금할 것입니다. 짧은 대답은 의존적이라는 것입니다. 이것이 이 기사의 마지막 주제로 이어집니다.
Ruby DSL - 사용해야 할 때와 사용하지 말아야 할 때
DSL의 구현 단계를 읽으면서 언어의 외부 대면 구문을 더 깔끔하고 사용하기 쉽게 만들면서 이를 실현하기 위해 내부적으로 점점 더 많은 메타프로그래밍 트릭을 사용해야 한다는 것을 눈치채셨을 것입니다. 그 결과 미래에 이해하고 수정하기가 엄청나게 어려울 구현이 되었습니다. 소프트웨어 개발의 다른 많은 일과 마찬가지로 이 또한 신중하게 검토해야 하는 절충안입니다.
도메인 특정 언어가 구현 및 유지 관리 비용의 가치가 있으려면 테이블에 훨씬 더 많은 이점을 가져와야 합니다. 이것은 일반적으로 가능한 한 많은 다른 시나리오에서 언어를 재사용할 수 있게 함으로써 달성되며, 그렇게 함으로써 많은 다른 사용 사례 간의 총 비용을 상각합니다. 프레임워크와 라이브러리는 많은 개발자가 사용하기 때문에 자체 DSL을 포함할 가능성이 더 높으며 각 개발자는 이러한 임베디드 언어의 생산성 이점을 누릴 수 있습니다.
따라서 일반적인 원칙에 따라 귀하, 다른 개발자 또는 응용 프로그램의 최종 사용자가 DSL을 많이 사용하게 될 경우에만 DSL을 빌드하십시오. DSL을 생성하는 경우 포괄적인 테스트 제품군을 포함하고 구현만으로는 파악하기 매우 어려울 수 있으므로 구문을 적절하게 문서화해야 합니다. 앞으로 당신과 동료 개발자들은 그것에 대해 감사할 것입니다.
Toptal 엔지니어링 블로그에 대한 추가 정보:
- 처음부터 통역사 작성에 접근하는 방법