기술 면접 자리에서 SOLID 5대 원칙에 대한 질문을 받아보신 분이라면 주목! 이 글에서는 SOLID 원칙의 역사와 장점, 그리고 각각의 원칙에서 중요한 점을 면접 상황 예시를 통해 가볍게 풀어보았습니다. SOLID에 대해 평소 궁금했던 점이나 오해하셨던 부분들을 바로잡는 데 도움이 되기를 바랍니다. 그럼 SOLID의 진정한 의미와 활용법을 함께 알아보세요!

안녕하세요, 카카오뱅크 담보여신개발팀 안드로이드 개발자 Loopy입니다. 글을 시작하면서 여러분께 한 가지 질문을 드리겠습니다.

"여러분은 SOLID에 대해 얼마나 잘 알고 계신가요?" 😲

저는 지난 몇 년간 사내 안드로이드 개발자 면접관으로 참여하면서, 면접을 보러 오시는 많은 분들께 SOLID 5대 원칙에 대해 질문을 드렸습니다. 많은 분들이 SOLID 5대 원칙의 정의는 잘 알고 계셨지만, 그 세부적인 내용에 대해서는 조금씩 오해를 하고 있다는 사실을 알게 되었습니다.

이 글은 SOLID 원칙에 대해 평소 궁금해 하신 분들이나 이미 알고 있다고 생각하시는 분들에게도 도움이 될 수 있는 글입니다. 각각의 원칙을 하나씩 살펴보며, 해당 원칙이 어떻게 적용되는지에 대해 잘못 알려진 부분들을 짚어보고, 그 진실은 무엇인지 파헤쳐 설명드리겠습니다. 🔍

SOLID의 탄생 배경

먼저 간단하게 ‘SOLID’가 어떻게 탄생하게 되었는지 그 역사에 대해 알아보겠습니다.

SOLID는 [그림 1]의 로버트 C. 마틴(Robert C. Martin)이 만들었습니다. 우리에게는 ‘엉클 밥(Uncle Bob)’ 혹은 ‘밥 아저씨’로 더 유명하시죠. 저는 이 글에서 편의상 밥 아저씨라고 부르겠습니다. 아마 IT업계 종사자라면, 밥 아저씨가 쓴 책을 한 번쯤 들어보신 적 있으실 겁니다. 바로 <클린 코드><클린 아키텍처> 입니다. SOLID 원칙의 창시자답게 밥 아저씨는 SOLID 원칙의 신봉자이기도 한데요. 앞서 말씀드린 2권 외의 여러 저서에서도 SOLID의 중요성을 반복해서 설명하고 있습니다.

1-solid-five-principle-father-robert-c-martin-image
[그림 1] SOLID 5대 원칙의 창시자, 로버트 C. 마틴(Robert C. Martin)

밥 아저씨는 1980년대 후반부터 유즈넷(Usenet), 지금으로 치면 ‘레딧(Reddit)‘과 같은 특정 주제나 관심사에 대한 의견을 게시하는 인터넷 게시판에서 소프트웨어 설계원칙에 대해 토론을 나누며 설계 원칙을 모으기 시작했습니다. 오랜 기간 토론을 통해 좋은 설계 원칙들을 모으고 모아 2000년대 초반에 5가지 원칙을 발표했습니다. 이때까지만 해도 SOLID 라고 부르지 않았는데요. 그 이유는 발표 당시 원칙의 순서가 지금과 약간 달랐기 때문입니다.

그런데 2004년, <레거시 코드 활용 전략>의 저자이신 마이클 C. 페더스(Michael C. Feathers)가 밥 아저씨에게 “원칙들을 재배열하면 첫 번째 글자를 조합했을 때 ‘SOLID’ 라는 단어를 만들 수 있습니다.” 라는 내용의 메일을 보내면서 ‘SOLID 5대 원칙’이 탄생하게 되었습니다. 그 이름처럼 5가지 원칙들 모두 20년 간 뜻이 변하거나 없어지지 않고 현재까지도 소프트웨어 설계에 있어 가장 중요한 원칙들로 견고하게 남아있습니다.

