[spring] graphql+kotlin+spring에서 GCS를 활용한 multiple image uploader 생성기

sujin·2023년 7월 1일
0

spring

목록 보기
6/13

이번주는 일주일동안 GCS를 spring에 연동하고 사용할 api를 만들어봤는데, 알고 시작했으면 좋았을 꿀팁들에 대해서 공유하려고 합니다.!!!
특히 graphql을 사용하며 postman으로 협업 혹은 test를 진행하고 있는 분들에게 도움이 될 거라고 믿습니다!! (사실 조회수는 0에 가깝지만,,,공유는 합니다^^)

0. Intro

회사에서 CKeditor를 사용하는데 이때, CKeditor에서 image를 upload할 때 plugin은 file loader 객체를 생성한다.
upload adapter를 이용해서 파일을 읽어서 서버에 업로드를 해주는데 이때 이 서버!! 어떻게 해야할까?

CKeditor 유로버전을 사용하면 storage를 제공한다. 그러나 너~무 비싸다. out이다.

cloud storage service를 사용하기로한다.
회사에서는 google cloud platform을 사용하고 있기에 GCS를 사용하기로 했다!

그러면 나는 무엇을하면 될까?
GCS를 WAS와 연동하고 Ckeditor에서 필요한 요청인 '다중 이미지 파일 업로드' api와 delete 그리고 image를 에디터를 통해 작성한 페이지 entity에 연결 시켜주면 된다.

그 과정에서 겪었던 시행착오들을 공유하려고한다.

1. GCS IAM 설정 및 spring storage bean 등록

IAM 설정

우선 GCS에 들어가서 bucket을 생성하고 image를 upload하거나 download할 준비가 완료가 됐다고
하자

GCS 권한 참고용 여기를 들어가보면 권한이 많고 관리자에게는 업로드 다운로드 등등의 권한을 주고 사용자에게는 볼 수만 있도록 하려고 해서 IAM을 사용하면서 signed url을 생성해야겠다고 생각했다.

signed url 이란 notion이나 google colab 등에서 링크공유를 했을 때 링크를 가진 사용자에게 권한을 부여하는데 권한을 사인해둔 링크라고 생각하면 된다.

그래서 IAM을 생성해서 project의 생성해놓은 storage의 bucket을 연결시켰다.

  • key는 json으로 받아놓고 spring에서 bean을 등록할 때 사용해야한다.

  • IAM을 사용하여 bean을 등록하면? IAM의 role의 역할을 소유자만이 해당 Url에 접근, 생성 등의 권한을 가지고 있게 된다.

  • signed url을 사용하여 editor의 내용을 누구나 볼 수 있도로 해줘야한다.

spring bean 등록

종속성 부여

implementation("com.google.cloud:spring-cloud-gcp-starter-storage:3.1.0")

버전...안 넣어어주고 build하는 과정에서 30분 날렸다. (저처럼 마세요~ ㅎㅎ)

  • 위에서 받아놓은 json file을 활용해 등록

나의 경우에는 properties를 만들어서 이후 계정이나 프로젝튿 등등 쉽게 바꾸게 하기 위해 환경변수를 yaml로 관리해줬다.

이부분은 편한대로 진행하면 되겠다!!

cloud:
  gcs:
    classPath: [~~.json]
    projectId: [~~~]
    bucketName: [~~~}

이렇게 넣어주면 스스로 storage 객체를 사용할 때 injection된댔는데......안됐다!!
또 30분 날렸다~ 편하게 하려고 하지 말고 안 될 땐 그냥 직접 bean을 만들자!

그러면 그냥 직접 등록해주면 된다!

bean등록

나는 config folder에 properties를 EnableConfigurationProperties를 사용해서 읽어주고 등록해줬다.

코드는 google cloud storage guied를 보면 예제가 잘 나와있어서 여기 참고하면 된다. java code를 제공해서 참고해서 kotlin으로 바꿨다!!

같이 봐보자!

