본문 바로가기
프로젝트 일지/BMT

8. Go를 이용한 스크래퍼 리팩토링하기 (4) - Json Marshaling, byte stream S3 upload

by 데브겸 2023. 8. 15.

저번 포스팅에서는 AWS SDK for Go v2 를 활용하여 AWS Credential Configure를 하고, S3에 코드 결과물을 업로드할 수 있는 S3Uploader를 구현하는 과정을 다뤘다. (링크: https://kyumcoding.tistory.com/63)

 

7. Go를 이용한 스크래퍼 코드 리팩토링하기 (3) - AWS Credential Configure, S3 Uploader 구현하기 (feat. AWS SD

저번 포스팅에서는 고루틴을 이용하여 19900개의 URL을 날려보고, 검색 결과를 goquery를 통해 가져와 저장하는 것까지 알아보았다. (링크: https://kyumcoding.tistory.com/62) 6. Go를 이용한 스크래퍼 코드 리

kyumcoding.tistory.com

 

문제는 S3에 업로드 하기 위한 그 결과물을 만들어내는 과정 또한 쉽지 않았다는 것. 오늘은 스크래핑한 결과물을 Golang 자료형에서 json으로 변환하고, 그것을 파일 형태로 S3에 적재되게 만드는 과정을 소개하겠다.

 

 

오늘 다룰 소스 코드 전체

func S3Uploader(INFO map[string][]map[string]interface{}, basics BucketBasics, fileName string) error {
	// data가 struct 형태일때는 이상하게 marshal이 되더니, map으로 바꾸니까 한방에 marshal이 잘 됨. 이유가 뭘까?
	content, err := json.MarshalIndent(INFO, "", " ")
	if err != nil {
		log.Fatalln("JSON marshaling failed: %s", err)
	}

	// json 바이트 스트림을 S3에 업로드
	_, err = basics.S3Client.PutObject(context.TODO(), &s3.PutObjectInput{
		Bucket: aws.String(os.Getenv("AWS_BUCKET_NAME")),
		Key:    aws.String(fileName),
		Body:   bytes.NewReader(content),
	})
	if err != nil {
		return fmt.Errorf("failed to upload, %v", err)
	}
	fmt.Println(fileName + "file successfully uploaded in S3")
	return nil
}

 

 


 

Json Marshaling

 

코드를 통해서 알 수 있겠지만, golang에서의 자료형을 json으로 바꾸기 위해선 marshal(마샬링) 과정이 필요했다. 처음 들어보는 단어였기 때문에 이해하는데 꽤 애를 먹었다. 지금까지 찾아봤을 때 cublr's warehouse 블로그가 가장 자세하게 설명해주신 것 같아서 이해하는데 많은 도움을 받았다. (JOINC 블로그 글에서도 꽤 많은 도움을 받았다)

 

컴퓨터 공학에서 마샬링은 데이터를 전송이나 보관에 유리한 방향으로 변환하는 과정, 즉 byte 데이터의 나열([]byte)로 변환을 의미한다. 반대로 언마샬링(unmarshaling)은 byte에서 유저가 지정한 타입으로 데이터를 변환하는 과정을 의미한다.  

*참고

Golang에서 Json 마샬링은 그 형식과 유사한 struct가 아닌 일반 변수에 대해서도 가능한데(즉, key-value 형식일 필요가 없는데), 이는 json 패키지가 
RFC 7159에 정의된 바대로 구현되었다고 적혀있다는 점에서 확인할 수 있다.

 

처음 코드에서는 INFO 변수를 map[string][]map[string]interface{} 이 아닌 struct로 조금 더 깔끔하게 작성하였다. 하지만 이상하게 struct로 된 변수를 marshal 해보니 (정확하게 기억은 안 나지만) 결과가 이상하게 나오는 것이었다. 여러가지를 시도하다가 결국 INFO 변수를 struct가 아닌 map으로 변경하니 marshal이 제대로 되었던 기억이 있다.

 

사실, 이 당시만 하더라도 marshal의 정확한 의미를 몰랐어서 왠 [68 44 50 52] 이런 형식의 데이터가 나오는 것을 보고 이상하게 여겼을 수도 있다. 다만 "data가 struct 형태일때는 이상하게 marshal이 되더니, map으로 바꾸니까 한방에 marshal이 잘 됨. 이유가 뭘까?"라고 주석을 달아놓은 것을 보면 그런 상황이었을 가능성은 적을 것 같다. (이래서 버저닝과 그때그때 기록해두는 것이 중요하다. 미래에서는 정확한 맥락도 기억 안 나고, 재현도 힘드니까. 참고로 포스팅을 작성하는 것은 문제를 해결한지 4개월 후의 시점이다)

 

다만, 이후에 이 포스팅을 작성하며 공부를 해보니 내가 struct로 표현이 불가한 json 형태, 즉 json의 키가 타입이 아닌 이름을 표현하는 케이스 였기 때문에 그랬을지도 모르겠다는 생각이 들었다.

 

내가 최종적으로 원하는 json 파일은 대략 아래와 같다.

// subway_information.json
{
    "1호선": [
        {
            "station_nm": "소요산역",
            "naver_code": 100
        },
        {
            "station_nm": "동두천역",
            "naver_code": 101
        }],
    "경의중앙선": [
        {
            "station_nm": "청량리역",
            "naver_code": 191
        }]
}

 

위 데이터에서 "1호선", "경의중앙선"으로 나타나는 키는 그 이름이 매번 바뀐다. 호선 이름이 바뀌거나, 폐지, 신설되는 경우를 대비하여 외부 데이터 소스에서 호선의 이름을 읽어오고, 그 이름을 키로 설정하는 방식이기 때문이다. struct에서 필드명이 정적으로 설정되어야 하지만, 내 코드에서는 그렇지 않기 때문에 단순 언마셜링이 불가했을 수도 있다. 따라서 struct가 아닌 동적인 키-값을 저장할 수 있는 map을 쓰는 것이 옳았던 것일 수 있다. (정확히 원인이 이거 때문이었는지 확신할 수 없어서 살짝 애매하게 적어둔다...)

 

Python으로는 dict로 편리하게 처리했겠지만, golang은 그렇지 않아서 고생했던 측면이 있는 것 같다. 복잡하고 깊은 map을 다루기 위한 mapstructure 라이브러리나 (사용법 한국어 정리 글), map <-> json을 편하게 해주는(take a golang map and flatten it or unflatten a map with delimited key) flat 라이브러리 가 있다고 하니 이후에는 이 둘의 도입을 검토해봐도 좋을 것 같다.

 

 


 

바이트 스트림 S3로 업로드 하기

 

json 바이트 스트림을 바로 S3에 업로드 하는 코드를 작성했지만, 사실 이는 어느정도 꼼수였다. 원래 내 계획은 subway_information.json 파일을 하나 만들고, 거기에 데이터를 쓴 후, 그 파일을 S3에 업로드 하는 것이었다. 하지만 해당 파일이 로컬이 아니라 lambda 위에서 쓰여지고, 저장되고, 전송된다는 사실이 변수였다.

 

AWS Lambda는 /tmp라는 임시 스토리지를 제공해준다. 그 말은 곧 lambda 위에서 저장하고, 그것을 꺼내 쓰기 위해서는 /tmp에서 꺼내가면 되는 것. 하지만 이상하게 파일을 만들어 저장하고, 이를 /tmp 위치에서 찾아 S3에 업로드 하려고 하는데 저장이 안 되는 것이었다. (파일 경로를 잘못쓴건지, 아니면 뭔가 다른 문제가 있는 것인지)

 

 

file을 lambda에 저장해서 업로드 하는 경우 예시

func (basics BucketBasics) UploadFile(bucketName string, objectKey string, fileName string) error {
	file, err := os.Open(fileName) // ./tmp/subway_information.json 와 같이 적어주기
	if err != nil {
		log.Printf("Couldn't open file %v to upload. Here's why: %v\n", fileName, err)
	} else {
		defer file.Close()
		_, err := basics.S3Client.PutObject(context.TODO(), &s3.PutObjectInput{
			Bucket: aws.String(bucketName),
			Key:    aws.String(objectKey),
			Body:   file,
		})
		if err != nil {
			log.Printf("Couldn't upload file %v to %v:%v. Here's why: %v\n",
				fileName, bucketName, objectKey, err)
		}
	}
	return err
}

 

 

사실 시간을 들이면 충분히 해결할 수 있는 에러였지만, 문득 그런 생각이 들었다. 

 

굳이 파일을 만들어서 저장할 필요가 있나? 그냥 바로 데이터 그 자체를 S3로 보내버리면 안 되나? 그래서 나는 Marshal하여 만들어진 []byte 값을 바로 basixs.S3Client.PutObject의 Body에 집어넣는 방식을 택했다 (사실 이때는 이 정도 문제의식만 있고, 이게 왜 되는지에 대해서는 알지 못했다)

 

 

내가 실제 구현한 코드

func S3Uploader(INFO map[string][]map[string]interface{}, basics BucketBasics, fileName string) error {
	content, err := json.MarshalIndent(INFO, "", " ")
	if err != nil {
		log.Fatalln("JSON marshaling failed: %s", err)
	}

	// json 바이트 스트림을 S3에 업로드
	_, err = basics.S3Client.PutObject(context.TODO(), &s3.PutObjectInput{
		Bucket: aws.String(os.Getenv("AWS_BUCKET_NAME")),
		Key:    aws.String(fileName),
		Body:   bytes.NewReader(content),
	})
	if err != nil {
		return fmt.Errorf("failed to upload, %v", err)
	}
	fmt.Println(fileName + "file successfully uploaded in S3")
	return nil
}

 

다시 공부하면서 알게 된 것은 PutObjectInput.Body에는 io.Reader 인터페이스가 들어와야 한다는 것. (공식문서 참고)

 

내가 구현한 것은 결국 아래와 같다.

1) json.Marshal로 INFO 변수의 값을 []byte로 변환

2) bytes.NewReader로 []byte 형태의 데이터를 Reader 타입으로 변환 (A Reader implements the io.Reader, io.ReaderAt, io.WriterTo, io.Seeker, io.ByteScanner, and io.RuneScanner interfaces by reading from a byte slice.)

3) PutObjectInput.Body에 Reader 타입으로 변환된 Json Marshaled INFO 데이터를 넣어서 S3에 업로드

 

 

