프로젝트 살펴보기 - 음악 스트리밍 서비스

강찬우·2024년 2월 19일

이번에 팀 프로젝트를 진행하면서 다른 사람들은 어떤 주제를 가지고 프로젝트를 진행했는지, 어떤 기술을 이용했는지 등에 대한 궁금증이 생겨 다른 분들의 프로젝트를 살펴 보고자 했다.

https://github.com/Techit-BitHarmony/Comma?tab=readme-ov-file
이번에 살펴볼 프로젝트는 이 프로젝트 이다.

이 프로젝트는 인디 작곡가를 위한 스트리밍 플랫폼이다. 이 프로젝트에서 궁금한 기술을 스트리밍을 서비스하는 방식과 scheduler였다. 이번에 진행한 프로젝트에서 간단한 스케쥴링이 필요했는데 단순한 작업이라 스프링의 기본적인 스케쥴러를 이용하였다. 그런데 이 프로젝트에서는 Quartz Scheduler라는 기술을 이용했기에 살펴보려 한다.


@RestController
@RequestMapping("/streaming")
@RequiredArgsConstructor
public class StreamingController {

    private final AlbumService albumService;
    private final StreamingService streamingService;
    private final SseProvider sseProvider;

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/upload") // get upload presigned url
    public GlobalResponse<UploadUrlResponse> getUploadURL(@ModelAttribute @Valid UploadUrlRequest uploadUrlRequest) {
        return GlobalResponse.of("200", streamingService.generateURL(uploadUrlRequest.filename()));
    }

    @Async
    @PostMapping("/status") // callback encode status
    public void encodeStatus(@RequestBody EncodeStatusRequest encodeStatusRequest) {
        streamingService.encodeStatus(encodeStatusRequest.filePath(),
                encodeStatusRequest.outputType(), encodeStatusRequest.status());
    }

    @GetMapping("/status") // sse emitter subscribe
    public SseEmitter getEncodeStatus(@RequestParam(name = "filePath") String filePath) {
        return sseProvider.subscribe(streamingService.extractUUID(filePath));
    }

}

스트리밍 컨트롤러를 살펴보면

getUploadURL(): 파일 업로드를 위한 사전 서명된 URL을 가져오는 GET 요청을 처리한다. 클라이언트는 업로드할 파일의 이름을 전달하고, 서버는 해당 파일에 대한 사전 서명된 URL을 생성하여 응답한다.

encodeStatus(): 파일 인코딩 상태를 수신하는 POST 요청을 비동기적으로 처리한다. 클라이언트는 인코딩된 파일의 경로, 출력 유형 및 인코딩 상태를 전송하고, 서버는 이를 처리하여 해당 파일의 인코딩 상태를 업데이트한다.

getEncodeStatus(): SSE (Server-Sent Events)를 사용하여 파일 인코딩 상태를 구독하는 GET 요청을 처리한다. 클라이언트는 파일 경로를 전달하고, 서버는 해당 파일에 대한 SSE 연결을 생성하여 클라이언트에게 인코딩 상태를 전송한다.

StreamingService.java

@Service
@RequiredArgsConstructor
public class StreamingService {

    private final RedisTemplate<String, String> redisTemplate;
    private final RedisMessageListenerContainer container;
    private final EncodingStatusListener encodingStatusListener;
    private final ChannelTopic topicStatus;

    private final NcpMusicUtil ncpMusicUtil;

    static private final String CHANNEL_NAME = "encodingStatus";

    public UploadUrlResponse generateURL(String fileName) {
        String filePath = ncpMusicUtil.path + UUID.randomUUID() + getExtension(fileName);
        return UploadUrlResponse.from(ncpMusicUtil.generatePresignedUrl(filePath), filePath);
    }

    private String getExtension(String fileName) {
        return fileName.substring(fileName.lastIndexOf('.'));
    }

    public void encodeStatus(String filePath, String outputType, EncodeStatus status) {
        switch (status) {
            case WAITING -> {
                container.addMessageListener(encodingStatusListener, topicStatus); // 토픽 등록
                sendEncodingStatus(extractUUID(filePath), outputType, EncodeStatus.WAITING);
            }
            case RUNNING -> {
                sendEncodingStatus(extractUUID(filePath), outputType, EncodeStatus.RUNNING);
            }
            case COMPLETE -> {
                sendEncodingStatus(extractUUID(filePath), outputType, EncodeStatus.COMPLETE);
                container.removeMessageListener(encodingStatusListener, topicStatus); // 토픽 해제
            }
            case FAILURE -> {
                sendEncodingStatus(extractUUID(filePath), outputType, EncodeStatus.FAILURE);
                throw new EncodingFailureException();
            }
            case CANCELED -> {
                sendEncodingStatus(extractUUID(filePath), outputType, EncodeStatus.CANCELED);
            }
        }
    }

