Search

Chapter 3. SOLID 원칙

생성일
2025/05/27 10:36
태그

단일 책임 원칙

클래스에 너무 많은 책임 지우지 말기

단일 책임 원칙(SRP, Single Response Principle)은 단 하나의 책임을 가진다는 의미이다.
객체지향 관점에서 SRP의 책임은 객체를 지칭하고, 따라서 객체는 단 하나의 책임만 가져야한다.
책임의 의미는 해야하는 것, 할 수 있는 것, 해야하는 것을 잘 할 수 있는 것 정도로 이해할 수 있다.
객체에 책임을 할당할 때는 작업을 가장 잘 수행할 수 있는 객체에 할당해야 한다.
우리가 설계 원칙을 학습하는 이유는 예측하지 못한 변경이 생기더라도 유연하고 확장성 있게 설계하는게 목적이다. 따라서 좋은 설계란 시스템에 요구사항이나 변경이 있을 때, 영향 받는 부분이 적은 설계하는 것이다.
객체가 책임을 많이 질수록, 내부에서 서로 다른 역할을 수행하는 코드끼리 강결합 가능성이 높다. 코드의 결합이 높으면 변경 사항이 생길 때 직간접적으로 사용하는 모든 코드를 다시 테스트 해야한다.(회귀 테스트) 이러한 회귀 테스트의 비용을 낮추는 방법은 변경사항 발생 시 영향 받는 부분을 줄이도록 설계하는 것이다.
책임 분리를 통해 하나의 클래스가 하나의 책임만 지도록 하고, 클래스의 변경 사유가 되는 이유는 하나로 만들어 설계한다.
Student의 예시로 이해하기
1.
Student 클래스가 수강 과목 조회/추가(데이터베이스 접근), 성적표 출력, 출석부 출력을 수행 → 너무 많은 책임
2.
Student가 변경될 수 있는 이유
데이터베이스 스키마 변경 시
학생이 지도 교수를 찾는 기능 추가
학생 정보를 성적표와 출력표 이외의 형식으로 출력
3.
Student의 책임 분리
데이터베이스 접근에 대한 책임 → 학생 DAO로 분리
출석부 출력 책임 → 출석부 객체로 분리
성적표 출력 책임 → 성적표 객체로 분리

책임을 여러 클래스에 나누지 말기

단일 책임 원칙은 하나의 클래스에 하나의 책임만 두는 것도 중요하지만, 반대로 하나의 책임을 하나의 클래스에 두는 것도 중요하다. 하나의 책임을 여러 클래스에 분산하여 설계하는 것도 단일 책임 원칙에 위배되며, 이런 경우를 산탄총 수술이라고불린다.
여러 클래스에 책임이 분산되면, 변경 사항이 발생했을 때 책임을 가진 모든 클래스를 찾아서 하나하나 변경해줘야하는 일이 생긴다. 이러한 대표적인 예시로 로깅, 보안, 트랜잭션과 같은 횡단 관심사 기능이 있다.
모든 비즈니스 로직마다 로그를 남기는 로직을 넣는다면, 로그에 수정사항이 발생했을 때 모든 비즈니스 로직을 수정해줘야한다. 이를 해결하는 방법은 이와 같은 부가 기능을 별개의 클래스로 분리해 책임을 담당하게 하는 것이다.
이와 같은 횡단 관심사 문제를 해결하는 방법으로는 AOP(Aspect-Oriented Programming)이 있다. AOP는 횡단 관심을 수행하는 코드를 Aspect라는 특별한 객체로 모듈화하고, 위빙(weaving)이라는 작업을 통해 모듈화한 코드를 핵심 기능에 끼워 넣는다.
AOP 용어 조인 포인트 : 애플리케이션의 특정 지점. 메서드 호출, 메서드 실행, 클래스 초기화 등의 시점이 대표적이다. 어드바이스 : 특정 조인포인트에서 실행되는 코드. 조인포인트 전후로 동작하는 Before 어드바이스, After 어드바이스 등 다양한 종류가 있다. 포인트컷 : 여러 조인포인트의 집합체로, 언제 어드바이스를 실행시킬지 정의. 애스펙트 : 어드바이스 + 포인트컷 위빙 : 포인트컷에 코드를 주입하는 과정. 컴파일 시점 위빙과 런타임 시점 위빙이 있다.

