Intro

OOD(Object Origined design)

SOLID 앞선 포스트들에서 계속 SOLID를 언급했을 정도로 해당 개념은 자바에서 기본 덕목 중에 하나로 다루어진다고 생각한다. why에 대한 고민도 좋지만, 그것을 떠나서 이것이 무엇인지 조차 이해를 못하고 있던 때가 있었는데 이 책을 통해서 이게 무엇이구나가 느껴졌다.

그 이후인 지금은 어째서 이것이 주요한가? 현재 DDD(Domain Driven Development) 개발이 주된 환경에서는 어떤 의미를 가지는가? 등의 고민과 생각이 있다.

하지만 앞서 말한 것처럼 이 개념 자체가 크게 어렵지는 않았다. 정확히는 이 책을 통해서 이해하는게 어렵지는 않았다. 안티패턴과 사용법에 대한 소개가 포함되기 때문이다.

다만, 이러한 안티패턴이라는 것도 개발하는 경우에는 팀 내에서 합의에 따라 정책이 될 수 있다.
즉, 이러한 경우는 S에 어긋난다고 표현되겠다라는 것을 알고 쓰느냐, 모르고 쓰느냐에 관한 차이라고 생각한다.

즉, 생각을 할 수 있고 분석을 할 수 있는 개발자가 될 수 있느냐 없느냐의 문제인 것이다.
이를 할 수 있는 개발자는 이러한 것을 하는 곳이건 아니건 이를 빠르게 캐치하고 업무에 적용할 수 있을 것이다.

현재는 Why를 이 정도로 납득하고 달려가고 있다.

이것이 맞는지 아닌지는 아직 미취업자이기에 나도 연차가 차고 실제 환경에서 더 발전할 수 있지 않을까 생각한다.

SOLID는 5가지로 나뉘며 이 순서대로 소개하며, 안티패턴 이후에 좋은 패턴에 대해 언급하는 순으로 기술한다.

S : 단일 책임 원칙(single responsibility principle, SRP)

O : 개방-폐쇄 원칙(open-closed principle, OCP)

L : 리스코프 치환 원칙(Liskov substitution principle, LSP)

I : 인터페이스 분리 원칙(interface segregation principle, ISP)

D : 의존관계 역전 원칙(dependency inversion principle, DIP)

S : 단일 책임 원칙(single responsibility principle, SRP)

1. S란 무엇인가?

필수 개념.

  • S는 단일 책임 원칙이다.
  • S는 ‘하나의 객체가 하나의 책임만 져야 한다.’는 의미다.
  • S는 클래스를 단 한 가지 목표만 가지고 작성해야 한다는 것을 의미한다.
  • S는 애플리케이션 모듈 전반에서 높은 유지보수성과 가시성 제어 기능을 유지하는 원칙이다.

실제 답변은 아래와 같은 틀로.

“먼저 SOLID는 ‘밥 삼촌’(uncle bob)이라고 불리는 로버트 C. 마틴(Robert C. Martin)이 처음 발표한 5가지 객체지향 설계(object-orineted design, ODD) 원칙의 약자입니다. S는 SOLID의 첫 번째 원칙이며 단일 책임 원칙(single responsibility principle, SRP)로 알려져 있습니다. 이 원칙은 하나의 클래스는 단 하나의 책임만 가져야 한다는 사실을 의미합니다. 이것은 모든 유형의 프로젝트에서 모델, 서비스, 컨트롤러, 관리자 클래스 등 모든 유형의 클래스가 준수해야 하는 매우 중요한 원칙입니다. 하나의 목표만을 위한 클래스를 작성한다면 애플리케이션 모듈 전반에서 높은 유지보수성과 가시성 제어를 유지할 수 있습니다. 다시 말해서 이 원칙은 높은 유지보수성을 유지하므로 비즈니스에 중대한 영향을 미치며 애플리케이션 모듈 전반에서 가시성 제어를 제공함으로써 캡슐화를 유지할 수 있습니다.

추가적인 설명이 필요하면 아래와 같이 코드를 작성할 수 있으며 이를 작성할 때도 계속 설명을 이어 나가야 하며 아래와 같은 플로우를 따르자.

예를 들어, 직사각형의 면적을 계산할 때, 처음에 직사각형의 면적을 계산하는 단위가 미터로 주어지고 면적도 미터로 계산헀는데, 계산한 값을 인치와 같은 다른 단위로 변환하고 싶을 때 단일 책임 원칙을 따르지 않은 경우와 단일 책임 원칙을 따른 경우에 대해서 말씀드리겠습니다.

단일 책임 원칙을 따르지 않은 경우

