Home 객체 지향 설계 5가지 원칙, SOLID
Post
Cancel

객체 지향 설계 5가지 원칙, SOLID

‘인터페이스를 통한 의존성 역전’에 대해 찾아보다가, 객체 지향 설계 5가지 원칙 중 DIP에 대한 내용임을 알게 되었다. 그래서 나머지 4가지 원칙도 함께 살펴보고, 정리해보고자 한다!

SOLID란?!

SOLID란 객체 지향 설계에서 지켜야 할 5가지 소프트웨어 개발 원칙을 말한다.

이 5가지 원칙을 적용한다면 코드의 유지보수성, 확장성, 유연성, 재사용성을 향상시킬 수 있다.

소프트웨어란 변화에 무엇보다도 민감하고, 이 변화에 대응하기 위해서는 유지보수성이 무엇보다도 중요하다. 그렇기 때문에, SOLID 원칙에서는 모두 유지보수의 중요성을 강조하고 있다.

유지보수가 무엇보다 중요하다는 것을 기억해두고, 이제 각각의 다섯가지 원칙에 대해 자세히 알아보자.

단일 책임 원칙 (Single Responsibility Principle, SRP)

하나의 클래스는 단 하나의 책임만 가져야 한다는 원칙이다.

쉽게 말하면, 하나의 클래스에 모조리 다 때려넣지 말고, 하나의 기능만 담당해 집중하도록 클래스를 분리해 설계하라는 원칙인 것이다.

하나의 클래스가 여러 기능을 가지고 있을 때, 한 기능의 변경으로 인해 이와 연관된 다른 기능의 변경으로 이어지는 연쇄 작용이 일어나게 된다. ( 예를 들어, A를 수정했더니 B를 수정해야 하고, B를 수정했더니 C를 수정하고, 다시 C를 수정했더니 A를 수정하게 되는.. 끝이 없는 순환이 일어날 수 있다는거다! 😂 )

클래스가 하나의 책임만 가진다면, 변경하는 이유는 단 하나 뿐이다. 이는 코드의 변경을 관리하고 이해하기 쉽게 만든다.

SRP 원칙 코드로 알아보기

먼저 단일 책임 원칙을 적용하지 않고 작성한 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
public class User {
    public void saveToDatabase() {
        // 데이터베이스 저장 로직
    }

    public void sendEmail() {
        // 이메일 발송 로직
    }
}

User라는 클래스에서 데이터베이스에 저장하는 로직과 이메일을 발송하는 로직 두 가지의 책임을 가진다.

User라는 클래스는 User와 관련된 하나의 책임만 가지되, 유저 저장 로직은 데이터베이스와 관련된 클래스에서, 이메일 발송 로직은 이메일 관련 클래스에서 담당하도록 변경하는 것이 좋다.

위 예시에 단일 책임 원칙을 적용하면 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class User {
	private String nickname;
	
	public void changeNickname(String nickname) {
		this.nickname = nickname;
	}
}

public class UserRepository {
    public void save(User user) {
        // 데이터베이스 저장 로직
    }
}

public class EmailService {
    public void sendEmail(User user) {
        // 이메일 발송 로직
    }
}

닉네임을 변경하는 로직은 User 클래스에, 유저 저장 로직은 UserRepository 클래스에, 이메일 발송 로직은 EmailService 클래스로 모두 분리하여, 각각의 클래스는 하나의 책임을 가지게 되었다.

잠깐, 그러면 그 ‘책임’의 기준은 누가 정해요?!

한 클래스에서는 하나의 책임만 담당하도록 해야 한다고, 그러니까 한 클래스에 많은 기능을 넣지 말라고 했다. 그러면 클래스에 기능을 얼마나 넣어주는게 많이 넣는거고, 적게 넣어주는걸까? 책임의 범위와 기준은 어떻게 정해야 할까?

무책임한 말 같지만, 그건 내가 정해야 한다!^ㅡ^ 남한테 물어보지 말고ㅎ

‘책임’이라는 게 기준이 모호하기 때문에, 사람마다 생각하는게 모두 다르고, 상황에 따라서도 달라질 수 있다.

그래서 변경을 책임의 기준으로 삼는 것이 좋다. 변경이 발생했을 때 변경의 파급 효과가 적으면 유지보수가 용이하기 때문에, 이에 맞게 내가 생각하는 범위 내에서 기준을 정하는 게 좋다는 의미이다. (처음 이야기했던 것처럼, 변화에 대응하기 위해서는 유지보수의 용이성이 무엇보다도 중요하므로! 😉)

