umla-sending 수정 반영

umla-sending 수정 반영 안내

요약 상태 갱신 결함(UPDATE 가 0행에 걸려 요약이 10분마다 다시 나가던 문제) 수정분이다. 운영 코드 3개 파일에 6곳을 고치고, 테스트 파일 1개를 새로 만든다(선택). 기준 커밋 5b671b8 대비 추가 31줄, 삭제 4줄.

읽는 법

− 지울 줄+ 추가할 줄표시 없음: 그대로 두는 줄(위치 확인용)

행 번호 두 칸은 전(아무것도 고치지 않은 원본 파일 기준)과 후(모든 단계를 끝낸 파일 기준)이다. 같은 파일의 앞 단계를 끝낸 뒤에는 줄이 밀려 있으므로, 각 단계의 "위치"와 "찾기" 문장은 앞 단계까지 반영한 상태의 행 번호를 따로 적어 두었다. 다 고친 뒤 맞춰 볼 때는 "후"를 본다.

들여쓰기는 공백 4칸이고 탭은 쓰지 않는다. 행 번호 옆 ×4 열의 숫자가 들여쓰기 단계다. 6 이면 공백 24칸, 6+2 는 26칸이다. 코드 앞의 옅은 세로선도 한 단계마다 하나씩 그렸는데, 좁은 화면에서는 폭을 줄여 그리므로 칸 수는 숫자로 센다. 긴 줄은 화면 폭에 맞춰 접혀 보이며, 접힌 둘째 줄부터는 코드 시작 열보다 두 칸 안쪽에서 시작한다. 접지 않고 옆으로 밀며 보려면 상단 "줄바꿈" 버튼을 끈다.

각 단계의 grep 명령은 umla-sending 폴더 안에서 실행한다. 결과가 정확히 1줄이어야 하며, 행 번호가 "찾기" 문장에 적힌 값과 같아야 한다. 단계 제목 왼쪽 체크박스는 이 핸드폰에만 저장된다.

작업 순서 (1번을 먼저 해야 2번 이후의 import 가 깨지지 않는다)

  1. 1. summary.py — 상수 SUMMARY_DATA_SOURCE 추가
  2. 2. external_sender.py — import 한 줄 추가
  3. 3. external_sender.py — 집계 변수 2개 선언
  4. 4. external_sender.py — UPDATE 결과로 행수를 세고 로그를 바꾼다
  5. 5. stocktwits_deliverer_helper.py — import 한 줄 추가
  6. 6. stocktwits_deliverer_helper.py — data_source 바인딩을 상수로 교체
  7. 7. 테스트 파일 신규 작성 (선택)
  8. 8. 반입 전 확인

community/stocktwits/models/summary.py

from .base import Base 아래, class 선언 위에 주석 5줄과 상수 1줄을 넣는다. 상수 아래에는 빈 줄 2줄을 둔다. (추가할 줄 8)

위치: 37행 바로 아래에 넣는다. 찾기: 아래 명령의 결과가 1줄, 35행이어야 한다.

