[SOLID] 03. SOLID 다섯 가지 원칙
SOLID 원칙은 2000년대 초반, 소프트웨어 개발의 품질을 높이기 위해 만들어진 객체지향 설계 원칙으로, 로버트 C. 마틴(Robert C. Martin), 흔히 “클린 코드 아저씨(Uncle Bob)”로 알려진 소프트웨어 공학자가 제안했다. 이 원칙들은 유지보수성과 확장성이 뛰어난 소프트웨어를 설계하기 위한 가이드라인으로, 소프트웨어 개발 과정에서 발생하는 결합도와 의존성 문제를 해결하는 데 중점을 둡니다. 2004년, 마이클 페더스(Michael Feathers)가 이 원칙들의 앞글자를 조합해 “SOLID”라는 이름을 만들었다.
이 원칙들은 객체지향 설계와 디자인 패턴의 기초를 이루며, 변화와 확장을 대비하는 견고한 소프트웨어를 만드는 데 필수적인 지침으로 자리 잡았고 현재는 다양한 언어와 프레임워크(예: 스프링, .NET)에서 자연스럽게 적용되고 있다.
SOLID 원칙은 객체지향 프로그래밍의 설계 원칙으로 스프링 프레임워크(Spring Framework)는 이 SOLID 원칙을 실현하기에 적합한 구조와 기능을 제공한다. 두 개념은 결합도가 낮고 유연한 설계를 목표로 하며, 스프링의 주요 특징인 DI(Dependency Injection)와 AOP(Aspect-Oriented Programming)는 SOLID 원칙을 구현하는 데 중요한 역할을 한다.
SOLID 다섯 가지 원칙
S - 단일 책임 원칙 (SRP)
클래스는 하나의 책임만 가져야 하며, 변경의 이유가 하나뿐이어야 한다.
O - 개방-폐쇄 원칙 (OCP)
소프트웨어는 확장에 열려 있고, 수정에는 닫혀 있어야 한다. 즉, 기존 코드를 변경하지 않고 기능을 확장할 수 있어야 한다.
L - 리스코프 치환 원칙 (LSP)
서브클래스는 언제나 자신의 부모 클래스 타입으로 대체 가능해야 하며, 부모 클래스의 행동을 위반하지 않아야 한다.
I - 인터페이스 분리 원칙 (ISP)
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 하며, 인터페이스는 작고 구체적으로 분리해야 한다.
D - 의존 역전 원칙 (DIP)
고수준 모듈은 저수준 모듈에 의존하지 말고, 둘 다 추상화에 의존해야 한다.
1. 단일 책임 원칙 (SRP: Single Responsibility Principle)
단일 책임 원칙(SRP)
은 객체지향 설계의 중요한 원칙 중 하나로, “하나의 클래스는 하나의 책임만 가져야 한다
“는 개념을 말한다. 클래스는 그 책임을 완전히 캡슐화해야 함을 말한다. 한 클래스가 너무 많은 역할을 담당하지 않도록 설계하여 응집도를 높이고 결합도를 낮게 설계해서 변경의 영향을 최소화하는 것이 목표이다.
여기서 책임
이란 “클래스가 바뀌어야 할 이유”를 의미한다. 즉, 클래스가 변경되는 이유가 오직 하나여야 한다
는 뜻이다. 하나의 클래스에 많은 책임이 있을 경우에는 클래스의 책임을 분리
하여 각 클래스가 하나의 책임에만 집중하도록 설계를 다시 고려해야 한다.
1-2. 단일 책임 원칙(SRP) 책임 분리
사용자 정보를 처리하는 클래스를 설계했는데, 이 클래스가 사용자 인증, 사용자 데이터 검증, 데이터베이스 작업 등을 모두 처리하고 있다.
이 클래스는 사용자 데이터 검증, 인증, 데이터베이스 저장 등 모두를 담당하고 있기에 많은 책임
을 가지고 있다. 그래서 특정 기능이나 데이터 처리 방식이 변경되면 UserManager 클래스도 수정해야 하는 경우 변경되는 이유가 오직 하나가 아닌 상태
로 변경에 취약해진다. 변경시에 단일 책임이 아닌 클래스를 테스트하려면 각기 다른 책임(인증, 저장, 검증 등)을 모두 다시 해야하게 된다.
1) 단일 책임 원칙(SRP) 위반 코드
public class UserManager {
public void registerUser(String username, String password) {
if (username == null || password == null) {
throw new IllegalArgumentException("Username or password cannot be null");
}
if (password.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters long");
}
saveToDatabase(username, password);
}
public boolean authenticateUser(String username, String password) {
// 인증 로직 (DB에서 조회)
System.out.println("Authenticating user...");
return true; // 간단히 인증 성공 가정
}
private void saveToDatabase(String username, String password) {
// DB 저장 로직
System.out.println("User saved to database");
}
}
2) 단일 책임 원칙(SRP) 적용
UserManager
클래스에 많은 책임 있다. 단일 책임 원칙을 위반하기에 책임을 분리
하여 각 클래스들이 하나의 책임에만 집중하도록 설계를 다시 고려해야 한다.
- ① UserValidator: 사용자 입력을 검증하는 책임
- ② UserAuthenticator: 사용자 인증을 처리하는 책임
- ③ UserRepository: 데이터베이스와 상호작용하는 책임
- ④ UserService: 위의 컴포넌트를 조합하여 최종적인 사용자 관리 로직을 담당
① 입력 검증
public class UserValidator {
public void validate(String username, String password) {
if (username == null || password == null) {
throw new IllegalArgumentException("Username or password cannot be null");
}
if (password.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters long");
}
}
}
② 인증 처리
public class UserAuthenticator {
public boolean authenticate(String username, String password) {
// 간단히 인증 로직 가정
System.out.println("Authenticating user...");
return "validUser".equals(username) && "securePassword".equals(password);
}
}
③ 데이터베이스 저장
public class UserRepository {
public void save(String username, String password) {
// 데이터베이스 저장 로직
System.out.println("User saved to database");
}
}
④ 최종 사용자 관리 서비스
public class UserService {
private final UserValidator validator;
private final UserAuthenticator authenticator;
private final UserRepository repository;
public UserService(UserValidator validator, UserAuthenticator authenticator, UserRepository repository) {
this.validator = validator;
this.authenticator = authenticator;
this.repository = repository;
}
public void registerUser(String username, String password) {
validator.validate(username, password);
repository.save(username, password);
}
public boolean login(String username, String password) {
return authenticator.authenticate(username, password);
}
}
단일 책임 원칙(SRP) 준수한 서비스
public class Main {
public static void main(String[] args) {
UserValidator validator = new UserValidator();
UserAuthenticator authenticator = new UserAuthenticator();
UserRepository repository = new UserRepository();
UserService userService = new UserService(validator, authenticator, repository);
// 사용자 등록
userService.registerUser("validUser", "securePassword");
// 사용자 로그인
boolean isAuthenticated = userService.login("validUser", "securePassword");
System.out.println("Authentication successful: " + isAuthenticated);
}
}
UserValidator
는 검증만, UserAuthenticator
는 인증만, UserRepository
는 데이터 저장만 담당하게 되면서 각 클래스들은 하나의 이유로만 변경
될 수 있다. 하나의 책임이 명확해지면서 각 클래스는 독립적으로 테스트할 수 있게 되었다.
스프링에서는 위와 같은 클래스를 각각 Bean으로 등록해서 컴포넌트를 분리하면 스프링의 의존성 주입(DI)을 통해 컴포넌트 간의 결합도를 낮추며 스프링 컨테이너에 위임하게 되면, UserService
는 각각의 책임을 가진 컴포넌트를 주입받아 사용하므로 SRP를 쉽게 준수할 수 있게 된다.
스프링에서는 계층화된 구조와 의존성 주입을 통해 SRP를 적용된 것을 알 수 있다. 스프링 애플리케이션에서 단일 책임 원칙을 이용해 서비스 계층 분리를 통해 구현되어있다.
- Controller: 사용자 요청을 처리하고 응답을 반환하는 책임
- Service: 비즈니스 로직을 처리하는 책임
- Repository: 데이터베이스 커넥션 담당하는 책임
그 외로 AOP나 Transaction을 통해 비즈니스 로직으로부터 책임을 분리해서 특정 클래스들에게 하나의 책임을 주고 있다.
2. 개방-폐쇄 원칙 (OCP: Open-Closed Principle)
개방-폐쇄 원칙(OCP)
은 “확장에는 열려 있고, 수정에는 닫혀 있어야 한다
“는 개념이다. 즉, 기존 코드를 수정하지 않고도 애플리케이션의 기능을 확장할 수 있어야 한다는 것을 의미한다. 이를 통해 코드의 안정성을 유지하고, 변화와 요구사항에 유연하게 대처할 수 있다.
2-1. 개방-폐쇄 원칙(OCP) 확장 제공
새로운 기능이 추가될 때 기존 코드를 수정하지 않고 새로운 코드를 추가함으로써 확장을 제공
한다. 기존 코드를 수정하면 예상치 못한 오류가 발생할 가능성이 높아진다. OCP를 따르면 기존 코드는 안정적으로 유지되고, 수정 대신 확장으로 문제를 해결
한다. OCP를 달성하기 위해 인터페이스나 추상 클래스를 사용
하여 구현과 의존성을 분리해서 새로운 요구사항은 기존 코드와 독립적으로 작성할 수 있다.
이커머스에서 할인 정책을 처리하는 아래와 같은 코드가 있다. 현재 VIP 사용자와 일반 사용자의 할인율을 처리한다. 이후 구독 할인 정책을 추가해야 할 경우 기존 클래스를 수정해야 한다. 그러면 기존 코드를 수정하면 예상치 못한 오류가 발생할 가능성이 높아진다.
1) 개방-폐쇄 원칙(OCP) 위반 코드
public class DiscountService {
public double calculateDiscount(String userType, double price) {
if (userType.equals("VIP")) {
return price * 0.2;
} else if (userType.equals("Regular")) {
return price * 0.1;
}
return price; // 할인 없음
}
}
2) 개방-폐쇄 원칙(OCP) 적용
DiscountService 클래스에는 할인 정책을 추가하려면 기존 코드에 분기처리를 추가해야 한다. 그러다 보면 if문이 의도치 않게 많아지면서 유지보수가 어려워지면서 새로운 요구사항 대응이 어려워진다.
확장에 취약
하기 때문에 코드가 변경될 때마다 새로운 조건이 추가되므로 가독성이 떨어지고 테스트가 복잡해진다.
각 할인 정책을 별도의 클래스로 분리
하고, 인터페이스를 통해 확장 가능
하도록 설계하고 새로운 할인 정책을 추가할 때 기존 코드를 수정하지 않고 새로운 클래스를 추가해서 개선하면 된다.
① 할인 정책 인터페이스 정의
public interface DiscountPolicy {
double calculateDiscount(double price);
}
② 할인 정책 따라 클래스 구현
public class VIPDiscount implements DiscountPolicy {
@Override
public double calculateDiscount(double price) {
return price * 0.2;
}
}
public class RegularDiscount implements DiscountPolicy {
@Override
public double calculateDiscount(double price) {
return price * 0.1;
}
}
public class SubscribeDiscount implements DiscountPolicy {
@Override
public double calculateDiscount(double price) {
return price * 0.5;
}
}
③ 할인 정책 서비스 클래스
public class DiscountService {
private final DiscountPolicy discountPolicy;
public DiscountService(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
public double applyDiscount(double price) {
return discountPolicy.calculateDiscount(price);
}
}
④ 개발-폐쇄 원칙(OCP) 준수한 서비스
public class Main {
public static void main(String[] args) {
DiscountService vipService = new DiscountService(new VIPDiscount());
DiscountService regularService = new DiscountService(new RegularDiscount());
DiscountService subscribeService = new DiscountService(new SubscribeDiscount());
System.out.println("VIP Discount: " + vipService.applyDiscount(100.0));
System.out.println("Regular Discount: " + regularService.applyDiscount(100.0));
System.out.println("Subscribe Discount: " + subscribeService.applyDiscount(100.0));
}
}
요청사항 및 세부적인 할인 정책 비즈니스 로직 수정으로 할인 정책 서비스 DiscountService
클래스는 변경되지 않고 각 할인 정책 클래스들이 수정이 되며, 캡슐화
를 통해 여러 객체에서 applyDiscount()
메서드 호출을 통해 쉽게 확장이 가능하다. 이를 통해 할인 정책 서비스의 기존 코드의 안정성이 유지가 된다.
스프링에서는 각 할인 정책 클래스들을 Bean으로 등록하고 DiscountPolicy
할인 정책 클래스를 통해 스프링의 의존성 주입(DI; Dependency Injection)
을 통해 정책 구현체를 런타임에 주입한다. 이를 통해 높은 확장성과 낮은 결합도를 실현할 수 있습니다.
개방-폐쇄 원칙은 기존 코드를 안정적으로 유지하면서 새로운 기능을 추가할 수 있도록 설계하는 데 도움을 준다. 스프링 프레임워크는 DI, Bean, AOP 등을 통해 개발-폐쇄 원칙(OCP)를 자연스럽게 준수할 수 있는 환경을 제공한다.
3. 리스코프 치환 원칙 (LSP: Liskov Substitution Principle)
리스코프 치환 원칙(LSP)
은 “서브클래스는 언제나 자신의 기반 클래스(Base Class) 타입으로 대체될 수 있어야 한다
“는 원칙이다. 이 원칙은 상속 관계에서 부모 클래스의 기능을 확장하거나 변경하지 않고 서브클래스를 사용하는 경우에도 프로그램이 정상적으로 동작해야 한다는 것을 의미합니다. 즉, 부모클래스와 자식 클래스 사이의 행위가 일관성이 있어야 한다.
3-1. 리스코프 치환 원칙(LSP) 확장 제공
자식클래스는 부모 클래스의 동작을 보장해야 하며, 부모 클래스 타입으로 교체되더라도 시스템의 동작이 일관성을 유지해야 합니다. 자식클래스는 부모 클래스가 제공하는 메서드의 동작을 변경하거나 부모 클래스에서 기대하는 반환타입을 변경하면 안된다. 즉, 상속 관계에서는 자식클래스는 반드시 부모 클래스의 속성과 동작을 포함하는 "특수화된" 객체여야 한다.
- 자식 클래스는 최소한 부모 클래스에서 가능한 행위를 수행할 수 있어야 한다.
- 자식 클래스는 부모 클래스의 역할을 대체할 수 있어야 한다.
클래스들의 행위는 일관
된다. - 자식 클래스는 부모 클래스의 책임을 무시하거나 재정의하지 않고, 확장만 수행한다.
리스코프 치환 원칙(LSP)을 준수하면 상속 구조에서 부모-자식 클래스 간의 결합도를 낮출 수 있다.
1) 리스코프 치환 원칙(LSP) 위반 코드
Bird(새)라는 부모 클래스와 이를 상속받는 Eagle(독수리)와 Penguin(펭귄) 자식 클래스를 설계시 새는 날 수 있다는 공통 기능을 가지므로 fly 메서드를 부모 클래스에 정의한다. Penguin은 Bird의 서브클래스지만, fly 메서드를 호출하면 예외를 발생시킨다. 이로 인해 프로그램은 Bird 타입의 객체를 다룰 때 예외 처리를 강제받아야 하며, 코드의 일관성이 깨진다.
public class Bird {
public void fly() {
System.out.println("I can fly!");
}
}
public class Eagle extends Bird {
@Override
public void fly() {
System.out.println("Eagle flies high!");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguin cannot fly!");
}
}
public class Main {
public static void main(String[] args) {
Bird eagle = new Eagle();
eagle.fly(); // 출력: Eagle flies high!
Bird penguin = new Penguin();
penguin.fly(); // UnsupportedOperationException 발생
}
}
2) 리스코프 치환 원칙(LSP) 적용
- 자식 클래스는 최소한 부모 클래스에서 가능한 행위를 수행할 수 있어야 한다.
- 자식 클래스는 부모 클래스의 역할을 대체할 수 있어야 한다.
클래스들의 행위는 일관
된다. - 자식 클래스는 부모 클래스의 책임을 무시하거나 재정의하지 않고, 확장만 수행한다.
새는 모두 날 수 있는 것이 아니라는 점을 고려하여, Flyable이라는 별도의 인터페이스를 만들어 “날 수 있는 새”와 “날 수 없는 새”를 구분합니다. 이렇게 하면 Penguin은 불필요한 fly() 메서드를 구현하지 않아도 된다. 날던 못날던 모든 새들은 먹기 때문에 먹는 메서드 eat()에 대해서는 일관된다.
① 공통 인터페이스 정의
public interface Flyable {
void fly();
}
② 새의 기본 속성을 정의하는 클래스
public abstract class Bird {
public abstract void eat(); // 모든 새는 먹을 수 있음
}
③ 날 수 있는 새와 날 수 없는 새의 구분
public class Eagle extends Bird implements Flyable {
@Override
public void eat() {
System.out.println("Eagle eats small animals.");
}
@Override
public void fly() {
System.out.println("Eagle flies high!");
}
}
public class Penguin extends Bird {
@Override
public void eat() {
System.out.println("Penguin eats fish.");
}
}
④ 자식 클래스는 최소한 부모 클래스에서 가능한 행위를 수행
public class Main {
public static void main(String[] args) {
Bird eagle = new Eagle();
eagle.eat(); // Eagle eats small animals.
Flyable flyingEagle = new Eagle();
flyingEagle.fly(); // Eagle flies high!
Bird penguin = new Penguin();
penguin.eat(); // Penguin eats fish.
}
}
인터페이스로 작업(Work)과 식사(Eat)를 분리하여, 펭귄과 독수리는 상속한 인터페이스의 관련 메서드만 구현하도록 설계한 모습이다.
interface Flyable {
void fly();
}
interface Eatable {
void eat();
}
class Penguin implements Eatable {
public void eat() { //먹기
}
}
class Eagle implements Flyable, Eatable {
public void fly() { //날기
}
public void eat() { //먹기
}
}
- 인터페이스 기반 설계
- 스프링의 의존성 주입은 인터페이스를 기반으로 동작하므로, 부모 타입을 활용하여 다양한 구현체를 교체할 수 있다.
- 이로써 LSP를 자연스럽게 준수하게 된다.
- 테스트 용이성
- LSP를 준수하면 Mock 객체를 활용한 단위 테스트가 간단해진다.
- 결합도 감소
- 부모 클래스 또는 인터페이스 타입에 의존함으로써 구현체 교체가 자유롭다.
- 새로운 요구사항에 따라 구체적인 구현체를 추가해도 기존 코드를 수정하지 않아도 된다.
리스코프 치환 원칙(LSP)은 상속 관계에서 자식클래스가 부모 클래스를 완전히 대체할 수 있도록 설계하는 것
을 목표로 한다. 이를 준수하면 코드의 안정성과 일관성이 유지되며, 스프링의 DI와 인터페이스 기반 설계는 LSP 준수를 자연스럽게 지원합니다. 올바른 상속
과 `추상화를 활용하여 설계를 개선하면 변화에 유연한 시스템을 구축할 수 있다.
4. 인터페이스 분리 원칙 (ISP: Interface Segregation Principle)
인터페이스 분리 원칙(ISP)
은 “클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다
“는 원칙이다. 이 원칙은 큰 인터페이스를 여러 개의 작은 인터페이스로 분리하여, 각 클라이언트가 자신에게 필요한 기능만 제공받을 수 있도록 설계
하는 것을 목표로 한다.
- 작고 명확한 인터페이스:
- 인터페이스는 클라이언트의 특정 요구사항에 맞도록 작고 단순하게 설계해야 한다.
- 여러 역할을 포함하는 거대한 인터페이스는 클라이언트를 불필요한 메서드에 의존하게 만들어 유지보수성을 떨어뜨린다.
- SRP와의 연관성:
- 인터페이스 분리 원칙은 단일 책임 원칙(SRP)과 연관된다.
- 하나의 인터페이스는 하나의 역할에 집중해야 한다.
- 의존성 최소화:
- 클라이언트가 필요하지 않은 기능에 의존하지 않도록 설계함으로써 결합도를 낮추고, 코드의 유연성을 높인다.
1) 인터페이스 분리 원칙(ISP) 위반 코드
복합기(Multifunction Device)는 여러 기능(복사, 스캔, 팩스)을 제공합니다. 복합기 예제로 복합기 클래스가 단일 MultifunctionDevice 인터페이스를 구현하도록 설계했다.
BasicPrinter는 print 기능만 제공하지만, scan과 fax 메서드를 구현해야 한다. 사용하지 않는 기능에 대한 구현은 의미가 없으며, 클라이언트도 이를 호출하지 못하게 해서 불필요한 의존성을 없애야 한다.
새로운 복합기가 추가될 때마다 사용하지 않는 기능을 계속 구현하는 구조로 확장성이 부족
하다.
public interface MultifunctionDevice {
void print(String content);
void scan(String content);
void fax(String content);
}
public class BasicPrinter implements MultifunctionDevice {
@Override
public void print(String content) {
System.out.println("Printing: " + content);
}
@Override
public void scan(String content) {
throw new UnsupportedOperationException("Scan not supported");
}
@Override
public void fax(String content) {
throw new UnsupportedOperationException("Fax not supported");
}
}
2) 인터페이스 분리 원칙(ISP) 적용
각 기능(프린트, 스캔, 팩스)을 별도의 인터페이스로 분리하여, 복합기나 단일 기능 장치가 필요한 기능만 구현하도록 설계를 해야한다.
① 기능별 인터페이스 정의
public interface Printer {
void print(String content);
}
public interface Scanner {
void scan(String content);
}
public interface Fax {
void fax(String content);
}
② 단일 기능 장치 클래스 구현
//기본 프린터
public class BasicPrinter implements Printer {
@Override
public void print(String content) {
System.out.println("Printing: " + content);
}
}
//기본 스캐너
public class BasicScanner implements Scanner {
@Override
public void scan(String content) {
System.out.println("Scanning: " + content);
}
}
//기본 팩스
public class BasicFax implements Fax {
@Override
public void fax(String content) {
System.out.println("Faxing: " + content);
}
}
③ 복합기 (프린트, 스캔, 팩스 가능)
public class AdvancedMultifunctionDevice implements Printer, Scanner, Fax {
@Override
public void print(String content) {
System.out.println("Printing: " + content);
}
@Override
public void scan(String content) {
System.out.println("Scanning: " + content);
}
@Override
public void fax(String content) {
System.out.println("Faxing: " + content);
}
}
④ 클라이언트는 필요한 인터페이스만 의존하여 사용
public class Main {
public static void main(String[] args) {
Printer printer = new BasicPrinter();
printer.print("Document 1");
Scanner scanner = new BasicScanner();
scanner.scan("Document 2");
AdvancedMultifunctionDevice mfd = new AdvancedMultifunctionDevice();
mfd.print("Document 3");
mfd.scan("Document 4");
mfd.fax("Document 5");
}
}
인터페이스 분리 원칙(ISP) 준수의 결과로 BasicPrinter는 이제 프린트 기능만 구현하므로 scan과 fax를 강제로 구현하지 않아도 된다. 새로운 기능이 추가되더라도 기존 클래스에는 영향을 미치지 않기에 확장성이 강화되었다. 여기서 예로 이메일 전송 기능을 추가하려면 새로운 EmailSender 인터페이스를 정의하면 된다. 이렇게 클라이언트가 자신이 필요로 하지 않는 메서드에 의존하지 않도록 설계되기에 클라이언트가 명확해진다.
스프링 프레임워크는 ISP를 자연스럽게 준수할 수 있는 구조를 제공한다. 스프링에서 인터페이스와 의존성 주입(DI)을 활용하여 특정 구현체에만 필요한 기능을 정의하고 사용할 수 있다. 스프링은 인터페이스와 의존성 주입을 활용하여 ISP를 자연스럽게 준수할 수 있는 환경을 제공한다. ISP를 올바르게 적용하면 변화에 강하고 안정적인 소프트웨어를 설계할 수 있다.
5. 의존 역전 원칙 (DIP: Dependency Inversion Principle)
의존 역전 원칙(DIP)
은 고수준 모듈(High-Level Module)은 저수준 모듈(Low-Level Module)에 의존해서는 안 되며, 둘 다 추상화(Abstraction)에 의존해야 한다
는 원칙이다. 이 원칙은 코드의 결합도를 낮추고, 변화와 확장에 강한 시스템을 설계할 수 있도록 도와준다. DIP는 주로 인터페이스 또는 추상 클래스를 활용하여 의존성을 분리하고, 고수준 모듈과 저수준 모듈 간의 직접적인 결합을 제거한다.
- 고수준 모듈:
- 애플리케이션의 비즈니스 로직 또는 주요 기능을 구현한 부분
- 예: 서비스 계층, 컨트롤러
- 저수준 모듈:
- 데이터베이스, 네트워크, 파일 시스템과 같은 세부 구현을 담당하는 부분
- 예: DAO(Data Access Object), 저장소 클래스
- 추상화:
- 고수준 모듈과 저수준 모듈 사이의 중간 계층으로, 인터페이스나 추상 클래스를 활용하여 동작의 규약을 정의
1) 의존 역전 원칙 (DIP) 위반 코드
애플리케이션에서 이메일 알림을 보내기 위해 EmailService를 사용한다고 가정시에 알림을 보내는 로직을 Notification 클래스에 직접 작성하면 EmailService에 의존하게 된다.
Notification 클래스는 구체적인 구현체(EmailService)에 직접 의존하고 있다. 만약 이메일 대신 SMS 또는 푸시 알림으로 변경하려면, Notification 클래스도 수정해야 한다. 새로운 알림 방식을 추가하려면 확장성 부족하므로 기존 코드를 변경해야 하므로 개방-폐쇄 원칙(OCP)
도 위배되게 된다.
public class EmailService {
public void sendEmail(String message) {
System.out.println("Sending email: " + message);
}
}
public class Notification {
private final EmailService emailService;
public Notification() {
this.emailService = new EmailService(); // 직접 의존
}
public void notifyUser(String message) {
emailService.sendEmail(message);
}
}
1) 의존 역전 원칙 (DIP) 적용
알림 서비스를 추상화(MessageService
)로 분리하여, Notification
클래스가 구체적인 구현체가 아닌 추상화에 의존하도록 설계를 한다. 새로운 알림 방식을 추가할 때도 기존 코드를 수정하지 않아도 된다.
Notification 클래스는 구체적인 구현체가 아닌 MessageService 인터페이스에 의존
하므로 DIP
를 준수한다.(의존성 역전)
새로운 알림 방식을 추가할 때 MessageService
를 구현한 새로운 클래스를 작성하기만 하면 되기에 확장도 용이
하다.
① 공통 인터페이스 정의
public interface MessageService {
void sendMessage(String message);
}
② 구체적인 구현 클래스
public class EmailService implements MessageService {
@Override
public void sendMessage(String message) {
System.out.println("Sending email: " + message);
}
}
public class SmsService implements MessageService {
@Override
public void sendMessage(String message) {
System.out.println("Sending SMS: " + message);
}
}
③ 고수준 모듈이 추상화에 의존하도록 설계
public class Notification {
private final MessageService messageService;
// 생성자 주입으로 의존성 전달
public Notification(MessageService messageService) {
this.messageService = messageService;
}
public void notifyUser(String message) {
messageService.sendMessage(message);
}
}
④ 의존성 역전을 이용한 서비스 사용
public class Main {
public static void main(String[] args) {
MessageService emailService = new EmailService();
Notification emailNotification = new Notification(emailService);
emailNotification.notifyUser("Email sended Noti !");
MessageService smsService = new SmsService();
Notification smsNotification = new Notification(smsService);
smsNotification.notifyUser("SMS sended Noti !");
}
}
◼︎ 스프링에서 DIP
스프링 프레임워크는 의존성 주입(Dependency Injection, DI)
을 통해 DIP를 자연스럽게 준수하도록 지원한다. 스프링 컨테이너는 인터페이스를 구현한 구체적인 빈(Bean)을 관리하며, 이를 필요한 곳에 주입(Injection)한다.
- 의존성 관리:
- 스프링은 인터페이스를 기반으로 빈을 관리하므로, DIP를 손쉽게 준수할 수 있다.
- 애플리케이션이 특정 구현체에 의존하지 않으므로 유연성과 확장성이 높다.
- DI(Dependency Injection):
- 생성자 주입 또는 필드 주입을 통해 고수준 모듈이 저수준 모듈에 직접 의존하지 않도록 설계할 수 있다.
- 구성의 유연성:
@Qualifier
를 사용하여 필요한 구현체를 선택하거나, 프로파일별로 다른 구현체를 사용할 수 있다.
- 테스트 가능성:
- Mock 또는 Stub 구현체를 활용하여 고수준 모듈의 동작을 독립적으로 테스트할 수 있다.
의존 역전 원칙(DIP)은 고수준 모듈과 저수준 모듈 간의 결합도를 낮추고, 확장성을 높이는 설계 원칙이다.. 스프링의 DI 기능은 DIP를 준수하기 위한 강력한 도구를 제공하며, 이를 통해 변화에 유연하고 유지보수가 용이한 시스템을 구축할 수 있다.