이번에 팀 프로젝트를 진행하면서 다른 사람들은 어떤 주제를 가지고 프로젝트를 진행했는지, 어떤 기술을 이용했는지 등에 대한 궁금증이 생겨 다른 분들의 프로젝트를 살펴 보고자 했다.
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에대해 더 공부해봐야 겠다는 생각을 하게 되었다.
나머지는 내일하자....