본문 바로가기

[OOP] 추상화 (객체 지향과 디자인 패턴)

 

들어가며

스타트업의 개발자로서 좋은 설계가 우선인가, 아니면 빠른 기능 개발이 우선인가 항상 고민하곤 했습니다. 제가 다녔던 스타트업은 빠르게 기능 개발을 해서, 시장에서 인정받아야 했기에, 빠른 시간 안에 많은 기능을 개발해야 했습니다. 그러다 문득 추가 기능을 개발하거나, 기존 기능을 수정해야 할 때, 많은 어려움을 겪곤 했습니다. 그렇게 일을 마치고, 더 나은 개발자가 되기 위한 공부를 하면서 제가 작성한 코드는 배려가 부족한 코드였다는 것을 깨달았습니다.

 

제가 개발한 코드는 당장의 기능 개발은 빠르게 할 수 있더라도, 재사용성이 대단히 떨어졌고, 가독성 또한 대단히 떨어졌습니다. 또한 기능을 수정하거나 추가할 때도 광범위한 코드를 건드려야만 했기에 효율성도 떨어졌습니다. 빠르게 기능을 개발하기 위해 적었던 코드가 언젠가는 발목을 잡을 수 있는 코드가 될 수 있다는 것을 깨달았습니다. 그렇다면 비즈니스에 도움이 되고, 다른 팀원들에게도 도움이 될 수 있는, 배려가 담긴 코드를 작성하기 위해서는 어떻게 해야 할까 고민하기 시작했습니다. 배려가 담긴 코드를 작성하기 위해 공부한 내용에 대해 작성해보고자 합니다. 이 글은 도서 객체 지향과 디자인 패턴을 바탕으로 작성됐습니다.

 

 

 

 

 


 

 

 

 

 

 

 

 

추상화

추상화는 컴퓨터 과학에서 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것을 말합니다. 즉 데이터나 프로세스 등을 의미가 비슷한 개념이나 표현으로 정의하는 과정을 뜻합니다. 구체적인 사물들간의 공통점을 취하고 차이점을 버리는 일반화를 사용하거나, 중요한 부분을 강조하기 위해 불필요한 세부 사항을 제거함으로써 단순하게 만듭니다. 결국 핵심은 불필요한 코드를 제거하고 중요한 부분을 살리는 것입니다.

 

예를 들어, for, while, do~while 등과 같은 문법은 반복문을 추상화한 것입니다. 실제로는 CPU의 명령을 통해서 반복이 구현되겠지만, 이 구현으로부터 '반복'이라는 개념을 뽑아내서 for, while, do~while 등으로 추상화한 것이죠.

 

조금 더 직관적인 예시를 들어 보겠습니다. 만약, 제가 은행 관련 애플리케이션을 만든다고 가정해 보겠습니다. 은행을 이용하는 고객의 정보가 필요할텐데, 고객의 정보라고 하면 이름, 주소, 휴대폰 번호, 주소 등이 있을 수 있습니다. 그런데, 사실 정보라는 범위가 모호하므로 위의 정보 외에도 좋아하는 음식, 취미, 특기 등까지도 포함할 수 있습니다. 다만, 은행 애플리케이션에서의 고객 정보로 좋아하는 음식, 취미, 특기는 필요하지 않습니다. 그래서 이러한 불필요한 정보를 제거함으로써 중요한 정보만 남기는 것 자체도 추상화라고 할 수 있습니다.

 

예를 통해 추상화에 대해 살펴보겠습니다. 어떤 프로그램을 만드는 데 다음과 같은 세 개의 기능이 있다고 하겠습니다.

 

 

  • FTP에서 파일을 다운로드
  • 소켓에서 데이터 읽기
  • DB 테이블의 데이터를 조회

 

그런데, 내용을 더 분석해 보니, 위 세 가지 기능은 모두 로그를 수집하기 위한 기능이었습니다. 즉, 이 세 기능을 추상화하면 '로그 수집'이라는 개념으로 정의할 수 있는 것입니다.

 

 

 

 

출처 : https://m.blog.naver.com/songintae92/221374047225

 

 

이를 실제 코드로 적용해 보면 다음과 같습니다.

 

 

class FtpLogFileDownloader {
}

class SocketLogReader {
}

class DbTableLogGateway {
}

interface LogCollector {
	public collect(): string;
}

 

 

위 코드에서 LogCollector가 추상 타입인데, 이 추상 타입만으로는 FTP에서 로그 파일을 다운로드할지, 소켓에서 데이터를 읽어 올 지, DB 테이블에서 데이터를 읽어 올 지 여부를 알 수 없습니다. 단지, 로그 정보를 수집한다는 의미만 제공할 뿐입니다. 따라서 LogCollector는 실제 로그를 어떻게 수집하는지에 대한 상세 구현에 대해서는 알 수 없습니다. 이런 추상 타입과 실제 구현 클래스는 상속을 통해서 연결합니다. 즉, 구현 클래스가 추상 타입을 상속받는 방법으로 둘을 연결하는 것입니다. 

 

 

