좋은 객체 지향 설계의 5원칙(SOLID)

업데이트:

좋은 객체 지향 설계, SOLID 원칙

객체지향을 사용함으로 얻을 수 장점 중 중요한 한가지를 뽑아보라면 다형성을 활용한 유연한 확장을 꼽을 수 있다. 하지만 다형성을 실현하기 위해 대충 만들어진 인터페이스, 추상클래스 등을 사용하게 되면 변경이 일어났을 때 오히려 더 많은 수고를 해야하는 상황을 맞이할 수 있다. 그렇기에 무엇보다 잘 설계할 수 있는 능력이 필요하다. 그러나 쉽사리 설계를 해낸다는 것은 어려운 법, 하지만 걱정하지말라. 우리에겐 클린코드로 유명한 로버트 마틴의 SOLID 원칙이 있고 이를 준수하면 좋은 객체 지향 설계를 해낼 수 있다!

요약

  • S (SRP) : 단일 책임 원칙
  • O (OCP) : 개방-폐쇄 원칙
  • L (LSP) : 리스코프 치환 원칙
  • I (ISP) : 인터페이스 분리 원칙
  • D (DIP) : 의존관계 역전 원칙

SOLID

SOLID는 각 원칙에 대한 가장 첫글자를 줄여서 표기한 것이다. 하나씩 살펴보도록 하자.

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

객체는 단 하나의 책임만 가져야 한다.
이 말만 보면 뭘 해야하는지 잘 이해가 가지 않는다. 하나의 책임이란 무엇인가? 예를 들어 Card라는 클래스가 있다면 이 클래스에게 주어진 하나의 책임이란 무엇인가? 쉽사리 판단할 수 없다. 즉, 프로그램의 문맥에 따라 다르다는 것이다. 그럼 도대체 무엇을 기준으로 하나의 책임을 분별해야하는 것일까? 완벽한 정답은 없지만 가장 중요한 척도로 생각할 수 있는 것은 변경에 얼마나 영향을 받는가? 이다. 예를 들어 UI가 변했다고 해서 그 UI에 해당하는 기능의 로직 클래스가 변경이 일어나야한다면, 그것은 단일 책임 원칙을 지키지 못했다 볼 수 있다. 반대로, UI가 변했지만 해당 UI를 사용자가 상호작용했을 때 일어나는 로직 클래스는 변화가 없다면 이것은 단일 책임 원칙을 잘 준수했다고 볼 수 있다.

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

소프트웨어의 요소는 기존의 코드에 대해 확장에는 열려있으나 변경에는 닫혀있어야한다.
기능을 확장해야할 때, 기존의 코드는 변경하지 않으면서 확장을 할 수 있도록 설계해야함을 의미한다. 어떻게 기능을 확장하는데 기존 코드에 변경이 없을 수 있느냐? 라는 궁금증이 일어날 수 있다. 이는 다형성을 적극적으로 활용하여 인터페이스를 구현한 새로운 클래스를 만들어내는 것으로 해결할 수 있다. 또한, 개방-폐쇄의 원칙을 준수하기 위해 코드를 작성하다보면 결국 변경이 어쩔 수 없이 일어나야하는 부분(인터페이스의 구현체를 new를 통해 정함)이라는 벽에 부딛히게 되는데, 이 부분은 가장 마지막에 설명할 의존관계 주입을 통해 해소할 수 있다.

L: 리스코프 치환 원칙(LSP, Liskov Substitution Principle)

프로그램의 객체는 정확성을 깨뜨리지 않으면서 하위 타입인 인스턴스로 바꿀 수 있어야한다.
다형성을 실현하기 위해 하위 클래스는 상위 클래스들의 인터페이스 규약을 정확히 지켜줘야한다는 것을 의미한다. 이렇게만 들어선 뭘 원하는지 이해하기 어려우므로 예를 통해 생각해보도록하자.

예시

자동차라는 인터페이스가 있다. 이 자동차 인터페이스는 엑셀 이라는 기능이 있고 이는 앞으로 전진할 의도를 가지고 있다. 그런데 하위 클래스에서 이 인터페이스를 구현을 뒤로가는 기능으로 구현했다. 무슨 일이 벌어질까? 상상도 할 수 없는 끔찍한 일이 생길 것이다. 이는 코드 상에선 정상이라고 컴파일 될 수 있으나 의도에 맞지 않는 구현을 했기에 문제가 된다. 그러므로 의도에 맞게 상위 클래스의 규약을 정확하게 지켜줘야하고 이 행위가 LSP 원칙을 지키는 것이다.

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

