https://dev-leeyjstar.tistory.com/entry/Spring-boot-AWS-S3-Presigned-URL%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B4%80%EB%A6%AC-1-S3-%EC%84%A4%EC%A0%95

 

[Spring boot] AWS S3 + Presigned URL로 이미지 관리 (1) - S3 설정

1. 구현 이유 현재 사이드 프로젝트에서 백엔드 spring boot jpa 개발을 맡고 있다. 이미지 crud를 구현하는 것을 목표로 s3를 활용해서 이미지를 관리하고자 한다. 전에는 naver cloud platform에서 제공해

dev-leeyjstar.tistory.com

 

1. 구현 이유

위에 1편에 이어서 작성한다. 나는 일단 s3를 만들기 위해 버킷을 생성했고 나는 IAM Role 방식으로 ec2 인스턴스에 role을 부여해 자동인증을 함으로서 ec2에 접근이 가능하도록 하는 방식으로 구현을 하려고 세팅을 했다. 

하지만 실제 개발을 하고 로컬에서 테스트를 하기 위해서는 결국 테스트용 Iam 을 만들고 access key를 발급받아서 테스트를 해야한다. 

테스트가 다 되면 다시 원래 했던 방식으로 돌릴것이다. 

 

즉 

로컬 : Access Key 방식 : 로컬에서만 키를 설정 (application-local.yml)

ec2 : iam role 방식 : 키 생략 (application-prod.yml)

 

아무튼 그 과정을 기록한다.. 

 

2. Iam 사용자를 생성

1. iAM 콘솔에서 사용자 추가를 한다. 

2. 사용자 이름은 s3-local-test 이렇게 했다. 

3. 권한은 AmazonS3FullAccess 로 했다 

4. access key 발급 (설명테그는 생략)

5. 키 다운한다. 한번만 보여지기 때문에 꼭 저장 잘 해야함 

이 키를 env 안에 넣어주면 된다. 

 

3. profile 환경 분리 

자.. 현재 나는 application.yml 에 설정들을 하면서 이걸 배포용, 개발용 다 공통으로 사용하고 있었다. 

하지만 지금은 s3 를 설정하면서 로컬에서는 Access Key를 등록해서 인증하는 방식을 사용할 것이고 실제 배포되었을 때는 ec2에 자동 등록이 되어 있는 IAM ROLE 을 사용할 것이다. 이런 분리된 환경에서의 설정을 해보자 

 

 

일단 

credentials? aws 인증 정보(자격증명 )

cloud:
  aws:
    credentials:
      access-key: AKIAxxxxxxxx
      secret-key: wJalrxxxxxxxx

aws 리소스를 사용할 수 있는 비밀번호 같은 것

 

 

1. 기존에 사용하던 application.yml 에 항상 공통되는 설정을 해준다. 버킷명, 공통 도메인 등등 (region, bucket)

2. application-local.yml 에는 s3 secret key만 설정해준다 (credentials 설정을 넣어줌)

3. application-prod.yml 에는 iam role이 자동 인증 작동되도록 키가 없는 버전으로 설정을 해주면 된다. (credentials 설정을 빼줌) 

 

사실 일반적으로는 prod 파일을 만들어줘야한다. 환경에 따라 오버라이딩이 필요한 설정이 있다면 만들어줘야 한다. 

지금 내 상황에서는 cloud.aws.region, bucket 같은 설정이 공통 yml 에 설정이 되어 있고

로컬 버전에만 credentials 설정을 해주면 되고 운영 버전에는 credentials를 빼면 되기 때문에 굳이 파일이 없어도 되는것.

 

하지만 일반적으로 application-prod.yml을 두는 이유는 나중에 운영에서만 바꾸고 싶은 설정이 있을 수 있기 때문이다. 

예를 들어 db url, 로그 레벨을 운영에서는 info로 하고 로컬에서는 debug로 할수 있다. api 키 같은 것도 달라질 수 있어서 운영용 application-prod.yml을 두는게 일반적이다. 하지만 나는 사이드프로젝트이고 지금은 생각해봐도 달라질 설정이 없을 거 같다. 

그리고 그떄 필요하면 파일을 만들면 되지 않을까 싶다 굳이 지금 만들 필요가 있을까...

그래서 안만든다.

 

 

3. 1. application.yml : 프로파일, aws 버킷과 리전을 설정했다. 

spring:
  application:
    name: recipe

  profiles:
    active: ${SPRING_PROFILES_ACTIVE:local} # 기본적으로 로컬 프로파일을 활성화

  sql:
    init:
      mode: ${SQL_INIT_MODE:always}

  cloud:
    aws:
      region:
        static: ap-northeast-2
    s3:
      bucket: recipic-bucket

 

 

3. 2. application-local.yml : 키 설정

cloud:
  aws:
    credentials:
      access-key: ${AWS_ACCESS_KEY}
      secret-key: ${AWS_SECRET_KEY}

 

 

3.3. git action 워크플로우 수정 

로컬에는 위에처럼 application.yml에 있는 local이 적용되서 사용되지만 실제 배포를 할 떄는 워크플로우의 .env 파일 기반의 실행하면 자동으로 spring 이 application-prod.yml 또는 default로 설정을 사용하게 된다. 

spring은 appilcation.yml에 적인 active: local보다도 환경변수로 전달된 spring_profile_active 값을 우선 적용한다. 

- name: 📝 Create .env file
  run: |
    echo "SPRING_PROFILES_ACTIVE=prod" >> .env
    echo "APP_DOMAIN=${{ secrets.APP_DOMAIN }}" >> .env
    echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env
    ...

 

 

4. 코드 

4.1. sdk 추가  (build.gradle)

implementation 'software.amazon.awssdk:s3'

 

4.2. s3Config.java

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;

@Configuration
public class S3Config {

    @Value("${cloud.aws.region.static}")
    private String region;

    @Value("${cloud.aws.s3.access-key:}")
    private String accessKey;

    @Value("${cloud.aws.s3.secret-key:}")
    private String secretKey;

    @Bean
    public S3Presigner s3Presigner() {
        S3Presigner.Builder builder = S3Presigner.builder()
                .region(Region.of(region));

        if (!accessKey.isEmpty() && !secretKey.isEmpty()) {
            // 로컬 환경: Access Key와 Secret Key를 사용
            builder.credentialsProvider(StaticCredentialsProvider.create(
                    AwsBasicCredentials.create(accessKey, secretKey)));
        } else {
            // EC2 환경: IAM Role을 자동 감지
            builder.credentialsProvider(DefaultCredentialsProvider.create());
        }

        return builder.build();
    }
}

- S3Presinger는 aws sdk에서 제공하는 클래스 중 하나로 presigned url을 생성하는 데 사용된다. 

 

4.3. service 


import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.*;

import java.net.URL;
import java.time.Duration;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class S3Service {

    private final S3Presigner s3Presigner;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    
     //  S3에 이미지 업로드용 Presigned URL 발급 (PUT 방식)
     
    public String generateUploadPresignedUrl(String folder, String originalFileName) {
        // 고유한 파일 경로 생성
        String key = folder + "/" + UUID.randomUUID() + "-" + originalFileName;

        // S3에 업로드할 파일 요청 정보
        PutObjectRequest objectRequest = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType("image/jpeg")
                .build();

        // Presigned URL 발급 요청
        PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(
                r -> r.putObjectRequest(objectRequest)
                      .signatureDuration(Duration.ofMinutes(5))
        );

        return presignedRequest.url().toString(); // 프론트가 이 URL로 업로드하게 됨
    }

    
     // S3에 있는 이미지 조회용 Presigned URL 발급 (GET 방식)
     
    public String generateViewPresignedUrl(String key) {
        // 조회 요청 정보 생성
        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();

        // Presigned GET URL 발급
        GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(10))
                .getObjectRequest(getObjectRequest)
                .build();

        PresignedGetObjectRequest presignedGetObjectRequest =
                s3Presigner.presignGetObject(getObjectPresignRequest);

        return presignedGetObjectRequest.url().toString();
    }

    // S3에서 이미지 삭제
     
    public void deleteFile(String imageUrl) {
        String key = extractKeyFromUrl(imageUrl);

        S3Client s3 = S3Client.builder()
                .region(Region.AP_NORTHEAST_2)
                .credentialsProvider(DefaultCredentialsProvider.create())
                .build();

        s3.deleteObject(DeleteObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build());
    }

    /**
     *  S3 URL에서 Key 추출
     * ex: https://bucket.s3.amazonaws.com/folder/abc.jpg → folder/abc.jpg
     */
    private String extractKeyFromUrl(String url) {
        int idx = url.indexOf(".amazonaws.com/") + ".amazonaws.com/".length();
        return url.substring(idx);
    }
}

 

