해당 포스팅은 인프런에서 제공하는 최범균 님의 '객체 지향 프로그래밍 입문'을 수강한 후 정리한 글입니다. 유료 강의를 정리한 내용이기에 제공되는 예제나 몇몇 내용들은 제외하였고, 정리한 내용을 바탕으로 글 작성자인 저의 언어로 다시 작성한 글이기에 서술이 부족하거나 잘못된 내용이 있을 수 있습니다. 그렇기에 해당 글은 개념에 대한 참고 정도만 해주시고, 강의를 통해 학습하시기를 추천합니다.(저렴한 가격임에도 정말 알차고 유익한 내용이기에 객체 지향 프로그래밍을 공부하시는 많은 개발자 분들은 꼭 보시길 추천드립니다.)
소프트웨어 개발에 있어 어느 시점부터 기능 추가는 줄어듦에도 불구하고, 코드 한 줄을 추가하는 비용이 증가하게 된다. 프로젝트 내부에 비슷한 코드들이 양산되고, 전체 코드의 양이 늘어나 그중 원하는 코드를 찾아 분석하고 수정하는 시간이 증가하기 때문이다.
소프트웨어 개발자인 Jessica kerr
는 "소프트웨어를 유지/보수한다는 것은 항상 동일하게 동작하도록 유지하는 것이 아닌 변화하는 세계에서 계속 유용하게 만드는 것이다."라고 언급한 바 있는데, 이를 통해 소프트웨어의 가치가 변화의 용의성, 즉 얼마나 유연하게 변화할 수 있는지에 달려있다고 생각해 볼 수 있다. 따라서 코드(기능)를 변화하는데 비용이 계속 증가하게 된다면 결국 그 소프트웨어의 가치는 떨어지게 된다고 볼 수 있다.
코드의 유지/보수 비용을 낮출 수 있는 방법으로 객체 지향, 함수형, 리액티브 등의 프로그래밍 패러다임을 적용하거나 DRY, TDD, SOLID, DDD, 클린 아키텍처, MSA 등의 코드/설계 방법론과 아키텍처 적용, 그리고 애자일, DevOps 등의 업무 프로세스/문화의 도입 등이 있다.
객체 지향 프로그래밍은 객체의 캡슐화와 다형성(추상화)을 통해 비용을 낮출 수 있다. 데이터를 여러 프로시저가 공유하는 방식인 절차 지향 방식에 비해 데이터와 프로시저를 객체 단위로 묶어서 특정 객체가 가진 데이터를 해당 객체를 통해서만 접근 가능하게 제어하고, 프로시저를 통해 객체 간 데이터를 공유하는 객체 지향 방식은 초기 설계가 상대적으로 어렵지만, 시간이 흐를수록 코드 수정이 용이하다는 장점이 있다.
객체 지향 프로그래밍에서 객체는 클래스의 인스턴스이다. 클래스 객체는 자료와 그 자료를 다루는 명령의 조합을 포함하여 객체가 메시지를 받고 자료를 처리하며 메시지를 다른 객체로 보낼 수 있도록 한다. 위키백과 - 객체
객체는 메서드(오퍼레이션)를 이용하여 이름, 파라미터, 결과로 구성된 기능을 명세하고, 객체 간 연결은 메서드를 호출함으로 이루어지며, 이러한 과정을 메시지를 주고받는다고 표현한다. 이렇듯 객체 지향 프로그래밍에서 객체는 필드(데이터)가 아닌 기능으로 정의되기에 setter와 getter만을 포함한 class는 객체보단 데이터(Data Class)에 가깝다.
캡슐화는 데이터와 그 관련 기능을 하나의 객체로 묶는 것으로, 기능의 구현과 구현에 사용된 데이터의 상세 내용을 외부에 감추는 정보 은닉(Information Hiding)의 의미도 포함한다. 캡슐화하지 않았을 경우 요구사항의 변화가 데이터 구조와 사용에 연쇄적인 변화를 발생시킬 수 있는데 반해 캡슐화의 사용은 연쇄적인 전파를 최소화하면서 객체 내부의 구현을 변경 가능하다는 장점이 있다. 또한, 기능에 대한 이해를 높이고 해당 기능의 의도에 대해 명확하게 파악할 수 있게 해 준다.
acc.getExpDate().isAfter(now); -> acc.isExpired();
Date date = acc.getExpDate(); -> acc.isValid(now);
date.isAfter(now);
연쇄적으로 가공하지 말고, 가공된 데이터를 받아오는 메서드만 호출하여 기능의 구현은 외부에 감추어야 하며, 이를 통해 기능을 사용하는 코드에 영향을 주지 않거나 최소화하면서 내부 구현을 변경할 수 있는 유연함을 얻을 수 있다.
다형성이란 객체 지향에서 한 객체가 여러 타입을 갖는 것을 말한다. 하나의 객체가 여러 타입의 기능을 제공하며, 타입의 상속을 통해 하위 타입이 상위 타입을 포함함으로 다형성을 구현한다.
추상화란 데이터나 프로세스 등을 의미가 비슷한 개념이나 의미 있는 표현으로 정의하는 과정이며, 아래의 두 예시처럼 특정한 성질을 뽑아내어 추상화하거나 공통된 성질로 추상화하는 두 가지 방식이 있다.
여러 구현 클래스를 대표하는 상위 타입을 도출하는 것으로 흔히 인터페이스 타입으로 추상화한다. 추상화 타입과 그 구현은 타입 상속으로 연결되는데, 먼저 인터페이스(Interface)는 기능에 대한 의미만을 제공하고 그 구현은 제공하지 않는다. 인터페이스 만으로는 어떻게 구현될지 알 수 없으며, 그 구현은 콘크리트 클래스(Concrete Class)에서 이루어진다. 즉 추상 타입인 인터페이스는 기능의 의도를 드러내고 그 기능의 구현은 감춘다. 인터페이스의 의도에 대한 기능은 각 콘크리트 클래스에서 다양한 방법으로 구현할 수 있으며(다형성), 이를 통해 객체는 의도의 변화 없이 다양한 방향으로 기능을 확장할 수 있는 유연함을 얻는다. 이렇듯 객체 지향 프로그래밍에서는 추상화를 통해 OCP 원칙(Open-Closed Principle - 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.)이 가능하게 만든다.
추상화는 의존 대상이 변경되는 시점에 이루어져야 한다. 추상화는 필연적으로 추상 타입의 증가를 만들어내고, 이는 곧 구조의 복잡도를 증가시키게 된다. 그렇기에 아직 존재하지 않는 기능에 대한 이른 추상화는 지양되어야 하며, 실제 변경이나 확장이 일어날 때 추상화를 시도해야 한다. 따라서 추상화를 잘하려면 구현의 의도가 무엇인지 고민해야 한다.
상속은 상위 클래스의 기능을 재사용하고 확장하는 방법으로 사용된다. 이러한 상속을 통한 기능 재사용에서 크게 세 가지 문제가 발생할 수 있는데, 먼저 상위 클래스 변경 시 그 여파가 하위 클래스에 영향을 줄 수 있기에 상위 클래스 변경의 어려움이 있을 수 있다. 두 번째로 새로운 조합이 생길 때마다 하위 클래스가 증가하며, 어떤 클래스를 상속받을지 애매한 상황이 발생할 수 있다. 마지막으로 상속받은 기능을 사용해야 하는 부분에 부모 타입의 기능이 오용될 수 있다는 문제점이 있다.
위의 문제점들을 방지하기 위해 객체의 상속에 앞서 해당 문제를 조립으로 해결할 수 있는지 검토해야 한다. 조립은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 것으로, 보통 필드로 다른 객체를 참조하는 방식으로 조립하거나 객체를 필요로 하는 시점에 생성/구하는 방식으로 이루어진다.
기능은 하위 기능으로 분해가 가능하다. 하나의 기능은 여러 하위 기능으로 구성되어 있으며, 분리한 각 기능을 알맞게 분배하는 것이 객체 지향 설계이다. 기능은 곧 책임이기 때문에 단일 책임 원칙(SRP : Single Responsibility Principle)을 지켜 적절하게 기능을 분리하지 않는다면 큰 클래스에서 많은 필드를 많은 메서드가 공유하게 되고, 큰 메서드에서 많은 변수를 많은 코드가 공유하게 되면서 결국 절차 지향에서와 같은 문제가 발생할 수 있다.
책임을 분배하고 분리하는 방법에는 패턴 적용, 계산 기능 분리, 외부 연동 분리, 조건별 분기 추상화가 있다. 역할 분배의 주의점은 그 의도와 의미가 잘 드러나는 이름을 사용하는 것에 있으며, 역할 분배가 잘 되었을 경우 단위 테스트가 용이 해진다는 이점도 있다. 이러한 책임 분배/분리 방법을 정리하면 아래의 표와 같다.
패턴 적용 | 계산 기능 분리 | 외부 연동 분리 | 조건별 분기 추상화 |
---|---|---|---|
MVC 패턴, AOP, GoF 등 | 캡슐화 | 네트워크, 메시징, 파일 등 연동 처리 코드 분리 | 반복적인 if-else는 추상화 |
의존이란 기능 구현을 위해 객체 생성, 메서드 호출, 데이터 사용 등 다른 구성 요소를 사용하는 것으로, 의존하는 대상에 변화가 생길 경우 그 변경이 전파될 가능성이 높아진다. 그렇기에 변경이 연쇄적으로 전파될 수 있는 순환 의존이 없도록 설계해야 하며, 한 클래스에서 너무 많은 기능을 제공하는 경우 각 기능마다 의존 대상이 다를 수 있고, 한 기능의 변경이 다른 기능에 영향을 줄 수 있기 때문에 몇 가지 단일 기능으로 묶어서 분리할 수 있는지 고려해야 한다. 의존 대상 객체를 직접 생성할 경우 생성 클래스가 바뀌면 의존하는 코드도 변하게 되기 때문에 의존 대상 객체를 직접 생성하지 않는 팩토리, 빌더나 의존 주입(Dependency Injection), 서비스 로케이터(Service Locator)를 사용해야 한다.
생성자나 setter 메서드를 사용하거나, 조립기(Assembler - 스프링 프레임워크의 Configure를 통한 Bean 등록과 같은)를 사용하여 외부에서 의존 객체를 주입하는 방법으로, 추상화된 상위 타입을 주입할 경우 의존 대상이 바뀌어도 조립기(설정)만 바꾸면 된다는 장점과 의존하는 객체 없이 대역 객체를 사용해서 다양한 경우의 수를 테스트해볼 수 있다는 장점이 있다.