[객체지향] 메시지와 의존성(인터페이스 설계, 의존성 관리)

개요

객체지향은 객체 간 협력으로 구현되고, 그 협력은 한 객체가 다른 객체에게 메시지(Message)를 보내면서 시작된다. 책임 할당이 협력에 참여할 객체를 정하는 문제라면, 그렇게 정해진 객체들이 서로에게 무엇을 어떻게 요청할지도 파악해야 한다.

메시지를 어떻게 설계하느냐가 결합도를 좌우한다. 객체의 내부 상태를 꺼내(ask) 외부에서 처리하면 두 객체가 강하게 얽히고, 메시지를 보내(tell) 객체가 스스로 처리하게 하면 결합이 느슨해진다. 이 원칙을 묻지 말고 시켜라(Tell, Don’t Ask)라 하며, 디미터 법칙(Law of Demeter)과 명령-쿼리 분리(Command-Query Separation, CQS)가 이를 구체화한 것이다.

메시지를 아무리 잘 설계해도 협력에는 의존성이 필수로 남는다. 한 객체가 다른 객체에게 메시지를 보내려면 그 대상을 알아야 하기 때문이다. 중요한 것은 그 의존이 구체적인 구현체가 아니라 추상화된 역할을 향하게 하는 것이다. 구현체에 직접 의존하면 메시지 설계가 아무리 좋아도 결합도는 다시 높아진다.

키워드설명
메시지(Message)객체가 다른 객체에게 보내는 오퍼레이션명과 인자의 조합
메서드(Method)메시지를 수신했을 때 실제로 실행되는 코드 블록
묻지 말고 시켜라(Tell, Don’t Ask)상태를 꺼내 처리하지 말고 메시지로 객체가 처리하게 하라는 원칙
디미터 법칙(Law of Demeter)객체가 직접 아는 대상에게만 메시지를 보내라는 결합도 가이드
명령-쿼리 분리(CQS)오퍼레이션은 상태를 바꾸는 명령이거나 값을 반환하는 쿼리 하나여야 한다는 원칙
의도를 드러내는 인터페이스어떻게가 아니라 무엇을 하는지를 이름에 드러내는 메서드 설계
컴파일타임 의존성코드에 정적으로 존재하는 인터페이스 수준의 의존
런타임 의존성실행 시점에 실제로 연결되는 구현체 수준의 의존
의존성 역전 원칙(DIP)구체 구현이 아니라 추상화된 역할에 의존하도록 방향을 두는 원칙

메시지와 메서드: 묻지 말고 시켜라

객체지향에서 협력은 한 객체(클라이언트)가 다른 객체(서버)에게 메시지를 전송하면서 시작된다. 메시지는 오퍼레이션명과 인자로 구성되고, 메서드는 메시지를 수신했을 때 실제로 실행되는 코드 블록이다. 같은 메시지라도 수신 객체의 타입에 따라 다른 메서드가 실행될 수 있는데, 이것이 다형성이다.

좋은 협력을 위해서는 메시지를 잘 설계해야 한다. 핵심 원칙이 묻지 말고 시켜라다. 객체의 내부 상태를 꺼내(ask) 외부에서 처리하지 말고, 메시지를 보내(tell) 객체가 스스로 처리하게 한다.

이 원칙을 어긴 코드와 지킨 코드를 비교한다. 데이터 중심으로 설계된 ReservationAgency는 다른 객체의 내부를 점(.)으로 여러 단계 파고든다.

// 잘못된 설계: 다른 객체의 내부를 꺼내 직접 처리한다
class ReservationAgency {
    fun reserve(screening: Screening, customer: Customer, audienceCount: Int): Reservation {
        val movie = screening.getMovie()
        val conditions = movie.getDiscountConditions()
 
        val discountable = conditions.any { condition ->
            if (condition.getType() == DiscountConditionType.PERIOD) {
                condition.getDayOfWeek() == screening.getWhenScreened().dayOfWeek &&
                    condition.getStartTime() <= screening.getWhenScreened().toLocalTime() &&
                    condition.getEndTime() >= screening.getWhenScreened().toLocalTime()
            } else {
                condition.getSequence() == screening.getSequence()
            }
        }
        // 할인 금액 계산도 movie.getMovieType() 을 꺼내 직접 분기한다
        val fee = if (discountable) movie.getFee().minus(movie.getDiscountAmount()) else movie.getFee()
        return Reservation(customer, screening, fee.times(audienceCount), audienceCount)
    }
}

screening.getMovie().getDiscountConditions() 같은 연쇄 호출은 ReservationAgency가 Screening, Movie, DiscountCondition의 내부 구조에 모두 결합됐다는 신호다. 이 셋 중 하나라도 내부가 바뀌면 ReservationAgency가 함께 깨진다.

개선된 코드는 Screening에게 메시지 하나만 보낸다.

