JUINTINATION

어댑터(Adapter) 패턴 본문

StudyNote

어댑터(Adapter) 패턴

DEOKJAE KWON 2024. 11. 2. 22:46
반응형

어댑터 패턴이란?

어댑터 패턴은 클래스의 인터페이스를 사용자가 기대하는 다른 인터페이스로 변환하는 패턴으로, 호환성이 없는 인터페이스 때문에 함께 동작할 수 없는 클래스들이 함께 작동하도록 해준다. GOF 디자인 패턴 중 구조 패턴에 해당하며, 이름 그대로 클래스를 어댑터로서 사용되는 디자인 패턴이다.

어댑터 패턴은 그 이름에서도 알 수 있듯이 조정에 따른 적응(Adaptation)에 사용되며, 호환되지 않는 인터페이스를 호환 가능한 인터페이스로 변환하여 두 클래스를 함께 작동할 수 있게 한다. 흔히 어댑터 패턴을 설명할 때 USB 어댑터를 예로 드는 경우가 많다. 두 개의 호환되지 않는 인터페이스가 USB 어댑터를 통해 함께 작동할 수 있다는 것을 생각하면 이해하기 쉬울 것이다.

클래스 어댑터와 객체 어댑터

어댑터 패턴에서는 클래스 어댑터와 객체 어댑터의 두 가지 형태가 있다. 클래스 어댑터는 상속 관계를 사용한 방식이고, 객체 어댑터는 합성 관계를 사용한 방식이다.
다음 코드에서 각 인터페이스와 클래스에 대한 설명은 다음과 같다.

  • ITarget: 변환할 대상 인터페이스
  • Adaptee: ITarget과 호환되지 않는 원본 인터페이스 그룹
  • Adapter 클래스: Adaptee를 ITarget 인터페이스에서 정의한 호환 가능한 인터페이스로 변환
// 상속 기반의 클래스 어댑터
interface ITarget {
    void f1();
    void f2();
    void fc();
}

class Adaptee {
    public void fa() { System.out.println("fa()"); }
    public void fb() { System.out.println("fb()"); }
    public void fc() { System.out.println("fc()"); }
}

class Adapter extends Adaptee implements ITarget {

    @Override
    public void f1() {
        super.fa();
    }

    @Override
    public void f2() { /* do nothing */ }

    // fc()를 구현할 필요 없이 Adaptee에서 직접 상속하는 것이 객체 어댑터와의 가장 큰 차이점

}
// 합성 기반의 객체 어댑터
interface ITarget {
    void f1();
    void f2();
    void fc();
}

class Adaptee {
    public void fa() { System.out.println("fa()"); }
    public void fb() { System.out.println("fb()"); }
    public void fc() { System.out.println("fc()"); }
}

class Adapter implements ITarget {

    private Adaptee adaptee;

    public Adapter(Adaptee adaptee) {
        this.adaptee = adaptee;
    }

    @Override
    public void f1() {
        adaptee.fa();
    }

    @Override
    public void f2() { /* do nothing */ }


    @Override
    public void fc() {
        adaptee.fc();
    }

}

그렇다면 실제 개발에서 클래스 어댑터와 객체 어댑터 중 어떤 것을 선택해야 할까? 여기서는 두 가지 기준에 따라 판단할 수 있다. 하나는 Adaptee 인터페이스의 수이고 다른 하나는 Adaptee 인터페이스와 ITarget 인터페이스 간의 적합도이다. 상세한 판단 규칙은 다음과 같다.

  1. Adaptee 인터페이스가 많지 않다면 어느 것을 사용해도 무방하다.
  2. Adaptee 인터페이스가 많지만, Adaptee와 ITarget 인터페이스의 정의가 대부분 같다면 Adapter 클래스가 상위 클래스 Adaptee의 인터페이스를 재사용할 수 있으므로 클래스 어댑터를 사용하는 것이 좋다. 실제로 객체 어댑터에 비해 클래스 어댑터의 코드가 더 작다.
  3. Adaptee 인터페이스가 많은 데다가 Adaptee와 ITarget 인터페이스의 정의가 대부분 다르다면, 상속 구조보다 유연한 합성 구조 기반의 객체 어댑터를 사용하는 것이 좋다.

