본문으로 바로가기

SOLID에 대해서 알아보자

category 카테고리 없음 2024. 6. 27. 06:53

단일 책임 원칙 (Single Responsibility Principle)

클래스는 한 가지 이유로 변경되어야 합니다

개념

각각의 클래스가 프로그램이 제공하는 기능의 단 하나의 부분만 책임 지도록 하세요

이 원칙의 목적은 복잡성을 줄이는 것입니다
프로그램이 성장하고 병경되면서 클래스들은 너무 커질것입니다
그렇게 될 경우 코드 탐색은 매우 느려질것이고, 클래스나 프로그램의 전체를 훑어봐야 특정 코드를 찾을 수 있게 되겠죠

또한 클래스가 너무 많은 작업을 수행 할 경우, 그 중 하나가 변경될 때마다 클래스를 변경해야 합니다. 그럴경우 변경할 생각이 없던 클래스의 다른 부분이 망가질 위험이 있습니다

실생활 예시

당신은 세계의 다양한 요리 메뉴가 있는 음식점을 오픈했습니다
당신은 중식, 양식, 일식을 모두 다 잘하는 요리사 한명을 고용할지 각각의 요리사 세명을 고용할지 고민중입니다
결국 당신의 선택은 요리사 세명을 고용하는 것이였습니다
요리사한테 문제가 생겼을경우 한명일때보다는 세명일때 더욱 유연하게 대처할 수 있기 때문입니다


예를들면 양식 요리사한테 문제가 생겼을경우 일단 중식, 일식만 판매한다던가 양식 요리사만 교체한다든가 할 수 있겠죠
하지만 요리사가 한명일경우에는 중식, 양식, 일식을 모두 다 할 수 있는 요리사를 새로 구하기 전까지는 장사를 못할겁니다

코드 예시

잘못된 예시

class Handler {
    func handle() {
        let data = fetchData()
        let newData = createNewData(data)
        uploadData(newData)
    }
    
    func fetchData() -> Data {
        // fetchData logic
        return Data()
    }
    
    func processingData(_ data: Data) -> Data {
        // processing logic
        return Data()
    }
    
    func uploadData(_ data: Data) {
        // uploadDatalogic
    }
}

해당 클래스는 3가지 일을 한다
1. data fetch
2. fetch한 데이터로 새로운 데이터 만들기
3. 만든 새로운 데이터를 업로드

이 책임들을 각각 작은 객체로 분리하자

옳게된 예시

class Handler {
    let apiHandler: APIHandler
    let processingHandler: ProcessingHandler
    
    init(apiHandler: APIHandler, processingHandler: ProcessingHandler) {
        self.apiHandler = apiHandler
        self.processingHandler = processingHandler
    }
    
    func fetchAndUploadProcessingData() {
        let data = apiHandler.fetchData()
        let processingData = processingHandler.processingData(data)
        apiHandler.uploadData(processingData)
    }
    
}

class APIHandler {
    func fetchData() -> Data {
        // fetch logic
        return Data()
    }
    
    func uploadData(_ data: Data) {
        // upload logic
    }
}

class ProcessingHandler {
    func processingData(_ data: Data) -> Data {
        // processing logic
        return Data()
    }
}

모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화 해야 한다

  • 클래스의 수정 이유는 단 하나여야 한다
  • 하나의 클래스는 하나의 책임을 가져야 한다
  • 하나의 책임이 여러개의 클래스에 나뉘어 있어서도 안된다

 

 

개방/폐쇄 원칙 (Open/Closed Principle)

클래스는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 합니다.

개념

기존의 코드를 변경하지 않으면서 기능을 추가 할 수 있어야 합니다

이 원칙의 목적은 새로운 기능을 구현할 때 기존 코드가 깨지지 않도록 하는 것입니다
클래스는 확장할 수 있을때, 자식클래스를 생성할 수 있을때, 기초 행동을 재정의 하고 새로운 메서드 및 필드를 추가하는 등 원하는 모든 작업을 수행할 수 있을때 Open(개방)되어있다고 할 수 있습니다