개방-폐쇄 원칙 (Open Closed Principle, OCP)

소프트웨어 구성 요소(클래스, 모듈, 함수 등)확장에는 열려있고, 변경에는 닫혀있어야 한다는 원칙이다.

변경사항이 발생하는 것은 소프트웨어 개발자에게는 숙명이다. 그렇기 때문에, 변경사항이 발생했을 때, 유연하게 코드를 추가하고 수정할 수 있는 것이 중요하다!

  • 확장에 열려있다 ⇒ 변경사항이 발생했을 때, 유연하게 코드를 추가하고 수정할 수 있다.
  • 변경에 닫혀있다 ⇒ 객체를 직접 수정하지 않고, 변경 사항을 적용할 수 있다.

즉, 새로운 기능이 추가될 때 기존 코드를 직접 수정하지 않고, 유연하게 수정할 수 있어야 한다는 원칙인 것이다.

결국에는 재사용성을 높여 유지보수 용이성을 높이자는 것! (여기서도 유지보수가 중요하다는 걸 알 수 있죠 😉 )

개방 폐쇄 원칙 = 추상화?!

결국 개방-폐쇄 원칙은 추상화를 의미하는 것으로도 볼 수 있다.

클래스를 추가할 때, 기존 코드를 크게 수정할 필요 없이 상속 관계에 맞춰 적절히 클래스를 추가하면 확장에 유연한 코드를 짤 수 있었다.

즉, 객체를 추상화해 사용하면서 확장엔 열려있고 변경엔 닫혀있는 유연한 구조를 만들어 사용했고, 자연스레 OCP 원칙의 효과를 이용해왔던 것이다 😊

OCP 원칙 코드로 알아보기

계속해서 예시를 살펴보자. 여러 형식의 보고서를 생성하는 프로그램을 구현한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ReportGenerator {
    public void generateReport(String reportType) {
        if (reportType.equals("PDF")) {
            generatePDFReport();
        } else if (reportType.equals("HTML")) {
            generateHTMLReport();
        } else if (reportType.equals("CSV")) {
            generateCSVReport();
        }
    }

    private void generatePDFReport() {
        // PDF 보고서 생성 로직
    }

    private void generateHTMLReport() {
        // HTML 보고서 생성 로직
    }

    private void generateCSVReport() {
        // CSV 보고서 생성 로직
    }
}

ReportGenerator는 PDF, HTML, CSV 타입을 입력받아 각 형태의 보고서를 생성한다.

그런데, XML 타입의 보고서를 생성하는 로직이 추가되어야 한다면? 다음과 같이 ReportGenerator 클래스가 다음과 같이 수정되어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ReportGenerator {
    public void generateReport(String reportType) {
        if (reportType.equals("PDF")) {
            generatePDFReport();
        } else if (reportType.equals("HTML")) {
            generateHTMLReport();
        } else if (reportType.equals("CSV")) {
            generateCSVReport();
        } else if (reportType.equals("XML")) {
            generateXMLReport();
        }
    }

    private void generatePDFReport() {
        // PDF 보고서 생성 로직
    }

    private void generateHTMLReport() {
        // HTML 보고서 생성 로직
    }

    private void generateCSVReport() {
        // CSV 보고서 생성 로직
    }
    
    private void generateXMLReport() {
        // XML 보고서 생성 로직
    }
}

이렇게 코드가 구성되면 다른 타입의 보고서를 생성하는 로직이 추가될 때마다, if문이 분기되는 코드가 계속해서 길게 추가되어야 한다.

이 코드를 OCP 원칙을 적용해 다음과 같이 변경해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 보고서 생성 인터페이스
public interface ReportGenerator {
    void generateReport();
}

// PDF 보고서 생성 클래스
public class PDFReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // PDF 보고서 생성 로직
    }
}

// HTML 보고서 생성 클래스
public class HTMLReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // HTML 보고서 생성 로직
    }
}

// CSV 보고서 생성 클래스
public class CSVReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // CSV 보고서 생성 로직
    }
}

public class ReportService {
    public void generateReport(ReportGenerator reportGenerator) {
        reportGenerator.generateReport();
    }
}

