프로그램을 설계할 땐 모듈 간 결합도는 낮추고, 모듈 내 응집도를 높여야 한다는 원칙이 있다.
모듈 간의 상호 의존성을 낮춰 한 부분이 조금 변한다고 해서 전체 시스템이 망가지지 않게 해야하고, 연관성이 있는 요소들을 하나의 모듈로 집중시켜 재사용 및 유지보수성을 높이는 것이다.
객체지향적 관점으로 위 원칙을 지키기 위해 나온 개념이 객체지향 설계 5원칙, SOLID이다.
1. SRP(단일 책임 원칙) : 하나의 모듈은 하나의 책임만을 갖는다.
나(부추)는 학생이기도, 어머니의 딸이기도, 누군가의 친구이기도 하다. 각각의 사회적 역할에서 나(사람)의 행위를 Person 객체에 정의해보았다.
public class Person {
public void study() {
System.out.println("공부합니다.");
}
public void play() {
System.out.println("함께 놉니다.");
}
public void hyodo() {
System.out.println("효도합니다.");
}
}
물론 '나'로서 모두 하는 행위이긴 하지만, 하나의 class에 몰려있으니 깔끔하지 못하다. 게다가 졸업을 해서 더이상 학생이 아니라거나, 갑자기 전 세계적 왕따가 됐다면 study()와 play()는 처치 곤란의 method가 된다. 졸업을 해서 학생이 아니게 되었는데, 효도하는 주체로서의 class를 바꿔버리면 단일 책임 원칙을 위반하게 된다.
어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다. _ 로버트 C.마틴
public class Student extends Person {
public void study() {
System.out.println("공부합니다.");
}
}
public class Friend extends Person {
public void play() {
System.out.println("함께 놉니다.");
}
}
public class Daughter extends Person {
public void hyodo() {
System.out.println("효도합니다.");
}
}
역할과 책임에 따라 클래스를 분리했다. 아무래도 같은 사람인만큼 공통의 속성이나 method 있을 것 같아 Person이라는 상위 class를 상속받는 형태를 취했다.
SRP의 핵심은 하나의 역할 혹은 책임을 가진 property / method / module / package 등을 따로 분리하는 것이다. git에 pull request를 올릴 때도 그 이유는 하나인 것이 깔끔하다.
같은 공부를 해도 국어국문학과 학생과 영어영문학과 학생이 하는 공부는 다르다. 전공에 따라 다른 공부를 하는 method를 구현하고 싶다고 해서, 아래와 같이 하면 단일 책임(행위) 원칙을 위반한 것이다. 필드나 method가 책임에 따라 다른 의미를 가져선 안된다.
public class CollegeStudent {
String major;
public CollegeStudent(String major) {
this.major = major;
}
public void study() {
if (this.major.equals("국어국문학과")) {
System.out.println("국문을 공부합니다.");
} else if (this.major.equals("영어영문학과")) {
System.out.println("영문을 공부합니다.");
}
}
}
다시 한 번, 하나의 역할과 책임만을 가진 클래스로 분리시켰다. 추상 클래스를 두고 책임에 따라 다르게 구현하도록 했다.
public abstract class CollegeStudent {
public abstract void study();
}
public class KoreanStudent extends CollegeStudent {
@Override
public void study() {
System.out.println("국문을 공부합니다.");
}
}
public class EnglishStudent extends CollegeStudent {
@Override
public void study() {
System.out.println("영문을 공부합니다.");
}
}
2. OCP(개방 폐쇄 원칙) : 자신의 확장엔 열려있고, 주변의 변화엔 닫혀있다.
아이패드를 갖고 애플펜슬로 필기를 하는 대학생이 있다.

계속 수기로 적자니 손이 너무 아프고, 말이 많은 교수님이라 적을 것도 많아서 노트북 타자필기를 하기로 했다.

OCP 위배다. 노트북과 아이패드는 대학생 입장에서 똑같이 필기를 위한 도구인데, 그게 좀 변화했다고 해서 사용하는 method 이름도 바뀌어버렸다. 주변의 변화에 닫혀있지 않은 것이다.

'필기도구'라는 상위 class를 만들어, 대학생 객체가 필기도구라는 주변의 변화에 닫혀있도록 했다. 아이패드와 노트북은 그를 상속받아, 필기도구라면 마땅히 해야할 일 "필기()"를 각자의 방식으로 구현하도록 했다. 이로써 필기도구는 기존의 아이패드, 노트북 뿐만 아니라 공책, 핸드폰(?) 등 확장에 열려있게 됐다.
이를 비슷하게 확인해 볼 수 있는 예시가 바로 JPA 인터페이스와 그 구현체들이다.

