객체지향 SOLID 원칙 이란?

카일·2020년 2월 27일
11

객체지향

목록 보기
1/2
post-thumbnail

안녕하세요 이번 포스팅에서는 객체지향에서 대표적인 원칙이라고 할 수 있는 SOLID원칙에 대해서 알아보고자 합니다. 설계가 올바르게 되었는지를 확인하는 하나의 기준과 가이드라인으로써 신뢰받고 있는 원칙에 대해서 학습함으로써 본인의 설계를 점검하고 재설계하는 과정에서 도움을 얻기를 기대합니다. 아래의 내용은 정인석, 채흥석 님이 지은 JAVA 객체지향 디자인 패턴이라는 책의 내용을 가져와 작성하는 것임으로 구체적인 설명과 예제를 원하신다면 이 책을 보시는 걸 추천합니다.

1 . SRP : 단일 책임 원칙

SRP란 Single Responsibility Principle라는 단일 책임 원칙을 의미하며 말 그대로 단 하나의 책임안을 가져야 한다는 것을 의미한다. 여기서 말하는 책임의 기본 단위는 객체를 의미하며 하나의 객체가 하나의 책임을 가져야 한다는 의미이다. 그렇다면 책임은 무엇인가? 객체지향에 있어서 책임이란 객체가 할 수 있는 것해야 하는 것 으로 나뉘어져 있다. 즉 한 마디로 요약하자면 하나의 객체는 자신이 할 수 있는 것과 해야하는 것만 수행 할 수 있도록 설계되어야 한다는 법칙이다.

그렇다면 왜 SRP를 지켜야하는 가? 이를 고전적 설계개념인 응집도와 결합도 관점에서 바라보자. 응집도란 한 프로그램 요소가 얼마나 뭉쳐있는가를 나타내는 척도이며 결합도는 프로그램 구성 요소들 사이가 얼마나 의존적인지를 나타내는 척도이다. 아래의 예제를 보며 SRP가 필요한 이유에 대해 생각해보자.

    public class Student {
    	public void getCourse(){	}
    	public void addCourse() {	}
    	public void save(){	}
    	public Student load() {	}
    	public void printOnReportCard() {	}
    	public void printOnAttendanceBook() {	}
    }

위의 예제에서 학생이라는 클래스는 수강과목을 조회하고 추가하고 데이터베이스에 저장하고 저장된 학생을 불러오고 기록을 출력하는 책임을 담당하고 있다. 이렇게 하나의 클래스가 다양한 책임을 갖는 경우가 문제가 되는 이유는 변경 이라는 관점에서 문제를 일으키기 때문이다.

잘 설계된 프로그램은 새로운 요구사항이나 변경이 있을 때 가능한 영향 받는 부분을 최소화해야한다. 하지만 위의 학생클래스에서는 데이터베이스와 관련된 활동 출력과 관련된 활동이 일어났을 때, 강좌를 조회하고 추가할 때 모두 변경의 대상이 된다. 즉 변화에 민감하게 대응해야하는 클래스가 되는 것이다.

뿐만 아니라 클래스 내부에서 서로 다른 역할을 수행하는 코드끼리 강하게 결합되는데 예를 들어 현재 수강과목을 조회하는 부분과 데이터베이스에서 학생 정보를 가져오는 코드는 어딘가에서 연결될 확률이 높다. 이러한 코드끼리의 결합은 하나의 변화에 대해 많은 변경사항을 발생시키고 관련된 모든 기능을 테스트해봐야하는 단점이 있다. 즉 이는 결국 유지보수 하기 어려운 대상이 된다. 따라서 각 객체는 하나의 책임만을 수행할 수 있도록 변경해야 한다.

위의 문제를 요약하면 프로그램에서 응집도는 낮아지고 결합도는 올라가게 된다. 객체가 다양한 기능을 하기 때문에 필요한 기능만이 모여있어야하는 응집도는 낮아지고 프로그램 내부에서 그리고 외부 프로그램간의 결합도는 올라가게 된다. (수 많은 기능과 얽혀있는 모든 클래스와 결합되기 때문에) 따라서 위와 같은 클래스는 학생학생DAO—성적표—출석부 등의 클래스를 통해 쪼개어 관리하는 것이 변경에 유연하게 대처할 수 있는 설계라고 할 수 있다. 이렇게 단일책임에 적절하게 분리하게 되면 변경된 부분만을 수정할 수 있고 각각 의존하고 있는 영역이 줄어들어 변경에 유연한 대처가 가능해진다. 뿐만 아니라 성적표 로직에 변경이 생기더라도 성적표와 관련된 부분만 테스트하면 되기때문에 유지보수 또한 관리하기 쉬워진다.(기존에는 학생 클래스 전체 + 의존하고 있는 모든 클래스 테스트)

