SOLID 원칙
객체 지향 설계의 5원칙으로 해당 원칙에 따르면 자식클래스의 인스턴스가 부모 클래스의 인스턴스를 대신하더라도 의도에 맞게 작동해야한다. 따라서 SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙이다.
- S : SRP(Single Responsibility Principle) 단일 책임 원칙
- O : OCP (Open Closed Principle) 개방 폐쇄 원칙
- L : LSP (Liskov Substitution Principle) 리스코프 치환 원칙
- I : ISP (Interface Segregation Principle) 인터페이스 분리 원칙
- D : DIP (Dependency Inversion Principle) 의존 역전 원칙
으로 각각의 원칙에 대해 알아보자.
1. SRP (Single Responsibility Principle) - 단일 책임 원칙
먼저 SRP 단일 책임원칙이란 클래스(객체)는 단하나의 책임만을 가져야한다는 원칙이다. 즉 하나의 기능담당만 해야한다는 점이다.
실생활 예시
식당에서 요리사는 요리만 하고, 웨이터는 서빙만 하며, 계산원은 계산만 담당하는 것과 같다. 각자 하나의 역할에만 집중해야 효율적이다.
잘못된 예시 (SRP 위반)
// SRP 위반 - 사용자 관련 모든 기능이 한 클래스에 있음
@Component
public class User {
private String name;
private String email;
// 사용자 정보 관리
public void setName(String name) { this.name = name; }
public void setEmail(String email) { this.email = email; }
// 데이터베이스 저장 (다른 책임)
public void saveToDatabase() {
// DB 저장 로직
System.out.println("사용자 정보를 DB에 저장");
}
// 이메일 발송 (또 다른 책임)
public void sendEmail(String message) {
// 이메일 발송 로직
System.out.println("이메일 발송: " + message);
}
}
올바른 예시 (SRP 준수)
// 사용자 정보만 담당
@Entity
public class User {
private String name;
private String email;
public void setName(String name) { this.name = name; }
public void setEmail(String email) { this.email = email; }
// getter methods...
}
// 데이터베이스 저장만 담당
@Repository
public class UserRepository {
public void save(User user) {
System.out.println("사용자 정보를 DB에 저장");
}
}
// 이메일 발송만 담당
@Service
public class EmailService {
public void sendEmail(String email, String message) {
System.out.println("이메일 발송: " + message);
}
}
2. OCP (Open Closed Principle) - 개방 폐쇄 원칙
OCP는 확장에 열려있어야하며 수정에는 닫혀있어야한다를 뜻한다. 기능 추가 요청이 오면 클래스를 확장을 통해 손쉽게 구현하면서, 확장에 따라 클래스의 수정은 최소화 하도록 작성해야하는 설계 기법으로 간단하게 추상화 사용을 통한 관계 구축을 권장하는 말이다.
실생활 예시
스마트폰에 새로운 앱을 설치할 때, 기존 운영체제를 수정하지 않고도 새로운 기능을 추가할 수 있는 것과 같다.
잘못된 예시 (OCP 위반)
@Service
public class PaymentService {
public void processPayment(String paymentType, double amount) {
if (paymentType.equals("CREDIT_CARD")) {
System.out.println("신용카드로 " + amount + "원 결제");
} else if (paymentType.equals("PAYPAL")) {
System.out.println("페이팔로 " + amount + "원 결제");
}
// 새로운 결제 방식 추가시 이 메소드를 수정해야 함 (OCP 위반)
}
}
올바른 예시 (OCP 준수)
// 결제 인터페이스 정의
public interface PaymentProcessor {
void processPayment(double amount);
}
// 각 결제 방식별 구현체
@Component
public class CreditCardProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
System.out.println("신용카드로 " + amount + "원 결제");
}
}
@Component
public class PayPalProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
System.out.println("페이팔로 " + amount + "원 결제");
}
}
// 새로운 결제 방식 추가 (기존 코드 수정 없이)
@Component
public class KakaoPayProcessor implements PaymentProcessor {
@Override
public void processPayment(double amount) {
System.out.println("카카오페이로 " + amount + "원 결제");
}
}
@Service
public class PaymentService {
public void processPayment(PaymentProcessor processor, double amount) {
processor.processPayment(amount);
}
}
3. LSP (Liskov Substitution Principle) - 리스코프 치환 원칙
LSP는 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있다는 원칙이다. 즉 다형성 원리를 이용하기 위한 원칙으로 보면 된다.
실생활 예시
모든 새는 날 수 있다고 가정했을 때, 펭귄도 새이지만 날 수 없다. 따라서 "새"라는 부모 클래스를 "펭귄"으로 대체했을 때 "날기" 기능이 제대로 작동하지 않는다.
잘못된 예시 (LSP 위반)
class Bird {
public void fly() {
System.out.println("새가 날고 있습니다.");
}
}
class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다!");
}
}
@Service
public class BirdService {
public void makeBirdFly(Bird bird) {
bird.fly(); // 펭귄이 들어오면 예외 발생 (LSP 위반)
}
}
올바른 예시 (LSP 준수)
// 기본 새 클래스
abstract class Bird {
public abstract void move();
}
// 날 수 있는 새
abstract class FlyingBird extends Bird {
public void fly() {
System.out.println("새가 날고 있습니다.");
}
@Override
public void move() {
fly();
}
}
// 날 수 없는 새
abstract class FlightlessBird extends Bird {
public void walk() {
System.out.println("새가 걷고 있습니다.");
}
@Override
public void move() {
walk();
}
}
class Eagle extends FlyingBird {
// 독수리는 날 수 있음
}
class Penguin extends FlightlessBird {
// 펭귄은 날 수 없지만 걸을 수 있음
}
@Service
public class BirdService {
public void makeBirdMove(Bird bird) {
bird.move(); // 어떤 새든 올바르게 작동
}
}
4. ISP (Interface Segregation Principle) - 인터페이스 분리 원칙
ISP는 인터페이스를 각각 사용에 맞게끔 잘게 분리해야한다는 설계 원칙이다. SRP가 클래스의 단일책임이라면 ISP는 인터페이스의 단일 책임이다. 또한 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는것이 목표이고 해당원칙의 주의해야할 점은 인터페이스를 분리하여 구성해 놓고 수정사항이 생기게 되면 또 다시 인터페이스들을 분리하는 행위를 가지면 안된다.
실생활 예시
TV 리모컨은 TV 조작만, 에어컨 리모컨은 에어컨 조작만 담당한다. 만약 하나의 리모컨에 모든 가전제품 버튼이 있다면 사용하기 복잡해진다.
잘못된 예시 (ISP 위반)
// 모든 기능이 하나의 인터페이스에 있음
interface Worker {
void work();
void eat();
void sleep();
void code(); // 개발자만 필요
void drive(); // 운전사만 필요
void cook(); // 요리사만 필요
}
// 모든 클래스가 불필요한 메소드까지 구현해야 함
@Component
class Developer implements Worker {
@Override
public void work() { System.out.println("개발 작업"); }
@Override
public void eat() { System.out.println("식사"); }
@Override
public void sleep() { System.out.println("수면"); }
@Override
public void code() { System.out.println("코딩"); }
// 개발자는 운전과 요리를 하지 않아도 구현해야 함
@Override
public void drive() { throw new UnsupportedOperationException(); }
@Override
public void cook() { throw new UnsupportedOperationException(); }
}
올바른 예시 (ISP 준수)
// 기본적인 인간의 행동
interface Human {
void eat();
void sleep();
}
// 각 역할별로 인터페이스 분리
interface Workable {
void work();
}
interface Codeable {
void code();
}
interface Driveable {
void drive();
}
interface Cookable {
void cook();
}
// 필요한 인터페이스만 구현
@Component
class Developer implements Human, Workable, Codeable {
@Override
public void eat() { System.out.println("개발자 식사"); }
@Override
public void sleep() { System.out.println("개발자 수면"); }
@Override
public void work() { System.out.println("개발 작업"); }
@Override
public void code() { System.out.println("코딩"); }
}
@Component
class Driver implements Human, Workable, Driveable {
@Override
public void eat() { System.out.println("운전사 식사"); }
@Override
public void sleep() { System.out.println("운전사 수면"); }
@Override
public void work() { System.out.println("운전 업무"); }
@Override
public void drive() { System.out.println("운전"); }
}
5. DIP (Dependency Inversion Principle) - 의존 역전 원칙
DIP 원칙은 어떤 Class를 참조해서 사용해야한다면 그 Class를 직접 참조하지 않고 그 대상의 상위 요소로 참조하는 원칙이다. 즉 구현 클래스에 의존하지말고 인터페이스에 의존해야한다. 이는 자주 변하는 것보다 거의 변하지 않는 것에 의존하여 각 클래스간의 결합도를 낮추는 것이다.
실생활 예시
전자제품을 콘센트에 꽂을 때, 각 제품마다 다른 콘센트를 만들지 않고 표준 플러그를 사용한다. 콘센트는 구체적인 제품이 아닌 표준 인터페이스에 의존한다.
잘못된 예시 (DIP 위반)
// 구체적인 구현체에 직접 의존
@Component
class MySQLDatabase {
public void save(String data) {
System.out.println("MySQL에 데이터 저장: " + data);
}
}
@Service
class UserService {
private MySQLDatabase database; // 구체적인 클래스에 의존 (DIP 위반)
public UserService() {
this.database = new MySQLDatabase(); // 강한 결합
}
public void saveUser(String userData) {
database.save(userData);
}
// PostgreSQL로 바꾸려면 코드 수정이 필요함
}
올바른 예시 (DIP 준수)
// 추상화된 인터페이스 정의
interface Database {
void save(String data);
}
// 구체적인 구현체들
@Component
class MySQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("MySQL에 데이터 저장: " + data);
}
}
@Component
class PostgreSQLDatabase implements Database {
@Override
public void save(String data) {
System.out.println("PostgreSQL에 데이터 저장: " + data);
}
}
@Service
class UserService {
private Database database; // 인터페이스에 의존 (DIP 준수)
// Spring의 의존성 주입 사용
@Autowired
public UserService(Database database) {
this.database = database;
}
public void saveUser(String userData) {
database.save(userData);
}
// 설정만 바꾸면 다른 DB로 교체 가능
}
// 설정 클래스
@Configuration
class DatabaseConfig {
@Bean
public Database database() {
return new MySQLDatabase(); // 여기서만 구체적인 구현체 선택
// return new PostgreSQLDatabase(); // 쉽게 교체 가능
}
}
결론
SOLID 원칙을 지키면 다음과 같은 이점을 얻을 수 있다:
- 유지보수성 향상: 코드 변경이 다른 부분에 미치는 영향을 최소화
- 확장성 증대: 새로운 기능 추가가 용이
- 테스트 용이성: 각 컴포넌트를 독립적으로 테스트 가능
- 코드 재사용성: 잘 분리된 컴포넌트는 다른 곳에서도 사용 가능
- 팀 협업 개선: 명확한 책임 분할로 개발자 간 협업이 원활
Spring Framework는 이러한 SOLID 원칙을 잘 구현할 수 있도록 도와주는 다양한 기능들을 제공한다. 의존성 주입(DI), 관점 지향 프로그래밍(AOP), 그리고 다양한 추상화 레이어들이 그 예시이다.