객체 지향 프로그래밍 입문

dev_314·2022년 12월 22일

최범균님의 객체 지향 프로그래밍 입문을 수강하고 정리한 내용입니다.

개발 비용 줄이기

SW가 고도화될수록, 새로운 코드가 추가되는 양은 줄어들지만 코드 한 줄을 작성하는데 드는 비용은 증가한다.

왜 이런 일이 발생할까? 코드의 크기가 커짐에따라, 다음 작업에 소요되는 시간이 증가하기 때문이다.

1. 코드 분석
2. 코드 변경

유지보수 관점에서 SW는, 단순히 이전처럼 잘 작동해야할 뿐 아니라 변화하는 환경에 맞춰 계속해서 유용하게 유지되어야 한다. 그리고 개발자는 낮은 비용으로 SW를 변화할 수 있도록 해야한다.

과연 유지보수성이 뛰어난 SW를 어떻게 개발할 수 있을까?

1. 프로그래밍 패러다임
	객체 지향, 함수형, 리액티브, ...
2. 코드, 설계, 아키텍처
	DRY, TDD, SOLID, DDD, MSA, ...
3. 업무 프로세스, 문화
	Agile, DevOps, ...

여러 방법이 있겠지만, 객체 지향관점으로 유지보수성이 뛰어난 SW의 특징을 살펴보자.

객체

절차 지향

절차 지향은 데이터를 여러 procedure가 공유한다.

// 글 수정 API
Account account = findOne(id);

if (!account.getStatus() == AccountStatus.SIGN_OUT) {
	if (account.hasAuthorized()) {
    	if (account.isWriter()) {
        	...
        }
    }
}

// 댓글 작성 API
Account account = findOne(id);

if (!account.getStatus() == AccountStatus.SIGN_OUT) {
	if (account.hasAuthorized()) {
		if (account.canWriteComments()) {
        	...
        }
    }
}
  1. 중복되는 코드가 존재한다. 이는 코드 수정을 할 때 여러 곳을 확인해야 함을 의미한다.
  2. 새로운 검증 코드가 추가될 경우, 기존 검증 코드에 영향을 주는지를 검증해야 한다.
  3. 두 API는 동일한 데이터를 중복해서 사용한다.

절차 지향 방식으로 코드를 짜게 되면, 시간이 지날수록 코드의 구조가 복잡해 진다. 이는 좋은 유지보수에 치명적이다.

객체 지향

데이터와 Procedure를 객체라는 단위로 묶는 방식을 의미한다.

객체 지향에서는 특정 객체가 가진 데이터는, 그 객체의 프로시저를 통해서만 접근 가능하다.
그렇다면 서로 다른 객체들은 어떻게 데이터를 주고 받을까?
객체들은 데이터를 주고 받기보단, 데이터를 다루는 프로시저를 제공함으로써 소통한다. (그렇게 설계해야 한다.)

객체

앞서 짧게 설명했듯이, 코드를 작성하는 관점으로는, 객체 = 데이터 + 프로시저라고 말할 수 있다.
그런데 런타임 환경에서 동작하는 객체의 관점에서는 객체 = 기능의 집합이라고 볼 수 있다.
이는 객체의 핵심은 객체가 어떤 기능을 제공하는가가 되어야 한다는 의미이다.

즉, 객체는 내부적으로 가진 필드(데이터)가 아닌, 객체가 제공하는 기능들을 통해 정의된다.

예를 들어,Memberid, password, status 데이터를 가지고 있는 단위로 인식하기 보다, 암호 변경하기 기능, 차단 여부 확인하기 기능을 제공하는 단위로 인식하는 것이 더 객체 지향스럽다고 할 수 있다.
이런 사고방식에 따르면, 필드는 객체가 기능을 수행하기 위해 필요한 데이터들들 보관하기 위해 자연스럽게(?) 추가되는 요소가 되는 것이다.

기능 중심으로 객체를 바라보는 관점은 다음과 같은 코드로 표현될 수 있다.

// 필드 중심의 사고 방식
VolumeController vc = new VolumeController();
vc.volume++; // 필드에 직접 접근 -> anti oop
int volume = vc.volume; // 필드에 직접 접근 -> anti oop

