모차르트 카탈로그 웹앱에는 오래전부터 "공연" 메뉴가 있었습니다. 다만 그 안은 하드코딩한 더미 10건이 전부였습니다. 카탈로그에서 작품을 듣고, 그 작품이 실제로 무대에 오르는 공연으로 자연스럽게 이어지면 좋겠다 — 그 흐름을 만들려고 공연 섹션을 제대로 채우기로 했습니다.

시작은 두 개의 질문이었습니다.

  • 국내외 공식 공연 정보를 가져올 수 있는 API가 있을까?
  • 가져온 공연을 제휴 딥링크로 자동 변환해 연결할 수 있을까?

답을 찾는 과정은 생각보다 길었고, 중간중간 작은 함정들이 있었습니다.

공식 데이터는 KOPIS에 있었다

국내 공연 정보의 1차 출처는 KOPIS(공연예술통합전산망) 였습니다. 예술경영지원센터가 운영하는 공식 시스템이고, 공연 목록·공연 상세를 OpenAPI로 제공합니다. 무료입니다.

키 발급은 공공데이터포털을 거쳤는데, 여기서 첫 번째 작은 깨달음이 있었습니다. 이 데이터셋은 포털이 직접 프록시하는 형태가 아니라 LINK형 이라, 발급받은 키로 호출하는 엔드포인트는 결국 KOPIS 자신의 주소였습니다. 다만 공공데이터포털의 "Encoding 키"는 +·/·= 같은 문자를 포함할 수 있어서, 흔히 쓰는 방식으로 쿼리 파라미터를 만들면 키가 한 번 더 인코딩되어 깨집니다. 그래서 다른 값은 정상적으로 인코딩하되 인증키만은 가공하지 않고 그대로 붙이도록 했습니다. 공공데이터포털 API의 단골 실패 지점입니다.

포스터에서도 비슷한 결의 문제가 있었습니다. KOPIS가 주는 포스터 URL은 http였고, 사이트는 https라 그대로 쓰면 브라우저가 혼합 콘텐츠로 막습니다. 처음엔 이미지를 우리 스토리지로 미러링할까 했지만, 확인해 보니 KOPIS는 https도 지원했습니다 — 다만 www가 붙은 주소는 www가 없는 주소로 리다이렉트됐습니다. 그래서 미러링 없이, 포스터 URL을 https·non-www 형태로 정규화해서 그대로 쓰기로 했습니다. 외부 의존이 남지만 저장 비용이 사라졌습니다.

제목이 아니라 곡목에서 모차르트를 찾기

다음 문제는 "어떤 공연이 모차르트 공연인가"였습니다.

제목으로 "모차르트"·"Mozart"를 검색하는 건 쉽습니다. 하지만 제목에 모차르트가 없어도 프로그램(곡목)에 모차르트 작품이 한 곡이라도 있는 공연이 많습니다. "신년음악회"에 교향곡 40번이 들어있는 식이지요. 그런 공연까지 잡고 싶었습니다.

그래서 2-pass로 설계했습니다. 제목 검색(한·영)으로 확실한 것을 모으고, 클래식 장르를 넓게 훑어 각 공연 상세의 텍스트에서 모차르트 흔적(이름, 쾨헬 번호 등)을 스캔하는 식입니다.

여기서 현실의 벽을 만났습니다. KOPIS 공연 상세에는 곡목을 담는 구조화된 필드가 없습니다. 소개글 필드가 있긴 한데, 실제로 후보들을 열어보니 곡목이 텍스트로 들어있는 경우는 손에 꼽았고, 대부분(체감 90%) 곡목이 "소개 이미지", 즉 리플릿 한 장으로만 제공됐습니다. 텍스트 스캔으로는 곡목 안의 모차르트를 거의 잡을 수 없다는 뜻이었습니다.

판단은 루틴의 몫으로

곡목이 이미지라면, 그 이미지를 읽어야 합니다. 이건 규칙으로 풀 문제가 아니라 맥락 이해가 필요한 일이었습니다.

마침 이 프로젝트에는 이미 Claude Routine 으로 돌아가는 파이프라인이 있습니다. 뉴스 수집도 같은 방식인데, 클라우드에서 도는 Claude가 판단·작성을 하고 구조화된 결과를 서버 엔드포인트로 보내면, 서버는 검증·저장만 합니다. 구독(OAuth) 기반이라 별도 API 키가 필요 없다는 점이 핵심입니다.