개방-폐쇄 원칙

확장에는 열려있고 수정에는 닫혀있는 설계

개방-폐쇄 원칙은 기존의 코드를 변경하지 않으며 기능을 추가할 수 있도록 설계가 되어야한다는 의미이다.
위의 성적표나 출석부를 출력하는 기능을, 특정 클라이언트에서 이용한다고 생각하면 아래와 같이 구성될 것이다.
이 상황에서 도서관 대여 명부와 같은 새로운 매체에 학생 대여 기록을 출력해야한다면? OCP를 위반하지않고(SomeClient를 수정하지않고) 구현하는 것은 어려울 것이다.
하지만 이처럼 인터페이스를 통해 캡슐화하여 처리한다면 OCP를 만족하도록 설계할 수 있다.
이런 설계는 추상화를 통한 다형성으로, 의존 관계를 역전시켜 A의 구현체에 변경이 생기거나 구현체가 추가되어도 클라이언트에는 영향을 받지 않게 한다.

OCP를 통한 좋은 테스트 작성하기

OCP의 또 다른 관점은 클래스를 변경하지 않고(Closed) 대상 클래스의 환경을 변경(Open) 할 수 있는 설계가 되어야 한다는 것이다.
이는 단위 테스트에서 중요한데, 특정 기능이 외부 서비스를 호출하는 경우 이를 테스트하려면 네트워크에 연결되거나 환경이 구축되어있어야만 테스트 할 수 있다. 하지만 외부 서비스를 흉내내는 가짜 객체를 만들어, 테스트의 특정 상태를 가상으로 만들 수 있게 된다.
테스트 더블 Dummy : 객체만 존재하고 내부 기능은 구현 X Stub : 정해진 응답만 반환하는 객체 Fake : 실제 기능을 대체하여 동작을 수행하는 객체 Mock : 호출 시 미리 정의한 기대 값을 반환 Spy : 실제와 동일한 기능을 수행하지만 메서드 호출을 기록

리스코프 치환 원칙

부모 클래스를 대체 가능하도록 설계하기

리스코프 치환 원칙(LSP, Liskov Substitution Principle)은 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있어야 한다는 의미이다. LSP를 만족하면 부모 클래스의 인스턴스 대신에 자식 클래스의 인스턴스로 대체해도 프로그램이 정상적으로 기능해야한다는 의미이다.
일반화 관계는 is a kind of 관계라고도 불리는데, 위 그림처럼 ‘원숭이 is a kind of 포유류’ 관계가 성립한다. 객체지향에서 is a kind of 관계는 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 변경 없이 그대로 사용할 수 있을 때 성립한다.
포유류는 알을 낳지 않고 새끼를 낳아 번식한다.
포유류는 젖을 먹여서 새끼를 키우고 폐를 통해 호흡한다.
포유류는 체온이 일정한 정온 동물이며 털이나 두까운 피부로 덮여 있다.
여기에 포유류 대신 원숭이를 넣어도 전혀 문제가 없다. 뿐만 아니라 포유류에 해당하는 다른 어떤 동물을 넣어도 성립한다.
public class Bag { private int price; public void setPrice(int price) { this.price = price; } public int getPrice() { return price; } }
Java
복사
위와 같이 Bag 클래스가 있을 때,
public class DiscountedBag extends Bag { private double discountdRate = 10; public void applyDiscount(int price) { super.setPrice(price - discountedRate * price); } }
Java
복사
Bag 클래스를 상속받는 DiscountedBag을 위와 같이 작성하면 Bag의 setPrice와 getPrice를 재정의하지 않았기 때문에 LSP를 만족한다고 볼 수 있다.
public class DiscountedBag extends Bag { private double discountdRate = 10; public void applyDiscount(int price) { super.setPrice(price - discountedRate * price); } public void setPrice(int price) { super.setPrice(price - discountedRate * price); } }
Java
복사
하지만 이와 같이 setPrice를 재정의한다면, Bag의 클래스의 행위와 일관되지 않으므로 LSP를 만족하지 않는다. 이는 피터 코드의 상속 규칙에서 “서브 클래스가 슈퍼 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행한다”는 규칙과도 통한다.

