Logo Ernie.SG

Berlayar: 안정적이고, 확장 가능하고, 유연한… 스택 만들기

2023년 8월 29일
9분 읽기
사용 가능한 태그가 없습니다
Table of Contents

내가 특히 A.I.에 끌리는 이유는, 이 기술이 적어도 인지와 창작의 차원에서는 ‘인간이라는 것이 무엇인가’라는 우리의 생각을 가장 가까이에서 찔러 보고 들춰 보는 기술이기 때문이다. 그런 말을 할 수 있는 기술은 많지 않다. 그리고 나는 아직 이 A.I.의 순간이 지닌 무게가 우리 어깨 위에 온전히 내려앉았다고 생각하지 않는다. 콘텐츠를 만들어 내는 비용과 장벽이 급격히 낮아지는 가운데, 우리가 살아 움직이는 역사 속에 산다는 것이 대체 무슨 뜻인지에 관한 이 감각 말이다. 사실 그런 순간을 살아가던 인류 대부분은 인쇄기가 종교개혁과 그 이후의 모든 것을 불러오리라, 혹은 인터넷이 대통령 선거와 얽히리라 미리 내다보지 못했다. 그렇게 여기저기서 Proof of Concept(PoC)을 몇 개 만들고 나니, 나는 전통적인 소프트웨어 개발 방식과 데이터 기반 개발이 앞으로 이뤄지는 방식 사이에 근본적인 차이가 있다는 쪽으로 내려앉고 있다. 전통적인 프로그래밍과 머신러닝의 차이는 이 속담과 비슷하다.

“사람에게 물고기 한 마리를 주면 하루를 먹일 수 있다. 물고기 잡는 법을 가르치면 평생을 먹일 수 있다.”

ML/LLM 기반 개발은 소프트웨어 개발의 패러다임 전환이다

우리 대부분은 실제 소프트웨어 개발에서 무슨 일이 벌어지는지조차 잘 모르는데, 데이터 기반 개발은 여기에 ‘유사 과학’처럼 보이는 손보기와 확률적 사고까지 잔뜩 끼얹는다. 그래서 여기에 따라오는 많은 프로세스, 워크플로, 도구는 숙련된 엔지니어에게도 거의 신세계다. 이 글은 두 실천 방식의 역사적 흐름과 차이를 꽤 잘 정리해 두었다. 다만 슬프게도 내가 느낀 바에 따르면 기술이 퍼지는 속도와 방식은 지역에 따라 너무 달라서, 우리는 이미 모두 타임머신 안에 살고 있다. SFTP가 떠오른다…

문제의 핵심은 결국 이것이다. 기계에게 어떻게 학습을 가르칠 것인가? 수집하는 데이터부터 차원 축소, 모델 선택, 하이퍼파라미터 튜닝 등 전달 과정의 모든 단계에서 계속 실험하면서도, 최종 사용자에게 안정적인 서비스를 어떻게 제공할 것인가? 대규모 언어 모델은 자연어, 지식, 추론 도구라는 강력한 무기를 들고 오지만, 자기만의 기묘한 성격도 있다. 그래서 이 모든 난제가 합쳐진 끝에 나는, 물론 ChatGPT의 도움을 받아, 이런 시스템을 확장하고 운영하려면 레지스트리 기반이면서 인터페이스 중심인 접근이 필요하다는 결론에 이르렀다.

조금 과학적 절차와도 비슷하다. 반복되는 작업과 워크플로가 있고, 결과를 계속 모니터링해야 하며, 이상적으로는 제공을 위한 트리거까지 자동화해야 한다. 그래서 이전에 만들어 본 실험들, 거기서 반복해서 보인 패턴들, 그리고 내가 이 시스템을 어떻게 만들려고 생각하는지 큰 줄기만 적어 보려 한다.

주말 프로토타입들

주말 하나로 무엇을 만들 수 있을까? 알고 보니 꽤 많은 것을 만들 수 있다. 하나가 다른 하나로 이어지고, 새 앱과 경험을 만들 수 있는 우주가 통째로 열린다. 하지만 마지막 제약은 결국 개발자 시간이다. 그래서 지금 내 관심은 어떤 개발자든 효율을 100배, 어쩌면 1000배까지 끌어올릴 수 있게 해 줄 기반 요소를 만드는 데 있다. 그러길 바란다.

