MEETING BRIEF

삼성증권 미팅 안건별 정리

거래소 정보는 어떻게 쓰이고 어떻게 등록하면 되는가, 종목 104개를 더하면 데이터가 얼마나 늘고 성능에 어떤 차이가 나는가, 조회 성능은 어떤 순서로 고치는가. 세 안건을 결론과 방법만 남겨 짧게 정리한 화면이다.

  • 미팅 2026-10-20 14:00
  • 삼성증권 AI기획팀과 현업 담당자
  • 메가존 서동진
  • 10월 1일~7일 원본 전수와 로컬 PostgreSQL 17.6, 16.15 실측
36 / 104
안건 1. 거래소 표기 불일치
접미사만으로 등록하면 번역 대상이 0건이 되는 종목. 등록 때 StockTwits 종목 정보로 세부 거래소를 채우면 해결된다
+1,227~1,758건/일
안건 2. 순증 번역 대상(평일)
수집과 적재는 그대로. 번역과 발송만 늘고 토큰은 PTU 하루 용량의 0.3% 미만
0~6 단계
안건 3. 수정 범위의 단계
진단, 설정값, 쿼리 파일, 데몬 코드, 번역 대상 큐, 경로 테이블, 저장 구조 순. 작은 범위부터 가고 목표에 못 미칠 때만 다음으로

작성 모델: Claude Fable 5.1 (claude-fable-5-1), 작성일 2026-10-20 오전

미팅준비.md의 긴 내용을 안건별 결론과 방법만 남겨 다시 쓴 문서다. 안건 1은 9월 10일 회신 메일의 개선안을 그대로 잇고 안건 2는 10월 1일~7일 원본 전수와 로컬 DB로 센 건수를 쓰며 안건 3은 수정 범위가 작은 것부터 큰 것 순으로 단계를 세웠다. 안건 3에는 스레드 쿼리의 재귀 제거와 테이블 구조를 따로 검토한 261020.02 검토보고서의 실측을 반영했다. 수치는 로컬 실측과 원본 집계이고 운영 외삽은 (추정)으로 표시했다. 웹 화면은 안건별_정리.html이고 같은 화면을 Cloudflare Pages에 올린 주소는 https://brief-261020-be5ir.pages.dev 다(2026-10-20 배포, 로컬 파일 링크는 뗀 사본, 주소를 아는 사람은 누구나 열 수 있다).

1. 안건 1. 거래소 정보 활용: 등록 단계에 매핑과 판정을 두는 구조

1.1 사실

  • 거래소 정보는 실제로 쓰인다. 수집에서는 쓰지 않고(StockTwits 전체 피드를 종목 조건 없이 받는다), 등록 종목과 대조하는 번역 대상 선정 9곳, 일일 요약 1곳, 발송 10곳, DB 함수 6곳에서 티커와 거래소를 함께 맞춘다. 값은 수집처 표기(NASDAQ, NYSE, NYSEArca, NYSEMkt, BATS, OTC)와 글자 그대로 같아야 하고 다르면 그 종목은 오류 없이 조용히 빠진다.
  • 삼성증권이 보낸 104개를 실제 글에 대 보니, 접미사를 단순 대응(.OQ는 NASDAQ, .N은 NYSE, .A는 NYSEMkt)해 등록하면 36개가 어긋난다. .N 51개 중 27개는 NYSEArca이고 .A 8개 중 6개는 BATS다. 이 접미사는 우리 발송 코드가 NYSE와 NYSEArca를 N으로, OTC와 BATS를 A로 접어 내보낸 값이라 처음부터 거래소 두 개씩을 뜻한다.
  • TipRanks와 Benzinga는 티커만으로 맞추므로 거래소 규칙은 StockTwits에만 적용한다.

1.2 구조: 매핑 테이블 하나로는 부족하고 매핑 테이블과 종목 조회를 함께 쓴다

삼성증권 거래소 정보와 StockTwits 거래소 정보를 잇는 매핑 테이블을 관리자 페이지에 두고 뒤의 파이프라인은 손대지 않는다는 방향은 맞다. 다만 한 가지를 보태야 한다. 삼성증권의 거래소 구분(접미사 .OQ, .N, .A)과 StockTwits 거래소는 일대일이 아니라 일대다다. .N은 NYSE일 수도 NYSEArca일 수도 있어서 매핑 테이블만으로는 종목마다 어느 쪽인지 정할 수 없다. 그래서 등록 서버가 두 가지를 함께 쓴다.

  1. 매핑 테이블: 삼성증권 구분마다 StockTwits 거래소의 허용 범위와 기본값을 적는다. "이 범위 안이면 맞다"를 판정하는 표다.
  2. 종목 조회: 티커로 수집된 종목 테이블(stocktwits_symbols)을 찾아 그 종목의 실제 StockTwits 거래소를 가져온다. "정확히 어느 거래소인가"를 정하는 조회다.