인터페이스를 특정 클라이언트에 특화되도록 분리시키라는 설계 원칙이다 이게 무슨 뜻인가 하면 한개의 범용 인터페이스 보다 특정 클라이언트에 대한 여러 인터페이스로 설계하라는 뜻이다. 사실 이렇게 써도 이해가 어려우므로 예를 들어 생각해보자.

예시

자동차라는 인터페이스를 만들었다고 생각해보자. 그리고 이 자동차 인터페이스는 사용자 클래스가 구현하고 있다. 자, 이제 자동차에 정비 기능이 추가되었다. 당연히 사용자는 정비 기능을 구현해야한다. 문제가 없어보인다. 하지만 함정이 있다. 사용자는 운전자가 될 수도있고 정비사가 될 수도있다. 운전자는 운전할 줄 알면되고 정비사는 정비를 할 줄 알면된다. 그러나 지금 설계대로면 운전자는 정비 기능을 알 필요가 없음에도 구현해야한다. 이런 문제를 해결하기 위해 인터페이스의 분리가 필요하다는 것을 느낄 수 있다. 이것을 원하는 대로 분리를 하게 되면 자동차 정비 인터페이스와 운전 인터페이스로 나누고 사용자 클래스 또한 정비사 클래스와 운전자 클래스로 나눈 뒤 각각 역할에 맞게 구현해주면 된다. 그럼 정비에 변경이 일어나도 운전자 클래스는 영향을 받을 필요가 없으며 필요없는 기능 또한 가질 일이 없어지는 것이다.

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

추상화에 의존하고 구체화에 의존하지 말아야한다. 변경에 닫힌 확장을 위해 지켜져야할 원칙으로 구현체를 의존하지말고 추상화에 의존하라는 뜻이다. 이를 지킬 수 있는 올바른 답 중 하나는 의존관계주입(Dependency Injection) 이다. 쉽게 코드로 생각해볼 때, 인터페이스는 추상화의 결정체이며 이 인터페이스를 상속받아 구현하고 있는 클래스는 구현체라 볼 수 있다. 이때, 다른 곳에서 구현 클래스를 의존해야할 경우 인터페이스를 의존해야지 구현 클래스를 의존하면 안된다는 것이다. 왜 이렇게 해야할까? 그 이유는 확장에 열려있고 변경에 기존 코드가 닫혀있기 위함이다. 예를 통해 생각해보도록 하자.

예시

교사 인터페이스와 학생 인터페이스가 있다. 교사 인터페이스는 가르치다 라는 행위를 가지고 있고 학생은 배우다 라는 행위를 가지고 있다. 교사라는 역할에는 여러 사람이 해당될 수 있다. 학생은 교사 인터페이스를 의존한다. 그러면, 교사에 어떤 사람이 오더라도 학생은 배우다를 수행할 수 있다.

예시 코드

Student Interface

public interface Student {  
    void study();  
}

Teacher Interface

public interface Teacher {  
    void teach();  
}

StudentA Class (Student Interface 를 상속)

public class StudentA implements Student {  
    private Teacher teacher;  
  
 public StudentA(Teacher teacher) {  
        this.teacher = teacher;  
  }  
  
  @Override  
  public void study() {  
        teacher.teach();  
  }  
}

TeacherA Class (Teacher Interface 를 상속)

public class TeacherA implements Teacher {  
  @Override  
  public void teach() {}  
}

TeacherB Class (Teacher Interface 를 상속)

public class TeacherB implements Teacher {  
  @Override  
  public void teach() {}  
}

Config Class (Dependency Injection 을 위한 클래스)

public class Config {  
    public Teacher teacher() {  
        return new TeacherA();  
  }  
}

Main Class

public class Main {  
    public static void main(String[] args) {
	    Config config = new Config();  
		StudentA studentA = new StudentA(config.teacher());
	}
}

위 코드에서 Config Class에서 학생에게 교사A를 배정할지 교사B를 배정할지 선택할 수 있다. StudentA는 실제로 교사가 누군지 몰라도 배우다는 행위를 할 수 있게 되었다. 학생이 TeacherA나 TeacherB 라는 구체 클래스를 의존하는 것이 아닌 Teacher 라는 인터페이스만 의존하고 있기 때문에 가능한 것이다. 이것이 DIP를 준수한 것이며 DIP를 준수할 경우 TeacherC, TeacherZ 등 여러 교사로 StudentA에 대한 변경없이 마음 것 확장할 수 있다는 것이다.

끝!

태그: ,

카테고리:

업데이트: