개요
객체지향, OOP
자바 입문부터 지금까지 정말 많이 들은 키워드들이지만 크게 와닿지는 않았다.
하지만 최근에 클린 코드와 클린 아키텍처에 대해서 조금씩 관심을 가지면서 깨달은 것은, 객체지향이 가장 베이스가 된다는 것이였다.
관련해서 검색하고 여러 강의들을 보며 공부한 것들을 정리해보려 한다.
왜 객체지향?
세상의 모든 소프트웨어가 한 두달 쓰다가 버릴 프로젝트이면 OOP란 개념은 안나왔을지도 모를 것 같다. 하지만 대부분의 서비스들은 기능이 추가되고 유지보수 되며, 리팩토링이 일어난다. 그리고 서비스가 커질수록 조직의 규모도 커지며, 기능을 추가하는 개발자보다 유지보수하는 개발자가 더 많아질 것이다. 이러한 조직에서 가장 중요한 것은 빨리 돌아가는 코드가 아닌, 잘 읽히는 코드이다.
사람의 마인드 구조는 절차지향적이다. 처음에는 절차지향적으로 코드를 짜는 것이 쉽고 빠르지만, 데이터와 그 데이터에 접근할 수 있는 함수 사이에 연관 관계가 낮다는 근본적인 문제가 있다.

그림을 보면, 데이터를 중심으로 로직을 추가하는 방식으로 프로그래밍을 해 나간다. 이렇게 되면 하나의 데이터에 대해 변경이 일어나면, 그 데이터에 대한 프로시저도 모두 변경이 되어야 한다.
class Product{
public String name;
public Integer price;
public Integer amount;
}
class ProductPriceCalculator{
public static Integer getTotalAmount(Product product){
return product.price * product.amount;
}
}
class ProductAdviertisement{
public static String getAdvertisingText(Product product){
return "싸다싸" + product.name + " " + product.price + "원!!";
}
}
다음과 같은 코드가 있을 때, Product에 대한 데이터가 변경이 된다면, 데이터를 직접 다루어서 기능을 제공하는 두 클래스의 함수가 모두 변경되어야 할 것이다.
Product에 대한 영향을 받는 클래스가 더 존재한다면, 영향을 받는 범위 또한 늘어날 것이다.