// 개선 설계: 메시지만 보내고 처리는 객체에게 맡긴다
class ReservationAgency {
    fun reserve(screening: Screening, customer: Customer, audienceCount: Int): Reservation {
        val fee = screening.calculate(audienceCount) // 어떻게 계산하는지는 모른다
        return Reservation(customer, screening, fee, audienceCount)
    }
}

ReservationAgency는 Screening이 요금을 어떻게 계산하는지 알 필요가 없다. 계산 방법은 Screening과 그 협력자들의 책임이다. 메시지로만 소통하면 캡슐화가 강화되고 결합도가 낮아진다.

디미터 법칙: 점 하나만 사용하라

묻지 말고 시켜라를 결합도 관점에서 구체화한 가이드가 디미터 법칙이다. 객체가 직접 알고 있는 대상에게만 메시지를 보내라는 원칙이다.

앞의 잘못된 코드에서 ReservationAgency가 직접 아는 대상은 Screening뿐이다. 그런데 screening.getMovie().getDiscountConditions()는 Screening이 돌려준 Movie에게, 다시 Movie가 돌려준 컬렉션에게 연쇄로 메시지를 보낸다. ReservationAgency 입장에서 Movie와 DiscountCondition은 직접 아는 대상이 아니라 협력자의 협력자일 뿐이다. 개선된 코드에서는 이 체인이 통째로 사라지고 screening.calculate() 하나만 남는다. Screening이 Movie에게, Movie가 DiscountCondition에게 보내는 메시지는 여전히 존재하지만, 그 협력은 각 객체 내부로 숨어 ReservationAgency의 눈에 보이지 않는다.

디미터 법칙과 응집도의 트레이드오프

원칙을 예외 없이 따르는 것이 항상 좋은 설계로 이어지지는 않는다. PeriodCondition이 screening.getWhenScreened()로 Screening의 내부를 참조하면 디미터 법칙 위반이다. 이를 없애려고 Screening에 isDiscountable(dayOfWeek, startTime, endTime) 메서드를 추가해 책임을 위임할 수 있다. 그런데 이 해결책은 Screening의 응집도를 떨어뜨린다. Screening의 본래 책임은 상영인데, 이제 할인 여부 판단이라는 다른 책임까지 떠맡기 때문이다.

때로는 응집도를 지키고 책임을 올바른 객체에 두기 위해 디미터 법칙을 약간 어기고 데이터를 요청하는 편이 더 나을 수 있다.

의도를 드러내는 인터페이스

메서드 이름은 어떻게 하는지가 아니라 무엇을 하는지를 드러내야 한다. 구현 방식을 이름에 노출한 다음 코드를 보자.

// 잘못된 설계: 이름에 구현 방식(Period, Sequence)이 노출된다
class PeriodCondition {
    fun isSatisfiedByPeriod(screening: Screening): Boolean { /* ... */ }
}
 
class SequenceCondition {
    fun isSatisfiedBySequence(screening: Screening): Boolean { /* ... */ }
}

이 이름은 클라이언트에게 객체의 구체 타입을 확인하고 올바른 메서드를 골라 호출할 책임을 떠넘긴다. 무엇을 하는지만 드러내도록 바꾸면 두 조건을 같은 인터페이스로 묶을 수 있다.

// 개선 설계: 무엇을 하는지(할인 조건 만족 여부)만 드러낸다
interface DiscountCondition {
    fun isSatisfiedBy(screening: Screening): Boolean
}
 
class PeriodCondition : DiscountCondition {
    override fun isSatisfiedBy(screening: Screening): Boolean { /* ... */ }
}
 
class SequenceCondition : DiscountCondition {
    override fun isSatisfiedBy(screening: Screening): Boolean { /* ... */ }
}

클라이언트는 구체 타입을 몰라도 되고, 두 조건이 같은 책임을 수행한다는 사실이 인터페이스로 명확해진다.

명령-쿼리 분리(CQS)

메시지를 설계할 때 함께 지켜야 할 원칙이 명령-쿼리 분리(Command-Query Separation, CQS)다. 오퍼레이션은 부수효과를 일으키는 명령(command)이거나, 부수효과 없이 값을 반환하는 쿼리(query) 중 하나여야 한다.

부수효과와 반환값이 뒤섞인 메서드는 호출부가 반환값만 보고는 상태가 바뀌었는지 예측하기 어렵게 만든다.

// 잘못된 설계: 명령과 쿼리가 한 메서드에 뒤섞인다
class Reservation {
    private var confirmed = false
 
    // 예약을 확정하면서(명령) 동시에 확정 성공 여부를 반환한다(쿼리)
    fun confirm(): Boolean {
        if (confirmed) return false
        confirmed = true
        return true
    }
}

confirm()을 호출한 쪽은 반환값을 보지 않고서는 예약이 이번에 확정된 것인지, 이미 확정돼 있던 것인지 구분할 수 없다. 반환값을 무시하고 호출하면 상태 변경 여부를 알 길이 없다.

// 개선 설계: 명령과 쿼리를 분리한다
class Reservation {
    private var confirmed = false
 