SOLID 원칙을 따르면 어떤 점이 더 좋을까요?

소프트웨어 개발자들에게 익숙한 SOLID 원칙은 함수와 데이터 구조를 효과적으로 결합하고, 이 집합들을 서로 유기적으로 연결하는 방법을 제시합니다. SOLID 원칙의 핵심은 다음과 같은 소프트웨어 구조를 만드는 데 있습니다.

1. 변경에 유연한 구조
'변경에 유연하다'는 말은 곧 '결합도는 낮고, 응집도는 높은 구조'를 의미합니다.
예를 들어 A라는 회사의 시스템이 작은 기능 추가에도 수십 개의 테스트가 깨질 만큼 복잡하다면, 기능을 자주 추가하는 것이 어려울 것입니다. 이때 매달 새로운 기능을 출시할 수 있는 경쟁사 B가 있다면, A회사는 경쟁에서 뒤처질 수밖에 없습니다. 따라서 시스템은 항상 결합도가 낮고 응집도가 높은 상태를 유지하는 것이 중요합니다.
2. 이해하기 쉬운 구조
'이해하기 쉽다'는 것은 '가독성이 좋다'로 표현할 수 있습니다.
아무리 좋은 코드라도 본인만 이해할 수 있다면 좋은 설계라 할 수 없습니다. 깔끔한 코드는 디버깅과 유지보수를 쉽게 만들어 소프트웨어 품질 향상에 기여합니다.

이처럼 결합도가 낮고 응집도가 높으며 가독성이 좋은 코드를 작성하는 것이 모든 소프트웨어 설계가 추구하는 궁극적인 지향점입니다. 이를 통해 유연하고 유지보수하기 쉬운 소프트웨어를 개발할 수 있습니다.

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

첫 번째로 알아볼 원칙은 SRP(Single Responsibility Principle), 즉 단일 책임 원칙입니다. 많은 분들이 이름 때문에 혼동을 겪곤 합니다. 그럼 면접 상황을 재구성하여 예시를 들어보겠습니다.

👨‍🦳 면접관: SRP에 대해서 설명 부탁드립니다.

👨🏻 지원자: SRP는 단일 책임 원칙으로서, 클래스를 만들 때 책임을 하나만 가져야 한다는 원칙입니다.

👨‍🦳 면접관: 좋은 답변 감사합니다. 만약 개발자 A, B가 생각하는 책임의 크기가 각각 다를 때는 어떻게 해야 할까요?

👨🏻 지원자: 서로 논의를 통해서 책임을 하나로 맞추면 될 것 같습니다.

👨‍🦳 면접관: 그럼 팀원이 10명이라고 했을 때, 10명의 논의를 통해 나온 결론이 명확하게 1가지의 책임을 가지고 있다고 할 수 있을까요?

👨🏻 지원자: 논의를 통해 결정했기 때문에 지켜야 하지만, 책임은 상황에 따라 다를 것 같습니다.

👨‍🦳 면접관: ...

위의 예를 통해 알 수 있듯이 각자가 생각하는 책임의 범위는 항상 동일하지 않습니다. 따라서 ‘책임’이라는 단어는 매우 주관적인 평가라고 설명할 수 있습니다. 그래서 밥 아저씨는 SRP를 아래와 같이 정의했습니다: “단일 모듈(module)은 변경의 이유가 하나, 오직 하나뿐 이어야 한다.”

변경의 이유가 하나라고 정의함으로써 객관적으로 파악이 가능해졌습니다. 서로 다른 책임을 분리하면 응집도가 높아지는 효과가 생깁니다. 응집도가 높아질 경우 가독성과 재사용성이 높아지고 유지보수가 쉬워집니다.

이어서 ‘책임’이라는 주관적 평가 때문에 발생할 수 있는 사례를 소개해 드리고자 합니다. 해당 이야기는 클린 아키텍처에 나와있는 유명한 CFO, COO 내용을 카카오뱅크에 맞게 여신과 수신의 금리 계산 코드로 각색해 보았습니다.