    private void sendEncodingStatus(String filePath, String outputType, EncodeStatus status) {
        String message = extractUUID(filePath) + ":" + outputType;
        redisTemplate.convertAndSend(CHANNEL_NAME, message + ":" + status);
    }

    public String extractUUID(String filePath) {
        String regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(filePath);
        if (matcher.find()) {
            return matcher.group();
        }

        throw new EncodingStatusNotFoundException();
    }

}

generateURL(String fileName): 업로드할 파일의 이름을 받아 해당 파일에 대한 사전 서명된 URL을 생성한다. 이 URL은 클라이언트가 파일을 업로드하기 위해 사용된다.

encodeStatus(String filePath, String outputType, EncodeStatus status): 파일의 인코딩 상태를 처리한다. 주어진 파일 경로, 출력 유형 및 상태에 따라 다양한 작업을 수행한다. 상태에 따라 Redis를 통해 메시지를 발행하고, 필요에 따라 메시지를 구독하는 Redis 메시지 리스너를 등록 또는 해제한다.

sendEncodingStatus(String filePath, String outputType, EncodeStatus status): 파일의 인코딩 상태를 Redis 채널을 통해 발행한다. 이 메서드는 encodeStatus() 메서드 내부에서 호출되며, 인코딩 상태를 Redis 채널을 통해 전달한다.

extractUUID(String filePath): 주어진 파일 경로에서 UUID를 추출한다. 파일 경로에서 UUID를 추출하는 데 사용된다.

여기서 컨트롤러에서 사용된 메서드는 generateURL과 encodeStatus이다.

 async function uploadMusicFile() {
        if (!musicFile) {
            toastWarning("음악 파일을 선택해주세요.");
            return;
        }

        const res = await fetch(`${$baseUrl}/streaming/upload?filename=${encodeURIComponent(musicFile.name)}`, {
            method: 'GET',
            headers: {
                'Authorization': getCookie('accessToken'),
            }
        });

        const statElement = document.getElementById('stat');

        if (res.ok) {
            const { data } = await res.json();

            filePath = data.filePath;

            let encodedFilePath = encodeURIComponent(filePath);
            let source = new EventSource($baseUrl + `/streaming/status?filePath=${encodedFilePath}`);
            source.addEventListener('Encoding Status', handleEvent);

            // Presigned URL로 파일 업로드
            const uploadRes = await fetch(data.uploadUrl, { method: 'PUT', body: musicFile });

            if (uploadRes.ok) {
                if (statElement) statElement.innerHTML = '업로드 성공!';
                toastNotice('업로드 성공!');

            } else {
                if (statElement) statElement.innerHTML = '업로드 실패!';
                toastWarning('업로드 실패!');
            }
        } else {
            if (statElement) statElement.innerHTML = 'Presigned URL을 받아오는 데 실패했습니다.';
            toastWarning('Presigned URL을 받아오는 데 실패했습니다.');
        }
    }

프론트의 이 코드에서 위 엔드포인트로 신호를 보내는데 처음에 presigned url을 만들고 그 요청이 정상적으로 실행되었다면 EventSource를 사용해서 SSE를 통해 파일의 인코딩 상태를 /streaming/status 엔드포인트에 연결하여 파일 업로드 후 서버에서 제공하는 SSE를 통해 파일의 인코딩 상태를 실시간으로 수신한다.

코드를 살펴보던중 업로드나 인코딩에 대한 코드가 부족하다고 생각했는데 ncloud의 object storage와 vod station의 기능들을 이용해 object storage에 파일이 올라가게 되면 그 파일에 대해 자동으로 인코딩을 진행한다는 것을 알게 되었다. 또한 음악파일을 업로드 하는 요청에 대해 찾아보았는데 pre signed url이라는 것을 생성하면 그 주소에 요청을 보내면 storage에 파일이 업로드 되는 것 이라는 것을 알게 되었다.

문서
https://guide.ncloud-docs.com/docs/vodstation-cloudfunctions
오늘 이 코드를 보면서 sse와 presigned url에대해 더 공부해봐야 겠다는 생각을 하게 되었다.

나머지는 내일하자....

0개의 댓글