o2o-negosium-original/schedules/anchoring/docs/개발용.md
민헌 f29e9723b0 feat(anchoring): 앵커링 값 자동 조정 자립 모듈 신설 (v1.2)
schedules/anchoring — backend 를 import 하지 않는 독립 컨테이너 배치 서비스.
회사 × 협력사유형(1유통/2제조/3총판) × 가격구간(3,000원, 33,334칸)별 앵커링
값(정수 천분율)을 격주 토 00:00 KST 에 협상 성공률로 자동 조정한다.

- 판정 = "가격 흔적" 기준: last_offered_price 가 있는 종료 재협상만 표본,
  DONE & bid ≤ 박제 앵커만 성공, 나머지(초과 합의·결렬·가격 쓰고 이탈) 실패.
  앵커는 비노출(엔진 내부 체결 임계) — agent 무변경
- 저장 = anchoring.rate_adjustments 1개(append-only, consumed_session_ids 박제),
  소비 경계 = sessions.anchoring_adjustment_id 마킹(멱등·이월). DDL 은 모듈
  소유(schema.sql, sessions 3컬럼 ALTER 포함)
- 안정성: 조정 INSERT+마킹 한 트랜잭션 + rowcount 불일치 전체 롤백,
  Redis TTL 7일 + 매주 조정 칸 re-SET, socket timeout 0.3s, DB 폴백,
  가격 제시율 0% WARN, --once 수동 캐치업
- 정적 기본 테이블(전 구간 10‰, 상한 정확히 1억·초과분 마지막 인덱스 클램프)
  기동 검증 실패 시 기동 중단
- 전체 async(SQLAlchemy+asyncpg, redis.asyncio) — negodata 가 reader 를 그대로
  이식 가능(docs/인수인계.md). 최종 문서 docs/{개발용,기획용,워크플로우}.md
- 테스트 15종: 골든 벡터(§11) + DB 통합(멱등·이월·격리·rowcount 롤백)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:29:46 +09:00

42 KiB
Raw Blame History

앵커링 시스템 구현 스펙 (개발용)

문서 성격: 이 문서만 보고 앵커링 시스템을 구현·유지보수할 수 있도록 작성된 규범 문서(최종 확정본). 규범 언어: MUST = 반드시 준수, MUST NOT = 금지, SHOULD = 권장, MAY = 선택. 스택: FastAPI(async) + PostgreSQL(SQLAlchemy async / SQL 수동 적용, Alembic 없음) + Redis + APScheduler — schedules/anchoring 자립 컨테이너(backend 내장 아님). 버전: v1.2 (2026-07-02 확정) — 정책 배경은 기획용.md, 흐름 해설은 워크플로우.md, 타 팀(negodata) 적용 명세는 인수인계.md 참조.


목차

  1. 확정 결정 요약
  2. 정적 기본 테이블
  3. 상수 정의
  4. 도메인 규칙
  5. 아키텍처
  6. DB 스키마
  7. Redis 캐시 규약
  8. 배치 잡 명세
  9. 견적 생성·협상 플로우
  10. 참조 구현
  11. 검증 벡터 (Golden Tests)
  12. 금지·봉인 사항
  13. 선행·연계 작업

1. 확정 결정 요약

