Vibe coding이 대세인 시점에 아직도 코딩을 논하는 것이 조금 old한 것일 수도 있으나, 언제가 기반 실력이 우선이 되어야 한다는 면에서는 새로운 코딩의 시대에서도 마찬가지 일겁니다.

상속을 주로 이용하여 코드를 짜다가 결국 프로젝트를 다시 만든 경험이 있는 사람의 관점에서, 왜 SOLID가 실무에서 의미가 있는지를 정리 해봅니다.

요점은 세가지 정도인데, 첫째, 상속을 기본값으로 두면 안 되는 이유. 둘째, is 관계와 has 관계를 코드 작성 시점에 구분하는 기준. 셋째, 추상 클래스와 인터페이스 중 무엇을 고를지 판단하는 표. 제 판단과 참고 자료의 설명이 갈리는 지점도 그대로 적었습니다.

언어를 배우는 단계와 설계를 배우는 단계는 다릅니다

CS 전공자로써 제가 학부에 다니던 시절에는 OOP가 가장 주류인 개발 개념이었습니다. 요즘은 DOD(Data-Oriented Design) 방식도 많이 쓰이지만, 그때는 객체지향이 사실상 표준어였습니다.

초보 시절의 제 관심사는 오직 언어였습니다. 새 언어를 배우고, 익숙해지고, 또 다른 언어를 배우고. 문법을 아는 것과 코드를 잘 짜는 것이 같은 일이라고 생각했습니다.

그 생각이 깨지는 데는 시간이 좀 걸렸습니다. 개발을 계속하다 보면 언어보다 설계가 결과를 훨씬 크게 좌우한다는 걸 알게 됩니다. 잘 설계된 프로그램은 유지보수도 쉽고 기능 추가도 쉽습니다. 반대로 스파게티처럼 얽힌 프로젝트는 손을 대는 것 자체가 고통입니다.

제 경우 그런 프로젝트를 만나면 대개 처음부터 다시 만드는 쪽을 택합니다. 이건 자랑이 아니라 실패담에 가깝습니다. 다시 만드는 게 고치는 것보다 빠르다고 판단되는 시점이 온다는 건, 애초에 설계가 틀어졌다는 뜻이니까요.

SOLID는 디자인 패턴의 문법이 아니라 전제입니다

SOLID는 디자인 패턴을 이야기할 때 가장 먼저 나오는 기본 원칙 중 하나입니다. 유니티 TIPS - 디자인 패턴의 기초, SOLID 원칙 이해하기 영상에서는 이 관계를 이렇게 설명합니다. 디자인 패턴을 적용하다 보면 자연스럽게 SOLID를 지키게 되고, SOLID를 지키며 개발하다 보면 결국 디자인 패턴에 도달한다는 겁니다. 둘은 순서가 아니라 서로 엮인 관계라는 것이죠.

다섯 원칙을 한 줄씩 정리하면 이렇습니다.

원칙한 줄 정의어길 때 생기는 일
SRP 단일 책임클래스는 하나의 책임만 가진다클래스가 비대해지고 재사용이 불가능해짐
OCP 개방-폐쇄확장에는 열리고 수정에는 닫힌다기능 추가할 때마다 기존 코드를 계속 건드림
LSP 리스코프 치환하위 클래스는 상위 클래스를 대체할 수 있어야 한다상속받고 메서드를 무효화하는 코드가 생김
ISP 인터페이스 분리쓰지 않는 메서드에 의존하지 않는다빈 껍데기 구현이 늘어남
DIP 의존 역전구체가 아닌 추상에 의존한다한 클래스를 고치면 딸린 클래스가 줄줄이 딸려옴

제가 이 표에서 가장 실무 체감이 컸던 건 ISP와 LSP입니다. 나머지 셋은 “그렇게 해야지” 정도로 넘어갈 수 있는데, 이 둘은 안 지키면 코드가 눈에 보이게 망가집니다.

아무 생각 없이 짜면 상속으로 갑니다

어두운 방에서 듀얼 모니터로 코드를 작성하고 있는 개발자

솔직히 말하면 저는 아무 생각 없이 개발할 때 그냥 OOP 상속만 씁니다. 부모 클래스 하나 만들고, 필요한 만큼 상속받아서 채워 넣습니다. 손이 그렇게 움직입니다.

문제는 이 방식이 초반에는 아주 편하다는 겁니다. 클래스 두세 개까지는 아무 문제가 없습니다. 문제는 네 번째, 다섯 번째 클래스에서 터집니다.

SOLID가 상속보다 인터페이스를 권하는 이유가 여기 있습니다. 핵심은 쓰지 않는 메서드가 생기는 문제입니다. 부모의 기능 중 절반만 필요한 자식이 등장하는 순간, 나머지 절반을 어떻게 처리할지 답이 없어집니다.

