🗓️ 2024. 01. 21
⏱️ 20

표준 스트림을 이용한 프로세스 간 통신으로 재현성(Reproducibility) 지키기

Node.js로 제어하고 파이썬으로 연산하기

제품 언어가 파이썬이 아닌데 AI 기능을 넣어야 해요

얼마 전, 실시간으로 들어오는 영상에 AI를 적용할 일이 생겼습니다. 고민할 점은 크게 두 가지였는데요,

첫 번째는 AI 모델을 서빙할 때 사용할 언어입니다. 모델 개발 언어와 모델 서빙 언어가 달라지면 재현성(Reproducibility)에 문제가 생길 수 있습니다. 저희 팀에선 모델을 만들 땐 파이썬을, 프론트엔드와 백엔드 등의 제품을 만들 땐 TypeScript를 사용하고 있습니다. 이때 제품 내에서 AI 모델을 서빙할 때 파이썬을 사용할지 TypeScript를 사용할지 결정이 필요했습니다.

두 번째는 지연율입니다. 실시간 모니터링에 사용되는 제품인만큼 지연율을 최대한 낮게 가져가야 했습니다.

이번 글에서는 표준 스트림을 이용한 프로세스 간 통신으로 재현성과 지연율 문제를 해결했던 경험을 소개드리려 합니다.
코드부터 빠르게 보고 싶으신 분은 예제부터 읽으시면 됩니다.

이 글에서 다루는 것

  • AI 모델 개발과 서빙 사이의 언어 불일치 문제
  • 표준 스트림을 이용한 프로세스 간 통신으로
    • 데이터 간단히 주고 받을 때 오버헤드 줄이기
    • 비즈니스 로직과 연산 분리하기
  • ffmpeg과 파이썬으로 손쉽게 영상에 AI 적용하기

이 글에서 다루지 않는 것

  • Node.js Stream API의 구체적인 사용법
  • Node.js Child Process 관리법
  • 파이썬 AI 프레임워크, 이미지 처리 라이브러리 설명
  • 상세한 ffmpeg 옵션 소개

재현성(Reproducibility)

AI 모델 개발과 서빙에 공통적으로 중요한 요소는 재현성입니다. 'reproducibility' 키워드로 검색하면 재현성을 강조하는 글을 쉽게 찾을 수 있으며, 실제 모델을 서빙하고 있는 기업에서도 재현성을 지키기 위해 많은 노력을 들이고 있습니다.

카카오페이 데이터 프로덕트 팀에선 재현성에서 비롯하는 추가적인 리소스를 줄이기 위해 파이썬으로 언어를 통일했습니다.

기존에는 각 개발 영역에서의 생산성을 위해 모델 개발을 하는 머신러닝 리서처는 파이썬을 이용했고, 모델을 서빙하는 백엔드 개발자는 코틀린+스프링부트로 직접 서버를 개발했습니다. 이는 각 엔지니어들의 개발 생산성은 높였지만 아래와 같은 어려운 점들이 있었습니다.

  1. 파이썬 기반의 모델 전/후처리 코드를 코틀린으로 변환하는 과정에서 추가적인 개발이 필요했고, 종종 변환 과정에서 결과의 정합성 이슈가 발생했습니다.
    ...

개발 언어 간 변환 과정에서 발생하는 문제점을 해소하기 위해 모델 학습, 개발 및 서빙을 위한 언어를 파이썬으로 통일하기로 결정했습니다. 그리고 모델 서빙 프레임워크를 도입함으로써 나머지 문제점을 해소하고자 했습니다.

모델 서빙 최적화를 위한 프레임워크 선정과 서빙 성능 극대화하기

토스에서는 자체적인 사내 라이브러리 개발과 ONNX 등을 이용해 이를 해결하고 있습니다.

이처럼 모델을 개발할 때 뿐만 아니라 모델 개발 언어와 서빙 언어가 다를 때에도 반드시 재현성을 고려해야 합니다.

파이썬으로 통일하면 편할까?

언뜻 보기엔 파이썬으로 언어를 통일하는 게 더 쉽고 간편해 보이지만 그 난이도는 개발팀과 제품의 상황에 따라 달라집니다.
변환해야 할 코드가 상대적으로 적거나, 파이썬으로 통일했을 때의 이점이 그다지 크지 않다면 통일하지 않는 편이 좋습니다.

