[객체지향] 다형성과 추상 타입

Hyebin Lee·2022년 2월 25일
0

JAVA

목록 보기
2/6
post-thumbnail

다형성과 추상 타입

상속

🌟 상속은 한 타입을 그대로 사용하면서 구현을 추가할 수 있도록 해주는 방법을 제공한다.

상속을 통해 하위타입이 물려받을 수 있는 메서드나 필드는 private 범위를 제외한 모든 것에서 가능하다.

하위 클래스는 필요에 따라 상위 클래스에 정의된 메서드를 새롭게 구현할 수도 있는데 이를 overriding이라고 한다.

  • 구현 상속: 상위 클래스에 정의된 기능을 재사용 하기 위한 목적으로 사용. 재정의를 통해 하위 타입은 상위타입의 기능을 자신에 맞게 @Override 하여 수정할 수 있다.
Plane p = new TurboPlane();
p.fly(); // 실제 p타입인 TurboPlane의 fly가 실행됨 

상속에서는 상속 받은 타입의 상위 클래스나 인터페이스로 객체 할당이 가능하다. 구현 상속의 경우 위의 코드와 같이 어느 클래스의 메소드가 실행되는지 혼동이 올 때가 있다. 이때는 변수가 가리키는 실제 타입에 초점을 두어서 생각해야 한다.

  • 인터페이스 상속: 추상 함수만 가진 추상 클래스를 상속 받는 것이다. 보통 유사한 기능을 구사하지만 해당 기능들이 미묘하게 다른 여러 객체가 필요할 때 인터페이스를 사용해서 쉽게 유사한 객체들을 만들어내고 메서드 명을 통일한다.

추상 타입

  • 콘크리트 클래스: 인터페이스를 실제로 구현하는 클래스

추상화는 반복되거나 공통되는 작업을 모두 추상화하는 것에 핵심이 있다.

public class FlowController{

private boolean useFile;

public FlowController(boolean useFile){
	this.useFile = useFile;
}

public void process(){
byte[] data = null;
if(useFile){
FileDataReader fileReader = new FileDataReader();
data = fileReader.read();
}else{
SocketDataReader socketReader = new SocketDataReader();
data = socketReader.read();
}

Encryptor encryptor = new Encryptor();
byte[] encryptedData = encryptor.encrypt(data);

FileDataWriter writer = new FileDataWriter();
writer.write(encryptedData);
}
}

다음 코드는 나름 플로우제어 기능이 있는 클래스를 따로 두어 파일 읽기, 암호화, 쓰기 기능을 나눈 객체지향적 코드로 보이지만 여기에는 한 가지 아쉬운 점이 있다.

바로 file 형식에 따라 읽는 방식이 달라질 때 if-else 블록의 코드 구성이 비슷하고 file 형식이 변경됨에 따라 읽기 기능이 변경하면 그와 상관없는 플로우제어 클래스까지 변경되어야 한다는 것이다.

⭐이렇듯 추상화는 반복되는 작업을 최소화하고, 본연의 책임과 상관없는 기능 구현의 변경 때문에 함께 바뀌는 일이 없도록 구현되어야 한다.

따라서 위의 코드는 바이트 데이터를 읽는 ByteSource 인터페이스와 ByteSource를 객체화하는 ByteSourceFactory 두 가지를 도출함으로써 해결할 수 있다.

추상화는 이렇듯 공통된 개념을 도출해서 추상 타입을 정의해 주기도 하지만, 많은 책임을 가진 객체로부터 책임을 분리하는 촉매제가 되기도 한다.

추상화를 하면 추상 타입을 사용하는 코드에 영향을 주지 않으면서 추상 타입의 실제 구현을 변경할 수 있다.

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

요구 사항이 바뀔 때 변화되는 부분은 이후에도 변경될 소지가 많다.

추상화가 되어 있지 않은 코드는 주로 동일 구조를 갖는 if-else 블록으로 드러난다.

인터페이스를 남발하지 마라