AOP(Aspect Oriented Programming) 또한 SRP의 예제가 될 수 있다. 여러개의 클래스가 로깅이나 보안, 트랜잭션과 같은 부분은 공유하고 있을 수 있다. 이런 부분을 모듈화를 통해 각각 필요한 곳에 위빙해주는 방식을 위해 도입된 AOP또한 로깅, 보안, 트랜잭션과 같은 부분을 하나의 모듈에 단일책임으로 부여하여 이를 사용하게 할 수 있도록 함으로써 SRP를 지키는 방법이다.

2 . 개방-폐쇄 원칙 (OCP)

Open-Closed Principle 개방 폐쇄 원칙이란 간단하게 말해 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계되어야 한다는 뜻이다. OCP에서 중요한 것은 요구사항이 변경되었을 때 코드에서 변경되어야 하는 부분과 변경되지 않아야하는 부분을 명확하게 구분하여, 변경되어야 하는 부분을 유연하게 작성하는 것을 의미한다. 또한 확장에는 유연하게 반응하며 변경은 최소화하는 것을 의미한다. 아래의 예를 통해 개방 폐쇠 원칙에 대해 알아보자.

    public class Computer {
    	private KakaoMessenger kakaoMessenger;
    	
    	public static void main(String[] args) {
    		Computer computer = new Computer();
    		computer.boot();
    	}
    
    	private void boot() {
    		System.out.println("BOOTING.....");
    		kakaoMessenger = new KakaoMessenger();
    		kakaoMessenger.boot();
    	}
    }
    package kail.study.java.solid;
    
    public class KakaoMessenger {
    	public void boot() {
    		System.out.println("Kakao BOOTING....");
    	}
    }

위의 코드에서 컴퓨터를 실행하면 카카오톡이 함께 실행되는 코드를 작성하였다. 하지만 카카오톡을 더이상 쓰지 않고 라인을 사용한다는 변경사항이 생기면 어떻게 될까? 위의 코드에서 카카오를 새로 생성하는 것이 아니라 라인을 생성하고 라인에게 boot 를 실행하라는 메세지를 보내야 할 것이다. 즉 외부의 변경사항에 의해서 내부의 Production Code에 변경사항이 발생한다. 이러한 문제를 해결하기 위해서 아래와 같이 인터페이스를 통해 메신저를 분리하였는데

    package kail.study.java.solid;
    
    public class Computer {
    	private Messenger messenger;
    	
    	public static void main(String[] args) {
    		Computer computer = new Computer();
    		computer.setMessenger(new LineMessenger());
    		computer.boot();
    	}
    
    	private void setMessenger(Messenger messenger) {
    		this.messenger = messenger;
    	}
    
    	private void boot() {
    		System.out.println("BOOTING.....");
    		messenger.boot();
    	}
    }
    package kail.study.java.solid;
    
    public class LineMessenger implements Messenger{
    
    	@Override
    	public void boot() {
    		System.out.println("Line BOOTING....");
    	}
    }
    package kail.study.java.solid;
    
    public interface Messenger {
    	void boot();
    }

이렇게 작성하는 경우 어떠한 메신저로 변경되어도 하나의 클래스만 추가함으로써 외부의 변경에는 유연하게 대응 할 수 있으며 내부적으로 Production Code를 변경하지 않는 OCP원칙을 지키는 코드를 완성 할 수 있다.

여담으로 초보자인 나는 이런 생각을 했다. 결국 클래스를 추가하는 것 또한 변경을 의미하고 Production Code의 일부를 말하는 것 아닐까? 하지만 이 부분은 아래와 같이 생각한다면 우문이었다는 것을 알게 된다.

  • Production Code란 기존에 짜여져 있는 코드를 의미하며 위의 코드에서 메신저가 어떤 메신저인지 판별하는 if 문을 통해 해결할 수도 있지만 이는 기존 코드의 전체 작동방식을 이해해야 할 수 있다. 즉 현업에서 관련 로직이 어떻게 작성되었고 작동하는지를 명확히 알고 있어야 추가적인 작업이 가능해진다.
  • 하지만 위와 같이 인터페이스를 통해 분리하면 기존의 코드에는 변경이 없으며 단순히 하나의 클래스를 추가하고 오버라이딩만 해준다면 내부 동작원리를 알지 못해도 사용 할 수 있다.

