객체 지향 프로그래밍
객체(Object)들의 모임이 서로 협력하는 것이 객체 지향 프로그래밍이다. 이때 메시지를 사용하여 객체끼리 협력한다.
객체란 물리적으로 존재하거나 추상적으로 생각할 수 있는 것 중에서 자신의 속성을 가지고 있고, 다른 것과 식별 가능한 것을 말한다.
나는 첫 프로그래밍을 C언어로 접했기 때문에 객체 지향의 장점을 절차 지향과 비교해 예시를 들 때 이해가 잘 됐다.
어떤 프로그램이 절차 지향 방식으로 짜였다면 프로그램에 어떠한 기능을 제외하려고 할 때 그 기능과 관련된 앞뒤 코드를 살펴보고, 삭제할 기능과 연결된 부분을 수정해야 한다(이는 물론 객체 지향에서도 마찬가지이긴 하다). 그러나 절치 지향과 객체 지향은 수정해야 할 코드의 범위가 다르다.
객체 지향 방식으로 (잘) 설계했다면 각각의 객체들끼리 의존도가 높지 않을 것이니 삭제하려는 기능 부분에 해당하는 코드만 삭제하면 된다. 다형성 특징도 잘 살렸다면 삭제한 객체를 사용하는 부분에서 수정할 것도 크게 없을 것이다. 그냥 삭제할 부분만 사악- 도려내면 되는 것이다.
그러나 절차 지향이라면 말이 다르다. 도려내는 게 안된다.
프로그램 실행 중간에 위치한 기능을 삭제하면, 삭제한 코드의 앞 뒤로 연결하는 코드를 만들어주어야 한다. 그리고 삭제한 코드에서 쓰이는 변수가 있다면-삭제한 부분에서 해당 변수가 변경되기라도 한다면- 그와 관련한 것도 처리를 해주어야 한다. 삭제한 부분에서 쓰이는 변수나 함수가 이후의 프로그램 진행에도 영향을 끼친다면-대부분 끼칠 것이다- 수정할 양이 상당히 방대해질 것이다.
이렇게 비교해보니 절차 지향이 객체 지향보다 무조건 안 좋은 것으로 보이지만 나름 두 가지 방법의 장단점은 존재한다.! 힛😋
좋은 객체 지향 프로그래밍이란?
객체 지향 프로그래밍(Object-Oriented Programming, OOP)은 컴퓨터 프로그래밍의 패러다임 중 하나이다. 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 "객체"들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다.
객체 지향 프로그래밍은 프로그램을 유연하고 변경이 쉽게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다.
객체 지향 프로그래밍은 프로그램을 유연하고 변경이 쉽게 만들어준다고 되어있는데 이는 무슨 뜻일까?
이해를 돕기 위해 먼저 프로그래밍 세계 대신, 실제 세계 예시를 들어본다.
사용자와 자동차가 있다고 하자. 이때 사용자가 타는 자동차는 K3가 될 수 있고, 아반떼가 될 수도 있고, 테슬라 모델 3이 될 수도 있다. 사용자는 (자동차 면허증만 가지고 있다면) 세 자동차 중 어느 자동차를 타든 문제없이 운전할 수 있다. K3를 타다가 아반떼를 탄다고 해서 운전 실력이 갑자기 미숙해지거나, 새로운 차에 적응해야 되는 등의 일은 없다는 것이다.
자동차 자체를 역할이라 칭하고 K3, 아반떼, 테슬라 모델 3 등의 차 기종을 자동차(역할)를 구현한 구현 객체라고 비유한다.
이를 곧 역할과 구현을 분리한다고 말한다. 위 예시에서 사용자를 클라이언트라 가정하고, 아래 역할과 구현 분리의 장점 네 가지를 살펴보자.
클라이언트는 대상의 역할(인터페이스)만 알면 된다.
→ 사용자는 자신이 운전하는 것이 자동차라는 것만을 알면 된다.
이 장점은 아래 2번 장점과 연관하여 생각할 때 이해가 더 쉽다.
클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
→ 사용자는 K3나 아반떼의 부품으로 어떤 게 쓰이는지 알 필요가 없다.
자동차 부품을 검색하면 많은 사진들이 나오는데, 이 부품들 중에서 평범한 사용자-자동차를 만들어본 적 없는-가 알고 있는 부품은 얼마 없을 것이다. 내부 부품을 알지 못하여도 사용자는 자동차를 잘 타고 다닌다. 즉, 사용자는 지금 내가 운전하는 게 자동차인 것만 알고 있다면 운전할 수 있는 것이다.
클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
→ 자동차 타이어를 바꾸거나 엔진 오일을 교체해도 사용자는 그에 대한 영향을 받지 않는다.
클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.
→ 사용자가 K3를 타다가 아반떼로 차를 바꿔 몰아도 이에 대해 영향을 받는 게 없다.
구현 대상 자체는 구현체를 말하는 것이다.
지금까지 예시를 든 것은 자바의 특징 중 하나인 다형성(Polymorphism)의 예시이다.
역할은 인터페이스이고 구현은 인터페이스를 구현한 클래스 즉, 구현 객체이다.
자바에서는 객체를 설계할 때 역할과 구현을 명확하게 분리하고, 객체 설계 시 역할을 먼저 부여한 뒤 그 역할을 수행하는 구현 객체를 만들어야 한다.
다형성의 본질은 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다는 것이다.
클라이언트를 변경하지 않고서도 서버의 구현 기능을 유연하게 변경할 수 있는 것이다.
정리하자면 좋은 객체 지향 프로그래밍이란 유연하며 변경이 용이하고, 확장 가능한 설계로 짤 수 있고 클라이언트에 영향을 주지 않는 변경이 가능하게 만드는 것이다.
이도 인터페이스를 안정적으로 설계해야 가능한 일이다. 만약 인터페이스를 설계하고 난 뒤 변경 사항이 생긴다면 이는 인터페이스를 처음부터 안정적으로 설계하지 않은 것이다.
안정적으로 설계하지 않은 인터페이스, 변경 사항이 생기는 인터페이스라면 클라이언트와 서버 모두 영향을 받게 된다.
객체 지향 특징 중 가장 중요하다고 볼 수 있는 게 다형성이라고 한다. 객체 지향의 특징으로는 추상화, 캡슐화, 상속, 다형성. 이렇게 네 가지가 있지만 사실상 추상화, 캡슐화, 상속이 모두 다형성을 위해 존재하는 것이 아닌가?
스프링 프레임워크를 사용하면 객체 지향의 다형성을 극대화할 수 있게 도와준다.
스프링을 배울 때 지속적으로 나오는 IoC(제어의 역전), DI(의존관계 주입)가 다형성을 활용해 역할과 구현을 구분하여 다룰 수 있게끔 한다. 그래서 스프링 사용 시 구현을 편리하게 변경할 수 있는 것이다. (스프링 빈 객체를 등록하여 구현을 손쉽게 변경할 수 있다.)
SOLID
좋은 객체 지향 설계를 위해서는 SOLID 원칙을 지키는 게 당연하다.
SOLID 원칙은 스프링 프레임워크의 근간이다.
SOLID 원칙은 소프트웨어 작업에서 프로그래머가 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 코드를 리팩터링 하여 코드 냄새를 제거하기 위해 적용할 수 있는 지침이다. 이 원칙들은 애자일 소프트웨어 개발과 적응적 소프트웨어 개발의 전반적인 전략의 일부다.
코드 냄새(Code smell)는 문제를 일으킬 가능성이 있는 소스 코드의 특징을 일컫는다. 위키백과에서 클래스를 예시로 든 걸 보면, 커다란 클래스가 Code smell을 야기하기도 한다.
각각의 원칙을 살펴보면 이러한 원칙을 지킴으로써 깔끔한 코드를 만들 수 있다는 걸 알게 된다.
SRP(Single responsibility principle): 단일 책임 원칙
하나의 클래스는 하나의 책임만 가져야 한다.
하나의 책임이라는 것은 모호한 표현이다. 이런 모호함 때문에 변경을 기준점으로 잡는다.
예를 들어, 어떠한 기능의 변경이 있을 때 다른 기능에게까지 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.
OCP(Open/Closed principle): 개방-폐쇄 원칙
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
흔히들 확장을 하기 위해서는 기존 코드를 변경해야 한다고 생각한다. 확장하는 내용, 즉 추가되는 기능 코드를 기존 코드에서 추가로 작성해야 한다고 생각하기 때문이다. 나도 그랬다.
그러나 다형성을 활용하면 굳이 기존 코드를 변경하지 않아도 확장이 가능하다. 인터페이스를 구현하는 새로운 클래스를 만들면 된다. 역할을 구현하는 구현 객체를 새로이 만드는 것은 엄연히 변경과는 다른 개념이므로 다형성을 활용하면 개방-폐쇄 원칙을 지킬 수 있다.
스프링에서 다형성은 사용했지만 의존관계 주입(DI)을 사용하지 않고 구현 객체를 변경하려면, 클라이언트 코드를 변경해야 될 수도 있다. 이러한 문제를 해결하기 위해 객체를 생성한 뒤 연관 관계를 맺어주는 별도의 설정이 필요하다.
기존에 JdbcMemberRepository 클래스가 존재했고, 확장을 위해 새로운 MybatisMemberRepository 클래스를 생성했다고 가정한다. 그리고 MemberService 클래스는 클라이언트 부분이다.
먼저 의존 관계 주입(DI)을 사용하지 않은 경우이다.
// 기존 코드
@Service
public class MemberService {
private MemberRepository memberRepository = new JdbcMemberRepository();
}
// 변경 코드
@Service
public class MemberService {
// private MemberRepository memberRepository = new JdbcMemberRepository();
private MemberRepository memberRepository = new MybatisMemberRepository();
}
확장을 위해 새로운 MybatisMemberRepository 클래스를 만들어서 사용하려 했으나, 이를 위해서는 클라이언트 부분인 MemberService 코드를 수정해야 한다. 이 때문에 OCP 원칙이 지켜지지 않는다.
우리는 다형성을 사용해서 기존 코드 수정이 아닌, 새로운 구현 객체를 만들었음에도 불구하고 위처럼 클라이언트 코드가 변경되는 상황이 발생한다.
스프링에서는 IoC와 DI를 사용하여 이러한 문제를 해결해준다. 아래는 의존 관계 주입(DI)을 사용한 경우다.
@Service
public class MemberService {
private MemberRepository memberRepository;
@Autowired
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
JdbcMemberRepository와 MybatisMemberRepository 클래스는 인터페이스인 MemberRepository를 구현한 클래스이다. 이후 Controller에서 MemberService 사용 시 Repository를 주입해주면 클라이언트 코드(여기서는 MemberService 부분)를 수정할 필요 없이 확장이 가능하다.
LSP(Liskov substitution principle): 리스코프 치환 원칙
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
프로그램의 정확성이란 프로그램이 수행하는 업무이다.
자동차의 액셀 기능을 예시로 들 때, 기본적으로 (기어가 D일 때를 가정) 액셀을 밟으면 앞으로 전진해야 한다.
액셀을 밟으면 자동차가 전진한다. 이게 프로그램의 정확성인 것이다.
예시로 자동차 인터페이스의 여러 기능 중 액셀 기능이 목표하는 바는 자동차가 앞으로 전진하는 것이다.
자동차 인터페이스를 구현하는 객체에서 액셀 기능을 만든다. 그런데 A 객체에서는 액셀 기능을 후진으로 만들었다고 가정한다. 이는 곧 자동차 인터페이스와 이를 구현한 A 객체 사이에서 LSP 원칙이 지켜지지 않았음을 의미한다.
사실상 후진 기능으로 만들었다 하더라도, 코드만 잘 작성했다면 컴파일 오류는 발생하지 않는다.
그러나 LSP 원칙에서 뜻하는 프로그램의 정확성은 컴파일 단계에서의 정확성이 아닌, 프로그램이 수행하는 업무가 목표하는 바와 일치하는지에 대한 정확성이다. 따라서 오류 유무와 SOLID 원칙을 지키는 것은 직접적인 연관이 없다고 말할 수 있다.
상위 타입이 하위 타입으로 인스턴스를 바꿀 수 있어야 한다는 것은 상속의 의미와 같다.
상위 타입인 자동차 인터페이스가 있고, 자동차의 하위 타입인 클래스가 갑자기 이륙 기능을 갖고 있으면 안 된다. (자동차는 날 수 없다..) 자동차 면허증을 가지고 있는 사용자는 (자동차를 구현한 객체인) K3나 아반떼 같은 걸 운전할 수 있지, 갑자기 보잉 747을 운전하진 못 한다.
ISP(Interface segregation principle): 인터페이스 분리 원칙
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
자동차는 단순히 운전 대상뿐만 아닌 정비 대상도 되어야 한다. 자동차 인터페이스라고 통틀어 설계하지 않고, 운전 인터페이스와 정비 인터페이스로 나누어 설계하면 사용자 클라이언트도 분리가 가능해진다.
자동차 인터페이스를 분리한다면 사용자 클라이언트를 운전자 클라이언트와 정비사 클라이언트로 분리할 수 있다.
분리하여 설계하면 정비 인터페이스가 변경된다 하더라도 운전자 클라이언트에게는 아무런 영향도 가지 않는다. 또한 한 개의 인터페이스에 너무 많은 기능이 들어가게 되면 복잡해지기 때문에 기능에 맞게 인터페이스를 분리해야 한다.
기능에 따른 인터페이스 분리는 인터페이스를 명확히 할 수 있고, 대체 가능성을 높일 수 있다.
DIP(Dependency inversion principle): 의존관계 역전 원칙
추상화에 의존해야지, 구체화에 의존하면 안 된다.
의존성 주입은 이 원칙을 따르는 방법 중 하나다. 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻이다.
구현 객체가 아닌, 역할에 의존하게 해야 한다는 것이다. 클라이언트가 인터페이스에 의존하여야 유연하게 구현체를 변경할 수 있다. 만약 클라이언트가 구현체에 의존하게 된다면 구현체의 변경이 매우 어려워진다.
예시는 위에서 들었던 것과 같다. 사용자는 자동차(추상화)만 알면 운전을 할 수 있다. 굳이 K3나 아반떼(구체화)에 대해 자세하게 알 필요가 없다. 사용자는 자동차에 의존하는 것이지, K3 자체에 의존하는 게 아니다. 이게 가능하기 위해선 클라이언트가 역할에 의존해야 된다는 것이다.
OCP 원칙을 설명할 때 본 첫 번째 예제 코드를 다시 한번 살펴보자.
@Service
public class MemberService {
// private MemberRepository memberRepository = new JdbcMemberRepository();
private MemberRepository memberRepository = new MybatisMemberRepository();
}
new MybatisMemberRepository()를 통해서 MemberService가 구현 클래스를 직접적으로 선택한다. 이는 곧 MemberService는 MemberRepository 인터페이스만 알고 있는 게 아닌, MybatisMemberRepository 클래스까지 알고 있음을 뜻한다. 여기서 MybatisMemberRepository를 변경하려고 하니 코드가 변경된다.
이는 DIP를 위반하는 것이다. MemberService 클래스가 역할(추상화)인 MemberRepository에만 의존해야 되는데, 위 코드 상으로는 new MybatisMemberRepository()를 함으로써 역할과 구현(구체화) 부분인 MybatisMemberRepository까지 의존하고 있다.
객체 지향의 핵심은 다형성이지만, 단순한 다형성만으로는 부품을 갈아 끼우듯이 개발할 수 없다.
다형성만을 가지고 개발할 시 구현 객체를 변경할 때 클라이언트 코드도 함께 변경될 수 있고, OCP와 DIP 원칙을 지키기 어렵다.
SOLID 원칙을 얘기할 때 스프링 프레임워크는 SOLID 원칙을 기반으로 한다고 했다.
옛날 어느 개발자가 좋은 객체 지향을 하기 위하여 OCP, DIP 원칙을 지키며 개발하다 보니 할 일이 너무 많아지게 되어 프레임워크를 만들었다. 그 프레임워크가 바로 스프링 프레임워크이다.
스프링은 의존성 주입(DI, Dependency Injection)과 DI 컨테이너를 제공함으로써 다형성을 보장함과 더불어 OCP와 DIP 원칙도 지킬 수 있게 해 준다.
DI 컨테이너에는 자바 객체들이 존재하고, 컨테이너 내에서 객체끼리의 의존 관계를 연결해주는 기능을 제공한다.
이로써 클라이언트 코드를 변경할 필요 없이 기능을 확장할 수 있으며 부품을 교체하듯이 개발할 수 있게 하는 것이다.
[인프런] 스프링 핵심 원리 - 기본 편 (김영한)