본문으로 건너뛰기

L2 Store 및 Prefetch 컨트롤러 설계

변경 검증 가이드 (다음 fetch 후):

git log eaa2bfee..HEAD -- lmcache/v1/distributed/l2_adapters/ lmcache/v1/distributed/storage_controllers/ docs/design/v1/distributed/l2_adapters/overall.md
  • base.py 의 invariant 10개 (eventfd 전역 고유, prefix-only loading, unlock 결국 성공 등) 가 깨지는 변경이면 "가정 및 Invariant 요약" 섹션 통째로 재검토.
  • storage_controllers/store_controller.py / prefetch_controller.py 의 lock invariant 표가 흐름 변경되면 본 문서의 Lock Invariant 표도 갱신.
  • l2_adapters/ 에 어댑터가 추가/삭제되면 "새로운 L2 어댑터 구현하기" 섹션의 레퍼런스 (mock/raw_block/nixl_dynamic) 재확인.

이 문서는 StoreController 와 PrefetchController 가 L2 어댑터와 어떻게 상호작용하는지, 어떤 invariant 를 유지하는지, 그리고 어떤 가정을 전제로 하는지를 설명합니다. 새로운 L2 어댑터를 구현하거나 컨트롤러 로직을 수정하는 개발자를 대상으로 합니다.


아키텍처 개요

┌────────────────────────┐
│ StorageManager │
│ submit_prefetch_task │
│ query_prefetch_status │
│ reserve/finish_write │
└────┬──────────┬─────────┘
│ │
┌──────────┘ └──────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ StoreController │ │ PrefetchController │
│ (백그라운드 스레드) │ │ (백그라운드 스레드) │
│ │ │ │
│ L1 쓰기 완료 │ │ 외부 submit │
│ → L2 에 저장 │ │ → L2 조회 │
│ → 락 해제 │ │ → 계획 + 로드 │
└────┬───────────────┘ │ → L1 read-lock │
│ └──────┬──────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ L2AdapterInterface(s) │
│ store / lookup_and_lock / load / unlock │
│ │
│ 각 어댑터는 3개의 독립된 eventfd 를 가짐: │
│ store_efd, lookup_efd, load_efd │
└─────────────────────────────────────────────┘

두 컨트롤러는 각각 백그라운드 스레드 하나로 실행되며, eventfd 에 대해 select.poll() 을 사용해 이벤트 기반 I/O 를 처리합니다. 같은 L2 어댑터 인스턴스 집합을 공유하지만 (계약상 thread-safe), 사용하는 eventfd 는 서로 다릅니다.


L2 어댑터 인터페이스

L2AdapterInterface (l2_adapters/base.py) 는 두 컨트롤러가 호출하는 논블로킹 I/O 기본 연산을 제공합니다. 모든 작업은 submit → eventfd poll → 결과 조회 패턴을 따릅니다.

Event FD

각 어댑터는 3개의 독립된 eventfd 를 노출합니다:

메서드사용 주체시그널 조건
get_store_event_fd()StoreControllerstore 작업 완료 시
get_lookup_and_lock_event_fd()PrefetchControllerlookup 작업 완료 시
get_load_event_fd()PrefetchControllerload 작업 완료 시

핵심 invariant: 모든 어댑터의 모든 eventfd 는 전역적으로 고유해야 합니다. 컨트롤러는 fd → adapter_index 맵을 만들기 때문에, fd 가 중복되면 이벤트가 잘못된 어댑터로 조용히 전달됩니다.

Store 작업

submit_store_task(keys, objects) -> L2TaskId
pop_completed_store_tasks() -> dict[L2TaskId, bool]
  • 버퍼는 호출자가 제공: objects 리스트는 StoreController 가 L1 read lock 을 걸어 관리하는 MemoryObj 참조를 담습니다.
  • 성공/실패는 작업 단위: store 작업은 전체 성공이거나 전체 실패입니다. 완료 딕셔너리의 bool 값이 True 면 성공, False 면 실패.
  • pop 의미론: pop_completed_store_tasks() 는 완료된 모든 작업을 한 번에 드레인합니다. 각 작업은 정확히 한 번만 나타납니다.

Lookup and Lock 작업