// 기능 중심의 사고 방식
VolumeController vc = new VolumeController();
vc.increaseVol(); // 메서드를 호출해서 기능을 사용 -> more like oop
int volume = vc.getVolume() // 메서드를 호출해서 기능을 사용 -> more like oop

기능 중심의 객체 지향 사고방식을 통해 코드의 의도를 더 잘 드러낼 수 있다.

Q: 이것도 객체?

public class Member {
	private String nmae;
    private String id;
    
    public void setName(String name) {
    	this.name = name;
    }
    
	public void setId(String id) {
    	this.id = id;
    }
    
    public String getName() {
    	return name;
    }
    
    public String getId() {
    	return id;
    }
}

A: 단순히 데이터를 set, get하는, 특별한 기능은 없는 class는 객체보다 데이터(구조체)에 더 가깝다.

메시지

객체와 객체의 상호작용을 메시지를 주고받는다라고 말한다.
메시지를 주고 받는다는 다양한 상황을 가리킨다.

1. 객체의 메서드 호출
2. 메서드가 데이터를 리턴
3. 익셉션이 발생

캡슐화

캡슐화란 데이터와 관련 기능을 묶는 행위라고 설명할 수 있다.

과연 캡슐화는 어떻게 좋은 코드를 달성하게 해주는가? 먼저 캡슐화를 하지 않는 코드를 살펴보자.

if (acc.getMembership() == REGULAR && acc.getExpDate().isAfter(now())) {
	...
}

// 서비스 요구사항이 변경되어 검증 로직이 변경, 추가됨

if (
	(acc.getServiceDate().isAfter(fiveYearAgo) && acc.getExpDate().isAfter(now())) ||
    (acc.getServiceDate().isBefore(fiveYearAgo) && addMonth(acc.getExpDate()).isAfter(now()))
) {
	...
}

요구사항이 변경되어 데이터 구조에도 변화가 발생했다. 그리고 그 데이터를 사용하는 로직, 코드가 흩어져있다 보니 여러 곳을 수정해야 하는 비효율이 발생했다.

캡슐화를 통해 개선해보자.

// 기능을 사용하는 외부에서는 hasRegularPermission가 어떻게 구현되었는지 알 수 없다. (알 필요 없다.)
if (acc.hasRegularPermission()) {
	...
}

public class Account {
	private Membershipe membership;
    private Date expDate;
    
    public boolean hasRegularPermission() {
    	return membershipe == REGULAR && expDate.isAfter(now());
    }
}
  1. 요구사항에 변경되어도hasRegularPermission메소드의 구현만 변경된다. 사용하는 곳은 변경할 부분이 없다.
    즉, 캡슐화를 통해 연쇄적인 변경 전파를 최소화했다.

  2. 캡슐화를 통해 데이터와 관련 기능을 묶음으로써, 객체가 기능을 어떻게 구현했는지는 외부에 감출 수 있다.
    즉, 사용하는 곳에서는 구현 방식을 고려하지 않고 코드를 작성하면 된다.

  3. 캡슐화를 통해 의도를 더 잘 나타낼 수 있다.

    if (acc.getMembership() == REGULAR() {
        ...
    }
    // 위는 왜 비교를 하는지 잘 이해가 안 됨, 밑은 메소드 이름을 통해 무엇을 위한 작업인지 알 수 있음
    if (acc.hasRegularPermission()) {
        ...
    } 

캡슐화를 잘 하기 위한 두 가지 원칙

1. Tell, Don't Ask

  • 데이터를 달라 하지 말고, 데이터를 가지고 있는 녀석에게 해달라고 하기
  • 위의 코드가 Tell, Don't Ask가 적용된 것

2. Demeter's Law (Don't Talk to Strangers)

  • 메서드에서 생성한 객체의 메서드만 호출
  • 파라미터로 받은 객체의 메서드만 호출
  • 필드로 참조하는 객체의 메서드만 호출

두 원칙 모두 메시지를 주고 받는 방식으로 객체가 상호작용해야 함을 이야기한다.

acc.getExpDate().isAfter(now);

or 

Date date == acc.getExpDate();
date.isAfter(now);

위 처럼 하지 말고, 캡슐화를 적용해서 메서드를 한 번만 호출하도록 변경하라.