직사각형 면적을 구하고 단위를 변환하는 문제를 다음과 같이 RectangleAreaCalculator 라는 하나의 클래스에서 구현할 수 있습니다. 하지만 이 클래스는 한 가지가 아닌 여러 가지 일을 하므로 단일 책임 원칙을 따르지 않습니다. 일반적으로 클래스가 수행하는 일을 표현하기 위해

또한’이라는 단어를 사용해야 한다면 단일 책임 원칙이 깨진 것이라고 할 수 있습니다. 예를 들어 다음 클래스는 면적을 계산(업무1)하면서 또한 면적을 인치로 변환합니다.(업무2)

public class RectangleAreaCalculator {
    private static final double INCH_TERM = 0.0254d;

    private final int width;
    private final int height;

    public RectangleAreaCalculator(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int area() {
        return width * height;
    }

    // 이 메서드는 단일 책임 원칙에 맞지 않습니다. 이 클래스는 면적 계산과 면적 변환
    // 두 가지 작업을 수행하므로 수정해야 하는 두 가지 이유가 있는 것입니다
    public double metersToInches(int area) {
        return area / INCH_TERM;
    }
}

이처럼 Anti Pattern 도 언급할 수 있어야 잘못된 것을 구분하고 업무를 분장할 수 있다는 것을 어필할 수 있지 않을까?

단일 책임 원칙을 따르는 경우

다음과 같이 RectangleAreaCalculator 클래스에서 metersToInches 메서드를 제거하면 단일 책임 원칙을 준수할 수 있습니다.

 public class RectangleAreaCalculator {
    private final int width;
    private final int height;

    public RectangleAreaCalculator(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int area() {
        return width * height;
    }
}

이제 RectangleAreaCalculator 클래스는 직사각형의 면적을 계산하는 한 가지 일만 하므로 단일 책임 원칙을 따릅니다.

다음으로 metersToInches 메서드는 별도의 클래스로 옮기겠습니다. 이 클래스에는 미터를 피트로 변화하는 새로운 메서드도 추가할 수 있습니다.

public class AreaConverter {
    private static final double INCH_TERM = 0.0254d;
    private static final double FEET_TERM = 0.3048d;

    public double metersToInches(int area) {
        return area / INCH_TERM;
    }
    
    public double metersToFeet(int area) {
        return area / FEET_TERM;
    }
}

이 클래스 역시 단일 책임 원칙을 따르기 떄문에 이것으로 작업은 끝입니다.

→ 하지만 여기서 중요한 것은 이처럼 SRP를 반드시 꼭 지켜야 하는가?는 자신의 업무 프로세서에 따라 다를 것입니다. 절대 변하지 않을 것이라는 보장이 있다거나, 위와 같은 간단한 분류가 아닌 매우 연관성이 높고 복잡도가 증가해 클래스가 지나치게 많아지는 등의 불필요한 리소스 소요를 막는 것 역시 너무나 중요하기에 실제 팀 내에서 정책과 코드에 관한 의사합일이 가장 중요할 것입니다.

O : 개방-폐쇄 원칙(open-closed principle, OCP)

2. O란 무엇인가?

필수 개념.