항목 결정
역할 분담 schedules/anchoring 자립 모듈 = 정적 테이블·rate 조회(reader)·조정 배치·Redis 규약·DDL 소유, 독립 컨테이너로 자체 스케줄 실행 / negodata = 세션 생성 시 reader 로 rate 조회 → 앵커가·rate 박제 (인수인계, §9.1) / backend = 협상 채팅(박제값 소비 + 마지막 제시가 기록, anchoring 모듈 무의존) (§9.2) / agent = 변경 없음(앵커 비노출 — 정보 비대칭 전략)
소유·수정 범위 직접 수정 가능 = backend·frontend·schedules(우리 모듈). negodata·agent는 인수인계 문서로 전달 → 담당 개발자가 적용
기본 테이블 서비스 시작 시 메모리 로드되는 불변 정적 테이블 (src/anchoring/resources/anchoring_base.json, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스
가격 상한 정적 테이블 상한 = 정확히 1억 원. target_price > 1억은 전부 마지막 인덱스(idx 33334) 로 클램프
멀티테넌시 앵커링 값은 회사(company)별로 독립 — 칸 키에 company_id(uuid) 포함
표본 전용 테이블 없음. 종료된 재협상 세션(negotiation.sessions)의 종료 후 불변 컬럼(target_anchoring_price, anchor_rate_permille, last_offered_price, bid_price, status)에서 배치 시점에 파생 판정한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다
표본 기준 "가격 흔적": 협력사가 가격을 한 번이라도 써낸(last_offered_price 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외
앵커 비노출 agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작
조정 이력 저장 append-only 조정 이력 anchoring.rate_adjustments 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시
소비 경계 sessions.anchoring_adjustment_id 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = 한 트랜잭션
평가 트리거 격주 토요일 00:00 (KST), 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가
평가 방식 누적 전량 평가: 미처리 유효 표본 전부(n건)로 r = 성공/n 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월
앵커링가 반올림 1원 단위 내림(floor) — 정수 연산만 사용
값 표현 앵커링 값은 정수 천분율(‰) 로 저장·계산 (부동소수점 산술 금지)
코드값 프로젝트 컨벤션: SMALLINT 1-based 코드 + 앱 enum 매핑, DB CHECK/FK/ENUM 없음

2. 정적 기본 테이블

서비스 시작 시 메모리에 로드되는 불변 리스트. DB에 저장하지 않으며, 런타임에 절대 수정하지 않는다 (MUST NOT).

파일: src/anchoring/resources/anchoring_base.json (리포에 커밋, 33,334행). 키는 프로젝트 컨벤션대로 snake_case.

[
  { "idx": 1,     "upper_bound": 3000,      "anchoring_value": 0.01 },
  { "idx": 2,     "upper_bound": 6000,      "anchoring_value": 0.01 },
  ...
  { "idx": 33333, "upper_bound": 99999000,  "anchoring_value": 0.01 },
  { "idx": 33334, "upper_bound": 100000000, "anchoring_value": 0.01 }
]

마지막 행(idx 33334)만 upper_bound가 idx × 3000(= 100,002,000)이 아니라 정확히 100,000,000(1억) 이다. 1억 초과 가격은 전부 이 마지막 인덱스로 클램프된다(§2.1).

2.1 매핑 규약 (MUST)

항목 규약
구간 범위 idx k의 구간 = [upper_bound − 3000, upper_bound) 좌폐우개
경계값 소속 target_price가 정확히 upper_bound와 같으면 다음 idx 소속. 예: 3,000원 → idx 2
내부 인덱스 변환 bracket_index = idx − 1 = target_price // 3000 (0-기반). DB·Redis·코드 내부는 bracket_index 사용
상한 클램프 target_price ≥ 99,999,000 → 전부 최상위 구간(idx 33334, bracket_index 33333). 1억 초과도 예외 없이 마지막 인덱스
시작값 칸의 시작 앵커링 값 = 해당 idx의 anchoring_value 천분율 변환 정수: int(anchoring_value * 1000). 현재 전 구간 10‰
기동 검증 로드 시 33,334행·idx 연속(1..33334)·upper_bound == min(idx*3000, 100_000_000)·0.01 ≤ anchoring_value ≤ 0.20 검증, 실패 시 기동 중단 (§13)
  • 시작값은 정적 테이블에서만 읽는다. 코드에 0.01/10 하드코딩 MUST NOT (테이블이 유일한 소스).
  • anchoring_value는 회사 무관 공통. 회사별 차이는 조정 이력의 누적에서만 발생한다.

3. 상수 정의

모든 비율은 정수 천분율(permille). 10‰ = 1%.

# src/anchoring/constants.py

ANCHOR_RATE_MIN = 10          # 하한 1%
ANCHOR_RATE_MAX = 200         # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드

# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
DELTA_PERMILLE = {
    1: 20,   # 유통(DISTRIBUTION)  ±2%
    2: 10,   # 제조(MANUFACTURE)   ±1%
    3: 15,   # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
}

SAMPLE_THRESHOLD = 10         # 평가 최소 유효 표본 수 (미만이면 스킵·이월)

PRICE_BRACKET_UNIT = 3_000    # 가격구간 폭 (원)
PRICE_MAX          = 100_000_000  # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
BRACKET_INDEX_MAX  = 33_333   # 0-기반 구간 인덱스 상한 (총 33,334칸)

EVAL_WEEK_PARITY   = 0        # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
                              # 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)

MARK_EXCLUDED = 0             # sessions.anchoring_adjustment_id 제외 확정 마킹값

CACHE_TTL_SECONDS = 7 * 24 * 3600   # Redis 키 TTL(§7) — stale 잔존 방지 보조
REDIS_SOCKET_TIMEOUT = 0.3          # 행(hang) 방지 — 초과 시 DB 폴백

앱 enum — backend 는 anchoring 판정을 하지 않으므로(무의존) enum 은 모듈 내부(constants.py)에 둔다 (프로젝트 컨벤션 — plain Enum, 1-based, 대상 컬럼 docstring):

class SupplierType(Enum):
    """협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
    (negodata SupplierType 과 동일 코드)"""
    NONE = 0          # 미지정 — 앵커링 칸 구성 불가(집계 제외)
    DISTRIBUTION = 1  # 유통
    MANUFACTURE = 2   # 제조
    SOLE_AGENCY = 3   # 총판

class AnchoringSampleType(Enum):
    """앵커링 표본 판정 결과(파생값 — DB 에 저장하지 않음, 평가 로직·로그용).
    기준 = "가격 흔적": 가격을 써낸 협상만 표본."""
    BID_SUCCESS = 1   # 정상종료 + bid ≤ 박제 앵커
    BID_FAIL = 2      # 가격 흔적 있으나 성공 아님 (앵커 초과 합의 / 결렬 / 가격 쓰고 이탈·만료)
    EXCLUDED = 3      # 가격 흔적 없음 / 앵커 박제 없음 / 유형 미지정
  • 상수 변경은 정책 재확정 사안이다. 코드에서 임의 조정 MUST NOT.
  • 앵커링 값을 float으로 저장·연산 MUST NOT. 모든 산술은 정수로 수행한다 (성공률 비교도 §10처럼 정수 비교).

4. 도메인 규칙

4.1 칸(cell) 식별

칸 = (company_id, supplier_type, bracket_index) 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.

bracket_index = min(target_price // 3_000, 33_333)
  • bracket_index 산출 기준 가격은 목표가(target_price) 다 (MUST). 1억 이상(≥ 99,999,000)은 전부 마지막 인덱스 33333.
  • 칸 해석 소스: company_id = partner.items.company_id (세션의 item 소유 회사 = 갑), supplier_type = quotation.quotations.supplier_type (재협상 1:1 견적에 기록됨).
  • 같은 구간·유형이라도 회사가 다르면 서로 다른 칸. 회사 간 표본·값 공유 MUST NOT.
  • 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
  • supplier_type ∉ {1,2,3} 이거나 company_id 미해석 세션은 칸을 구성할 수 없다 → 가격 산출은 정적 테이블 시작값으로 동작(§9), 집계에서는 제외(§4.3).

4.2 앵커링가 계산

anchor_price = target_price × (1000 − anchor_rate_permille) // 1000
  • target_price가 정수(원)이므로 위 식은 정수 연산만으로 정확한 내림을 보장한다.
  • 부동소수점 곱셈 경유 MUST NOT (int(price * 0.99), round(price * 0.99) 형태 금지).
  • 결과는 항상 1원 단위 정수.

4.3 표본 판정 (배치 시점 파생 — "가격 흔적" 기준)

표본 = 종료된 재협상 세션 중 협력사가 가격을 한 번이라도 써낸 것. 전용 테이블 없이, 배치가 아래 종료 후 불변 입력에서 판정을 파생한다.

한 줄 요약: "가격을 써낸 협상만 세고 — 앵커 이하로 합의됐으면 성공, 나머지는 전부 실패."

판정 입력:

컬럼 의미 기록 시점
sessions.target_anchoring_price 제안 당시 앵커링가 (판정 기준) negodata 세션 생성 시 1회 박제 (§9.1)
sessions.anchor_rate_permille 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) 동상
sessions.last_offered_price 협력사 마지막 제시가 = 가격 흔적 (NULL = 가격을 써낸 적 없음) backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변
sessions.status / bid_price 종료 상태 / 확정 투찰가 세션 종료 시 확정

판정 대상: qt_type = 1(재협상) AND status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED} AND deleted = false.

판정 조건 유효 표본 성공
BID_SUCCESS status=DONE AND bid_price ≤ target_anchoring_price O O
BID_FAIL 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) O X
EXCLUDED last_offered_price IS NULL(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 X —
  • "유효 표본" = EXCLUDED가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
  • 왜 실패에 결렬·이탈이 반드시 포함돼야 하나: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(check_price_match) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
  • 판정 입력 컬럼은 종료 후 절대 수정 금지 (MUST NOT — §12). last_offered_price 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.

4.4 평가 산식 (누적 전량 평가)

배치 시점에 칸별로 수행한다.

pending = 해당 칸의 미처리(anchoring_adjustment_id IS NULL) 유효 표본 전부
n = |pending|

n < 10  →  평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
n ≥ 10  →  r = (pending 중 BID_SUCCESS 건수) / n

        ┌ +δ(p)   if r ≥ 0.60
delta = ┤  0      if 0.30 ≤ r < 0.60
        └ −δ(p)   if r < 0.30

anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200)
→ 한 트랜잭션으로:
   ① anchoring.rate_adjustments INSERT (n, success, before/after, consumed_session_ids 박제)
   ② 소비 세션 UPDATE sessions SET anchoring_adjustment_id = <조정 id>
      WHERE session_id IN (...) AND anchoring_adjustment_id IS NULL   ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
  • 분모는 항상 실제 누적 건수 n (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
  • delta = 0이어도, clamp에 막혀 값이 안 변해도 조정 레코드는 반드시 INSERT하고 표본을 소비(마킹)한다 (MUST).
  • "표본 소비" = 마킹. 물리 삭제 없음. EXCLUDED·칸 구성 불가 세션은 평가와 무관하게 anchoring_adjustment_id = 0으로 일괄 마킹해 재스캔을 방지한다.
  • 한 칸은 한 배치에서 최대 1회 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).

