객체 지향과 디자인 패턴 : 네이버 도서
네이버 도서 상세정보를 제공합니다.
search.shopping.naver.com
[1~2. 객체 지향]
유연한 구조를 만들 수 있게 해주는 객체 지향
소프트웨어의 가치는 사용자가 요구하는 기능을 올바르게 제공하는 데에 있다. 이 요구사항은 언제나 변하기 때문에 시간의 흐름에 따라 이전에 필요 없다고 생각했던 기능이 필요해질 수도 있고, 기존에 구현된 기능의 일부를 변경해야 할 수도 있다.
소프트웨어는 이 흐름에 따라 유연하게 변화할 수 있어야 한다. 처음부터 복잡한 구조로 시작하다보면 나중에 가서는 수천, 수만 줄에 이르는 매우 복잡한 설계를 가지게 되는데, 여기서 새로운 요구사항이 발생하면 그에 따른 수정 비용과 시간은 만만치 않을 것이다. 이것보다는 변화에 잘 대응할 수 있도록 소프트웨어의 구조를 잘 설계하고, 더 빠른 시간에 더 적은 노력을 들여서 수정하는 것이 낫다.
변화 가능한 유연한 구조를 만들어 주는 핵심 기법 중 하나가 바로 객체 지향이다.
절차 지향과 객체 지향
객체 지향 언어인 자바를 사용하더라도 실제 결과물은 절차 지향적으로 진행되는 경우를 많이 경험했을 것이다. 이는 객체 자체와 객체 지향의 핵심인 캡슐화 및 추상화가 적용되지 않았기 때문이다.
절차 지향은 데이터를 조작하는 코드를 별도로 분리해서 함수나 프로시저와 같은 형태로 만들고, 각 프로시저들이 데이터를 조작하는 방식으로 코드를 작성한다. 프로시저는 다른 프로시저를 이용할 수 있고, 각각의 프로시저가 동일한 데이터를 공유할 수도 있다.
다수의 프로시저들이 한 데이터를 공유하는 방식으로 만들어지기 때문에 이런 방식은 프로그램 규모가 커져서 데이터 종류가 증가하고, 이를 사용하는 프로시저가 증가하게 되면 많은 문제가 발생하게 된다. 새로운 요구사항이 생겨서 프로그램의 한 곳을 수정하게 되면 그 데이터를 공유하고 있는 모든 프로시저를 다 수정해야 하는 것이다.
하지만 절차 지향과 달리 객체 지향은 데이터 및 데이터와 관련된 프로시저를 객체 라는 단위로 묶는다.
객체는 프로시저를 실행하는데 필요한 만큼의 데이터를 가지며, 이러한 객체들이 모여 프로그램을 구성한다. 객체는 자신만의 기능을 제공하며, 각 객체들은 서로 연결되어 다른 객체가 제공하는 기능을 사용할 수 있게 된다.
프로그램의 규모가 작을 경우 절차 지향 방식이 더 간결하게 보일 수 있고, 객체 지향 방식은 복잡한 구조로 보일 수 있다. 하지만 객체 지향적으로 만든 코드에서는 객체의 데이터를 변경하더라도 해당 객체로만 변화가 집중되고 다른 객체에는 영향을 주지 않기 때문에 요구사항의 변화가 발생했을 때 절차 지향 방식보다 프로그램을 더 쉽게 변경할 수 있는 장점이 있다.
객체의 핵심은 기능(오퍼레이션)을 제공하는 것
어떤 객체가 있다고 할 때, 그 객체가 내부적으로 어떤 데이터 타입 값을 가지고 있는지, 어떻게 기능을 구현하는 지는 중요하지 않다. 중요한 것은 객체가 제공하는 기능 그 자체이다.
보통 객체가 제공하는 기능을 오퍼레이션 (Operation) 이라고 부른다. 객체가 제공하는 오퍼레이션을 사용할 수 있으려면, 그 오퍼레이션의 사용법을 알아야 한다. 오퍼레이션의 사용법은 일반적으로 다음과 같이 세 개로 구분되며, 시그니처 (Signature) 라고 부른다.
- 기능 식별 이름
- 파라미터 및 파라미터 타입
- 결과값
객체가 제공하는 모든 오퍼레이션의 집합을 인터페이스(Interface) 라고 부르며, 서로 다른 인터페이스를 구분할 때 사용되는 명칭이 바로 타입 (Type) 이다. 여기서 말하는 인터페이스는 자바 언어의 인터페이스가 아니라, 객체 지향의 오퍼레이션의 집합이라는 뜻이다. 인터페이스는 객체를 사용하기 위한 일종의 명세나 규칙이라고 생각하면 된다.
인터페이스는 객체가 제공하는 기능에 대한 명세서일 뿐, 실제 객체가 기능을 어떻게 구현하는지에 대한 내용은 포함하고 있지 않다. 클래스에서 구현을 정의하는 것은 클래스이고 이 클래스에는 오퍼레이션을 구현하는 데 필요한 데이터 및 오퍼레이션의 구현이 포함되어 있다.
어플리케이션의 구현은 기능을 제공하는 여러 객체들이 모여서 최종적으로 완성한다. 객체들은 서로 오퍼레이션을 실행해달라는 요청과 그 요청에 대한 응답을 주고 받는다. 이 때, 오퍼레이션의 실행을 요청하는 것을 '메시지를 보낸다' 고 표현한다. 자바 언어에서는 메서드를 호출하는 것이 메시지를 보내는 것이 된다.
객체의 책임과 크기
객체는 객체가 제공하는 기능으로 정의된다고 했다. 이는 다시 말하면 객체마다 자신만의 책임을 가진 기능을 가지고 있다는 것이 된다. 한 객체가 갖는 책임을 정의한 것이 바로 타입과 인터페이스이다. 각 객체에 올바른 책임을 할당하기 위해서는 필요한 기능 목록을 정리해야 한다. 이 기능들을 어떻게 객체에게 분배하느냐에 따라 객체의 구성이 달라진다.
ex)
- 파일에 byte 데이터를 제공한다.
- 파일에 byte 데이터를 쓴다.
- byte 데이터를 생성한다.
- 전체 흐름을 제어한다.
기능이 몇 개 안되는 경우에도 객체에 할당할 수 있는 기능 별 다양한 조합의 구성이 가능하다. 게다가 각 상황에 따라 객체가 가져야 할 기능의 종류와 개수가 달라지기 때문에, 모든 상황에 맞는 객체-책임 구성 규칙이 존재하는 것은 아니다. 하지만 객체가 얼마나 많은 기능을 제공할 것인가에 확실한 규칙이 존재하는데 그 규칙은 바로 객체가 갖는 책임의 크기는 작을 수록 좋다는 것이다. 이 뜻은 곧 객체가 제공하는 기능의 개수가 적다는 것을 의미한다.
한 객체에 많은 기능이 포함되면 그 기능과 관련된 데이터들도 한 객체에 모두 포함된다. 이 구조는 객체에 정의된 많은 오퍼레이션들이 데이터들을 공유하는 방식으로 프로그래밍된다는 것인데, 이는 곧 데이터를 중심으로 개발하는 절차 지향적인 방식과 동일한 구조가 된다. 절차 지향의 가장 큰 단점인 기능 변경의 어려움 문제가 발생하게 되는 것이다.
따라서 객체가 갖는 책임의 크기는 작아질수록 객체 지향의 장점인 변경의 유연함을 얻을 수 있다. 이와 관련된 원칙이 바로 단일 책임 원칙 (SRP, Single Responsibility Principle) 이다. 단일 책임 원칙은 객체는 단 한 개의 책임만을 가져야 한다는 원칙이다. 단일 책임 원칙을 따르다보면 자연스럽게 기능의 세부 내용이 변경될 때 변경해야 할 부분이 한 곳으로 집중되고, 다른 객체의 코드가 변경될 가능성은 줄어든다.
의존
객체 지향적으로 프로그램을 구현하다 보면 다른 객체가 제공하는 기능을 이용해서 자신의 기능을 완성하는 객체가 출현하게 된다. 한 객체가 다른 객체를 이용한다는 것은, 즉 의존한다는 것은 실제 구현에서는 한 객체의 코드에서 다른 객체를 생성하거나 다른 객체의 메서드를 호출한다는 것을 뜻한다.
객체를 생성하든 메서드를 호출하든 파라미터로 전달받든, 다른 타입에 의존하게 되면 의존하는 타입에 변경이 발생할 때 자신 객체도 함께 변경될 가능성이 높다. 순환 의존이 발생할 경우 적극적으로 이를 해소하는 방법을 찾아야 한다. (의존 역전 원칙, DIP - Dependency inversion principle)
캡슐화
객체 지향은 기본적으로 캡슐화를 통해서 한 곳의 변화가 다른 곳에 미치는 영향을 최소화한다. 캡슐화는 객체가 내부적으로 기능을 어떻게 구현하는지를 감추는 것이다. 이를 통해 내부 기능의 구현이 변경되더라도 이 기능을 사용하는 코드는 영향을 받지 않도록 만들어준다. 즉 내부 구현 변경의 유연함을 주는 기법이 바로 캡슐화이다.
예시로 아래 코드를 한 번 보자. 이 코드는 절차 지향적으로 작성된 코드이다.
// member.getExpiryDate() 는 만료 일자 데이터를 가져오는 메서드이다.
if (member.getExpiryDate() != null &&
member.getExpiryDate.getDate() < System.currentTimeMillis()) {
//만료 되었을 때의 처리
}
이 코드를 사용하던 도중에, female 회원인 경우 기간을 30일 더 늘려주는 요구사항이 생겼다고 하자. 그렇다면 이 코드를 사용하고 있는 모든 곳으로 가서 member 가 female 일 경우~ 라는 코드로 변경해주어야 한다. 수고스럽게 다 변경해주었다고 해도 다음에 다른 요구사항이 생기면 그에 맞춰서 또 전체 코드를 변경해주어야 하는 문제가 발생한다. 이렇게 데이터를 중심으로 프로그래밍 한 경우 이 데이터를 사용하는 모든 코드들도 연쇄적인 변경이 생긴다.
이제 이 코드를 객체 지향적으로 바꿔보자.
// 메서드 구현
public boolean isExpired() {
return expiryDate != null && expiryDate.getDate() < System.currentTimeMillis();
}
// 실제 코드에서 사용
if (member.isExpired()) {
//만료에 따른 처리
}
이 코드를 살펴보면, 다른 클래스에서는 isExpired() 메서드가 만료 여부 확인 기능을 제공한다는 것만 알고 있고 어떻게 내부적으로 구현 되었는지는 알지 못한다.
그렇다면 이번에도 female 회원인 경우 기간을 30일 더 늘려주는 요구사항이 생겼다고 하자. 일단 먼저 isExpired() 메서드 내부로 가서 if (female) 일 경우~ 를 추가해주면 된다. 그리고 이 메서드가 사용된 코드로 가서 변경할 사항이 생겼는지 확인해 보면 전혀 수정할 사항이 없는 것을 확인할 수 있다. 내부 구현만 수정했을 뿐 이 기능을 수행하고 있는 isExpired() 라는 코드는 변경할 필요가 없는 것이다.
이렇게 기능 구현을 캡슐화하면 내부 구현이 변경되더라도 기능을 사용하는 곳의 영향은 최소화할 수 있다. 이는 캡슐화를 통해 내부 기능 구현 변경의 유연함을 얻을 수 있다는 것을 의미한다.
캡슐화를 위한 두 개의 법칙
캡슐화를 잘 지키기 위한 두 개의 규칙은 아래와 같다.
1) Tell, Don't Ask : 데이터를 물어보지 않고 기능을 실행해달라고 말하는 규칙이다. 즉 아래와 같이 기능 실행을 요청하는 방식으로 코드를 작성하면 된다. 기능 실행을 요청하는 방식으로 코드를 작성하다 보면, 자연스럽게 해당 기능을 어떻게 구현했는지의 여부가 감춰진다.
if (member.isExpired()) {
//만료에 따른 처리
}
2) 데미테르의 법칙 (Law of Demeter)
- 메서드에서 생성한 객체의 메서드만 호출
- 파라미터로 받은 객체의 메서드만 호출
- 필드로 참조하는 객체의 메서드만 호출
데미테르의 법칙을 이해하기 위해 아래 코드를 살펴보자.
public void processSome(Member member) {
if (memeber.getDate().getTime() < ... ) {
// 구현
}
}
이 코드는 데미테르의 법칙을 어긴 것이다. 데미테르의 법칙에 따르면 파라미터로 전달 받은 객체의 메서드만 호출하도록 되어 있는데, 위 코드의 경우 파라미터로 전달받은 member 의 getDate() 메서드 호출 뒤에, 다시 getDate() 가 리턴한 Date 객체의 getTime() 메서드를 호출했기 때문이다.
따라서 데미테르의 법칙을 따르려면 위 코드를 member 객체데 대한 한 번의 메서드 호출로 변경해 주어야 한다. 이는 결국 데이터 중심이 아닌 기능 중심으로 코드를 작성하도록 유도하기 때문에 기능 구현의 캡슐화를 향상시켜 준다.
// 기존 코드
member.getDate().getTime()
// 데미테르의 법칙을 지킨 코드
member.someMethod()
**데미테르의 법칙을 지키지 않은 전형적인 증상**
- 연속된 get 메서드 호출
value = someObject.getA().getB().getValue();)
- 임시 변수의 get 호출이 많음
A a = someObject.getA();
B b = a.getB();
value = b.getValue();
[3. 다형성과 추상 타입]
다형성
다형성 (Polymorphism) 은 한 객체가 여러 가지 (poly) 모습 (morph) 을 갖는다는 것을 의미한다. 여기서 모습이란 타입을 말하는데, 즉 다형성이란 한 객체가 여러 타입을 가질 수 있다는 것이다. 자바에서는 타입 상속을 통해서 다형성을 구현한다.
public class Plane {
public void fly() {
}
}
public interface Turbo {
public void boost();
}
public class TurboPlan extends Plane implements Turbo {
public void boost() {
//가속
}
}
위의 코드는 TurboPlan 이 Plane 과 Turbo 를 상속받고 있다. 이는 TurboPlane 한 객체가 아래와 같이 여러 타입을 가질 수 있다는 것을 의미한다.
TurboPlane tp = new TurboPlane();
Plane p = tp;
p.fly();
Turbo t = tp;
t.boost();
구현 상속을 할 때 재정의 (Overriding) 를 통해서 하위 타입은 상위 타입에 정의된 기능을 자기에 맞게 수정할 수 있다. 예를 들어 TurboPlane 클래스는 Plane 에 정의된 fly() 메서드를 새롭게 구현하고 싶을 경우 다시 자신의 클래스에서 재정의 할 수 있다.
추상화
추상화(abstraction) 는 데이터나 프로세스 등을 의미가 비슷한 개념이나 표현으로 정의하는 과정이다. 추상화된 타입은 오퍼레이션의 시그니처만 정의할 뿐 실제 구현을 제공하지는 못한다.
추상 타입을 실제 구현으로 연결하기 위해서는 상속을 이용하면 된다. 즉 구현 클래스가 추상 타입을 상속받는 방법으로 둘을 연결한다. 이런 구현 클래스들은 실제 구현을 제공한다는 의미에서 '콘크리트 클래스' 라고 부른다. 추상 타입을 사용하면 기존 코드를 건드리지 않으면서 콘크리트 클래스를 교체할 수 있는 유연함을 얻을 수 있다.
인터페이스에 대고 프로그래밍하기
추상 타입을 통해 얻을 수 있는 재사용의 유연함은 '인터페이스에 대고 프로그래밍 하기' 라는 규칙을 통해 얻을 수 있다. 이 말은 실제 구현을 제공하는 콘크리트 클래스를 사용해서 프로그래밍하지 말고, 기능을 정의한 인터페이스를 사용해서 프로그래밍 하라는 뜻이다. 추상 타입을 이용하면 기존 코드를 건드리지 않으면서 콘크리트 클래스를 교체할 수 있는 유연함을 얻을 수 있는데 '인터페이스에 대고 프로그래밍하기' 규칙은 바로 추상화를 통한 유연함을 얻기 위한 규칙이다.
인터페이스는 그 인터페이스를 사용하는 코드 입장에서 작성해야 하고, 의미도 조금 더 명확하게 해주어야 한다.
주의할 점은 유연함을 얻는 과정에서 추상 타입이 증가하고 구조도 복잡해지기 때문에 모든 곳에서 인터페이스를 사용해서는 안된다는 것이다. 이 경우 불필요하게 프로그램의 복잡도만 증가시킬 수 있다. 그래서 변경 가능성이 희박할 때는 인터페이스는 지양하고, 변경 가능성이 높은 경우에 한해서만 사용해야 한다.
테스트 주도 개발 (TDD)
TDD 는 구현할 코드에 대한 테스트를 먼저 작성하고, 작성한 테스트를 통과하는 코드를 점진적으로 완성해 나가는 방식으로 개발을 진행한다. 이렇게 하다 보면 자연스럽게 아직 완성하기 힘든 구현 때문에 테스트를 할 수 없는 경우가 발생하는데, 이런 경우 테스트를 할 수 없게 만드는 부분을 별도의 인터페이스로 분리하게 되면 Mock 객체 (실제 콘크리트 클래스 대신에 진짜처럼 행동하는 객체)를 만드는 방식으로 테스트를 진행할 수 있다.
이 때 별도로 분리되는 인터페이스는 테스트 대상이 되는 클래스와 구분되는 책임을 갖게 되는 경우가 많으며, 이는 곧 새로운 책임을 도출하게 된다는 것을 뜻한다. 이렇게 TDD 는 테스트를 강제함으로써 알맞은 책임을 가진 객체를 도출하도록 유도한다. 즉, 객체 지향 설계를 유도하는 좋은 개발 방식이 바로 TDD 이다.
'독서&그 외' 카테고리의 다른 글
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴' 을 읽고 (7장, 주요 디자인 패턴) (0) | 2022.10.12 |
---|---|
'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴' 을 읽고 (4장~6장) (0) | 2022.10.10 |
'그림으로 배우는 HTTP & Network Basic' 10장~11장 (0) | 2022.10.07 |
'그림으로 배우는 HTTP & Network Basic' 7장~9장 (0) | 2022.10.06 |
'그림으로 배우는 HTTP & Network Basic' 4장~6장 (1) | 2022.10.05 |
댓글