  • O는 개방-폐쇄 원칙이다.
  • O는 ‘소프트웨어 컴포넌트는 확장에 관해 열려 있어야 하고 수정에 관해서는 닫혀 있어야 한다’ 는 의미다.
  • O는 다른 개발자가 작업을 수행하기 위해 반드시 수정해야 하는 제약 사항을 클래스에 포함해서는 안 된다는 사실을 의미합니다. 다른 개발자가 클래스를 확장하기만 하면 원하는 작업을 할 수 있도록 해야 합니다.
  • O는 다양하고 직관적이며 유해하지 않은 방식으로 소프트웨어 확장성을 유지하는 원칙이다.

실제 답변은 아래와 같은 틀로.

“먼저 SOLID는 ‘밥 삼촌’(uncle bob)이라고 불리는 로버트 C. 마틴(Robert C. Martin)이 처음 발표한 5가지 객체지향 설계(object-orineted design, ODD) 원칙의 약자입니다. O는 SOLID의 두 번째 원칙이며 개방-폐쇄 원칙(open-closed principle, OCP)로 알려져 있습니다. 소프트웨어 컴포넌트는 확장에 관해 열려 있어야 하고 수정에 관해서는 닫혀 있어야 한다는 사실을 의미합니다. 이것은 다른 개발자가 단순히 클래스를 확장하기만 해도 클래스의 동작을 수정할 수 있도록 클래스를 설계하고 작성해야 한다는 의미입니다. 따라서 클래스는 다른 개발자가 작업을 수행하기 위해 반드시 수정해야 하는 제약 사항을 클래스에 포함해서는 안 되며 다른 개발자가 클래스를 확장하기만 하면 원하는 작업을 할 수 있도록 해야 합니다. 소프트웨어 확장성을 다양하고 직관적이며 유해하지 않은 방식으로 반드시 유지해야 하지만, 다른 개발자가 클래스의 전체 또는 핵심 로직을 수정하고 싶어하지 않을까 염려할 필요는 없습니다. 기본적으로 이 원칙을 따르면 코드는 핵심 로직을 수정할 수 있는 접근 권한은 주지 않지만 일부 클래스를 확장하고, 초기화 매개변수를 전달하며, 메서드를 오버라이딩 하고 다른 옵션을 전달하는 등의 방법으로 흐름이나 동작을 수정할 수 있는 좋은 프레임워크처럼 동작할 것입니다.

추가적인 설명이 필요하면 아래와 같이 코드를 작성할 수 있으며 이를 작성할 때도 계속 설명을 이어 나가야 하며 아래와 같은 플로우를 따르자.

예를 들어, 직사각형, 원 등 여러 가지 도형이 있고 그 도형들 면적의 합을 구하고자 하는 상황을 가정하겠습니다. 먼저 개방-폐쇄 원칙을 따르지 않은 구현 방법부터 살펴보겠습니다.

개방-폐쇄 원칙을 따르지 않은 경우

각 모양은 Shape 인터페이스를 구현할 것입니다. 코드는 매우 간단합니다.

public interface Shape { }
public class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
}
public class Circle implements Shape {
    private final int radius;

    public Circle(int radius) {
        this.radius = radius;
    }

    public int getRadius() {
        return radius;
    }
}

이 코드에서 클래스의 생성자를 활용하여 서로 다른 크기의 직사각형과 원을 쉽게 만들 수 있습니다. 여러 가지 도형을 만들었다면 모든 면적을 합합니다. 면적의 합을 구하기 위해 AreaCalculator 클래스를 아래와 같이 정의합니다.

import java.util.List;

public class AreaCalculator {
    private final List<Shape> shapes;

    public AreaCalculator(List<Shape> shapes) {
        this.shapes = shapes;
    }

    // 더 많은 도형을 추가하려면 이 클래스를 수정해야 하므로 개방-폐쇄 원칙에 맞지 않습니다.
    public double sum() {
        int sum = 0;
        for (Shape shape : shapes) {
            if (shape.getClass().equals(Circle.class)) {
                sum += Math.PI * Math.pow(((Circle) shape).getRadius(), 2);
            } else if (shape.getClass().equals(Rectangle.class)) {
                sum += ((Rectangle) shape).getHeight() * ((Rectangle) shape).getWidth();
            }
        }

        return sum;
    }
}

각 도형의 면적을 구하기 위한 고유의 공식이 있으므로 도형의 유형을 구분하려면 if-else 또는 switch 구조가 필요합니다. 또한 삼각형과 같은 새로운 도향을 추가하고 싶으면 새로운 if문을 추가하기 위해 AreaCalculator 클래스를 수정해야 합니다. 이것은 이 코드가 개방-폐쇄 원칙을 위반하는 것입니다. 걔방-폐쇄 원칙을 준수하도록 코드를 수정하려면 모든 클래스에서 몇 가지를 수정해야 합니다. 이렇듯 개방-폐쇄 원칙을 따르지 않는 코드를 고치는 것은 간단한 예제에서조차 상당히 까다롭습니다!

개방-폐쇄 원칙을 따르는 경우

주된 아이디어는 AreaCalculator에서 도형의 면적 계산 공식을 빼서 공식이 필요한 각 Shape 클래스로 옮기는 것입니다. 즉 직사각형, 원 등의 각 도형에 해당하는 클래스에서 자신의 면적을 계산하도록 하는 것입니다. 각 도형 클래스에서 면적을 계산하도록 Shape 인터페이스 정의에 area 메서드를 추가합니다.

public interface Shape {
	public double area();
 }

다음으로 Rectangle과 Circle 클래스는 Shape 인터페이스의 area 메서드를 다음과 같이 구현합니다.

public class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return width * height;
    }
}
public class Circle implements Shape {
    private final int radius;

    public Circle(int radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * Math.pow(radius, 2);
    }
}

이제 AreaCalculator 클래스는 Shapes라는 List 객체를 가지고 각 도형마다 해당하는 area 메서드를 호출해서 면적을 합산할 수 있습니다.

