
개요
우리는 왜 클린아키텍처를 도입해야 할까요? 클린 아키텍처는 코드를 더 유지보수하기 쉽고,
확장 가능하며, 테스트하기 좋게 만들어줍니다. 그 이유는 크게 두 가지 입니다.
설명에 앞서 일단 객체지향의 설계 5원칙 "SOLID" 에 대해서 알아 볼 필요가 있습니다.
(저도 다시 복습 해보고자...)

SOLID 원칙이란? 객체 지향 설계의 다섯 가지 핵심 원칙
소프트웨어 개발에서 중요한 목표 중 하나는 유지보수성, 확장성, 그리고 코드의 재사용성을 높이는 것입니다.
이를 달성하기 위해서 SOLID 원칙이 제시되었습니다. SOLID는 소프트웨어 설계 원칙들의 약어로, 각 원칙은 객체 지향 프로그래밍에서 모듈을 더 유연하고 확장 가능하게 만들기 위해 제안되었습니다. 이 글에서는 SOLID 원칙이 무엇인지,
각 원칙이 왜 중요한지 살펴보겠습니다.
1. Single Responsibility Principle (SRP) - 단일 책임 원칙
"하나의 클래스는 하나의 책임만 가져야 한다."
단일 책임 원칙은 말 그대로 클래스가 단 하나의 책임만 가져야 한다는 것입니다. 클래스가 여러 가지 책임을 가지게 되면, 코드의 변경이 발생할 때마다 여러 곳에 영향을 미칠 수 있어 유지보수가 어려워집니다. 각 클래스가 하나의 책임만 가지면 코드의 모듈성이 향상되고, 코드의 수정이 필요한 경우 해당 책임만 변경하면 됩니다.
class ReportPrinter {
public void printReport() {
// 보고서 출력
}
}
( 위 클래스는 오직 보고서를 출력하는 책임만을 가지므로 SRP를 잘 따르고 있습니다.)
2. Open/Closed Principle (OCP) - 개방/폐쇄 원칙
"확장에는 열려 있고, 수정에는 닫혀 있어야 한다."
OCP는 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장할 수 있도록 설계해야 한다는 원칙입니다. 즉, 기존
코드는 건드리지 않고, 기능 확장은 상속이나 인터페이스 구현을 통해 가능해야 한다는 것입니다.
interface PaymentMethod {
void pay();
}
class CreditCard implements PaymentMethod {
public void pay() {
// 신용카드 결제
}
}
class PayPal implements PaymentMethod {
public void pay() {
// PayPal 결제
}
}
( 새로운 결제 수단이 필요할 경우, PaymentMethod 인터페이스를 구현하는 새로운 클래스를 추가하기만 하면 됩니다.
기존 코드를 수정할 필요 없이 확장이 가능합니다.)
3. Liskov Substitution Principle (LSP) - 리스코프 치환 원칙
"하위 클래스는 상위 클래스를 대체할 수 있어야 한다."
LSP는 하위 클래스가 상위 클래스의 역할을 대신할 수 있어야 한다는 원칙입니다. 즉, 프로그램이 상위 클래스의 객체
대신 하위 클래스의 객체를 사용하더라도 프로그램의 동작에 문제가 없어야 합니다. 이 원칙을 어기면 다형성이 깨지며,
예상치 못한 버그가 발생할 수 있습니다.
class Bird {
public void fly() {
// 새가 날다
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다.");
}
}
( 이 코드는 LSP를 위반한 예시입니다. Penguin 클래스는 Bird의 하위 클래스이지만, fly 메서드를 지원하지 않으므로
상위 클래스의 동작을 제대로 대체하지 못합니다. 펭귄과 같은 날 수 없는 새를 Bird 클래스에서 분리해 설계하는 것이
필요합니다.)
4. Interface Segregation Principle (ISP) - 인터페이스 분리 원칙
"클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다."
인터페이스 분리 원칙은 하나의 거대한 인터페이스를 만들기보다는 여러 개의 작은 인터페이스로 나누어 클라이언트가
자신이 필요로 하는 기능만 의존하도록 해야 한다는 것입니다. 이렇게 하면 필요하지 않은 메서드 때문에 클래스가 불필요한 의존성을 가지는 것을 방지할 수 있습니다.
interface Printer {
void print();
void scan();
}
class SimplePrinter implements Printer {
public void print() {
// 출력만 가능
}
public void scan() {
// 지원하지 않는 기능
}
}
( Printer 인터페이스는 print와 scan 두 기능을 모두 포함하고 있지만, SimplePrinter는 출력만 가능하므로 불필요하게 scan 메서드를 구현하게 됩니다. 이를 ISP에 맞게 수정하면)
interface PrintFunction {
void print();
}
interface ScanFunction {
void scan();
}
class SimplePrinter implements PrintFunction {
public void print() {
// 출력만 가능
}
}
( 이제 SimplePrinter는 필요한 메서드만 구현할 수 있게 되었습니다.)
5. Dependency Inversion Principle (DIP) - 의존성 역전 원칙
"고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다."
DIP는 의존성 역전 원칙으로, 상위 모듈(비즈니스 로직)이 하위 모듈(세부 구현)에 의존하지 않고, 추상화된 인터페이스에 의존하도록 만들어야 한다는 원칙입니다. 이를 통해 상위 모듈의 변경이 하위 모듈에 미치는 영향을 최소화할 수 있습니다.
interface NotificationService {
void sendNotification();
}
class EmailNotification implements NotificationService {
public void sendNotification() {
// 이메일 알림 발송
}
}
class NotificationManager {
private final NotificationService notificationService;
public NotificationManager(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void notifyUser() {
notificationService.sendNotification();
}
}
( NotificationManager는 구체적인 구현 클래스인 EmailNotification에 의존하지 않고,
NotificationService 인터페이스에 의존하기 때문에,
나중에 알림 방식을 변경해도 NotificationManager를 수정할 필요가 없습니다.)
위 예제가 아직 이해가 안되신다면 조금 더 쉽게 의존성에 대해서부터 설명을 조금 드리도록 하겠습니다.
먼저, "의존성"이라는 말은 어떤 코드가 다른 코드에 필요로 하는 관계를 의미합니다. 자바에서는 클래스나 메서드가
다른 클래스나 메서드를 사용할 때, 이를 의존한다고 합니다.
예를 들어보겠습니다. 만약 우리가 TV를 켜기 위해 리모컨을 사용한다면, 리모컨은 TV에 의존한다고 할 수 있습니다.
리모컨 자체로는 TV를 켜는 기능이 없고, TV가 필요합니다. 자바에서도 하나의 클래스가 다른 클래스의 기능을 사용해야
할 때 의존하는 상황이 발생합니다.
DIP가 필요하지 않은 단순한 상황을 예로 들어보면..
만약 우리가 간단하게 리모컨과 TV의 관계를 코드로 표현한다고 가정 해보겠습니다. 리모컨 클래스가 TV를 켜는 상황을 자바 코드로 작성하면 이렇게 될 것 입니다.
class TV {
public void turnOn() {
System.out.println("TV is turned on");
}
}
class RemoteControl {
private TV tv;
public RemoteControl(TV tv) {
this.tv = tv;
}
public void pressButton() {
tv.turnOn();
}
}
여기서 RemoteControl 클래스는 TV 클래스에 의존합니다. 왜냐하면, RemoteControl은 TV 객체가 없으면 동작할 수 없죠. 이 관계를 의존성이라고 부릅니다. RemoteControl은 TV 클래스가 있어야만 기능을 수행할 수 있기 때문에, TV 클래스에 의존한다고 할 수 있습니다.
이제 이 상황에서 문제가 생길 수 있는 상황을 생각해봅시다. 나중에 RemoteControl이 TV뿐만 아니라 다른 장치
(예: 에어컨)를 제어할 수 있어야 한다고 가정 해보겠습니다. 그럼 어떻게 해야 할까요?
RemoteControl에 새로운 장치인 에어컨을 추가하려면 RemoteControl을 계속 수정해야 합니다.
class AirConditioner {
public void turnOn() {
System.out.println("AirConditioner is turned on");
}
}
class RemoteControl {
private TV tv;
private AirConditioner airConditioner;
public RemoteControl(TV tv, AirConditioner airConditioner) {
this.tv = tv;
this.airConditioner = airConditioner;
}
public void pressButtonForTV() {
tv.turnOn();
}
public void pressButtonForAC() {
airConditioner.turnOn();
}
}
이렇게 되면 RemoteControl 클래스는 TV뿐만 아니라 에어컨에도 의존하게 되고, 코드가 복잡해집니다. 새로운 장치가
추가될 때마다 RemoteControl을 수정해야 하니, 코드 유지보수가 어려워질것 입니다.
이제 DIP가 왜 필요한지 이해하기 위해, DIP를 적용한 방법을 생각해보겠습니다.
DIP는 이런 문제를 해결하기 위해, 상위 클래스(여기서는 RemoteControl)가 구체적인 클래스(TV, AirConditioner)와 직접적으로 연결되지 않고, 추상적인 인터페이스와 연결되게 합니다. 이렇게 하면 새로운 장치를 추가할 때마다 RemoteControl을 수정할 필요가 없어집니다.
// Device라는 인터페이스를 정의
interface Device {
void turnOn();
}
// TV와 에어컨은 이제 Device 인터페이스를 구현
class TV implements Device {
public void turnOn() {
System.out.println("TV is turned on");
}
}
class AirConditioner implements Device {
public void turnOn() {
System.out.println("AirConditioner is turned on");
}
}
// RemoteControl은 더 이상 특정 장치(TV, 에어컨)에 의존하지 않고, 추상적인 Device에 의존
class RemoteControl {
private Device device;
public RemoteControl(Device device) {
this.device = device;
}
public void pressButton() {
device.turnOn();
}
}
이제 RemoteControl은 TV나 AirConditioner 같은 구체적인 클래스가 아니라, Device라는 추상적인 인터페이스에 의존하게 되었습니다. 만약 다른 장치(예: 라디오)를 추가해야 한다면, RemoteControl 클래스를 수정하지 않고 새로운 장치 클래스만 Device 인터페이스를 구현하면 됩니다.
class Radio implements Device {
public void turnOn() {
System.out.println("Radio is turned on");
}
}
이제 좀 더 이해가 되셨나요? DIP를 적용하면 상위 클래스(리모컨)가 하위 클래스(구체적인 장치들) 에 직접 의존하지
않기 때문에, 시스템이 더 유연하고 확장 가능해집니다. DIP는 "구체적인 구현"에 의존하지 말고, "추상화된 인터페이스"에 의존하라는 것이 핵심입니다.
결론
SOLID 원칙은 유지보수성과 확장성을 고려한 소프트웨어 설계의 핵심 원칙입니다. 각 원칙은 독립적으로도 유용하지만, 함께 적용되면 더욱 강력한 시너지를 발휘합니다. 이러한 원칙을 일상적인 개발 과정에서 적용함으로써, 더 유연하고 견고한 시스템을 설계할 수 있습니다. 처음에는 어렵게 느껴질 수 있지만, 연습을 통해 익숙해지면 코드의 질을 크게 향상시킬 수 있습니다.
'개발 > Java' 카테고리의 다른 글
트랜잭션을 위한 SAGA 패턴 (1) | 2024.11.21 |
---|---|
Java 애플리케이션에서 로깅 구현하기 (feat. SLF4J) (2) | 2024.07.12 |
깨굴딱지의 코드연못입니다
올챙이가 개구리로 거듭나듯, 끊임없는 노력으로 진화하는 개발자의 길을 걷습니다. 🐸