[Clean Architecture] OOP의 SOLID 원칙을 Swift의 관점에서 이해하기

2024. 2. 13. 14:55Architecture, Design Pattern

0️⃣ OOP(Object-Oriented Programming)와 SOLID 원칙

"일단 앱잼 기간 중에 빨리 기능부터 구현하고, 우리 앱잼 끝나면 진짜 리팩토링하자!"

단기간에 결과물을 내야하는 솝트 동아리 내의 과제, 합동 세미나, 장기 해커톤 앱잼 같은 곳에서 가장 많이 들었고, 가장 많이 해온 말이다.

하지만 해당 기능 개발이 끝나면, 또 다른 기능 개발이 개발자들을 기다리고 있고....
또 다른 기능 구현을 우선적으로 하다 보면, 코드 정리나 프로젝트의 구조를 개선하는 일은 후순위가 항상 되기 마련이었다.
어쩌면 리팩토링은 평생 동안 목표에 그칠 수밖에 없는 "이룰 수 없는 과제"일지도 모르겠다.


그래서 나는 여기서 의문을 하나 가지게 되었다.
"겉으로 보이지 않는 코드의 퀄리티나 프로그램의 구조를 굳이 신경 써야 하는 것일까?" "어쩌면 아키텍처란 것이 불필요한 게 아닐까?"

이 의문은 로버트 C. 마틴의 <클린 아키텍처: Clean Architecture> 책을 읽으면서 완전히 잘못된 생각이었다는 것을 깨닫게 된다.
책의 내용을 일부 발췌해서 정리해보겠다.

소프트웨어 개발의 진리와도 같은 한 문장이 있는데, 그것은 "빨리 가는 유일한 방법은 제대로 가는 것이다."라는 문장이다.

소프트웨어 개발자는 보통 두 가지 측면에서 책임을 가진다.
하나는, 기능명세서와 같은 요구사항을 세상에 실제 화면과 기능으로 구현해야 한다는 책임이다.
여기에는 흔히 "결과물"에 해당하는 기능을 만드는 것, 그리고 기능을 구현하다가 발생한 문제(버그)를 해결하는 것까지 모두 포함하는 아주 일반적인 개발자의 업무라고 생각하는 내용이 담겨있다고 이해하면 된다.

또 다른 하나는, "소프트"웨어라는 이름에 걸맞게 부드러운 제품을 구현해야 한다는 책임이다.
다시 말해, 변경이 쉬워야 한다는 뜻이다. 변경이 쉽기 위해서는 코드의 이해도 쉬워야 할 것이고, 새로운 기능이 추가될 수 있도록 열려 있어야 한다는 것이고, 일부 범위의 수정이 일어나도 전체에 대한 변화가 적어야 한다는 것이다.
이 두 번째 책임을 다하기 위해 필요한 것이 바로 우리가 아키텍처 Architecture라고 부르는 지식이다.

다시 첫 문장으로 돌아가보자.
"빨리 가는 것"은 첫 번째 책임, "제대로 가는 것"은 두 번째 책임에 빗대어 이해해 볼 때, 결국 "빨리 기능부터 구현하고, 구조를 신경 쓰자"리고 말하는 것은 있을 수 없는 말이라는 것을 책에서 설명하고 있었다.

"코드의 퀄리티와 아키텍처를 고려해 개발하는 것이 곧 빠른 기능 개발을 위한 유일한 방법"이다.


Swift 특성이 객체 지향 프로그래밍(OOP) 언어인 것도, 함수형 프로그래밍 언어인 것도, 프로토콜 지향 언어인 것도.

모두 결국은 좋은 아키텍처를 만들기 위해,
다시 말해, 소프트웨어라는 이름에 걸맞은 가치를 지니기 위해,
더 쉽게 말해, 변경하기 쉽고, 이해하기 쉽도록 하고자 지니게 된 특성이자 기능이라고 받아들이면 되겠다.

그리고 특히, 객체 지향 프로그래밍(Object-Oriented Programming) 관점에서 이 책임을 다하기 위해 세부적인 규칙(함수와 데이터 구조를 클래스로 어떻게 배치하는 것이 좋을지 + 클래스 간의 결합은 어떻게 하면 좋을지)을 정해놓은 것이 바로 SOLID 원칙이다.