public class Main {
    public static void main(String[] args) {
        ReportService reportService = new ReportService();
        // CSV 보고서 생성
        reportService.generateReport(new CSVReportGenerator());
        // HTML 보고서 생성
        reportService.generateReport(new HTMLReportGenerator());
    }
}
  • 각각의 보고서 형식은 ReportGenerator 인터페이스를 구현하는 별도의 클래스로 분리했다.
  • ReportService 클래스에는 ReportGenerator 타입의 파라미터를 받아 보고서를 생성하는 메서드인 generateReport()가 구현되어 있다.
    ⇒ 각 보고서 생성 클래스는 ReportGenerator 인터페이스를 구현했으므로, generateReport() 메서드의 파라미터에 각각의 보고서 생성 클래스를 넘겨주면 된다.

만약 위에서 말했던 것처럼 새로운 타입의 보고서를 생성하는 로직이 추가된다면 어떻게 하면 될까?

간단하게 ReportGenerator 인터페이스를 구현하는 클래스를 하나 만들고, 이를 기존과 동일한 방식으로 사용하기만 하면 된다! 😉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 보고서 생성 인터페이스
public interface ReportGenerator {
    void generateReport();
}

// PDF 보고서 생성 클래스
public class PDFReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // PDF 보고서 생성 로직
    }
}

// HTML 보고서 생성 클래스
public class HTMLReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // HTML 보고서 생성 로직
    }
}

// CSV 보고서 생성 클래스
public class CSVReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // CSV 보고서 생성 로직
    }
}

// XML 보고서 생성 클래스
public class XMLReportGenerator implements ReportGenerator {
    @Override
    public void generateReport() {
        // XML 보고서 생성 로직
    }
}

public class ReportService {
    public void generateReport(ReportGenerator reportGenerator) {
        reportGenerator.generateReport();
    }
}

public class Main {
    public static void main(String[] args) {
        ReportService reportService = new ReportService();
        // CSV 보고서 생성
        reportService.generateReport(new CSVReportGenerator());
        // HTML 보고서 생성
        reportService.generateReport(new HTMLReportGenerator());
        // XML 보고서 생성
        reportService.generateReport(new XMLReportGenerator());
    }
}

추가 사항에 대해 새 클래스를 추가하되, 기존 코드의 변경은 전혀 하지 않으면서 변경에 대응할 수 있었다. 이것이 바로 OCP 원칙의 장점이다 😉

리스코프 치환 원칙 (Liskov Substitution Principle, LSP)

객체는 프로그램의 정확성을 해치지 않으면서 자식 객체로 치환할 수 있어야 한다는 원칙이다.

서브 타입은 언제나 기반 타입으로 교체 가능해야 한다는 의미인데, ‘교체 가능하다’는 건 다음과 같은 의미이다.

자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위는 수행이 보장되어야 한다.

다시 말하면, 부모 클래스를 사용하는 곳에서 자식 클래스를 대신 사용했을 때, 원래 의도대로 코드가 동작해야 한다는 것이다.

리스코프 치환 원칙 = 다형성!

앞에서 복잡하게 이야기했지만, 간단히 말하면 LSP 원칙은 다형성을 지원하기 위한 원칙인 것이다.

다형성과 확장성을 극대화하기 위해서는 하위 클래스 사용보다는 상위 클래스(인터페이스)를 사용하는 것이 더 좋다. 이는 다시 말하면, 하위 클래스 대신 상위 클래스를 사용해도 문제가 없이 돌아갈 수 있도록 해야 한다는 의미이고, 이는 리스코프 치환 원칙에서 설명하는 것과 같음을 알 수 있다 😊

Java의 Collection Framework

LSP 원칙을 가장 잘 적용한 예시가 바로 Java의 Collection Framework 이다.

ArrayList에 add()로 아이템을 추가하는 메서드가 있을 때, 이 ArrayList를 LinkedList로 변경하고 싶은 경우를 생각해보자. add() 라는 메서드를 사용하는 부분은 변경하지 않고, ArrayList에서 LinkedList로 타입만 변경할 수 있다.

List 타입으로 해당 변수를 선언하고, 그 변수에 ArrayList를 선언해 사용하다가 LinkedList로 변경해 할당해주기만 하면 된다!

이는 Collection 인터페이스의 추상 메서드를 ArrayList, LinkedList와 같은 하위 클래스에서 implement하여 인터페이스 규약을 잘 지켜 구현하도록 설계해두었기 때문에 가능한 일이다.

