Spring

[Spring] Spring Boot + Oracle Cloud 연동하여 배포하기 [4] - Object Storage를 이용하여 이미지를 업로드 하기

PlatinumeOlive 2024. 4. 5. 01:34

이전 포스팅에서는, 버킷을 생성하고 의존성을 주입받는 등 연결에 필요한 설정들을 해보았습니다.

 

 

저번 포스팅을 짧게 끊은 이유는.. 왠지 이번 글이 길어질거 같아 나누었습니다. 바로 이미지 업로드 기능을 구현해보도록 하겠습니다.

이 글은 제가 작은 프로젝트를 진행하며 공부하며 정리한 글이기때문에, 코드의 질도 낮고 부정확한 부분이 많을거라고 생각합니다. 감안하고 봐주시면 감사하겠습니다. (특히 저는 제대로된 배포는 안해봤습니다)

저는 Gradle 스프링 부트 3.2.3버전을 사용중이며, JDK 17 자바를 사용하고 있습니다.

 

이미지 업로드 기능 구현

도메인 설계

Image 

패키지 위치는 다음과 같습니다.

@Entity
@Getter
@Builder
@AllArgsConstructor
@Table(name = "Image")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Image extends BaseTimeEntity {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "image_idx")
	private Long idx;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "member_idx")
	private Member member;

	@Column(length = 1000)
	private String accessUri;

	private String imgUrl;

	private String parId;

	@Enumerated(EnumType.STRING)
	private ImageType imageType;

	public void updateAccessUri(String accessUri) {
		this.accessUri = accessUri;
	}

	public void updateParId(String parId) {
		this.parId = parId;
	}

	public void updateImageType(ImageType imageType) {
		this.imageType = imageType;
	}

}

 

먼저, ImageType은 이것이 프로필 사진인지, QR코드 사진인지 구분하기 위해 만든 Enum클래스 타입입니다.

public enum ImageType {
    KAKAO, PROFILE;

    public static ImageType fromName(String type) {
        return ImageType.valueOf(type.toUpperCase(ENGLISH));
    }
}

 

type에 kakao, profile 둘중 하나를 넣게되면 대문자로 바꾸어, 일치하는 타입의 Enum 객체가 반환됩니다.

 

이 외에, parId처럼 새로 보이는 것도 있고, accessUri, imgUrl 등에 대해 설명을 해드리기에 앞서, 이것들을 이해하기 위해서는 오라클 Object Storage에 대한 이해가 필요합니다. (이것 때문에 조금 더 고생했던 기억이..)

 


Bucket, Object, PreAuth

Bucket은, AWS S3 bucket과 동일합니다. 객체를 업로드하고 관리할 수 있는 곳이죠.

 

Object는, 말그대로 bucket에 저장될 객체(파일)입니다.

 

그럼 PreAuth는 무엇일까요? 잠깐 S3로 객체를 올릴때를 회상해보자면, 아래와 같이 amazonS3Client에 putObject 메서드를 통해 PublicRead 권한을 넣고 간편하게 업로드 하였고, 그 링크를 얻기 위해서 getUrl을 통해 간단히 받아왔습니다. 

 

private String putS3(File uploadFile, String fileName) {
    amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(
            CannedAccessControlList.PublicRead));
    return amazonS3Client.getUrl(bucket, fileName).toString();
}

 

물론, 보안을 위해서는 객체 업로드시 private 옵션을 넣고, S3 cloudFront를 통해 접근할수도 있지만.. 자세히 해본것도 아니고 아직 저에게는 꽤나 어려운 내용이라 넘어가겠습니다.

 

본론으로 돌아와서, 그럼 오라클 버킷에 있는 객체에 프론트가 접근하기 위해서는 어떻게 해야할까요?

객체에 있는 점 3개 -> 객체 상세정보로 이동해보면, Url이 하나 보입니다.

안내된 URL를 크롬에 붙여넣기 해보면? 아무것도 나오지 않습니다. 외부에서 접근이 가능하도록 만들어진 URL이 아닌, 버킷 내부의 URL뿐이기 때문입니다.

 

프론트에서 사용자의 프로필 이미지를 받아오는 것과 같은 경우에, 외부에서 접속 가능한 링크가 필요한데 어떻게 할까요?

 

바로, PreAuth, 사전 인증된 요청을 생성하여 접근하는 것 입니다. 콘솔에서 생성하는 방법을 한번 보겠습니다.

 

아까 객체에서 점 3개를 누른 뒤 -> 사전요청된 요청 생성 버튼을 클릭합니다.

 

