새소식

Spring

[Spring] Naver Cloud Platform으로 스트리밍 서비스 개발하기(1)

  • -

[(링크) 해당 게시글은 네이버 클라우드 플랫폼 공식 블로그에도 소개되었습니다 !]

 

포착(Phochak)

포착 서비스는 이번 넥스터즈 활동에서 저희가 출시한 서비스입니다 :)

 

포착은 여행을 가서 촬영한 짧은 영상들을 저장하고 공유하는 아카이빙 및 스트리밍 서비스입니다.

 

포착 서비스

 

네이버 클라우드 플랫폼의 든든한 지원

감사하게도 Naver Cloud Platform의 지원으로 크레딧을 지원받아 AWS 가 아닌 NCP를 무.료.로 이용하게 되었습니다 !

 

NCP를 쭉 들여다보니, AWS와 거의 동일한 환경을 제공합니다. 심지어 AWS SDK와 호환되게끔 구성이 되어있습니다.

이 게시글에서 사용할 NCP 클라우드 요소들을 소개하면..

  • Server(EC2): 서버
  • Object Strage(S3): 비용 효율적인 정적 파일 보관/관리
  • Cloud Functions(Lambda): 서버리스 코드 실행
  • CDN+(Cloud Front): 효율적인 콘텐츠 전송 네트워크
  • VOD Station(Cloud Flare): 스트리밍을 위한 인코딩 및 채널 관리

 

 

잘못된 초기 설계 - 업로드

초반 설계 및 구현

초반에는 이렇게 설계 및 구현을 했습니다.

그냥 일반적인 게시글 업로드 구현 하듯이 Multipart로 쏴서, S3에 미디어만 업로드 하면 될 줄 알았으나.. 두 가지 문제가 발생했습니다.

 

문제1. 영상 위주 서비스기 때문에, 게시글 작성에 시간과 돈이 두 배로 발생한다.

중간 서버를 거치게 되면, 아무래도 내부에서 S3로 업로드 하는 시간이 추가로 발생한다. 그리고 네트워크 비용도 추가로 발생합니다.

그리고 미디어를 포워딩 해주는 프록시 서버를 따로 둔다고 해도, 장기간 connection을 유지하는 형태이기 때문에 과부화가 우려됩니다.

 

 

문제2. 우리는 인코딩도 해야한다.

업로드도 그렇지만, 인코딩도 사실상 처리 시간에 기약이 없습니다.

업로드 시간 + 인코딩 시간을 커넥션을 붙잡고 있으면 끔찍할 것 같습니다.

 

그래서 S3에 직접 업로드 하는 방식을 채택했습니다

 

Presigned URL를 활용한 파일 업로드

S3 업로드를 client에 공개적으로 열기 위해서는 Presigned URL을 활용하는 방식이 있습니다. Presinged URL은 인증된 클라이언트만 요청을 할 수 있도록 url에 임시 키값을 넣어주는 형식입니다. AWS ADMIN ADK에서 이 Presigned URL을 제공합니다.

 

일단 업로드에 대한 Flow를 보면 다음과 같습니다.

Presigned URL을 사용한 미디어 업로드

 

그리고 AWS에서 제공하는 S3 SDK를 활용하면 다음과 같이 쉽게 서버 측에서 Presigned URL 발급이 가능합니다.

해당 내용에 대한 문서는 따로 존재하지 않아서, FnA 에 나오는 내용을 참고하여 AWS SDK를 활용하면 됩니다.

 

private final String bucketName;
private final AmazonS3 s3Client; //S3 SDK 접근 객체
 
//생성자 함수입니다.
public NCPStorageRepository( 
        @Value("${ncp.s3.end-point}") String endPoint,
        @Value("${ncp.s3.region-name}") String regionName,
        @Value("${ncp.s3.bucket-name}") String bucketName,
        @Value("${ncp.s3.access-key}") String accessKey,
        @Value("${ncp.s3.secret-key}") String secretKey) {
    this.bucketName = bucketName;
    this.s3Client = AmazonS3ClientBuilder.standard()
            .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endPoint, regionName))
            .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)))
            .build();
}

@Override
public URL generatePresignedUrl(String objectName) {
    Date expiration = new Date();
    long expTimeMillis = expiration.getTime();
    expTimeMillis += 1000 * 60 * 30; // 30분
    expiration.setTime(expTimeMillis);
    return s3Client.generatePresignedUrl(bucketName, objectName, expiration);
}

 

