객체 지향 설계의 5가지 기본 원칙입니다.
: 하나의 클래스는 하나의 책임만 가져야 합니다.
// 기본 코드
public class User {
private String name;
public void login() { ... }
public void saveUser() { ... }
}
기존 코드가 위와 같다면, 단일 책임 원칙을 적용한 코드는 아래와 같습니다.
// 단일 책임 원칙 적용 코드
public class User {
...
}
public class AuthService {
public void login(User user)
}
public class UserRepository {
public void saveUser(User user)
}
실제로는 상황에 따라 책임의 크기가 달라질 수 있습니다.
단일 책임 원칙을 적용하는 이유는 코드를 수정할 때 파급 효과를 줄이기 위함입니다.
소프트웨어 요소는 확장에 열려 있어야 하고, 수정에는 닫혀 있어야 합니다.
쉽게 말해, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장을 할 수 있도록 설계해야 한다는 의미입니다.
// 기존 코드
public class Shape {
public String type;
}
public class AreaCalculator {
public double calculate(Shape shape) {
if (shape.type.equals("circle")) {
return /* 원의 넓이 계산 */;
} else if (shape.type.equals("square")) {
return /* 사각형의 넓이 계산 */;
}
}
}
기존 코드가 위와 같다면 개방 폐쇄 원칙을 적용한 코드는 아래와 같습니다.
// 개방 폐쇄 원칙 적용 코드
public interface Shape {
double calculateArea();
}
public class Circle implements Shape {
public double calculateArea() { return /* 원의 넓이 계산 */; }
}
public class Square implements Shape {
public double calculateArea() { return /* 사각형의 넓이 계산 */; }
}
public class AreaCalculator {
public double calculate(Shape shape) {
return shape.calculateArea();
}
}
이 경우에는 새로운 도형이 추가된다고 해도 Shape 인터페이스만 구현하면 되고, 해당 메서드를 실행시키는 AreaCalculator는 수정시킬 필요가 없습니다.
다형성을 활용하여서 문제를 해결한다는 것이 핵심입니다. 위에 선언된 Shape라는 인터페이스를 Circle, Square와 같은 클래스들이 구현합니다. 이 과정에서 새로운 기능을 구현하는 것입니다.
인터페이스라는 것으로 역할을, 구현체로 구현을 담당하며 역할과 구현을 분리하는 식입니다.
// Circle을 계산하는 경우
public class Main {
public static void main(String[]) {
AreaCalculator areaCalculator = new AreaCalculator();
Circle circle = new Circle();
areaCalculator.calculate(circle);
}
}
// Square를 계산하는 경우
public class Main {
public static void main(String[]) {
AreaCalculator areaCalculator = new AreaCalculator();
// Circle circle = new Circle();
Square square = new Square();
areaCalculator.calculate(square);
}
}
위 코드에서 기존에는 Circle 객체를 만들어 Circle을 계산했다가 Square를 계산하려고 하니 Circle 객체를 만든 부분을 지우고 Square 객체를 만드는 것을 볼 수 있습니다. 즉, 구현 객체를 변경하기 위해서 기능을 사용하는 부분의 코드를 수정해야 한다는 것입니다.
이러한 부분에서 문제가 발생하기 때문에 객체의 생성과 사용 등을 자동적으로 설정하는 것이 필요합니다. 그리고 그것을 해결해주는 것이 이후에 공부하게 될 Spring Container입니다.
자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다는 원칙입니다. 쉽게 말해 부모 클래스를 사용하는 곳에서는 해당 부모 클래스를 상속받는 자식 클래스를 사용해도 프로그램 동작에 문제가 없어야 한다는 것입니다.
리스코프 치환 원칙을 위반하는 예시는 아래와 같습니다.
class Car {
public void accelerate() {
System.out.println("자동차가 휘발유로 가속합니다.");
}
}
class ElectricCar extends Car {
@Override
public void accelerate() {
throw new UnsupportedOperationException("전기차는 이 방식으로 가속하지 않습니다.");
}
}
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.accelerate(); // "자동차가 가속합니다."
Car electricCar = new ElectricCar();
electricCar.accelerate(); // UnsupportedOperationException 발생
}
}
부모 클래스인 Car는 휘발유로 가속한다는 내용이 담겨있습니다. 반면, 이를 상속받은 ElectricCar 클래스는 전기차는 휘발유를 사용하여 가속하지 않는다는 내용과 함께 예외를 던지고 있습니다.
이렇게 된다면, 부모 클래스를 사용한 부분에 자식 클래스를 넣게 되면, 예외가 발생할 것입니다.
위 코드를 토대로 리스코프 치환 원칙을 지키는 코드는 아래와 같습니다.
// 가속 기능(역할)을 인터페이스로 분리
interface Acceleratable {
void accelerate();
}
class Car implements Acceleratable {
@Override
public void accelerate() {
System.out.println("내연기관 자동차가 가속합니다.");
}
}
class ElectricCar implements Acceleratable {
@Override
public void accelerate() {
System.out.println("전기차가 배터리로 가속합니다.");
}
}
public class Main {
public static void main(String[] args) {
Acceleratable car = new Car();
car.accelerate(); // "내연기관 자동차가 가속합니다."
Acceleratable electricCar = new ElectricCar();
electricCar.accelerate(); // "전기차가 배터리로 가속합니다."
}
}
Acceleratable이라는 인터페이스를 만들었고, Car와 ElectricCar 각각의 클래스는 해당 인터페이스를 구현합니다. Car에서는 내연기관 자동차가 가속합니다 라는 메세지, ElectricCar에서는 전기차가 배터리로 가속합니다 라는 메세지를 출력하고 있으며, 둘 모두 커다란 기능 하나를 세부적으로 구현하면서 정상적인 작동을 만들어내고 있습니다.
하지만 만약, 여기서 또 뒤로 간다는 기능이 담긴다면 LSP를 위반하는 코드가 될 것입니다. 그렇기 때문에 LSP를 지키기 위해서는 공통 기능이 무엇인지, 큰 부분을 이야기하고, 세부적인 내용은 구현체에서 다루도록 합니다.
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다 라는 의미입니다. 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 하며, 하나의 커다란 인터페이스를 안고 가는 것 대신, 여러 개의 작은 인터페이스로 분리하여 사용해야 합니다.
// 기존 코드
public interface Animal {
void fly();
void run();
void swim();
}
public class Dog implements Animal {
public void fly() { /* 사용하지 않음 */ }
public void run() { /* 달리기 */ }
public void swim() { /* 수영 */ }
}
여기 Animal이라는 커다란 범용 인터페이스 하나가 있습니다. Dog는 Animal을 구현하는 클래스이지만, 실제로 개는 날 수 없기 때문에 fly라는 메서드를 사용하지 않습니다.
하지만 java의 인터페이스 특징으로, 어떤 기능을 구현하도록 하는 틀 역할을 하기 때문에 불필요한 메서드를 어쩔 수 없이 구현해야 합니다.
ISP를 지킨 코드는 아래와 같습니다.
// 인터페이스 분리 원칙 적용 코드
public interface Runnable {
void run();
}
public interface Swimmable {
void swim();
}
public class Dog implements Runnable, Swimmable {
public void run() { /* 달리기 */ }
public void swim() { /* 수영 */ }
}
Animal이라는 큰 범용 인터페이스 하나 대신, Runnable, Swimmable이라는 여러 개의 작은 인터페이스로 나누고, Dog 클래스에 필요한 인터페이스만 구현합니다. 특성상 클래스는 여러 개의 인터페이스를 구현할 수 있으므로 이러한 코드가 가능한 것입니다.
인터페이스 분리 원칙을 지키면 인터페이스가 명확해진다는 장점이 있습니다.
추가로 덧붙이자면, Spring의 기능 대부분은 인터페이스로 분리가 되어 있습니다.
구체적인 클래스에 의존하지 말고, 인터페이스나 추상 클래스에 의존하도록 설계하라는 원칙입니다.
// 기존 코드
// Email 알림 클래스
class EmailNotifier {
public void sendEmail(String message) {
System.out.println("Email 알림: " + message);
}
}
// 알림 시스템
class NotificationService {
private EmailNotifier emailNotifier;
public NotificationService() {
// 구체적인 클래스인 EmailNotifier에 의존
this.emailNotifier = new EmailNotifier();
}
public void sendNotification(String message) {
emailNotifier.sendEmail(message);
}
}
public class Main {
public static void main(String[] args) {
NotificationService service = new NotificationService();
service.sendNotification("안녕하세요! 이메일 알림입니다.");
}
}
위 코드에서 NotificationService는 EmailNotifier클래스를 의존합니다. 만약, EmailNotifier가 아닌 다른 알림 기능이 추가가 된다면, NotificationService는 수정이 되어야 합니다. 여기서 의존관계 역전 원칙이 위배됩니다.
// 의존관계 역전 원칙 적용 코드
// 알림 인터페이스(추상화)
interface Notifier {
void send(String message);
}
// Email 알림 클래스
class EmailNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("Email 알림: " + message);
}
}
// SMS 알림 클래스
class SMSNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("SMS 알림: " + message);
}
}
// 알림 서비스 (높은 수준 모듈)
class NotificationService {
// 추상화된 인터페이스에 의존
private Notifier notifier;
// 의존성 주입 (생성자를 통해 주입)
public NotificationService(Notifier notifier) {
this.notifier = notifier;
}
public void sendNotification(String message) {
// notifier가 어떤 구현체인지 상관하지 않음
notifier.send(message);
}
}
public class Main {
public static void main(String[] args) {
// Email 알림을 사용
Notifier emailNotifier = new EmailNotifier();
NotificationService emailService = new NotificationService(emailNotifier);
emailService.sendNotification("안녕하세요! 이메일 알림입니다.");
// SMS 알림을 사용
Notifier smsNotifier = new SMSNotifier();
NotificationService smsService = new NotificationService(smsNotifier);
smsService.sendNotification("안녕하세요! SMS 알림입니다.");
}
}
구체화된 EmailNotifier 클래스가 아닌, 추상화된 Notifier 인터페이스에 의존합니다. 이렇게 하면 새로운 알림 방식이 추가된다고 해도 NotificationService는 변경되지 않습니다.
필요한 Notifier 객체는 외부에서 주입을 받고, NotificationService는 어떤 Notifier 객체를 사용할지에 대한 세부적인 것을 몰라도 되기 때문에 의존성이 약해집니다.
이렇게 되면 모듈간의 결합도를 낮추고, 유연성, 확장성까지 높일 수 있게 됩니다.
하지만 위에 OCP처럼, 클라이언트 측에서 알림 서비스를 바꿀 때마다 넣는 객체를 달리해야 하므로, 다형성만으로는 OCP, DIP를 지킬 수 없다는 단점이 있습니다.
위에서 언급했듯이 다형성만으로는 OCP, DIP를 지키기 힘들다고 하였습니다. 하지만, Spring에서는 다형성에서는 힘들었던 OCP, DIP를 IOC, DI를 통해 가능하도록 합니다.
OCP, DIP 원칙을 지킬 수 있도록 해줍니다. 다형성에서 OCP, DIP를 지키지 못하는 이유는 클라이언트 측에서 코드를 변경을 해야 했기 때문인데, Spring은 코드의 변경 없이 기능을 확장할 수 있도록 해줍니다.
만약, 이러한 기능이 없었더라면 OCP, DIP를 지키기 위해서 개발자 입장에서는 해야할 일이 무척 많아질 것입니다. 이러한 것들을 Framework로 만들어서 제공하기 때문에 개발자가 개발에 집중할 수 있도록 도와줍니다.
위에서 보았듯이, 모든 설계를 인터페이스로 하여 원칙들을 지켜나갔습니다. 즉, 이상적으로는 모든 설계를 인터페이스로 해야 코드가 유연해집니다. 하지만, 추상화 과정에서 비용이 발생하기 때문에 확장 가능성이 없다면 구현 클래스를 직접 활용하고, 추후 변경될 때 인터페이스로 리팩토링합니다.
자료 및 코드 출처: 스파르타 코딩클럽