잠깐! 여신과 수신이 뭔가요? 🤔
은행업에서 여신과 수신은 은행의 주요 기능 중 일부이며, 금융 시스템 내에서 자금의 순환을 돕는 중요한 역할을 합니다.
- 여신(與信): 은행이 고객에게 자금을 대출하는 활동으로, 이를 통해 은행은 이자 수익을 창출합니다. (예: 대출, 신용카드 발급, 담보대출 등)
- 수신(受信): 고객이 은행에 자금을 예금하는 것을 의미하며, 은행의 운영 자금 및 대출 자금으로 활용됩니다. 예금에 대해서는 이자 지급이 이루어집니다. (예: 예금 계좌, 적금, 예금증서(CD) 등)
class InterestRate {
    fun getDepositInterestRate(): Float {
        // 수신금리를 계산합니다.
    }

    fun getLoanInterestRate(): Float {
        // 여신금리를 계산합니다.
    }

    private fun getBaseInterestRate(): Float {
        // 기준금리를 계산합니다.
    }
}

우선 위와 같이 InterestRate 클래스 안에 금리 계산 코드를 작성했습니다. 그냥 코드만 보았을 땐 문제가 없어 보입니다. 하지만 해당 클래스는 수신 module여신 module에서 호출될 가능성이 높으며, 그 결과 InterestRate를 함께 참조할 수 있습니다. 그런데 여기서 더 큰 문제는 여신과 수신 금리 계산 시, 기준금리를 가져오기 위해 getBaseInterestRate 메서드를 동일하게 사용한다고 가정해 보겠습니다.

[그림 2]를 보니 수신 module, 여신 module 각각 변경의 이유가 2개처럼 보입니다. 그림만 봐도 왠지 불안합니다. 비수가 날아와 꽂히는 느낌! 분명 이슈가 발생할 것 같다고 개발자의 직감이 말해주는 듯합니다. 😂

2-interest-rate-class-calling-rece-carding-modules-image
[그림 2] InterestRate 클래스를 호출하는 수신, 여신 module

그럼 여기에 추가로 발생할 수 있는 상황을 가정해보겠습니다. 만약 여신팀에서 필요에 의해 getBaseInterestRate 메서드 속 기준금리를 가져오는 코드를 변경한다면 어떻게 될까요?

feature 브랜치를 생성하여 요구사항을 개발하고, 테스트 코드도 작성하고, QA 단계까지 모두 정상적으로 진행될 때까지는 아무런 이슈도 발생하지 않았습니다. 그렇게 드디어 운영 배포가 나갑니다. 그리고 며칠이 지나서야 수신금리가 잘못 계산되고 있다는 걸 알았을 때는 이미 늦었고, 잘못된 수신금리가 나가고 있다는 보고를 받은 경영진들은 크게 당황할 정도로 상황이 나빠져 있을 것입니다.

우리가 여기서 알 수 있는 교훈을 뭘까요? 공통 코드를 수정할 때는 옆팀에 공유를 잘 하자? 🤔 물론 이것도 굉장히 중요한 부분입니다. 하지만 처음부터 아래 [그림 3]처럼 책임이 분리되어 있었다면 어땠을까요? 모듈별로 변경의 이유가 각각 분리되어 있었다면, 서로에 대한 책임도 분리되어 각각의 수정에 대해 영향을 받지 않았을 것입니다.

3-different-class-calling-rece-carding-modules-image
[그림 3] 각각 다른 클래스를 바라보는 수신, 여신 module

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

두 번째 원칙은 OCP(Open-Closed Principle)라는 개방-폐쇄 원칙입니다. 앞서 소개드린 SRP에 이어서 면접 상황을 재구성해 보았습니다.

👨‍🦳 면접관: OCP에 대해 설명 부탁드립니다.

👩🏼‍🦰 지원자: OCP는 개방 폐쇄 원칙으로 기존 수정에는 닫혀 있고, 확장에는 열려 있어야 한다는 원칙입니다.

