S3 사진 저장 기능 구현 회고록

김민우·2025년 1월 20일
0

잡동사니

목록 보기
25/28

댓글, 회원 가입 등 다양한 도메인에서 "사진 업로드"라는 공통 기능이 필요합니다. 저의 궁극적 목표인 도메인간 결합도 낮추기, 유지보수 및 확장성를 위해 어떤 고민을 했는지 남겨보려 합니다.

모든 도메인에서 사진만 업로드하는 것이 아닌, 사진 외에 다른 정보들을 같이 요청합니다. 편의를 위해 아래 글에선 모두 댓글 생성을 예로 들겠습니다.

댓글 생성 Request Body

{
    "imageObjectKey": {이미지 오브젝트 키},
    "content": {댓글 내용},
    "postId": {게시글 ID},
    "parentId": {부모 댓글 ID}
}

업로드는 누가?


1. 프론트엔드(FE)에서 직접 업로드

흐름

  1. 사용자가 댓글 생성
  2. FE는 S3에 이미지 업로드 후 URL 반환
  3. 2에서 받은 URL을 포함하여 댓글 생성 API 호출

백엔드 입장에선 가장 구현하기 편한 방식이지만, 그만큼 부작용도 가장 많은 방법이라 생각합니다. 1순위는 보안입니다. 일정 시간동안 프론트엔드(외부)에서 S3에 접근할 수 있다는 것 자체가 보안상 위험 소지가 있습니다. 그 일정 시간을 추적해서 외부 공격자가 S3에 원하지않는 파일을 업로드할 경우, 속수무책으로 당할 수밖에 없습니다.

또한, 엑세스 키가 이원화된다는 문제도 무시하면 안되겠죠. 최근, 소셜 로그인을 구현할 때도 이러한 이유로 인해 모든 과정을 백엔드에서 처리했습니다.

업로드 시에도 과금이 들어가는 것도 고려해야 합니다. 실제로 public S3에 수 차례 고용량의 이미지를 업로드하여 서버 비용을 늘려버린 공격이 있었습니다. 때문에 private로 관리하여, S3 인아웃 자체를 제한하는 것이 보안상, 지갑상으로 안전합니다.

이러한 이유들로 프론트엔드는 그냥 백엔드에서 제공하는 API 만 사용한다는 개념으로 구현하는게 좋다고 합니다. 심하게 말해 프론트엔드는 그냥 UI만 만진다고 생각하는게 좋다고 합니다. 데이터, 3rd party API 이런 건 다 백엔드에서 하는게 구조적으로나 효율적으로나 보안으로나 훨씬 좋습니다. 그러라고 백엔드가 있는거죠.

참고

2. WAS에서 처리


앞서 언급한 보안 문제를 일부 해결할 수 있고 트랜잭션을 고려했을 때 가장 이상적인 방법입니다.

3. 파일 서버에서 처리 (+임시 버킷 사용)


실제 현업에선 대부분 파일 서버를 별도로 둔다고 합니다. 파일이라는 대용량 트래픽을 서비스 백엔드에서 분리하여, 서비스의 메모리 및 트래픽을 제어하는 데 의미가 있기 때문입니다.

더 찾아본 결과, 서버에서 트랜잭션으로 S3작업을 묶어도 결국 S3는 DB 트랜잭션 처럼 원자성을 지원하지 않아 서버에서 롤백이 일어난다고 해도 이미 업로드된 파일은 지워지지 않는다는걸 알았습니다. 결국 게시물 저장에서도 오류가 발생하면 다시 한번 s3에 접근해서 지워야 한다는 것이죠.

이러한 이유들로 파일 서버를 두는 방법을 선택했습니다. 흐름은 아래와 같습니다.

  1. 사용자 요청: 사용자가 댓글을 작성하거나 프로필 이미지를 업로드하는 등 이미지를 포함한 생성 요청을 수행합니다.
  2. 이미지 업로드 API 호출: 프론트엔드(FE)에서 백엔드 파일 서버(BE-File)로 이미지를 업로드합니다.
  3. 파일 서버의 S3 업로드: 파일 서버가 이미지를 S3에 업로드한 후, 임시 경로의 ObjectKey를 클라이언트에 반환합니다.
  4. 이미지와 데이터 생성 요청: 클라이언트는 ObjectKey를 포함하여 최종 생성 요청을 백엔드 애플리케이션 서버(WAS)로 전송합니다.
  5. WAS에서 최종 데이터 처리:
    • ObjectKey를 활용하여 S3 내의 이미지를 영구 저장 경로로 이동.
    • DB에 데이터 저장.
  6. 임시 파일 관리: TTL(Time-To-Live) 설정을 통해 임시 경로에 일정 시간 경과 시 자동 삭제

