카테고리 없음

클린아키텍처 - (좋은벽돌까지만이라도 만들어보자) 로버트C.마틴

2료일 2024. 10. 14. 13:51

왜 설계와 아키텍처를 고민해야 할까?

작년 이맘때 처음 클린 아키텍처 책을 읽었는데, 이번에 다시 펼쳐본 이유는 명확했습니다.

 

객체지향 설계에 대해 생각하는 기회가 생겼거든요 🤔

 

로버트 C. 마틴(Uncle Bob)이 강조하는 핵심은 간단합니다.

 "빨리 가는 유일한 방법은 제대로 가는 것이다"

 

저도 데드라인이 코앞이면 "일단 찍어내고 나중에 리팩토링하자!"라고 생각하는 개발자입니다.

 

하지만 현실은 어떤가요?

 

그 '나중'은 절대 오지 않아요 😅

기능 구현 끝나자마자 바로 새로운 요구사항이 들어오죠. 그렇게 악순환이 시작됩니다.

 

결국 처음부터 깨끗한 아키텍처로 설계하는 것이 실제로는 더 빠르고 효율적이라는걸 깨달았습니다.

 

기능 vs 아키텍처, 뭐가 더 중요할까?

소프트웨어의 어원을 생각해보세요. Soft + ware = 부드러운 제품이죠.

하드웨어를 부드럽게 변경하는 역할이 소프트웨어인데, 정작 소프트웨어 자체가 딱딱하면 모순이 아닐까요?

 

두 가지 선택지가 있다면:

  • 완벽하게 동작하지만 변경 불가능한 프로그램
  • 당장 동작하지 않지만 쉽게 수정 가능한 프로그램

어떤 게 더 가치있을까요? 당연히 후자입니다.


왜냐고요? 변경하는 것에도 비용이 들어가거든요.

동작은 하지만 수정 비용이 천문학적이라면 결국 방치할 수밖에 없어요.

 

요즘은 더더욱 실감나는 얘기예요. ChatGPT 같은 AI로 어떻게든 굴러가는 코드는 만들 수 있거든요.

하지만 우리가 개발하는 건 단일 기능이 아니라 복잡한 의존성을 가진 시스템이잖아요? 그래서 아키텍처가 중요한 겁니다

아키텍처를 위해 투쟁해라!!!!!! 개발팀들이여!!!ㅋㅋㅋ라고 책에 써져있습니다

SOLID 원칙으로 이해하는 iOS 설계

SOLID 원칙은 객체지향에만 적용되는 게 아니에요. 목적은 명확합니다:

  1. 변경에 유연하고
  2. 이해하기 쉽고
  3. 재사용 가능한

컴포넌트를 만드는 것이죠.

SRP: 단일 책임 원칙

"하나의 모듈은 하나의 액터에 대해서만 책임져야 한다"

많은 분들이 "하나의 기능만 해야 한다"고 오해하는데, 그건 함수 레벨의 얘기예요.

잘못된 예시  

class UserManager {
    func calculatePay() -> Double {
        // 회계팀이 관리하는 로직
        return salary * 1.1
    }
    
    func saveToDatabase() {
        // DB 관리자가 관리하는 로직
        CoreData.save(user)
    }
    
    func generateReport() -> String {
        // 인사팀이 관리하는 로직  
        return "사용자 보고서: \(name)"
    }
}

이렇게 되면 회계팀이 급여 계산 로직을 바꿀 때 DB팀이나 인사팀도 영향을 받게 됩니다.

올바른 예시 ✅

class PayCalculator {
    func calculatePay() -> Double {
        return salary * 1.1
    }
}

class UserRepository {
    func save(_ user: User) {
        CoreData.save(user)
    }
}

class ReportGenerator {
    func generateUserReport(_ user: User) -> String {
        return "사용자 보고서: \(user.name)"
    }
}

각 클래스가 하나의 변경 이유만 갖도록 분리했어요!

OCP: 개방-폐쇄 원칙

"확장에는 열려있고, 변경에는 닫혀있어야 한다"

제가 처음 이 원칙을 적용해본 경험을 공유해볼게요.

Before (확장할 때마다 수정 필요) ❌

class TapHandler {
    func handle(tool: String) {
        if tool == "hammer" {
            print("깡깡!")
        } else if tool == "drill" {
            print("드르르...")
        }
        // 새 도구 추가할 때마다 여기를 수정해야 함
    }
}

After (확장에 열려있음) ✅

protocol Tappable {
    func tap()
}

class Hammer: Tappable {
    func tap() { print("깡깡!") }
}

class Drill: Tappable {
    func tap() { print("드르르...") }
}

class TapHandler {
    func handle(tool: Tappable) {
        tool.tap()  // 새 도구가 추가되어도 이 코드는 변경되지 않음
    }
}

이렇게 되면 확장에는 열려있고 변경에는 닫혀있도록 수정할 수 있어요

LSP: 리스코프 치환 원칙

"자식 타입은 부모 타입으로 교체할 수 있어야 한다" 

사실 이거만 보고는 뭔소리야?❓⁉️ 이랬습니다

 

좀 더 생각을 해보면 일반적으로 자식타입은 부모타입을 상속받고 더 구체적인 메서드와 개념을 가지고 있죠

예를 들어 동물 -> 포유류 -> 고래로 갈수록 범위는 좁아지지만, 속성과 행동은 더 구체적이고 풍부해지는 것 처럼요.

