[SoftwareEngineering] SOLID란?

두지·2023년 4월 5일
0
post-thumbnail

개발자라면 기본 소양 중 하나인 클래스의 설계 원칙에 대해 들어봤나요?!

클래스를 설계할 때 참고할 수 있는 몇가지 원칙이 있다.
원칙을 따르지 않고 더 빨리 개발하기도 하지만 훗 날 유지보수 측면에선 절약한 시간보다 훨씬 많은 시간비용이 많이 들지 모른다.
클래스 설계를 잘하기 위한 5가지 원칙이 있다.

SOLID

S : Single-Responsibility Principle, SRP (단일 책임 원칙)
O : Open-Closed Principle, OCP (개방 폐쇄 원칙)
L : Liskov Substitution Principle, LSP (리스코프 교체 원칙)
I : Interface Segregation Principle, ISP (인터페이스 분리 원칙)
D : Dependency Inversion Principle, DIP (의존 관계 역전 원칙)

하나씩 설명과 몇 가지는 간단한 코드예제로 설명해보겠다.

S:Single-Responsibility Principle, SRP(단일 책임 원칙)

단일 책임 원칙 : 클래스를 변경해야하는 이유는 단 하나여야한다.

로버트 마틴(Robert C. Martin)이 말했다. 단일 책임이란 말 그대로 책임이 하나라는 의미인데, 여기서 책임이라는 의미는 변경하는 이유라고 말이다. 따라서 단일 책임의 원칙은 클래스를 설계할 때 변경하는 이유가 하나가 되도록 해야한다는 것이다. 모든 클래스가 단 한가지의 책임, 즉 변경의 이유를 오직 하나만 갖도록 설계하고 클래스는 그 책임을 완전히 캡슐화하는 것을 말한다. 그렇지않고 클래스 않에 2가지의 책임이 있도록 하면 응집력이 떨어지는 설계이다. 따라서 단일 책임을 갖도록 하여 응집도가 높은 설계를 하라는 것이다.

이 원칙은 객체 수가 늘어나고 구조가 복잡해지더라도 재사용 및 유지보수를 위해 지켜져야한다.

예를들어 하나의 클래스에 성적관련 메서드와 수강 관련 메서드가 함께 있다면 두가지 이유로 변경될 것이다. 하나는 성적 관련 메서드에 변경이 되었을 때와 나머지 하나는 수강 관련 메서드에 변경이 되었을 때이다. 이렇게 클래스를 변경할 이유가 두가지라면 각각의 클래스로 분리하여 SRP의 원칙을 따라야한다.

SRP를 적용 전 후를 비교하는 클래스이다.

SRP 적용 전(클래스 1개)

Class :
학사관리

Method :
+수강신청()
+시간표조회()
+개설과목조회()
+성적입력()
+성적조회()
+누적성적조회()

SRP 적용 후(클래스 2개)

Class :
수강관리

Method :
+수강신청()
+시간표조회()
+개설과목조회()

Class :
성적관리

Method :
+성적입력()
+성적조회()
+누적성적조회()

이렇게 책임을 분리하여 클래스를 설계하면 된다.

O : Open-Closed Principle, OCP (개방 폐쇄 원칙)

개방 폐쇄 원칙 : 변경에는 닫혀 있어야하고, 확장에는 열려있어야한다.

이 원칙은 예전에 스프링을 배울때도 자주 언급되던 원칙이다. 개방 폐쇄 원칙은 확장될 것은 개방하고, 변경될 것엔 폐쇄 되어야한다는 것이다.

먼저, 예시를 보여주고 설명하겠다.

Class :
Video_player

Method :
+MP4_play()

MP4 형식 확장 파일만 재생할 수 있는 Video_player 클래스이다. 그런데 시간이 지나 파일 형식이 MP21로 업그레이드 된다면

Class :
Video_player

Method :
+MP21_play()

