[Web] 최종프로젝트(7) spring boot + S3 + pre-signed URL 적용

hyewon jeong·2023년 3월 2일
1

web

목록 보기
9/24
post-thumbnail

엔티티 설계

📌 1. S3 vs pre-signed URL

1. 어떠한 이유로 해당 기능을 사용하였는지

S3 (Simple Storage Service) 개념

  • AWS S3는 업계 최고의 확장성과 데이터 가용성 및 보안과 성능을 제공하는 온라인 오브젝트(객체) 스토리지 서비스이다. (참고로 S 앞글자가 3개라서 S3 이라고 한다.)

쉽게 말하자면, 스토리지 즉 구글 드라이브 처럼 파일 저장 서비스이며, 데이터를 온라인으로 오브젝트 형태로 저장하는 서비스라고 보면 된다.

  • Simple Storage Service
    앞에 온라인이라는 글자가 붙는 이유는 데이터 조작에 HTTP/HTTPS를 통한 API가 사용되기 때문이다.

  • 또한 편리한 UI 인터페이스를 통해 어디서나 쉽게 데이터를 저장하고 불러올 수 있어 개발자가 쉽게 웹 규모 컴퓨팅 작업을 수행할 수 있도록 한다.

  • S3는 저장하는 데이터 양에 대한 비용도 저렴하고, 저장할 수 있는 데이터 양이 무한에 가깝다.

S3 flow

user -> server -> S3 이미지 업로드

📌 pre-signed URl 사용이유? 보안 !!!!

pre-signed URL flow

user -> client(pre-signed URL 발급 요청 ) -> server(S3에 pre-signed URL 발급요청)->client(pre-signed URL 권한을 이용 하여 S3에 이미지 파일 업로드)-> server(파일이 아닌 url로 글 등록)

사용이유 1) Presigned url을 사용하면 서버에서는 Presign url만 클라이언트에게 던져주고, 실제 파일 업로드는 클라이언트가 함

이미지 업로드는 매우 부하가 큰 작업으로 JSON을 주고 받는 일반 API요청에 비하면 훨씬 부하가 큽니다. 이미지 파일 용량 자체가 매우 크니깐요. 따라서 이미지 업로드가 백엔드 서버를 거치게 되면 백엔드 서버가 금방 죽을 수 있다.

사용이유 2) 보안문제, 데이터를 아무나 업로드하고 삭제 하는 것을 방지 하는 과정도 함께 분리함.

프론트에서 바로 S3와 같은 파일저장소나 데이터베이스로 데이터를 보내지 않고 백엔드를 거치는 이유를 생각해봐야 되요. 보안문제 때문이죠. 아무나 업로드하고 삭제하면 안되니깐요. 저희가 정해둔 규칙 안에서 데이터가 관리되어야겠죠.

PresignedUrl을 이용하면 이미지를 업로드할 때 백엔드 서버를 거치지 않고 클라이언트에서 바로 S3로 업로드가 가능해져요. 원래는 이미지를 백엔드가 이미지를 S3로 전달함과 동시에 보안절차(aws sdk secret key활용해서 aws s3 접속)도 같이 한번에 진행이 되죠. 이 과정을 분리시키는거에요. 백엔드는 presignedUrl 생성으로 보안절차 작업만 해주는겁니다. Client가 AWS S3로 바로 업로드할 수 있도록요.

2. 해당 기능의 코드는 어떠한 로직을 가지고 있는지

1. 유저가 글 등록할때 파일 선택 후 글 등록 클릭시(파일선택했을때가 아닐까?)

2. 클라이언트는 서버에게 pre-signed URL 발급 요청함

3. 서버는 S3에게 발급 요청을 해서 pre-signed URL을 받아 클라이언트에게 준다.

4. 클라이언트는 발급 받은 권한(S3 권한 :pre-signed URL)으로 S3에 파일 업로드한다.

5. 서버는 객체 URL을 받아서 글 등록할때 객체 url과 함께 게시글 제목, 내용을 등록한다.