4.5 현재 앵커링 값 조회

값은 저장된 단일 상태가 아니라 조정 이력의 최신 행이다.

rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after
       없으면 → 정적 테이블 시작값 (§2.1)
  • 재현성: 조정 행에 박제된 consumed_session_ids(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. 조정 이력은 유일 진실 원천이며 보호 대상이다 (백업 정책 적용 MUST).
  • 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 consumed_session_ids를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 MUST NOT (배치 시각 의존이므로 불가능).
  • 알려진 완화: sessions 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.

5. 아키텍처

[anchoring 서비스 기동 — schedules/anchoring 독립 컨테이너]
      정적 기본 테이블 메모리 로드·검증 (불변, §2) ── Redis 클라이언트 init ── APScheduler 기동

[견적/세션 생성 — negodata, 인수인계 §9.1]
      │  칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
      │  → 세션 INSERT 에 target_anchoring_price + anchor_rate_permille 박제 (재생성 상속 폐지)
      ▼
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
      │  박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
      │  가격 입력 턴마다 last_offered_price 갱신 (가격 흔적)
      ▼
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
      │
      │  격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
      │  → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
      ▼
anchoring.rate_adjustments ────────── 진실 원천 (INSERT only, consumed_session_ids·값 변화 박제)
      │
      │  배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
      ▼
Redis  anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7일
      │
      │  GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
      ▼
[다음 견적/세션 생성]  조정된 rate 로 앵커가 산출
  • 조정 이력 테이블에 UPDATE / DELETE MUST NOT.
  • 배치가 sessions에 쓰는 것은 anchoring_adjustment_id 단 하나 — 다른 컬럼 수정 MUST NOT.
  • 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
  • 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
  • Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).