앞의 영상은 이걸 탈것 예시로 설명합니다. Vehicle이라는 부모 클래스에 전진, 후진, 좌회전, 우회전을 넣어두면 자동차는 잘 동작합니다. 그런데 기차가 등장하면 어떻게 될까요. 기차는 레일 위를 따라 전진과 후진만 합니다. 좌회전과 우회전은 존재할 수 없는 동작입니다.

이때 대부분은 상속받고 좌회전, 우회전을 빈 함수로 만들어 버립니다. 동작은 합니다. 하지만 이건 부모 클래스의 방향성을 따르지 않는 것이고, LSP 위반입니다. 저도 실무에서 이런 일을 꽤 자주 봤습니다.

해법은 인터페이스로 쪼개는 겁니다.

  1. IMovable — 전진, 후진
  2. ITurnable — 좌회전, 우회전
  3. 자동차는 둘 다 구현
  4. 기차는 IMovable만 구현

이렇게 하면 나중에 수륙양용 같은 게 추가돼도 인터페이스 조합만 바꾸면 됩니다. 빈 함수가 단 하나도 생기지 않습니다.

is와 has를 코드 쓰기 전에 구분하십시오

SOLID를 제대로 쓰려면 is 관계와 has 관계를 구분해야 합니다. 저는 이게 SOLID에서 가장 실용적인 부분이라고 생각합니다.

  • is 관계: 상속. “나는 로봇이다”
  • has 관계: 인터페이스 구현. “나는 스위치로 켤 수 있다”

로봇 예시가 명확합니다. 스위치로 작동하는 로봇을 만든다고 해봅시다. Robot이라는 클래스도 있고 Switchable이라는 개념도 있습니다. 둘 다 클래스로 만들면 다중 상속이 필요해집니다.

C++는 다중 상속이 됩니다. 저처럼 C++을 먼저 배운 사람은 C#으로 넘어올 때 이게 안 돼서 답답합니다. 하나만 골라야 하는데, 내가 구현해야 할 기능이 그 하나에 없으면 막힙니다.

그런데 C++의 다중 상속에는 다이아몬드 상속 문제가 따라옵니다. A를 상속한 B1, B2를 다시 함께 상속하면 컴파일러가 어느 쪽 함수를 부를지 모릅니다. 영상에서는 C#이 이 문제를 언어 차원에서 아예 막아버린 것이라고 설명합니다. 다중 상속을 허용하지 않는 언어가 허용하는 언어보다 많고, 비교적 최근 언어일수록 인터페이스를 대안으로 제공합니다.

그래서 답은 이렇게 갈립니다. 로봇은 상속(is), 스위처블·무버블·데미저블은 인터페이스(has). 클래스 상속은 하나, 인터페이스는 여러 개. 이 조합이 결국 표준 해법입니다.

인터페이스는 멤버 변수를 못 가집니다

인터페이스를 제대로 쓰려면 그 특성을 정확히 알아야 합니다. 저는 이 차이를 모르고 쓰면 설계가 틀어진다고 봅니다. 아래 표는 영상에서 정리한 내용을 기준으로 옮긴 것입니다.

항목추상 클래스인터페이스
메서드 구현전체 또는 일부 구현 가능선언만 가능
변수·필드선언·사용 가능불가능
프로퍼티가능선언만 가능
static 멤버가능불가능
생성자가능불가능
접근 한정자전부 사용 가능전부 public
관계ishas

핵심은 두 번째 줄입니다. 인터페이스는 멤버 변수를 가질 수 없고, 메서드와 프로퍼티만 선언할 수 있습니다. 이걸 모르고 인터페이스에 상태를 담으려 하면 설계가 처음부터 꼬입니다.

여기서 제 의견과 자료의 초점이 조금 다릅니다. 영상은 “has 관계 = 인터페이스 구현"으로 정리합니다. 그런데 has-a를 포함 관계, 즉 다른 클래스를 멤버로 갖는 것으로 이해하는 경우도 많습니다. 영상 안에서도 시청자가 같은 질문을 했고, 답변은 언어마다 다르고 다중 상속 이슈와 얽혀 있다는 쪽이었습니다. 저는 이 부분을 이렇게 정리합니다. has는 “무엇을 가지고 있느냐"가 아니라 “무엇을 할 수 있느냐"로 읽는 게 실무에서 덜 헷갈립니다.

그래서 지금 코드를 어떻게 봐야 하나

서버 랙에 꽂힌 모듈을 손으로 빼내고 있는 모습

원칙을 강박으로 삼을 필요는 없습니다. 다만 아래 체크리스트에 걸리는 게 있으면 한 번 멈추고 볼 만합니다.

  • 상속받은 클래스에 빈 함수나 아무것도 안 하는 오버라이드가 있는가 (LSP)
  • 하나의 클래스가 입력, 이동, 사운드를 전부 처리하고 있는가 (SRP)
  • 기능을 추가할 때마다 기존 계산기·매니저 클래스를 수정하는가 (OCP)
  • 인터페이스 하나에 서로 상관없는 메서드가 몰려 있는가 (ISP)
  • A를 고치면 B도 반드시 같이 고쳐야 하는 구간이 있는가 (DIP)