여기 있는 이름이 아까 위 image 도메인에 있던 parId입니다. 사전 요청된 요청에 대한 이름이라고 볼 수 있습니다. 액세스 유형과, 만료일도 지정 가능합니다. 이제 생성 버튼을 누르면 아래와 같은 화면이 표시됩니다.

 

 

 만든 사전인증된 요청에서 만들어준 공개적으로 접속이 가능한 URL입니다. Image 도메인의 AccessUri에 저장될 정보입니다. 이 링크는 다시 표시되지 않기에 어딘가에 꼭 저장해주어야 합니다.

 

이 링크를 크롬에 붙여넣기 하면 내가 올린 오브젝트에 접근과 다운로드가 가능합니다.

 

S3에 비하면 많이 번거로운 작업입니다. 이것을 매번 직접 콘솔 와서 해줄수 없으니, 스프링 내부에서 해결해야 합니다. 백엔드가 사전요청을 생성하여 Image 테이블에 AccesUri와 ParId를 가지고 있다가 프론트가 요청할 시 링크를 넘겨주는 방식으로 구동되도록 해보겠습니다.


ImageService

public interface ImageService {

	public Long uploadProfileImg(MultipartFile file, Long memberIdx) throws Exception;
	public Long uploadKakaoQrImg(MultipartFile file, Long memberIdx) throws Exception;

	public String getPublicImgUrl(Long imageIdx, Long memberIdx) throws Exception ;

	public MultipartFile downloadImg(Long imageIdx, Long memberIdx) throws Exception;

	public void deleteImg(Image image) throws Exception;

}

 

후에 Aws로 바꿀수도 있겠다..라는 확장성을 고려하여 인터페이스로 설계하였습니다. 저는 프로젝트에서 프로필 이미지와, 카카오 결제 QR 이미지를 넣기 때문에 두가지 기본 기능을 구성하였고, 공개 Url과 오브젝트 경로인 imgUrl 두가지를 받아올 수 있도록 구성하였습니다.

OracleImageService

위 인터페이스와 같은 경로에 구성하였습니다. 먼저 기본 구성이 되는 부분을 보겠습니다.

private final MemberRepository memberRepository;

private final ImageRepository imageRepository;

private static final String BUCKET_NAME = 버킷 이름;
private static final String BUCKET_NAME_SPACE = 버킷 네임스페이스;
private static final String PROFILE_IMG_DIR = "profile/";
private static final String KAKAO_IMG_DIR = "kakao/";
public static final String DEFAULT_URI_PREFIX = "https://" + BUCKET_NAME_SPACE + ".objectstorage."
        + Region.AP_CHUNCHEON_1.getRegionId() + ".oci.customer-oci.com";


public ObjectStorage getClient() throws Exception {
    ConfigFile config = ConfigFileReader.parse("~/.oci/config", "DEFAULT");

    AuthenticationDetailsProvider provider = new ConfigFileAuthenticationDetailsProvider(config);

    return ObjectStorageClient.builder()
            .region(Region.AP_CHUNCHEON_1)
            .build(provider);
}

public UploadManager getManager(ObjectStorage client) throws Exception {
    UploadConfiguration configuration = UploadConfiguration.builder()
            .allowMultipartUploads(true)
            .allowParallelUploads(true)
            .build();
    return new UploadManager(client, configuration);
}

 

먼저, BUCKET_NAME과 BUCKET_NAME_SPACE에 각각 버킷 이름과 네임스페이스에 해당하는 값을 입력합니다.

 

그리고 이전에 다운받았었던 config파일의 경로를 넣고 configFile을 생성하여  최종적으로 Object Storage를 만들어 줍니다. (이때 Region은 꼭 본인이 속한 리전을 넣어주어야 합니다) 이 Object Storage가 오라클 클라우드의 클라이언트로 사용됩니다.


AWS S3 config처럼 설정파일을 이용하지 않는 이유? 

원래는 동일한 방식으로 Object Storage를 Config 파일에 넣어주려고 하였습니다. 

 

Object Storage는 한 메서드로 사용 후 client.close()메서드로 File등 과 같이 닫아주어야합니다. 배포가 계속 되는 상황이라면, 최초 실행시 Config에서 할당한 후 열린 상태로 두어도 되지만, 개발 단계에서는 계속 서버를 껏다 켜야하고, 그러다보면 메모리 누수가 생길것임을 우려하여 임시로 메서드 실행 시 마다 client를 생성하고 닫아주는 방식을 택하였습니다.

 