👨‍🦳 면접관: 답변 감사합니다. 어떻게 하면 기존의 코드를 수정하지 않고 기능을 추가할 수 있을까요?

👩🏼‍🦰 지원자: ...

OCP의 경우 이름이 매우 직관적이어서 많은 분들이 정의는 잘 알고 있었지만, 방법을 제시하는 사람은 드물었습니다. 밥 아저씨는 OCP를 이렇게 정의했습니다: “확장에 열려 있어야 하고, 변경에는 닫혀 있어야 한다.”

만약 새로운 요구사항을 구현하는 데 기존에 있던 코드를 전반적으로 수정해야 한다면, 좋은 설계라고 할 수 없을 것입니다. 간단한 사례를 통해 OCP를 적용하는 방법에 대해 알아보겠습니다. 밥 아저씨의 저서 <클린 아키텍처>에 있는 보고서 웹, 프린터 출력 사례를 카카오뱅크 스타일로 재구성한 내용입니다.

[그림 4]와 같이 대부분의 앱에서 사용하고 있는 컨텐츠를 상하 스크롤하는 화면이 이미 구현되어 있다고 합시다.

4-kakaobank-test-app-scroll-screen-example-image
[그림 4] 카카오뱅크 테스트 앱을 상하 스크롤하는 화면 예시

어느 날 A/B 테스트 결과에 따라 [그림 5]처럼 상하 스크롤 또는 좌우 스와이프가 될 수 있도록 좌우 스와이프 화면을 추가 개발해 달라는 요구사항이 들어왔습니다. 기존 코드의 수정량을 최소화하면서 요구사항을 만족하려면 어떻게 구현해야 할지 알아보겠습니다. 간단하게 데이터의 흐름을 그려보면 아래 [그림 5]와 같습니다.

5-data-flow-chart-scroll-swipe-screens-image
[그림 5] 출력용 데이터에 따라, 상하 스크롤과 좌우 스와이프 화면으로 나뉘는 데이터 흐름도

우선 데이터의 구조를 생각해 보면, 상하 스크롤 시에는 데이터의 자료구조가 List<Content> 형태이며, 좌우 스와이프 시에는 각 페이지마다 리스트가 필요하므로 List<List<Content>> 형태가 필요할 것으로 보입니다. 출력용 데이터 생성 시, 각 화면에 맞는 자료구조를 매핑할 수 있도록 비즈니스 로직을 각각 만들고, 앞서 설명했던 SRP(단일 책임 원칙)를 적용하여 상하 스크롤 화면과 좌우 스와이프 화면의 책임을 분리합니다. 이제 이 흐름도를 이해하기 쉽도록 [그림 6]처럼 간단한 클래스 다이어그램으로 변경해 보겠습니다.

6-class-diagram-including-old-new-screens-image
[그림 6] 기존 및 새로운 화면 타입을 포함한 클래스 다이어그램

클래스 다이어그램을 보면, 기존의 상하 스크롤 화면(Scroll Fragment)을 그대로 유지하면서, 좌우 스와이프 화면(Swipe Fragment)을 추가할 수 있도록 설계된 것을 알 수 있습니다. 여기에 추가로 고수준 정책 모듈을 보호하기 위해, 이후에 살펴볼 DIP(의존성 역전 원칙)가 적용되어 있습니다.

그러므로 앞으로 새로운 타입의 화면을 추가할 때 수정량을 최소화하기 위해서는 템플릿 메서드 패턴이나 전략 패턴을 사용해 비즈니스 로직을 묶어서 인터페이스로 정의 후, 의존성 주입(DI, Dependency Injection)을 통해 타입과 로직을 매핑하고 동적으로 로드하도록 구현할 수 있습니다. 이후에도 또 다른 요구사항이 생긴다면, 새로운 타입을 정의하고 비즈니스 로직만 추가하면 되어, 기존 코드를 전혀 변경하지 않고도 기능을 확장할 수 있습니다.

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