자바 어플리케이션에서 ORM을 이용하기 위해선 JPA 인터페이스만 알면 된다. 구체적 구현체로 Hibernate를 쓰든, EclipseLink를 쓰든 JPA 가이드에서 정의한 method를 사용하기만 하면 그만이다. "구현체"라는 주변 변화에 어플리케이션은 닫혀있다. 한편 JPA는 여러 종류의 구현체를 가질 수 있음으로써 자신의 확장엔 열리게 되었다.
JVM도 마찬가지이다. 실제 하드웨어를 돌리는 OS가 무엇이든, JRE만 구성되어있다면 자바 코드는 어디서든 동작한다. 소스코드는 OS 변화에 닫혀있고, JVM은 다양한 OS의 확장에 열려있다.
구체적인 것들을 묶는 인터페이스가 하나의 쿠션으로 작용하여 이와같은 확장 폐쇄 원칙을 지킬 수 있게 해주는 것 같다.
3. LSP(리스코프 치환 원칙) : 하위 타입은 상위 타입으로 대체 가능해야 한다.
- 하위 클래스 is a kind of 상위 클래스 : 하위 타입은 상위 타입의 한 종류이다.
- 구현 클래스 is able to 인터페이스 : 구현 타입은 인터페이스 할 수 있어야 한다.'
상속과 관련한 위 두 문장을 잘 지키는 선에서 객체 설계를 했다면 리스코프 치환 원칙을 잘 지켰다고 볼 수 있다. OOP의 상속과 관련된 상위/하위 개념은 조직이나 계층이 아닌 "분류"의 개념이기 때문에, 하위 클래스는 상위 클래스의 부분집합으로 표시된다.
사람을 하나의 상위 클래스로, 여자와 남자를 하위 클래스로 볼 수 있다. 여자와 남자는 각각 사람의 분류이기 때문에, 사람이 할 수 있는 일은 뭐든 할 수 있어야 한다. "여자"는 "남자"가 할 수 있는 일은 할 수 없어도 "사람"이 할 수 있는 일은 가능해야 한다는 뜻이다.
4. ISP(인터페이스 분리 원칙) : SRP의 다른 해결책
1번의 예시를 다시 한 번 들고와보자. Student, Daughter, Friend 각각의 역할을 하는 클래스로 분리하는 대신 각 역할을 interface들로 나누는 방법이 있다.
public interface Student {
public void study();
}
public interface Daughter {
public void hyodo();
}
public interface Friend {
public void play();
}
그리고 각 인터페이스들을 Person 객체가 구현한다.
public class Person implements Student, Friend, Daughter {
@Override
public void hyodo() {
System.out.println("효도합니다.");
}
@Override
public void play() {
System.out.println("같이 놉니다.");
}
@Override
public void study() {
System.out.println("공부합니다.");
}
}
각 역할을 하는 객체가 필요해질때마다, 참조 변수 타입을 인터페이스로 하여 원하는 method를 호출할 수 있다. 클래스 분리가 아닌 인터페이스 분리로 코드를 깔끔하게 만들었다. 같은 문제에 대한 다른 해결방식인 것이다.
그러나 ISP를 이용한 방법은 한 클래스 안에 인터페이스의 구현이 몰려있고, 다중 구현을 써야해서 SRP의 방식보단 덜 깔끔해보인다. 웬만하면 클래스를 나누는 SRP를 쓰는 것이 국룰인듯.
# 인터페이스 최소주의 원칙
상위 클래스는 풍성할수록 좋고, 상위 인터페이스는 작을수록 좋다. ISP와 관련해서 함께 나오는 원칙이다.
상위 클래스가 풍성하다는 말은, 상위 클래스를 상속받는 하위 클래스의 공통점을 최대한! 많이 묶어 올려보냈다는 말이다. 이렇게 되면 상속이라는 특성을 아주 유용하게 사용할 수 있다. 참조 변수형을 편하게 상위 타입으로 해놓고 이것저것 쓰다가, 하위 타입의 필드/method가 필요할 때 최소한으로 타입 캐스팅을 해서 해당 속성을 이용하는 것이다.
상위 인터페이스가 작다는 말은, 특정 역할을 수행하는 인터페이스의 SRP의 개념과 연관된다. 앞선 예시에서 Daughter과 Student의 기능을 한꺼번에 합친 인터페이스가 나온다면 인터페이스 최수주의 원칙을 위반한 것이다.
5. DIP(의존 역전 원칙) : 추상적인 것, 변하지 않는 것에 의존해야 한다.
- 구체적인 것이 추상적인 것에 의존해야 한다.
- 변하는 것이 변하지 않는 것에 의존해야 한다.
여기서 '구체적인 것'이란 상위 클래스나 인터페이스가 아닌 하위 클래스와 구체적 구현체를 말한다. 추상적이고 변하지 않는 것은 그 반대다. "concrete", 즉 구현이 확실하고 자주 변하는 클래스에 의존을 하지 말라는 원칙이다.
OCP의 학생-필기도구가 DIP를 위반한 예시이다. (DIP와 OCP는 SRP와 ISP처럼 연관성이 높다)아이패드는 필기도구를 구현한 concrete 구현체이다. 필기도구 구현체는 아이패드가 될 수도 있고, 노트북이 될 수도 있고, 공책이 될 수도 있다. 쉽게 변한다는 말이다. 대학생이 이같이 쉽게 변하는 구체 클래스에 의존하면 코드를 깔끔하게 작성하기 힘들어진다. 재사용도 힘들고, 유지보수의 난이도도 올라간다.
따라서 변하지 않는, 즉 추상화된 상위 클래스나 인터페이스에 의존하여 구현 클래스의 변화에 영향을 최소화 하는 것이 바람직하다.
자신보다 변하기 쉬운 것에 의존하지 마라.
상위 클래스 / 인터페이스 / 추상 클래스일수록 변화할 확률이 적다. 객체 의존성을 추가할 것이라면 이와 같은 것들에 의존해야 한다는 것이 DIP이다.
마무리
SOLID 원칙을 지켜 프로젝트를 설계하면 보통 소스 파일의 갯수가 많아지고, 심한 경우 프로그램 자체의 성능이 떨어질 수도 있다. 그러나 원칙을 지켜 설계한 코드는 이해, 유지보수 및 확장에 유리하고 이를 통해 얻을 수 있는 혜택은 그 이상이다.
객체지향 5원칙에 대해 ChatGPT가 설명한 것으로 정리한다.

