SOLID 원칙 - 객체지향 디자인 패턴의 기본기
영상 속 예시 코드
객체지향 프로그래밍은 배우고 활용하기 매우 까다로운 개념이다. 문법과 기능을 익히는 것도 어렵지만, 이를 효과적으로 사용하는 방법을 체득하기까지는 많은 시행착오가 필요하다.
정보처리기사를 준비하며 디자인 패턴을 공부할 때는 솔직히 잘 와닿지 않았다. 그런데 이번 1차 프로젝트를 진행하면서 당시에는 이해하지 못했던 구조들이 실제로 어떻게 적용되는지 많이 체감하게 되었다. 그래서 원래 SOLID 원칙만 정리하려 했지만, 디자인 패턴까지 간략하게 함께 정리해 두면 나중에 복습할 때 유용할 것 같아 한 게시물에 담았다.
객체지향 프로그래밍으로 소프트웨어를 구현할 때, 보다 효율적이고 견고하며 유지보수가 용이한 프로그램을 만들기 위한 원칙이 있다. 바로 SOLID 원칙이다.
SOLID 원칙
하지만 이 SOLID 원칙을 지키는 것이 항상 능사는 아니다. 대부분의 경우에는 최적의 선택으로 작용하지만, 실무에서는 때때로 유연함을 발휘해야 할 때도 있다.
핵심: 각 클래스는 하나의 책임만 가져야 한다.
각 클래스가 한 가지 일을 위해 만들어져야 한다는 의미이며, 메서드가 하나만 있어야 한다는 것은 아니다. 하나의 책임을 위해 여러 메서드가 사용될 수 있다. 단, 클래스가 이들을 통해 수행하는 대표적인 업무는 하나여야 한다.
위반 시 발생하는 문제
준수 시 장점
// ⛔️ Noncompliant Example
public class UserService {
public void saveUser(User user) {
// 사용자 정보를 데이터베이스에 저장
System.out.println("User saved to database: " + user.getName());
}
public void sendWelcomeEmail(User user) {
// 환영 이메일 전송
System.out.println("Welcome email sent to: " + user.getEmail());
}
public void logUserActivity(User user) {
// 로그 기록
System.out.println("Logging activity for user: " + user.getName());
}
}
class User {
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
// ✅ Compliant Example
public class UserRepository {
public void saveUser(User user) {
// 데이터베이스에 사용자 저장
System.out.println("User saved to database: " + user.getName());
}
}
public class EmailService {
public void sendWelcomeEmail(User user) {
// 사용자에게 환영 이메일 전송
System.out.println("Welcome email sent to: " + user.getEmail());
}
}
public class UserActivityLogger {
public void logUserActivity(User user) {
// 사용자 활동 로그 기록
System.out.println("Logging activity for user: " + user.getName());
}
}
class User {
private String name;
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
public class UserService {
private UserRepository userRepository = new UserRepository();
private EmailService emailService = new EmailService();
private UserActivityLogger userActivityLogger = new UserActivityLogger();
public void registerUser(User user) {
userRepository.saveUser(user);
emailService.sendWelcomeEmail(user);
userActivityLogger.logUserActivity(user);
}
}
코드 예시:
전자상거래 시스템에서 주문을 처리할 때, 주문 데이터 검증, 결제 처리, 이메일 발송, 재고 관리를 모두 한 클래스에서 처리하면 SRP 위반이다. 각각을 별도의 클래스로 분리해야 한다.
핵심: 각 클래스는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
클래스를 수정하지 말고 확장해서 사용하라는 의미로, 새로운 기능이 필요할 때 기존 코드를 변경하는 것이 아니라, 새로운 코드를 추가하는 방식으로 확장해야 한다.
위반 시 발생하는 문제
준수 시 장점
// ⛔️ Noncompliant Example
public class ReportGenerator {
public void generateReport(String type) {
if (type.equals("PDF")) {
System.out.println("Generating PDF report...");
} else if (type.equals("HTML")) {
System.out.println("Generating HTML report...");
}
// 새로운 형식을 추가하려면 이 메서드를 수정해야 한다
}
}
// ✅ Compliant Example
public interface Report {
void generate();
}
public class PDFReport implements Report {
@Override
public void generate() {
System.out.println("Generating PDF report...");
}
}
public class HTMLReport implements Report {
@Override
public void generate() {
System.out.println("Generating HTML report...");
}
}
public class XMLReport implements Report {
@Override
public void generate() {
System.out.println("Generating XML report...");
}
}
public class Main {
public static void main(String[] args) {
Report pdfReport = new PDFReport();
pdfReport.generate(); // Generating PDF report...
Report htmlReport = new HTMLReport();
htmlReport.generate(); // Generating HTML report...
Report xmlReport = new XMLReport();
xmlReport.generate(); // Generating XML report...
}
}
코드 예시:
리포트 생성 시스템에서 PDF, Excel, HTML 등 다양한 형식을 지원해야 할 때, 각 형식마다 if문을 추가하는 대신 인터페이스를 구현한 별도의 클래스를 만든다.
핵심: 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.
자식은 최소한 부모가 하는 일은 다 해야 한다는 의미로, 부모 클래스 객체가 들어갈 자리에 자식이 들어가더라도 부모가 하던 일은 지장이 없어야 한다.
위반 시 발생하는 문제
준수 시 장점
주의사항:
단순히 메서드 오버라이딩만으로 판단할 수 있는 문제가 아니다. 행위적 하위타입(behavioral subtyping)을 고려해야 한다. 즉, 자식 클래스가 부모 클래스의 의도된 행위를 위반하지 않아야 한다.
// ⛔️ Noncompliant Example - 새와 펭귄
public class Bird {
public void fly() {
System.out.println("Bird is flying");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
// 펭귄은 날 수 없다
throw new UnsupportedOperationException("Penguins cannot fly");
}
}
public class Main {
public static void main(String[] args) {
Bird bird = new Bird();
bird.fly(); // Bird is flying
Bird penguin = new Penguin();
penguin.fly(); // UnsupportedOperationException 발생
}
}
// ✅ Compliant Example - 새와 펭귄
public interface Flyable {
void fly();
}
public class Bird {
public void eat() {
System.out.println("Bird is eating");
}
}
public class Sparrow extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("Sparrow is flying");
}
}
public class Penguin extends Bird {
// 펭귄은 Flyable을 구현하지 않는다
}
public class Main {
public static void main(String[] args) {
Bird sparrow = new Sparrow();
sparrow.eat(); // Bird is eating
((Flyable) sparrow).fly(); // Sparrow is flying
Bird penguin = new Penguin();
penguin.eat(); // Bird is eating
// ((Flyable) penguin).fly(); // 컴파일 에러, Penguin은 Flyable이 아니다
}
}
// ⛔️ Noncompliant Example - 직사각형과 정사각형
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
class AreaCalculator {
public void calculateArea(Rectangle rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
System.out.println("Area: " + rectangle.getArea());
// Rectangle 예상 출력: Area: 20
// Square 실제 출력: Area: 16 (LSP 위반)
}
}
// ✅ Compliant Example - 직사각형과 정사각형
interface Shape {
int getArea();
}
class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public int getArea() {
return width * height;
}
}
class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
@Override
public int getArea() {
return side * side;
}
}
class AreaCalculator {
public void calculateArea(Shape shape) {
System.out.println("Area: " + shape.getArea());
}
}
public class Main {
public static void main(String[] args) {
AreaCalculator calculator = new AreaCalculator();
Shape rectangle = new Rectangle(5, 4);
calculator.calculateArea(rectangle); // Output: Area: 20
Shape square = new Square(5);
calculator.calculateArea(square); // Output: Area: 25 (예상대로 동작)
}
}
코드 예시:
핵심: 클래스는 자신이 사용하지 않을 메서드를 구현하도록 강요받지 않아야 한다.
인터페이스는 자격증과 같은 것이다. 자동차 운전 면허를 따기 위해 굴착기까지 운전할 줄 알아야 한다면, 자동차 운전을 하고 싶은 사람들은 강제로 굴착기 운전까지 배워야 한다. 이는 부적합하다.
위반 시 발생하는 문제
준수 시 장점
// ⛔️ Noncompliant Example
public interface Worker {
void work();
void eat();
}
public class Employee implements Worker {
@Override
public void work() {
System.out.println("Employee is working");
}
@Override
public void eat() {
System.out.println("Employee is eating");
}
}
public class Robot implements Worker {
@Override
public void work() {
System.out.println("Robot is working");
}
@Override
public void eat() {
// 로봇은 식사를 하지 않는다
throw new UnsupportedOperationException("Robots do not eat");
}
}
public class Main {
public static void main(String[] args) {
Worker employee = new Employee();
employee.work(); // Employee is working
employee.eat(); // Employee is eating
Worker robot = new Robot();
robot.work(); // Robot is working
robot.eat(); // UnsupportedOperationException 발생
}
}
// ✅ Compliant Example
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public class Employee implements Workable, Eatable {
@Override
public void work() {
System.out.println("Employee is working");
}
@Override
public void eat() {
System.out.println("Employee is eating");
}
}
public class Robot implements Workable {
@Override
public void work() {
System.out.println("Robot is working");
}
// Robot은 Eatable 인터페이스를 구현하지 않는다
}
public class Main {
public static void main(String[] args) {
Workable employee = new Employee();
employee.work(); // Employee is working
((Eatable) employee).eat(); // Employee is eating
Workable robot = new Robot();
robot.work(); // Robot is working
// ((Eatable) robot).eat(); // 컴파일 에러, Robot은 Eatable이 아니다
}
}
코드 예시:
직원(Employee)과 로봇(Robot)이 있을 때, 둘 다 일(work)을 하지만 로봇은 식사(eat)를 하지 않는다. Worker 인터페이스에 eat을 포함시키면 Robot은 사용하지 않는 메서드를 구현해야 한다.
핵심: 고수준 모듈이 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
구체적인 동작을 직접 구현하는 로직은 저수준 모듈이고, 이를 제어하는 추상화된 로직을 제공하는 것은 고수준 모듈이다. 고수준 모듈과 저수준 모듈 모두 추상화(인터페이스)에 의존해야 한다.
위반 시 발생하는 문제
준수 시 장점
// ⛔️ Noncompliant Example
public class Fan {
public void spin() {
System.out.println("Fan is spinning");
}
public void stop() {
System.out.println("Fan is stopping");
}
}
public class Switch {
private Fan fan;
public Switch(Fan fan) {
this.fan = fan;
}
public void turnOn() {
fan.spin();
}
public void turnOff() {
fan.stop();
}
}
// ✅ Compliant Example
public interface Switchable {
void turnOn();
void turnOff();
}
public class Fan implements Switchable {
@Override
public void turnOn() {
System.out.println("Fan is spinning");
}
@Override
public void turnOff() {
System.out.println("Fan is stopping");
}
}
public class Light implements Switchable {
@Override
public void turnOn() {
System.out.println("Light is on");
}
@Override
public void turnOff() {
System.out.println("Light is off");
}
}
public class Switch {
private Switchable device;
public Switch(Switchable device) {
this.device = device;
}
public void turnOn() {
device.turnOn();
}
public void turnOff() {
device.turnOff();
}
}
public class Main {
public static void main(String[] args) {
// 선풍기에 연결
Switch fanSwitch = new Switch(new Fan());
fanSwitch.turnOn(); // Fan is spinning
fanSwitch.turnOff(); // Fan is stopping
// 전등에 연결
Switch lightSwitch = new Switch(new Light());
lightSwitch.turnOn(); // Light is on
lightSwitch.turnOff(); // Light is off
}
}
실무 예시:
스위치(Switch)가 특정 선풍기(Fan)에만 동작하도록 구현하면, 나중에 전등(Light)을 켜고 싶을 때 스위치를 수정해야 한다. 대신 스위치가 Switchable 인터페이스에 의존하면, 어떤 기기든 연결할 수 있다.
디자인 패턴은 소프트웨어 설계에서 자주 발생하는 문제들에 대한 재사용 가능한 해결책이다. GoF(Gang of Four)가 정리한 23가지 디자인 패턴은 목적에 따라 생성(Creational), 구조(Structural), 행위(Behavioral) 3가지로 분류된다.
객체의 인스턴스 생성에 관여하고, 클래스 정의와 객체 생성 방식을 구조화하고 캡슐화하는 패턴이다. 객체 생성의 복잡성을 숨기고, 시스템이 어떤 구체 클래스를 사용하든지 독립적으로 만들어 준다.
Abstract Factory (추상 팩토리)
Builder (빌더)
Factory Method (팩토리 메서드)
Prototype (프로토타입)
Singleton (싱글톤)
클래스나 객체를 조합해 더 큰 구조를 만드는 패턴이다. 서로 다른 인터페이스를 가진 객체들을 함께 동작시키거나, 복잡한 구조를 단순화한다.
Adapter (어댑터)
Bridge (브릿지)
Composite (컴포지트)
Decorator (데코레이터)
Facade (파사드)
Flyweight (플라이웨이트)
Proxy (프록시)
객체나 클래스 사이의 알고리즘이나 책임 분배에 관련된 패턴이다. 한 객체가 혼자 수행할 수 없는 작업을 여러 개의 객체로 어떻게 분배하는지, 객체 사이의 결합도를 최소화하는 것에 중점을 둔다.
Chain of Responsibility (책임 연쇄)
Command (커맨드)
Interpreter (인터프리터)
Iterator (반복자)
Mediator (중재자)
Memento (메멘토)
Observer (옵저버)
State (상태)
Strategy (전략)
Template Method (템플릿 메서드)
Visitor (방문자)