이제부터 다루게 될 5가지 SOLID 원칙은 모두
(1) 변경 사항이 발생했을 때 기존 코드에 대한 영향(리소스가)이 최소화되고, (2) 확장에 있어 자유로울 수 있도록 하기 위해 존재하는 가이드이다.
개발에 있어 위 원칙들을 하나도 어기지 않고 개발하는 것은 사실상 불가능하지만,
결국 궁극적으로 SOLID 원칙을 어기게 되더라도 "Software Developer가 가져야 할 책임"을 잊지 말자는 경각심을 일깨우며, 자세한 원칙을 하나씩 Swift의 관점에서 살펴보도록 하겠다.

 

1️⃣ SRP (Single Responsibility Principle) : 단일 책임 원칙

✔️ 각 클래스 또는 모듈은 하나의 책임(=변경의 이유가 단 하나)만을 가지도록 설계해야 한다는 원칙

단일 책임 원칙에서 "책임"이 의미하는 바가 무엇일까?

"책임"은 Clean Architecture 상에서는 "변경의 이유"라고 설명되어 있는데,
이를 쉽게 풀어서 설명하면 "클래스 또는 모듈이 수행하는 기능이자 역할"이라고 말할 수 있다.

아래 예시의 경우 LoginService라는 하나의 클래스 내에서 네트워크 호출 -> 데이터 디코딩 -> 데이터 표출까지 세 가지 책임을 지고 있는 것을
각각 핸들러 클래스로 분리해서 구현, 기존 LoginService에서는 로그인이라는 책임만을 지도록 추상화한 모습이다.

여기서 드는 의문 한 가지.
"어느 정도를 책임의 범위로 볼 것인가"라는 의문이다.
위의 예시의 경우에도 로그인 자체를 하나의 책임으로 볼 수도 있고, 네트워크 호출 안에서도 책임을 더 추상화할 수도 있을 것 같기 때문이다.

이때 결국 중요한 것은 "변경이 필요한 액션"이라고 볼 수 있다.
너무나도 지나친 추상화를 하게 된다면, 코드를 읽고 코드를 유지보수하는 과정에 있어 오히려 불편함을 불러일으킬 수 있기에, 변경의 범위를 잘 판단해서 추상화시키는 것이 중요하겠다.

 

 

2️⃣ OCP (Open-Close Principle) : 개방-폐쇄 원칙

✔️ 새로운 코드를 추가하는 확장에는 열려있어야 하고, 기존 코드를 수정하는 변경에는 닫혀있어야 한다는 원칙

"확장"과 "변경"에 대한 개념을 이해하기만 되므로, 토스트 가게를 예시로 설명해 보겠다.

처음 토스트 가게를 개업할 때, 햄토스트 단일 메뉴로 개업했다고 가정해 보자.
햄토스트 클래스 안에는 빵을 굽는 함수와 소스를 바르는 함수가 각각 포함되어 있으며, 토스트를 만드는 함수 안에서 해당 메서드를 호출하는 모습이었다.

#1. 확장: 새로운 메뉴가 추가되는 상황

그런데 장사가 너무 잘된 나머지, 신규 메뉴인 "햄치즈토스트"를 추가하고자 한다.
빵을 굽거나 소스를 바르는 과정은 동일했지만 치즈를 올리는 과정이 필요해 별도의 함수를 구현해줘야 했고, 결국 별도의 햄치즈토스트 클래스를 만들기로 한다.
이제 사장님은 토스트 만들기라는 같은 이름 아래 햄토스트를 만드는 함수와, 햄치즈토스트를 만드는 함수를 2개씩 구현해줘야 한다.
아마 메뉴가 늘어날 때마다 "토스트를 만드는 함수"의 개수는 계속 늘어나게 될 것이다.

"확장에 대해 열려있다"라고 말하기 위해서는 무엇인가 기존 클래스를 더 잘 활용해서 새로운 메뉴를 추가할 수 있어야 한다.


#2. 변경: 기존 햄토스트의 레시피가 변경되는 상황

원래 햄토스트는 빵을 구우는 레시피였는데, 느끼하다는 손님들의 의견을 반영해서 빵을 굽지 않고 그냥 준비만 하는 레시피로 변경하고자 하는 상황이다.
햄토스트 클래스 내에 있는 빵 굽기 메서드를 빵준비 메서드로 변경해줘야 했다.
그랬더니, 기존 토스트 만드는 함수 내에서 빵 굽기 메서드를 호출하는 부분에서 바뀐 레시피를 적용하지 못하고 에러가 발생하게 된다.