submit_lookup_and_lock_task(keys) -> L2TaskId
query_lookup_and_lock_result(task_id) -> Bitmap | None
submit_unlock(keys) -> None
  • 락 획득: lookup_and_lock 은 어떤 key 가 존재하는지 확인하고 발견된 key 에 L2 측 락을 원자적으로 획득합니다. lookup 과 load 사이에 L2 eviction 이 발생하는 것을 방지합니다.
  • 세밀한 결과: keys[i] 가 발견되어 락이 걸렸으면 비트 i 가 set 된 Bitmap 을 반환합니다.
  • 일회성 조회: query_lookup_and_lock_result 는 진행 중이면 None 을 반환하다가, 완료되면 Bitmap 을 정확히 한 번 반환합니다. 이후 호출은 다시 None.
  • Unlock 계약: submit_unlock 은 fire-and-forget 입니다. 어댑터는 반드시 결국 성공을 보장해야 합니다 (필요하면 내부적으로 재시도). 호출자는 절대 재시도하지 않습니다.

Load 작업

submit_load_task(keys, objects) -> L2TaskId
query_load_result(task_id) -> Bitmap | None
  • 버퍼는 호출자가 제공: objects 리스트는 미리 할당된 L1 write 버퍼를 담습니다. 어댑터는 로드한 데이터를 이 버퍼에 직접 씁니다.
  • 세밀한 결과: keys[i] 가 성공적으로 로드되었으면 비트 i 가 set 된 Bitmap 을 반환합니다.
  • 일회성 조회: lookup 과 동일한 의미론 — Bitmap 을 정확히 한 번 반환합니다.

Thread Safety

어댑터는 StoreController 스레드와 PrefetchController 스레드의 동시 호출에 안전해야 합니다. 실제로는 store 작업과 lookup/load 작업이 별도의 내부 상태를 사용하므로, 작업별 락이나 락-프리 큐로 구현하면 대체로 어렵지 않습니다.

Task ID 범위

L2TaskId 값은 단일 어댑터 내에서만 고유합니다. 여러 어댑터에 걸쳐 작업을 추적할 때는 복합 키 (adapter_index, task_id) 를 사용하세요.


StoreController

목적: L1 쓰기가 완료된 후 L1 데이터를 비동기로 L2 에 복제합니다.

소스: storage_controllers/store_controller.py

라이프사이클

StorageManager.__init__
→ StoreController(l1_manager, l2_adapters, descriptors, policy)
→ controller.start() # 백그라운드 스레드 시작
...
StorageManager.close()
→ controller.stop() # 스레드 종료, 락 해제

이벤트 기반 루프

StoreController 의 백그라운드 스레드는 다음을 poll 합니다:

  1. StoreListener eventfdfinish_write() 완료 시 L1Manager 가 발화. 리스너는 L1Manager 에 등록된 L1ManagerListener 입니다.
  2. 어댑터별 store eventfd — L2 store 작업 완료 시 발화.

데이터 흐름

L1 finish_write()

▼ (L1Manager 리스너 콜백, L1 lock 내부 실행 — 논블로킹 필수)
StoreListener.on_l1_keys_write_finished(keys)
│ keys 추가 + eventfd 신호

_store_loop: poll 깨어남


_process_new_keys(keys)

├─ 1. key 를 shape 별로 그룹화 (현재: (model_name, kv_rank))
│ L1 는 공유 풀이라 한 번의 drain 에 shape/dtype 이 다른 model/parallelism 이
│ 섞일 수 있으며, submit_store_task 는 균일한 (shape, dtype) 을 전제로 합니다.

├─ 2. 각 shape 그룹에 대해:
│ StorePolicy.select_store_targets(group_keys, adapters)
│ → dict[adapter_index, list[ObjectKey]]

├─ 3. 각 어댑터 타깃에 대해:
│ L1Manager.reserve_read(target_keys) → MemoryObj + read lock 획득
│ adapter.submit_store_task(keys, objs)
│ InFlightStoreTask 로 추적

▼ (이후, store_efd 가 시그널된 각 어댑터에 대해)
_drain_l2_store_completions(signaled_adapters)
│ adapter.pop_completed_store_tasks() → 각 InFlightStoreTask 에
│ l2_store_result 성공/실패 기록


_advance_request(task_key, task) [상태 전환]
│ l2_store_result 가 아직 None 이면 건너뜀


