OOP (Object Oriented Programming)은 실세계의 '객체'라는 개념으로 프로그래밍하는, 프로그램 설계 방법론이자 개념의 일종이다. 이에 기반이 되는 대표적인 객체 지향 언어로 'Java'가 있다.
이러한 객체 지향 프로그램을 올바르게 설계해 나가는 방법, 원칙이 바로 객체 지향 설계의 5원칙 (SOLID) 이다.
SOLID는 다음 다섯가지 원칙의 앞글자를 따서 부르는 이름으로, 원칙의 종류는 다음과 같다.
- SRP (Single Responsibility Principle) : 단일 책임 원칙
- OCP (Open Closed Principle) : 개방 폐쇄 원칙
- LSP (Liskov Substitution Principle) : 리스코프 치환 원칙
- ISP (Interface Segregation Principle) : 인터페이스 분할 원칙
- DIP (Dependency Inversion Principle) : 의존 역전 원칙
이 원칙들은 고전 원칙인 "응집도는 높이고, 결합도는 낮추라" 는 고전 윈칙을 객체 지향의 관점에서 재정립한 것이라고 볼 수 있다.
응집도는 하나의 모듈 내부에 존재하는 구성 요소들의 기능적 관련성이다.
(응집도가 높은 모듈은 독립성이 높고 하나의 책임에 집중하여 기능의 수정, 유지보수가 용이하다.)
결합도는 모듈간의 상호 의존 정도이다.
(결합도가 낮으면 모듈 간 상호 의존성이 줄어들어 객체의 재사용, 확장성을 증대 시킬 수 있다.)
결합도 수준 : 데이터 결합도, 컨트롤 결합도, 내용 결합도 등..
응집도 수준 : 기능 응집도, 순차 응집도, 시간 응집도, 논리 응집도 등..
1. SRP (Single Responsibility Principle) : 단일 책임 원칙
어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다. - 로버트 C. 마틴
SRP, 단일 책임 원칙은 단어에서도 알 수 있듯, 클래스를 각각 하나의 역할과 책임만을 갖게 해야한다는 원칙이다. 이 원칙으로 클래스를 설계하면, 하나의 역할과 책임을 갖는 클래스는 변경하는 이유가 한가지만 존재하게 된다.
다음과 같이 학생이자, 남자친구이자, 군인인 사람이 있다고 치고 이 사람의 행동을 정의한 Person이라는 클래스를 만들었다고 해보자.
public class Person{
int age;
int date; // 사귄 날짜
public void studySomething(){ } // 공부하기
public void drinkingWater() { } // 물 마시기
public void meetGirlfriend() { } // 여자친구 만나기
public void shootingRifle() { } // 총쏘기
}
위 클래스를 이용해서 남자친구의 역할을 할, Person aBoyfriend = new Person() 이라는 코드로 객체를 생성했다고 해보자. aBoyfriend가 가리키는 객체는 남자친구의 역할뿐만 아니라, 군인의 역할, 학생의 역할도 수행할 수 있다. 역할과 책임이 너무나도 많아진 것이다. 이렇게 클래스를 설계하면 생성된 객체는 필요한 역할과 책임에 집중 할 수 없게된다. 남자친구의 역할을 할 객체가 총도 쏘고, 공부도 할 수 있는 것처럼 말이다.
이러한 클래스 내부에는 기능적 관련성이 없는 요소들이 구성되어 있기 때문에, 응집도는 높이고 결합도는 낮추라 라는 원칙에 위배되고 이는 곧, 재사용과 확장성이 떨어지는 클래스 설계라고 볼 수 있다.
위 코드에서 객체가 '남자친구'라는 하나의 역할에만 집중하게 하기 위해서는 어떻게 해야할까?
바로, 책임/역할을 기점으로 클래스를 나누고 이를 상속하면 된다.
public class Person{
int age;
// 생성자 생략..
public void drinkingWater() { } // 물 마시기
}
public class Boyfriend extends Person{
int date; // 사귄 날짜
// 생성자 생략..
public void meetGirlfriend() { } // 여자친구 만나기
}
다음과 같이 Person 클래스에 공통적인 속성과 행동만을 남겨두고, 상속받는 Boyfriend라는 클래스를 정의하여 사용하면 역할과 책임을 분리할 수 있다.
단일 책임 원칙은 위와 같이 클래스 뿐만 아니라 속성, 메소드, 패키지, 모듈, 컴포넌트, 프레임워크 등에도 적용할 수 있는 개념이다. 따라서, 하나의 속성이 여러 의미를 갖는다거나, 하나의 메서드가 너무 많은 기능을 구현하고 있는 경우에도 똑같이 단일 책임 원칙을 적용하여 역할과 책임을 분리할 수 있다. 속성은 새로운 속성을 선언함으로써, 메소드는 오버라이딩이나 오버로딩을 통해서 말이다.
추상화를 통해 클래스들을 선별하고 속성과 메소드를 설계할 때 반드시 단일 책임 원칙을 고려하는 습관을 들여야 한다.
2. OCP (Open Closed Principle) : 개방 폐쇄 원칙
자신의 확장에는 열려있고, 주변의 변화에 대해서는 닫혀 있어야 한다. - 로버트 C. 마틴
위 문장에서 '확장에 열려있다'라는 말은 기능(모듈, 함수, 클래스 등)의 확장, 변경이 가능하다는 의미이고 '주변에 변화에 대해서 닫혀있다'는 말은 기능이 확장되거나 변화해도 그것들을 사용하는 기존 코드들은 변화하지 않는다는 의미이다.
public class Driver{
private Sonata driversCar;
public Driver(){
driversCar = new Sonata();
}
public void startTheCar(){ // 차 시동걸기
driversCar.startSonata(); // 소나타 시동 걸기
}
}
public class Sonata{
public void startSonata(){
System.out.println("차 열쇠로 시동을 건다.");
}
}
public class K9{
public void startK9(){
System.out.println("스마트폰 어플을 통해 시동을 건다.");
}
}
위의 코드 예시에서, K9과 Sonata의 시동을 거는 방법(메소드)가 다르다고 해보자. Driver클래스에서 보면 차로 '소나타'를 소유하고 있고, 따라서, 시동을 거는 메소드도 'startSonata()'로, 소나타에 맞추어져 있다. 이 때, Driver클래스의 차를 K9으로 바꾸게 된다면, 시동을 거는 메소드인 startTheCar() 메소드와 인스턴스 변수 driversCar도 바뀌어야 할 것이다. 이처럼 객체의 인스턴스가 바뀔 때 기존의 코드가 변화해야되는 경우는 개방 폐쇄 원칙을 잘 지키지 못한 예시이다.
이 코드를 개방 폐쇄 원칙에 따라 바꾸어보자.
public class Driver{
private Car driversCar;
public Driver(Car driversCar){
this.driversCar = driversCar;
}
public void startTheCar(){ // 차 시동걸기
driversCar.startCar(); // 차 종류에 따라 시동 거는 방법이 달라짐
}
}
public abstract class Car(){
public abstract void startCar();
}
public class Sonata extends Car{
@Override
public void startCar(){
System.out.println("차 열쇠로 시동을 건다.");
}
}
public class K9 extends Car{
@Override
public void startCar(){
System.out.println("스마트폰 어플을 통해 시동을 건다.");
}
}
기존의 코드와 다른 점은 K9과 Sonata에 상위 클래스를 두고 시동을 거는 메소드를 각각 Override 하고 있다. 따라서, Driver가 소유하고 있는 차가 K9이든 Sonata든 시동을 걸 때, 각각 재정의 된 메소드가 호출됨으로써 기존 코드의 변경 없이 인스턴스에 따라 다른 기능을 수행하게 된다.
자바와 자바의 JDBC는 개방 폐쇄 원칙의 좋은 예시라고 볼 수 있다. JDBC는 설정 부분을 제외하고 클라이언트 코드의 변경 없이 다른 데이터베이스를 사용할 수 있다. 자바는 JVM이 각 운영체제에 맞게 자바 코드를 실행시켜 주기 때문에 하나의 소스 코드로 여러 운영체제에서 실행 될 수 있다. 자바 소스 코드는 운영체제의 변화에 닫혀있고, 각 운영체제별 JVM은 확장에 열려있는 구조가 되는 것이다.
이처럼, 개방 폐쇄 원칙은 기능 확장,변화에 대해 초점을 맞추어 이해해야 한다. 개방 폐쇄 원칙을 잘 지킨 모듈에서는 기능을 추가하거나 변경할 때 해당 기능을 사용하는 코드에는 변화가 없어, 수정, 확장의 측면에서 유리하다.
3. LSP (Liskov Substitution Principle) : 리스코프 치환 원칙
서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다. - 로버트 C. 마틴
리스코프 치환 원칙은 하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 역할을 하는 데 문제가 없어야 한다는 원칙이다. 상속과 인터페이스를 올바르게 활용하고 있다면 이미 LSP를 잘 지키고 있다고 할 수 있다.
상속은 부모 - 자식간의 관계가 아니다. 또한, 상속을 계층도의 형태로 이해하고 있다면, 리스코프 치환 원칙에 어긋나는 설계를 할 확률이 높다. 상속은 분류도가 되어야 한다.
class Father{
public void fixSomething(){
System.out.println("fixing...");
}
/*
.
아빠의 역할
.
*/
}
class Daughter extends Father{
}
위의 계층도 형태로 클래스를 구성한 코드를 보자. 아빠가 상위 클래스이고 딸이 하위 클래스이다. 리스코프 치환 원칙은 하위 클래스의 인스턴스가 상위 클래스의 역할을 하는 데 문제가 없어야 한다고 했다. 따라서, 딸이라는 클래스의 객체가 아빠의 역할을 하는 데 문제가 없어야 한다. 이는 논리적으로 맞지 않다.
계층도의 다른 형태인 회사 조직도를 생각해보아도 그렇다. 회장이 최상위 클래스이고 사내 인턴이 하위 클래스라고 했을 때 리스코프 치환 원칙에 따르면, 인턴이 회장 역할을 하는데 문제가 없어야 한다. 이처럼 계층도의 형태로 상속을 이용하는 것은 잘못된 클래스 설계라고 할 수 있다.
class Animal{
public void eat(){ }
}
class Birds extends Animal{
public void fly(){ }
}
class Eagle extends Birds{
public void huntingFood(){ }
}
class Eagle extends Birds{
public void huntingFood(){ }
}
class Pigeon extends Birds{
public void makeNest(){ }
}
다음은 분류도의 형태로 상속을 이용한 모습이다. Eagle 클래스의 인스턴스는 Birds의 행동인 fly()와, Animal의 행동인 eat()를 하는데에 전혀 이상함이 없다. 하위 클래스의 것들이 상위 클래스의 역할을 하는 데 전혀 문제가 없는 것이다.
이처럼 상위 클래스에서 하위 클래스로 갈수록 추상적인 개념에서 보다 구체적으로 개념이 변화하게 되는 것이 올바른 상속의 활용이라고 볼 수 있다. 위의 예시 코드에서도 최상위 클래스인 Animal에서 Birds, Eagle로 개념이 점차 구체화 되는 것을 볼 수 있다. 계층도가 아닌 분류도의 형태로 클래스를 구성한다면 리스코프 치환 원칙은 잘 지켜질 수 있다.
4. ISP (Interface Segregation Principle) : 인터페이스 분리 원칙
클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다. - 로버트 C.마틴
SRP를 설명할 때 예시를 들었던 Person 클래스를 다시 보자.
public class Person{
int age;
int date; // 사귄 날짜
public void studySomething(){ } // 공부하기
public void drinkingWater() { } // 물 마시기
public void meetGirlfriend() { } // 여자친구 만나기
public void shootingRifle() { } // 총쏘기
}
Person클래스는 누군가의 남자친구이자, 군인이자, 학생의 역할을 지니고 있다. Person 클래스 인스턴스에 역할과 책임이 너무 많은 것이다. 만약, 남자친구의 역할을 하기 위해 Person boyfriend = new Person() 로 객체를 생성했다고 해보자. boyfriend가 가리키는 객체는 남자친구의 메소드 뿐만 아니라, 자신이 사용하지 않는 군인 역할의 메소드, 학생 역할의 메소드까지 전부 호출할 수 있는 상태가 된다.
SRP에서는 공통적인 것들을 상위 클래스에 두고 여러 서브 클래스로 나누었지만, ISP에서 제시한 해결책은 공통적이지 않은 메소드들을 인터페이스로 나누는 것이다.
public class Person implements Shootable, Boyfriend, Learnable {
int age;
public void drinkingWater() { } // 물 마시기
}
public interface Shootable {
public void shootingRifle();
}
public interface Boyfriend {
public final int date = 20220802;
public void meetGirlfriend();
}
public interface Learnable {
public void studySomething();
}
이제 Boyfriend tom = new Person() 로 객체를 생성하면 자신이 사용하는 메소드와 속성에만 의존 관계를 맺을 수 있다. 이처럼 ISP는 인스턴스가 핵심 기능에 집중하기 위해 여러개의 인터페이스로 나누어 사용하는 것을 말한다.
결론적으로 SRP와 ISP는 같은 문제에 대한 두 가지 다른 해결책이라고 볼 수 있다. 요구사항과 설계자의 취향에 따라 단일 책임의 원칙이나 인터페이스 분할 원칙 중 하나를 선택해서 설계할 수 있다.
또한, 인터페이스 분할 원칙에서 함께 등장하는 원칙이 인터페이스 최소주의 원칙이다. 하나의 인터페이스에는 최소한의 메소드를 구현하는 것이 좋다는 것이다. 최소한의 기능을 제공하며 하나의 역할에만 집중하기 위해 나온 원칙이 바로 인터페이스 최소주의 원칙이다. 많은 메소드를 가지고 있는 인터페이스를 여러 인터페이스를 분할하는 것도 ISP이다. 상위 클래스는 풍성하게, 인터페이스는 최소한으로 구성하자.
5. DIP (Dependency Inversion Principle) : 의존 역전 원칙
고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.
자주 변경되는 구체(Concrete) 클래스에 의존하지 마라. - 로버트 C. 마틴
다음과 같은 샌드위치 클래스가 있다고 해보자.
public class Sandwich{
FlatBread flatBread;
/*
..기타 속성들
*/
public Sandwich(){
flatBread = new FlatBread();
}
}
이 샌드위치의 구성 요소로 '플랫 브레드'라는 빵이 속성으로 있다. 그런데 만약 빵과 소스를 바꾸고 싶다면 어떻게 해야할까?
이 경우에서는 샌드위치가 변경 되기 쉬운 구체 클래스에 의존하고 있기 때문에, 기존의 코드를 변경해야 하는 번거로움이 생긴다.
public class Sandwich{
Bread bread;
/*
..기타 속성들
*/
public Sandwich(Bread bread){
this.bread = bread;
}
}
public interface Bread{
}
public class FlatBread implements Bread {
}
public class HoneyOat implements Bread {
}
하지만 다음과 같이 추상화된 것에 의존하는 형태라면 어떻게 될까? 기존 코드의 수정 없이, Sandwich의 Bread 인스턴스가 FlatBread이 될 수도, HoneyOat가 될 수도 있는 상태가 된다. 즉 Bread라는 인터페이스를 구현하는 클래스는 Sandwich의 bread 객체 참조 변수의 인스턴스가 될 수 있다는 뜻이다.
기존에는 FlatBread가 그 무엇에도 의존하지 않는 클래스였는데, 변경된 코드에서는 Bread라는 추상적인 인터페이스에 의존하고 있다. 이 것을 의존의 방향이 역전되었다고 한다. 위의 예시 코드에서는 외부 클래스에서 객체를 주입 받게 하였는데, 이를 의존성 주입이( DI- Dependency Injection )라고 하고, 생성자를 통해 의존성을 주입받고 있기때문에 생성자 주입이라고 할 수 있다.
즉, DI(의존성 주입)를 통해, DIP(의존 역전 원칙)를 적용 시킨 것이다.
이처럼 변하기 쉬운 구체 클래스에 의존하던 것을 추상화된 인터페이스/상위 클래스를 의존하게 하여 변하기 쉬운 것에 영향을 받지 않게 하는 것이 의존 역전 원칙 (DIP)이다. 이러한 개념은 OCP와 비슷한 논리를 가진다.
참고
https://www.nextree.co.kr/p6960/
스프링 입문을 위한 자바 객체 지향의 원리와 이해 - (김종민 / 위키북스)