객체지향-객체(Object) Part-2

4 minute read

Step 1 : 다형성과 추상 타입

객체지향이 주는 장점은 구현 변경의 유연함이다. 앞서 객체는 캡슐화를 통해서 객체를 사용하는 다른 코드에 영향을 최소화하면서 객체의 내부 구현을 변경할 수 있는 유연함을 얻을 수 있었다.

유연함을 얻을 수 있도록 해주는 또 다른 방법은 추상화에 있다.

먼저 추상화를 가능하게 해주는 다형성에 대해 알아보자.


다형성(Polymorphysm)은 한 객체가 여러 가지(poly) 모습(morph)를 갖는다는 것을 뜻한다. 여기서 모습이란 타입을 뜻하는데 즉, 다형성이란 한 객체가 여러 타입을 가질 수 있다는 것을 말한다.

자바와 같은 정적 타입 언어에서는 타입 상속을 통해서 다형성을 구현한다.

타입 상속인터페이스 상속구현 상속으로 구분해 볼 수 있다.


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

자바와 같이 클래스 다중 상속을 지원하지 않는 언어에서는 인터페이스를 이용해서 객체가 다형을 갖게 된다.


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

구현상속은 보통 상위 클래스에 정의된 기능을 재사용하기 위한 목적으로 사용된다.

Step 2 : 추상 타입과 유연함

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

추상 타입과 실제 구현 클래스는 상속을 통해서 연결한다.

즉, 구현 클래스가 추상 타입을 상속받는 방법으로 둘을 연결하는것이다.

**하위타입[실제구현클래스] 은 상위 타입[추상 타입]에 정의된 기능을 실제로 구현하는데, 이들 클래스들은 실제 구현을 제공한다는 의미에서 **

‘콘크리트 클래스(concrete class)’라고 부른다.


why abstraction need?

이쯤 되면 필자가 뭘 좋아하는지 알 것이다.

필자는 왜(why)에 집중하며 한두줄 요약을 즐긴다.

그럼 왜 추상화가 필요하냐 ? 콘크리트 클래스만 직접 사용해도 되지 않냐? 라고 생각 할 수 있다.

물론 처음에는 문제가 되지 않는다.

핵심부터 말하자면 추상화 역시 변경의 유연함을 증가시켜 준다.

이게 무슨 말이야?

아래 예시를 보자

요구사항은 파일을 암호화하는 프로그램이다

1
2
3
4
5
6
7
8
9
10
11
12
13
public class DataController {
 
    public void process() {
        FileDataReader reader = new FileDataReader();
        byte[] data = reader.read();
 
        Encryptor encryptor = new Encryptor();
        byte[] encryptedData = encryptor.encrypt(data);
        
        FileDataWriter writer = new FileDataWriter();
        writer.write(encryptedData);
    }
}
cs

어느 날 새로운 요구 사항이 들어왔다.

기존의 파일로부터 데이터를 읽는것 뿐만아니라, Http 통신을 통해 데이터를 읽어와 암호화 할 수 있게 끔 구현 해달라는 것이다.

이제 어떻게 해야할까? Http 통신을 통해 데이터를 읽어오는 HttpDataReader 클래스를 만든 뒤

다음과 같이 조건식을 두어서 할 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DataController {
    
    private boolean isHttp;
 
    public DataController (boolean isHttp) {
        this.isHttp = isHttp;        
    }
 
    public void process() {
        byte[] data = null;
 
        if (isHttp) {
             HttpDataReader httpReader = new HttpDataReader();
            data = httpReader.read();
        }
        else {
            FileDataReader fileReader = new FileDataReader();
            data = fileReader.read();
        }
 
    
        Encryptor encryptor = new Encryptor();
        byte[] encryptedData = encryptor.encrypt(data);
        
        FileDataWriter writer = new FileDataWriter();
        writer.write(encryptedData);
    }
}
cs

눈치가 빠른 사람이라면 본능적으로~~ 이상함을 느꼈을것이다.

만약 Socket을 통해 데이터를 읽어 오라는 기능이 추가 된다면 또 하나의 if-else 블록이 process() 메서드 내부에 추가되고 또 다른 추가적인 수정이 이루어질 것이 틀림없다.


DataFlowController의 책임은 파일이건 소켓이건 http이건 상관없이 데이터를 읽어 오고 이를 암호화하여 특정 파일에 기록하는 것이다.

