SOLID 원칙 :: 관심사의 분리와 DI컨테이너
참고 - 2023.09.25 - [Java] - 객체 지향 설계의 기본 :: 역할과 구현의 분리
SOLID 원칙
SOLID원칙이란 좋은 객체 지향 프로그래밍을 설계하기 위한 기본 원칙으로, 여러 소프트웨어 엔지니어들과 클린 코드로 유명한 로버트 마틴이 제안한 개념이다.
이 원칙은 객체 지향 프로그래밍에서 유지보수성이나 확장성, 재사용성 등 다양한 측면에서 효율적인 시스템을 설계할 수 있도록 지침을 제공하는 중요한 개념이다.
SOLID는 다음과 같이 다섯 가지 원칙으로 구성된다.
1. 단일 책임 원칙 - SRP(Single Responibility Principle)
SRP원칙은 하나의 객체는 하나의 책임만 가져야 함을 의미한다. 여기서 책임이란, 클래스가 프로그램 내에서 수행하는 역할 또는 기능이라고 말할 수 있으며, 클래스가 맡은 역할이 많을수록 변경에 대한 영향력이 커질 수 있기 때문에 단일 역할만 가져야 한다는 것을 말하는 개념이다. 즉, 변경이 있을 때 그에 따른 파급 효과가 적다면 단일 책임 원칙을 잘 따른 것이라고 할 수 있다.
2. 개방-폐쇄 원칙 - OCP(Open/closed principle)
OCP 원칙은 소프트웨어 엔티티(요소: 클래스, 모듈, 함수 등)는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다는 것을 말한다. 내용을 보면 모순처럼 느껴질 수 있지만, 이 원칙은 앞서 서술한 역할과 구현의 분리 개념과 관련이 깊다.
*참고* - 2023.09.25 - [Java] - 객체 지향 설계의 기본 :: 역할과 구현의 분리 |
확장
하나의 역할(인터페이스)을 정의하면 새로운 구현클래스를 추가할 수 있다.
변경
역할과 구현이 잘 분리되어 있다면, 역할이나 다른 구현 클래스를 변경할 필요 없다.
3. 리스코프 치환 원칙- LSP(Liskov substution principle)
LSP 원칙은, 프로그램의 객체는 정확성을 깨지 않으면서 하위 타입 인스턴스로 바꿀 수 있어야 한다"를 말한다. 즉, 구현 클래스는 역할(인터페이스)의 의도대로 완전하게 수행할 수 있는 상태이어야 한다는 말이다. 이것은 단순히 컴파일에 성공하는 것을 넘어서, 구현은 역할에 충실해야 프로그램의 일관성이 보장된다는 의미다.
4. 인터페이스 분리 원칙 - ISP(Interface segregation principle)
ISP 원칙은, 특정 클라이언트를 위한 하나의 범용 인터페이스보다 여러 개의 작은 단위의 인터페이스가 낫다라는 개념을 말한다.
아래 그림을 예로 자세히 알아보자.
위 그림에서 보이는 것처럼 큰 역할(동물 인터페이스)을 좀 더 작은 단위로 분리하면, 변경이 이루어질 때 파급효과가 적다. 예를들어, 개과 인터페이스에 변경이 있어도 고양이과 인터페이스에는 영향을 주지 않고, 마찬가지로 고양이과 인터페이스에 변경이 있을 때도 개과 인터페이스에는 영향이 없다.
이와 같이 ISP 원칙에 따라 인터페이스를 적절하게 분리하면, 인터페이스가 명확해지고, 대체 가능성이 높아진다.
5. 의존관계 역전 원칙 - DIP(Dependency inversion principle)
이 원칙은 "클라이언트 코드는 구체화에 의존하지 않고 추상화에 의존해야 한다" 라는 방법론을 제시하는 개념이며, 쉽게 말해 구현 클래스에 의존하지 않고, 인터페이스(역할)에 의존하라는 것을 말한다.
클라이언트 코드가 구현체가 아닌 역할에 의존적이면 유연하게 구현체를 변경할 수 있다.
SOLID의 모든 원칙을 준수하면서 클라이언트 코드를 설계할 수 있을까?
가능하지만, 다형성만으로는 모든 원칙을 지키기는 어렵다.
아래 예시 서비스를 통해 자세히 알아보자.
주문 도메인 흐름
- 주문 생성: 클라이언트는 주문 서비스에 주문 생성을 요청한다. (회원 id, 상품명, 상품 가격)
- 회원 조회 : 할인 가능 여부 확인을 위해 주문 서비스는 회원 저장소에서 회원의 정보(등급)를 조회한다.
- 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환한다.
SOLID 원칙에 따라 주문 도메인을 다이어그램으로 표현하면 아래와 같다.
이를 코드로 표현하면 아래와 같다.
엔티티 - 회원, 주문
// 등급
enum Grade{
VIP,
BASIC
}
public class Client{ // 회원 엔티티
private Long id; // id
private String name; // 이름
private Grade grade; // 등급
// Constructor, getter, setter ....
}
public class Order { //주문 엔티티
private Long clientId; // 회원 아이디
private String itemName; // 상품명
private int itemPrice; // 상품 가격
private int discountPrice; // 할인 가격
public int calculatePrice(){ return itemPrice-discountPrice; }
// Constructor, getter, setter ....
}
인터페이스
인터페이스 (역할) - 주문 서비스, 회원 저장소, 할인 정책
// 회원 서비스 인터페이스
public interface ClientService{
void sign(Client client); // 가입
Client findClient(Long clientId); // 회원 조회
}
// 회원 저장소 인터페이스
public interface ClientRepository{
void save(Client client); // 클라이언트 정보 저장
Client loadId(Long clientId); // 클라이언트 아이디 반환
}
// 주문 서비스 인터페이스
public interface OrderService{
Order createOrder(Long clientId, String itemName, int itemPrice); // 주문 객체 생성
}
// 할인 정책 인터페이스
public interface DiscountPolicy{
int discount(Client client, int price); // 대상, 금액
}
구현체
회원 서비스, 저장소 - 구현체
// 회원 서비스 구현체
// 가입과 조회의 기능을 가짐
public class ClientServiceImpl implements ClientService{
private final ClientRepository clientRepository = new MemoryClientRepository;// 메모리 저장소 객체
@Override
public void sign(Client client) { // Client 파라미터로 받음.
clientRepository.save(client); // Client 객체가 저장
}
@Override
public Client findClient(Long clientId) { // client id를 받기 위한 파라미터
return ClientRepository.loadId(clientId); // 저장소에 해당 속성을 가진 객체를 반환
}
}
// 회원 저장소 구현체
public class MemoryClientRepository implements ClientRepository{
private static Map<Long, Client> store = new HashMap<>(); // id와 객체를 HashMap에 담은
@Override
public void save(Client client) { //Client 객체를 파라미터로 받음
store.put(client.getId(), client); // key는 id(getter로 불러옴), value는 객체를 저장
}
@Override
public Client loadId(Long clientId) { // id를 파라미터로 받음(map의 키로써 객체를 접근하기 위함)
return store.get(clientId); // id(key 값)으로 객체를 반환
}
}
주문 서비스 - 구현체
// 주문 서비스 구현체
public class OrderServiceImpl implements OrderService{
private final ClientRepository clientRepository new MemoryClientRepository; // 회원 메모리 저장소
private final DiscountPolicy discountPolicy = new FixDiscountPolicy; // 정액 할인 정책
@Override
public Order createOrder(Long clientId, String itemName, int itemPrice) { // 주문 생성
Client client = clientRepository.loadId(clientId); // 저장소 id를 찾아서 반환
int discountPrice = discountPolicy.discount(client, itemPrice); // 할인 메서드
return new Order(clientId, itemName, itemPrice, discountPrice); // Order 생성자에 값을 할당하고 반환
}
}
정액, 정률 할인 정책 - 구현체
// 정액 할인 정책 구현체
public class FixDiscountPolicy implements DiscountPolicy{
private int discountFixAmount = 5000; // 5000원 할인
@Override
public int discount(Client client, int price) { // client 객체와 상품 가격을 파라미터로 받음
if (client.getGrade() == Grade.VIP) { // client 객체의 등급이 VIP일 경우,
return discountFixAmount; // 할인 반환
}else {
return 0; // VIP가 아닌 경우 0 반환
}
}
}
public class RateDiscountPolicy implements DiscountPolicy{
private int discountPercent = 10; // 10퍼센트
@Override
public int discount(Client client, int price) {
if(client.getGrade() == Grade.VIP){ // 객체의 등급이 VIP인 경우,
return price * discountPercent / 100; // 상품* 10 / 100 = 상품가의 10퍼센트
} else {
return 0;
}
}
}
예시 코드는 최대한 SOLID 원칙에 따라 설계했으나, 정말로 모든 원칙이 다 만족하는 코드라고 말할 수 있을까?
우선, 역할과 구현 분리에 의해 단일 책임 원칙(SRP), 리스코프 치환 원칙(LSP) 그리고 인터페이스 분리 원칙(ISP)은 잘 적용된 것 처럼 보여진다.
그렇다면 개방-폐쇄 원칙과(OCP)과 의존관계 역전 원칙(DIP)은 어떨까?
코드를 객체 다이어그램으로 표현한 아래 그림을 통해 알아보자.
다이어그램에서 확인되는 것처럼, 회원 서비스는 저장소 구현체, 주문 서비스 저장소와 할인 정책 구현체에 의존하고 있다. 즉, 클라이언트 코드가 추상화에 의존하지 않고 구체화에 의존하고 있으므로, DIP에 위반된다. 또한 아래, 코드를 보면 OCP도 성립하지 않는다.
public class ClientServiceImpl implements ClientService{
// private final ClientRepository clientRepository = new MemoryClientRepository;// 메모리 저장소 객체
private final ClientRepository clientRepository = new DBClientRepository; // DB 저장소
}
// 주문 서비스 구현체
public class OrderServiceImpl implements OrderService{
//private final ClientRepository clientRepository = new MemoryClientRepository; // 회원 메모리 저장소
//private final DiscountPolicy discountPolicy = new FixDiscountPolicy; // 정액 할인 정책
// 클라이언트 코드 변경
private final ClientRepository clientRepository = new DBClientRepository; // DB 저장소
private final DiscountPolicy discountPolicy = new RateDiscountPolicy; // 정률 할인 정책
}
예시 코드를 보면 정책 변경 시에 클라이언트 코드를 변경해야하므로 OCP에 위반된다.(변경에는 닫혀있어야 함.)
그렇다면 어떻게 해야 SOLID의 모든 원칙을 만족하는 클라이언트 코드를 만들 수 있을까?
의존성 주입
첫 번째로 클라이언트 코드를 추상화에 의존하도록 만들어야 한다.
private final ClientRepository clientRepository; // 인터페이스(역할)- 추상화
private final DiscountPolicy discountPolicy;// 인터페이스(역할)- 추상화
이처럼 추상화에 의존하도록 설계하면, DIP에는 만족한다. 그러나 이대로 프로그램을 실행하면, 구현체가 없으므로 당연히 Null Pointer Exception이 발생한다.
이를 방지하기 위해서는 OrderServiceImpl에 ClientRepository와 DiscountPolicy의 구현 객체를 대신 생성하고 주입해주어야하는 역할이 필요하다.
이때, 필요한 것이 DI 컨테이너(설정자)이다.
*참고* 의존성 주입은 영어로 Dependency(의존성) Injection(주입)이며, 줄여서 흔히 DI라고 표현한다.
DI컨테이너는 애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성해서 연결하는 책임을 가진 설정자 같은 개념이다.
// 설정 클래스
public class AppConfig {
// 의존성 주입을 위해서만 사용되는 객체이므로 static
public static MemoryClientRepository clientRepository() { // 회원 저장소 객체 생성
return new MemoryclientRepository();
}
public ClientService clientService(){
return new ClientServiceImpl(clientRepository()); // 회원 서비스에 저장소 객체의 의존성을 주입해서 반환
}
public static DiscountPolicy discountPolicy() { // 할인 정책 객체 생성
return new RateDiscountPolicy();
}
public OrderService orderService(){
return new OrderServiceImpl(clientRepository(), discountPolicy()); // 저장소, 할인 의존성 주입
}
}
위 코드에서 보이는 것처럼 AppConfig라는 설정자 클래스에서 애플리케이션의 실제 동작에 필요한 구현 객체를 생성했다.
이제, AppConfig에서 생성한 객체 인스턴스의 참조를 연결하기 위해 클라이언트 코드에 생성자 만들어 주입시켜준다.
public OrderServiceImpl(ClientRepository clientRepository, DiscountPolicy discountPolicy) {
this.clientRepository = clientRepository;
this.discountPolicy = discountPolicy;
}
public ClientServiceImpl(ClientRepository clientRepository) {
this.clientRepository = clientRepository;
}
이처럼 설정자가 각 클라이언트 코드에 필요한 객체의 의존성을 대신 주입시키기 때문에, 각 클라이언트 코드는 추상화에만 의존하게 되었다.
코드를 다이어그램으로 표현하면 다음과 같다.
그림에서 보이는 것처럼 애플리케이션의 전반적인 부분을 AppConfig에서 구성하므로, 변경이 필요할 때 구성 영역(AppConfig)만 영향을 받고, 사용 영역에서는 전혀 영향을 받지 않는다.
지금까지 순수 Java 코드만으로 SOLID원칙에 따른 객체 지향 설계 방법과 DI컨테이너(AppConfig)의 역할에 대해 설명해보았다.
이 포스팅 글은 스프링의 핵심 원리를 이해하기 위한 준비 단계에 불과하다.
다음 이어지는 글은 앞서 살펴본 프로젝트를 스프링으로 전환하는 과정에 대해 서술할 예정이다.
출처 - 스프링 핵심 원리 이해 (김영한)