의존 역전 원칙

의존성 주입을 통한 의존 역전

의존 역전 원칙(DIP, Dependency Inversion Principle)은 객체 사이에 의존 관계가 발생할 때, 변화하기 쉬운 것 혹은 자주 변화하는 것보다 변화하기 어려운 것 또는 거의 변화가 없는 것에 의존하라는 원칙이다.
정책, 전략과 같은 큰 흐름이나 개념 같은 추상적인 것은 변하기 어려운 것에 해당하고, 구체적인 방식이나 사물 등과 같은 것은 변하기 쉬운 것으로 구분한다.
아이가 장난감을 가지고 노는 상황에서, 어떤 장남감을 가지고 놀 지는 그날그날 다를 것이다. 구체적인 장난감은 변하기 쉽고, 아이가 장난감을 가지고 노는 행위는 변하기 어렵다.
객체지향 관점에서 변하기 어려운 것을 인터페이스나 추상 클래스로 표현할 수 있다. DIP를 만족하기 위해 구체적인 클래스보다 인터페이스나 추상 클래스를 의존하도록 설계해야한다. 이를 통해 변화에 유연한 시스템을 만들 수 있다.
DIP를 만족하면 의존성 주입(DI, Dependency Injection)을 통해 변화에 열려있는 코드를 작성할 수 있다. 의존성 주입이란 클래스 외부에서 의존되는 것을 대상 객체의 인스턴스 변수에 주입하는 기술로,
public class Kid { private Toy toy; Kid(Toy toy) { this.toy = toy; } } public class Main { public static void main(String[] args) { Toy toy = new Robot(); // Toy toy = new Train(); // 외부에서 의존 클래스 변경 Kid k = new Kid(toy); } }
Java
복사
이처럼 의존하는 클래스에서 의존 대상 객체를 변경하기 않고도 외부에서 의존 객체를 변경할 수 있다.

인터페이스 분리 원칙

기능별로 인터페이스 분리하기

인터페이스 분리 원칙(ISP, Interface Segregation Principle)은 클라이언트 관점에서 클라이언트 자신이 이용하지 않는 기능에는 영향 받지 않게 설계해야 한다는 의미이다.
복합기를 예시로 들어보면 복합기는 프린트, 복사, 팩스 기능이 모두 가능하다.
이를 객체지향으로 나타냈다고 생각하면, 복합기 클래스는 매우 비대해질 가능성이 크다. 복합기 클래스의 모든 기능을 클라이언트가 동시에 사용하는 경우는 거의 없을 것이다. 때문에 각각의 기능이 서로에게 영향을 미치지 않도록 분리해야한다. 복사 기능을 수정했을 때 팩스 기능이 영향을 받지 않도록 해야한다는 의미이다.
복합기 클래스를 ISP를 적용해서 다시 설계해본다면,
이와 같이 설계 될 것이다. 이처럼 의존 관계를 역전 시켜 기능 변경의 영향이 없도록 하는 것이다. 추가적으로 이와 같이 설계해두면 팩스의 기능만 사용하는 클래스나 프린터의 기능만 사용하는 클래스가 생기더라도 기능에 대한 책임 분리를 용이하게 할 수 있다.