OCP를 또 하나의 관점은 클래스를 변경하지 않고도 대상 클래스의 환경을 변경할 수 있는 설계가 되어야 한다. 이를 위해 Mock Stub 등의 객체들이 사용되며 특히 단위테스트에서 이러한 것들이 유용하게 사용된다.

3 . 리스코프 치환 원칙(LSP)

Liskov Substitution Principle 리스코프 치환 원칙은 단순하게 풀어보면 LSP는 일반화 관계에 대한 이야기이며 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위를 수행할 수 있어야 한다는 의미이다. 좀 더 쉽게 이야기하면 LSP를 만족하면 프로그램에서 부모 클래스의 인스턴스 대신에 자식 클래스의 인스턴스로 대체해도 프로그램의 의미는 변화되지 않는다. 이를 위해 부모 클래스와 자식 클래스의 행위는 일관되어야 한다. 아래의 예제를 보면

    package kail.study.java.solid;
    
    public class Bag {
    	private double price;
    
    	public double getPrice() {
    		return price;
    	}
    
    	public void setPrice(double price) {
    		this.price = price;
    	}
    }
    package kail.study.java.solid;
    
    public class DiscountedBag extends Bag{
    	private double discountRate;
    
    	public void setDiscountRate(double discountRate) {
    		this.discountRate = discountRate;
    	}
    
    	public void applyDiscount(int price) {
    		super.setPrice(price- (int)(discountRate * price));
    	}
    }

프로그램에서 Bag를 사용하고 있는 부분을 DiscountedBag으로 대체하여도 LSP에 위반되지 않는다. 부모의 기능을 오버라이딩하지 않고 그대로 사용하고 있기 때문에 일반화 관계가 성립하기 때문이다. 하지만 DiscountedBag 클래스에는 applyDiscount의 기능을 가지고 있다. 이 기능을 사용하게 되면 부모와 자식은 대체관계가 되지 않는다. 즉 자식클래스가 부모클래스를 오버라이딩하거나 추가적인 기능을 통해 부모의 상태를 변경시키는 것은 LSP원칙을 위반하는 것이다.

즉 LSP는 서브 클래스가 슈퍼 클래스의 책임을 무시하거나 재정의 하지 않고 확장만 수행한다는 것을 의미한다. 다시 말해 부모가 수행하고 있는 책임을 그대로 수행하면서 추가적인 필드나 기능을 제공하려는 경우에만 상속을 하는 것이 바람직하며 부모 클래스의 책임을 변화시키는 기능은 LSP 법칙에 위배 된다고 볼 수 있다.

4 . 의존 역전 원칙 DIP

객체지향 프로그래밍에서 객체는 서로 도움을 주고 받으며 의존 관계를 발생시킨다. Dependency Inversion Principle은 그러한 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존하라는 가이드라인을 제공하는 원칙이다.

그렇다면 OCP에서도 언급했던 변화하기 쉬운 것과 변화하지 않는 것은 무엇을 기준으로 구분하면 되는가? 정책이나 전략과 같은 어떤 큰 흐름이나 개념 같은 추상적인 것은 변하기 어려운 것에 해당하고 구체적인 방식, 사물 등과 같은 것은 변하기 쉬운 것으로 구분하면 좋다. 예를 들어 아이가 장난감을 가지고 논다라는 개념에서 아이와 장난감은 자주 변화되지 않는 개념적인 것이고 장난감은 종류에 따라 다양해 질 수 있다. 따라서 장난감이라는 부분을 추상화하여 의존하는 것이 바람직하다.