import java.util.List;

public class AreaCalculator {
    private final List<Shape> shapes;

    public AreaCalculator(List<Shape> shapes) {
        this.shapes = shapes;
    }

    public double sum() {
        int sum = 0;
        for (Shape shape : shapes) {
            sum += shape.area();
        }

        return sum;
    }
}

이 코드는 개방-폐쇄 원칙을 따릅니다. AreaCalculator 클래스를 수정하지 않고도 새로운 도형을 추가할 수 있으므로 수정에 관해 닫혀 있으면서 확장에 관해서는 열려 있습니다!

L : 리스코프 치환 원칙(Liskov substitution principle, LSP)

3. L이란 무엇인가?

필수 개념.

  • L은 리스코프 치환 원칙이다.
  • L은 ‘파생 타입은 반드시 기본 타입을 완벽하게 대체할 수 있어야 한다’ 는 의미다.
  • L은 ‘서브클래스의 객체는 슈퍼클랫의 객체와 반드시 같은 방식으로 동작해야 한다’는 사실을 의미한다.
  • L은 타입 변환 후에 뒤따라오는 런타임 타입 식별에 유용한 원칙이다.

실제 답변은 아래와 같은 틀로.

“먼저 SOLID는 ‘밥 삼촌’(uncle bob)이라고 불리는 로버트 C. 마틴(Robert C. Martin)이 처음 발표한 5가지 객체지향 설계(object-orineted design, ODD) 원칙의 약자입니다. L은 SOLID의 세 번째 원칙이며 리스코프 치환 원칙(Liskov substitution principle, LSP)로 알려져 있습니다. 파생 타입은 반드시 기본 타입을 완벽하게 대체할 수 있어야 한다’ 는 걸 의미합니다. 이것은 어떤 클래스를 상속받은 클래스는 오류 없이 애플리케이션 전반에서 사용 가능해야 하는 것을 의미합니다.

더 자세히는 리스코프 치환 원칙은 서브클래스의 객체가 슈퍼클래스의 객체와 반드시 같은 방식으로 동작해야 한다는 의미입니다. 따라서 모든 서브클래스 또는 파생된 클래스는 아무런 문제없이 그들의 슈퍼클래스를 대체할 수 있어야 합니다. 대부분의 경우 이렇게 하면 타입 변환 후에 뒤따라오는 런타임 타입 식별에 유용합니다. 예를 들어 어떤 메서드 foo(p)가 p 는 타입 T라고 하겠습니다. 이때 T의 하위 타입인 S에 대하여 q가 S타입이라면 foo(q) 가 성립해야 합니다.

추가적인 설명이 필요하면 아래와 같이 코드를 작성할 수 있으며 이를 작성할 때도 계속 설명을 이어 나가야 하며 아래와 같은 플로우를 따르자.

‘프리미엄’, ‘VIP’, ‘무료’라는 세 가지 유형의 회원이 있는 체스 동호회가 있다고 가정하겠습니다. 여기에는 기본 클래스 역할을 하는 Member(회원), 추상 클래스와 PremiumMember(프리미엄 회원) VipMember(VIP 회원), FreeMember(무료 회원)의 세 가지 서브클래스가 있습니다. 이 세 가지 서브클래스(회원)의 유형이 기본 클래스를 대체할 수 있는지 살펴보겠습니다.

리스코프 치환 원칙을 따르지 않은 경우

Member 클래스는 추상 클래스이며 체스 동호회의 모든 구성원을 나타내는 기본 클래스입니다.

public abstract class Member {
    private final String name;

    public Member(String name) {
        this.name = name;
    }

    public abstract void joinTournament();
    public abstract void organizeTournament();
}'

PreminumMembber 클래스는 체스 토너먼트에 참가하거나 그러한 토너먼트를 주최할 수 있습니다. 이에 따라 다음과 같이 꽤 간단하겐 구현할 수 있습니다.

public class PremiumMember extends Member {
    public PremiumMember(String name) {
        super(name);
    }
        
    @Override
    public void joinTournament() {
        System.out.println("Premium member joins tournament ...");
    }

    @Override
    public void organizeTournament() {
        System.out.println("Premium member organize tournament ...");
    }
}

VipMember 클래스는 PremiumMember 클래스와 겅의 동일합니다.

public class VipMember extends Member {
    public VipMember(String name) {
        super(name);
    }

    @Override
    public void joinTournament() {
        System.out.println("VIP member joins tournament ...");
    }

    @Override
    public void organizeTournament() {
        System.out.println("VIP member organize tournament ...");
    }
}