_finalize_store(task_key, task) [최종 처리]

├─ 4. L1Manager.finish_read(read_locked_keys) → read lock 해제

├─ 5. 성공 시: StorePolicy.select_l1_deletions(keys) → L1 에서 삭제
│ 실패 시: 경고 로그 (best-effort, 재시도 없음)


완료. policy 가 삭제하지 않는 한 key 는 L1 에 남습니다.

Lock Invariant

단계L1 Lock 상태L2 Lock 상태
store 전UnlockedN/A
store 중Read-lockedN/A
store 후UnlockedN/A
  • store 중 read lock 은 어댑터가 읽는 동안 L1 데이터가 eviction 되는 것을 방지합니다.
  • 항상 해제됨: stop()_cleanup_in_flight_tasks() 를 호출하여 작업이 완료되지 않았더라도 모든 in-flight read lock 을 해제합니다.

StorePolicy

policy 는 두 가지를 결정합니다:

  1. select_store_targets(keys, adapters) → dict[int, list[ObjectKey]] 어떤 어댑터가 어떤 key 를 받을지. 한 key 가 여러 어댑터에 갈 수 있습니다. DefaultStorePolicy: 모든 key → 모든 어댑터.

  2. select_l1_deletions(keys) → list[ObjectKey] L2 store 성공 후 L1 에서 evict 할 key. DefaultStorePolicy: 삭제 안 함 (빈 리스트).

policy 는 --l2-store-policy 로 이름으로 선택합니다 (기본값: "default"). 새 policy 는 import 시점에 register_store_policy(name, cls) 로 자기 등록하며, storage_controllers/__init__.py 가 자동으로 발견합니다.


PrefetchController

목적: 서빙 요청이 오기 전에 L2 에서 L1 으로 KV 캐시 데이터를 비동기로 로드합니다. L1 에 없는 key 에 대해 StorageManager.submit_prefetch_task() 가 호출합니다.

소스: storage_controllers/prefetch_controller.py

라이프사이클

StorageManager.__init__
→ PrefetchController(l1_manager, l2_adapters, descriptors, policy)
→ controller.start() # 백그라운드 스레드 시작
...
StorageManager.close()
→ controller.stop() # 스레드 종료, 모든 락 해제

외부 API (Thread-Safe)

# 서빙 스레드에서 호출
request_id = controller.submit_prefetch_request(keys, layout_desc)

# 서빙 스레드에서 폴링
result = controller.query_prefetch_result(request_id) # int | None
  • submit_prefetch_request 는 요청을 큐에 넣고 eventfd 로 백그라운드 스레드에 신호를 보낸 후 즉시 반환합니다.
  • query_prefetch_result 는 진행 중이면 None 을 반환하다가, prefix hit count 를 정확히 한 번 반환합니다 (pop 의미론).

Prefix 전용 로딩

핵심 invariant: 발견된 key 중 연속된 prefix 만 로드합니다.

L2 에 key {0, 1, 3, 4} 가 있고 key 2 가 없다면, key {0, 1} 만 로드합니다. 인덱스 2 의 gap 이 있으면 vLLM 엔진은 key 3, 4 를 사용할 수 없습니다 (연속된 KV 캐시 prefix 가 필요하기 때문). 이것들을 로드하면 I/O 대역폭과 L1 메모리만 낭비됩니다.

이는 policy 가 raw load plan 을 계산한 후 trim_load_plan_to_prefix() 로 강제합니다.

이벤트 기반 루프

PrefetchController 의 백그라운드 스레드는 다음을 poll 합니다:

  1. Submission eventfdsubmit_prefetch_request() 가 신호.
  2. 어댑터별 lookup eventfd — lookup 작업 완료 시 신호.
  3. 어댑터별 load eventfd — load 작업 완료 시 신호.

요청 상태 머신

각 요청은 두 단계를 거칩니다:

LOOKUP ──────────────────────────► PLAN_AND_LOAD ──────────► COMPLETED
│ │
│ 모든 어댑터에 lookup_and_lock 제출 │ load plan 계산
│ │ L1 write 버퍼 예약
│ 모든 lookup 완료 대기 │ load 작업 제출
│ │ 모든 load 완료 대기
▼ │ 최종 처리

데이터 흐름

submit_prefetch_request(keys, layout_desc)

