Swift/개념 & 응용

[Swift] SOLID 원칙 with Swift

유정주 2023. 3. 29. 15:05
반응형

서론

최근에 디자인 패턴 글과 ViewController, ViewModel의 역할 분리 글을 작성했습니다.

해당 글들에서는 배경지식 없이도 이해가 되도록 표현을 풀어서 쓰다보니 정확성이 떨어진다고 느껴졌습니다.

이도저도 아니게 된 것 같아 보다 정확한 내용 전달을 위해

이후 관련 글에서는 "단일책임 원칙", "개방폐쇄 원칙" 등의 단어로 표현을 명확하게 하려고 합니다.

 

그런 의미로 이번 포스팅에서 SOLID 원칙에 대해 정리해보고,

Swift와 iOS에서는 어떻게 적용할 수 있는지 알아보겠습니다.

 

SOLID란

SOLID란 OOP(객체 지향 프로그래밍) 및 설계의 기본 원칙으로,

  1. 단일 책임 원칙 (Single Responsibilty Principle)
  2. 개방 폐쇄 원칙 (Open-Closed Principle)
  3. 리스코프 치환 원칙 (Liskov Substitution Principle)
  4. 인터페이스 분리 원칙 (Interface Segregation Principle)
  5. 의존관계 역전 원칙 (Dependency Inversion Principle)

위 5가지로 구성됩니다.

 

OOP와 SOLID 원칙을 잘 따르면 코드의 유연성, 확장성, 유지보수, 재사용성에 이점이 있으며,

아래 세 가지 문제를 해결할 수 있습니다.

  • Fragility: 작은 변화가 버그를 일으킬 수 있는데, 테스트가 용이하지 않아 미리 파악하기 어려운 것
  • Immobility: 재사용성의 저하. 불필요하게 엮인 의존성 때문에 재사용성이 낮아짐
  • Ridgidity: 여러 곳에 영향을 줘서 작은 변화에도 많은 곳을 변경해야 함

따라서 SOLID 원칙은 꾸준히 상기해야할 지식입니다.

 

이제 5가지 원칙을 하나씩 살펴보도록 합시다.

Swift의 프로토콜을 적절히 사용하면 간단하게 SOLID를 따를 수 있습니다.

아래에서 살펴볼 예시들도 프로토콜을 아주 적절히 사용하였으므로 이점에 주목하면 많은 도움이 될 듯 하네요.