6. DB 스키마

프로젝트 컨벤션 준수: FK/CHECK/PG ENUM 없음, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 모듈 소유 — schema.sql 한 파일(스키마+테이블+sessions ALTER+인덱스, psql 수동 적용). postgres-init 에는 anchoring 파일을 두지 않는다.

네이밍 결정 — 기존 코드베이스 용어와 통일:

개념 명칭 이유
조정 이력 테이블 anchoring.rate_adjustments 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현
협력사 유형 supplier_type 기존 quotations.supplier_type과 용어 통일
가격구간 price_bracket_index 가격구간임을 명시 (코드 내부 변수는 bracket_index)
표본 수 nego_count "협상 결과 n건" — 정책 문서 용어
값 변화 anchor_rate_before / anchor_rate_after sessions.anchor_rate_permille와 계열 통일 (‰)
소비 창 consumed_session_ids "이 조정이 소비한 세션"임을 명시
생성 시각 created_at 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각)
소비 마킹 sessions.anchoring_adjustment_id 조정 테이블명과 정합

6.1 조정 이력 (신설 — 유일한 새 테이블)

CREATE SCHEMA IF NOT EXISTS anchoring;

-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
    id                   BIGSERIAL   PRIMARY KEY,
    company_id           uuid        NOT NULL,   -- 테넌트(partner.items.company_id 유래)
    supplier_type        SMALLINT    NOT NULL,   -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
    price_bracket_index  INTEGER     NOT NULL,   -- 가격구간 0..33333 (앱 보장)
    nego_count           INTEGER     NOT NULL,   -- 유효 표본 수 n (>=10, 앱 보장)
    success_count        INTEGER     NOT NULL,   -- n 중 성공(BID_SUCCESS) 건수
    anchor_rate_before   SMALLINT    NOT NULL,   -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
    anchor_rate_after    SMALLINT    NOT NULL,   -- 조정 후 값(‰), clamp [10,200] 앱 보장
    consumed_session_ids JSONB       NOT NULL,   -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
    created_at           TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 현재 값 조회 최적화: 칸별 최신 조정
CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell
    ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC);

6.2 sessions 확장 (기존 테이블 ALTER)

ALTER TABLE negotiation.sessions
  ADD COLUMN IF NOT EXISTS anchor_rate_permille    SMALLINT    NULL,  -- 제안 당시 rate(‰) 박제
  ADD COLUMN IF NOT EXISTS last_offered_price      BIGINT      NULL,  -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
  ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT      NULL;  -- NULL=미처리 0=제외확정 >0=소비한 조정 id