메서드를 MP21_play()로 바꿀 것이다. 또 시간이 지나서 재생할 수 있는 코덱으로 AVI_play()가 추가된다면

Class :
Video_player

Method :
+MP21_play()
+AVI_play()

이렇게 AVI_play() 메서드를 추가시켜 수정해야하고 또 WMV 코덱까지 지원한다면

Class :
Video_player

Method :
+MP21_play()
+AVI_play()
+WMV_play()

이렇게된다. 만약 계속 이런식으로 지원가능한 파일 형식이 늘어날 때마다 클래스를 수정하는 것이 맞는것인가 봐야한다. OCP를 적용 전 설계 과정이 있다.

먼저, 계속해서 바뀌는 것인지 무엇인지 찾아야한다. 여기 예제의 클래스에서는 코덱의 종류이다. 유사한 종류의 코덱을 계속 추가하는 경우에는 계속 바뀌는 것들을 상속 구조로 만든다. 여기서는 코덱의 종류를 묶어 이름을 붙인 playCode 상위 클래스를 놓고 하위 클래스에는 코덱의 종류를 배치해 계속 추가할 수 있도록 한다.

그 다음은, Video_player 클래스에서 이들을 호출해 사용하도록 한다.

이 처럼 개방 폐쇄 원칙을 따라 클래스를 설계하면 다음과 같은 장점이 있다.

  • 변경에 닫혀 있는 설계
    새로운 지원 파일 형식이 추가되어도 Video_Player 클래스를 수정할 필요가 없다. 이것이 바로 "변경에는 닫혀 있어야 한다"에 해당하는 말이다. 즉, 계속된 추가 사항이 있어도 그것들 끼리 추가할 수 있도록 설계해야지 Video_Player 클래스를 수정하게 만들면 안 된다는 것이다. 그래서 추가라는 변경에는 아무런 영향이 없도록 닫혀 있게 된다.
  • 확장에 열려 있는 설계
    새로운 파일 형식이 추가되어도 상속 구조로 만들어져 있으므로 추가되는 하위 클래스만 만들어주면 왼다. 이것이 "확장에는 열려 있어야 한다"에 해당하는 말이다. 즉, 새로운 클래스를 쉽게 추가할 수 있도록 설계 되어 있다.

L : Liskov Substitution Principle, LSP (리스코프 교체 원칙)

리스코프 교체 원칙 : 상위 클래스의 객체는 언제나 자신의 하위 클래스의 객체로 교체할 수 있어야 한다. (Liskov, 미국 MIT 대학교수 제시)

LSP로 불리는 리스코프 교체 원칙에 의하면 상위 클래스의 객체가 들어갈 자리에 하위 클래스의 객체를 넣어도 문제 없이 잘 작동한다. 이를 돕기 위해서 강사료 계산 프로그램(코드)을 가지고 설명한다.

일반(전업) 시간 강사는 90,000원/1시간
직업 있는 강사는 45,000/1시간(전업 강사의 50%)
대학원 시간 강사 180,000/1시간(전업 강사의 200%)
야간 강의 시간 강사 135,000/1시간(전업 강사의 150%)

(LSP 위반 Lecturer_Before 클래스)


public class Lecturer_Before{
    public Lecturer(){
        System.out.println("[리스코프 교체 원칙 위반]");
    }
    
    public String lecturer;
    public int charge = 90000;
    public void Lecturer(){
        System.out.println("일반 강사");
        System.out.println("강사료 : " + charge + "원\n");
    }
}

(LecturerNight 클래스)

public class LecturerNight extends Lecturer_Before{
    public LecturerNight(){
       lecturer = "야간 강사";
    }
    public void Lecturer(){
        System.out.println(lecturer);
        System.out.println("강사료 : " + charge*1.5 + "원\n");
    }
}

(LecturerAtGS 클래스)

