새소식

Spring

[Spring] Naver Cloud Platform으로 스트리밍 서비스 개발하기(2) - 푸시 서비스로 인코딩 결과 전송하기

  • -

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

 

 

 

개요

이전 편에서 위 순서도까지의 구현을 진행했습니다.

 

그렇지만 포스트 작성이 어쩔 수 없이 서버에서 비동기 처리로 진행되면서, 다음 요구 사항을 어떻게든 해결해야 합니다.

 

"게시글 작성 성공 시 해당 게시글로 이동할 수 있는 푸시를 제공하고, 실패 시 재시도를 할 수 있게 제공"

 

소켓 등의 방식도 생각해봤지만 통신 규격에 RestAPI 외에 신경 쓸 요소가 생긴다는 것과, 유저가 앱을 이탈했을 때는 서비스가 관여할 수 있는 범위에서 벗어나게 됩니다.

 

그래서 결국 푸시 알람으로 해당 로직을 구현하기로 했고, 물론 네이버 클라우드 플랫폼에서 제공하는 'Simple & Easy Notification Service' 을 활용했다면 좋았겠지만, 클라이언트 개발자분들의 편의를 고려하여 기존에 익숙한 firebase의 FCM을 사용하게 되었습니다. 

 

 

 

요구사항 정리

구현에 앞서 요구사항을 정리합니다.

 

앞으로의 서비스 확장 가능성도 고려한다면, 다음 기능을 우선적으로 생각해야 합니다.

 

  1. VOD Station 및 Clound Function(Lambda)에서 받은 인코딩 콜백을 수신하여 DB상의 미디어 상태관리
  2. 미디어 상태를 푸시 알람으로 전달 (시작 / 성공 / 실패)
  3. 이후에도 서비스에서 이벤트성 푸시를 사용할 것을 고려하여 모듈화

 

 

기능개발: 인코딩 결과 상태 관리

 

이전 포스트에 Clound Function에 명시한 url을 통해 콜백 요청을 컨트롤러로 받을 수 있었습니다.

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'
    }
}))
@PostMapping("/encoding-callback")
public void encodingCallback(@RequestBody EncodingCallbackRequestDto encodingCallbackRequestDto) {
    shortsService.processPost(encodingCallbackRequestDto);
}

 

콜백으로 오는 정보가 따로 문서에 명시되어 있지 않은데, 직접 로그를 찍어보고 나온 결과를 DTO로 공유합니다.

public class EncodingCallbackRequestDto {
    private Integer categoryId;
    private String categoryName;
    private Integer encodingOptionId;
    private Integer fileId;
    private String filePath;
    private String outputType;
    private String status;
}

 

 

그리고 여기에서 중요한 status 를 다양한 상황에서 로그로 찍어본 결과 다음 결과를 얻을 수 있었습니다.

아래에 명시한 것과 다른 케이스의 결과가 존재하는지에 대해서는 확실하지 않지만, 그럴듯한 케이스들을 모두 테스트한 결과 다음 결과만 얻을 수 있었습니다.

/**
 * 인코딩 콜백은 다음 순서로 들어옵니다.
 * 성공 시: WAITING - RUNNING - COMPLETE
 * 성공 시: WAITING - RUNNING - FAILURE
*/

 

 

* 아직 문서화가 되어 있지 않아서, 개인적인 예상입니다.

WAITING: 인코딩 큐에 배치

RUNNING: 인코딩 처리 시작

COMPLETE: 업로드 성공 ( complete 라서, 혹시 성공이 아닌 다른 케이스가 있는지 모르겠습니다 ) 

FAILURE: 업로드 실패 ( 원래 .txt 였던 파일 .mp4로 변환 후에 업로드를 해서 얻은 결과 )

 

아마 여기 어디 명시가 되어 있었다면 좋았을 텐데요.

https://api.ncloud-docs.com/docs/vodstation-status-info-list

 

 

 

아쉬운건 실패 시에 콘솔 -> VOD Station -> Status 에서는 이렇게 실패 사유들을 볼 수 있었으나, 이 값을 어떻게 받아와야 하는지를 모르겠습니다 .. ㅠㅠ

클라이언트에게 어떤 사유로 인코딩에 실패했는지에 대해서 알려주고 싶었는데, 서비스 상태코드 등으로 표현된다면 좋을 것 같습니다.

 

 

 