4.4. controller


import lombok.RequiredArgsConstructor;
import njb.recipe.service.S3Service;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/s3")
public class S3Controller {

    private final S3Service s3Service;

    /**
     *  업로드용 Presigned URL 발급
     * 프론트에서 파일 업로드 전에 요청
     */
    @GetMapping("/presigned-upload")
    public ResponseEntity<String> getPresignedUploadUrl(
            @RequestParam String folder,
            @RequestParam String fileName
    ) {
        String presignedUrl = s3Service.generateUploadPresignedUrl(folder, fileName);
        return ResponseEntity.ok(presignedUrl);
    }

    /**
     * 조회용 Presigned URL 발급
     * 프론트에서 비공개 이미지 조회 시 요청
     */
    @GetMapping("/presigned-view")
    public ResponseEntity<String> getPresignedViewUrl(
            @RequestParam String key // 예: ingredient/uuid-filename.jpg
    ) {
        String viewUrl = s3Service.generateViewPresignedUrl(key);
        return ResponseEntity.ok(viewUrl);
    }

    /**
     * 이미지 삭제 (서버가 직접 삭제 요청)
     */
    @DeleteMapping("/file")
    public ResponseEntity<Void> deleteImage(@RequestParam String imageUrl) {
        s3Service.deleteFile(imageUrl);
        return ResponseEntity.noContent().build();
    }
}

- 조회용 url 발급 ,,, s3 버킷에 저장할 수 있는 presigned url을 발급받아서 저장을 했다. 하지만 굳이 비공개로 해서 조회용 presigned url까지 발급할 필요가 있을까라는 의문점이 들었다. 

- 보안적인 측면을 생각하면 비공개로 하는게 맞을 거 같아서 코드상의 구현을 했다. 하지만 일단 s3 버킷은 퍼블릭 읽기 허용으로 바꿔서 접근 가능 할 수 있게 하는게 좋겠다. 나중에 뭐 바꾸면 되니까.. 지금은 이게 더 편리할 거 같다. 

 

 

5. 테스트 

서버 실행 후

5.1. s3에 업로드용 presigned url이 잘 발급되는지 보자

 

- param에 값을 잘 넣어준다. 이때 저렇게 폴더 이름을 넣어주면 s3 버킷 내에 폴더 아래로 이미지가 저장이 된다. 

 

https://recipic-bucket.s3.ap-northeast-2.amazonaws.com/ingredint/9aeabㅇㄹㄴㅇㄹㄴㅇㄹㄴㅇㄹ.testing.jpg?X-Aㄴㅇㅇㄹㄴㅇㄹㄴㅇㄹㄴㅇ

뭐 이런식으로 presigned.url이 날라온다. 

 

5.2. presignedURL로 s3에게 이미지 저장 요청하기 (put)

- 요청 url을 아까 받은 presignedURL로 저장하고 싶은 이미지를 put 요청을 하면 200응답을 확인할 수 있다. 

 

내 버킷 아래에 아까 param으로 넣었던 폴더 경로 밑에 이미지가 저장된 것이 보인다. 

저장 요청이 됐으면 이미지를 프론트에서는 <img src={imageUrl}> 바로 이미지를 띄울 수 있는 것이 좋다. 

그래서 s3에 저장된 이미지를 바로 받을 수 있게 s3를 퍼블릭으로 열어두면 된다. 

여기서 생겼던 트러블 슈팅은 따로 기록.... 하겠다..

.

.

.

 

 

 

일단 파일 업로드를 위한 presigned URL을 발급을 받았을 때 이것은 서명된 url로 "?" 물음표 뒤에 붙어 있는 값들은 aws에서 발급한 access key, signature 같은 것들이다. 

그래서 실제로 프로트에서 img 테그를 통해 이미지를 조회할 때는 split('?')[0] 를 사용해서 쿼리 스트링을 제거하고 수순한 이미지 주소만 가져다가 사용하면 된다. 

 

그래서 백엔드 디비 서버에 이미지 주소 경로를 저장할때도 순수 이미지 주소만 저장해주면 된다. 

이거는 굳이 넣지 않겠다. 그냥 디비 crud니까..

 

그래서 https://recipic-bucket.s3.ap-northeast-2.amazonaws.com/ingredint/9aeabㅇㄹㄴㅇㄹㄴㅇㄹㄴㅇㄹ.testing.jpg?X-Aㄴㅇㅇㄹㄴㅇㄹㄴㅇㄹㄴㅇ

이렇게 presigned URL 응답으로 준 값에 ?만 제거하고 검색을 하면 이미지에 퍼블릭 접근이 가능해서 조회를 할 수 있게 된다. 

 

아무 화면 캡처에서 저장했더니 ㅎㅎ 이렇다..ㅎ

 

 

 

1. 구현 이유 

현재 사이드 프로젝트에서 백엔드 spring boot jpa 개발을 맡고 있다. 

이미지 crud를 구현하는 것을 목표로 s3를 활용해서 이미지를 관리하고자 한다.  전에는 naver cloud platform에서 제공해주는 스토리지를 사용한 적이 있는데 aws의 버킷은 처음 사용한다. 그래서 기록하고자 함

현재 프로젝트는 ec2서버에 git action을 활용해 cicd가 구현이 되어있다. 완전히 배포된 게 아니라 개발 버전으로 cicd로 구축해서 프론트와도 편하게 연동하고 만들었다. 

 

2. 개념 

2.1 aws s3(Simple Storage Service)? 

아마존에서 제공하는 오브젝트 스토리지 서비스로 파일(이미지, 문서, 동영상 등)을 버킷에 저장하는 것을 말한다.

2.2 Bucket ? 

"object를 관리하는 컨테이너로 파일시스템"

s3에서 데이터를 저장할 수 있는 컨테이너 역할을 하는 공간이다. 파일을 저장하기 위해 버킷안에 저장을 해야하고 이때 사용하는 버킷의 이름은 고유해야한다. 

버킷 단위로 접근해서 접근 정책, cors 설정, 버전관리 등을 할 수 있다. 

2.3 Object ? 

s3에서 실제로 저장되는 파일(이미지 파일, 텍스트 파일, pdf, json ,,, etc) -> 파일과 파일 정보로 구성된 저장돤위

구성요소

1. key : 오브젝트의 "파일 경로 + 이름" : images/user/logo.png 이런거 

2. value : 실제 파일 데이터 (바이너리 형태로 저장된다)

3. metadata : 오브젝트의 속성 정보 (content-type, content-lenght,,)

 

 

3. s3 설정

크게 버킷생성, 버킷 권한 (cors 설정 같은거 프론트에서 접근하기 위해), IAM Role 설정(spring/ec2 등에서 접근위함) 

3.1 버킷 생성 

1. s3 접속

https://console.aws.amazon.com/s3/


2. 버킷 생성 클릭 

 

 

https://console.aws.amazon.com/s3/

 

console.aws.amazon.com

 

3. 설정

버킷 이름 고유한 이름 
AWS 리전 서울(ap-northeast-2) 
객체 소유권 ACL 비활성화(기본값 그대로 두기)
퍼블릭 접근 차단 기본값 — 보안상 안전
버전 관리 비활성화
기본 암호화
활성화 (Amazon S3 managed keys 추천)

 

 

3.2 버킷 권한 설정

spring 서버가 s3에 직접 get, put 등등 요청을 하는 백엔드 중심 구조에서는 cors 설정이 필요없다. 

예를 들어 MultipartFile -> controller -> s3 Service 이런 식을 말한다. 전에 내가 ncp에서 오브젝트 스토리지를 사용했을 때 이런 방식을 고수했다. 하지만 이번 s3를 사용할때는 프론트에서 직접 s3에 업로드를 하는 방식을 취할 것이다. (이 부분은 아래에서 더 자세히)

아무튼 프론트에서 직접 접근하기에 cors 설정을 해줘야한다. 

 

생성한 버킷 -> 권한 탭 -> cors 규칙 추가 -> 사진과 같이 json 형태로 추가해주면 된다. 

❘ 개발 초기에는 "*" 로 열어두고 운영단계가 되면 우리 도메인만 허용해주는게 좋다

 