(해당 포스팅에서 다루는 예시들은 https://github.com/ochococo/OOD-Principles-In-Swift에서 확인할 수 있습니다.)

 

단일 책임 원칙 (SRP: The Single Responsibility Principle)

단일 책임 원칙이란 하나의 객체는 하나의 책임을 가져야 한다는 원칙입니다.

책임이 분리되어야 객체가 변경이 일어날 때 다른 객체에 영향을 최소한으로 줄 수 있습니다.

단일 책임 원칙을 제대로 지키지 않으면 어떤 패턴을 도입하려고 해도 잘 안될 가능성이 높다고 하네요.

이런 이유로 단일 책임 원칙을 모튼 패턴의 시작이라고도 한다고 합니다.

 

iOS에서 단일 책임 원칙을 위반한 객체는 아주 가까이 있는데요.

바로 ViewController입니다.

Massive View Controller 현상을 줄이기 위해 다양한 패턴들이 시도되고 있고,

단일 책임 원칙을 지키기 위해 ViewController에서 책임을 분리한다는 공통점이 있습니다.

대표적인 예시로 MVVM은 ViewController에서 비즈니스 로직을 분리하고, Coordinator 패턴은 Flow 로직을 분리하죠.

 

예시

단일 책임 원칙을 잘 지킨 예시를 보겠습니다.

아래 예시는 문을 열고 닫는 행위를 코드로 표현했습니다.

protocol Openable {
    mutating func open()
}

protocol Closeable {
    mutating func close()
}

단일 책임 원칙을 따르기 위해 문 객체, 여는 객체, 닫는 객체를 분리하였습니다.

Openable과 Closeable 프로토콜로 필수 요구사항을 정의하여 객체가 어떤 동작을 하는지 명확히 표현했습니다.

 

// I'm the door. I have an encapsulated state and you can change it using methods.
struct PodBayDoor: Openable, Closeable {

    private enum State {
        case open
        case closed
    }

    private var state: State = .closed

    mutating func open() {
        state = .open
    }

    mutating func close() {
        state = .closed
    }
}

// I'm only responsible for opening, no idea what's inside or how to close.
final class DoorOpener {
    private var door: Openable

    init(door: Openable) {
        self.door = door
    }

    func execute() {
        door.open()
    }
}

// I'm only responsible for closing, no idea what's inside or how to open.
final class DoorCloser {
    private var door: Closeable

    init(door: Closeable) {
        self.door = door
    }

    func execute() {
        door.close()
    }
}

Door는 Openable과 Closeable을 모두 채택하여 열고 닫을 수 있는 객체라는 것을 나타냈습니다.

open, close 메서드에서는 문의 상태를 변경하네요.

이 메서드들은 Opener와 Closer에 의해 호출됩니다.

 

단일 책임 원칙을 위해 Opener 객체와 Closer 객체가 따로 존재합니다.

Opener 객체는 Openable 프로퍼티를 가지고 있고 Opener의 execute를 통해 open 메서드를 호출합니다.

마찬가지로 Closer 객체는 Closeable 프로퍼티를 가지고 close 메서드를 호출하고 있습니다.

 

let door = PodBayDoor()

// ⚠️ Only the `DoorOpener` is responsible for opening the door.
let doorOpener = DoorOpener(door: door)
doorOpener.execute()

// ⚠️ If another operation should be made upon closing the door,
// like switching on the alarm, you don't have to change the `DoorOpener` class.
let doorCloser = DoorCloser(door: door)
doorCloser.execute()

이제 인스턴스를 생성하여 각자의 동작을 수행합니다.

만약 DoorCloser에 변화가 있더라도 (문이 닫힐 때 소리를 울린다거나)

Door나 DoorOpener에는 전혀 영향을 주지 않습니다.

 

또한, PodBayDoor 클래스가 아니더라도 Openable과 Closeable을 채택하고 준수한다면

어떤 객체에도 재사용을 할 수 있습니다.

예를 들면, Opener로는 병뚜껑을 딸 수 있겠죠.

 

단일 책임 원칙을 잘 지키는 법

단일 책임 원칙은 메서드, 클래스의 크기를 줄이면 됩니다.

크기를 줄이기 위해 고민하면서 과연 이 위치에 꼭 필요한 코드인지 검토하게 됩니다.

추상화 레벨에 대한 고민도 필요합니다.

"물을 끓인다"라는 단어에는 "정수기를 튼다", "물을 담는다", "전기 포트 전원을 킨다" 등의 단계가 내포되어 있습니다.

이런 고민 과정에서 단일 책임 원칙과 개발 편의성 사이에서 적절히 타협해야 할 것입니다.

 

개방 폐쇄 원칙 (OCP: Open-Closed Principle)

개방 폐쇄 원칙은 확장에는 열려 있지만 변경에는 닫혀 있어야 한다는 원칙입니다.

열려 있다는 의미는 확장(기능 추가 등)을 할 수 있어야 한다는 것이고,

닫혀 있어야 한다는 것은 확장을 했을 때 다른 부분에 영향을 주지 않아야 한다는 것을 의미합니다.

 

이러한 특성들은 추상화를 통해 이루어지고, Swift에서 추상화는 프로토콜을 이용해 가능합니다.

개방 폐쇄 원칙을 잘 지킨 좋은 예시를 봅시다.

 

예시

protocol Shooting {
    func shoot() -> String
}

// I'm a laser beam. I can shoot.
final class LaserBeam: Shooting {
    func shoot() -> String {
        return "Ziiiiiip!"
    }
}

// I have weapons and trust me I can fire them all at once. Boom! Boom! Boom!
final class WeaponsComposite {

    let weapons: [Shooting]

    init(weapons: [Shooting]) {
        self.weapons = weapons
    }

    func shoot() -> [String] {
        return weapons.map { $0.shoot() }
    }
}

let laser = LaserBeam()
var weapons = WeaponsComposite(weapons: [laser])

weapons.shoot()

 

처음에는 레이저빔만 구현이 되어 있습니다.

레이저빔은 Shooting 프로토콜을 준수하였고 shoot을 하면 Ziiiiip 소리를 냅니다.

WeaponsComposite은 무기들을 저장해놨다가 모든 무기를 한 번에 shoot 합니다.

 

// I'm a rocket launcher. I can shoot a rocket.
// ⚠️ To add rocket launcher support I don't need to change anything in existing classes.
final class RocketLauncher: Shooting {
    func shoot() -> String {
        return "Whoosh!"
    }
}

let rocket = RocketLauncher()

weapons = WeaponsComposite(weapons: [laser, rocket])
weapons.shoot()

이후에 미사일 무기가 추가되었습니다.

코드에 미사일 무기가 추가 되었지만 레이저빔이나 WeaponsComposite의 코드는 전혀 바뀌지 않았습니다.

 

만약 WeaponsComposite이 Shooting 타입이 아니라 LaserBeam 타입을 출력했다면

RocketLauncher 클래스가 추가되었을 때 WeaponsComposite의 코드가 수정되어야 했을 것입니다.

 

위 예시처럼 개방 폐쇄 원칙의 핵심은 확장이 일어나도 다른 코드에 주는 영향을 최소화하는 것이며,

프로토콜을 이용해 적절한 레벨의 추상화를 이루는 것이 중요합니다.

 

개방 폐쇄 원칙을 잘 지키는 법

개방 폐쇄 원칙은 if/switch를 줄이는 방식으로 연습할 수 있습니다.

 

로켓과 레이저빔을 이용해 예시를 들어보면

if 로켓 {
    print("슈우웅")
} else if 레이저빔 {
    print("지이잉")
}

이런 코드나

switch 무기 {
case .로켓:
    print("슈우웅")
case .레이저빔:
    print("지이잉")
default:
    print("불발")
}

이런 코드보다는

 

위에서 본 예시처럼

Protocol 쏠수있어 {
    func 쏜다()
}

"쏠 수 있는 것"로 추상화한다면 개방 폐쇄 원칙을 잘 지킬 수 있을 것입니다.

 

리스코프 치환 원칙 (LSP: The Liskov Substitution Principle)

리스코츠 치환 원칙은 부모 클래스를 자식 클래스의 인스턴스로 바꿔도

프로그램 동작에 영향을 없어야 한다는 원칙입니다.

 

리스코프 치환 원칙 예시의 국룰은 직사각형과 정사각형입니다.

정사각형은 직사각형에 포함됩니다.

그래서 직사각형이 부모 클래스이고 정사각형이 자식 클래스가 됩니다.

 

하지만 정사각형이 필요한 곳에 직사각형이 들어가면 제대로 동작을 안 할 수 있습니다.

정사각형은 직사각형의 조건 + 가로/세로가 같아야 하기 때문입니다.

즉, 부모 클래스의 동작을 제한해야 한다는 점에서 리스코프 치환 원칙을 위반했다고 볼 수 있습니다.

 

이를 해결하는 간단한 방법은 자식이 부모의 동작을 제한하지 않으면 됩니다.

무슨 말인지 예시를 통해 알아봅시다. (이번 예시는 좀 어려운듯 ㅎ;)

 

예시

let requestKey: String = "NSURLRequestKey"

// I'm a NSError subclass. I provide additional functionality but don't mess with original ones.
class RequestError: NSError {

    var request: NSURLRequest? {
        return self.userInfo[requestKey] as? NSURLRequest
    }
}

 

RequestError는 NSError의 자식 클래스입니다.

리스코프 치환 원칙을 잘 지킨다면

NSError 인스턴스를 RequestErorr 인스턴스로 바꿔도 잘 동작해야 합니다.

 

// I fail to fetch data and will return RequestError.
func fetchData(request: NSURLRequest) -> (data: NSData?, error: RequestError?) {

    let userInfo: [String:Any] = [requestKey : request]

    return (nil, RequestError(domain:"DOMAIN", code:0, userInfo: userInfo))
}

// I don't know what RequestError is and will fail and return a NSError.
func willReturnObjectOrError() -> (object: AnyObject?, error: NSError?) {

    let request = NSURLRequest()
    let result = fetchData(request: request)

    return (result.data, result.error)
}

fetchData 메서드를 보면 NSError가 아니라 ReqeustError를 반환하고 있는데요.

fetchData 메서드를 호출하는 willReturnObjectorError 메서드에서는 NSError를 반환하고 있습니다.

이제 willReturnObjectorError 메서드를 호출하는 코드를 보겠습니다.

let result = willReturnObjectOrError()

// Ok. This is a perfect NSError instance from my perspective.
let error: Int? = result.error?.code

// ⚠️ But hey! What's that? It's also a RequestError! Nice!
if let requestError = result.error as? RequestError {
    requestError.request
}

willReturnObjectorError 호출 결과를 result로 받아 에러를 확인합니다.

NSError 타입인 error?.code는 당연히 잘 동작합니다.

NSError의 자식 클래스인 RequestError로 타입 캐스팅을 해도 제대로 동작하는 것을 확인할 수 있습니다.

 

즉, 리스코프 치환 원칙이 잘 지켜졌습니다!

 

반대로 iOS에서 리스코프 치환 원칙을 어긴 사례를 보겠습니다.

required init?(coder aDecoder: NSCoder) {
  fatalError("init(coder:) has not been implemented")
}

위 코드는 매우 익숙할 것입니다.

UIView를 상속한 클래스에서 반드시 작성해야 하는 init이죠.

이렇게 오버라이드를 통해 자식 클래스에서 부모의 함수를 퇴화시키는 것도 리스코프 치환 원칙 위반입니다.

 

리스코프 치환 원칙은 엄격하게 지키는 것이 힘들다고 합니다.

하지만 많은 곳에서 어긴다면 상속의 의미가 없어지고 개방 폐쇄 원칙도 지킬 수 없게 됩니다.

예를 들면 위에서 프로토콜을 이용해 개방 폐쇄 원칙을 지키도록 만들었는데,

프로토콜을 채택한 곳에서 메서드를 퇴화시켜버리면 프로토콜 기준으로 작성된 코드들에 악영향을 주겠죠.

 

이처럼 리스코프 치환 원칙은 적절한 타협이 중요합니다.

너무 엄격하게 지키려고 하면 생산성이 비효율적이고 너무 해치려고 하면 안정성이 낮아지기 때문입니다.

따라서 리스코프 치환 원칙 위반인지 아닌지를 숙지하며 적절히 사용해야 합니다.

 

인터페이스 분리 원칙 (ISP: The Interface Segregation Principle)

인터페이스 분리 원칙은 사용하지 않는 인터페이스에 의존하면 안 된다는 원칙입니다.

즉, 불필요한 인터페이스를 포함시키지 않을수록 인터페이스 분리 원칙을 잘 지킨 것으로 볼 수 있습니다.

protocol GestureProtocol {
    func didTap()
    func didDoubleClick()
    func didLongClick()
}

해당 프로토콜은 탭, 더블 클릭, 롱클릭을 모두 가지고 있습니다.

그래서 탭만 필요한 경우에도 더블 클릭과 롱클릭 메서드를 구현해야 하죠.

이처럼 불필요한 인터페이스를 가지고 있는 경우 인터페이스 분리 원칙 위반입니다.

 

이제 인터페이스 분리 원칙을 잘 지킨 예시를 봅시다.

 

예시

아래 예시는 우주선과 우주정거장입니다.

// I have a landing site.
protocol LandingSiteHaving {
    var landingSite: String { get }
}

// I can land on LandingSiteHaving objects.
protocol Landing {
    func land(on: LandingSiteHaving) -> String
}

// I have payload.
protocol PayloadHaving {
    var payload: String { get }
}

// I can fetch payload from vehicle (ex. via Canadarm).

protocol PayloadFetching {
    func fetchPayload(vehicle: PayloadHaving) -> String
}

착륙 지점, 착륙 동작을 분리하고

탑재물와 탑재물을 가져오는 동작을 분리했습니다. (의역)

 

이제 우주정거장과 우주선 클래스를 살펴보겠습니다.

final class InternationalSpaceStation: PayloadFetching {

    // ⚠ Space station has no idea about landing capabilities of SpaceXCRS8.
    func fetchPayload(vehicle: PayloadHaving) -> String {
        return "Deployed \(vehicle.payload) at April 10, 2016, 11:23 UTC"
    }
}

// I'm a barge - I have landing site (well, you get the idea).
final class OfCourseIStillLoveYouBarge: LandingSiteHaving {
    let landingSite = "a barge on the Atlantic Ocean"
}

// I have payload and can land on things having landing site.
// I'm a very limited Space Vehicle, I know.
final class SpaceXCRS8: Landing, PayloadHaving {

    let payload = "BEAM and some Cube Sats"

    // ⚠️ CRS8 knows only about the landing site information.
    func land(on: LandingSiteHaving) -> String {
        return "Landed on \(on.landingSite) at April 8, 2016 20:52 UTC"
    }
}

let crs8 = SpaceXCRS8()
let barge = OfCourseIStillLoveYouBarge()
let spaceStation = InternationalSpaceStation()

spaceStation.fetchPayload(vehicle: crs8)
crs8.land(on: barge)

우주정거장에서는 탑재물을 가져올 수만 있으면 됩니다.

따라서 fetchPayload 메서드에서는 PayloadHaving 타입을 파라미터로 받습니다.

우주정거장은 SpaceSCRS8 우주선의 Lading 여부를 몰라도 본인의 동작을 제대로 수행할 수 있습니다.

다른 우주선들도 마찬가지로 자신이 필요한 동작만 채택하여 불필요한 구현이 없도록 구현했습니다.

 

인터페이스 분리 원칙을 아주 잘 지킨 예시였습니다.

 

의존관계 역전 원칙 (DIP: Dependency Inversion Principle)

SOLID의 마지막 원칙이네요.

의존관계 역전 원칙은 구체 개념이 아닌 추상 개념에 의존해야 한다는 원칙입니다.

또한 추상 개념은 구체 개념에 의존하면 안 되고, 구체 개념이 추상 개념에 의존해야 합니다.

 

저번에 작성했던 Protocol을 이용한 ViewModel 의존성 주입에서도 의존관계 역전 원칙을 지키며 구현했습니다.

구체적인 ViewModel 클래스 타입이 아니라 ViewModelable 프로토콜 타입으로 선언했기 때문입니다.

 

예시

protocol TimeTraveling {
    func travelInTime(time: TimeInterval) -> String
}

final class 타임머신A: TimeTraveling {
	func travelInTime(time: TimeInterval) -> String {
		return "Used Flux Capacitor and travelled in time by: \(time)s"
	}
}

final class 브라운박사 {
	private let timeMachine: TimeTraveling

    // ⚠️ Emmet Brown is given a `TimeTraveling` device, not the concrete class `DeLorean`!
	init(timeMachine: TimeTraveling) {
		self.timeMachine = timeMachine
	}

	func travelInTime(time: TimeInterval) -> String {
		return timeMachine.travelInTime(time: time)
	}
}

let timeMachine = 타임머신A()

let mastermind = 브라운박사(timeMachine: timeMachine)
mastermind.travelInTime(time: -3600 * 8760)

브라운 박사는 타임머신A로 시간 여행을 했습니다.

하지만 추후 확장되어 타임머신B로도 시간 여행을 할 수 있고, 타임머신C로도 시간 여행을 할 수 있습니다.

만약 브라운박사 클래스의 timeMachine 변수가 타임머신A였다면 B와 C로는 시간 여행이 불가능합니다.

"시간 여행이 가능한 것"을 추상화한 TimeTraveling 프로토콜을 이용했기 때문에

이를 채택하는 타임머신A, 타임머신B, 타임머신C 모두 사용할 수 있는 것이죠.

 

이처럼 의존관계 역전 원칙은 재사용성에 큰 기여를 하는 원칙입니다.

 

이상으로 OOP의 SOLID 원칙 포스팅을 마치겠습니다.

감사합니다!

 

참고

https://soojin.ro/blog/solid-principles-in-swift

https://github.com/ochococo/OOD-Principles-In-Swift

https://leechamin.tistory.com/518#--%--%EC%-D%--%EC%A-%B-%EA%B-%--%EA%B-%--%--%EC%--%AD%EC%A-%--%--%EC%-B%--%EC%B-%--%---DIP%-A%--Dependency%--Inversion%--Principle-


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형