배경

컴퓨터와 회화(painting)를 전공했고 꽤 성공한 누군가가 프로그래머는 과학자보다 화가와 공통점이 더 많다고 했다. 정말로, 프로그래밍을 잘하려는 노력을 하다보면 과학보다는 경험적으로 실력을 쌓아야하는 운동이나 미술과 비슷하다고 느껴진다.

좋은 그림을 알아보는 능력과 그림을 잘 그리는 것이 별개이듯 코드를 보고 좋다 나쁘다를 판단할 줄 아는 것과 좋은 코드를 짜는 실력은 안타깝게도 붙어오지 않는다. 코드를 많이 보게 되고 많이 짜보고 남이 짠 코드를 유지보수 해보면서 경험적으로 판별하는 후각이 조금은 탑재된 것 같다. 하지만 내가 키보드를 두들기기 시작하면 답답함이 찾아온다. 이렇게 짜면 안된다는.. 본능적인? 경험적인? 느낌은 마구 들지만, 그렇다면 이렇게 말고 어떻게 다르게, 좋게 짜야하는지에 대한 명쾌한 답은 없는 그런 상태이다. 학창 시절에 시험을 치다보면 자신있게 답을 고르는 경우도 있지만 확실히 아닌 답을 제거한 후에 남아있는 것 중에서 고르는 경우도 있다. 확실히 아닌 것을 배제하고 나면 남아 있는 선택지에 더 많은 고민을 투자할 수 있고, 답을 선택할 확률이 높아진다. 프로그래밍으로 다시 돌아와보면, 잘못된 패턴이나 유지보수에 좋지 않은 코드의 모습을 알고 이를 피하면 좀 더 나은 코드를 쓰게 되지 않을까 하는 생각을 하게 됐다.

작년, 회사에서 함수형 스위프트 프로그래밍 스터디를 했었다. 스터디를 시작하던 날, 공부 방향을 잡기 위해 가이드를 받고자 사내에서 함수형 스위프트 강의를 하셨던 멘토님을 초빙해 조언을 부탁했다. 그 자리에서 멘토님은 지금까지 약 1년간 내 프로그래밍 공부의 방향이 된 말을 하셨다. “함수형 프로그래밍이 얼마나 어떻게 좋은지 비교하려면 잘 짠 함수형 코드와 잘 짠 객제 지향형 코드를 비교해야하는데 아직도 내가 객체 지향으로 잘 짠다고 할 수 없어서 비교가 어렵다.” 아.. ‘OOP식’으로 매일 코딩을 한다고해서, 할 줄 아는 것이 OOP 밖에 없다고 내가 OOP를 하고 있는거구나 라고 여겼었던 내 무의식적인 생각이 뿌리부터 흔들렸다. 그래서 다시 기본부터 잘해야겠다고 생각했고 이 글은 지난 몇 개월 간 습득한 지식의 요약이다.

스위프트와 SOLID

SOLID는 로버트 마틴이라는 사람이 명명한 객체 지향 프로그램 및 설계의 기본 원칙 다섯 개이다. 스위프트는 멀티패러다임 언어이지만 Foundation, UIKit 등 코코아 터치 프레임워크들은 기본적으로 OOP에 근간을 두고 있다. SOLID 원칙들과 iOS를 연관지어서 살펴보니 내가 쓰고 있는 많은 클래스들이 왜 그런식으로 만들어졌는지 알 수 있었고, 또 내가 어떻게 이 원칙들을 녹여낼 수 있는지 좀 더 와닿게 이해할 수 있었다. 본문에서는 각 원칙에 따라 만들어진(혹은 위반하고 있는) iOS 프레임워크의 예시와 함께 이 원칙을 잘 지키기 위해서 어떻게 코딩을 해야하는지, 특히 이 원칙을 어긴 것은 어떤 형태인지 중점적으로 정리했다. 앞서 말했듯, 처음부터 코드를 잘 짤 순 없으니 최소한 잘못된 코드가 어떤건지는 알고 피해가자는 컨셉이다. 그러면 어려운 이야기를 전부 이해하지 못했더라도 어떻게든 그 방향으로는 나아갈 수 있다고 생각하기 때문이다.