아무튼 결론은, 우리도 모르는 사이에 Java를 사용하면서 사용해 온 ‘다형성’이라는 개념을 원칙으로 정의한 것이 LSP 원칙이라고 이해하면 된다 😄

LSP 원칙 코드로 알아보기

사각형(Rectangle) 클래스와 정사각형(Square) 클래스를 구현한 예시를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

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

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height;
    }
}

Square 클래스는 Rectangle 클래스를 상속받아 구현하였다.

정사각형은 네 변의 길이가 같으므로 너비와 높이가 같다. 그래서 Square 클래스에서 setWidth()와 setHeight를 오버라이딩할 때, 파라미터로 받은 값을 너비(width)와 높이(height) 변수에 동일하게 할당하도록 구현하였다.

만약 Rectangle 클래스를 사용하는 코드를 Square 클래스로 변경한다면 어떤 일이 발생할까?

1
2
3
4
5
6
7
8
public static void main(String[] args) {
    // Rectangle rect = new Rectangle();
    Rectangle rect = new Square();
    rect.setWidth(5);
    rect.setHeight(10);
    
    System.out.println("Area: " + rect.getArea());
}

Square 클래스로 대체되었더라도 getArea() 메서드 호출로 동일한 값인 50이 반환되어야 한다. 하지만 전혀 다른 값인 100이 반환되었다.

setHeight() 메서드 호출로 너비와 높이가 모두 10으로 할당되었고, 이로 인해 넓이가 100으로 반환된 것이다.

Square 클래스는 잘못된 객체를 상속받은 것이다. 정사각형과 직사각형은 상속 관계가 성립될 수 없는 관계인 것이다! Rectangle 클래스를 잘못 상속받아 올바르게 확장되지 못했고, 이로 인해 자식 객체가 부모 객체의 동작을 완전히 대체하지 못해 기대하는 결과 값이 반환되지 않았다. 이는 LSP 원칙을 위배하는 경우이다.

그러면 이 잘못된 예시를 LSP 원칙을 적용하여 수정해보자.

이 경우, 올바른 상속 관계를 다시 성립해야 한다. 즉, 정사각형과 직사각형 클래스가 더 큰 범주의 클래스를 상속받을 수 있도록 상속 관계를 수정하면 된다.