-- 배치 스캔 최적화: 미처리 세션만
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
    ON negotiation.sessions (qt_type, status)
    WHERE anchoring_adjustment_id IS NULL AND deleted = false;

주의사항:

  • sessions.target_anchoring_price는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).
  • 신규 DB 구축 시 적용 순서: postgres-init/01~04 → schedules/anchoring/schema.sql (IF NOT EXISTS 라 재적용 안전).
  • backend 모델(models.py)에는 sessions 3컬럼만 추가한다 — rate_adjustments 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).

7. Redis 캐시 규약

항목 규약
키 anchor:{company_id}:{supplier_type}:{bracket_index} — supplier_type 은 SMALLINT 코드값. 예: anchor:0b0e…:1:10
값 정수 천분율 문자열. 예: "30"
TTL 7일 (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8)
캐시 미스 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → SET 후 사용
갱신 배치가 평가한 칸 SET + 매주 토 잡 실행 시(격주 게이트 무관) 조정 이력 보유 칸 전체 re-SET (§8 절차 0.5)
장애 내성 Redis 에러 시 GET→None 취급(DB 폴백), SET 은 로그만 남기고 무시 (MUST — 견적 생성·배치를 Redis 가 막으면 안 됨). socket timeout 0.2~0.5초 설정 MUST(행 방지)
  • 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
  • ⚠️ stale 키는 "미스"가 나지 않는다: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
  • 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
  • 클라이언트: redis.asyncio — 사용 주체는 anchoring 서비스(배치 SET/re-SET)와 negodata(reader GET, 인수인계). backend 는 Redis 를 쓰지 않는다. 설정은 모듈 config.toml + REDIS_HOST/PORT/PASSWORD env 오버라이드. Redis 인스턴스는 모듈 docker-compose 에 동봉(negodata 가 같은 인스턴스를 바라봄).