acc.isExpired();
acc.isValid();

정리하자면, 캡슐화는 외부에 기능을 감추는 것이다.
외부에 기능을 감춤으로써, 변경의 전파를 최소화 할 수 있고, 코드의 의미를 더 잘 드러낼 수 있다.

다형성과 추상화

Q: 객체 지향는 어떻게 변경 비용을 낮춰주는가?
A: 객체 지향 프로그래밍은 `캡슐화 + 다형성(추상화)`를 통해 변경 비용을 줄인다.

이번에는 다형성, 추상화에 대해 살펴보겠다.

다형성

  • 여러(poly) 모습(morph)을 갖는 것

객체 지향에서의 다형성은 다음의 모습으로 드러난다.

1. 한 객체가 여러 타입을 갖는 것 = 한 객체가 여러 타입의 기능을 제공하는 것
2. 보통 타입 상속을 통해 다형성을 구현함

추상화

  • 데이터나 프로세스 등을 의미가 비슷한 개념이나 의미 있는 표현으로 정의하는 과정

두 가지 방식의 추상화

  1. 특정한 성질을 뽑아내는 방식
    • 아이디, 이름, 이메일 데이터를 묶어서 User라고 추상화
  2. 공통 성질을 뽑아내는 방식
    • 지포스, 라데온을 묶어서 GPU라고 추상화
    • SCP로 파일 업로드, HTTP로 데이터 전송, DB 테이블에 삽입을 묶어서 푸시 발송 요청이라고 추상화

타입 추상화

  • Concrete(구현) 클래스들을 대표하는 상위 타입(추상화 타입) 도출
  • 흔히 인터페이스 타입으로 추상화
  • 추상화 타입과 구현은 타입 상속으로 연결됨

이를 통해 추상 타입을 사용한 프로그래밍을 할 수 있게 됨

// 여러 종류의 notifier class(concrete class)가 있지만, 그들을 대표하는 추상 타입(인터페이스 타입)을 사용
Notifier notifier = getNotifier(...);
notifier.notify();

추상 타입을 사용함으로써 구현을 감출 수 있다. 캡슐화와 마찬가지로, 사용하는 입장에서는 기능의 구현을 신경 쓸 필요 없고, 의도를 더 잘 드러낼 수 있다.

왜 추상 타입을 사용하는가

  • 변경의 유연성, 변경 전파의 최소화
private DefaultWrapper dWrapper;

public Gift purchaseGift(int itemId) {
	...상품 생성
    // 포장
    return dWrapper.wrap(item);
}

위와 같이 아이템 번호를 입력받으면 아이템을 생성 한 뒤, 포장을 해서 반환하는 기능이 있다고 하자.
요구사항이 변경되어 고객의 등급이 높으면 다른 방법으로 포장을 한다고 하자.

private DefaultWrapper dWrapper;
private LuxuryWrapper lWrapper;

public Gift purchaseGift(int userid, int itemId) {
	...상품 생성
    // 포장
    if (user.isVip()) {
    	return lWrapper.wrap(item);
    } else {
	    return dWrapper.wrap(item);
    }
}

제품 구매의 핵심 로직인 상품 생성은 변화가 없는데, 덜 중요한 포장 로직이 계속해서 변하다보니 전체 메서드에 변경이 발생한다. 추상타입으로 해결해보자.

기본 포장기(?)와 럭셔리 포장기의 공통점인 포장 작업을 추상 타입으로 추상화해보자.

public Gift purchaseGift(int userid, int itemId) {
	...상품 생성
    // 포장
    Wrapper wrapper = getWrapper(userid);
    return wrapper.wrap(item);
}

private Wrapper getWrapper(int userid) {
	if (user.isVip()) {
    	return new LuxuryWrapper();
    } else {
    	return new DefaultWrapper();
    }
}
... 
Wrapper 인터페이스, Concrete class는 생략 
...

새로운 포장 방식이 추가되어도, 기존 포장 방식의 구현 방법이 변경되어도 핵심 메서드 purchaseGift에는 변화가 생기지 않는다. 추상 타입을 통해 변경의 전파를 막을 수 있게 되었다.

더 나아가 getWrapper에 발생하는 변화도 예방해보자.

