ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SOLID한 코드를 써보자
    Apple🍎/Architecture pattern 2024. 7. 25. 22:36

    단일 책임 원칙 (SRP)

    class AccountManager {
        func createAccount(for user: User) -> Account { /* ... */ }
        func closeAccount(_ account: Account) { /* ... */ }
    }
    
    class TransactionProcessor {
        func deposit(amount: Decimal, to account: Account) { /* ... */ }
        func withdraw(amount: Decimal, from account: Account) throws { /* ... */ }
    }
    
    class AuthenticationService {
        func login(username: String, password: String) -> Bool { /* ... */ }
        func logout(user: User) { /* ... */ }
    }

    각각의 클래스는 하나의 책임만을 가지고 있습니다:

    • AccountManager는 계좌 생성과 폐쇄만 담당
    • TransactionProcessor는 입금과 출금 처리만 담당
    • AuthenticationService는 로그인과 로그아웃만 담당

    이렇게 분리함으로써 각 클래스는 단 하나의 이유로만 변경될 수 있습니다. 예를 들어, 인증 방식이 변경되어도 AuthenticationService만 수정하면 되고, 다른 클래스들은 영향을 받지 않습니다.
    -> “클래스를 변경하는 이유가 오직 하나여야 한다”라는 의미는 위의 경우 인증방식 수정으로 인해 변경된 클래스는 인증방식 외의 계좌생성이나 입출금에 관련한 변경으로 인해 코드가 변경되는 일이 없어야한다는 의미입니다.

    책임의 범위는 어떻게 나눌 수 있을까?

    단일 책임 원칙(Single Responsibility Principle, SRP)에서 "단일 책임"의 정의는 상황에 따라 다소 주관적일 수 있습니다. 그러나 일반적으로 다음과 같은 기준을 고려합니다:

    1.매우 작은 책임 단위:

    ```
    class EmailValidator {
     func isValidEmail(_ email: String) -> Bool {
         // 이메일 유효성 검사 로직
     }
    }
    ```

    이 클래스는 이메일 유효성 검사라는 단 하나의 책임만 갖습니다.

    2.약간 더 큰 책임 단위:

    class UserAuthenticator { func authenticate(username: String, password: String) -> Bool { // 사용자 인증 로직 } func generateToken(for user: User) -> String { // 토큰 생성 로직 } }

    3.더 큰 책임 단위:

    class OrderProcessor { func processOrder(_ order: Order) { validateOrder(order) calculateTotal(order) applyDiscount(order) createInvoice(order) updateInventory(order) } private func validateOrder(_ order: Order) { /* ... */ } private func calculateTotal(_ order: Order) { /* ... */ } private func applyDiscount(_ order: Order) { /* ... */ } private func createInvoice(_ order: Order) { /* ... */ } private func updateInventory(_ order: Order) { /* ... */ } }

     

    이 클래스는 "주문 처리"라는 하나의 큰 책임을 갖고 있지만, 여러 관련 작업을 포함합니다.

    "단일 책임"의 적절한 수준은 다음을 고려하여 결정해야 합니다:

    1. 코드의 재사용성: 너무 작은 책임 단위는 재사용이 어려울 수 있습니다.
    2. 유지보수성: 너무 큰 책임 단위는 유지보수가 어려울 수 있습니다.
    3. 시스템의 복잡성: 복잡한 시스템에서는 더 세분화된 책임이 필요할 수 있습니다.
    4. 팀의 이해와 합의: 팀 내에서 "단일 책임"에 대한 공통된 이해가 필요합니다.

    결론적으로, "단일 책임"의 수준은 절대적이지 않으며, 프로젝트의 맥락, 팀의 합의, 그리고 시스템의 복잡성에 따라 적절히 조정되어야 합니다. 중요한 것은 클래스나 모듈이 응집도 높고 명확한 목적을 가지며, 변경의 이유가 최소화되도록 설계하는 것입니다.

    이 클래스는 "사용자 인증"이라는 하나의 책임을 갖지만, 인증과 토큰 생성이라는 두 가지 관련 작업을 수행합니다.

     

    개방-폐쇄 원칙 (OCP)

    protocol AccountType {
        func calculateInterest() -> Decimal
    }
    
    class SavingsAccount: AccountType {
        func calculateInterest() -> Decimal {
            // 저축 계좌 이자 계산 로직
        }
    }
    
    class CheckingAccount: AccountType {
        func calculateInterest() -> Decimal {
            // 당좌 계좌 이자 계산 로직
        }
    }
    
    class FixedDepositAccount: AccountType {
        func calculateInterest() -> Decimal {
            // 정기 예금 계좌 이자 계산 로직
        }
    }

    AccountType 프로토콜을 통해 새로운 계좌 유형을 추가할 때 기존 코드를 수정하지 않고도 확장이 가능합니다:

    • 새로운 계좌 유형(예: FixedDepositAccount)을 추가할 때, 기존 코드를 변경하지 않고 AccountType 프로토콜을 구현하는 새 클래스를 만들기만 하면 됩니다.
    • 이는 코드가 확장에는 열려있고(새 계좌 유형 추가 가능), 수정에는 닫혀있음(기존 코드 변경 불필요)을 보여줍니다.
    • 프로토콜을 이용해 하나의 일관성을 가진 유형을 먼저 만든 뒤, 해당 유형에 포함되는 구체적인 사례들을 추가하게 만듬으로써 기존의 정의한 기능은 변경하지 않으면서도 새로운 특정 사례에 대해 대응이 가능해집니다.

    Open-Closed Principle을 지키지 않은 경우

    1. 코드 수정의 위험성:
      • 새로운 기능을 추가할 때마다 기존 코드를 수정해야 합니다.
      • 이는 이미 테스트되고 안정적인 코드를 변경하게 되어 버그 발생 위험이 높아집니다.
    2. 유지보수의 어려움:
      • 시스템이 커질수록 변경해야 할 코드의 양이 늘어나 유지보수가 복잡해집니다.
      • 한 부분의 수정이 예상치 못한 다른 부분에 영향을 줄 수 있습니다.
    3. 확장성 저하:
      • 새로운 기능을 추가하기 위해 기존 코드를 계속 수정해야 하므로, 시스템의 확장이 어려워집니다.
      • 이는 소프트웨어의 진화와 적응을 방해합니다.
    4. 테스트의 복잡성 증가:
      • 기존 코드가 수정될 때마다 관련된 모든 테스트를 다시 수행해야 합니다.
      • 이는 테스트 과정을 길고 복잡하게 만듭니다.
    5. 코드 재사용성 감소:
      • 특정 상황에 맞춰진 코드는 다른 상황에서 재사용하기 어려워집니다.
      • 이는 코드 중복을 증가시키고 전체적인 코드 품질을 저하시킵니다.
    6. 의존성 관리의 어려움:
      • 다양한 모듈 간의 의존성이 증가하여 한 부분의 변경이 연쇄적으로 다른 부분에 영향을 미칩니다.
    7. 병렬 개발의 어려움:
      • 여러 개발자가 동시에 작업할 때, 같은 코드를 수정하게 되어 충돌이 발생할 가능성이 높아집니다.
    8. 버전 관리의 복잡성:
      • 기존 코드의 잦은 수정으로 버전 관리가 복잡해지고, 이전 버전으로의 롤백이 어려워집니다.
    9. 시스템 이해도 저하:
      • 지속적인 코드 수정으로 시스템의 전체적인 구조와 흐름을 이해하기 어려워집니다.
    class PaymentProcessor {
        func processPayment(method: String, amount: Double) {
            switch method {
            case "CreditCard":
                // 신용카드 결제 처리
            case "PayPal":
                // PayPal 결제 처리
            case "BankTransfer":
                // 계좌이체 처리
            default:
                fatalError("Unsupported payment method")
            }
        }
    }

    이 코드에서 새로운 결제 방식(예: Apple Pay)을 추가하려면 PaymentProcessor 클래스를 직접 수정해야 합니다. 이는 다음과 같은 문제를 야기할 수 있습니다:

    • 이미 사용되고 있는 기존 코드를 수정하므로 버그 발생 위험이 높아집니다.
    • PaymentProcessor 클래스가 점점 커지고 복잡해집니다.
    • 새로운 결제 방식을 추가할 때마다 이 클래스를 수정해야 합니다.
    • 특정 결제 방식에 대한 로직 변경이 다른 결제 방식에 영향을 줄 수 있습니다.

    OCP를 적용하면 이러한 문제들을 해결할 수 있습니다. 예를 들어, 결제 방식을 프로토콜로 추상화하고 각 결제 방식을 별도의 클래스로 구현하면, 새로운 결제 방식 추가 시 기존 코드를 수정하지 않고 확장할 수 있습니다.

     

    리스코프 치환 원칙 (LSP)

    class BankAccount {
        var balance: Decimal
    
        func withdraw(amount: Decimal) throws {
            guard amount <= balance else {
                throw BankError.insufficientFunds
            }
            balance -= amount
        }
    }
    
    class SavingsAccount: BankAccount {
        override func withdraw(amount: Decimal) throws {
            guard amount <= balance, balance - amount >= 100 else {
                throw BankError.minimumBalanceRequired
            }
            balance -= amount
        }
    }
    • SavingsAccountBankAccount의 하위 클래스로, BankAccount가 사용되는 모든 곳에서 SavingsAccount로 대체할 수 있습니다.
    • SavingsAccountwithdraw 메서드는 BankAccount의 동작을 유지하면서 추가적인 제약 조건(최소 잔액 유지)을 둡니다.
    • 이는 하위 클래스가 상위 클래스의 동작을 위반하지 않으면서 기능을 확장할 수 있음을 보여줍니다.

     

    인터페이스 분리 원칙 (ISP)

    protocol Depositable {
        func deposit(amount: Decimal)
    }
    
    protocol Withdrawable {
        func withdraw(amount: Decimal) throws
    }
    
    protocol InterestEarning {
        func addInterest()
    }
    
    class CheckingAccount: Depositable, Withdrawable {
        func deposit(amount: Decimal) { /* ... */ }
        func withdraw(amount: Decimal) throws { /* ... */ }
    }
    
    class SavingsAccount: Depositable, Withdrawable, InterestEarning {
        func deposit(amount: Decimal) { /* ... */ }
        func withdraw(amount: Decimal) throws { /* ... */ }
        func addInterest() { /* ... */ }
    }
    • 큰 인터페이스 대신 작고 특정한 인터페이스들(Depositable, Withdrawable, InterestEarning)로 분리했습니다.
    • 각 계좌 유형은 필요한 인터페이스만 구현합니다. 예를 들어, CheckingAccount는 이자 기능이 필요 없으므로 InterestEarning을 구현하지 않습니다.
    • 이는 클라이언트가 자신이 필요로 하는 메서드만 구현하도록 하여, 불필요한 의존성을 줄입니다.

     

    의존관계 역전 원칙 (DIP)

    protocol DataStore {
        func saveAccount(_ account: Account)
        func getAccount(id: String) -> Account?
    }
    
    class DatabaseDataStore: DataStore {
        func saveAccount(_ account: Account) { /* 데이터베이스에 저장 */ }
        func getAccount(id: String) -> Account? { /* 데이터베이스에서 조회 */ }
    }
    
    class AccountService {
        private let dataStore: DataStore
    
        init(dataStore: DataStore) {
            self.dataStore = dataStore
        }
    
        func createAccount(for user: User) -> Account {
            let account = Account(user: user)
            dataStore.saveAccount(account)
            return account
        }
    }

    이 코드는 DIP를 설명합니다:

    • AccountService는 구체적인 DatabaseDataStore가 아닌 추상화된 DataStore 프로토콜에 의존합니다.
    • 이를 통해 고수준 모듈(AccountService)이 저수준 모듈(DatabaseDataStore)에 직접 의존하지 않고, 둘 다 추상화(DataStore 프로토콜)에 의존하게 됩니다.
    • 이는 의존성을 역전시켜 모듈 간 결합도를 낮추고, 테스트와 확장성을 향상시킵니다. 예를 들어, 데이터 저장 방식을 변경하고자 할 때 AccountService를 수정할 필요 없이 새로운 DataStore 구현체를 만들어 주입하면 됩니다.

    의존성 역전이란?

    1. 전통적인 의존성 구조: 전통적으로 고수준 모듈이 저수준 모듈에 직접 의존합니다.
    2. 고수준 모듈(AccountService) → 저수준 모듈(DatabaseDataStore)

    이 경우, AccountServiceDatabaseDataStore의 구체적인 구현에 직접 의존합니다.

    1. DIP를 적용한 의존성 구조:
    2. 고수준 모듈(AccountService) → 추상화(DataStore 프로토콜) ← 저수준 모듈(DatabaseDataStore)

    이 구조에서는:

    • 고수준 모듈이 저수준 모듈에 직접 의존하지 않습니다.
    • 대신, 둘 다 추상화(인터페이스 또는 프로토콜)에 의존합니다.
    • 저수준 모듈이 추상화를 구현하는 형태가 됩니다.

    "의존성 역전"이라고 부르는 이유:

    1. 의존성 방향의 변화:
      • 전통적으로는 고수준 모듈이 저수준 모듈에 의존했습니다.
      • DIP에서는 저수준 모듈이 추상화를 통해 고수준 모듈의 요구사항에 맞춰집니다.
    2. 제어의 역전:
      • 저수준 모듈이 고수준 모듈의 추상화된 인터페이스를 구현하게 됩니다.
      • 이는 고수준 모듈이 저수준 모듈의 동작을 "제어"하는 형태가 됩니다.
    3. 의존성 주입:
      • 구체적인 구현체(저수준 모듈)가 고수준 모듈에 주입됩니다.
      • 이는 전통적인 의존성 구조의 "역방향"입니다.
    4. 관심사의 분리:
      • 고수준 모듈은 추상화에만 관심을 가집니다.
      • 저수준 모듈의 구체적인 구현 세부사항은 고수준 모듈과 분리됩니다.

    이러한 "역전" 구조를 통해 시스템은 더 유연하고 확장 가능하며 테스트하기 쉬워집니다. 고수준 정책(비즈니스 로직)이 저수준 세부사항(데이터 접근, UI 등)에 의해 오염되지 않고 독립적으로 존재할 수 있게 되는 것입니다.
    따라서 "의존성 역전"이라는 표현은 전통적인 의존성 흐름을 뒤집고, 더 유연하고 확장 가능한 구조를 만든다는 의미를 내포하고 있습니다.

    'Apple🍎 > Architecture pattern' 카테고리의 다른 글

    예제코드 개요 및 구성 파악하기  (0) 2023.12.06

    댓글

Designed by Tistory.