(실제로 계속 실행하고 종료하길 반복하니 Oracle 인스턴스의 메모리 사용 비율이 높아진거 같았습니다.. 인스턴스가 죽은적도 있었고...)

 

추후 완성도가 높아짐에 따라 변경하려고 합니다. 아직은 개발단계이기 때문에 감안하고 봐주세요!


KAKAO_IMG_DIR과 PROFILE_IMG_DIR에 대해 설명드리겠습니다. 

업로드된 카카오 이미지와 프로필 이미지를 한 디렉토리에 전부 넣어서 관리하면 나중에 보기도 어렵고 문제가 생길거 같아, 일정한 저만의 규칙을 만들었습니다.

 

만약, 유저 idx(인덱스)가 1번인 유저가 프로필 사진을 업로드 했다면, 버킷에 저장되는 경로와 이름은 다음과 같이 저장됩니다.

 - {idx}/profile/{랜덤UUID} + "원래 파일이름"

 

업로드한 파일의 이름이야 겹칠 수 있기 때문에 그런 일이 없도록 UUID를 활용하였습니다. 카카오 QR이미지는 profile이 kakao로 바뀐 경로에 업로드 됩니다.

 

사진 업로드 로직

@Override
public Long uploadProfileImg(MultipartFile file, Long memberIdx) throws Exception {
    File uploadFile = convert(file)  // 파일 변환할 수 없으면 에러
            .orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));
    String fileDir = memberIdx + "/" + PROFILE_IMG_DIR;
    Member member = memberRepository.findByIdx(memberIdx).orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
    return upload(uploadFile, fileDir, member);
}

@Override
public Long uploadKakaoQrImg(MultipartFile file, Long memberIdx) throws Exception{
    File uploadFile = convert(file)  // 파일 변환할 수 없으면 에러
            .orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));
    String fileDir = memberIdx + "/" + KAKAO_IMG_DIR;
    Member member = memberRepository.findByIdx(memberIdx).orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
    return upload(uploadFile, fileDir, member);
}

public Long upload(File uploadFile, String dirName, Member member) throws Exception {
    ObjectStorage client = getClient();
    UploadManager uploadManager = getManager(client);

    String fileName = dirName + UUID.randomUUID() + uploadFile.getName();   // 버킷에 저장된 파일 이름
    String contentType = "img/" + fileName.substring(fileName.length() - 3); // PNG, JPG 만 가능함
    PutObjectRequest request =
            PutObjectRequest.builder()
                    .bucketName(BUCKET_NAME)
                    .namespaceName(BUCKET_NAME_SPACE)
                    .objectName(fileName)
                    .contentType(contentType)
                    .build();
    UploadRequest uploadDetails =
            UploadRequest.builder(uploadFile).allowOverwrite(true).build(request);

    UploadResponse response = uploadManager.upload(uploadDetails);
    log.info("Upload Success. File : {}", fileName);

    client.close();
    removeNewFile(uploadFile);
    return saveImageToMember(member, fileName);
}

private Long saveImageToMember(Member member, String fileName) {
    Image image = Image.builder()
            .member(member)
            .imgUrl(fileName)
            .imageType(ImageType.PROFILE)
            .build();
    if (fileName.contains(KAKAO_IMG_DIR)) {
        image.updateImageType(ImageType.KAKAO);
    }
    imageRepository.save(image);
    member.getImages().add(image);
    return image.getIdx();
}

// 로컬에 파일 업로드 해서 convert
private Optional<File> convert(MultipartFile file) throws IOException {
    File convertFile = new File(System.getProperty("user.home") + "/rideTogetherDummy/" + UUID.randomUUID() +file.getOriginalFilename());
    if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
        try (FileOutputStream fos = new FileOutputStream(
                convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
            fos.write(file.getBytes());
        }
        return Optional.of(convertFile);
    }
    return Optional.empty();
}