공연도 같은 구조로 옮겼습니다.

  • 서버는 KOPIS에서 객관적인 데이터(제목·일정·장소·예매처·포스터)와 함께, 판단 재료인 곡목 텍스트와 소개 이미지 URL 까지 모아 후보로 제공합니다.
  • 루틴의 Claude가 곡목 텍스트가 있으면 그것을, 없으면 소개 이미지를 직접 읽어(비전) 모차르트 작품 포함 여부와 장르를 판단합니다.
  • 확정된 공연만 서버로 돌려보내 저장합니다.

서버는 결정론적인 일(데이터 수집, URL 정규화, 저장)만 맡고, 사람의 눈이 필요한 판단은 루틴이 맡는 — 깔끔한 분업이 됐습니다.

"한 편의 오페라"라는 함정

장르 분류에서 재미있는 오답을 하나 만났습니다.

피아노 리사이틀 하나가 자꾸 "오페라"로 분류됐습니다. 곡목을 보면 전부 피아노 소나타인데 말이지요. 원인은 소개글에 있었습니다.

건반 위에 펼쳐지는 한 편의 오페라, … 피아노 한 대 안에 한 편의 오페라를 품은 모차르트다.

마케팅 카피의 비유 였습니다. 소개글 전체를 장르 키워드로 스캔하다 보니 이 "오페라"에 걸린 것입니다.

해결은 단순했습니다. 장르는 홍보 산문이 아니라 실제 곡목에서만 판단해야 합니다. KOPIS 소개글은 보통 [공연소개](산문)와 [PROGRAM](곡목)으로 나뉘어 있어서, 프로그램 섹션 이후만 잘라 그 안에서 판단하도록 했습니다. 곡과 홍보 문구를 구분하는 것 — 그게 맥락이었습니다.

딥링크결정론적 URL과 NOL의 함정

두 번째 질문, 제휴 딥링크 차례입니다.

음반 쪽에는 이미 교보·YES24 딥링크가 있었는데, 공연(NOL)까지 들어오면서 변환기를 하나로 합쳤습니다. 링크프라이스 딥링크는 다행히 서명이 없는 결정론적 URL 이라, 서명 API를 호출할 필요 없이 서버에서 문자열로 직접 조립할 수 있습니다. 실제 발급된 딥링크와 우리가 만든 것을 글자 단위로 맞춰보니 정확히 일치했습니다. 채널마다 다른 건 호스트와 머천트 코드뿐이라, 호스트로 채널을 가려 감싸도록 했습니다. 리다이렉트 경로도 음반·공연을 하나(/api/go/[entity]/[id])로 통일했습니다.

여기서 마지막 함정이 나왔습니다. NOL 머천트 코드를 넣고 딥링크를 따라가 봤더니, 공연이 아니라 야놀자 모텔 앱 홈 으로 떨어졌습니다. 알고 보니 그 머천트는 여행·숙박 쪽이라 공연 URL을 무시하고 자기 앱 홈으로 보냈던 것입니다. 인터파크 공연 URL을 NOL 티켓 주소 형식으로 바꿔주는 정규화까지 붙여봤지만, 머천트가 타깃을 무시하는 한 소용이 없었습니다.

게다가 확인해 보니 NOL은 앱(iOS·Android) 설치 실적만 인정 되고 PC·모바일 웹 전환은 인정되지 않는 구조였습니다. 그렇다면 지금 단계에서 웹 딥링크는 의미가 없습니다. 그래서 공연은 당분간 제휴 변환을 끄고 원본 예매처 링크로 그대로 이동 하게 했습니다. 대신 엔티티 단위로 켜고 끌 수 있게 만들어, 나중에 앱 딥링크를 붙일 때 한 줄이면 다시 켜집니다. 음반 쪽 딥링크는 그대로 둡니다.

대가성 고지 문구도 그에 맞췄습니다. 실제로 수수료가 발생하는 음반에는 고지를 남기고, 원본 링크로만 가는 공연 섹션에서는 뺐습니다.

그리고 카드를 수십 번 다듬었다

데이터가 들어오자 그다음은 화면이었습니다. 포스터는 외부 호스트라 라운드 모서리가 hover에서 깜빡였고(합성 레이어를 고정해 해결), 한글 제목은 단어 중간에서 잘렸으며(word-break: keep-all), 예매처 배지를 버튼 옆에 끼웠다가 가격이 잘려 더 어색해지기도 했습니다. 결국 예매처는 포스터 코너로 보내고, "연주"·"예매 가격" 같은 고정 라벨은 덜어내고, 날짜의 요일과 공연 시간의 요일이 겹쳐서 시간만 추출하는 식으로, 한 칸씩 정리했습니다. 이런 다듬기는 코드보다 눈이 하는 일이라 반복이 많았습니다.

