이전에 개발했던 사이드 프로젝트에서는 이미지 파일을 관리해야 하는 비즈니스 요구사항을 만족시키기 위해 외부 의존성인 오브젝트 스토리지를 도입했었는데요. 많은 오브젝트 스토리지 서비스들 중 AWS에서 제공하는 매니지드 서비스인 S3를 채택하여 사용하고 있었답니다.
그런데, 애플리케이션 레벨에서 AWS S3와 통신하는 비즈니스가 구체화 되어있다보니 상황에 따라 다른 오브젝트 스토리지로의 교체가 어려운 구조라는 것을 알게 되었어요. 즉, 애플리케이션이 S3에 강하게 결합되어 있다고 느꼈죠. 물론 처음에는 그리 큰 문제가 아니라고 생각했지만, 테스트를 작성하면서 S3로의 강결합에 대한 문제가 있다는 것도 발견했어요.
그래서 애플리케이션에 강하게 결합된 S3와의 구체적인 내용들을 추상화를 통해 줄여볼 수 있었어요. 실제 프로덕션 레벨에서 애플리케이션의 비즈니스 로직이 특정한 스토리지 시스템에 종속되지 않고 유연하게 교체할 수 있으며 테스트 수행시에도 구체적인 스토리지를 주입하지 않으니 쉽게 테스트할 수 있는 구조로 개선되었습니다.
이번 글에서는 이렇게 추상화를 통해 S3와 같은 외부 시스템과 애플리케이션의 결합도를 어떻게 낮추었는지 간단히 이야기 해볼게요.
- 앞으로 언급할 애플리케이션의 비즈니스 로직을 담당하는 객체들은 비즈니스(Business) 영역 내 패키징이 되어 있고, 외부 시스템과 연동하는 외부 의존성을 주입받아 구현하는 객체들은 인프라스트럭처(Infrastructure) 영역 내 패키징이 되어 있습니다.
- BucketService 객체는 애플리케이션에서 다루는 이미지 파일을 연동된 오브젝트 스토리지의 버킷(Bucket)을 관리하는 비즈니스 영역의 서비스 객체입니다.
- AwsS3Uploader 객체는 요청에 따라 실제로 AWS의 S3와 통신하며 버킷의 데이터 생명주기를 관장하는 인프라스트럭처 영역의 서비스 객체입니다.
@Service
@RequiredArgsConstructor
public class BucketServiceImpl implements BucketService {
// AWS S3로의 통신을 담당하는 구현 클래스 직접 의존
private final AwsS3Uploader awsS3Uploader;
@Override
public BucketUploadResponse uploadImages(List<MultipartFile> requestImageFiles) {
... // 유효성 검사 생략
List<String> imagePathList = uploadS3BucketWithGetUrls(requestImageFiles);
return BucketUploadResponse.of(imagePathList);
}
private List<String> uploadS3BucketWithGetUrls(List<MultipartFile> imageFiles) {
return imageFiles.stream()
.map(awsS3Uploader::upload)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
위와 같은 코드로 구현이 되어 있는데요. 간단히 코드 구조를 살펴보시죠.
BucketServiceImpl
구현 클래스에서는 AwsS3Uploader
클래스를 주입받아 uploadImages
함수 내에서 요청으로 받은 이미지 파일들(requestImageFiles)을 uploadS3BucketWithGetUrls
함수를 호출하고 업로드한 객체 URL을 문자열 리스트로 반환받아 응답해요.
이어서 uploadS3BucketWithGetUrls
함수에서는 클래스 레벨에서 주입받은 awsS3Uploader
의 upload
함수를 호출하여 실제 S3에 업로드 후 객체 URL을 반환합니다.
여기서 주목할 점은 uploadImages
함수도, uploadS3BucketWithGetUrls
함수도 아니에요. 바로 BucketServiceImpl
객체가 AwsS3Uploader
객체를 직접 의존하고 있다는 점입니다.
앞에서 본 코드의 구조를 도식화해보았어요. 사실 이 구조만으로도 오브젝트 스토리지와 통신하는데는 큰 문제가 없을 수도 있다고 느낄 수 있습니다. 하지만 테스트를 작성하면서 BucketServiceImpl
객체가 AwsS3Uploader
객체를 직접 의존하면 안되는 이유를 알게 되었어요. 이번에는 BucketServiceImplTest
테스트 코드를 봐 볼게요.
class BucketServiceImplTest {
private BucketService bucketService;
@BeforeEach
void setUp() throws Exception {
AmazonS3 amazonS3 = Mockito.mock(AmazonS3.class);
this.bucketService = new BucketServiceImpl(new AwsS3Uploader(amazonS3));
given(amazonS3.putObject(any(PutObjectRequest.class))).willReturn(new PutObjectResult());
given(amazonS3.getUrl(any(), any())).willReturn(new URL("https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"));
}
@Test
@DisplayName("[Green] 요청받은 이미지 파일들을 업로드 한 후, URL 경로가 담긴 목록을 응답받는다.")
void 이미지_업로드후_URL_경로를_응답받는다() {
// given
List<MultipartFile> request = createImageFileList(1);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images())
.hasSize(1)
.allMatch(url -> url.contains("test.png"));
}
private static List<MultipartFile> createImageFileList(int size) {
return IntStream.range(0, size)
.mapToObj(i -> new MockMultipartFile("test", "test.png", "image/png", "test_file".getBytes(UTF_8)))
.collect(Collectors.toList());
}
}
위 테스트 클래스는 소형 테스트 구조로 스프링의 테스트 컨텍스트를 실행하지 않도록 작성했는데요. 비즈니스 로직의 호출 시점부터 실제 S3와의 연동까지의 흐름을 검증하는 성격의 통합 테스트와는 달리 비즈니스 영역의 책임과 관심사만을 적은 비용으로 빠르게 테스트하기 위함이에요.
그래서 BucketServiceImplTest
테스트 클래스에서는 AwsS3Uploader
객체를 통해 실제 버킷과 통신하는 관심사보다는 비즈니스 클래스인 BucketServiceImpl
의 uploadImages
함수의 검증 여부 관심사가 더 중요하다는 기준을 세워 테스트되어야 합니다.
그런데, BucketServiceImpl
비즈니스 객체에서 AwsS3Uploader
객체를 직접 의존하다보니 BucketServiceImpl
의 uploadImages
함수를 테스트하기 위해 AwsS3Uploader
객체에 대한 의존성을 Mocking해야 하는 번거로운 작업 비용이 추가적으로 발생합니다.
@BeforeEach
void setUp() throws Exception {
// bucketService에 필요한 의존성 주입
AmazonS3 amazonS3 = Mockito.mock(AmazonS3.class);
this.bucketService = new BucketServiceImpl(new AwsS3Uploader(amazonS3));
...
// S3 업로드 검증을 위한 Mocking
given(amazonS3.putObject(any(PutObjectRequest.class))).willReturn(new PutObjectResult());
given(amazonS3.getUrl(any(), any())).willReturn(new URL("https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png"));
}
다시 한 번 setUp
함수를 보면 BucketServiceImpl
클래스에 대한 사전 작업으로 느껴지기보다는 AwsS3Uploader
클래스에 대한 픽스처라는 게 바로 보이게 됩니다. 이는 빠르고 정확성이 높은 소형 테스트를 작성하는 데에는 큰 걸림돌이 될 것이라고 느껴졌어요. 또한, AWS S3가 아닌 MiniO와 같은 다른 오브젝트 스토리지로의 교체시 프로덕션 코드와 테스트 코드 모두 관리해야 하는 불필요한 비용이 발생하게 된다고 판단했어요.
🤔 비즈니스 담당 객체가 S3 연동 객체를 직접 의존하게 되면..
- 테스트시 S3에 대한 Mock 의존성이 불가피하다.
- 다른 오브젝트 스토리지로 교체시 관리 비용 증가된다.
그렇다면, 외부 시스템과 강하게 결합된 비즈니스 영역의 객체들을 어떻게 개선해볼 수 있을까요? 바로 추상화입니다. 추상화에 대한 개념적인 정의는 여기서 다루지는 않겠지만, 한 가지 기준을 토대로 추상화를 반영했는데요. 구체적인 내용에 의존하기보다 단순한 내용에 의존하자라는 취지로 접근했다고 볼 수 있을 것 같아요.
추상화를 통해 개선될 구조를 다시 한번 도식화해보았어요. 비즈니스 관심사를 다루는 서비스 클래스인 BucketService
에서는 단순하게 명세된 함수 선언부만을 가지도록 확장된 ObjectStorageClient
라는 인터페이스를 참조하도록 변경해 볼 수 있어요. 그리고 외부 시스템과 통신하는 관심사를 다루는 AwsS3Uploader
클래스가 ObjectStorageClient
인터페이스를 구현하도록 하는거죠!
이렇게 되면 비즈니스에서는 단순한 내용만을 가지는 인터페이스를 의존하고 있는 상태이기 때문에 구체적인 구현부인 AwsS3Uploader
를 몰라도 됩니다. 이는 S3뿐만 아니라 MiniO와 같은 다른 오브젝트 스토리지로 교체하는 비용을 줄이는 데 중요한 역할을 해줍니다.
또 또 있습니다! 바로 추상화를 통한 인터페이스를 통해 테스트 가능성이 대폭 높아져요. BucketService
가 실제 외부 시스템 구현부인 AwsS3Uploader
를 의존하게 되어 앞에서 봤던 테스트 코드들에도 영향이 있었는데요. 통합 테스트가 아닌 소형 테스트임에도 S3에 대한 의존을 Mock을 통해 제어해야 했었죠.
그런데 이제는, ObjectStorageClient
와 같은 인터페이스를 확장함으로서 테스트 시, 해당 인터페이스를 구현하는 Fake 테스트 객체를 추가적으로 확장할 수 있게 됩니다. Fake 객체에서는 프로덕션에서와 같은 실제 외부 시스템과 연동하는 관심사를 부여할 필요 없이 BucketService
의 순수한 핵심 로직만을 검증할 수 있는 테스트 코드를 작성할 수 있게 도와줍니다.
🧐 추상화를 통해 비즈니스 담당 객체가 인터페이스를 의존하도록 하면..
- 테스트시 S3에 대한 Mock 의존성 필요없이 핵심 영역만을 대상으로 검증할 수 있다!
- MiniO와 같은 다른 오브젝트 스토리지로 교체하는 비용과 관리영역을 절감할 수 있다!
이제 기획한 추상화 전략을 실제 코드에 적용해볼까요?
public interface ObjectStorageClient {
String upload(MultipartFile file);
}
ObjectStorageClient
라는 새로운 인터페이스를 확장합니다. 해당 인터페이스는 S3와의 연동 및 통신을 책임지는 구체적인 내용을 담당하는 AwsS3Uploader
나 다른 오브젝트 스토리지 확장시 추가되는 XxxUploader
의 구현부를 대응하기 위한 선언부만을 명세하게 됩니다.
@Service
@RequiredArgsConstructor
public class BucketServiceImpl2 implements BucketService {
// 구현체가 아닌 인터페이스 의존
private final ObjectStorageClient objectStorageClient;
@Override
public BucketUploadResponse uploadImages(List<MultipartFile> requestImageFiles) {
... // 유효성 검사 생략
List<String> imagePathList = uploadS3BucketWithGetUrls(requestImageFiles);
return BucketUploadResponse.of(imagePathList);
}
private List<String> uploadS3BucketWithGetUrls(List<MultipartFile> imageFiles) {
return imageFiles.stream()
.map(objectStorageClient::upload)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
프로덕션 코드 동작에 문제가 없도록 BucketServiceImpl
클래스를 바로 수정하지 않고 BucketServiceImpl
클래스를 복사한 BucketServiceImpl2
클래스를 만들어 줍니다. (새로 교체할 클래스명은 편의상 고민없이 지었답니다.) 그리고 AwsS3Uploader 클래스가 아닌 ObjectStorageClient 인터페이스의 의존성을 주입해요.
@Primary
@Component
@RequiredArgsConstructor
public class AwsS3Uploader2 implements ObjectStorageClient {
private final AmazonS3 amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
@Override
public String upload(MultipartFile file) {
... // S3 파일 업로드 후 객체 URL 반환 로직
}
BucketServiceImpl2
클래스를 새로 만든 것처럼 AwsS3Uploader
를 복사한 AwsS3Uploader2
클래스를 만들어 줍니다. 그리고 ObjectStorageClient
인터페이스를 구현하도록 선언한 후 ObjectStorageClient
인터페이스에 선언해둔 upload
함수를 구현하도록 @Override
어노테이션을 붙여줍니다. 또한, @Primary
어노테이션을 붙여 여러 오브젝트 스토리지 구현체 중 해당 구현체를 우선적으로 사용하겠다고 명시해줍니다.
@Component
public class MinioUploader implements ObjectStorageClient {
@Value("${minio.endpoint}")
private String endPoint;
@Value("${minio.access_key}")
private String accessKey;
@Value("${minio.secret_key}")
private String secretKey;
@Override
public String upload(MultipartFile file) {
... // MiniO 파일 업로드 후 객체 URL 반환 로직
}
그리고 다른 오브젝트 스토리지와의 교체가 정말 수월한지 비교 분석해보기 위해 AwsS3Uploader2
클래스뿐만 아니라 MinioUploader
클래스도 구현해줬어요. 추후 MiniO로 오브젝트 스토리지를 교체하거나 추가 연동해야 한다면 AwsS3Uploader2 클래스에 선언한 @Primary
어노테이션을 제거하고 해당 클래스에 @Primary
어노테이션을 붙이면 손쉽게 교체가 가능해집니다.
public class FakeObjectStorageUploader implements ObjectStorageClient {
@Override
public String upload(MultipartFile file) {
return "https://saramara-storage.s3.ap-northeast-2.amazonaws.com/test.png";
}
}
프로덕션 레벨의 AwsS3Uploader
처럼 실제 S3와 통신하는 값비싼 비용을 들이지 않고, 테스트 컨텍스트에서 보다 빠르고 쉬운 의존성 주입을 위한 FakeObjectStorageUploader
클래스를 개발하여 동일하게 ObjectStorageClient
인터페이스를 구현합니다.
class BucketServiceImpl2Test {
private BucketService bucketService;
@BeforeEach
void setUp() {
// Fake 객체를 주입하므로 불필요한 S3 관련 Mocking 제거
this.bucketService = new BucketServiceImpl2(new FakeObjectStorageUploader());
... // 기타 셋업
}
@Test
@DisplayName("[Green] 요청받은 이미지 파일들을 업로드 한 후, URL 경로가 담긴 목록을 응답받는다.")
void 이미지_업로드후_URL_경로를_응답받는다() {
// given
List<MultipartFile> request = createImageFileList(1);
// when
BucketUploadResponse response = bucketService.uploadImages(request);
// then
assertThat(response.images())
.hasSize(1)
.allMatch(url -> url.contains("test.png"));
}
private static List<MultipartFile> createImageFileList(int size) {
return IntStream.range(0, size)
.mapToObj(i -> new MockMultipartFile("test", "test.png", "image/png", "test_file".getBytes(UTF_8)))
.collect(Collectors.toList());
}
}
자, 이제 진짜 마지막입니다. 기존의 BucketServiceImplTest
테스트 클래스를 기반으로 BucketServiceImpl2Test
테스트 클래스를 작성하는 데, AwsS3Uploader
를 직접 주입받던 BucketServiceImpl
에서 ObjectStorageClient
인터페이스를 주입받도록 개선했으니 테스트 패키지에서 해당 인터페이스를 구현헀던 FakeObjectStorageUploader
를 인자로 주입할 수 있게 됩니다.
이렇게 되면, 이전에는 셋업 내에 필요했던 S3 관련 픽스처를 걷어낼 수 있게 되죠!
해당 테스트 케이스를 테스트해보면 테스트가 정상적으로 성공합니다. 추상화가 반영된 인터페이스 전략에 따라 실제 S3와 통신하는 AwsS3Uploader
클래스가 아닌 인터페이스를 구현하는 Fake 객체인 FakeObjectStorageUploader
클래스를 통해 외부 시스템 연동 비용을 전혀 신경쓰지 않고 BucketServiceImpl
의 uploadImages
함수를 검증하는 데 집중할 수 있게 되었어요.
이렇게 문제가 없다는 것을 확인했으니 실제 프로덕션 코드에 반영해보아요. 기존 프로덕션 코드를 기반으로 복사하여 개발했던 객체들을 프로덕션 코드로 바꿔주면 됩니다. 물론 프로덕션 코드로 변경한 후 잊지 말고 다시 한 번 테스트를 수행하는 것이 백 번 옳습니다.
- BucketServiceImpl2 > BucketServiceImpl
- AwsS3Uploader2 > AwsS3Uploader
- BucketServiceImpl2Test > BucketServiceImplTest
애플리케이션의 중요한 영역인 비즈니스 계층에 침투된 외부 시스템과의 강결합을 추상화를 통해 우아하게 개선해볼 수 있었어요. 특히, 비즈니스 객체가 구현체 클래스에게 의존하던 흐름을 인터페이스를 통해 역전시키는 의존성 역전 전략을 반영하여 비즈니스 영역의 테스트 가능성(Testability)을 높이기까지의 과정을 눈으로 보고 느껴보니 재미있게 개발하며 테스트해볼 수 있어 뿌듯했던 것 같아요.
추상화 반영 전후로 테스트 코드 내 setUp 함수의 라인 수를 줄였다는 것이 대단하지 않을 수 있지만 해당 테스트 클래스의 의도와 목적을 선명하게 할 수 있다는 점에서 추상화는 꼭 필요했다고 단언할 수 있습니다!
⏳ 이번 글은 2일동안 7시간을 투자하여 작성했습니다.