[Programming] SOLID - 객체 지향 5대 설계 원칙

반응형

이번 포스트에서는 특정 언어에 대한 학습이 아닌 프로그래밍 스킬에 대해 적어보고자 합니다.

독자 여러분들 중 Java, C#, Python과 같은 객체 지향 프로그래밍 언어를 사용하시고 계신다면 OOP(객체 지향 프로그래밍)이라는 단어를 쉽게 접해보셨을 것입니다.

 

 

 

OOP (Object Oriented Programming)

OOP는 우리 말로 객체 지향 프로그래밍이라는 뜻입니다. 이 객체 지향 프로그래밍을 위키에서 보면 아래와 같습니다.

 

 

객체 지향 프로그래밍 - 위키백과, 우리 모두의 백과사전

 

ko.wikipedia.org

쉽게 얘기하자면 우리가 C 언어로 작성되었던 명령 형식의 절차 지향 프로그래밍과는 달리 OOP는 객체라는 독립된 단위로 프로그램을 형성시키는 스킬입니다. 

 

절차 지향 프로그래밍에서 '절차'는 함수를 의미하며, 반복되는 동작을 함수 및 프로시저 형태로 모듈화하여 사용하는 것입니다. 여기서 프로시저란, return 값이 없는 즉, void 함수를 의미하며 대표적인 예시로 printf가 있습니다.

 

반복된 동작을 모듈화하여 코드를 줄일 수 있는 방법은 나름 획기적이긴 하나, 프로시저 자체로는 굉장히 추상적입니다. 예를 들면, 상품을 등록하고 관리하는 재고 관리 프로그램을 작성한다고 생각해봅시다.

 

  • '상품' 이라는 자료형을 구현해야 함.
    (종류가 여러 개라면 속성이 필요하지만 하나의 상품이라고만 생각해두자.)

  • '상품'에 대한 함수를 구현해야 함. 
    (등록, 삭제, 수정 등)

 

위에 대한 요구사항을 생각해봤을 때, 절차 지향 프로그래밍으로 보면 구현하지 못하는 것은 아닙니다. 하지만 만약 구현을 해야한다고 한다면 위 두 사항을 별도로 생각해야 하며 상품은 상품이고, 상품에 대한 함수는 별도로 존재하기 때문에 같은 소스 코드 내에 있다 하더라도 이 자료형과 함수가 연관 관계가 있다라는 걸 보증하기 어렵습니다.

 

즉, 논리적으로 묶여있을 수 없는 구조이기 때문에 동작이 추상적이라고 볼 수 있습니다.

이를 해결하기 위한 패러다임으로 OOP가 등작한 것입니다.

 

 

 

객체 지향 설계

그렇다면 우리가 요구사항을 받아 객체 지향 프로그래밍으로 최대한 장점을 이끌어내기 위해 프로그램을 개발해야 한다면 어떻게 설계하는 것이 좋을까요?

 

  1. 먼저 요구사항(제공해야 할 기능)을 세분화 합니다.

    기획자로부터 받은 요구사항을 최대한 자세하게 정의 받고 이를 세분화 하여 정리해놓습니다. 

  2. 1번에서 세분화한 내용을 Class Diagram 등으로 구현해 놓습니다.

    Class Diagram은 우리가 요구사항을 객체로 표현하기 위해 프로그래밍 코드를 작성하기 위한 설계도입니다.

  3. 기능을 구현하는 데 필요한 데이터를 객체에 추가합니다.

    Class Diagram에 들어갈 필드 등을 추가하는 작업을 진행합니다. 회의에 따라 자유롭게 추가/삭제할 것입니다.

  4. 3번에서 설계한 객체들의 행동(함수)을 구현합니다.

    예를 들면, 상품 재고 관리 프로그램에서는 상품 추가, 제거, 수정 등이 있을 것입니다.

  5. 객체 간 메서드 호출 방법을 정의합니다.

    상품 객체에 있는 메서드를 다른 객체에서 호출해야 한다면 그 접근을 어떻게 해야 할지 결정해야 합니다.

 

객체 지향 설계는 이렇게 요구사항들을 객체로 정의하여 기능들을 구현하고 각 객체 사이의 관계들을 명세하여 함수들에 대한 접근까지를 정하면 객체 지향 프로그래밍의 장점을 최대한 활용할 수 있습니다.

 

 

 

SOLID

SOLID는 이러한 객체 지향 설계 원칙을 로버트 마틴이 5가지로 정의한 원칙의 알파벳을 딴 것입니다. 

