3. 오브젝트와 의존관계1

1. 의존관계

  • 의존관계는 반드시 두 개 이상의 대상이 존재하고, 하나의 대상이 다른 대상을 사용, 호출, 생성, 인스턴스화, 전송 등을 할 때 의존관계가 있다고 이야기한다.

    • 쉽게 생각해서 하나의 객체에 다른 객체를 사용하기 위해 내부적으로 변수를 정의하고 있다면 의존관계가 있다고 이야기한다.

  • 클래스 사이에 의존관계가 있을 때 의존 대상이 변경되면 이를 사용하는 클래스의 코드도 영향을 받는다.

  • 클래스 레벨의 의존관계는 코드를 보면 알 수 있다.

  • 오브젝트 간 의존관계는 런타임에 이루어지기 때문에, 코드를 보아도 알 수 없는 경우가 있다.

    • 오브젝트 간 의존관계는 인터페이스를 통해서 코드에 보여지기 때문에, 명확하게 알 수 없는 경우가 있다.

예시 코드

  • 아래 코드는 의존관계가 매우 강하게 연결되어 있다.

  • PaymentService.prepare() 메서드에서 환율정보를 가져오고, 원화로 계산하는 모든 기능을 담당하고 있다.

package spring.hellospring;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public class Payment {
    private Long orderId;
    private String currency;
    private BigDecimal foreignCurrencyAmount;
    private BigDecimal exRate;
    private BigDecimal convertedAmount;
    private LocalDateTime validUntil;

    public Payment(Long orderId, String currency, BigDecimal foreignCurrencyAmount, BigDecimal exRate, BigDecimal convertedAmount, LocalDateTime validUntil) {
        this.orderId = orderId;
        this.currency = currency;
        this.foreignCurrencyAmount = foreignCurrencyAmount;
        this.exRate = exRate;
        this.convertedAmount = convertedAmount;
        this.validUntil = validUntil;
    }

    // getter, setter, toString
}

2. 관심사의 분리 (Separation of concerns)

  • 관심사는 동일한 이유로 변경되는 코드의 집합으로 현재 PaymentService 로직에는 2개의 관심사가 있다.

    • 관심사1 : 환율정보 가져오기

    • 관심사2 : 가져온 환율을 기준으로 Payment 오브젝트 리턴

  • 한 메서드 안에 여러개의 관심사가 있다면 분리한다.

리팩터링

리팩터링은 기존의 코드를 외부의 동작방식에는 변화 없이 내부 구조를 변경해서 재구성하는 작업 또는 기술을 말한다.

리팩토링을 하면 코드 내부의 설계가 개선되어 코드를 이해하기가 더 편해지고, 변화에 효율적으로 대응할 수 있다. 결국, 생산성은 올라가고, 코드의 품질은 높아지며, 유지보수하기 용이해지고, 견고하면서도 유연한 제품을 개발할 수 있다.

리팩토링이 필요한 코드의 특징을 나쁜 냄새라고 부르기도 한다. 대표적으로, 중복된 코드는 매우 흔하게 발견되는 나쁜 냄새다.

메서드 추출(extract method) 리팩터링

  • PaymentService.prepare() 매서드 내 2가지 책임

    • 관심사1 : 환율정보 가져오기

    • 관심사2 : 가져온 환율을 기준으로 Payment 오브젝트 리턴

  • 메서드 추출을 통해서 한 메서드 내에 가지고 있는 관심을 분리해주었다.

3. 상속을 통한 확장

  • 하지만 조금 더 큰 관점에서 바라보자면, 여러 관심사를 하나의 클래스에서 처리하는 것은 유지보수 측면에서 바람직하지 못하다.

  • 때문에, 상속을 통한 확장을 통해서 환율 정보를 가져오는 메서드를 다른 클래스로 분리해보자.

  • 아 그리고, PaymentSerivce 를 실행하는 관심사도 다른 클래스(Client)로 분리해보자.

이후부터는 상속을 통한 클래스 구현으로 환율을 가져오는 정책을 변경할 수 있다.

고정된 환율을 가져오는 SimpleExRatePaymentService 를 만들어보자.

  • 문제 없이 사용되는 것을 볼 수 있다.

문제점

  • 상속은 관심사가 다른 코드를 분리해내고, 서로 독립적으로 변경 또는 확장할 수 있도록 만드는 간단하면서도 효과적인 방법이다.

  • 하지만, 상속은 매우 큰 단점이 있다.

  • 자바는 다중상속을 허용하지 않는다. 따라서 또 다른 관심사를 분리할 경우, 기능을 확장하기 매우 어렵다!

    • 예를 들어, VIP 에게 추가 환율을 적용해주는 정책이 추가되면 해당 기능을 구현하는 메서드를 추가해야 한다.

    • 해당 정책을 사용하지 않는 서브 클래스 또한, 해당 메서드를 구현해야 한다.

  • 또, 상속은 상하위 클래스의 관계가 매우 밀접하다. 상위 클래스의 변경에 따라 하위 클래스를 모두 변경하는 상황이 발생할 수 있다.

    • 유연성이 높은 설계는 결합도를 낮추는 것이 중요하다.

  • 디자인 패턴에 일부 적용된 특별한 목적을 위해서 사용하는 경우가 아니라면, 상속을 통한 확장은 관심사 분리에 사용하기에는 분명한 단점이 있다. (상속의 한계)

4. 클래스 분리

  • 단순히 상속을 사용하는 것은 부모, 자식 클래스 간 강한 결합이 발생한다는 점이 큰 단점이다.

  • 관심사에 따라서 클래스를 분리해서 각각 독립적으로 사용할 수도 있다.

    • 예를 들어, 환율정보를 가져오는 관심사를 클래스 분리를 통해 각각 독립적으로 사용할 수 있다.