"변경에 대해 닫혀있다"라고 말하기 위해서는 레시피가 변경될 수 있는 부분에 대해 미리 다른 곳에서 (적용이 안 되는) 에러가 발생하지 않도록 대응을 해둬야 한다.


결론적으로 OCP가 개발자에게 요구하는 것은
"바뀌지 않을 부분""바뀔 수 있는 부분(기존 내용 변경 혹은 새로운 기능의 추가)"에 대해 명확하게 구분을 하라는 의미라고 볼 수 있다.

왼쪽 코드는 확장이 이루어지는 코드 예시, 오른쪽 코드는 변경이 이루어지는 코드 예시이다.

해당 예시의 경우에는 "요리를 해야 한다"는 행위를 클래스에서 바뀌지 않을 본질적인 행위로 바라볼 수 있다. (어떤 메뉴든 요리는 해야 함)
그 안에 있는 "빵을 굽고", "소스를 바르고" 하는 행위들은 변경이나 확장이 가능한 요소로 바라본다.

Swift에서는 변하지 않을 부분을 프로토콜(Protocol)로 선언하도록 한다.
그렇게 되면 프로토콜은 하나의 가이드이기 때문에 "변경이 닫혀있는(Close)" 구조를 갖게 되며,
프로토콜을 채택하는 클래스의 구현부는 자유롭게 코드를 추가할 수 있으니 "확장이 열려있는(Open)" 구조를 갖게 되는 것이다.

결국 핵심은 변하지 않을 부분에 대해 프로토콜로 인터페이스화 시켜 변경을 막고,
변할 수 있는 부분에 대해서는 프로토콜 채택으로 자유롭게 확장할 수 있도록 만드는 것이 Swift의 OCP 원칙이다.

 

 

3️⃣ LSP (Liskov Substitution Principle) : 리스코프 치환 원칙

✔️ 상위 클래스로 동작하는 곳에서 하위 클래스를 넣어도 문제없이 동작해야 한다는 원칙

리스코프 치환 원칙을 어기지 않고 코드를 짠다는 것은,
Swift 기준으로 "super를 사용하지 않고 override 키워드를 단독으로 사용해서는 안된다"와 같은 의미라고 볼 수 있다.

💡 잠깐 복습하고 지나가기!
override : 부모 클래스에서 만든 틀을 자식 클래스에서도 사용하지만, 내용을 바꾸는 "재정의"를 하고 싶을 때 사용하는 키워드
super : 재정의를 할 때, 부모 키워드에서 만든 내용도 불러오고 싶은 경우 사용하는 키워드

부모 클래스에 해당하는 Sports와, 자식 클래스에 해당하는 Football 클래스가 있는 예시를 살펴보자.
부모 클래스에서 구현한 makeCheers()의 작동 부분이 자식 클래스에서는 오버라이딩되며 다른 기능으로 변경된 것을 확인할 수 있다.

이런 경우에 대해 기존 Sports 클래스로 인스턴스를 만들어 makeCheers를 동작시키는 부분에 대해,
자식 클래스인 Football 클래스로 인스턴스를 대체하여 makeCheers를 동작시켰을 때 다른 결과를 출력하게 되는데,
해당 경우가 LSP를 위반한 사례라고 볼 수 있을 것이다.

이를 해결하기 위해서 역시 프로토콜을 선언하고, 이를 채택하고 구현하는 방법을 제시해 줄 수 있다.

하지만, 모든 상속관계가 있는 클래스에 대해서 아래와 같이 프로토콜로 대체하거나,
혹은 재정의를 하지 않는 방식으로 LSP를 지키는 것은 클래스의 확장성을 오히려 제대로 못 이용하는 비효율적인 방법이 될지도 모르겠다.

 

 

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

✔️ 사용하지 않는 것에 의존해서는 안된다는 원칙

OCP에 따르면 Swift에서 "변경되지 않는 부분", 즉 인터페이스는 프로토콜(Protocol)을 활용해서 구현했다.

이번에 다루는 ISP는 Swift 기준에서 이 프로토콜을 생성할 때 지켜야 하는 원칙에 해당하는 내용이다.
쉽게 말해 해당 프로토콜을 채택하는 곳에서 모두 필요한 내용만을 담으라는 아주 단순한 규칙인데, 아래 코드 예시를 살펴보자.

