다형성과 추상 타입

디우·2022년 3월 6일
0

다형성과 상속

  • 다형성은 한 객체가 여러 가지 모습(type)을 가질 수 있는 것을 의미한다.
    객체가 A, B, C의 타입을 가지고 있다면 타입 A의 정의된 기능도 실행할 수 있고, B, C에 정의된 기능도 실행 요청이 가능하다.
  • 자바는 정적 타입 언어이다. 즉, 타입이 컴파일 타임에 결정되게 된다. 따라서 타입을 상속함을 통해서(implements, extends) 다형성을 구현한다.
public interface Comparable<T> {
    public int compareTo(T o);
}
public class LottoNumber implements Comparable<LottoNumber> {
	...
    @Override
    public int compareTo(LottoNumber o) {
        return number - o.getNumber();
    }
}

와 같은 코드가 있다고 하면 LottoNumber는 Comparable 타입에 정의된 메소드의 실행을 요청할 수 있다. 또한 적절한 예시는 아니라고 생각 되지만 Comparable comparable = LottoNumber.from(10);와 같이 선언하는 것도 가능하다.

인터페이스 상속과 구현 상속

  • 타입 상속은 크게 인터페이스 상속과 구현 상속으로 구분된다.

  • 인터페이스 상속은 타입 정의만을 상속받는 것이다.

  • 인터페이스 상속의 경우, 인터페이스는 기능(메소드)의 시그니처(메소드 이름, 매개변수, 리턴 타입)만을 제공할 뿐 실제 구현은 제공하지 않는다. 그에 대한 구현 코드는 인터페이스를 상속받은 Concrete Class에서 제공한다.

  • 구현 상속은 클래스 상속(extends)을 통해서 이루어진다.

  • 상위 클래스에서 정의된 기능(메소드)를 재사용할 수 있으며, 재정의(overriding)을 통해서 상위 타입에서 정의된 기능을 수정할 수도 있다.

예시

public class Plane {
	public void fly() {
    	System.out.println("날다");
    }
}
public class TurboPlane extends Plane {
	public void fly() {
    	System.out.println("터보 날다");
    }
}
Plane plane = new TurboPlane();
plane.fly();

위와 같은 코드에서 fly() 실행 시 과연 출력문은 어떻게 될까?
너무 쉬운 질문일 수도 있지만 앞서 설명한 내용을 충분히 이해하고 있는지 확인하기에 적절한 예시 코드라고 생각한다.

현재 참조변수라고 부르는 plane은 Plane 타입이지만 실제로 참조하고 있는 인스턴스는 TurboPlane이므로 TurboPlane에 구현된 fly() 메소드가 호출되어 출력문은 "터보 날다"가 된다.

이것이 다형성의 가장 큰 특징이다. 만약 Plane을 상속하는 다른 Concrete Class가 있다면 어떨까? 해당 plane 이라는 참조변수는 그 Concrete Class로부터 생기는 인스턴스를 참조할 수도 있게 되는 것이다.


추상 타입과 유연함

추상화는 데이터나 프로세스 등을 의미가 비슷한 개념(표현)으로 정의하는 과정이다.

위의 문장만을 통해서 추상화라는 것을 바로 이해하기는 어려울 수 있다. 책의 예시를 이용해서 추상화라는 개념을 다시 한 번 보자.

만약 'FTP에서 파일을 다운로드', '소켓에서 데이터 읽기', 'DB 테이블의 데이터를 조회'라는 기능(프로세스)가 있다고 해보자. 여기서 이 3개의 비슷한 개념(공통된 부분)을 추출하면 어떻게 될까? 위 3가지 기능은 모두 "로그를 수집하기 위한 기능"이라는 결론을 내릴 수 있게 된다.

즉, 위의 3가지 기능으로 부터 "로그 수집" 이라는 개념을 끄집어 내는 과정을 추상화라고 할 수 있는 것이다. 그리고 타입은 이런 추상화의 대상이 된다.

추상 타입은 구현을 제공하지 못한다. 따라서 인터페이스로 정의된다.

위의 문장과 같이 추상화된 타입은 기능(operation)의 시그니처만 정의할 뿐 실제 구현(How)를 제공하지는 못한다. 즉 위의 예시에서 추상화된 개념은 로그 정보를 수집한다.라는 의미만 전달할 뿐 어떻게(How) 수집하는지에 대한 구체적인 내용은 전달하지 못한다.

추상 타입과 실제 구현의 연결

  • 추상 타입과 실제 구현 클래스는 상속(구현, implements)을 통해서 연결한다.
public interface LogCollector {
	public void collect(); //시그니처만 제공. 실제 구현 제공 x
}
public class FtpLogFileDownloader implements LogCollector {
	public void collect() {
    	//상세 구현
    }
}
public class SocketLogReader implements LogCollector {
	public void collect() {
    	//상세 구현
    }
}
public class DbTableLogGateway implements LogCollector {
	public void collect() {
    	//상세 구현
    }
}

그리고 이는 LogCollector collector = createLogCollector(); 와 같이 적절한 Concrete Class의 인스턴스로 할당할 수 있게 되고, 다형성에 의해 collect() 메소드 호출은 실제 collector 참조변수가 참조하는 인스턴스의 객체 타입의 collect가 호출될 것이다.

추상 타입을 이용한 구현 교체의 유연함

Q. 왜 추상 타입을 이용하는 것일까?

물론 처음에는 문제가 되지 않는다고 생각할 수 있다. 오히려 인터페이스를 정의하는 것이 번거롭다고 느낄 수도 있다.