FreeMember 클래스는 토너먼트에 참가할 수 있지만 토너먼트를 주최할 수는 없습니다.

이것은 organizeTournament 메서드에서 다뤄야 하는 문제입니다. organizeTourment 메서드에서 의미 있는 문구를 가진 예외를 발생시키거나 혹은 다음과 같이 문구를 그냥 표시할 수도 있습니다.

public class FreeMember extends Member {
    public FreeMember(String name) {
        super(name);
    }

    @Override
    public void joinTournament() {
        System.out.println("Classic member joins tournament ...");
    }

    // 이 메서드는 무료 회원이 토너먼트를 개최할 수 없으므로
    // 리스코프 치환 원칙에 맞지 않습니다. 기본 클래스를 대체할 수 없습니다.
    @Override
    public void organizeTournament() {
        System.out.println("A free member cannot organize tournaments");
    }
}

그러나 예외를 발생시키거나 문구를 표시한다고 리스코프 치환 원칙을 지키는 것은 아닙니다. 무료 회원은 토너먼트를 주최할 수 없으므로 기본 클래스를 대체할 수 없으며 이는 리스코프치환 원칙에 맞지 않습니다. 다음과 같이 회원 리스트에 해당하는 members1을 생성해 보세요.

import coding.challenge.bad.Member;
import coding.challenge.good.TournamentJoiner;
import coding.challenge.good.TournamentOrganizer;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        System.out.println("\nApproach that doesn't follow LSP:\n");

        List<Member> members1 = List.of(
          new coding.challenge.bad.PremiumMember("Jack Hores"),
          new coding.challenge.bad.VipMember("Tom Johns"),
          new coding.challenge.bad.FreeMember("Martin Vilop")
        );

        // 이 코드는 리스코프 치환 원칙에 맞지 않습니다. 무료 회원은 토너먼트를 주최할 수 없습니다.
        for (Member member : members1) {
            member.organizeTournament();
        }

        System.out.println("\nApproach that follow LSP:\n");
	}
}

그리고 다음 for문을 실행해보면 작성했던 코드가 리스코프 치환 원칙에 어긋난다는 것을 알 수 있을 것입니다. FreeMember 클래스가 Member 클래스를 대체할 차례가 왔을 때 FreeMember 클래스는 체스 토너먼트를 주최할 수 없어서 필요한 작업을 할 수 없기 때문입니다.

이러한 상황은 치명적인 결함을 일으킵니다. 이 상태로는 애플리케이션 구현을 계속할 수 없습니다. 리스코프 치환 원칙을 준수하는 코드를 얻으려면 솔루션을 다시 설계해야 합니다. 그럼 한 번 수정해 보겠습니다.

리스코프 치환 원칙을 따르는 경우

체스 토너먼트에 참가하고 주최하는 두 가지 일을 분리하기 위해 두 가지 인터페이스를 정의하는 것으로 리팩터링을 시작하겠습니다.

public interface TournamentJoiner {
    public void joinTournament();
}
public interface TournamentOrganizer {
    public void organizeTournament();
}

다음으로 기본 추상 클래스인 Member에서 앞 두 가지 인터페이스를 다음과 같이 구현합니다.

public abstract class Member implements TournamentJoiner, TournamentOrganizer {
    private final String name;

    public Member(String name) {
        this.name = name;
    }  
}

PremiumMember와 VipMember는 수정하지 않고 Member 기본 클래스를 그대로 상속받도록 하겠습니다. 그러나 FreeMemeber 클래스는 토너먼트를 주최할 수 없기 땜누에 Member 기본 클래스를 상속받지 않을 것입니다. 대신 TournamnetJoiner 인터페이스만 구현하겠습니다.

public class FreeMember implements TournamentJoiner {
    private final String name;

    public FreeMember(String name) {
        this.name = name;
    }

    @Override
    public void joinTournament() {
        System.out.println("Free member joins tournament ...");
    }
}

이제 체스 토너먼트에 참가할 수 있는 회원 리스트 members2 를 다음처럼 정의할 수 있습니다.

List<TournamentJoiner> members2 = List.of(
          new coding.challenge.good.PremiumMember("Jack Hores"),
          new coding.challenge.good.VipMember("Tom Johns"),
          new coding.challenge.good.FreeMember("Martin Vilop")
        );

List 객체 members2에 반복문을 실행하면서 각 회원 유형을 TournamentJoiner 인터페이스로 대체해보면 기대한 방식으로 잘 동작하고 리스코프 치환 원칙도 준수한다는 것을 확인할 수 있습니다.

// 이 코드는 리스코프 치환 원칙을 준수합니다.
        for (TournamentJoiner member : members2) {
            member.joinTournament();
        }

