無테고리 인생살이

[JAVA] 좋은 객체 지향 프로그램을 만들기 위한 5가지 원칙 - SOLID 본문

Java

[JAVA] 좋은 객체 지향 프로그램을 만들기 위한 5가지 원칙 - SOLID

無격 2023. 2. 3. 23:00

출처 : https://velog.io/@haero_kim/SOLID

 

객체 지향의 개념과 4대 특성을 어느 정도 이해하고 있다면, 객체 지향 프로그램을 올바르게 설계하는 방법(원칙)에 대해 알고 있어야 한다. 객체 지향 언어의 등장부터 지금까지 수많은 객체 지향 프로그램이 개발되었고, 많은 시행착오와 베스트 프렉티스 속에서 객체 지향 설계 5원칙이 완성되었다. 앞 글자만 따서 SOLID라고 부른다.


S - 단일 책임 원칙 (Single Responsibility)

O - 개방 폐쇄 원칙 (Open Closed)
L - 리스코프 치환 원칙 (Liskov Substitution)
I - 인터페이스 분리 원칙 (Interface Segregation)
D - 의존관계 역전 원칙 (Dependency Inversion)


SOLID는 좋은 소프트웨어 설계를 위해 응집도는 높이고, 결합도는 낮추는 기본 원칙(High Cohesion, Loose Coupling)을 객체 지향 관점에서 재확립(재정의)한 개념이자 원칙이다. 높은 응집도와 낮은 결합도는 유지보수를 수월하게 만들고, 객체 지향의 4대 특성의 대원칙이었던 유지보수의 편리성 또한 객체 지향 5원칙인 SOLID에도 적용이 된다. 또, SOLID는 개념이기 때문에 객체 지향 프로그램을 구성하는 상태, 행위, 클래스, 객체, 패키지, 모듈, 라이브러리, 프레임워크, 아키텍처 등 다양한 곳에서 적용될 수 있고 개발자는 자신이 개발 중인 소프트웨어에 SOLID를 녹여 내야 한다. SOLID를 잘 녹여낸 소프트웨어는 이해하기 쉽고, 리팩터링과 유지보수가 수월하며, 논리 정연하다. SOLID는 객체 지향 4대 특성을 발판으로 하고 있으며, 디자인 패턴의 뼈대이고 스프링 프레임워크의 근간이다.

 

 


1. SRP (Single Responsibility Principle) : 단일 책임 원칙

  • 하나의 클래스는 하나의 책임만 가져야한다.

하나의 책임..이라는 것은 굉장히 모호하다. 책임이 클거나 작을 수도 있고 문맥과 상황에 따라 다르기 때문이다. 하나의 클래스가 하나의 책임만을 가지고 있는지, 즉 SRP 원칙을 잘 따랐는지를 판단하는 기준을 애매모호한 책임 대신 '변경'에 두자! 변경사항이 생겼을 때, 애플리케이션 파급 효과가 적다면 단일 책임 원칙을 잘 따른 설계로 판단하자는 것이다. 

SRP 원칙을 잘 따르는 프로그램을 만들기 위해서 개발자는 클래스의 책임 범위를 적절하게 조절해 요구사항이 변경되었을 때 하나의 클래스의 한 부분만 고치면 되도록 설계해야 한다.

SRP는 객체 지향 4대 특성 중 어떤 것과 관련있을까?

: SRP 단일 책임 원칙과 가장 관계가 깊은 것은 모델링 과정인 추상화이다. 애플리케이션 경계(개발할 도메인)을 정하고 추상화를 통해 클래스를 설계할 때 반드시 단일 책임 원칙을 고려하는 습관을 가져야 한다. 추상화를 통해 클래스를 설계할 때 뿐만 아니라 리팩터링을 통해 코드를 개선할 때도 SRP를 위배한 곳을 찾아 적용시키는 것이 중요하다.

 


2. OCP (Open Closed Principle) : 개방 폐쇄 원칙

  • 확장에는 열려있고, 변경에는 닫혀있어야 한다. 

코드를 변경하지 않고, 기능을 추가(확장)할 수 있도록 설계하라는 의미이다.

그렇다면, 코드의 변경없이 어떻게 기능을 확장할 수 있을까?

: 객체 지향의 4대 특성 중 하나인 다형성을 활용해서 개방 폐쇄 원칙을 지킬 수 있다(?)..

다형성을 활용했지만 OCP 원칙이 지켜지지 않은 경우 

기존의 MemberService는 MemoryMemberRepository에만 의존해 데이터를 읽고 쓰는 중이었다. 하지만, DB에 접근해서 데이터를 조작하기 위해 MemberRepository 인터페이스와 JdbcMemberRepository 구현체를 추가로 만들었다. 기존 코드가 변경된 것이 없고 새로운 인터페이스와 구현체가 추가된 것이므로 다형성을 통해 개방 폐쇄 원칙을 잘 지킨 것으로 보인다(?).. 이제 아래의 코드를 통해 구현체를 바꿔보자.

public class MemberService {
    // private MemberRepository m = new MemoryMemberRepository();
    private MemberRepository m = new JdbcMemberRepository(); 
}

JdbcMemberRepository로 구현체를 변경하기 위해서, 위 코드처럼 기존 코드를 주석 처리하고 구현체를 직접 바꿔줘야한다. 이는 코드의 변경을 통해 기능을 확장한 것이므로, 다형성을 활용했지만 OCP 원칙을 위반한 코드이다. 

그렇다면, 다형성을 사용했지만 해결되지 않은 이 문제를 어떻게 해결해야 할까?