위의 예시에서 처음에는 Ftp를 이용해서만 로그를 수집한다고 하자. 그러면 FtpLogFileDownloader ftpLogFileDownloader = new FtpLogFileDownloader; 와 같이 사용해도 처음에는 문제가 되지 않는다. 하지만 새로운 요구 사항이 추가되어 FTP뿐 아니라 소켓을 통해서도 로그를 수집할 수 있게 되었다고 하자.

그렇다면 if-else 구문으로 조건에 따라 FtpLogFileDownloader와 SocketLogReader를 구분해주면서 로그를 읽어와야되게 될 수 있다.

하지만 이는 유지보수 측면에서 절대 좋은 방법이 아니다. 지금은 두 개 뿐이어서 if-else로 족할지 모르지만 만약 계속해서 추가되어 100개가 넘는 로그 수집 방법이 존재한다고 하면 일일이 이를 다 if 조건문으로 구분해줄 것인가?

로그 수집기를 이용해서 로그를 수집하는 책임을 가지는 클래스가 LogController라고 한다면 이 클래스는 어디서 부터 로그를 수집하는지는 상관이 없고, 로그를 수집한다는 것에만 집중하면 된다.

따라서 생성자 주입등의 방법을 통해서 구체적인 인스턴스를 주입받음으로써 LogController는 로그를 수집한다는 것에만 집중할 수 있게 된다.

결국 우리는 공통된 개념, 비슷한 개념이나 표현으로 정의하는 과정인 추상화를 통해서 LogCollector와 같은 인터페이스를 만들고 이를 상속해서 구현하도록 함으로써 다형성의 장점을 누릴 수 있게 되는 것이다.

Q. 추상화 통해 얻는 장점은?

A. LogController 입장에서는 실제 로그를 수집하는 객체의 종류가 바뀌더라도 변경할 필요가 없게 된다. 또한 LogController에서 로그를 수집하는 제어의 흐름이 변경되게 되어도 객체를 생성하는 부분은 변경되지 않는다.

추상화는 공통된 개념을 도출해서 추상 타입을 정의해 주기도 하지만, 또한 많은 책임을 가진 객체로부터 책임을 분리하는 촉매제가 되기도 한다.
[개발자가 반드시 정복해야할 객체지향과 디자인 패턴 (p.76)]

변화되는 부분을 추상화하기

우리가 추상화를 통해서 변경의 유연함을 얻을 수 있다는 것을 보긴 했지만, 이것이 결코 쉬운 것 만은 아니다. 하지만 추상화를 잘 할 수 있는 팁을 하나 책에서 언급하고 있는데 바로 변화되는 부분을 추상화하는 것이다.

요구사항이 변경될 때 변화가 발생하는 부분은 향후에도 계속해서 변경될 가능성이 있다. 그러한 부분에 추상화를 적용하면 향후 변경에 대해서도 유연하게 대처할 수 있게 된다. 즉, 새로운 기능이 추가되거나 변경될 때 수정을 최소화하면서 새로운 요구사항을 반영할 수 있게 되는 것이다. (추상 타입을 사용하는 코드에 영향을 주지 않으면서 추상타입의 실제 구현만을 변경함으로써)

인터페이스에 대고 프로그래밍하기

  • 인터페이스에 대고 프로그래밍하기 (변화 가능성이 있는 경우에 한해서)

이는 객체 지향의 유명한 규칙 중 하나라고 한다. 그리고 추상화를 통해서 유연함을 얻기 위한 규칙이기도 하다.

그리고 여기서 말하는 인터페이스란 자바의 interface가 아닌 개념적인 인터페이스를 이야기하며, 결국 기능을 정의한 인터페이스를 사용해서 프로그래밍 하라는 의미가 된다.

주의
인터페이스를 통해서 유연함을 얻는 과정에서 추상 타입이 증가하고 구조도 복잡해지기 때문에 모든 곳에서 인터페이스를 사용하는 것은 그리 좋지 않은 생각이다. 위의 규칙에서 괄호안에 언급한 것과 같이 변화 가능성이 높은 경우에 한해서만 사용해야지 그렇지 않고 모든 곳에 사용하게 되면 복잡도만 증가시킬 수 있다.

인터페이스라는 것은 추상화 과정을 통해서 도출되는데, 추상화는 변화가 발생하는 곳에서부터 시작된다.

인터페이스는 인터페이스 사용자 입장에서 만들기

이는 인터페이스의 Naming에 관한 언급이라고 생각되는데, 인터페이스를 작명할 때 특정 구현에 관련된 네이밍보다는 추상적인 이름, 인터페이스를 사용하는 client 입장에서의 이름을 사용해야한다는 의미이다. 즉, 앞선 예에서 FtpLogFileDownloaderIF 라는 이름보다는 LogCollector 라는 이름이 더 적절하다는 의미이다.

인터페이스와 테스트

인터페이스를 사용하여 추상화를 함으로써 테스트 코드 작성에 있어서도 이점을 얻을 수 있다. LogController 에서 LogCollector 인터페이스를 구현(implements)하는 Concrete Class를 사용하여 테스트를 진행한다고 해보자.

하지만 아직 Concrete Class에 대한 구현이 마쳐지지 않았다. 하지만 LogController를 테스트해보고 싶다. 그러한 경우, 우리는 이미 인터페이스를 통해서 어떤 기능(what)을 제공하는지 알고 있으므로 실제 Concrete Class 대신에 진짜처럼 동작하는 가상의 구현 객체, 즉 Mock객체라고 부르는 객체를 직접 만들어 테스트해볼 수 있게 된다.
(실제로 Mock 객체를 위한 클래스를 별도로 만들기 보다는 Mockito와 같은 프레임워크를 이용해서 Mock 객체를 생성하여 활용할 수도 있다.)

profile
꾸준함에서 의미를 찾자!

0개의 댓글