// 로컬에 저장된 이미지 지우기
public void removeNewFile(File targetFile) {
    log.info("@@@@@@@@ 지울 대상 파일 이름"+targetFile.getName());
    log.info("@@@@@@@@ 지울 대상 파일 경로"+targetFile.getPath());
    if (targetFile.exists()) {
        if (targetFile.delete()) {
            log.info("@@@@@@@@ File delete success");
            return;
        }
        log.info("@@@@@@@@ File delete fail.");
    }
    log.info("@@@@@@@@ File not exist.");
}

 

  • uploadProfileImage(), uploadKakaoImage() : 저희는 프론트에서 MultipartFile로 이미지 파일을 받아올 예정입니다. 오라클 버킷에는 File형식의 업로드를 해야하기 때문에, 변환 과정이 필요합니다. 그래서 파일을 convert()함수를 통해 변환하고, 각자 업로드할 이미지의 종류에 따라 알맞은 경로에 들어가도록 경로의 이름을 설정하여 upload()함수에 넘겨줍니다. (ex. 1/kakao)

  • convert() 함수 : MultipartFile을 바꾸는 방법에는 세가지 정도가 존재하는데, 저는 그중 로컬에 임시로 저장을 하는 방식으로 변환하는 방법을 선택하였습니다. 아직 Buffer나 Stream을 다루기엔 좀 어려웠기 때문이죠,, 그래서 미리 만든 더미 디렉토리에 위치하도록 하였고, 가끔 서버가 구동되는 동안에는 자바 SE에서 파일을 붙잡고 있는지 removeNewFile() 도 그렇고 수동으로도 지우지 못하는 현상이 생기더라구요.. 여러방면으로 알아보기도 하고, 혹시 FileOutputStream이 문제인가 싶어서 try-with-resources 도 사용해보았는데 결국 해결이 되진 않았습니다. 나중에 바꿔봐야 겠습니다.

    try-with-resources가 궁금하다면 아래 링크를 참고하세요
    - https://mangkyu.tistory.com/217
 

[Java] try-with-resources란? try-with-resources 사용법 예시와 try-with-resources를 사용해야 하는 이유

이번에는 오랜만에 자바 문법을 살펴보고자 합니다. Java7부터는 기존의 try-catch를 개선한 try-with-resources가 도입되었는데, 왜 try-catch가 아닌 try-with-resources를 사용해야 하는지, 어떻게 사용하는지

mangkyu.tistory.com

 

  • removeNewFile() : 위 conver함수 과정에 임시로 로컬에 생긴 더미파일을 제거하는 함수입니다. 가끔 잘 동작이 안될때가 있지만 아직 이유는 찾지 못했습니다. 그냥 나중에 수동으로 디렉토리에 있는거 주기적으로 싹 날리려고 생각중입니다.

  • upload() : 실질적으로 업로드를 진행하는 함수입니다. AuthenticationRequest를 생성하여 최종적으로 UploadRequest를 빌드합니다. 응답이 적힌 reponse를 토대로 여러 조작이 가능하지만 저는 당장 사용하지는 않겠습니다.

  • saveImageToMember() : 업로드를 마치고, Image를 생성하고 Repository를 통해 저장하고, 멤버가 가진 이미지 리스트에 담아주는 작업을 수행합니다. 성공시 image객체의 인덱스 번호를 반환합니다

이렇게 까지가 버킷에 이미지를 업로드를 진행하는 함수들이였습니다. 다만 이렇게 단순히 업로드를 해놓는게 목적이 아니고, 결국 업로드된 이미지를 받아오는 것이 목적이기 때문에 이제부터는 PreAuth를 생성하여 외부에서 접근 가능한 AccessUri를 받아오는 메서드를 작성해보겠습니다.

 

객체 사전 요청 생성 및 공개 링크 받는 로직

@Override
public String getPublicImgUrl(Long imageIdx, Long memberIdx) throws Exception {
    ObjectStorage client = getClient();
    Image img = imageRepository.findImageByIdx(imageIdx).orElseThrow(
            () -> new ErrorHandler(ErrorStatus.IMAGE_NOT_FOUND)
    );
    AuthenticatedRequest authenticatedRequest = getPreAuth(img.getImgUrl());

    Member member = memberRepository.findByIdx(memberIdx).orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
    addAccessUriToMember(member, authenticatedRequest, img.getImgUrl());

    log.info("PublicImgUrl 발급에 성공하였습니다 : {}", DEFAULT_URI_PREFIX + authenticatedRequest.getAccessUri());
    client.close();
    return DEFAULT_URI_PREFIX +authenticatedRequest.getAccessUri();
}

public AuthenticatedRequest getPreAuth(String imgUrl) throws Exception{
    ObjectStorage client = getClient();

    Calendar cal = Calendar.getInstance();
    cal.set(2024, Calendar.DECEMBER, 30);

    Date expireTime = cal.getTime();

    log.info("권한을 얻어오기 위해 시도중입니다. 파일 이름 : {}", imgUrl);
    CreatePreauthenticatedRequestDetails details =
            CreatePreauthenticatedRequestDetails.builder()
                    .accessType(AccessType.ObjectReadWrite)
                    .objectName(imgUrl)
                    .timeExpires(expireTime)
                    .name(imgUrl)
                    .build();

    CreatePreauthenticatedRequestRequest request =
            CreatePreauthenticatedRequestRequest.builder()
                    .namespaceName(BUCKET_NAME_SPACE)
                    .bucketName(BUCKET_NAME)
                    .createPreauthenticatedRequestDetails(details)
                    .build();

    CreatePreauthenticatedRequestResponse response = client.createPreauthenticatedRequest(request);
    client.close();
    return AuthenticatedRequest.builder()
            .authenticateId(response.getPreauthenticatedRequest().getId())
            .accessUri(response.getPreauthenticatedRequest().getAccessUri())
            .build();
}