같은 방법으로 체스 토너먼트를 주최할 수 있는 회원리스트 members3를 다음과 같이 정의할 수 있습니다.

List<TournamentOrganizer> members3 = List.of(
          new coding.challenge.good.PremiumMember("Jack Hores"),
          new coding.challenge.good.VipMember("Tom Johns")
        );

FreeMember 는 TournamentOrganizer 인터페이스를 구현하지 않기 때문에 리스트에 포함될 수 없습니다. List 객체 members3에 반복문을 실행하면서 TournamentOrganizer 인터페이스로 대체해 보면 기대한 방식으로 잘 동작하고 리스코프 치환 원칙도 준수한다는 것을 확인할 수 있습니다.

// 이 코드는 리스코프 치환 원칙을 준수합니다.
        for (TournamentOrganizer member : members3) {
            member.organizeTournament();
        }

끝입니다! 이제 모든 코드는 리스코프 치환 원칙을 따릅니다. 아래는 모든 코드를 통합한 Main class입니다.

import example.TournamentJoiner;
import example.TournamentOrganizer;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        System.out.println("\nApproach that doesn't follow LSP:\n");

        List<Member> members1 = List.of(
          new coding.challenge.bad.PremiumMember("Jack Hores"),
          new coding.challenge.bad.VipMember("Tom Johns"),
          new coding.challenge.bad.FreeMember("Martin Vilop")
        );

        // 이 코드는 리스코프 치환 원칙을 준수합니다.
        for (Member member : members1) {
            member.joinTournament();
        }

        System.out.println();
        System.out.println("\nApproach that follow LSP:\n");

        List<TournamentJoiner> members2 = List.of(
          new coding.challenge.good.PremiumMember("Jack Hores"),
          new coding.challenge.good.VipMember("Tom Johns"),
          new coding.challenge.good.FreeMember("Martin Vilop")
        );
        
        List<TournamentOrganizer> members3 = List.of(
          new coding.challenge.good.PremiumMember("Jack Hores"),
          new coding.challenge.good.VipMember("Tom Johns")
        );

        // 이 코드는 리스코프 치환 원칙을 준수합니다.
        for (TournamentJoiner member : members2) {
            member.joinTournament();
        }

        System.out.println();

        // 이 코드는 리스코프 치환 원칙을 준수합니다.
        for (TournamentOrganizer member : members3) {
            member.organizeTournament();
        }
    }
}

I : 인터페이스 분리 원칙(interface segregation principle, ISP)

4. I란 무엇인가?

필수 개념.

  • I는 인터페이스 분리 원칙이다.
  • I는 ‘클라이언트가 사용하지 않을 불필요한 메서드를 강제로 구현하게 해서는 안 된다.’는 의미다.
  • I는 클라이언트가 사용하지 않을 메서드를 강제로 구현하는 일이 없을 때까지 하나의 인터페이스를 2개 이상의 인터페이스로 분할하는 원칙이다.

실제 답변은 아래와 같은 틀로.

“먼저 SOLID는 ‘밥 삼촌’(uncle bob)이라고 불리는 로버트 C. 마틴(Robert C. Martin)이 처음 발표한 5가지 객체지향 설계(object-orineted design, ODD) 원칙의 약자입니다. I는 SOLID의 네 번째 원칙이며 인터페이스 분리 원칙(interface segregation principle, ISP)로 알려져 있습니다. 이 원칙은 클라이언트가 사용하지 않을 불필요한 메서드를 강제로 구현하게 해서는 안 된다는 사실을 의미합니다. 다시 말해 클라이언트가 사용하지 않을 메서드를 강제로 구현하는 일이 없을 때까지 하나의 인터페이스를 2개 이상의 인터페이스로 분할해야 한다는 것을 의미합니다. 예를 들어 connect, socket, http 총 세 가지 메서드를 가지는 Connection 인터페이스가 있다고 가정하겠습니다. 이때 어떤 클라이언트는 HTTP를 통한 연결만을 위해 이 인터페이스를 구현하고 싶을 수도 있습니다. 이런 경우 socket 메서드는 불필요하므로 클라이언트가 이 메서드를 비워둘 것입니다. 이렇게 되면 잘못된 설계입니다. 이러한 상황을 방지하려면 Connection 인터페이스를 2개의 인터페이스로 나누어야 합니다. 즉, socket 메서드를 가지는 SocketConnection 인터페이스와 http 메서드를 가지는 Http-Connection 인터페이스로 나누어야 합니다. 두 인터페이스는 공통된 메서드인 connect를 가지는 Connection 인터페이스를 상속받을 것입니다.