세 번째 원칙은 LSP(Liskov Substitution Principle), 리스코프 치환 원칙입니다. LSP는 다른 원칙들과 달리 사람의 이름을 따와서 만들어졌기 때문에, 이름만으로는 어떤 원칙인지 유추하기 어렵습니다. 그 내용 또한 어렵기 때문에, 몇 년 동안 면접 질문으로 “SOlID 원칙 중에 자신 있는 하나를 선택해서 설명해 주세요.“라는 질문에 5개의 원칙 중 단 한 번도 선택받지 못한, 선택률 0%를 자랑하는 원칙이기도 합니다.

1998년 바바라 리스코프(Barbara Liskov)는 하위 타입을 다음과 같이 정의했습니다: “S 타입의 객체 o1과 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 프로그램 P 에서 o2를 o1으로 치환해도 P의 행위가 변하지 않는다면, S는 T의 하위타입이다.”

좀 헷갈리시죠? 방금 말씀드린 정의를 하나씩 천천히 살펴보도록 하겠습니다.

1. 타입 T가 있습니다.
2. (정답을 미리 알려드리면) 서브타입 S가 있습니다.
3. T타입으로 만든 프로그램 P가 있습니다.
4. 프로그램 P에서 T를 서브타입 S로 치환합니다.
5. 프로그램의 행위가 변하지 않고 정상적으로 동작한다면, S는 T의 하위타입입니다.

리스코프 치환 원칙에서 가장 중요한 부분은 치환해도 ‘프로그램의 행위’가 변하지 않는다는 것입니다. 하위 타입 구현 시, 부모 타입의 기능을 무작위로 수정한다면, 타입을 치환해서 호출하려 할 때 동작 여부를 확신할 수 없기에, 타입을 서로 치환할 수 없게 됩니다. 따라서 LSP 원칙은 하위타입 구현 시, 부모의 기능을 수정하면 안 된다는 원칙입니다.

LSP를 위반하는 전형적인 문제로 유명한 ‘정사각형/직사각형 문제’를 살펴보겠습니다. 우선, 아래와 같이 직사각형(Rectangle)의 너비와 높이를 설정하고 넓이를 계산하는 Rectangle 클래스를 만들어봅시다.

open class Rectangle {
    private var height = 0
    private var width = 0

    fun area() = height * width
    
    open fun setHeight(height: Int) {
        this.height = height
    }

    open fun setWidth(width: Int) {
        this.width = width
    }
}

이제 이 클래스를 상속받아 정사각형(Square) 클래스인 Square 클래스를 구현해 보겠습니다. 정사각형은 특성상 너비와 높이가 같아야 합니다.

class Square : Rectangle() {
    override fun setHeight(height: Int) {
        this.width = height
        this.height = height
    }

    override fun setWidth(width: Int) {
        this.width = width
        this.height = width
    }
}

정사각형 클래스는 너비나 높이를 설정할 때 모두 같은 값이 되도록 부모 클래스의 setHeightsetWidth 메서드를 변경했습니다. 이렇게 부모 클래스의 기능을 변경함으로써 LSP를 위반하게 되고, 이에 따른 문제가 발생합니다.

누군가 아래와 같이 너비를 5, 높이를 2로 설정한 직사각형의 넓이가 10일 것이라고 단언하는 함수 foo를 작성했다고 가정해 봅시다.

fun foo(rect: Rectangle) {
    rect.setHeight(5)
    rect.setWidth(2)
    assert(rect.area() == 10)
}

먼저 직사각형을 전달하는 경우에는 아무 문제 없이 함수가 동작합니다.

foo(Rectangle())

하지만 내부 구현을 알지 못하는 누군가 정사각형을 전달한다면 문제가 발생합니다.

foo(Square())

정사각형은 모든 변의 길이가 같아야 하기 때문에, 정사각형을 직사각형의 하위 클래스로 만들면 setWidthsetHeight 메서드를 호출할 때 문제가 생깁니다. 너비나 높이를 따로 설정할 수 없기 때문에, 정사각형을 직사각형의 하위 클래스로 정의하면 정사각형이 직사각형의 동작을 따를 수 없게 됩니다. 이는 LSP를 위반하게 되는 상황입니다.