private void deletePreAuth(String parId) throws Exception {
    ObjectStorage client = getClient();
    DeletePreauthenticatedRequestRequest request =
            DeletePreauthenticatedRequestRequest.builder()
                    .namespaceName(BUCKET_NAME_SPACE)
                    .bucketName(BUCKET_NAME)
                    .parId(parId)
                    .build();

    client.deletePreauthenticatedRequest(request);
    client.close();
}

private void addAccessUriToMember(Member member, AuthenticatedRequest request, String imgUrl) {
    List<Image> images = member.getImages();
    for (Image img : images) {
        if (img.getImgUrl().equals(imgUrl)) {
            img.updateAccessUri(DEFAULT_URI_PREFIX + request.getAccessUri());
            img.updateParId(request.authenticateId);
        }
}

@Data
@Builder
static class AuthenticatedRequest {
    String accessUri;
    String authenticateId;
    String imgUrl;
}

 

getPublicImmgUrl() 메서드는, getPreAuth() 메서드를 통해 사전 승인된 요청을 생성하고, 응답으로 온 parId(사전 승인된 요청 이름)과 AccessUri를 받아와 멤버와 이미지 객체에 저장해주는 역할을 합니다. 

 

getPreAuth() 메서드에 보면, CreatePreauthenticationDetails를 통해 사전 승인된 요청의 속성을 설정 할 때, 날짜를 올해 말로 설정해두었습니다. 매번 조회시마다 사전 요청이 생성되는것은 비효율적이라고 생각하였기 때문입니다. 사진을 바꾸지 않은 한 계속 유지시키고, 사진을 변경할 때만 deletePreAuth()를 통해 기존 요청을 삭제하고 새로 생성하도록 로직을 구성할 예정입니다.

 

전체 코드

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class OracleImageService implements ImageService {

	private final MemberRepository memberRepository;

	private final ImageRepository imageRepository;


	private static final String BUCKET_NAME = 버킷 이름;
	private static final String BUCKET_NAME_SPACE = 버킷 네임스페이스;
	private static final String PROFILE_IMG_DIR = "profile/";
	public static final String DEFAULT_URI_PREFIX = "https://" + BUCKET_NAME_SPACE + ".objectstorage."
			+ Region.AP_CHUNCHEON_1.getRegionId() + ".oci.customer-oci.com";
	private static final String KAKAO_IMG_DIR = "kakao/";

	private static final long PRE_AUTH_EXPIRE_MINUTE = 20;

	public ObjectStorage getClient() throws Exception {
		ConfigFile config = ConfigFileReader.parse("~/.oci/config", "DEFAULT");

		AuthenticationDetailsProvider provider = new ConfigFileAuthenticationDetailsProvider(config);

		return ObjectStorageClient.builder()
				.region(Region.AP_CHUNCHEON_1)
				.build(provider);
	}

	public UploadManager getManager(ObjectStorage client) throws Exception {
		UploadConfiguration configuration = UploadConfiguration.builder()
				.allowMultipartUploads(true)
				.allowParallelUploads(true)
				.build();
		return new UploadManager(client, configuration);
	}

	public UploadConfiguration getUploadConfiguration() {
		//upload object
		return UploadConfiguration.builder()
				.allowMultipartUploads(true)
				.allowParallelUploads(true)
				.build();
	}

	@Override
	public Long uploadProfileImg(MultipartFile file, Long memberIdx) throws Exception {
		File uploadFile = convert(file)  // 파일 변환할 수 없으면 에러
				.orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));
		String fileDir = memberIdx + "/" + PROFILE_IMG_DIR;
		Member member = memberRepository.findByIdx(memberIdx).orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
		return upload(uploadFile, fileDir, member);
	}
	@Override
	public Long uploadKakaoQrImg(MultipartFile file, Long memberIdx) throws Exception{
		File uploadFile = convert(file)  // 파일 변환할 수 없으면 에러
				.orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));
		String fileDir = memberIdx + "/" + KAKAO_IMG_DIR;
		Member member = memberRepository.findByIdx(memberIdx).orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
		return upload(uploadFile, fileDir, member);
	}

	@Override
	public String getPublicImgUrl(Long imageIdx, Long memberIdx) throws Exception {
		ObjectStorage client = getClient();
		Image img = imageRepository.findImageByIdx(imageIdx).orElseThrow(
				() -> new ErrorHandler(ErrorStatus.IMAGE_NOT_FOUND)
		);
		AuthenticatedRequest authenticatedRequest = getPreAuth(img.getImgUrl());
	

		Member member = memberRepository.findByIdx(memberIdx).orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
		addAccessUriToMember(member, authenticatedRequest, img.getImgUrl());

		log.info("PublicImgUrl 발급에 성공하였습니다 : {}", DEFAULT_URI_PREFIX + authenticatedRequest.getAccessUri());
		client.close();
		return DEFAULT_URI_PREFIX +authenticatedRequest.getAccessUri();
	}

	@Override
	public MultipartFile downloadImg(Long imageIdx, Long memberIdx) throws Exception{
		return null;
	}

	// 버킷에서 이미지와 인증정보 삭제
	@Override
	public void deleteImg(Image image) throws Exception {
		ObjectStorage client = getClient();
		DeleteObjectRequest request =
				DeleteObjectRequest.builder()
						.bucketName(BUCKET_NAME)
						.namespaceName(BUCKET_NAME_SPACE)
						.objectName(image.getImgUrl())
						.build();

		deletePreAuth(image.getParId());
		imageRepository.delete(image);

		client.deleteObject(request);
		client.close();
	}

	// 오라클 버킷으로 파일 업로드
	public Long upload(File uploadFile, String dirName, Member member) throws Exception {
		ObjectStorage client = getClient();
		UploadManager uploadManager = getManager(client);

		String fileName = dirName + UUID.randomUUID() + uploadFile.getName();   // 버킷에 저장된 파일 이름
		String contentType = "img/" + fileName.substring(fileName.length() - 3); // PNG, JPG 만 가능함
		PutObjectRequest request =
				PutObjectRequest.builder()
						.bucketName(BUCKET_NAME)
						.namespaceName(BUCKET_NAME_SPACE)
						.objectName(fileName)
						.contentType(contentType)
						.build();
		UploadRequest uploadDetails =
				UploadRequest.builder(uploadFile).allowOverwrite(true).build(request);

		UploadResponse response = uploadManager.upload(uploadDetails);
		log.info("Upload Success. File : {}", fileName);

		client.close();
		removeNewFile(uploadFile);
		return saveImageToMember(member, fileName);
	}

	private Long saveImageToMember(Member member, String fileName) {
		Image image = Image.builder()
				.member(member)
				.imgUrl(fileName)
				.imageType(ImageType.PROFILE)
				.build();
		if (fileName.contains(KAKAO_IMG_DIR)) {
			image.updateImageType(ImageType.KAKAO);
		}
		imageRepository.save(image);
		member.getImages().add(image);
		return image.getIdx();
	}

	private void addAccessUriToMember(Member member, AuthenticatedRequest request, String imgUrl) {
		List<Image> images = member.getImages();
		for (Image img : images) {
			if (img.getImgUrl().equals(imgUrl)) {
				img.updateAccessUri(DEFAULT_URI_PREFIX + request.getAccessUri());
				img.updateParId(request.authenticateId);
			}
		}
	}


	// 로컬에 파일 업로드 해서 convert
	private Optional<File> convert(MultipartFile file) throws IOException {
		File convertFile = new File(System.getProperty("user.home") + "/rideTogetherDummy/" + UUID.randomUUID() +file.getOriginalFilename());
		if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
			try (FileOutputStream fos = new FileOutputStream(
					convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
				fos.write(file.getBytes());
			}
			return Optional.of(convertFile);
		}
		return Optional.empty();
	}

	// 로컬에 저장된 이미지 지우기
	public void removeNewFile(File targetFile) {
		log.info("@@@@@@@@ 지울 대상 파일 이름"+targetFile.getName());
		log.info("@@@@@@@@ 지울 대상 파일 경로"+targetFile.getPath());
		if (targetFile.exists()) {
			if (targetFile.delete()) {
				log.info("@@@@@@@@ File delete success");
				return;
			}
			log.info("@@@@@@@@ File delete fail.");
		}
		log.info("@@@@@@@@ File not exist.");
	}

	public AuthenticatedRequest getPreAuth(String imgUrl) throws Exception{
		ObjectStorage client = getClient();

		Calendar cal = Calendar.getInstance();
		cal.set(2024, Calendar.DECEMBER, 30);

		Date expireTime = cal.getTime();

		log.info("권한을 얻어오기 위해 시도중입니다. 파일 이름 : {}", imgUrl);
		CreatePreauthenticatedRequestDetails details =
				CreatePreauthenticatedRequestDetails.builder()
						.accessType(AccessType.ObjectReadWrite)
						.objectName(imgUrl)
						.timeExpires(expireTime)
						.name(imgUrl)
						.build();

		CreatePreauthenticatedRequestRequest request =
				CreatePreauthenticatedRequestRequest.builder()
						.namespaceName(BUCKET_NAME_SPACE)
						.bucketName(BUCKET_NAME)
						.createPreauthenticatedRequestDetails(details)
						.build();

		CreatePreauthenticatedRequestResponse response = client.createPreauthenticatedRequest(request);
		client.close();
		return AuthenticatedRequest.builder()
				.authenticateId(response.getPreauthenticatedRequest().getId())
				.accessUri(response.getPreauthenticatedRequest().getAccessUri())
				.build();
	}

	private void deletePreAuth(String parId) throws Exception {
		ObjectStorage client = getClient();
		DeletePreauthenticatedRequestRequest request =
				DeletePreauthenticatedRequestRequest.builder()
						.namespaceName(BUCKET_NAME_SPACE)
						.bucketName(BUCKET_NAME)
						.parId(parId)
						.build();

		client.deletePreauthenticatedRequest(request);
		client.close();
	}

	@Data
	@Builder
	static class AuthenticatedRequest {
		String accessUri;
		String authenticateId;
		String imgUrl;
	}


}

 

