추상화 그거 진짜 왜 쓰나요?

Youth·5일 전
1

TIL

목록 보기
22/22

안녕하세요 오늘은 추상화라는 주제로 글을 쓰게된 킴스캐슬입니다
제가 iOS를 처음 공부했을 때를 생각 해보면(약 3년전이네요) 그때는 protocol을 많이쓰는 개발자가 되게 멋져보였던 기억이 나네요

그만큼 protocol이라는 것에 대한 이해도 없었고 왜 써야하는지에 대한 공감도 크게 하지 못했던 시기였던것같습니다

물론 이론적으로 추상화라는 주제가 가지는 장점이 있겠지만 오늘은 그것보다는 실제로 개발하면서 protocol을 통해 추상화를 하니 이런점이 좋더라라는 몇 가지 case를 소개해보려합니다

있다치고 개발하기

실제 개발을 하다보면 UI를 개발하는 iOS개발자 특성상 모든 디자인과 API가 완벽하게 갖춰진 뒤에 개발을 할 수 있는 경우가 그렇게 많지 않습니다. 오히려 자, 우리는 이런 기능을 개발할거예요라는 이야기를 서버와 iOS개발자가 동시에 듣는 경우가 더 많습니다

하지만, iOS개발자는 API가 나온 후에 UI를 그리고 API를 연결해서 기능을 돌아가게 만들어야하기에 최종적으로는 데드라인에 쫓기게 됩니다. 이때 protocol은 API의 완성여부와 상관없이 작업을 할 수 있는 하나의 무기가 되어줄 수 있습니다

예를들어서 지금 내가 개발해야하는 뷰에 세가지의 기능이 있다고 해봅시다

  1. 유저가 해당 뷰에 진입하면 들어갈 수 있는 채팅방 목록을 보여준다
  2. 유저가 채팅방에 들어가면 프로필 이미지를 업로드하고 채팅방에 들어가게된다
  3. 채팅을 친다

우선 1번을 보면 유저가 해당 뷰에 진입하면 어떤 리스트형태의 UI를 보여주면 될거같습니다. 실제로 나온 디자인을 보니 채팅방 이미지, 채팅방 이름, 들어와있는 인원 수라는 데이터를 받아오면 될거같습니다

그리고 서버분한테 물어보는거죠

혹시 유저가 들어갈 수 있는 채팅방 목록을 불러오려면 어떤 값이 필요해요?

서버분은 고민을 하다가 아래와 같이 대답하셨습니다

userID가 있으면 될거같네요?

그러면 이제 있다치고 개발하기 위한 최소한의 준비는 끝났습니다

우선 1번기능을 위해서는 UI에는 채팅방 이미지, 채팅방 이름, 들어와있는 인원 수가 있으면 됩니다 그리고 이런데이터를 받아오기위해서는 userID를 주면됩니다

결국 이렇게 추상화가 가능합니다

userID를 서버에 주면 (이하 생략) 채팅방 이미지, 채팅방 이름, 들어와있는 인원 수가 n개 들어있는 데이터 리스트를 준다

이걸 코드로 짜보면 이렇게 짤 수 있습니다

struct ChattingRoomInfo {
    let mainImageUrl: String
    let title: String
    let count: Int
}

protocol ChattingRoomProtocol {
    func getChattingRoomList(userID: String) async throws -> [ChattingRoomInfo]
}

(이하 생략)을 넣은 이유는 userID를 실제로 검증하는 단계가 있을 수도 있고 (말은 안되지만) 이미지따로 채팅방 이름이랑 들어와 있는 인원을 각각 API를 통해서 받으라고 할 수도 있습니다. 근데 그런건 아직 모르겠고 이렇게 값이 들어온다고 치고 추상화를 할 수 있습니다

실제 getChattingRoomList이 내부적으로 어떻게 [ChattingRoomInfo]를 만들어서 줄지는 모르겠고 나는 그냥 userID를 줬으니까 어떻게든 [ChattingRoomInfo]를 만들어서 주면되는겁니다

실제로 저 값이 어떻게 만들어서 return될지는 나중에 생각할 수 있습니다

우선 첫번째 기능은 완료를 했습니다