버킷을 2개로 구분하여 최초 이미지는 임시 버킷에 저장합니다. 이후, DB에 저장이 완료되면 경로를 변경하여 오류 대처를 합니다.

서두에 언급한 의존성 분리, 데이터 무결성을 어느정도 확보할 수 있다는 장점이 있습니다.

4. 그 외 방법

Presigned URL 활용
최근 기술입니다. 제대로 활용하진 못했으나, Presigned URL은 file-type, max-size 같은 값을 설정할 수 없어 악용될 가능성이 있다고 합니다. 하긴 아마존 입장에선 돈 끌어모을려면 빼는게 좋겠네요

어플리케이션에서 이미지를 어떻게 표현할까?


실제 DB에 저장되는 이미지는 문자열에 불과하지만 어플리케이션에선 이 경로를 검증해야 되는 책임이 있습니다. 이를 위해 VO를 사용했습니다.

@Getter
@EqualsAndHashCode
@ToString
public class Image {
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png");
    private static final String IMAGE_URL_PREFIX = "https://{S3 경로}/data/";

    private final String objectKey;
    
    private Image(final String objectKey) {
        validateIsNullOrEmpty(objectKey);
        validateImgaeExtension(objectKey);
    	this.objectKey = objectKey;
    }
    
    public static Image from(fianl String objectKey) {
    	return new Image(objectKey);
    }

    public String toUrl() {
        return IMAGE_URL_PREFIX + objectKey;
    }
    
    private void validateIsNullOrEmpty(final String objectKey) {
        if (objectKey == null || objectKey.isEmpty()) {
            throw new NotFoundImageObjectKeyException();
        }
    }
    
    private void validateImgaeExtension(final String objectKey) {
        final String extension = StringUtils.getFilenameExtension(objectKey);
        if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) {
            throw new InvalidCommentImageExtensionException(extension);
        }
    }
}

단순히, 확장자와 null 여부만 판단하면 되는 줄 알았습니다... 그러나, 기능 개발을 하면서 아래 문제들이 발생했습니다.

  • 도메인마다 허용되는 확장자가 다르다.
    • 프로필 사진: jpg, jpeg, png.
    • 댓글 사진: jpg, jpeg, png, gif
  • null일 수도 있다.
    • 프로필 사진: null인 경우 기본 프로필 이미지 적용
    • 댓글: null 허용
    • 게시글: null 허용 X

1. 합성을 사용해보자.

많은 아티클을 접하면서 일반화된 규칙은 “상속대신 합성을 사용하자” 입니다. 저 또한, 가장 먼저 합성을 사용해서 공통 로직을 추려내보자 라는 생각을 했습니다.

@EqualsAndHashCode
@Getter
@ToString
public class ProfileImage {
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png");
    private static final ProfileImage DEFAULT_PROFILE_IMAGE = ProfileImage.from("/profiles/default-profile-image.png");
    
    private final BaseImage image;
    
    private ProfileImage(final BaseImage image) {
        this.image = image;
    }
    
    public static ProfileImage from(final String objectKey) {
        if (objectKey == null || objectKey.isEmpty()) {
            return DEFAULT_PROFILE_IMAGE;
        }
        
        final String extension = StringUtils.getFilenameExtension(objectKey);
        if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) {
            throw new InvalidProfileImageExtensionException(extension);
        }
        
        return new ProfileImage(BaseImage.from(objectKey));
    }
}
@EqualsAndHashCode
@Getter
@ToString
public class CommentImage {
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "gif");
    
    private final BaseImage image;
    
    private CommentImage(final BaseImage image) {
        this.image = image;
    }
    public static CommentImage from(final String objectKey) {
        if (objectKey == null || objectKey.isEmpty()) {
            return new CommentImage(BaseImage.getEmptyInstance());
        }
        
        final String extension = StringUtils.getFilenameExtension(objectKey);
        if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) {
            throw new InvalidCommentImageExtensionException(extension);
        }
        
        return new CommentImage(BaseImage.from(objectKey));
    }
}

