객체 지향 프로그래밍(OOP)과 객체 지향 프로그래밍 설계의 5가지 원칙 SOLID에 대해 알아보자

Explanation

오늘은 간단한 예시를 통해서 객체 지향 프로그래밍과 객체 지향 프로그래밍 설계의 5가지 원칙인 SOLID에 대해서 가볍게 알아보겠습니다!

이 글에 직접적인 코드 예제를 사용하고 있지는 않지만 TypeScript를 염두하여 작성하였기 때문에 Java나 C#의 Interface나 다형성 관련 부분은 포함되어 있지 않습니다.

1. 객체 지향 프로그래밍

우선, 프로그래밍이란?! 사람이 컴퓨터에게 명령을 내리는 행위를 이야기합니다.
일반적으로 가장 간단한 프로그래밍 방법은, 필요로 하는 일을 하나하나 컴퓨터에게 설명하는 방법인데 이는 내용이 장황해지고 반복이 많아 비효율 적이며, 이는 이후에 직관적이지 않고 방대한 양으로 인해 수정이 어려워지게 되는 문제가 있습니다.

이를 개선하기 위한 하나의 방법이 많은 반복이 일어날 것으로 예상되는 일을 별도로 묶어놓고 필요할 때마다 가져다 사용하는 방법입니다. 그리고 여기서 이 묶음이 바로 함수입니다. 이렇게 함수를 사용하여 순차적으로 컴퓨터에게 명령을 내리는 프로그래밍 방법을 절차 지향 프로그래밍이라고 합니다.

그리고 점점 프로그램이 커지고 복잡해지다 보면 이 방법으로는 기능 확장하거나 유지 보수하기에 어려워지게 됩니다. 예를 들어, 함수가 1,000개 이상으로 많아진다면 프로그램에 새로운 기능을 추가할 때, 필요한 함수를 찾기 어려워지고 이는 중복 함수를 만들 게 되어 함수의 수가 더 늘어나는 악순환이 될 수 있습니다. 또한 프로그램을 유지 보수하던 중 문제가 발생하였을 때, 많은 함수들의 각각의 의존성 때문에 수정이 어렵기 때문에 유지 보수가 힘들어집니다.

위와 같은 문제로 나온 방법이 객체 지향 프로그래밍으로, 사람도 일을 할 때 직업을 나누고 그 직업에 맞는 능력과 그 직업에 맞는 기능을 수행하는 것처럼 프로그램에 객체를 만들어서 각 객체마다 역할을 부여하여 일을 시키는 방법입니다.

현실 세계를 예로 들어보면, 편의점을 운영한다고 했을 때, 이전까지는 모든 직원들이 모든 일을 하고 있었는데 이제는 ‘계산하는 직원’, ‘물건을 정리하는 직원’, ‘청소를 하는 직원’, ‘물건을 발주하는 직원’ 등.. 이렇게 역할을 나눈 것입니다. 여기서 ‘계산하는 직원’의 상태를 속성으로 그리고 ‘계산하는 직원’이 하는 일을 메서드로 정의한 것이 하나의 클래스가 되는 것이고 실제로 편의점에서 일하는 직원은 ‘계산하는 직원’ 클래스의 인스턴스가 되는 것입니다.

이 글에서는 이후, 계속 이 편의점을 예시로 사용합니다!

이렇게 역할을 클래스로 나눔으로써 프로그램을 확장할 때에는 확장 대상의 클래스를 변경을 하거나 또는 새로운 클래스를 추가하여 새로운 기능을 확장할 수 있고, 문제가 생겼을 때는 문제가 발생한 클래스만을 확인하고 수정하면 되기 때문에 유지 보수하기에 용이하게 됩니다. 이러한 프로그래밍 방법이 바로 객체 지향 프로그래밍입니다.

2. 클래스

이어서 객체 지향 프로그래밍을 하기 위한 객체(Class)에 대한 특징에 대해 알아봅니다.

캡슐화