public Gift purchaseGift(int userid, int itemId) {
	...상품 생성
    // 포장
    Wrapper wrapper = WrapperFactory.instance().getWrapper(userid);
    return wrapper.wrap(item);
}

// Wrapper를 선택하는 정책이 여러 개인 경우, 여러 종류의 Concrete WrapperFactory를 생성 & 사용
public interface WrapperFactory {
	Wrapper getWrapper(int userid);
    
    static WrapperFactory instance() {
    	return new DefaultWrapperFactory();
    }
}

public class DefaultWrapperFactory implements WrapperFactory {

	@Override
	public Wrapper getWrapper(int userid) {
    	if (user.isVip()) {
        	return LuxuryWrapper();
        } else {
        	return DefaultWrapper();
        }
    }
}

이를 통해, 새로운 포장 방식이 추가되어도, 기존 포장 방식 선택 기준이 변경되어도 오직 WrapperFactory만 변경하면 된다.

추상화는 언제해야 하는가?

추상화를 통해 변경의 전파를 최소화 할 수 있다는 사실을 알게 되었다.
그렇다면 모든 경우에 추상화를 도입하면 되는 것일까?

코드를 보면 알 수 있듯, 추상화를 거칠수록 새로운 추상 타입이 등장한다.(Wrapper, WrapperFactory)
이는 변경의 최소화라는 장점과 동시에, 코드 복잡도 증가라는 단점을 동반한다.

그렇기 때문에 무턱대로 무턱대로 추상화를 하는 것은 좋지 않다. 다음의 원칙에 따라 추상화 도입을 고민하자.

  1. 아직 존재하지 않는 기능에 대한 이른 추상화는 주의
    • 잘못된 추상화 가능성
    • 복잡도 증가
  2. 실제 변경, 확장이 발생할 때 추상화를 시도하라
    • 기존에는 DefaultWrapper만 존재
    • 요구사항이 변경되어 LuxuryWrapper가 도입될 때, Wrapper 추상 타입을 도입함
  3. 구현을 한 이유가 무엇 때문인지 생각해야 함
    • Wrapper에서 공통된 특징을 추출할 때, 여러가지 특징이 도출될 수 있음
    • 도출 결과가 어떻게 됐든, Concrete Wrapper에 공통점이 무엇인가를 계속해서 고민하라

OCP (Open-Closed Principle)

  • open for Extension, close for Modification
  • 추상화를 통해 Open-Closed Principle을 달성할 수 있다.
  • 새로운 concrete Class가 추가되더라도(open for Extension), 이를 사용하는 곳에서는 코드 변경이 없어야 한다 (close for Modification)

상속보단 조립

상속을 통해 상위 클래스의 기능을 재사용, 확장할 수 있다. 그런데 상속은 다음의 문제점을 지니고 있다.

상속의 문제점

1. 상위 클래스 변경이 어려워짐

- 상위 클래스의 변경이 모든 하위 클래스에 영향을 미치게 됨
- 하위 클래스에 미칠 영향을 고려하다보니, 상위 클래스 변경이 점점 어려워짐
- 상위 클래스의 작동 방식을 어느정도 알고 있어야 하위 클래스를 구현할 수 있음 -> 상위 클래스의 캡슐화가 약해졌다고 볼 수 있음 (하위 클래스에 노출됨)

2. 클래스가 불필요하게 증가

class 저장장치 {...}

class 압축저장장치 extends 저장장치 {...}
class 암호화저장장치 extends 저장장치 {...}
class 캐싱저장장치 extends 저장장치 {...}

class 압축,암호화저장장치 extends (압축저장장치? 암호화저장장치?) {...}
class 압축,캐싱저장장치 extends (압축저장장치? 캐싱저장장치?) {...}

새로운 기능이 추가될 때 마다

1. 새로운 (하위) 클래스가 추가되어야 함. 
2. 어떤 상위 Class를 상속받을 것인가에 대한 모호함 증가

3. 상속 오용 가능성

class ShoppingList extends ArrayList<Item> {
	private int maxSize;
    private int currSize;
    
    public void add(Item item) {
    	if (currSize + 1 > maxSize) {
        	return;
        }
        
        currSize += 1;
        super.add(item);
    }
}