둘을 합친 판정이 등록 서버의 래퍼다. 조회된 거래소가 매핑 허용 범위 안이면 자동 등록하고 범위 밖이거나 조회가 안 되면 관리자 확인으로 넘긴다. 확정된 값만 지금과 같은 모양으로 community_delivery_symbols에 들어가므로 번역, 요약, 발송 데몬은 바뀌지 않는다.

삼성증권 엑셀(ISIN, RIC, 종목명, 필수값 4개)
  → 등록 서버: RIC에서 티커와 접미사 분리(BRKb → BRK.B)
  → stocktwits_symbols에서 티커 조회 → 거래소 후보 0개, 1개, 2개 이상
  → 매핑 테이블(접미사 → 허용 거래소 집합, 기본값)과 대조
  → 자동 등록 / 관리자 확인 / 미발견 기본값(미검증 표시)
  → 확정 행만 community_delivery_symbols에 저장(기존 모양 그대로)
  → 번역, 요약, 발송 데몬은 변경 없음

1.3 테이블 두 개를 더한다 (기존 테이블은 바꾸지 않는다)

테이블 역할 비고
suml.community_exchange_mapping (신규) 삼성증권 구분과 StockTwits 거래소의 허용 범위와 기본값 행 6개로 시작. 관리자 페이지에서 편집
suml.community_delivery_symbols_staging (신규) 업로드 원본과 판정 결과(자동, 확인, 미발견), 확인자와 시각 등록 이력이 여기 남는다. 지금은 이력이 남지 않는다
suml.community_delivery_symbols (기존) 확정된 배송 종목. 모양 그대로 파이프라인이 읽는 유일한 표. 변경 없음
-- 매핑 테이블: 삼성증권 접미사 → StockTwits 거래소 허용 범위
CREATE TABLE suml.community_exchange_mapping (
    customer_code        VARCHAR(10)  NOT NULL,   -- 'OQ', 'N', 'A' (삼성증권 RIC 접미사)
    customer_name        VARCHAR(20)  NOT NULL,   -- '나스닥', '뉴욕', '아멕스'
    stocktwits_exchange  VARCHAR(50)  NOT NULL,   -- StockTwits 표기 그대로
    is_default           BOOLEAN      NOT NULL DEFAULT FALSE,  -- 종목 조회가 안 될 때 넣는 기본값
    applied_at           TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('Asia/Seoul', now()),
    PRIMARY KEY (customer_code, stocktwits_exchange)
);
INSERT INTO suml.community_exchange_mapping (customer_code, customer_name, stocktwits_exchange, is_default) VALUES
 ('OQ', '나스닥', 'NASDAQ',   TRUE),
 ('N',  '뉴욕',   'NYSE',     TRUE),
 ('N',  '뉴욕',   'NYSEArca', FALSE),
 ('A',  '아멕스', 'BATS',     TRUE),
 ('A',  '아멕스', 'OTC',      FALSE),
 ('A',  '아멕스', 'NYSEMkt',  FALSE);

행 6개는 104개 종목 실측(1.1절)과 발송 변환표의 역방향에서 나온 값이고 미팅에서 확정한다. 기본값은 조회가 안 되는 종목(QLD, BOXX, FBRT)을 "미검증"으로 넣을 때만 쓴다.

-- 업로드 원본과 판정 결과
CREATE TABLE suml.community_delivery_symbols_staging (
    id               SERIAL PRIMARY KEY,
    upload_batch_id  VARCHAR(40)  NOT NULL,   -- 업로드 1회 단위
    isin             VARCHAR(12),
    ric_code         VARCHAR(20)  NOT NULL,   -- 'SCHD.N' (삼성증권 원본)
    symbol           VARCHAR(20)  NOT NULL,   -- 'SCHD', 'BRK.B' (StockTwits 표기로 정규화)
    customer_code    VARCHAR(10)  NOT NULL,   -- 'N'
    resolved_exchange VARCHAR(50),            -- 판정 결과 거래소
    symbol_id        INTEGER,                 -- stocktwits_symbols.id (다음 반입의 id 매칭에 쓴다)
    decision         VARCHAR(20)  NOT NULL,   -- 'AUTO', 'NEEDS_REVIEW', 'NOT_FOUND', 'CONFIRMED', 'REJECTED'
    decision_reason  VARCHAR(200),            -- '허용 범위 밖: NYSEMkt', '조회 결과 2건' 등
    prdt_id          VARCHAR(50)  NOT NULL,   -- 이하 기존 등록 테이블의 필수 값
    stock_name       VARCHAR(200) NOT NULL,
    customer_count   INTEGER      NOT NULL,
    holding_amount   BIGINT       NOT NULL,
    customer_rank    INTEGER,
    confirmed_by     VARCHAR(50),
    confirmed_at     TIMESTAMP WITH TIME ZONE,
    _created_at      TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT timezone('Asia/Seoul', now())
);

1.4 판정 규칙과 예시 SQL