▼ (크로스 스레드: 제출 큐 + eventfd 신호)
_drain_submission_queue → _pending_queue

▼ (max_in_flight 미만인 경우)
_start_lookup_phase(request_id, keys, layout_desc)

├─ 모든 어댑터에 lookup_and_lock_task(keys) 제출

▼ (모든 어댑터 lookup 완료 대기)
_advance_request(request, signaled_adapters) [LOOKUP 단계]
│ _poll_lookup_results(request, signaled_adapters[LOOKUP])
│ all_lookups_done() 시:


_transition_to_load_phase(request)

├─ 1. PrefetchPolicy.select_load_plan(keys, lookup_results, adapters)
│ → dict[adapter_index, Bitmap]

├─ 2. trim_load_plan_to_prefix()
│ → 연속된 prefix key 만 유지

├─ 3. L1Manager.reserve_write(keys, is_temporary=True, mode="new")
│ → L1 write 버퍼 할당

├─ 4. 성공적으로 예약된 key 만 남도록 plan 재트리밍

├─ 5. Phase 1 unlock: lookup 에서 잠겼지만 load plan 에 없는 L2 key unlock

├─ 6. 어댑터별 load_task(keys, objs) 제출

▼ (모든 어댑터 load 완료 대기)
_advance_request(request, signaled_adapters) [PLAN_AND_LOAD 단계]
│ _poll_load_results(request, signaled_adapters[PLAN_AND_LOAD])
│ all_loads_done() 시:


_finalize_load(request)

├─ 7. Phase 2 unlock: load plan 의 모든 L2 key unlock

├─ 8. L1Manager.finish_write_and_reserve_read(loaded_keys)
│ → 원자적으로: write unlock + read lock

├─ 9. L1Manager.finish_write(failed_keys) + delete(failed_keys)
│ → 부분 실패 정리

├─ 10. prefix 이후의 로드된 key 에 대한 read lock 해제
│ (부분 load 실패로 gap 이 생길 수 있음)


_complete_request(request_id, prefix_hits)
│ 결과 저장, in-flight 추적에서 제거

Lock Invariant

단계L1 Lock 상태L2 Lock 상태
Lookup없음발견된 key 는 locked
plan 이후Write-locked (예약된 버퍼)plan key locked; 나머지 unlocked (phase 1)
load 중Write-lockedplan key locked
load 후Read-locked (prefix key)모두 unlocked (phase 2)
최종 처리 후Read-locked (prefix 만)모두 unlocked
  • L1 write 버퍼는 임시: is_temporary=True 로 필요 시 eviction controller 가 회수 가능하지만, 보통 수명이 짧습니다.
  • 원자적 write→read 전환: finish_write_and_reserve_read() 는 write 완료와 read lock 획득 사이에 eviction 이 발생하는 window 를 없앱니다.
  • 최종 처리 후 read lock: prefix key 는 서빙 엔진이 사용할 수 있도록 L1 에 read-locked 상태를 유지합니다. 호출자(StorageManager) 는 사용 후 finish_read_prefetched() 로 이를 해제합니다.

L2 Lock 관리

L2 lock 은 lookup 과 load 사이에 어댑터 측 eviction 을 방지합니다. 성공, 실패, 종료 모든 경우에 반드시 해제되어야 합니다.

Phase 1 unlock (_unlock_unneeded_keys): load plan 계산 후, lookup 에서는 잠겼지만 최종 load plan 에 포함되지 않는 key 를 즉시 unlock. 다음 경우에 발생합니다:

  • policy 가 해당 key 를 다른 어댑터에 할당.
  • prefix 트리밍으로 제외됨.
  • L1 write 예약 실패.

Phase 2 unlock (_unlock_all_plan_keys): load 완료 후 (성공/실패 무관) load plan 의 모든 key 를 unlock.

종료 정리 (_cleanup_in_flight_requests): in-flight 요청에 대해 보유한 모든 L1, L2 lock 해제.

PrefetchPolicy

select_load_plan(keys, lookup_results, adapters)dict[int, Bitmap]

모든 어댑터의 lookup bitmap 을 받아 key 를 어댑터에 중복 없이 할당합니다. 각 key 는 최대 하나의 어댑터 bitmap 에만 나타납니다.