변화 가능성이 높은 콘크리트 클래스 대신 이를 추상화한 인터페이스를 사용하면 다소 구조는 복잡해지지만 변경의 유연함이라는 효과를 얻을 수 있다. 그러나 변경 가능성이 매우 희박한 클래스에 대해 인터페이스를 만들면 오히려 프로그램의 구조만 복잡해지고 유연함의 효과는 누릴 수 없는 상황이 발생하게 된다.

인터페이스 이름을 지을 때는 해당 인터페이스를 사용하는 코드 입장에서 짓자

Mock 객체

추상화 방법을 활용하는 대표적인 사례는 Mock 객체 생성을 통한 테스트이다.

💡 기능 전반을 플로우 제어하는 FlowController 클래스와 파일을 읽는 FileDataReader 클래스를 각자 다른 개발자가 맡아서 개발한다고 가정해보자. 그런데 FlowController 개발이 먼저 완료되어서 해당 클래스가 FileDataReader 구현 없이 잘 작동하는지 테스트해보고자 한다.

위와 같은 상황에서 인터페이스를 활용하는 것이 크게 도움이 된다. 아직 만들어지지 않은 FileDataReader의 상위 인터페이스를 활용하여 테스트용 임의의 객체를 생성해 내는 것이다. 이 때 그 테스트용 임의의 객체가 Mock 객체가 된다.

public void testProcess(){
	ByteSource fileSource = new FileDataReader();
	FlowController fc = new FlowController(fileSource);
	fc.process();
}

위와 같은 테스트코드는 아직 FileDataReader가 구현되지 않았기 때문에 비정상적으로 동작한다. 하지만 ByteSource 인터페이스를 사용하고 있어서 FileDataReader 클래스 구현이 완성되지 않았더라도 FlowController 클래스를 테스트할 수는 있다.

이를 정상적으로 동작하게 하기 위해서는 Mock 객체를 쓸 수 있다.

public void testProcess(){
	ByteSource mockSource = new MockByteSource();
	FlowController fc = new FlowController(mockSource);
	fc.process();
}

class MockByteSource implements ByteSource{
	public byte[] read(){
		byte[] data = new byte[128]; //data를 테스트 목적의 데이터로 초기화 
		return data;
	}
}

위에서 본 바와 같이 MockByteSource는 아직 만들어지지 않은 FileDataSource의 상위 인터페이스를 상속 받아 테스트용으로 그와 비슷한 형태를 Mocking 한 인스턴스이다.

재사용: 상속보단 조립

상속을 하면 상위 클래스에 구현된 기능을 그대로 재사용할 수 있기 때문에 상속은 재사용에 용이하지만 상속을 사용할 경우 몇 가지 문제점이 있다. 그 문제점을 살펴보고 그에 대한 대안책인 조립을 살펴보자.

상속을 통한 재사용의 단점 1: 상위 클래스 변경의 어려움

상속 계층을 따라 상위 클래스의 변경이 하위 클래스에 영향을 주기 때문에 최악의 경우 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있다. 이런 이유 때문에 클래스 계층도가 커질 수록 상위 클래스를 변경하는 것은 점점 어려워진다.

상속을 통한 재사용의 단점 2: 클래스의 불필요한 증가

다중 상속을 할 수 없는 자바에서는 한 개의 클래스만 상속받고 다른 기능은 별도로 구현해야 한다. 따라서 필요한 기능의 조합이 증가할 수록, 상속을 통한 기능 재사용을 하면 클래스의 개수는 함께 증가하게 된다.

😧 예를 들어 파일 보관소를 구현한 Storage 클래스에서 압축기능을 추가한 CompressedStorage와 암호화 저장을 제공하는 EncryptedStorage가 요구사항 기능이 추가되어 하위 인터페이스로 구현되었다고 하자. 이 때 갑자기 암호화를 하고 압축을 하는 기능이 추가적인 요구사항으로 들어오면 결국은 CompressedEncryptedStorage를 새로운 클래스로 생성해야 한다.

상속을 통한 재사용의 단점 3: 상속의 오용과 IS -A 관계

아래 코드를 보자

public class Container extends ArrayList<Luggage>{
	private int maxSize;
	private int currentSize;