동시에 클래스가 다른 클래스에 의해 사용될 준비가 100% 되었다면 해등 클래스는 Closed(폐쇄)되었다고 할 수 있습니다
이때 사용될 준비가 되었다는 것은 클래스의 인터페이스가 명확하게 정의되어 있으며 미래에 변경되지 않는다는 뜻입니다

클래스가 이미 개발, 테스트, 검토의 단계를 마쳤고 이미 다른곳에서 사용되는 경우 해당 클래스의 변경은 위험합니다
클래스의 변경이 필요한 경우에는 해당 클래스를 직접 변경하는것이 아닌 자식클래스를 만든 후 원하는 부분들을 재정의해야합니다
그렇게 변하는 기능 및 새로운 기능을 추가하면서도 원래 클래스를 손상하지 않게 됩니다

단 클래스에 버그가 있을 경우 그 문제를 수정하려고 자식클래스를 만들지 말고 그냥 가서 수정하세요
자식클래스는 부모 클래스의 문제들에 대하여 책임을 져서는 안됩니다.

코드 예시

잘못된 예시

class Handler {
    func handle() {
        let data = fetchData()
        let newData = createNewData(data)
        uploadData(newData)
    }
    
    func fetchData() -> Data {
        // fetchData logic
        return Data()
    }
    
    func processingData(_ data: Data) -> Data {
        // processing logic
        return Data()
    }
    
    func uploadData(_ data: Data) {
        // uploadDatalogic
    }
}

class AClass {
	let handler = Handler()
    
    func handle() {
    	handler.handle()
    }
}

Handler 객체는 원격 스토리지에서 파일을 fetch 해와서 사용한다
Handler 객체를 BClass 라는 새로운 클래스에서도 사용할려고 한다
BClass 에서는 파일을 원격 스토리지가 아닌 로컬스토리지에서 fetch 해줄려 한다

이를 해결하기 위해 Handler 클래스를 수정해줬다
하지만 이는 변경에 닫혀있어야 하는 개방/폐쇄 원칙에 어긋난다

옳게된 예시

protocol Handler {
	func handle()
}

class RemoteHandler {
	func handle() {
        let data = fetchData()
        let newData = createNewData(data)
        uploadData(newData)
    }
    
    func fetchData() -> Data {
        // fetchData logic
        return Data()
    }
    
    func processingData(_ data: Data) -> Data {
        // processing logic
        return Data()
    }
    
    func uploadData(_ data: Data) {
        // uploadDatalogic
    }
}

class LocalHandler {
	func handle() {
        let data = fetchData()
        let newData = createNewData(data)
        uploadData(newData)
    }
    
    func fetchData() -> Data {
        // fetchData logic
        return Data()
    }
    
    func processingData(_ data: Data) -> Data {
        // processing logic
        return Data()
    }
    
    func uploadData(_ data: Data) {
        // uploadDatalogic
    }
}

class AnotherHandler: Handler {
	override func fetchData(_ flag: Bool) -> Data {
		// fetchData logic
		return Data()
	}
}

class BClass {
	let handler: Handler
    
    init(handler: Handler) {
    	self.handler = handler
	}
    
    func handle() {
    	handler.handle()
    }
}

class BClass {
	let handler: Handler
    
	init(handler: Handler) {
    	self.handler = handler
	}
    
    func handle() {
    	handler.handle()
    }
}

Handler 라는 프로토콜이 있고 실제 메서드 구현은 해당 프로토콜을 채택한 구상객체에서 하게 된다
이제 새로운 handler 메서드를 구현할경우 이미 구현된 클래스를 변경할 필요 없이 Handler 프로토콜에서 새로운 클래스를 파생할 수 있다

잘못된 예시

struct Dog {
	let name: String
	let age: Int    
}