두번째 기능을 보겠습니다 유저가 채팅방에 들어가면 프로필 이미지를 업로드하고 채팅방에 들어가게된다 자 그러면 여기서 생각해야하는건 유저 입장에선 특정 채팅방에 들어가야하고 이미지를 주면 서버에서는 너 입장할 수 있어혹은 너 입장 못해라는걸 알려줘야겠죠???

추상화를 해봅시다

protocol ChattingRoomProtocol {
    /// 첫 번째 기능
    func getChattingRoomList(userID: String) async throws -> [ChattingRoomInfo]
    /// 두 번째 기능
    func userEnterChattingRoom(profileImage: UIImage) async throws -> Bool
}

그런데 여러분 생각을 해봅시다 서버입장에서는 이미지만 주면 될까요? 정보가 좀 부족합니다. 왜냐면 그 프로필이미지라는게 특정 채팅방에 설정되는 이미지이기도하고 그 이미지가 누구의 이미지인지도 알아야합니다. 즉 두가지 정보가 더 필요합니다

  1. 어떤 유저의 이미지인지 알아야 함
  2. 어떤 채팅방의 이미지인지 알아야 함

어떤 유저인지는 userID를 넘겨주면되는데 어떤 채팅방인지를 알려주려면 아 각각의 채팅방이 id를 가지고 있겠구나라는 생각에 도달하게 됩니다

즉, 위의 코드를 아래와같이 수정할 수 있습니다

protocol ChattingRoomProtocol {
    func getChattingRoomList(userID: String) async throws -> [ChattingRoomInfo]
    func userEnterChattingRoom(userID: String, chattingRoomID: String, profileImage: UIImage) async throws -> Bool
}

근데 여기서도 약간 고민되는 부분이 userID는 알겠는데 chattingRoomID를 어디서 받아와야할지가 애매합니다...주는 곳이 없거든요. 채팅방의 정보이기때문에 그럼 채팅방의 정보를 받아올때 id까지 받아오면 되겟다는 생각이 듭니다

getChattingRoomList의 반환값인 ChattingRoomInfo에 chattingroomid를 같이 받아오면됩니다

그러면 코드가 전체적으로는 아래와같이 수정됩니다

struct ChattingRoomInfo {
    let id: String
    let mainImageUrl: String
    let title: String
    let count: Int
}

protocol ChattingRoomProtocol {
    func getChattingRoomList(userID: String) async throws -> [ChattingRoomInfo]
    func userEnterChattingRoom(userID: String, chattingRoomID: String, profileImage: UIImage) async throws -> Bool
}

자, 이제 세번째 기능은 쉽게 구현할 수 있습니다
유저가 채팅을 하는데있어서 어떤 text를 썼는지를 주면되는데 어떤 유저가 어떤 채팅방에서 썼는지를 알아야하고 그럼 서버에서 채팅 잘 갔음~ 혹은 채팅이 잘 못갔어 ㅠㅠ를 알려줘야하죠

제가 맡은 기능에 대한 추상화가 완료되었습니다

struct ChattingRoomInfo {
    let id: String
    let mainImageUrl: String
    let title: String
    let count: Int
}

protocol ChattingRoomProtocol {
    func getChattingRoomList(userID: String) async throws -> [ChattingRoomInfo]
    func userEnterChattingRoom(userID: String, chattingRoomID: String, profileImage: UIImage) async throws -> Bool
    func chatting(userID: String, chattingRoomID: String, chatText: String) async throws -> Bool
}

이정도를 만들었다면 API가 아직 완성이 안된시점에서도 개발을 충분히 할 수 있습니다. UI입장에서는 서버에서 이런 데이터가 왔다 치고 화면을 구성할 수 있습니다

그러다가 2~3일 정도 지나서 API가 다 나왔다고 하면 그제서야 실제 구현부를 구현해주면됩니다. 서버에서 데이터의 네이밍이 어떻게 되더라도 제가 기능을 구현하는데 필수적인 데이터는 ChattingRoomInfo로 추상화를 해놨기때문에 ChattingRoomInfo로 바꿔서 반환해주면됩니다. 내부에서 어떤 로직을 사용하더라도 최종적으로는 ChattingRoomInfo가 반환 되기 때문에 UI쪽 코드들을 변경하지 않아도 됩니다

만약에 이렇게 protocol로 추상화를 하지 않았다면 어떻게 될까요?

첫번째기능인 유저가 들어갈 수 있는 채팅방조회하기라는 기능을 보시죠