토스트를 만들 때 필요한 가이드를 "토스트가이드"라는 이름의 프로토콜로 만들었다.
햄치즈야채토스트, 베이컨야채토스트 등... 각 토스트는 이 토스트가이드 프로토콜을 채택해서 자세한 구현부를 추가하기만 하면 됐다.
하지만, 야채가 들어가지 않는 햄치즈토스트 같은 경우에는 해당 토스트가이드를 채택했어도, 야채준비에 대한 구현부는 구현할 필요가 없는 상황이 발생하게 되었다.

이런 경우가 ISP를 위반했다고 보는 상황이다.

이런 경우 프로토콜을 더 쪼개는 방식으로 ISP를 지킬 수 있다.

햄치즈야채토스트의 경우에는 필수재료가이드 프로토콜과 선택재료가이드 프로토콜 2개를 모두 채택해서 필요한 메서드를 모두 구현해 주면 되겠고, 햄치즈토스트의 경우에는 필수재료가이드 프로토콜만 채택해서 구현해주면 된다. (야채준비 메서드 구현필요 x)

 

 

5️⃣ DIP (Dependency Inversion Principle) : 의존성 역전 원칙

✔️ 상위 -> 하위 방향으로 의존하는 것이 아닌, 하위(구체화) -> 상위(추상화) 방향으로 의존해야 한다는 원칙

다르게 말해 "의존성 주입 (Dependency Injection)"이라고 부르는 내용이다.

의존성 역전 원칙 (Dependency Inversion Principle)에 대해 이해하기 위해서는
우선 그 안에서 사용하는 개념인, 먼저 "의존(Dependence)"이 무엇인지, "주입(Injection)은 무엇인지"에 대해 알고 넘어가야 한다.

의존이란 다른 객체를 참조하는 행위이다.
다르게 말해 "A가 B에 대해 의존적"이라는 것은, "A객체가 정상적으로 동작하기 위해서는 B객체가 반드시 필요하다"는 뜻이다.
그래서 흔히, OOP에서는 의존성이 존재하는 코드는 좋지 않은 코드라고 말한다.
왜냐면, B에 대해 수정사항이 발생했을 때, 해당 객체를 참조하고 있는 A객체 역시 동시에 수정이 필요하므로, 변경 사항이 발생했을 때 기존 코드에 대한 리소스가 최소화되는 것을 지향하는 OOP 입장에서 좋지 않다고 판단하게 되는 것이다.

만약 빵 객체의 "구워"라는 메서드 이름이 "굽기"라고 바뀌면, 햄버거 객체의 재료준비 메서드 내의 코드도 바뀌어야 한다.

주입외부에서 생성한 객체(값)를 할당하는 행위이다.

클래스 내부에서 꼭 객체를 구현하지 않아도 된다는 점에서 위의 코드보다는 다소 좋아진 방식처럼 보이지만,
단순한 주입만으로는 여전히 햄버거를 만들기 위해 빵이 필요한 의존적인 관계를 벗어나지 못하고 있다.

아래는 생성자 주입 방식에 해당, 프로퍼티나 메서드로도 주입할 수 있다.

그럼 이제 다시 돌아와서, 의존성 주입(DI: Dependency Injection)이란 무엇이냐?
하위 객체를 추상화한 프로토콜(Protocol)을 만들고, 하위 객체가 해당 프로토콜을 채택(의존)하도록 만드는 것을 말한다.

이때 가이드라인에 불과한 프로토콜의 자세한 구현 내용은 외부에서 처리하게 되고,
상위 객체에서는 외부에서 생성한 (프로토콜을 채택하고 있는) 하위 객체를 할당받기 때문에 주입(Injection)의 개념이 들어가는 것이다.

즉, 어떤 프로토콜을 의존하고 있는 하위 객체가 상위 객체에 주입되는 것의존성 주입이라고 말할 수 있다.

SOLID 원칙에 따르면 의존성 주입보다는 의존성 역전이라는 말을 사용하는데,

이것도 생각해 보면 본래 상위 객체(햄버거)가 하위 객체(빵)를 의존하고 있던 구조에서
하위 객체(빵)가 추상화된 인터페이스(프로토콜)에 의존하는 구조로 바뀌면서 상위 객체는 확장에 있어 자유로워진 형태로 바뀌었기 때문에 의존성이 역전되었다(= 의존 관계가 뒤집혔다)라는 말을 사용하는 것이다.

의존성 역전 원칙 적용 전, 후 구조 비교