헌데 위 코드는 데이터를 읽어오는 요구사항의 변화가 생길때마다 DataFlowController이 계속 영향을 받는다.

이런 상황들이 유지보수를 어렵게 만드는 것이다.


그럼 어떻게 해야 유지 보수를 쉽게 만들까?

자세히 살펴보면 요구사항에는 공통점이 있다.

바로 xx로부터 바이트 데이터를 읽어 온다는 것이다.

이러한 공통점은 상세구현을 동일한 개념으로 추상화 할 수 있다.

아래 코드는 바이트 데이터 읽기를 추상화한 내용이다.

1
2
3
public interface ByteSource {
    public byte[] read();
}
cs

이제 추상타입을 만들었으니, FileDataReader와 HttpDataReader는 모두 ByteSource 타입을 상속받도록 하자.

두 클래스 모두 ByteSource 타입을 상속받았으므로 다형성의 원리에 의해 ByteSource 타입으로도 동작한다.

따라서 DataFlowController는 다음과 같이 동작 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DataController {
 
    private boolean isHttp;
 
    public DataController(boolean isHttp) {
        this.isHttp = isHttp;
    }
 
    public void process() {
        ByteSource byteSource = null;
        if ( isHttp ) {
            byteSource = HttpDataReader();
        } 
        else {
            byteSource = FileDataReader();
        }
        byte[] data = byteSource();
    }
}
cs

소스코드도 앞의 것보다 훨씬 간결해졌다.

하지만 여전히 거슬리는게 있다. 그것은 바로 if-else이다.. 난 니가 싫어..

새로운 종류의 ByteSource 구현이 필요하면 새로운 if 블록이 추가 되겠지?

디자인 패턴을 공부하신분이라면 이럴 땐 팩토리메소드 패턴을 사용한다.

아닌 분들을 위해서 다시 친절하게 설명에 들어간다.


여기서도 자세히 살펴보면 또 공통점이 보인다.

바로 ByteSource 타입의 객체를 생성하는 부분이다.

ByteSource의 콘크리트 클래스를 이용해서 객체를 생성하는 부분이 if-else 블록에 잡혀있는데,

이 부분을 해결하지 않으면 결국 DataFlowController는 영원히 ByteSource의 콘크리트 클래스가 변경될 때마다 운명을 함께한다.

이럴때 사용하는 방법 중 하나로써 위에서 설명한 바와 같이 팩토리 메소드 패턴을 사용하는데 ByteSource 타입의 객체를 생성하여 제공하는 기능을 별도 객체(Factory)로 분리 한뒤, 그 객체를 사용하여 ByteSource를 제공 받는다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ByteSourceFactory {
    
    public static ByteSource getByteSourceReader() {
        if ( isHttp() ) {
            return new HttpDataReader();
        }
        else {
            return new FileDataReader();
        }     
    }
    
    public static boolean isHttp() {
        String isHttp = System.getProperty("isHttp");
        return isHttp != null && Boolean.valueOf(isHttp);
    }
}
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DataFlowController {
 
    public void process() {
        
        ByteSource byteSource = ByteSourceFactory.getByteSourceReader();
        byte[] data = byteSource.read();
 
        Encryptor encryptor = new Encryptor();
        byte[] encryptedData = encryptor.encrypt(data);
 
        FileDataWriter writer = new FileDataWriter();
        writer.write(encryptedData);    
    }
}
cs

ByteSourceFactory 클래스의 getByteSourceReader 메소드는 ByteSource 타입의 객체를 생성하여 제공한다.

이제 Socket을 통해 암호화 할 데이터를 읽어 와야 하는 새로운 요구가 발생했다고 가정해보자.

또 하나의 ByteSource 구현 클래스가 추가 되겠지만, 더 이상 DataFlowController 클래스의 코드는 아무 영향을 받지않고, 기존의 자신의 책임만 수행한다.


위의 변환 과정을 통해 객체를 생성하는 책임과 흐름을 제어하는 책임이 한 곳에 섞여 있음을 알게되었고,

이 중 객체 생성 책임을 DataFlowController에서 ByteSourceFactory로 분리 할 수 있었다.

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

처음 도입부에서 말했듯이 추상화변경의 유연함을 증가시켜 준다는 것을 하나 하나 살펴가며 체험했다.

Step 3 : program to interface