DIP를 만족하려면 어떤 클래스가 도움을 받을 때 혹은 의존할 때 구체적인 클래스는 변화할 확률이 높기 때문에 이를 추상화한 인터페이스나 추상 클래스와 의존관계를 맺도록 설계해야 한다. 컴퓨터와 키보드의 예제에서도 단순히 한 제조사의 키보드가 아닌 전체 키보드와 의존관계를 맺음으로써 DIP 원칙을 준수한 것이다. DIP를 만족하면 의존성 주입이라는 기술로 변화를 쉽게 수용할 수 있는 코드를 작성할 수 있다. 의존성 주입이란 말 그대로 클래스 외부에서 의존되는 것을 대상 객체의 인스턴스 변수에 주입하는 기술이다.

    package kail.study.java.solid.dip;
    
    public class Kid {
    	private Toy toy;
    
    	public void setToy(Toy toy) {
    		this.toy = toy;
    	}
    
    	public void play() {
    		System.out.println(toy.toString());
    	}
    }
    package kail.study.java.solid.dip;
    
    public class Lego extends Toy{
    	@Override
    	public String toString() {
    		return "Lego";
    	}
    }
    package kail.study.java.solid.dip;
    
    public abstract class Toy{
    	public abstract String toString();
    }

    package kail.study.java.solid.dip;
    
    public class Main {
    	public static void main(String[] args) {
    		Kid kid = new Kid();
    		kid.setToy(new Lego());
    		kid.play();
    	}
    }

위와 같이 변경될 수 있는 장난감은 abstract class 혹은 interface 를 통해 관리함으로써 변경사항에 대해서 유연하게 대처할 수 있고 변화하는 부분을 추상화하여 변화되지 않는 형태로 만든 추상클래스를 의존하기 때문에 DIP원칙과 OCP 둘다 만족하는 형태를 갖는다.

5 . 인터페이스 분리 원칙 ISP

Interface Segregation Principle 인터페이스 분리 원칙은 클라이언트에서는 클라이언트 자신이 이용하지 않는 기능에는 영향을 받지 않아야 한다는 내용이 담겨 있다. 예를 들어 복합기를 이용하는 다양한 사람을 생각해보자. 복사를 하고 싶은사람과 프린트를 하고 싶은사람, 팩스를 보내고 싶은 사람은 복합기가 다양한 기능을 제공하고 있지만 본인이 원하는 기능만이 작동하면 되며 자신이 이용하지 않는 기능에 대해서는 영향을 받지 않는다. 이러한 기능을 제공하고 싶을 때 사용되는 것이 ISP이며 사용 방법은 범용의 인터페이스를 만드는 것이 아니라 클라이언트에 특화된 인터페이스를 사용해야한다. 즉 인터페이스를 클라이언트에 특화되도록 분리시키라는 설계 원칙이라고 할 수 있다.

ISP와 SRP는 동일한 문제에 대해 다른 해결책을 제시하고 있는 것과 비슷하다. 하나의 클래스가 기능이 비대하다면 책임을 분할하여 이를 갖게하는 것이 SRP이고 비대한 기능을 인터페이스로 분할하여 사용하는 것이 ISP를 의미한다. 물론 책임을 적절히 분할하여 각각의 인터페이스를 사용한다면 둘다를 충족하는 것이지만 그렇지 않은 경우도 존재한다.

예를 들어 게시판의 여러 기능을 구현한 메서드를 제공하는 클래스에서는 CRUD가 제공된다. 그러나 클라이언트에 따라서 게시판의 이러한 기능 중 일부분만 사용할 수 있고 관리자는 모든 기능을 사용할 수 있다고 가정하자. 이 경우 게시판은 관련된 책임을 수행하므로 SRP를 만족하지만 이 클래스의 모든 메서드가 들어 있는 인터페이스가 클라이언트와 관리자 모두가 사용한다면 ISP에는 위배된다. 이 경우 관리자용 인터페이스와 일반 인터페이스를 분리함으로써 ISP 위반 또한 함께 해결 할 수 있다.

즉 다양한 기능을 인터페이스화함으로써 클라이언트에서 인터페이스를 사용할 때 타 인터페이스의 영향을 받지 않고 본인이 구현하고자 하는 기능만을 선택할 수 있게 한다

7개의 댓글

comment-user-thumbnail
2020년 12월 4일

감사합니다!!! 많은 도움이 되었습니다

답글 달기
comment-user-thumbnail
2021년 6월 2일

잘 읽고 갑니다

답글 달기
comment-user-thumbnail
2021년 11월 25일

정말 쉽게 설명해주셔서 이해에 많은 도움 됐습니다!

답글 달기
comment-user-thumbnail
2022년 7월 11일

내일 면접인데 ㅠㅠ 게시물 보고 도움 많이 얻고 갑니다!! 감사합니다!!

답글 달기
comment-user-thumbnail
2022년 7월 12일

공부하는데 많은 도움이 되었습니다. 감사합니다 개발자님! :)

답글 달기