3.3 IAM 권한 설정

s3는 퍼블릭한 서비스로도 사용할 수 있지만 대부분의 경우는 사용자의 개인적인 저장공간이 된다. 민감한 사용자의 파일이 저장되는데 이거를 쉽게 접근할 수 있으면 안된다. 그래서 IAM(Identity and Access Management)을 사용해서 제어할 수 있다. 

IAM을 설정하는데는 두가지 방식이 있다. 첫번째는 Access Key를 만들어서 .yml에 설정하는 방식으로 spring 서버에서 직접 s3에 접근할때 사용한다.  두번째는 IAM Role 방식으로 ec2 인스턴스에 iam role을 부여해서 자동인증을 함으로서 ec2에서 직접 접근한다고 보면 된다. 

 

보통 다른 블로그나 글들을 보면 첫번째 방식인 access key 방식을 자주 사용하는거 같다. 아무래도 yml에 키 저장해서 바로 쓸 수 있어서가 아닐까..? 싶다

전자 같은 경우는 키 노출 위험도 있지만 로컬. ci/cd 환경에서 구축이 편한거 같다. 하지만 키 만료되면 교체해야하고 보안적인 리스크가 존재한다. 후자는 ec2에만 부여된 권한이기에 키 자체가 없어 안전하다 하지만 ec2 내부에서만 동작하기 로컬이나 cicd 환경에서는 잘 안될 수도 있다는 점이다. 그래도 유지보수 측면에는 키 만료 걱정도 없고 역할만 유지하면 되서 aws 에서 권장하는 방식이라고 한다. 

 

 내 프로젝트 현재 상황 : git actions은 단지 ec2에 ssh로 접속해 jar 파일을 배포하고 실행하면서 cicd를 구축했다. 그래서 s3 접근은 ec2에 배포된 spring 서버가 수행을 하기 때문에 ec2에 IAM role만 있다면 자동으로 접근이 가능할거 같아 후자를 선택했댜. 

 

하지만 혹시나 로컬이나 테스트 중에 필요하다면 추가 블로그를 작성해야겠다

 



1. IAM 역할 생성 

2. 신뢰할 수 주체 선택에서 

 - aws 서비스 

 - ec2 선택

3. 권한 정책에 아래를 추가

4. 역할 이름은 자유롭게

개발 초기에는 FullAccess 를 사용하고 추후에는 PutObject, GetObject, DeleteObject만 남기는게 보안상 좋다.

 

 

5. ec2 인스턴스에 role 연결

기존에 쓰고 있는 ec2에 연결하면 된다. 이거는 따로 설명하지 않겠다.

ec2 인스턴스 - 작업 - 보안 - iam 역할 수정 - 위에서 만든 역할 선택 후 저장하면 된다. 

 

 

6. 테스트

s3 버킷에 접근이 가능한지, s3 버킷이 존재하는지 확인해보자.

일단 ssh 로 ec2에 들어와서 aws cli 부터 설치한다. 

# 1. AWS CLI 설치 파일 다운로드
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"

# 2. unzip 설치 (압축 풀기 도구)
sudo apt update && sudo apt install unzip -y

# 3. 압축 해제
unzip awscliv2.zip

# 4. AWS CLI 설치
sudo ./aws/install

# 5. 설치 확인
aws --version

 

그 다음에 임의로 텍스트 파일을 생성해 존재유무를 봐보자 

ubuntu@ip-000-00-00-00:~$ echo "hello recipic" > test.txt
ubuntu@ip-000-00-00-00:~$ aws s3 cp test.txt s3://recipic-bucket/test.txt --region ap-northeast-2
upload: ./test.txt to s3://recipic-bucket/test.txt
ubuntu@ip-000-00-00-00:~$ aws s3 ls s3://recipic-bucket/ --region ap-northeast-2
2025-04-16 16:01:05         14 test.txt

참고로 텍스트 파일을 생성하고 테스트를 안하면 아무것도 출력이 안된다 (에러가 아니라 s3 버킷은 존재하지만 파일이 없는 상태 의미)

 

 

 

 

4. Presigned URL ? 

말 그대로 미리 서명된 url..? "s3 객체에 접근할 수 있는 임시 서명된 url" 이다. 

이 url을 가진 사람은 IAM인증이 없어도 일정 시간동안 요청이 가능하다. 그래서 스프링 서버는 url을 생성해서 프론트에 주면 클라이언트는 그 url에 직접 이미지를 업로드하거나 조회를 한다. 

 

aws 공식문서를 참고하면 개념적 설명이 잘 되어 있다.

https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/PresignedUrlUploadObject.html

 

미리 서명된 URL을 통해 객체 공유 - Amazon Simple Storage Service

미리 서명된 URL을 통해 객체 공유 미리 서명된 URL을 사용하여 다른 사람이 Amazon S3 버킷에 객체를 업로드하도록 허용할 수 있습니다. 미리 서명된 URL을 사용하면 상대방에게 AWS 보안 자격 증명

docs.aws.amazon.com

 

 

이 방식을 사용하는 이유 ? 

기존 방식 presigned url 
클라이언트 → 서버 → S3 클라이언트 → S3 직접 접근
서버가 파일을 직접 중계해야 함 (트래픽 부담 ↑) 서버는 URL만 발급하고 트래픽 부담 ↓
파일 업로드/다운로드 시 서버 처리 필요 클라이언트가 직접 처리하므로 서버 부하 줄임
보안 위해 S3를 private으로 설정하기 어려움 private으로 두고, URL로만 제한적 접근 가능

 

 

전에 ncp의 오브젝트 스토리지를 사용했을 때는 서버를 거치는 방식을 사용했었다. 그래서 직접 mulipart file 방식으로 직접 받고 사용해보니 서버에서 복잡한 서비스 로직을 구현해야 했다. 

당시에 구현했던 글이 있어 참고한다. 

https://dev-leeyjstar.tistory.com/entry/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-ncp-Object-Storage-OOS-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%98%AC%EB%A6%AC%EA%B8%B0-%EC%88%98%EC%A0%95%ED%95%98%EA%B8%B0

 

[트러블 슈팅] ncp Object Storage (OOS) 이미지 올리기, 수정하기

오또케 ㅠ 나의 첫 이미지 ... ncp 활용일기.....감격스러워 1.  환경 및 개발 이유현재 멀티캠퍼스 풀스택 부트캠프 과정에서 프로젝트를 진행하고 있다. 백엔드는 springboot + mybatis , 프론트는 type

dev-leeyjstar.tistory.com

 

 

이 방식이 성능, 확장성, 서버 부하, 보안 측면에도 기존에 구축했던 방식보다 더 나은 방식인 것 같다. 

 

4.1 로직 

앞서 말한 것 처럼 presigned url은 s3에 대해 제한시간동안 요청을 허용하는 서명된 url로 서버는 이 url을 생성해서 클라이언트에게 전다라하고 클라이언트는 이 url을 활용해 직접 s3에 요청을 보낸다. 

 

1. 이미지 업로드 

시나리오는 회원 정보를 새롭게 저장한다고 해보자