위와 같은 과정을 통해 file을 생성하고 쓰고 저장하고 다시 읽어서 업로드 하는 것이 아니라 메모리에 올라와 있는 데이터를 바로 S3로 전송할 수 있었다.

 

결국 4개의 글을 쓰고서야 첫번째 코드 리팩토링 과정을 모두 설명했다... 하나하나가 모두 챌린징한 과정이었기에 쓰는데 생각보다 오래 걸렸던 것 같다. 다음 포스팅부터는 내가 Golang을 써야했던 진짜 이유인 동적 이벤트가 필요한 스크래퍼를 구현하는 과정을 소개한다. 또한 해당 스크래퍼를 Dockerize하려고 했던 피땀눈물을,,, 소개하려 한다.

 

 


 

스택오버 플로우를 보니 S3 업로드 관련 나와 비슷한 고민, 공부를 했던 글을 있어서 해당 질문자가 제시하는 3가지 방법을 소개하자면,

https://stackoverflow.com/questions/47621804/upload-object-to-aws-s3-without-creating-a-file-using-aws-sdk-go

 

Upload object to AWS S3 without creating a file using aws-sdk-go

I am trying to upload an object to AWS S3 using golang sdk without needing to create a file in my system (trying to upload only the string). But I am having difficulties to accomplish that. Can any...