예를 들어, 현재 저희 개발팀의 경우 2명의 AI 엔지니어와 1명의 프로덕트 엔지니어로 구성되어있습니다. AI 엔지니어분들은 박사 학위의 연구원 출신이셔서 연구 이외의 목적으로 파이썬을 다뤄본 경험이 부족했습니다. 프로덕트 엔지니어인 저 또한 프로덕션 레벨의 파이썬 코드를 작성해본 경험이 없었습니다. 그래서 파이썬으로 언어를 통일한다면 당분간 프로덕션 레벨의 생산성이 유지되지 않습니다.

프로덕션 레벨의 파이썬 코드를 리딩할 수 있는 인원이 없는 상황 + 제품 출시까지 시간이 2달여밖에 남지 않은 상황에서 FastAPI, django 같은 서버 프레임워크나 torchserve, mlflow 같은 모델 서빙 프레임워크를 배워서 서비스에 적용하는 건 어렵기 때문에 파이썬으로 언어를 통일하는 효용이 크지 않았습니다.

특수한 제품 상황

개발팀의 리소스를 고민할 때 제품 상황 역시 함께 고려해야 합니다. 제품 상황을 잘 파악해야 그에 걸맞는 기술적 고민과 리소스 배분이 가능하기 때문입니다. 보안상 저희 제품을 자세히 설명드리긴 어려워서 예를 들어보겠습니다.

여러 공장에 CCTV를 설치해두고 생산현장을 실시간으로 모니터링하고자 합니다. 근무중인 직원들의 얼굴은 블러 처리해서 흐릿하게 가려져야 합니다. 이때 구체적인 추가 조건들은 아래와 같았습니다.

  1. 얼굴이 블러 처리된 모니터링 영상과 블러 처리가 되지 않은 원본 영상은 파일로 저장되어야 한다.
  2. AI 기능(얼굴 블러 처리)은 켜고 끌 수 있어야 한다.
  3. CCTV 영상이 공장 밖으로 나가선 안 되기 때문에 각 공장에 설치된 딥러닝 서버(로컬 환경)에서 모든 처리가 이뤄져야 한다.
  4. 하나의 딥러닝 서버에서 실시간으로 동시에 처리될 수 있는 CCTV 영상은 최대 2개이다.

여건에 맞게 설계하기

전체적인 여건을 종합해보면 로컬 환경에서 최대 2개의 실시간 영상에 AI 모델을 적용시키면 되는 상황입니다. 때문에 서빙 프레임워크 도입이나 별도의 파이썬 서버 구축은 리소스 측면에서도 어려운 상황이었지만, 제품 측면에서도 규모에 맞지 않는 오버엔지니어링이 됩니다.

그럼 어떻게 서빙 프레임워크나 웹 서버없이 모델을 서빙할 수 있을까요?
데이터(CCTV 영상)의 생성과 처리가 모두 로컬단에서 진행되고, 그 규모가 작기 때문에 단순히 파이썬 프로세스를 띄우는 것만으로도 충분히 서빙할 수 있습니다. 파이썬 프로세스의 표준 입력으로 데이터를 전달하고 표준 출력으로 연산 결과를 돌려받으면 HTTP처럼 오버헤드가 발생하지도 않습니다.

결과적으로, 익숙한 언어로 파이썬 프로세스를 제어하면서 프로세스 간 통신으로 데이터를 주고 받으면 파이썬 코드를 그대로 사용할 수 있기 때문에 재현성도 지키면서 속도까지 챙길 수 있습니다.

데이터 연산만 파이썬에게 맡기기

예제 코드를 보기 전에 핵심 아이디어가 되는 표준 스트림을 먼저 간단히 설명드리겠습니다.

표준 스트림이란?

표준 입출력(stdin/stdout)은 이미 익숙하실 텐데요, 표준 스트림(Standard Streams)은 여기에 표준 에러(stderr)까지 포함된 개념입니다. 아시다시피 키보드로 값을 입력하거나 모니터로 값을 출력할 때, 혹은 파이프라인이나 리다이렉션을 이용해 한 프로그램의 출력을 다른 프로그램의 입력으로 연결할 때 쓰입니다.

