코딩하다보면 인터페이스를 많이 만든다. 함수, 생성자. 프로토콜 등. 하루에도 수십번씩 인터페이스를 어떻게 할지 결정해야 한다.
인터페이스를 만들 때 황금률이 있다. ‘구현은 많은 일을 하면서도, 인터페이스는 간단해야 한다’. 간단하게 말해 내부에서는 많은 일을 하지만, 밖에서는 그런 거 다 몰라도 쉽게 쓸 수 있으면 된다. 그러면 좋은 인터페이스다.
하나의 인터페이스를 다양한 곳에서 재사용할 수 있다면 인터페이스는 간단해진다. 그래서 사용하는 맥락이 달라질 때도 쓸 수 있도록 하고 싶을 때, 흔히 하는 표현으로 ‘파라미터를 뚫어준다’. 사용자가 직접 커스텀할 수 있는 파라미터를 넣도록 설계한다는 뜻이다.
흔한 예시로, 아래와 같은 코드가 있다.
func queryValue(for key: String, caseSensitive: Bool) -> String?
특정 key에 대한 값을 조회하는 함수다. 보통은 대문자 key를 넣어도 소문자 key까지 알아서 조회해주면 편하다. 하지만 어떤 경우에는 대문자 key를 넣었으면 대문자 key만 나와야 한다. 그래서 둘다 가능할 수 있게 caseSensitive: Bool
이라는 파라미터를 뚫어준다.
다만 이게 항상 좋지는 않다. caseSensitive: Bool
같은 파라미터가 계속 늘어나면, 인터페이스가 복잡해진다. 파라미터가 너무 많으니 호출하는 쪽에서 이해하기 어려워진다.
그냥 잘 몰라도 알잘딱깔센으로 해주면 안돼? 하는 마음이 든다.
그럴 때 쓰는 훌륭한 도구가 바로 Default Argument다. Swift 언어뿐만 아니라 대부분의 modern language에서 대부분 제공하는 기능이다.
함수를 선언할 때, default argument도 같이 선언한다. 사용하는 쪽에서는 이 argument가 optional parameter가 된다. 즉, 이 값을 굳이 넣지 않아도 컴파일 에러가 나지 않는다. 알아서 default 값으로 넣어준다.
func queryValue(for key: String, caseSensitive: Bool = false) -> String?
caseSensitive: Bool
에 default argument를 설정했다. 이제 호출하는 쪽이 간단해진다.
let accountNumber = queryValue(for: "accountNumber")
default argument를 사용하면 구현에서 더 많은 기능을 하면서도, 인터페이스는 간단하다. 좋은 인터페이스를 만들 수 있는 효과적인 기능이다. 그래서 많은 개발자들이 애용한다.
여기까지는 기본적인 내용. default argmuent에 대한 글을 쓰게 된 이유는 따로 있다. 생각없이 default argument를 썼다가 버그를 만든 경험을 몇번 하게 됐기 때문이다.
호출하는 쪽에서는 안 넣었는데, 알아서 값이 들어간다. 분명 편리한 기능이지만, 어떤 상황에서는 굉장히 짜증나는 버그를 만든다. 간단한 default argument 하나 때문에 몇 시간을 디버깅한 적도 있다.
몇 번 큰코 다치면서 default argument 를 쓸 때 주의해야하는 경우를 체득하게 되었다.
skipAd: Bool
이라는 파라미터를 default argument 로 지정한 코드가 있었다. 화면 여러개로 구성된 퍼널을 띄우는 인터페이스였다.
이 퍼널에는 마지막에 광고 단계가 있다. 이 광고 단계를 skip할 수 있게 만드는 파라미터였다. default argument는 true
로 돼있었다.
대부분의 경우 광고는 보여준다. 특정 퍼널만 예외다. 그러면 default argument 를 true
로 설정하는 것? 문제가 없어보인다. 나도 그렇게 생각했다.
하지만 생각해보면 이 파라미터에는 애초에 안전한 default parameter라는 게 없었다. 왜냐하면 이 광고라는 게 굉장히 중요한 비즈니스 로직이었기 때문이다.
이 파라미터를 실수해서 발생할 수 있는 상황은 이렇다.
1/ 광고를 띄우면 안 되는데 광고를 띄웠다.
2/ 광고를 띄워야 하는데 광고를 안 띄웠다.
둘 중 하나가 별 문제 없는 상황이면 default 로 해도 된다. 하지만 이 퍼널을 통과하는 유저 트래픽과, 광고 매출의 중요성을 생각해봤을 때, 1번과 2번 둘다 매우 큰 문제였다.
광고를 실수로 안 띄웠을 경우, 매출에 타격이 갈 수 있다. 광고를 실수로 띄웠을 경우? 고객에게 항의를 받을 수 있다는 것이다.
즉, 잘 모르고 호출하면 안되는 종류의 파라미터였던 것이다.
또 하나 예를 들어보자면 이런 게 있다. Default arguments in Swift 라는 글에서 인용했다.
enum ConflictResolution {
case overwriteExisting
case stopIfExisting
case askUser
}
func store<T: Storable>(
_ value: T,
conflictResolution: ConflictResolution = .stopIfExisting
) throws {
...
}
어떤 값을 저장하는 인터페이스다. 그런데 conflict가 있을 때 해결하는 방식을 optional parameter로 뚫어놓았다.
호출하는 쪽에서는 간단하게 이렇게 호출한다.
try store(value)
문제는 conflict가 있을 때 멈추는 것 (stopIfExisting
)이 그렇게 뻔한 동작이 아니라는 점이다. 사용 하는 쪽에서는 store를 호출했으니 분명 값이 있기를 기대했다. 근데 기대와는 다르게 파라미터의 유무에 따라 아예 store 가 약속한 동작을 하지 않아버린다.
default argument 를 덮어쓰기 (overwriteExisting
) 로 해도 위험한 건 마찬가지다. 기존에 있는 데이터가 예상치 못하게 날아가버릴 수 있기 때문.
이 경우에는, 편리하려고 쓴 default argument가 오히려 나중의 큰 버그를 만드는 불씨가 될 수 있다.
나는 이미 많은 곳에서 호출하고 있는 API를 바꿀 때는 default argument를 많이 쓰곤 했다. 왜냐하면 일일이 다 호출부를 바꾸지 않아도, 내가 원하는 곳에서만 파라미터를 넣으면 동작하니까.
하지만 그렇게 많은 곳에서 호출하는 인터페이스라면, 오히려 앱의 동작에 끼치는 영향은 더 크다고 볼 수 있다. 게다가 내가 optional로 만든 parameter가 예상과 전혀 다른 동작을 만들 수도 있다면?
그럴 때는 아무리 귀찮더라도, default argument를 안 쓰는 게 안전하다. skipAd
케이스에서 버그를 일으키고 내가 배운 경험칙이다.
Data transfer object의 정확한 정의는 조금씩 다 다른 것 같다. 값 객체라고 부르기도 하고. 불변 객체라고 하기도 하고.
아무튼 여기서 DTO는 1) 객체가 스스로 행동/기능을 갖지 않고. 2) 값만 다른 객체로 전달하기 위한 목적일 때를 말한다.
예를 들면 이런 객체.
struct UserDTO: Codable {
let id: Int
let name: String
let email: String?
let age: Int?
// You can also provide default values if needed
init(id: Int, name: String, email: String? = nil, age: Int? = nil) {
self.id = id
self.name = name
self.email = email
self.age = age
}
}
클라이언트 개발을 하다보면, DTO 안에 들어있는 필드가 많은 경우, 그리고 각각의 필드가 채워지는 시점이 다른 경우가 꽤 있다. 사용자가 폼을 하나씩 채워가면서 그 안에 있는 값이 생긴다든지. 여러 개의 API를 서버로 호출해서 그 결과값을 받고, DTO에 저장한 다음, 최종적으로 마지막 API의 요청값으로 보낸다든지..
위의 코드에서 보다시피 이 DTO에는 default argument를 많이 쓴다. 특정 값은 없을 수도 있고. 나중에 채워질 수도 있고 하기 때문.
DTO는 1/ 전달이 되면서 복사, 생성이 많이 일어난다, 2/ 프로퍼티가 빈번하게 추가된다.
그러다보니 새로운 프로퍼티를 추가했을 때 생성, 복사 함수에 깜빡하고 파라미터를 넣지 않는 실수가 자주 발생한다.
그러다보면 이런 버그가 발생한다. 특정 퍼널을 시작할 때 분명히 age를 24로 넣고 시작했다. 그런데 화면을 몇개 지나 최종적으로 서버에 올라가는 값이 nil이다? 이거 왜 그러지?
컴파일 에러가 발생하지 않고 중간에 값이 바뀌기 때문에 디버깅하기가 어렵다. DTO가 여러 컴포넌트에 걸쳐 쓰이고 있다면 더더욱 짜증이 난다.
그래서 크고 중요한 DTO일 때는 가급적이면 default argument를 안 쓴다.
다른 언어에서는 interface
를 정의할 때 보통 구현은 추가할 수 없다. 하지만 swift의 가장 큰 특징 중 하나인 protocol
은 인터페이스를 정의하는데 쓰이면서도, 기본 구현을 추가할 수 있다. 이것을 protocol extension이라고 한다. (이 protocol extesion을 적극적으로 쓰는 것을 protocol-oriented programming 라고 부르기도 한다.)
Protocol은 추상화된 타입이다. default argument 기능을 제공하지 않는다. 하지만 protocol extension 으로 사용해서 default argument 기능을 만들 수 있다.
protocol extension 안에서 protocol 내부에 선언된 함수를 한번 더 호출하면 된다. 이 패턴을 쓰면, 외부에서는 알 수 없지만, message 파라미터를 넣지 않았을 때 자동으로 2번 함수가 불린다. 그리고 2번 함수는 default argument와 함께 1번 함수의 구현체를 호출한다.
protocol Greetable {
func greet(message: String) // 1) 여기서는 default argument 불가능
}
extension Greetable {
func greet(message: String = "Hello") { // 2) extension의 구현부에서는 default argument 가능
greet(message: message)
}
}
위의 케이스와 다르게, protocol extension에 default arugment를 꼭 쓰지 말아야하는 것은 아니다. 하지만 잘못 하면 실수할 수 있는 여지가 있다. 예를 들면, greet
에 파라미터가 2개 추가되었다고 하자.
protocol Greetable {
func greet(message: String, name: String, emoji: String) // 1
}
extension Greetable {
func greet(message: String = "Hello", to name: String = "Guest", withEmoji emoji: String = "😊") {
greet(message: message, name: name, emoji: emoji) // 2
}
}
그런데 깜빡하고 (!) 구현체에는 파라미터를 추가해주지 않았다.
struct Person: Greetable {
func greet(message: String) { // 3
print(message)
}
}
그렇지만 다음과 같이 구현체에 greet 을 호출했을 때 컴파일 에러가 나지 않는다.
let person = Person()
person.greet()
왜냐하면, person에게는 Greetable의 extension이 이 준 기본 구현 함수 (2)가 있기 때문이다.
대신 여기서 개발자는 이상한 에러를 겪는다. protocol 내부에서 ‘무한 루프’가 일어난다.
이것이 protocol extension을 사용해서 default parameter를 사용했을 때의 함정이다. 무한 루프는 곧 크래시로 이어진다. 1번, 2번 케이스처럼 크래시도 안 나고 아예 동작을 이상하게 해버리는 경우보다 좀 낫긴 하다.
하지만 실수하기 좋은 코드인데 컴파일 에러가 나지 않는다. 개발자를 어리둥절하게 만드는 케이스다. 조심해서 써야 한다.
원칙은 쉽고 도구는 많다. 하지만 실제로 써보면서 상황에 맞게 판단하고 위험할 때를 알아야 한다. 편리하면 실수하기 쉽다. 어려우면 실수하기 어렵다. 그 밸런스는 결국 시행착오 과정에서 배우는 것 같다.