해당 원칙의 가장 큰 목적은 시간이 지나도 유지 보수와 확장이 쉬운 시스템 설계를 하기 위함인데, 어떠한 것인지 있는지 하나씩 살펴보도록 하겠습니다.

 

 

 

SRP (Single Responsibility, 단일 책임의 원칙)

-> 모든 클래스는 하나의 책임만을 부여받으며, 단 하나의 이유만을 바탕으로 변경되어야 한다.

 

우리는 클래스를 이용하여 객체 지향 프로그래밍을 합니다. 이 클래스가 제공하는 모든 기능은 클래스가 부여받은 책임에 기반하여 작성되어야 하는데, 여기서 책임이란 말이 조금 모호합니다.

 

여기서 말하는 책임이란, 한 클래스가 수행할 수 있는 기능이라고 보시면 됩니다.

 

만약 하나의 클래스가 수행할 수 있는 기능이 여러 가지라면 클래스 내부 함수끼리 강결합이 발생하게 됩니다. 강결합이 발생하면, 새로운 요구 사항이나 프로그램 변경으로 인해 클래스 내부의 동작들이 연쇄적으로 변경되는 대참사가 발생하게 되어, 유지보수가 더욱 어려워집니다.

 

코드로 예시를 들어보도록 하겠습니다.

 

register 함수로 인해 RegisterService 클래스는 상품을 등록하는 책임(기능) 뿐만 아니라 상품 등록에 필요한 상품 이미지나 이름 등을 검증하는 책임도 수행합니다. 이렇게 되면 상품을 등록하는 절차가 변경되어도 수정해야 되고, 이름 검증 방법이나 이미지 검증 방법도 바뀌면 이 역시 같은 기능을 수정하게 되는데, 잘못 수정하면 멀쩡하게 동작하는 기능까지 무력화 되버리는 위험성도 내재되기 때문에 단일 책임 원칙에 위반됩니다.

 

각 파라미터에 대한 검증 책임은 각 파라미터별 서비스 클래스에 위임하고 상품 등록은 오직 상품을 등록하는 것에만 책임을 지도록 리팩토링하면 단일 책임의 원칙을 잘 지킬 수 있습니다.

 

다만 주의해야 할 점이 몇 가지 있습니다.

 

  • 단일 책임 원칙의 핵심은 클래스의 변경으로 인한 파급 효과를 최소화 하는 것
  • 위 사항에 너무 집착하여 엄격하게 원칙을 준수하는 걸 강요하는 것은 좋은 방법이 아님.
  • 필요에 따라 원만하게 사용하여 협업을 깨뜨리지 않는 조건을 준수하며 사용을 권장.

 

 

OCP (Open/Closed, 개방 폐쇄의 원칙)

-> 기능의 확장 가능성은 열어두 되, 변경 가능성은 닫아두자.

 

즉, 기존의 코드를 가능한한 변경하지 않고 기능을 추가하거나 수정할 수 있도록 구현해야 한다는 것입니다. 프로그래밍을 한 번 쯤 해봤다면, 그리고 유지보수를 해 본 경험이 있다면, 기존의 코드를 변경하는 것은 개발자에게 있어서 매우 큰 부담일 것입니다. 잘 동작하던 코드를 건드려서 괜히 버그를 발생시키는 것은 아닌지, 기존에 동작한 코드의 이해를 100% 보장하기 어렵다는 점에서 나오는 문제입니다.

 

따라서 기존의 기능의 변경이나 확장이 필요한 경우에는 기존 클래스를 상속, 즉 기존 코드를 유지하면서 요구사항을 만족시키는 것이 가장 안전합니다. 

 

신선 제품을 취급하기 위해 기존의 상품에서 제조 일자와 유통 기한을 추가한 코드입니다.

신선 제품이 아닌 제품군에서는 유통 기한과 제조 일자가 없을 수도 있기 때문에 이렇게 기존 코드를 수정하는 것은 매번 추가되는 상품 종류 요구사항을 반영하기 위해 매번 코드를 수정해야 하는 불편함을 초래하게 됩니다.

 

따라서 Open/Closed 원칙을 지키기 위해 OOP 특징인 상속을 이용해서 코드를 구현하면,

 

이렇게 Product 라는 공통의 상품을 상위 클래스로 놓고, 하위 클래스들이 상속하여 사용할 수 있도록하여 기존의 상품 클래스를 수정하지 않고도 새로운 유형의 상품에 대한 추가 요구 사항을 확장할 수 있도록 합니다.

 

