SOLID 법칙이란?
2000년대 초 로버트 마틴이 주창한 객체지향 5원칙을 두문법칙 기억법으로 정리해놓은 것입니다.
a.k.a 경선식 영단어처럼요..
그럼 하나씩 살펴봅시다.
S: Single Responsibility Principle (SRP) 단일 책임 원칙
한 클래스는 하나의 책임만 가져야 한다는 원칙
사실 저는 이 법칙이 가장 어려운 것 같습니다. 얼마나 책임을 구분해야하는 걸까?
어디부터 어디까지가 단 하나의 책임으로 둘 수 있을까? 라고 말이죠..
SRP를 알아보기 위한 사례로 온라인 도서 관리 시스템로 생각해봅시다.
도서를 관리하기 위해 우선 책 Book 클래스를 만듭니다. 해당 책에는 제목, 저자, 내용 등 이 존재할 거고요.
저는 책 내용을 출력하는 기능, 책 내용을 저장하는 기능, 책 내용을 로드하는 기능 등도 시스템에 두고 싶습니다.
class Book {
private String title;
private String author;
private String content;
public Book(String title, String author, String content) {
this.title = title;
this.author = author;
this.content = content;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public String getContent() {
return content;
}
// 책 내용을 출력하는 기능
public void printContent() {
System.out.println(content);
}
// 책 내용을 저장하는 기능
public void saveContent() {
// 파일 시스템에 내용을 저장하는 로직
}
// 책 내용을 로드하는 기능
public void loadContent() {
// 파일 시스템에서 내용을 로드하는 로직
}
}
지금은 작은 부분이니 그렇게 길어보이지도 않고 적당한 책임을 하나에 클래스에 준 것 같기도 합니다.
그러나 Book 클래스는 Book과 관련된 것(제목을 가져오거나, 저자 이름을 가져오거나 등)들만 두고 나머지는 다른 책임으로 생각해봅시다.
class Book {
private String title;
private String author;
private String content;
public Book(String title, String author, String content) {
this.title = title;
this.author = author;
this.content = content;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public String getContent() {
return content;
}
}
// 내용 출력 기능을 담당하는 클래스
class BookPrinter {
public void printContent(Book book) {
System.out.println(book.getContent());
}
}
// 내용 저장 기능을 담당하는 클래스
class BookPersistence {
public void saveContent(Book book) {
// 파일 시스템에 내용을 저장하는 로직
}
public void loadContent(Book book) {
// 파일 시스템에서 내용을 로드하는 로직
}
}
책의 데이터 관리, 출력, 저장, 로드 기능을 각각의 책임으로 분리해 다른 클래스로 나눴습니다.
O: Opend-Closed Principle (OCP) 개방 폐쇄 원칙
개방-폐쇄 원칙은 "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하지만, 변경에는 닫혀 있어야 한다"고 주장합니다. 이는 기존의 코드를 변경하지 않고도 시스템의 기능을 확장할 수 있어야 함을 의미합니다.
사실 모든 서비스 구현에서 확장에는 무조건 열려있어야 유지보수하기도 편리하니까 이건 당연한 원칙같은데 참 놓치기 어려운 것 같습니다.
예를 들어 제가 티켓을 판매하는 시스템을 만들었다고 합시다.
원래는 정가로만 판매하다가 이제 할인 기능도 추가 하려고합니다.
이때 만 65세 이상은 Senior 타입으로 50%를 할인해준다고 하고
A 쿠폰을 가지고 있는 사람들은 20% 로 할인해주는 것으로 구현하려고 합니다.
class DiscountCalculator {
public double calculateDiscount(String type, double price) {
if ("SENIOR".equals(type)) {
return price * 0.5;
} else if ("A-COUPON".equals(type)) {
return price * 0.8;
}
return price;
}
}
그러다 B 쿠폰도 생긴 겁니다.
B 쿠폰을 가지고 있는 사람들은 35%으로 할인해주는 것으로 구현하려고 합니다. 그럼 똑같이 else if 로 하면 될까요?
class DiscountCalculator {
public double calculateDiscount(String type, double price) {
if ("SENIOR".equals(type)) {
return price * 0.5;
} else if ("A-COUPON".equals(type)) {
return price * 0.8;
} else if ("B-COUPON".equals(type)) {
return price * 0.65;
}
return price;
}
}
그럼 할인 할 수 있는 것들이 많아지고 복잡해질 수록 if 문을 추가하면 그게 좋은 설계일까요? 확장에 너무 어려워지죠.
interface Discount {
double apply(double price);
}
class SeniorDiscount implements Discount {
public double apply(double price) {
return price * 0.5;
}
}
class ACouponDiscount implements Discount {
public double apply(double price) {
return price * 0.8;
}
}
class DiscountCalculator {
public double calculate(double price, Discount discount) {
return discount.apply(price);
}
}
애초에 이렇게 설계했다면 B 쿠폰 사용해서 할인 받는 것을 추가하기 위해서는 Discount 인터페이스를 그에 맞게 구현하면 됩니다.
interface Discount {
double apply(double price);
}
class SeniorDiscount implements Discount {
public double apply(double price) {
return price * 0.5;
}
}
class ACouponDiscount implements Discount {
public double apply(double price) {
return price * 0.8;
}
}
class BCouponDiscount implements Discount {
public double apply(double price) {
return price * 0.65;
}
}
class DiscountCalculator {
public double calculate(double price, Discount discount) {
return discount.apply(price);
}
}
그런데 이렇게 설계한 것은 OCP 법칙만 충족한 것은 아닙니다. 뒤에 나오는 DIP, 의존 관계 역전 원칙도 충족한 것입니다.
DiscountCalculator 클래스는 구체적인 할인 클래스를 의존하는 것이 아닌 인터페이스, 즉 추상화된 것을 의존하고 있기 때문입니다.
L: Liskov Substitution Principle (LSP) 리스코프 치환 법칙
리스코프 치환 원칙은 이름이 참 어렵지만,
"프로그램에서 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체해도 프로그램의 정확성에 영향을 미치지 않아야 한다"라는 것이 주된 의미입니다.
즉, 부모 클래스를 상속받은 모든 자식 클래스는 부모 클래스의 행위를 완전하게 지원할 수 있어야합니다.
이게 무슨 의미냐,
예를 들어 새라는 클래스가 있고 날 수 있는 독수리, 날 수 없는 펭귄 클래스가 각각 있을때 새를 상위클래스 즉 부모클래스로 두고 나머지를 자식클래스로 구현한 것을 생각해봅시다.
class Bird {
public void fly() {
System.out.println("This bird is flying.");
}
}
class Eagle extends Bird {
@Override
public void fly() {
System.out.println("The eagle is soaring high.");
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly.");
}
}
위 설계에서 Penguin 클래스는 fly 메서드를 지원하지 않으며, 이를 호출하면 예외가 발생합니다. 이는 LSP를 위반합니다. Bird 타입의 객체를 Penguin으로 대체했을 때, fly 메서드를 호출하면 프로그램의 정확성이 보장되지 않기 때문입니다.
그럼 어떻게 해야할까요?
새 중에 날 수 있는 새, 날 수 없는 새가 있으니 추상 클래스로 Bird를 만들어 두고 날 수 있는 것들을 Flyable이라는 인터페이스를 구현하도록 해봅시다.
abstract class Bird {
public abstract void makeSound();
}
interface Flyable {
void fly();
}
class Eagle extends Bird implements Flyable {
@Override
public void makeSound() {
System.out.println("The eagle screeches.");
}
@Override
public void fly() {
System.out.println("The eagle is soaring high.");
}
}
class Penguin extends Bird {
@Override
public void makeSound() {
System.out.println("The penguin squawks.");
}
}
사실 이 사례도 단순히 LSP만 충족되는 것이 아니라 ISP로도 생각할 수 있을 것 같습니다.
I: Interface Segregation Principle (ISP) 인터페이스 분리 법칙
클라이언트가 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 구성해야 한다는 설계 원칙
= 클라이언트에 특화된 여러 개의 작은 인터페이스가 하나의 일반적인 용도 인터페이스보다 낫다는
ISP를 준수하면 클라이언트에 필요하지 않은 메서드에 대한 의존성을 제거하여, 더 명확하고 사용하기 쉬운 인터페이스를 제공할 수 있습니다.
이게 무슨 말이냐, 이제 대격동 AI 시대에서.. 인간 Worker와 AI Worker가 있다고 합시다...
interface Worker {
void work();
void shower();
}
class HumanWorker implements Worker {
public void work() {
// 일하는 로직
}
public void eat() {
// 샤워하는 로직
}
}
class AIWorker implements Worker {
public void work() {
// AI가 일하는 로직
}
public void shower() {
// AI는 샤워 기능이 필요 없음
throw new UnsupportedOperationException("AI do not take a shower");
}
}
AI는 샤워 기능이 필요 없는데 굳이 Worker 인터페이스를 저렇게 만들고 저렇게 구현하는게 맞는 설계법인가 고민해야합니다.
인터페이스 분리 법칙에 따라 다음과 같이 특화된 인터페이스로 나눠 여러개의 작은 인터페이스를 구현하고 적절하게 설계해봅시다.
interface Workable {
void work();
}
interface Showerable {
void shower();
}
class HumanWorker implements Workable, Showerable {
public void work() {
// 일하는 로직
}
public void shower() {
// 샤워하는 로직
}
}
class AIWorker implements Workable {
public void work() {
// AI가 일하는 로직
}
}
D: Dependency Inversion Principle (DIP) 의존관계 역전 법칙
고수준 모듈이 저수준 모듈에 직접적으로 의존하는 전통적인 의존성 구조를 역전시켜야 한다는 원칙
= 의존 관계를 맺을 때 구체적인 클래스보다는 인터페이스나 추상 클래스에 의존 해야 한다.
예시를 보면 이해하기 쉽습니다.
만약 의존 관계를 맺을 때 구체적인 클래스에 의존할 경우(고수준 모듈이 저수준 모듈에 직접적으로 의존 한 경우)?
전기 스위치 클래스(고수준 모듈)와 전구 클래스(저수준 모듈)가 존재할 때
전기 스위치 클래스가 전구 클래스를 직접적으로 의존하여 전기 스위치를 press 할때마다 전구가 켜진다고 생각해봅시다.(꺼지는 건 현재 없다고 생각해봅시다.)
그럼 코드는 다음과 같습니다.
class LightBulb {
public void turnOn() {
System.out.println("LightBulb: Bulb turned on...");
}
public void turnOff() {
System.out.println("LightBulb: Bulb turned off...");
}
}
class ElectricPowerSwitch {
public LightBulb bulb;
public ElectricPowerSwitch(LightBulb bulb) {
this.bulb = bulb;
}
public void press() {
System.out.println("Switch pressed.");
bulb.turnOn(); // 직접적인 의존
}
}
만일 전구가 아니라 선풍기 fan도 해당 전기 스위치가 작동함에 따라 켜지고 꺼지게 하고 싶다면 어떻게 될까요?
저 코드는 더이상 쓸 수가 없습니다. 재사용이 불가하다는 것이에요.
물론 현재 ElectricPowerSwitchForLight, ElectricPowerSwitchForFan으로 각자 SRP에 맞게 클래스를 나눠 볼수도 있겠네요.
그럼 동일한 전구인 백열등과 형광등 클래스를 ElectricPowerSwitchForLight에 둔다고 생각해보면 어떨까요? 어떻게 해야 좋은 설계 방법일까요?
ElectricPowerSwitchForLight 클래스를 백열등, 형광등과 같은 구체적인 클래스를 의존하도록 하지 말고 인터페이스나 추상클래스를 의존하도록 설계하는 겁니다.
그러면 백열등, 형광등은 Lignt라는 인터페이스를 직접 구현하는 걸로 합니다.
interface Light {
void lightOn();
void lightOff();
}
class IncandescentLight implements Light {
@Override
public void lightOn() {
System.out.println("Incandescent light: turned on");
}
@Override
public void lightOff() {
System.out.println("Incandescent light: turned off");
}
}
class FluorescentLight implements Light {
@Override
public void lightOn() {
System.out.println("Fluorescent light: turned on");
}
@Override
public void lightOff() {
System.out.println("Fluorescent light: turned off");
}
}
그러면 ElectricPowerSwitchForLight 클래스는 Light 인터페이스를 의존할 수 있습니다.
class ElectricPowerSwitchForLight {
private Light light;
public ElectricPowerSwitchForLight(Light light) {
this.light = light;
}
public void pressOn() {
if (light instanceof IncandescentLight) {
System.out.println("Switching incandescent light...");
light.turnOn();
} else if (light instanceof FluorescentLight) {
System.out.println("Switching fluorescent light...");
light.turnOn();
}
}
// pressOff()도 동일
}
사실 원칙들 별로 예시 사례와 그에 맞는 코드를 보여봤지만
모든게 연결되어있다는 걸 알 수 있습니다.
그만큼 SOLID별로 이야기 하는 모든 게 객체 지향적인 설계라는 의미겠죠.
'JAVA > 기초개념' 카테고리의 다른 글
[Java] 객체 지향 언어의 특징 - 다형성 (오버로딩, 오버라이딩) (0) | 2024.05.28 |
---|---|
[Java] 객체 지향 언어의 특징 - 상속 (0) | 2024.05.28 |
[Java] 객체 지향 언어의 특징 - 추상화 (1) | 2024.05.17 |
[Java] 객체 지향 언어의 특징 - 캡슐화(Encapsulation) (0) | 2024.05.16 |