프로그래밍을 하다보면 많은 경우에, 어떠한 일을 처리할 때 특정 클래스가 독립적으로 이를 해결하지 않고 여러 클래스가 힘을 합쳐서 주어진 일을 해결하게 됩니다. 그리고 이렇게 여러 클래스가 함께 일을 해결할 때를 고려하여 각 클래스가 서로에 대해서 영향을 줄 수 있는 영역과 영향을 줄 수 없는 영향을 나누어 놓는데, 이를 캡슐화라고 합니다. 클래스가 정의될 때 외부에서 알 수 있고 사용할 수 있는 영역과 그렇지 않은 영역을 분명히 함으로써 각 클래스 간의 의존성을 낮추고 각 클래스가 자신의 역할에만 충실할 수 있도록 합니다. 그리고 이러한 명확한 부분은 전체적인 코드의 복잡성을 줄이고 클래스 자체의 유연성을 높여줍니다.

아까 이야기했던 편의점을 예로 들면, ‘물건을 정리하는 직원’은 자신의 역할인 ‘물건 정리’를 위해 편의점의 어떤 곳에 어떤 물건이 있고 현재 물건들의 재고가 어떻게 되는지 알고 있습니다. 그리고 ‘물건을 발주하는 직원’은 자신의 역할인 ‘발주’를 위해 어떠한 물건이 부족할 때 어떤 곳에 어떻게 발주해야 하는지 알고 있습니다.

여기서 ‘물건을 발주하는 직원’은 물건을 발주하기 위해서는 ‘물건을 정리하는 직원’에게 어떤 물건의 재고가 부족한지 확인해야 합니다. 이렇게 어떠한 일을 해결하기 위해서 여러 클래스가 함께 일을 해결하게 되는 일이 생기는 것이고 이때, ‘물건을 정리하는 직원’ 클래스에게는 부족한 재고를 알려주는 메서드를 외부에서 알 수 있는 영역으로 설정하고 그리고 그 밖의 어떠한 물건이 어떤 곳에 있는지, 현재 물건들의 재고 상태가 어떤지 등은 외부에서 알 수 없도록 정의하여 클래스가 자신의 역할을 수행함에 있어 외부에 영향을 받지 않도록 하고 자신의 역할을 견고히 할 수 있도록 합니다. 이것이 바로 캡슐화입니다.

상속

프로그램이 점점 커지고 복잡해지면 어떠한 일을 해결하는 역할이 세분화되게 되는데, 이때 클래스는 상속을 통해서 자식 클래스에게 부모 클래스의 속성과 메서드를 물려줄 수 있습니다. 그리고 자식 클래스는 추가적인 자신의 세부적인 역할의 속성이나 메서드를 추가로 정의할 수 있고 또는 부모 클래스의 속성이나 메서드를 재정의할 수도 있습니다.

상속을 하지 않고 별개의 클래스를 추가로 정의할 수도 있지만, (만약 상속으로 구현이 가능함에도 별개의 클래스로 정의한다면) 불필요한 공통의 코드가 생성되는 것이고 이는 변경 사항이 생겼을 때 각각의 클래스 모두에게 변경해 줘야 하는 유지 보수의 어려움이 있습니다. 그리고 무엇보다도 클래스 간의 관계가 생성되지 않는 단점이 있습니다.

상속을 통해 생성된 자식 클래스는 부모 클래스의 속성과 메서드를 물려받습니다. 그리고 이는 부모 클래스가 사용되는 곳에 자식 클래스로 대체될 수 있음을 이야기합니다. 편의점의 예를 들어 이야기하자면, ‘계산하는 직원’은 현금 결제 기능만을 지원하는데, 이 역할을 상속받은 ‘카드 계산도 가능한 직원’을 정의한 것입니다. ‘카드 계산도 가능한 직원’은 상속을 통해 ‘계산하는 직원’의 속성과 메서드를 물려받았기 때문에 ‘계산하는 직원’이 휴가를 갔을 때의 공백을 ‘카드도 가능한 계산하는 직원’이 대체할 수 있습니다.

위와 같이 상속을 통한 클래스 들은 하나의 카테고리로 묶이게 되고, 이는 특정한 자리에 사용할 수 있는 클래스들의 묶음으로 정의되는 것입니다. 편의점의 예를 들어, 편의점 사장이 편의점의 계산대를 추가로 추가 하고 추가한 계산대의 위치할 사람을 정할 때, 고민 없이 ‘계산이 가능한 역할 묶음'(‘계산하는 직원’의 상속 클래스들의 모음) 중에 선택을 하면 됩니다.

