iOS 개발에서 쉽게 도전해볼만한 리팩토링 접근법 8가지

thinkySide·2024년 12월 20일
0
post-thumbnail

본 포스팅은 애플 디벨로퍼 아카데미 @POSTECH 테크 포럼 이벤트, '기술 글자랑 대회'의 게시글을 가져와 작성되었습니다.

안녕하세요, 3기 주니어 러너 한톨입니다! 😇

오늘은 Tech Forum 기술 글자랑 대회의 마지막 주제인
‘더 나은 코드, 리팩토링’ 관련 게시글을 작성해보려 합니다.

개발을 하다보면 ‘리팩토링’ 이라는 단어를 자주 접하게 됩니다.
(보통은 일정이 촉박할 때 많이 사용하긴 하지만요.)

저는 관성적으로 리팩토링을 조금씩 하고 있기는 하지만
구체적으로 어떤 목적으로, 어떻게 진행하고 있는지
깊게 고민해본 적은 없는 것 같아 반성하게 되는 것 같습니다.

그래서 이를 정리하고 공유해보고자,
iOS 개발에서 제가 직접 경험해본 것들을 바탕으로
여러가지 리팩토링 접근법에 대해 설명해보겠습니다.


리팩토링의 의미와 목적

리팩토링은 소프트웨어 공학에서 말하길, 다음과 같습니다.

“결과의 변경 없이 코드의 구조를 재조정함”

조금 더 쉬운 말로 풀어보자면,
기존 코드의 동작을 유지하면서 코드 구조를 개선하는 작업입니다.

이어서 리팩토링의 목적은 맥락에 따라 조금씩 달라질 수 있으나,
근본적으로 지향하는 것들은 다음과 같습니다.

  • 가독성 향상
  • 유지보수성 및 확장성 향상
  • 성능 개선

아래 정리할 리팩토링 접근법은 위와 같은 의미와 목적 아래
시도할 수 있다는 맥락으로 읽어주시길 바랍니다!


1. 의도가 전해지는 네이밍

개발자의 가장 어려운 고민 중 하나는 ‘이름 짓기’입니다.
변수, 함수, 객체, 프로토콜 등,,, 찰떡 같은 이름 짓기는 고역 그 자체입니다.
하지만 이름을 잘 짓는 것만큼 의도를 효과적으로 드러낼 수 있는 방법이 없습니다.

애플의 API Design Guidelines를 살펴보며, 아래와 같이 리팩토링 해볼 수 있습니다.

  • 사용 시점을 기준으로 명확히 작성하는 것이 가장 중요한 목표 (명확성이 간결성보다 중요하다!)
  • 코드를 읽는 사람이 이해하는데 필요한 모든 단어를 사용
  • 타입 및 프로토콜의 네이밍은 PascalCase 사용 (각 단어의 첫 글자를 대문자로 작성)
  • 그 외 모든 것들은 lowerCamelCase 사용 (첫 단어는 소문자로 시작하고, 그 뒤의 단어의 첫 글자는 대문자로 작성)

자세한 내용은 API Design Guidelines를 살펴보실 것을 권장드립니다!

A. 변수 네이밍

  • 길더라도 의도가 명확히 전달되는 네이밍
  • Bool 타입의 변수에 is , has 접두사 사용
// Before 😈
struct BeforeContentView: View {
    
    @State private var alert = false
    
    private let text = "민톨"
    private let smart = false
    private let owner = true
    
    var body: some View {
        VStack {
            Text(text)
                .foregroundStyle(smart ? .blue : .red)
            
            if owner {
                Text("주인이 있는 강아지입니다.")
            }
        }
        .alert("민톨 알림!", isPresented: $alert) {
            Button("확인") {}
        }
    }
}
// After 😇
struct AfterContentView: View {

    @State private var isMintolAlertPresented = false
    
    private let dogName = "민톨"
    private let isSmart = false
    private let hasOwner = true
    
    var body: some View {
        VStack {
            Text(dogName)
                .foregroundStyle(isSmart ? .blue : .red)
            
            if hasOwner {
                Text("주인이 있는 강아지입니다.")
            }
        }
        .alert("민톨 알림!", isPresented: $isMintolAlertPresented) {
            Button("확인") {}
        }
    }
}

B. 함수 네이밍

  • Side-Effect가 없는 경우 명사로 지정
  • Side-Effect가 있는 경우 명령형 동사로 지정
  • 함수를 사용하는 위치에서 영어 문장의 형태가 되도록 네이밍
// Before 😈
func fetchTodayDate() -> Date {
    return .now
}

func index(num: Int) {
    self.selectedIndex = num
}