public class LecturerAtGS extends Lecturer_Before{
    public LecturerAtGS(){
       lecturer = "대학원 강사";
    }
    public void Lecturer(){
        System.out.println(lecturer);
        System.out.println("강사료 : " + charge*2.0 + "원\n");
    }
}

(LecturerWithWork 클래스)

public class LecturerAtGS extends Lecturer_Before{
    public LecturerAtGS(){
       lecturer = "직업있는 강사";
    }
    public void Lecturer(){
        System.out.println(lecturer);
        System.out.println("강사료 : " + charge*0.5 + "원\n");
    }
}

(Main 클래스)

public class Main{
    public static void main(String[] args) {
        LecturerAtGS lect01 = new LecturerAtGS();
        lect01.Lecturer();

        LecturerNight lect02 = new LecturerNight();
        lect02.Lecturer();

        LecturerWithWork lect03 = new LecturerWithWork();
        lect03.Lecturer();

        Lecturer_Before lect04 = new Lecturer_Before();
        lect04.Lecturer();
    }
}

실행결과

[리스코프 교체 원칙 위반]
대학원 강사
강사료 : 180000원

[리스코프 교체 원칙 위반]
야간 강사
강사료 : 135000원

[리스코프 교체 원칙 위반]
직업이 있는 강사
강사료 : 45000원

[리스코프 교체 원칙 위반]
일반 강사
강사료 : 90000원

얼핏 보면 다형성의 재정의를 활용하여 잘 설계한 경우라고 보일 수도 있고, 이것은 리스코프 교체 원칙을 위반했다. 그 이유는 '상위 클래스의 객체가 들어갈 자리에 하위 클래스의 객체를 넣어도 문제없이 같은 결과가 나와야한다.이다. 그런데 이 클래스의 다이어그램은 Lecturer()를 하위 클래스에서 재정의하여 사용했기 때문에 상위 클래스 Lecutere()에서 대학원 시간강사의 Lecturer()을 대체하면 문제가 발생한다. 이러한 이유는 재정의(오버라이딩)을 무분별하게 잘못 사용했기 때문이다.

리스코프 교체 원칙은 상속과 오버라이딩(재정의)의 중요성을 강조한다. 지금까지 상속 구조를 가볍게 생각하고 사용했다면, 피터 코드의 상속규칙에 맞게 사용해야한다. 이 규칙 중 하나는 "하위 클래스는 상위 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행한다."라고 되어있다. 그렇다면 재정의가 필요없는데 왜 다형성의 개념을 자세히 다룰까? 그 해답은 여기에 있다.

지금까지 내용으로 보면 재정의(오버라이딩)을 마치 문제의 주범으로 생각할 수 있다. 그러나 그것은 재정의를 잘못 썼기 때문이다. 재정의는 상위 클래스의 메서드가 abstract(추상) 메서드일때만 사용해야한다. LSP를 위반한 메서드도 구현부분을 가지고 있는 일반적인 메서드이다.

즉, Lecturer이라는 추상 클래스를 선언하고 구현부에는 메서드를 구현하지 않고 abstract method만 선언해놓는다.

그리고 따로 LecturerNomal 클래스를 따로 만들어 놓고 다른 하위 클래스처럼 Lecturer 추상 클래스의 상속받아 구현한다.

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

인터페이스 분리 원칙 : 클라이언트는 자신이 사용하지 않는 메서드와 의존 관계를 맺으면 안 된다.(by 로버트 마틴)

다수의 클라이언트가 일반적인 인터페이스 하나를 같이 사용하는 것보다 각각의 클라이언트가 필요한대로 여러개의 구체적인 인터페이스로 분리하는것이 더 낫다는 뜻이다. 그렇다고 무조건 모든 클라이언트에게 각각의 인터페이스를 제공하라는 것은 아니다/ 각각의 클라이언트가 필요로하는 메서드군이 존재할때 분리하라는 뜻이다.