여기서도 주의점이 있습니다.

 

  • 기존 코드를 수정하는 데 쉬운 문제에서 이 원칙을 적용하지 말 것.
  • 추상 클래스, 인터페이스로도 이 원칙을 지킬 수 있다. 상황에 맞게 적용하자.

 

 

LSP (Liskov Substituation, 리스코프 치환 원칙)

-> 자식 클래스를 부모 클래스처럼 사용할 수 있도록 설계하여야 한다.

 

이 원칙은 우리가 위에서 사용한 상속과 관련이 있는 원칙입니다. 만약, 자식 클래스가 부모 클래스에서 기대하는 동작과 다르게 동작한다면, 같은 부모 클래스를 상속하더라도 자식 클래스마다 기대하는 결과가 달라지는 문제가 발생을 하는데, 왜 이런 문제가 발생하는지 알아보죠.

 

이 코드는 Circle-Ellipse Problem 위키의 슈도 코드를 기반으로 구현한 것입니다. 먼저 타원이라는 부모 클래스를 만듭니다. 타원으로 기본적으로 반지름이 다른 원입니다.

 

그런 다음 자식 클래스인 원 클래스를 만듭니다. 원은 기본적으로 양 반지름의 길이가 같기 때문에 하나의 반지름의 길이만 setter 할 수 있도록 구현한다면 버그가 발생합니다.

 

위 main 함수를 보면 버그를 발생하는 케이스가 있습니다. 바로 다른 반지름을 4로 설정해버리면 Circle 클래스는 Circle의 넓이가 아닌 Ellipse의 넓이가 구해지는 버그가 발생하게 되는 것입니다.

 

여러가지 방법이 있겠지만 이런 경우에는 추상 클래스를 이용해 풀어볼 수 있습니다.

 

행동이 아닌 모습이 비슷한 부분이라면 추상을 통해서 큰 틀을 만들고, 이들을 새로 구현하는 것 방법으로 나아간다면 원하는 요구 사항을 보다 정확하게 구현할 수 있습니다.

 

잘 보면, 리스코프 치환 원칙을 지키지 않았을 때 결국에는 개방 폐쇄 원칙도 지켜지지 않는 파급 효과가 나타나게 됩니다. 기능 확장을 위해 여러 번 코드를 수정해야 하는 일이 잦아지게 되기 때문입니다.

 

  • 상속 관계에서는 반드시 일반화 관계가 성립되어야 함.
  • 단순히 재사용 목적을 위한 용도로 설계하지 말아야 함.
  • 처음부터 모든 것을 완벽하게 설계하는 것은 불가능하다. 따라서 처음에는 구체화 보다는 추상화에 의존하자.
  • 추상화 레벨을 가급적 단순하게 하자.

 

특히 마지막 사항은 처음 OOP하시는 분들이 많이 실수하는 부분인데, 추상화를 통해 코드를 깔끔하게 하는 것이 목적이 아니라 확장이 용이하고 기존의 코드를 살리기 위한 목적이 크므로 추상화를 많이 하게 되면 오히려 분석이 어려우질 수 있으니 너무 추상화에 의존하는 것은 자제하도록 합니다.

 

 

 

ISP (Interface Segregation, 인터페이스 분리 법칙)

-> 인터페이스를 구현하는 클래스는 필요한 함수만 구현할 수 있도록 설계하자

 

인터페이스는 말그대로 구현해야 할 메서드를 정의하기 위한 컴포넌트입니다. 원칙 그대로 인터페이스는 최소한의 크기로만 구현하며 그렇게 해야만 클래스가 어떤 클래스인지 정확하게 묘사할 수 있습니다.

 

우리 서비스가 앞서 사용했던 신선 제품 뿐아니라 패션 제품까지 카테고리를 늘리기 위해 의류 상품에 인터페이스를 추가하였습니다. 그런데, 의류 상품에 상의, 하의만을 고려하여 허리 사이즈(waistSize)를 넣었는데, 생각해보니 모자, 신발 등에는 필요없는 항목입니다.

 

이처럼 인터페이스는 구현해야 하는 부분만 정의해야 확장이 용이해집니다. 구현하지 않아도 될 부분까지 추가해버리면 차후 확장시 기존 인터페이스를 수정해야 하는 영향이 발생하기 때문에 강결합이 생깁니다.

 