grep -nF 'from .base import Base' community/stocktwits/models/summary.py
전후×4
3535from .base import Base
3636​
3737​
38+# * 요약 테이블(community_ticker_daily_summaries_processed)의 data_source 고정값
39+# ! DDL(doc/community/stocktwits/datamodels/sql/7.ddl_summary.sql)의 data_source 주석과
40+# ! 요약 생성 측(umla-translation StocktwitsSummaryPostprocessor.DATA_SOURCE)이 같은 값을 쓴다.
41+# ! 전송용 조회 SQL(deliverer_summary_message_query_v4.sql)은 이 컬럼을 'Samsung' 별칭으로
42+# ! 바꿔 돌려주므로, 상태를 갱신할 때는 payload 값이 아니라 이 상수를 PK 조건에 바인딩한다.
43+SUMMARY_DATA_SOURCE = "StockTwits"
44+​
45+​
3846class CommunityTickerDailySummaryProcessed(Base):
39471"""
40481* 티커별 일일 요약 결과 테이블
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
후×4
35from .base import Base
36​
37​
38# * 요약 테이블(community_ticker_daily_summaries_processed)의 data_source 고정값
39# ! DDL(doc/community/stocktwits/datamodels/sql/7.ddl_summary.sql)의 data_source 주석과
40# ! 요약 생성 측(umla-translation StocktwitsSummaryPostprocessor.DATA_SOURCE)이 같은 값을 쓴다.
41# ! 전송용 조회 SQL(deliverer_summary_message_query_v4.sql)은 이 컬럼을 'Samsung' 별칭으로
42# ! 바꿔 돌려주므로, 상태를 갱신할 때는 payload 값이 아니라 이 상수를 PK 조건에 바인딩한다.
43SUMMARY_DATA_SOURCE = "StockTwits"
44​
45​
46class CommunityTickerDailySummaryProcessed(Base):
471"""
481* 티커별 일일 요약 결과 테이블

community/services/external_sender.py

community.interfaces import 바로 아래에 한 줄을 추가한다. (추가할 줄 1)

위치: 15행 바로 아래에 넣는다. 찾기: 아래 명령의 결과가 1줄, 15행이어야 한다.

grep -nF 'from community.interfaces import DeliveryItem, StatusUpdateInfo' community/services/external_sender.py
전후×4
1313from core.log import LogHandler
1414from core.utils import sftp_utils
1515from community.interfaces import DeliveryItem, StatusUpdateInfo
16+from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE
1617​
1718​
1819class CommunityExternalSender:
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
후×4
13from core.log import LogHandler
14from core.utils import sftp_utils
15from community.interfaces import DeliveryItem, StatusUpdateInfo
16from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE
17​
18​
19class CommunityExternalSender:

community/services/external_sender.py

_update_summary_statuses 안, import json 다음의 for item in items: 위에 두 줄과 빈 줄을 넣는다. (추가할 줄 3)

위치: 345행 바로 아래에 넣는다 (이 파일의 앞 단계를 아직 안 했으면 344행). 찾기: 아래 명령의 결과가 1줄, 344행 (앞 단계를 아직 안 했으면 343행)이어야 한다.

grep -nF 'import json' community/services/external_sender.py
전후×4
342343​
3433443import json
344345​
346+3updated_rows = 0
347+3unmatched_tickers: List[str] = []
348+​
3453493for item in items:
3463504payload = item.payload
3473514tickers_raw = payload.get("tickers", "[]")
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
후×4
343​
3443import json
345​
3463updated_rows = 0
3473unmatched_tickers: List[str] = []
348​
3493for item in items:
3504payload = item.payload
3514tickers_raw = payload.get("tickers", "[]")

community/services/external_sender.py

session.execute 결과를 result 에 받고, data_source 바인딩을 상수로 바꾸고, 완료 로그를 실제 갱신 행수로 바꾸고, 누락 경고를 추가한다. (지울 줄 3, 추가할 줄 16)

위치: 377행을 고친다 (이 파일의 앞 단계를 아직 안 했으면 373행). 찾기: 아래 명령의 결과가 1줄, 374행 (앞 단계를 아직 안 했으면 370행)이어야 한다.