Bertrand - 프롬프트에서 발행까지

동기/사용 사례: 최소한의 노력과 유지보수로 1,000명이 매달 100달러를 내게 하려면 무엇을 만들 수 있을까를 생각하다 보니 이걸 해 보고 싶어졌다. 프롬프트에서 발행까지 이어지는 도구, 혹은 완전 자동화된 소셜 미디어 에이전시라면 가능해 보였고 기술적으로도 만들 수 있어 보였다. 그래서 위 기능들을 가지고 놀아 보려고, 내가 수강 중이던 코스의 플래시카드를 만들어 보았다. 응답이 더 관련성 있게 나오는지, 아주 제한된 상황에서 LLM의 생성 능력을 어디까지 밀어붙일 수 있는지 보고 싶었다.

가설: LLM에 새로운 지식을 넣고 기억을 부여하면, 더 나은 Retrieval Augmented Generation(RAG)을 만들고 경험을 개인화할 수 있다.

Bertrand - ChatGPT 프롬프트-투-퍼블리시 플러그인

결과와 배운 점: 이것이 GraphQL과 벡터 데이터베이스, 정확히는 Weaviate와의 첫 만남이었다. 덕분에 내가 이전 발표에서 한참 떠들었던 임베딩도 훨씬 더 잘 이해하게 됐다. 내가 필요하다고 생각한 것은 개별 프로세스를 서로 연결해, 새로운 트렌드를 학습하고 콘텐츠를 생성한 뒤 예약하고 발행하는 과정을 처음부터 끝까지 자동화하는 방식이었다. 그리고 물론 그런 게 있었다! Langchain과 Deep Lake를 발견했고, 다음 PoC들에서 더 만지작거리기 시작했다.

Arthur - 검색과 가져오기

동기/사용 사례: ChatGPT로 이것저것 만들다 보니, 그리고 최소 자원으로 최대 효율을 뽑아내는 것에 대한 내 평소 애정 덕분에, LLM 기반 페어 프로그래머가 문서, 자료, 웹 검색 등에서 계속 알아서 새로운 것을 배울 수 있으면 좋겠다는 생각이 들었다. 명령을 받으면 자율적으로 코드를 짜고, 컴파일하고, 만난 오류를 바탕으로 스스로 고칠 수 있다면 더 좋고. 직접 검색하고 읽는 것보다, 찾던 답을 담은 응답이 바로 돌아오는 편이 빠르다. 또 사람과 사람 사이의 커뮤니케이션이 억양과 응답 지연 때문에 얼마나 어렵고 시간을 잡아먹는지에도 꽤 지쳐 있었다. 그렇다면 그냥 A.I.가 기존 코드베이스를 학습하게 하고 거기에 질문하면 되지 않을까? A.I.가 기존 프로젝트 구조와 코드베이스에 대한 지식을 갖추고 나면, 프롬프트에서 기능 구현까지 가는 일이 그렇게 먼 도약은 아닐 것처럼 보였다.

가설: LLM이 서로 다른 지식 원천을 의미 있게 다루는 전략을 만들면, 무엇이든 조회하고 그 위에서 생성할 수 있다.

Arthur - 무엇이든 검색하고 가져오기

결과와 배운 점: 생성된 콘텐츠의 품질은, 그게 정말 중요하다면, 약간 문제가 된다. 그래서 더 큰 요지는 LLM 기반 앱이 민첩성과 반복의 수준을 완전히 끌어올린다는 것이다. 모든 단계가 반복 개선과 최적화의 대상이 되기 때문이다. 더 많은 콘텐츠를 먹이면 더 나은 콘텐츠를 만들지도 모른다. 콘텐츠의 의미 구조를 더 잘 이해하도록 도와주면 검색과 생성도 나아질지 모른다. 나는 .json을 객체 단위로 쪼개는 방식을 만들었다. 그게 합리적으로 보였기 때문이다. 하지만 토큰 제한에 걸려서 가져오는 청크 수를 제한해야 했다. 수집/적재 대상이 되는 모든 데이터 소스, 문서든 코드든 오디오든 비디오든 이미지든, 그 무엇이든 더 작거나 더 의미 있는 단위로 나누는 방식은 너무나 많고, 사용할 수 있는 임베딩 모델도 다르고, 벡터 저장소(여기서는 Deep Lake를 썼다)와 검색 전략도 다르다. 그래서 프로덕션에는 정말 이런 시스템이 필요하다.

  • 안정적일 것 - 사용자가 일관된 경험을 할 수 있도록

  • 기능 확장이 쉬울 것 - 데이터 수집/적재를 위한 새 소스나 새 임베딩 모델 등을 쉽게 추가할 수 있도록