여기서 한발자국 더 나아가보자, PaymentService 입장에서 환율을 가져오는 로직은 필수적이다 때문에, 해당 정책을 관리하는 인스턴스 변수를 만들고 생성자를 통해서 초기화해주자.

문제점

  • 결과적으로 클래스 레벨에 사용 의존관계가 만들어지기 때문에, 강한 코드 수준의 결합이 생긴다. 실제로 사용할 클래스가 변경되면 이를 이용하는 쪽에서도 변경이 일어나게 된다. 상속을 통한, 확장 처럼 유연한 확장도 불가능해진다..

  • 상속한 것이 아니기 때문에 사용하는 클래스의 메서드 이름과 구조도 제각각일 수 있다. 그래서 클래스가 변경되면 많은 코드가 따라서 변경되어야 한다.

  • 클래스가 다르다는 것을 제외하면 관심사의 분리가 잘 된 방법은 아니다..

5. 인터페이스 도입

  • 현재 PaymentService 입장에서 환율을 가져오는 방법이 변경되면 아예 사용하는 코드가 달라질 수 있다.

    • 목적은 같지만, 동작의 일관성이 없을 수 있다..

  • 환율을 가져오는 클래스는 인터페이스를 상속해 PaymentService 입장에서는 동일한 인터페이스의 메서드를 사용하도록 한다. 때문에 PaymentService 가 의존하는 클래스가 변경되더라도 사용하는 메서드 이름의 변경이 일어나지 않는다.

문제점

  • 하지만, 클래스 인스턴스는 만드는 생성자를 호출하는 코드에는 클래스 이름이 등장하기 때문에, 사용하는 환율 정보를 가져오는 클래스가 변경되면 PaymentService 의 코드도 일부분이지만 따라서 변경되어야 한다.

    • PaymentService 에서는 환율을 가져오는 구현체와 강력한 결합도를 가진다.

  • 여전히 상속을 통한 확장만큼의 유연성도 가지지 못한다.

6. 관계설정 책임의 분리

인터페이스를 사용했을 때 단점

  • 앞에서 ExRateProvider 인터페이스를 사용하도록 작성했지만, 구현 클래스에 대한 정보를 가지고 있다면, PaymentService 여전히 ExRateProvider 를 구현한 특정 클래스에 의존하는 코드가 된다.

  • 자신이(PaymentService) 어떤 클래스의 오브젝트를 사용할지를 결정한다면 관계설정 책임을 직접 가지고 있는 것이다.

  • 이 관계설정 책임을 자신을 호출하는 앞의 오브젝트에게(Client) 넘길 수 있다. 이렇게 되면 코드 레벨의 의존관계에서 자유로워진다. 이후에는 오직 인터페이스에만 의존하는 코드가 되기 때문에, 구현 클래스의 오브젝트를 사용하게 되더라도 PaymentService 의 코드가 변경되지 않는다.

  • 관계설정의 책임을 가진 앞의 클래스(Client) 는 생성자를 통해서 어떤 클래스의 오브젝트를 사용할지 결정한 것을 전달해주면 된다.

7. 오브젝트 팩토리

  • 현재 Client 는 클라이언트로서의 책임과 PaymentService 의 관계를 설정해주는 2가지의 책임을 가지고 있다.

  • 관계설정의 책임은 다른 클래스로 넘겨주도록 하자.

    • 결합도가 낮은 설계는 하나의 클래스에 하나의 책임만 있어야 한다는 것이다.

  • ObjectFactory 클래스를 만들어 관계설정의 책임을 부여하자.

8. 원칙과 패턴

원칙은 객체지향의 원칙, 패턴은 디자인 패턴을 의미한다.

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

  • 클래스나 모듈은 확장에 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

  • 클래스가 기능을 확장할 때 클래스의 코드는 변경되지 않아야 한다.

  • 아래 그림을 볼 때 ExRateProvider 의 기능이 변경(확장) 되더라도, PaymentService 의 코드는 변경되지 않는다.

    • 아래 그림에서는 ExRateProvider 의 구현체를 Client 에서 넣어준다.

높은 응집도와 낮은 결합도(High Coherence and low coupling)

  • 응집도가 높다는 것은 하나의 모듈이 하나의 책임 또는 관심사에 집중되어 있다는 뜻. 때문에, 변화가 일어날 때 해당 모듈에서 변하는 부분이 크다.

    • 응집도가 높은 코드는 코드의 변경이 일어날 시, 다른 코드에 영향도가 적다.

    • 유지보수 비용이 적다!

  • 책임과 관심사가 다른 모듈과는 낮은 결합도, 즉 느슨하게 연결된 형태를 유지하는 것이 좋다.

전략 패턴(Strategy Pattern)

  • 자신의 기능 맥락(Context) 에서, 필요에 따라서 변경이 필요한 알고리즘을 인터페이스를 통해 통째로 외부로 분리시키고, 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라서 바꿔 사용할 수 있게 하는 디자인 패턴

  • 우리 코드에서는 PaymentService 라는 맥락 안에서 ExRateProvider 에 해당 하는 전략을 Client 가 수정하여 사용할 수 있다.

  • 다른 예로, 아래처럼 Collections.sort() 를 사용할 때 다양한 전략들을 넣어주어 사용할 수 있다.

    • 전략은 아마 함수형 인터페이스이지 않을까?

제어의 역전(Inversion of Control)

  • 제어권 이전을 통한 제어관계 역전 (프레임워크 기본동작 원리)

  • 아래 그림과 같이 우리는 코드를 발전시켜 나가면서 PaymentService 에 있던 객체 생성의 책임을 Client 로 이전했다.

Last updated