vs code

        //--------------------[글등록버튼 누를시 실행] --------------------------------
        function contactWrite() {
            var presignedUrlg = "이미지.jpa"
            console.log("이미지라고 나와야함 = " + presignedUrlg)

            //--------------------[프리사인url발급요청---]-------------------------
            var presigned = {
                "url": "http://localhost:8080/api/managers/notices/presigned",
                "method": "POST",
                "timeout": 0,
                "headers": {
                    "Authorization": localStorage.getItem('accessToken'),
                    "Content-Type": "application/json"
                },
                "data": JSON.stringify({
                    "imageName": document.getElementById("contact-File").files[0].name
                }),
            };

            console.log("이미지 이름이 나와야함 = " + document.getElementById("contact-File").files[0].name)

            $.ajax(presigned).done(function (response) {
                presignedUrlg = response.toString()

                console.log("함수안에서" + presignedUrlg)
                //--------------------[프리사인url발급성공시 클라이언트가 S3에 이미지 upload요청--]-------------------------
                var upload = {
                    "url": presignedUrlg,
                    "method": "PUT",
                    "timeout": 0,
                    "processData": false,
                    "Content-Type": "binary/octet-stream",
                    "data": document.getElementById("contact-File").files[0]
                };

                $.ajax(upload).done(function (response) {
                    console.log("s3업로드성공시"+response);
                });

                //--------------------[프리사인url발급성공시 서버에 게시글 upload요청--]-------------------------
                var settings = {
                    "url": "http://localhost:8080/api/managers/notices",
                    "method": "POST",
                    "timeout": 0,
                    "headers": {
                        "Authorization": localStorage.getItem('accessToken'),
                        "Content-Type": "application/json"
                    },
                    "data": JSON.stringify({
                        "title": $('#contactTitle').val(),
                        "content": $('#contactContent').val(),
                    }),
                };

                $.ajax(settings).done(function (response) {
                    console.log(response);
                    alert(response);
                    //window.location = './contactPageIndex-notice.html'
                });

            });
            //----------------[프리사인 발급및 게시글 등록 끝]-------------------------
            //-----------------------------------------------------------------

            const dtoTitle = $('#contactTitle').val();

            if (!dtoTitle) {
                return alert("제목을 입력해주세요")
            }
            const dtoContent = $('#contactContent').val();

            if (!dtoContent) {
                return alert("내용을 입력해주세요")
            }
            console.log("함수밖에서" + presignedUrlg)

        }


        document.getElementById("mypage").style.display = "none";
        document.getElementById("MainLogout").style.display = "none";
        document.getElementById("adminpage").style.display = "none";
        if (localStorage.getItem('accessToken') === null) {
            $('#findbyIdandPw').show()
        } else {
            $('#findbyIdandPw').hide()
        }


    </script>

application.yml

cloud:
  aws:
    credentials:
      secret-key: [시크릿키]
      access-key: [에세스키]
    s3:
      bucket: [버킷이름]
    region:
      static: ap-northeast-2
    stack:
      auto: 'false'

백엔드부분
noticeController

public class NoticeController {

  private final NoticeService noticeService;
  private  final PresignedUrlService presignedUrlService;
  private String path; //이미지파일 경로

  //관리자 공지사항 등록
  @PostMapping("")
  public ResponseEntity saveNotice(@RequestBody @Valid NoticeRequest noticeRequest,
      @AuthenticationPrincipal UserDetailsImpl userDetails) {
    String imageUrl = presignedUrlService.findByName(path);
    noticeService.saveNotice(noticeRequest, userDetails.getBase().getId(),imageUrl);
    return ResponseEntity.ok("등록 완료");
    //new ResponseEntity<>("등록완료",HttpStatus.CREATED);
  }

  /**
   * S3에게 pre-signed URL (권한) 요청
   */
  @PostMapping("/presigned")
  public String createPresigned(@RequestBody ImageNameDTO imageNameDTO
      ) {
      path ="contact";  //원하는 경로 지정
      String imageName = imageNameDTO.getImageName();
      return presignedUrlService.getPreSignedUrl(path,imageName);
  }
  ....

noticeService

  @Transactional
  @Override
  public void saveNotice(@Valid NoticeRequest noticeRequest, Long managerId,String imageUrl) {
    Notice notice = noticeRequest.toEntity(managerId,imageUrl);
    noticeRepository.save(notice);
  }

noticeRequestDto

@Getter
@NoArgsConstructor(force = true, access = AccessLevel.PROTECTED)
public class NoticeRequest {
  @NotBlank
  private final String title;
  @NotBlank
  private final String content;


  @Builder
  public NoticeRequest(String title, String content) {
    this.title = title;
    this.content = content;

  }

  public Notice toEntity(Long managerId, String imageUrl) {
    return Notice.builder()
        .managerId(managerId)
        .title(title)
        .content(content)
        .imageUrl(imageUrl)
        .build();

  }
}

noticeResponseDto

@Getter
@NoArgsConstructor(force = true,access = AccessLevel.PROTECTED)
public class NoticeResponse {