표준 스트림을 통해 프로세스 간 통신(IPC, Inter-Process Communication)을 별도의 설정없이 간단하게 구현할 수 있는데요, 간단하기 때문에 데이터 전송량이 많아지거나 통신 패턴이 복잡할 땐 적합하지 않을 수 있습니다.

이런 표준 스트림을 이용한 IPC 코드는 node.js 스트림 API로 쉽게 작성할 수 있는데요, 아주 쉬운 예시로 알아보겠습니다.

간단한 문자열 처리하기

먼저 각각의 언어에서 표준 입출력을 다루는 방법을 살펴보겠습니다.
두 예제 모두 표준 입력으로 받은 소문자를 대문자로 변환하여 표준 출력으로 내보냅니다.

// 자바스크립트에선 이렇게 사용합니다.
// index.js
process.stdin.on('data', (value) => {
  const upper = value.toString().toUpperCase();
  process.stdout.write(upper);
  
  process.exit(0);
});
# 파이썬에선 이렇게 사용합니다.
# app.py
import sys

value = sys.stdin.readline()
sys.stdout.write(value.upper())

이번엔 프로세스 간 통신으로 node.js에서 파이썬의 표준 입력으로 값을 보내고 파이썬의 표준 출력으로 값을 다시 반환받는 코드로 바꿔보겠습니다.
(파이썬 코드는 수정사항이 없습니다)

// index.js
import { spawn } from 'node:child_process';

function toUpperCase(value) {
  // 자식 프로세스로 파이썬 생성
  const pythonProcess = spawn('python3', ['./app.py']);

  // 파이썬의 표준 출력으로 나온 데이터를 콘솔에 출력
  pythonProcess.stdout.on('data', (chunk) => {
    console.log(chunk.toString());
  });

  // 파이썬 프로세스가 종료되면 콘솔로 표시
  pythonProcess.on('close', () => {
    console.log('파이썬 프로세스 종료됨');
  });

  // 파이썬의 표준 입력에 hello, world 전송
  pythonProcess.stdin.write(value);
  pythonProcess.stdin.end();
}

toUpperCase('hello, world!');
$ node index.js
HELLO, WORLD!
파이썬 프로세스 종료됨

데이터가 오가는 흐름을 그림으로 나타내면 다음과 같습니다.

마치 요청-응답 구조처럼 보이지 않나요?마치 요청-응답 구조처럼 보이지 않나요?

수정된 node.js 코드에는 문자열을 대문자로 변환하는 어떠한 로직도 없습니다. 그 연산은 모두 파이썬 프로세스에게 맡겼고, 파이썬 코드 역시 입력받은 값을 처리하기만 할뿐 별다른 로직이 없습니다. 이제 이 방식을 응용해서 영상에 AI를 적용해보겠습니다.

영상에 AI 적용하기

위 예시에선 문자열을 다뤘기 때문에 별도의 라이브러리없이 버퍼를 인코딩/디코딩할 수 있었습니다. 문자열 버퍼와 달리 미디어 인코딩/디코딩에는 외부 라이브러리가 필요합니다. 대표적인 라이브러리인 ffmpeg을 설치해주세요.

# macOS
brew install ffmpeg

# Ubuntu
sudo apt update && sudo apt install ffmpeg

제목은 'AI 적용'이라고 썼지만 핵심적인 코드만 소개하기 위해 프레임을 어둡게 만드는 정도만 처리해보겠습니다. 실제 코드에 사용하실 땐 handle_frame 함수의 구현만 고쳐주세요. 표준 출력으로 uint8 형태의 ndarray 값만 넘겨주시면 됩니다.

# darkening.py

import argparse
import sys
import numpy as np
from typing import Tuple


def get_args() -> Tuple[int, int]:
    parser = argparse.ArgumentParser()
    parser.add_argument("--width")
    parser.add_argument("--height")

    args = parser.parse_args()

    return [int(args.width), int(args.height)]


def handle_frame(frame):
  return frame * 0.3


[width, height] = get_args()
frame_size = width * height * 3