이러한 LSP 위반을 막기 위해서는 분기문 등을 통해 코드를 실행하기 전에 객체의 유형을 확인하고 실행해야 합니다. 하지만 이렇게 하면 코드가 객체의 유형에 의존하게 되어, 결국 객체의 유형을 서로 치환하여 사용할 수밖에 없는 상황이 됩니다.

여기까지 보면, LSP는 단순히 상속 관계에서의 하위 타입을 설명하는 원칙이라고 생각될 수 있습니다. 저도 그렇게 생각했고, 심지어는 SOLID의 창시자인 밥 아저씨도 그렇게 생각했다고 합니다. 그러나 시간이 흐르면서 이는 잘못된 이해였다는 사실을 깨달았습니다. 현재의 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 설계 원칙이 되었습니다. 간단한 사례를 통해 인터페이스와의 관계에 대해 알아보겠습니다.

7-importance-interface-implementation-car-start-image
[그림 7] 인터페이스 구현의 중요성 (feat. 자동차 시동)

위의 사진처럼 자동차의 시동을 걸 수 있는 다양한 인터페이스(키 또는 버튼)가 있다고 가정해 보겠습니다. 이때 어느 방식을 사용하더라도 자동차의 시동이 정상적으로 걸리고 앞으로 나갈 준비가 되어야 합니다. 인터페이스가 잘 구현되었다면, 키나 버튼 중 어느 것을 사용하든지 자동차의 시동이 잘 걸릴 것이라고 믿을 수 있습니다. 그런데 만약 어떤 영화에서처럼, 누군가가 시동을 걸었을 때 자폭하는 기능을 추가했다고 상상해 본다면, 우리는 이 인터페이스를 안심하고 사용할 수 있을까요? 💣

우리가 만든 프로젝트에는 수천, 수만 개의 클래스가 존재하기 때문에 모든 클래스의 구현을 항상 기억할 수는 없습니다. 따라서 하위 타입을 안심하고 치환해서 사용하려면, 하위 타입을 구현할 때 부모의 구현을 변경하지 않아야 합니다. 또한, 인터페이스를 구현할 때는 혼동되지 않도록 정확하게 구현해야 합니다.

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

네 번째 원칙은 ISP(Interface Segregation Principle), 인터페이스 분리 원칙입니다. 이전과 동일하게 면접 상황을 재구성하여 해당 원칙을 한 번 살펴보겠습니다.

👨‍🦳 면접관: ISP에 대해 설명 부탁드립니다.

👨🏽‍🦱 지원자: ISP는 인터페이스 분리 원칙으로 인터페이스를 잘 분리해야 한다는 원칙입니다.

👨‍🦳 면접관: 네, 답변 감사합니다. 인터페이스를 어떻게 잘 분리할 수 있나요?

👨🏽‍🦱 지원자: 기능 및 책임에 따라 분리할 수 있을 것 같습니다.

👨‍🦳 면접관: ...

ISP의 경우 네이밍 그대로 설명하시는 분들이 많았고, 그 내용 자체가 틀리다고 할 수는 없지만 상황에 따라서 잘못된 답변이 될 수 있어 조금 더 상세한 내용에 대해 질문을 이어갔는데요. 대부분의 경우, 기능에 따라서 인터페이스를 분리해야 한다는 약간은 추상적인 답변이 많았습니다. 밥 아저씨는 다음과 같이 ISP를 정의했습니다: “사용하지 않는 것에 의존하지 않아야 한다.”

다시 말해, 기능별로 인터페이스를 나눈다고 하더라도 만약 사용하지 않는 함수가 존재한다면, 인터페이스를 분리해야 한다는 뜻입니다. ISP가 탄생하게 된 최초의 클래스 다이어그램을 살펴보겠습니다.

8-three-users-referencing-ops-diagram-image
[그림 8] User1, User2, User3가 OPS를 참조하는 다이어그램