그리고 ZOMG, 가상 환경과 컨테이너화는 정말 중요하다. 코드 한 줄 쓰기도 전에 설정 과정의 3/4쯤을 차지하는 느낌이다…

The Sound of Stories

동기/사용 사례: 나는 이 기술들을 실제로 실시간으로 진행되는 공연에 배치하고, A.I.로 인간의 능력을 증강해 이전에는 불가능했던 일을 할 수 있게 만드는 데 관심이 있다. 관객이 이야기꾼이든 사랑하는 사람이든 누군가의 목소리로, 여러 언어로 이야기를 들을 수 있는 인터랙티브 스토리텔링 경험으로 이어질 만한 여러 컴포넌트를 실험해 보니, 이 모든 것을 한데 묶어 사람들 앞에 가져가는 것이 자연스러운 다음 단계처럼 보였다.

가설: A.I.를 통해 새로운 경험과 능력을 열고 서로 쌓아 올릴 수 있다. 예컨대 텍스트 생성과 다른 서비스를 연결해, 생성된 이야기를 누군가가 읽어 주게 만드는 식이다.

The Sound of Stories

결과와 배운 점: Streamlit은 LLM 기반 앱의 UI를 프로토타이핑하기에는 좀 별로다. Chainlit이 더 나아 보인다. SeamlessM4T는 다소 실망스럽지만, 내가 잘 아는 언어들에서는 텍스트-텍스트, 텍스트-음성, 음성 복제가 이미 해결된 문제에 가깝다. 여기서 진짜 장애물은 지연 시간과 다른 언어들일지도 모른다. 세 PoC에는 모두 반복되는 패턴이 있었다. 내가 어떤 데이터 소스를 수집/적재하고, 어떤 방식으로든 청크로 나눈 다음(특정 문자 기준으로 나누거나, 객체 기준으로 나누거나), 앞의 두 경우에는 검색을 위해 벡터 데이터베이스에 저장할 임베딩을 생성했다. 이번 경우에는 “프롬프트 엔지니어링”이 훨씬 많았다.

덧붙이자면, 이 PoC 이후로 나는 프롬프트 엔지니어링이 진짜 직업이 될 것 같지는 않다고 생각하게 됐다. 설령 직업이 된다 해도 현대판 타자기 업무 같은 것에 가까울 것이다. 프롬프트를 반복해서 다듬는 이 전체 과정은 자동화가 훨씬 더 잘할 수 있는 일처럼 느껴진다. 실제로 규모가 커질 때 가장 큰 과제 중 하나는 결국 이것이다. 내가 한 조정이 성능에 미치는 영향을 어떻게 효과적으로 모니터링하고 평가할 것인가? 사람의 검토를 넣을 것인가? MLOps로 과정을 최대한 처음부터 끝까지 자동화할 것인가?

이 특정 PoC에서는 서로 다른 서비스 사이에 데이터를 넘기고, 멀티모달리티를 조금 더 가지고 놀고 싶었다. 예를 들어 생성된 텍스트를 SeamlessM4T에 넘겨 오디오를 만들 수 있으니, LLM이 생성한 출력은 다른 서비스가 소비할 수 있다. 동시에 어떤 형태로든 최종 사용자에게 렌더링될 수도 있다. 다시 말해 이런 RAG의 소비 또는 표현 계층 역시 맞춤화할 수 있다.

그래서 개발과 프로덕션에서 안정적이고 확장 가능한 시스템이라는 조건에 더해, 이 시스템은 다음도 필요하다.

  • 유연할 것 - 워크플로를 쉽게 재구성하거나 컴포넌트를 교체할 수 있도록

  • 규모 확장성이 있을 것 - 실시간 상황에서 일정 수준의 지연 시간과 품질을 보장할 수 있도록. 결국 내가 걱정하는 것은 실제 규모에서의 성능이다.

1부: 확장 가능한 수집/적재, 청크 분할, 임베딩 시스템 설계하기