class Zoo {
    var animals: [Dog] = []
    
    func addAnimal(_ animal: Dog) {
    	animals.append(dog)
	}
    
    func hello() {
    	print("멍멍")
    }
}

동물원 객체가 있다
해당 동물원은 예산이 없어서 강아지밖에 없다
따라서 현재 코드는 기능에 문제가 없다

하지만 나중에 동물원이 돈을 많이 벌어서 새로운 동물들을 들여온다고 생각해보자

현재 Zoo 객체는 강아지 타입밖에 받아들일 수 없어 필연적으로 Zoo 객체를 수정해야 한다

옳게된 예시

protocol Animal {
	var name: String { get }
    var age: Int { get set }
    
    func hello()
}

struct Dog: Animal {
	var name: String
    var age: Int
    
    func hello() {
    	print("멍멍")
    }
}

struct Tiger: Animal {
	var name: String
    var age: Int
    
    func hello() {
    	print("어흥")
    }
}

class Zoo {
	var animals: [Animal] = []
    
    func add(_ animal: Animal) {
    	animals.append(animal)
    }
    
    func hello(_ animal: Animal) {
    	animal.hello()
    }
}

Animal 프로토콜을 생성하고 실제 동물 구상 객체들이 Animal 프로토콜을 채택하게 해 주었다
Zoo 클래스는 동물 구상 객체에 의존하지 않고 Animal 프로토콜을 의존해주도록 했다
이제 동물원에 새로운 동물을 들여오더라 하더라도 Animal 이라는 프로토콜을 채택하고 있으면 Zoo 객체에서 받아들일 수 있기때문에 Zoo 객체를 수정해줄 필요가 없어진다


확장에 열려있다

  • 모듈의 확장성을 보장한다
  • 새로운 변경사항이 발생할 경우 유연하게 코드를 추가함으로써 애플리케이션의 기능을 큰 힘을 들이지 않고 확장할 수 있다

변경에 닫혀있다

  • 객체를 직접적으로 수정하는것은 제한해야 한다
  • 새로운 변경 사항이 발생했을때 객체를 직접 수정해야 한다면 새로운 변경사항에 대해 유연하게 대응할 수 없는 애플리케이션이다
  • 따라서 객체를 직접 수정하지 않고도 변경사항을 적용할 수 있도록 설계해야 한다

 

 

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

자식 클래스는 부모 클래스의 행동과 계속 호환되어아 햡니다

개념

서브타입은 언제나 기반타입으로 교체 할 수 있어야 한다
즉, 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동해야 한다

리스코프 치환 원칙같은 경우 여러 방식으로 해석 가능한 다른 원칙들과 달리 형식적인 요구사항이 있다

1. 자식 클래스의 메서드의 매개변수 유형들은 부모 클래스의 메서드의 매개변수 유형들보다 더 추상적이거나 추상화 수준이 같아야 한다. 

class Animal { }
class Dog: Animal { }
class Bulldog: Dog { }

class ParentClass {
	func feed(_ a: Dog) { }
}	
    
class FirstChildClass: ParentClass {
	func feed(_ a: Animal) { }
}
    
class SecondChildClass: ParentClass {
	func feed(_ a: Bulldog) { }
}

부모클래스는 Dog 타입에 먹이를 주는 함수가 있다
첫번째 자식클래스에서는 매개변수로 Dog 의 상위클래스인 Animal을 받는다
두번째 자식클래스에서는 매개변수로 Dog 의 하위클래스인 Bulldog을 받는다
만약 부모클래스의 객체 대신 자식클래스의 객체를 전달한다고 생각해보자

첫번째 자식클래스는 모든 동물들에게 먹이를 줄 수 있으므로 클라인트가 전달하는 모든 Dog 에게 먹이를 줄 수 있다

반면 두번째 자식클래스에서는 매개변수를 불독으로만 제한했다
따라서 해당 메서드는 불독 이외의 다른 종의 Dog에는 먹이를 주지 못하며 부모클래스와 호환되지 못한다