🔁 전체 흐름

  1. 프론트에서 이미지 선택
  2. 백엔드 /api/s3/presigned-url?type=memberprofile&fileName=itsme.jpg 호출
  3. Presigned PUT URL을 응답받음 (정해놓은 유효시간 동안만 )
  4. 프론트는 해당 URL에 PUT 요청으로 s3에 이미지 업로드
  5. 업로드 성공 → URL에서 ? 이전 경로만 잘라서 S3 이미지 경로 확보
  6. /api/memberprofile 등록 API에 imageUrl 포함해서 전송
  7. 백엔드는 DB에 저장 (imageUrl 컬럼
    -> 결론적으로는 회원정보를 저장하기 전에 미리 이미지를 s3에 저장해놓고 저장해 놓은 경로를 받아서 회원 정보를 디비에 저장할때 이 경로도 같이 저장해놓으면 회원 디비에 회원의 이미지까지 같이 저장이 된다. 

📌 참고사항

  • 업로드 URL은 PUT 방식
  • S3 URL 저장 시 ? 쿼리 파라미터 제거: presignedUrl.split('?')[0]

 

2. 이미지 조회 

시나리오는 등록된 회원의 이미지를 조회하는 것

🔁 전체 흐름

  1. 클라이언트가 GET /api/memberprofile/{id} 호출
  2. 백엔드는 DB에서 imageUrl 조회
  3. 응답 JSON에 포함해서 전달
  4. 프론트에서 <img src={imageUrl} />로 출력

📌 참고사항

  • 이미지 URL은 S3 주소 그대로 사용 가능 (버킷이 public-read 또는 CloudFront 연동이면 더 빠름)
  • S3가 비공개 버킷이라면 조회용 Presigned GET URL도 발급해야 함

 

3.이미지 수정 

시나리오는 회원의 이미지를 바꾸고 싶을 때 

🔁 전체 흐름

  1. 프론트: 새 이미지 선택
  2. 백엔드에 Presigned PUT URL 요청 (새 파일 이름으로)
  3. 프론트에서 새로운 URL로 이미지 업로드
  4. 업로드 성공 후:
    • 이전 이미지 URL S3에서 삭제 요청 (선택적)
    • 새로운 이미지 URL을 포함한 /api/memberprofile/{id} 수정 API 호출
  5. 백엔드는 imageUrl 컬럼을 새 URL로 업데이트

📌 참고사항

 

4. 이미지 삭제 

시나리오는 회원 정보를 삭제하면서 이미지가 같이 삭제되도록

🔁 전체 흐름

  1. 프론트: /api/memberprofile/{id} 삭제 API 요청
  2. 백엔드:
    • DB에서 해당 재료의 imageUrl 조회
    • S3 DeleteObject 요청
    • 재료 정보 DB에서 삭제

📌 참고사항

  • Presigned URL은 필요 없음 (서버에서 직접 삭제 처리)
  • S3의 key는 imageUrl에서 버킷 주소를 제거해서 추출

만약 회원 자체 삭제가 아니라 이미지만 삭제한다면 위에 3번처럼 수정하는 방식으로 삭제할 에정이다. 

 

코드 적인 구현은 2편에서 to be continue..

1. 구현 동기 

현재 냉장고 관리를 주제로 사이드 프로젝트를 진행하면서 냉장고 안에 재료들이 유통기한이 임박할 때 알림을 해주는 기능을 구현하고자 했다. 

 

그리고 나 같은 경우는 앱프로젝트가 아니라 웹 기반 구현이기 때문에 처음에 초기 fcm 설정할 때만 웹일 경우는 조금 다르게 해야했다. 

환경은 spring boot , jpa, react를 사용하고 있고 개발 버전을 공유하기 위해 gitaction으로 cicd가 개발 버전으로 자동 배포가 되어 있는 상태다. 이때 겪었던 트러블 슈팅도 기록하겠다. 

 

2. 이론 

2.1 FCM (Firebase Cloud Messaging) 이란? 

구글에서 제공하는 무료 푸시 메시징 서비스이다. 

웹, 앱 등 다양한 플랫폼에서 서버-> 클라이언트로 알림 메시지를 전송할게 해준다. 

 

- fcm token : fcm 서버가 특정 기기(브라우저 또는 앱) 으로 알림을 보내기 위해 사용하는 고유 식별자이다. 집 주소처럼 이 토큰을 활용해서 해당 디바이스로 메시지를 전달한다고 생각하면 된다. 이 토큰은 유효기간이 바뀔 수 있어서 재발급이 가능하다. 

발급 후에 디비의 멤버 테이블에 저장해서 알림을 보낼때마다 사용할 것이다. 

 

- 포그라운드 : 사용자가 앱을 현재 열고 있는 상태로 쉽게 생각하면 이미 사용자가 웹사이트를 보고 있는 상태라고 생가하면 된다. 

이때는 onMessage() 방식으로 처리하면 된다. 리액트 앱에서 직접 알림을 처리하면 된다. 

- 백그라운드 : 앱이 열려 있지 않거나 꺼져있는 상태이다. 비활성화 상태에서는 firebase-messaging-sw.js로 서비스 워커를 통해 브라우저가 자체적으로 알림을 띄워준다. 

- 서비스 워커 : 브라우저에 등록되어 있는 백그라운드 작업자 스크립트로 앱이 꺼져 있어도 알림을 받을 수 있게 한다. 

 

 

2.2 구조

 

1. 클라이언트가 접속하면 

2. 리액트 서버에서 파이어베이스에게 해당 브라우저에 고유 fcm 토큰을 요청한다. 

3. 토큰이 발급되면 스프링 백엔드 서버로 토큰을 전송하고 

4. 백엔드에서는 알림을 보낼려고 하는 시점들을 서비스 로직을 구현하고 

5. 알림을 보내고 싶을 때 firebase에게 요청을 한다. 

6. firebase는 백엔드가 요청한 fcm 토큰을 가지고 해당 디바이스에게 알림을 전송해준다. 

 

 

 

 

 

 

 

 

 

 

 

 

3. 구현 

일단 파이어베이스 콘솔에서 프로젝트를 추가하고 웹 앱을 등록해줘야 한다. 

앱등록하는 방법은 검색해보면 잘 나와있고 그냥 너무 간단해서 넘긴다. 

 

3.1 fcm 토큰을 발급받기 (react)

- VAPID 키 : fcm은 웹 푸시 알림을 위해 브라우저와 통신하는데 이때 vapidkey라는 공개키를 요구한다. 

처음에는 안보일 수도 있는데 이때는 Generate Key pair 를 눌러 인증서를 생성하면 된다. 

프론트에서 토큰 발급할 때 필요한 것 

파이어베이스 프로젝트 설정 - 클라우드 메시징

 

 

 

서비스 워커 설정 이 파일은 꼭 public/ 폴더 아래 경로에 있어야 된다고 한다. 

이 초기화 코드들을 파이어베이스 설정에서 일반 탭에 가면 초기화 코드를 볼 수 가 있다. 

 

 

firebase-messaging.tsx 

import { initializeApp } from "firebase/app";
import { getMessaging, getToken } from "firebase/messaging";

const firebaseConfig = {
  apiKey: "",
  authDomain: "",
  projectId: "",
  storageBucket: "",
  messagingSenderId: "",
  appId: "",
  measurementId: "",
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

export const requestFcmToken = async () => {
  try {
    const permission = await Notification.requestPermission();
    if (permission !== "granted") {
      console.log("알림 권한 거부됨");
      return null;
    }

    const token = await getToken(messaging, {
      vapidKey:
        " 아까 파이어베이스에서 발급 받은 키 넣으삼",
    });

    console.log("✅ FCM 토큰:", token);
    return token;
  } catch (error) {
    console.error("❌ FCM 토큰 발급 실패", error);
    return null;
  }
};

 

 

App.tsx

import React, { useEffect } from "react";
import { requestFcmToken } from "./firebase-messaging";
import { getMessaging, onMessage } from "firebase/messaging";
import { initializeApp } from "firebase/app";

function App() {
  const firebaseConfig = {
    apiKey: "",
    authDomain: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: "",
    measurementId: "",
  };

  // Firebase 앱 & 메시징 초기화
  const app = initializeApp(firebaseConfig);
  const messaging = getMessaging(app);

  // 포그라운드 메시지 수신 핸들러 등록
  useEffect(() => {
    onMessage(messaging, (payload) => {
      console.log("📩 포그라운드 메시지 수신:", payload);

      if (payload.notification) {
        const { title, body } = payload.notification;
        new Notification(title ?? "알림", { body: body ?? "" });
      }
    });
  }, [messaging]);

  // FCM 토큰 발급 버튼 동작
  const handleGetToken = async () => {
    const token = await requestFcmToken();
    if (token) {
      alert(`FCM 토큰:\n${token}`);
    }
  };

  return (
    <div style={{ padding: 20 }}>
      <h1>📬 FCM 테스트 앱</h1>
      <button onClick={handleGetToken}>FCM 토큰 받기</button>
    </div>
  );
}

export default App;

 

이렇게 두가지 코드로 테스트를 진행했다. 

.

.

.

결과 버튼을 누르면 내 브라우저에 토큰이 잘 넘어온게 보인다

 

 

3.2  fcm 토큰 브라우저에 알람 보내기 

 

설정 

1. 백엔드에서도 env 환경변수 추가한다.

 

2. 의존성 추가 

implementation 'com.google.firebase:firebase-admin:9.2.0'

 

3. firebase 콘솔에서 서비스 계정 키를 다운해야한다. 

설정 - 서비스 게정 - 새키 발급 

serviceAccountKey.json 이 파일이 다운로드가 되는데 

src/main/resources/firebase/ 경로 밑에 넣어주면 된다. 

 

 

 

 

 

 

FirebaseConfig.java : sdk 초기화, env에 저장된 환경변수 설정 등을 한다. 

package njb.recipe.global.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import io.github.cdimascio.dotenv.Dotenv;

import java.io.ByteArrayInputStream;
import java.io.IOException;

@Slf4j
@Configuration
public class FirebaseConfig {

    @PostConstruct
    public void initialize() {
        try {
            Dotenv dotenv = Dotenv.configure().directory("./").load();

            String privateKey = dotenv.get("FIREBASE_PRIVATE_KEY");
            if (privateKey != null) {
                privateKey = privateKey.replace("\\n", "\n");
            } else {
                log.error("❌ FIREBASE_PRIVATE_KEY is not set in .env file");
                return;
            }

            String clientEmail = dotenv.get("FIREBASE_CLIENT_EMAIL");
            if (clientEmail == null) {
                log.error("❌ FIREBASE_CLIENT_EMAIL is not set in .env file");
                return;
            }

            String projectId = dotenv.get("FIREBASE_PROJECT_ID");
            if (projectId == null) {
                log.error("❌ FIREBASE_PROJECT_ID is not set in .env file");
                return;
            }

            String serviceAccountJson = String.format(
                "{\n" +
                "  \"type\": \"service_account\",\n" +
                "  \"project_id\": \"%s\",\n" +
                "  \"private_key_id\": \"%s\",\n" +
                "  \"private_key\": \"%s\",\n" +
                "  \"client_email\": \"%s\",\n" +
                "  \"client_id\": \"%s\",\n" +
                "  \"auth_uri\": \"%s\",\n" +
                "  \"token_uri\": \"%s\",\n" +
                "  \"auth_provider_x509_cert_url\": \"%s\",\n" +
                "  \"client_x509_cert_url\": \"%s\"\n" +
                "}",
                projectId,
                dotenv.get("FIREBASE_PRIVATE_KEY_ID"),
                privateKey,
                clientEmail,
                dotenv.get("FIREBASE_CLIENT_ID"),
                dotenv.get("FIREBASE_AUTH_URI"),
                dotenv.get("FIREBASE_TOKEN_URI"),
                dotenv.get("FIREBASE_AUTH_PROVIDER_X509_CERT_URL"),
                dotenv.get("FIREBASE_CLIENT_X509_CERT_URL")
            );


            FirebaseOptions options = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(new ByteArrayInputStream(serviceAccountJson.getBytes())))
                .setProjectId(projectId)
                .build();

            if (FirebaseApp.getApps().isEmpty()) {
                FirebaseApp.initializeApp(options);
                log.info("✅ Firebase SDK initialized successfully");
            } else {
                log.info("ℹ️ Firebase SDK already initialized");
            }
        } catch (IOException e) {
            log.error("❌ Firebase SDK initialization failed: {}", e.getMessage(), e);
        } catch (Exception e) {
            log.error("❌ Unexpected error during Firebase SDK initialization: {}", e.getMessage(), e);
        }
    }
}

 

 

FcmController.java

package njb.recipe.controller;

import njb.recipe.dto.token.FcmNotificationRequestDTO;
import njb.recipe.dto.token.FcmTokenRequestDTO;
import njb.recipe.global.jwt.CustomUserDetails;
import njb.recipe.service.FcmService;
import njb.recipe.service.MemberService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/fcm")
public class FcmController {

    private final MemberService memberService;
    private final FcmService fcmService;

    public FcmController(MemberService memberService, FcmService fcmService) {
        this.memberService = memberService;
        this.fcmService = fcmService;
    }
// 여기는 fcm token 을 저장하는 부분
    @PutMapping("/token")
    public ResponseEntity<String> updateFcmToken(@AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody FcmTokenRequestDTO fcmToken) {
        String memberId = userDetails.getMemberId();
        memberService.updateFcmToken(memberId, fcmToken);
        return ResponseEntity.ok("FCM token updated successfully");
    }
// 알림 요청
    @PostMapping("/send-test-notification")
    public ResponseEntity<String> sendTestNotification(@RequestBody FcmNotificationRequestDTO request) {
        return fcmService.sendNotification(request);
    }
}

fcm token을 리액트에서 받으면 디비에 저장하는 부분은 따로 설명안하겠다. 그냥 받으면 멤버 엔티티에 넣어주면 된다. 

 

DTO

package njb.recipe.dto.token;

public class FcmNotificationRequestDTO {
    private String fcmToken;
    private String title;
    private String body;

    // 생성자
    public FcmNotificationRequestDTO(String fcmToken, String title, String body) {
        this.fcmToken = fcmToken;
        this.title = title;
        this.body = body;
    }

    // 기본 생성자 추가
    public FcmNotificationRequestDTO() {
        // 필드 초기화가 필요하다면 여기에 추가
    }

    // Getter 및 Setter
    public String getFcmToken() {
        return fcmToken;
    }

    public void setFcmToken(String fcmToken) {
        this.fcmToken = fcmToken;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }
}

 

 

FcmService.java

package njb.recipe.service;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.MessagingErrorCode;

import njb.recipe.dto.token.FcmNotificationRequestDTO;

import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class FcmService {
    // fcm에게 푸시 전송

    public ResponseEntity<String> sendNotification(FcmNotificationRequestDTO request) {
        // 메시지 구성
        Message message = Message.builder()
            .setToken(request.getFcmToken())
            .setNotification(Notification.builder()
                .setTitle(request.getTitle())
                .setBody(request.getBody())
                .build())
            .build();

        try {
            // 메시지 전송
            String response = FirebaseMessaging.getInstance().send(message);
            log.info("Fcm 푸시 전송 성공: {}", response);
            return ResponseEntity.ok("Fcm 푸시 전송 성공: " + response);
        } catch (FirebaseMessagingException e) {
            String errorMessage = "Fcm 푸시 전송 실패: " + e.getMessage();
            if (e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
                errorMessage = "유효하지 않은 FCM 토큰: " + request.getFcmToken();
                log.error(errorMessage);
                return ResponseEntity.status(400).body(errorMessage);
            }
            log.error(errorMessage, e);
            return ResponseEntity.status(500).body(errorMessage);
        } catch (Exception e) {
            String errorMessage = "Fcm 푸시 전송 실패: " + e.getMessage();
            log.error(errorMessage, e);
            return ResponseEntity.status(500).body(errorMessage);
        }
    }
}

여기서는 fcm 코드가 유효한지 체크하는 것도 넣어줬다 

 

 

.

.

.

테스트 

dto 형식으로 스웨거든 api 테스트 할 수 있는걸로 뭐든 하면 된다. 

 

 

 

 

 

 

브라우저에 알림이 온걸 확인할 수 있다...!!!!

 

 

 

 

4. 트러블 슈팅 

4.1 git action ci/cd 중에 오류 

나는 개발 버전을 자동화된 배포를 해놓고 개발을 하고 있다. 

그래서 개발한 것을 dev 브랜치에 올리면 개발 버전이 자동 배포가 된다. 

문제는 gitaction에서 파이어베이스 env 환경 변수를 설정할때 오류가 났었다. 

 

아 뭐가 문제였냐면.....git action에서 환경변수 값들을 secrets 키에 넣어줄 떄 바로 아래 키가 문제였다. 

 

이 키 같은 경우는 파이어베이스에서는 "" 쌍따옴표까지 같이 들어가야하고 줄바꿈 \n도 같이 들어가야 한다. 

근데 단순히 복붙해서 gitaction secrets 키 안에 넣으니 인코딩 이슈로 문제가 났었다. 

 

그래서 해결 방법은 base64로 인코딩 해서 넣은 후 git action 빌드할 때 필요한 워크플로우 파일에서 다시 base64를 해석하는 방식으로 했다. 그러니까 다행히 저 쌍따옴표와 줄바꿈 이슈는 해결이 됐다 

 

echo "📦 Decoding FIREBASE_PRIVATE_KEY from base64..."
            echo '${{ secrets.FIREBASE_PRIVATE_KEY }}' | base64 -d > decoded_key.pem
            ENCODED_KEY=$(cat decoded_key.pem | sed ':a;N;$!ba;s/\n/\\n/g')
            echo FIREBASE_PRIVATE_KEY="$ENCODED_KEY" >> .env
            rm decoded_key.pem

이 코드다... 이렇게 워크플로우 파일을 수정해서 해결했다 ㅠㅠ

 

4.2  갑자기 키 오류..?

POST https://fcm.googleapis.com/v1/projects/njbproject-39407/messages:send
{
  "error": {
    "code": 404,
    "message": "Requested entity was not found.",
    "status": "NOT_FOUND",
    "details": [
      {
        "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError",
        "errorCode": "UNREGISTERED"
      }
    ]
  }
}

 

알림 요청을 했을때 파이어베이스에서 키가 유효하지 않다며 404에러가 났었다

홀리 ... 이게 뭔가 했다...

삽질 끝에 알게 된 것은,,, 내가 프론트에서 fcm 토큰을 요청을 할때 로컬 3000번 포트로 처음에 요청을 해서 토큰을 받았었다. 

근데 잠시 다른 프로젝트 서버 열어볼 일이 있어서 그  프론트 서버를 3000번으로 열고 해당 fcm 테스트용 프론트 서버를 3001로 열었었다. 그러니까 fcm토큰이 기존에 받았던거랑 다르게 날라왔었다. 

그냥 아무 생각없이 다른 토큰으로 요청을 해보니까 이런 오류가 난것이다.. 

 

결론은 프론트 서버 포트를 동일하게 유지해야만 디바이스에 해당하는 fcm 토큰이 유효한 것이 넘어온다는 것..? 인거 같다

아무튼 문제는 해결됐으니 다행이다...

 

오또케 ㅠ 나의 첫 이미지 ... ncp 활용일기.....감격스러워

 

1.  환경 및 개발 이유

현재 멀티캠퍼스 풀스택 부트캠프 과정에서 프로젝트를 진행하고 있다. 

백엔드는 springboot + mybatis , 프론트는 typescript + react 이다

 

- 기존에 이미지를 저장하는 로직은 백엔드의 정적폴더에 경로를 지정하고 -> 프론트에서 MultipartFile로 이미지 저장 및 수정을 요청하면 ->  service 단에서 지정한 정적폴더 경로(resources/static : 이게 자바에서 범용적인 정적 폴더일거야) 에 저장하고 디비에 이미지 폴더 이름을 저장하는 방식을 사용했다. 

- 이미지를 조회할때는 프론트에서 실제 백엔드 로컬서버에서 http://localhost:8001~~~~/이미지.Jpg 이런식으로 정적폴더에 접근해 이미지를 가져오게 했다 

 

- 배포를 위해서 이미지를 따로 저장하는 네이버클라우드플랫폼(ncp) Object Storage를 활용해 이미지를 저장하고 가져오는 방향을 추진했다. 

 

2. ncp 에서 기본 세팅 

멀티캠퍼스 측에서 이미 서브계정을 받았고 이 계정 생성같은 경우는 따로 설명하지 않겠다. 

 

1. 네이버 클라우드 콘솔에 접속해 Object Storage 에 접속해 버킷을 생성한다. 아래 링크대로 이용신청 후 버킷을 생성하면 된다. 

https://guide.ncloud-docs.com/docs/objectstorage-start 

 

2. api 인증키를 받아야하는데 우리같은 경우는 멀티캠퍼스에서 받은 서브계정이기 때문에 따로 메인계정에 api 인증키를 받을 수 있게 권한 요청을 하면 api 인증키를 받을 수 있다. 

 

3. 백엔드 설정 

3.1  build.gradle 설정 

네이버 클라우드 같은 경우는 aws s3 sdk 를 지원한다. 그래서 의존성에 이 한줄을 추가해주면 된다. 

implementation 'com.amazonaws:aws-java-sdk-s3:1.11.238'

 

3.2 application.properties

access-key와 secert-key는 api 인증키 받을 때 나온거다 그리고 endpoint는 나와 동일하게 쓰면 된다. 버킷 이름도 지정한걸로 쓰고, region도 설정한걸로 써라 

#ncp
ncp.objectstorage.access-key=ncp_iam_비밀이에요
ncp.objectstorage.secret-key=ncp_iam_비밀이에요
ncp.objectstorage.endpoint=https://kr.object.ncloudstorage.com
ncp.objectstorage.bucket-name=workspace(당신껄로해요)
ncp.objectstorage.region: kr-standard(당신껄로해요)

 

3.3 NcpStorage.config 

ncp object Storage에 연결하기 위한 설정으로 우리가 이미지를 저장하기 위해 필요한 AmazonS3Client를 빈 생성해 사용할 수 있도록 한다. 버킷이름은 이따 이미지 저장할때 쓰려고 반환했다. 

 
@Configuration
public class NcpStorageConfig {
@Value("${ncp.objectstorage.access-key}")
private String accessKey;

@Value("${ncp.objectstorage.secret-key}")
private String secretKey;

@Value("${ncp.objectstorage.endpoint}")
private String endpoint;

@Value("${ncp.objectstorage.bucket-name}")
private String bucketName;

@Value("${ncp.objectstorage.region}")
private String region;

@Bean
public AmazonS3Client objectStorageClient() {
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)))
.build();
}