[그림 8] 다이어그램에서는 3개의 클래스 User1, User2, User3 이 하나의 OPS를 참조하는 것을 볼 수 있습니다. 언뜻 크게 문제없어 보이지만 한 가지 이슈가 있었는데요. User1은 op1(), User2는 op2(), 그리고 User3은 op3()만 호출한다고 합니다. 이 경우 User1은 사용하지 않는 op2()와 op3()를 참조하고 있다고 할 수 있습니다. 만약 op2()가 수정되면, 전혀 관계없는 User1이 언어에 따라서는 다시 빌드되거나 영향을 받을 수 있습니다. 이러한 이유로, 사용하지 않는 인터페이스에는 의존하지 말아야 합니다. 그럼 ISP를 반영하여 인터페이스를 분리해 보겠습니다.

9-users-referencing-ops-through-interface-diagram-image
[그림 9] User별 인터페이스를 통해 OPS를 참조하는 다이어그램

[그림 9]를 보면, User1, User2, User3과 OPS 사이에 각각의 인터페이스가 생겼습니다. 이제 User1은 U1Ops, User2는 U2Ops, User3는 U3Ops를 의존하게 되어 더 이상 사용하지 않는 것에 의존하지 않습니다. 그리고 OPS에서 해당 구현을 제공하기 때문에 기존과 동일한 동작을 보장합니다.

지금도 프로젝트를 열어 보면 더 이상 사용하지 않는 함수들에 의존하고 있거나, 사용하지 않는 인터페이스에 ‘Do nothing’이라고 붙여둔 주석들이 곳곳에 보입니다. 처음에는 필요에 의해서 함수를 구현했지만 요구사항들이 추가되고, 기존에 있던 기능들을 참조하면서 사용하지 않는 함수들이 생겨났을 겁니다. 이렇듯 시간이 지남에 따라 기술부채가 눈덩이처럼 쌓이기 전에, ISP에 따라 사용하지 않는 것은 분리해 보면 어떨까요?

🇩 : Dependency Inversion Principle(의존성 역전 원칙)

마지막 다섯 번째 원칙인 DIP(Dependency Inversion Principle), 의존성 역전 원칙입니다. 다른 원칙들과 마찬가지로 면접 상황을 재구성해 보겠습니다.

👨‍🦳 면접관: DIP에 대해서 설명 부탁드립니다.

👱🏻‍♀️ 지원자: DIP는 의존성 역전 원칙으로서 의존성을 역전시킬 수 있다는 원칙입니다.

👨‍🦳 면접관: 좋은 답변 감사합니다. 왜 의존성을 역전시킬까요?

👱🏻‍♀️ 지원자: 의존관계를 역전시키기 위해 의존성을 역전하는 것으로 알고 있습니다.

👨‍🦳 면접관: 의존관계를 역전시키는 이유는 뭘까요?

👱🏻‍♀️ 지원자: 의존성 때문에 참조가 어려울 때 의존성을 역전시켜서 참조할 수 있을 것 같습니다.

👨‍🦳 면접관: ...

DIP는 실무에서도 많이 사용되는 원칙으로, 많은 분들이 그 정의와 방법에 대해 잘 알고 있습니다. 다만 DIP를 사용해야 하는 근본적인 이유를 아는 분들은 많지 않았던 것 같습니다. 밥 아저씨는 다음과 같이 DIP를 정의했습니다: “추상화에 의존해야 하며 구체화에 의존하면 안 된다.”

DIP는 이름과 그 정의가 잘 맞지 않는 것처럼 보일 수 있으므로, 왜 이런 이름이 붙게 되었는지 살펴보겠습니다. 안정된 추상화를 위해서는 변동성이 낮은 추상화에 의존해야 합니다. 추상 인터페이스와 이를 구체화한 구현체가 있을 때, 인터페이스가 수정되면 구현체도 함께 수정해야 합니다. 반면, 구현체가 수정되더라도 인터페이스는 대부분의 경우 변경될 필요가 없습니다. 따라서 인터페이스는 구현체보다 변동성이 낮습니다. 즉, 변동성이 큰 구현체보다 안정된 인터페이스를 참조해야 합니다.