UI쪽에서 protocol로 바라본다는 뜻은 유저ID를 주면 결과적으로 [ChattingRoomInfo]를 주는 친구에게 부탁을 하는것과 동일합니다. 이 친구가 [ChattingRoomInfo]를 A한테 받아서 주는지 B한테 받아서 주는지 혹은 자기 주머니에서 꺼내서 주는지는 전혀 신경쓰지 않습니다. 그냥 어떻게든 주기만 하면됩니다

자유로워지는겁니다

반대로 protocol을 사용하지 않았다면 어떻게될까요?
그러면 실제 구현체를 바라보게될겁니다. 근데 그 구현부가 A한테 받아서 준다는걸로 구현이 되어있다고 해볼게요. 그말은 UI가 바라본 녀석은 유저ID를 주면 A한테 받아서 [ChattingRoomInfo]를 주는 녀석에게 부탁을 하는것과 동일해집니다

만약에 B한테 받게되면? 자기 주머니에서 꺼내주게되면 아얘 다른 친구에게 부탁하는 것과 동일하기때문에 달라지게되면 UI는 코드자체를 수정해야합니다

자유롭다기 보다는 빡빡해지죠. [ChattingRoomInfo]를 주는 주체가 달라지면 안됩니다

일상생활에서 보면

내가 돈이있고 우유를 사는데 모르겠고 우유를 살 수만 있다면 어떨까요? 집앞 cu를 가되되고 쿠팡에서 시켜도 되고 친구한테 부탁을 해도 되겠죠

근데 만약에 내가 돈이있는데 무조건 우유를 cu에서만 사야되는 병에 걸렸다면? 집앞에 cu는 없지만 gs가 있어도 몇 km 떨어진 cu에가야하는겁니다...

우린 이런 걸 보고 유도리가 부족하다...고 표현합니다
유연하지 못하다는거죠

(탕수육처럼 유도리있게~)

protocol을 사용하면 유연한 코드를 짤 수 있다는게 대충 이런 의미입니다

"속성"에 집중할 수 있다

제목을 짓기 조금 어려웠는데 protocol을 type으로 사용할 수 있다는 장점을 이야기해보려고 합니다

예를들어서 하나의 뷰에 텍스트필드가 3개가 있었다고 해보겠습니다
그래서 텍스트필드 관련 객체를 하나 만들어서 각 객체마다 적절한 유효성 검사를 해봐야한다라고 했을때 이런 코드를 짜볼 수 있습니다

final class MyTextfieldInfo {
    var userInputText: String
    
    init(userInputText: String) {
        self.userInputText = userInputText
    }
    
    func isValid() -> Bool {
        if userInputText.isEmpty {
            return false
        }
        return true
    }
}

이런 텍스트 필드가 3개가 있고 여기있는 isValid를 한번에 모든 textfield에서 호출해야한다면 어떨까요

저라면 텍스트필드를 하나의 list에 묶어서 map으로 모든 요소의 isValid라는 메서드를 실행시킬 것 같습니다

var textfields: [MyTextfieldInfo] = [
    MyTextfieldInfo(
        userInputText: "아이디"
    ),
    MyTextfieldInfo(
        userInputText: "비밀번호"
    ),
    MyTextfieldInfo(
        userInputText: "전화번호"
    )
]

func textFieldAllValid() -> Bool {
    if !textfields.map { $0.isValid() }.filter { !$0 }.isEmpty {
        return true
    }
    return false
}

대충 이런식으로 말이죠

근데 갑자기 이번에는 텍스트 필드 세개 위에 유저 프로필이미지를 넣을 수 있는 공간을 만들고 유저가 프로필을 넣었는지 넣지 않았는지에 대한 valid검사도 해야할거같아요...라고 했다면 어떨까요??

분명히 텍스트필드도 검사를 한다는 행위를 하고 프로필도 검사를 한다는 행위를 하는건 알겠는데 프로필 객체를 만들어보면 애매한 부분이 생깁니다

final class MyProfileInfo {
    var profile: UIImage?
    
    func isValid() -> Bool {
        if profile != nil {
            return true
        }
        return false
    }
}

이런 객체까지 만든건 좋은데 이 객체를 이전 로직처럼 map을 하려해도 이미 var textfields: [MyTextfieldInfo]라는 타입의 list라고 정의가 되어있어서 MyProfileInfo라는 타입의 객체는 들어갈 수가 없습니다