public String getBucketName() {
return bucketName;
}
}

 

 

4. 코드 추가 

우리 프로젝트 같은 경우는 이미지를 저장하고 수정하는 기능들이 각 팀원별로 가지며 브랜치가 나눠져 있다. 그래서 모든 기능별로 서비스단에서 이미지를 저장로직을 추가하는게 비효율적이라고 생각했다. 그래서 따로 이미지 저장하는 함수와 삭제하는 함수를 가진 클래스를 만들어 

각 기능별로 클래스를 주입받아 함수를 사용하면 코드 한줄로 사용할 수 있다!!!1 (우린 너무 똑똑해 야무져 이뻐) 

 

4.1 FileService.java 

라는 이름으로 이미지 저장하고 삭제하는 클래스 만들어서 공통으로 사용하자 

 

@Service
@RequiredArgsConstructor
public class FileService {

private final AmazonS3Client amazonS3Client;
private final NcpStorageConfig ncpStorageConfig; // NCP 스토리지 설정 주입

public String putFileToBucket(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file is empty or null.");
}

String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file has no original filename.");
}

String fileName = UUID.randomUUID().toString() + "_" + originalFilename.replace(" ", "_");
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());

try (InputStream inputStream = file.getInputStream()) {
// NCP 오브젝트 스토리지에 이미지 업로드
amazonS3Client.putObject(new PutObjectRequest(ncpStorageConfig.getBucketName(), fileName, inputStream, metadata));
return fileName; // 업로드된 파일 이름 반환
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image upload failed: " + e.getMessage());
}
}