추가적인 설명이 필요하면 아래와 같이 코드를 작성할 수 있으며 이를 작성할 때도 계속 설명을 이어 나가야 하며 아래와 같은 플로우를 따르자.

방금 설명했던 Connection 인터페이스를 가지고 인터페이스 분리 원칙을 따르지 않는 경우부터 살펴보겠습니다.

인터페이스 분리 원칙을 따르지 않은 경우

Connection 인터페이스는 다음과 같이 세 가지 메서드를 정의합니다.

public interface Connection {
    public void socket();
    public void http();
    public void connect();
}

WwwPingConnection 클래스는 서로 다른 웹사이트를 HTTP로 핑(ping)합니다. 따라서 http 메서드는 필요하지만 socket 메서드는 필요 없습니다. 이때 의미 없는 방법으로 socket 메서드를 구현한 것에 주목하세요. WwwPingConnection 클래스는 Connection 인터페이스를 구현하므로 socket메서드도 강제로 구현해야 합니다.

public class WwwPingConnection implements Connection {
    private final String www;

    public WwwPingConnection(String www) {
        this.www = www;
    }

    @Override
    public void http() {
        System.out.println("Setup an HTTP connection to " + www);
    }

    @Override
    public void connect() {
        System.out.println("Connect to " + www);
    }

    // 이 구현은 인터페이스 분리 원칙에 맞지 않습니다. 이 클래스는 socket 메서드가 필요하지 않지만 강제로 재정의해야 합니다.
    @Override
    public void socket() { }
}

socket과 같이 필요 없는 메서드를 빈 칸으로 구현하거나 의미있는 예외를 발생시키는 것은 굉장히 좋지 못한 솔루션입니다. 다음 코드를 확인해 보겠습니다.

Main에 있는 구현 메서드 일부를 보겠습니다.

