Ruby로 API를 래핑할 때는 반드시 설정(configuration) 방법이 필요합니다. 래퍼에 사용자 이름과 시크릿 키가 필요할 수도 있고, 호스트 주소만 필요할 수도 있죠.
이를 처리하는 방법은 여러 가지가 있습니다. 그렇다면 어떤 방식을 선택해야 할까요?
간단한 전역 설정 방식
서비스가 항상 곁에 있는 것처럼 동작하기를 원할 수 있습니다. 앱의 어디에서든 바로 사용할 수 있다면 얼마나 좋겠습니까. 그렇지 않으면 API를 사용하는 한 줄마다 설정하는 데 세 줄을 추가로 써야 하니까요!
상수나 클래스 속성을 사용해 설정을 전역으로 만들 수 있습니다:
ProductApi.root = "https://staging-host.example.com/"
ProductApi.user = "justin"
ProductApi.secret = "mysecret123"
def show
@product = ProductApi.find(params[:id])
end
수많은 젬(gem)이 이 패턴을 사용합니다. 작성하기 쉽고 사용하기도 정말 간편합니다. 하지만 몇 가지 큰 문제가 있습니다:
-
ProductApi는 하나만 존재할 수 있습니다.서로 다른 두 사용자 계정으로 Product API를 사용하거나, 하나의 앱에서 서로 다른 서버에 접속하고 싶어도 방법이 없습니다.
-
ProductApi는 실수로 변경되기 쉬운 전역 데이터를 가집니다.어떤 스레드나 앱의 일부가
ProductApi.user값을 변경하면,ProductApi를 사용하는 다른 모든 코드가 깨져버립니다. 그리고 이런 종류의 버그는 추적하기가 무척 고통스럽습니다.
결국 클래스 변수에는 이런 저런 문제가 따릅니다. 대신 Product API 클래스의 인스턴스를 설정하면 어떨까요?
#initialize를 사용하면 어떻게 될까?
인스턴스 방식을 사용한다면, 필요할 때마다 API 래퍼를 생성하고 설정하게 됩니다:
def show
product_api = ProductApi.new(
root: "https://staging-host.example.com/",
user: "justin",
secret: "mysecret123")
@product = product_api.find(params[:id])
end
이제 API를 사용할 때마다 서로 다른 정보를 전달할 수 있습니다. 다른 메서드나 스레드가 해당 인스턴스를 공유하지 않으므로, 모르는 사이에 값이 변경될 걱정도 없습니다.
훨씬 나아 보입니다. 하지만 여전히 충분히 편리하지는 않습니다. API를 사용할 때마다 매번 설정해야 하기 때문입니다.
대부분의 경우 API가 어떻게 설정되었는지는 신경 쓰고 싶지 않고, 적절한 기본 옵션으로 그냥 바로 사용하고 싶을 겁니다. 하지만 인스턴스 방식에서는 API를 사용하는 앱의 모든 부분이 설정 방법을 알고 있어야 합니다.
다행히 필요할 때는 언제든 변경할 수 있는 유연성을 유지하면서도, 좋은 기본값과 함께 전역 접근의 편리함을 모두 누릴 수 있는 방법이 있습니다.
그리고 이 패턴은 의외로 흔한 곳에서 자주 등장합니다. 바로 OS X와 iOS 개발입니다.
좋은 기본값과 유연성을 모두 얻는 방법
API 래퍼의 각 인스턴스를 자유롭게 설정할 수 있으면서도, 굳이 신경 쓰고 싶지 않을 때를 위해 전역 "기본(default)" 인스턴스도 함께 제공하면 어떨까요?
iOS와 Mac OS SDK 전반에서 이런 "defaultSomething" 또는 "sharedWhatever" 패턴을 어디서든 볼 수 있습니다:
[[NSURLSession sharedSession] downloadTaskWithURL:@"https://www.google.com"];
[[NSFileManager defaultManager] removeItemAtPath:...];
그리고 기본 인스턴스만으로는 부족할 때는 언제든 새 인스턴스를 만들어 요청할 수 있습니다:
NSURLSession *session = [NSURLSession sessionWithConfiguration:...];
NSFileManager *fileManager = [[NSFileManager alloc] init];
Ruby에서도 default_api 클래스 메서드를 활용해 비슷한 구조를 만들 수 있습니다:
def show
@product = ProductApi.default_api.find(params[:id])
end
...
def show_special
special_product_api = ProductApi.new(
root: "https://special-product-host.example.com/",
user: "justin",
secret: "mysecret123")
@special_product = special_product_api.find(params[:id])
end
구현 코드는 대략 다음과 같을 수 있습니다:
class ProductApi
def initialize(root:, user:, secret:)
@root, @user, @secret = root, user, secret
end
def self.default_api
@default_api ||= new(
root: ENV['PRODUCT_API_ROOT'],
user: ENV['PRODUCT_API_USER'],
secret: ENV['PRODUCT_API_SECRET'])
end
def find(product_id)
...
end
end
여기서는 default_api에 환경 변수를 사용했지만, 설정 파일을 활용할 수도 있습니다. 또한 ||= 부분을 스레드 로컬(thread-local) 또는 요청 로컬(request-local) 저장소를 사용하도록 바꾸면 멀티스레드 환경에서도 더 안전하게 만들 수 있습니다.
하지만 이 정도면 충분히 괜찮은 출발점입니다.
제가 접한 대부분의 젬(예: Twitter 젬)은 필요할 때마다 API 객체를 직접 설정하고 생성하도록 안내합니다. 이것도 나쁘지 않은 해결책입니다(물론 실제로는 사람들이 결국 전역 변수에 할당해두곤 합니다).
하지만 한 단계 더 나아가 미리 설정된 기본 객체까지 함께 제공하면, 훨씬 편안하고 즐거운 개발 경험을 누릴 수 있습니다.