객체지향 프로그래밍은 기능이 객체 내부에 존재하여 객체들끼리 서로 메시지를 주고받는다. 객체 내부에 데이터와 기능이 응집되어 있으며, 객체 내부의 변경은 외부에 영향을 미치지 않는다.
class Product{
public String name;
public Integer price;
public Integer amount;
public Integer getTotalAmount(){
return this.price * this.amount;
}
public String getAdvertisingText() {
return "싸다싸" + this.name + " " + this.price + "원!!";
}
}
다음 코드처럼 객체 내의 데이터와 기능을 묶는다면, 객체 내부의 데이터 혹은 기능이 변경되더라도 기능을 사용하는 외부 로직은 영향을 미치지 않을 것이다.
결국 객체지향의 가장 큰 목표는 변경에 용이한 소프트웨어를 만드는 것이며, 리팩토링을 통해 객체지향적인 소프트웨어로 변해가는 과정을 가져야 한다.
객체지향의 4대 특징
객체지향 소프트웨어가 갖는 4대 특성이 있다.
캡슐화
'자신의 상태보다 행위를 보여줘라'
위에서 본 코드에 예시로 설명이 가능할 것 같다. 객체 내에 행위를 응집시키게 된다면, 상태 변경에 용이해진다.
상속
자식 클래스가 부모 클래스의 상태와 행위를 물려받아 사용하는 것을 의미한다.
public class Parent {
public int parentPublicInt;
protected int parentProtectedInt;
private int parentPrivateInt;
public void someMethod() {
System.out.println("Parent someMethod");
}
}
public class Child extends Parent {
public void anotherMethod() {
System.out.println("Child anotherMethod");
this.parentProtectedInt = 0;
this.parentPublicInt = 0;
//private 제어자로 상속 불가
//this.parentPrivateInt = 0;
}
그러면 상속은 부모 클래스의 상태와 행위를 재사용하기 위한 기능이구나!
위처럼 오해하기 쉽지만 반은 맞고 반은 틀린 것 같다.
상속은 'is-a' 관계를 나타내기 위한 기능이며, 이러한 관계가 성립되려면 '상태'를 재사용하기 위한 상속이 올바른 상속이다.
하지만 부모 클래스의 '행위'를 재사용하기 위한 상속은 옳지 못한 상속이다. 부모 클래스의 행위가 변경된다면, 부모 클래스의 행위를 재사용하기 위한 자식 클래스가 모두 변경될 것이며, 이는 변경에 용이하지 못한 소프트웨어일 것이다.
이처럼 상속은 가장 강한 의존성 중 하나이다. 그렇다면 상속은 궁극적으로 왜 필요한가? 이유는 다형성이다.
참고로 로직을 재사용하기 위해서는 상속보다는 조합(composite)과 위임(delegate)이 적절하다.
다형성
다형성은 OOP의 꽃이라고도 불린다. 다형성은 역할과 구현을 구분하는 것이 가장 중요하다.

Driver는 Car의 기능을 동작시킨다.
Driver는 Car의 모든 종류에 대해 시동을 켜고 운전을 할 수는 있지만, Car마다 다르게 구현되어 있는 기능들을 세세하게 알 필요는 없다. Car는 K3뿐만이 아닌 아반테, 테슬라가 될 수도 있는 다형적인 모습인 것이다. 이 때, Driver는 달라지는 차에 대해 공부할 필요가 없다. 클라이언트(Driver)에 영향을 주지 않는 기능(Car) 변경, 이것이 다형성이다.
Java에서 Car는 추상클래스 혹은 인터페이스가 될 것이고, K3Car나 Model3Car는 Car를 구현한 클래스일 것이다.
K3Car가 Car의 자리에 들어갈 수도 있고, Model3Car가 Car의 자리에 들어갈 수도 있다. 이런 다형성을 통한 유연한 기능 변경을 상속 통해서 나타낼 수 있는 것이다.
상속은 로직의 재사용을 위해 사용되는 것이 아니다! 라는 뉘앙스로 작성하였지만, 그것은 Car의 입장에서이다.
클라이언트인 Driver의 로직이 변경되지 않고 세부 기능을 확장할 수 있기에, Driver의 로직이 재사용되는 것으로 볼 수도 있겠다.
추상화
세상의 Car가 K3Car 밖에 없다면, Driver는 K3Car를 직접 참조해도 무방하다. 하지만 그렇지 않다. Car는 세상의 여러 종류가 있고, Driver는 여러 종류의 Car를 몰고 싶다.
이렇듯 기존의 동일한 기능의 구현체가 2개 이상 생긴다면 인터페이스를 등장시켜서 세부적인 기능을 추상적인 역할로 추상화 시킬 수 있어야 한다.
객체지향의 5가지 원칙 - SOLID 원칙
위에서 설명한 객체지향의 4대 특성은 객체지향의 메커니즘일 뿐이지 저 자체로 객체지향을 만족한다고 할 수는 없다.
객체지향의 4대 특성을 객체지향의 재료들이라고 비유한다면, SOLID 원칙은 객체지향을 만드는 레시피라고 볼 수 있다.
결론부터 말한다면, 객체지향의 핵심은 객체 간 의존성 관리를 잘하는 것이다.
의존성 관리가 잘못될 경우, 변경과 재사용이 어렵다.
다형성에서 본 것처럼 기능이 변경되어도 클라이언트의 로직이 변경되지 않도록 의존성을 잘 관리해야 하며, SOLID 원칙은 관련된 가이드를 제공한다.
SRP (Single Responsibility Principle)
하나의 클래스는 하나의 책임만을 가져야 한다.
여기서 말하는 책임이란 변경의 원인을 의미한다. 결국 다시 풀어서 얘기하면 다음과 같을 것이다.
하나의 클래스에는 변경을 일으키는 원인이 같은 기능들만을 모아놓아라

이미지의 Service 클래스는 변경을 일으키는 원인이 User와 Article 2가지가 있다. 이러한 구조는 SRP를 위반한 것이다.

SRP를 만족하려면 변경을 일으키는 원인이 같은 기능들별로 묶어놓아야 한다.
OCP (Open Closed Principle)
소프트웨어는 확장에는 열려 있고 변경에는 닫혀있어야 한다.
확장과 변경에 의미가 무엇일까?

클라이언트인 Service는 Repository 인터페이스를 의존하고 있다.
Service 입장에서는 DatabaseRepository가 없어지더라도 코드가 변경되지 않는다. 변경에는 닫혀있는 것이다

요구사항 추가로 Repository의 기능이 추가되었다고 해보자. Service의 코드 변경이 없더라도 인터페이스 구현체를 통해 Repository의 기능을 손쉽게 추가할 수 있다. 확장에는 열려있는 것이다.
LSP (Liskov Substitution Principle)
부모 클래스가 할 수 있는 행동은 자식 클래스도 할 수 있어야 한다.
상속받은 자식 클래스가 부모 클래스의 기능을 완벽하게 수행해야 한다는 의미이다.
public class Parent{
/**
* 모든 수를 받을 수 있음
*/
public void someMethod(int input){
System.out.println("Parent 호출");
}
}
public class Child extends Parent{
/**
* 양수만 받을 수 있음
*/
@Override
public void someMethod(int input){
if(input <= 0){
throw new RuntimeException();
}
System.out.println("Child 호출");
}
}
부모 클래스는 모든 수를 인자로 받을 수 있지만, 자식 클래스는 양수만 받을 수 있도록 하였다.
부모 클래스로 부터 상속 받은 기능의 일부를 제한한 것이다.

클라이언트에서 Parent를 파라미터로 전달받은 후 기능에 음수를 넣어 실행한다면, 인스턴스가 Parent인지 Child인지 알지 못하며, Child일 경우 예외가 발생하게 된다.

해결방법은 다음과 같을 수 있을 것이다.
1. 무조건 Parent 인스턴스만 넘겨주기 - 다형성 활용 X
2. instanceof 키워드를 통해 인스턴스 구분 후 호출 - 다형성 활용 X
결국은 LSP 원칙을 통해 OCP를 받쳐주는 다형성에 관한 원칙을 제공하고, instanceof나 다운캐스팅과 같은 다형성 위반 징조를 사전에 방지할 수 있다.
더불어 부모 클래스의 로직을 몰아넣고 하위 클래스에서 재사용하는 방식의 안티패턴도 방지할 수 있다.
ISP (Interface segregation Principle)
클라이언트 별로 사용하는 기능만 제공하도록 세분화된 인터페이스를 만들어야 한다.


인터페이스 기능이 왼쪽과 같은 상황에서 오른쪽 그림처럼 관계가 정의되어 있다면, UserRepository 입장에서는 자신이 사용하지 않는 기능인 Article 관련 기능들을 모두 구현해야 한다.

사용하는 기능만 제공하도록 큰 인터페이스를 작은 인터페이스로 분리하여, 기능에 대한 변경의 여파를 최소화 하자.
SRP와 ISP의 목적을 비교해 볼 필요가 있다.
SRP : 책임을 하나만 관리하기 위해 분리
ISP : 구현클래스 혹은 클라이언트에서 사용되지 않는 메서드를 분리하기 위해 인터페이스를 분리
DIP (Dependency inversion principle)
고수준 컴포넌트는 저수준 컴포넌트에 의존하지 않아야 한다.

고수준과 저수준을 나누는 기준은 다음과 같다.
- 고수준 - 기술에 종속적이지 않음 (무엇을 한다)
- 저수준 - 기술에 종속적임 (어떻게 한다)
고수준 컴포넌트는 조금 더 추상적인 존재이며, 다른 컴포넌트와의 상호작용을 관리한다.
저수준 컴포넌트는 고수준 컴포넌트의 동작에 대한 세부 구현이다.

고수준 컴포넌트가 저수준 컴포넌트를 의존하게 된다면, 인터페이스로 의존 방향이 모이도록 설계하여 의존 관계를 역전시켜주어야 한다.
이것이 의존 역전 원칙(DIP) 이다.
OCP와 DIP의 관점을 볼 필요가 있다.
OCP : 소프트웨어를 확장하거나 변경할 때, 다른 코드가 영향을 받지 않음
DIP : 고수준 컴포넌트가 저수준 컴포넌트에 의존하지 않는 것
의존성 주입

Service는 Repository의 세부 구현(인스턴스)을 몰라도 된다. 하지만 Service를 실제로 생성하고 동작하기 위해서는 Repository의 세부 구현을 알아야 한다. 그렇기에 외부에서 의존 주입 동작을 통해 런타임 시점에 각 객체들이 어떤 인스턴스를 사용할지 설정해주어야 한다.
POJO에서는 각각의 객체의 의존성 주입에 대한 코드를 작성해주어야 하지만, 스프링에서는 의존성 주입을 편하게 해주는 여러 마법같은 설정들이 존재한다.
스프링의 가장 큰 핵심은 DI이다. 한 클래스의 기능을 잘 분할해서 별도의 클래스로 분리하고, 인터페이스로 추상화를 잘 시켜준다면 우리는 스프링의 본질인 객체지향 프로그래밍의 더 가까워질 수 있을 것이다.
정리
스프링을 통한 웹 개발자이지만, 되돌아봤을 때 내 코드는 아직은 객체지향적으로 작성하고 있지 못한 것 같다.
3달 뒤에 나, 내 코드를 리뷰해주는 분 어느 누가 읽어도 술술 읽히는 코드, 기능이 추가 혹은 변경되어도 수월하게 적용할 수 있는 코드를 짜고 싶다.
다음은 DDD에 대해 다시 한 번 공부해보며 글을 남겨보겠습니다.