위의 GCS guied의 캡처본이다! 주석된 부분을 나는 properties에 정의해서 읽어줬고 이거는 upload 기능에 대한 것이기에 storage 등록 아래의 내용부터는 (BlobId등록) api마다 다르기에 service에서 bean injection할 대상을인 storage 등록까지만 참고하였다.
(물론 아래의 내용은 api를 만들때 참고!)

@Bean
fun storage(properties: GcsProperties): Storage {
        val classPathResource = ClassPathResource(properties.classPath);
        val googleCredentials = GoogleCredentials.fromStream(classPathResource.inputStream);
        val projectId = properties.projectId
        return StorageOptions.newBuilder()
            .setProjectId(projectId)
            .setCredentials(googleCredentials)
            .build()
            .service;
}

그래서 나는 이렇게 등록해줬다.

자 그럼 service를 만들 준비가 다 됐다!!
그렇다면 service를 만들고 fetcher까지 api를 만드는 과정을 함께 봐보자!

2. Service - upload 부분

delete는 공식문서 따라서 그냥 해주면 된다!

upload부분도 사실 따라서 하면 된다. 한가지 공유하고 싶은 부분이 있다면 signed url이다.

이것도 여기 참고하였고 signed url을 생성하는 방법은 3가지가 존재하는데 HMAC은 AWS를 사용하지 않아서 pass하였고 V2,V4중에서 아래 예제가 있는 V4를 선택했다.

공식문서에서는

// Define resource
    BlobInfo blobInfo = BlobInfo.newBuilder(BlobId.of(bucketName, objectName)).build();

    URL url =
        storage.signUrl(blobInfo, 15, TimeUnit.MINUTES, Storage.SignUrlOption.withV4Signature());

이렇게 나와있다.

fun generateV4GetObjectSignedUrl(name: String): String = storage.signUrl(
        BlobInfo.newBuilder(BlobId.of(properties.bucketName, name))
            .build(),
        properties.duration,
        TimeUnit.MINUTES,
        Storage.SignUrlOption.withV4Signature()
    ).toString()

이렇게 만들어줬다. 그리고 transactional이 걸린 upload method에서 signed url을 생성하는데 사용하였고 IAM으로 접근이 차단된 url역시 해당 upload method에서 만들어줘서 결과적으로 signed url과 iam 소유자가 사용할 수 있는 url을 제공해주도록 하였다.

이때, editor 페이지의 pk안에 여러개의 image가 들어갈 수 있기 때문에 Image entity의 경우에는 페이지 entity와 다대일 관계를 가지기 때문에 페이지의 pk값을 받아서 그 안에 multiple image를 모두 넣어주었다. 이러한 비즈니스 로직은 원하는 서비스가 있다면 거기에 맞춰서 작성하면 될 것이다!

multiple uploader

MultipartFile list를 받아서 foreach를 사용해서 하나씩 save해줬다!
물론 page Entity의 pk를 함께 넣어줬다!

3. Fetcher - Multipartfile parsing부분

DgsData와 DataFetchingEnviroment을 사용

  • graphql을 사용할 때 multipartfile은 기존 restapi를 사용할 때처럼 multipartfile로 그냥 받을 수 없다.

Jackson object mapper를 deserialize가 불가능하다. 그렇기때문에 그냥 받을수 없다. 따라서, 파일 인수를 이용해서 명시적으로 가져와야한다.

그렇다면, DgsData와 DataFetchingEnviroment를 사용해야할 것이다.
dfe를 사용해 getArgument로 우리가 넘긴 인수를 받으면 된다.

참고한 사이트를 기반으로 설명을 하겠다!! 여기에 들어가면 더 자세히 알 수 있다.

아래코드는 netflix grapqhl의 공식문서를 가져온 것이다.

@DgsData(parentType = DgsConstants.MUTATION.TYPE_NAME, field = "uploadScriptWithMultipartPOST")
    public boolean uploadScript(DataFetchingEnvironment dfe) throws IOException {
        // NOTE: Cannot use @InputArgument  or Object Mapper to convert to class, because MultipartFile cannot be
        // deserialized
        MultipartFile file = dfe.getArgument("input");
        String content = new String(file.getBytes());
        return ! content.isEmpty();
    }