나아가, 앞서 이야기한 클래스 간의 관계만을 위한 클래스도 있는데 이것이 바로 ‘추상 클래스’입니다. ‘추상 클래스’는 독립적으로 인스턴스를 만들 수 없고 클래스를 카테고리로 묶는 역할만을 합니다. 그리고 ‘추상 클래스’에는 그 역할을 의미하는, 반드시 정의되어야 하는(실제로 구체화되지는 않은) 메서드(‘추상 메서드’)가 선언되어 있습니다.

편의점의 예를 들면 ‘청소 마스터’라는 이름의 ‘청소하기’라는 추상 메서드가 있는 ‘추상 클래스’를 정의하고 ‘청소 마스터’를 상속받은 ‘물 청소 마스터’, ‘창문 청소 마스터’, ‘바닥 쓸기 마스터’ 등의 ‘청소하기’라는 메서드가 구현된 ‘구체 클래스’를 정의해서 ‘청소 마스터’의 위치에, 창문 청소가 필요하면 ‘창문 청소 마스터’를 위치 시키고 물 청소가 필요하면 ‘물 청소 마스터’를 위치시켜 ‘청소하기’라는 메서드를 사용하여 해당 기능이 동작하게 할 수 있습니다.

3. SOLID

지금까지 객체 지향 프로그래밍과 객체에 대해 얕게나마 알아보았고 이제 객체 지향 프로그래밍에서 많이 이야기되는 객체 지향 프로그래밍 설계의 5가지 원칙인 SOLID에 대해 알아보겠습니다.

‘원칙’이라는 단어가 조금은 엄격하게 느껴질 수도 있지만, ‘객체 지향 프로그래밍’ 자체가 방법론에 대한 이야기이기 때문에, 맞고 틀리고의 접근보다는 여기서의 이야기는 대체적으로 지향하는 방향을 가리키는 것 정도로 생각하면 좋을 것 같습니다.

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

하나의 클래스는 하나의 책임만 갖는다는 원칙입니다. 여기에서도 ‘책임’이라는 단어의 의미가 모호하여 많은 프로그래머들 사이에서도 의견이 분분한 것 같습니다.

이 글 역시 짧은 저의 생각일 뿐 정답이 아닙니다.

이전의 글 내용에서 클래스를 역할로 표현하였습니다. 즉 하나의 역할은 하나의 책임을 갖는다는 이야기가 됩니다. 편의점의 예를 들면 ‘청소하는 직원’에게는 편의점의 청소에 대한 하나의 책임을 갖게 해야 합니다. 현재 ‘청소하는 직원’에게는 청소에 대한 책임만 가지고 있기 때문에 단일 책임 원칙에 부합하는 것으로 보입니다. 하지만 조금 다르게 생각해 보면 ‘청소하는 직원’이 하는 일은 ‘창고 정리’, ‘바닥 청소’, ‘창문 청소’, ‘화장실 청소’가 있습니다. 편의점이 아주 작다면 문제가 되지 않지만, 만약 아주 큰 편의점이라면 현재 ‘청소하는 직원’에게는 너무 많은 책임을 갖고 있는 것입니다.

이렇듯 ‘단일 책임 원칙’이라는 말은 이름 그대로 ‘하나의 클래스는 하나의 책임을 갖는다’라고 이해하기 보다는 ‘하나의 클래스가 갖는 책임은 해당 서비스의 규모나 복잡성에 맞춰 적당한 책임을 가져야 한다.’고 이해하는 것이 더 적합하지 않을까 생각합니다.
그 이유는 ‘단일 책임 원칙’의 책임에 너무 매몰되다 보면 클래스가 무분별하게 많아질 수 있고 이는 오히려 프로그램의 유지 보수를 어렵게 할 수 있습니다. 그렇기 때문에 프로그램의 규모와 복잡도를 고려하여 설계하는 것이 중요한 것 같습니다.

위와 같이 편의점의 크기가 크다면(프로그램이 커지고 복잡하다면) ‘청소하는 직원’의 역할은 ‘창고 정리하는 직원’, ‘화장실 청소하는 직원’, ‘창문 청소하는 직원’ 등으로 나누어 정의함으로 다시 각 역할에 하나의 책임을 갖게 하는 것이 ‘단일 책임 원칙’입니다.