출처 : https://m.blog.naver.com/songintae92/221374047225

 

 

상속을 이용해서 추상 타입을 실제 구현 클래스로 연결하면 다음과 같이 추상 타입을 이용해서 코드를 작성하는 것이 가능해집니다. 

 

 

collector: LogCollector = createLogCollector();    //다형성
collector.collect();

 

 

다형성에 의해 collector.collect() 코드는 실제 collector 객체 타입의 collect() 메서드를 호출할 것입니다. 만약 createLogCollector()가 SocketLogReader 클래스의 객체를 생성하면, collector.collect()는 SocketLogReader 타입의 collect() 메서드를 실행하게 됩니다. 

 

 

위 그림에서 각 하위 타입들은 모두 상위 타입인 LogCollector 인터페이스에 정의된 기능을 실제로 구현하는데, 이들 클래스들은 실제 구현을 제공한다는 의미에서 '콘크리트 클래스'라고 부릅니다. 

 

 

reader: SocketLogReader = new SocketLogReader();
reader.collect():

 

 

하지만 여기서 위의 코드처럼 콘크리트 클래스를 직접 사용해도 문제가 없는데, 왜 추상 타입을 사용해야 할까요? 이에 대해 간단한 예제를 통해 알아보겠습니다. 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

추상화 적용

처음(추상화되기 이전)에는 Ftp를 통해 File로 저장된 Log를 읽어와 데이터를 다시 기록하는 프로그램을 작성해야 한다고 하겠습니다. 코드는 아래와 같습니다.

 

 

class FlowController {

	public process(): void {
		ftpLogFileDownloader: FtpLogFileDownloader = new FtpLogFileDownloader();
		data: string = ftpLogFileDownloader.collect();

		writer: FileDataWriter = new FileDataWriter();
		writer.write(data);
	}
}

 

 

그런데 이후에 요구사항이 추가되었습니다. Socket을 통해서도 Log를 수집해와서 데이터를 기록해야 한다고 합니다. 그럼 코드는 아래와 같이 변경될 것입니다.

 

class FlowController {

	private useFtp: boolean;

	public FlowController(useFtp: boolean) {
		this.useFtp = useFtp;
	}

	public process(): void {
		data: string = null;
		if(useFtp) {
			ftpLogFileDownloader: FtpLogFileDownloader = new FtpLogFileDownloader();
		    data = ftpLogFileDownloader.collect();
		} else {
		    socketLogReader: SocketLogReader = new SocketLogReader();
			data = socketLogReader.collect();
		}

		writer: FileDataWriter = new FileDataWriter();
		writer.write(data);
	}
}

 

 

여기서 보면 요구사항이 추가됨에 따라 아래와 같은 문제점이 발생합니다.

 

 

  • 요구사항 추가에 따라 if-else 블록이 추가되었다. 이에 따라 코드의 복잡도가 증가한다. (요구사항이 늘어날수록 계속 추가)
  • FlowController의 본연의 책임(흐름 제어)과 상관없는 데이터 수집의 구현의 변경 때문에 FlowController도 함께 변경된다.

 

우리는 요구사항을 분석하여 위의 요구사항들이 결국 로그 수집이라는 개념으로 추상화할 수 있다는 것을 알게 되어 추상화를 합니다. 이렇게 하면 코드는 아래와 같이 변경될 수 있습니다.

 

 

class FlowController {
... 생략
	public process(): void {
		logCollector: LogCollector = null;
		if(useFtp) {
			logCollector = new FtpLogFileDownloader();
		} else {
			logCollector = new SocketLogReader();
		}

		data: string = logCollector.collect();

		writer: FileDataWriter = new FileDataWriter();
		writer.write(data);
	}
}

 

위의 코드를 보면 코드가 이전보다 약간 단순해졌다는 것을 알 수 있습니다. 이제 if-else 블록 코드를 보겠습니다. 여기서는 새로운 종류의 LogCollector 구현이 필요하면 계속해서 if-else 구문이 늘어날 수 있습니다. 그런데 이 구문을 자세히 보면 공통적인 내용이 있습니다. 이는 바로 LogCollector의 타입의 객체를 생성하는 부분입니다. 이 뜻은 곧 다음과 같습니다.

 

 

  • FlowController는 흐름을 제어하는 책임 + LogCollector를 생성하는 책임을 가지고 있다.

 

따라서 우리는 LogCollector를 생성하는 책임을 분리하기 위해 아래와 같은 두 가지 방법 중 한 가지를 택해서 추상화를 진행해야 합니다.

 

  • 1. LogCollector 타입의 객체를 생성하는 기능을 별도 객체로 분리한 뒤, 그 객체를 사용해서 LogCollector 생성
  • 2. 생성자(또는 다른 메서드)를 이용해서 사용할 LogCollector를 전달받기

 