DefaultPrefetchPolicy: 각 key 를 해당 key 를 가진 가장 낮은 인덱스의 어댑터에 할당하는 단순 greedy 방식.

policy 는 --l2-prefetch-policy 로 이름으로 선택합니다 (기본값: "default"). 새 policy 는 import 시점에 register_prefetch_policy(name, cls) 로 자기 등록하며, storage_controllers/__init__.py 가 자동으로 발견합니다.

최대 In-Flight 제한

컨트롤러는 동시 prefetch 요청을 max_in_flight (기본값: 8) 로 제한합니다. 이를 초과하는 요청은 _pending_queue 에 쌓이며, in-flight 요청이 완료될 때 dequeue 됩니다.

참고: 이는 단순 카운트 기반 제한입니다. 향후 개선 방향으로 in-flight 요청의 L1 메모리 사용량에 기반한 동적 admission controller 가 있습니다.


통합: StorageManager

StorageManager (storage_manager.py) 는 모든 것을 연결하는 최상위 진입점입니다.

Prefetch 흐름 (서빙 엔진 관점)

# 1. Submit: L1 먼저 확인, 나머지를 L2 에 위임
handle = sm.submit_prefetch_task(keys, layout_desc)

# 2. Poll: 완료까지 대기
while True:
found_count = sm.query_prefetch_status(handle)
if found_count is not None:
break

# 3. Read: prefetch 된 데이터 접근 (read lock 보유)
with sm.read_prefetched_results(keys[:found_count]) as objs:
# objs 사용 ...
pass

# 4. Release: read lock 해제
sm.finish_read_prefetched(keys[:found_count])

PrefetchHandle

@dataclass(frozen=True)
class PrefetchHandle:
request_id: int # L2 요청이 필요 없으면 -1
l1_prefix_hit_count: int # 이미 L1 에 있는 앞쪽 key 수
total_requested_keys: int
submit_time: float # 지연 시간 로깅용

submit_prefetch_task 는 먼저 L1 에서 연속된 prefix hit 를 확인합니다:

  • 모든 key 가 L1 hit: request_id=-1 인 handle 반환 (L2 작업 불필요).
  • 일부 miss: 나머지 key 를 PrefetchController 에 제출.

query_prefetch_status 는 L1 hit 과 L2 결과를 합산합니다: total_hits = l1_prefix_hit_count + l2_prefix_hits.


가정 및 Invariant 요약

  1. 모든 eventfd 는 전역 고유해야 합니다. 모든 어댑터와 모든 작업 유형에 걸쳐 중복이 있으면 poll 기반 dispatch 가 깨집니다.

  2. L2 task ID 는 어댑터별, 전역이 아닙니다. (adapter_index, task_id) 를 복합 키로 사용하세요.

  3. 조회 결과는 일회성입니다. query_lookup_and_lock_result()query_load_result() 는 작업당 non-None 값을 정확히 한 번 반환합니다.

  4. submit_unlock 은 결국 반드시 성공해야 합니다. 컨트롤러는 절대 재시도하지 않습니다. 어댑터가 내부적으로 재시도를 처리해야 합니다.

  5. Prefix 전용 로딩. L2 에서 발견된 key 의 연속된 prefix 만 로드합니다. Gap 이 있으면 prefix 가 끊깁니다.

  6. 리스너 콜백은 L1Manager 의 lock 내부에서 실행됩니다. StoreListener 는 논블로킹이어야 합니다 (추가 + eventfd 신호만). L1Manager 메서드를 절대 호출해서는 안 됩니다 (데드락).

  7. prefetch 의 L1 write 버퍼는 임시입니다. 필요 시 eviction 을 허용하기 위해 is_temporary=True 로 할당됩니다.

  8. 원자적 write→read 전환. finish_write_and_reserve_read() 는 prefetch write 완료와 서빙 엔진을 위한 read lock 획득 사이의 eviction 을 방지합니다.

  9. 두 컨트롤러 모두 종료 시 모든 lock 을 해제합니다. stop() 은 완료 상태에 관계없이 항상 in-flight 작업을 정리합니다.

  10. L2 어댑터는 thread-safe 해야 합니다. StoreController 스레드와 PrefetchController 스레드의 동시 호출이 전제됩니다.


새로운 L2 어댑터 구현하기

순수 Python 어댑터