프로그램을 설계할 땐 모듈 간 결합도는 낮추고, 모듈 내 응집도를 높여야 한다는 원칙이 있다.
모듈 간의 상호 의존성을 낮춰 한 부분이 조금 변한다고 해서 전체 시스템이 망가지지 않게 해야하고, 연관성이 있는 요소들을 하나의 모듈로 집중시켜 재사용 및 유지보수성을 높이는 것이다.
객체지향적 관점으로 위 원칙을 지키기 위해 나온 개념이 객체지향 설계 5원칙, SOLID이다.
1. SRP(단일 책임 원칙) : 하나의 모듈은 하나의 책임만을 갖는다.
나(부추)는 학생이기도, 어머니의 딸이기도, 누군가의 친구이기도 하다. 각각의 사회적 역할에서 나(사람)의 행위를 Person 객체에 정의해보았다.
public class Person {
public void study() {
System.out.println("공부합니다.");
}
public void play() {
System.out.println("함께 놉니다.");
}
public void hyodo() {
System.out.println("효도합니다.");
}
}
물론 '나'로서 모두 하는 행위이긴 하지만, 하나의 class에 몰려있으니 깔끔하지 못하다. 게다가 졸업을 해서 더이상 학생이 아니라거나, 갑자기 전 세계적 왕따가 됐다면 study()와 play()는 처치 곤란의 method가 된다. 졸업을 해서 학생이 아니게 되었는데, 효도하는 주체로서의 class를 바꿔버리면 단일 책임 원칙을 위반하게 된다.
어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다. _ 로버트 C.마틴
public class Student extends Person {
public void study() {
System.out.println("공부합니다.");
}
}
public class Friend extends Person {
public void play() {
System.out.println("함께 놉니다.");
}
}
public class Daughter extends Person {
public void hyodo() {
System.out.println("효도합니다.");
}
}
역할과 책임에 따라 클래스를 분리했다. 아무래도 같은 사람인만큼 공통의 속성이나 method 있을 것 같아 Person이라는 상위 class를 상속받는 형태를 취했다.
SRP의 핵심은 하나의 역할 혹은 책임을 가진 property / method / module / package 등을 따로 분리하는 것이다. git에 pull request를 올릴 때도 그 이유는 하나인 것이 깔끔하다.
같은 공부를 해도 국어국문학과 학생과 영어영문학과 학생이 하는 공부는 다르다. 전공에 따라 다른 공부를 하는 method를 구현하고 싶다고 해서, 아래와 같이 하면 단일 책임(행위) 원칙을 위반한 것이다. 필드나 method가 책임에 따라 다른 의미를 가져선 안된다.
public class CollegeStudent {
String major;
public CollegeStudent(String major) {
this.major = major;
}
public void study() {
if (this.major.equals("국어국문학과")) {
System.out.println("국문을 공부합니다.");
} else if (this.major.equals("영어영문학과")) {
System.out.println("영문을 공부합니다.");
}
}
}
다시 한 번, 하나의 역할과 책임만을 가진 클래스로 분리시켰다. 추상 클래스를 두고 책임에 따라 다르게 구현하도록 했다.
public abstract class CollegeStudent {
public abstract void study();
}
public class KoreanStudent extends CollegeStudent {
@Override
public void study() {
System.out.println("국문을 공부합니다.");
}
}
public class EnglishStudent extends CollegeStudent {
@Override
public void study() {
System.out.println("영문을 공부합니다.");
}
}
2. OCP(개방 폐쇄 원칙) : 자신의 확장엔 열려있고, 주변의 변화엔 닫혀있다.
아이패드를 갖고 애플펜슬로 필기를 하는 대학생이 있다.

