좋은 소프트웨어는 변화에 잘 대응하는 것을 말한다. 개발하는 과정에서 기획이 수정되거나 기능이 바뀐다면 그에 따라 코드도 수정을 해야된다. 이때 큰 에러 없이 잘 대응하기 위해서는 좋은 설계가 바탕이 되어야 한다.
좋은 설계는 어떤 한 부분을 수정하였을때 영향을 받는 범위가 적은 것을 이야기 한다.
이 SOLID 원칙이 바로 좋은 객체지향 설계를 하기 위해 나온 원칙이다.
SRP는 단일 책임 원칙으로 객체는 하나의 책임만 가져야 한다 라는 뜻을 가지고 있다.
위에 사진처럼 한 객체가 여러가지 책임(일)을 하는것이 아닌 각 객체는 각자의 책임을 가지고 있는것을 이야기 한다.
이 SRP를 지키지 않고 개발한다면 A 기능을 수정하는데 B, C도 함께 수정해야하는 문제가 생기게 된다. 따라서 각 기능들을 객체 단위로 분리하여 유지보수성을 높이는 방법이다.
SRP가 지켜지지 않은 코드
class UserManager {
void createUser(User user) {
// 사용자 생성 로직
}
void sendEmail(User user, String message) {
// 이메일 전송 로직
}
}
SRP를 적용한 코드
class UserManager {
void createUser(User user) {
// 사용자 생성 로직
}
}
class EmailService {
void sendEmail(User user, String message) {
// 이메일 전송 로직
}
}
OCP의 정의는 소프트웨어의 확장에는 열려있고 수정에는 닫혀있어라 라는 뜻이다.
처음 이 말을 들었을때는 이해가 잘 안되고 무슨 말인지 잘 모를 수 있다. 소프트웨어 기능을 확장 시킬려면 무조건 코드를 수정해야하는거 아닌가??? 라고 생각할 수 있다.
여기서 말하는것은 수정을 최소화 하도록 설계를 해야한다는것이다. 이를 구현할 수 있는 방법이 바로 다형성이다. 다형성을 통해 새로운 기능이 들어와도 인터페이스를 통해 기능을 사용하면 된다.
OCP를 지키지 않은 코드
class PaymentProcessor {
void pay(String method) {
if (method.equals("card")) {
// 카드 결제
} else if (method.equals("cash")) {
// 현금 결제
}
}
}
OCP를 지킨 코드
interface PaymentMethod {
void pay();
}
class CardPayment implements PaymentMethod {
@Overriding
public void pay() {
// 카드 결제 처리
}
}
class CashPayment implements PaymentMethod {
@Overriding
public void pay() {
// 현금 결제 처리
}
}
class PaymentProcessor {
void pay(PaymentMethod method) {
method.pay();
}
}
LSP는 자식클래스로 부모클래스를 대체할 수 있어야 한다 라는 뜻이다.
이 LSP는 다형성 원리를 사용하기 위한 원칙 개념이다. 자동차 라는 인터페이스가 있고 이를 구현한 구현체들이 있을때 경찰차, 승용차 등등이 나올 수 있는데 여기서 헬기가 하위 클래스로 나온다면 부모 클래스를 대체하지 못하기 때문에 위반이 된다.
class Rectangle {
protected int width, height;
void setWidth(int width) { this.width = width; }
void setHeight(int height) { this.height = height; }
int area() { return width * height; }
}
class Square extends Rectangle {
void setWidth(int side) {
width = height = side;
}
void setHeight(int side) {
width = height = side;
}
}
Rectangle rect = new Square();
rect.setWidth(5);
rect.setHeight(10);
System.out.println(rect.area()); // 기대값: 50, 실제값: 100
위와 같이 예제 코드가 있을때 어떤 문제점이 있을까??
현재 직사각형 클래스의 타입으로 정사각형 객체를 담고 있다. 이때 정사각형은 모든 변의 길이가 같기때문에 setWidth, setHeight를 해도 항상 마지막에 설정한 길이가 모든 변의 길이가 된다.
반면에 직사각형은 가로, 세로 길이가 다르기 때문에 이 정사각형은 직사각형을 대체할 수 없다.
따라서 이 코드는 LSP를 위반한 코드이고 이를 해결하기 위해서는
abstract class Shape {
abstract int area();
}
class Rectangle extends Shape {
private int width, height;
void setWidth(int width) { this.width = width; }
void setHeight(int height) { this.height = height; }
int area() { return width * height; }
}
class Square extends Shape {
private int side;
void setSide(int side) { this.side = side; }
int area() { return side * side; }
}
이와 같이 Shape라는 추상 클래스를 만들어서 각각이 상속받아 구현하여 같은 레벨에 위치 시키면 된다.
ISP는 인터페이스 분리 원칙으로 말 그대로 인터페이스를 분리 시키는 원칙이다.
가끔보면 하나의 범용 인터페이스로 다양한 클래스의 인터페이스로 사용하는 코드들이 있는데 이때의 문제점은 바로 각각의 클래스가 이 인터페이스를 구현하기 위해 자신과 관련이 없는 다른 메서드 또한 구현해야한다.
interface Worker {
void work();
void eat();
}
class Robot implements Worker {
public void work() { /* 로봇 일 처리 */ }
public void eat() { /* 로봇은 먹지 않음 */ }
}
위에 코드로 예시를 들자면 Worker 라는 인터페이스를 만들었는데 이를 현재 로봇이 구현하고 있다. 이 로봇은 밥을 먹지 않고 일만 하는데 Worker가 eat()라는 메서드를 가지고 있어 무조건 억지로 구현해야 하는 상황이 발생하고 있다.
이를 해결하기 위해
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class Robot implements Workable {
public void work() { /* 로봇 일 처리 */ }
}
class Human implements Workable, Eatable {
public void work() { /* 인간 일 처리 */ }
public void eat() { /* 식사 처리 */ }
}
Workable과 Eatable로 인터페이스를 분리 시키면 된다.
따라서 하나의 범용 인터페이스 보다는 여러개의 인터페이스로 분리 시키는것이 좋다.
DIP는 클라이언트 코드가 각각의 구현체에 의존하는것이 아닌 추상화 된 인터페이스를 의존하게 만드는것을 이야기 한다.
이 DIP를 지키는 이유는 만약 위에 사진에서 클라이언트가 운전자라고 하고 각각의 하위 모듈에 테슬라 자동차, 현대 자동차, 기아 자동차가 있다고 가정하면 운전자가 테슬라 자동차에 직접적으로 의존했을때 다른 현대 자동차, 기아 자동차는 운전을 하지 못하게 된다.
따라서 이를 해결하기 위해 자동차 라는 인터페이스를 만들어서 운전자는 테슬라 자동차에 의존하는것이 아닌 자동차에 의존하여 다양한 자동차를 모두 운전할 수 있게 된다.
class MySQLDatabase {
void saveDataInMySQL(String data) {
System.out.println("MySQL에 저장: " + data);
}
}
class InMemoryDatabase {
public void saveDataInMemory(String data) {
System.out.println("메모리에 저장: " + data);
}
}
class DataService {
private MySQLDatabase db = new MySQLDatabase();
void save(String data) {
db.saveDataInMySQL(data);
}
}
위에 데이터베이스를 예로 하는 코드로 설명을 하자면 현재 데이터베이스를 MySQL로 사용하고 있는데 이를 메모리에 저장하는 방식으로 바꿀려면 DataService 코드에서 new 하는 부분부터 save 함수에서 db 메서드 호출 부분까지 모두 수정을 해야하는 참사가 발생한다.
// 추상화: 인터페이스 정의
interface Database {
void saveData(String data);
}
// 구체 구현 A
class MySQLDatabase implements Database {
public void saveData(String data) {
System.out.println("MySQL에 저장: " + data);
}
}
// 구체 구현 B
class InMemoryDatabase implements Database {
public void saveData(String data) {
System.out.println("메모리에 저장: " + data);
}
}
// 상위 모듈: 추상화에만 의존
class DataService {
private Database db;
public DataService(Database db) {
this.db = db; // 추상화에 의존 → 유연성 확보
}
void save(String data) {
db.saveData(data);
}
}
하지만 위와 같이 Database 라는 인터페이스를 만들어서 각자 구현하도록 하면 DataService의 코드에서는 현재 수정해야할 부분이 하나도 없게 된다.
즉 DataService는 인터페이스에만 의존하여 이 구현체에 누가 들어오든 상관없이 자신의 일만 수행하게 된다.
그리고 위에 코드는 db 구현체를 생성자로 받아서 사용하고 있는데 이 방법은 이 구현체를 결정하는것 또한 이 DataService가 결정하던것을 외부에게 맡겨 DataService는 더 이상 수정하는 부분 없이 오로지 자신의 일에만 집중할 수 있다.