객체지향 SOLID 원칙 - SRP, OCP, LSP, ISP, DIP
오늘은 간단하게 객체지향 SOLID 5대 원칙에 대해 정리해두려고한다.
1.SRP (Single Responsibility Principle) - 단일 책임 원칙
한 클래스는 하나의 책임만 가져야 한다.
SRP 원칙은 클래스가 하나의 기능만을 가지며, 어떤 변화에 의해 클래스를 변경해야하는 이유는 오직하나 뿐이어야한다는 원칙이다.
SRP에서는 책임자체가 분명해지기 때문에, 변경에 의한 연쇄 작용에서 자유로워 질 수가 있다.
SRP를 잘 적용한다면 가독성과 유지보수가 좋아지기 때문에 실무에서 사용하기는 쉽진 않겠지만, 항상 생각하면서 프로그램을 짜면 코드의 품질은 올라갈 것이다.
예를 간단하게 보여주도록 하겠다. 아래 코드를 보자.
public class Person {
public static void cook() {
System.out.println("음식을 만듭니다.");
}
public static void shoot() {
System.out.println("총을 쏩니다.");
}
public static void drive() {
System.out.println("운전을 합니다");
}
}
Person이라는 클래스가 있는데 이 클래스를 단일 책임 원칙으로 분리를 하면 아래와 같이 될 것이다.
public class Chef {
public static void cook() {
System.out.println("음식을 만듭니다.");
}
}
public class Soldier {
public static void shoot() {
System.out.println("총을 쏩니다.");
}
}
public class Driver {
public static void drive() {
System.out.println("운전을 합니다");
}
}
아주 간단한 예로 단일 책임의 원칙을 표현해 보았다.
2. OCP (Open-Closed Principle) - 개발-폐쇄 원칙
소프트웨어 요소는 확장에는 열려있으나 변경에는 닫혀 있다.
이 원칙은 변경을 위한 비용은 줄이고, 확장을 위한 비용은 늘려야 한다는 원칙이다.
요구사항의 변경이나 추가사항이 발생해도, 기존 구성요소에는 수정이 일어나지 않고, 기존 구성요소를 쉽게 확장하여 재사용가능하도록 만들어야 한다.
이 원칙을 지키기 위해서는 객체지향의 추상화와 다형성을 활용해야한다.
간단한 예를 하나 보여주도록 하겠다.
먼저 아래 예시는 OCP 가 적용이 되지 않은 상황을 의미한다.
public class Person {
private ShinhanCard card;
public Person(ShinhanCard card) {
this.card = card;
}
public void pay() {
System.out.println("결제를 시도합니다.");
card.swipeCard();
System.out.println("결제가 완료되었습니다.");
}
}
public class ShinhanCard {
public void swipeCard() {
System.out.println("신한카드로 카드를 긁습니다.");
}
}
public class Main {
public static void main(String[] args) {
ShinhanCard shinhanCard = new ShinhanCard();
Person person = new Person(shinhanCard);
person.pay();
}
}
위의 예제를 보면 사람은 지불을 할때 신한카드로 카드를 긁고, 결제를 한다. 어떤 상점에서든 말이다.
여기서 내가 짜놓은 코드로는 .. Person 객체는 신한카드로 밖에 결제할 수 가 없다.
만약에 이 사람이 신한카드가 안되는 상점으로 갔다고 하면... 아무것도 할수가 없게 된다.
하지만 OCP 원칙을 지키면서 개발을 하면 얘기는 달라진다.
먼저 Card라는 추상클래스를 하나 만들었다.
public abstract class Card {
public abstract void swipeCard();
}
그리고 해당 클래스를 상속하였고 swipeCard 메서드를 오버라이드하였다. 신한카드 뿐만아니라 우리카드도 만들었다.
public class ShinhanCard extends Card{
@Override
public void swipeCard() {
System.out.println("신한카드로 카드를 긁습니다.");
}
}
public class WooriCard extends Card{
@Override
public void swipeCard() {
System.out.println("우리카드로 카드를 긁습니다.");
}
}
이제 Person 클래스는 멤버변수로 특정 카드가 아닌 Card 를 멤버변수로 가진다.
public class Person {
private Card card;
public Person(Card card) {
this.card = card;
}
public void pay() {
System.out.println("결제를 시도합니다.");
card.swipeCard();
System.out.println("결제가 완료되었습니다.");
}
}
이렇게하면 어떤 카드든 만들어낼 수가 있고, 국민카드가 추가되든, 하나카드가 추가되든 뭘하든 상관 없이 자유롭게 추가 할 수 있는 구조가 된다.
public class Main {
public static void main(String[] args) {
Card shinhanCard = new ShinhanCard();
Person person1 = new Person(shinhanCard);
person1.pay();
System.out.println();
Card wooriCard = new WooriCard();
Person person2 = new Person(wooriCard);
person2.pay();
}
}
3. LSP(Liskov Substitution Principle) - 리스코프 치환 원칙
서브 타입은 언제나 기반 타입으로 교체할 수 있어야한다.
리스코프 치환 원칙은 서브타입은 기반 타입이 약속한 규약(접근제한자나 예외를 포함) 을 지켜야 한다는 원칙이다.
클래스 상속, 인터페이스 상속을 이용해 확장성을 획득한다.
다형성과 확장성을 극대화 하기위해 인터페이스를 사용하는 방법도 있고, Composition을 이용할 수도 있다.
이걸 설명하기 위해서 가장 많이들 설명하시는 직사각형 - 정삼각형 예제를 통해 설명을 드리겠다.
public class Rectangle {
protected double width;
protected double height;
public void setWidth(double width) {
this.width = width;
}
public double getWidth() {
return this.width;
}
public void setHeight(double height) {
this.height = height;
}
public double getHeight() {
return this.height;
}
public double getArea() {
return this.getWidth() * this.getHeight();
}
}
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setHeight(5);
rectangle.setWidth(10);
System.out.println(rectangle.getArea());
if(isOver(rectangle)) {
throw new RuntimeException();
}
}
public static boolean isOver(Rectangle rectangle) {
return rectangle.getArea() > 80;
}
}
코드는 간단하다 단순하게 Rectangle 이라는 직사각형 클래스가 있고, Main 클래스에서는 생성된 클래스는 면적을 뿌려주고 만약에 넓이가 80이 넘어가면 에러를 리턴한다
그런데 여기서 Square 라는 정사각형을 Rectangle를 상속받아 부모-자식 관계로 표현해서 만들어 보았다.
클래스에서는 넓이를 입력받으면 높이도 넓이로 값을 세팅해버리고, 높이를 입력 받으면 넓이도 높이로 세팅해버린다.(정사각형이니깐....)
public class Square extends Rectangle{
@Override
public void setWidth(double width) {
this.width = width;
this.height = width;
}
@Override
public void setHeight(double height) {
this.height = height;
this.width = height;
}
}
Main 클래스에서 Square를 생성해주는것만 바꾸고 실행해보면...
public class Main {
public static void main(String[] args) {
Rectangle rectangle = new Square();
rectangle.setHeight(5);
rectangle.setWidth(10);
System.out.println(rectangle.getArea());
if(isOver(rectangle)) {
throw new RuntimeException();
}
}
public static boolean isOver(Rectangle rectangle) {
return rectangle.getArea() > 80;
}
}
RuntimeException 이 발생한다.
위에서 말한 것 처럼 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위타입을 사용하는 프로그램이 정상동작해야하지만 그렇지 못한 .. 즉 LSP를 위반한 코드를 짜게 된것이다. 만약에 이걸 개선하고자한다면 isOver 메서드 같은 기능이 필요하다면 부모-자식 관계를 표현하는 상속이 아닌 별개의 타입으로 구현하면 문제가 해결된다.
4. ISP(Interface Sergregation Principle) - 인터페이스 분리 원칙
자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다
인터페이스 분리 원칙을 적용하기 위해서는 가능한 최소한의 인터페이스만을 구현해야한다.
만약에 어떤 클래스를 이용하는 클라이언트가 여러 개고, 이들이 클래스의 특정 부분만 이용하면, 여러 인터페이스로 분리해주고 클라이언트가 필요한 기능만 전달하게 코드를 짜야한다.
SRP 가 클래스의 단일 책임 원칙이라면, ISP는 인터페이스의 단일 책임 원칙이다.
public class Robot implements Operatable{
@Override
public void fly() {
System.out.println("날다");
}
@Override
public void attack() {
System.out.println("공격하다");
}
@Override
public void work() {
System.out.println("일하다");
}
}
public interface Operatable {
void fly();
void attack();
void work();
}
위의 구현된 클래스를 보면 로봇은 날고, 공격하고, 일하는 기능을 가지고 있다. Operatable 인터페이스에서 그런 기능을 구현하도록 설계 되어있기 때문이다.
하지만 만약에 누군가가.. 평화주의(?) 로봇을 만들려고한다고 해보자. 지금설계 구조상에서는 불가능하다. 즉 공격하는 기능은 제외해야하기 때문이다.
그렇기에 아래처럼 인터페이스를 분리해줄 필요가 있다.
public interface Flyable {
void fly();
}
public interface Attackable {
void attack();
}
public interface Workable {
void work();
}
이렇게하면 공격하지 못하는 로봇을 만들수 있게 된다.
public class Robot implements Flyable, Workable{
@Override
public void fly() {
System.out.println("날다");
}
@Override
public void work() {
System.out.println("일하다");
}
}
5. DIP(Dependency Inversion Principle) - 의존성 역전 원칙
상위 모델은 하위 모델에 의존하면 안된다. 둘다 추상화에 의존하여야 한다.
추상화는 세부 사항에 의존해서는 안된다. 세부 사항은 추상화에 따라 달라진다.
의존성 역전 원칙은 하위 모델의 변경이 상위 모델의 변경을 요구하는 위계관계를 끊으면서 적용된다.
실제 사용관계는 그대로 이지만 추상화를 매개로 하여 메시지를 주고 받으면서 관계를 느슨하게 해준다.
아래 자동이체기능이 있는 Controller와 신한은행을 이용하는 Service클래스가 있다고 가정하자.
class PaymentController {
// ...
@PostMapping("/api/payment/auto")
public void autoPay(@RequestBody ShinhanBankDto.PaymentRequest req) {
shinhanBankPaymentService.autoPay(req);
}
}
class ShinhanBankPaymentService {
// ...
public void autoPay(ShinhanBankDto.PaymentRequest req) {
ShinhanBankApi.autoPay(req);
}
}
만약에 여기서 하나은행이 추가되었다고 가정해보자.
class PaymentController {
//....
@PostMapping("/api/payment/auto")
public void autoPay(@RequestBody ShinhanBankDto.PaymentRequest req) {
if(req.getBankType() == BankType.SHINHAN)
shinhanBankPaymentService.autPay(req);
else if(req.getBankType() == BankType.HANA)
hanaBankPaymentService.autoPay(req);
}
}
이렇게 확장에 굉장히 유연하지 않은 방식으로 추가를 해줘야한다... 은행이 늘어나면 늘어날수록 더 비효율적으로 변할 것이다.
여기서 추상화 할수 있는 Service 인터페이스를 하나 넣어준다.
public interface BankPaymentService {
//...
void autoPay(BankPaymentDto.PaymentRequest req);
}
그리고 신한이든 하나든 필요한 서비스를 이 인터페이스를 구현하여 만든다.
public class ShinhanBankPaymentService implements BankPaymentService {
//..
@Override
public void autoPay(BankPaymentDto.PaymentRequest req) {
shinhanBankApi.autoPay(req);
}
}
public class HanaBankPaymentService implements BankPaymentService {
//..
@Override
public void autoPay(BankPaymentDto.PaymentRequest req) {
hanaBankApi.autoPay(req);
}
}
그러고는 컨트롤러에서는 별도의 분기 없이 팩토리 클래스를 활용하여 은행의 타입에 따라 실행이 되도록 코드를 짜준다.
class PaymentController {
//....
@PostMapping("/api/payment/auto")
public void autoPay(@RequestBody ShinhanBankDto.PaymentRequest req) {
final BankPaymentService bankPaymentService =
bankPaymentFactory.getType(req.getBankType());
bankPaymentService.autoPay(req);
}
}
이렇게 짜주면 DIP 원칙을 지켜 코드를 구현할 수 있다.
끝