이를 조금 다르게 이야기해 보자면, ‘책임’이라는 단어의 쓰임을 생각해 보면 어떠한 문제가 발생하였을 때 그 문제에 대한 대가를 감당해야 하는 것을 말합니다. 그리고 우리는 그 대가를 감당해야 하는 사람을 책임자라고 부릅니다. 즉, ‘단일 책임 원칙’을 잘 지킨다는 것은 어떠한 문제가 발생하였을 때, 그 책임자를 찾기 쉬워야 하고 그 책임의 범위가 작을수록 좋다는 의미입니다.

편의점의 예를 들면, 어느 날 편의점 사장이 편의점에 왔는데 창문이 아주 더러운 것을 확인하였습니다. 사장은 ‘청소하는 직원’들을 모두 불러서 화장실이 더러운 것에 대해 주의를 주어야 합니다. 이는 열심히 바닥을 청소하던 직원이나 화장실을 청소하던 직원에게 불필요하게 안 좋은 영향을 줄 수 있습니다. 만약 이때 ‘단일 책임 원칙’이 잘 지켜졌다면 ‘화장실 청소 직원’에게만 주의를 줌으로써 문제를 간편하게 해결할 수 있는 것입니다.

개방-패쇄 원칙(OCP: Open-Closed Principle)

클래스는 확장에는 열려있고 수정에는 닫혀있어야 한다는 원칙입니다.

편의점의 예로 들면, ‘계산하는 직원’의 하는 일에는 손님이 구매한 물건의 결제를 처리하는 일이 있습니다. 그런데 여기에는 다양한 결제 수단이 있을 수 있습니다. 현금 결제, 카드 결제, 포인트 결제 등.. 이렇게 결제 수단이 추가될 때마다 이 ‘계산하는 직원’은 손님이 구매한 물건의 결제를 처리하는 일에 조건이 추가되어야 하며 즉, 하는 일을 수정해야 합니다. 이 부분이 바로 ‘개방-폐쇄 원칙’의 ‘수정에는 닫혀 있어야 한다.’ 부분과 맞지 않는 부분입니다.

이 부분을 수정하는 한 가지 방법으로 예를 들자면, ‘계산하는 직원’이라는 클래스를 ‘결제하기’와 같은 결제 로직의 ‘추상 메서드’를 갖는 ‘추상 클래스’로 정의하고 이를 상속받는 ‘현금을 계산하는 직원’, ‘카드를 계산하는 직원’, ‘포인트를 계산하는 직원’ 등..으로 클래스를 나누어 정의하고 ‘계산하는 직원’의 위치에 필요에 따라 해당하는 직원이 일하게 합니다.

그리고 위와 같이 새로운 결제 수단이 추가되어도 새로운 클래스를 추가함으로써 계속 확장할 수 있는데 이 부분이 바로 ‘개방-폐쇄 원칙’의 ‘확장에는 열려있다.’라는 부분을 이야기합니다.

리스코프 치환 법칙(LSP: Liskov Subsitution Principle)

자식 클래스는 부모 클래스를 완전히 대체할 수 있어야 한다는 원칙입니다.

편의점의 예를 들어 ‘물건을 정리하는 직원’을 부모 클래스로 상속받은 ‘이벤트 상품을 진열하는 직원’이라는 기존의 물건을 정리하는 일에 확장해서 이벤트 상품을 돋보이게 진열하는 일까지 할 수 있는 자식 클래스가 있다고 했을때, ‘물건을 정리하는 직원’이 몸이 안 좋아서 갑자기 휴가를 써도, 자식 클래스인 ‘이벤트 상품을 진열하는 직원’은 적어도 부모 클래스가 기존에 하던 일을 완전히 대체하여 수행할 수 있어야 한다는 이야기입니다.

즉, 간단하게 이야기한다면 클래스를 ‘상속’을 하기 전에 이 클래스의 관계가 ‘상속’에 적합한 지, 그리고 자식 클래스에서 부모 클래스의 메서드를 오버라이딩(재정의) 한다면 부모의 의도와 다르게 오버라이딩 되지 않았는지 주의해야 한다는 원칙입니다.

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

