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. 구현 이유 

현재 사이드 프로젝트를 진행하면서 spring boot 백엔드를 맡고 있다. 

현재까지의 인프라는 aws rds로 디비 관리를 하고 있고, ec2 + git action 로 cicd를 구현해 git branch의 dev 브랜치에 개발 코드가 병합되면 자동 배포가 되도록 설정했다. 그리고 aws route 53으로 도메인까지 설정을 완료한 단계이다. 

 

거의 프로젝트의 기능 구현은 완성이 마무리 되고 있는 단계에서 https 설정하는 역할을 맡게 되었다. 

항상 이 부분은 다른 분들이 해봐서 이 설정을 하는 것에 꼼꼼히 알지는 못했다. 

그래서 처음할떄 꼼꼼히 알아보고 해보고 싶어 기록을 한다. 

 

 

2. HTTPS란 ? 

http(HyperText Transfer Protocol) ? 

: 웹에서 데이터를 주고 받기 위한 비보안 통신 프로토콜이다. 

- 클라이언트와 서버간 요청과 응답을 통해 html, json, 이미지 등 다양한 형식의 데이터를 주고받는다. 

- 기본적으로 80번 포트를 사용한다. 

- http는 데이터를 평문(암호화 전 상태)으로 전송하기 때문에 중간에 사용자의 데이터를 가로채거나 쉽게 노출될 가능성이 있다. ! 

- 그리고 우리가 좀 고도화된 서비스를 이용할때 http로만 된것을 사용하면 api 서비스가 제한될 가능성도 매우 높다 

예를 들어 위치정보 api, 카메라, 마이크 사용, 백그라운드 작업을 도와주는 서비스 워커, Oauth(외부 인증)같은 서비스도 http 에서는 제한이 된다. 무엇보다 브라우저에서 http 사이트에 대해 주의 요함, 안전하지 않음 메시지를 표시하기에 사용자 신뢰도가 하락되며 위조 사이트로 오해받을 수 있다는 점이다. 

 

그럼 언제 개발단계에서 https를 쓰는게 좋을까..?

- 초기 단계에서는 로컬에서 http로 작업해도 좋지만 외부 api, oauth, 결제, 미디어 접근 같은 고도화된 기능들을 사용할 떄는 개발환경부터 https 적용을 권장한다고 한다. 

-  나도 지금 파이어베이스 fcm(알림) 기능을 구현하면서 프론트 - 백 연동 과정에서 테스트를 할때 막힘없이 하기 위해 지금 하는 것도 있다.. ㅎ

 

https (HyperText Transfer Protocal Secure) ? (http Secure/Http over SSL)

: http에 보안계층(ssl/tls) 암호화를 추가한 보안 프로토콜이다.  

- 데이터를 주고 받기 전에 암호화된 통신 채널을 만들고 나서, 그 채널을 통해 데이터가 전달된다. 

- 사용자가 브라우저로 웹사이트에 접속할 때 데이터가 암호화되어 전송하기 때문에 중간에 도청이나 위번조를 방지할 수 있다. 

- 기본적으로 443 포트를 사용한다. 

- 주소창에 https:// 로 시작하게 되며 자물쇠 아이콘이 뜬다 ㅎ이로서 사용자가 안심하고 사용하며 신뢰성을 높일 수 있다. 

- seo 향상 : 구글도 https로 적용된 사이트를 더 우선적으로 보여준다. 

- 암호화과정으로 약간 느릴 수 있다. (비해 http가 약간 더 빠르다고 하는데 잘 모르겠다)

- 프론트 같은 경우는 api 호출 시 https가 아니면 브라우저가 막을 수 도 있다. 

 

https의 동작 원리 (ssl/tls 과정) 

1. 클라이언트가 서버에게 요청 : 사용자가 "https://example쏼라쏼라.com"에 접속 

2. 서버가 인증서(SSL 인증서)를 보낸다. : 서버는 자신이 신뢰할 수 있는 서버라는 걸 증명하기 위해 디지털 인증서를 보낸다. 

3. 클라이언트가 인증서 유효성 확인 : 브라우저는ㄴ 인증서의 유효성을 검증한다(발급기관, 유효기간, 도메인 일치 등)

4. 대칭키 생성을 위한 공개키 암호화 : 클라이언트는 임의의 대칭키(세션 키)를 생성하고 서버의 공개키로 그 대칭키를 암호화해서 서버로 전송한다. 

5. 서버는 비밀키로 복호화 : 서버는 자신의 비밀키로 그 암호화된 대칭키를 복호화하다. 이제 양쪽은 동일한 대칭키를 공유하게 된 것이다.

6. 데이터 암호화 통신 시작 : 이후부터 클라이언트와 서버는 공유된 대칭키로 데이터를 주고 받는다. 

 

 

 

3. Nginx ? 

가볍고 빠른 고성능 웹서버,,, 단순히 웹 서버 역할을 넘어서 리버스 프록시 , 로드 밸러스, 캐시 서버로 활용된다는 것,,,

 

웹 서버(web server) 

사용자의 요청(http)을 받아서 정적인 리소스(html, css, 이미지 등)를 직접 클라이언트에게 전달해주는 프로그램.

사용자가 https://example.com/index.html 을 요청하면 -> 웹 서버가 index.html 파일을 찾아서 사용자에게 직접 응답한다

대표적으로 아파치, nginx, 등등 

- 클라이언트에게 직접 응답 

- 정적파일들이 리소스 

- index.html 서빙

 

리버스 프록시 서버 (Reverse Proxy Server)

중간에 있는 서버로 사용자의 요청을 받아서 실제 백엔드서버로 전달하고 백엔드가 처리한 응답을 다시 사용자에게 중계하는 역할이라고 생각하면 된다. (클라이언트 요청을 받아 백엔드 서버에 전달 & 응답 중계)