	public Container(int maxSize){
		this.maxSize = maxSize;
	}
	public void put(Luggage lug) throws NotEnoughSpaceException{
		 if(!canContain(lug))
			throw new NotEnoughSpaceException();
		super.add(lug);
		currentSize += lug.size();
	}

	public void extract(Luggage lug){
		super.remove(lug);
		this.currentSize -=lug.size();
	}

	public boolean canContain(Luggage lug){
		return maxSize >= currentSize +lug.size();
	}

}

위의 코드는 잘 상속한 것 같지만 하나 치명적인 문제가 있다. 프레임워크에 따라 Container 클래스를 이용할 때 ArrayList의 메서드까지 자동완성되어 사용할 수 있도록 유도된다는 점이다.

Container 클래스는 수화물을 넣고 빼고 넣을 수 있는지 검사하는 기능을 갖고 있지 ArrayList와는 전혀 다른 기능을 한다. 그런데 Container 클래스에서 사용되어서는 안될 add 와 같은 메서드가 자동완성되니 이를 사용하는 다른 개발자가 실수로 add 메서드를 container 클래스 객체에 사용할 확률이 매우 높다.

이러한 장애를 방지하기 위해서 상속은 무조건 IS -A 법칙을 따라야 한다. Container is a ArrayList가 성립하지 않으니 ArrayList를 Container에 상속하는 것은 좋지 못한 방법이다.

⭐조립을 이용한 재사용

객체 조립은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 객체를 만들어내는 것이다.

객체 지향 언어에서 객체 조립은 보통 필드에서 다른 객체를 참조하는 방식으로 구현된다. 한 객체가 다른 객체를 조립해서 필드로 갖는다는 것은 다른 객체의 기능을 사용한다는 의미를 내포 한다. 이 때 사용되는 다른 객체는 재사용된다고 할 수 있다.

조립은 클래스가 많이 늘어나서 복잡해지는 것을 방지하며 클래스의 오용도 방지한다. 또한 필드에서 다른 객체를 참조하는 것이기 때문에 해당 객체가 변화하여도 참조하는 객체는 크게 변하지 않는다. 이와 같은 이점 외에도 조립은 런타임에서 변경이 가능하다는 장점이 있다.

public class Storage
public class CompressedStorage extends ..
public class CompressedEncryptedStorage extends CompressedStorage ..

//사용 코드
CompressedEncrypetedStorage storage = new CompressedEncryptedStorage();

위의 코드에서 storage의 압축 알고리즘을 변경하려면 소스코드에서 CompressedEncryptedStorage 클래스가 다른 클래스를 상속받도록 변경하고 소스 코드를 다시 컴파일해서 다시 배포해야 한다.

그러나 조립을 활용하면 런타임 중에도 얼마든지 코드 교체가 가능하다.

public class Storage{
	private Compressor compressor = new Compressor();
	public void setCompressor(Compressor compressor){
	this.compressor = compressor;
	}
	public void save(FileData fileData){
		byte[] compressedByte = compressor.compress ..
}

//사용 코드
Storage storage = new Storage();
storage.save(someFileData);// Compressor 객체로 압축

storage.setCompressor(new FastCompressor());
storage.save(anyFileData); // FastCompressor 객체로 압축

위임

위임은 내가 할 일을 다른 객체에게 떠넘긴다는 의미를 담고 있으며 보통 조립 방식을 이용해서 위임을 구현한다.

예를 들어 B라는 클래스가 build 라는 메소드를 실행해야 하는데 이미 똑같은 기능을 A라는 클래스가 제공하고 있을 경우, B는 A를 필드로 갖고 와서 조립한 뒤 public void build(){ a.build()} 와 같은 식으로 기능을 위임할 수 있다.

물론 보통 위임할 때 조립 방식으로 해당 객체를 필드로 가져와 연결하지만 그 자리에서 직접 객체를 생성해서 기능을 요청할 수도 있다. 아래와 같이.

public void build(){
AClass a = new AClass();
a.build();
}

상속은 언제 사용하나?

상속은 보통 기능의 확장 이 있고 IS - A 관계가 명확할 때 사용한다.

그러나 상속으로 너무 많은 클래스가 생성될 것 같다면 조립을 고려해보아야 한다.

0개의 댓글