사각형 객체를 구현하고, 정사각형과 직사각형 클래스가 이를 상속받아 구현될 수 있도록 수정해보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class Shape {
    private int width;
    private int height;
    
    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

// 직사각형 클래스
public class Rectangle extends Shape {
    public Rectangle(int width, int height) {
        setWidth(width);
        setHeight(height);
    }
}

// 정사각형 클래스
public class Square extends Shape {
    public Square(int side) {
        setWidth(side);
        setHeight(side);
    }
}

public static void main(String[] args) {
    // Rectangle rect = new Rectangle();
    Shape rectangle = new Rectangle(10, 5);
    Shape square = new Square(5);
    
    System.out.println("Rectangle Area: " + rectangle.getArea());
    System.out.println("Square Area: " + squre.getArea());
}

Shape 클래스를 두고, Rectangle 클래스와 Square 클래스가 해당 클래스를 상속받아 구현하도록 수정했다. 그리고 main 메서드에서 Shape 타입으로 Rectangle과 Square을 선언해 할당하여 사용하도록 하였다.

위와 같이 코드를 수정하면, 우리가 기대했던대로 각 사각형의 넓이가 잘 반환됨을 알 수 있다.

인터페이스 분리 원칙 (Interface Segregation Principle, ISP)

큰 인터페이스 하나를 여러 개의 작은 인터페이스로 분리하여 클라이언트가 필요한 기능만을 제공받을 수 있도록 해야 한다는 원칙이다.

인터페이스를 작고 구체적으로 쪼개서, 불필요한 메서드에 의존하지 않도록 하는 것이 목적이다.

SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP 원칙은 인터페이스의 단일 책임을 강조하는 것으로 볼 수 있다.

인터페이스 분리는 처음 한 번만! 🤔

ISP 원칙을 적용하기 위해 인터페이스를 분리해 구성해두었다고 하자. 근데 그러다가 나중에 무언가 수정사항이 생겨서 이에 맞게 또 인터페이스를 분리하면 안된다.

인터페이스를 또 분리하게 되면, 이미 해당 인터페이스를 구현하여 사용하고 있는 많은 클래스들과 이를 사용하는 클라이언트에서 문제가 연쇄적으로 일어날 수 있기 때문이다.

한 번 구성한 인터페이스는 웬만해서 변해서는 안된다! 그래서 처음 설계 시 기능의 변화를 염두에 두고 인터페이스를 설계하는 것이 중요하지만..! 쉽지 않다 🥹

ISP 원칙 코드로 알아보기

다양한 기능을 지원하는 프린터 인터페이스를 설계하는 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public interface Printer {
    void printDocument(String document);
    void scanDocument(String document);
    void faxDocument(String document);
}

public class SimplePrinter implements Printer {
    @Override
    public void printDocument(String document) {
        // 문서 출력 로직
    }

    @Override
    public void scanDocument(String document) {
        // 지원하지 않는 기능
        throw new UnsupportedOperationException("Scan not supported");
    }

    @Override
    public void faxDocument(String document) {
        // 지원하지 않는 기능
        throw new UnsupportedOperationException("Fax not supported");
    }
}

Printer 인터페이스에는 문서를 인쇄하고, 스캔하고, 팩스를 보내는 기능이 있다.

단순히 문서를 인쇄하는 기능만을 포함하는 SimplePrinter 클래스를 만들려고 한다. 그래서 Printer 인터페이스를 implements 하고, 각 추상 메서드를 구현하였다.

하지만 여기서 문제가 있다. SImplePrinter는 문서를 인쇄하는 기능만 구현하면 되는데, 지원하지 않는 문서 스캔 기능과 팩스 전송 기능 또한 구현해야만 한다. 그래서 해당 기능을 구현하는 메서드에는 Exception을 발생시키도록 하였다.

이렇게 필요 없는 기능을 강제로 구현해야 하는 낭비가 발생하고 있기 때문에, 인터페이스를 더 작게 쪼개, 지원하는 기능만 구현하도록 하는 것이 옳다.

인터페이스를 기능에 맞게 작게 쪼개고, 이렇게 작게 분리된 인터페이스를 지원하는 기능만을 선별해 implements 하도록 해보자.

Print는 인쇄, 스캔, 팩스 전송 기능을 지원하므로 각 기능을 Printable, Scannable, Faxable 세 가지의 인터페이스로 잘게 쪼갠다. 그리고 SimplePrinter가 지원하는 인쇄 기능만을 구현할 수 있도록 SimplePrinter 클래스는 Printable만을 implements 하도록 한다.

그리고 만약 세 기능을 모두 지원하는 복합기 클래스를 만들고자 한다면, 각 세 가지의 인터페이스를 모두 implements하는 MultiFunctionPrinter 클래스를 만들면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public interface Printable {
    void printDocument(String document);
}

public interface Scannable {
    void scanDocument(String document);
}

public interface Faxable {
    void faxDocument(String document);
}

public class SimplePrinter implements Printable {
    @Override
    public void printDocument(String document) {
        // 문서 출력 로직
    }
}

public class MultiFunctionPrinter implements Printable, Scannable, Faxable {
    @Override
    public void printDocument(String document) {
        // 문서 출력 로직
    }

    @Override
    public void scanDocument(String document) {
        // 문서 스캔 로직
    }

    @Override
    public void faxDocument(String document) {
        // 문서 팩스 로직
    }
}

이렇게 ISP 원칙을 적용하여 코드를 수정하면, 필요 없는 추상 메서드를 구현해야 하는 낭비를 줄일 수 있다. 그리고 새로운 요구사항이 있을 때, 손쉽게 요구사항을 반영할 수 있다.

의존성 역전 원칙 (Dependency Inversion Principle, DIP)

상위 모듈이 하위 모듈에 의존하지 않고, 둘 다 추상화에 의존하도록 하여 의존성을 역전시키는 원칙이다.

쉽게 말해서, 어떤 클래스에서 특정 클래스를 사용하려는데 그 클래스가 구현 클래스인 경우, 그 클래스를 직접 사용하지 말고 그 클래스의 상위 요소(추상 클래스 or 인터페이스)를 사용하라는 의미이다.

어, 결국 그러면 ‘추상화’를 이용하라는 건데?

맞다! 구체적인 것에 의존하지 말고 추상적인 것에 의존하라는 말은, 결국 추상화를 이용하라는 말과 같다고 볼 수 있다.

상위 클래스일수록, 인터페이스일수록, 추상 클래스일수록 변하지 않을 가능성이 높기 때문에, 하위 클래스나 구체 클래스가 아닌 그 상위 요소들에 의존하라는 것이다. 즉, 의존 관계를 맺을 때는 자주 변하는 것보다는, 잘 변하지 않는 것에 의존하는 것이 좋다.

그래서 사실 DIP 원칙은 우리가 앞서 살펴봤던 OCP 원칙과도 긴밀한 관계가 있다!

DIP 원칙 코드로 알아보기

결제 시스템을 설계하는 예시를 들어보겠다.

PaymentService에서는 Paypal을 통한 결제를 지원한다. 아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class PaypalPaymentProcessor {
    public void processPayment(double amount) {
        // PayPal 결제 처리 로직
        System.out.println("Processing payment of $" + amount + " via PayPal.");
    }
}

class PaymentService {
    private PaypalPaymentProcessor paymentProcessor;

    public PaymentService() {
        paymentProcessor = new PaypalPaymentProcessor();
    }

    public void makePayment(double amount) {
        paymentProcessor.processPayment(amount);
    }
}

public class Main {
    public static void main(String[] args) {
        PaymentService paymentService = new PaymentService();
        paymentService.makePayment(100.0);
    }
}

PaymentService 클래스에서 PaypalPaymentProcessor 클래스를 사용하여 결제하도록 코드를 작성했다. 하지만 여기서 문제는 PaymentService 클래스가 PaypalPaymentProcessor 라는 구체 클래스에 의존하고 있다는 점이다.

이 경우 만약 결제 방식이 신용카드 결제로 변경된다면, PaymentService 클래스의 코드를 모두 변경해야 하는 문제가 발생한다.

이 코드를 DIP 원칙을 적용하여 수정해보자.

PaypalPaymentProcessor 라는 구체 클래스에 의존하는 것이 아닌, 추상화된 인터페이스를 하나 생성하여 해당 인터페이스에 의존하도록 변경해보겠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
interface PaymentProcessor {
    void processPayment(double amount);
}

class PaypalPaymentProcessor implements PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        // PayPal 결제 처리 로직
        System.out.println("Processing payment of $" + amount + " via PayPal.");
    }
}