참고로 objectName 의 경우, 파일 중복을 방지하기 위해 UUID 값을 생성해서 rename 해줬습니다.

그래서 요청값으로 파일 확장자를 받아서, String objectName = UUID + "." + fileExtension; 이렇게 네이밍을 해줬습니다.

 

저희 서비스에서는 이 UUID 값을 'uploadKey' 라고 부릅니다.

 

이 업로드 키는 여기저기서 중요하게 쓰이는데, 다음 용도로 사용됩니다.

  • 클라이언트는 해당 주소와 파일명이 강제되어, S3 스토리지 내에서의 파일명 중복을 피할 수 있습니다.
  • 이후 인코딩된 파일과 DB에 생성된 게시글 데이터를 연결해주는 키값 역할 뿐만 아니라 인코딩 상태를 체크할 수 있는 값이 됩니다.
  • 이 업로드 키를 조합하여 스트리밍 가능한 url과 썸네일 url도 만들게 됩니다.

 

'업로드키(uploadKey)'라는 키워드가 이 글에서 계속 나오니, 기억해두세요!

 

사실 이렇게 해야한다는 가이드라인은 없었으나, 인코딩 과정을 거치면서 고유 ID 값도 달라져서 이렇게 UUID를 통해서 하는 방식 외에는 따로 아이디어가 떠오르지 않았습니다. 혹시 인코딩 전과 후의 오브젝트를 매핑할 수 있는 다른 방법이 있다면 알려주세요 :)

 

인코딩 설정 - 버킷

 

VOD Station 개요 - VOD Station

 

guide.ncloud-docs.com

인코딩 세팅은 생성은 위의 공식문서 과정을 따라하면 됩니다.

 

저는 저희 서비스 성격에 맞게, 버킷 구조를 다음과 같이 만들어 봤습니다.

 

phochak-shorts 버킷:

    - /shorts: 인코딩 된 영상 보관

    - /thumbnail: 추출된 썸네일 보관

phochak-shorts-original 버킷: 루트에 클라이언트가 직접 원본 파일 업로드

 

 

 

인코딩 설정 - Cloud Functions(Lambda) 자동 인코딩 및 콜백

 

클라이언트가 직접 S3에 오브젝트 업로드 이후에 다음 요구사항이 발생합니다.

  • S3 업로드 이벤트를 받아서, 자동으로 인코딩을 해줘야 합니다.
  • 인코딩된 파일을 전체 공개로 설정하여 클라이언트에서 접근 가능하게 해야합니다.
  • 서버로 콜백을 쏴서 인코딩 진행 결과를 알려줘야 합니다.

 

공식 문서에 있는 예제를 변형시켰으며, python 코드입니다.

공식문서 참고

import hashlib
import hmac
import base64
import requests
import time
import json


def make_signature(url, timestamp, access_key, secret_key):
    timestamp = int(time.time() * 1000)
    timestamp = str(timestamp)

    secret_key = bytes(secret_key, "UTF-8")

    method = "PUT"

    message = method + " " + url + "\n" + timestamp + "\n" + access_key
    message = bytes(message, "UTF-8")
    sign_key = base64.b64encode(
        hmac.new(secret_key, message, digestmod=hashlib.sha256).digest()
    )
    print(sign_key)
    return sign_key


def make_header(timestamp, access_key, sign_key):
    headers = {
        "Content-Type": "application/json; charset=utf-8",
        "x-ncp-apigw-timestamp": timestamp,
        "x-ncp-iam-access-key": access_key,
        "x-ncp-apigw-signature-v2": sign_key,
    }
    return headers


def main(args):
    object_name = args.get("object_name")
    input_bucket_name = args.get("container_name")
    notifiation_url = args.get("notifiation_url")
    output_bucket_name = args.get("output_bucket_name")

    api_url = args["api_url"]
    full_url = f'{args["base_url"]}{api_url}'
    timestamp = str(int(time.time() * 1000))

    sign_key = make_signature(
        api_url, timestamp, args["access_key"], args["secret_key"]
    )
    headers = make_header(timestamp, args["access_key"], sign_key)

    try:
        res = requests.put(full_url, headers=headers, data=json.dumps({
            'bucketName' : input_bucket_name,
            'pathList': [ object_name ],
            'notificationUrl': notifiation_url,
            'output': {
                'bucketName': 'phochak-shorts',
                'filePath': '/',
                'accessControl': 'PUBLIC_READ',
                'thumbnailAccessControl': 'PUBLIC_READ'
            }
        }))

        if res.status_code == 200:
            return {"input_bucket": input_bucket_name, "object_path": object_name, "res": res.text, "done": True}
        else:
            raise Exception({"done": False, "error_message": res.text})

    except Exception as e:
        raise Exception({"done": False, "error_message": str(e)})

 