그러면 아래와 같이 각각의 case를 따로 정의해줘야합니다

func AllValid() -> Bool {
    if !textfields.map {
        $0.isValid()
    }.filter {
        !$0
    }.isEmpty && profiles.map {
        $0.isValid()
    }.filter {
        !$0
    }.isEmpty {
        return true
    }
    return false
}

갑자기 profile과 textfield이외의 다른 검증 객체가 생긴다면 어떨까요?
아마도 이 if문은 계속해서 커지고 읽기도 수정하기도 매우 어려워질겁니다

이렇게 된 이유는 클래스 객체 타입의 list로 정의해줬기 때문입니다
[MyTextfieldInfo]라는 의미는 MyTextfieldInfo타입의 객체가 들어있는 list라는 의미입니다

분명히 MyTextfieldInfo도 MyProfileInfo도 검증한다는 공통점이 있음에도 MyProfileInfo는 MyTextfieldInfo의 list에 공존할 수 없습니다

그런데 만약에 MyTextfieldInfo라는 객체의 list가 아닌 isValid를 하는 친구들의 list라는 정의로 바뀌면 어떨까요???

let 검증객체들: [isValid를 하는 친구들 타입] = [텍스트필드1이면서 isValid하는 친구, 텍스트필드2이면서 isValid하는 친구, 프로필1이면서 isValid하는 친구]

라고 정의할 수 있고

이 친구들은 모두 isValid라는 함수를 가지고 있는 친구이기에

if문을 계속해서 쌓아는것이 아닌

func allValid() -> Bool {
    if !검증객체들.map { $0.isValid() }.filter { !$0 }.isEmpty {
        return true
    }
    return false
}

로 한번에 로직을 짤 수 있습니다

이렇게 어떤 타입을 특정 객체가 아닌 이런 동작을 하는 객체로 만들어주는 것이 protocol을 type으로 사용하는 의미입니다

실제로 protocol을 활용해서 위 코드를 개선시켜보겠습니다

이런 동작을 하는 객체에서 이런 동작을 맡아줄 protocol을 선언해주겠습니다

protocol Validable {
    func isValid() -> Bool
}

검증 관련된 동작을 protocol로 추상화를 해주고

이 protocol을 textfield와 profile에 채택해주면됩니다

final class MyTextfieldInfo: Validable {
    var userInputText: String
    
    init(userInputText: String) {
        self.userInputText = userInputText
    }
    
    func isValid() -> Bool {
        if userInputText.isEmpty {
            return false
        }
        return true
    }
}

final class MyProfileInfo: Validable {
    var profile: UIImage?
    
    func isValid() -> Bool {
        if profile != nil {
            return true
        }
        return false
    }
}

그리고 이제 protocol타입으로 list를 만들면 됩니다

let validComponents: [Validable] = [
    MyTextfieldInfo(userInputText: "아이디"),
    MyTextfieldInfo(userInputText: "비밀번호"),
    MyTextfieldInfo(userInputText: "전화번호"),
    MyProfileInfo()
]

func allValid() -> Bool {
    if !validComponents.map {
        $0.isValid()
    }.filter {
        !$0
    }.isEmpty {
        return true
    }
    return false
}

만약에 갑자기 새로운 검증관련 컴포넌트가 생기더라도 그 객체를 Validable을 채택하도록 하고 저 validComponents라는 list에 넣어주기만 하면 allValid라는 메서드를 전혀 건드리지 않고도 기능을 추가할 수 있게 됩니다

기능을 추상화하면 기능을 타입으로 사용할 수 있고 여기는 무조건 textfield만 들어와야해!가 아닌 검증하는 메서드만 있으면(프로토콜을 채택했다면) 아무나 들어올 수 있어요~를 통해서 유도리있는 코드를 작성할 수 있게 됩니다


저번주까지는 글을 되게 많이 썼는데...최근에 회사에서 프로젝트를 진행하게되면서 글쓰는데 시간을 많이 할애하지 못했네요...다음에는 swift concurrency에 대한 시리즈 글을 적어볼까합니다. 많이 쓰지만 내가 과연 잘 알고쓰는걸까? 라는 생각이 요즘 많이 들어서 많이 공부하고 작성해보도록 하겠습니다:)

그럼! 오늘은 여기서 물러가보겠습니다!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글

관련 채용 정보