스프링 입문을 위한 자바 객체 지향의 원리와 이해를 보고 5장은 꼭 정리해야겠다 싶어서 예시와 함께 이해해 보려고 노력했다!
이 원칙들은 주기적으로 봐야 할 것 같아서 포스팅한다...!
개요
SOLID는 객체 지향 프로그래밍의 설계 원칙으로, 로버트 C. 마틴(Robert C. Martin)이 제시한 다섯 가지 원칙이다. 마이클 페더스(Michael Feathers)가 이를 두문자어로 정리하여 널리 알려졌다.
SOLID의 5대 원칙
- SRP(Single Responsibility Principle): 단일 책임 원칙
- OCP(Open Closed Principle): 개방 폐쇄 원칙
- LSP(Liskov Substitution Principle): 리스코프 치환 원칙
- ISP(lnterface Segregation Principle): 인터페이스 분리 원칙
- DIP(Dependency Inversion Principle): 의존 역전 원칙
이 원칙들은 응집도를 높이고 결합도를 낮추어 유지보수성과 재사용성을 향상하는 데 목적이 있다.
💡 결합도와 응집도
좋은 소프트웨어 설계를 위해서는 결합도(coupling는 낮추고 응집도(cohesion)는 높이는 것이 바람직하다.
결합도는 클래스간의 상호 의존 정도로서 결합도가 낮으면 모듈 간의 상호 의존성이 줄어들어 객체의 재사용이나 수정, 유지보수가 용이하다.
응집도는 하나의 모듈 내부에 존재하는 구성 요소들의 기능적 관련성으로, 응집도가 높은 모듈은 하나의 책임에 집중하고 독립성이 높아져 재사용이나 기능의 수정 유지보수가 용이하다
SRP(Single Responsibility Principle) : 단일 책임 원칙
“어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다” - 로버트 C. 마틴
위 그림을 보면 남자가 지닌 책임이 너무나도 많다.
이 책임을 나눠 보자.
남자라는 하나의 클래스가 역할과 책임에 따라 네 개의 클래스로 쪼개진 것을 볼 수 있다.
그리고 역할과 클래스명도 딱 떨어지니 이해하기도 좋다.
여기서는 클래스의 분할에 대해서만 이야기했지만 단일 책임 원칙은 속성, 메서드, 패키지, 모듈, 컴포넌트, 프레임워크 등에도 적용할 수 있는 개념이다.
잘못된 케이스
단일 책임 원칙은 잘못된 케이스를 보는 게 더 바람직하다.
class 강아지 {
final static Boolean 수컷 = true;
final static Boolean 암컷 = false;
Boolean 성별;
void 소변보다() {
if(this.성별 = 수컷) {
// 한쪽 다리를 들고 소변을 본다.
} else {
// 뒷다리 두 개를 굽혀 앉은 자세로 소변을 본다.
}
}
}
메서드가 단일 책임 원칙을 지키지 않을 경우 나타나는 대표적인 냄새가 바로 분기 처리를 위한 if 문이다.
강아지가 수컷이냐 암컷이냐에 따라 소변보다()
메서드에서 분기 처리가 진행되고 있다.
강아지 클래스에서 수컷 강아지와 암컷 강아지의 행위 2가지를 모두 책임지고 있어서 그렇다.
이런 경우 단일 책임 원칙을 적용해 코드를 리팩터링해보자
abstract class 강아지 {
abstract void 소변보다();
}
class 수컷강아지 extends 강아지 {
void 소변보다() {
//한쪽 다리를 들고 소변을 본다.
}
}
class 암컷강아지 extends 강아지 {
void 소변보다() {
//뒷다리 두 개로 앉은 자세로 소변을 본다.
}
}
단일 책임 원칙과 가장 관계가 깊은 것은 바로 모델링 과정을 담당하는 추상화임을 알 수 있다.
애플리케이션의 경계를 정하고 추상화를 통해 클래스들을 선별하고 속성과 메서드를 설계할 때 반드시 단일 책임 원칙을 고려하는 습관을 들이자.
이 객체의 역할과 책임이 뭘까?를 항상 생각해 보자.
데이터베이스에서 정규화 과정을 거치는 것도 테이블과 필드에 대한 단일 책임 원칙의 적용이라고 할 수 있다.
예시
잘못된 예시
로또 프로그램을 만드는 데, 로또 번호 발급, 사용자 로또 번호와 로또 번호 매칭 기능, 우승자 선정 기능이 한 클래스에 있으면 어떨까?
class Lotto {
public List<Integer> generateNumbers() { ... }
public boolean validateNumbers(List<Integer> numbers) { ... }
public boolean isWinner(List<Integer> userNumbers, List<Integer> winningNumbers) { ... }
}
Lotto
가 번호 발급, 검증, 당첨 여부 판단까지 세 가지 책임을 모두 가지고 있다.
하나의 기능만 수정하더라도 Lotto
전체에 영향을 미칠 수 있다.
또한 단위 테스트가 어렵고, 코드가 복잡해지기 쉽다.
개선된 예시
// 번호 발급
class LottoNumberGenerator {
public List<Integer> generateNumbers() { ... }
}
class LottoNumberValidator {
public boolean validateNumbers(List<Integer> numbers) { ... }
}
class LottoWinnerSelector {
public boolean isWinner(List<Integer> userNumbers, List<Integer> winningNumbers) { ... }
}
이렇게 기능을 각각의 클래스로 분리할 수 있다.
각 클래스가 독립적이므로 하나의 기능을 수정해도 다른 클래스에 영향이 없고, 각 클래스별로 단위 테스트가 가능하다.
OCP(Open Closed Principle): 개방 폐쇄 원칙
“소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다.” -로버트 C. 마틴
“자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.”
운전자는 마티즈를 끌고 다니다 어느 날, 쏘나타로 차를 바꿨다.
이때 기어조작 방식이 바뀌어 운전자에 영향을 미치게 된다.
자동차라는 추상화를 한 뒤 상위 클래스 또는 인터페이스로 중간에 두면 어떨까?
다양한 자동차가 생긴다고 해도 객체 지향 세계의 운전자는 운전 습관에 영향을 받지 않게 된다.
다양한 자동차가 생긴다고 하는 것은 자동차 입장에서는 자신의 확장(쏘나타, 아반떼, 그랜져 등..)에는 개방돼 있는 것이고, 운전자 입장에서는 주변의 변화에 폐쇄돼 있는 것이다.
데이터베이스 프로그래밍을 경험한 적이 있다면 개방 폐쇄 원칙의 아주 좋은 예를 이미 알고 있을 것이다.
그 예란 바로 JDBC다.
JDBC를 사용하는 클라이언트는 데이터베이스가 오라클에서 MySQL로 바뀌더라도 Connection을 설정하는 부분 외에는 따로 수정할 필요가 없다. Connection 설정 부분을 별도의 설정 파일로 분리해 두면 클라이언트 코드는 단 한 줄도 변경할 필요가 없다.
JDBC뿐만 아니라 iBatis, MyBatis, 하이버네이트 등등 데이터베이스 프로그래밍을 지원하는 라이브러리와 프레임워크에서도 개방 폐쇄 원칙의 예를 볼 수 있다.
개방 폐쇄 원칙을 무시하고 프로그램을 작성하면 객체 지향 프로그래밍의 가장 큰 장점인 유연성, 재 사용성, 유지보수성 등을 얻을 수 없다. 따라서 객체 지향 프로그래밍에서 개방 폐쇄 원칙은 반드시 지켜야 할 원칙이다.
예시
OCP는 기존 코드를 수정하지 않고 확장 가능하도록 설계하는 것을 목표로 한다.
여기서는 번호 발급 방식을 인터페이스로 추상화하고, 자동 발급과 수동 발급 클래스를 구현한다.
// 번호 발급 방식 인터페이스
interface LottoNumberGenerator {
List<Integer> generateNumbers();
}
// 자동 발급
class AutoLottoNumberGenerator implements LottoNumberGenerator {
@Override
public List<Integer> generateNumbers() { ... }
}
// 수동 발급
class ManualLottoNumberGenerator implements LottoNumberGenerator {
private final List<Integer> numbers;
public ManualLottoNumberGenerator(List<Integer> numbers) {
this.numbers = numbers;
}
@Override
public List<Integer> generateNumbers() {
return numbers;
}
}
새로운 발급 방식이 추가되더라도 기존 코드는 수정하지 않고 확장 가능하다.
LSP(Liskov Substitution Principle): 리스코프 치환 원칙
“서브 타입은 언제나 기반 타입(base tgpe)으로 교체할 수 있어야 한다.” - 로버트 C. 마틴
객체 지향의 상속은 다음의 조건을 만족해야 한다.
- 하위 클래스 is a kind of 상위 클래스 - 하위분류는 상위 분류의 한 종류다
- 구현 클래스 is able to 인터페이스 - 구현 분류는 인터페이스 할 수 있어야 한다.
인터페이스 할 수 있어야 한다는 말은 'AutoCloseable - 자동으로 닫힐 수 있어야 한다.'로 해석하면 된다.
위 두 개의 문장대로 구현된 프로그램이라면 이미 리스코프 치환 원칙을 잘 지키고 있다고 할 수 있다.
하지만 위 문장대로 구현되지 않은 코드가 존재할 수 있는데 바로 상속이 조직도나 계층도 형태로 구축된 경우다.
“하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는 데 문제가 없어야 한다.”
아버지 춘향이 = new 딸();
딱 봐도 이상하다.
상위 클래스의 객체 참조 변수에는 하위 클래스의 인스턴스를 할당할 수 있어야 한다.
때문에 상속이 조직도나 계층도 형태로 구축되면 안 되는 것이다!
예제
계속 재탕 중인 로또 번호 발급이 예시에서 만약 ManualLottoNumberGenerator
가 자기 멋대로 메서드를 수정해 버렸다.
class InvalidLottoNumberGenerator implements LottoNumberGenerator {
// 번호 발급 방식 인터페이스
interface LottoNumberGenerator {
List<Integer> generateNumbers();
}
// 자동 발급
class AutoLottoNumberGenerator implements LottoNumberGenerator {
@Override
public List<Integer> generateNumbers() { ... }
}
// 수동 발급
class ManualLottoNumberGenerator implements LottoNumberGenerator {
private final List<Integer> numbers;
public ManualLottoNumberGenerator(List<Integer> numbers) {
this.numbers = numbers;
}
@Override
public List<Integer> generateNumbers() {
throw new UnsupportedOperationException("지원되지 않는 기능입니다.");
}
}
LottoNumberGenerator
는 번호를 발급하는 기능(generateNumbers
)을 제공해야 한다는 계약을 갖고 있다.
한마디로 부모 클래스의 행동 규약을 어긴 셈이다.
이러면 수동 번호 발급기를 사용했을 때 로또 번호가 발급되어야 하는데 예외를 던지며 이 계약을 위반했다. 이는 상위 타입의 기대를 충족하지 못하는 하위 타입의 잘못된 설계다.
LottoNumberGenerator generator = new ManualLottoNumberGenerator(numbers);
generator.generateNumbers() // 예외 발생
이것이 리스코프 치환 원칙의 중요 포인트다.
“서브 타입은 언제나 기반 타입(base tgpe)으로 교체할 수 있어야 한다.”
이것은 하위 타입(ManualLottoNumberGenerator
)이 상위 타입(LottoNumberGenerator
)로 치환될 수 있어야 한다는 LSP를 위반하는 것이다.
직사각형, 정사각형 문제
직사각형(Rectangle)과 정사각형(Square)은 상속 관계에서 LSP를 위반하는 대표적인 사례로 자주 언급된다.
Rectangle
클래스를 정의하고, Square
클래스를 이를 상속받아 설계한다고 가정해 보자.
class Rectangle {
private int width;
private int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width); // 정사각형은 가로와 세로가 같아야 함
}
@Override
public void setHeight(int height) {
super.setWidth(height); // 정사각형은 가로와 세로가 같아야 함
super.setHeight(height);
}
}
이 설계는 얼핏 보면 직사각형과 정사각형의 관계를 잘 표현한 것처럼 보이지만, 실제로는 문제가 있다.
Square
는 Rectangle
의 서브타입이지만, Rectangle
을 사용하는 코드가 Square
에서 올바르게 동작하지 않는 경우가 생긴다. 아래의 코드를 보자.
public static void printArea(Rectangle rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(10);
System.out.println("Area: " + rectangle.getArea());
}
public static void main(String[] args) {
Rectangle rect = new Rectangle();
Square square = new Square();
printArea(rect); // 출력: Area: 50 (정답)
printArea(square); // 출력: Area: 100 (예상과 다름)
}
직사각형(Rectangle)을 전달했을 때는 정상적으로 동작했지만,Square
를 전달했을 때는 가로와 세로가 동일해지는 특성 때문에 예상치 못한 결과가 나왔다.
뭐가 문제인가?
상속은 "is-a" 관계를 나타낸다. (정확히는 is-a-kind-of)
즉, Square
가 Rectangle
을 상속받았다는 것은 Square
가 Rectangle
의 모든 동작을 올바르게 수행할 수 있어야 함을 의미한다.
즉, 정사각형이 직사각형을 상속받는 이 상속 관계가 잘못된 설계인 것이다.
해결 1: 상속 대신 별도 클래스 사용
직사각형과 정사각형을 별도의 클래스 계층으로 설계한다.
상속을 사용하지 않음으로써 상속으로 인한 제약을 피할 수 있다.
class Rectangle {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square {
private int side;
public Square(int side) {
this.side = side;
}
public void setSide(int side) {
this.side = side;
}
public int getArea() {
return side * side;
}
}
해결 2: 인터페이스 활용
공통된 동작만을 인터페이스로 추출하고, 직사각형과 정사각형이 각각 이 인터페이스를 구현하도록 한다.
interface Shape {
int getArea();
}
class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
public void setSide(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
ISP(lnterface Segregation Principle): 인터페이스 분리 원칙
“클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.” - 로버트 C. 마틴
SRP의 남자 클래스를 보면 단순히 남자 클래스의 책임을 분리해서 다수의 클래스로 만드는 방법이었다.
여기서 남자 클래스를 분할하는 게 아니라 인터페이스를 두어 여자친구를 만날 때는 남자친구 역할만, 어머니와 있을 때는 아들, 직장 상사와 있을 때는 사원, 소대장 앞에서는 소대원 인터페이스로 인격을 바꾸는 것이다.
이게 바로 ISP원칙을 적용한 것이다.
SRP와 ISP
결론적으로 단일 책임 원칙(SRP)과 인터페이스 분할 원칙(ISP)은 같은 문제에 대한 두 가지 다른 해결책이라고 볼 수 있다.
프로젝트 요구사항과 설계자의 취향에 따라 단일 책임 원칙이나 인터페이스분할 원칙 중 하나를 선택해서 설계할 수 있다.
하지만 특별한 경우가 아니라면 단일 책임 원칙을 적용하는 것이 더 좋은 해결책이라고 할 수 있다.
인터페이스는 작을수록 좋다.
인터페이스 분할 원칙을 이야기할 때 항상 함께 등장하는 원칙 중 하나로 인터페이스 최소주의 원칙이라는 것이 있다.
인터페이스를 통해 메서드를 외부에 제공할 때는 최소한의 메서드만 제공하라는 것이다.
그 이유를 조금 더 살펴보자. 리스코프 치환 원칙(LSP)에 따라 하위 객체는 상위 객체인 척할 수 있다
사람 김학생 = new 학생("김학생", new Date(2000, 01, 01), "20000101-1234567",
"20190001");
사람 이군인 = new 군인("이군인", new Dated998, 12, 31), "19981231-1234567",
"19-12345678");
System, out. printin(김 학생 .이름);
System, out. printin(이군인. 이름);
// System.out.printin(김학생.생일); // 사용불가
// System.out.printin(이군인.생일); // 사용불가
System.out.printin(((학생) 김학생).생일); // 캐스팅 필요
System.out.printin(((군인) 이군인).생일); // 캐스팅 필요
빈약한 상위 클래스를 사용하면 여기저기 형변환이 발생하면서 상속의 혜택을 누르지 못한다.
예시
잘못된 예시
class LottoService {
public List<Integer> generateNumbers() { ... }
public boolean validateNumbers(List<Integer> numbers) { ... }
public boolean isWinner(List<Integer> userNumbers, List<Integer> winningNumbers) { ... }
}
위의 SRP의 예시와 동일하게 로또 번호 발급, 사용자 로또 번호와 로또 번호 매칭 기능, 우승자 선정 기능이 한 클래스에 있을 때 ISP 원칙을 적용해 보자.
개선된 예시
각 인터페이스를 작게 분리하여 필요에 따라 독립적으로 사용할 수 있도록 설계할 수 있다.
interface LottoNumberGenerator {
List<Integer> generateNumbers();
}
interface LottoNumberValidator {
boolean validateNumbers(List<Integer> numbers);
}
interface LottoWinnerSelector {
boolean isWinner(List<Integer> userNumbers, List<Integer> winningNumbers);
}
DIP(Dependency Inversion Principle): 의존 역전 원칙
“고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.”
“추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.”
"자주 변경되는 구체(Concrete) 클래스에 의존하지 마라 “ - 로버트 C. 마틴
자동차와 타이어의 관계를 이런 식으로 설계한다면 자동차의 타이어를 교체하기 참 번거로울 것이다.
스노우타이어에서 타이어로 변경할 때 자동차 클래스의 코드를 변경해야 한다.
즉, 자동차가 영향에 노출되는 것이다.
이처럼 타이어를 추상화하고 인터페이스에 의존하게 하면 타이어를 아무리 교체해도 자동차는 영향을 받지 않는다.
자동차는 자신보다 변하기 쉬운 스노우타이어에 의존하던 관계를 중간에 추상화된 타이어 인터페이스를 추가해 두고 의존 관계를 역전시키고 있다.
이처럼 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 스노우타이어, 일반타이어, 광폭타이어 -> 추상화
/ 타이어 -> 추상화
로 의존 관계를 전환하는 것이다.
의존 역전 법칙: “자신보다 변하기 쉬운 컷에 의존하지 마라.”
상위 클래스일수록, 인터페이스일수록, 추상 클래스일수록 변하지 않을 가능성이 높기에 하위 클래스나 구체 클래스가 아닌 상위 클래스, 인터페이스, 추상 클래스를 통해 의존하라는 것이 바로 의존 역전원칙이다.
이 설명은 개방 폐쇄 원칙(OCP)에도 나온 설명이다. 이렇게 하나의 해결책을 찾으면 그 안에 여러 설계 원칙이 녹아있는 경우가 많다.
예제
잘못된 설계
위의 예시와 비슷하게 로또 번호 발급기에 대해 2가지 방식이 있다고 가정해 보자.
// 랜덤 발급
class RandomLottoNumberGenerator {
public List<Integer> generateNumbers() { ... }
}
// 수동 발급
class ManualLottoNumberProvider {
private final List<Integer> numbers;
public ManualLottoNumberProvider(List<Integer> numbers) {
this.numbers = numbers;
}
public List<Integer> provideNumbers() {
return numbers;
}
}
만약 DIP를 지키지 않는다면 어떻게 될까
class LottoApplication {
private final RandomLottoNumberGenerator generator;
public LottoApplication() {
// 구체 클래스에 직접 의존
this.generator = new RandomLottoNumberGenerator();
}
public void run() {
List<Integer> winningNumbers = generator.generateNumbers();
System.out.println("Winning Numbers: " + winningNumbers);
}
}
LottoApplication
은 RandomLottoNumberGenerator
에 직접 의존하고 있다.
만약 로또 번호 발급 방식을 수동 발급(ManualLottoNumberProvider
)으로 변경해야 한다면, LottoApplication
코드를 수정해야 한다.
개선된 설계
먼저 추상화된 인터페이스를 두어 로또 발급기를 구현한다.
LottoApplication
이 LottoNumberGenerator
인터페이스에 의존하도록 수정하여 DIP를 준수한다.
// 번호 생성 기능 인터페이스
interface LottoNumberGenerator {
List<Integer> generateNumbers();
}
// 랜덤 발급
class RandomLottoNumberGenerator implements LottoNumberGenerator {
@Override
public List<Integer> generateNumbers() { ... }
}
// 수동 발급
class ManualLottoNumberProvider implements LottoNumberProvider {
private final List<Integer> numbers;
public ManualLottoNumberProvider(List<Integer> numbers) {
this.numbers = numbers;
}
@Override
public List<Integer> provideNumbers() {
return numbers;
}
}
class LottoApplication {
private final LottoNumberGenerator generator;
// 의존성 주입
public LottoApplication(LottoNumberGenerator generator) {
this.generator = generator;
}
public void run() {
List<Integer> winningNumbers = generator.generateNumbers();
}
}
이때 LottoApplication
은 인터페이스에만 의존하기 때문에, 로또 번호가 어떻게 발급되는지는 알 필요가 없다.
번호 발급 방식이 랜덤 발급 -> 수동 발급으로 변경되어도 LottoApplication
는 수정할 필요가 없다.
그냥 LottoNumberGenerator
가 알아서 만들어주겠거니~하는 거다.
'💻 Dev > Java & OOP' 카테고리의 다른 글
싱글톤 패턴은 thread safe하지 않다?(개선 방식 4가지) (0) | 2025.02.11 |
---|---|
JDBC 드라이버 로딩으로 알아보는 Class.forName (0) | 2025.01.03 |
스레드 로컬(Thread Local)이란? (0) | 2024.12.29 |
자바 직렬화(Java Serializable) (0) | 2024.12.27 |
자바는 왜 Lambda&Stream을 도입했을까? feat.함수형 프로그래밍 (0) | 2024.12.26 |
리플렉션(Reflection)으로 DI 구현해보기 +단점 (0) | 2024.12.22 |