인터페이스에 대고 프로그래밍하기란 객체지향의 유명한 규칙 중 하나로 실제 구현을 제공하는 콘크리트 클래스를 사용해서 프로그래밍하지 말고, 기능을 정의한 인터페이스를 사용해서 프로그래밍하라는 뜻이다.

그런데, 인터페이스는 최초 설계에서 바로 도출되기 보다는, 요구 사항의 변화와 함께 점진적으로 도출이 된다.

즉, 인터페이스는 새롭게 발견된 추상 개념을 통해서 도출 되는것이다.

추상 타입을 사용하면 기존 코드를 건드리지 않으면서 콘크리트 클래스를 교체할 수 있는 유연함을 얻을 수 있는데, 주의 할 점은 유연함을 얻는 과정에서 추상타입이 증가하고 구조도 복잡해지기 때문에 변화 가능성이 높은 경우에 한해서 사용해야한다.

인터페이스의 이름은 사용하는 코드입장에서 작성할 것

  • 위 코드의 SockeDataReader만 필요한 상황에서 향후 구현 변경의 유연함을 얻기 위해 SocketDataReaderIF라는 인터페이스를 만들고 사용했다고 가정했을 때,

    이 상황에서 File을 이용하여 데이터를 읽어오는 기능이 필요하다고 요구사항이 추가되면

    FileDataReader가 SocketDataReaderIF 인터페이스를 사용하기엔 혼동을 주기에 무리가 있다.

    쉽게 말하면 후에 어떤 요구사항이 생겨서 변경 될지 모르니까 유연하게 사용되는 공통적인 이름을 쓰자는거..

인터페이스와 테스트

두 명의 서로 다른 개발자가 각각 DataFlowController 클래스와 HttpDataReader 클래스를 개발한다고 가정해보자.

DataFlowController 개발자가 HttpDataReader 개발자보다 먼저 코드 작성을 완료해서 테스트를 해보려고

다음과 같이 코드를 작성 하였다.

1
2
3
4
public void testProcss() {
    DataFlowController dfc = new DataFlowController();
    dfc.process();
}
cs

하지만, 아직 HttpDataReader 개발자가 구현을 완료하지 못했다.

이 상태에서 위 코드를 테스트하면 HttpDataReader.read() 메서드는 비정상적인 데이터를 제공하기에 DataFlowController 클래스에 대한 테스트는 불가능하다.

만약 HttpDataReader 클래스 대신 ByteSource 인터페이스를 사용해서 생성자 주입을 받는 방식으로 구현되어있으면 어떨지 보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class DataFlowController {
 
    private ByteSource byteSource;
 
    public DataFlowController(ByteSource byteSource) {
        this.byteSource = byteSource;
    }
 
    public void process() {
        byte[] data = byteSource.read();
    }    
}
cs
1
2
3
4
5
6
7
8
9
public void testProcess() {
    
    ByteSource byteSource = new HttpDataReader();
 
    DataFlowController dfc = new DataFlowController(byteSource);
 
    dfc.process();
}
 
cs

물론 위 테스트 코드도 HttpDataReader 클래스의 구현이 완료되지 않았기 때문에 비정상적으로 동작하게 된다.

하지만, ByteSource 인터페이스를 사용하기 때문에 HttpDataReader 클래스가 구현이 완료가 되지않았어도

Mock(가짜, 모의)객체를 사용하여 DataFlowController에 대한 테스트를 할 수 있는데 코드는 다음과 같다.

1
2
3
4
5
public void testProcess() {
    ByteSource byteSource = new MockByteSource();
    DataFlowController dfc = new DataFlowController(byteSource);
    dfc.process();
}
cs
1
2
3
4
5
6
class MockByteSource implements ByteSource {
    public byte[] read() {
        byte[] data = new byte[128];
        return data;
    }
}
cs

위 코드에서 MockByteSource 클래스는 ByteSource 인터페이스를 상속받아 구현하고 있는데,

이 클래스의 read() 메소드는 테스트에 필요한 byte 데이터를 하드 코딩하여 제공한다.

이렇게 인터페이스를 사용하게 되면 실제 사용할 콘크리트 클래스의 개발이 완료되지 않았더라도,

Mock 객체를 사용하여 테스트가 가능하다.

참조 - 개발자가 반드시 정복해야 할 객체지향과 디자인패턴 By 최범균

Categories:

Updated:

Comments