public void deleteFileFromBucket(String fileName) {
try {
// NCP 오브젝트 스토리지에서 이미지 삭제
amazonS3Client.deleteObject(ncpStorageConfig.getBucketName(), fileName);
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image deletion failed: " + e.getMessage());
}
}
}

 

- putFileToBucket(MultipartFile file) : 이미지를 ncp oos 에 저장하는 함수 

  사용자들이 이미지를 저장할때 같은 이름으로 중복이 될 수 있다. 그래서 UUID를 활용해 이미지 이름앞에 특수값을 넣어주면 스토리지에 저장될때 이미지 이름이 겹칠 일이 없다. 

  metadata 객체를 사용하면 업로드할때 콘텐츠 유형, 길이 ,캐시 제어 등 정보를 설정 할수 있다. 그리고 InputStream을 활용해 이미지 아까 컨피그에서 의존성 주입 받은 amazonS3Client의 putObject함수를 사용하면 이미지가 저장된다. 

 

- deleteFileFromBucket(String fileName) : ncp oos에서 이미지를 삭제하는 함수 

   이거 같은 경우는 이미지를 수정할때 새로운 이미지가 들어오게 되는데 이때 기존 이미지를 삭제하고 새로운 이미지를 저장할때 그 삭제를 할 때 쓰는 친구다. 이거는 특별한거 없이 의존성 주입받은 amazonS3Client에 deleteObject()함수를 쓰면 끝난다. 

 

+ 아까 컨피그에서 버킷이름을 반환하게 했는데 저장하고 삭제할때 쓰기위해 반환한거임 >< 

 

 

자.. 이제 실제 코드에 적용해보자 

4.2 Controller 

이 컨트롤러는 유저와 관한 걸로 회원가입을 할때 쓰는 컨트롤러를 보여주겠다. 사실 여기는 기존에 정적폴더 경로로 저장했던 코드와 동일하다 그대로 MultipartFile로 파일 받고, ModelAttribute를 사용해 회원가입을 할때 body로 들어오는 데이터를 UserRequestDTO와 매칭해준다.  그냥 일반적인 회원가입 컨트롤러임 