객체를 생성하고 연관관계를 맺어주는 별도의 조립을 해주는 스프링 컨테이너가 해당 문제를 해결해준다. 즉, OCP 원칙을 지키기 위해서 다형성과 함께 DI, IoC 컨테이너가 필요하다. 아래 DIP 원칙에서 다시 한번 설명하겠지만, 다형성과 스프링 프레임워크가 만나야 OCP, DIP 원칙을 지킬 수 있다.

 

 


3. LSP (The Liskov Substitution Principle) : 리스코프 치환 원칙

  • 프로그램에서 상위 클래스의 인스턴스를 하위 클래스의 인스턴스로 바꿔도 프로그램의 의미가 변하지 않아야 한다.
  • 다형성을 지원하기 위한 원칙으로, 다형성에서 하위 클래스는 인터페이스 규약을 전부 지켜야 한다는 것을 의미한다. 

 

아래 두 문장을 구현한 프로그램이면 이미 리스코프 치환 원칙을 잘 지키고 있는 것이다. 

  • 하위 클래스 is a kind of 상위 클래스
  • 구현 클래스 is able to 인터페이스

리스코프 치환 원칙은 객체 지향의 상속이라는 특성을 올바르게 사용하면 자연스럽게 얻게 된다. 

 


4. ISP (Interface Segregation Principle) : 인터페이스 분리 원칙

  • 범용 인터페이스 하나보다 특정 클라이언트를 위해 여러 인터페이스로 분리하는 것이 더 좋다.
    (인터페이스는 최소한의 기능(메서드)만 제공해야 한다)

예를 들어보자면,

'자동차'라는 인터페이스에 목적이 다른 drive, repair 기능을 두는 것보다, '운전', '정비' 인터페이스로 분리하여 각각의 기능만을 가지는 것이 더 좋다. 이렇게 ISP 원칙을 지키면 분리된 인터페이스에 따라 클라이언트도 '운전자', '정비사'로 분리할 수 있고 '정비' 인터페이스가 변경되어도 '운전자' 클라이언트에 영향을 주지 않는다.

ISP 위반

interface Car {
	public void drive();
	public void repair();
}

class Driver implements Car {
	@Override
	public void drive() { ... }
    
	@Override    
	public void repair() { ... } // Driver 객체에겐 불필요한 기능
}

운전자 클래스는 불필요한 수리 기능까지 구현해야하는 문제가 발생.. 이러한 문제를 해결하기 위해 인터페이스의 기능을 분리한다.

ISP 적용

interface Drive {
	public void drive();
}

interface Repair {
	public void repair();
}

class Driver implements Drive {
	@Override
	public void drive() { ... }
}

class Mechanic implements Repair {
	@Override
	public void repair() { ... }
}

인터페이스를 분리하면서 Drive, Repair 둘 중 하나의 인터페이스가 변경되더라도 나머지 하나의 클라이언트 객체의 코드는 변경되지 않는다. 

 

 


5. DIP (Dependency Inversion Principle) : 의존관계 역전 원칙

  • 클라이언트 코드는 구체적인 대상이 아닌 추상화 정도가 높은 대상과 의존 관계를 맺어야 한다. 

구현 클래스가 아닌 인터페이스에 의존하라는 의미이다. (OCP 원칙과 굉장히 유사)

위의 개방 폐쇄 원칙에서 사용했던 그림과 코드를 다시 확인해보자.

MemberService(클라이언트 코드)가 구현 클래시인 MemoryMemberRepository와 JdbcMemberRepository말고 MemberRepository 인터페이스에 의존해야 한다는 것이 DIP 원칙이다. 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있다. 

public class MemberService {
    // private MemberRepository m = new MemoryMemberRepository();
    private MemberRepository m = new JdbcMemberRepository(); 
}

해당 코드는 위의 OCP 설명 글에서, 다형성을 사용했지만 MemberService(클라이언트)에서 코드를 변경해야 확장이 가능해서 OCP를 위반한다고 했다. DIP 원칙 관점으로 보자면, MemberService는 분명히 인터페이스에 의존하고 있지만 동시에 구현 클래스에도 의존하고 있다. 추상화에만 의존하라는 원칙을 지키지 않았기 때문에, DIP도 위반하고 있는 코드다. 결과적으로 위 코드는 다형성을 활용했지만 OCP, DIP 모두를 지키지 않았다. 

 

 


정리 

객체 지향의 핵심은 '다형성'이다. 하지만, OCP와 DIP에서 봤듯이 다형성만으로 클라이언트 객체의 코드 변경 없이 구현체를 손쉽게 갈아 끼울 수 없다. 즉, 다형성만으로는 OCP, DIP 원칙을 위반할 수 밖에 없다는 것이다. 이를 해결하기 위해 스프링 프레임워크 등장했다. (더 자세히는 스프링 프레임워크가 아니라 DI 컨테이너 !)
스프링은 다형성을 극대화해서 사용할 수 있게 해준다. 스프링의 IoC(제어의 역전), DI(의존 관계 주입)은 다형성을 활용해서 역할과 구현을 편리하게 다룰 수 있도록 지원한다.  스프링 프레임워크는 객체 지향의 특성, 설계 원칙, 디자인 패턴 위에 구현돼 있기 때문에 제대로 이해하고 넘어가야 한다.

 

 
 
 
 
 
 
 

참고자료

www.inflearn.com/course/스프링-핵심-원리-기본편

스프링 입문을 위한 자바 객체 지향의 원리와 이해 / 김종민

https://youngjinmo.github.io/2021/04/principles-of-oop/

Team-ITDA / 1eehyeji

https://junghyungil.tistory.com/98