종목 조회 결과 매핑 대조 판정 예(104개 실측)
후보 1개 허용 범위 안 AUTO. 조회된 거래소로 저장 SCHD(.N → NYSEArca), DRAM(.A → BATS) 등 98개
후보 1개 허용 범위 밖 NEEDS_REVIEW. 관리자가 거래소를 고른다 QBTS(.OQ인데 NYSE), LEU(.N인데 NYSEMkt), ITA(.A인데 NYSEArca)
후보 2개 이상(거래소가 다름) NEEDS_REVIEW. 후보를 모두 보여 준다 로컬 종목 8,044개 중 CHAD 1개뿐
후보 0개 NOT_FOUND. 보류를 권하고 즉시 등록을 원하면 기본값으로 넣고 미검증 표시 QLD, BOXX, FBRT
기존 is_final 행과 같은 티커 기존 행 유지, 중복 등록 안 함 ASST, BABA, BE, QBTS, RDW, SNDK, USAR

판정은 SQL 한 번으로 한다. 업로드 행을 staging에 넣은 뒤 아래를 실행하면 decision과 resolved_exchange가 채워진다.

WITH cand AS (
    SELECT st.id AS staging_id, s.id AS symbol_id, s.exchange,
           COUNT(*) OVER (PARTITION BY st.id) AS candidate_count,
           EXISTS (SELECT 1 FROM suml.community_exchange_mapping m
                   WHERE m.customer_code = st.customer_code AND m.stocktwits_exchange = s.exchange) AS is_allowed
    FROM suml.community_delivery_symbols_staging st
    LEFT JOIN suml.stocktwits_symbols s ON s.symbol = st.symbol
    WHERE st.upload_batch_id = :batch_id
)
UPDATE suml.community_delivery_symbols_staging st SET
    symbol_id = CASE WHEN c.candidate_count = 1 THEN c.symbol_id END,
    resolved_exchange = CASE WHEN c.candidate_count = 1 AND c.is_allowed THEN c.exchange END,
    decision = CASE
        WHEN c.symbol_id IS NULL THEN 'NOT_FOUND'
        WHEN c.candidate_count > 1 THEN 'NEEDS_REVIEW'
        WHEN NOT c.is_allowed THEN 'NEEDS_REVIEW'
        ELSE 'AUTO' END,
    decision_reason = CASE
        WHEN c.symbol_id IS NULL THEN 'StockTwits 종목 테이블에 없음'
        WHEN c.candidate_count > 1 THEN '조회 결과 ' || c.candidate_count || '건'
        WHEN NOT c.is_allowed THEN '허용 범위 밖: ' || c.exchange
        ELSE NULL END
FROM cand c WHERE c.staging_id = st.id;

확정된 행은 기존 모양으로 옮긴다. ric_code는 기존 DDL 주석의 규약("티커.StockTwits 거래소명", 예 TSLA.NASDAQ)을 따른다. 기존 스크립트처럼 DELETE로 교체하지 않고 이전 행의 is_final을 내린다.

INSERT INTO suml.community_delivery_symbols
    (prdt_id, ric_code, stock_name, customer_count, holding_amount, customer_rank,
     symbol, exchange, is_final, applied_at, _data_source, _created_by)
SELECT prdt_id, symbol || '.' || resolved_exchange, stock_name, customer_count, holding_amount, customer_rank,
       symbol, resolved_exchange, TRUE, timezone('Asia/Seoul', now()), 'admin_upload:' || upload_batch_id, confirmed_by
FROM suml.community_delivery_symbols_staging
WHERE upload_batch_id = :batch_id AND decision IN ('AUTO', 'CONFIRMED');

1.5 수정 범위