제가 못찾았을 수 있지만, 콜백 주소 설정이나 접근 권한 설정 필드에 대한 설명이 없어서, S3 등 연관된 API 문서들을 보고 필드 네이밍을 예측해서 테스트 했습니다 :(

 

해당 필드와 연관된 링크라도 넣어줬으면 좋았을텐데, 너무 간소화된 예제 코드만 넣어놔서 문서가 중간에 잘린 느낌이였습니다. 

제가 작성한 output 객체를 참고해주시면 될 것 같습니다.

 

 

이제 영상을 S3 버킷에 업로드하면, 자동으로 인코딩이 되는 모습을 확인할 수 있습니다.

 

 

 

게시글 작성 서버 전체 로직

위 과정을 가쳐 다음과 같은 로직이 완성되었습니다.

서버 로직에서 핵심적으로 고민해야할 부분은 다음과 같습니다.

 

case1. 인코딩이 포스트 생성보다 먼저 끝난 경우:

  • 인코딩 완료 콜백 수신
    • uploadKey값으로 shorts 데이터가 DB에 생성되어 있는지 확인 -> 없음
    • shorts 데이터만 미리 생성
    • shortsState = IN_PROGRESS
  • 포스트 생성 요청 수신
    • uploadKey값으로 shorts 데이터가 DB에 생성되어 있는지 확인 -> 있음
    • post 엔티티를 생성하고 이미 생성되있는 shorts를 연결
    • shortsState = OK

case2. 포스트 생성이 인코딩보다 먼저 끝난 경우:

  • 포스트 생성 요청 수신
    • uploadKey값으로 shorts 데이터가 DB에 생성되어 있는지 확인 -> 없음
    • post, shorts 데이터 생성
    • shortsState = IN_PROGRESS
  • 인코딩 완료 콜백 수신
    • uploadKey값으로 shorts 데이터가 DB에 생성되어 있는지 확인 -> 있음
    • shortsState = OK

 

해당 비즈니스 로직에 대한 코드 구현은 다음과 같습니다.

@Override
public void connectShorts(String uploadKey, Post post) {
    Optional<Shorts> optionalShorts = shortsRepository.findByUploadKey(uploadKey);

    if(optionalShorts.isPresent()) {
        // case: 인코딩이 먼저 끝나있는 경우
        Shorts shorts = optionalShorts.get();
        shorts.updateShortsState(ShortsStateEnum.OK);
        post.setShorts(shorts);
    } else {
        // case: 인코딩이 끝나지 않은 경우
        String shortsFileName = generateShortsFileName(uploadKey);
        String thumbnailFileName = generateThumbnailsFileName(uploadKey);
        Shorts shorts = Shorts.builder()
                        .uploadKey(uploadKey)
                        .shortsUrl(shortsFileName)
                        .thumbnailUrl(thumbnailFileName)
                        .build();
        shortsRepository.save(shorts);
        post.setShorts(shorts);
    }
}

@Override
@Transactional
public void connectPost(EncodingCallbackRequestDto encodingCallbackRequestDto) {
    String uploadKey = getKeyFromFilePath(encodingCallbackRequestDto.getFilePath());

    Optional<Shorts> optionalShorts = shortsRepository.findByUploadKey(uploadKey);

    if(optionalShorts.isPresent()) {
        // case: 포스트 생성이 먼저된 경우 -> 상태 변경
        Shorts shorts = optionalShorts.get();
        shorts.updateShortsState(ShortsStateEnum.OK);
    } else {
        // case: 포스트 생성이 되지 않은 경우 -> shorts 만 미리 생성
        String shortsFileName = generateShortsFileName(uploadKey);
        String thumbnailFileName = generateThumbnailsFileName(uploadKey);
        Shorts shorts = Shorts.builder()
                .uploadKey(uploadKey)
                .shortsUrl(shortsFileName)
                .thumbnailUrl(thumbnailFileName)
                .build();
        shortsRepository.save(shorts);
    }
}

클라이언트에서 접근 가능한 URL은 키값과 미리 지정된 Prefix로 조합하여 저장합니다.

 

// 영상 url 생성
private String generateShortsFileName(String uploadKey) {
    return ncpStorageProperties.getShorts().getStreamingUrlPrefixHead() + uploadKey
    	+ ncpStorageProperties.getShorts().getStreamingUrlPrefixTail();
}

// 썸네일 url 생성
private String generateThumbnailsFileName(String uploadKey) {
    return ncpStorageProperties.getThumbnail().getThumbnailUrlPrefixHead() + uploadKey
    	+ ncpStorageProperties.getThumbnail().getThumbnailUrlPrefixTail();
}

// url에서 uploadKey 파싱
private String getKeyFromFilePath(String filePath) {
    return filePath.substring(filePath.lastIndexOf("/") + 1, filePath.indexOf("_"));
}

 

 

마지막으로 위의 로직에 대한 테스트 코드입니다.

위의 코드와 같이 참고하시면 이해가 쉬울 것 같습니다.

 

조금 길어서 접어두겠습니다.

더보기

 

@ExtendWith(MockitoExtension.class)
class NCPShortsServiceTest {

    @InjectMocks NCPShortsService ncpShortsService;

    @Mock ShortsRepository shortsRepository;

    @Test
    @DisplayName("인코딩이 끝나있는 경우, 게시글을 shorts 객체와 연결한다")
    void connectShorts_encodingDone() {
        //given
        String uploadKey = "uploadKey";
        Post post = Post.builder()
                .user(new User())
                .postCategory(PostCategoryEnum.TOUR)
                .build();
        Shorts shorts = Shorts.builder()
                .shortsUrl("url")
                .thumbnailUrl("url")
                .build();
        given(shortsRepository.findByUploadKey(uploadKey)).willReturn(Optional.of(shorts));

        //when
        ncpShortsService.connectShorts(uploadKey, post);

        //then
        assertThat(post.getShorts()).isEqualTo(shorts);
        assertThat(shorts.getShortsStateEnum()).isEqualTo(ShortsStateEnum.OK);
    }

    @Test
    @DisplayName("인코딩이 진행중인 경우, ShortsStatus를 in progress로 유지하고 shorts 객체를 생성한다")
    void connectShorts_encodingInProgress() {
        //given
        String uploadKey = "uploadKey";
        Post post = Post.builder()
                .user(new User())
                .postCategory(PostCategoryEnum.TOUR)
                .build();
        given(shortsRepository.findByUploadKey(uploadKey)).willReturn(Optional.empty());

        //when
        ncpShortsService.connectShorts(uploadKey, post);

        //then
        verify(shortsRepository, times(1)).save(any());
    }

    @Test
    @DisplayName("인코딩 완료 콜백 이후, 포스트 생성이 먼저 끝난 경우에 상태를 변경한다")
    void connectPost_postCreated() {
        //given
        EncodingCallbackRequestDto encodingCallbackRequestDto = EncodingCallbackRequestDto.builder()
                .filePath("/shorts/UPLOADKEY_encoded.mov")
                .build();
        Post post = Post.builder()
                .user(new User())
                .postCategory(PostCategoryEnum.TOUR)
                .build();
        Shorts shorts = new Shorts();
        given(shortsRepository.findByUploadKey(any())).willReturn(Optional.of(shorts));

        //when
        ncpShortsService.connectPost(encodingCallbackRequestDto);

        //then
        assertThat(shorts.getShortsStateEnum()).isEqualTo(ShortsStateEnum.OK);
    }

    @Test
    @DisplayName("인코딩 완료 콜백 이후, 포스트 생성이 아직 되지 않은 경우에 shorts 객체만 미리 생성한다")
    void connectPost_postNotCreated() {
        //given
        EncodingCallbackRequestDto encodingCallbackRequestDto = EncodingCallbackRequestDto.builder()
                .filePath("/shorts/UPLOADKEY_encoded.mov")
                .build();
        given(shortsRepository.findByUploadKey(any())).willReturn(Optional.empty());

        //when
        ncpShortsService.connectPost(encodingCallbackRequestDto);

        //then
        verify(shortsRepository, times(1)).save(any());
    }
}

 

 

다음 고민 거리 - 인코딩에 실패한다면? 

실패에 사후 로직 대한 처리는 서버에서도 그렇고 클라이언트에서도 까다로운 고민입니다.

후보로는 다음을 생각해봤습니다.

  • FCM 푸시를 통한 상태 전달
  • Socket
  • health check API

 

고민 끝에 FCM으로 푸시 서비스를 구현해야겠다는 결정을 내렸습니다.

어차피 서비스 이벤트 푸시를 언젠가는 구현해야 하기도 하고, 다른 구현 방식들은 서버에 부하를 주는 요소들입니다.

 

전체 스트리밍 서비스의 Flow는 이렇습니다!!!

인코딩 실패에 대한 처리는 구현 후에 다음 게시글에서 다루겠습니다.

 

다음 글 보러가기

 

 

서비스 전체 아키텍쳐

서비스 전체 아키텍쳐에 대해서 궁금하실 것 같아서 공유합니다.

 

마지막으로.. Naver Cloud Platform 사용 후기

네이버 클라우드 플랫폼 팀에 압도적 감사를..

처음 해당 클라우드 서비스를 사용하고 나서 깜짝 놀랐습니다.

일반적으로 AWS 에서 제공하는 기능들을 대부분 제공하고 있었고, 또 기존 AWS 사용자들을 위한 가이드나 API 호환성 고려 등이 너무 잘되어 있었습니다.

 

특히 스트리밍 서비스를 이렇게 간단하게 구현할 수 있을거라고 생각을 전혀 하지 못했는데,

[인코딩 - 썸네일추출 - CDN으로 스트리밍] 이 과정을 모두 통합해서 제공하는 것이 너무 인상깊었습니다.

그것도 스트리밍에 대한 지식이 전혀 없는 저조차 하루만에 너무 쉽고 빠르게 구현이 가능했습니다. 개발자 실직 위기를 느꼈다..

 

반면에 조금 아쉬웠던 부분은, 개발 단계에서 생각보다 많은 사용료가 지출되었습니다 ㅠㅠ

해당 서비스 이용량에 따라서 부과되는 금액은 확실히 적었지만, VOD Station의 기본 고정 사용 금액이 부담스럽다는 생각이 들었습니다.

서비스 초기 개발/런칭/운영을 하는 분들을 위해서, 고정 사용 금액도 구간 요금을 책정하면 좋지 않았을까..라는 생각을 했습니다.

 

또 일부 문서에 대한 업데이트가 필요해 보였습니다.

VOD Station을 위한 Cloud Function(Lambda) action을 작성하는데 어떤 필드 명으로 callback 을 넣는지에 대한 설명이 명시되어 있지 않고, 그냥 파이썬 코드만 덩그러니 있어서 다른 서비스 문서들을 살펴보면서 "대충 이거지 않을까!!!" 하면서 때려맞추기를 시전했었습니다 ㅠㅠ

 

그리고 Object Storage 업로드를 위해 Presigned URL을 활용해야 했었는데, 해당 내용에 대한 문서가 거의 없었고, 스토리지 버킷 SDK에 대한 예제도 1.11 버전이여서 조금 조심조심 하면서 구현을 했습니다. 물론 이 부분은 AWS에 익숙하신 분들이라면 문제가 없었을 것 같지만, AWS S3 를 사용해본적이 없는 저에게는 조금 어려운 숙제였습니다. 네이버 클라우드 플랫폼 같은 자국의 클라우드 서비스를 사용하게 되면 장점 중 하나가 친근한 한국어로 작성된 문서라고 생각을 하는데, 생각보다 허들이 있었습니다...!

 

 

Naver Cloud Platform에 대해서 아쉬웠던 부분만 말씀을 드린 것 같은데, 전체적인 후기는 "너무 좋았다" 입니다.

 

만약 제가 국내 스타트업에서 서비스를 런칭한다면 합리적인 가격, 고객 지원, 한글 문서 등의 대체 불가능한 장점이 있기 때문에 고려해볼 대상일 것 같습니다. 백엔드 개발을 공부하는 저 개인에게도 스트리밍과 클라우드 플랫폼에 대한 애정이 생겼습니다 :)

 

 

다음 편에서 뵙겠습니다!

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.