2. 자식클래스의 메서드의 반환 유형은 부모클래스의 메서드의 반환 유형의 하위유형이거나 일치해야 한다 

class Animal { }
class Dog { }
class Booldog { }
  
class ParentClass {
	func buyDog() -> Dog {
		return Dog()
	}
}	
    
class FirstChildClass: ParentClass {
	func buyDog() -> Animal {
		return Animal()
	}
}
    
class SecondChildClass: ParentClass {
	func buyDog() -> Booldog {
		return Booldog()
	}
}

부모클래스에는 buyDog() 이라는 메서드가 있고 Dog을 반환한다
첫번째 자식클래스는 Dog의 하위유형인 Animal 을 반환하고 두번재 자식클래스는 상위유형인 Bulldog 을 반환한다

첫번째 자식클래스는 어떠한 동물이든 다 반환한다
Dog을 위해 설계된 구조에 알 수 없는 다른 동물을 받기때문에 문제가 된다

반면 두번째 자식클래스에서는 Booldog을 반환한다
이는 Dog이니까 아무문제 없다

3. 자식클래스의 메서드는 부모 클래스에서 던지지 않을거라 예상되는 예외 유형(에러)를 던져서는 안됩니다

즉 예외 유형들은 부모 메서드가 이미 던질 수 있는 예외 유형들의 하위유형 혹은 일치해야 합니다

enum SomeError: Error {
	case someError
}

class ParentClass {
	func doSomething(_ a: String) { }
}

class ChildClass {
	func doSomething(_ a: String) throws {
		if a > 10 {
			throw SomeError.someError
		}
	}
}

예상치 못한 예외는 앱 전체를 충돌시킬 수 있다

대부분의 현대 프로그래밍 언어들은 위 규칙들이 언어에 내장되어 있어서 해당 규칙들을 위반하는 프로그램은 컴파일 할 수 없다

이제부터 설명하는 규칙들은 컴파일러로 못잡는 규칙들이다

4. 자식클래스는 사전 조건들을 강화해서는 안된다 

enum SomeError: Error {
	case someError(String)
}

class ParentClass {
    func doSomething(_ a: Int) throws {
        if a < 0 {
            throw SomeError.someError("음수이면 안됩니다")
        }
    }
}

class FirstChildClass: ParentClass {
    override func doSomething(_ a: Int) throws {
        if a <= 0 {
            throw SomeError.someError("0보다 커야합니다")
        }
    }
}

부모클래스의 doSomething 함수는 파라미터로 받은 숫자가 음수이면 안된다는 조건이 있다
자식클래스에서 doSomething 함수를 재정의하면서 0이면 안된다는 조건이 추가됐다

해당 함수에 음수들이 전달될때 잘 작동하던 클라이언트 코드는 이 자식 클래스 객체와 작업하기 시작하면 문제가 생길 수 있다
부모클래스와 동일한 수준의 조건을 기대하고 사용하는 프로그램 코드에서 예상치 못한 문제가 발생할 수 있기 때문이다

5. 자식클래스는 사후 조건들을 약화해서는 안된다

enum SomeError: Error {
    case someError(String)
}

class ParentClass {
	func doSomething(_ a: Int) throws -> Int {
		if a < 0 {
		throw SomeError.someError("음수이면 안됩니다")
        }        
        return a
	}
}

class ChildClass: ParentClass {
    override func doSomething(_ a: Int) throws -> Int {
        return a
    }
}

부모클래스의 doSomething 함수는 반환할 값이 유효한 값인지 검사하고 있다
자식클래스는 doSomething 함수를 재정의하면서 해당 조건을 제거하여 조건을 약화시켰다

이 역시 음수를 반환할거라고 예상하지 못하는 클라이언트 코드에서는 오작동을 일으킬것이다