근데 난 친절하고 나중에 까먹으니까 미래의 나를 위해 친절히 코드 넣어놈 ㅋ

@PostMapping("/user/register")
public ResponseEntity<String> registerUser(@ModelAttribute UserRequestDTO userRequestDTO,
@RequestParam(value = "memberImgFile", required = false) MultipartFile memberImgFile) {
return loginServiceImple.registerUser(userRequestDTO, memberImgFile); // 회원가입 처리
}

 

 

4.3 Service 

너무 좋다 혁신적이다. 위에서 만든 fileServie 의존성 주입해주고 , fileService.putFileToBucket(file)만 해주면 된다니 !! 나머지 코드들은 디비에 회원가입때 들어오는 정보들 저장하는거다 

@Autowired
private FileService fileService; // FileService 주입
public ResponseEntity<String> registerUser(UserRequestDTO userRequestDTO, MultipartFile memberImgFile) {

// 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(userRequestDTO.getPassword());
userRequestDTO.setPassword(encodedPassword); // 암호화된 비밀번호로 설정

// 이미지 저장 로직 추가
if (memberImgFile != null && !memberImgFile.isEmpty()) {
String fileName = fileService.putFileToBucket(memberImgFile); // FileService를 사용하여 이미지 저장
userRequestDTO.setMemberImg(fileName); // DB에 저장할 파일 이름 설정
logger.info("Image uploaded successfully to NCP Object Storage: {}", fileName); // 성공 로그
} else {
userRequestDTO.setMemberImg(null); // 이미지가 없을 경우 null 설정
}

// DB에 저장하기 전 UserRequestDTO 상태 로그
logger.info("UserRequestDTO before saving to DB: {}", userRequestDTO);

try {
logger.info("Registering user: {}", userRequestDTO);
userMapper.registerUser(userRequestDTO);
UserResponseDTO user = userMapper.getUserByEmail(userRequestDTO.getEmail());
logger.info("회원가입 후 저장된 뱃지 저장용 유저의 아이디 : {}", user.getMemberId());
// 뱃지 생성 호출
badgeService.createBadge(user.getMemberId());
crewBadgeManager.createCrewBadge(user.getMemberId());
 
logger.info("성공로그 이메일 : {}", userRequestDTO.getEmail()); // 성공 로그
} catch (DataIntegrityViolationException e) {
logger.error("Data integrity violation: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("{\"Code\": \"DATA_INTEGRITY_VIOLATION\", \"Message\": \"데이터 무결성 위반.\"}");
} catch (Exception e) {
logger.error("Registration failed: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("{\"Code\": \"REGISTRATION_FAILED\", \"Message\": \"" + e.getMessage() + "\"}");
}

// 회원가입 성공 메시지를 JSON 형식으로 반환
return ResponseEntity.ok("{\"message\": \"회원가입 성공\"}");
}

 

- 이미지 수정하는거는 조금 더 여기서 추가되는게 있다. 

  1. 이미지 수정 요청이 들어옴 

  2. 해당 아이디로 기존에 저장했던 이미지 파일 이름을 디비에서 가져오는 mapper, xml 코드를 만든다. 

  3. 아까 FileService에서 만들었던 파일 삭제 함수로 기존 파일이름을 넣어서 삭제해주고 

  4. 저장하는 함수에 새로운 이미지 파일을 넣어서 보내주면 된다 !! 

 

 

포스트맨이든 프론트랑 연동해서 쓰면 정상적으로 

 

저장된게 보일거다 !!!!!!!!

 

 

 

 

 

5. 여기가 진짜 트러블 슈팅... 프론트에서 오브젝트 스토리지에 저장한 이미지 가져오기 

프론트에서는 특별히 고칠 건 없었다. 

프론트에서 백엔드의 정적폴더에 접근할때 기존에는 BaseURL(백엔드 URL)을 지정해놓고 디비에 이미지 파일 이름 조회하면 응답으로 내려오는 파일 이름을 붙여서 접근했다. 

 

예를 들어 이렇게 http://localhost:8001/static/aldifefslfjosdf.jpg 요딴식으로 

const BASE_URL = "https://localhost:8001";
<ProfileImage
src={
userInfo.memberImg === null
? "/img/default/UserDefault.png"
: `${BASE_URL}${userInfo.memberImg}`
}
alt="Profile" 
/>

 

이거를 이렇게 ncp에 맞는 url로 바꾸면 된다. 

https://<버킷 이름>.<엔드포인트>/<파일 이름> 형식이다. 그리고는 바꿀게 없다. 

 

 

 

 

근데 요청을 하니까 ... 이런 오류가 났다 ㅠㅠㅠㅠ

 

공식 문서를 통해 cors문제인지 막 찾아봐도 안보임 ㅠㅠ

https://guide.ncloud-docs.com/docs/objectstorage-troubleshoot-common

 

 

 

6. 이미지 내려받기 해결 

근데 oos에 저장된 이미지들을 보던 중에 올라간 이미지를 클릭하고 꼼꼼히 살펴보던 중 권한관리 탭에서 전체공개에서 공개안함이 되어 있었다. 분명 우리는 처음에 버킷 생성하고 뭐시기.. 앞에 초반 작업 문서보고 따라했을 때 분명 public으로 했다. 

 

근데 알고보니까 이미지를 올리고 올려진 이미지에 대해서 접근하는 권한은 따로 설정을 해줘야 한단다...쉣더뻑킹임 이런건 처음 설정할때 나중에 퍼블릭으로 해줘야 한다고 대문짝만하게 광고해야한다. 하루동안 삽질 했다 

 

해결법 

1. ncp 콘솔에서 저장된 이미지에 대해 직접 다 퍼블릭읽기 권한 주기 -> 이런 귀찮고 ㅁㅊ짓은 하지말자 

2. aws cli 사용해 파일 권한 변경 멍령어를 써서 읽기 권한 주기 -> 이거 aws cli 설치해야한다. 나는 가난한 용량의 대학생 개발자이기에 뭐 설치하는거 끔찍히 싫어한다. 

3. s3 호환 api 사용해 설정 -> 이거는 그냥  어려워보였다 이해하기 싫었어요. 헤헤 

 

.

.

.

마지막 해결법은 이미지 업로드 할때 putObject 함수에서 퍼블릭 읽기 권한을 추가!!!

하면 된단다... 처음부터 알았으면 이렇게 코드 짰을거다. 그니까 이거는 이미지 저장하는 코드에서 권한을 줘서 처음 저장할때부터 퍼블릭 권한을 가진 이미지들이 생성되는거다. 그러면 따로 뭔가를 하지 않아도 자동으로 퍼블릭 상태의 이미지들을 프론트에서 받을 수 있었다. 

아까 위에서 코드의 재활용성을 높이기 위해서 만든 이미지 저장 , 삭제 클래스 FileService만 조금 수정해주면 된다. 


@Service
@RequiredArgsConstructor
public class FileService {

private final AmazonS3Client amazonS3Client;
private final NcpStorageConfig ncpStorageConfig; // NCP 스토리지 설정 주입

public String putFileToBucket(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file is empty or null.");
}

String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Uploaded file has no original filename.");
}

String fileName = UUID.randomUUID().toString() + "_" + originalFilename.replace(" ", "_");
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());

try (InputStream inputStream = file.getInputStream()) {
// NCP 오브젝트 스토리지에 이미지 업로드 시 퍼블릭 읽기 권한 추가
PutObjectRequest putObjectRequest = new PutObjectRequest(
ncpStorageConfig.getBucketName(),
fileName,
inputStream,
metadata
).withCannedAcl(CannedAccessControlList.PublicRead); // 퍼블릭 읽기 권한 설정

amazonS3Client.putObject(putObjectRequest);

return fileName; // 업로드된 파일 이름 반환
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image upload failed: " + e.getMessage());
}
}

public void deleteFileFromBucket(String fileName) {
try {
// NCP 오브젝트 스토리지에서 이미지 삭제
amazonS3Client.deleteObject(ncpStorageConfig.getBucketName(), fileName);
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Image deletion failed: " + e.getMessage());
}
}
}

 

 

하.. 만족해....잘거야.. 내일은 백ㅇ네드 프론트 둘다 배포할거야 ncp 재미지다 헤헤헤헤ㅔ 졸려핑 

 