특히 마지막 항목은 제 경험상 프로젝트를 갈아엎게 만드는 1순위 원인입니다. 스위치가 문(Door)을 직접 참조하는 구조를 생각해 보십시오. 나중에 불기둥, 해머 같은 기믹이 추가되면 스위치 코드에 분기가 계속 붙습니다. ISwitchable 인터페이스 하나만 두고 스위치가 그것만 알게 하면, 기믹을 아무리 추가해도 스위치는 건드릴 일이 없습니다.

이 예시가 재미있는 건 DIP를 지켰더니 OCP도 자동으로 지켜졌다는 점입니다. 영상의 표현대로 다섯 원칙은 따로 있는 게 아니라 서로 엮여 있습니다.

원칙을 아는 것과 지키는 것의 거리

Unity의 UI 코드를 보면 Selectable이 UIBehaviour를 상속받고, 포인터 다운·업·이동 핸들러 인터페이스를 각각 나눠 구현합니다. 영상에서도 “이 정도면 너무 잘게 쪼갠 것 아닌가” 싶을 만큼 나눠져 있다고 표현합니다. 원칙을 지키려고 고생한 흔적이 보인다는 것이죠.

저는 이 지점이 중요하다고 봅니다. 원칙을 아는 것과 실제로 지켜서 만드는 것 사이에는 상당한 거리가 있습니다. 인터페이스를 나누는 건 당장은 파일 수만 늘어나는 귀찮은 일입니다. 이득은 6개월 뒤 기능을 추가할 때 나타납니다.

제 결론은 단순합니다. 아무 생각 없이 짜면 상속으로 가고, 조금 더 고민해서 적절한 인터페이스로 SOLID를 지킨 소프트웨어가 결과적으로 고품질입니다. 이건 이론이 아니라, 갈아엎어 본 사람의 체감입니다.

마치며

  • 언어를 배우는 단계가 끝나면 설계가 결과를 좌우합니다. 스파게티 프로젝트는 고치는 것보다 다시 만드는 게 빠를 때가 있습니다.
  • SOLID가 상속보다 인터페이스를 권하는 이유는 쓰지 않는 메서드 문제입니다. 빈 오버라이드가 보이면 LSP 위반 신호입니다.
  • is는 상속, has는 인터페이스. C#처럼 다중 상속이 없는 언어에서는 클래스 하나 + 인터페이스 여러 개가 표준 해법입니다.
  • 인터페이스는 멤버 변수를 가질 수 없고 메서드와 프로퍼티만 선언합니다. 이 특성을 모르면 설계가 처음부터 꼬입니다.
  • 다섯 원칙은 독립적이지 않습니다. DIP를 지키면 OCP도 따라옵니다.

자주 묻는 질문

Q. SOLID 원칙을 꼭 다 지켜야 하나요?

강박을 가질 필요는 없습니다. 다만 원칙 기반으로 개발하면 코드가 깔끔해지고 유지보수가 쉬워진다는 것이 참고 영상의 설명이고, 제 경험도 같습니다. 특히 빈 오버라이드와 순환 참조 두 가지만 피해도 절반은 성공입니다.

Q. 추상 클래스와 인터페이스 중 뭘 써야 하나요?

“나는 ~이다"로 읽히면 추상 클래스, “나는 ~할 수 있다"로 읽히면 인터페이스입니다. 공유할 상태(멤버 변수)나 기본 구현이 필요하면 추상 클래스가 답입니다. 인터페이스는 변수도 생성자도 static 멤버도 가질 수 없습니다.

Q. C#에서 다중 상속이 안 되는 이유가 뭔가요?

C++처럼 다중 상속을 허용하면 다이아몬드 상속 문제가 생깁니다. 공통 조상을 두 경로로 상속했을 때 컴파일러가 어느 구현을 호출할지 판단하지 못합니다. C#은 이 위험을 언어 차원에서 제거하고 인터페이스를 대안으로 제공합니다.

Q. SOLID와 디자인 패턴은 무슨 관계인가요?

디자인 패턴 책들은 대부분 SOLID를 전제로 깔고 시작합니다. 패턴을 적용하면 SOLID를 지키게 되고, SOLID를 지키면 패턴에 가까워집니다. 순서가 있는 게 아니라 서로 엮인 관계입니다.

Q. OOP 대신 DOD로 가는 추세인데 SOLID를 배울 필요가 있나요?

DOD가 많이 쓰이는 건 사실입니다. 다만 SOLID의 핵심은 결합도를 낮추고 변경 범위를 좁히는 것이고, 이건 패러다임과 무관하게 유효합니다. 상용 엔진과 라이브러리 대부분이 이 원칙 위에 만들어져 있어서, 남의 코드를 읽기 위해서라도 알아야 합니다.

참고 자료