계속 수기로 적자니 손이 너무 아프고, 말이 많은 교수님이라 적을 것도 많아서 노트북 타자필기를 하기로 했다.

OCP 위배다. 노트북과 아이패드는 대학생 입장에서 똑같이 필기를 위한 도구인데, 그게 좀 변화했다고 해서 사용하는 method 이름도 바뀌어버렸다. 주변의 변화에 닫혀있지 않은 것이다.

'필기도구'라는 상위 class를 만들어, 대학생 객체가 필기도구라는 주변의 변화에 닫혀있도록 했다. 아이패드와 노트북은 그를 상속받아, 필기도구라면 마땅히 해야할 일 "필기()"를 각자의 방식으로 구현하도록 했다. 이로써 필기도구는 기존의 아이패드, 노트북 뿐만 아니라 공책, 핸드폰(?) 등 확장에 열려있게 됐다.
이를 비슷하게 확인해 볼 수 있는 예시가 바로 JPA 인터페이스와 그 구현체들이다.

자바 어플리케이션에서 ORM을 이용하기 위해선 JPA 인터페이스만 알면 된다. 구체적 구현체로 Hibernate를 쓰든, EclipseLink를 쓰든 JPA 가이드에서 정의한 method를 사용하기만 하면 그만이다. "구현체"라는 주변 변화에 어플리케이션은 닫혀있다. 한편 JPA는 여러 종류의 구현체를 가질 수 있음으로써 자신의 확장엔 열리게 되었다.
JVM도 마찬가지이다. 실제 하드웨어를 돌리는 OS가 무엇이든, JRE만 구성되어있다면 자바 코드는 어디서든 동작한다. 소스코드는 OS 변화에 닫혀있고, JVM은 다양한 OS의 확장에 열려있다.
구체적인 것들을 묶는 인터페이스가 하나의 쿠션으로 작용하여 이와같은 확장 폐쇄 원칙을 지킬 수 있게 해주는 것 같다.
3. LSP(리스코프 치환 원칙) : 하위 타입은 상위 타입으로 대체 가능해야 한다.
- 하위 클래스 is a kind of 상위 클래스 : 하위 타입은 상위 타입의 한 종류이다.
- 구현 클래스 is able to 인터페이스 : 구현 타입은 인터페이스 할 수 있어야 한다.'
상속과 관련한 위 두 문장을 잘 지키는 선에서 객체 설계를 했다면 리스코프 치환 원칙을 잘 지켰다고 볼 수 있다. OOP의 상속과 관련된 상위/하위 개념은 조직이나 계층이 아닌 "분류"의 개념이기 때문에, 하위 클래스는 상위 클래스의 부분집합으로 표시된다.
사람을 하나의 상위 클래스로, 여자와 남자를 하위 클래스로 볼 수 있다. 여자와 남자는 각각 사람의 분류이기 때문에, 사람이 할 수 있는 일은 뭐든 할 수 있어야 한다. "여자"는 "남자"가 할 수 있는 일은 할 수 없어도 "사람"이 할 수 있는 일은 가능해야 한다는 뜻이다.
4. ISP(인터페이스 분리 원칙) : SRP의 다른 해결책
1번의 예시를 다시 한 번 들고와보자. Student, Daughter, Friend 각각의 역할을 하는 클래스로 분리하는 대신 각 역할을 interface들로 나누는 방법이 있다.
public interface Student {
public void study();
}
public interface Daughter {
public void hyodo();
}
public interface Friend {
public void play();
}
그리고 각 인터페이스들을 Person 객체가 구현한다.
public class Person implements Student, Friend, Daughter {
@Override
public void hyodo() {
System.out.println("효도합니다.");
}
@Override
public void play() {
System.out.println("같이 놉니다.");
}
@Override
public void study() {
System.out.println("공부합니다.");
}
}
각 역할을 하는 객체가 필요해질때마다, 참조 변수 타입을 인터페이스로 하여 원하는 method를 호출할 수 있다. 클래스 분리가 아닌 인터페이스 분리로 코드를 깔끔하게 만들었다. 같은 문제에 대한 다른 해결방식인 것이다.
그러나 ISP를 이용한 방법은 한 클래스 안에 인터페이스의 구현이 몰려있고, 다중 구현을 써야해서 SRP의 방식보단 덜 깔끔해보인다. 웬만하면 클래스를 나누는 SRP를 쓰는 것이 국룰인듯.
# 인터페이스 최소주의 원칙
상위 클래스는 풍성할수록 좋고, 상위 인터페이스는 작을수록 좋다. ISP와 관련해서 함께 나오는 원칙이다.
상위 클래스가 풍성하다는 말은, 상위 클래스를 상속받는 하위 클래스의 공통점을 최대한! 많이 묶어 올려보냈다는 말이다. 이렇게 되면 상속이라는 특성을 아주 유용하게 사용할 수 있다. 참조 변수형을 편하게 상위 타입으로 해놓고 이것저것 쓰다가, 하위 타입의 필드/method가 필요할 때 최소한으로 타입 캐스팅을 해서 해당 속성을 이용하는 것이다.
상위 인터페이스가 작다는 말은, 특정 역할을 수행하는 인터페이스의 SRP의 개념과 연관된다. 앞선 예시에서 Daughter과 Student의 기능을 한꺼번에 합친 인터페이스가 나온다면 인터페이스 최수주의 원칙을 위반한 것이다.
5. DIP(의존 역전 원칙) : 추상적인 것, 변하지 않는 것에 의존해야 한다.
- 구체적인 것이 추상적인 것에 의존해야 한다.
- 변하는 것이 변하지 않는 것에 의존해야 한다.
여기서 '구체적인 것'이란 상위 클래스나 인터페이스가 아닌 하위 클래스와 구체적 구현체를 말한다. 추상적이고 변하지 않는 것은 그 반대다. "concrete", 즉 구현이 확실하고 자주 변하는 클래스에 의존을 하지 말라는 원칙이다.
OCP의 학생-필기도구가 DIP를 위반한 예시이다. (DIP와 OCP는 SRP와 ISP처럼 연관성이 높다)아이패드는 필기도구를 구현한 concrete 구현체이다. 필기도구 구현체는 아이패드가 될 수도 있고, 노트북이 될 수도 있고, 공책이 될 수도 있다. 쉽게 변한다는 말이다. 대학생이 이같이 쉽게 변하는 구체 클래스에 의존하면 코드를 깔끔하게 작성하기 힘들어진다. 재사용도 힘들고, 유지보수의 난이도도 올라간다.
따라서 변하지 않는, 즉 추상화된 상위 클래스나 인터페이스에 의존하여 구현 클래스의 변화에 영향을 최소화 하는 것이 바람직하다.
자신보다 변하기 쉬운 것에 의존하지 마라.
상위 클래스 / 인터페이스 / 추상 클래스일수록 변화할 확률이 적다. 객체 의존성을 추가할 것이라면 이와 같은 것들에 의존해야 한다는 것이 DIP이다.
마무리
SOLID 원칙을 지켜 프로젝트를 설계하면 보통 소스 파일의 갯수가 많아지고, 심한 경우 프로그램 자체의 성능이 떨어질 수도 있다. 그러나 원칙을 지켜 설계한 코드는 이해, 유지보수 및 확장에 유리하고 이를 통해 얻을 수 있는 혜택은 그 이상이다.
객체지향 5원칙에 대해 ChatGPT가 설명한 것으로 정리한다.