6. 부모 클래스의 불변속성들은 보존되어야 한다

불변속성이란 객체가 해당 객체로 이해되기 위해 갖추어야 하는 조건들이다
즉 부모 클래스의 데이터의 값의 조건은 자식 클래스에서도 계속 유지되어야 한다는 것이다

class ParentClass {
	var num: Int = .zero
	var _num: Int {
		get {
			return num
		}
		set {
			if newValue >= 0 {
				num = newValue
			}
		}
	}
}

class ChildClass: ParentClass {
	func doSomething(_ a: Int) {
		num = a
	}
}

부모클래스의 num 변수는 항상 0 혹은 양수만을 가질 수 있다
그러나 자식클래스의 doSomething 함수에서 아무런 조건없이 num에 값을 할당해주고 있다

그로인해 num 에 음수가 할당될 수 없다는 부모클래스의 불변속성이 깨져버렸다


A가 B를 상속받았으면 B로서도 역할을 할 수 있어야 한다




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

클라이언트들은 자신이 사용하지 않는 메서드에 의존하도록 강요되어서는 안된다

개념

인터페이스를 잘게 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것이다

클래스 상속은 하나의 부모 클래스만 가질 수 있도록 하지만 동시에 구현할 수 있는 인터페이스(프로토콜) 의 수를 제한하지는 않는다
따라서 서로 관련 없는 많은 메서드들을 하나의 인터페이스에 집어넣을 필요가 없으며 이를 더 정제된 인터페이스로 나눠야한다
필요하다면 그 전부를 단일 클래스에서 구현할 수 있다

코드 예시

잘못된 예시

protocol Gesture {
	func didTap()
    func didSwipe()
    func didScroll()
}

class Button: Gesture {
	func didTap() {
    	// tap Gesture logic
    }
    func didSwipe() {
    	// not used
    }
    func didScroll() {
    	// not used
    }
}

하나의 인터페이스(프로토콜)에 Gesture 관련 모든 메서드들이 들어있다
따라서 해당 인터페이스를 구현하는 구상클래스에서 해당 메서드가 필요하지 않더라 하더라도 구현을 해줘야 한다

옳게된 예시

protocol TapGesture {
	func didTap()
}
protocol SwipeGesture {
	func didSwipe()
}
protocol ScrollGesture {
	func didScroll()
}

class Button: TapGesture {
	func didTap() {
    	// tap Gesture logic
    }
}

각각의 Gesture를 분리하고 사용하는 기능을 가지고 있는 프로토콜만 채택하여 구현해주었다
이로인해 원하는 기능만 구현이 가능해졌다

만약 새로운 gesture 가 필요하다면 해당 프로토콜을 채택하여 구현해주면 될것이다

주의점

한번 인터페이스를 분리하여 구성해놓았다면 더이상 분리해서는 안된다

이미 구현되어 있는 프로젝트에 또 인터페이스를 분리한다면 이미 해당 인터페이스를 구현하고 있는 클래스 및 클라이언트에 문제가 생길 수 있다
또한 인터페이스가 너무 많아진다면 코드는 더욱 복잡해진다는 사실을 잊지 말고 균형을 유지해야한다

 

 

 

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

상위 계층 클래스들은 하위 계층 클래스들에 의존해서는 안됩니다

개념

객체에서 어떤 Class를 참조해서 사용하는 상황이 발생한다면 그 Class를 직접 참조하는 것이 아닌 그 대상의 상위요소(추상클래스 or 인터페이스)로 참조해야 한다

일반적으로 클래스는 다음 두 계층으로 분류가 가능하다

  • 하위 계층 클래스들은 디스크의 작업, 네트워크 통신 등과 같은 기본 작업을 구현한다
  • 상위 계층 클래스들은 하위 계층 클래스들이 무언가를 하도록 지시하는 복잡한 비즈니스 로직을 포함한다

