Java #2 객체지향 설계 원칙 SOLID #
#2025-09-02
목차 #
- 단일 책임 원칙 (Single Responsibility Principle, SRP)
- 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
- 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
- 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
- 의존 역전 원칙 (Dependency Inversion Principle, DIP)
- 공통 특성: 응집도를 높이거나 결합도를 낮추는 설계
#
1. 단일 책임 원칙 (Single Responsibility Principle, SRP) #
#1 정의
- 한 클래스는 하나의 책임만 가져야 하고 클래스가 변경되어야 할 이유는 오직 하나여야 한다.
#
#2 SRP 위반 예제
// SRP 위반 예시
class Employee {
private String name;
private double hourlyRate;
private int hoursWorked;
public Employee(String name, double hourlyRate, int hoursWorked) {
this.name = name;
this.hourlyRate = hourlyRate;
this.hoursWorked = hoursWorked;
}
// 직원 정보 출력 (책임 1)
public void printEmployeeInfo() {
System.out.println("직원 이름: " + name);
System.out.println("시급: " + hourlyRate);
System.out.println("근무 시간: " + hoursWorked);
}
// 급여 계산 (책임 2)
public double calculatePay() {
return hourlyRate * hoursWorked;
}
}
public class SRPViolationDemo {
public static void main(String[] args) {
Employee emp = new Employee("홍길동", 15000, 40);
emp.printEmployeeInfo();
System.out.println("이번 주 급여: " + emp.calculatePay() + "원");
}
}
#
#3 의문점
Employee 클래스가 3가지 역할을 동시에 하게되는게 문제가되는이유?
- 기능이 섞여 있으면 한 영역을 고치면서 다른 영역에 코드 충돌 위험이 커진다.
- 콘솔 대신 파일 출력으로 바꾸려고 printEmployeeInfo()를 수정했는데, 그 과정에서 calculatePay() 관련 필드를 잘못 건드려 급여 계산이 틀려버릴 수 있다
- 작은 변경에도 클래스 전체를 건드려야해서 유지보수성이 떨어진다.
- 출력 로직 바꾸려고 Employee 클래스를 열면, 급여 계산과 데이터 관리 코드까지 다 보여서 코드 접근에 대한 불확실성이 커지고 불필요하게 큰리스크를 안게된다.
- 결론
- 3가지 이유로 고친다고 해서 실행이 안되는 건 아니지만 실무에서는 한 클래스에는 하나의 책임만 부여하는 것이 장기적으로 안전하고 효율적이다.
#
2. 개방-폐쇄 원칙 (Open-Closed Principle, OCP) #
#1 정의
- 소프트웨어 요소(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다.
#
#2 OCP 위반 예제
// OCP 위반: 새로운 동물이 추가될 때마다 AnimalSound 클래스를 수정해야 함
class Animal {}
class Dog extends Animal {}
class Tiger extends Animal {}
class Cat extends Animal {}
class AnimalSound {
public void makeSound(Animal animal) {
if (animal instanceof Dog) {
System.out.println("멍멍");
} else if (animal instanceof Tiger) {
System.out.println("어흥");
} else if (animal instanceof Cat) {
System.out.println("야옹");
} else {
System.out.println("알 수 없는 동물 소리");
}
}
}
public class OCPViolationExample {
public static void main(String[] args) {
AnimalSound soundMaker = new AnimalSound();
soundMaker.makeSound(new Dog()); // 멍멍
soundMaker.makeSound(new Tiger()); // 어흥
soundMaker.makeSound(new Cat()); // 야옹
}
}
#
#3 의문점
class AnimalSound에서 수정이 항상 따라온다는것의 의미?
- 새로운 동물이 추가될 때마다 기존에 이미 잘 동작하고 있던 AnimalSound 클래스의 코드를 바꿔야 한다.
- 그러면 OCP의 핵심인 ‘새로운 기능은 추가할 수 있지만, 기존 기능은 그대로 두어야 한다’가 위배된다.
- 새로운 동물을 추가해도 AnimalSound라는 기존 클래스의 내부 코드를 건드리지 않아도 되는 다음과 같은 형태여야한다.
- 새로운 동물이 추가될 때마다 기존에 이미 잘 동작하고 있던 AnimalSound 클래스의 코드를 바꿔야 한다.
// OCP 준수 예시
interface Animal {
void makeSound();
}
class Dog implements Animal {
public void makeSound() { System.out.println("멍멍"); }
}
class Tiger implements Animal {
public void makeSound() { System.out.println("어흥"); }
}
// 새로운 동물 추가 시 AnimalSound 수정할 필요 없음
class Horse implements Animal {
public void makeSound() { System.out.println("히이잉"); }
}
class AnimalSound {
public void makeSound(Animal animal) {
animal.makeSound(); // 다형성 활용
}
}
public class OCPCompliant {
public static void main(String[] args) {
AnimalSound soundMaker = new AnimalSound();
soundMaker.makeSound(new Dog());
soundMaker.makeSound(new Tiger());
soundMaker.makeSound(new Horse()); // 기존 코드 수정 없이 확장 가능
}
}
-> 이렇게 만들면 Horse 같은 새로운 동물이 추가되더라도 AnimalSound는 전혀 수정하지 않고 그대로 재사용할수있음.
#
3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP) #
#1 정의
- 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
- 상속 관계에서 부모 타입으로 선언된 객체 자리에 자식 객체를 넣어도 프로그램이 정상적으로 동작해야 한다.
#
#2 LSP 위반 예제
// LSP 위반 사례
class Bird {
void fly() {
System.out.println("새가 날아갑니다!");
}
}
class Penguin extends Bird {
@Override
void fly() {
// 펭귄은 날 수 없는데도 부모의 fly()를 오버라이드해야 함
throw new UnsupportedOperationException("펭귄은 날 수 없어요!");
}
}
public class LSPViolationExample {
public static void main(String[] args) {
Bird bird1 = new Bird();
Bird bird2 = new Penguin(); // 부모 타입에 자식 객체 할당
bird1.fly(); // 정상 동작
bird2.fly(); // ❌ 실행 시 예외 발생 → LSP 위반
}
}
#
#3 의문점
Bird bird2 = new Penguin();에서 펭귄 객체에 ‘인스턴스와 객체의 분리’가 어떻게 적용되는가? + 컴파일 시점 타입과 실행 시점 타입의 차이가 어떻게 LSP 위반으로 이어지는가?
- 객체는 클래스라는 설계도로부터 생성된 실체. 즉 new Penguin()으로 생성된 펭귄.
- 인스턴스는 어떤 클래스의 “구체적인 사례”라는 의미에서 바라본 객체. 즉 Penguin penguin = new Penguin();이면 penguin은?
- “Penguin 클래스의 인스턴스"이자 동시에 “Bird 클래스의 인스턴스”.
- Penguin의 사례이자 Bird의 사례.
- 모든 인스턴스는 객체이지만 객체를 어떤 타입 관점에서 바라보느냐에 따라 인스턴스라고 부른다.
- Bird bird2 = new Penguin();에서
- 실제로 만들어진 것은 Penguin 객체이고
- 이 객체는 Penguin 클래스의 인스턴스임과 동시에 Penguin이 Bird를 상속했기 때문에 Bird 클래스의 인스턴스. 따라서 bird2라는 참조 변수는 Bird 타입을 기준으로 이 객체를 다룬다. 여기서 “타입은 Bird, 실제 객체는 Penguin” 이라는 분리가 발생한다.
- 컴파일 vs 실행
- bird2 변수의 정적 타입(compile-time type) 은 Bird이므로 bird2.fly() 호출은 컴파일러가 허용한다. 하지만 실제 실행 시점(run-time type) 은 Penguin이므로 Penguin.fly() 가 실행되며 UnsupportedOperationException 처리된다 즉, Bird라는 부모 타입의 계약(fly() 가능하다) 은 Penguin 객체에서는 깨져버린다.
- 결론
- 인스턴스와 객체의 분리는 “Bird 타입 인스턴스로서의 펭귄"이라는 다형성 상황을 만들어주지만 펭귄이 fly() 계약을 제대로 지키지 못하면서 LSP 위반이 발생했다.
#
4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP) #
#1 정의
- 하나의 범용적인 큰 인터페이스보다는 여러 개의 구체적이고 작은 인터페이스로 나누는 것이 좋다.
#
#2 ISP 위반 예제
// ISP 위반 예제
interface SmartMachine {
void print();
void fax();
void scan();
}
// Printer는 사실 print만 필요하지만,
// 불필요한 fax(), scan()까지 억지로 구현해야 함
class Printer implements SmartMachine {
@Override
public void print() {
System.out.println("문서를 출력합니다.");
}
@Override
public void fax() {
// 실제 프린터에는 필요 없는 기능
System.out.println("❌ 프린터는 팩스를 지원하지 않습니다.");
}
@Override
public void scan() {
// 실제 프린터에는 필요 없는 기능
System.out.println("❌ 프린터는 스캔을 지원하지 않습니다.");
}
}
public class ISPViolationDemo {
public static void main(String[] args) {
SmartMachine printer = new Printer();
printer.print();
printer.fax(); // 의미 없는 기능 호출
printer.scan(); // 의미 없는 기능 호출
}
}
#
5. 의존 역전 원칙 (Dependency Inversion Principle, DIP) #
#1 정의
- 고수준 모듈은 저수준 모듈에 의존하면 안 된다.
- 상위 비즈니스 로직이 하위 세부 구현에 직접 묶이지 않고, 추상화(인터페이스)에 의존해야 한다.
#
#2 DIP 위반 예제
// 구체적인 구현체에 의존하는 예시 (DIP 위반)
class SnowTire {
public void roll() {
System.out.println("❄️ 눈길을 달리는 스노우 타이어");
}
}
class Car {
private SnowTire tire;
public Car() {
this.tire = new SnowTire(); // ❌ 특정 구현체에 직접 의존
}
public void drive() {
tire.roll();
}
}
public class DIPViolationExample {
public static void main(String[] args) {
Car car = new Car();
car.drive();
}
}
#
#3 의문점
이 코드가 DIP를 위반하는 이유? (고수준 모듈 vs 저수준 모듈)
- 의존 역전 원칙: 고수준 모듈은 저수준 모듈에 의존하지 말고, 둘 다 추상화에 의존해야 한다.
- 고수준 모듈인 Car는
- “주행” = drive()이라는 목표만 있으면 되는데 new SnowTire()를 해 버리면서, 특정 부품인 스노우 타이어와 묶이게 됨
- 결론
- 이 코드가 DIP를 위반하는 이유는 고수준 모듈이 목적(drive())보다 수단(tire)에 자신을 종속시켜서.
- Car은 ‘Tire 인터페이스’에만 의존하고 실제 어떤 타이어를 쓸지는 외부에서 주입(Dependency Injection)받아야 한다.
“Car(고수준모듈)이 Tire 인터페이스에만 의존하고 실제 어떤 타이어를 쓸지는 외부에서 주입받아야 한다”의 의미?
- Tire 인터페이스
- 타이어라면 반드시 roll() 기능을 제공해야 한다.
- 타이어
- Tire 인터페이스를 지키면서 자기 방식대로 동작하는 타이어 (저수준 모듈/아래 코드에서 SnowTire, NormalTire)
- Car(고수준모듈)이 Tire 인터페이스에만 의존해야한다:
class SnowTire implements Tire {
public void roll() {
System.out.println("❄️ 눈길을 달리는 스노우 타이어");
}
}
class NormalTire implements Tire {
public void roll() {
System.out.println("🚗 일반 도로를 달리는 일반 타이어");
}
}
class Car {
private Tire tire; // 인터페이스에만 의존
public Car(Tire tire) { // 외부에서 주입
this.tire = tire;
}
public void drive() {
tire.roll(); // "굴러간다"는 사실만 사용
}
}
- 타이어 2종류: SnowTire, NormalTire
- class Car에서 타이어 관련 코드를 보면 public Car(Tire tire) { // 외부에서 주입 this.tire = tire; } 니까 특정 타이어 종류랑 묶여있지 않음
- 실제 어떤 타이어를 쓸지를 외부에서 주입:
public class Main {
public static void main(String[] args) {
Car snowCar = new Car(new SnowTire()); // 겨울철엔 스노우 타이어
snowCar.drive();
Car normalCar = new Car(new NormalTire()); // 여름철엔 일반 타이어
normalCar.drive();
}
}
- new Car(new SnowTire()) 에서 실제 어떤 타이어가 들어올지는 실행 시점에 외부에서 결정된다.
- 그래서 Car는 본질적인 책임(주행)에만 집중할 수 있고 타이어의 종류가 바뀌어도 Car 클래스 자체는 수정할 필요가 없다.
- 결론
- Car는 추상화(Tire 인터페이스)에만 의존하고, 구체적인 객체 생성과 선택은 외부(Main)에서 맡게 됨으로써 결합도를 낮추고 유연성을 확보한다.
#
6. 공통 특성: 응집도를 높이거나 결합도를 낮추는 설계 #
SOLID 객체지향 설계 원칙은 모듈 간 결합도는 낮추고 각 모듈 내부의 응집도는 높여서 일관성있고 유연한 구조를 만드는게 목적.
- SRP (단일 책임 원칙): 클래스가 한 가지 책임만 가지게 해서 응집도를 높임.
- OCP (개방-폐쇄 원칙): 확장에는 열려 있고 변경에는 닫혀 있게 해서 코드 변경 없이 새로운 기능을 붙일 수 있게 해서 응집도를 유지하면서 변화에 유연하게 설계.
- LSP (리스코프 치환 원칙): 부모 타입을 대체할 수 있는 자식 타입을 보장해서 결합도를 낮추면서 일관성 있게 설계.
- ISP (인터페이스 분리 원칙): 불필요한 의존성을 줄이고 필요한 인터페이스만 사용하게 해서 결합도를 낮추고 응집도를 높임.
- DIP (의존 역전 원칙): 고수준 모듈과 저수준 모듈이 추상화에 의존하도록 해서 결합도를 낮추고 응집도를 강화.