핵심은 부모의 약속을 깨면 안된다는 것입니다.

약속을 깨는 나쁜 예시 ❌

class GamePiece {
    var hp = 100
    
    func takeDamage(_ damage: Int) {
        hp -= damage
        print("HP: \(hp)")
    }
}

class ImmortalPiece: GamePiece {
    override func takeDamage(_ damage: Int) {
        // 오히려 회복시켜버리는 경우
        hp += damage
    }
}

이렇게 되면 다른 개발자는 당연히 부모로부터 상속받았으니 같은 기능을 한다고 생각하고 저 메서드를 사용할텐데 꼬이게 되고 어디서부터 추적을 해야하나 고민이 되는 상황이 만들어지는거죠

LSP가 주는 🏷️

1. 버그 감소

  • 예상치 못한 동작으로 인한 런타임 에러가 줄어들고 타입 안정성이 보장되거든요.

2. 테스트 용이성

  • 부모 타입으로 모든 자식 타입을 테스트할 수 있고 경계 케이스 처리가 간단해지죠.

3. 코드 수정의 안전성

  • 부모의 약속을 지키는 자식 클래스들로 구성된 시스템에서는 코드가 예측 가능하고, 확장이 쉬우며, 유지보수가 안전합니다.
  • 새로운 서브클래스를 추가해도 기존 코드가 깨지지 않고, 리팩토링이 두렵지 않아요.

결국 LSP를 지키는 것은 안전하고 견고한 소프트웨어를 만드는 핵심 전략 중 하나라고 할 수 있습니다

ISP: 인터페이스 분리 원칙

"사용하지 않는 인터페이스에 의존하면 안된다"

Swift에서는 프로토콜을 적절히 분리하라는 의미죠.

DIP: 의존성 역전 원칙

"상위 수준 모듈은 하위 수준 모듈에 의존해서는 안된다"

Before (구체에 의존) ❌

class UserService {
    let coreDataManager = CoreDataManager()
    
    func saveUser(_ user: User) {
        coreDataManager.save(user)  // 구체적인 구현에 의존
    }
}

After (추상에 의존) ✅

protocol UserRepositoryProtocol {
    func save(_ user: User)
}

class UserService {
    let repository: UserRepositoryProtocol
    
    init(repository: UserRepositoryProtocol) {
        self.repository = repository
    }
    
    func saveUser(_ user: User) {
        repository.save(user)  // 추상에 의존
    }
}

이제 DB를 CoreData에서 Realm으로 바꿔도 UserService는 전혀 변경되지 않아요


컴포넌트 원칙: 어떻게 묶고 나눌 것인가

SOLID 원칙이 클래스 레벨의 설계 원칙이라면, 컴포넌트 원칙은 좀 더 큰 단위의 설계 원칙입니다.

 

여기서 컴포넌트란 배포 가능한 최소 단위를 말하는데요, iOS로 치면 SPM 패키지, Framework, Library 같은 것들이죠.

 

그런데 왜 이런 원칙이 필요할까요?

프로젝트가 커지면 모든 코드를 한 곳에 두기 어렵거든요.
어떤 클래스들을 같은 컴포넌트로 묶고, 어떤 클래스들을 분리해야 할까요? 그 답이 바로 컴포넌트 원칙입니다.

 

컴포넌트 응집도 원칙: "무엇을 같이 묶을까?"

REP (재사용/릴리스 등가 원칙)

"재사용 단위는 릴리스 단위와 같다"

이게 무슨 말이냐면요, 다른 사람이 재사용할 수 있으려면 버전 관리가 되어야 한다는 겁니다.

예를 들어볼게요. Alamofire를 쓴다고 생각해보세요.

우리가 pod 'Alamofire', '~> 5.0' 이렇게 버전을 명시하잖아요?

이게 바로 릴리스 단위입니다. 만약 Alamofire가 버전 관리가 안 되고 매일 코드가 바뀐다면?

우리 프로젝트가 갑자기 빌드 에러로 터질 수 있겠죠.

그래서 git tag를 붙여 버전을 관리하는 거죠!!

 

CCP (공통 폐쇄 원칙)

"동일한 이유로 변경되는 클래스들을 같은 컴포넌트에 모으라"

이건 SRP의 컴포넌트 버전이에요. 같이 변경될 가능성이 높은 것들은 한 곳에 모으라는 거죠.

예를들어:

 

  • LoginView, LoginViewModel, LoginRepository는 "로그인 로직 변경"이라는 같은 이유로 수정될 가능성이 높습니다.
  • 이들을 FeatureLogin이라는 하나의 모듈에 묶어두는 것이 CCP를 따르는 것입니다.

 

 

CRP (공통 재사용 원칙)

필요 없는 것에 의존하게 강요하지 마라.

이건 ISP의 컴포넌트 버전입니다. 실제로 겪은 예시로 설명해볼게요.

제가 이미지 처리 기능만 필요했는데, 어떤 라이브러리를 추가했더니 네트워킹, 데이터베이스, 푸시 알림 기능까지 딸려왔어요.

앱 용량만 쓸데없이 커지는 거죠.

 


클린 아키텍처의 완성형



빨리 가는 유일한 방법은 제대로 가는 것이다. - 로버트 C.마틴

 

자 여기까지 좋은 벽돌을 골랐고 이제는 건물을 세워볼 단계이다.