위와 같이 의류 제품에 대한 최소한의 필드를 놓고 바지, 겉옷, 모자, 신발 등 같은 분류에 넣어 확장하기 용이하도록 설계합니다.

 

  • 인터페이스의 최소화는 하나의 인터페이스에 반드시 필요한 기능만을 구현해두자. 
  • 위 사항을 이용해 class <-> interface가 1:1이 된 설계가 되지 않도록 하자.

 

 

DIP (Dependency Inversion, 의존 역전 원칙)

-> 상위 모듈과 하위 모듈은 추상적인 약속을 기반으로 소통해야 한다. 자세한 구현은 추상적인 개념에 기반해야 한다.

 

객체를 사용할 때는 구체적인 클래스가 아닌 추상 클래스나 인터페이스에 의존해야 한다는 원칙입니다. 

객체 지향 설계의 핵심인 모듈 간 의존 관계 해소를 강조하는 원칙인데, 모듈 간의 의존 관계가 낮아져야만 모듈 세부 사항을 변경하더라도, 전체 코드에 미치는 영향이 적고, 적은 양의 코드 변경으로 최대 효과를 누릴 수 있기 때문입니다.

 

위 코드는 이메일과 SMS를 보내고 난 뒤, 사용자에게 알림을 발생시키는 코드 구현 중 일부입니다. 사용자에게 알림을 담당하는 Notification 클래스는 Email을 전송하는 Email 클래스와 SMS 클래스를 직접 호출하는데, 이렇게 되면 Email 클래스나 SMS 클래스의 send 메서드 이름이 변경되거나 호출하는 방법이 변경되는 등 어느 하나의 클래스라도 변경이 발생하면 Notification 클래스도 같이 변경해야만 합니다.

 

이러한 강결합을 해결하기 위해서는 interface를 이용하는 방법이 있습니다.

 

상위 모듈과 하위 모듈의 결합을 느슨하게 하기 위한 방법으로 둘이 서로 규약할 수 있는 인터페이스를 중간에 하나 사용하여 두 클래스가 서로 강하게 결합하지 않도록 중간 다리를 하나 만들어주는 것입니다.

 

이렇게 하면 하위 모듈이 추가로 더 생기더라도 IMessage 라는 인터페이스 규약만 지켜지면 문제가 없습니다.

 

  • 이 원칙의 핵심은 하위 모듈이 상위 모듈에서 정의한 추상 타입을 의존한다는 것임.
  • 따라서 하위 모듈이 변경 되더라도 상위 모듈은 전혀 신경쓰지 않음.
  • 이 원칙은 그저 원칙으로만 여겨질 수 있다. 하지만 OOP는 소프트웨어 개발의 생산성을 올리기 위함, 절대 원칙을 강요하지 않고, 논리적으로 설득하라. 

 

특히 마지막 주의 사항에서 논리적인 설득에는 명확한 데이터에 기반하여 설득하는 것이 있습니다. 무작정 SOLID 원칙이 좋다고 해서 동료들에게 원칙을 강요하는 커뮤니케이션이 된다면 그 소프트웨어는 생산성이 바닥으로 떨어질 수 있습니다.

 

 

 

마치며...

SOLID 원칙이라는 프로그래밍의 중요한 개념을 적어봤습니다. 좀 긴 내용이니 정리해보자면...

 

  • SRP와 ISP의 핵심은 무분별한 객체의 크기를 막아줌.

    -> 하나의 클래스 혹은 인터페이스에서 필요 이상의 코드를 적지 않도록 함으로써 관점을 흐리지 않아 한 기능의 변경이 다른 곳까지 영향을 미치지 않도록 하여 유지보수성을 좋게 합니다.

  • LSP와 DIP는 OCP를 지원하는 역할

    -> OCP는 자주 변화하는 부분을 추상화하고, 다형성을 이용하여 기능 확장을 용이하게 하되, 기존 코드에는 보수적이도록 합니다. (변화하는 부분을 추상화 하는 것: DIP, 다형성 구현을 도와주는 것: LSP)

 

또 하나, 앞서 말씀드리는 것처럼 설계 원칙은 우리가 어떤 하나의 소프트웨어를 개발하기 위해 정하는 일종의 규칙입니다. 같이 개발하는 동료들과 공감대를 형성하는 것이 우선일 것이며, 이를 지나치는 원칙은 강요가 되고, 이는 소프트웨어의 생산성을 저해하는 원인이 됩니다.

 

 

 

반응형

Tistory Comments 0