stackoverflow.com

 

1. 메모리에 데이터가 이미 올려져 있는 경우 strings.NewReader 혹은 bytes.NewReader로 변환하여 넣기

_, err = basics.S3Client.PutObject(context.TODO(), &s3.PutObjectInput{
	Bucket: aws.String(os.Getenv("AWS_BUCKET_NAME")),
	Key:    aws.String(fileName),
	Body:   strings.NewReader(content), // 상황에 따라 bytes.NewReader
})

 

2. 파일이 이미 저장되어 있는 경우

file, err := os.Open(fileName)
if err != nil {
	log.Printf("Couldn't open file %v to upload. Here's why: %v\n", fileName, err)
} else {
	defer file.Close()
	_, err := basics.S3Client.PutObject(context.TODO(), &s3.PutObjectInput{
		Bucket: aws.String(bucketName),
		Key:    aws.String(objectKey),
		Body:   file,
	})

 

3. 다뤄야 하는 데이터가 너무 클 경우 (처리까지 너무 오래 걸리거나, 백엔드 서버에서 부담이 갈 것 같은 경우)

S3의 미리 서명된 URL (Pre-signed URL) 이용하여 프론트에서 직접 데이터 업로드. (미리 서명된 URL과 그 방법에 대한 한국어 설명은  https://blog.walkinpcm.com/20 을 통해 확인 가능)

 

AWS S3에 파일을 업로드 하기 위한 Pre-signed URL과 Pre-signed POST

요즘은 흔히 파일서버로 AWS S3를 이용합니다. 그래서 파일 업로드 기능을 만들때 S3에 어떻게 파일을 업로드 할지 고민하게 되는데요. Front-end 입장에서는 S3에 파일을 업로드 하는 방법으로 크게

blog.walkinpcm.com