안녕하세요 킴스캐슬입니다:)
오늘 포스팅은 제목이 좀 길죠? protocol을 사용해서 유연하고 확장 가능한 코드를 작성해보자
라는 주제로 포스팅을 작성해보려합니다
저는 현재 Plu라는 일기서비스앱을 만들고있는데요
3주전즈음에 프로젝트를 만들다가 실제로
코드를 이렇게짜면 엄청 깔끔해지네...?
라는 생각을 들었던 경험이있어서 블로그 주제를 기록해놓고 이제서야 작성하게되었습니다
지금 Plu라는 프로젝트의 뷰를 몇개 가져와보겠습니다
몇가지 상황이 더 있을수 있지만 대표적으로 두가지뷰를 가져와봤습니다
로그인뷰는 아마 앱을 개발해보신 개발자분이라면 한번쯤은 만들어보셨을 그런뷰죠
그리고 오른쪽 뷰또한 어떤 데이터들을 유저가 원하는 순서로 필터링해서 보여주는뷰고 이런 뷰도 자주 만드는뷰이기도 합니다
두가지 뷰의 공통적인 특징이 뭐라고 생각하시나요?
제가 생각했을때 두 뷰의 공통적인 특징은 비슷한 기능이 언제든지 추가될수있다
라고 생각합니다
예를들어서 로그인뷰의 경우에 네이버로그인, 구글로그인이 추가될수있는거죠 그리고 오른쪽 필터뷰의 경우엔 오래된순같은필터기능이 추가될수있겠죠
우선 로그인쪽을 먼저 볼게요
func makeKakaoFuture() -> AnyPublisher<(type: LoginType, state: LoadingState), Never> {
return Future<(type: LoginType, state: LoadingState), NetworkError> { promise in
Task {
do {
// ✅ 여기서부터
let kakaoToken = try await self.loginKakaoWithApp()
...생략...
}.catch { error in
return Just((type: .kakao, state: .error(message: "카카오 로그인 실패")))
// ✅ 여기까지
}
...
}
func makeAppleFuture() -> AnyPublisher<(type: LoginType, state: LoadingState), Never> {
return Future<(type: LoginType, state: LoadingState), NetworkError> { promise in
Task {
do {
// ✅ 여기서부터
let appleToken = try await self.loginApple()
...생략...
}.catch { error in
return Just((type: .apple, state: .error(message: "애플 로그인 실패")))
// ✅ 여기까지
}
...
}
카카오로그인을 위한 메서드가 있고 애플로그인을 위한 메서드가 있습니다
만약에 네이버로그인 구글로그인이 생긴다면 아마도 두개의 메서드가 추가될겁니다
그렇게되면 분명히 로그인이라는 비슷한기능을 하는 함수인데 개별적으로 나눠져있게됩니다
로그인종류가 추가되면 함수를 새로만들어야하고 버튼을 눌렀을때 새로만든 함수를 또 불러서 호출해줘야합니다
확장마다 주먹구구식으로 코드를 작성해야하는 찜찜한 상황입니다...
swift를 해보신 분들이라면 이렇게 생각하실수도 있습니다
음... 어쨌든 타입이 다른거니까 enum으로 만들어서 타입별로 switch문을 써주면 함수 하나로 만들 수 있지 않을까요?
저희도 처음에는 enum과 switch문을 활용한 분기처리방식을 사용하는것을 1차적으로 떠올렸는데요
여기에는 한가지 문제점이 있었습니다
switch문이나 if, else if문 같은 분기처리방식은 OCP라는 개방폐쇄원칙을 위반할수밖에 없습니다
OCP는 개방 폐쇄의 원칙이라고 하고 영어로는 Open Close Principle인데 간단히 말해서 확장에 대해서는 개방(OPEN)되어야 하지만 변경에 대해서는 폐쇄(CLOSE)되어야 한다는 의미입니다
즉, 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계가 되어야 한다는 의미입니다.
type을 만들어서 switch문으로 로그인쪽 코드를 작성하게되면 메서드 내부에서 switch문이 존재하겠죠?
이런 경험 많으실텐데 만약에 원래 이런 enum이 있었다고 가정해보겠습니다
enum LoginType { case kakao, apple }
그리고 메서드 내에서 switch문으로 메서드 내부의 동작이 case별로 분기처리가 되어있을겁니다 예를들어서 토큰을 발급받는 메서드가 다른경우 아래와 같이 분기처리를 하게될겁니다
func makeFuture() -> AnyPublisher<(type: LoginType, state: LoadingState), Never> {
...생략...
var token = ""
switch type {
case .kakao:
token = try await self.loginKakao()
case .apple:
token = try await self.loginApple()
...생략...
}
그렇게 되면 만약에 LoginType이라는 enum에 naver가 추가된다면 makeFuture라는 메서드내부 switch에서 case하나 처리 안해줬다고 컴파일에러가 발생하겠죠?
OCP의 원칙을 준수하려면 코드를 변경하지 않으면서 기능의 확장이 가능한 구조여야하는데 enum과 switch를 활용하게되면 이렇게 기능을 확장하기위해서는 코드를 변경해야하는 상황이 발생하게됩니다
만약에 LoginType이 정말 여러곳에서 사용된다면 enum에 case하나 추가할때마다 관련된 모든 코드를 변경해줘야합니다 앱의 규모가 작을때는 큰 문제가 되지 않을수있지만 정말 중요한로직의 경우엔 여러곳에 사용되고있을수도있고
이건 좀 아닌거같은데...?
라는 생각이들게됩니다
자 그러면 switch문으로 분기한 코드를 한번 볼까요
분명히 토큰을 발급받는다
는 동작은 동일하지만 kakao토큰을 받는 실제 동작과 apple관련 토큰을 받는 실제 동작을 차이게 있을겁니다 애초에 호출하는 API가 다를거니까요
그러다보니 분명히 비슷하긴한거같은데 메서드자체가 다르니까 어쩔수없나...
라는 생각이 드실수 있습니다
하지만 중요한건 토큰을 발급받는다
는 동작에 대한 추상화와 결과로 토큰의 반환
이 동일하다는 점에서 추상화가 가능하다는 점을 주목할 수 있습니다
혹시나 제 combine관련 포스팅을 보신 분들이라면 이 구조가 open combine에서 본 구조들과 좀 비슷하다는걸 느끼셨을수도 있습니다
위의 영상을 보시면 분명히 나는 downstream이라는 객체의 receive를 찾기위해서 클릭했는데
어떤 downstream인지 저 많은 객체들 중에서 하나를 찾아서 골라줘야합니다
즉 recieve(subscription:)이라는 메서드는 어떤 프로토콜에 추상화가 되어있고 저 많은 객체들이 subscription이라는걸 recieve하는 동작을 수행하게됩니다 물론 subscription을 recieve해서 내부적으로 어떤 동작을 하는지는 모릅니다 각자 알아서 하게될겁니다
중요한건 protorocol을 통한 추상화를 통해 subscription을 receive하는 동작은 똑같이 수행한다는겁니다 그리고 세세하게 어떤 동작을하는지는 알 필요가없어진다는 뜻이고 이는 결합도도 낮아진다는 뜻이기도합니다
객체가 객체를 바라보는게아니라 결국 protocol이라는 추상화를 바라보고있는거니까요
그러면 위의 코드를 다시한번 가져와볼까요
func makeKakaoFuture() -> AnyPublisher<(type: LoginType, state: LoadingState), Never> {
return Future<(type: LoginType, state: LoadingState), NetworkError> { promise in
Task {
do {
// ✅ 여기서부터
let kakaoToken = try await self.loginKakaoWithApp()
...생략...
}.catch { error in
return Just((type: .kakao, state: .error(message: "카카오 로그인 실패")))
// ✅ 여기까지
}
...
}
func makeAppleFuture() -> AnyPublisher<(type: LoginType, state: LoadingState), Never> {
return Future<(type: LoginType, state: LoadingState), NetworkError> { promise in
Task {
do {
// ✅ 여기서부터
let appleToken = try await self.loginApple()
...생략...
}.catch { error in
return Just((type: .apple, state: .error(message: "애플 로그인 실패")))
// ✅ 여기까지
}
...
}
로그인 메서드는 크게 두가지 공통적인 추상화가 가능합니다
- 토큰을 생성해서 반환해주는 메서드
- 에러가발생했을때 메세지를담은 Just를 반환해주는 메서드
그럼 두가지를 protocol로 추상화 해보겠습니다
protocol Login {
func makeToken() async throw -> String
func makeJustWhenErrorOccur() -> Just<String>
}
이렇게되면 함수를 사용하는 메서드입장에선 코드가 아래와같이 하나로 통일됩니다
func makeFuture(login: Login) -> AnyPublisher<String, Never> {
return Future<String, NetworkError> { promise in
Task {
do {
// ✅ 여기서부터
let token = try await login.makeToken()
...생략...
}.catch { error in
return login.makeJustWhenErrorOccur()
// ✅ 여기까지
}
...
}
메서드를 호출할때 어떤 객체를 넣어주느냐에 따라서 나오는 토큰이 다르고 반환되는 Just가 달라지게됩니다
Apple로그인의 경우 makeFuture에 Apple이라는 객체를 넣어준다고 해볼까요
struct Apple: Login {
...생략...
func makeToken() async throw -> String {
return try await self.loginAppleWithApp()
}
func makeJustWhenErrorOccur() -> Just<String> {
return Just("애플로그인 오류발생")
}
private func loginAppleWithApp() async throws -> String {
// login 로직
return "apple app token"
}
}
그러면 메서드에서 Apple이라는 객체를 받으면 Apple의 makeToken이라는 메서드를 실행하게되고 Apple객체는 apple app token을 반환해주게됩니다 오류가발생했을때는 애플로그인오류발생이라는 string을 반환해주는 just가 들어가겠죠
여기서 naver가 추가되어도 혹은 google이 추가되어도 makeFuture라는 메서드는 변하지 않습니다
그저 Naver라는 객체에 Login이라는 프로토콜을 채택하게하고 구현부를 만들어주고 그 Naver라는 객체를 메서드외부에서 주입시켜주기만하면되니까요
struct Naver: Login {
...생략...
func makeToken() async throw -> String {
return try await self.loginNaverWithApp()
}
func makeJustWhenErrorOccur() -> Just<String> {
return Just("네이버로그인 오류발생")
}
private func loginNaverWithApp() async throws -> String {
// login 로직
return "Naver app token"
}
}
이렇게 사용한다면 OCP원칙이 맞게 설계를 할수있고 이전에 비해 확장성이 향상된 코드가 될 수있습니다
위에서 유저가 필터링을 할수있는 뷰가 하나 있었죠
이것도 똑같이 생각을 해보겠습니다
최신순을 누르나 공감순을 누르나 결과적으로는 필터링해서 필터링된결과가 반환된다
는 측면으로 추상화가 가능합니다 필터링을 어떻게하는지는 각자 알아서 하는거죠
그러면 추상화를 한번 해볼까요?
protocol AnswerFilter {
func getOtherAnswers(input: OtherAnswer) -> OthersAnswer
}
사실 우리가 OtherAnswer이라는 데이터타입을 받고나서 정렬을 해도 Data의 타입자체가 변하지는 않죠
그래서 OtherAnswer를 받아서 OtherAnswer를 반환해주는 메서드를 추상화할수있습니다
모든 데이터는 기본적으로 최신순으로 주어진다고 가정해보겠습니다
그러면 최신순의 데이터는 그냥 그대로 데이터를 받은그대로 내보내주게끔 함수를 만들면되고
struct LatestFilter: AnswerFilter {
func getOtherAnswers(input: OtherAnswer) -> OthersAnswer {
return OtherAnswer
}
}
공감순의 데이터는 최신순으로받은데이터를 공감순으로 바꿔서 내보내주면되겠죠
struct EmpathyFilter: AnswerFilter {
func getOtherAnswers(input: OtherAnswer) -> OthersAnswer {
let dummy = input
let answers = dummy.answers.sorted { lhs, rhs in
return lhs.empathyCount > rhs.empathyCount
}
return OthersAnswer(elementType: dummy.elementType,
date: dummy.date,
question: dummy.question,
answersCount: dummy.answersCount,
answers: answers)
}
}
그러면 이객체들을 사용하는 쪽에서는 아래와같이 사용할 수 있습니다
func filterAnswers(input: AnswerFilter) -> OthersAnswer {
return input.getOtherAnswers(input: OthersAnswer.dummmy())
}
그리고 갑자기 어느날 오래된 순으로 바꾸는기능이 추가되었다면
struct OldFilter: AnswerFilter {
func getOtherAnswers(input: OtherAnswer) -> OthersAnswer {
return Array(input.reversed())
}
}
이렇게 최신순으로 주어질 input을 reversed로 바꿔서 반환해주면될거고 이 객체를 메서드에 넣어주기만하면 됩니다
실제로 개발을 하다보면 분명히 큰 틀에선 같은 동작을 하는녀석인데 조금조금씩 구현이다른경우가 많죠
저같은 경우는 그때마다 고민을 하지만 결국은 switch문을 사용했었는데 문득 open combine 라이브러리를 뜯어볼때의 구조가 갑자기 생각나서 이것도 이렇게 하면되지않을까...?
라는 생각이 들어서 적용을 해본건데 실제로 코드가 간결해지고 확장성 측면에서도 좋아지는것같아서 뿌듯한 생각이 들었습니다
사실 알고보니 이런 방식의 디자인 패턴이 존재하고 있더라고요
전략 패턴 (Strategy Pattern)
이라는 디자인패턴이 위에 설명한 방식으로 사용하는거여서 나중에 알게되고나서 아 이것도 디자인 패턴이었구나 하고 디자인패턴의 유용함에 대해서 다시금 깨닫게된 그런 순간이기도했습니다
해당 부분에 리팩터링을 진행하면서 뿌듯했던 부분은 전략패턴이라는 디자인패턴을 모르는 상태에서 오픈소스의 구조를 떠올리고 내 프로젝트에 적용을해서 개선시켰다는 부분이었습니다
아직까지는 공부를하고있는 취준생개발자지만 많은 코드를 보고 경험해야 내 코드가 바뀌고 좋은코드가 될수있구나라는 생각이 들었던 그런 경험이어서 이렇게 가져와보게되었습니다
작년까지만해도 protocol의 사용에 익숙하지 않았는데 이제는 추상화는 어떻게해야할지 그걸위해 protocol을 어떻게 사용해야할지에대해서 조금은 더 알게된 느낌입니다
그럼 저는 여기서 다음포스팅을 준비하러 가보겠습니다
긴 글 읽어주셔서 감사합니다
그럼 20000!