Cover image of latest post

Project

Web

DevOps

Music

[Showpang] 프로젝트 1차 정리

Projects - July 3, 2024

프로젝트에 관해

현재 Showpang 이라는 프로젝트를 진행중이다. Showpang은 사용자가 장르, 무드, 템포를 선택하면 그에 맞는 음악을 생성하여 스트리밍 할 수 있는 웹 서비스이다.

원래부터 음악에 관심이 많았고, 작곡해본 경험도 몇몇 있던지라 음악과 개발을 엮으면 더욱 재미있을 것 같아 참여하게 되었다. 작곡(+α; 잡?무) 부분을 맡게 되었고, 추후에 AI 기능을 접목시킬 예정이라 일단은 python으로 화성확 + 확률 기반의 작곡 알고리즘을 만들게 되었다!

개발 내용

1~2 주에 한 번 만나서 개발하는 것이었고, 프로젝트에 참여한 인원 대부분이 꽤나 바쁜 탓에, 솔직히 그리 애자일한 개발이 진행되지는 못했다. 하지만, 그럼에도 불구하고 나름 잘 개발했고, 모였을 때 다양한 분야의 기술적 대화를 나눌 수 있었다.

아래의 내용들은 23년 12월부터 24년 6월까지의 전반적인 개발 내용을 요약하고, 그 중 중요하다고 생각한 것들을 추린 것이다.

음악 생성 파트

python의 pretty_midi 모듈을 이용해서 midi 파일을 만드는 방식으로 진행하였다.

멜로디 패턴 확률모형 제작

멜로디 패턴멜로디 패턴

먼저, 주어진 장르, 무드에 따라 randomness 라는 값을 변화시켜서 멜로디의 규칙성을 조절했다.

4/4박자 기준으로, randomness 가 낮을 수록 정박(4분음표)에 음이 나오도록 하였고, 높을 수록 무작위하게 (높은 엔트로피) 나올 수 있도록 하였다. 중요한 것은, 균등하게 확률이 퍼지는 것이 아니라, 한 박의 subdivision 내에서도 정박성을 유지하게끔 하면서 퍼지게 하는 것이었다.

따라서 이러한 randomness 에 따른 멜로디의 등장 확률 분포를 만들 필요가 있었다.