막상 구현하니 좀 아쉬웠습니다. 외부에서 BaseImage.toUrl(), BaseImage.getObjectKey() 를 호출해야 하므로 객체 포인터 참조가 많아졌습니다.

@Service
@Slf4j
@RequiredArgsConstructor
public class FileService {
    private final FileClient fileClient;

    public void moveImage(final String objectKey) {
        fileClient.move(objectKey)
                .publishOn(Schedulers.boundedElastic())
                .subscribe();
    }
}
fileService.moveImage(Member.getProfileImage().getObjectKey());
fileService.moveImage(comment.getImage().getObjectKey());

3. 상속을 사용해볼까?

다형성을 활용하기 위한 수단으로 업캐스팅이 제일 이상적이라 생각합니다. 앞서 발생한 객체 포인터 참조가 많아지는 문제와 중복 코드 2개를 모두 해결할 수 있는 방법을 고민하다, 추상 클래스를 도입하기로 결정했습니다.

@EqualsAndHashCode
@Getter
@ToString
public abstract class AbstractImage {
    private static final String IMAGE_URL_PREFIX = "https://componote.s3.ap-northeast-2.amazonaws.com/data/";

    private final String objectKey;

    protected AbstractImage(final String objectKey) {
        this.objectKey = objectKey;
    }

    public boolean isEmpty() {
        return objectKey == null || objectKey.isEmpty();
    }

    public String toUrl() {
        if (isEmpty()) {
            return null;
        }

        return IMAGE_URL_PREFIX + objectKey;
    }
}
@EqualsAndHashCode(callSuper = true)
@ToString
public class CommentImage extends AbstractImage {
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "gif");
    private static final CommentImage EMPTY_INSTANCE = new CommentImage(null);

    public CommentImage(final String objectKey) {
        super(objectKey);
    }

    public static CommentImage from(final String objectKey) {
        if (objectKey == null || objectKey.isEmpty()) {
            return EMPTY_INSTANCE;
        }

        final String extension = StringUtils.getFilenameExtension(objectKey);
        if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) {
            throw new InvalidCommentImageExtensionException(extension);
        }

        return new CommentImage(objectKey);
    }
}
@EqualsAndHashCode(callSuper = true)
@ToString
public class ProfileImage extends AbstractImage {
    private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png");
    private static final ProfileImage DEFAULT_PROFILE_IMAGE = ProfileImage.from("/profiles/default-profile-image.png");

    public ProfileImage(final String objectKey) {
        super(objectKey);
    }

    public static ProfileImage from(final String objectKey) {
        if (objectKey == null || objectKey.isEmpty()) {
            return DEFAULT_PROFILE_IMAGE;
        }

        final String extension = StringUtils.getFilenameExtension(objectKey);
        if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) {
            throw new InvalidProfileImageExtensionException(extension);
        }

        return new ProfileImage(objectKey);
    }
}

지금은 구현되지 않았지만, 이미지 확장자를 AbstractImage 에서 1차 검증을 하고 나머지는 자식 클래스에서 하는게 좋을 것 같습니다.

사실 하면서 느낀 점은 도메인내에 비슷한 구조를 가진 것을 무조건적으로 엮으려 하는게 좋은 방법은 아니란 것입니다. 중복 코드가 발생하더라도 초반에 이를 무조건 묶어버리면 추후에 정말 힘들어 질 것 같다는 생각이 들었습니다. 소규모 프로젝트에선 상관이 없으나, 중/대규모 프로젝트에서 이렇게 설계하면 안 좋을 것 같습니다.

또한, 합성이 무조건 좋다는 말 도한 반신반의 하게 되었습니다. 일반적으로 잘 알려진 방법을 무조건 맹신하기 보단 내 상황에 정말 맞는지? 나중에 문제될 건 없는지? 를 끊임없이 고민하는 것이 중요하다 생각합니다.

0개의 댓글