어댑터 패턴의 개념
한국에서 사용하던 플러그를 외국에서도 사용하려면 플러그 모양을 바꿔주는 어댑터가 필요하다.
이와 비슷하게, 디자인 패턴에서의 어댑터 패턴도 어떤 인터페이스를 클라이언트에서 요구하는 형태로 적응시키는 역할
을 한다.
어떤 새로운 라이브러리를 사용해야 하는데 그 인터페이스가 기존의 인터페이스와 다를 경우, 새 인터페이스를 기존의 인터페이스에 적응시켜주는 클래스를 만들면 된다.
클래스 어댑터와 객체 어댑터
어댑터 패턴에는 클래스 어댑터, 객체 어댑터라는 두 가지 방식이 있다.
클래스 어댑터는 상속 관계를 사용한 방식이고, 객체 어댑터는 합성 관계를 사용한 방식이다.
헤드퍼스트 디자인 패턴에도 나오는 유명한 예시인, Duck과 Turkey의 예제로 설명해보겠다.
합성 기반의 객체 어댑터
interface Duck {
void quack();
void fly();
}
기존에 이런 오리 인터페이스가 있다고 하자.
interface Turkey {
void gobble();
void fly();
}
그리고 이 인터페이스를 사용하다, Turkey라는 새로운 인터페이스가 등장했다고 하자.
Duck과 Turkey는 서로 함수 시그니처가 다르기 때문에 호환되지 않는다.
이 때 어댑터 패턴을 사용해볼 수 있다.
class TurkeyAdapter implements Duck {
private final Turkey turkey;
public TurkeyAdapter(Turkey turkey) {
this.turkey = turkey;
}
@Override
public void quack() {
turkey.gobble();
}
@Override
public void fly() {
turkey.move();
}
}
다음과 같이, Turkey를 가지고 있는(has-a, 합성) 래핑 클래스를 만들었고, Duck과 같은 인터페이스를 가지도록 구현하여 인터페이스가 호환되도록 어댑터를 구현했다.
이제 Duck을 사용하는 곳에서 Turkey를 사용할 경우, TurkeyAdapter를 이용할 경우 두 개를 같이 사용할 수 있다.
상속 기반의 클래스 어댑터
interface Duck {
void quack();
void fly();
}
class Turkey {
public void quack() {
System.out.println("꽥꽥");
}
public void move() {
System.out.println("움직인다");
}
}
class TurkeyAdaptor extends Turkey implements Duck {
// 시그니처가 겹치는 quack은 구현할 필요가 없이, 직접 Turkey에서 상속받은 걸 사용한다
@Override
public void fly() {
super.move();
}
}
상속 기반의 클래스 어댑터는 어댑터를 만들 대상을 상속 받아서 구현한다.
상속했기 때문에, 이미 Turkey에도 존재하는 quack이라는 메소드는 굳이 구현할 필요가 없었다.
모든 메소드를 만들어줘야하는 객체 어댑터와의 가장 큰 차이점이 바로 이것이다.
어떤 것을 사용할까
실제 개발에서 클래스 어댑터와 객체 어댑터 중 어떤 것을 선택하면 좋을까?
(Adaptee = 어댑터를 만들어야하는 인터페이스, 위의 예제에선 Turkey)
(Target = 만들고자 하는 인터페이스, 위의 예제에선 Duck)
- Adaptee 인터페이스의 수가 많지 않다면, 어떤 방식을 사용하든 괜찮다.
- Adaptee 인터페이스가 많지만 Adaptee와 Target의 정의가 대부분 같다면 상속을 통해 재사용할 수 있으므로 클래스 어댑터가 더 코드가 짧아 좋다.
- Adaptee 인터페이스가 많고, Adaptee와 Target의 정의가 대부분 다르다면 더 유연한 구조인 객체 어댑터가 더 좋다.
어댑터 패턴의 응용
어댑터 패턴은 두 인터페이스가 호환되지 않을 때 사용되므로, 일반적으로 설계 결함을 교정하는 보상 패턴이라고 할 수 있다.
설계 초기 단계에서 인터페이스 비호환성 문제를 피할 수 있다면 어댑터 패턴을 사용될 필요가 없다.
실제 개발 단계에선 어떨 때 인터페이스가 호환되지 않는 일이 발생할까?
호출하는 API의 response가 변경될 수 있는 경우
웹 개발 프로젝트 초기 단계에서는 API에 대해 아무것도 정해진 게 없는 상황이 많고, 이 경우 API 응답 모델을 예상해서 더미 데이터를 이용해 개발을 진행해야 한다.
이 때 예상했던 응답 모델과 실제 구현된 API의 응답 모델이 다를 경우 UI 컴포넌트의 변경이 일어나곤 한다. API 응답이 어떻게 내려오는지에 대한 관심사를 한 곳에 묶고, UI 컴포넌트의 변경을 최소화하기 위해 어댑터 패턴을 사용해볼 수 있다.
해당 내용에 대해 더 자세한 내용은 여기서..
(추가로 최근 출시된 '우아한 타입스크립트 리액트' 책에도 뷰모델과 관련하여 비슷한 내용의 부분이 있었는데, 추후 추가할 예정입니다)
변경될 수 있는 외부 라이브러리를 사용하는 경우
프로젝트를 오랜 시간동안 운영하다보면 라이브러리의 변경은 뜻하지 않게 찾아온다.
- Java HTTP 라이브러리를 RestTemplate에서 WebClient으로 변경할 수도 있다.
- Node.js에서 시간 타입을 Date에서
js-joda
를 사용하게 될 수도 있다. - 혹은 ERP 시스템이 Java Swing 기반의 응용 프로그램에서 Spring 기반의 Web 프로젝트로 전환이 될 수도 있다.
이렇게 라이브러리가 변경될 때마다 코드를 대거 수정해야 한다면 꽤나 곤란할 것이다. 이런 상황에서 어댑터 패턴을 사용해볼 수 있다.
인프런이 변화에 대응하기 위해 Http 클라이언트를 래핑해 만든 이야기는 유명하고, 또 인터페이스나 인프라 기반 요소의 변경에 영향을 받지 않기 위한 포트와 어댑터 아키텍처는 더 유명하다.
지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기
결함이 있는 인터페이스 설계가 캡슐화된 경우
외부 시스템의 인터페이스가 많은 static 메소드를 포함하는 것과 같은 결함이 있다면, 테스트 용이성에 영향을 미치게 된다.
이런 설계 결함을 분리하기 위해 외부 시스템의 인터페이스를 다시 캡슐화하여 높은 사용성과 테스트 용이성을 가진 인터페이스로 재구축할 수 있다.
우테코 프리코스의 로또 게임에서는 Randoms.pickNumberInRange
라는 랜덤 숫자 생성 라이브러리를 제공했다.
class LottoGenerator {
public Set<Integer> generate() {
Set<Integer> generatedNumbers = new HashSet<>();
while (generatedNumbers.size() < 6) {
int number = Randoms.pickNumberInRange(1, 45);
generatedNumbers.add(number);
}
return generatedNumbers;
}
}
그런데 이 함수는 static으로 이루어져있어, 이런 식으로 직접 의존해서 사용할 경우 LottoGenerator라는 클래스의 테스트를 짜기에 어려움이 많았다.
이런 코드로는 랜덤 숫자 생성 결과가 1으로만 6번 나왔을 때 중복을 다 걸러내고 다음 숫자를 뽑아낼지, 아니면 그대로 1을 6개 담은 Set을 반환할지를 검증하기 어려웠다.
이럴 때 어댑터 패턴을 활용해서 결함이 있는 인터페이스 설계를 캡슐화할 수 있다.
interface NumberGenerator {
int pickNumberInRange(int minInclusive, int maxInclusive);
}
이렇게 생긴, 내가 원하는 인터페이스를 만든다.
// Randoms를 캡슐화한 제너레이터
class RandomNumberGenerator implements NumberGenerator {
@Override
int pickNumberInRange(int minInclusive, int maxInclusive) {
return Randoms.pickNumberInRange(minInclusive, maxInclusive);
}
}
그 후 이런 식으로 어댑터를 만들어준다.
// 테스트 시 사용할 가짜 제너레이터
class StubNumberGenerator implements NumberGenerator {
@Override
public int pickNumberInRange(int minInclusive, int maxInclusive) {
return 1;
}
}
class LottoGenerator {
private final NumberGenerator numberGenerator;
public LottoGenerator(NumberGenerator numberGenerator) {
this.numberGenerator = numberGenerator;
}
public Set<Integer> generate() {
Set<Integer> generatedNumbers = new HashSet<>();
while (generatedNumbers.size() < 6) {
int number = numberGenerator.pickNumberInRange(1, 45);
generatedNumbers.add(number);
}
return generatedNumbers;
}
}
이제는 어댑터 패턴 덕에 Stub 객체를 주입할 수 있게 되어, 테스트 용이성을 갖춘 인터페이스로 재구축할 수 있었다.
여러 클래스의 인터페이스 설계를 통합할 경우
함수의 구현은 각 라이브러리에 따라 다를 수 있다. 어댑터 패턴을 통해 인터페이스를 통합하고 조정한 다음, 다형성을 이용해 코드 논리를 재사용할 수 있다.
class AFilter {
public String filterObsceneWord(String text) {
// ...
}
public String filterPoliticalWord(String text) {
// ...
}
}
class BFilter {
public String filter(String text) {
// ...
}
}
class CFilter {
public String filter(String text, String mask) {
// ...
}
}
민감 단어 필터링 시스템을 구축하는데, 각 필터들을 각자 다른 외부 라이브러리를 사용한다고 하자.
각 Filter들의 메소드의 정의들도 각자 다른 것을 볼수 있다.
interface Filter {
String filter(String text);
}
다음과 같이 인터페이스 정의를 통일할 인터페이스를 만들자.
class AFilterAdapter implements Filter {
private AFilter aFilter;
@Override
public String filter(String text) {
String maskedText;
maskedText = aFilter.filterObsceneWord(text);
maskedText = aFilter.filterPoliticalWord(maskedText);
return maskedText;
}
}
class BFilterAdapter implements Filter {
private BFilter bFilter;
@Override
public String filter(String text) {
return bFilter.filter(text);
}
}
class CFilterAdapter implements Filter {
private CFilter cFilter;
@Override
public String filter(String text) {
return cFilter.filter(text, "***");
}
}
그 후 이렇게 각 필터 별로 어댑터를 구현한다.
String text;
for(Filter filter : filters) {
text = filter.filter(text);
}
return text;
그 덕분에 이렇게 다형성을 활용해서 enhanced-for를 돌릴 수도 있게 되었다.
그리고 또 다른 필터가 추가되어야 할 때 다형성을 이용하여 확장성도 챙길 수 있다.
'개발 일반 > 디자인 패턴' 카테고리의 다른 글
디자인 패턴 깊게 핥아보기 - 플라이웨이트 패턴 (1) | 2023.11.25 |
---|