새 시스템에서 개발을 시작할때 보통은 하위 계층 클래스들은 먼저 디자인 한 다음 상위 계층 클래스들을 디자인 하기 시작한다
이는 해당 시점에 하위 계층 기능들이 구현되지 않았거나 명확하지 않기 때문에 상위 계층에서 무엇이 가능한지 확신할 수 없기 때문이다
이러한 접근 방식은 비즈니스 로직 클래스들이 하위 계층 클래스들에 의존하게 되는 경향이 있다
이렇게 될 경우 하위 계층에 변화가 있을 때마다 클라이언트 혹 상위 계층의 코드를 자주 수정해야 한다

의존관계 역전 원칙은 이러한 의존 관계의 방향을 바꾸는 것이다

1. 상위계층 클래스가 의존하는 하위계층 작업의 인터페이스를 되도록 비즈니스 용어를 사용해 설명해야 한다
2. 구상 하위 계층 클래스 대신 이러한 인터페이스에 의존하는 상위 계층 클래스들을 만들 수 있다
3. 하위 계층 클래스들이 이러한 인터페이스들을 구현하면 이들은 비즈니스 로직 계층에 의존하게 되어 원래 의존관계의 
   방향이 역전된다

코드 예시

잘못된 예시

class Bus { 
	var str = "버스"
}

class Commute {
	var bus: Bus?
    
	func boarding(bus: Bus) {
    	self.bus = bus
    }
    
    func goToWork() {
    	if let bus = bus {
    		print("\(bus.str)을(를) 타고 출근")
        } else {
        	print("걸어서 출근")
        }
    }
}

위 상황에서 Commute(상위계층)은 Bus(하위계층)라는 구체적인 객체에 의존하고 있다
만약 버스를 놓쳐서 택시를 타는경우 Commute 클래스를 수정해줘야 한다

class Bus {
	var str = "버스"
}

class Taxi {
	var str = "택시"
}

class Commute {
	var bus: Bus?
    var taxi: Taxi?
    
    func boarding(bus: Bus) {
    	self.bus = bus
    }
    
    func boarding(taxi: Taxi) {
    	self.taxi = taxi
    }
    
    func goToWork() {
    	if let bus = bus {
        	print("\(bus.str)을(를) 타고 출근")
        } else if let taxi = taxi {
			print("\(taxi.str)을(를) 타고 출근")
        } else {
        	print("걸어서 출근")
        }
    }
}

Commute라는 상위계층 클래스가 변하기 쉬운 하위계층에 의존하게 되면 하위계층 변화의 영향에 직접적으로 노출된다
따라서 탈 수 있는 대중교통이 늘때마다 Commute클래스는 계속해서 수정될것이다

옳게된 예시

protocol PublicTransport {
	var str: String { get }
}

class Bus: PublicTransport {
	var str = "버스"
}

class Taxi: PublicTransport {
	var str = "택시"
}

class Commute {
	var publicTransport: PublicTransport?
    
    func boarding(publicTransport: PublicTransport) {
    	self.publicTransport = publicTransport
    }
    
    func goToWork() {
    	if let pt = publicTransport {
        	print("\(publicTransport.str)을(를) 타고 출근")
        } else {
        	print("걸어서 출근")
        }
	}
}

더이상 Commute 클래스는 구체적인 객체들에 의존하지 않는다
대신 PublicTransport라는 추상적인 개념에 의존하고 있다

이제 탈수있는 대중교통이 늘더라도 하위계층 클래스가 PublicTransport를 채택하고 있는 이상 Commute 객체를 수정하지 않더라도 문제가 없을것이다

의존관계 역전 원칙은 종종 개방/폐쇄 원칙과 함께 진행된다
당신은 하위 계층 클래스를 확장하여 기존 클래스들을 손상하지 않고 다른 비즈니스 로직 클래스들과 함께 사용할 수 있다


하위 계층의 클래스가 상위 계층의 추상화에 의존하는것이 의존관계 역전 원칙의 핵심이다