grep -nF 'if not ticker:' community/services/external_sender.py
전후×4
3703744if not ticker:
3713755continue
372376​
373−4session.execute(
377+4result = session.execute(
3743785update_query,
3753795{
3763806"status": mapped_status,
377−6"data_source": payload.get("data_source", "StockTwits"),
381+6# ! 조회 SQL 은 data_source 를 'Samsung' 별칭으로 돌려주므로 payload 값을
382+6# ! 바인딩하면 PK(data_source = 'StockTwits')와 어긋나 0행 갱신이 된다
383+6"data_source": SUMMARY_DATA_SOURCE,
3783846"ticker": ticker,
3793856"summary_date": payload.get("created_at"),
3803865},
3813874)
388+4if result.rowcount > 0:
389+5updated_rows += result.rowcount
390+4else:
391+5unmatched_tickers.append(ticker)
382392​
3833933session.commit()
3843943self.logger.info(
385−4f"[요약] 상태 업데이트 완료: {len(items)}개 -> {mapped_status}"
395+4f"[요약] 상태 업데이트 완료: 대상 {len(items)}건, "
396+4f"갱신 {updated_rows}행 -> {mapped_status}"
3863973)
398+3if unmatched_tickers:
399+4self.logger.warning(
400+5f"[요약] 상태 갱신 누락: {len(unmatched_tickers)}건이 상태 테이블의 "
401+5f"PK(data_source, ticker, summary_date)와 매칭되지 않음 "
402+5f"(ticker: {', '.join(unmatched_tickers)}) -> {mapped_status}"
403+4)
387404​
3884052except Exception as e:
3894063session.rollback()
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
후×4
3744if not ticker:
3755continue
376​
3774result = session.execute(
3785update_query,
3795{
3806"status": mapped_status,
3816# ! 조회 SQL 은 data_source 를 'Samsung' 별칭으로 돌려주므로 payload 값을
3826# ! 바인딩하면 PK(data_source = 'StockTwits')와 어긋나 0행 갱신이 된다
3836"data_source": SUMMARY_DATA_SOURCE,
3846"ticker": ticker,
3856"summary_date": payload.get("created_at"),
3865},
3874)
3884if result.rowcount > 0:
3895updated_rows += result.rowcount
3904else:
3915unmatched_tickers.append(ticker)
392​
3933session.commit()
3943self.logger.info(
3954f"[요약] 상태 업데이트 완료: 대상 {len(items)}건, "
3964f"갱신 {updated_rows}행 -> {mapped_status}"
3973)
3983if unmatched_tickers:
3994self.logger.warning(
4005f"[요약] 상태 갱신 누락: {len(unmatched_tickers)}건이 상태 테이블의 "
4015f"PK(data_source, ticker, summary_date)와 매칭되지 않음 "
4025f"(ticker: {', '.join(unmatched_tickers)}) -> {mapped_status}"
4034)
404​
4052except Exception as e:
4063session.rollback()

community/stocktwits/services/api/stocktwits_deliverer_helper.py

deliverer_message_query import 바로 아래에 한 줄을 추가한다. (추가할 줄 1)

위치: 21행 바로 아래에 넣는다. 찾기: 아래 명령의 결과가 1줄, 21행이어야 한다.

grep -nF 'from .queries.deliverer_message_query import DelivererMessageQueries' community/stocktwits/services/api/stocktwits_deliverer_helper.py
전후×4
1919from core.log import LogHandler
2020from core.utils.json_handler import JsonHandler
2121from .queries.deliverer_message_query import DelivererMessageQueries
22+from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE
2223from community.stocktwits.models.published import (
23241CommunityMessagePublished,
24251CommunityUserPublished,
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
후×4
19from core.log import LogHandler
20from core.utils.json_handler import JsonHandler
21from .queries.deliverer_message_query import DelivererMessageQueries
22from community.stocktwits.models.summary import SUMMARY_DATA_SOURCE
23from community.stocktwits.models.published import (
241CommunityMessagePublished,
251CommunityUserPublished,

community/stocktwits/services/api/stocktwits_deliverer_helper.py

update_summaries_processing_status 안의 summary["data_source"] 를 상수로 바꾸고 주석 한 줄을 붙인다. (지울 줄 1, 추가할 줄 2)

위치: 723행을 고친다 (이 파일의 앞 단계를 아직 안 했으면 722행). 찾기: 아래 명령의 결과가 1줄, 720행 (앞 단계를 아직 안 했으면 719행)이어야 한다.

grep -nF 'update_query,' community/stocktwits/services/api/stocktwits_deliverer_helper.py
전후×4
7197205update_query,
7207215{
7217226"status": status,
722−6"data_source": summary["data_source"],
723+6# ! 조회 SQL 의 'Samsung' 별칭 대신 PK 값('StockTwits')을 바인딩한다
724+6"data_source": SUMMARY_DATA_SOURCE,
7237256"ticker": ticker,
7247266"summary_date": summary["created_at"],
7257275},
수정 후 모습 (새 행 번호 기준, 그대로 두는 줄 포함)
후×4
7205update_query,
7215{
7226"status": status,
7236# ! 조회 SQL 의 'Samsung' 별칭 대신 PK 값('StockTwits')을 바인딩한다
7246"data_source": SUMMARY_DATA_SOURCE,
7256"ticker": ticker,
7266"summary_date": summary["created_at"],
7275},
_update_summary_statuses 수정 후 전체 (community/services/external_sender.py 308~409행, 102줄)
후×4
3081def _update_summary_statuses(
3092self,
3102session: Session,
3112items: List[DeliveryItem],
3122target_status: str,
3131) -> None:
3142"""
3152* 요약 상태 업데이트
316​
3172Args:
3183session: SQLAlchemy 세션
3193items: 요약 DeliveryItem 리스트
3203target_status: 변경할 상태값
3212"""
3222if not items:
3233return
324​
3252try:
3263# * processing_status가 DELIVERED/DELIVERY_FAILED로 매핑됨
3273status_map = {
3284"PIPELINE_COMPLETED": "DELIVERED",
3294"DELIVERY_FAILED": "DELIVERY_FAILED",
3303}
3313mapped_status = status_map.get(target_status, target_status)
332​
3333update_query = text(
3344"""
3354UPDATE suml.community_ticker_daily_summaries_processed
3364SET processing_status = :status,
3375_updated_at = CURRENT_TIMESTAMP
3384WHERE data_source = :data_source
3395AND ticker = :ticker
3405AND summary_date = :summary_date
3413"""
3423)
343​
3443import json
345​
3463updated_rows = 0
3473unmatched_tickers: List[str] = []
348​
3493for item in items:
3504payload = item.payload
3514tickers_raw = payload.get("tickers", "[]")
352​
3534# * tickers 파싱
3544if isinstance(tickers_raw, str):
3555tickers_json = json.loads(tickers_raw)
3564else:
3575tickers_json = tickers_raw
358​
3594if not tickers_json:
3605continue
361​
3624# * 첫 번째 요소에서 ticker 추출
3634ticker_exchange = tickers_json[0]
3644if isinstance(ticker_exchange, str):
3655parts = ticker_exchange.split("|")
3665ticker = parts[0] if parts else None
3674else:
3685ticker = (
3696ticker_exchange.get("symbol")
3706if isinstance(ticker_exchange, dict)
3716else None
3725)
373​
3744if not ticker:
3755continue
376​
3774result = session.execute(
3785update_query,
3795{
3806"status": mapped_status,
3816# ! 조회 SQL 은 data_source 를 'Samsung' 별칭으로 돌려주므로 payload 값을
3826# ! 바인딩하면 PK(data_source = 'StockTwits')와 어긋나 0행 갱신이 된다
3836"data_source": SUMMARY_DATA_SOURCE,
3846"ticker": ticker,
3856"summary_date": payload.get("created_at"),
3865},
3874)
3884if result.rowcount > 0:
3895updated_rows += result.rowcount
3904else:
3915unmatched_tickers.append(ticker)
392​
3933session.commit()
3943self.logger.info(
3954f"[요약] 상태 업데이트 완료: 대상 {len(items)}건, "
3964f"갱신 {updated_rows}행 -> {mapped_status}"
3973)
3983if unmatched_tickers:
3994self.logger.warning(
4005f"[요약] 상태 갱신 누락: {len(unmatched_tickers)}건이 상태 테이블의 "
4015f"PK(data_source, ticker, summary_date)와 매칭되지 않음 "
4025f"(ticker: {', '.join(unmatched_tickers)}) -> {mapped_status}"
4034)
404​
4052except Exception as e:
4063session.rollback()
4073tb = traceback.format_exc()
4083self.logger.error(f"[요약] 상태 업데이트 실패: {e}\n스택 트레이스:\n{tb}")
4093raise

test/test_external_sender.py

새 파일이다. 144줄 전체를 옮겨 적는다. 시간이 없으면 건너뛰고 아래 "반입 전 확인"의 2번부터 진행해도 된다. 테스트는 DB 없이 돈다(MagicMock).

파일 전체 보기 (144줄)
행×4
1"""
2* CommunityExternalSender 테스트 코드
3! 요약 상태 업데이트(_update_summary_statuses)의 바인딩 값과 결과 로그 검증
4! 배경: 요약 조회 SQL(deliverer_summary_message_query_v4.sql)은 data_source 를
5! 'Samsung' 별칭으로 돌려주지만, 상태 테이블(community_ticker_daily_summaries_processed)의
6! PK 는 data_source = 'StockTwits' 이므로 payload 값을 그대로 바인딩하면 UPDATE 가 0행에 걸린다.
7"""
8​
9import os
10import sys
11import unittest
12from datetime import date
13from unittest.mock import MagicMock
14​
15# * 프로젝트 루트 경로 추가
16sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17​
18from core.log import LogHandler
19from core.adapters import DatabaseAdapter
20from community.interfaces import DeliveryItem
21from community.services.external_sender import CommunityExternalSender
22from community.stocktwits.services.api.stocktwits_deliverer_helper import (
231StocktwitsDelivererHelper,
24)
25​
26​
27def _summary_payload(**overrides):
281"""* v4 요약 조회 SQL 이 돌려주는 형태의 payload (상태 갱신에 쓰이는 키만)"""
291payload = {
302"data_source": "Samsung",
312"record_type": "SUMMARY_AAPL",
322"id": -2026103001000000001,
332"tickers": '["AAPL|NASDAQ"]',
342"created_at": date(2026, 10, 30),
351}
361payload.update(overrides)
371return payload
38​
39​
40def _summary_item(**overrides) -> DeliveryItem:
411return DeliveryItem(
422id="-2026103001000000001",
432source="stocktwits",
442data_type="summary",
452payload=_summary_payload(**overrides),
462table_name="suml.community_ticker_daily_summaries_processed",
472id_column="id",
481)
49​
50​
51def _session_with_rowcount(rowcount: int) -> MagicMock:
521session = MagicMock()
531session.execute.return_value = MagicMock(rowcount=rowcount)
541return session
55​
56​
57class TestUpdateSummaryStatuses(unittest.TestCase):
581"""* CommunityExternalSender._update_summary_statuses 테스트"""
59​
601def setUp(self):
612self.mock_logger = MagicMock(spec=LogHandler)
622self.sender = CommunityExternalSender(
633logger=self.mock_logger, config={}, hostname="test-host"
642)
65​
661def test_binds_stocktwits_data_source_even_when_payload_says_samsung(self):
672"""* payload 의 data_source 가 'Samsung' 이어도 UPDATE 는 PK 값 'StockTwits' 로 바인딩한다"""
682session = _session_with_rowcount(1)
69​
702self.sender._update_summary_statuses(
713session, [_summary_item()], "PIPELINE_COMPLETED"
722)
73​
742params = session.execute.call_args.args[1]
752self.assertEqual(params["data_source"], "StockTwits")
762self.assertEqual(params["ticker"], "AAPL")
772self.assertEqual(params["summary_date"], date(2026, 10, 30))
782self.assertEqual(params["status"], "DELIVERED")
79​
801def test_logs_actual_updated_row_count_not_item_count(self):
812"""* 완료 로그는 요청 건수가 아니라 실제 갱신 행수를 적는다"""
822session = _session_with_rowcount(0)
83​
842self.sender._update_summary_statuses(
853session, [_summary_item()], "PIPELINE_COMPLETED"
862)
87​
882info_messages = [c.args[0] for c in self.mock_logger.info.call_args_list]
892self.assertTrue(
903any("대상 1건" in m and "갱신 0행" in m for m in info_messages),
913f"완료 로그에 실제 갱신 행수가 없음: {info_messages}",
922)
93​
941def test_warns_when_updated_rows_fall_short_of_items(self):
952"""* 갱신 행수가 대상 건수보다 적으면 경고를 남긴다"""
962session = _session_with_rowcount(0)
97​
982self.sender._update_summary_statuses(
993session, [_summary_item()], "PIPELINE_COMPLETED"
1002)
101​
1022self.mock_logger.warning.assert_called_once()
1032self.assertIn("AAPL", self.mock_logger.warning.call_args.args[0])
104​
1051def test_no_warning_when_every_item_is_updated(self):
1062"""* 모든 건이 갱신되면 경고를 남기지 않는다"""
1072session = _session_with_rowcount(1)
108​
1092self.sender._update_summary_statuses(
1103session, [_summary_item()], "PIPELINE_COMPLETED"
1112)
112​
1132self.mock_logger.warning.assert_not_called()
114​
115​
116class TestLegacyHelperUpdateSummariesProcessingStatus(unittest.TestCase):
1171"""* StocktwitsDelivererHelper.update_summaries_processing_status 테스트 (비운영 경로, 동일 결함)"""
118​
1191def setUp(self):
1202self.mock_logger = MagicMock(spec=LogHandler)
1212self.helper = StocktwitsDelivererHelper(
1223logger=self.mock_logger,
1233db_adapter=MagicMock(spec=DatabaseAdapter),
1243config={},
1253hostname="test-host",
1262)
127​
1281def test_binds_stocktwits_data_source_even_when_payload_says_samsung(self):
1292"""* 레거시 경로도 payload 의 'Samsung' 대신 PK 값 'StockTwits' 로 바인딩한다"""
1302session = _session_with_rowcount(1)
131​
1322self.helper.update_summaries_processing_status(
1333session, [_summary_payload()], "DELIVERED"
1342)
135​
1362params = session.execute.call_args.args[1]
1372self.assertEqual(params["data_source"], "StockTwits")
1382self.assertEqual(params["ticker"], "AAPL")
1392self.assertEqual(params["summary_date"], date(2026, 10, 30))
1402self.assertEqual(params["status"], "DELIVERED")
141​
142​
143if __name__ == "__main__":
1441unittest.main()

실행(umla-sending 폴더에서, pytest 가 없어도 된다):

python3.11 test/test_external_sender.py -v

마지막 줄이 OK 이고 Ran 5 tests 여야 한다.

동작 변화: 수정 뒤에는 그날 요약이 09시 이후 첫 회차에 한 번만 나간다. 전송이 실패하면 다음 회차에 자동으로 다시 나가지 않으므로, 실패한 행은 사람이 되돌린다. 먼저 SELECT 로 건수를 보고(0건이면 조건이 틀린 것), 그 다음에 UPDATE 한다.

SELECT ticker, processing_status, _updated_at FROM suml.community_ticker_daily_summaries_processed WHERE data_source = 'StockTwits' AND summary_date = CURRENT_DATE AND processing_status IN ('DELIVERY_QUEUED','DELIVERY_IN_PROGRESS','DELIVERY_FAILED') ORDER BY ticker;
UPDATE suml.community_ticker_daily_summaries_processed SET processing_status = 'COMPLETED' WHERE data_source = 'StockTwits' AND summary_date = CURRENT_DATE AND processing_status IN ('DELIVERY_QUEUED','DELIVERY_IN_PROGRESS','DELIVERY_FAILED');