다소 불친절한 코드와 설명이였지만,, 코드를 보며 조금씩 이해해나가시면 금방 사용할 수 있으실겁니다! ㅎㅎ


MemberController에 로직 추가

자 이제 마지막으로, 멤버가 프로필과 카카오 QR 사진을 업로드 하기 위한 컨트롤러 로직이 필요하겠죠?

 

먼저, 업로드 로직입니다.

@PostMapping(value = "/api/member/image/{type}")
public ApiResponse<ImageUriResponseDto> uploadImage(@RequestPart(value="image", required = true) MultipartFile image,
                                                    @PathVariable("type") String type) throws Exception{
    System.out.println("type = " + type);
    ImageType imageType = ImageType.fromName(type);
    Member loginMember = SecurityUtil.getLoginMember()
            .orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));

    Long imageIdx;
    if (imageType == ImageType.KAKAO) {
        imageIdx = oracleImageService.uploadKakaoQrImg(image, loginMember.getIdx());
    } else {
        imageIdx = oracleImageService.uploadProfileImg(image, loginMember.getIdx());
    }

    String accessUri = oracleImageService.getPublicImgUrl(imageIdx, loginMember.getIdx());
    
    memberService.checkExistPrevImageAndDeletePrev(imageIdx, loginMember.getIdx(), imageType);
    
    ImageUriResponseDto responseDto = ImageUriResponseDto.builder()
            .accessUri(accessUri)
            .build();
    return ApiResponse.onSuccess(responseDto);

}

 