다만 이 글에서는 마지막 두 원칙을 제외한 SOL(쏠?)까지만을 다룬다. 그 이유는 여러가지가 있는데, 아직 뒷 부분은 설명을 할 수 있을 만큼 이해를 다 하지 못했고 또 ID는 이 글의 컨셉에 맞춰 해당 원칙이 위반된 상황을 판단하려고 하는 것이 훨씬 어렵고 미묘하기 때문이다. 하지만 SOL만으로도 내 코드의 개선 사항을 수두룩 빡빡하게 찾는데는 전혀 부족함이 없었고 끝이 안보인다!

Single Responsibility Principle

단일책임 원칙은 모든 클래스는 하나의 책임만 가져야 한다는 원칙이다. 로버트 마틴은 책임을 변경하려는 이유로 정의했다. SRP의 가장 대표적인 위반 사례는 Massive View Controller 현상이다. 이런 MVC의 문제를 해결하기 위해 여러 다른 패턴들이 생겨나고 시도되고 있는데, 그것들의 공통점은 너무 많은 역할을 하고 있는 뷰컨트롤러를 쪼개서 단일 책임만을 가지는 여러 클래스를 만들려고 하는 것이다.

SRP를 지키기 위해 당장 시도해볼 수 있는 것은 클래스를 작게 만드는 것이다. 클래스가 작다는 것이 SRP의 충분 조건은 아니지만 필요 조건이다. 이 목표를 달성하기 위해서 멘토님이 제시한 10-200룰을 기준으로 코딩해보고 있다. 10-200룰이란 함수는 10줄 이내, 클래스는 200줄 이내로 만드는 것이다. 하지만 이를 엄격하게 지키기는 쉽지 않고, 아직은 이상향으로 여기는 수준이다. 현실적으로는 200줄이 넘어가면 혼자 머리 속에서 경보를 발령하고 더이상 안 늘어나게 노력하거나 리팩토링을 시도한다. 10-200룰은 실제로 시도해보면 정말 많은 고민을 하게하고 나를 불편하게 만드는 규칙이다. 하지만 그런 불편함을 극복해나가면서 배움이 생기는 것 같다.

우선 ‘함수는 10줄’ 룰을 지키려면 함수는 하나의 작업만 해야한다라는 대원칙을 지킬 수 밖에 없고 또 추상화 레벨에 대해 고민을 하게 된다. 추상화 레벨이란 얼만큼의 정보를 해당 함수에서 노출할 것인가 하는 것이다. 신라면 봉지 뒷면에 써있는 조리법을 살펴보자. 물 550ml를 끓인다, 면과 스프, 후레이크를 넣는다, 4분 30초 더 끓인다로 되어있다. 세 단계의 추상화 레벨이 비슷하다. 물을 끓이는 단계는 더 쪼개면 냄비를 꺼낸다, 정수기를 튼다, 물을 담는다, 가스렌지에 올린다, 불을 지핀다 등등이 있을 것이다. 하지만 이걸 라면 끓이는 법에 포함시키진 않는다. 왜냐면 그것들은 물 550ml를 끓인다라는 단계(함수)로 묶어서 표현했기 때문이다. 이런 식으로 작업(함수)을 어느 수준까지 쪼개서 설명(추상화)할 것인지에 대해 고민을 하게 만들어 준다.

‘클래스는 200줄’ 룰을 지키려면 프로퍼티나 함수, 메서드를 그냥 손이 가는 곳 아무데나 만들어서 쓸 수 없다. 꼭 얘가 이 곳에 있어야 하는 이유를 찾아야 한다. 해당 클래스에 있을 이유가 없으면 SRP 위반이다. 가장 쉽게 판단할 수 있는 방법 하나는, 함수나 메서드 내부에서 self의 프로퍼티나 메서드를 얼마나 쓰고 있는지 보는 것이다. 만약 하나도 쓰고 있지 않다면 그 클래스 안에 있을 이유가 전혀 없는 것이다. 또는 self의 호출 빈도가 적을수록 클래스와 연관성이 떨어지는 것이니 나중에 클래스를 리팩토링 하거나 다이어트 시켜야 할 상황이 오면 우선적으로 내쫓을 후보가 되는 것이다.

SRP를 모든 패턴의 시작이라고 한다. SRP를 제대로 지키지 못한 채로 코딩을 하면 그 어떤 패턴을 도입해보려고 해도 잘 안될 가능성이 높다고 한다. 클래스는 하나의 역할만 해야한다는 것, 객체지향을 배우면서 가장 먼저 배우는 원칙임에도 정말 지키기 쉽지 않은 것 같다.