  private final Long id;
  private final Long managerId;
  private final String title;
  private final String content;

  private final String image;
  @JsonFormat(shape= JsonFormat.Shape.STRING, pattern="yyyy-MM-dd HH:mm")
  private final LocalDateTime createdDate;
  //private final LocalDateTime modifiedDate;



  public NoticeResponse(Notice notice) {
    this.id = notice.getId();
    this.managerId = notice.getManagerId();
    this.title = notice.getTitle();
    this.content = notice.getContent();
    this.createdDate = notice.getCreatedDate();
    this.image = notice.getImageUrl(); // 이미지 url
  //  this.modifiedDate = notice.getModifiedDate();
  }
}

ImageNameDto

@Getter
@NoArgsConstructor(force = true)
public class ImageNameDTO {

  private final String imageName;

    public ImageNameDTO(String imageName) {
        this.imageName = imageName;
    }
}

PresignedUrlService

@Slf4j
@Component
@RequiredArgsConstructor
public class PresignedUrlService {
    private final AmazonS3 amazonS3;

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

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


    public String getPreSignedUrl(String prefix, String fileName) {

        String onlyOneFileName = onlyOneFileName(fileName);

        useOnlyOneFileName = onlyOneFileName;

        if (!prefix.equals("")) {
            onlyOneFileName = prefix + "/" + onlyOneFileName;
        }
        GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePreSignedUrlRequest(bucket, onlyOneFileName);

        return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString();
    }

    private GeneratePresignedUrlRequest getGeneratePreSignedUrlRequest(String bucket, String fileName) {
        GeneratePresignedUrlRequest generatePresignedUrlRequest =
                new GeneratePresignedUrlRequest(bucket, fileName)
                        .withMethod(HttpMethod.PUT)
                        .withExpiration(getPreSignedUrlExpiration());
        generatePresignedUrlRequest.addRequestParameter(
                Headers.S3_CANNED_ACL,
                CannedAccessControlList.PublicRead.toString());
        return generatePresignedUrlRequest;
    }

    private Date getPreSignedUrlExpiration() {
        Date expiration = new Date();
        long expTimeMillis = expiration.getTime();
        expTimeMillis += 1000 * 60 * 2;
        expiration.setTime(expTimeMillis);
        log.info(expiration.toString());
        return expiration;
    }

    private String onlyOneFileName(String filename){
        return UUID.randomUUID().toString()+filename;

    }

    public String findByName(String path) {
//        if (!amazonS3.doesObjectExist(bucket,editPath+ useOnlyOneFileName))
//            return "File does not exist";
        log.info("Generating signed URL for file name {}", useOnlyOneFileName);
//        return  amazonS3.getUrl(bucket,editPath+useOnlyOneFileName).toString();
        return "https://"+bucket+".s3."+location+".amazonaws.com/"+path+"/"+useOnlyOneFileName;
    }
}

S3Config

@Configuration
public class S3Config {

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

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

    @Value("${cloud.aws.region.static}")
    private String region;
    @Bean
    @Primary
    public BasicAWSCredentials awsCredentialsProvider(){
        return new BasicAWSCredentials(accessKey, secretKey);
    }

    @Bean
    public AmazonS3 amazonS3Client() {
        return AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCredentialsProvider()))
                .build();
    }
}


3. 코드를 작성하며 발견된 버그오류는 어떠한게 있었는지 그리고 어떻게 해결하였는지.

  1. 맥인 나의 컴퓨터로 로직 작성후 공지사항에 이미지파일 함께 올렸을때403 에러가 발생했다.

  2. 그래서 다른 팀원의 페이지 이미지 업로드 해도 마찬가지였다.
    하지만 다른 팀원은 에러 없이 잘 되었다.
    팀원 모두 윈도우 사용중이라 혹시 맥북문제인가?? 혹시 모르니 집에 있던 윈도우컴으로 시도 했다.

  3. 윈도우컴으로 하니 팀원페이지 , 내 페이지도 에러없이 잘되었다.

  4. 추후 AWS 배포를 맡기로 하여 새로 내가 만든 S3버킷 사용시 문제 해결
    아마도 설정에 문제가 있었던 것 같다.

profile
개발자꿈나무

1개의 댓글

comment-user-thumbnail
2023년 12월 4일

클라이언트에서 S3에 있는 사진에 접근하려면 어떻게 해야되나요?

답글 달기