그렇다면 이때, 스키마 설계는 어떻게 해야할까?

File이라는 scheme는 존재하지 않는다. Upload 스키마를 사용해야한다.

아래의 코드 역시 공식문서를 가져온것이다.

scalar Upload

extend type Mutation  {
    uploadScriptWithMultipartPOST(input: Upload!): Boolean
}

single이 아닌 multiple 생성

그러면 위의 코드가 딱 보면 하나만 처리한다는 것을 알 수 있는데 여러개의 Upload 입력을 보내고 이를 받아서 처리하는 코드로만 변경하면 된다.


scalar Upload

extend type Mutation  {
    uploadScriptWithMultipartPOST(input: [Upload!]!): Boolean
}

이렇게 하면 될 것이다!
물론 Boolean이 나오지 않고 url이 나오도록 하려면 entity를 스키마에 타입을 설정해주고 그것을 반환해주면 될 것이다~

Argument 추가하기

역시 예제 기반으로 보여주겠다!
만약에 title을 같이 보내고 싶다면?

scalar Upload

extend type Mutation  {
    uploadScriptWithMultipartPOST(title: String!, input: [Upload!]!): Boolean
}

이런식으로 변경하고 fetcher도 이에 맞게 수정해줘야하는데 위의 fetcher 코드에서 어떤 부분이 추가 되어야할까?

InputArgument annotation을 사용해서 데이터를 읽어오는게 필요할 것이다! arg를 읽어오면 되고 이를 service에 함께 넘겨서 원하는 로직을 구현하면 된다.

4. postman에서 사용하기

content-type을 multipart-form data 는 simple request이기에 cors error(CSRF error)를 동반한다..
따라서

위의 사진처럼 header에 등록해주자!!

body

postma에서 file upload test를 진행하기 위해서는 form-data를 이용해야한다.

이거는 30분이 아니라 하루 이틀 날려먹었다~~


나는 mutation이름을 uploadLocalImage로 해서 mutation이름이 저렇다!


{ "query": "mutation uploadScriptWithMultipartPOST($title: String, $input: [Upload!]!) { 
	uploadScriptWithMultipartPOST(title: $title, input: $input){ 
    url\n signedUrl }\n}",
    "variables": {"title": "title" , "input": [null, null] } }

잘 보이게 띄어쓰기를 해놨는데 string을 operation의 value에 넣어줘야한다.

inputArgument (이것도 map에 넣어주는걸로 당연히 생각했지만 그게 아니였다...)

이거는 map으로 넣지 않고 operation의 string값 안에 바로 직접 넣어준다.
따라서 variables안에 "title"안에 "title"이 들어있는 이유다. 만약 "title2"를 넣어주고 싶다면 "title": "title2"를 하면 되겠죠??
File전송

map을 사용해야한다. 위의 operation에서 "image" : [null, null]이다
2개를 넣을 수 있는데 map value를

{"1": ["variables.input.0"], "2": ["variables.input.1"]}
다음과 같이 넣어준다.
그러면 null첫번째에 1번 key에 저장된 value인 image.png 파일을 매핑해주고 null 2번째에는 key 2에 해당하는 value에 저장된 file을 매핑해준다.

참고

그래도 했는데 잘 안 된다면 다른 request를 만들어서 다시 해봐라! 나도 중간중간에 뭐가 꼬였는지 안 된적이있었는데 그대로 다음날 다른 request에 복붙해보니 됐다...!!


마무리

많은 내용을 한번에 공유하기 위해서 간단한 예제로 작성을 해봤다!
이번에 느낀거는 공식 문서는 생각보다 이런 예제까지 있어?하는 것까지...많은 것을 알려준다!

뭔가 안 된다하면 구글링해서 스택오버플로우를 들어가는 것보다 공식 github 이라던가 page를 들어가서 먼저 조사를 해보자!!를 느꼈다

그리고 postman은 진짜 잘 안 나오더라...특히 file이랑 title과 같은 다른 인자를 같이 전달하는 방법은 결국 찾지 못했고 내가 이것저것 차례대로 해보다가 유레카~했다

0개의 댓글