이렇게 인터페이스를 분리해 용도가 명확한 인터페이스를 제공할 수 있어 클래스에 필요한 메서드를 선언할 수 있고, 시스템의 내부 의존성을 낮춰(낮은 결합성) refactoring을 쉽게 해 재사용성을 높힐수도 있다.

아래는 인터페이스 분리 원칙을 위반한 예시이다.

  • 학사관리 클래스에 많은 메서드가 있고 이 메서드는 학생, 교수, 직원이 모두 사용한다.

Class :
학사관리
Method:
+수강신청()
+성적조회()
+성적등록()
+출석부조회()
+수강신청기간설정()
+성적조회기간설정()

  • 학생이 사용하는 것은 수강신청(), 성적조회() 뿐이다.
  • 직원이 사용하는 것은 수강신청기간설정(), 성적조회기간설정() 뿐이다.
  • 교수가 사용하는 것은 성적등록(), 출석부조회()와 연관관계를 맺고 있다.

위 예시를 보면 교수와 직원은 사용하지 않는 수강신청(), 성적조회() 메서드와 연관을 맺고 있고 학생 또한 성적등록(), 수강신청기간설정(), 성적조회기간()등을 맺고 있다. 이렇게 교수와 직원도 필요하지 않는 메서드와 연관을 맺는데 이러한 문제점을 해결하기 위해 인터페이스를 분리하여 메서드의 구현도 같이 분리된다.

조심할것은 '단일 책임 원칙'과 혼동되면 안 된다. 단일 책임 원칙은 클래스의 단일 책임을 강조하고 크래스로 분리되어 메서드의 구현도 같이 분리된다. 반면에 인터페이스 분리 원칙은 인터페이스의 단일 책임을 강조하는 것으로 인터페이스 부분만 분리하고 구현하는 부분은 그대로 남아 있다. 예를들어 학생 인터페이스의 수강신청(), 성적조회()의 구현은 학사관리 클래스에 그대로 남아있다.

D : Dependency Inversion Principle, DIP (의존 관계 역전 원칙)

의존 관계 역전 원칙 : 클라이언트는 구체 클래스가 아닌 추상 클래스에 의존해야한다.

객체지향 설계에서 의존관계는 쉬운 부분이 아니다. 서로의 관계가 의존적이라는 것은 결합도가 높다는 것이고 이는 클래스 설계에서 결코 좋은 관계가 아니다. 그러면 단순한 형태로 설계한 예를 보자. 모든 Game 객체를 GameServer 클래스에서 직접 생성해 연관 관계를 맺고 if 조건문을 실행하고자 하는 게임 값인 String game이 supermario인 경우에 Supermario 객체를 초기화하고 Supermario 객체를 초기화 하고 Supermario 객체의 게임명, 버전 정보를 리턴한다. 마지막으로 tetris 경우도 Tetris 객체를 초기화 하고 객체의 정보를 리턴한다.

(DIP 적용 전의 GameServer 클래스)

public class GameServer{
	public String title;
    public Stirng version;
    public Supermario supermario;
    public Tetris tetris;
    public LOL lol;
    public Racing racing;
    public void Game_Play(String game){
    	chiceGame(game);
        Start();
    }
    