ShoppoingList sl = new ShoppingList();
sl.add(item); // shoppingList의 add 호출
sl.add(item); // ArrayList의 add 호출

사용하는 입장에서, ShoppingList의 add와 ArrayList의 add를 헷갈릴 수 있다.

이런 문제들을 해결하기 위해, 상속보단 조립을 사용하라

조립 (Composition)

  • 기능을 재사용하고 싶은 클래스가 있으면, 그 클래스의 객체를 필드로 설정하거나 필요한 시점에 생성하는 방식
  • 여러 객체를 묶어서 복잡한 기능을 제공하는 방식
// 상속을 통한 기능 발전
저장장치

압축저장장치 extends 저장장치 {...}
암호화저장장치 extends 저장장치 {...}

// 조립을 통한 기능 발전
class Storage {
	private Compressor compressor = new Compressor();
    private Encryptor encryptor = new Encrpytor();
    private Cache cache = new Cache();
}

불필요하게 concrete class가 늘어나는 상황, 모호한 상속을 방지할 수 있다.

class ShoppingList {
	private int maxSize;
    private int currSize;
    private List<Item> shoppingList = new ArrayList<>();
    
    public void add(Item item) {
    	if (currSize + 1 > maxSize) {
        	return;
        }
        
        currSize += 1;
        shoppingList.add(item);
    }
}

ShoppoingList sl = new ShoppingList();
sl.add(item); // shoppingList의 add 호출

조립을 사용하니 put을 혼동할 여지가 사라지게 되었다.

상속, 조립 고려사항

  1. 상속하기 전에 조립으로 해결할 수 있는지 검토
  2. 진짜 하위 타입인 경우에만 상속을 사용하라
    • Container는 ArrayList의 하위 타입이 아니고, 단순히 기능이 필요했던 것 뿐이였음 -> 상속을 사용하면 X

기능과 책임 분리

하나의 기능은 여러 하위 기능으로 분리할 수 있다.

상품 주문 = 사용자 검증 + 상품 수량 검증 + 결제 금액 계산 + ...

분리한 하위 기능을 누가 제공할 것인가?를 결정하는 작업은 객체지향 설계의 기본 과정이다.
기능을 분리한 뒤, 각 객체에게 분리한 기능을 제공할 책임을 분산한다.

상품 주문(OrderService) = 사용자 검증(UserRepository) + 상품 수량 검증(ItemRepository) + 결제 금액 계산 (OrderService)  + ...

그런데 한 클래스나 메서드가 커지면 객체 지향에서도 절차 지향의 문제점(코드 중복 등)이 발생하게 된다.

큰 클래스: 많은 필드가 생김 -> 많은 메서드에서 공유할 가능성 발생
큰 메서드: 메서드에서 여러 변수를 사용하게 됨

따라서 알맞은 객체로 하위 기능을 적절히 분배하는 것에 신경을 써야한다.

책임 분배, 분리 방법

1. 패턴 적용
전형적인 역할 분리 패턴에 따라 기능을 분리하자

분류예시
Controller, Service, DAO
복잡한 도메인Entity, Value, Repository, domain service
AOPAspect(공통 기능)
GoFFactory, Builder, Strategy, Template Method, Proxy, Decorator, ...

2. 계산 분리