결국 일단 서비스 단에서는 다음과 같이 처리했습니다.

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

    switch (encodingCallbackRequestDto.getStatus()) {
        case "WAITING":
            connectPost(uploadKey);
            break;
        case "RUNNING":
            break;
        case "FAILURE":
            shortsRepository.updateShortState(uploadKey, ShortsStateEnum.FAIL);
            break;
        case "COMPLETE":
            shortsRepository.updateShortState(uploadKey, ShortsStateEnum.OK);
            break;
        default:
            log.error("NCPShortsService|Undefined encoding callback status message: {}",
                    encodingCallbackRequestDto.getStatus());
    }
}

서비스 내에서는 단순화 시켜서 IN_PROGRESS, OK, FAIL 이렇게 값 세 개만 관리하도록 정의하였고, DB 데이터 생성 처리 시점에 대한 내용은 이전 포스트에 담았습니다.

 

 

그리고 혹시 모를 예상치 못한 메시지에 대해서 빠르게 대응하기 위해 위와 같이 default 로그 처리하였습니다.

(에러 로그를 슬랙으로 받게 Exception 핸들러 처리를 해두었습니다. 헤헤...)

 

 

기능 개발: 미디어 상태를 푸시로 전달 (시작 / 성공 / 실패)

이제 클라이언트에게 다음과 같은 푸시 알람을 전달합니다.

  • 인코딩 시작 (사실은 대기 큐에 있는 WAITING 상태)
  • 인코딩 성공 (COMPLETE)
  • 인코딩 실패 (FAILURE)

 

 

일단 스프링 빈 등록 시점에 서버 쪽의 firebase 인증을 완료하게 설정했습니다.

원래 실제 요청을 보내는 모듈 빈의 생성자 쪽에서 해당 로직을 넣을까 고민했지만, 다른 기능들도 config에 몰아놓고 관리를 하고 있어서 동일하게 적용하였습니다.

 

다음은 파이어베이스 설정 빈입니다.

 

@Profile("prod")
@Configuration
public class FirebaseConfig {

    @Value("${firebase.project.id}")
    private String projectId;

    @Value("${firebase.private.key}")
    private String privateKey;

    @Bean
    public FirebaseApp firebaseApp() throws IOException {
        FirebaseOptions options = new FirebaseOptions.Builder()
                .setCredentials(GoogleCredentials.fromStream(
                        new ByteArrayInputStream(privateKey.getBytes())))
                .setProjectId(projectId)
                .build();

        return FirebaseApp.initializeApp(options);
    }

    @Bean
    public FirebaseMessaging firebaseMessaging() throws IOException {
        return FirebaseMessaging.getInstance(firebaseApp());
    }
}

그리고 클라이언트 식별을 위해 FCM Device Token을 로그인 시 받을 수 있게 로그인 컨트롤러 v2를 별도로 만들었습니다. 

@GetMapping("/v2/login/{provider}")
public CommonResponse<JwtResponseDto> loginV2(@PathVariable String provider, @Valid LoginV2RequestDto requestDto) {
    Long loginUserId = userService.login(provider, requestDto.getToken(), requestDto.getToken());
    return new CommonResponse<>(jwtTokenService.issueToken(loginUserId));
}

 

 

 

이제 실제로 클라이언트에게 알람을 보내는 기능을 작성해야 합니다.

여기에서는 설계 측면에서 다음 두 가지를 고려했습니다.

 

  1. 이후에도 서비스에서 이벤트성 푸시를 사용할 것을 고려해야하며, 모듈 재사용성과 클라이언트 응답 일관성을 고려해야 한다.
  2. 로컬에서 서버를 띄워서 테스트를 진행하거나, 테스트를 작성할 때 빈 자체를 Mocking하여 무의미하게 만들고 싶지 않다.

 

일단 지금은 서버:클라이언트 = 1:1 상황만 구현이 목표기 때문에, registrationToken을 통해 특정 유저 한 명에게 단건 메시지를 보내는 방식을 구현하였습니다.

 

 

일단 첫번째 조건 중 응답 일관성을 위해서, 항상 NotificationFormDto 를 만들어서, 어떤 데이터를 보내기 위해서는 해당 폼을 지키도록 하였습니다. 그리고 해당 DTO를 JSON으로 변환하여 푸시 알람의 body에 담도록 했습니다.

 