{type}이 카카오냐, 프로필이냐에 따라 다른 서비스 로직을 호출합니다. 업로드와 동시에 외부에서 접근 가능한 Uri를 생성하여 반환합니다. (바로 프론트가 사용할 수 있도록). 

 

멤버는 사진과 카카오 Qr이미지 각각 하나만 가지고 있을 수 있기 때문에, 업로드가 완료되면 memberService.checkExistPrevImageAndDeletePrev() 메서드를 통해 기존에 등록되어 있던 이미지가 있으면 지워주는 로직을 호출합니다.

 

결론적으로 이미지 업로드와 수정 API가 동일합니다.

 

다음은 조회 로직입니다.

@GetMapping("/api/member/image/{type}")
public ApiResponse<ImageUriResponseDto> getImage(@PathVariable("type") String type) throws Exception{
    Member loginMember = SecurityUtil.getLoginMember()
            .orElseThrow(() -> new ErrorHandler(ErrorStatus.MEMBER_NOT_FOUND));
    return ApiResponse.onSuccess(memberService.getImage(ImageType.fromName(type), loginMember.getIdx()));
}

 

이건 그냥 memberService에서 멤버가 가진 Image객체중 type이 맞는것을 리턴하는거라.. 별 로직은 없습니다.


이미지 업로드 테스트