class CreditCardPaymentProcessor implements PaymentProcessor {
    @Override
    public void processPayment(double amount) {
        // 신용카드 결제 처리 로직
        System.out.println("Processing payment of $" + amount + " via Credit Card.");
    }
}

class PaymentService {
    private PaymentProcessor paymentProcessor;

    public PaymentService(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    public void makePayment(double amount) {
        paymentProcessor.processPayment(amount);
    }
}

public class Main {
    public static void main(String[] args) {
        // PayPal 결제
        PaymentProcessor paypalProcessor = new PaypalPaymentProcessor();
        PaymentService paypalPaymentService = new PaymentService(paypalProcessor);
        paypalPaymentService.makePayment(100.0);

        // 신용카드 결제
        PaymentProcessor creditCardProcessor = new CreditCardPaymentProcessor();
        PaymentService creditCardPaymentService = new PaymentService(creditCardProcessor);
        creditCardPaymentService.makePayment(200.0);
    }
}

PaymentProcessor 인터페이스를 만들고, 해당 인터페이스를 구현하는 PaypalPaymentProcessor 클래스와 CreditCardPaymentProcessor를 만들었다.

그리고 이를 사용하는 PaymentService는 PaymentProcessor라는 상위 요소(인터페이스)에 의존하도록 수정했다. 그리고 생성자를 통해 필요한 결제 처리기를 파라미터로 받아 주입하도록 했다.

마지막으로, main 메서드에서 결제 로직을 구현할 때, PaymentService에 각 인터페이스를 구현한 PaypalPaymentProcessor와 CreditCardPaymentProcessor를 만들어 생성해 결제하는 로직을 구현하였다.

이렇게 구체적인 결제 처리기 구현체에 직접 의존하지 않고 상위 인터페이스에 의존하며, 다른 결제 방식을 도입할 때 필요에 따라 쉽게 다른 결제 처리기를 적용하여 사용하도록 할 수 있었다!

참고

This post is licensed under CC BY 4.0 by the author.

Java의 final, static 키워드

싱글톤 패턴 (Singleton Pattern)