public class Main {
    public static void main(String[] args) {

        WwwPingConnection www1 = new WwwPingConnection("www.yahoo.com");

        www1.http();
        www1.socket();  // 이 메서드는 아무 것도 하지 않지만 클라이언트는 그것을 알지 못합니다.
        www1.connect();

이 코드로 무엇을 얻을 수 있을까요? 작동하기는 하지만 아무 의미가 없는 코드나, HTTP 엔드포인트가 없기 때문에 connect 메서드에 의해 발생하는 예외 또는 ‘소켓은 지원하지 않습니다!’와 같은 예외를 발생시킬 수도 있을 것입니다. 그렇다면 도대체 이런 메서드는 왜 존재하는 걸까요? 이런 의미없는 메서드를 없애기 위해 인터페이스 분리 원칙에 따라 리팩터링을 해 보겠습니다.

인터페이스 분리 원칙을 따르는 경우

인터페이스 분리 원칙을 준수하려면 Connection 인터페이스를 분리해야 합니다. connect 메서드는 모든 클라이언트에 필요하므로 이 메서드는 Connection인터페이스에 남깁니다.

public interface Connection {
    public void connect();
}

http와 socket 메서드는 다음과 같이 Connection 인터페이스를 확장하는 별도의 인터페이스에 포함됩니다.

public interface HttpConnection extends Connection {
    public void http();
}
public interface SocketConnection extends Connection {
    public void socket();
}

이렇게 하면 WwwPingConnection 클래스는 HttpConnection 인터페이스만 구현하며 http 메서드를 사용할 수 있습니다.

public class WwwPingConnection implements HttpConnection {
    private final String www;

    public WwwPingConnection(String www) {
        this.www = www;
    }

    @Override
    public void http() {
        System.out.println("Setup an HTTP connection to " + www);
    }

    @Override
    public void connect() {
        System.out.println("Connect to " + www);
    } 
}

끝입니다! 이제 모든 코드는 인터페이스 분리 원칙을 따릅니다. 이제 마지막으로 의존관계 역전 원칙에 관해 살펴보겠습니다.

D : 의존관계 역전 원칙(dependency inversion principle, DIP)

5. D란 무엇인가?

필수 개념.

  • D는 의존관계 역전 원칙이다.
  • D는 ‘구체화가 아닌 추상화에 의존해야 한다’ 는 의미다.
  • D는 다른 구상 모듈(concrete modules)에 의존하는 구상 모듈 대신, 구상 모듈을 결합하기 위한 추상 계층을 사용한다는 것을 의미한다.
  • D는 구상 모듈을 분리한다.

“먼저 SOLID는 ‘밥 삼촌’(uncle bob)이라고 불리는 로버트 C. 마틴(Robert C. Martin)이 처음 발표한 5가지 객체지향 설계(object-orineted design, ODD) 원칙의 약자입니다. D는 SOLID의 마지막 원칙이며 의존관계 역전 원칙(dependency inversion principle, DIP)로 알려져 있습니다. 이 원칙은 구체화가 아닌 추상화에 의존해야 한다는 사실을 의미합니다. 다른 구상 모듈에 의존하는 구상 모듈 대신, 구상 모듈을 결합하기 위해 추상 계층에 의존해야 한다는 것을 의미합니다. 이를 위해 모든 구상 모듈은 추상적인 내용만 노출해야 합니다.

이렇게 하면 구상 모듈은 분리된 상태를 유지하면서 다른 구상 모듈의 기능 또는 플러그인을 확장할 수 있습니다. 일반적으로 상위 구상 모듈과 하위 구상 모듈 사이에는 높은 결합이 발생합니다.

추가적인 설명이 필요하면 아래와 같이 코드를 작성할 수 있으며 이를 작성할 때도 계속 설명을 이어 나가야 하며 아래와 같은 플로우를 따르자.

데이터베이스 JDBC URL을 나타내는 PostgreSQLJdbcUrl 클래스를 하위 모듈이라고 하고 데이터베이스와 연결하는 클래스인 ConnectToDatabase를 상위 모듈이라고 가정해서 다음 상황을 살펴 보겠습니다.

의존관계 역전 원칙을 따르지 않은 경우

connect 메서드에 PostgreSQLJdbcUrl 타입의 인수를 넘긴다면 의존관계 역전 원칙을 어기는 것입니다. PostgreSQLJdbcUrl과 ConnectToDatabase 클래스의 코드를 살펴보겠습니다.

public class PostgreSQLJdbcUrl {
    private final String dbName;

    public PostgreSQLJdbcUrl(String dbName) {
        this.dbName = dbName;
    }

    public String get() {
        return "jdbc:postgresql:// ... " + this.dbName;
    }
}
public class ConnectToDatabase {
    public void connect(PostgreSQLJdbcUrl postgresql) {
        System.out.println("Connecting to " + postgresql.get());
    }
}

MySQLJdbcUrl과 같이 다른 JDBC URL 타입을 생성하는 경우라면 connect(PostgreSQLJdbcUrlpostgresql) 메서드를 사용할 수 없습니다. 따라서 구체화에 대한 의존관계를 버리고 추상화에 대한 의존관계를 만들어야 합니다.

의존관계 역전 원칙을 따르는 경우

각 JDBC URL에서 구현해야 하는 인터페이스로 추상화를 나타낼 수 있습니다.

public interface JdbcUrl {
    public String get();
}

다음으로 PostgreSQLJdbcUrl 클래스는 JdbcUrl 인터페이스를 구현하며 PostgreSQL 데이터베이스에 특화된 JDBC URL을 반환합니다.

public class PostgreSQLJdbcUrl implements JdbcUrl {
    private final String dbName;

    public PostgreSQLJdbcUrl(String dbName) {
        this.dbName = dbName;
    }

    @Override
    public String get() {
        return "jdbc:postgresql:// ... " + this.dbName;
    }
}

정확히 같은 방법으로 MySQLJdbcURL 클래스, OrcaleJdbcUrl 클래스 등(직접 해 보기?)을 작성할 수 있습니다.

public class MySQLJdbcUrl implements JdbcUrl {
    private final String dbName;

    public MySQLJdbcUrl(String dbName) {
        this.dbName = dbName;
    }

    @Override
    public String get() {
        return "jdbc:mysql:// ... " + this.dbName;
    }
}

마지막으로 ConnectYoDatabase의 connect 메서드는 JdbcUrl 인터페이스에 의존하기 때문에 이 추상화를 구현하는 모든 JDBC URL에 연결할 수 있습니다.

public class ConnectToDatabase {
    public void connect(JdbcUrl jdbcUrl) {
        System.out.println("Connecting to " + jdbcUrl.get());
    }
}

지금까지 객체지향 프로그래밍의 기본 개념과 널리 사용되는 SOLID 원칙을 살펴보았습니다. 양이 많았지만 그만큼 실질적인 개발의 내용에서 인사이트를 주기도 하고, 룰로 규정되기도 하기에 정리를 해 보았습니다 🙂

다음은 빈출 질문 또는 각종 질문에 관한 정리 시간으로 찾아 오겠습니다.

profile
하루 하루 즐겁게

1개의 댓글

comment-user-thumbnail
2023년 8월 16일

좋은 글 감사합니다. 자주 방문할게요 :)

답글 달기