여기서는 1번 방법을 통해 LogCollector 객체를 생성하는 기능을 분리하겠습니다.

 

 

class LogCollectorFactory {

	private useFtp: boolean;

	public create(): LogCollector {
		if(useFtp()) return new FtpLogFileDownloader();
		return new SocketLogReader();
	}

	private useFtp(): boolean {
		return useFtp;
	}


	private static instance: LogCollectorFactory = new LogCollectorFactory();
	public static getInstance(): LogCollectorFactory {
		return instance;
	}

	private LogCollectorFactory() {}
}

 

 

이렇게 LogCollectorFactory 클래스를 통해 LogCollector 타입의 객체를 생성하는 과정을 추상화했습니다. 이렇게 하면 FlowController 클래스의 코드는 아래와 같이 단순해집니다.

 

 

class FlowController {
	public process(): void {
		logCollector: LogCollector = LogCollectorFactory.getInstance().create();
		data: string = logCollector.collect();

		writer: FileDataWriter = new FileDataWriter();
		writer.write(data);
	}
}

 

 

이렇게 하면 만약 Ftp, Socket이 아니라 Database에서 Log를 수집해오도록 변경되어도 FlowController는 변경되지 않습니다. 실제 추상화 과정을 통해서 아래와 같은 유연함을 얻을 수 있게 됩니다.

 

  • LogCollector의 종류가 변경되면, LogCollectorFactory만 변경될 뿐, FlowController 클래스는 변경되지 않습니다. 
  • FlowController의 제어 흐름을 변경할 때, LogCollector 객체를 생성하는 부분은 영향을 주지 않으면서 FlowController만 변경하면 됩니다. 

 

정리하자면 우리는 두 가지 추상화를 통해 소스를 유연하게 변경했습니다.

 

 

  • 로그 데이터 수집하기 : LogCollector 인터페이스 도출
  • LogCollector 객체를 생성하기 : LogCollectorFactory 도출

 

위와 같은 과정을 통해, 추상화는 공통된 개념을 도출해서 추상 타입을 정의해 주기도 하지만, 또한 많은 책임을 가진 객체로부터 책임을 분리하는 촉매제가 된다는 것을 알 수 있습니다.

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

추상화 어떻게 적용할까?

추상화는 저절로 되는 것은 아닙니다. 아래의 내용을 숙지하면 조금 더 수월하게 추상화할 수 있습니다.

 

 

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

요구 사항이 바뀔 때 변화되는 부분은 이후에도 변경될 여지가 많습니다. 이런 부분을 추상 타입으로 교체하면 향후 변경에 유연하게 대처할 수 있는 가능성이 높아집니다. 

 

 

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

다음은 객체 지향의 유명한 규칙 중 하나입니다. 추상 타입을 사용하면 기존 코드를 건드리지 않으면서 콘크리트 클래스를 교체할 수 있는 유연함을 얻을 수 있었는데, 인터페이스에 대고 프로그래밍하기 규칙은 바로 추상화를 통한 유연함을 얻기 위한 규칙입니다.

 

주의할 점은 유연함을 얻는 과정에서 타입이 증가하고, 구조도 복잡해지기 때문에 모든 곳에서 인터페이스를 사용해서는 안 됩니다. 이 경우, 불필요하게 프로그램의 복잡도만 증가시킬 수 있습니다. 인터페이스를 사용해야 할 때는 변화 가능성이 높은 경우에 한해서 사용해야 합니다. 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

마치며

객체지향 프로그래밍의 필요성을 점점 느끼고 있습니다. 언젠가, 코드에 대한 역할과 책임을 명확하게 보여줄 수 있는 코드를 작성할 수 있다면 동료들이, 일을 더 수월하게 할 수 있지 않을까 생각합니다. 배려가 담긴 코드를 작성하기 위해 꾸준하게 노력하고 싶습니다. 

 

 

 

 

 

 

 


 

 

 

 

 

 

출처

 

객체 지향과 디자인 패턴 - YES24

객체 지향 안내서. 객체, 책임, 의존, 캡슐화 등 객체 지향의 주요 개념들을 쉬운 예제와 그림을 통해 이해하기 쉽게 설명한다.

www.yes24.com

 

객체 지향 프로그래밍 입문 - 인프런 | 강의

잘 하는 개발자가 되기 위해서는 유연한 코드를 작성할 줄 알아야합니다. 객체 지향을 이용해서 변경하기 좋은 유연한 코드를 만드는 방법을 알아보세요., - 강의 소개 | 인프런...

www.inflearn.com

 

객체 지향

이번 포스트에서는 객체지향에 대해 포스트를 진행하겠다.절차지향과 객체 지향객체 지향을 잘 하려면 이와...

blog.naver.com