L2AdapterInterface 를 직접 구현하세요. 인메모리 어댑터의 레퍼런스 구현은 mock_l2_adapter.py 를, 내구성 있는 로컬 디바이스 어댑터는 raw_block.md 를 참고하세요. 기존 파일을 수정할 필요가 없습니다. l2_adapters/ 패키지에 새 모듈(예: my_l2_adapter.py)을 만들고 모듈 레벨에서 자기 등록하면 됩니다:

# 모듈 하단:
register_l2_adapter_type("my_type", MyL2AdapterConfig)
register_l2_adapter_factory("my_type", _create_my_l2_adapter)

__init__.pypkgutil.iter_modules()*_l2_adapter.py 모듈을 모두 자동 발견하지만, lazy import 합니다 — 해당 adapter type 이 실제로 런타임에 요청될 때만 모듈을 로드합니다 (third-party 의존성 보호).

Persist / Recover (선택)

재시작 시 캐시된 데이터를 유지하는 어댑터는 PersistConfig 를 사용합니다 (JSON 키 "persist_enabled" 에서 파싱, 기본값 True). Lookup 은 miss 시 항상 보조 스토리지를 확인합니다. 전용 인터페이스 메서드는 없으며, 어댑터는 기존 close() 경로에 persist 를 통합합니다. 레퍼런스 구현은 nixl_store_dynamic_l2_adapter.py, 설계 세부사항은 nixl_store.md 를 참고하세요.

Native (C++/Rust) 스토리지 백엔드

C++ 또는 Rust 로 작성된 고성능 백엔드는 공유 native connector 프레임워크를 사용하세요. 단일 C++ connector 구현이 비-MP 모드 (ConnectorClientBase 경유) 와 MP 모드 (NativeConnectorL2Adapter 경유) 모두 에서 동작합니다.

전체 가이드: csrc/storage_backends/README.md

NativeConnectorL2Adapter (native_connector_l2_adapter.py) 는 pybind 래핑된 IStorageConnectorL2AdapterInterface 로 연결합니다:

  • connector 의 단일 eventfd 에서 3개의 Python eventfd 생성
  • 백그라운드 demux 스레드로 작업 유형별 완료 라우팅
  • ObjectKey 직렬화 및 MemoryObj 버퍼 추출 처리
  • 원격 백엔드를 위한 클라이언트 측 locking (refcount 딕셔너리)

레퍼런스 구현: csrc/storage_backends/redis/ 의 Redis (RESP) connector 가 통합 가이드 5단계를 모두 보여줍니다.


새로운 Store 또는 Prefetch Policy 구현하기

두 policy 모두 이름 기반 레지스트리와 자동 모듈 발견을 사용합니다. 새 policy 를 추가하려면 storage_controllers/ 에 파일 하나만 만들면 됩니다 — 기존 파일을 수정할 필요가 없습니다.

Store Policy

  1. 새 파일 생성 (예: storage_controllers/store_policy_tiered.py).
  2. StorePolicy 를 상속하고 select_store_targets()select_l1_deletions() 를 구현합니다.
  3. 모듈 레벨에서 register_store_policy("tiered", TieredStorePolicy) 를 호출합니다.
from lmcache.v1.distributed.storage_controllers.store_policy import (
StorePolicy,
AdapterDescriptor,
register_store_policy,
)
from lmcache.v1.distributed.api import ObjectKey


class TieredStorePolicy(StorePolicy):
def select_store_targets(self, keys, adapters):
# 커스텀 로직 ...
...

def select_l1_deletions(self, keys):
return []


register_store_policy("tiered", TieredStorePolicy)

이제 --l2-store-policy tiered 로 사용할 수 있습니다.

Prefetch Policy

동일한 패턴: PrefetchPolicy 를 상속하고, select_load_plan() 을 구현한 후, register_prefetch_policy("name", cls) 를 호출합니다.

발견 메커니즘

storage_controllers/__init__.pypkgutil.iter_modules() 로 import 시점에 패키지의 모든 모듈을 import 합니다. 모듈이 import 되면 모듈 레벨의 register_*_policy() 호출이 해당 policy 를 레지스트리에 등록합니다. --l2-store-policy--l2-prefetch-policy CLI 인자는 레지스트리를 사용해 choices 목록을 구성합니다.