문제 상황 

현재 spring boot를 사용하고 있고, 공공데이터포털에서 open api 를 사용해 연결하는 작업을 하던 중이었다.. 

분명히 키는 잘 발급 받았는데 자꾸 아래와 같이 에러가 떴다....

 

에러를 넘겼지만 당시 상태 코드는 200으로 넘어왔다... 그래서 나에게 리턴하는 메시지는 서비스키 문제라고 하지만 서비스키 문제가 아닐것이라고 짐작 . 

 

 

 

 

공공데이터 포털에서 open api 를 가져오기 위한 초기 세팅 

1. 일단 내가 원하는 데이터를 선택하고 활용신청을 하면 된다. 

2. 이때 서비스 키를 바로 사용하면 정상적으로 ? 요청이 안될 수 도 있다고 했다. 그래서 무려 난 개발 3일 전에 신청을 해놨다 ㅋㅋ

3. 서비스키(encoding key)와 callbackurl(end point), datatype(api가 내려 줄 데이터 타입)등을 application.properties에 아주 잘 적용했다. 

4. 처음 오류가 났을때 작성했던 코드 

package com.example.mini.please_mini_pjt.hosapi.ctrl;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.ResponseEntity;

import java.net.URI;
import java.net.URISyntaxException;

@RestController
@RequestMapping("/api")
public class HosApiController {

    @Value("${openApi.serviceKey}")
    private String serviceKey;
    
    @Value("${openApi.callBackUrl}")
    private String callBackUrl;

    @Value("${openApi.dataType}")
    private String dataType;

    @GetMapping("/Hosinfo")
    public ResponseEntity<String> getMethodName(@RequestParam(value = "Q0") String Q0, 
                                                @RequestParam(value = "Q1") String Q1,
                                                @RequestParam(value = "QT") String QT) {
        System.out.println("client end point : /api/Hosinfo");
        System.out.println("serviceKey = " + serviceKey);
        System.out.println("callBackUrl = " + callBackUrl);
        System.out.println("dataType = " + dataType);
        System.out.println("params = " + Q0 + "\t" + Q1 + "\t" + QT);

        // 요청 URL 생성
        String requestURL = callBackUrl + 
                            "?serviceKey=" + serviceKey + 
                            "&Q0=" + Q0 + "&Q1=" + Q1 +
                            "&QT=" + QT;

        System.out.println("url check = " + requestURL);

        // RestTemplate을 이용하여 GET 요청
        RestTemplate restTemplate = new RestTemplate();
        try {
            // URI 클래스를 사용하여 URL 인코딩
            URI uri = new URI(requestURL);

            // API 요청을 보내고 응답 받기
            String jsonString = restTemplate.getForObject(uri, String.class);
            System.out.println("API response = " + jsonString);

            // 요청 결과를 그대로 반환
            return ResponseEntity.ok(jsonString);
        } catch (URISyntaxException e) {
            // URI 생성 중 오류 발생 시 처리
            System.out.println("URI Syntax Error: " + e.getMessage());
            return ResponseEntity.status(400).body("잘못된 요청 URL입니다.");
        } catch (Exception e) {
            // 요청이 실패했을 경우 오류 메시지 출력
            System.out.println("API 요청 중 오류 발생: " + e.getMessage());
            return ResponseEntity.status(500).body("API 요청 중 오류 발생");
        }
    }
}

 

 

 

해결 과정

1.  오류 메시지처럼 서비스 키가 문제인지 아닌지 아는 방법 !!

코드를 자세히 보면 requestUrl 을 만들고 이를 확인하기 위해

출력하는 부분이 보일 것이다.

 

System.out.println("url check = " + requestURL);

이때 출력되는 url을 웹에서 테스트 해보면 !

아주 잘 데이터가 넘어온다.. ㅋㅋ

그렇다는 건 서비스키가 문제가 아니라는 뜻 ...

나의 못생긴 코드가 문제라는 뜻이다..

 

 

 

 

 

 

2. URI 클래스 사용하기 

 삽질해보니 다른 분들은 이 방법대로 해서 해결이 된걸 보고,,,, 수정했는데
하지만 나는 해결이 안됐다. 그래도 참고한 자료를 넣을게요 아래 블로그 보고 했습니다. 

 

https://velog.io/@cco2416/%EC%A1%B8%EC%97%85%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EA%B3%B5%EA%B3%B5%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8F%AC%ED%84%B8-service-key-is-not-registered-error-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95

 

[졸업프로젝트]공공데이터 포털 service key is not registered error 해결 방법

공공데이터 포털의 API를 졸업프로젝트를 진행하면서 사용하게 되었는데, 이 과정에서 "service key is not registered error"를 만나게 되었다. 이 오류를 해결하기 위한 과정이 적혀있다.

velog.io

 

3. 마지막 해결법 !! 인코딩 문제..

1. Service Key를 다시 인코딩하지 말기:

서비스 키는 이미 인코딩된 값이므로, 이를 다시 인코딩하면 문제가 발생할 수 있다. 현재 serviceKey에 인코딩된 값이 들어가 있다면, URLEncoder.encode()로 중복 인코딩하지 않도록 해야 한다...

2. 한글 파라미터는 명시적으로 인코딩하기:

한글로 되어 있는 Q0, Q1 파라미터는 브라우저에서 자동 인코딩되지만, 스프링에서 직접 요청을 만들 때는 URLEncoder로 인코딩을 해줘야 한다. 

 

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;

import com.example.mini.please_mini_pjt.hosapi.domain.HosDetailDTO;
import com.example.mini.please_mini_pjt.hosapi.domain.HosItemDTO;
import com.example.mini.please_mini_pjt.hosapi.service.HosApiService;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

@RestController
@RequestMapping("/api")
public class HosApiController {
    @Autowired
    private HosApiService hosApiService;


    @Value("${openApi.serviceKey}")
    private String serviceKey;
    
    @Value("${openApi.callBackUrl}")
    private String callBackUrl;
    
    @Value("${openApi.dataType}")
    private String dataType;

    @Value("${openApi.callBackUrl2}")
    private String callBackUrl2;

    @GetMapping("/Hosinfo")
    public ResponseEntity<List<HosItemDTO>> getMethodName(@RequestParam(value = "Q0", required = false) String Q0,
                                                @RequestParam(value = "Q1", required = false) String Q1,
                                                @RequestParam(value = "QT", required = false) String QT) {
        System.out.println("client end point : /api/Hosinfo");
        System.out.println("serviceKey = " + serviceKey);
        System.out.println("callBackUrl = " + callBackUrl);
        System.out.println("params = " + Q0 + "\t" + Q1 + "\t" + QT);

        HttpURLConnection http = null;
        InputStream stream = null;
        String result = null; 
        List<HosItemDTO> list = null;

        try {
            // 한글 파라미터 인코딩 처리
            String encodedQ0 = URLEncoder.encode(Q0, StandardCharsets.UTF_8.toString());
            String encodedQ1 = URLEncoder.encode(Q1, StandardCharsets.UTF_8.toString());
            String encodedQT = URLEncoder.encode(QT, StandardCharsets.UTF_8.toString());

            // 이미 인코딩된 serviceKey를 다시 인코딩하지 않음
            String requestURL = callBackUrl +
                                "?serviceKey=" + serviceKey +
                                "&Q0=" + encodedQ0 +
                                "&Q1=" + encodedQ1 +
                                "&QT=" + encodedQT;

            System.out.println("url check = " + requestURL);

            

            //수정본 
            URL url = new URL(requestURL);
            http = (HttpURLConnection)url.openConnection();
            System.out.println("http connection = " + http);
            int code = http.getResponseCode();
            System.out.println("http response code = " + code);
            if(code == 200) {
                stream = http.getInputStream();
                result = readString(stream);
                System.out.println("result = " + result);
                list = hosApiService.parseXml(result);
                
            }


        } catch (Exception e) {
            e.printStackTrace();
        } finally {

        }
        return new ResponseEntity<>(list, HttpStatus.OK);
       
    }
    
    

    public String readString(InputStream stream) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(stream,"UTF-8"));
        String input = null ;
        StringBuilder result = new StringBuilder();
        while((input = br.readLine()) != null) {
            result.append(input + "\n\r");
        }
        br.close();
        return result.toString();
    }
}

 

우웩우에구우게욱웩 

+ Recent posts