print(fetchTodayDate()
index(num: 3)
// After 😇
func today() -> Date {
    return .now
}

func updateIndex(to index: Int) {
    selectedIndex = index
}

print(today())
updateIndex(to: 3)

C. 객체 네이밍

  • 길더라도 의도가 명확히 전달되는 네이밍
// Before 😈
struct ListView: View {
    ...
}

class DataManager {
    ...
}
// After 😇
struct LearnerInfoListView: View {
    ...
}

class LearnerPersistenceDataManager {
    ...
}

D. 프로토콜 네이밍

  • 적절한 명사를 찾고, 찾을 수 없으면 접미사 able 혹은 ible 사용
// Before 😈
protocol LearnerProtocol {
    func challengeBasedLearning()
}

protocol Grow {
    func sutdy()
}

// 사용 예시
class Student: LearnerProtocol, Grow {}
// After 😇
protocol Learner {
    func challengeBasedLearning()
}

protocol Growable {
    func sutdy()
}

// 사용 예시
class Student: Learner, Growable {}

2. 함수 재설계 하기

개인적으로 함수를 제대로 설계하는 것이 리팩토링의 가장 작은 기본 단위이지 않을까 생각합니다.
좋은 함수를 설계하는 방법은 다양하지만, 제가 경험한 가장 직관적이고 적용하기 쉬운 개념을 설명드리려 합니다.

에릭 노먼드의 ‘함수형 코딩’에서는 코드를 3가지 분류로 나눌 수 있다고 이야기합니다.

  • 액션: 부르는 시점에 의존
    • 실행 시점 혹은 횟수 또는 모두에 의존
  • 계산: 입력값을 계산 해 출력 ⭐️
    • 같은 입력값을 가지고 계산하면 항상 같은 결괏값 출력
    • 언제 어디서 계산해도 결과는 같고, 외부에 영향 X
    • 테스트하기 쉽고, 여러번 호출해도 안전
  • 데이터: 이벤트에 대한 사실을 기록

액션은 전역 변수, 특정 UI 등에 의존해 실행되기 때문에
side-effect가 발생하고, 테스트와 관리가 어렵습니다.
하지만 액션 없이 프로그램이 돌아가길 기대한다는 것은, 꿈 같은 이야기입니다.
(변화하지 않는 소프트웨어를 상상해보세요!)

그렇기에 에릭 노먼드는 이야기합니다.

  • 액션을 코드 전체에 영향을 주지 않도록 격리시켜야 한다.
  • 코드의 많은 부분을 액션에서 계산으로 옮기면 결과적으로 액션도 다루기 쉬워진다!

이를 적용하기 위해 우리는 암묵적 입출력을 명시적 입출력으로 변환해야 합니다.

  • 암묵적 입력 → 인자
  • 암묵적 출력 → 리턴 값

액션을 계산으로 잘 변환했는지의 기준은, 암묵적 입출력이 존재하는지의 여부로 판단할 수 있습니다.

// 전역 변수 (일반적으로 암묵적 입출력을 야기함) 😈
var total = 0

// 인자 (명시적 입력) 😇
func addToTotal(amount: Int) -> Int {
    
    // 콘솔 출력 (암묵적 출력) 😈
    // 전역 변수 읽기 (암묵적 입력) 😈
    print("Old total: \(total)")
    
    // 전역 변수 쓰기 (암묵적 출력) 😈
    total += amount
    
    // 리턴값 (명시적 출력) 😇
    return total
}

// 위 함수는 암묵적 입출력이 1개 이상 존재하기 때문에 '액션'이 됩니다!

액션을 계산으로 바꾸면 다음과 같은 효과를 기대할 수 있게 됩니다.
1. 테스트 용이성: 계산은 언제 어디서나 원하는 만큼 테스트를 실행할 수 있다. (side-effect X)
2. 예측 가능성: 같은 입력을 넣으면 같은 출력이 나오기 때문에, 예측 가능하다.
3. 조합 용이성: 계산끼리 조합해 더 큰 계산을 만들고, 유지보수 및 확장에도 용이해진다.

위 개념을 적용한 리팩토링 예시를 소개드립니다.
(제가 소개드린 개념은 함수형 코딩 내용 중 극히 일부입니다. 요 책만을 가지고 쓰고 싶은 글이 정말 많을 정도로
좋은 내용이 무지막지하게 많으니, 꼭! 한번 읽어보시길 추천드립니다! 😉)

A. 액션을 계산으로 바꾸기

// Before 😈
final class LearnerRepository {
    
    var learners: [Learner]
    var currentIndex: Int
    
    func fetchLearners() async {
    
        // 전역 변수 읽기 (암묵적 입력) 😈
        let learners = await NetworkClient.get(index: currentIndex)
        
        // 전역 변수 쓰기 (암묵적 출력) 😈
        self.learners = learners
    }
}
// After 😇
final class LearnerRepository {
    
    var learners: [Learner]
    var currentIndex: Int
    
    // 인자 받기 (명시적 입력) 😇
    func fetchLearners(from index: Int) async -> [Learner] {
        let learners = await NetworkClient.get(index: index)
        
        // 리턴값 (명시적 출력) 😇 
        return learners
    }
    
    // 액션을 위한 함수 따로 분리하기!
    // 여러 계산 함수를 조합해 액션을 구성할 수 있습니다.
    func updateLearners() async {
        self.learners = await fetchLearners(from: currentIndex)
    }
}

3. 옵셔널 바인딩 확인하기

옵셔널은 Swift 언어에서 가장 중요하고, 가장 많이 사용되는 개념 중 하나입니다.
하지만 때때로, 잘못된 옵셔널 처리는 런타임 에러를 발생시키거나,
예측 하기 어려운 오류를 발생시키기도 합니다.
(함수가 실행은 됐는데 왜 아무것도 동작 안했지,,? 와 같은,,,)

저 또한 “일단 사용하기 쉽게 처리해 놓자!” 와 같은 안일한 마음으로
옵셔널을 사용해 후회했던 경험이 나는 것 같습니다,, 🥲

일반적으로 자주 실수했던 패턴을 정리해보자면,

  • 강제 언래핑 ! 키워드 사용으로 인한 런타임 에러
  • if let , guard let 옵셔널 바인딩 후 별도의 처리 없이 return ⭐️⭐️⭐️
  • nil-coalescing 연산자 사용으로 인한 예상치 못한 기본값 반환

사실 강제 언래핑의 경우, 대부분의 상황에서 사용을 지양한다는 말이 유명(?)하기도 하고,
nil-coalescing 의 경우도 기본값 반환이 일반적으로 큰 문제를 야기하진 않습니다.

결국 가장 많이 놓치는 경우는 옵셔널 바인딩으로 당장 눈에 보이지 않아 더 주의를 기울여야합니다.

옵셔널 바인딩 후 에러를 던지는 것만으로도, 예측 가능성을 크게 끌어올릴 수 있습니다.

// Before 😈
var playedMusic: Music?

func playMusic() {
    
    guard let playedMusic else {
        // 함수가 조기 종료되어도 문제 파악이 어려움!
        return 
    }
    
    playedMusic.play()
}

func fetchMusicList() async -> [Music] {
    if let musicList = await NetworkClient.get() {
        return musicList
    } else {
        // 값이 없어서 빈 배열인지, 서버 에러로 빈 배열인지 확인이 어려움!
        return [] 
    }
}
// After 😇
var playedMusic: Music?

func playMusics() throws {
    
    guard let playedMusic else {
        // 조기 종료와 함께 에러 던지기
        throw MusicError.noMusic
    }
    
    playedMusic.play()
}

func fetchMusicLists() async -> Result<[Music], MusicError> {
    if let musicList = await NetworkClient.get() {
        return .success(musicList)
    } else {
        // 값이 없다면 에러 던지기
        return .failure(MusicError.noMusic)
    }
}

4. 명확한 에러 처리

위에서 옵셔널 바인딩 처리 후 예외 케이스에 대해 에러를 던져주었습니다.
하지만 던지는 사람이 있으면 받는 사람도 있어야겠죠? ⚾
에러를 throw 하는 것만큼 중요한 것은, 에러를 catch 하는 것입니다.

다양한 케이스에 대한 유연한 에러 처리는 긍정적인 사용자 경험 설계의 필수 요소입니다.
개발자 입장에서 디버깅, 테스트 등을 용이하게 만들기도 하죠.

일반적으로 놓칠 수 있는 에러 처리 포인트를 정리해보겠습니다.

  • 여러 개의 에러를 던지고 있지만, 하나의 에러 처리만 구현하는 경우
  • Task 클로저 내부에서 에러 처리를 해주지 않는 경우

특히, Task 클로저 내부에서 에러를 던지는 함수를 호출하고 에러 처리를 하지 않아도
별다른 컴파일 에러를 내지 않기 때문에(Task 내부로 에러를 던짐) 가장 많이 놓치는 지점 중 하나입니다.
(보통 비동기 함수에서 에러를 던지기 때문에 자주 발생하기도 합니다.)

이에 대한 해결책으로 throws 키워드로 에러를 던지는 것 대신
Result 타입을 이용해 명시적으로 에러 처리를 할 수 있게 만들 수 있습니다.

A. 여러 개의 에러 처리

/// 공통 코드

// 1. 에러 열거형
enum MintolError: Error {
    case angry
    case hungry
    case sick(isSerious: Bool)
}

// 2. 구조체 내 에러 함수 구현
struct Mintol {
    let isAngry: Bool
    let isHungry: Bool
    let isSick: Bool
    
    func playWithMintol() throws {
        if isAngry {
            throw MintolError.angry
        } else if isHungry {
            throw MintolError.hungry
        } else if isSick {
            throw MintolError.sick(isSerious: false)
        }
        
        print("민톨이와 행복하게 놀았답니다")
    }
}
// Before 😈
do {
    let mintol = Mintol()
    try mintol.playWithMintol()
} catch {
    // ⭐️ 무슨 에러가 발생하든 같은 에러 처리를 해주고 있습니다,,,
    print("\(error) 에러가 발생했군요!")
}
// After 😇
do {
    try mintol.playWithMintol()
} catch MintolError.angry {
    print("민톨이가 화가 잔뜩 났아요 🥵")
} catch MintolError.hungry {
    print("민톨이는 배가 고파요 🍔")
} catch MintolError.sick(let serious) {
    let message = serious ? "많이" : "조금"
    print("민톨이가 \(message) 아파요 😭 ")
}

B. Task 클로저 내부에서 에러 처리

/// 공통 코드

// 1. 에러 열거형
enum HantolError: Error {
    case noStamina(stamina: Int)
}
// Before 😈

// 1. 구조체 내 비동기 에러 함수 구현
struct Hantol {
    var stamina = (0...100).randomElement()!
    
    func writePostOnTechForum() async throws {
        guard stamina < 30 else {
            throw HantolError.noStamina(stamina: stamina)
        }
        
        await NetworkClient.write()
    }
}

// 2. 비동기 에러 함수 호출
Task {
    // ⭐️ Task 클로저 내부에서 에러 처리를 해주지 않아도
    // 컴파일 에러가 나지 않습니다! (= 개발자가 실수 할 수 있음)
    let hantol = Hantol()
    try await hantol.writePostOnTechForum()
    print("테크 포럼에 글을 슉샥 적었답니다!")
}
// After 😇

// 방법 1. Task 내부에서 에러 처리 해주기
Task {
    do {
        try await hantol.writePostOnTechForum()
        print("테크 포럼에 글을 슉샥 적었답니다!")
    } catch HantolError.noStamina(let stamina) {
        print("[현재 체력: \(stamina)] 준비된 체력이 소진되었습니다,,,)")
    }
}

// 방법 2. Result 타입을 반환하는 함수로 설계하기 (한톨 추천 🍚)
struct Hantol {
    var stamina = (0...100).randomElement()!
    
    // ⭐️ throws 키워드 삭제 및 Result 타입 반환
    func writePostOnTechForum() async -> Result<Void, Error> {
        guard stamina < 30 else {
            return .failure(HantolError.noStamina(stamina: stamina))
        }
        
        await NetworkClient.write()
        return .success(())
    }
}

Task {
    let result = await hantol.writePostOnTechForum()
    
    // ⭐️ Result 타입을 이용한 명시적 에러 처리
    switch result {
    case .success:
        print("테크 포럼에 글을 슉샥 적었답니다!")
        
    case .failure(let error):
        if let error = error as? HantolError {
            switch error {
            case .noStamina(let stamina):
                print("[현재 체력: \(stamina)] 준비된 체력이 소진되었습니다,,,)")
            }
        }
    }
}

5. 열거형 활용하기

한정된 케이스를 사용함과 동시에 원시값(RawValue)과 연관값(Associated Value)을 이용한
풍부한 표현력, 프로토콜 채택 가능, 값타입의 이점까지 Swift의 열거형 enum 은 다양한 맥락에서 활용이 가능합니다.

열겨형에 가장 기본적으로 활용되는 ‘일반적인 상태 정의’를 제외하고
제가 자주 활용하는 열거형 사용 패턴을 정리하자면,

  • 상수 및 리소스 관리
  • 디자인 컴포넌트 내 Variation 정의

상수 및 리소스 관리를 열거형으로 하게 될 경우의 이점은
반복되는 상수 및 리소스 사용에 대한 중앙 집중화로,

  1. 개발자의 실수를 방지
  2. 변경에 용이
  3. 메모리 효율 개선

등의 효과를 기대할 수 있습니다.

디자인 컴포넌트의 경우 디자이너와 미리 정의된 케이스의 한해 컴포넌트를 표현할 수 있고,
(필요한 값만 받아올 수 있음) 이에 따른 관리 및 가독성 향상 등을 기대할 수 있습니다.

이외에도, 상황에 따라 Stringenum 으로 사용함으로써 메모리 효율을 개선시킬 수도 있습니다.
(위 내용은 Tech Forum의 아이작의 황금 고블린 게시글에서 자세히 논의되었습니다. 꼭 한번 읽어보세요!)

A. 상수 및 리소스 관리

// Before 😈
struct LearnerView: View {
    
    @State private var isNoticeAlertPresented = false
    
    var body: some View {
        VStack {
            // 토씨 하나라도 틀리면 안됩니다,,! (복사 & 붙여넣기 지옥)
            Text("Apple Developer Academy @POSTECH에 오신 것을 환영합니다!")
            
            HStack {
                // 오타가 날 경우 이미지 에셋이 불러와지지 않을 수도 있겠네요.
                Image("AppleIcon")
                    .frame(width: 100, height: 100)
                    .foregroundStyle(.black)
                
                Spacer()
                
                Image("PostechIcon")
                    .frame(width: 200, height: 200)
                    .foregroundStyle(.pink)
            }
        }
        .alert("Apple Developer Academy @POSTECH 공지사항 안내", isPresented: $isNoticeAlertPresented) {
            Text("매크로 잘 하고 계신가요?")
        }
    }
}
// After 😇

// 1. 상수 관리용 열거형
enum Const {
    static let academyName = "Apple Developer Academy @POSTECH"
}

// 2. 이미지 리소스 관리용 열거형
enum CustomImageResource: String {
    
    case appleIcon
    case postechIcon
    
    var widthHeight: CGFloat {
        switch self {
        case .appleIcon: 100
        case .postechIcon: 200
        }
    }
    
    var color: Color {
        switch self {
        case .appleIcon: .black
        case .postechIcon: .pink
        }
    }
}

// 3. 열거형을 활용한 CustomImage View
struct CustomImage: View {
    
    let resource: CustomImageResource
    
    init(_ customImageResource: CustomImageResource) {
        self.resource = customImageResource
    }
    
    var body: some View {
        Image(resource.rawValue)
            .frame(width: resource.widthHeight, height: resource.widthHeight)
            .foregroundStyle(resource.color)
    }
}

// 4. 최종 코드
// 가독성이 훨씬 좋아지지 않았나요?
struct LearnerView: View {
    
    @State private var isNoticeAlertPresented = false
    
    var body: some View {
        VStack {
            Text("\(Const.academyName)에 오신 것을 환영합니다!")
            
            HStack {
                CustomImage(.appleIcon)
                
                Spacer()
                
                CustomImage(.postechIcon)
            }
        }
        .alert("\(Const.academyName) 공지사항 안내", isPresented: $isNoticeAlertPresented) {
            Text("매크로 잘 하고 계신가요?")
        }
    }
}

B. 디자인 컴포넌트 내 Variation 정의

// Before 😈
struct ActionButton: View {
    
    let title: String
    let backGroundColor: Color
    let foregroundColor: Color
    let isDisabled: Bool
    let tapAction: () -> Void
    
    var body: some View {
        Button {
            tapAction()
        } label: {
            Text(title)
                .frame(maxWidth: .infinity)
                .frame(height: 56)
                .background(backGroundColor)
                .foregroundStyle(foregroundColor)
                .clipShape(RoundedRectangle(cornerRadius: 12))
        }
        .disabled(isDisabled)
    }
}

// 사용 예시
// 모든 상태 값을 지정해줘야 합니다.
// 만약 디자인 시스템이 바뀐다면 사용하는 곳 전부 바꿔야겠죠,,?
ActionButton(
    title: "시작하기",
    backGroundColor: .red,
    foregroundColor: .blue,
    isDisabled: false,
    tapAction: {
        print("시작!")
    }
)
// After 😇
struct ActionButton: View {
    
    enum ButtonType {
        case primary
        case secondary
        case disabled
        
        var backGroundColor: Color {
            switch self {
            case .primary: return .blue
            case .secondary: return .red
            case .disabled: return .gray
            }
        }
        
        var foregroundColor: Color {
            switch self {
            case .primary, .secondary: return .white
            case .disabled: return .gray
            }
        }
    }
    
    let title: String
    let buttonType: ButtonType
    let tapAction: () -> Void
    
    var body: some View {
        Button {
            tapAction()
        } label: {
            Text(title)
                .frame(maxWidth: .infinity)
                .frame(height: 56)
                .background(buttonType.backGroundColor)
                .foregroundStyle(buttonType.foregroundColor)
                .clipShape(RoundedRectangle(cornerRadius: 12))
        }
        .disabled(buttonType == .disabled)
    }
}

// 사용 예시
// ⭐️ 사용부에서는 컴포넌트 타입이 무엇인지만 지정합니다!
ActionButton(
    title: "시작하기",
    buttonType: .primary,
    tapAction: {
        print("시작!")
    }
)

6. 접근 제어자 설정하기

적절한 접근 제어자 설정은 캡슐화, 은닉화에 있어 중요한 역할을 수행합니다.
라이브러리를 사용하는데 모든 요소가 public 처리되어있는 모습을 상상할 수 있으신가요?

속성을 사용해도 되는건지, 함수가 어떤 이펙트를 불러올지
예측하기 어려워지고 이는 예기치 못한 상황을 발생시킬 수 있습니다.
(. 을 찍었을 때 나타나는 수많은 리스트는 덤이랍니다.)

접근제어자 설정의 핵심은, 객체 혹은 모듈 간의 통신에서
어떤 정보를 노출하고 은닉할지 선택 기준을 수립하는 것에서 시작됩니다.

저는 크게 2가지 기준으로 접근제어자를 설정합니다.

  • 객체 혹은 모듈 외부에서 호출되야 하는가?
  • 값이 외부에서 변경될 가능성이 있는가?

A. 일반적으로 사용할 수 있는 접근제어자

// Before 😈
@Observable
class ViewModel {
    
    // ViewModel 외부에서 변경이 가능해
    // 값이 ViewModel 내부에서만 바뀐다는 것을 보장할 수 없습니다.
    var learnerList: [Learner]
    
    init(learnerList: [Learner]) {
        self.learnerList = learnerList
    }
    
    func fetchLearnerList() async {
        let learnerList = await NetworkClient.get()
        self.learnerList = filterLearnerList(from: learnerList, by: "한톨")
    }
    
    // filter 함수는 View에서 호출할 필요가 없습니다.
    func filterLearnerList(from learnerList: [Learner], by name: String) -> [Learner] {
        learnerList.filter { $0.name == name }
    }
}
// After 😇
@Observable

// 접근제어자는 아니지만, 상속 및 재정의가 불필요한 class에 final 키워드를 붙임으로써
// Dynamic Dispatch -> Static Dispatch 방식으로 전환이 가능합니다.
// 이는 class의 Vtable을 이용해 메서드를 호출하는 방식보다 빠르기 때문에
// 성능 개선을 기대할 수 있게 됩니다.
final class ViewModel {
    
    // ⭐️ 값을 읽을 수는 있지만, 쓸 수는 없게 설정해줌으로써
    // ViewModel 내부에서만 값이 변경 됨을 보장합니다!
    private(set) var learnerList: [Learner]
    
    init(learnerList: [Learner]) {
        self.learnerList = learnerList
    }
    
    func fetchLearnerList() async {
        let learnerList = await NetworkClient.get()
        self.learnerList = filterLearnerList(from: learnerList, by: "한톨")
    }
    
    // View에서 호출할 필요가 없는 함수는 private 접근제어자로 숨겨줍니다.
    private func filterLearnerList(from learnerList: [Learner], by name: String) -> [Learner] {
        learnerList.filter { $0.name == name }
    }
}

B. SwiftUI의 @State 변수의 접근제어자 설정

/// 번외.swift

struct LearnerView: View {
    
    // ⭐️ SwiftUI에서 @State 변수는 private 접근 제어자 키워드가 권장됩니다.
    //
    // [애플 공식 문서 - @State]
    //
    // Declare state as private to prevent setting
    // it in a memberwise initializer, which can conflict
    // with the storage management that SwiftUI provides.
    
    // SwiftUI가 제공하는 저장소 관리와 충돌할 수 있는
    // 멤버와이즈 생성자에서 state를 설정하는 것을 방지하기 위해
    // state를 private로 선언합니다.
    
    // @Binding의 경우 별도의 접근 제어자 설정에 대한 이야기는 없는 것 같네요!
    
    @State private var name = "한톨"
    
    var body: some View {
        Text(name)
    }
}

7. 의존성 관계 개선하기

의존성 관계는 애플리케이션 구조 혹은 비즈니스 요구사항이 복잡해질수록 중요해지는 키워드입니다.
좋은 의존성 관계를 가진 프로젝트는 변화에 드는 비용을 줄일 수 있게 됩니다.

변화에 드는 비용을 줄일 수 있다는 것은, 비즈니스 가치 창출로 이어지는 맥락이기 때문에
많은 소프트웨어 개발 팀이 이를 개선하기 위해 노력합니다.

의존성 관계 개선의 핵심은, 추상적인 것과 구체적인 것을 나누는 데서 시작할 수 있습니다.

클린 아키텍처에서 사용되는 용어를 빌려 표현해보자면, 아래는 추상적인 것으로 분류할 수 있습니다.

  • 비즈니스에서 사용되는 개념을 표현하는 Entity
  • 핵심 비즈니스 로직을 나타내는 UseCase

그에 반해 아래는 구체적인 것으로 분류할 수 있습니다.

  • 화면을 나타내는 View
  • DB 혹은 Framework 등의 Data

추상적 / 구체적이라는 단어를 달리 말하면, 변화하기 어려운 것 / 변화하기 쉬운 것 으로 재정의할 수 있습니다.

다시 돌아와 의존성 관계 개선의 목적은 변화에 드는 비용을 줄임이었습니다.
그에 따라 자연스럽게 변화하기 쉬운 것들이 변화하기 어려운 것들에 의존해야 함을 직감할 수 있습니다.

(반대로, Entity 혹은 UseCase 가 자주 변화할 것으로 예상된다면,
양방향 의존도 고려해볼 수 있습니다! - 이에 대한 좋은 예시가 Tech Forum의
구체적이지 않은 요구사항으로 개발하기- 확장성을 고려한 모델 설계 게시글에서 살펴볼 수 있습니다!)

본 단락의 목적은 클린 아키텍처에 대한 이해가 아닌,
소프트웨어를 설계할 때 의존 관계를 어떻게 가져갈까에 대한
기준을 제시하고자 하는 것입니다.

위를 포함한 2가지 기준으로 의존성 개선 방법 예시를 정리해보겠습니다.

  1. 변화하기 쉬운 것이 변화하기 어려운 것을 의존하는 형태
  2. 데이터 흐름 상 의존할 수 밖에 없을 때, protocol 을 이용한 의존성 역전
// Before 😈

// 1. View (변화하기 쉬운 것)
// View는 변화하기 가장 쉬운 요소 중 하나입니다.
struct ContentView: View {
    
    // 변화하기 쉬운 View가 변화하기 쉬운 DTO에 의존하고 있습니다.
    // 이 의존 관계는 불안정합니다.
    @State private var learners: [LearnerAPIResponse] = []
    
    // 변화하기 어려운 업무 규칙, BusinessLogic을 의존하고 있습니다.
    // 하지만 BusinessLogic 객체 내에서 구체적 것에 의존하고 있기에,
    // 이 의존 관계 또한 불안정합니다.
    
    private let businessLogic = LearnerBusinessLogic()
    
    var body: some View {
        VStack {
            ForEach(learners) { learner in
                Text(learner.name)
            }
            
            Button("러너 불러오기") {
                Task {
                    let learners = await businessLogic.showLearners()
                    self.learners = learners
                }
            }
        }
    }
}

// 2. 핵심 BusinessLogic (변화하기 어려운 것)
struct LearnerBusinessLogic {
    
    // ⭐️ 변화하기 쉬운 NetworkClient를 의존하고 있습니다.
    private let networkClient = NetworkClient()
    
    // ⭐️ 러너를 보여준다! 라는 행동은 핵심 비즈니스 로직이자 변화하기 어려운 추상적인 동작입니다.
    // 하지만 2가지 변화하기 쉬운 것에 의존하고 있어 의존 관계가 불안정합니다.
    // 
    // 1. NetworkClient()
    // 2. LearnerAPIResponse DTO
    // 
    // 네트워크 객체 혹은 DTO가 업데이트 된다면,
    // 변화하기 어려워야 할 BusinessLogic 객체를 포함한
    // 이를 의존하고 있는 모든 객체에서 컴파일 에러를 뱉을 것입니다!
    func showLearners() async -> [LearnerAPIResponse] {
        return await networkClient.fetchLearners()
    }
}

// 3. NetworkClient (변화하기 쉬운 것)
// 네트워크는 통제할 수 없는 외부 요인으로서,
// 어떤 값이 들어올지 예측하기 어렵습니다.
struct NetworkClient {

    // 외부 요인인 서버 DTO 타입에 의존하고, 반환하고 있습니다.
    func fetchLearners() async -> [LearnerAPIResponse] {
        // some learnerAPIResponse...
    }
}
// 😇 After

// 1. Entity (변화하기 어려운 것)
// 앱 내에서 '러너'라는 컨셉을 표현하는 공통 구조체입니다.
// '러너'라는 개념을 추상적으로 표현하고 있습니다.
struct Learner: Identifiable {
    let id = UUID()
    let name: String
}

// 2. Protocol (변화하기 어려운 것)
// 추상적으로 '러너 리스트를 받아온다'를 나타내는 프로토콜입니다.
// 반환 타입 또한 변화하기 어려운 Learner Entity를 반환하고 있습니다.
// 이를 이용해 의존성 역전이 가능합니다.
protocol LearnerClient {
    func fetchLearners() async -> [Learner]
}

// 3. 핵심 BusinessLogic (변화하기 어려운 것)
struct LearnerBusinessLogic {
    
    // 구체적인 NetworkClient를 의존하는 것이 아닌,
    // 추상적인 프로토콜을 의존하고 있습니다.
    // LearnerClient를 준수하는 어떤 객체라도 이 자리에 들어갈 수 있습니다.
    private let networkClient: LearnerClient
    
    // 외부에서 의존성을 주입 받습니다.
    // 이로써 BusinessLogic 객체는 구체적인 어느 것도 알고 있지 못하게 됩니다.
    init(networkClient: LearnerClient) {
        self.networkClient = networkClient
    }
    
    // 프로토콜을 통해 구체적으로 어떻게 가져오는지는 모르지만,
    // [Learner]를 반환할 것이라는 것만 알고 있습니다.
    func showLearners() async -> [Learner] {
        return await networkClient.fetchLearners()
    }
}

// 4. NetworkClient (변화하기 쉬운 것)
// LearnerClient를 채택하고 있습니다.
// 이로써 추상적인 BusinessLogic과 구체적인 NetworkClient
// 간의 통신이 가능해집니다. (프로토콜을 이용한 의존성 역전)
struct NetworkClient: LearnerClient {
    
    // 구체적인 타입인 DTO를 추상적인 타입인 Learner Entity로 변환합니다.
    func fetchLearners() async -> [Learner] {
        let learnerAPIClientDTO = [ // some learnerAPIDTO... ]
        return learnerAPIClientDTO.map { Learner(name: $0.name) }
    }
}

// 5. View (변화하기 쉬운 것)
struct ContentView: View {
    
    // 추상적인 타입인 Learner Entity에 의존하고 있습니다.
    @State private var learners: [Learner] = []
    
    // View에서 BusinessLogic이 들고 있을 구체적인 타입을 주입하고 있습니다.
    // 이를 이용해 Mock 객체를 생성 후 테스트도 가능해집니다.
    private let businessLogic = LearnerBusinessLogic(networkClient: NetworkClient())
    
    var body: some View {
        VStack {
            ForEach(learners) { learner in
                Text(learner.name)
            }
            
            Button("러너 불러오기") {
                Task {
                    let learners = await businessLogic.showLearners()
                    self.learners = learners
                }
            }
        }
    }
}

8. GCD 방식을 Swift Concurrency로 마이그레이션 하기

전통적으로 Swift에서는 GCD(Grand-Central-Dispatch) 방식을 사용해
비동기 및 동시성 처리를 구현했습니다.

하지만 GCD 방식은 다양한 문제점이 있었고, (가독성 및 안정성 등의 문제)

이를 개선하기 위해 애플은 WWDC21에서 Swift Concurrency를 소개했습니다.

여전히 GCD를 사용하는 것은 큰 문제없이 동작할 수 있지만,

  • 효율적인 비동기 및 동시성 처리
  • 효과적인 에러처리
  • 높은 가독성 (솔직히 제일 와닿는 것 중 하나…)
  • 작업의 우선순위 지정
  • 컴파일러 단의 Tread-Safe 검사
  • 애플의 많은 최신 Framework, API 등에서의 지원 (SwiftUI와의 호환성)

등의 너무나 많은 이점을 뒤로하는 것이 점점 어려워지는 것 같습니다.

또한 기존 Delegate 메서드를 Swift Concurrency 방식으로 점진적 마이그레이션 또한 가능 하기에
일관적인 비동기 및 동시성 처리 방법을 사용하는 것은 프로젝트 관점에서도 유효할 것입니다.

A. 비동기 GCD 코드 Swift Concurrency로 마이그레이션

// Before 😈

// 1. NetworkClient
struct NetworkClient {
    
    // @escaping completion 인자를 이용해 비동기 코드의 호출 시점을 결정합니다.
    static func get(completion: @escaping (Result<[Learner], Error>) -> Void) {
    
        // 리턴 타입이 없기 때문에, completion 클로저를 호출해주지 않아도
        // 컴파일 에러가 나지 않습니다.
        // 이는 개발자가 신경 써서 값 반환을 해주어야 한다는 뜻이기도 합니다.
        guard let learners = fetchLearners() else {
            completion(.failure(LearnerError.noData))
            return 
        }
        
        // 성공 케이스 또한 마찬가지입니다!
        completion(.success(learners))
    }
}

// 2. LearnerManager
class LearnerManager {
    
    private(set) var learners: [Learner] = []
    
    func updateLearners() {
    
        // completion 클로저를 이용해 비동기 값을 반환받습니다.
        NetworkClient.get { result in
            switch result {
            case .success(let learners):
            
                // Main-Thread에서 값을 업데이트하고 있습니다.
                DispatchQueue.main.async { [weak self] in
                    self?.learners = learners
                }
                
            case .failure(let error):
                print(error)
            }
        }
    }
}

// 3. 사용 예시
let learnerManager = LearnerManager()
learnerManager.updateLearners()
// 😇 After

// 1. NetworkClient
struct NetworkClient {
    
    // async 키워드를 이용한 비동기 함수 정의
    // 명시적인 반환 타입으로 개발자의 실수를 막아줍니다.
    static func get() async -> Result<[Learner], Error> {
        guard let learners = fetchLearners() else {
            return .failure(LearnerError.noData)
        }
        
        return .success(learners)
    }
}

// 2. LearnerManager
// (🚨 현재 해당 코드는 Swift6에서 에러가 발생합니다.
// GCD -> Swift Concurrency로의 마이그레이션이라는 맥락상
// 설명을 돕기 위한 코드로, 이와 관련해 너그럽게 봐주셨으면 합니다!)
class LearnerManager {
    
    private(set) var learners: [Learner] = []
    
    func updateLearners() async {
        let result = await NetworkClient.get()
        
        switch result {
        case .success(let learners):
        
            // Main-Thread에서 값을 업데이트하고 있습니다.
            await MainActor.run {
                self.learners = learners
            }
            
        case .failure(let error):
            print(error)
        }
    }
}

// 3. 사용 예시
let learnerManager = LearnerManager()

// Task 클로저 내부에서 비동기 작업을 실행할 수 있습니다.
// await 키워드를 만나면 완료될 때까지 기다린 후 재개합니다.
Task {
    await learnerManager.updateLearners()
}

B. Delegate 코드 Swift Concurrency로 마이그레이션

// 😈 Before

// 1. Delegate 프로토콜
protocol NetworkManagerDelegate: AnyObject {
    func networkManager(didUpdateLearnerList learnerList: [Learner])
    func networkManager(didFailWithError error: NetworkError)
}

// 2. NetworkManagerDelegate 구현체
// 분리가 어렵기 때문에 이러한 형태로 직접 채택해서 사용.
// (ViewController 등에서 Delegate를 채택해 사용하는 것과 비슷한 맥락!)
final class LearnerManager: NetworkManagerDelegate {
    
    private(set) var learners: [Learner] = []
    
    func networkManager(didUpdateLearnerList learnerList: [Learner]) {
        self.learners = learnerList
    }
    
    func networkManager(didFailWithError error: NetworkError) {
        print(error)
    }
}
// 😇 After

// 1. Delegate 프로토콜
protocol NetworkManagerDelegate: AnyObject {
    func networkManager(didUpdateLearnerList learnerList: [Learner])
    func networkManager(didFailWithError error: NetworkError)
}

// 2. NetworkManagerDelegate 구현체
final class NetworkClient: NetworkManagerDelegate {
    
    // ⭐️ 비동기 값을 받을 Continuation
    var continuation: CheckedContinuation<[Learner], Error>?
    
    func networkManager(didUpdateLearnerList learnerList: [Learner]) {
	      // 값을 받아올 시, continuation에 값을 실은 후 재개
        continuation?.resume(returning: learnerList)
    }
    
    func networkManager(didFailWithError error: NetworkError) {
        // 에러가 들어올 시, continuation에 에러를 실은 후 재개
        continuation?.resume(throwing: NetworkError.noData)
    }
}

// 3. Delegate 값을 사용할 객체
final class LearnerManager {
    
    private let networkClient = NetworkClient()
    
    private(set) var learners: [Learner] = []
    
    func fetchLearners() async throws {
    
        // ⭐️ withCheckedThrowingContinuation으로 continuation 생성 후
        // NetworkClient의 continuation에 할당
        // 이를 통해 Delegate의 값을 Cocurrency 방식으로 받아올 수 있음
        // + Delegate 객체의 로직 분리
        let learnerList = try await withCheckedThrowingContinuation { [weak self] continuation in
            self?.networkClient.continuation = continuation
        }
        
        self.learners = learnerList
    }
}

마치며

리팩토링에 대한 키워드를 간단하게 나열해보려 했는데 작성하다보니 너무 길어진 것 같습니다,,! 🤔
(사실 2가지가 더 있었는데,, 작은 내용이기도 한 것 같아 빼버렸습니다)

어려운 내용은 아니었지만, iOS 개발을 진행하며 한번쯤 생각해볼만한 지점들을 정리할 수 있는 것에 의미를 두고자 합니다.
(또 누군가에게 조금이나마 도움이 될 수 있다면 더 기쁠 것 같네요 😀)

언제나 피드백 및 질문에 열려있습니다! 같이 이야기하며 성장할 수 있었으면 합니다.
긴 글 읽어주셔서 감사합니다!

profile
UX 한스푼 넣은 iOS 디발자 한톨 / Apple Developer Academy @POSTECH 3기

0개의 댓글