    // 명령: 상태를 바꾸고 반환값이 없다
    fun confirm() {
        check(!confirmed) { "이미 확정된 예약이다" }
        confirmed = true
    }
 
    // 쿼리: 상태를 바꾸지 않고 값만 반환한다
    fun isConfirmed(): Boolean = confirmed
}
구분명령(Command)쿼리(Query)
목적객체의 상태를 변경객체의 상태를 조회
부수효과있음없음
반환값없음(void)있음
반복 호출결과가 달라질 수 있음몇 번을 불러도 결과가 같음(멱등)

명령과 쿼리를 분리하면 쿼리는 몇 번을 호출해도 결과가 같다는 것이 보장되고, 명령이 실행됐는지 여부는 예외나 후속 쿼리로 명확하게 확인할 수 있다. 디버깅 중에 쿼리 메서드를 호출해 상태를 확인하는 습관도 이 분리 덕분에 안전해진다.

의존성 관리: 추상화에 의존하기

협력에는 의존성이 필수다. 한 객체가 다른 객체에게 메시지를 보내려면 그 대상을 알아야 하기 때문이다. 중요한 것은 그 의존이 구체적인 구현체가 아니라 추상화된 역할(인터페이스)을 향하게 하는 것이다.

Product가 상품 이미지를 가져온다고 하자. ProductJpaImageRepository라는 구체 클래스에 직접 의존하면 Product는 JPA(Java Persistence API)라는 특정 컨텍스트에 종속된다. JPA가 아니라 S3에서 이미지를 가져오라는 요구사항이 생기면 Product 내부 코드를 직접 고쳐야 한다.

// 잘못된 설계: 구체 구현(JPA)에 직접 의존한다
class Product(val id: Long) {
    private val imageRepository = ProductJpaImageRepository() // JPA 에 종속
 
    fun fetchImages(): List<ProductImage> = imageRepository.fetchAllBy(id)
}

반대로 Product가 ProductImageRepository 인터페이스에 의존하면, Product는 이미지를 어떻게 가져오는지 알 필요가 없다. 런타임에 다른 구현체를 주입하기만 하면 된다.

// 개선 설계: 추상화(역할)에 의존하고 런타임에 구현체를 주입한다
interface ProductImageRepository {
    fun fetchAllBy(productId: Long): List<ProductImage>
}
 
class ProductImageJpaRepository : ProductImageRepository { /* ... */ }
class ProductImageS3Repository : ProductImageRepository { /* ... */ }
class ProductImageCacheRepository : ProductImageRepository { /* ... */ }
 
class Product(
    val id: Long,
    private val imageRepository: ProductImageRepository, // 인터페이스에만 의존
) {
    fun fetchImages(): List<ProductImage> = imageRepository.fetchAllBy(id)
}

여기서 컴파일타임 의존성과 런타임 의존성을 구분하는 것이 중요하다.

구분컴파일타임 의존성런타임 의존성
존재 위치코드에 정적으로 존재(인터페이스 타입)실행 중 메모리에 존재(구현체 인스턴스)
결정 시점코드 작성 시점에 고정프로그램 실행 시점에 결정
Product가 아는 것ProductImageRepository 라는 이름과 시그니처그 순간 주입된 구현체가 JPA인지 S3인지는 모름
새 구현체 추가 시 영향없음(인터페이스는 그대로)구현체 하나 추가 + 주입 설정만 변경

컴파일타임에 Product 코드 안에는 ProductImageRepository 인터페이스만 존재한다. Product는 그 구현체가 JPA인지 S3인지 알지 못한다. 실제 어떤 구현체가 연결될지는 런타임에 주입으로 결정된다. 이 분리 덕분에 Product는 컨텍스트에 독립적이다. Cache 같은 새 요구사항이 생겨도 ProductImageCacheRepository 구현체를 만들고 주입만 바꾸면 되며, Product 코드는 손대지 않는다.

좋은 객체지향 설계는 컴파일 시점에 안정적인 인터페이스에 의존하게 하고, 런타임 시점에 구체적인 구현체를 주입해 유연성을 확보한다. 특정 컨텍스트에 의존하지 말고 추상적인 역할에 의존해야 한다. 이것이 의존성 역전 원칙(DIP, Dependency Inversion Principle)의 핵심이다.

다음 도식은 컴파일타임 의존성과 런타임 의존성의 분리를 보여준다. 컴파일 시점의 Product는 ProductImageRepository 인터페이스에만 의존하고, 실행 시점에 JPA, S3, Cache 구현체 중 하나가 주입으로 연결된다.

캡슐화는 getter를 막는 것이 아니라 구현을 감추는 것이다. getFee()처럼 내부 상태를 그대로 반환하는 메서드는 캡슐화가 아니다. 캡슐화는 불안정한 구현을 안정적인 인터페이스 뒤에 감추는 것이다. 상태를 노출하는 대신, 그 상태로 무엇을 할지를 메시지로 제공해야 한다. 결국 좋은 설계란 의존성을 없애는 것이 아니라 의미 있고 관리 가능한 의존성을 만드는 일이다.