어댑터 패턴을 사용해야 하는 경우

  • 호환되지 않는 인터페이스
    • 예를 들어, 새로운 라이브러리를 기존 코드에 통합해야 할 때와 같이 기존 시스템과 새로운 시스템 간에 호환되지 않는 인터페이스가 있을 때, 어댑터를 사용하여 두 시스템이 원활하게 통신할 수 있도록 한다.
  • 재사용 가능한 코드
    • 이미 존재하는 클래스를 수정할 수 없거나 수정하고 싶지 않을 때, 어댑터 패턴을 사용하여 기존 클래스를 새로운 인터페이스에 맞게 변환할 수 있다. 이렇게 하면 기존 코드를 재사용하면서도 새로운 기능을 추가할 수 있다.
  • 유연한 시스템 설계
    • 프로젝트가 성장함에 따라 새로운 구성 요소가 추가되는 경우, 어댑터를 사용하면 기존 코드를 변경하지 않고도 새로운 구성 요소를 통합할 수 있다. 이는 시스템의 유연성과 유지보수성을 높일 수 있다.
  • 변경의 캡슐화
    • 어댑터를 사용하면 인터페이스 변경이 필요한 경우, 변경 사항을 어댑터에 캡슐화할 수 있다. 이렇게 하면 클라이언트 코드가 변경될 필요 없이 어댑터만 수정하면 된다.
  • 다양한 구현 지원
    • 어댑터는 다양한 구현을 지원할 수 있기 때문에 여러 종류의 객체가 동일한 인터페이스를 통해 상호작용할 수 있게 만들 수 있다. 이는 특히 다형성을 활용하는 데 유리하다.

퍼사드 패턴과 어댑터 패턴

현재 진행중인 프로젝트에 어댑터 패턴을 적용한 부분은 ImageService와 ProfileImageService이다. 해당 프로젝트는 동영상을 공간 이미지로 변환해주는 서비스로, 공간 이미지를 의미하는 Image와 회원의 프로필 이미지를 의미하는 ProfileImage 엔티티가 있다.

Image와 ProfileImage는 역할이 거의 동일하지만 약간의 차이가 있다. 두 엔티티를 코드로 보면 다음과 같다.

package com.example.vpapi.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = {"uploader"})
public class Image extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long ino;

    @Column(nullable = false)
    private String fileName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "uploader_id", nullable = false)
    private Member uploader;

}
package com.example.vpapi.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "member")
public class ProfileImage extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long pino;

    @Column(nullable = false)
    private String fileName;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false, unique = true)
    private Member member;

}

Image는 Member와 일대다 관계, ProfileImage는 Member와 일대일 관계이기 때문에 어노테이션에 약간의 차이가 있다. 그래서 각각의 서비스는 역할이 거의 유사함에도 불구하고 일부러 두 개의 서비스로 나눴는데, IntelliJ에서 코드가 중복된다고 경고를 하는 문제가 있었다.

사실 그냥 넘어갈 수 있는데 이런 상황을 해결하지 않으면 안 되는 성격이라 지난 퍼사드(Facade) 패턴에 대한 글을 작성하면서 언급했던 ConvertFacade를 만들면서 ImageFacade를 같이 만들었다. 해당 코드는 아래와 같다.

package com.example.vpapi.facade;

import com.example.vpapi.dto.ImageDTO;
import com.example.vpapi.dto.ProfileImageDTO;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.Map;

@Transactional
public interface ImageFacade {

    ImageDTO getImage(Long ino);

    ProfileImageDTO getProfileImage(Long pino);

    Boolean existsProfileImageByMno(Long mno);

    ProfileImageDTO getProfileImageByMno(Long mno);

    Map<String, String> viewImage(Long ino) throws IOException;

    Map<String, String> viewImageThumbnail(Long ino) throws IOException;

    ResponseEntity<Resource> viewProfileImage(Long pino);

    ResponseEntity<Resource> viewProfileImageThumbnail(Long pino);

    ResponseEntity<Resource> viewProfileImageByMno(Long mno);

    ResponseEntity<Resource> viewProfileImageThumbnailByMno(Long mno);

    Map<String, Long> registerImage(ImageDTO imageDTO) throws IOException;

    Map<String, Long> modifyProfileImage(ProfileImageDTO profileImageDTO);

    void removeImage(Long ino);

    void removeProfileImage(Long pino);

    void removeProfileImageByMno(Long mno);

}