사용자가 https://example.com/login 요청 -> nginx가 먼저 요청을 받고 -> 내부에 있는 스프링 서버(localhost:8080)으로 넘김 -> 스프링 서버가 로그인 처리 후 응답 -> nginx가 그 응답을 다시 사용자에게 전달 

- 클라이언트 요청을 대신 처리 

- 동적 처리 백엔드와 연결이 리소스

- /login 요청 -> spring 서버에 전달 : 앞단에서 대신 요청을 받아서 뒤에 숨어있는 백엔드 서버에 전달해주는 중간자 역할

 

로드 밸런서 : 여러 서버에 트래픽을 균등하게 분산 

https 처리 : ssl 인증서를 설치해서 https 종단점을 제공

캐시 서버 : 자주 요청 되는 리소스르 미리 저장해 빠르게 저장 

 

왜 Nginx가 필요할까? 

위에서 말한 두가지를 모두 할 수 있다

1. 웹 서버와 백엔드 분리 

스프링 서버는 8080 포트로 일반적으로 구성된다. 하지만 우리가 도메인을 연결하고 싶은 것은 80(http), 443(https)포트이다. 

여기서 nginx가 중간에서 요청을 받아 스프링 서버에 전달해준다. 

2. https를 쉽게 구성 가능 

nginx에 ssl 인증서 설정만 하면 바로 https지원이 가능하다 

3. 부하 분산 

여러 백엔드 서버가 있다면 요청을 나눠 줄 수도 있다. 

 

 

과정 

1. 도메인에 ssl 인증서를 연결 : https를 사용하려면 ssl/tls 인증서가 필요하다. 이 인증서를 통해 "내 도메인은 신뢰해"를 증명

  - 인증서는 보통 Let's Encrypt같은 무료 기관에서 발급받고, Nginx에 설정한다. 

2. 443 포트로 들어온 https 요청 : 클라이언트는 https:domain.com으로 접속하는데 이 요청은 443포트로 들어와 nginx가 리스닝중

3. nginx가 받아서 ssl 암호화 해제 (복호화) : 클라이언트는 인증서의 공개키로 암호화된 요청을 보낸다. niginx는 설정된 개인키를 이용해 복호화를 진행한다. (암호화된 Https ->평문http 전환)  "ssl termination", "offloading" 이라고 부른다고 한다. 

4. 내부 서버(spring)에 http로 전달 : 복호화된 평문 요청은 내부 백엔드 서버로 http 프로토콜로 전달된다. -> 이게 위에서의 리버시 프록시 역할이다 (여기서 nginx는 실제 백엔드 서버의 존재를 숨기고 대신 통신을 중계) -> 스프링 서버가 서비스 로직을 처리 후 응답을 다시 nginx에 전달

5. 응답도 다시 브라우저에 https 전달 : 응답을 받은 nginx는 클라이언트에게 https형식으로 암호화해서 전달한다. 이때도 인증서를 이용

 

브라우저 <-> Nginx : https(암호화된 통신 )

Nginx <-> Spring : http(내부 평문 통신)

Nginx : 암호해제 + 프록시 역할 

 

.

.

이렇게 되면 백엔드 서버는 Https 처리 안해도 되고, 설정이 간단해지면서 nginx로 ssl 인증서 관리, https 연결, 포트 오픈 등의 모든걸 전담한다. 

 

4. 설정 

1. ec2 서버에 nginx 설치 