while buffer := sys.stdin.buffer.read(frame_size):
    # 이미지 처리를 위해 raw 데이터를 ndarray로 변환
    frame = np.frombuffer(buffer, np.uint8).reshape([height, width, 3])

    # 이미지 전처리
    # ...

    # AI 연산
    dark_frame = handle_frame(frame)

    # 이미지 후처리
    # ...

    # uint8의 ndarray 타입으로 변환
    result = dark_frame.astype(np.uint8)
    
    sys.stdout.buffer.write(result.tobytes())

이제 node.js에서 위 파이썬 프로세스와 ffmpeg을 활용해 이미지 처리 스트림을 만들어보겠습니다.

// index.js

import { spawn } from 'node:child_process';
import fs from 'node:fs';

const width = 1920; // 영상 너비
const height = 1080; // 영상 높이

function darkening(input) {
  // 파이썬 프로세스의 인자로 영상의 너비와 높이를 전달
  const pythonProcess = spawn('python3', [
    'darkening.py',
    '--width', `${width}`,
    '--height', `${height}`,
  ]);
  
  // 입력받은 영상을 uint8의 rgb 채널을 갖는 포맷으로 변환 후 표준 출력으로 내보내기
  const source = spawn('ffmpeg', [
    '-y',
    '-i', input,
    '-f', 'rawvideo',
    '-pix_fmt', 'rgb24', 
    'pipe:1',
  ]);
  
  // 표준 입력으로 들어온 rgb24 포맷의 영상을 mp4로 변환 후 표준 출력으로 내보내기
  const destination = spawn('ffmpeg', [
    '-y',
    '-pix_fmt', 'rgb24',
    '-f', 'rawvideo',
    '-s', `${width}x${height}`,
    '-i', 'pipe:0',
    '-f', 'mp4',
    '-pix_fmt', 'yuv420p',
    '-c:v', 'libx264',
    '-movflags', 'empty_moov',
    'pipe:1',
  ]);
  
  const writeStream = fs.createWriteStream('result.mp4');
  
  // 모든 프로세스를 node.js의 pipe 메서드로 연결
  source.stdout.pipe(pythonProcess.stdin);
  pythonProcess.stdout.pipe(destination.stdin);
  destination.stdout.pipe(writeStream);
}

darkening('sample.mp4');

darkening 함수의 마지막 세 줄을 도식화하면 다음과 같습니다.

node.js의 스트림 API로 손쉽게 데이터 흐름을 구현할 수 있습니다.node.js의 스트림 API로 손쉽게 데이터 흐름을 구현할 수 있습니다.

마지막으로 표준 스트림을 이용한 IPC로 챙긴 이점을 정리해보겠습니다.

  • 익숙한 node.js 코드로 전체 프로그램 및 비즈니스 로직 제어
  • 데이터 전/후처리 및 AI 작업은 파이썬에게 위임하여 재현성 유지
  • 서로 다른 언어로 변환하는 리소스 제거
  • 각 언어의 목적에 맞게 책임 분리
  • 추가적인 수행 시간은 파이썬 프로세스를 띄우는 정도만 소요

이상적인 해결책과 현실적인 해결책

재현성지연율이라는 큰 주제에 비해 단촐한 해결책이 나왔습니다. 해결하고자 하는 상황이 단순했기 때문에 그 해결책이 단촐한 건 당연합니다.
사실, 단촐해야만 합니다. 문제를 해결할 수 있는 가장 쉽고 빠른 선택지로 결정해야 하니까요. 대단한 기술을 활용한 건 아니지만 모델 서빙을 둘러싼 환경과 상황이 복잡하지 않다면 이런 식으로 간단히 서빙할 수도 있다는 것을 보여드리고 싶었습니다. 저는 팀에 맞는 현실적인 해결책이 곧 그 팀의 이상적인 현실책이라고 생각하거든요.

이 글에서 다루지 못한 내용들도 있는데요, 그 자체로도 큰 글감들이기 때문에 그 소재들을 남겨두고 글을 마치도록 하겠습니다.

  • 표준 스트림을 통한 IPC는 익명 파이프(anonymous pipe) 방식입니다.
  • 더 많은 양을 처리해야 한다면 파이썬을 멀티 프로세스로 띄워두고 Round-Robin하게 처리할 수도 있습니다.
  • 이 글의 예제에는 에러 처리를 포함한 자식 프로세스 관리 코드가 하~나도 없습니다. 실무에서 그대로 쓰시면 안돼요!
돌아가기
© 2024 VERYCOSY.