public class NotificationFormDto {
    private NotificationType type; // 어떤 타입의 푸시인가
    private String actionTarget; // 어떤 대상에 대한 푸시인가
    private String title; // 푸시 제목
    private String content; // 푸시 내용
}

여기에서 actionTarget은 푸시를 눌렀을 때 바로 업로드된 게시글로 이동해주게 한다던가, 또는 만약 이벤트 푸시라면 해당 이벤트 페이지로 이동할 수 있도록 설정하는 값입니다. 포스트의 id값이나 공지 게시글의 id값을 넣게 됩니다.

 

 

 

public class FCMNotificationClient {
    @Async
    @Override
    public void postToClient(NotificationFormDto notificationFormDto, String registrationToken) {
        Message msg = Message.builder()
                .setToken(registrationToken)
                .putAllData(objectMapper.convertValue(notificationFormDto, Map.class))
                .build();
        try {
            fcm.send(msg);
        } catch (FirebaseMessagingException e) {
            log.error("[Internal Error Message] FCM 전송 실패", e);
            throw new PhochakException(ResCode.INTERNAL_SERVER_ERROR);
        }
    }
}

이렇게 모듈화를 통해서 service layer에서 동적으로 해당 client 모듈을 이용할 수 있게 만들었습니다.

 

좋은 설계인가에 대한 확신은 없으나, 최근 AWS S3 ADMIN SDK 를 보면서 이러한 식으로 SDK를 구성한 것을 보고 인상깊었습니다.

 

 

 

그리고 두 번째로, 로컬에서 서버를 띄워서 테스트를 진행하거나 테스트를 진행할 때 빈 객체를 Mocking을 하지 않고 실제로 동작하는 객체를 넣어주고 싶었습니다. 그래서 고민을 했던게, "테스트 환경에서는 어플리케이션 로그로 대신하자." 입니다.

 

그래서 다음과 같이 테스트용 구현체를 하나 더 만들었고, @Profile()을 통해 각 상황에 맞게 등록할 빈을 구분했습니다.

@Profile("!prod") //테스트시 빈 등록

@Profile("prod") //운영환경시 빈 등록

 

 

마지막으로, NotificationService.java 에서 device token을 조회하고, FCM 활용을 위한 client를 사용하는 로직입니다.

@Override
public void postEncodeState(String uploadKey, ShortsStateEnum shortsStateEnum) {
    List<Object[]> queryResult = fcmDeviceTokenRepository.findDeviceTokenAndPostIdByUploadKey(uploadKey);

    if(queryResult.isEmpty()) {
        log.error("[NotificationServiceImpl|postEncodeState] UploadKey 와 FCM 토큰 매칭 실패. 업로드 키: {}", uploadKey);
        throw new PhochakException(ResCode.INTERNAL_SERVER_ERROR);
    }

    String deviceToken = queryResult.get(0)[0].toString();
    Long postId = Long.valueOf(String.valueOf(queryResult.get(0)[1]));

    notificationClient.postToClient(getEncodeStateMessage(postId, shortsStateEnum), deviceToken);
}
@Query("SELECT T.token, P.id FROM Post P JOIN P.user U JOIN U.fcmDeviceToken T JOIN P.shorts S WHERE S.uploadKey = :uploadKey")
List<Object[]> findDeviceTokenAndPostIdByUploadKey(@Param("uploadKey") String uploadKey);

 

마무리

전편과 함께 Naver Cloud Platform을 통해 스트리밍 서비스를 구현하는 과정을 모두 적어봤습니다.

 

클라우드 서비스에서 정말 너무 많은 것들이 추상화 되어서 밥그릇이 걱정되지만, 동작 원리를 이해하고 잘 사용한다면 이러한 시스템을 직접 구현하고 고도화 하는 과정에서도 잘 해낼 수 있지 않을까요 ?!?!

개발자로서 제 밥그릇이 걱정되기 시작했습니다.

 

덕분에 클라우드와 미디어 산업 쪽으로 진지하게 고민해보는 시간도 가졌습니다.

클라우드 제공자 입장에서 추상화된 아키텍처를 고객(클라이언트)에게 어떤 코드와 글로 설명을 할지도 고민해보는 흥미로운 시간이었습니다.

 

Contents

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

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