우아한테크코스 레벨 1에서 학습한 내용을 정리한 글입니다.
💭 들어가며
미션을 진행할 때마다 항상 "좋은 설계란 무엇일까?"라는 고민을 하게 된다. 그 물음에 대한 답을 찾기 위해 다양한 시도를 해보지만, 결국은 SOLID 원칙으로 귀결되곤 한다.
SOLID 원칙은 객체지향 설계에서 지켜야 할 다섯 가지 핵심 원칙을 말하며, 변화에 유연하고 유지보수가 쉬운 구조를 만드는 데 큰 도움을 준다. 궁극적으로 좋은 설계란 변경 사항이 생겼을 때 영향을 받는 범위가 최소화된 구조라고 생각한다. 이런 구조를 만들기 위해 SOLID 원칙은 좋은 기준점이 되어 준다.
- SRP (Single Responsibility Principle): 단일 책임 원칙
- OCP (Open Closed Principle): 개방 폐쇄 원칙
- LSP (Liskov Substitution Principle): 리스코프 치환 원칙
- ISP (Interface Segregation Principle): 인터페이스 분리 원칙
- DIP (Dependency Inversion Principle): 의존 역전 원칙
이번 글에서는 SOLID 원칙을 정리하고, 각 원칙을 잘 준수했는지 판단하는 본인의 간단한 공식까지 소개해 보려 한다.
이 원칙들은 각각 독립적인 개념처럼 보이지만, 실제로는 서로 긴밀하게 연결되어 있다.
✅ SRP(Single Responsibility Principle): 단일 책임 원칙
"한 클래스는 하나의 책임만 가져야 한다."
▶ 특징
- 여기서 '책임'이란 클래스가 담당하는 기능을 의미한다.
- 한 클래스가 여러 기능을 담당하고 있다면, 그중 하나의 기능만 변경되더라도 전체 코드에 영향을 미칠 수 있다.
- 단일 책임 원칙을 지키면 코드 변경의 여파를 줄일 수 있다.
- 다만 '하나의 책임'의 기준은 상황에 따라, 또는 개발자마다 다르게 해석될 수 있다.
▶ 판단 기준
"이 클래스를 수정해야 하는 이유는 몇 가지인가?"
수정해야 하는 이유가 두 가지 이상이면 SRP를 위반했다고 판단한다.
▶ 예시
🔽 SRP를 위반한 코드
class Order {
private List<String> items;
public Order(List<String> items) {
this.items = items;
}
// 주문 관련 책임: 물품 추가
public void addItem(String item) {
items.add(item);
}
// 주문 관련 책임: 물품 조회
public List<String> getItems() {
return items;
}
// 결제 관련 책임: 결제
public void pay() {
// 결제 처리 로직
System.out.println("결제 완료");
}
// 출력 관련 책임: 영수증 출력
public void printReceipt() {
// 영수증 출력 로직
System.out.println("영수증 출력 완료");
}
}
위 클래스는 수정이 필요한 이유가 세 가지 존재하므로, SRP를 위반한 구조이다.
- 주문 관리
- addItem(), getItems()
- 예: 물품을 다루는 방식이 변경되는 경우
- 결제 처리
- pay()
- 예: 결제 수단이 추가되거나 결제 방식이 변경되는 경우
- 영수증 출력
- printReceipt()
- 예: 영수증 출력 형식이 텍스트에서 PDF로 바뀌는 경우
🔽 SRP를 준수한 코드
class Order {
private List<String> items;
public Order(List<String> items) {
this.items = items;
}
// 주문 관련 책임: 물품 추가
public void addItem(String item) {
items.add(item);
}
// 주문 관련 책임: 물품 조회
public List<String> getItems() {
return items;
}
}
class Payment {
// 결제 관련 책임: 결제
public void pay(Order order) {
// 결제 처리 로직
System.out.println("결제 완료");
}
}
class Printer {
// 출력 관련 책임: 영수증 출력
public void printReceipt(Order order) {
// 영수증 출력 로직
System.out.println("영수증 출력 완료");
}
}
이제 Order는 오직 주문과 관련된 물품 관리만 책임지고, Payment는 결제 처리, Printer는 영수증 출력을 책임진다. 각 클래스의 책임이 명확히 분리되어 있어, 변경이 필요한 경우에도 해당 책임을 가진 클래스만 수정하면 되기 때문에 SRP를 준수하는 구조가 된다.
✅ OCP(Open Closed Principle): 개방 폐쇄 원칙
"확장에는 열려 있고, 변경에는 닫혀 있어야 한다."
▶ 특징
- 기능을 추가해야 할 때는 클래스를 확장해서 구현하고, 기존 코드는 수정하지 않도록 해야 한다는 원칙이다.
- 확장에 열려 있다: 새로운 기능이 필요할 때 코드를 추가하는 것이 쉽다.
- 변경에 닫혀 있다: 새로운 기능이 필요할 때 기존 코드를 수정하지 않는다.
- OCP 원칙은 추상화를 기반으로 한 상속, 다형성, 인터페이스 사용을 권장하는 원칙이다.
- 즉, if, else나 switch로 분기 처리하기보다는, 다형성을 활용해 새로운 기능을 유연하게 확장할 수 있도록 설계하는 것이다.
▶ 판단 기준
"새로운 기능을 추가할 때, 기존 코드를 수정해야 하는가?"
그렇다면 OCP를 위반했다고 판단한다.
▶ 예시
🔽 OCP를 위반한 코드
class DiscountCalculator {
public double calculateDiscount(String discountType, double price) {
if (discountType.equals("PROMOTION")) {
return price * 0.9;
} else if (discountType.equals("SEASONAL")) {
return price * 0.8;
} else {
return price;
}
}
}
새로운 할인 정책인 "크리스마스 할인"을 추가하려면 else if 문을 추가해야 한다. 즉, 기존 로직을 수정하지 않고는 확장이 불가능하므로 OCP 위반이다.
🔽 OCP를 준수한 코드
interface DiscountPolicy {
double applyDiscount(double price);
}
class PromotionDiscount implements DiscountPolicy {
@Override
public double applyDiscount(double price) {
return price * 0.9;
}
}
class SeasonalDiscount implements DiscountPolicy {
@Override
public double applyDiscount(double price) {
return price * 0.8;
}
}
class DiscountCalculator {
public double calculateDiscount(DiscountPolicy discountPolicy, double price) {
return discountPolicy.applyDiscount(price);
}
}
DiscountCalculator는 DiscountPolicy 인터페이스만 알고 있으며, 내부 로직을 수정할 필요 없이 새로운 정책 클래스를 주입받아 사용할 수 있다. 이제 새로운 할인 정책이 필요하다면 DiscountPolicy를 구현한 새로운 클래스를 추가하면 된다.
✅ LSP(Liskov Substitution Principle): 리스코프 치환 원칙
"하위 타입은 언제나 자신의 상위 타입으로 교체(치환)해도 프로그램의 정확성이 보장되어야 한다."
▶ 특징
- 상속 관계에서 자식 클래스가 부모 클래스의 행동을 위배하면 안 된다는 원칙이다.
- 쉽게 말해, 다형성을 제대로 사용하고 싶다면 반드시 지켜야 하는 조건이라고 볼 수 있다.
- 상위 타입으로 선언된 변수에 하위 타입의 인스턴스를 할당했을 때(업캐스팅), 기대한 동작이 그대로 수행되어야 한다는 의미다.
- 따라서 LSP를 지키기 위해서는 부모 클래스의 메서드를 오버라이딩할 때 각별히 주의해야 한다. 부모가 기대하는 규약을 깨뜨려서는 안 된다.
▶ 판단 기준
“자식 클래스를 부모 클래스처럼 사용해도 기대한 결과가 나오는가?”
그렇지 않다면 LSP를 위반했다고 판단한다.
개인적으로는 SOLID 원칙 중 가장 체화하기 어려웠던 원칙이었지만, 다음과 같은 상황을 조심하면 될 것 같다.
1. 자식 클래스가 부모 클래스의 행동 결과를 변경하거나 예상과 다른 값을 반환하는 경우
2. 자식 클래스가 부모에는 없던 예외를 던지는 경우
3. 자식 클래스가 부모보다 더 엄격한 제약 조건을 추가하는 경우
참고: 나만의 기억법
LSP는 "니코 분신술"이다. 부모 타입 안에 자식 객체를 넣는 순간 분신술을 쓴다고 보면 된다.
만약 분신의 행동과 니코의 행동이 같은지 의심이 된다면, 이미 LSP를 위반하고 있는 것이다. (분신인지 본체인지 구분할 필요 없이 동일하게 행동해야 하기 때문이다.)
▶ 예시
🔽 LSP를 위반한 코드: 직사각형/정사각형
class Rectangle {
protected double width;
protected double height;
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(double width) { // 부모의 의도와 다른 코드
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(double height) { // 부모의 의도와 다른 코드
super.setHeight(height);
super.setWidth(height);
}
}
Rectangle rectangle = new Square();
rectangle.setWidth(4);
rectangle.setHeight(5);
System.out.println(rectangle.getArea()); // 예상: 20.0, 실제: 25.0
Rectangle처럼 사용했지만, Square는 너비와 높이를 동시에 바꾸기 때문에 결과가 예상과 다르게 나온다. 사용자는 4 * 5 = 20을 기대했지만, 실제 결과는 5 * 5 = 25가 되어버린다. 부모 클래스의 규약을 자식 클래스가 깨버린 대표적인 사례로, LSP를 위반하고 있다.
🔽 LSP를 준수한 코드: 직사각형/정사각형 1️⃣ 상속 관계 제거
interface Shape {
double getArea();
}
class Rectangle implements Shape {
private double width;
private double height;
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
@Override
public double getArea() {
return width * height;
}
}
class Square implements Shape {
private double side;
public void setSide(double side) {
this.side = side;
}
@Override
public double getArea() {
return side * side;
}
}
Rectangle과 Square는 공통 인터페이스(Shape)만 구현하며, 상속 관계는 맺지 않는다. 각 도형의 규칙을 독립적으로 보장할 수 있어, LSP를 위반하지 않는다.
🔽 LSP를 준수한 코드: 직사각형/정사각형 2️⃣ 정사각형 생성을 Rectangle 내부로 제한
class Rectangle {
private double width;
private double height;
public static Rectangle createSquare(double side) {
return new Rectangle(side, side);
}
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
// ...
}
Square라는 클래스를 별도로 만들지 않고, 정사각형은 Rectangle 내부에서 생성하도록 제한한다. 정사각형을 별도로 구분할 필요가 없고, LSP도 위반하지 않는다.
🔽 LSP를 위반한 코드: 새/펭귄
class Bird {
public void fly() {
System.out.println("새는 난다.");
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 못 난다.");
}
}
Bird bird = new Penguin();
bird.fly(); // UnsupportedOperationException
Bird처럼 사용했지만, Penguin은 fly()를 오버라이딩해서 예외를 던진다. 즉, 부모의 기대를 자식이 어긴 경우로 LSP를 위반한 상황이다.
🔽 LSP를 준수한 코드: 새/펭귄
interface Flyable {
void fly();
}
class Bird {
public void sound() {
System.out.println("새가 소리를 낸다.");
}
}
class Sparrow extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("참새가 난다.");
}
@Override
public void sound() {
System.out.println("참새가 소리를 낸다.");
}
}
class Penguin extends Bird {
@Override
public void sound() {
System.out.println("펭귄이 소리를 낸다.");
}
}
'날 수 있음’은 모든 새의 공통 특성이 아니므로, 행동을 별도의 인터페이스인 Flyable로 분리했다. 이렇게 하면 펭귄은 새(Bird)이지만 날지 못하는 동물이라는 규칙을 지키면서도 LSP를 위반하지 않게 된다.
✅ ISP(Interface Segregation Principle): 인터페이스 분리 원칙
"한 인터페이스는 한 가지 역할만 잘 정의해야 한다."
▶ 특징
- 인터페이스가 너무 많은 기능(메서드)을 포함하고 있으면, 구현 클래스는 필요하지 않은 메서드까지 구현해야 할 수 있다.
- ISP 원칙은 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것이 목표이다.
- ISP는 인터페이스를 사용하는 클라이언트를 기준으로 기능을 분리함으로써, 클라이언트가 자신의 목적에 맞는 인터페이스만 사용할 수 있도록 하는 것이 목표다.
- SRP가 클래스의 책임 분리에 초점을 둔다면, ISP는 인터페이스의 책임 분리를 강조한다.
- 주의할 점은, 한 번 인터페이스를 분리했다면 이후에 또다시 분리하지 않는 것이 좋다. 인터페이스는 클래스들 간의 계약 역할을 하기 때문에, 변경이 발생하면 이를 구현하고 있는 모든 클래스에 영향을 줄 수 있다. 따라서 처음부터 적절한 수준에서 책임을 분리하는 개발자의 판단력이 중요하다고 볼 수 있다.
▶ 판단 기준
"클라이언트가 사용하지 않는 메서드까지 오버라이딩해야 하는가?"
그렇다면 ISP를 위반했다고 판단한다.
▶ 예시
🔽 ISP를 위반한 코드
public interface Device {
void print(String document);
void scan(String document);
void fax(String document);
}
public class BasicPrinter implements Device {
@Override
public void print(String document) {
System.out.println("인쇄 기능 동작: " + document);
}
@Override
public void scan(String document) {
throw new UnsupportedOperationException("스캔 기능 없음"); // 사용하지 않는 기능
}
@Override
public void fax(String document) {
throw new UnsupportedOperationException("팩스 기능 없음"); // 사용하지 않는 기능
}
}
BasicPrinter는 print만 필요하지만, Device 인터페이스에 포함된 scan과 fax 메서드도 강제로 오버라이딩해야 한다. 필요하지 않은 기능에 의존하고 있으며, 이는 ISP 위반이다.
🔽 ISP를 준수한 코드
public interface Printer {
void print(String document);
}
public interface Scanner {
void scan(String document);
}
public interface Fax {
void fax(String document);
}
public class BasicPrinter implements Printer {
@Override
public void print(String document) {
System.out.println("인쇄 기능 동작: " + document);
}
}
public class MultiDevice implements Printer, Scanner, Fax {
@Override
public void print(String document) { /* 구현 */ }
@Override
public void scan(String document) { /* 구현 */ }
@Override
public void fax(String document) { /* 구현 */ }
}
기능별로 인터페이스를 분리하면, 클라이언트는 자신이 필요한 기능만 구현하면 된다. BasicPrinter는 Printer 인터페이스만 구현하며 불필요한 기능에 의존하지 않아도 된다. 반면 모든 기능이 필요한 MultiDevice는 Printer, Scanner, Fax 인터페이스를 모두 구현하면 된다. 이처럼 각 인터페이스가 역할에 따라 적절히 분리되어 있으면, ISP를 잘 준수한 구조라고 할 수 있다.
✅ DIP(Dependency Inversion Principle): 의존 역전 원칙
"추상화에 의존해야지, 구체화에 의존하면 안 된다."
▶ 특징
- 어떤 클래스를 사용할 때 구체 클래스에 직접 의존하는 것이 아니라, 그 클래스의 상위 요소(추상 클래스나 인터페이스)에 의존하라는 원칙이다.
- 이는 상위 모듈(비즈니스 로직)과 하위 모듈(구현 세부 사항) 간의 결합도를 낮추기 위해 중간에 추상화 계층을 두고 느슨한 결합을 유도하는 방식이다.
- DIP를 잘 지키면, 하위 모듈이 변경되더라도 상위 모듈은 기존 추상화에 그대로 대응할 수 있어 수정이 필요 없다.
- 대표적인 사례로는 Spring의 Repository 패턴이 있다. Repository 인터페이스에 의존하고 실제 구현체는 DI(의존성 주입)를 통해 주입되므로, DIP를 잘 준수한 구조라고 볼 수 있다.
▶ 판단 기준
"추상화를 할 수 있음에도 구체 클래스에 직접 의존하고 있는가?"
그렇다면 DIP를 위반했다고 판단한다.
▶ 예시
🔽 DIP를 위반한 코드
class MySqlDatabase {
public void connect() {
System.out.println("MySQL에 연결");
}
public void disconnect() {
System.out.println("MySQL 연결 해제");
}
}
class UserService {
private MySqlDatabase database = new MySqlDatabase();
public void registerUser(String username) {
database.connect();
System.out.println(username + " 등록 완료");
database.disconnect();
}
}
UserService는 MySqlDatabase라는 구체 클래스에 직접 의존하고 있다. 만약 OracleDatabase로 변경하려면 UserService의 코드를 직접 수정해야 한다. Database라는 개념은 충분히 추상화할 수 있음에도 불구하고, 각 구현체에 의존하고 있으므로 DIP를 위반한 구조이다.
🔽 DIP를 준수한 코드
interface Database {
void connect();
void disconnect();
}
class MySqlDatabase implements Database {
@Override
public void connect() {
System.out.println("MySQL에 연결");
}
@Override
public void disconnect() {
System.out.println("MySQL 연결 해제");
}
}
class OracleDatabase implements Database {
@Override
public void connect() {
System.out.println("Oracle DB에 연결");
}
@Override
public void disconnect() {
System.out.println("Oracle DB 연결 해제");
}
}
class UserService {
private Database database;
public UserService(Database database) {
this.database = database;
}
public void registerUser(String username) {
database.connect();
System.out.println(username + " 등록 완료");
database.disconnect();
}
}
UserService는 더 이상 구체 클래스가 아닌 Database 인터페이스에만 의존한다. 실제 구현체는 생성자나 DI 컨테이너를 통해 외부에서 주입된다. MySqlDatabase든 OracleDatabase든 자유롭게 교체할 수 있고, UserService는 수정 없이 재사용 가능하다. 이는 DIP를 올바르게 적용한 구조이며, Spring의 Repository 패턴이 대표적인 예시이다.
📍 참고 자료
- 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 최범균
- 위키 백과 - SOLID (객체 지향 설계)