8. 배치 잡 명세

  • 러너: schedules/anchoring 자립 컨테이너의 APScheduler(AsyncIOScheduler, Asia/Seoul) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(coalesce=True, max_instances=1, misfire_grace_time=3600), 진입점은 python -m anchoring.main(상주) / python -m anchoring.main --once(수동 1회, 게이트 무시).
  • 스케줄: 매주 토 00:00 KST 트리거(CronTrigger(day_of_week="sat", hour=0, minute=0)) + 잡 내부에서 ISO 주차 % 2 == EVAL_WEEK_PARITY 격주 게이트 (기준 패리티는 상수 고정 MUST).
  • 멱등성: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 AND anchoring_adjustment_id IS NULL 조건 + rowcount = n 검증(불일치 시 전체 롤백) MUST 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
  • 원자성: 조정 INSERT 와 세션 마킹은 같은 DB 세션의 한 트랜잭션에서 실행한다(MUST). 모듈은 자체 async 엔진(session_scope)을 쓰므로 자연 충족된다. (참고: backend 의 DB_SESSION_MNG.execute_lambda_run은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
절차 (run_evaluation_batch(force=False)):
0.   force 아니고 격주 게이트 미충족 → 절차 0.5 만 수행 후 종료
0.5. 캐시 정합(매주, 게이트 무관): 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET
     (SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
1. 미처리 종료 재협상 세션 스캔:
   sessions s JOIN quotation.quotations q ON q.qt_id = s.quotation_id
              JOIN partner.items i        ON i.item_id = s.item_id
   WHERE s.anchoring_adjustment_id IS NULL AND s.deleted = false
     AND s.qt_type = 1 AND s.status IN (3, 4, 5)
2. 세션별 파생 판정(§4.3):
   - EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
     → anchoring_adjustment_id = 0 일괄 마킹 (재스캔 방지)
   - 유효 표본 → 칸별 그룹 적재
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
   anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값)
   anchor_rate_after  = evaluate_pending(...)              # §4.4 / §10
   ① anchoring.rate_adjustments INSERT (consumed_session_ids 박제)
   ② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchor_rate_after   (best effort, TTL 7일)
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / 상·하한 도달 / 이월 칸 수 / 제외 마킹 건수
   + 가격 제시율(종료 재협상 세션 중 last_offered_price 보유 비율) — 0% 면 WARN
     (backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
  • INSERT+마킹(3)과 Redis SET(4) 사이 장애 시: 캐시는 stale이지만 TTL(7일)·다음 주 re-SET(절차 0.5)이 회복한다. 트랜잭션은 DB까지만 보장하면 된다.
  • n < 10 칸의 유효 표본은 마킹하지 않는다 — 그것이 이월이다.
  • 배치 실패·지연 시에도 견적 생성·협상은 캐시(또는 on-demand 조회)로 계속 동작한다.
  • 별도 batch_runs 테이블 없음 — 조정 이력이 곧 실행 기록이며, 회차 요약은 LOG로 남긴다.
  • 운영 런북: 토 00:00 에 서비스가 내려가 있었다면(misfire_grace 1h 초과) 그 회차는 스킵되고 패리티 게이트 때문에 2주 뒤 실행된다. 누적 평가라 데이터 손실은 없으나, 재기동 후 --once 수동 1회 실행으로 즉시 따라잡을 수 있다.

9. 견적 생성·협상 플로우 (역할 분담)

앵커가의 산출·박제 주체는 negodata(견적 생성 측) 이고, backend(협상 채팅)는 박제값의 소비자다. negodata 변경은 직접 수정하지 않고 인수인계.md로 전달한다. agent 는 변경하지 않는다.

9.1 견적/세션 생성 — negodata (인수인계 대상)

현재 negodata _build_quotation은 세션 생성 시 target_anchoring_price를 구 방식으로 채운다 (신규: int(tp * (1 - quotation_settings.anchoring_value)) float 계산 / 재생성: 직전 라운드 값 상속). 새 앵커링 모듈 전달 후 아래로 교체된다:

세션(상품 × 공급사) 생성 시마다:
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
   bracket_index = min(target_price // 3000, 33333)
2. rate 조회 (모듈의 reader 이식):
   supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
   그 외(미지정 등)       → 정적 테이블 시작값 (유일 폴백 — §12)
3. anchor_price = target_price × (1000 − rate) // 1000   ← 정수 연산 MUST (기존 float 식 폐기)
4. 세션 INSERT 에 target_anchoring_price = anchor_price, anchor_rate_permille = rate 포함 (박제)
  • 재생성 상속 폐지 (MUST): 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
  • quotation_settings.anchoring_value 는 앵커가 계산에 더 이상 사용하지 않는다(컬럼·화면 표기는 유지 가능).
  • 전환기 동작: negodata 적용 전까지는 구 방식 값이 계속 박제된다 — 판정(§4.3)은 박제된 anchor 기준이므로 표본·조정은 그동안에도 유효하게 쌓이고, negodata 적용 시점부터 조정된 rate 가 실제 기준가에 반영되기 시작한다(자연 부트스트랩, 별도 마이그레이션 불필요).

9.2 협상 채팅 — backend (직접 구현, anchoring 모듈 무의존)

backend/services/chat_service.py::_resolve_anchor_price — quotation_settings.anchoring_value 읽기 삭제. _agent_context가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다.

1. 박제값 사용 (MUST): sessions.target_anchoring_price 를 그대로 사용.
   → negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
   → 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
   박제하지 않는다 → 이 세션은 anchor 박제가 없어 배치 판정에서 자동 EXCLUDED (학습 무오염).
   agent 에는 양수 anchor 가 보장되어 기존 검증(ValueError) 안전.
3. agent 컨텍스트로 anchor_price 전달 (기존 AgentChatContext.anchor_price 그대로)

가격 흔적 기록 (MUST):

  • agent 는 변경하지 않는다. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
  • backend send()가 가격 입력 턴(price is not None)의 봇 메시지를 저장하는 트랜잭션에 UPDATE sessions SET last_offered_price = :price WHERE session_id = :id를 함께 넣는다 — 메시지 저장과 원자적, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
  • 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).

10. 참조 구현

# src/anchoring/service.py  (순수 함수만 — DB/Redis 접근 없음)
from anchoring.constants import (
    ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE,
    SAMPLE_THRESHOLD, PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX,
    AnchoringSampleType,
)
from anchoring.base_table import get_base_rate_permille   # 정적 테이블 조회 (§2)


def calc_bracket_index(target_price: int) -> int:
    """목표가 → 가격구간 인덱스(0-기반). §4.1 — 1억 이상은 마지막 인덱스로 클램프.
    정적 테이블 idx = 반환값 + 1"""
    return min(target_price // PRICE_BRACKET_UNIT, BRACKET_INDEX_MAX)


def calc_anchor_price(target_price: int, rate_permille: int) -> int:
    """앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
    return target_price * (1000 - rate_permille) // 1000


def judge_sample_type(
    is_done: bool,                    # sessions.status == DONE(3)
    bid_price: int | None,            # 확정 투찰가(DONE 시)
    last_offered_price: int | None,   # 마지막 제시가 — NULL 이면 가격 흔적 없음
    anchor_price: int | None,         # sessions.target_anchoring_price (박제 앵커)
) -> int:
    """배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
    if anchor_price is None or last_offered_price is None:
        return AnchoringSampleType.EXCLUDED.value
    if is_done and bid_price is not None and bid_price <= anchor_price:
        return AnchoringSampleType.BID_SUCCESS.value
    return AnchoringSampleType.BID_FAIL.value


def evaluate_pending(
    rate_before: int,
    sample_types: list[int],   # 미처리 유효 표본 전량의 판정 코드
    supplier_type: int,        # SMALLINT 코드 1/2/3
) -> int | None:
    """누적 전량 평가. §4.4
    반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월)
    호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
    """
    n = len(sample_types)
    if n < SAMPLE_THRESHOLD:
        return None

    success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
    delta = DELTA_PERMILLE[supplier_type]

    # r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
    if success * 10 >= n * 6:
        adjusted = rate_before + delta
    elif success * 10 < n * 3:          # r < 0.30
        adjusted = rate_before - delta
    else:                                # 0.30 ≤ r < 0.60
        adjusted = rate_before

    return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted))


def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int:
    """현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
    if latest_adjusted_rate is not None:
        return latest_adjusted_rate
    return get_base_rate_permille(bracket_index)   # int(anchoring_value * 1000)
-- 배치의 미처리 세션 스캔 (§8 절차 1)
SELECT s.session_id, s.status, s.bid_price,
       s.target_price, s.target_anchoring_price, s.anchor_rate_permille,
       s.last_offered_price, q.supplier_type, i.company_id
FROM negotiation.sessions s
JOIN quotation.quotations q ON q.qt_id  = s.quotation_id AND q.deleted = false
JOIN partner.items        i ON i.item_id = s.item_id
WHERE s.anchoring_adjustment_id IS NULL
  AND s.deleted = false
  AND s.qt_type = 1
  AND s.status IN (3, 4, 5)
-- 현재 값 조회 (캐시 미스 시)
SELECT anchor_rate_after
FROM anchoring.rate_adjustments
WHERE company_id = :c AND supplier_type = :p AND price_bracket_index = :b
ORDER BY id DESC
LIMIT 1

11. 검증 벡터 (Golden Tests)

아래 케이스가 전부 통과해야 한다 (pytest 고정). 위치: 골든 벡터·배치 통합 = schedules/anchoring/tests/, 가격 흔적 기록·NULL 폴백 = backend/tests/.

11.1 앵커링가 계산 (내림 검증)

target_price rate(‰) 계산 anchor_price
30,000 200 30,000 × 800 // 1000 24,000
26,706 10 26,706 × 990 // 1000 = 26,438.94 → 내림 26,438
29,999 15 29,999 × 985 // 1000 = 29,549.015 → 내림 29,549
0 10 0 0

11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)

target_price bracket_index 정적 테이블 idx upper_bound
0 0 1 3,000
2,999 0 1 3,000
3,000 1 (경계는 상위 구간) 2 6,000
99,999,000 33,333 (마지막 구간 진입) 33,334 100,000,000
100,000,000 33,333 33,334 100,000,000
150,000,000 33,333 (1억 초과 → 마지막 인덱스 클램프) 33,334 100,000,000

정적 테이블 검증: 33,334행 · idx 1..33334 연속 · upper_bound == min(idx*3000, 100_000_000) · 마지막 행만 100,000,000.

11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10)

pending 구성 n r 판정 anchor_rate_after
성공 8 / 실패 5 13 ≈ 0.615 ≥ 0.60 → +20 30
성공 7 / 실패 6 13 ≈ 0.538 유지 10
성공 3 / 실패 10 13 ≈ 0.231 < 0.30 → −20 10 (하한 clamp)
성공 6 / 실패 4 10 0.60 정확히 경계 포함 → +20 30
성공 3 / 실패 7 10 0.30 정확히 유지 10
성공 9 / 실패 0 9 — 평가 안 함 (이월) None

δ 스왑 가드 (MUST): evaluate_pending(10, [성공10/10], supplier_type=2) == 20 (제조 +10), supplier_type=3 → 25 (총판 +15).

clamp·격리 케이스:

시나리오 기대
rate_before 200, r = 0.9 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨
A사 칸 평가 B사의 같은 (p, b) 칸 값에 영향 없음
조정 이력 없는 칸 정적 테이블 시작값(10) 반환
EXCLUDED 15건 + 유효 5건 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리

11.4 파생 판정

status bid_price last_offered_price anchor_price 기대
DONE 24,000 24,000 24,000 BID_SUCCESS (같아도 성공)
DONE 24,001 24,001 24,000 BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등)
REJECTED NULL 25,000 24,000 BID_FAIL (가격 쓰고 결렬)
NOT_PARTICIPATED (일괄마감) NULL 25,000 24,000 BID_FAIL (가격 쓰고 중간 이탈)
임의 종료 상태 NULL NULL 24,000 EXCLUDED (가격 흔적 없음)
DONE 24,000 24,000 NULL EXCLUDED (앵커 박제 없음)

11.5 배치 멱등성·이월·소비 (DB 통합 — 세션 시드 기반)

시나리오 기대
유효 13건 시드 → 배치 조정 1행(n=13, consumed_session_ids 13개 박제, 10→30) + 13건 모두 anchoring_adjustment_id=조정 id
직후 배치 재실행 조정 0건 (전 칸 pending < 10 — 마킹 멱등)
2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 4주 차 배치에서 13건 전량 1회 평가
배치 1회 누락 → 다음 배치 4주치 pending으로 1스텝 평가, 별도 보정 불필요
supplier_type NULL 세션 집계 제외 + 마킹 0, 이후 배치에서 재스캔 안 됨
세션 종료가 배치 스캔 직후 커밋 마킹 안 됐으므로 다음 배치에서 정상 소비 (영구 누락 없음)
마킹 rowcount ≠ n (경합 시뮬레이션: pending 일부를 미리 마킹) 조정 INSERT 포함 전체 롤백 — 조정 0건, 이중 조정 없음
배치 2개 프로세스 동시 실행(오설정 시뮬레이션) 한쪽만 조정 성공, 다른 쪽은 rowcount 불일치 롤백 → 칸당 조정 정확히 1건
Redis 에 옛 rate 를 심고 주간 잡 실행(격주 게이트 OFF 주) 절차 0.5 re-SET 으로 최신 rate 로 회복
가격 제시율 0% 상태에서 배치 실행 요약 로그에 WARN 출력 (backend 기록 배선 유실 감지)

11.6 읽기 경로·가격 흔적 (E2E 스모크)

시나리오 기대
재협상 채팅 → 가격 입력 턴 last_offered_price 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제)
가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) last_offered_price 보존 → 배치에서 BID_FAIL 표본
같은 세션에서 배치가 값 변경 후 다음 턴 앵커 불변 (박제값 사용)
박제 없는 세션(NULL 폴백) anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작
Redis 정지 상태에서 견적 생성(negodata reader) DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백)

12. 금지·봉인 사항

구현자가 임의로 추가·가정하면 안 되는 항목:

  • 극희소 칸 fallback (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
  • 정적 기본 테이블 변경 — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
  • 조정 이력의 UPDATE/DELETE, 소급 무효화·보정 — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
  • sessions 판정 입력 컬럼(target_anchoring_price, anchor_rate_permille)의 사후 수정, last_offered_price 의 종료 후 수정 — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 anchoring_adjustment_id 단 하나.
  • 파라미터 동적 조정 (δ, 경계 60/30, clamp 10/200, 임계 10건, 구간 3,000원, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
  • 성공률 외 신호 반영 (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
  • 회사 간 값·표본 공유 또는 전사 통합 평가 — 칸은 회사별 완전 독립.
  • float 산술 — 앵커링가·rate 계산에 부동소수점 사용 금지 (round(target*0.99) 패턴 금지).
  • 앵커가 노출 — 앵커가를 협력사 화면에 표시하는 변경은 판정 의미론(§4.3의 무편향 전제)까지 바꾸는 정책 재확정 사안.

13. 선행·연계 작업

담당 구분: [우리] = backend/schedules 직접 구현(완료), [인수인계] = 모듈·명세를 전달 → 담당 개발자가 적용.

# 항목 담당 상태
1 DDL — anchoring.rate_adjustments + sessions 3컬럼 ALTER [우리 — 모듈] schema.sql, psql 적용 시점 협의 구현 완료 (§6)
2 세션 생성 시 앵커 산출을 새 시스템으로 교체 — _build_quotation 앵커 계산 교체 + 재생성 상속 폐지 [인수인계 — negodata] §9.1. reader 는 모듈(async)에서 그대로 이식
3 agent 변경 없음 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님
4 재협상 식별 — sessions.qt_type = 1 로 판별 (확인됨)
5 company_id 식별 — partner.items.company_id (세션→item 조인, 기존 _agent_context 해석 방식과 동일)
6 quotations.supplier_type 기록 [인수인계 — negodata] 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0)
7 정적 테이블 로드 검증 [우리 — 모듈] 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료
8 Redis 인프라 [우리 — 모듈] 모듈 docker-compose 에 redis 동봉, negodata 가 같은 인스턴스 참조. backend 는 Redis 무의존
9 스케줄러·배치 [우리 — 모듈] 자립 컨테이너(APScheduler, --once 수동 실행 지원). 구현 완료
10 backend 채팅 수정 [우리 — backend] _resolve_anchor_price 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 last_offered_price 갱신, sessions 모델 3컬럼, quotation_settings.anchoring_value 읽기 제거(컬럼은 유지). 구현 완료