클래스는 자신이 사용하는 메서드에만 의존해야 한다는 원칙입니다. 조금 다르게 이야기한다면, 자신이 사용하지 않는 메서드에 구현을 강제해서는 안 된다는 이야기입니다.

여기에서 이야기하는 interface는 Java, C#, 또는 Swift의 protocol과 같은 개념으로 TypeScript에서 이야기하는 interface와는 조금 다른 개념입니다.

아까 이야기했던 ‘청소 마스터’라는 추상 클래스가 있고 이 추상 클래스에는 ‘걸레 빨기’, ‘청소하기’의 추상 메서드를 가지고 있다고 했을 때, 이 추상 클래스를 상속받은 ‘물 청소 마스터’, ‘창문 청소 마스터’, ‘바닥 쓸기 마스터’가 있는데, 여기서 ‘바닥 쓸기 마스터’는 걸레를 사용하지 않는데, 추상 메서드로 ‘걸레 빨기’가 있기 때문에, 자신이 사용하지 않는 메서드를 구현해야 하는 상황이 되는데, 이 부분이 ‘인터페이스 분리 원칙’에 맞지 않는 부분입니다.

위와 같은 상황에서는 ‘청소 마스터’의 추상 클래스에 ‘걸레 빨기’ 추상 메서드를 제거하거나 ‘바닥 쓸기 마스터’를 ‘청소 마스터’를 상속받지 않고 별개로 구현하는 방법 등의 수정 방법이 있습니다.

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

고수준 모듈이 저수준 모듈에 직접적으로 의존해서는 안 되며, 대신 추상화된 인터페이스에 의존해야 한다는 원칙입니다.

여기서의 고수준과 저수준은 추상화가 많이 될수록 고수준이며, 구체적일수록 저수준입니다. 고수준 모듈은 시스템의 상위 정책이나 로직을 구현하고, 저수준 모듈은 이를 지원하는 구체적인 작업을 수행합니다. 아주 간단히, 과일 클래스와 사과 클래스가 있다면, 과일은 사과와 같은 구체적인 클래스의 추상화된 상위 개념이므로 고수준의 클래스입니다.

다시 편의점의 예를 들면, 이전에 ‘개방-폐쇄 원칙’을 이야기할 때 ‘계산하는 직원’이라는 추상 클래스를 만들고 각 결제에 따라 구체 클래스를 정의했는데요, 이번에는 다르게 ‘계산하는 직원’이라는 클래스가 있고 ‘결제 처리기’ 라는 추상 클래스를 정의합니다. ‘결제 처리기’ 추상 클래스는 ‘결제 처리’ 라는 추상 메서드를 가지고 있고, 이제 ‘결제 처리기’ 추상 클래스를 상속받은 ‘현금 결제 처리기’, ‘카드 결제 처리기’, ‘포인트 결제 처리기’라는 구체 클래스를 정의합니다.

여기에서 ‘결제 처리기’ 추상 클래스를 상속받은 ‘현금 결제 처리기’, ‘카드 결제 처리기’, ‘포인트 결제 처리기’는 구체적인 클래스로 ‘계산하는 직원’의 하위 개념으로 저수준의 클래스입니다. 즉, ‘계산하는 직원’은 ‘현금 결제 처리기’, ‘카드 결제 처리기’, ‘포인트 결제 처리기’와 같은 저수준의 클래스에 의존하지 않게 하는 것이 ‘의존성 역전 원칙’에 부합하는 것입니다. 대신 ‘계산하는 직원’은 추상화한 ‘결제 처리기’에 의존하여 로직을 구현해야 합니다.

만약 ‘계산하는 직원’이 ‘현금 결제 처리기’, ‘카드 결제 처리기’, ‘포인트 결제 처리기’와 같은 저수준의 클래스를 직접 의존하게된다면, 새로운 결제 방식이 추가될때마다 ‘계산하는 직원’에도 추가된 결제 방식에 대한 의존을 추가해야 하는 변경이 필요하게 되고 뿐만 아니라 결제 방식이 제거 되거나 변경될때마다 ‘계산하는 직원’ 클래스도 변경을 해줘야 합니다. 이렇게 되면 클래스의 결합도가 높아지고 유연성도 떨어지며 점점 ‘계산하는 직원’의 코드가 복잡해져서 유지보수가 어려워지게 됩니다.