ISP에서 봤던 익숙한 User1 클래스와 OPS 클래스 다이어그램을 보면, 구현체를 직접 참조할 경우 아래 [그림 10]과 같이 표현됩니다.

10-user1-referencing-ops-directly-diagram-image
[그림 10] User1이 OPS를 직접 참조하는 다이어그램

해당 구현을 DIP의 정의에 맞게 구현체를 직접 참조하지 않고, 인터페이스를 통해 참조하도록 수정하면 [그림 11]처럼 변경할 수 있습니다.

11-u1ops-interface-referencing-ops-diagram-image
[그림 11] U1Ops 인터페이스를 통해 OPS를 참조하는 다이어그램

여기에서 User1에서 OPS로 제어 흐름이 U1Ops 인터페이스와 OPS 구현체 사이에서는 코드 의존성과 정반대 방향으로 빨간색 곡선이 가로지르는 것을 볼 수 있습니다. 제어흐름과 반대 방향으로 역전되는 이유로 인해 이 원칙의 이름을 ‘의존성 역전 원칙’인 DIP라고 부르게 되었습니다.

위와 같은 특성으로 인해 실무에서 많이 사용되며, 여러분이 잘 아시는 <클린 아키텍처>양파 껍질 이미지에서도 DIP를 찾아볼 수 있습니다.

12-legendary-onion-skin-hierarchical-architecture-image
[그림 12] 계층적 아키텍처 패턴을 설명하는 전설의 양파 껍질 이미지

여기서 많이 보셨을 클린 아키텍처 이미지가 있습니다. 클린 아키텍처는 양파 껍질처럼, 가장 안쪽에 변하지 않는 고수준 정책을 배치하고, 바깥쪽으로 갈수록 변동성이 큰 저수준 모듈들을 배치합니다. 의존성은 바깥쪽에서 안쪽으로 향하고, 이렇게 하면 바깥쪽의 변화가 안쪽에 영향을 주지 않도록 할 수 있습니다. 따라서 변동성이 큰 저수준 모듈들은 바깥쪽에 자리 잡고, 이들의 변화가 안쪽으로 전파되지 않는 것이 클린 아키텍처의 핵심입니다.

해당 이미지의 우측 하단에 있는 클래스 다이어그램을 보면, 핑크색 화살표의 제어 흐름이 초록색 Controllers 껍질에서 시작해 빨간색 Use Cases 껍질을 거쳐 다시 초록색 Presenter 껍질로 진행되는 것을 알 수 있습니다. 여기서 Use Cases가 Presenter를 참조하기 위해 DIP가 필요합니다. Use Cases 내에 인터페이스를 정의하고, 이를 Presenter에서 구현하여 주입하면 안쪽 껍질에 있는 Use Cases가 Presenter의 기능을 호출할 수 있습니다.

마무리하며

SOLID 원칙을 잘 활용하면 소프트웨어 설계를 유연하게 하고 이해하기 쉽게 만들 수 있습니다. 하지만 원칙을 완벽히 준수하려면 많은 노력이 필요합니다.

13-sandcastle-vs-disneyland-sleeping-beauty-castle-image
[그림 13] 모래성 vs. 디즈니랜드의 '잠자는 숲속의 공주 성'

위의 무너지기 쉬운 모래성과 견고하게 지은 캘리포니아 디즈니랜드의 상징인 ‘잠자는 숲속의 공주 성(Sleeping Beauty Castle)‘을 비교해 보면, 당연히 후자를 만드는 데 더 많은 시간과 노력이 들었을 것입니다. 소프트웨어도 마찬가지로, 우아하게 만들기 위해서는 많은 노력이 필요합니다. 안타깝게도 우리에게는 완벽한 해결책이 없습니다. SOLID 원칙은 우아한 소프트웨어를 만들기 위한 이정표로, 항상 마음속에 새기고 여러분만의 견고한 성을 만들기를 바라며 글을 마치도록 하겠습니다. 감사합니다!