public void order(int itemId, int count) {
	Item item = itemRepository.findById(itemId);
    int totalPrice = 0;
    
    if (user.isVip()) {
    	totalPrice += item.getPrice() * count / 2 + ...
    } else if (user.isGold() {
    	...
    } else if ...
}

단순 계산 로직을 별도의 클래스로 분리하라

public void order(int itemId, int count) {
	Item item = itemRepository.findById(itemId);
	PriceCalculator pc = new priceCalculator(user.getMembership(), item, count);
    
    int totalPrice = pc.calculate();
}

3. 연동 분리

  • 네트워크, 메시징, 파일 등 연동 처리 코드를 분리

4. 조건 분기를 추상화

if (fileId.startsWith("local")) {
	fileUrl = "/files" + fileId.substring(6) + ...
} else if (fileId.startsWith("s")) {
	fileUrl = "http://" + fileId.substring(3) + "a" + ...
}

조건문에서 비슷한 로직을 검증한다면 별도의 클래스로 분리할 수 있다.

FileInfo fi = new FileInfo(fileId);
String url = fi.getUrl();

class FileInfo {
	public String getUrl() {
		if (fileId.startsWith("local")) {
			return "/files" + fileId.substring(6) + ...
		} else if (fileId.startsWith("s")) {
			return "http://" + fileId.substring(3) + "a" + ...
		}
    }
}

분리를 할때 의도가 잘 드러나도록 이름을 지어야 한다. 안그러면 의미가 없다.

이렇게 분리를 잘 하면 테스트가 용이해진다. 테스트 대상을 제외한 나머지 의존성을 편하게 설정할 수 있기 때문이다.

의존과 DI

Dependency & Clean code

A가 B에 '의존한다' 혹은 '의존성을 갖는다'는 무슨 뜻일까?
A가 자신의 기능 구현을 위해, 다른 구성 요소 B를 사용하는 의미다.

EX) 
A에서 B객체 생성
A에서 B메서드 호출
A에서 B의 데이터 사용

등의 경우에 의존 관계가 성립할 것이다.

지금까지 살펴본 객체 지향을 통한 좋은 코드 관점에서, 의존 관계를 가짐은 곳 변경이 전파될 가능성을 의미한다.
즉, 내가 의존하는 대상이 바뀌면 나도 바뀔 가능성이 높아진다.

EX)
호출하는 메서드의 파라미터가 변경
호출하는 메서드가 발생할 수 있는 익셉션 타입이 추가

그렇기 때문에 무한한 변경의 전파가 발생할 수 있는 순환 의존은 위험하다. 클래스, 패키지, 모듈 등 모든 수준에서 순환 의존이 없도록 해야 한다.

A -> B -> C -> A -> B -> ...

또한 너무 많은 것에 의존하는 것도 좋지 않다. 자신이 의존하는 것이 많을 수록, 자신이 변경될 가능성이 높기 때문이다. 의존하는 대상은 적을 수록 좋다.

A	-> B
	-> C
    -> D
    -> E
    ...

한 클래스에서 많은 기능을 제공하는 경우

어쩔 수 없이 의존하는 대상이 많아질 수 있다.

EX) 각 기능(메서드)마다 의존하는 대상이 다른 경우

두 가지 문제가 발생할 수 있다.

  1. 한 메서드에서 발생한 변경이 다른 메서드에도 영향을 줌
  2. 한 메서드만 테스트를 하고 싶은데, 다른 메서드에서 사용하는 의존관계도 전부 초기화해야 하는 불편함

한 클래스가 제공하는 기능이 많다면, 기능 별로 클래스를 분리해보는 것을 고려

// 각 메서드에서 서로 다른 의존성을 가지는 상황
class UserService {
	public void regist() {...}
    public void changepw() {...}
    public void blockUser() {...}
}

기능 별로 클래스를 분리해보자

// 한 클래스에서 한 개의 의존성을 가지도록 분리
class UserRegisterService {
	public void regist() {...}
}

class ChangePasswordService {
    public void changepw() {...}
}

class BlockUserService() {
    public void blockUser() {...}
}

클래스 수는 증가하지만 각 클래스의 의존도가 감소했다. 즉, 한 기능을 수정했을 때 수정의 전파 가능성 감소했다.
이렇게 의존성을 낮추면 기능 별 테스트 용이성이 상승하는 효과를 거둘 수 있다.

여러 기능을 단일 기능으로 묶을 수 있는지를 고려

A 	-> B			A 	-> B
	-> C				-> C
    -> D				-> D''
    -> D'

DD'가 큰 관점에서는 동일한 목적을 위해 작동하므로 하나의 클래스 D''로 묶음 -> A에서 의존하는 클래스 감소

의존 대상을 직접 생성하지 말라

의존 대상 객체를, 생성하는 클래스에서 직접 생성하게되면, 의존 클래스가 바뀌면 생성하는 클래스도 변경이 발생한다. 그러므로 의존 대상 객체를 직접 생성하지 말라

다음등의 방법으로 직접 생성을 회피할 수 있다.

1. Factory, Builder
2. Dependency Injection
3. Service Locator