해당 인터페이스를 구현하면서 하나의 클래스에서 두 가지 서비스를 가지고 각각의 로직을 수행할 수 있게 되었다. 그러나 퍼사드 패턴은 이러한 이 상황에 적합하지 않은데, 그 이유는 아래와 같다.

  • 목적
    • 퍼사드 패턴은 복잡한 서브시스템에 대한 단순화된 인터페이스를 제공하며 여러 클래스의 복잡성을 감춰 클라이언트가 더 쉽게 사용할 수 있도록 돕는 역할이지만, ImageController와 ProfileImageController는 각각 ImageService, ProfileImageService를 사용하면 되기 때문에 현재 상황에 적합하지 않는다.
  • 사용 사례
    • 퍼사드 패턴은 복잡한 라이브러리나 프레임워크를 사용할 때 여러 기능을 조합하여 간단한 메서드로 제공하는 경우에 적합하지만, ImageController는 Image 관련 데이터만 필요하기 때문에 ProfileImageService가 필요하지 않고, ProfileImageController는 반대로 ProfileImage 관련 데이터만 필요하기 때문에 ImageService가 필요하지 않다. 그렇기 때문에 ConvertFacade 처럼 Video 엔티티를 Image 엔티티로 변환하는 과정처럼 두 서비스를 조합할 필요가 없다.

그래서 퍼사드 패턴을 적용해도 정상적으로 작동은 하지만 굳이 퍼사드 패턴을 적용할 필요가 없는 것이다.

다음은 어댑터 패턴을 적용한 UnifiedImageServiceAdapter 인터페이스 코드이다.

실제 프로젝트에 적용해보기

package com.example.vpapi.service.adapter;

import com.example.vpapi.dto.ImageDTO;
import com.example.vpapi.dto.ProfileImageDTO;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.Map;

@Transactional
public interface UnifiedImageServiceAdapter {

    ImageDTO getImage(Long ino);

    ProfileImageDTO getProfileImage(Long pino);

    Boolean existsProfileImageByMno(Long mno);

    ProfileImageDTO getProfileImageByMno(Long mno);

    Map<String, String> viewImage(Long ino) throws IOException;

    Map<String, String> viewImageThumbnail(Long ino) throws IOException;

    ResponseEntity<Resource> viewProfileImage(Long pino);

    ResponseEntity<Resource> viewProfileImageThumbnail(Long pino);

    ResponseEntity<Resource> viewProfileImageByMno(Long mno);

    ResponseEntity<Resource> viewProfileImageThumbnailByMno(Long mno);

    Map<String, Long> registerImage(ImageDTO imageDTO) throws IOException;

    Map<String, Long> modifyProfileImage(ProfileImageDTO profileImageDTO);

    void removeImage(Long ino);

    void removeProfileImage(Long pino);

    void removeProfileImageByMno(Long mno);

}

아무것도 구현하지 않는 이러한 형태는 어댑터 패턴의 일반적인 사용 방식과는 다소 차이가 있지만, 어댑터 패턴의 핵심 개념은 서로 다른 인터페이스를 가진 객체들이 함께 작업할 수 있도록 중재하는 것이기 때문에 이를 적용했다고 볼 수 있을 것이다.

결론적으로 어댑터 패턴의 일부 원칙을 따르고 있지만, 전통적인 어댑터 패턴의 형태와는 조금 다르다.

그렇다면 이 코드가 퍼사드(Facade) 패턴을 적용한 것과 무엇이 다른지에 대해서는 해당 인터페이스의 구현체를 보면 알 수 있다.

package com.example.vpapi.service.adapter;

import com.example.vpapi.dto.ImageDTO;
import com.example.vpapi.dto.ProfileImageDTO;
import com.example.vpapi.service.ImageService;
import com.example.vpapi.util.CustomFileUtil;
import com.example.vpapi.util.ImageType;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.Base64;
import java.util.Map;

@Service
@RequiredArgsConstructor
public class ImageServiceAdapter implements UnifiedImageServiceAdapter {

    private final ImageService imageService;

    private final CustomFileUtil fileUtil;

    @Override
    public ImageDTO getImage(Long ino) {
        return imageService.get(ino);
    }

    @Override
    public ProfileImageDTO getProfileImage(Long pino) {
        throw new UnsupportedOperationException("Not supported by ImageService");
    }

    /* 이하 생략 */

}
package com.example.vpapi.service.adapter;

import com.example.vpapi.dto.ImageDTO;
import com.example.vpapi.dto.ProfileImageDTO;
import com.example.vpapi.service.ProfileImageService;
import com.example.vpapi.util.CustomFileUtil;
import com.example.vpapi.util.ImageType;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

@Service
@RequiredArgsConstructor
public class ProfileImageServiceAdapter implements UnifiedImageServiceAdapter {

    private final ProfileImageService profileImageService;

