이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.
객체지향을 이해하기 위해 필요한 여러가지 키워드가 있지만, 그 개념들 대부분은 의존 관계와 관련이 있습니다. 이번 포스팅에서는 이 의존 관계가 객체지향 패러다임에서 얼마나 중요한 의미를 가지는지 알아보겠습니다.
의존 관계
는 의존하는 것들 사이에 관계를 의미합니다. 의존한다는게 어떤 의미인지 알면 의존 관계도 자연스럽게 이해할 수 있습니다.
의존이라는 단어는 우리가 일상생활에서도 많이 사용하는 단어입니다. 바로 다음과 같이 말이죠.
A는 B에 의존적이다.
ex) 물건의 가격은 공급에 의존적이다.
여기서 의존적이라는건 A가 B의 변화에 민감하게 반응한다고도 말할 수 있습니다. 물건의 가격과 공급을 예시로 든다면 '가격은 공급에 의존적이기 때문에, 물건의 가격은 물건의 공급량에 따라 크게 영향을 받는다.'고 표현할 수 있겠죠.
그럼 반대로 B도 A의 변화에 민감하게 반응할까요? 그건 알 수 없습니다. 'B역시 A에 의존적'이라는 명제가 나와있지 않기 때문입니다.
의존은 이렇게 그림으로도 표현할 수 있습니다. 이 그림에는 나무와 나무에 걸쳐진 사다리가 하나 있습니다. 여기서 무엇이 무엇에게 의존하고 있을까요?
생각하신 것처럼 사다리가 나무에 의존하고 있습니다. 나무는 사다리가 없더라도 혼자 서있을 수 있습니다. 하지만 사다리는 나무가 사라져버리면 그대로 넘어지게됩니다. 이는 사다리가 나무에게 일방적으로 의존하고 있기 때문입니다.
이처럼 의존이라는 표현은 프로그래밍뿐 만 아니라 일반적으로 널리 사용되는 개념입니다. 그럼 프로그래밍에서 이야기하는 의존은 어떤 것일까요?
프로그래밍에서의 의존, 특히 객체지향 프로그래밍에서의 의존은 코드 사이의 의존을 이야기합니다.
public class RealMessageSender {
public void send() {
// 실제로 메시지 전송
}
}
public class FakeMessageSender {
public void send() {
// 메시지는 보내지 않고 메시지를 보냈다는 로그만 출력
}
}
public class Client {
public void someMethod() {
// 메시지를 보내기 전 실행되는 어떤 작업
FakeMessageSender messageSender = new FakeMessageSender();
messageSender.send();
}
}
위 코드에는 RealMessageSender
, FakeMessageSender
, Client
총 3개의 클래스가 존재합니다. 여기서 누가 누구에게 의존하고 있을까요?
Client
가 FakeMessageSender
에게 의존하고 있습니다. 이 상태에서 FakeMessageSender
를 제거하고 프로그램을 실행한다면 Client
는 컴파일 오류가 발생할 것입니다. 이는 Client
가 FakeMessageSender
에 의존하고 있기 때문에 변화에 영향을 받기 때문입니다.
그럼 그 반대로 Client
가 없어진다면 어떨까요? FakeMessageSender
는 여전히 잘 컴파일 될 겁니다. FakeMessageSender는 Client에 의존하고 있지 않기 때문입니다.
지금 봤던 예시처럼 코드에서의 의존 관계는 어떤 클래스가 다른 클래스를 사용하고 있을 때 발생합니다. 이는 크게 세 가지 경우로 나눌 수 있습니다.
- 다른 클래스의 레퍼런스 변수를 사용하는 경우
- 다른 클래스의 인스턴스를 생성하는 경우
- 다른 클래스를 상속 받는 경우
위에서 확인한 코드는 1, 2의 경우를 따른다고 할 수 있습니다.
3번의 경우는 위 코드에서 확인할 수 없지만 역시 전형적인 의존 관계가 생기는 케이스입니다. 상속한 부모가 사라지면 당연히 자식은 컴파일 에러가 발생합니다. 여기서는 클래스를 기준으로 얘기하였지만, 의존 관계는 interface 역시 마찬가지로 적용됩니다.
의존 관계가 무엇인지는 이제 알겠습니다. 코드를 작성하려면 당연히 의존 관계가 존재할 수 밖에 없겠다는 생각이 드실겁니다. 맞는 말입니다. 코드에서의 의존 관계는 필수적일 수 밖에 없습니다.
그러나 때로는 문제가 되는 의존 관계도 있습니다. 위에서 살펴본 MessageSender 예시 코드를 다시 살펴보겠습니다.
public class RealMessageSender {
public void send() {
// 실제로 메시지 전송
}
}
public class FakeMessageSender {
public void send() {
// 메시지는 보내지 않고 메시지를 보냈다는 로그만 출력
}
}
public class Client {
public void someMethod() {
// 메시지를 보내기 전 실행되는 어떤 작업
FakeMessageSender messageSender = new FakeMessageSender();
messageSender.send();
}
}
Client
가 인터페이스(역할)가 아닌 구현 클래스(구현)를 의존하고 있습니다. 이는 여러 문제가 있을 수 있지만 특히 실제 환경과 테스트 환경에서 서로 다른 구현 클래스가 필요한 상황이 오면 그때마다 코드가 변경될 수 있는 상황이 올 수 있습니다.
public interface MessageSender {
void send();
}
public class RealMessageSender implements MessageSender {
public void send() {
// 실제로 메시지 전송
}
}
public class FakeMessageSender implements MessageSender {
public void send() {
// 메시지는 보내지 않고 메시지를 보냈다는 로그만 출력
}
}
public class Client {
private MessageSender messageSender;
Client(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void someMethod() {
// 메시지를 보내기 전 실행되는 어떤 작업
messageSender.send();
}
}
위 문제를 해결하기 위해서는 Client
가 구현 클래스를 의존하지 않도록 MessageSender
라는 인터페이스의 생성을 통해 클래스를 추상화 시킬 수 있습니다. 그럼 Client
는 추상화에 의존할 수 있고 상황에 따라 코드의 변경 없이 필요한 인스턴스를 생성자로 주입받아 사용할 수 있게 됩니다.
코드의 변경으로 의존 관계는 다음과 같이 변합니다. Client
는 MessageSender
라는 인터페이스만 의존하고 있고, 구현 클래스인 FakeMessageSender
와 RealMessageSender
역시 MessageSender
인터페이스에만 의존하고 있습니다.
여기서 다시 한번 강조하는 중요 포인트는 Client
는 더이상 구현 클래스를 직접 의존하지 않게 되었고, 인터페이스를 통해 간접적으로 의존 관계가 생기면서 상황에 따라 유연하게 구현 클래스를 선택할 수 있게 되었다는 점입니다.
의존하는 대상을 단순히 클래스에서 인터페이스로 변경했을 뿐인데 굉장한 이점을 얻게 된 것입니다.
1. 다른 클래스의 레퍼런스 변수를 사용하는 경우
2. 다른 클래스의 인스턴스를 생성하는 경우
3. 다른 클래스를 상속 받는 경우
위에서 살펴본 코드에서 의존 관계가 발생할 수 있는 세가지 경우입니다. 우린 1번과 2번의 경우를 제거함으로 더 유연한 의존 관계의 코드를 작성할 수 있었습니다.
👀 3번에 경우는 향후 '전략 패턴'을 다루는 포스팅에서 다시 알아보겠습니다.
의존 관계를 얘기할 때 빠질 수 없는 것 중 하나가 강하게 의존하는것과 약하게 의존하는것을 구분하는 것입니다. 여기서 강하게 의존하는것과 약하게 의존하는것은 상대적인 개념이기 때문에 상황에 따라 달라질 수 있습니다.
왼쪽 그림은 Client
가 구현 클래스인 FakeMessageSender
에 의존하고 있고, 오른쪽 그림은 Client
가 인터페이스인 MessageSender
를 통해서 간접적으로 구현클래스들을 의존하고 있습니다.
그럼 두 그림중 어느쪽이 더 강하게 의존하고 있을까요? 바로 왼쪽입니다.
의존 관계에서 많이 사용되는 표현으로 구현 클래스를 직접 의존하는 경우를 강하게 결합하고 있다고 표현하고, 오른쪽 그림처럼 추상화(인터페이스)를 통해 간접적으로 구현 클래스들을 의존하는 경우를 느슨하게 결합하고 있다고 표현합니다.
당연히 객체들의 관계가 느슨하게 결합되어 있는 오른쪽 그림이 더 객체지향적이라고 할 수 있습니다.
의존 관계의 강약은 비단 클래스간에만 나타나는게 아닙니다. 클래스를 이루는 구성요소인 메서드와 필드 사이에서도 발생합니다.
Case01
class Product {
public String name;
public Integer price;
public Integer amount;
}
class SomeClass {
public void someMethod(Product product) {
Integer totalAmount = product.price * product.amount;
// 어떤 로직들
}
public void anotherMethod(Product product) {
// 어떤 로직들
Integer totalAmount = product.price * product.amount;
// 어떤 로직들
}
}
Case02
class Product {
private String name;
private Integer price;
private Integer amount;
public Integer getTotalAmount() {
return this.price * this.amount;
}
}
class SomeClass {
public void someMethod(Product product) {
Integer totalAmount = product.getTotalAmount();
// 어떤 로직들
}
public void anotherMethod(Product product) {
// 어떤 로직들
Integer totalAmount = product.getTotalAmount();
// 어떤 로직들
}
}
우선 클래스 간의 의존 관계부터 따져보면 첫 번째 코드와 두 번째 코드 모두 SomeClass
가 Product
에 의존하고 있는 코드입니다. 따라서 Product
가 변경되면 SomeClass
역시 영향을 받게됩니다.
두 코드 모두 SomeClass
가 Product
에 의존하고 있다는 것은 동일하지만 의존에 정도에는 차이가 있습니다.
첫 번째 코드의 Product
는 필드가 모두 public으로 공개되어있고 그 필드를 SomeClass
가 사용하고 있습니다. 즉, SomeClass
는 Product
클래스의 구체적인 필드까지 모두 알고있습니다.
반면 두 번째 코드에서는 Product
의 필드는 모두 private 접근 제한자로 설정되어 있어 외부에 공개되어 있지 않고 오직 메서드만 공개하고 있습니다.
첫 번째 코드는 필드 하나만 변경하더라도 그 필드를 사용하고 있는 SomeClass
의 많은 부분이 영향을 받겠지만, 두 번째 코드는 필드 변경 정도는 공개되어 있는 메서드인 getTotalAmount
메서드만 변경해주면 SomeClass
역시 크게 영향을 받지 않습니다.
결과적으로 두 코드 모두 SomeClass
가 Product
클래스에 의존하고 있다는건 동일하지만, 첫 번째 코드가 더 강하게 결합되어 있기 때문에 의존하고 있는 클래스의 변경에 더 크게 영향을 받게 되는 것입니다.
일반적으로 우리가 객체지향적인 코드를 작성한다면 두 객체의 결합도를 최대한 낮출 수 있도록 해야합니다. 따라서 필드를 private
접근 제한자로 설정하여 외부에 직접적으로 공개하지 않고, 관련한 메서드를 통해 로직을 처리할 수 있도록 해야합니다.