객체지향 프로그래밍
개요
-
컴퓨터 프로그래밍의 역사가 발전하면서 대규모 프로젝트를 개발하는 사례가 점차 늘어갔고, 이에 따라 대규모 프로젝트를 구현함에 있어 나타날 수 있는 유지보수, 모듈화, 확장성 등 여러 문제에 대한 인식이 나타나기 시작했다.
-
특히 1970년대에 그래픽 환경에서 유저 인터페이스를 제공하는 컴퓨터 시스템 구현을 시도했던 제록스의 Palo Alto Research Center (PARC)에서는 ‘스몰토크’라는 언어를 제시하였는데, 이는 상기한 복잡한 문제를 ‘객체’와 ‘클래스’라는 개념을 통해 해결하려는 시도였다. 이러한 논의로부터 촉발되어 ‘객체지향 프로그래밍’이라는 흐름이 나타나기 시작했으며, 특히 이러한 대규모 프로젝트 구현의 문제를 해결하는 것에 깊은 관심을 가진 덴마크 컴퓨터과학자 비야네 스트로스트룹이 제시한 C++이라는 언어가 대중화되어 객체지향 프로그래밍이 점차 대중화되기 시작했다.
-
프로그램의 규모가 커지고 기능 추가 및 유지보수에 참여하는 개발자가 늘어날수록 이에 드는 리소스 또한 커질 수밖에 없는데, 새로 코드를 읽게 된 개발자일수록 기존 코드베이스에서 기능 추가 또는 유지보수가 필요한 부분을 파악하는 데 시간이 들 수밖에 없기 때문이다. (더러는 한 개발자가 이미 과거에 구현한 부분을 다른 개발자가 찾지 못해 다른 곳에 새로 개발하기도 한다.) 객체지향 프로그래밍은 이처럼 큰 규모의 프로그램에 새 기능을 추가하거나 유지보수 하는 데 드는 리소스를 최소화할 수 있는 구조를 설계하기 위해 제시된 개념으로 생각할 수 있다.
객체지향 프로그래밍의 기본 아이디어
-
객체지향 프로그래밍은 전체 프로젝트를 서로 독립적으로 동작하는 여러 개의 객체로 나누어, 먼저 각 객체의 속성(attributes)과 동작(methods)을 정의하고 이를 조합하여(이때 객체간 오가는 정보를 메시지라 하며, 객체의 메소드 호출 및 함수값 리턴을 메시지의 송수신으로 표현하기도 한다) 복잡한 기능을 제공하는 프로그램을 구현한다. 이를 통해 코드의 재사용성과 유지보수성을 높이고, 코드를 더 쉽게 이해할 수 있도록 한다.
-
핵심은 내부에서 각 객체를 높은 완성도로 구현했다면 외부(이 맥락에서 호출자, 클라이언트라고도 한다)에서는 객체의 구체적 구현에 대해 잘 모르고 각 객체의 메소드와 속성에 대한 이해만 있더라도 그것만으로 전체 프로그램 로직을 아무 문제 없이 구현할 수 있게 한다는 점에 있다. 특히 외부에서 객체의 구체적 구현에 모르도록 하게 하는 게 중요한데(encapsulation), 이는 데이터 무결성을 유지하고 코드를 보다 쉽게 이해하고 관리(객체의 내부 구현을 변경했더라도 외부 코드를 수정하지 않아도 된다는 점 등)할 수 있게 하기 위함이다.
- 객체지향 프로그래밍은 이런 캡슐화를 강력 지원하기 위해 내부에서만 사용되는 변수를
private,protected같은 키워드를 통해 선언해서 외부에서 접근 못하게 하고 접근은 getter, setter 함수를 통해서만 할 수 있게 하는 등의 여러 기법을 사용한다.
- 객체지향 프로그래밍은 이런 캡슐화를 강력 지원하기 위해 내부에서만 사용되는 변수를
-
이처럼 외부 로직으로부터 잘 분리된 객체 단위로 프로그램을 구현하도록 하면, 상속 및 다형성 등을 통해 기존 코드를 재사용하면서 기능을 확장하기에도 용이하다는 점에서 추가적 이점이 있다.
-
-
따라서 어떤 프로젝트를 객체지향 프로그래밍의 관점에서 높은 완성도로 개발한다는 것은, 전체 프로젝트를 얼마나 적절히 그리고 효율적으로 여러 객체로 잘 나누었는지와 깊은 관련이 있다. 예를 들어 대규모 프로젝트를 거의 수개 이하의 객체로 거의 나누지 않았다면, 이는 지나치게 하위 계층에서 구현돼야 할 코드를 전체 프로그램 로직 안에 그대로 들어가 있다는 뜻이 된다. 이렇게 되면 한 줄을 수정해도 그것이 전체 프로그램 로직에 어떤 영향을 줄지 파악하기 어려워, 프로그램의 유지보수 및 확장에 상당한 어려움이 발생한다.
- 반대로 지나치게 많은 객체로 분리하는 것도 비슷한 문제가 발생할 수 있는데, 결국 전체 프로그램 로직에서 보이는 객체가 너무 많아지면 분리하지 않았을 때와 마찬가지로 지나치게 하위 계층이 그대로 전체 프로그램 로직에 노출된 것과 마찬가지가 되기 때문이다.
캡슐화
Tell-Don’t-Ask
- 외부 코드에서, 객체의 속성이나 메소드로부터 값을 얻어와서(ask) 그에 대해 연산을 하고 그에 따라 판단을 하는 로직을 구현할 수도 있으나, 외부 코드에서 요구되는 관심사는 단순할 수 있는 반면 이는 상세한 구현 로직이 지나치게 외부에 노출돼있는 것이 되어 응집도가 떨어지고 결합도가 높아지며 유지보수 및 가독성에 불리할 수 있다. 예를 들어 다음 코드는 ‘인출 조건을 유연하게 설정할 수 있다’는 새로운 요구사항이 추가되는 경우 지나치게 외부에서 구현을 변경해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Account:
def __init__(self, balance):
self.balance = balance
def get_balance(self):
return self.balance
def set_balance(self, balance):
self.balance = balance
# 외부 코드
account = Account(100)
if account.get_balance() < 0:
print("잔액이 부족합니다.")
- 이러한 점을 고려하여, 외부 코드에서는 그 외부에서의 관심사만을 단순히 객체에 조회하고(tell) 그에 따른 리턴값만으로 판단을 하는 로직을 구현하는 것을 Tell-Don’t-Ask 라 한다. 예를 들어 다음 코드는 인출 조건을 유연하게 설정할 수 있다는 새로운 요구사항이 추가되더라도 관련 구현을
alert_if_balance_is_okay_to_withdraw메소드만 수정하면 되기 때문에 상대적으로 높은 응집도, 낮은 결합도를 갖게 되어 유지보수 및 가독성에 유리하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Account:
def __init__(self, balance):
self.balance = balance
def set_minimum_balance(self, balance):
self.minimum_balance = balance
def alert_if_balance_is_okay_to_withdraw(self):
if self.balance > self.minimum_balance:
print("인출 조건에 부합합니다.")
# 외부 코드
account = Account(100)
account.set_minimum_balance(10)
account.alert_if_balance_is_okay_to_withdraw()
Law of Demeter
- Demeter project 에서 영향을 받아 이름 지어진 법칙으로(Demeter proejct는 농업의 여신 데메테르의 이름을 본따 붙여짐), ‘다른 객체와 소통하는 객체는 자신의 메소드 호출로 얻어진 객체와만 소통할 수 있고, 그렇게 얻어진 객체에서 다시 메소드를 호출하여 얻은 객체와 소통할 수 없다’ 라는 내용의 법칙이다. 객체 간의 결합도를 낮추고 시스템을 더 모듈화하고 유지보수성을 향상시키기 위한 목적으로 1987년 제시되었다.
1
2
// LoD 위반: Car의 엔진 객체에 직접 접근
int horsepower = car.getEngine().getHorsepower();
-
Tell-Don’t-Ask와는 독립적인 맥락에서 제시된 개념이나, 해결 필요성이나 방법 등에서 encapsulation 관점에서 이와 유사하게 볼 수 있다.
-
LoD 위반 케이스를 해결하려면 wrapper method 를 사용해야 하는데, 일부 경우에는 성능 및 공간 오버헤드가 발생할 수 있다는 등의 단점이 있다.
단일 책임 원칙(Single Responsibility Principle)
-
그러므로 객체지향 프로그래밍에서는 ‘무엇을 기준으로 객체를 분리할 것인가’가 가장 핵심적인 문제가 된다. 이에 관해 객체지향 프로그래밍에서 가장 널리 받아들여지는 원칙은 ‘단일 책임 원칙’ 이다. 즉, ‘하나의 객체는 하나의 기능에 대해서만 책임을 가져야 한다’ 라는 원칙으로, 소프트웨어 공학에서 흔히 이야기 되는 ‘관심사 분리(Separation of Concerns)’를 객체지향 프로그래밍의 관점에서 해석한 것으로 볼 수 있다.
- 관심사 분리란 소프트웨어 공학 전반에서 널리 받아들여지는 개념으로, 관련 있는 개념들끼리는 서로 가까운 곳에서 잘 응집돼있고(high cohesion) 서로 관련 없는 개념들끼리는 낮은 결합도로 연결돼있어(loose coupling) 어느 하나의 수정이 다른 하나의 수정에 큰 영향을 미치지 않아야 한다는 개념이다. 객체지향 프로그래밍처럼 소프트웨어 구현에만 쓰이는 개념이 아니라, 프로그램의 구조 설계부터 개발 방법론, 분업 등 모든 분야에 걸쳐 두루 적용할 수 있는 개념이다.
-
단일 책임 원칙에서 ‘책임’이란 그 객체가 전체 프로젝트에서 가져야 하는 기능, 역할 따위를 의미한다. 구체적으로 어디서부터 어디까지를 ‘단일 책임’의 기준으로 할 것인지를 잘 판단하는 것이 단일 책임 원칙을 잘 지키고 있는 것인지를 결정한다.
-
객체 내에 유사한 기능을 하는 코드들은 하나의 책임범위에 속한다고 볼 수 있다.
-
만약 어떤 객체가 여러 개의 책임을 동시에 갖고 있다면, 그 객체는 전혀 다른 기능에 관한 변경을 수행하는 경우에도 번번히 변경이 일어날 것이다. 이 역시 그 객체가 단일 책임 원칙을 준수하는지 여부를 판단하는 기준이 될 수 있다.
-
-
private키워드를 사용하여 내부 구현을 은닉하면, 객체의 역할에 맞는 메소드만 외부에서 접근 가능하게 할 수 있다. 이처럼 캡슐화를 통해 SRP를 준수하며 프로그램을 개발할 수 있다.
확장에는 열려있고, 변경에는 닫혀있다(Open-Close Principle)
-
객체지향 프로그래밍에서, 코드의 재사용성 및 확장성을 높이기 위한 원칙으로서 개방-폐쇄 원칙이 있다. 이는 객체가 기능 추가 등 확장에는 열려 있어야 하나, 이미 구현된 내용의 수정, 변경에는 닫혀 있어야 한다는 원칙으로, 다음과 같은 이유에서 중요하다.
-
변경에 닫혀있다
-
이미 잘 동작하는 코드에 변경을 최소화하고, 새 기능이나 요구사항을 추가할 때 기존 코드의 직접적인 수정을 피하도록 하는 것을 의미한다. 이는 기존 코드를 수정하면 예상치 못한 버그가 발생할 수 있고, 이미 검증된 기능을 가진 기존 코드를 유지하여 그 기능이 여전히 올바르게 동작한다는 확신을 유지하기 위함이다. 이처럼 변경에 닫혀있는 구조로 프로그램을 구현하는 경우 regression 테스트에 드는 리소스를 아낄 수 있다는 이점도 있다.
-
기존 코드가 변경에 닫혀있어 확장만으로 새 요구사항을 충족할 수 있다면 이는 그만큼 이미 기존 구현이 각 요소간 결합도가 충분히 낮은 상태를 유지하고 있다는 뜻이 된다. 이처럼 변경에 닫힌 구조를 유지하는 것은 그만큼 유지보수가 용이하고 확장성 있는 코드를 구현하는 데 그만큼 높은 중요도가 있다.
-
-
확장에 열려있다
-
새로운 기능이나 요구사항을 추가할 때 기존 코드의 변경 없이 기능을 확장할 수 있도록 하는 것을 의미한다. 이는 곧 기존 코드의 구조를 대부분 그대로 재사용하되 그 중 새 요구사항에 필요한 부분에 한정하여 새로운 구현을 한다는 것인데, 그만큼 잘 동작하는 기존 코드를 최대한 활용할 수 있다는 점에서 개발 리소스를 아끼면서 기존 코드의 안정성을 그대로 유지한다는 점에서 유지보수 등 측면에서 여러 이점이 있다.
-
상속
-
어떤 클래스가 있을 때, 그에 관한 자식 클래스를 두어 그 부모 클래스가 가진 모든 멤버 변수와 메소드를 똑같이 갖도록 하는 것을 ‘상속’이라 한다. 이때, 부모 클래스의 일부 멤버 변수나 메소드를 오버라이드 해서 내부 구현만은 전혀 새로 구현할 수 있다(polymorphism).
-
다형성(polymorphism): ‘메서드나 연산자가 이름이 같아 같은 방식으로 사용되나 다양한 방식으로 동작하여 다른 사용 결과(타입)를 갖는다’는 특성을 뜻한다. 오버라이딩 또는 오버로딩 등의 형태로 구현되며, 상속에서는 상기한 바와 같이 부모 클래스의 다른 메소드는 그대로 사용하면서 일부 구현 방식이 달라져야 하는 부분만 다르게 구현하도록 하는 형태로 활용된다. 코드의 재사용성, 유연성 및 확장성을 높이는 객체지향 프로그래밍의 중요한 특성이다.
-
상속에서 주의할 부분
-
부모 클래스의 변경이 모든 자식 클래스에 영향이 생긴다.
-
부모 클래스의 내부 구현을 자식 클래스에서 사용하면 부모 클래스의 캡슐화가 약해진다. (
protected키워드를 사용하여 자식 클래스에게 접근을 허용하는 경우 등.) -
부모 클래스의 모든 메소드가 필요한 상황이 아니라면 상속하는 것은 피해야 한다.
- 이러한 상황에서는 상속 대신 그 클래스의 속성 중 하나로서 원래 그 클래스를 사용하는 방식으로 코드를 재사용할 수 있다. 이를 composition 이라 하며, 이처럼 상속보다 composition을 선호해야 한다는 원칙을 ‘composition over inheritance’ 라고도 한다.
-
-
-
인터페이스/추상메소드: 인터페이스/추상메소드를 정의해두어, 새로운 클래스를 추가할 때 이를 상속/구현하도록 할 수 있다. 이렇게 구현하면 기존 코드를 거의 그대로 재사용하는 것이 가능하다. 예를 들면, 메소드에서 인자를 인터페이스 또는 부모 클래스의 타입으로 전달받아 그 부모/인터페이스의 메소드를 사용하는 경우를 생각할 수 있다. 이 경우 그 부모 클래스/인터페이스를 상속/구현하는 클래스 또한 이 메소드의 인자로 전달돼 이 메소드의 코드를 그대로 재사용할 수 있다.
-
-
리스코프 치환 원칙(Liskov Substitution Principle)
-
리스코프 치환 원칙이란 ‘자식 클래스에서 메소드를 오버라이드 하는 방향은 부모 클래스 메소드가 의도한 취지를 따라야 한다’라는 원칙을 뜻한다. 예를 들어 어떤 함수가 인자로 어떤 객체를 받고, 그 함수 내에서 그 객체의 메소드가 호출되고 있는 상황을 생각하자. 여기서 만약 인자로 그 객체의 클래스를 상속한 자식 클래스가 전달되는 경우가 있다 하면, 그 함수에서 그 인자 객체 메소드를 실행하는 부분은 인자로 전달된 객체가 자식 클래스더라도 부모 클래스 기준으로 작성된 코드의 의도에 따라 동작해야 하는 것이다. OCP와 마찬가지로, 자식 클래스를 구현하면서 객체지향 프로그래밍의 핵심 취지인 ‘상위 계층에서 객체의 구체적 구현에 대해 잘 모르고 각 객체와 메소드의 인터페이스에 대한 이해만 있더라도 그것만으로 전체 프로그램 로직을 아무 문제 없이 구현’이라는 내용을 실현하기 위해 반드시 준수해야 하는 중요한 원칙이다.
-
만약 자식 클래스를 구현할 때에 리스코프 치환 원칙을 준수하기 어려울 정도로 자식 클래스로 왔을 때 각 메소드의 의미가 크게 달라진다면(예를 들어, 부모 클래스로
Rectangle을 정의하고 자식 클래스로Square를 정의한다면,Square.setHeight()메소드가 갖는 기능은Rectangle.setHeight()메소드가 갖는 기능과 여러 면에서 다를 수밖에 없다), 이는 그 자식 클래스가 그 부모 클래스와 부모-자식 관계로 있을 수 없는 기능 및 개념을 갖고 있기 때문일 수 있다. 이러한 상황에서는 이 부모-자식 상속관계를 유지하기보다 인터페이스 등 새로운 상속관계를 정의하는 것이 대안이 될 수 있다. -
OCP와 LSP는 모두 클래스 간 상속 관계를 어떻게 하는 것이 적합한가에 관해 제시하는 원칙이지만, 주목하는 초점이 다르다. OCP는 ‘모듈, 인터페이스, 클래스 등의 설계가 확장에 열려있고, 변경에는 닫혀 있도록 하는 것이 바람직하다’라는 원칙이라면, LSP는 ‘자식 클래스를 구현할 때에는 그것이 부모 클래스의 설계 의도에 반하지 않도록 해야 한다’라는 원칙이라 할 수 있다. (각 원칙에 따라 구현하고 있음에도 불구하고 어느 한 원칙을 준수하는 것이 곤란한 상황일 경우 인터페이스 수정이 필요할 때가 있을 수 있다는 점에서 해결 방안이 유사한 경우가 있을 수 있기는 하다.)
인터페이스 분리 원칙(Interface Segregation Principle)
- ‘객체가 높은 응집도를 갖되 관련 없는 요소들끼리는 서로 낮은 결합도를 가져야 한다’라는 SRP와 유사하게, 인터페이스 또한 그 기능에 따라 적절히 높은 응집도를 갖는 요소들을 갖되 관련성 없는 요소들끼리 적절히 분리해야 한다는 것이 ISP다. 예를 들어,
Animal이라는 이름의 거대한 인터페이스를 만들어 개와 새가 모두 이 인터페이스를 구현한다고 하면, 개는 사용하지 않는 ‘fly()’ 메서드를 포함해야 할 수도 있다. 대신WalkableAnimal,FlyableAnimal,SwimmableAnimal같은 작고 구체적인 인터페이스로 분리하면, 각 동물 클래스가 자신에게 맞는 인터페이스만을 구현할 수 있다.
의존성 역전 원칙(Dependency Inversion Principle)
-
high-level 모듈을 구현함에 있어 그 내부적으로 low-level 구현에 의존하도록 하지 말아야 한다는 원칙을 DIP 라 한다. 예를 들어, 외부 DB 에 접속해서 데이터를 읽고 쓰는 작업을 수행하는 객체를 생성했는데 이 접속 드라이버를 MySQL 만을 위한 타입으로 정의해둔다면, 만약 외부 DB가 PostgreSQL 로 바뀐다거나 테스트코드를 추가한다든가 하는 상황이 되면 MySQL에 의존적으로 쓰여진 기존 코드를 전부 다 변경해야 한다. 이는 high-level 모듈과 low-level 모듈의 구현의 결합도가 지나치게 높은 것이어서 유지보수에 어려운 구조가 된다.
-
이를 해결할 수 있는 방법이, high-level 모듈 구현에서는 되도록 추상화된 타입, 인터페이스 등에 의존해 구현하는 것이다. 구체적으로 다음과 같은 방법이 있다.
-
메소드에서 외부로부터 객체를 인자로 전달 받을 때는 객체 타입을 인터페이스로 한다.
-
하위 객체의 메소드를 외부에서 호출할 때는 리턴 타입을 인터페이스로 하고, 이 값을 저장하는 속성 또는 로컬 변수 또한 인터페이스 타입으로 선언한다.
-
의존성 주입(dependency inversion)
-
프로그램 로직 내에서 일일이 매번
new키워드로 구체적인 객체를 생성해서 쓸 게 아니라, 생성자의 인자로부터 추상화된 클래스 타입이나 인터페이스로서 객체를 전달받아 이로써 프로그램 내부 로직을 구현하도록 하는 것을 의존성 주입이라 한다. 이 역시 high-level 구현과 low-level 구현 사이 결합도를 떨어트리는 DIP 를 준수하는 구현 사례로 볼 수 있다. -
객체의 생성 및 생명주기 관리를 내부의 코드가 아닌 외부 컨테이너 또는 프레임워크가 담당하게 하는 것을 제어의 역전(Inversion of Control) 디자인 패턴이라 하며, 의존성 주입은 IoC 패턴의 대표적 구현 사례로 볼 수 있다.
-
-
-
low-level 모듈을 추상화할 때에는 그것이 high-level 모듈에서 어떻게 사용될지를 기준으로 해야 한다.