    public void Start(){
    	System.out.println("게임명 : " + title);
        System.out.println("버전 : "  + version;
        System.out.println(title + "을(를) 시작합니다.\n");
    }
    
    public void choiceGame(String game){
    	if(game.equals("supermario")){
        supermario = new Supermario();
        title = supermario.returnTitle();
        version = supermario.returnVersion();
        } else if(game.equals("tetris")){
        tetis = new Tetris();
        title = tetris.returnVersion();
        version = tetris.returnVersion();
        }else if(game.equals("lol")){
        lol = new LOL();
        title = lol.returnVersion();
        version = lol.returnVersion();
        }else if(game.equals("racing")){
        racing = new Racing();
        title = racing.returnVersion();
        version = racing.returnVersion();
        }else
        System.out.println("지원하지 않는 게임입니다.");
    }
}

눈에 띄는 것은 조건문을 사용했다는 것이다. 이것은 조건의 변화(추가,삭제)에 따라서 수정할 수 밖에 없다. 따라서 앞에서 배운 OCP(개방 폐쇄 원칙)을 위반하고 있다.

이렇게 객체를 구현 클래스인 GameServer에서 직접 생성하면 GameServer 클래스는 생성된 모든 게임 클래스에 직접 의존한다. 각 게임의 내용이 바뀌거나 업그레이드가 된다면 GameServer 클래스의 객체 선언부터 조건문까지 변경해야한다 그래서 게임 클래스인 LOL, Racing, Supermario, Tetris 등의 구현에 의존한다고 할 수 있다.

의존 관계 역전 원칙은 이런 의존 관계를 역전하여 설계하자는 것이다. 이렇게 하는 이유는 게임 객체의 변화에 직접 영향을 받기 때문이다.

그렇다면 왜 이런 현상이 발생했을까?
그것은 바로 구체 클래스에 의존하기 때문이다. 따라서 의존 관계 역전 원칙에는 구체 클래스에 의존하지 않도록 하고 자주 바뀌는(추가,삭제) 클래스를 상속 구조로 만들어 추상 클래스에 의존하도록 설계한다. 또 다른 의미로는 '고수준 구성 요소가 저수준 구성 요소에 의존하면 안된다'는 뜻이다. 여기서 고수준 구성 요소는 GameServer 클래스를 말하고 있다. 저수준 구성 요소는 각 게임 클래스가 된다.

DIP를 적용한 GameServer 클래스

public class GameServer {
	public Games games;
    public void Game_Play(Games games){
    	games.Start();
    }
}

DIP를 적용한 Games 클래스

public **abstract** class Games{
	public String title, version;
    public void Start(){
    	System.out.println("게임명 : " + title);
        System.out.println("버전 : " + version);
        System.out.println(title + "을(를) 시작합니다.");
    }
}

각 게임에 해당하는 클래스

LOL 클래스

public class LOL extends Games{
	public LOL(){
    	title = returnTitle();
    	version = retrunVersion();
    }
    public String returnTitle(){
   	 	return "League of Lengends";
    }
    public String returnVersion(){
    	return "v.2020"
    }
}

Racing 클래스

public class Racing extends Games{
	public Racing(){
    	title = returnTitle();
    	version = retrunVersion();
    }
    public String returnTitle(){
    	return "Racing";
    }
    public String returnVersion(){
    	return "v.2.3"
    }
}

Supermario 클래스

public class Supermario extends Games{
	public Supermario(){
    	title = returnTitle();
    	version = retrunVersion();
    }
    public String returnTitle(){
    	return "Supermario";
    }
    public String returnVersion(){
    	return "v.2.0"
    }
}

Tetris 클래스

public class Tetris extends Games{
	public Tetris(){
    	title = returnTitle();
    	version = retrunVersion();
    }
    public String returnTitle(){
    	return "Tetris";
   	}
   	public String returnVersion(){
    	return "v.1996.03.15"
    }
}

Main 클래스

public class Main{
	public static void main(String[] args){
    	GameServer gameserver = new GameServer();
        gameserver.Game_Play(new Supermario());
        gameserver.Game_Play(new Tetris());
        gameserver.Game_Play(new LOL());
        gameserver.Game_Play(new Racing());
    }
}
        

이렇게 클래스를 설계하면 '구체 클래스가 아닌 추상 클래스에 의존하도록 설계하라는' DIP(의존 관계 역전 원칙)을 지키게 된다.

출처: 쉽게 배우는 소프트웨어공학, 한빛아카데미

profile
인생은 끝이 없는 도전의 연속입니다. 저는 끝 없이 함께 새로운 도전을 합니다.

0개의 댓글