DI

의존하는 대상을 직접 생성하지 않고, 대신에 생성자나 메서드를 통해 주입받는 방식을 의미한다.

// UserService가 UserRepository, BookRepository에 의존한다.
public class UserService {
	private UserRepository userRepository;
    private BookRepository bookRepository;
    
    // 생성자 주입
    public UserService(UserRepository userRepository) {
    	this.userRepository = userRepository;
    }
    
    // 메서드 주입
    public void setBookRepository(BookRepository bookRepository) {
    	this.bookRepository = bookRepository;
    }
}

// 초기화
UserRepository userRepository = new UserRepository();
UserService userService = new UserService(userRepository);

BookRepository bookRepository = new BookRepository();
userService.setBookRepository(bookRepository);

프로그램을 실행하는 메인 메서드에서 직접 DI를 통해 의존 관계를 설정할 수도 있으나, 주로 조립기(Assembler)를 통해 DI를 실시한다.

Java의 대표적인 조립기가 바로 Spring Framework인 것이다.

DI 장점

  1. 의존 대상이 바뀌게 되면 조립기의 설정 코드만 변경하면 된다.
    • 의존 객체를 사용하는 클래스의 코드를 변경할 필요가 없다.
  2. 의존하는 객체를 실제로 설정하지 않고, 이를 대체하는 대역 객체를 사용해서 테스트를 할 수 있다.
    • 실제로는 DB에 연결된 Repository를 사용해야 하는데, DI를 사용했기 때문에 메모리 DB를 사용해서 테스트를 할 수 있다.

DIP

고수준 모듈, 저수준 모듈

고수준 모듈

  • 의미 있는 단일 기능을 제공하는 모듈
  • 상위 수준의 정책 구현

저수준 모듈

  • 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현

예를 들어, 도면 이미지를 NAS에 저장하고, 측정 정보를 DB 테이블에 저장하는 기능은 다음과 같이 분석 가능하다.

고수준 모듈저수준 모듈
도면 이미지 저장NAS에 저장
측정 정보 저장ScaleInfo 테이블에 저장

Q: 만약 고수준 모듈이 저수준 모듈을 직접 의존하면 어떤 문제가 발생하는가?
A: 고수준 모듈의 정책은 변하지 않았는데, 고수준 모듈이 저수준 모듈에 의존하다보니 고수준 모듈의 코드가 변경된다.

'NAS에 이미지 저장한다'는 저수준 모듈이 'Amazon S3에 저장한다'는 내용으로 변경됨 -> 고수준 모듈이 영향 받음

이러한 문제를 방지하기 위해 DIP (Dependency Inversion Principle)를 준수해야 한다.

DIP

의존 역전 원칙은 고수준 모듈은 저수준 모듈의 구현에 의존하면 안 됨을 의미한다.
대신, 저수준 모듈이 고수준 모듈에서 정의한 추상타입에 의존하도록 설계해야 한다.

class UserService {
	private MemoryUserRepository userRepository;
    private NasFileStorage fileStorage;
}

위와 같이 UserRepository(고수준 모듈)MemoryUserRepository, NasFileStorage(저수준 모듈)를 의존해서는 안 된다. 요구 사항이 변경되어 Nas에서 Amazon S3로 변경되면, UserRepository의 코드가 변경되기 때문이다.

대신 다음과 같이 저수준 모듈이 고수준 모듈에 의존하도록 설계해야 한다.

class UserService {
	private UserRepository userRepository;
    private Storage storage;
}

// 고수준 모듈에서 추상 타입 제시
interface UserRepository {...}
interface Storage {...}

// 고수준 모듈에서 제시한 타입을 저수준 모듈에서 의존(준수)
class MemoryUserRepository implements UserRepository {...}
class NasFileStorage implements Storage {...}

이와 같이 DIP를 준수하여 설계하기(추상 타입 도출) 위해서는 고수준 모듈 관점으로 추상화해야 한다.

DIP를 잘 따르면 유연성을 확보할 수 있다. 즉, 수준 모듈의 변경을 최소화 하면서, 저수준 모듈의 변경 유연성을 높일 수 있다.

profile
블로그 이전했습니다 https://dev314.tistory.com/

0개의 댓글