무대 전에 미리 듣기 — 곡목을 작품·악장에 잇다

목록과 예매까지 들어오자 마지막 조각이 남았습니다. 카탈로그의 "듣기"를 공연 쪽으로 잇는 일입니다. 공연 하나를 눌렀을 때 그 무대에 오를 곡을 미리 들어볼 수 있어야, "듣다가 무대로"가 완성됩니다.

먼저 공연 상세 페이지(/concert/[id])를 만들고, 그 한가운데에 레파토리(곡목) 영역을 두었습니다. 핵심은 곡목을 단순 텍스트가 아니라 작품 → 악장 계층으로 저장한 것입니다. 곡목 한 줄(작품)을 부모로, 그 아래 악장을 자식(self-relation)으로 두고, 모차르트 작품이면 쾨헬 번호로 우리 카탈로그의 작품(그리고 악장)에 연결합니다. 연결되면 상세의 레파토리에서 작품 전체는 물론 악장 하나만 골라 미리듣기가 됩니다 — 카탈로그의 재생 패널을 그대로 재사용합니다. 연결은 수집 시점에 K번호로 자동 해소하고, 못 맞춘 곡은 텍스트로 보존해 두었다가 관리자가 잇습니다. 들어볼 곡이 하나도 없는 공연이면 이 영역은 통째로 숨깁니다. 상세의 공유·미리보기 이미지(OG)는 공연 포스터를 그대로 씁니다.

관리자 곡목 편집과 "저장하면 악장이 사라지는" 함정

국내 공연 곡목은 "K.550" 없이 "교향곡 40번"으로만 적히는 경우가 많아 자동 연결이 자주 빗나갑니다. 그래서 관리자 곡목 편집기에 작품 검색 연결(K번호·제목으로 찾아 잇고, 작품을 고르면 곡명도 자동으로 채움)과 악장 연결(연결된 작품의 악장 목록에서 선택)을 붙였습니다.

여기서 데이터 손실 함정을 하나 만났습니다. 편집 화면이 곡목을 불러올 때 악장(자식 항목)을 빼고 작품만 읽었는데, 저장은 곡목을 통째로 지우고 다시 쓰는 방식이었습니다. 둘이 만나니, 자동수집이 채워 둔 악장이 관리자가 공연을 한 번 저장하는 순간 사라졌습니다. 불러오는 쪽에 악장을 포함시키는 것으로 막았습니다 — 읽기와 쓰기가 같은 모양을 봐야 한다는, 흔하지만 놓치기 쉬운 함정이었습니다.

장르는 이미지에서 읽은 곡목으로도 보정, 제목의 &는 보존

장르 분류도 한 번 더 손봤습니다. 한 모차르트 실내악 공연이 "기타"로 떨어져 있었는데, 제목("…카메라타 솔…")에도 공식 분류에도 장르 단서가 없고 곡목은 이미지뿐이었기 때문입니다. 그래서 루틴이 이미지에서 읽어 추출한 곡목 텍스트로 서버가 장르를 한 번 더 추론해 루틴 분류와 합치도록 했습니다(디베르티멘토·카메라타 같은 실내악 신호도 키워드에 보강). 1차 판단은 곡목을 보고 이해하는 루틴의 몫, 서버는 추출된 곡목으로 빠진 장르를 메우는 안전망입니다.

작은 표기 문제도 하나 바로잡았습니다. 공연 제목의 &를 화면에서는 폰트만 달리해 그대로 보여주기로 했는데, 정작 수집 단계에서 &를 가운뎃점(·)으로 치환해 버려 제목에서 &가 사라지고 있었습니다. 치환을 걷어내 원문 &를 보존하도록 했습니다.

남은 것

이제 수집 → 비전 곡목 판독 → 작품·악장 연결 → 무대 전 미리듣기까지 한 줄로 이어집니다. 남은 건 운영 쪽입니다. 루틴을 실제 일정으로 돌리며 이미지 곡목의 분류 정확도와 작품·악장 자동 연결률을 지켜보고, 관리자 검수로 공개 여부와 곡목 연결을 다듬는 흐름이 남았습니다.

돌아보면 이 작업의 절반은 "공식 데이터를 가져오기"였고, 나머지 절반은 "그 데이터의 빈틈(곡목이 이미지라는 것, 머천트가 타깃을 무시한다는 것)을 어떻게 메우느냐"였습니다. API 문서에는 적혀 있지 않은, 실제로 호출해 보고 따라가 봐야 드러나는 빈틈들이었습니다.