STEP 1. 개선 전 코드
https://github.com/Hyune-c/blogcode-delegate/commit/7ac445974e1fc497509813303d80151ce705ce1c
AWS S3 조작을 담당하는 서비스로 개선 전 코드는 중복 코드와 확장성에 큰 단점을 가지고 있었습니다.
초기에는 연관된 로직이 적어 괜찮았지만, 그것을 감안하더라도 가독성과 확장에 취약한 코드였습니다.
그러던 중 S3 버킷 추가의 요건이 생겼습니다.
STEP 2. 코드 개선
버킷 추가를 하기 전 기존 코드의 개선을 진행했습니다.
1. Properties
기존의 코드는 service 내에 accessKey와 secretKey가 노출되어 있었고, 매 요청마다 amazonS3 오브젝트를 생성했습니다.
@Configuration
@RequiredArgsConstructor
public class AmazonS3Beans {
public static final Regions CLIENT_REGION = Regions.AP_NORTHEAST_2;
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Bean
public AmazonS3 amazonS3() {
final BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(CLIENT_REGION)
.build();
}
}
- amazonS3을 재사용하기 위해 bean으로 선언했습니다.
- 이후 코드에서는 accessKey, secretKey가 노출되지 않습니다.
public interface AmazonS3Properties {
String getStagePath();
String getBucketName();
default String getProvendorContentUrl() {
throw new UnsupportedOperationException("잘못된 호출 입니다.");
}
}
@Component
public class AmazonS3PublicProperties implements AmazonS3Properties {
@Value("${env.profile}")
private String stagePath;
@Value("${cloud.aws.provendor.content}")
private String provendorContentUrl;
@Value("${cloud.aws.bucket.public}")
private String bucketName;
}
- public 버킷은 url 연결이 가능한 도메인을 가집니다.
- 인터페이스를 통해 버킷의 설정 값을 다르게 설정하고 가져올 수 있습니다.
2. Request 생성 책임 Provider
기존의 코드는 request object의 생성 책임 구분이 없었고, 비슷한 구현이 반복되었습니다.
public abstract class AbstractAmazonS3UploadRequestProvider {
private final String stagePath;
private final String bucketName;
protected AbstractAmazonS3UploadRequestProvider(final AmazonS3Properties properties) {
this.stagePath = properties.getStagePath();
this.bucketName = properties.getBucketName();
}
public PutObjectRequest createRequest(final File file) {
return createRequest(file.getName(), file);
}
public abstract PutObjectRequest createRequest(final String sourceName, final File file);
protected PutObjectRequest createRequest(final String objectKey, final File file, final CannedAccessControlList cannedAclHeader) {
final PutObjectRequest request = new PutObjectRequest(bucketName, objectKey, file);
request.setMetadata(ObjectMetadataType.getMetadata(file));
request.withCannedAcl(cannedAclHeader);
return request;
}
protected String generateObjectKey(final String sourceName) {
final LocalDateTime now = LocalDateTime.now();
final String hash = LocalDateTimeUtils.toIsoFormat(now) + "_" + DigestUtils.md5Hex(String.valueOf(now.getNano()));
return stagePath + "/" + hash + "/" + sourceName;
}
@Getter
@RequiredArgsConstructor
public enum ObjectMetadataType {
CSV("text/csv; charset=UTF-8"),
XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=UTF-8"),
XLS("application/vnd.ms-excel; charset=UTF-8"),
HTML("text/html; charset=UTF-8");
...
}
}
@Component
public class AmazonS3PublicUploadRequestProvider extends AbstractAmazonS3UploadRequestProvider {
private static final CannedAccessControlList CANNED_ACL_HEADER = CannedAccessControlList.PublicRead;
public AmazonS3PublicUploadRequestProvider(final AmazonS3PublicProperties properties) {
super(properties);
}
@Override
public PutObjectRequest createRequest(final String sourceName, final File file) {
final String objectKey = generateObjectKey(sourceName);
return createRequest(objectKey, file, CANNED_ACL_HEADER);
}
}
S3 업로드를 위한 Request 생성 책임을 Provider로 모았습니다.
- 생성자를 통해 설정을 주입하며, 중복 코드를 줄였습니다.
- 사용자는 오픈된 2개의 createRequest() 메서드를 통해 PutObjectRequest를 생성합니다.
- 나노초 값을 시드로 한 해시를 통해 중복되지 않는 object path를 구현했습니다.
- 공개 여부는(CANNED_ACL_HEADER) 구현 클래스에서 정의합니다.
3. Delegate 클래스
기존의 코드는 특정 목적을 위한 메서드를 새로 만들었습니다.
public abstract class AbstractAmazonS3Service {
private final AmazonS3 amazonS3;
private final String bucketName;
protected AbstractAmazonS3Service(final AmazonS3Properties properties, final AmazonS3 amazonS3) {
this.amazonS3 = amazonS3;
this.bucketName = properties.getBucketName();
}
public PutObjectResult putObject(final PutObjectRequest request) {
try {
return amazonS3.putObject(request);
} catch (final SdkClientException e) {
log.error(e.getClass().getName(), e);
throw new IllegalStateException();
}
}
public String getObjectFullPath(final String objectKey) {
return amazonS3.getUrl(bucketName, objectKey).getPath();
}
public void getObject(final String objectKey, final File targetFile) {
amazonS3.getObject(new GetObjectRequest(bucketName, objectKey), targetFile);
}
}
public class AmazonS3PublicService extends AbstractAmazonS3Service {
public AmazonS3PublicService(final AmazonS3PublicProperties properties, final AmazonS3 amazonS3) {
super(properties, amazonS3);
}
}
S3를 조작을 위임하는 클래스를 구현했습니다.
- 생성자를 통해 설정을 주입하며, 중복 코드를 줄였습니다.
- S3만 조작하도록 수정하고, 특화 로직처럼 보이던 네이밍을 수정했습니다.
- 오브젝트 추가, 오브젝트 주소 가져오기, 오브젝트 가져오기
STEP 3. 버킷 추가
@Component
public class AmazonS3PrivateProperties implements AmazonS3Properties {
@Value("${env.profile}")
private String stagePath;
@Value("${cloud.aws.bucket.private}")
private String bucketName;
}
@Component
public class AmazonS3PrivateUploadRequestProvider extends AbstractAmazonS3UploadRequestProvider {
private static final CannedAccessControlList CANNED_ACL_HEADER = CannedAccessControlList.Private;
public AmazonS3PrivateUploadRequestProvider(final AmazonS3PrivateProperties properties) {
super(properties);
}
@Override
public PutObjectRequest createRequest(final String sourceName, final File file) {
final String objectKey = generateObjectKey(sourceName);
return createRequest(objectKey, file, CANNED_ACL_HEADER);
}
}
@Service
public class AmazonS3PrivateService extends AbstractAmazonS3Service {
public AmazonS3PrivateService(final AmazonS3PrivateProperties properties, final AmazonS3 amazonS3) {
super(properties, amazonS3);
}
}
- 새로운 버킷이 추가되어도 Properties, Provider, Service만 추가하면 되어 영향도를 최소화할 수 있습니다.
STEP 4. 사용 코드
@Service
public class SomethingWithExcelService {
private final AbstractAmazonS3UploadRequestProvider amazonS3DelegateProvider;
private final AbstractAmazonS3Service amazonS3Delegator;
public SomethingWithExcelService(final AmazonS3CommonPrivateUploadRequestProvider amazonS3DelegateProvider, final AmazonS3PrivateService amazonS3Delegator) {
this.amazonS3DelegateProvider = amazonS3DelegateProvider;
this.amazonS3Delegator = amazonS3Delegator;
}
public void doSomething(final File file) {
// 전처리 ...
final PutObjectRequest request = amazonS3DelegateProvider.createRequest(file);
amazonS3Delegator.putObject(request);
// 후처리 ...
}
}
- 파일 업로드에 필요한 버킷을 생성자 주입하기에 유연한 변경이 가능합니다.
조금 더 생각해 볼 것
- 처음에는 파일 조작 방법으로 S3 이외의 것이 들어올 수 있다고 생각해 Facade 패턴도 고려했습니다.
- Facade 패턴을 사용하면 사용되는 service를 package-private으로 숨길 수 있습니다.
- 하지만 S3 이외의 파일 조작을 하는 것은 너무 먼 미래일 것 같아 지금의 개선을 유지했습니다.
- Provider를 Service의 inner class로 이동하면 좀 더 간결한 코드가 될 수 있습니다.
- 하지만 코드 리뷰 결과 클래스가 커지는 것보다는 분리하는 것이 좋겠다는 의견을 수렴했습니다.
220611 아래의 내용은 피드백을 받아 수정한 내용으로 회사코드까지는 적용되지 않았습니다.
STEP 5. 다중 상속을 활용한 Properties 구조화
- STEP 3의 properties는 하나의 타입과 2개의 구현체로 설계했었습니다.
- 슈퍼 타입과 서브 타입을 통해 개선했습니다.
- STEP 3는 AmazonS3Properties가 3개의 메서드를 가지고 있었습니다.
- getProvendorContentUrl() 메서드의 사용을 public 서브 타입에서만 접근할 수 있도록 개선했습니다.
조금 더 생각해볼 것 2
- AmazonS3Client는 singleton bean으로 생성되지만, ThreadSafe하기에 지금의 코드를 유지해도 괜찮습니다.
'Series > 실전!' 카테고리의 다른 글
[트러블 슈팅] ECS 클러스터 안의 서비스간 네트워크 연결 불가 (4) | 2022.09.24 |
---|---|
Repository 가볍게 관리하기 (0) | 2022.08.01 |
Custom LocalDateUtils 리팩토링 (0) | 2022.01.04 |
Datadog 에서 GraphQL 모니터링 맛보기 (0) | 2021.12.14 |
느슨한 결합도의 설계를 위해! (2) (0) | 2021.12.04 |