    private final CustomFileUtil fileUtil;

    @Override
    public ImageDTO getImage(Long ino) {
        throw new UnsupportedOperationException("Not supported by ProfileImageService");
    }

    @Override
    public ProfileImageDTO getProfileImage(Long pino) {
        return profileImageService.get(pino);
    }

    /* 이하 생략 */

}

기존의 ImageFacadeImpl은 getImage, getProfileImage 메서드와 같이 사용에 혼동을 줄 수 있는 비슷한 역할의 메서드들이 모두 구현되어 있지만, ImageServiceAdapter를 예로 들 때 getProfileImage 메서드는 UnsupportedOperationException를 던지는 등 필요없는 로직은 실행 자체가 불가능하다는 차이점이 있다.

@RequiredArgsConstructor 어노테이션 관련 문제

기존 컨트롤러의 코드를 보면 @RequiredArgsConstructor 어노테이션이 적용되어 다음 코드를 통해 스프링에서 자동으로 객체를 불러왔었다.

// ImageController.java
@Qualifier("imageServiceAdapter")
private final UnifiedImageServiceAdapter unifiedImageServiceAdapter

// ProfileImageController.java
@Qualifier("profileImageServiceAdapter")
private final UnifiedImageServiceAdapter unifiedImageServiceAdapter

하지만 이렇게 코드를 유지하면 ImageController와 ProfileController에서 UnifiedImageServiceAdapter 구현체 객체를 불러올 때 충돌이 발생한다. 그 원인을 정리해보자면 다음과 같다.

  • 두 개의 UnifiedImageService 타입의 구현체
    • ImageController와 ProfileImageController 둘 다 UnifiedImageService 인터페이스를 주입받으려고 한다.
    • 두 컨트롤러 모두 @RequiredArgsConstructor를 사용하고 있다.
    • Spring은 UnifiedImageService 타입의 빈을 주입하려고 할 때 ImageServiceAdapter와 ProfileImageServiceAdapter 두 개의 구현체를 발견한다.
  • 필드 레벨의 @Qualifier 어노테이션
    • @RequiredArgsConstructor를 사용할 때 필드 레벨의 @Qualifier는 Spring이 생성자 매개변수에 대한 정보를 얻는 데 사용되지 않는다.
    • 따라서 Spring은 여전히 어떤 빈을 주입해야 할지 결정하지 못하고 에러를 발생시킨다.

이를 해결하기 위해 컨트롤러의 코드를 생성자 주입 방식으로 변경하고, 생성자 매개변수에 @Qualifier를 적용하여 Spring이 올바른 빈을 식별할 수 있도록 해야한다. 수정된 컨트롤러 코드는 다음과 같다.

package com.example.vpapi.controller;

import com.example.vpapi.dto.ImageDTO;
import com.example.vpapi.service.adapter.UnifiedImageServiceAdapter;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.util.Map;

@RestController
@RequestMapping("/api/images")
public class ImageController {

    private final UnifiedImageServiceAdapter unifiedImageServiceAdapter;

    public ImageController(@Qualifier("imageServiceAdapter") UnifiedImageServiceAdapter unifiedImageServiceAdapter) {
        this.unifiedImageServiceAdapter = unifiedImageServiceAdapter;
    }

    /* 이하 생략 */

}
package com.example.vpapi.controller;

import com.example.vpapi.dto.ProfileImageDTO;
import com.example.vpapi.service.adapter.UnifiedImageServiceAdapter;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/profile/images")
public class ProfileImageController {

    private final UnifiedImageServiceAdapter unifiedImageServiceAdapter;

    public ProfileImageController(@Qualifier("profileImageServiceAdapter") UnifiedImageServiceAdapter unifiedImageServiceAdapter) {
        this.unifiedImageServiceAdapter = unifiedImageServiceAdapter;
    }
    
    /* 이하 생략 */
    
}

결론

전통적인 방식은 아니지만 아무튼 현재 진행중인 프로젝트에 어댑터 패턴을 적용했다. 정석적인 방법을 좋아하는 나에게 그렇게 반가운 상황은 아니라 약간 찜찜하긴 하지만, 이 기회에 다양한 디자인 패턴을 공부해보며 프로그래밍 능력이 향상되는 경험이었던 것 같다. 현재 적용된 방식보다 더 정석적인, 더 적합한 문제 해결 방법을 발견한다면 공부한 뒤에 다시 글로 정리하며 깔끔하게 처리하고 싶다는 생각이 들었다.

728x90
Comments