그렇다면 실제로 이 시스템은 어떤 모습일까? 나는 아직 리팩터링을 하며 과정을 하나씩 밟아 가는 중이고, 만들면서 새로운 용어와 프로세스를 익히고 있다. 일단 큰 줄기만 아래에 풀어 보자면 이렇다.

레지스트리를 둔다.

이 레지스트리는 파일 확장자와 그에 대응하는 프로세서의 매핑을 관리한다. 각 프로세서는 해당 파일 형식을 어떻게 청크로 나누고 파싱할지 알고 있어야 한다.

FILE_TYPE_PROCESSORS = {
    ".py": PythonFileProcessor,
    ".md": MarkdownFileProcessor,
    # Add other file types and their processors here...
}

기본 클래스도 둔다.

from abc import ABC, abstractmethod
 
class DataSource(ABC):
    """
    Base class for any data source. This provides a generic interface for data ingestion.
    """
 
    @abstractmethod
    def ingest(self):
        """
        Ingest data and return a consistent format for further processing.
        """
        pass
 
    @abstractmethod
    def get_metadata(self):
        """
        Extract metadata from the data source. This metadata can vary depending on the source type.
        """
        pass
 
class GitRepoSource(DataSource):
    def ingest(self):
        # Logic to clone/pull git repos and filter relevant files.
        pass
 
    def get_metadata(self):
        # Extract metadata specific to git repositories.
        pass
 
class PDFSource(DataSource):
    def ingest(self):
        # Logic to ingest PDFs.
        pass
 
    def get_metadata(self):

파일 프로세서, 청크 분할, 임베딩, 그리고 파이프라인의 다른 단계들에 대해서도 필요에 따라 같은 방식을 반복한다.

CHUNKING_STRATEGIES = {
    ".py": PythonCodeChunker,
    ".md": MarkdownChunker,
    # ... other chunkers
}

즉 아래와 같은, 설정 가능하고 유연한 처리 로직이 필요하다는 뜻이다.

def process_files(files, embedding_strategy_name):
    embedding_strategy = EMBEDDING_STRATEGIES[embedding_strategy_name]()
 
    for file_path in files:
        file_extension = os.path.splitext(file_path)[1]
        file_processor = FILE_TYPE_PROCESSORS.get(file_extension)
 
        if not file_processor:
            print(f"No processor found for {file_extension}. Skipping...")
            continue
 
        chunks = file_processor().process(file_path)
        for chunk in chunks:
            embedded_text = embedding_strategy.embed(chunk["content"])
            # Store embedded_text and metadata...

이런 레지스트리와 추상 클래스 접근 방식은 다음과 같은 이점을 준다.

  • 규모 확장성(Scalability): 딕셔너리(FILE_TYPE_PROCESSORSEMBEDDING_STRATEGIES)를 사용해 파일 형식과 임베딩 전략을 각각 대응하는 프로세서에 쉽게 매핑한다. 새 파일 형식이나 임베딩 방식 지원은 딕셔너리를 확장하는 일만큼 단순하므로, 이 설계는 본질적으로 규모 확장성이 높다.

  • 견고함: 관심사를 더 뚜렷하게 분리하고 실패를 더 효과적으로 격리할 수 있다. 한 파일 프로세서의 코드 변경이 다른 프로세서에 영향을 주어서는 안 된다.

  • 기능 확장성(Extensibility): 처음부터 기능 확장을 염두에 둔 설계다. 앞서 말했듯 새 파일 형식이나 임베딩 전략을 지원하려면 새 클래스를 만들고 해당 레지스트리에 추가하면 된다. 기존 코드 변경을 최소화할 수 있다.

  • 유연성: 유연성이 높다. 컴포넌트가 딕셔너리에 등록되어 있으므로 컴포넌트를 교체하거나 새로 추가하기가 간단하다. 예를 들어 “.py” 파일용 프로세서를 바꾸는 일은 FILE_TYPE_PROCESSORS 딕셔너리의 항목 하나를 업데이트하는 것만큼 단순하다.

위 방식은 빠른 개발과 협업에 매우 유리하다. 하지만 우리가 원하는 것은 프로덕션에서 안정적이고 일관된 시스템이라는 점을 떠올려 보자. 여기서 그림의 인터페이스 중심 부분이 등장한다. 이 이야기는 후속 글에서 다루겠다.


원래 PubPub의 erniesg.pubpub.org/pub/zc0zx741에 게시한 글입니다.