객체 지향 프로그래밍(OOP)에서 지향해야 할 5가지 설계 원칙을 SOLID라고 칭한다.
개인 프로젝트를 시작하면서, 완벽하고 빈틈없는 코드를 짜겠다 라는 목표를 세웠다. 그러나 생각만 하는 것 보다는, 5원칙을 참고하여 좀더 명확한 목표를 잡고 프로그래밍 하는 것이 낫겠다고 느꼈다. 지금까진 SOLID 원칙이 중요하다고 지나가듯이 들었던 말들 뿐이었는데, 이번에 제대로 공부해보며 어째서 중요한 것인지 이해할 수 있었다.
5원칙 중에는 나도 모르게 이미 적용하고 있던 것들도 있었고, 하긴 하는데 왜 하는지는 모르는 것도 있었고, 아예 모르는 것도 있었다. 실무 가서 일하다 보면 혼나고 깨지면서 자동으로 이 5원칙에 따라 코드를 짜게 된다던데, 지금부터 미리 5원칙을 이해하고 코드를 설계할 수 있다면 큰 장점이 되지 않을까 싶었다.
사실 이런 원론적인 내용들은 실무와 동떨어지고 효율이 없다고 생각하여 그다지 좋아하는 편은 아닌데, SOLID 원칙은 실제로 개발에 도움이 되는 것 같다. 도움이 되는 정도가 아니라 그냥 이렇게 짜는게 좋을 것 같다고 본능이 말한다. 글로만 보면 뭔 소린지 이해가 안 되지만, 예시 코드를 보면 아 이게 이거구나 하고 느낌이 온다.
객체 지향 프로그래밍 5원칙 (SOLID)
1. 단일 책임 원칙(SRP, Single Responsibility Principle) : 클래스는 하나의 책임만 가져야 한다.( 클래스가 수정되는 이유는 한가지여야 한다 )
2. 개방-폐쇄 원칙(OCP, Open-Closed Principle) : 객체는 확장은 허용하되 변경은 피해야 한다.
3. 리스코프 치환 원칙(LSP, Liskov Substitution Principle) : 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
4. 인터페이스 분리 원칙(ISP, Interface Segregation Principle) : 클라이언트에서 사용하지 않는 메서드는 사용해선 안 된다.
5. 의존성 역전 원칙(DIP, Dependency Inversion Principle) : 고수준의 클래스(컨트롤러 등)는 구체적인 저수준 클래스에 의존해서는 안 된다. 또한, 저수준 클래스도 추상화(인터페이스 등)에 의존해야 한다(구현함으로써).
SOLID 원칙을 따르면 코드 유지보수성, 재사용성, 테스트 용이성, 확장성, 유연성 등이 향상되고 버그가 감소할 수 있다. 또한 코드 구조가 일관되기 때문에 팀원 간 협업도 수월하게 진행될 수 있다.
1. 단일 책임 원칙 (SRP)
// 나쁜 예시 -> 하나의 클래스가 여러 책임을 가지고 있음 (사용자, 이메일, 보고서)
class UserService {
public void saveUser(User user) {
// 사용자 저장 로직
}
public void sendEmail(User user) {
// 이메일 전송 로직
}
public String generateReport(User user) {
// 보고서 생성 로직
}
}
// 좋은 예시 -> 하나의 클래스가 하나의 책임만 가짐
class UserService {
private EmailService emailService;
private ReportService reportService;
public void saveUser(User user) {
// 사용자 저장 로직
}
}
class EmailService {
public void sendEmail(User user) {
// 이메일 전송 로직
}
}
class ReportService {
public String generateReport(User user) {
// 보고서 생성 로직
}
}
하나의 클래스는 하나의 책임만 가져야 하며, 변경되는 이유는 단 하나여야 한다.
즉 UserService가 변경되는 이유는 반드시 User 관련 문제여야 한다.
예를 들어 위 나쁜 예시 기준으로, 보고서 생성 부분에서 문제가 생겼을 경우 보고서와 전혀 상관없어 보이는 UserService를 수정해야 할 것이다.
하나의 클래스가 여러 책임을 지니게 된다면 클래스의 목적이 모호해지고, 기능을 수정할 때 영향을 받는 범위도 커져서 유지보수가 힘들어진다. 작성한 본인도 이게 뭐 하는 클래스인지 설명할 수가 없는 스파게티 코드가 되어버릴 것이다.
2. 개방-폐쇄 원칙 (OCP)
// 나쁜 예시
class PaymentProcessor {
public void processPayment(String paymentType, double amount) {
if (paymentType.equals("CREDIT_CARD")) {
// 신용카드 결제 처리
} else if (paymentType.equals("PAYPAL")) {
// 페이팔 결제 처리
}
// 새로운 결제 방식이 추가될 때마다 이 메소드를 수정해야 함
}
}
// 좋은 예시 -> 메서드를 인터페이스로 정의하고 각 클래스에서 오버라이드하여 구현
interface PaymentMethod {
void processPayment(double amount);
}
class CreditCardPayment implements PaymentMethod {
@Override
public void processPayment(double amount) {
// 신용카드 결제 처리
}
}
class PayPalPayment implements PaymentMethod {
@Override
public void processPayment(double amount) {
// 페이팔 결제 처리
}
}
class PaymentProcessor {
public void processPayment(PaymentMethod paymentMethod, double amount) {
paymentMethod.processPayment(amount);
}
}
객체는 확장은 허용하되 변경은 피해야 한다.
나쁜 예시에서는 결제 메서드인 processPayment()를 결제 방식이 추가될 때마다 변경하고 있다.
즉 기존 메서드를 계속 수정해야 하기 때문에 실수를 할 확률이 높고 유지보수하기 힘들어진다.
좋은 예시에서는 공통되는 결제 메서드 processPayment()를 인터페이스로 선언해 놓고, 각 결제 방식 클래스에서 구현하여 별도의 메서드로 구분해 두었다. 이렇게 하면 기존 메서드를 변경하지 않고 확장하는 방식으로 추가 기능 구현이 가능하다.
3. 리스코프 치환 원칙 (LSP)
// LSP 위반 예시
interface Bird {
// 이 메서드는 모든 구현체가 날 수 있다는 '계약'을 의미
void fly(int altitude);
void eat();
}
class Sparrow implements Bird {
@Override
public void fly(int altitude) {
System.out.println("참새가 " + altitude + "미터 높이로 날아오릅니다");
}
@Override
public void eat() {
System.out.println("참새가 씨앗을 먹습니다");
}
}
class Penguin implements Bird {
@Override
public void fly(int altitude) {
// 펭귄은 날 수 없으므로 '계약'을 위반
throw new UnsupportedOperationException("펭귄은 날 수 없습니다");
// 또는, 아무것도 하지 않는 것도 계약 위반입니다
}
@Override
public void eat() {
System.out.println("펭귄이 물고기를 먹습니다");
}
}
LSP는 좀 헷갈렸다. 자식은 언제나 부모를 "대체"할 수 있어야 한다.
부모에 선언된 메서드(기능)를 "계약"이라고 칭할 경우, 해당 부모를 상속받은 자식들은 부모가 가진 계약(기능)을 정상적으로 실행할 수 있어야 하며, 부모를 자식으로 "대체"하더라도 부모의 계약은 정상 작동하여야 한다.
위 나쁜 예시에서, Bird 인터페이스를 상속받은 자식들은 fly()와 eat()모두 정상작동 해야 하지만, 펭귄은 날 수 없으므로 fly()메서드 호출 시 에러를 던진다.
Bird 인터페이스를 상속받은 자식은, 반드시 fly() 와 eat() 이라는 계약이 모두 정상작동 해야 하고 그렇게 기대된다. 위 코드에서는 fly()가 작동하지 않고, Penguin은 Bird를 대체하지 못 했으므로 LSP 위반이다.
// 기본 새 인터페이스 - 모든 새의 공통 동작만 정의
interface Bird {
void eat();
}
// 날 수 있는 새를 위한 인터페이스
interface FlyingBird extends Bird {
void fly(int altitude);
}
// 참새 구현 - 날 수 있는 새
class Sparrow implements FlyingBird {
@Override
public void fly(int altitude) {
System.out.println("참새가 " + altitude + "미터 높이로 날아오릅니다");
}
@Override
public void eat() {
System.out.println("참새가 씨앗을 먹습니다");
}
}
// 펭귄 구현 - 날 수 없는 새이므로 Bird만 구현
class Penguin implements Bird {
@Override
public void eat() {
System.out.println("펭귄이 물고기를 먹습니다");
}
// 날 수 없으므로 fly 메서드가 없음
}
// 안전하게 사용할 수 있는 클라이언트 코드
class BirdClient {
// 모든 새에게 먹이 주기
public void feedBird(Bird bird) {
bird.eat();
}
// 날 수 있는 새만 비행시키기
public void letBirdFly(FlyingBird bird, int altitude) {
bird.fly(altitude);
}
}
위 예시는 인터페이스 분리 원칙(ISP)를 적용해 LSP를 지키도록 개선한 코드이다. eat()을 상위 인터페이스인 Bird로 분리하여, 날지 못하는 펭귄은 Bird를 상속받게 했다.
이제 Sparrow 클래스는 부모 FlyingBird 인터페이스의 eat() fly()를 정상작동 하고 있으며, Penguin 클래스는 부모 Bird 인터페이스의 eat() 을 정상작동 하고 있다. 즉 부모의 기능을 자식이 온전히 수행하고 있으므로, 자식이 부모를 대체할 수 있게 되었고 LSP가 지켜졌다.
// Bird 타입을 기대하는 메서드
void feedBird(Bird bird) {
bird.eat();
}
// Bird 타입을 기대하지만 Penguin을 전달
Penguin penguin = new Penguin();
feedBird(penguin); // Penguin은 Bird 타입으로 자동 변환되어 전달됨
위 코드를 보면, Bird 타입을 기대하는 feedBird() 메서드에 Bird를 상속받은 Penguin을 전달해도 문제가 없다. 물론 Penguin에서 추가 정의한 기능들은 사용할 수 없겠지만, 자동으로 Bird 타입으로 변환되기 때문에 feedBird() 메서드는 동작하는 데 문제가 전혀 없다.
만약 LSP를 위반하여 Penguin이 Bird의 기능을 정상작동하지 못하거나 완전히 바꿔 버렸을 경우, 위 코드에서는 분명히 문제가 발생할 것이다. LSP가 지켜졌고 자식이 부모를 완전히 대체할 수 있기 때문에 가능한 코드다.
그러나 LSP 위반의 기준이 애매하다. 만약 기능이 정의된 부모 클래스를 extends한 후 메서드를 override 하여 기능을 바꾼다면 그것은 LSP 위반인 것인가?
LSP 위반의 기준
1. 사전조건을 강화하면 안 됨: 부모 클래스보다 더 엄격한 입력 제약을 두면 안 됨
2. 사후조건을 약화하면 안 됨: 부모 클래스보다 더 약한 결과를 보장하면 안 됨
3. 불변조건을 유지해야 함: 부모 클래스의 핵심 동작 특성을 유지해야 함
LSP 위반의 예
- 예외를 발생시키거나, null을 반환하는 등 부모 메서드의 계약을 깨는 방식으로 오버라이드 하는 것
- 부모 메서드가 보장하는 동작을 수행하지 않는 것
- 메서드 시그니처는 같지만, 완전히 다른 동작을 하도록 구현
LSP를 준수하는 예
- 부모 메서드의 기본 동작은 유지하되 기능을 확장
- 같은 결과를 보장하되 더 효율적인 알고리즘으로 대체
- 부모의 동작을 포함하면서 추가 기능 제공
즉 부모를 대체 가능한 수준으로 확장/개선 하는것이 LSP를 준수한다고 할 수 있다.
4. 인터페이스 분리 원칙 (ISP)
// 나쁜 예시 -> 로봇이 사용하지 않는 메서드도 구현하고 있음
interface Worker {
void work();
void eat();
void sleep();
}
class HumanWorker implements Worker {
@Override
public void work() {
// 일하는 로직
}
@Override
public void eat() {
// 먹는 로직
}
@Override
public void sleep() {
// 자는 로직
}
}
class RobotWorker implements Worker {
@Override
public void work() {
// 일하는 로직
}
@Override
public void eat() {
// 로봇은 먹지 않음 - 불필요한 구현
throw new UnsupportedOperationException();
}
@Override
public void sleep() {
// 로봇은 자지 않음 - 불필요한 구현
throw new UnsupportedOperationException();
}
}
// 좋은 예시 -> 인터페이스를 분리하여 실제로 사용하는 메서드만 구현
interface Workable {
void work();
}
interface Eatable {
void eat();
}
interface Sleepable {
void sleep();
}
class HumanWorker implements Workable, Eatable, Sleepable {
@Override
public void work() {
// 일하는 로직
}
@Override
public void eat() {
// 먹는 로직
}
@Override
public void sleep() {
// 자는 로직
}
}
class RobotWorker implements Workable {
@Override
public void work() {
// 일하는 로직
}
}
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
위 나쁜 예시에서, Worker 인터페이스에는 work(), eat(), sleep() 메서드가 있다. 사람은 일하고 먹고 자기 때문에 Worker를 상속받아도 문제없다. 그러나 로봇은 먹고 자지 않기 때문에 Worker를 상속받을 경우 쓸모도 없는 eat(), sleep()을 구현해야 하고 의존성이 생겨버린다.
좋은 예시에서는 사람, 로봇이 각각 필요한 메서드만 구현할 수 있도록 인터페이스를 3개로 분리했다. 사람은 3개 인터페이스를 모두 구현하고, 로봇은 자신이 필요한 work()만 상속받아 구현했다.
이와 같이, 사용하지 않는 메서드에 의존성이 생겨선 안 된다. 인터페이스를 분리하여 필요한 것만 상속받고 구현할 수 있게 해야 한다.
ISP 예시를 보면 LSP 예시 코드와 비슷한 느낌이 든다. 실제로 그렇다. ISP와 LSP는 밀접한 관계가 있으며, ISP를 지키면 LSP를 지킬 확률이 높아진다. 그 이유는 아래와 같다.
1. 작고 응집력 있는 인터페이스
- ISP에 따라 인터페이스가 작고 명확한 책임을 가지면, 인터페이스를 구현하는 클래스들은 그 특정 책임에만 집중할 수 있다.
- 이렇게 되면 각 인터페이스의 계약(기능)이 명확해져서 LSP를 위반할 여지가 줄어든다.
2. 불필요한 메서드 제거
- 클라이언트가 사용하지 않는 메서드에 의존하지 않게 함으로써(ISP), "사용하지 않는 메서들를 빈 구현으로 남겨두거나/예외를 던지는" 식의 LSP 위반을 방지한다.
5. 의존성 역전 원칙 (DIP)
// 나쁜 예시: 고수준 모듈(서비스)이 저수준 모듈에 직접 의존
class NotificationService {
private EmailSender emailSender;
public NotificationService() {
this.emailSender = new EmailSender(); // 저수준 모듈에 직접 의존
}
public void notify(User user, String message) {
emailSender.sendEmail(user.getEmail(), message);
}
}
// 클래스에 이메일 발송 로직 직접 구현 (저수준 모듈)
class EmailSender {
public void sendEmail(String email, String message) {
System.out.println("이메일 발송: " + email + ", 내용: " + message);
}
}
// 좋은 예시: 고수준 모듈이 추상화(인터페이스)에 의존
// 인터페이스 선언
interface MessageSender {
void sendMessage(String target, String message);
}
// 인터페이스를 통해 이메일 발송 로직 구현 (저수준 모듈)
class EmailSender implements MessageSender {
@Override
public void sendMessage(String email, String message) {
System.out.println("이메일 발송: " + email + ", 내용: " + message);
}
}
// 인터페이스를 통해 SMS 발송 로직 구현 (저수준 모듈)
class SMSSender implements MessageSender {
@Override
public void sendMessage(String phoneNumber, String message) {
System.out.println("SMS 발송: " + phoneNumber + ", 내용: " + message);
}
}
// 고수준 모듈(서비스)
class NotificationService {
private final MessageSender sender;
// 저수준 모듈 대신 추상화(인터페이스)에 의존
public NotificationService(MessageSender sender) {
this.sender = sender;
}
public void notify(User user, String message) {
sender.sendMessage(user.getContactInfo(), message);
}
}
// 사용 예시 -> 저수준 모듈은 주입받아 사용
public class Main {
public static void main(String[] args) {
// 이메일 알림 사용
MessageSender emailSender = new EmailSender();
NotificationService emailService = new NotificationService(emailSender);
// SMS 알림 사용
MessageSender smsSender = new SMSSender();
NotificationService smsService = new NotificationService(smsSender);
}
}
고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 또한, 저수준 모듈도 추상화에 의존해야 한다(구현함으로써).
전통적인 방식은 고수준 모듈이 저수준 모듈에 직접 의존하는 것이지만, DIP는 이 관계를 역전시켜서 저수준 모듈이 고수준 모듈에서 정의한 인터페이스를 따르도록 한다. 저수준 모듈이 인터페이스를 구현하는 지점이 "의존성 역전"이 일어나는 지점이라고 할 수 있다.
위 나쁜 예시 코드에서, 고수준 모듈은 구체적인 로직이 구현된 저수준 모듈에 직접 의존했다.
좋은 예시 코드에서는, MessageSender 인터페이스를 만든 후 고수준 모듈이 이 추상화(인터페이스)에 의존한다.
그리고 저수준 모듈이 MessageSender를 구현하는 시점에 의존성 역전이 일어났고(고수준 모듈이 원하는 틀에 맞춰 저수준 모듈이 구현됨), 결과적으로 고수준 모듈은 인터페이스를 통해 구현된 저수준 모듈들을 자유롭게 바꿔 끼워가며 사용할 수 있게 되었다.
SOLID 원칙 간의 연관성
SOLID 원칙을 공부하면서, 각 원칙이 비슷하거나 밀접하게 연결되어 있다고 느꼈다. 특히 단일 책임 원칙(SRP)를 제외한 4원칙은 매우 비슷하다고 느껴졌기에, 각 원칙 간의 연관성에 대해 조금 더 공부해 보았다.
1. 개방-폐쇄 원칙(OCP)과 리스코프 치환 원칙(LSP)
- LSP를 지키면 기존 코드를 변경하지 않고 새로운 기능을 확장할 수 있기 때문에, OCP를 자연스럽게 따르게 된다.
2. 리스코프 치환 원칙(LSP)과 인터페이스 분리 원칙(ISP)
- ISP를 지키면 구현 클래스가 예상치 못한 동작을 할 가능성을 줄여, LSP를 지키기 쉽게 한다.
3. 인터페이스 분리 원칙(ISP)과 의존성 역전 원칙(DIP)
- 작고 집중된 인터페이스(ISP)는 DIP가 말하는 "추상화에 의존"하기 좋은 형태를 제공한다.
4. 의존성 역전 원칙(DIP)과 개방-폐쇄 원칙(OCP)
- 추상화에 의존(DIP)하면 구체적인 구현을 변경하지 않고 새로운 기능을 추가할 수 있으므로 두 원칙이 상호보완적으로 작용한다.
단일 책임 원칙(SRP)은 나머지 원칙들과 직접적인 연관성은 적으나, SRP를 따르면 클래스와 모듈이 작고 집중되어 다른 원칙들을 적용하기 쉬워진다. 즉, SRP는 다른 원칙들을 효과적으로 적용하기 위한 토대를 만든다고 볼 수 있다.
결과적으로 SOLID 원칙들은 함꼐 적용할 때 시너지 효과를 내고, 더 유연하고 유지보수하기 쉬운 코드 구조를 만들어 준다.
================
규칙 보다는 지향점으로..
SOLID 원칙은 이상적인 설계 지침이긴 하지만, 모든 상황에서 완벽하게 100% 적용할 수는 없다. 프로젝트 규모와 복잡성, 시간 및 자원 제약, 실용성 등을 따져보았을 때 분명히 타협해야 할 부분들이 존재한다.
규모가 작은 프로젝트는 과도한 추상화가 쓸데없이 복잡성만 증가시킬 수 있고,시간관계상 완벽한 설계보단 당장 작동하는 코드가 우선일 때라거나,때로는 아주 단순한 코드가 더 이해하기 쉬울 수 있다.
따라서 SOLID 원칙은 반드시 지켜져야 할 "규칙" 이라기 보단 "지향점"으로 보는 것이 좋다. 그러나 확실히 이해하고 있어야 한다고 생각한다. 알면서 하지 않는 것과 몰라서 못 하는 것은 다르니까. 명확한 이해에 기반하여 상황에 따라 유연하게 적용할 수 있도록 하는 것이 가장 좋은 방향성이라고 생각한다.
'Development > Java' 카테고리의 다른 글
Java 클래스의 구성 (필드/생성자/메소드) (2) | 2023.10.15 |
---|---|
Java 자유로운 버전 변경 방법/ 다수 버전 병행 사용법/ 버전 확인 (0) | 2023.10.15 |