Open-Closed Principle

개방폐쇄 원칙은 확장에는 열려 있으나 변경에는 닫혀있어야 한다는 원칙이다. 열려 있다는 것은 기능 추가나 변경을 할 수 있어야 한다는 것이고 닫혀있다는 것은 기능 추가를 할 때 그 모듈을 쓰고 있는 코드들을 줄줄이 수정하지 않아야 한다는 것이다. OCP 위반의 대표적인 예는 어떤 타입에 대한 반복적인 분기문이다. 즉, 하나의 enum에 대해 여러 군데에서 반복적으로 if/switch문을 쓰고 있다면 고민을 해봐야한다. 왜냐하면 이런 경우, 기능 추가는 case를 한줄 추가하는 것만큼이나 쉽지만 그렇게 하는 순간 해당 enum을 스위칭하고 있는 모-오든 코드를 찾아서 수정해줘야 한다. exhaustive하게 짰다면 그나마 컴파일러의 도움을 받을 수도 있겠지만 default문을 추가했다면 그것도 여의치 않다.

OCP는 if/switch를 최대한 안 쓰는 방법을 통해 연습할 수 있다. 모든 분기문을 없애는 것은 아니고(불가능하고) enum 같이 타입을 분기하는 지점에 대해서. 처음에 이 얘기를 들었을때는 말도 안된다고 생각했다. if문은 프로그래밍의 가장 기본 아닌가? 어떻게 if/switch 없이 분기를 할 수 있지? 처음엔 너무 어색하고 답답했지만 시도해보면서 어떤식으로 내 코드에 도움이 되는지 조금씩 느끼고 있다. 앞서 언급한 10-200룰과도 상호보완적이다. 분기문을 없애는 것 만으로도 함수 및 클래스의 길이를 많이 줄일 수 있다.

그러면 if/switch문을 대체할 수 있는 방법 두 가지를 소개한다. 첫번째로 protocol(혹은 class)을 만들고 상속받아 쓰는 방법이다. 이 방법이 직접적으로 OCP를 지키는 구조다. 더 자세한 내용은 이 블로그에 있는 내용을 참고하면 되겠다. 또 한가지 간단한 방법은 딕셔너리를 활용하는 것이다. 다만 이 방법은 OCP를 지키는 구조는 아니다. 얘는 default절이 있는 switch문과 동일한 약점이 있기 때문에 case가 자주 변경될 것 같을 때는 피해야하고, 분기문을 없애고 싶을 때 제한적으로 사용하면 좋을 것 같다.

기존 switch문 코드

switch reason {
  case .initializing:
    self.instructionMessage = "Move your phone".localized
  case .excessiveMotion:
    self.instructionMessage = "Slow down".localized
  case .insufficientFeatures:
    self.instructionMessage = "Keep moving your phone".localized
  case .relocalizing:
    self.instructionMessage = "Move your phone".localized
}

딕셔너리 활용하여 분기문을 없앤 코드

//적절한 곳에 딕셔너리 생성
let trackingStateMessages: [TrackingState.Reason : String] 
                         = [.initializing.        : "Move your phone".localized,
                            .excessiveMotion      : "Slow down".localized,
                            .insufficientFeatures : "Keep moving your phone".localized,
                            .relocalizing         : "Move your phone".localized]

//switch문 대체
self.instructionMessage = trackingStateMessages[reason]

Liskov Subtitution Principle

마지막(?)으로 리스코프 치환 원칙은 상위 타입(수퍼클래스)을 하위 타입(서브클래스)의 인스턴스로 바꿔도 프로그램의 동작을 해치지 않아야 한다는 원칙이다. 정의는 어렵지만 이를 지키기 위한 방법은 의외로 간단하다. 자식이 부모의 동작을 제한해서는 안된다. 전형적인 위반 사례로는 직사각형을 상속받아서 만든 정사각형 클래스를 생각하면 된다. 정사각형은 너비와 높이가 같아야 하기 때문에 너비와 높이를 자유롭게 바꿀 수 있는 직사각형 부모 클래스의 동작을 제한해야만 원하는 동작을 만들 수 있다. 이런 경우가 LSP의 위반이다. LSP를 지키기 위해서는 직사각형이 정사각형을 상속받거나, 아니면 너비와 높이를 아예 let으로 만들어버리면 된다. (값을 바꾸는 동작 자체를 없애버리면 제한할 동작이 없기 때문에)