대상 수정 비고
관리자 페이지 화면 업로드 결과 화면(자동 N건, 확인 M건, 미발견 K건)과 확인 처리 화면(거래소 드롭다운, 확정 버튼), 매핑 테이블 편집 화면 이 저장소 밖의 등록 서버. 위치와 담당을 미팅에서 확인
등록 서버 엑셀 파서(RIC 분리, 클래스 주식 정규화), 판정 SQL 실행, 확정 저장, 업로드 배치 id 발급 1.4절 SQL 두 개가 핵심
DB 테이블 2개 추가(community_exchange_mapping, community_delivery_symbols_staging), 매핑 행 6개 doc/community/stocktwits/datamodels/sql/5.ddl_admin.sql 권위본과 설치 사본에 함께 추가. 기존 테이블 무변경
월 1회 재검증 배치 is_final 행의 symbol과 exchange를 stocktwits_symbols와 대조해 어긋난 종목 보고 등록 뒤 상장 거래소가 바뀐 종목(WMT 사례) 탐지
umla-crawler, umla-preprocessing, umla-translation, umla-sending 변경 없음 등록 테이블의 모양이 그대로라서
발송 변환표(선택) aggregator.py:628-634에 NYSEMkt를 A로 더하는 한 줄 LEU 같은 NYSEMkt 종목이 `LEU

1.6 이번 104개와 그 뒤

  • 등록 서버 수정 전이라도 메가존이 같은 판정을 스크립트로 돌려 등록 SQL과 확인 목록을 만들어 준다. 104개 결과는 자동 98개, 확인 3개(QBTS, LEU, ITA), 미발견 3개(QLD, BOXX, FBRT)이고 기존 목록과 겹치는 7개는 기존 행을 유지한다. 삼성증권이 확인 3개의 거래소와 미발견 3개의 처리를 정해 주면 등록 SQL이 완성된다. 운영 DB에 넣을 때는 건수를 먼저 보고하고 승인 뒤 실행한다.
  • 지금 서비스 중인 목록도 같은 대조를 한 번 돌린다. 개발용 목록 151개에서는 10개가 어긋나 있었다.
  • 다음 반입에서는 staging에 남긴 symbol_id를 써서 배송 종목을 StockTwits 심볼 id로 매칭하는 구조로 간다. 안건 3의 큐 단계(community_delivery_symbol_ids)와 같은 표라 한 묶음으로 반입한다. 그때부터는 거래소 표기가 바뀌어도 매칭이 끊기지 않는다.

1.7 삼성증권이 정할 것

  • 매핑 행 6개(접미사별 허용 거래소와 기본값)와 확인 대상 세 종류(허용 범위 밖, 조회 2건 이상, 미발견)의 처리 방식.
  • 미발견 3개를 보류할지 기본값으로 넣을지. 권장은 보류 뒤 첫 언급 때 재확인.
  • 104개 목록에 필수 값 열(상품 ID, 보유 고객 수, 보유 금액, 고객 순위)을 더해 다시 주는 것과 관리자 페이지와 등록 서버 수정의 담당과 일정.
  • 발송 변환표에 NYSEMkt를 더하는 한 줄 수정을 이번 반입에 넣을지.

2. 안건 2. 데이터 사용량: 일주일 실측과 추가 종목의 몫

2.1 일주일 전체 건수 (10월 1일~7일 원본 파일)

날짜 요일 메시지 좋아요 친구 관계
10-01 목 91,376 161,913 11,762
10-02 금 97,254 167,959 17,987
10-03 토 22,591 55,554 7,290
10-04 일 22,557 52,139 12,422
10-05 월 86,720 166,874 13,100
10-06 화 105,199 186,889 16,690
10-07 수 100,419 176,018 15,258
합계 526,116 967,346 94,509
평일 평균 96,194 171,931 14,959
  • 세 종류 합계 1,587,971건이 일주일에 수집되어 전부 적재된다. 메시지 526,116건은 생성 511,234건과 삭제 14,882건이고 본문(답글과 리셰어가 아닌 글)은 268,256건이다. 출처는 crawler-profile.json(261019.03)과 데이터사용량_산정_검토.md 5.1절이다.
  • 종목을 늘려도 이 수집량과 적재량은 그대로다. 수집기가 전체 피드를 받기 때문이다.

2.2 추가 예정 104개 종목의 몫 (같은 일주일)

항목 건수 전체 대비
104개 종목이 언급된 메시지(로컬 DB 적재분 472,490건 기준) 13,163 2.8%
그중 본문 11,523 본문 기준 4.3%
원본 기준 본문 후보 11,805 본문 268,256건의 4.4%
필터 통과 번역 대상 11,172 통과율 94.6%
순증 번역 대상(기존 종목과 함께 언급되지 않은 것) 9,203 비교 목록 7일 95,361건의 9.7%
스레드 재처리 트리거(답글과 리셰어) 6,062
차트 이미지가 있는 후보 1,036
종목 연결 행 14,418 353,031행의 4.1%
신규 종목 메시지에 달린 좋아요(로컬 DB, 7일치 메시지에 달린 11,557건 중) 277 2.4%
작성자 수 3,505
  • 좋아요는 메시지에 달리므로 종목 비중을 따라가고 친구 관계는 종목과 무관하다. 둘 다 처리 부하에는 영향이 없다.
  • 하루로 바꾸면 평일 평균 번역 대상 2,109건(주말 313건, 최대 10월 6일 2,647건), 순증 1,758건, 스레드 트리거 1,124건, 이미지 189건이다. 비교 목록과 겹치는 7개(SNDK 등)가 이미 서비스 중이면 순증은 약 1,227건이다. 비교 목록이 개발용 더미 151개라 운영 목록을 받아 다시 센다.

2.3 산정했을 때 예상되는 성능 차이

구간 영향 근거
수집과 적재 없음 전량 수집, 종목 조건 없음
번역 부하 순증 기준 하루 1,227~1,758건, 지금 규모(평일 18,070건)의 6.8~9.7% 증가. 배치 상한 1,000건 기준 하루 2배치. 토큰은 PTU 100 하루 용량의 0.17~0.29%(하한, 추정) 데이터사용량_산정_검토.md 6.2절과 6.4절
스레드 재처리 트리거 하루 1,124건, 클러스터당 약 3.3~3.5행이므로 많아야 약 3,900행/일 재처리(추정 상한) 261019.04 근거 perf_v3__V3_count
조회 성능(현행 구조) 배송 종목 연결 행이 평일 기준 10.1~11.8% 늘고 현행 쿼리는 이 행 수에 비례하므로 조회 비용도 그만큼 는다(추정). 그날 메시지부터 읽는 구조로 바꾸면 영향이 작아지고 큐 구조에서는 큐 행 수만 는다 서버증설_로직개선_검토_v2.md 3.5절
발송 하루 2,882건 이상(번역 1,758 + 트리거 1,124) 데이터사용량_산정_검토.md 6.5절
DB 증가 처리 테이블과 발송 테이블 각 2,882행, 이력 테이블 17,000행 이상. 로컬 행 폭으로 하루 약 12MB, 30일 약 370MB(추정) 로컬 행 폭 실측
증설 판단 하루 총량으로는 요인이 아니다. 1분 단위 PTU 사용률 30일과 429 비율, 서버 지표를 받아야 확정한다 7장 자료 요청

3. 안건 3. 서버 증설과 로직 개선: 작은 수정부터 단계별로

3.1 결론

  • 느린 원인은 서버가 아니라 쿼리가 읽는 순서다. 선정 쿼리가 배송 종목에서 출발해 보관 중인 이력 전체를 훑은 뒤에야 날짜로 거른다. 로컬에서 그날 적재가 40행이든 47만 행이든 읽은 페이지가 879,099과 893,102로 같다. PostgreSQL이 배송 종목에 걸린 행을 495행으로 어림하는데 실제는 194,277행이라 날짜 인덱스를 두고도 쓰지 않는다. 인덱스 추가와 아카이빙이 효과가 없던 이유도 여기에 있다.
  • 스레드 쿼리는 재귀 자체가 느린 것이 아니다. 로컬 PostgreSQL 17에서 v3의 2.5초 중 상향 재귀는 44~54ms이고 나머지는 CTE끼리의 잘못된 조인(약 1.8초)과 하향 재귀 안의 재스캔(약 0.65초), 종목에서 출발하는 후보 선정(0.45~0.95초)이다. 운영과 같은 PostgreSQL 16에서는 첫째 항이 없어 1.2초다(261020.02 검토보고서 1장).
  • 사용자가 확인한 운영 사실 두 가지(2026-10-20). 운영에는 각 쿼리의 가장 마지막 판본이 적용되어 있다(신규 본문 v1, 스레드 v3). 운영 stocktwits_messages에는 _created_at 인덱스가 이미 있다. 인덱스가 있는데도 느린 것은 플래너가 날짜 인덱스를 고르지 않는다는 로컬 실험(임시 인덱스로 677ms가 645ms)과 들어맞으며 쿼리 모양을 바꿔야 인덱스가 쓰인다는 뜻이다. 저장소 로더는 스레드 쿼리로 v1을 읽으므로(target_messages_query.py:298) 저장소를 운영에 맞추는 작업이 반입 패키지에 들어간다.
  • 서버 증설은 지금 결정하지 않는다. 쿼리를 고친 뒤 Azure 지표(cpu_percent, memory_percent, read_iops, disk_iops_consumed_percentage 최근 30일)로 판정한다. 등급이 Burstable이면 그것만은 먼저 General Purpose 이상으로 바꾼다.

3.2 단계: 작은 수정 범위부터

각 단계는 앞 단계로 목표(운영 복제 DB에서 두 쿼리 모두 0.5초 이하, 날짜 세 개 이상에서 결과 차집합 0)에 닿지 않을 때만 다음으로 간다. 모든 단계는 반입 전에 운영 규모 데이터로 검증한다.

단계 수정 범위 무엇을 하나 로컬 효과 다음 단계로 가는 조건
0 변경 없음 운영 진단 SQL 10개, Query Store 7일, 서버 등급과 지표 확인. 운영 쿼리 판본은 최신(신규 본문 v1, 스레드 v3)으로 확인됐으므로 저장소 로더를 v3로 맞춘다 항상 먼저 한다
1 설정값만 유휴 폴링 주기 연장(데몬이 날짜마다 약 12초에 한 번 조회하므로 결과 0건일 때도 DB 시간의 약 17%를 쓴다, 로컬 기준 추론). TRANSLATION_CHUNK_SIZE를 낮춰 토큰 한도가 작동하게 한다 조회 횟수 자체가 준다 1회 비용이 여전히 크면
2 쿼리 파일만 (코드 변경 없음) 신규 본문: 그날 적재분을 MATERIALIZED CTE로 먼저 고른다. _created_at 인덱스는 운영에 이미 있으므로 DDL 권위본과 설치 사본에만 추가해 맞춘다. 스레드: v3d(하향 재귀 제거, CTE 간 조인 제거, 순위 필터), 스키마와 데몬 코드 무변경 신규 본문 0.70초가 0.12~0.18초, 결과 동일. 스레드 PG16 1.19초가 0.86초, PG17 2.54초가 0.87초, 결과 동일 스레드가 0.5초를 넘는다. 남은 시간의 80%가 종목에서 출발하는 후보 선정이라 SQL 모양으로는 더 줄지 않는다
3 데몬 파이썬 코드 스레드를 네 번에 나눠 실행한다(그날 답글 후보 선정, 부모 id 묶음 조회 반복, 파이썬 확정, 문맥 조회). 배송 종목은 심볼 id 배열로 넘긴다. 구 쿼리와 신 쿼리를 고르는 설정값을 만든다 스레드 캐시 뒤 0.13~0.17초, 첫 실행 0.4~0.9초. 신규 본문 0.12~0.16초. 결과 동일 남는 연결 행 해시 구간(0.16초 중 0.07초)이 운영 이력 크기만큼 늘어 다시 느려지면
4 타겟 테이블 분리 (테이블 2개 + 전처리기) 적재할 때 번역 대상 여부를 한 번만 판정해 큐 stocktwits_translation_targets에 넣고 배송 종목은 community_delivery_symbol_ids로 푼다. 데몬은 큐만 읽는다 신규 본문 13~30ms, 스레드 후보 읽기 0.14~0.17초. 이력 크기와 무관 보관 기간 연장이나 종목 두 배 증가로 0.5초를 넘으면
5 경로 테이블 (스레드 재귀 0회) 댓글마다 루트까지의 조상 배열과 깊이를 저장해 재귀를 없앤다 PG16 0.68초, PG17 0.77초. 2단계 대비 0.1초 안팎의 이득 운영 EXPLAIN에서 상향 재귀가 실행 시간의 20% 이상일 때만. 시제품에서 결함 5건(트리거와 백필의 판정 불일치, 관계 변경 시 하위 경로 부패, 삭제 비동기화, 배포 공백, 상태 컬럼 부재)이 확인되어 설계로 닫기 전에는 넣지 않는다
6 저장 구조 재설계와 파티셔닝 원본 이벤트 저장소와 현재 상태 분리, 삭제 tombstone, 좋아요 카운터. 메시지 파티셔닝은 8월 조건 4개(보관 6개월 이상, 임시 테이블 복합 키, 적재 검증, 필수 파일 25개 체크리스트) 충족 시만 보관 6개월 이상 확정 등 조건 충족 시. 지금은 0개 충족
  • 2단계에서 스레드가 SQL만으로 0.86초까지 내려간 것은 261020.02 검토의 새 실측이다. 그 아래로는 SQL 모양을 바꿔도(날짜 우선 후보 선정, 부분 인덱스, LATERAL 프로브) 변하지 않았고 3단계의 파이썬 분리에서만 0.13~0.17초가 나왔다. 그래서 "쿼리만으로는 안 된다"는 말은 0.5초 목표 기준에서 맞고 2.5초를 0.9초로 줄이는 데까지는 쿼리만으로도 된다.
  • 2단계와 3단계의 스레드 후보는 v3의 의미(댓글 트리거만, 루트까지의 조상 경로만)를 그대로 쓴다. 운영이 v3라는 것은 사용자가 확인했다. 저장소 로더는 아직 v1을 읽으므로(target_messages_query.py:298, 2026-04-26 이후 불변) 반입 패키지에서 저장소를 운영과 같은 v3로 맞춘 뒤 그 위에 v3d와 4단계 조회를 얹는다.
  • 신규 본문 쿼리의 중복 행(한 본문이 배송 종목 둘에 걸리면 두 번 나오는 것)을 없애는 수정은 결과 행이 달라지므로 정확도 수정으로 승인받는다. 배치당 서로 다른 메시지가 802~885개에서 1,000개로 는다.

3.3 데몬 파이썬 코드는 어디를 어떻게 고치나 (3단계 예시)

데몬의 조회는 한 곳에서 갈라진다. _run_postprocessing_pipeline이 fetch_fn을 한 번 불러 메시지 dict 목록을 받고 그대로 _process_messages에 넘긴다(stocktwits_message_postprocessor.py:253-262). 그래서 고칠 곳은 "조회 함수"뿐이고 번역, 요약, 상태 갱신 등 뒤쪽 코드는 손대지 않는다. 새 함수가 지금과 같은 12개 컬럼의 dict 목록을 돌려주기만 하면 된다.

파일 무엇을 고치나 크기
umla-translation/stocktwits/governance/queries/target_messages_query.py 조회 함수 3개를 추가한다. 배송 종목 심볼 id 읽기, 신규 본문 v2 조회, 스레드 4단계 조회. 기존 함수는 그대로 둔다 약 120줄 추가
umla-translation/stocktwits/governance/queries/sql/ SQL 파일 3개를 추가한다. target_messages_new_body_only_v2.sql(그날 적재분 우선, :symbol_ids 바인드), thread_cluster_step1_v1.sql(그날 답글 후보), thread_cluster_context_v1.sql(루트와 문맥 조회). 기존 v1, v3 파일은 지우지 않는다 파일 3개
umla-translation/stocktwits/services/api/stocktwits_message_postprocessor.py postprocess_new_body와 postprocess_only_thread_cluster가 설정값에 따라 구 함수와 신 함수 중 하나를 fetch_fn으로 고른다 각 3줄
umla-translation/stocktwits/config.env.dev와 운영 config.env POSTPROCESSOR_QUERY_VERSION="v2" 키를 더한다. legacy로 바꾸면 구 쿼리로 돌아간다 1줄
umla-translation/stocktwits/test/ 날짜 세 개 이상에서 구 함수와 신 함수의 결과를 비교하는 테스트 파일 1개

아래는 수정 방향을 보여 주는 예시 코드다. 실행 검증은 하지 않았고 스레드 4단계 흐름은 로컬에서 결과 동일성을 확인한 시제품 스크립트(261019.04/근거/scripts/06b_python_frontier_loop_any.py)를 함수 모양으로 옮긴 것이다. 반입 패키지에서는 실제 코드와 테스트를 따로 만든다.

예시 1. 배송 종목을 심볼 id 배열로 한 번 읽는다(target_messages_query.py에 추가).

@staticmethod
def fetch_delivery_symbol_ids(session: Session) -> List[int]:
    """
    * 배송 종목(is_final)을 StockTwits 심볼 id 배열로 돌려준다
    ! 데몬 실행 시작 때 한 번만 호출한다. 쿼리 본문에서 COALESCE 문자열 비교를 없애는 것이 목적
    """
    sql = text(
        "SELECT DISTINCT s.id FROM suml.stocktwits_symbols s "
        "JOIN (SELECT DISTINCT symbol, exchange FROM suml.community_delivery_symbols "
        "      WHERE is_final = TRUE) ds "
        "  ON s.symbol = ds.symbol AND COALESCE(s.exchange, '') = COALESCE(ds.exchange, '')"
    )
    return [row[0] for row in session.execute(sql)]

예시 2. 신규 본문 조회 v2. SQL 파일은 그날 적재분을 먼저 고르고 종목은 = ANY(:symbol_ids)로 판정한다. 파일 안에서 CTE로 심볼 id를 계산하면 느려지므로(2,046ms) 반드시 파이썬이 배열을 넘긴다.

@staticmethod
def fetch_new_body_messages_v2(
    session: Session, target_date: str, limit: int, logger: Logger
) -> List[Dict[str, Any]]:
    """
    * 신규 본문 조회 v2: 그날 적재분 우선 + 심볼 id 배열 판정 + EXISTS 중복 제거
    ! 반환 dict 컬럼 12개는 v1과 같다. 다운스트림 변경 없음
    """
    symbol_ids = TargetMessagesQuery.fetch_delivery_symbol_ids(session)
    sql = TargetMessagesQuery._load_sql("target_messages_new_body_only_v2.sql").format(
        limit_clause=f"LIMIT {limit}" if limit != -1 else ""
    )
    result = session.execute(
        text(sql), {"target_date": target_date, "symbol_ids": symbol_ids}
    )
    columns = result.keys()
    return [dict(zip(columns, row)) for row in result]

예시 3. 스레드 4단계 조회. 한 문장 SQL 대신 후보 선정, 부모 반복 조회, 파이썬 확정, 문맥 조회로 나눈다.

@staticmethod
def fetch_only_thread_cluster_messages_v2(
    session: Session, target_date: str, limit: int, logger: Logger
) -> List[Dict[str, Any]]:
    """
    * 스레드 클러스터 조회 v2: 4단계 분리 실행
    ! 재귀 CTE 한 문장은 반복마다 메시지 테이블 전체를 해시하므로(로컬 2.4~3.1초) 쓰지 않는다
    ! 반환 dict 컬럼 12개와 정렬은 v3와 같다. 결과는 로컬에서 v3와 행 단위로 같았다
    """
    symbol_ids = TargetMessagesQuery.fetch_delivery_symbol_ids(session)

    # * 1단계: 그날 답글 가운데 루트와 직접 부모가 있고 루트가 배송 종목인 후보 (created_at 인덱스)
    step1 = TargetMessagesQuery._load_sql("thread_cluster_step1_v1.sql")
    candidates = session.execute(
        text(step1), {"target_date": target_date, "symbol_ids": symbol_ids}
    ).fetchall()  # (id, root_id, in_reply_to_message_id, created_at)

    # * 클러스터를 첫 답글 시각 순으로 limit + 100개까지만 남긴다 (검증 탈락분 여유)
    first_at = {}
    for _, root_id, _, created_at in candidates:
        if root_id not in first_at or created_at < first_at[root_id]: first_at[root_id] = created_at
    kept_roots = {r for r, _ in sorted(first_at.items(), key=lambda kv: (kv[1], kv[0]))[: limit + 100]}
    limited = [c for c in candidates if c[1] in kept_roots]

    # * 2단계: 직접 부모 id를 묶어 기본키로 조회하고, 그 부모의 부모를 다시 조회하기를 루트에 닿을 때까지 반복
    parent_of = {}
    frontier = {parent for _, _, parent, _ in limited if parent is not None}
    while frontier:
        ids = [i for i in frontier if i not in parent_of]
        if not ids: break
        rows = session.execute(
            text("SELECT id, in_reply_to_message_id FROM suml.stocktwits_messages WHERE id = ANY(:ids)"),
            {"ids": ids},
        ).fetchall()
        for node_id, parent_id in rows: parent_of[node_id] = parent_id
        for node_id in ids:
            if node_id not in parent_of: parent_of[node_id] = "MISSING"
        frontier = {p for _, p in rows if p is not None and p in parent_of and p != "MISSING"} - set(parent_of)

    # * 3단계: 체인이 루트에 닿는 답글만 남기고 클러스터 limit개를 확정한다
    valid, context_by_target = [], {}
    for message_id, root_id, parent_id, created_at in limited:
        path, node, has_reached_root = [message_id], parent_id, False
        while node is not None and node != "MISSING" and node not in path:
            path.append(node)
            if node == root_id: has_reached_root = True; break
            node = parent_of.get(node)
        if has_reached_root: valid.append((message_id, root_id, created_at)); context_by_target[message_id] = path
    first_valid = {}
    for _, root_id, created_at in valid:
        if root_id not in first_valid or created_at < first_valid[root_id]: first_valid[root_id] = created_at
    roots = sorted({r for r, _ in sorted(first_valid.items(), key=lambda kv: (kv[1], kv[0]))[:limit]})
    context_ids = sorted({n for m, r, _ in valid if r in roots for n in context_by_target[m]})

    # * 4단계: 루트와 문맥 id만 넘겨 현행과 같은 12개 컬럼을 만든다. 리셰어 수도 이 id에 대해서만 센다
    context_sql = TargetMessagesQuery._load_sql("thread_cluster_context_v1.sql")
    result = session.execute(
        text(context_sql), {"roots": roots, "context_ids": context_ids, "all_ids": sorted(set(roots) | set(context_ids))}
    )
    columns = result.keys()
    return [dict(zip(columns, row)) for row in result]

예시 4. 설정값으로 구 쿼리와 신 쿼리를 고른다(stocktwits_message_postprocessor.py). 반입 뒤 현장 수정 인력이 없으므로 config.env 한 줄로 되돌릴 수 있어야 한다.

def postprocess_only_thread_cluster(self, target_date: str) -> Dict[str, int]:
    # * POSTPROCESSOR_QUERY_VERSION이 "v2"면 4단계 조회, 그 밖(legacy)이면 기존 한 문장 쿼리
    is_v2 = self.config.get("POSTPROCESSOR_QUERY_VERSION") == "v2"
    fetch_fn = (
        TargetMessagesQuery.fetch_only_thread_cluster_messages_v2
        if is_v2 else TargetMessagesQuery.fetch_only_thread_cluster_messages
    )
    return self._run_postprocessing_pipeline(
        target_date=target_date,
        fetch_fn=fetch_fn,
        mode_name="MESSAGE_ONLY_THREAD_CLUSTER_BATCH",
        log_header=f"릴레이션 클러스터 후처리 시작 (target_date={target_date}, query={'v2' if is_v2 else 'legacy'})",
        batch_limit_key="POSTPROCESSOR_ONLY_THREAD_CLUSTER_BATCH_LIMIT",
        batch_unit="스레드 클러스터",
    )

반입 전 검증은 세 가지다. 날짜 세 개 이상에서 fetch_only_thread_cluster_messages(v3)와 _v2의 결과를 행 단위로 비교해 차집합 0을 확인한다. 신규 본문은 메시지 id 집합이 같고 리셰어 수가 실제와 맞는지 확인한다(중복 제거로 행 단위는 달라진다). 그다음 데몬 1배치를 실행해 처리 건수와 _pipeline_disposition 전이를 전후로 대조한다.

3.4 삼성증권이 정할 것

  • v3 의미(댓글 트리거만, 리셰어 제외)를 그대로 유지할지. 저장소 로더를 v3로 맞추는 작업은 메가존이 한다.
  • 목표 응답 시간을 숫자로. 권장 제안은 두 쿼리 모두 0.5초 이하.
  • 중복 행 제거 승인, 2단계부터 4단계까지 한 번에 반입할지(권장) 2단계만 먼저 넣을지.
  • 메시지 보관 기간 1개월 유지 여부(파티셔닝 조건 1), 읽기 복제본과 등급 변경의 비용 승인 주체, 반입 검증과 승인 담당.

4. 요청 자료

세 안건을 확정하려면 삼성증권에서 아래를 받아야 한다. 상세 SQL은 미팅준비.md 7장에 있다.

  1. 운영 배송 종목 목록(is_final = TRUE의 symbol, exchange, ric_code, stock_name).
  2. 104개 목록에 상품 ID, 보유 고객 수, 보유 금액, 고객 순위를 더한 파일.
  3. Azure OpenAI Provisioned-Managed Utilization V2 1분 값 최근 30일과 429 비율, 번역 호출 토큰 집계 30일.
  4. 운영 PostgreSQL 등급, vCore, 메모리, 스토리지와 IOPS, SELECT version(), 진단 SQL 1~10번 결과, Azure 지표 최근 30일.
  5. 운영 서버의 target_messages_query.py 298행 값(스레드 쿼리 판본), 운영 config.env의 배치와 병렬도와 청크 값, 운영에 추가한 인덱스와 아카이빙 내역.
  6. 운영 규모 검증 환경 확보 방법(1개월치 반출, Blob 아카이브 4주, 복제 DB 중 가능한 것).

자료를 받으면 3영업일 안에 운영 실측으로 2차 보고를 드린다.