- nginx(웹 서버 역할), ec2(내 서비스가 돌아가는 컴터), nginx설치 (이 서버가 https 요청을 처리할 수 있게 준비하는 과정

- ec2 서버가 웹 서버가 역할을 하기 위함

- ssl 인증서는 nginx 설치된 ec2 서버에 발급 및 설정하면 된다. 

sudo apt install nginx //설치
sudo systemctl status nginx // 설치 확인

 

 

2. ec2 인바운드 규칙 확인 80, 443, 22 포트 열어두기 

 

3. Nginx Proxy 설정 

설정 파일 열고 

sudo nano /etc/nginx/sites-available/default

 

 

 

도메인은 이미 route53에서 설정해줬던 도메인 넣어주면 된다. 

 

그리고 아래처럼 테스트 했을 때 ok 가 뜨면 끝

sudo nginx -t

 

 

4. Cerbot 설치 

sudo apt install certbot python3-certbot-nginx

 

참고로 여기서 보이는 python3은 프로젝트가 spring이랑은 아무 상관이 없다 ! 

나도 엥 왜 파이썬? 이랬는데 의미없음.

 

5. SSL 발급 

sudo certbot --nginx -d api.recipic.shop

 

하면 이메일 같은거 입력하고 약관 동의 yes 하면 끝난다!!

 

 

그리고 기존에 사용했던 스웨거에 https 들어가보면 정상적으로 접속이 된다 !

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 토큰이 유효한 것이 넘어온다는 것..? 인거 같다

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

 

 

1. ci/cd 파이프라인이란

CI/CD(Continuous Integration/Continuous Deployment) 파이프라인은 코드 변경 사항을 자동으로 빌드, 테스트, 배포하는 과정을 의미한다.

1.1 CI(Continuous Integration, 지속적 통합)

  • 개발자가 코드를 변경하면 이를 자동으로 빌드하고 테스트하는 과정을 의미하며, 작은 코드 변경을 자주 통합하여 코드 충돌을 줄이고, 버그를 조기에 발견하는 것이 목표다.
  • CI에서는 코드가 push될 때 테스트를 실행하여 코드의 안정성을 검증한다.

1.2 CD(Continuous Deployment 또는 Continuous Delivery, 지속적 배포)

  • Continuous Deployment (지속적 배포) : 코드 변경 사항이 자동으로 테스트를 거친 후, 자동으로 운영 서버(Production)에 배포되는 과정, 코드 변경이 검증되면 별도의 수동 승인 없이 배포된다.
  • Continuous Delivery (지속적 제공) : 코드 변경 사항이 자동으로 준비되지만, 최종 배포는 사람이 승인하는 방식입니다.

CI/CD는 개발자가 코드를 변경하면 자동으로 테스트하고 배포하여, 더 빠르고 안정적인 배포 환경을 구축하는 것이 목표이다. 

 

지금 나는 실제 배포 버전이 아닌 개발버전에서의 cicd를 구축하는 과정에 있다. 개발 과정에서는 자주 변경되는 코드들을 쉽게 홥치고 이를 바로 프론트엔드와 공유를 용이하게 하기 위해 개발용cicd를 구축하고 있다. 

 

 

2. ci/cd 파이프라인의 구성 요소 

2.1 workflow(워크플로우)

ci/cd의 전체적인 작업 흐름을 정의하는 자동화된 프로세스이다. 

github에 올려놓은 레포지토리에 .github/flows/ 폴더 아래에 yml 파일을 작성해 작업흐름을 만들면 된다.

워크플로우는 특정 이벤트가 발생했을 때 실행된다

 

2.2 event (이벤트)

워크플로우가 실행하는 트리거(trigger, 실행조건) 이다. 

예를 들어 우리는 ci/cd 환경을 구축하는 것이기에 코드 변경사항을 푸시해서 올렸다고 가정해보자. 그럼 새로운 변경사항으로 이벤트가 발생한 것이다. 이처럼 워크플로우가 실행되는 '이벤트' 그 자체라고 생각하면 될 듯 하다. 

 

대표적인 이벤트는 아래 같은 걸들..?

  • push → 코드가 특정 브랜치로 푸시되었을 때 실행
  • pull_request → PR이 열리거나 병합될 때 실행
  • schedule → 정해진 시간마다 실행 (예: 매일 자정에 실행)
  • workflow_dispatch → 수동으로 실행
  • release → 새로운 릴리즈가 생성될 때 실행

 

2.3 job (잡)

; 한 개의 워크플로우는 여러개의 잡(job, 작업단위)로 구성될 수 있다. 잡은 순차적(병렬)으로 처리된다. 

워크플로우에서 이벤트에 따라 처리하는 작업들을 정의하는 것으로 job끼리는 의존관계도 가질 수 있다(선행으로 어떤거 할지 .. 이런거)

 

2.4 step (스텝)

: 잡 내에서 실행되는 개발 작업으로 이 step은 shell 에서 동작하는 cli와 동일하게 실행된다. 정의한 step 순서대로 실행되며 step 데이터를 공유할 수도 있다. 

 

2.5 runner(러너) , Artifact(아티팩트)

러너는 github actions에서 워크플로우를 실행하는 서버로 github에서 제공해주는 클라우드 러너를 사용하거나 ec2같은 셀프호스티드 러너도 설정할 수 있다(참고로 내가 쓴 ec2는 러너 방식이 아니다! 프로젝트 정책에 따라 알아서 잘 하면 될듯) 

아티팩트는 cicd 과정에서 생성된 빌드 파일, 로그 등을 저장하는 기능이다. 

 

 

3. ci/cd 흐름

3.1 흐름 

1. 개발자가 github에 코드를 push  : dev 브랜치에 코드가 푸시 

2. github action이 실행 : workflows폴더 안에 만든 yml 파일에 정의된 작업이 1번의 푸시 작업으로 인한 이벤트를 감지하고 자동으로 실행된다. 

3.github actions가 ec2에 ssh로 접속하여 배포한다. : ec2 설정할때 가져온 ssh 비밀키를 github action에 환경변수로 넣어줌으로서 접속이 가능해진다. 

4. ec2 서버에서 애플리케이션 실행 (즉 ec2는 github actions에서 배포된 최신 애플리케이션이 실행되는 서버라고 생각하면 된다) 

 

 

 

3.2 ec2 생성, ssh로 접속할 것 

ssh로 안하면 중간에 빌드 오류가 났었던거 같다

1편에서 aws ec2를 일단 구축했다

https://dev-leeyjstar.tistory.com/entry/ec2-git-action-%EA%B0%9C%EB%B0%9C-%EB%B2%84%EC%A0%84-cicd-%ED%94%84%EB%A6%AC%ED%8B%B0%EC%96%B4-ec2-%EC%83%9D%EC%84%B1-1

 

ec2 + git action 개발 버전 ci/cd - 프리티어 ec2 생성 (1)

왜 하게 됐나면요..현재 사이드 프로젝트를 진행 중이며 자바 백엔드 개발을 하는 중이다. 프론트와 협업을 하는 과정에서 백엔드 개발 서버 공유해주는 과정에서 문제가 생겼다. 프론트도 테

dev-leeyjstar.tistory.com

https://dev-leeyjstar.tistory.com/entry/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-ec2-ssh-%EC%A0%91%EC%86%8D-%EC%98%A4%EB%A5%98-Permission-denied-publickey

 

[트러블 슈팅] ec2 ssh 접속 오류 Permission denied (publickey).

문제 상황 ec2를 생성하고 키 페어 .pem 인 공개키를 받게 되는데 이거를 잘 저장하고 사용하다가 접속 에러가 났다 가능한 원인들EC2 인스턴스의 퍼블릭 키가 변경되었거나, 올바른 키가 등록되

dev-leeyjstar.tistory.com

 

 

4. github 설정 

4.1 env 환경변수 설정

프로젝트를 진행하면 레포에 올라가지 않는 .env 파일안에 환경변수들을 넣어줘야한다. 

setting - secrets and variableds 탭 - actions 항목에 본인의 env 환경변수들을 넣어준다. 

참고로 레포는 본인 레포여야만 settings를 할 수있다. 협력자나 그런건 환경변수를 적용할 수 없다 

 

4.2 ssh 키 생성 

github actions가 ec2에 접속할 수 있도록 새로운 ssh 키를 생성하고 github에 등록을 해야한다!!

 

일단 ec2 서버에 ssh 로 접속해서 들어온다. 

ssh-keygen -t rsa -b 4096 -f ~/.ssh/github_deploy -N ""
  • -t rsa: RSA 알고리즘 사용
  • -b 4096: 4096비트 길이의 키 생성
  • -f ~/.ssh/github_deploy: 키 파일을 ~/.ssh/github_deploy 로 저장
  • -N "": 패스프레이즈 없이 생성

이제 2개의 파일이 생성됨:

  • ~/.ssh/github_deploy (🔒 개인 키)
  • ~/.ssh/github_deploy.pub (🔑 공개 키)

공개키를 ec2의 authorized_keys에 추가한다(github actions의 접속을 허용해주는 것)

cat ~/.ssh/github_deploy.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

 

  • ~/.ssh/github_deploy.pub 내용을 ~/.ssh/authorized_keys에 추가 → EC2에서 SSH 접근을 허용
  • chmod 600 ~/.ssh/authorized_keys → 보안 강화를 위해 권한 설정

ec2에서 생성한 개인키(github_deploy)를 github에 등록해야한다. 그럼 이 키를 github actions가 ec2에 ssh로 접속할 때 사용하게 되는 것이다. 

ubuntu@ip-000-00-00-00:~cat ~/.ssh/github_deploy
-----BEGIN OPENSSH PRIVATE KEY-----
.
.
.
.
-----END OPENSSH PRIVATE KEY-----

b

 

 

저 명령어를 하면 아래와 같이 begin으로 시작해서 end로 끝나는 키가 출력되는데 이 키를 github의 환경변수로 넣어주면 된다.

 

엄청 중요한거....! 저거 그대로 다 복사해야한다...! ---- Begin 부터 End의 ---- 이거까지 다 복사해서 넣어야 된다는 점을 매우 중요\

난 이걸로 엄청 삽질했다 아무리 해도 안되길래 ㅠㅠ

 

아무튼 env 환경변수들을 넣어준 곳에 EC2_SSH_KEY 라는 이름으로 저 키를 넣어주면 끝난다. 

 

 

5. workflow yml 파일 생성 

레포 - actions 탭에서 - 파란색 "set up a workflow yourself " 누르면 yml 파일 생성할 수 있는데 여기서 워크플로우를 만들어주면 된다. 

 

 

name: Deploy to EC2                                       # 워크플로우 이름: "Deploy to EC2"

on:
  push:
    branches:
      - dev                                             # 'dev' 브랜치에 push될 때 실행

jobs:
  deploy:                                               # 'deploy' 작업 정의 (EC2 배포 작업)
    runs-on: ubuntu-latest                              # 최신 Ubuntu 환경에서 실행

    env:                                                # 환경변수 설정 (GitHub Secrets에서 값을 가져옴)
      JWT_ACCESS_EXPIRATION: ${{ secrets.JWT_ACCESS_EXPIRATION }}  # 액세스 토큰 만료 시간
      JWT_REFRESH_EXPIRATION: ${{ secrets.JWT_REFRESH_EXPIRATION }}  # 리프레시 토큰 만료 시간
      JWT_SECRET: ${{ secrets.JWT_SECRET }}                          # JWT 암호화에 사용되는 비밀키
      MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}                    # 메일 서비스 사용자 이름
      MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}                    # 메일 서비스 비밀번호
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}                  # OpenAI API 키
      LOCAL_DB_URL: ${{ secrets.LOCAL_DB_URL }}                      # 로컬 데이터베이스 URL
      LOCAL_DB_USERNAME: ${{ secrets.LOCAL_DB_USERNAME }}            # 로컬 데이터베이스 사용자 이름
      LOCAL_DB_PASSWORD: ${{ secrets.LOCAL_DB_PASSWORD }}            # 로컬 데이터베이스 비밀번호
      GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}                # 구글 OAuth 클라이언트 ID
      GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}        # 구글 OAuth 클라이언트 비밀키
      KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }}                  # 카카오 OAuth 클라이언트 ID
      KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}          # 카카오 OAuth 클라이언트 비밀키
      NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}                  # 네이버 OAuth 클라이언트 ID
      NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }}          # 네이버 OAuth 클라이언트 비밀키

    steps:
      - name: 📥 Checkout source code                  # 소스 코드 체크아웃 단계
        uses: actions/checkout@v3                       # GitHub 제공 checkout 액션 사용

      - name: 🛠 Setup JDK 17                           # JDK 17 설정 단계
        uses: actions/setup-java@v3                      # GitHub 제공 setup-java 액션 사용
        with:
          distribution: 'temurin'                      # Temurin 배포판 선택
          java-version: '17'                           # Java 버전 17 사용

      - name: 📦 Build with Gradle (Skip Tests)         # Gradle 빌드 단계 (테스트는 건너뜀)
        run: |
          chmod +x gradlew                             # gradlew에 실행 권한 부여
          ./gradlew clean build -x test                # 테스트 건너뛰고 클린 빌드 실행
          echo "🔍 Checking build/libs/ directory..."   # 빌드 결과물 디렉토리 확인 메시지 출력
          ls -lh build/libs/ || exit 1                  # build/libs/ 디렉토리 내용 확인, 없으면 종료

          # 'plain'이 포함되지 않은 첫 번째 JAR 파일을 선택하여 변수에 저장
          JAR_FILE=$(ls build/libs/*.jar | grep -v 'plain' | head -n 1)
          if [[ -z "$JAR_FILE" ]]; then                # JAR 파일이 없으면 에러 메시지 출력 후 종료
            echo "🚨 No valid JAR file found in build/libs/. Build failed!"
            exit 1
          fi

          # JAR 파일 크기 확인 (10MB 이상이어야 함)
          FILE_SIZE=$(stat --format=%s "$JAR_FILE" 2>/dev/null || stat -f%z "$JAR_FILE")
          if [[ $FILE_SIZE -lt 10485760 ]]; then        # 파일 크기가 10MB 미만이면 빌드 실패 처리
            echo "🚨 JAR file size is too small ($FILE_SIZE bytes). Build failed!"
            exit 1
          fi

          echo "✅ JAR file found: $JAR_FILE ($FILE_SIZE bytes)"  # 유효한 JAR 파일 확인 메시지 출력

      - name: ✅ Run Tests (Optional, Allow Failure)   # 테스트 실행 단계 (실패해도 계속 진행)
        continue-on-error: true                         # 테스트 실패 시에도 다음 단계로 진행
        run: |
          ./gradlew test                               # 프로젝트 테스트 실행

      - name: 🏗 Setup SSH Key                          # SSH 키 설정 단계
        run: |
          echo "${{ secrets.EC2_SSH_KEY }}" > private_key.pem  # GitHub Secrets에서 EC2 SSH 키를 파일로 저장
          chmod 400 private_key.pem                      # SSH 키 파일의 권한을 읽기 전용(400)으로 설정

      - name: 🔍 Test SSH Connection                    # SSH 연결 테스트 단계
        run: |
          ssh -o StrictHostKeyChecking=no -i private_key.pem ubuntu@"본인 ip" "echo '✅ SSH connection successful!'"  # EC2 서버에 SSH 연결 후 성공 메시지 출력

      - name: 📂 Transfer JAR file to EC2               # 빌드된 JAR 파일을 EC2로 전송하는 단계
        run: |
          JAR_FILE=$(ls build/libs/*.jar | grep -v 'plain' | head -n 1)  # 유효한 JAR 파일을 다시 선택하여 변수에 저장
          scp -i private_key.pem "$JAR_FILE" ubuntu@"본인 ip":~/app/spring-boot-app.jar  # SCP를 사용하여 JAR 파일 전송

      - name: 🚀 Deploy to EC2                          # EC2에 배포 및 실행 단계
        run: |
          ssh -o StrictHostKeyChecking=no -i private_key.pem ubuntu@"본인 ip" <<EOF
            echo "📂 Checking and creating app directory..."  # 애플리케이션 디렉토리 확인 및 생성 메시지 출력
            mkdir -p ~/app                                     # ~/app 디렉토리 생성 (이미 있으면 무시)
            cd ~/app                                           # 작업 디렉토리를 ~/app으로 변경

            echo "📝 Creating .env file on EC2..."             # EC2에서 .env 파일 생성 시작 메시지 출력
            echo "JWT_ACCESS_EXPIRATION=$JWT_ACCESS_EXPIRATION" > .env  # .env 파일에 각 환경변수 저장 (덮어쓰기)
            echo "JWT_REFRESH_EXPIRATION=$JWT_REFRESH_EXPIRATION" >> .env
            echo "JWT_SECRET=$JWT_SECRET" >> .env
            echo "MAIL_USERNAME=$MAIL_USERNAME" >> .env
            echo "MAIL_PASSWORD=$MAIL_PASSWORD" >> .env
            echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env

            echo "LOCAL_DB_URL=$LOCAL_DB_URL" >> .env
            echo "LOCAL_DB_USERNAME=$LOCAL_DB_USERNAME" >> .env
            echo "LOCAL_DB_PASSWORD=$LOCAL_DB_PASSWORD" >> .env

            echo "GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID" >> .env
            echo "GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET" >> .env
            echo "KAKAO_CLIENT_ID=$KAKAO_CLIENT_ID" >> .env
            echo "KAKAO_CLIENT_SECRET=$KAKAO_CLIENT_SECRET" >> .env
            echo "NAVER_CLIENT_ID=$NAVER_CLIENT_ID" >> .env
            echo "NAVER_CLIENT_SECRET=$NAVER_CLIENT_SECRET" >> .env

            echo "🔍 Checking .env file on EC2..."           # 생성된 .env 파일 내용 확인 메시지 출력
            cat .env                                          # .env 파일 내용 출력

            echo "🔄 Exporting environment variables from .env"  # .env 파일의 환경변수 내보내기 시작
            set -o allexport                                  # 모든 변수를 자동으로 환경변수로 내보내도록 설정
            source .env                                       # .env 파일의 변수들을 현재 셸에 적용
            set +o allexport                                  # 자동 내보내기 옵션 해제

            echo "🔍 Checking if a Java application is running..."  # 기존 실행 중인 애플리케이션 확인 메시지 출력
            RUNNING_PID=\$(pgrep -f 'spring-boot-app.jar' || true)  # 실행 중인 spring-boot-app.jar 프로세스 PID 검색
            if [[ ! -z "\$RUNNING_PID" ]]; then               # 기존 애플리케이션이 실행 중이면
              echo "🚀 Stopping previous application..."      # 기존 애플리케이션 종료 메시지 출력
              kill -9 "\$RUNNING_PID"                         # 강제 종료
            fi

            echo "📦 Deploying new application..."           # 새 애플리케이션 배포 시작 메시지 출력
            nohup java -jar spring-boot-app.jar > log.txt 2>&1 &  # 백그라운드에서 새 애플리케이션 실행 (로그는 log.txt에 저장)

            echo "✅ Deployment complete. Checking application logs..."  # 배포 완료 후 로그 확인 메시지 출력
            sleep 5                                          # 애플리케이션 실행 대기 (5초)
            tail -n 20 log.txt                               # log.txt 파일의 마지막 20줄 출력하여 상태 확인
          EOF                                               # 원격 SSH 세션 종료 표시

 

나 같은 경우는 설정을 최대한 간단하게 하고 싶어서 ci, cd를 하나의 파이프라인으로 실행을 했다. 

빌드와 배포까지 바로 이루어지기에 개발자 개입없이 자동으로 최신 버전이 배포가 된다. 운영서버까지 바로 할수 있다. 

하지만 배포 안정성이 낮아질 수 있고 테스트가 실패되면 배포가 중단 될 가능성이 있다. 그래서 ci, cd를 분리하는 게 안정성을 따지면 좋을 거 같다 하지만 난 실제 서비스 운영이 아니고 일단 개발용이고 하니 최대한 간단하고 빠르게 하고 싶어 통합해서 사용했다 

 

 

 

yml 파일에 만든 워크 플로우로 잡안에 있는 스텝들이 순차적으로 잘 실행되어 올바르게 실행된 것을 볼 수 있다. 

초록색으로 잘 뜨면 ec2 서버에서도 확인해보자 

 

 

.jar 프로세스가 정상 실행중이라고 한다 ㅎㅎ 

왜 하게 됐나면요..

현재 사이드 프로젝트를 진행 중이며 자바 백엔드 개발을 하는 중이다. 

프론트와 협업을 하는 과정에서 백엔드 개발 서버 공유해주는 과정에서 문제가 생겼다. 

프론트도 테스트를 하기 위해서 누군가는 서버를 계속 열어줘야 하거나, 못열어줄 경우 프론트가 직접 백엔드 코드를 받아서 열거나, 

jar를 빌드해서 공유를 해주면서 너어어어어어엉무 불편했다. 

 

그래서 개발 버전 자동화 배포를 해야겠다고 생각이 들었다 

 

 

일단 ec2를 만들어 보자...

aws 계정을 만들어야 하고 꼭 루트 계정이 아닌 IAM 사용자를 이용하자 

돈없는 100수이기 때문에 프리티어를 사용할 것이다

 

1. aws에 들어가서 ec2를 검색 -> 인스턴스 시작 

 

- resion은 분사되어 운영되는 위치를 말하며 서울로 지정했다

- 한국어 설정으로 바꾸고 하면 편하다 영어 못해성 ㅎㅎ

- 이름은 편하게 설정하고

- AMI: Amazon Machine Image)는 인스턴스를 시작하는데 필요한 운영체제로 우분투로 하고 프리티어 가능한 우분투 서버로 설정

 

 

 

 

 

 

 

 

2. 키 페어 설정

 

 

- 인스턴스 유형은 프리티어 사용한 버전으로 설정했다

- 키페어는 ec2같은 리소스들을 안전하게 접근하기 위한 공개키가 암호화된 것이라고 한다. 이 키는 유출하지 않는 것이 기존에 있다면 생성하지 않아도 되지만 없으면 생성하면 된다. 

- 키 페어 이름은 자유롭게, 유형은 rsa, 키 파일 형식은 .pem으로 한다

- rsa가 보안성이 좋고, pem이 여러 운영체제에 호환이 좋다고 한다

- 키가 다운되면 개인적인 곳에 잘 저장하면 된다

 

3. 네트워크와 스토리지 설정

- 프리티어 계정은 월별 30gb 스토리지 양을 제공하는데 여러 ebs 스토리지에 걸쳐 분배할 수 있다고 한다. 

- 하나만 사용할 것이라 30으로 해주었다. 기본값 8GB (Amazon Linux, Ubuntu) 유지해도 괜찮을 듯 하당

 

그런 다음 인스턴스를 생성해주면 끝난당

 

4. 보안그룹 설정 

보안그룹은 aws에서 제공하는 가상 방화벅이다. aws 리소스(ec2)에 대한 인바운드 - 들어오는 트래픽, 아웃바운드 - 나가는 트래픽을 

정하는데 사용된다. 어떤 ip에서 어떤 포트로 들어오는지, 어떤 트래픽을 내보내는지 정할 수 있다. 

 

 

 

 

인스턴스 생성 후 보안그룹 탭에서 보안그룹 생성하기를 누르면 볼 수 있다. 

기본적으로 허용해야 할 인바운드(Inbound) 규칙

SSH 22 내 IP (Your IP) GitHub Actions에서 SSH로 배포하려면 필요
HTTP 80 0.0.0.0/0 웹 서버 접근 허용 (백엔드가 HTTP 사용 시)
HTTPS 443 0.0.0.0/0 SSL 사용 시 필수
API 서버 포트 8080, 3000 등 0.0.0.0/0 백엔드 애플리케이션 포트 (Express, Spring Boot 등)
PostgreSQL/MySQL 5432/3306 (내부 네트워크 또는 특정 IP) DB 접근이 필요하면 설정

GitHub Actions에서 SSH 배포를 할 경우, 보안성을 강화하려면 EC2에 SSH 접근을 GitHub Actions 전용으로 제한하는 게 좋다고 한다. 일단은 내 ip로만 해놓고 나중에 문제가 생기면 수정할 생각이다. 

 

아웃바운드(Outbound) 규칙

기본적으로 EC2의 아웃바운드 트래픽은 모두 허용되어 있지만 , 했다. 

HTTP/HTTPS 80, 443 GitHub에서 코드 다운로드, 패키지 설치 (예: npm install, apt-get update)
SSH 22 필요하면 아웃바운드로 SSH 트래픽 허용

 

 

 

 

이렇게 보안그룹 설정이 끝나면 다시 인스턴스로 들어와 내가 만든 인스턴스의 보안그룹 탭에서 내 보안그룹을 설정해주면 끝난다.

 

 

3. 연결 테스트를 해보자

- 내 인스턴스에 연결 탭을 누르면 위와 같이 보이는데 예에서 보이는 저 링크를 복사한다. 

 

 

1.  일단 키가 저장되어 있는 폴더로 이동

(base)   ~ cd ~/Desktop/이연주

 

2. 경로에 키가 있는지 확인 

(base)   이연주 ls -l lyjkey.pem

-rw-r--r--@ 1 leeyeonju  staff  1678  3 10 17:13 lyjkey.pem

 

3. 키 파일의 권한을 조정

AWS에서는 보안상 SSH 키 파일이 다른 사용자나 그룹에서 접근할 수 없도록 설정해야 한다.


(안 그러면 SSH에서 "Permissions are too open" 오류 발생)

(base)   이연주 chmod 400 lyjkey.pem

(base)   이연주 ls -l lyjkey.pem

-r--------@ 1 leeyeonju  staff  1678  3 10 17:13 lyjkey.pem

 

 

4. 실행 (아까 복사한 걸 실행)

(base)   이연주 ssh -i lyjkey.pem ubuntu@----------------.compute.amazonaws.com

 

 

5. 정상 접속이 되면 한번 업데이트 해주세요

ubuntu@ip-뭐시기뭐시기 :~$ sudo apt update && sudo apt upgrade -y

 

6. 서버를 재 부팅 하는게 좋겠어요

ubuntu@ip-뭐시기뭐시기:~$ sudo reboot

 

7. 4번 처럼 다시 서버에 재 접속 한 후 

ubuntu@ip-뭐시기뭐시기:~$ uname -r

6.8.0-1024-aws 이렇게 출력결과 나오면 성공 ! 

 

문제 상황 

ec2를 생성하고 키 페어 .pem 인 공개키를 받게 되는데 이거를 잘 저장하고 사용하다가 접속 에러가 났다

 

가능한 원인들

  1. EC2 인스턴스의 퍼블릭 키가 변경되었거나, 올바른 키가 등록되지 않음
  2. EC2 인스턴스의 보안 그룹에서 SSH(22번 포트)가 허용되지 않음 -> 보안 그룹 설정은 잘 됌
  3. EC2에 연결된 IAM 역할이 SSH 접속을 막고 있음
  4. EC2 인스턴스가 키 페어 없이 생성되었거나, 올바른 키가 등록되지 않음 -> 키페어는 잘 등록이 되어있음 
  5. EC2 인스턴스를 재부팅하면서 새 키 페어가 필요해졌을 가능성

아무튼,, 여러 원인들이 있을거 같은데 자꾸 안되길래 새로운 키를 발급해서 적용해보려고 한다. 

 

 

 

1.  새 ssh key 생성 (로컬 환경에서 실행한다. )

ssh-keygen -t rsa -b 4096 -f ~/.ssh/newlyj-ec2key.pem

 

  • -t rsa → RSA 알고리즘 사용
  • -b 4096 → 4096비트 키 길이 설정 (보안 강화)
  • -f ~/.ssh/new-ec2-key.pem → 새 키 파일을 ~/.ssh/ 폴더에 저장
  • 이때 비번 입력창이 나오는데 엔터치면 비번없이 만들어진다

2. 키 권한 설정

(base) ➜  ~ chmod 400 ~/.ssh/newlyj-ec2key.pem

 

 

3. 키 생성 확인

(base) ➜  ~ ls -l ~/.ssh/newlyj-ec2key.pem ~/.ssh/newlyj-ec2key.pem.pub

-r--------  1 leeyeonju  staff  3414  3 12 15:59 /Users/leeyeonju/.ssh/newlyj-ec2key.pem
-rw-r--r--  1 leeyeonju  staff   762  3 12 15:59 /Users/leeyeonju/.ssh/newlyj-ec2key.pem.pub
  • -r-------- (400 권한) → 개인키 (.pem)는 읽기만 가능해야 함
  • -rw-r--r-- (644 권한) → 공개키 (.pub)는 읽기 가능

4. 키 무결성 확인 (rsa 인지 확인)

(base) ➜  ~ ssh-keygen -lf ~/.ssh/newlyj-ec2key.pem

4096 SHA256:vH9XC...............MacBookPro.local (RSA)
  • 4096 → 4096비트로 생성됨 (정상)
  • SHA256:.... → 키 지문 (Fingerprint)
  • (RSA) → RSA 타입인지 확인

5. 등록할 공개키 확인 

(base) ➜  ~ cat ~/.ssh/newlyj-ec2key.pem.pub

ssh-rsa AAAAB3.................@iyeonjuui-MacBookPro.local

 

  • ssh-rsa → RSA 키
  • AAAAB3... → 키 내용
  • leeyeonju@iyeonjuui-MacBookPro.local → 키를 생성한 사용자

 

그럼 공개키 생성이 끝났다. ec2에 등록해보자 

 

ec2에 기존에 사용하던 인스턴스에 들어감 -> 연결 -> ec2 인스턴스 연결 탭 에서 연결로 들어가면 ec2에 접속이 된다. 

지금 나는 인스턴스는 문제가 없고 ssh 연결만 안되는거기 때문에 잘 접속이 됐다.

 

6. ec2 인스턴스에 접속해 authorized_keys 파일에 공개 키 추가

mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "ssh-rsa AAAAB3... (생략) ... leeyeonju@iyeonjuui-MacBookPro.local" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

 위에서 조회한 공개키를 붙여넣어주면 된다. 

 

7. 등록확인 

ubuntu@ip-내ip :~$ ls -ld ~/.ssh
drwx------ 2 ubuntu ubuntu 4096 Mar 11 10:27 /home/ubuntu/.ssh
ubuntu@ip-내ip :~$ ls -l ~/.ssh/authorized_keys
-rw------- 1 ubuntu ubuntu 1510 Mar 12 07:07 /home/ubuntu/.ssh/authorized_keys

 

drwx------ (700 권한)이면 정상 

-rw------- (600 권한)이면 정상

 

8. authorized_keys 파일에 키가 정상적으로 저장되었는지 확인 

ubuntu@ip-172-31-40-60:~$ cat ~/.ssh/authorized_keys
ssh-rsa  .....................== leeyeonju@iyeonjuui-MacBookPro.local

 

 

9. ssh 서비스 확인 

sudo systemctl restart sshd  # SSH 서비스 재시작
sudo systemctl status sshd   # SSH 상태 확인

 

ubuntu@ip-----:~$ sudo systemctl restart sshd
Failed to restart sshd.service: Unit sshd.service not found.
ubuntu@ip-----:~$ sudo systemctl list-units --type=service | grep ssh
  ssh.service                                    loaded active running OpenBSD Secure Shell server

현재 ssh.service는 실행 중(active running)이지만, sshd.service가 아닌 ssh.service로 동작하고 있다고 한다. 

 

ubuntu@ip---------:~$ sudo systemctl restart ssh
Warning: The unit file, source configuration file or drop-ins of ssh.service changed on disk. Run 'systemctl daemon-reload' to reload units.
ubuntu@ip---------:~$ sudo systemctl status ssh
Warning: The unit file, source configuration file or drop-ins of ssh.service changed on disk. Run 'systemctl daemon-reload' to reload units.
● ssh.service - OpenBSD Secure Shell server
     Loaded: loaded (/usr/lib/systemd/system/ssh.service; disabled; preset: enabled)
    Drop-In: /usr/lib/systemd/system/ssh.service.d
             └─ec2-instance-connect.conf
     Active: active (running) since Wed 2025-03-12 07:12:32 UTC; 18s ago
TriggeredBy: ● ssh.socket
       Docs: man:sshd(8)
             man:sshd_config(5)
    Process: 20094 ExecStartPre=/usr/sbin/sshd -t (code=exited, status=0/SUCCESS)
   Main PID: 20096 (sshd)
      Tasks: 1 (limit: 1129)
     Memory: 1.3M (peak: 1.5M)
        CPU: 15ms
     CGroup: /system.slice/ssh.service
             └─20096 "sshd: /usr/sbin/sshd -D -o AuthorizedKeysCommand /usr/share/ec2-instance-connect/eic_run_authorized_keys %u %f -o AuthorizedKeysCommandUser ec2-instance-connect [list>

ssh 서비스 재 시작 후 상태 확인 

현재 ssh.service가 Active: active (running) 상태이며, 포트 22에서 정상적으로 리스닝하고 있다고 한다. 

 

 

 

10. 다시 로컬로 와서 ec2 접속을 위한 키 페어가 생성됐는지 확인 

ase) ➜  ~ ls -l ~/.ssh/*.pem

-r--------  1 leeyeonju  staff  3414  3 12 15:59 /Users/leeyeonju/.ssh/newlyj-ec2key.pem

 

11. 2. EC2의 퍼블릭 IP 확인

1️⃣ AWS 콘솔EC2 대시보드인스턴스 선택
2️⃣ "Public IPv4 address" (퍼블릭 IP 주소) 확인

 

(base) ➜  ~ ssh -i ~/.ssh/newlyj-ec2key.pem ubuntu@public -ip 주소

 

 

 

 

끝!

아....

얼마전에 h2 세팅만 해놓고 다시 h2 접속하려니까 오류가 난다...

 

개발은 항상 개발과정보다 세팅 과정이 은근 더 어려운거 같댜ㅑ.......

 

1. 첫번 째 오류 

(Database "/Users/*/* not found, either pre-create it or allow remote database creation (not recommended in secure environments) [90149-200] 90149/90149 (도움말)

 

처음에 h2를 설치했을때 웹에서 따로 찾아서 설치를 했었다. 

근데 이게 맥북의 버전 이슈? 로 권한 문제란다...shit..

 

그래서 이거는 다시 homebrew를 통해 설치했다. 

 

1. homebrew 에서 설치 

brew install h2

 

2. h2 서버 실행 

h2 -web

 

이렇게 서버를 실행하면

 http://172.30.1.21:8082 이런 url이 나오는데 이걸로 접속하면 h2 서버가 정상적으로 열렸다...

 

2. 두번 째 오류

Connection is broken: "java.net.ConnectException: Connection refused: localhost" [90067-232]

 

아.. 이거는 알아보니

첫번 째 문제를 해결하기 위해 처음 웹사이트에서 다운 후 brew를 통해 다시 설치 한 다음 

서버 실행할때 h2 -web 이렇게 실행해서 문제가 생긴거라고 한다. 

 

근데 좀 헷갈리는게 brew로 설치하고 바로 직후에 h2 -web 이렇게 서버를 실행했을 때는 문제가 생기지 않았는데 

tcp를 통해 접속하기 위해? 서는 다른 명령어를 사용해야 하는듯 싶다

 

h2 -tcp -web -pg

 

H2 서버 실행 명어 설명

1. h2 -web

 

  •  명령어는 H2 데이터베이스  콘솔을 실행한다. 웹 콘솔은 브라우저를 통해 데이터베이스에 접근할 수 있는 인터페이스를 제공한다. 그러나 이 명령어만으로는 TCP/IP 연결을 수신하지 않는다는 문제가 있다.

2. h2 -tcp 

  • 이 옵션은 H2 서버 TCP 드로 실행!  TCP 모드에서는 외부 클라이가 H2 데이터베이스에 연결할 수 있도록 TCP/IP 소켓을 통해 연결을 신한다. 이 모드가 활성화되어야 다른 플리이션이나 클라이트가 H2 데이터베이스에 연결할 수 있다 !
  • -pg : 이 옵션 PostgreSQL 호환 모드로 H2를 실행한다. 이 모드를 사용하면 H2 데이터베이스가 PostgreSQL의 SQL 문법과 기능을 지원하게 되며 이는 PostgreSQL과 성을 위해 유용하다

이렇듯 명령어 사용에 유의하자..! 알고 쓰자!!

 

오또케 ㅠ 나의 첫 이미지 ... 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