iOS 프레임워크에서도 LSP 위반 사례를 찾아 볼 수 있는데,

var label = UILabel(frame: .zero)
var button = UIButton(frame: .zero)
var segmentedControl = UISegmentedControl(frame: .zero)
var textField = UITextField(frame: .zero)
var slider = UISlider(frame: .zero)
var switchButton = UISwitch(frame: .zero)
var activityIndicator = UIActivityIndicatorView(frame: .zero)
var progressView = UIProgressView(frame: .zero)
var stepper = UIStepper(frame: .zero)
var imageView = UIImageView(frame: .zero)

let views: [UIView] = [...] //위 뷰들을 UIView 어레이에 저장

views.forEach { $0.frame.size.height = 30 } //뷰들의 height를 30으로 변경

let height = views.reduce(0) { $0 + $1.frame.height } 
print(height) //과연 결과는?

10개의 UIView subclass들의 높이를 30으로 바꿨으니 300이 되길 예상할수도 있지만 실제 결과는 272다. 왜냐하면 일부 뷰들은 intrinsicSize를 마음대로 바꿀수 없게 되어 있기 때문이다. 이렇게 UIView(부모 타입)의 동작(높이를 바꾸는 것)을 제한하는 일부 뷰들(UIProgressView, UISwitch 등)이 바로 LSP 위반이다.

또한 iOS 개발을 하다보면 아래의 코드를 본 적이 있고 만들어낸 적도 있을 것이다. 이처럼 부모의 함수를 오버라이드해서 퇴화시켜 버리는 함수는 만들면 LSP 위반이다.

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

LSP를 절대 어기지 않고 프로그래밍을 하는 것은 어렵다. 하지만 너무 많은 곳에서 LSP를 어긴다면 문제가 생긴다. 상위 클래스를 기준으로 코딩할 수 없어지고, 그렇게되면 상속 자체가 의미가 없어지고 OCP조차 지킬수 없게 된다. 예를 들어 UITableView는 UITableViewCell을 기준으로 만들어져 있기 때문에 우리가 만든 커스텀 셀이 LSP를 지키지 않는다면 테이블뷰도 제대로 동작하지 않을 것이다. 또한 protocol를 만드는 이유도 추상화된 인터페이스를 기준으로 코드를 작성하기 위해서인데 상속받은 타입이 protocol의 메서드를 퇴화시켜버리면 프로토콜을 기준으로 작성된 코드들이 줄줄이 망가질 수 밖에 없다.

상속은 객체 지향 프로그래밍의 중요한 부분이고 잘 쓰면 유용하지만 잘못된 상속을 만들면 문제가 발생할 수 있다. 반면 때에 따라서는 LSP를 위반함으로써 편리함과 단순함을 얻게 될 수도 있다. 결론적으로, LSP를 모든 곳에서 지키려고만 하면 비효율적이고 너무 자주 위반하면 예상하지 못한 결과들 때문에 안정성을 해치게 된다. 그러므로 상속을 만들때는 LSP 위반인지 아닌지를 숙지하고 사용하는 것이 좋겠다.

결론

이 글의 목표는 SOLID 대원칙을 완벽히 이해하려는 것이 아니다. 게다가 모든 원칙을 철저히 다 지키는 코드만 짜려고 하는 것은 비효율적이라고 볼 수도 있다. 예를 들어 모든 것을 OCP로만 짜려고 하면 너어무 불편하다. 분기문을 쓰지 않으려고 매번 프로토콜을 만들고 상속을 해야한다면 개발하는 시간도 오래걸리고 불필요한 복잡성만 증가하는 꼴이다. 따라서 적절한 트레이드 오프가 필요하다. 다섯 가지 원칙 중에서 SRP와 OCP는 이해는 되지만 가장 지키기 어렵고 끊임없이 노력해야하는 부분인 것 같다. 무엇이 정답인지 모르고 헷갈릴때 확실히 정답이 아닌 것을 지워내는 것처럼, 위 원칙을 어기는 코드 습관을 파악해서 일단 그것부터 제거하고 개선해보려고 한다.

끝.

같이 보면 좋은 자료(코드 예제): OOD Principles in Swift, Design Patterns in Swift

이 글의 초안을 읽어준 김형중, 김찬희, 김창기님에게 고마움을 전합니다.