원래 테스트 로직을 작성했는데, Run all Test를 습관처럼 눌러대서,, 자꾸 쓸데없는 파일들이 올라가는 문제가 생기더군요. 그래서 전부 주석처리 해뒀습니다 ㅎㅎ

 

결국  저는 PostMan으로 몇번 테스트 해보고 끝냈습니다. 

 

토큰 넣고, form-data로 위 사진처럼 사진처럼 이름적고, file 선택하고 이미지 업로드해서 요청 보내면 끝입니다.

 

추가적으로 궁금하신 부분이나 요청하실 부분이 있다면 댓글 남겨주세요 !

길고 부족했던 글 여기까지 봐주셔서 감사합니다. 꼭 도움이 되었으면 좋겠네요 ㅎㅎ

 

전체 코드는 아래에서 확인하실 수 있습니다.

https://github.com/dh1010a/RideTogetherHYU_BE

 

GitHub - dh1010a/RideTogetherHYU_BE

Contribute to dh1010a/RideTogetherHYU_BE development by creating an account on GitHub.

github.com

 

다음 포스팅에서는 스프링에서 빌드 후 서버로 옮겨, 실행하는 방법에 대해 작성하도록 하겠습니다.

 

- 그 전에, jersey 라이브러리 관련하여 에러를 탐방해본 경험도 함께 포스팅 하였습니다. 그냥 심심하신 분들만 한번 읽어주시고 의견 내주시면 감사하겠습니다 ㅎㅎ 


참고자료

- https://docs.oracle.com/en-us/iaas/api/#/en/objectstorage/20160918/MultipartUpload/CreateMultipartUpload

 

Oracle Cloud Infrastructure API Reference and Endpoints

 

docs.oracle.com

- https://docs.oracle.com/en-us/iaas/Content/API/Concepts/apisigningkey.htm

 

Required Keys and OCIDs

Both OCIDs are in the Console, which can be accessed by signing in here: https://cloud.oracle.com. If you don't have a login and password for the Console, contact an administrator. If you're not familiar with OCIDs, see Resource Identifiers. Tenancy's OCID

docs.oracle.com

- https://yongc.tistory.com/65

 

오라클 클라우드(OCI) 자율 운영 데이터베이스 연결하기

이번에 개인 프로젝트를 진행하면서 무료로 사용할 수 있는 오라클 클라우드의 자율운영 데이터베이스(오라클 DB)를 사용하게 되었습니다. 인텔리제이에서 개발하면서 내부 테이블 확인 등 쿼

yongc.tistory.com

- https://thekoguryo.github.io/oci/chapter07/4/

 

7.4 Object 권한 관리 - Public Bucket

7.4 Object 권한 관리 - Public Bucket 생성된 Bucket은 기본적으로 Private 상태입니다. 인증없이는 접근할 수 없는 상태입니다. Bucket을 Public으로 변경하게 되면 별

thekoguryo.github.io

- https://github.com/oracle/oci-java-sdk/issues/276

 

Anonymous access to Object Storage · Issue #276 · oracle/oci-java-sdk

Is there an example/docs on how to configure anonymous read access to an ObjectStorage bucket?

github.com

- https://github.com/oracle/oci-java-sdk

 

GitHub - oracle/oci-java-sdk: Oracle Cloud Infrastructure SDK for Java

Oracle Cloud Infrastructure SDK for Java. Contribute to oracle/oci-java-sdk development by creating an account on GitHub.

github.com

- https://riverblue.tistory.com/53

 

Spring OCI(oracle cloud infrastructure) Java SDK 활용 (1) Object Storage bucket 정보 가져오기

Storage AWS나 오라클 같은 클라우드 서비스에는 파일을 업로드 할 수 있는 Storage 서비스가 있다. 대표적으로 AWS의 S3가 있는데 확실히는 잘 모르지만, AWS에서는 이 S3를 활용할 수 있는 api를 제공하

riverblue.tistory.com

- https://kedric-me.tistory.com/entry/Oracle-Cloud-%ED%8F%89%EC%83%9D%EB%AC%B4%EB%A3%8C-%EB%82%B4-%EC%84%9C%EB%B2%84-%EB%A7%8C%EB%93%A4%EA%B8%B0-1

 

[Oracle Cloud] Spring boot 배포(gradle, jar) - (1)

들어가며 이 글은 2022년 관광공모전에서 만들었던 Comehear의 서버환경을 AWS 에서 Oracle Cloud로 이전하면서 작성하게 됐다. Oracle Cloud 오라클 클라우드(Oracle Cloud)는 오라클사 관리 데이터 센터의 글

kedric-me.tistory.com