# 패턴의 확률분포를 만듦.
# 큰 randomness는 더욱 분산이 큰 분포를 만듦 = 높은 엔트로피.
def _make_probability_distribution(self):
    depth = np.log2(self.division).astype(np.uint8) - 1
    weights = np.zeros(depth)

    r = self.randomness
    primary = -0.5*(r - 2)

    def h(x, a): return x/a

    # 정박이 아닌 박의 확률을 구하기 위해 interpolate.
    # somthing magical happens (?)
    weights[0] = primary

    for i in range(depth - 1):
        weights[i+1] = (1-h(r, 2**i))*(1-primary) + h(r, 2**i)*primary

    pd = np.zeros(self.bar_length * self.division)

    for i in reversed(range(depth)):
        step = (self.division // self.measure[1]) // 2**i
        pd[::step] = weights[i]

    self._pd = pd

간단한 interpolation 을 이용해서 이를 구현할 수 있었다.

다음 멜로디 결정 정책

이 이외에도, 다음 멜로디를 부드럽게 연결시키기 위해서 정규분포를 사용하였다. 또한, 현재 코드에 있는 음이 선택될 확률을 높였다.

FastAPI 서버 구축 및 연동

FastAPI 서버FastAPI 서버

spring 벡엔드 서버에서 음악 생성 요청을 받고, 생성된 음악을 응답해야 했기 때문에 FastAPI 서버를 구축했다.

다음과 같은 순서로 음악 생성 요청이 처리된다.

  1. 음악 생성 요청을 받는다 (장르, 무드, 템포)
  2. 음악 생성 함수를 호출한다.
  3. 음악 생성 함수는 정해진 알고리즘에 따라 midi 파일을 생성한다.
  4. fluidsynth 가 주어진 soundfont 를 이용해서 midi → wav 로 렌더링한다.
  5. ffmpeg 가 wav → mp3 로 인코딩한다.
  6. 생성한 파일(주기적으로 삭제됨)을 저장하여 FastAPI 가 응답을 보낼 수 있도록 한다.
  7. 음악 파일을 스트림 형태로 응답한다.

일단 적은 수의 요청이 들어왔을 때에는 문제 없이 저 과정들이 수행되었다. 하지만, 많은 요청이 비동기로 들어왔을 때 문제가 발생했다.

문제점

학생신분이었던 우리는 최대한 무과금으로 프로젝트를 진행하기 위해서 aws 의 프리티어를 이용하고 있었다. 하지만, 프리티어의 ec2 인스턴스는 vCPU 1개, 메모리도 1GB 의 가난한 스펙이었기 때문에 매 요청 처리 시 cpu utilization 이 100% 가까이 기록되었다. 그러다 보니, 서버가 이를 버티지 못하고 죽는 경우도 허다했다.

병목 지점을 찾기 위해 성능 모니터링을 진행했고, ffmpeg 가 그 원인임을 알게 되었다. ffmpeg 의 cpu 활용률을 줄이려니 응답속도가 확연히 느려졌고, 이 방법 또한 수많은 요청을 견뎌내기는 힘들어 보였다.

이를 어떻게 해결했는지는 다음 포스트에.

스트리밍

AWS Lambda + Redis 를 이용한 일회용 presigned-url 발급 (폐기)

음악 스트리밍은 HLS(HTTP Live Streaming) 를 사용하기로 결정했다. 기본적으로 스트리밍은 유저에게 온전한 mp3 파일을 보내지 않기 때문에 유저에게 쪼개진 음악 파일(.ts) 과 그 순서를 명시한 플레이리스트 파일(.m3u8) 을 전달하여 원활한 스트리밍이 될 수 있도록 구현하였다.

하지만 동일한 플레이리스트 파일을 요청하면 인증되지 않은 사용자도 스트리밍을 할 수 있다는 문제가 있었다. 따라서, 처음에 유저의 인증 여부를 확인하고 딱 한 번만 사용할 수 있는 요청 url 을 알려주는 방식을 생각했다.

AWS 의 cloudfront 에는 presigned url 기능이 있는데, 이를 활용하면 s3의 데이터에 일정 시간만 접근할 수 있는 url 을 만들 수 있다. 하지만, 접근한 횟수 기반으로 요청을 제한하는 기능은 없었다. 따라서, 외부 redis 서버를 이용해서 해당 url 을 해싱하여 캐싱하는 방법을 사용했다. (마치 분산 락 처럼?) 대략적인 워크플로우는 다음과 같다.

presigned url 워크플로우presigned url 워크플로우

  1. 유저가 음악 플레이리스트 파일을 요청한다
  2. Cloudfront 에 붙은 Lambda@Edge 에서 요청된 주소를 해싱한다.
  3. 이것이 외부 redis 서버에 저장되어 있는지 확인한다.
    1. (없다면) redis 에 키값으로 저장하고, presigned url 을 발급하여 응답한다.
    2. (있다면) 사용된 url 이므로 403 forbidden 응답을 보낸다.


redis 에 저장할때는 timeout 을 걸어주어 일정 시간이 지나면 없어지게 했다.

실제로 잘 작동함을 확인했지만, 굉장히 먼 길을 돌아가는 느낌을 받았다. 외부 redis 를 써야하는 문제점도 있고, 분명히 더 간단하게 해결할 수 있을 것 같았다. 또한, 직접 테스트 해보진 않았지만 동시성과 관련된 문제도 고려해봐야 할 것 같았다.

따라서, 팀원들과 논의 끝에 signed cookie 를 사용하기로 결정하였다. 이것이 훨씬 간단하고, 인증여부에 따른 권한 부여도 훨씬 쉬웠기 때문이다. 이에 대한 자세한 이야기도 추후 포스트에.

Github Actions 를 이용한 CI/CD

우리는 상당히 가난한 대학생이었고, 그래서 aws free tier 를 사용했다. 하지만, 프리 티어에서 쓸 수 있는 ec2 인스턴스는 cpu 1 코어에 1GB 메모리라는 상당히 저조한 사양을 가지고 있었기 때문에, 도커 이미지를 빌드하는 것 조차 불가능했다.

따라서, 배포를 위해 github actions 에서 도커 이미지를 빌드하고, 이후 ec2 에서는 이미지를 풀 받는 방식으로 개발했다.

github actions로 구성한 CI/CDgithub actions로 구성한 CI/CD

추후에 테스트도 붙여서 보다 괜찮은 CI/CD 파이프라인을 구축할 계획이다.

프론트엔드 도커 이미지 다이어트 (feat. Multi-Stage Build)

프론트엔드는 next.js 를 사용했는데, 단순하게 도커 이미지로 만드니 2GB 라는 무시무시한 크기를 가지게 되었다. (spring 도 500MB 정도인데 ㄷㄷ)

원인을 분석해보니, 최종 배포에 필요하지 않은 파일이나 의존성 등이 포함되어서 그랬던 것이었다. 따라서, next.js 공식 문서에서 추천하는 도커 빌드 방법을 참고하여 도커 파일을 구성하였다.

next.js/examples/with-docker/Dockerfile at canary · vercel/next.js

next.js/examples/with-docker/Dockerfile at canary · vercel/next.js

The React Framework. Contribute to vercel/next.js development by creating an account on GitHub.

GitHub

FROM node:20-alpine AS base

# === Layer 1: 의존성 및 node_modules 설치
# 의존성 설치
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /src

# 노드 패키지 설치
COPY . ./
RUN yarn --frozen-lockfile
RUN rm -rf ./.next/cache

# === Layer 2: next 빌드 ===
# 프로젝트 빌드
FROM base AS builder
WORKDIR /src
COPY --from=deps /src/node_modules ./node_modules
COPY . ./
RUN yarn build

# === Layer 3: 프로젝트 실행 ===
FROM base AS runner
WORKDIR /src

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# COPY --from=builder /src/public ./public  # 나중에 public 폴더가 있으면 추가
COPY --from=builder --chown=nextjs:nodejs /src/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /src/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

프론트엔드 도커 이미지 다이어트프론트엔드 도커 이미지 다이어트

이렇게 Dockerfile 을 바꿈으로써 이미지 크기가 줄어들었고, 도커허브에 푸시/풀 하는 시간이 줄어들어서 전체적인 배포 시간이 단축되었다!

FastAPI 서버 도커라이징 개선

그 외에도 사소하게 FastAPI 로 만들었던 음악 생성 서버의 도커라이징을 개선했다.

python 외에도 ffmpeg, fluidsynth 같은 외부 프로그램을 사용했기 때문에, 이것들도 도커 이미지에 넣었어야 했는데, 매번 ubuntu 위에 ffmpeg, fluidsynth 를 까는 것이 큰 시간적 낭비라고 생각했다.

따라서, 먼저 python, ffmpeg, fluidsynth 가 있는 이미지(크게 수정되지 않으므로)를 준비하고, 이를 매 배포마다 풀 받아서 여기에 필요한 모듈과, 변경된 코드를 올리는 방식으로 수정했다.

도커라이징 속도 개선도커라이징 속도 개선

결과적으로 배포에 걸리는 시간이 많이 단축되었다!

느낀 점

아직 갈길이 무척이나 멀지만, 일단 다양한 문제들을 직접 경험해보고, 이를 극복해나가는 과정이 상당히 뜻깊었다.

특히, 기존에는 아예 모르던 데브옵스쪽을 한 번 건들여보니 전반적인 개발 및 배포 과정이 그려진 것 같은 느낌이 들었다. 나중에 2차 정리도 기대하시라!