팀 프로젝트 잇다(itda) 를 정리한다. 치매 환자 보호자가 말하듯 쓴 메모를 로컬 LLM 이 정해진 JSON으로 바꾸고, 진료일에 의사용 경과 요약지로 묶는 온디바이스 AI 서비스다. Gemma 4 E4B QLoRA 파인튜닝, GGUF 양자화, Ollama 구조화 출력, 평가 설계까지 전 과정을 따라간다. 1. 문제 정의: 왜 로컬 LLM인가 진료실에서 "요즘 어떠셨어요?"라는 질문에 보호자가 할 수 있는 답은 "좀 나빠지신 것 같아요" 정도다. 야간 각성이 몇 번이었는지, 약을 바꾼 뒤 무엇이 달라졌는지는 기억 속에 흩어져 있다. 그렇다고 체크리스트 앱은 입력 부담이 커서 오래 쓰기 어렵다. 여기서 요구사항 네 개가 나온다. ID 요구사항 기술 결정 R1 입력 부담 최소화 자유 서술 메모 → LLM 추출 R2 로컬 처리 4B급 모델 + Ollama, 외부 호출 없음 R3 의사용 경과 요약 임상 틀(NPI 영역) 기준 12개 유형 R4 신뢰할 수 있는 집계 숫자·판단은 코드, 승인된 사건만 집계 R2 가 가장 강한 제약이다. 돌봄 기록은 민감 정보이고, 기록 대상자는 외부 전송 여부를 스스로 판단하기 어렵다. 이 조건 하나가 모델 크기, 서빙 도구, 양자화 방식을 모두 정한다. 2. LLM에게는 "추출"만 맡긴다 모델이 하는 일은 메모 한 건을 사건 목록 JSON으로 바꾸는 것뿐이다. 날짜 계산, 비율, 증가 판단은 전부 코드가 맡는다. 소형 모델은 날짜와 숫자에서 실수가 잦기 때문이다. 입력 "어젯밤 두 시쯤 깨셔서 한참 거실 왔다갔다 하심. 저녁 약은 안 드신다고 버티심." 출력 {"events": [ {"type": "night_waking", "status": "present", "time_expr": "어젯밤", "count": 1, "evidence": "어젯밤 두 시쯤 깨셔서"}, {"type": "wandering_exit", "status": "present", "time_expr": "어젯밤", "count": 1, "evidence": "한참 거실 왔다갔다 하심"}, {"type": "medication_refusal", "status": "present", "time_expr": null, "count": 1, "evidence": "저녁 약은 안 드신다고 버티심"} ]} type : 12개 코드의 닫힌 목록이다. 자유 텍스트면 집계가 불가능하다. time_expr : 원문 표현 그대로. 날짜 변환은 모델 몫이 아니다. evidence : 원문을 고치지 않고 복사한다. 확인 카드 대조와 자동 검수에 쓴다. 스키마보다 어려운 것은 경계 규칙 이다. "밤에 깨서 돌아다님"은 야간 각성과 배회 둘 다로 기록하고, "저녁은 반 공기 드심"처럼 양만 적힌 문장은 추출하지 않는다. "~밖에"처럼 줄었다는 표현이 있어야 식사량 감소다. 이런 규칙을 시스템 프롬프트와 학습 데이터에 똑같이 넣는다. 3. 합성 데이터: 정답을 먼저 정한다 실제 메모는 개인정보라 모을 수 없다. 그래서 정답 JSON을 먼저 뽑고, Claude로 문장만 만든다. LLM이 라벨을 정하지 않으니 라벨 오류가 구조적으로 줄어든다. 짚고 넘어갈 설계는 두 가지다. 첫째, 메모 20%에 반복 질문·낮잠·의문문 같은 방해 요소(distractor) 를 섞어 "뽑지 말아야 할 것"을 학습시킨다. 둘째, 문체 13종을 학습·검증·평가로 완전히 분리 한다. 평가 점수가 "본 적 없는 문체"에 대한 일반화를 뜻하게 하려는 장치다. 4. QLoRA 학습과 양자화 QLoRA 는 4bit로 양자화한 베이스 모델 위에 LoRA(작은 보조 가중치)만 학습하는 방식이다. Unsloth로 RTX 4070 Ti 12GB 한 장에서 학습한다. r 16, alpha 16, 학습률 2e-4, 실효 배치 8, 2에폭(약 494스텝)이다. 핵심은 손실을 JSON 응답에만 거는 것 ( train_on_responses_only )이다. 시스템 프롬프트와 메모를 외우지 않고 추출만 배운다. W&B 기준 eval loss는 0.0163 → 0.0119로 내려가고, grad_norm 최대 0.34, NaN 0건으로 안정적이다. 학습 후 LoRA를 병합해 GGUF Q8_0을 만들고, llama.cpp로 Q4_K_M 까지 내린다. F1은 0.003만 내주고 크기와 CPU 최대 처리 시간을 크게 줄인다. 보호자 PC에는 보통 GPU가 없으니 CPU 기준이 서비스 기준이다. 5. Ollama 서빙: 직접 돌려봐야 보이는 함정 Ollama format 에 JSON 스키마를 넣으면 출력 형식이 강제된다. 아래가 실제 백엔드 호출 형태다. import json import ollama res = ollama.chat( model="itda-gemma4-e4b-q4_k_m", # 파인튜닝 후 Ollama에 등록한 이름 messages=[{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": memo_text}], format=EVENT_SCHEMA, # JSON 스키마로 출력 강제 options={"temperature": 0}, think=False, # 생각 모드를 꺼야 학습 형식대로 답한다 ) events = json.loads(res["message"]["content"])["events"] 서빙 단계에서 만나는 함정은 세 가지다. 필드 순서 : 스키마를 강제하면 Qwen·EXAONE은 필드가 알파벳순으로 바뀌어 학습 순서와 어긋나고 성능이 떨어진다. Gemma 계열은 순서를 유지한다. 필드 순서도 학습의 일부다. 채팅 템플릿 : EXAONE은 Modelfile의 TEMPLATE을 무시하고 생각 모드가 켜진 채 등록되어 F1이 0.24까지 떨어진다. 그래서 Modelfile을 손으로 쓰지 않고 GGUF 안의 jinja 템플릿을 렌더링해 만든다. think 옵션 : 베이스 모델은 생각 모드가 켜져 있으면 384토큰 안에 JSON을 끝내지 못한다(통과율 13%). 같은 데이터로 학습한 후보를 서비스 조건에서 비교하면 Gemma 4 E4B Q4_K_M이 정확도와 CPU 속도의 균형이 가장 좋다. 6. 평가: 네 가지 질문으로 나눈다 "점수가 올랐다"는 한 줄로는 부족하다. 학습이 안정적인지, 목표 능력이 생겼는지, 기존 능력이 유지되는지, 사람이 봐도 나은지를 따로 묻는다. F1은 0.857 → 0.986, 메모 완전일치는 65% → 95%로 오른다. 처리 시간은 노트북 GPU 1.6초/건, CPU만 쓰면 7.8초/건이다. 베이스가 가장 약한 과민·짜증, 사람·장소 혼동, 망상은 모두 경계 규칙이 걸린 유형이다. 과민·짜증은 초조·공격(몸으로 드러나는 행동)과, 혼동은 망상(사실이 아닌 믿음)과 헷갈리기 쉽다. 파인튜닝 후 셋 다 0.99 이상으로 오른다. 기존 능력은 일반 질문 30 + KMMLU 50문항으로 확인한다. 70.0% → 68.8%로 −1.2%p, 허용 기준 3%p 안이다. 일반 질문에 추출 JSON으로 답하는 "모드 고착"도 0%다. 채점 로직은 "같은 메모 안에서 type 과 status 가 같으면 정답"이다. 아래는 이를 단순화한 실행 가능한 버전이다. from collections import Counter def event_keys(events): # 같은 메모 안에서 (type, status)가 같으면 같은 사건 return Counter((e["type"], e["status"]) for e in events) def score(gold_memos, pred_memos): tp = fp = fn = exact = 0 for gold, pred in zip(gold_memos, pred_memos): g, p = event_keys(gold), event_keys(pred) hit = sum((g & p).values()) tp += hit fp += sum(p.values()) - hit fn += sum(g.values()) - hit exact += int(g == p) # 메모 완전일치 precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 return {"precision": round(precision, 3), "recall": round(recall, 3), "f1": round(f1, 3), "exact_match": round(exact / len(gold_memos), 3)} def evidence_in_memo(memo, events): # 근거 구절이 원문에 글자 그대로 있는지 return all(e["evidence"] in memo for e in events) memo = "어젯밤 두 시쯤 깨셔서 한참 거실 왔다갔다 하심. 저녁 약은 안 드신다고 버티심." gold = [[ {"type": "night_waking", "status": "present", "evidence": "어젯밤 두 시쯤 깨셔서"}, {"type": "wandering_exit", "status": "present", "evidence": "한참 거실 왔다갔다 하심"}, {"type": "medication_refusal", "status": "present", "evidence": "저녁 약은 안 드신다고 버티심"}, ]] pred = [[ {"type": "night_waking", "status": "present", "evidence": "어젯밤 두 시쯤 깨셔서"}, {"type": "agitation", "status": "present", "evidence": "한참 거실 왔다갔다 하심"}, # 오분류 {"type": "medication_refusal", "status": "present", "evidence": "저녁 약은 안 드신다고 버티심"}, ]] print(score(gold, pred)) # {'precision': 0.667, 'recall': 0.667, 'f1': 0.667, 'exact_match': 0.0} print(evidence_in_memo(memo, pred[0])) # True 사건 하나만 틀려도 메모 완전일치는 0이다. F1과 완전일치를 함께 보는 이유다. 7. 판단은 코드가 한다: p-관리도 요약지의 "증가" 표시는 LLM이 아니라 p-관리도 (의료 품질 관리에서 비율 변동을 감시하는 통계 기법)로 계산한다. 분모는 기록한 날만 쓰고, 기록이 적을수록 문턱이 올라가 우연히 튄 값에 표시가 붙지 않는다. import math def upper_limit(p_bar, n, sigma=3): # 관리 상한 = p̄ + 3·√(p̄(1−p̄)/n) return p_bar + sigma * math.sqrt(p_bar * (1 - p_bar) / n) for n in (60, 20): ucl = upper_limit(0.10, n) need = math.floor(ucl * n) + 1 # 상한을 넘기는 최소 발생일 수 print(f"기록일 {n}일 → 상한 {ucl:.1%}, {need}일 이상이면 '증가'") # 기록일 60일 → 상한 21.6%, 13일 이상이면 '증가' # 기록일 20일 → 상한 30.1%, 7일 이상이면 '증가' 요약지 첫 장의 핵심 요약 문장은 같은 파인튜닝 모델이 쓴다. 회귀 평가에서 일반 능력 유지를 확인하므로 요약 전용 모델을 따로 두지 않고, 설치할 모델도 하나로 줄인다. 입력은 계산된 사실뿐이고, 숫자 일치·금지 표현("악화", "때문" 등)을 코드가 검사해 떨어지면 문장 틀로 대체한다. 숫자는 코드가, 문장은 모델이 맡는 분업이다. 8. 한계와 다음 과제 모든 수치는 합성 평가 세트 기준이다. 문체를 분리해도 같은 방식으로 만든 데이터라 점수가 부풀었을 수 있다. 목록 밖 관찰(반복 질문 → 식사량 감소로 오추출)도 남은 약점이다. 실제 보호자 메모로 재검증하는 것이 다음 단계다. 핵심 요약 민감 데이터 → 온디바이스 . 이 제약이 모델 크기·서빙·양자화를 결정한다. LLM은 추출만 , 날짜·숫자·판단은 코드가 맡는다. 합성 데이터는 정답 먼저, 문장 나중 . 평가 문체는 학습과 완전히 분리한다. QLoRA는 응답에만 손실을 걸고, 채팅 템플릿·필드 순서·think 옵션 을 학습과 서빙에서 일치시킨다. 평가는 안정성·목표 능력·기존 능력·사람 판단 네 갈래로 나눈다.
Finding athletic clothing that seamlessly transitions from an intense studio session to casual errands takes quite a bit of effort. Many fitness enthusiasts want versatile pieces that offer superior support without compromising their personal fashion sense. Building a premium alo yoga wardrobe provides the exact balance of technical performance and highly flattering modern silhouettes. The style experts at Editorialist frequently recommend this popular brand for anyone wanting to elevate their daily activewear collection. You will quickly realize why these specific garments remain highly coveted by stylish individuals everywhere. Moving away from basic gym clothes allows you to embrace a highly functional aesthetic for your busy lifestyle. This simple wardrobe upgrade completely transforms how you feel before starting a tough meditation session or a long morning run. Selecting luxury athletic pieces like alo yoga activewear ensures you maintain a highly polished appearance even when sweating heavily. Your daily commute to the studio becomes much more manageable when you stop fighting with uncomfortable waistbands. Alo yoga fabric technology and premium athletic materials Creating highly functional activewear requires immense skill and incredibly soft performance textiles. Authentic alo yoga garments usually feature proprietary moisture wicking blends that feel amazing against your bare skin. Crafters spend hours perfecting the stretch and compression levels to ensure maximum comfort during heavy movement. Every single seam reflects a deep commitment to lasting quality and charming visual appeal. The interior construction plays a massive role in how these athletic pieces feel during a long intense workout. Flat seams offer subtle smoothness that makes running for extended periods much more bearable. Your body will definitely appreciate the thoughtful construction and breathable fabrics during an intense indoor cycling class. Investing in high quality activewear means your favorite workout gear will survive daily wear perfectly. Styling your alo yoga pieces for everyday life Integrating these sporty items into your regular rotation takes very little effort on busy mornings. You can easily pair your favorite alo yoga leggings with an oversized knit sweater for a relaxed weekend coffee run. Adding a structured denim jacket creates a highly polished casual aesthetic perfect for running errands around town. Keeping your daily accessories minimal allows the sleek athletic lines to act as the true focal point of your entire outfit. If you want to transition your outfit for a casual lunch date you have endless styling options available. Throwing a sharp trench coat over your matching athletic set instantly creates an entirely new and sophisticated look. Swapping your heavy running trainers for clean white leather sneakers makes the entire look appropriate for a relaxed afternoon exploring the city. This extreme versatility proves that premium activewear deserves a permanent spot in your daily clothing collection. Getting the exact right fit is very important when you buy compression clothing for heavy daily use. A proper fit ensures your activewear stays securely in place preventing any awkward rolling while stretching. Trying on different alo yoga silhouettes helps you figure out exactly what flatters your personal body type best. Taking time to review specific sizing charts will guarantee that you feel completely happy with your new athletic purchase. Maintaining the pristine condition of your workout gear requires a very simple daily laundry routine. You should always wash your expensive activewear in cold water to protect the delicate technical fibers from heat damage. Keeping them away from standard fabric softeners ensures the material retains its essential sweat absorbing properties over time. Investing in versatile athletic clothing completely changes how you approach your daily morning dressing routine while keeping you entirely comfortable. View more: https://editorialist.com/shop/alo-yoga-activewear/
분할 정복 기법 문제를 작은 하위 문제로 나누고(분할) 각각을 해결(정복)한 뒤, 그 결과를 결합(통합)하여 원래 문제를 해결하는 알고리즘 기법 분할 정복 기법 적용된 대표적 정렬 알고리즘 : 퀵 정렬, 병합 정렬 분할 정복 기법 유래 1805 년 12월 2일 아우스터리츠 전투에서 나폴레옹이 사용한 전략 전력이 우세한 연합군을 공격하기 위해 나폴레옹은 연합군의 중앙부로 쳐들어가 연합군을 둘로 나눔 둘로 나뉜 연합군을 한 부분씩 격파함 분할 정복 기법의 설계 전략 분할(Divice) : 해결할 문제를 여러 개의 작은 부분으로 나눔 정복(Conquer) : 나눈 작은 문제를 각각 해결 통합(Combine) : (필요하다면) 해결된 해답을 모음 분할 정복 기법의 구조 Top-down approach 예시 분할 정복 기법의 예시 가짜 동전 찾기 n 개의 동전들 중에 가짜 동전이 하나 포함되어 있다. 가짜 동전은 진짜 동전에 비해 아주 조금 가볍다. 진짜 동전들의 무게가 동일하다고 할 때 양팔 저울을 이용해서 가짜 동전을 찾아보자. * 양팔 저울을 최소로 사용해서 가짜 동전을 찾는 방법은 무엇인가? * 예를 들어 동전이 24(진짜 23, 가짜 1)개 있다면? 거듭 제곱 분할 정복 기법을 이해하기 위해, 자연수 C의 n 제곱 값을 구하는 함수를 구현해봅시다 반복(Iterative) 알고리즘 : O(n) Iterative_Power(x, n) result <- ` FOR i in 1 -> n result <- result * x RETURN result 분할 정복 기반의 알고리즘 : O(log2n) Recursive_Power(x, n) IF n == 1: RETURN x IF n is even y <- Recursive_power(x, n/2) RETURN y * y ELSE y <- Recursive_Power(x, (n-1)/2) RETURN y * y * x 병합 정렬(Merge Sort) 여러 개의 정렬된 자료의 집합을 병합하여 한 개의 정렬된 집합으로 만드는 방식 병합 정렬 과정 자료를 최소 단위의 문제까지 나눈 후에 차례대로 정렬하여 최종 결과를 얻어냄 top-down 방식 시간 복잡도 O(n lon n) 병합 정렬 과정 예시 {69, 10, 30, 2, 16, 8, 31, 22}를 병합 정렬하는 과정 분할 단계 : 전체 자료 집합에 대하여, 최소 크기의 부분집합이 될 때까지 분할 작업을 계속한다. 병합 단계 : 2개의 부분 집합을 정렬하면서 하나의 집합으로 병합한다. 8개의 부분집합이 1개로 병합될 때까지 반복 병합 정렬 알고리즘 분할 과정 merge_sort(LIST m) IF length(m) == 1 : RETURN m LIST left, rigth middle <- length(m) / 2 퀵 정렬 (Quick Sort) 이진 검색
try catch 문 예외가 발생했을 때 프로그램을 멈추지 않고 안전하게 다음 코드를 실행하도록 처리하는 핵심 문법 "이 코드를 실행하고(try), 문제가 생기면 이렇게 처리해(catch)." try { // 오류가 발생할 수 있는 코드 } catch (예외타입 변수) { // 오류가 발생했을 때 실행할 코드 } try { int num = Integer.parseInt("abc"); } catch (NumberFormatException e) { System.out.println("숫자를 입력해주세요."); } NumberFormatException e 숫자로 바꾸려고 했는데 숫자로 바꿀 수 없을 때 발생하는 오류 ex. Integer.parseInt("123"); // 정상 Integer.parseInt("abc"); // NumberFormatException ( 오류 e 는 발생할 오류를 저장하는 변수 이름이다 : 발생한 예외정보가 포함된다.
📌 문제 설명 문자열 s가 주어진다. 문자열은 여러 개의 집합을 표현하고 있고, 각 집합에는 숫자들이 들어 있다. 예를 들어 "{{2},{2,1},{2,1,3},{2,1,3,4}}" 라면 {2} {2,1} {2,1,3} {2,1,3,4} 가 들어 있는 것이다. 이 집합들을 이용해서 [2,1,3,4] 라는 튜플을 찾아야 한다. 중요한 조건은 집합의 원소 순서는 중요하지 않다. 각 집합은 튜플의 앞부분을 포함한다. 원소가 적은 집합부터 확인하면 새로운 숫자를 하나씪 찾을 수있다. 💡 처음 문제를 보고 든 생각 처음에는 문자열 안에 {{2},{2,1},{2,1,3},{2,1,3,4}} 처럼 여러 데이터가 들어 있어서 이걸 어떻게 분리해야 하는지부터 어려웠다. 특히 처음에는 for i in s: 처럼 문자열 자체를 반복하면 각 집합이 하나씩 나올 것이라고 생각했다. 하지만 문자열을 for문으로 돌리면 문자 하나씩 꺼내진다. 예를 들어 s = "{{2},{2,1}}" for i in s: 이면 { { 2 } , { 2 , 1 } } 처럼 문자 하나씩 나온다. 따라서 먼저 문자열을 집합 단위 로 분리해야 한다. 🔥 전체 풀이 흐름 이 문제는 크게 다음 순서로 해결한다. 문자열 정리 ↓ 집합별로 분리 ↓ 각 숫자를 문자열 → 정수로 변환 ↓ 2차원 리스트 groups 생성 ↓ 원소 개수가 적은 순서로 정렬 ↓ 작은 집합부터 숫자를 하나씩 확인 ↓ answer에 없는 숫자 발견 ↓ answer에 추가 ↓ 현재 group 탐색 종료 🔍 전체 코드 def solution(s): answer = [] s = s[2:-2] sets = s.split("},{") groups = [] for group in sets: numbers = list(map(int, group.split(","))) groups.append(numbers) groups.sort(key=len) for group in groups: for num in group: if num not in answer: answer.append(num) break return answer 🧠 1. answer = [] answer = [] 빈 리스트를 만든다. 최종적으로 튜플의 원소를 저장할 공간이다. 처음에는 answer = [] 이고, 숫자를 하나씩 찾으면서 [2] [2,1] [2,1,3] [2,1,3,4] 처럼 늘어난다. 🧠 2. 문자열 슬라이싱 s = s[2:-2] 이 부분이 처음에는 상당히 헷갈릴 수 있다. 원래: {{2},{2,1},{2,1,3},{2,1,3,4}} 이다. 문자열에서 s[2:-2] 를 하면 앞에서 2개 제거 뒤에서 2개 제거 앞에서 2개 제거 뒤에서 2개 제거 한다. 즉, {{2},{2,1},{2,1,3},{2,1,3,4}} ^^ ^^ 제거 제거 결과: 2},{2,1},{2,1,3},{2,1,3,4 가 된다. 📌 슬라이싱 기본 문법 문자열[시작:끝] 여기서 중요한 점: 👉 끝 인덱스는 포함하지 않는다. 그리고 음수 인덱스도 사용할 수 있다. 예: s[-1] → 마지막 문자 s[-2] → 뒤에서 두 번째 문자 따라서 s[2:-2] 는 2번째 위치부터 뒤에서 2번째 위치 직전까지 가져오는 것이다. 🧠 3. split() sets = s.split("},{") 여기서는 문자열을 "},{" 를 기준으로 잘라낸다. 예: 2},{2,1},{2,1,3},{2,1,3,4 ↓ [ "2", "2,1", "2,1,3", "2,1,3,4" ] 가 된다. 📌 split()의 의미 문자열.split(기준) 은 문자열을 특정 기준으로 잘라서 리스트로 만든다. 예: "apple,banana,melon".split(",") 결과: ["apple", "banana", "melon"] 이번 문제에서는 s.split("},{") 이므로 "},{" 를 기준으로 집합들을 분리한 것이다. 🧠 4. groups = [] groups = [] 빈 리스트를 하나 만든다. 왜 필요할까? 아직 sets 안의 숫자들은 "2" "2,1" "2,1,3" "2,1,3,4" 처럼 문자열이다. 우리가 원하는 것은 [ [2], [2,1], [2,1,3], [2,1,3,4] ] 같은 형태이다. 따라서 변환한 결과를 저장할 새로운 리스트가 필요하다. 그 역할이 groups이다. 🔥 5. for group in sets for group in sets: sets 안에서 하나씩 꺼낸다. 예를 들어: sets = [ "2", "2,1", "2,1,3", "2,1,3,4" ] 이면 반복하면서 이면 반복하면서 첫 번째 group = "2" 두 번째 group = "2,1" 세 번째 group = "2,1,3" 네 번째 group = "2,1,3,4" 가 된다. 🔥 6. group.split(",") group.split(",") 각 group 안의 숫자들을 다시 , 기준으로 분리한다. 예: group = "2,1,3" 이면 group.split(",") 결과: ["2", "1", "3"] 이다. 여기서 중요한 점: 👉 아직 문자열이다. "2" "1" "3" 🔥 7. map() map(int, group.split(",")) 여기가 이번 문제에서 새롭게 배운 중요한 문법이다. map()은 여러 개의 값에 같은 함수를 하나씩 적용하는 기능 이다. 형식: map(함수, 여러 개의 값) 이번에는 map(int, ["2", "1", "3"]) 이므로 각 원소에 int()를 적용한다. "2" → int("2") → 2 "1" → int("1") → 1 "3" → int("3") → 3 즉, map(int, ["2", "1", "3"]) 은 ["2", "1", "3"] ↓ int 적용 ↓ [2, 1, 3] 을 만드는 과정이다. 📌 map()의 핵심 map(int, data) 라고 하면 data 안에 있는 각각의 값에 int()를 적용한다. 예: map(int, ["10", "20", "30"]) ↓ 10 20 30 🧠 8. 왜 list()가 필요한가? list(map(int, group.split(","))) 여기서는 split ↓ map ↓ list 순서로 처리된다. 안쪽부터 보면: 1 split group.split(",") "2,1,3" ↓ ["2", "1", "3"] 2 map map(int, ["2", "1", "3"]) 각각 int() 적용. 3 list list(...) map으로 만들어진 값을 실제 리스트로 만든다. 결과: [2, 1, 3] ⭐ 한 줄을 분해해서 읽는 방법 numbers = list(map(int, group.split(","))) 처음부터 한 번에 읽으려고 하면 어렵다. 안쪽부터 읽으면 된다. group.split(",") ↓ 문자열을 , 기준으로 자름 map(int, ...) ↓ 각 문자열에 int 적용 list(...) ↓ 리스트로 만듦 최종: numbers = [2, 1, 3] 🔥 9. groups.append(numbers) groups.append(numbers) 변환한 numbers를 groups에 넣는다. 예를 들어 numbers = [2,1,3] 이면 groups.append(numbers) 후: groups = [ [2], [2,1], [2,1,3] ] 처럼 된다. 📌 append() 리스트.append(값) 은 리스트의 맨 뒤에 값을 하나 추가한다. 예: numbers = [] numbers.append(2) numbers.append(5) 결과: [2, 5] 🧠 여기까지 데이터가 어떻게 변했는지 이 부분은 꼭 기억해두자. 원본 문자열 "{{2},{2,1},{2,1,3}}" ↓ s[2:-2] "2},{2,1},{2,1,3" ↓ split("},{") ["2", "2,1", "2,1,3"] ↓ group 하나 꺼냄 "2,1,3" ↓ split(",") ["2", "1", "3"] ↓ map(int, ...) 2, 1, 3 ↓ list(...) [2, 1, 3] ↓ append() groups에 저장 ↓ [ [2], [2,1], [2,1,3] ] 문자열 → 문자열 리스트 → 숫자 리스트 → 2차원 리스트 이 흐름이 이번 문제에서 가장 중요하다. 🔥 10. groups.sort(key=len) 이제 groups 안에는: groups = [ [2,1,3,4] [2], [2,1,3] [2,1] ] 처럼 여러 리스트가 들어 있다. groups.sort(key=len) 을 실행하면 👉 각 리스트의 길이를 기준으로 정렬한다. 각각: [2,1,3,4] → 길이 4 [2] → 길이 1 [2,1,3] → 길이 3 [2,1] → 길이 2 따라서: [ [2], [2,1], [2,1,3], [2,1,3,4] ] 가 된다. 📌 key=의 의미 sort(key=기준) 은 무엇을 기준으로 정렬할지 지정하는 것 이다. groups.sort(key=len) 이면 각각의 원소에 len()을 적용한 결과를 기준으로 정렬 한다. ❗ key=len vs key=len() 중요하다. key=len ⭕️ key=len() ❌ key=len은 "나중에 각각의 원소에 len을 적용해." 라는 뜻이다. len()은 지금 당장 실행하려는 형태라서 key에 넣을 수 없다. 🔥 11. 중첩 for 정렬된 groups를 이제 하나씩 꺼낸다. for group in groups: 예: group = [2] group = [2,1] group = [2,1,3] group = [2,1,3,4] 그런데 각 group 안에도 숫자가 여러 개 있다. 그래서 다시: for num in group: 을 사용한다. 즉: groups ↓ group 하나 ↓ num 하나 구조다. ⭐ 12. if num not in answer if num not in answer: 이번 문제의 핵심 조건이다. 뜻: 현재 숫자 num이 아직 answer에 없다면 이다. 예: answer = [2,1] num = 2 이면 2 not in [2,1] ❌ 거짓 따라서 if 안의 코드는 실행되지 않는다. 반대로: num = 3 이면 3 not in [2,1] ✅ 참 그래서: answer.append(3) 을 실행한다. 📌 in / not in x in 리스트 👉 x가 리스트 안에 있는가? x not in 리스트 👉 x가 리스트 안에 없는가? 예: 2 in [1,2,3] → True 4 in [1,2,3] → False 4 not in [1,2,3] → True 🔥 13. break if num not in answer: answer.append(num) break 새로운 숫자를 찾으면 answer.append(num) 으로 추가한다. 그리고 바로: break 한다. break의 정확한 의미 break는 현재 실행 중인 가장 가까운 for 또는 while 반복문 하나를 즉시 종료한다. 이번 코드에서는: for group in groups: for num in group: if num not in answer: answer.append(num) break break가 종료하는 것은: for num in group 이다. 바깥쪽 for group in groups는 종료하지 않는다. 그래서 다음 group으로 넘어간다. 🔥 14. continue break와 같이 알아두면 좋다. continue 는 현재 반복만 건너뛰고 다음 반복으로 넘어간다. 예: for num in group: if num in answer: continue answer.append(num) break 여기서는 이미 answer에 있는 숫자 → continue → 다음 num 확인 이다. break와 continue 차이 문법 의미 break 반복문 자체를 종료 continue 현재 반복만 건너뜀 pass 아무것도 하지 않음 이번 문제에서는 break 가 필요하다. 왜냐하면 새로운 숫자 하나를 찾으면 👉 현재 group에서는 더 볼 필요가 없기 때문이다. 🧠 break 흐름 다시 보기 for group in groups: for num in group: if num not in answer: answer.append(num) break 예를 들어: group = [2,1,3] answer = [2,1] 이면 num = 2 → 이미 있음 → 다음 num = 1 → 이미 있음 → 다음 num = 3 → 없음 → answer에 추가 → break 현재 group 종료 ↓ 다음 group 이다. 🎯 전체 알고리즘 정리 1 문자열 양끝의 {{ }} 제거 ↓ 2 },{ 기준으로 집합 분리 ↓ 3 각 숫자를 , 기준으로 분리 ↓ 4 문자열 숫자를 int로 변환 ↓ 5 groups에 2차원 리스트로 저장 ↓ 6 리스트 길이순으로 정렬 ↓ 7 작은 group부터 확인 ↓ 8 숫자를 하나씩 확인 ↓ 9 answer에 없는 숫자 발견 ↓ 10 answer에 추가 ↓ 11 break ↓ 12 다음 group 🧠 이번 문제에서 새로 배운 Python 문법 1 문자열 슬라이싱 s[2:-2] 앞 2개와 뒤 2개를 제외하고 가져온다. 2 split() s.split(",") 특정 문자를 기준으로 문자열을 나눠 리스트로 만든다. 3 map() map(int, data) data의 각 원소에 int()를 적용한다. 4 list() list(map(...)) 반복 가능한 값을 리스트로 만든다. 5 append() groups.append(numbers) 리스트 뒤에 하나의 값을 추가한다. 6 sort(key=...) groups.sort(key=len) 각 원소에 len()을 적용한 결과를 기준으로 정렬한다. 7 in num in answer num이 answer 안에 있는지 확인한다. 8 not in num not in answer num이 answer 안에 없는지 확인한다. 9 break break 가장 가까운 반복문 하나를 종료한다. 10 continue continue 현재 반복만 건너뛰고 다음 반복으로 넘어간다. ❗ 이번 문제에서 특히 기억할 것 map()은 map(함수, 여러 값) 👉 여러 값에 같은 함수를 각각 적용 sort(key=...)는 sort(key=기준) 👉 그 기준을 이용해서 정렬 break는 반복문 하나 종료 continue는 현재 반복만 패스 ↓ 다음 반복 not in은 아직 없는가? 라고 읽으면 편하다. 그래서 if num not in answer: 는 "이 숫자 아직 정답에 없나?" 라고 읽으면 된다. 💭 이번 문제에서 가장 중요한 사고방식 처음에는 문자열 하나가 너무 복잡해 보였다. 하지만 하나씩 뜯어보면: 문자열 ↓ 집합 문자열 ↓ 숫자 문자열 ↓ 정수 ↓ 리스트 ↓ 2차원 리스트 로 변환해 나간 것이다. 그리고 마지막에는 groups ↓ group ↓ num 순서로 다시 하나씩 꺼내면서 if num not in answer: 로 필요한 숫자만 골랐다. 즉 이번 문제는 단순히 튜플 문제를 푼 것이 아니라, 문자열로 들어온 복잡한 데이터를 원하는 자료구조로 변환하고, 중첩 반복문으로 단계적으로 탐색하는 방법 을 연습한 문제라고 볼 수 있다. 🔥 한 줄 정리 👉 문자열을 split()과 map()으로 숫자 리스트로 변환하고, sort(key=len)으로 작은 집합부터 정렬한 뒤, not in으로 새로운 원소만 찾아 break하는 문제
안녕하세요. 하이스트레인저에서 테크리드를 맡고 있는 고수진입니다. 영화를 보고 나서 “재미있었다”는 감상은 남지만, 어떤 장면에서 몰입했고 어디서 관심이 끊겼는지까지 설명하기는 쉽지 않습니다. 저희는 관객이 콘텐츠를 보는 동안 나타나는 생체·행동 신호를 통해 그 반응을 더 구체적으로 이해하고, 콘텐츠 제작과 개선에 참고할 수 있도록 돕고자 합니다. 그러려면 분석 결과부터 믿을 수 있어야 합니다. 서로 다른 센서의 신호가 같은 장면에 연결되어 있는지, 모델이 이미 본 사람이나 구간을 기억해서 높은 점수를 받은 것은 아닌지 확인해야 합니다. 테크리드로서 데이터 수집 구조와 모델 검증을 함께 살펴보는 이유도 여기에 있습니다. 이번 글에서는 서로 다른 신호를 하나의 시간축에 맞추는 일부터, 모델에 정말 새로운 데이터를 보여주는 평가를 설계하는 일까지 이어서 살펴보겠습니다. 먼저, 데이터를 분리했는데도 평가 점수를 그대로 믿기 어려운 상황 하나로 시작해보겠습니다. 학습 데이터와 평가 데이터를 분리했습니다. 같은 행이 양쪽에 들어가지 않는 것도 확인했습니다. 이제 평가 점수를 믿어도 될까요? 5초짜리 데이터를 1초씩 옮겨가며 잘랐다면, 서로 다른 두 행에 같은 원본 신호가 4초나 들어 있을 수 있습니다. 파일에서는 다른 샘플이지만, 모델 입장에서는 상당 부분을 이미 본 셈입니다. 모델이 잘 배운 걸까, 시험 문제가 낯익었던 걸까? 위 상황은 데이터 분할의 함정을 설명하기 위한 예시입니다. 멀티모달 AI를 만들 때는 이보다 앞선 질문도 생깁니다. 그 5초 안에 묶어놓은 뇌파, 맥파, 시선이 애초에 같은 장면의 데이터가 맞을까요? 이전 TRIBE v2 × InsightFlow 비교 실험 에서는 영상 분석의 표본 내 설명력이 시간 순서를 고려한 블록 교차검증에서 유지되지 않았습니다. 당시 결과만으로 원인이 데이터 누수였다고 결론 낼 수는 없습니다. 다만 “관계가 보인다”는 것과 “새로운 데이터에서도 예측할 수 있다”는 것을 구분해야 했습니다. 이번에는 그 결과를 해석하기 위해 돌아봐야 할 데이터의 경로를 살펴보겠습니다. 어떤 신호를 함께 묶었는지, 무엇을 한 샘플로 만들었는지, 그리고 무엇을 처음 보는 데이터로 정했는지. 세 가지를 따라가다 보면 수집 시스템의 설계가 어떻게 모델 평가까지 이어지는지 볼 수 있습니다. 1. 데이터가 만 행이면, 관찰도 만 번일까? 데이터프레임에 만 행이 있다고 해도, 만 번의 독립적인 관찰이 쌓였다고 볼 수는 없습니다. 한 사람이 본 하나의 영상을 잘게 나눠 만든 데이터일 수도 있기 때문입니다. 멀티모달 데이터를 다룰 때는 먼저 ‘데이터 한 건’의 의미를 정해야 합니다. 센서가 보낸 패킷 하나일 수도 있고, 5초 구간의 특징 벡터 하나일 수도 있습니다. 한 사람이 하나의 장면을 본 기록일 수도 있습니다. 무엇을 한 건으로 정의하느냐에 따라 저장 구조, 모델 입력, 검증 단위가 달라집니다. 예를 들어 ‘참여자 한 명이 특정 장면을 보는 동안의 반응’을 분석한다고 가정해보겠습니다. 이 경우 특징값만 저장해서는 그 데이터가 어떤 관찰에서 만들어졌는지 추적하기 어렵습니다. 구분 필드 예시 필요한 이유 관찰 대상 participant_id , session_id 동일 참여자와 반복 측정을 구분 콘텐츠 content_id , scene_id 동일 작품과 장면을 구분 시간 구간 window_start , window_end 특징값이 만들어진 원본 범위를 확인 데이터 품질 valid_ratio , quality_flag 신호 부족과 실제 낮은 반응을 구분 처리 이력 preprocessing_version 어떤 처리 조건으로 만들어졌는지 확인 이 필드들은 분석 결과를 다시 찾기 위한 메타데이터이면서, 학습과 평가 데이터를 나누는 기준이기도 합니다. 참여자 정보가 없으면 새로운 사람에 대한 평가를 구성하기 어렵고, 원본 구간이 없으면 두 샘플이 같은 신호를 공유하는지 확인하기 어렵습니다. 따라서 수집 스키마를 정할 때부터 평가할 상황을 함께 생각할 필요가 있습니다. 2. 관객은 10초 장면을 봤는데, 서버에는 10.8초에 도착했다 영화의 놀라는 장면이 끝난 직후, 조용한 대화 장면이 시작됐다고 가정해보겠습니다. 관객의 반응이 조금 늦게 서버에 도착했다면, 그 반응을 어느 장면에 붙여야 할까요? 이 질문에 답하려면 로그에 찍힌 시간이 무엇을 뜻하는지부터 구분해야 합니다. 시간 의미 측정 시각 센서가 신호를 취득한 시점 수신 시각 앱이나 서버가 데이터를 받은 시점 콘텐츠 재생 위치 사용자가 실제로 보고 있던 영상의 위치 예를 들어 영상의 10초 지점에서 발생한 신호가 통신 지연으로 10.8초에 도착할 수 있습니다. 이때 수신 시각으로 장면을 연결하면 다음 장면의 반응으로 배정될 가능성이 있습니다. 서버에 도착한 순서만으로는 실제 발생 순서를 충분히 설명할 수 없는 이유입니다. 장치가 각각 자신의 시계를 사용한다면 시계 사이의 차이도 고려해야 합니다. 시작 시점의 차이인 오프셋과, 시간이 흐르며 누적되는 드리프트를 구분해야 합니다. LSL의 시간 동기화 문서에서도 샘플 타임스탬프와 시계 오프셋 측정값을 함께 사용해 서로 다른 시계의 데이터를 연결합니다.[1] 여기에 영상 재생 상태가 추가됩니다. 버퍼링이나 일시정지가 발생하면 측정 시작 이후 경과 시간과 영상 재생 위치가 달라집니다. 따라서 재생 시작 시각 하나만 저장하는 방식으로 충분한지, 재생·정지·탐색 이벤트와 중간 재생 위치를 함께 기록해야 하는지 판단해야 합니다. 다음 그림은 전송 지연과 일시정지가 있는 상황을 단순화한 예시입니다. 그림 1. (a) 측정 시각과 수신 시각의 차이, (b) 일시정지로 발생하는 경과 시간과 영상 위치의 차이. 아래 차트의 7초 시점에서 영상은 5초 위치에 있음. 실측값이 아닌 설명용 예시임. 이때 목표는 모든 장치에 동일한 타임스탬프를 붙이는 데서 끝나지 않습니다. 어떤 근거로 시간을 변환했는지, 어느 정도의 오차가 남을 수 있는지까지 확인할 수 있어야 합니다. 허용 가능한 오차도 장면 단위 분석인지, 짧은 이벤트 직후의 반응을 분석하는지에 따라 달라집니다. 3. 같은 5초인데 데이터 개수는 전부 다르다 시간축을 맞췄다고 데이터가 곧바로 같은 형태가 되지는 않습니다. EEG, PPG, 카메라 데이터는 수집 주기가 다르고, 각 신호에서 추출하는 특징도 다릅니다. “모델은 아직 안 돌렸고, 타임스탬프만 세 시간째 보고 있습니다.” 예를 들어 EEG 256Hz, PPG 100Hz, 카메라 30fps인 구성을 가정하면, 누락이 없는 5초 구간에는 각각 1,280개, 500개, 150개의 시점이 들어갑니다. 카메라의 150프레임 모두에서 유효한 시선이나 얼굴 특징을 얻는다는 보장은 없습니다. 그림 2. EEG 256Hz, PPG 100Hz, 카메라 30fps를 가정했을 때 0초 이상 0.25초 미만의 샘플 시각. 각 세로선은 이상적인 수집 시점이며 신호 진폭을 뜻하지 않음. 모든 채널이 0초에서 시작한다는 가정의 예시임. 특징 기반 융합을 한다면 각 신호에서 특징을 추출한 뒤 공통 분석 구간에 연결할 수 있습니다. 원시 신호를 직접 입력하는 모델이라면 별도의 리샘플링이나 마스킹 설계가 필요할 수 있습니다. 어느 방식을 택하든 데이터 개수를 맞추는 것과 시간적 의미를 맞추는 것은 별개의 작업입니다. 또한 모든 특징을 동일한 길이의 원본 구간에서 계산해야 하는 것도 아닙니다. 모델이 1초마다 결과를 출력하더라도, 어떤 특징은 직전의 더 긴 신호 구간을 사용해 계산할 수 있습니다. 이 경우 출력 시각만 기록하면 입력이 참조한 범위를 놓치게 됩니다. 예를 들어 30초 시점의 특징이 0~30초 데이터를 사용했다면, 해당 특징의 원본 범위도 함께 추적해야 합니다. 이 정보는 뒤에서 학습·평가 경계의 중복을 판단하는 데 필요합니다. 결측 처리도 같은 맥락에서 봐야 합니다. 시선이 검출되지 않은 구간을 0으로 채우면, 유효한 측정값 0과 측정 실패를 구분하기 어렵습니다. 보간 여부와 별개로 유효 비율이나 결측 마스크를 남겨야 모델 입력과 결과를 해석할 수 있습니다. 4. train과 test를 나눴는데, 원본은 겹쳤다 이제 도입에서 던진 질문으로 돌아오겠습니다. 학습과 평가에 같은 행이 없는데도, 모델이 평가 데이터의 일부를 이미 보았을 수 있을까요? 슬라이딩 윈도우를 만든 뒤 행 단위로 무작위 분할했다면 가능합니다. 잘라낸 구간들의 행 번호는 달라도 원본 신호가 겹칠 수 있기 때문입니다. 5초 윈도우를 1초씩 이동시키는 예를 보겠습니다. 샘플 원본 구간 배정 예시 A 0초 이상~5초 미만 학습 B 1초 이상~6초 미만 평가 C 2초 이상~7초 미만 학습 그림 3. 5초 윈도우를 1초씩 이동시키면 A와 B가 1~5초의 원본 신호를 공유함. 음영과 점선은 두 구간이 공유하는 4초 범위를 나타냄. A와 B는 4초 분량의 원본 신호를 공유합니다. 서로 다른 행이지만 독립된 관찰로 보기는 어렵습니다. 평가 샘플이 학습 데이터와 얼마나 분리되어 있는지 확인하려면 행 번호보다 원본 신호의 범위를 봐야 합니다. “행 번호가 다른데... 이게... 새로운 데이터인가요?” 이를 피하려면 평가 목적에 맞춰 참여자·콘텐츠·시간 블록을 먼저 나누고 각 영역 안에서 윈도우를 만들거나, 이미 생성된 윈도우의 원본 범위를 확인해 경계를 넘는 샘플을 제외하는 방법을 고려할 수 있습니다. 시간 경계에 간격을 두는 경우에도 윈도우 길이만 보면 충분하지 않을 수 있습니다. 더 긴 과거를 참조한 특징, 필터의 영향 범위, 예측 대상의 시간 범위까지 함께 살펴야 합니다. 원본이 겹치지 않더라도 가까운 구간 사이에 시간적 의존성이 남는지도 별도로 확인해야 합니다. 다만 모든 분할에서 모든 종류의 중복을 없애야 한다는 뜻은 아닙니다. 같은 사람의 다음 구간을 예측하는 서비스와 처음 만나는 사람을 분석하는 서비스는 평가할 조건이 다릅니다. 먼저 무엇에 대한 일반화를 확인하려는지 정해야 합니다. 5. 처음 보는 사람인가, 처음 보는 영화인가? “새로운 데이터에서 잘 맞습니다.” 이 말에서 ‘새로운’이 무엇인지에 따라 실험은 달라집니다. 같은 관객의 다음 장면을 맞히는 것과, 처음 온 관객이 새로운 영화를 볼 때의 반응을 맞히는 것은 다른 문제입니다. 평가하려는 상황 분할의 중심 단위 같은 측정에서 이후 구간을 예측 시간 순서 같은 사람의 다른 날 측정에 적용 세션 처음 참여한 사람에게 적용 참여자 보지 못한 작품에 적용 콘텐츠 새로운 사람과 새로운 작품에 동시에 적용 참여자와 콘텐츠를 모두 분리 scikit-learn도 시계열 데이터와 그룹이 있는 데이터에 서로 다른 교차검증 방식을 제공합니다. 동일 참여자의 여러 샘플이 존재한다면 참여자 단위 분할을 통해 해당 참여자가 평가 시점에 학습 데이터에 포함되지 않도록 할 수 있습니다.[2] 참여자만 분리한 평가로 새로운 콘텐츠에 대한 일반화까지 확인했다고 말할 수는 없습니다. 두 조건을 동시에 평가하려면 학습과 평가 사이에 참여자 집합과 콘텐츠 집합이 각각 겹치지 않도록 설계해야 합니다. 참여자와 콘텐츠를 이어 붙인 조합 ID만 분리하면 같은 사람이 다른 작품으로 양쪽에 등장할 수 있습니다. 이 선택은 제품 요구사항과도 연결됩니다. 서비스가 분석하려는 대상이 기존 사용자인지, 새로운 고객인지, 매번 새롭게 들어오는 콘텐츠인지에 따라 필요한 평가가 달라집니다. 6. 정규화는 끝냈는데, 시험지를 먼저 본 셈이라면 학습·평가 구간을 잘 나눴더라도 그 전에 전체 데이터로 정규화를 끝냈다면 어떨까요? 평가 데이터의 정보가 평균과 표준편차를 통해 이미 학습 과정에 들어갔을 수 있습니다. 정답 라벨을 직접 보여주지 않았어도, 데이터에서 학습하는 전처리 과정은 평가 정보를 참조할 수 있습니다. 데이터에서 학습되는 스케일링, 결측 대체, 특징 선택 등의 변환은 각 학습 폴드에서 추정하고 평가 폴드에는 적용만 해야 합니다. scikit-learn의 Pipeline 은 이러한 단계를 모델과 함께 묶는 데 도움이 됩니다.[3] 실시간 분석을 목표로 한다면 미래 구간을 사용하는지도 확인해야 합니다. 세션 종료 후 전체 기록으로 정규화한 결과가 좋더라도, 분석 도중에는 같은 정보를 사용할 수 없습니다. 초기 보정 구간을 사용하는 제품이라면 평가에서도 같은 보정 조건을 재현해야 합니다. 비교 기준 역시 분명해야 합니다. 멀티모달 모델을 평가한다면 동일한 분할과 평가 표본에서 단일 모달리티 모델과 비교하는 것이 출발점이 될 수 있습니다. 특정 신호를 추가하면서 결측 때문에 평가 대상까지 달라졌다면, 점수 차이에 표본 구성의 영향이 섞였는지도 살펴야 합니다. 이전 TRIBE 실험에서 씬 길이를 통제한 뒤 예측값의 추가 설명력을 살펴본 것도 비교 기준을 명확히 하려는 접근이었습니다. 다만 그 분석 결과를 새로운 데이터에 대한 예측 성능과 동일하게 해석해서는 안 됩니다. 7. 점수가 내려갔다. 이제 무엇을 고칠까? “학습할 땐 맞았는데요...” 처음 보는 데이터: “때...앵...” 검증 결과를 열었을 때의 당혹감에 붙인 상황 캡션. 방송: MBC 《무한도전》. 이미지 게재 출처: 세모짤 . 원본 변경 없음. 검증 결과가 기대보다 낮게 나왔다면 어느 단계의 문제인지 좁혀가야 합니다. 아래는 결과를 바로 원인으로 단정하지 않고 추가 점검으로 연결하는 예입니다. 관찰된 현상 다음에 확인할 내용 장면 전환 부근에서 오차가 커짐 재생 위치 기록, 시간 정렬 오차, 특징이 참조한 구간 참여자를 분리하면 성능이 낮아짐 개인별 차이, 보정 조건, 학습 표본의 다양성 콘텐츠를 분리하면 성능이 낮아짐 콘텐츠별 분포와 라벨 구성, 학습 데이터의 범위 일부 센서가 누락되면 결과가 불안정해짐 결측 처리, 품질 정보, 누락 조건별 평가 오프라인 평가와 실제 운영 결과가 다름 지연, 입력 가용 시점, 전처리 차이 새로운 모델을 적용할 수도 있지만, 수집 이벤트를 추가하거나 분석 구간을 다시 정의하는 것이 다음 작업일 수도 있습니다. 판단의 근거를 남기려면 결과와 함께 데이터 버전, 전처리 조건, 분할 기준, 평가 대상의 구성을 기록해야 합니다. 일을 하면서 생각하게 된 건 이 경계를 연결하는 일이 중요한 것 같다는 것입니다. 수집 단계에서 남기지 않은 정보는 모델 개발 단계에서 복구하기 어렵습니다. 반대로 평가할 상황이 정의되지 않으면 수집 단계에서도 어떤 정보를 반드시 남겨야 하는지 판단하기 어렵습니다. 성능표 옆에 남겨야 할 질문 모델의 점수를 보면 다음 실험을 떠올리기 쉽습니다. 층을 더 쌓을지, 다른 모델을 써볼지, 모달리티를 하나 더 넣을지 고민하게 됩니다. “일단 레이어 추가는 잠깐 멈추고. 원본 로그부터 열자.” 그전에 입력 한 행을 원본까지 따라가 볼 필요가 있습니다. 어떤 사람이, 어떤 장면을 보았고, 어느 구간의 신호가 들어갔는지. 그리고 평가 데이터는 학습 데이터와 무엇을 공유하는지 말입니다. 수집 시스템과 모델 평가를 함께 봐야 하는 이유도 여기에 있습니다. 모델 개발 단계에서 필요한 참여자 ID나 재생 기록을 수집 단계에서 남기지 않았다면, 나중에 코드를 바꾸는 것만으로는 해결하기 어렵습니다. 다음에 좋은 성능표를 보게 된다면 이 질문을 먼저 던져보려고 합니다. 이 모델에게 정말 새로운 것은 무엇이었을까? 그 질문에 답할 수 있어야 점수를 실제 서비스의 조건과 연결할 수 있다고 생각하게 되었습니다. 참고 자료 Lab Streaming Layer — Time Synchronization scikit-learn — Cross-validation: evaluating estimator performance scikit-learn — Common pitfalls and recommended practices Histranger — TRIBE v2 × InsightFlow: 예측된 뇌 반응과 실제 관객 반응을 비교해봤습니다
색상 토큰, 타이포그래피, 인터랙션을 하나씩 정리하기 운영툴의 기능과 메뉴가 늘어나면서 UI에도 조금씩 다른 패턴이 쌓이기 시작했습니다. 같은 역할의 색상이 서로 다른 값으로 사용되거나, 비슷한 버튼인데도 hover와 active 상태의 표현이 다르고, 아이콘의 크기와 간격도 조금씩 달랐습니다. 각 화면만 놓고 보면 큰 문제는 아니지만, 기능이 계속 추가되는 상황에서는 같은 역할을 하는 UI에 동일한 기준을 적용할 필요가 있다고 생각했습니다. 이번에는 Sidebar UI를 개선하면서 기존 구조를 정리하고, 그 과정에서 반복해서 사용되는 색상, 타이포그래피, 아이콘, hover/focus와 같은 인터랙션 기준 을 함께 정리해보았습니다. 처음부터 별도의 디자인 시스템이나 공통 컴포넌트 라이브러리를 만드는 것이 목표는 아니었습니다. 기존 구조를 최대한 유지하면서 실제로 공통 기준으로 가져갈 수 있는 부분과 각 컴포넌트에 남겨두는 것이 나은 부분을 하나씩 구분하는 방식으로 진행했습니다. Sidebar를 시작점으로 Sidebar는 대부분의 화면에서 항상 노출되는 영역입니다. 메뉴가 늘어나면서 메뉴 계층, 권한에 따른 노출, 접기/펼치기, active 상태, tooltip, 아이콘, 브랜드 영역 등 여러 UI 기준이 한곳에 모여 있었습니다. 특정 페이지 하나를 수정하는 것보다 여러 패턴을 동시에 확인할 수 있었기 때문에 Sidebar를 공통 UI 기준을 정리하는 시작점으로 잡았습니다. 기존 Sidebar에는 메뉴 렌더링뿐 아니라 권한과 설정값에 따른 노출 조건도 함께 들어 있었습니다. 따라서 단순히 화면을 새롭게 구성하는 것보다는 기존 동작을 유지하면서 구조와 표현을 정리하는 것 을 우선했습니다. 공통화의 범위 정하기 처음에는 Sidebar 자체를 좀 더 범용적인 공통 컴포넌트 형태로 분리하는 방향도 검토했습니다. 하지만 현재 화면에서만 필요한 책임까지 별도의 shared component로 분리하면 파일은 늘어나고, 실제 변경 내용을 파악하기는 오히려 어려워질 수 있었습니다. 최종적으로는 기존 SideBar , SideBarItem 을 중심으로 유지하면서 역할이 명확하게 분리되는 부분만 나누었습니다. SideBar ├─ SidebarBrand ├─ NavSection │ └─ SideBarItem └─ Account / Settings 공통화의 기준도 단순히 “다른 곳에서 재사용할 수 있는가?” 에 두지 않았습니다. 분리했을 때 기존보다 책임이 명확해지고, 코드를 이해하기 쉬워지는지를 우선해서 판단했습니다. 재사용 가능성이 있다는 이유만으로 abstraction을 늘리는 것보다 현재 코드에서 필요한 역할을 명확하게 만드는 쪽이 더 중요했습니다. 색상 값이 아니라 역할을 정의하기 Sidebar 스타일을 정리하면서 비슷한 계열의 색상이 여러 위치에서 각각 다른 값으로 사용되고 있는 부분도 함께 확인했습니다. 단순히 기존 색상을 CSS 변수로 옮기는 것만으로는 의미가 크지 않다고 생각했습니다. 중요한 것은 색상 값 자체보다 UI에서 어떤 역할을 하는 색상인가 였습니다. 예를 들어 다음처럼 역할을 기준으로 token을 구성했습니다. --color-brand-accent: ...; --color-brand-accent-strong: ...; --color-brand-accent-soft: ...; --color-brand-accent-border: ...; --color-text: ...; --color-text-secondary: ...; --color-text-muted: ...; --color-border: ...; --color-hover: ...; active 상태의 색상을 특정 orange 값으로 관리하는 대신 brand-accent 라는 의미로 정의했습니다. 이렇게 해두면 이후 브랜드 컬러가 변경되더라도 같은 역할을 하는 UI를 한 곳에서 조정할 수 있습니다. 반대로 모든 값을 token으로 만들지는 않았습니다. Sidebar width나 item height처럼 한 컴포넌트 안에서만 의미가 있는 값까지 전부 token으로 올리면 어떤 값이 실제 공통 기준인지 오히려 불분명해질 수 있기 때문입니다. 이번 작업에서는 여러 UI에서 같은 의미로 반복되는 값은 token으로, 특정 컴포넌트의 구현 세부사항은 해당 컴포넌트에 남기는 것 을 기준으로 두었습니다. Tailwind와 CSS의 경계 프로젝트에서는 Tailwind를 사용하고 있기 때문에 spacing이나 flex layout처럼 일반적인 스타일은 가능한 한 template에서 처리했습니다. 하지만 Sidebar를 정리하다 보니 utility class만으로 처리하는 것이 오히려 복잡해지는 부분도 있었습니다. Scrollbar pseudo-element, Vue <Transition> 의 enter/leave class, tooltip arrow, Teleport된 tooltip, active/hover/collapsed 상태가 겹치는 스타일 등이 대표적인 경우였습니다. 이런 스타일은 별도의 sidebar.css 에 남겼습니다. 즉 CSS 파일을 없애는 것이 목표가 아니라, Tailwind가 잘 처리하는 부분은 Tailwind로, CSS 기능 자체가 필요한 부분은 CSS로 역할을 구분했습니다. 한 컴포넌트에서만 사용하는 단순 spacing이나 layout 때문에 별도의 CSS를 늘리지는 않고, CSS로 관리할 이유가 명확한 부분만 남기는 방식입니다. 상태는 닫혔는데, 왜 메뉴는 계속 보일까? Section 메뉴의 접기/펼치기 기능을 확인하면서 예상하지 못했던 문제가 하나 있었습니다. 코드상으로는 isOpen 값이 정상적으로 false 가 되었고 chevron도 닫힌 상태로 바뀌었지만, 하위 메뉴는 화면에서 계속 보였습니다. 처음에는 Vue Transition의 타이밍 문제를 의심했습니다. 하지만 실제 element와 computed style을 확인해보니 원인은 CSS 우선순위였습니다. 프로젝트에서는 Tailwind를 global important 모드로 사용하고 있었습니다. @import 'tailwindcss' important; 따라서 grid utility는 최종적으로 다음과 같이 적용됩니다. .grid { display: grid !important; } 반면 Vue의 v-show 는 element에 inline style을 추가합니다. style="display: none;" 결과적으로 다음 두 스타일이 충돌하게 됩니다. v-show display: none vs Tailwind display: grid !important Tailwind의 !important 가 우선되면서 Vue의 상태 값과 실제 화면이 달라졌던 것입니다. 여기에 다시 display: none !important 를 추가해서 해결할 수도 있었지만, CSS 우선순위를 한 단계 더 복잡하게 만들고 싶지는 않았습니다. 그래서 해당 영역은 v-show 대신 v-if 로 변경해 DOM 자체를 mount/unmount하도록 처리했습니다. <Transition name="nav-section-items"> <div v-if="showItems"> ... </div> </Transition> 수정 자체는 작았지만, 상태 값만 확인했다면 원인을 찾기 어려운 문제였습니다. 이후에는 UI 상태가 예상과 다를 때 framework 상태뿐 아니라 실제 DOM과 computed style까지 함께 확인 하는 쪽으로 검증 범위를 넓혔습니다. Hover와 Focus 상태 다루기 Sidebar가 접힌 상태에서는 메뉴명을 직접 표시할 수 없기 때문에 hover나 focus 시 tooltip을 보여주도록 구성했습니다. 그런데 메뉴를 클릭해 route 이동이 완료된 후에도 tooltip이 계속 남는 문제가 있었습니다. 처음에는 hover 상태가 해제되지 않는 문제라고 생각했습니다. 하지만 마우스를 완전히 다른 영역으로 이동해도 tooltip이 사라지지 않았습니다. document.activeElement 를 확인해보니 원인은 hover가 아니라 focus 였습니다. 메뉴 버튼을 마우스로 클릭하면 브라우저가 해당 button에 focus를 남기고 있었고, tooltip 표시 조건은 다음과 같은 형태였습니다. collapsed && (isHovered || isFocused) 따라서 mouseleave가 발생해 isHovered 가 false 가 되어도 isFocused 가 계속 true 라 tooltip이 남아 있었습니다. 단순히 click마다 blur() 를 호출하면 바로 해결할 수 있지만, 그렇게 하면 키보드 사용자가 Enter 나 Space 로 메뉴를 실행했을 때도 focus가 사라집니다. 그래서 pointer click인 경우에만 focus를 해제하도록 범위를 좁혔습니다. if (event.detail > 0) { button.value?.blur(); } 마우스로 클릭했을 때는 navigation 후 tooltip이 닫히고, Tab으로 focus한 경우에는 tooltip이 보이며, Enter나 Space로 이동한 경우에는 keyboard focus가 유지되도록 했습니다. 작은 tooltip 동작이었지만 마우스 UX만 기준으로 수정하면 키보드 인터랙션에서 또 다른 regression을 만들 수 있는 부분이었습니다. 같은 17px인데 왜 다르게 보일까? Collapsed와 Expanded 상태의 아이콘을 비교하면서 Expanded 쪽 아이콘이 조금 더 작아 보이는 부분도 있었습니다. 처음에는 상태별로 다른 size가 적용된 것이라고 생각했습니다. 하지만 실제 렌더링 값을 측정해보니 두 상태 모두 같은 크기였습니다. icon container: 18 × 18px svg: 17 × 17px 차이는 주변 layout에서 발생했습니다. Collapsed 상태에서는 정사각형 영역의 중앙에 아이콘만 배치되지만, Expanded에서는 텍스트와 함께 배치되면서 padding과 주변 요소의 영향을 받습니다. 즉 computed size는 같지만 optical size는 다르게 느껴질 수 있는 상태 였습니다. 설정 버튼과 Sidebar toggle을 정리할 때도 같은 기준을 적용했습니다. 실제 클릭 영역은 충분한 크기로 유지하고, 화면에서 보이는 hover/focus surface는 별도로 다뤘습니다. 실제 hit area ┌────────────────┐ │ │ │ ┌──────┐ │ │ │ icon │ │ ← hover / focus surface │ └──────┘ │ │ │ └────────────────┘ 기본 상태에서는 배경을 표시하지 않고 hover나 focus가 발생했을 때만 rounded surface를 보여주는 식입니다. 결국 디자인 기준을 맞춘다는 것은 width , height 같은 숫자를 동일하게 만드는 것만으로는 부족했습니다. 같은 역할의 UI가 비슷한 크기와 방식으로 인식되고, 동일한 방식으로 반응하는지 까지 확인할 필요가 있었습니다. Typography 기준 정리 Sidebar 브랜드 영역을 정리하면서 전역 font token도 함께 확인했습니다. 처음에는 다음과 같은 설정이 있었습니다. --font-body: 'Pretendard', 'Pretendard Variable', ...; --font-display: 'Chakra Petch', var(--font-body); 그런데 실제 프로젝트를 확인해보니 Chakra Petch 는 dependency에도 없었고 별도의 CSS import나 @font-face 도 존재하지 않았습니다. 즉 설정에는 존재하지만 실제로는 로드되지 않는 font였습니다. Pretendard도 현재 사용하는 CSS를 직접 확인해보니 @font-face 에 정의된 family는 다음 하나였습니다. font-family: 'Pretendard Variable'; 이에 맞춰 token도 실제 runtime에서 유효한 값을 기준으로 정리했습니다. --font-body: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif; --font-display: var(--font-body); 설정 파일에 font 이름이 있다고 해서 실제 사용할 수 있는 font인 것은 아니었습니다. 디자인 token도 결국 runtime에서 실제로 유효한 값과 연결되어 있어야 했습니다. 웹폰트 로딩 최적화 Pretendard를 적용한 뒤 production build의 asset도 확인해보았습니다. 처음 적용한 Variable font는 WOFF2 한 파일이 약 1.96MiB 였습니다. font-display: swap 이 적용되어 있어 초기 paint 자체를 막는 구조는 아니었지만, UI 개선을 위해 정적 resource를 2MB 가까이 추가하는 것은 확인해볼 필요가 있었습니다. Pretendard에는 dynamic subset 방식도 제공되고 있었습니다. 다만 subset이라는 이름만 보고 바로 변경하지는 않았습니다. Dynamic subset의 WOFF2 파일 전체를 합치면 오히려 full variable보다 크기 때문입니다. 차이는 unicode-range 에 있었습니다. Dynamic subset에서는 현재 페이지에서 필요한 문자에 해당하는 font chunk만 브라우저가 요청합니다. 그래서 동일한 production build와 navigation sequence를 기준으로 두 방식을 직접 비교했습니다. 방식 첫 진입 Player 화면 이동 후 누적 Full Variable 약 1.96 MiB 약 1.96 MiB Dynamic Subset 약 181 KiB 약 206 KiB 첫 진입 기준으로는 약 90% 정도의 font transfer를 줄일 수 있었습니다. Player 화면으로 이동할 때 필요한 chunk 하나가 추가로 요청되었지만, 이후 여러 한글 화면을 이동해도 추가 font request는 발생하지 않았습니다. 사용하고 있는 font weight와 한글 glyph도 함께 확인한 뒤 최종적으로 import를 변경했습니다. @import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css'; 여기서 중요하게 본 것은 build 결과에 생성된 전체 asset의 합이 아니라 실제 브라우저가 얼마를 요청하는가 였습니다. 파일이 여러 개로 나뉘었다는 것만으로는 최적화라고 보기 어렵기 때문에 실제 Network 데이터를 기준으로 적용 여부를 결정했습니다. 공통 Layout 변경의 영향 범위 Sidebar를 변경하면서 예상하지 못했던 다른 화면의 regression도 발견했습니다. 상위 layout의 flex/scroll 구조가 달라지면서 Player 상세 화면에서 검색 영역이 지나치게 줄어들거나 내부 scrollbar가 중첩되는 문제가 발생했습니다. Sidebar 자체에서는 정상적으로 보였지만, 상위 layout sizing이 변경되면서 하위 페이지의 flex와 overflow까지 영향을 받은 것입니다. 결국 상세 화면에서 어느 element가 scroll owner가 되어야 하는지를 다시 정리했습니다. 이후에는 Sidebar만 확인하지 않고 주요 화면으로 이동하면서 검색 영역 높이, 상세 영역의 scroll, content sizing, Sidebar collapse/expand 이후 layout까지 함께 확인했습니다. 공통 Layout을 수정하는 작업에서는 수정한 component 하나만 정상적으로 보이는 것으로 검증을 끝내기 어렵다는 점을 확인한 부분이었습니다. 구조를 옮길 때 놓치기 쉬운 정책 Navigation 구조를 정리하면서 기존 SideBar.vue 안에 있던 메뉴 구성 로직을 별도의 navigation tree로 분리했습니다. 그런데 작업 후 특정 설정값에 따라 숨겨져야 하는 레거시 메뉴가 계속 표시되는 문제가 있었습니다. 확인해보니 기존 SideBar.vue 에는 단순한 route rendering뿐 아니라 특정 설정값을 확인해 메뉴를 숨기는 조건도 함께 들어 있었습니다. 구조를 분리하면서 렌더링 로직은 이동했지만 이 조건 하나가 빠졌던 것입니다. 이후 기존 revision과 비교해 해당 조건을 그대로 navigation tree로 옮겼습니다. 이 경험 이후 navigation 관련 코드를 분리할 때는 route 구조뿐 아니라 permission, feature flag, settings, hidden condition, route metadata처럼 기존 component 안에 함께 들어 있던 정책도 같이 확인 하게 되었습니다. UI component 안에는 생각보다 화면을 그리는 코드 이상의 역할이 들어 있을 수 있었습니다. 사용하지 않는 설정 정리 작업 마지막에는 이번 변경에서 새로 추가하거나 수정한 항목을 기준으로 실제 reference를 다시 확인했습니다. 그 과정에서 더 이상 읽지 않는 route.meta.icon , 사용되지 않는 관련 type, breadcrumb에서 제거된 i18n key, 실제로 로드되지 않는 font family, 작업 중 의도하지 않게 변경된 dependency version 등을 정리했습니다. 반대로 token이나 CSS class를 단순히 코드 양을 줄이기 위해 제거하지는 않았습니다. 실제 reference가 존재하고 역할이 명확하다면 그대로 유지했습니다. 결국 정리의 기준은 파일 수나 코드 줄 수보다 남아 있는 코드가 실제 역할을 가지고 있는가 였습니다. 작은 기준을 공통 규칙으로 이번 작업은 별도의 디자인 시스템을 구축하기 위해 시작한 작업은 아니었습니다. Sidebar를 정리하다 보니 자연스럽게 여러 기준을 다시 생각하게 되었습니다. 같은 역할의 색상을 어떻게 관리할지, 어떤 값까지 token으로 만들지, hover와 focus를 어떻게 표현할지, click target과 visual surface를 어떻게 구분할지, Tailwind와 별도 CSS의 경계를 어디에 둘지 등을 하나씩 정리했습니다. 처음부터 많은 token이나 공통 component를 만드는 방식보다, 기존 UI에서 반복되고 있는 결정을 먼저 찾고 같은 상황에서 다음에도 같은 결정을 내릴 수 있도록 기준을 만드는 방식 으로 접근했습니다. 이번에는 Sidebar가 시작점이었지만, 앞으로 다른 화면을 정리할 때도 이 기준을 조금씩 적용하면서 범위를 확장해볼 수 있을 것 같습니다.
1. null vs undefined 둘 다 “값이 없음”을 나타내지만 의미가 다르다. let a; console.log(a); // undefined let b = null; console.log(b); // null undefined : 값이 아직 할당되지 않음 null : 개발자가 의도적으로 “값 없음”을 넣음 실무에서는 API 응답, 초기 상태값, optional 값 처리할 때 자주 만난다. 2. == vs === 1 == "1" // true 1 === "1" // false == 는 비교 전에 타입을 자동 변환한다. === 는 타입과 값이 모두 같은지 비교 한다. 그래서 실무에서는 거의 항상: if (a === b) { } 처럼 === 를 사용한다. 3. 타입 강제 변환(Type Coercion) JavaScript는 상황에 따라 타입을 자동으로 바꾼다. "5" + 1 // "51" "5" - 1 // 4 + 는 문자열 연결로 동작할 수 있지만, - 는 숫자 연산만 가능해서 숫자로 변환된다. 이런 암묵적 변환 때문에 JS에서 예상하지 못한 결과가 생길 수 있다. 4. Truthy / Falsy JS에서는 boolean이 아니어도 조건문에서 참/거짓으로 판단된다. Falsy 값: false 0 "" null undefined NaN 이외 대부분의 값은 Truthy다. if ("hello") { console.log("실행됨"); } 특히 주의할 것: [] // truthy {} // truthy 빈 배열과 빈 객체도 true 취급된다. 5. NaN NaN 은 Not a Number라는 뜻이다. Number("hello"); // NaN 특이하게: NaN === NaN // false 그래서 확인할 때는: Number.isNaN(value); 을 사용한다. 6. typeof 타입 확인: typeof 10 // "number" typeof "hello" // "string" typeof true // "boolean" typeof undefined // "undefined" typeof {} // "object" 그런데 유명한 예외가 있다. typeof null // "object" 이건 JS 초기 설계에서 생긴 오래된 버그성 동작이다. 핵심 정리 undefined → 값이 아직 없음 null → 의도적으로 값 없음 == → 타입 변환 후 비교 === → 타입 + 값 비교 → 실무에서 기본 사용 Truthy / Falsy → 조건문에서 값 자체가 true/false처럼 평가됨 []와 {}는 truthy NaN → 숫자로 변환할 수 없는 값 typeof null === "object" → JS의 오래된 특이사항
웹 애플리케이션 개발 시 피그마(Figma) 디자인을 코드로 구현할 때 단순히 이미지 캡처나 눈대중 수치를 이용하면 패딩, 마진, 폰트 스펙 등에서 오차가 발생한다. VS Code 기반의 Claude Code 환경에 Figma MCP(Model Context Protocol) Server 를 연동하면, 피그마 API를 통해 Auto Layout 수치, 컬러 토큰, Typography 정보를 직접 읽어와 정밀하게 1:1 코드로 변환할 수 있다. 시행착오 끝에 성공한 Figma MCP 서버 설정 과정 및 트러블슈팅 가이드를 정리한다. 1. 피그마 API 토큰 발급 Figma 웹 또는 데스크톱 앱 로그인 후 프로필 아이콘 클릭 $\rightarrow$ Settings 이동. Personal access tokens 섹션으로 이동. Generate new token 클릭. Token Scopes 설정 시 보안을 위해 읽기 전용( :read ) 권한만 선택한다. file_content:read (필수: 레이어 구조, Auto Layout 수치 조회) file_metadata:read library_assets:read / library_content:read file_dev_resources:read 생성된 토큰( figd_... 형태)을 복사해 둔다. 2. Windows PowerShell 스크립트 실행 권한 설정 PowerShell 환경에서 claude CLI 실행 시 스크립트 권한 에러( PSSecurityException )가 발생할 수 있다. 다음 명령어로 현재 사용자 계정의 실행 정책을 변경한다. Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser 권한 변경 확인 시 Y 를 입력한다. 3. .claude.json MCP 설정 파일 구성 npm 공식 스코프 패키지명이 아닌, 검증된 Figma MCP 패키지( figma-developer-mcp )를 사용해야 404 및 바이너리 실행 에러를 방지할 수 있다. VS Code 터미널에서 아래 명령어로 설정 파일을 연다. code C:\Users\Administrator\.claude.json 파일 내용을 다음과 같이 작성하고 저장한다. { "mcpServers": { "figma": { "type": "stdio", "command": "npx", "args": [ "-y", "figma-developer-mcp", "--stdio" ], "env": { "FIGMA_API_KEY": "figd_여기에_발급받은_토큰_입력" } } } } type: "stdio" : Claude Code와 MCP 서버 간 로컬 프로세스 직통 통신(Standard Input/Output) 방식이다. -y : npx 실행 시 패키지 자동 다운로드 승인 옵션이다. --stdio : 백그라운드 stdio 모드로 MCP 서버를 구동하는 옵션이다. 4. 트러블슈팅 (Troubleshooting) 1 npm error 404 Not Found (@modelcontextprotocol/server-figma) 원인 : @modelcontextprotocol/server-figma 패키지는 npm 레지스트리에 존재하지 않는다. 해결 : figma-developer-mcp 패키지를 사용하도록 지정한다. 2 npm error could not determine executable to run (mcp-server-figma) 원인 : npx 에서 해당 패키지의 실행 바이너리를 지정하지 못할 때 발생한다. 해결 : args 에 figma-developer-mcp 와 --stdio 옵션을 명시한다. 5. 연동 상태 확인 및 사용 방법 연동 확인 터미널에서 아래 명령어를 실행하여 연결 상태를 확인한다. claude mcp list 정상적으로 연동되면 다음과 같이 ✓ Connected 상태로 표시된다. figma: npx -y figma-developer-mcp --stdio - ✓ Connected Claude Code 활용 프롬프트 예시 피그마의 특정 프레임 우클릭 $\rightarrow$ Copy link to selection 으로 링크를 복사한 뒤 Claude Code 터미널에 전달한다. "이 피그마 프레임( https://www.figma.com/design/...?node-id=123-456)의 의) 레이아웃과 스타일을 Figma MCP로 읽어와서 src/components/Header.tsx 파일로 Tailwind CSS를 적용해 1:1로 정확히 작성해 줘. Auto Layout의 gap과 padding 수치를 엄격하게 지켜 줘."
알림만 보내면 되는 줄 알았다 신고가 끝나면 고객에게 알린다. 실무자가 하던 일이고, 그걸 시스템이 보내게 만들었다. 메시지 종류를 정하고, 보낸 기록이 남는 틀과 팝빌로 내보내는 연결을 만들었다. 여기까지는 하루 안에 됐다. 만들고 나서 빠진 게 보였다. 고객이 받아야 하는 건 "끝났습니다"라는 말이 아니라 신고서와 납부서 같은 서류다. 그런데 알림톡에는 파일을 못 붙이고, 글자와 버튼만 들어간다. 알림을 보내는 쪽을 만들어놓고, 정작 보내야 하는 건 못 보내는 상태였다. 버튼 뒤에 링크를 뒀다 서류는 서버에 올려두고, 버튼에 그 서류로 가는 주소를 달았다. 고객이 버튼을 누르면 서류가 내려온다. 세무나 법무 쪽에서는 다들 이렇게 한다. 메시지 규정에도 걸리지 않는다. 이때 그 주소는 열쇠 자체였다. 주소를 아는 사람이 곧 받는 사람이다. 주소가 카톡 대화창에 남고, 그 폰을 여는 사람이면 누구나 연다. 본인 확인을 붙일까 생각했다. 그리고 과하다고 적었다. 고객용 사이트를 따로 만들거나 인증을 붙이는 건 혼자 돌리는 시스템에 오버스펙이라고. 주소를 아무나 못 맞히게 길게 만들고, 며칠 지나면 죽게 했다. 그 정도면 된다고 봤다. 하루 만에 뒤집었다 그 판단은 하루를 못 갔다. 버튼을 누르면 파일이 바로 내려오지 않고 페이지가 하나 뜬다. 그 페이지가 휴대폰 끝 네 자리를 묻는데, 알림이 간 바로 그 번호의 끝자리다. 맞으면 그때 서류가 내려온다. 몇 번 틀리면 그 링크는 죽고, 실무자가 다시 보내야 한다. 대가가 있었다. 확인 페이지를 웹사이트 쪽 새 주소에 두면 버튼 주소도 바뀌고, 버튼 주소가 바뀌면 카카오 쪽 심사를 처음부터 다시 받아야 한다. 페이지는 웹사이트가, 파일은 서버가 맡는 구조가 심사를 한 번 더 기다리는 것보다 낫다고 보고 알면서 바꿨다. 오버스펙이라고 적은 지 하루 만에 그걸 넣었고, 심사도 처음부터 다시 신청했다. 신고 한 건은 폴더째 떨어진다 링크 하나에 파일 하나로 만들었다. 서류를 하나 올리면 링크가 하나 나온다. 실제로 신고 한 건이 끝나면 파일이 하나가 아니다. 신고서, 납부서, 영수증, 그 신고에 쓴 자료까지 폴더째 떨어진다. 실무자는 그 폴더를 통째로 보내야 한다. 선택지가 셋이었다. 폴더를 압축해 파일 하나로 올린다. 파일마다 알림을 따로 보낸다. 링크 하나에 파일 여러 개를 매단다. 압축 하나면 보내는 쪽은 링크 하나로 끝난다. 그런데 받는 쪽은 폰이다. 폰에서 압축 파일은 여는 것부터 일이다. 세 번째로 갔다. 링크를 열면 파일 목록이 뜨고 파일마다 따로 받는다. 한꺼번에 받는 버튼도 있는데, 그건 전체를 압축해서 내려준다. 거절한 건 압축이 아니라 압축 하나만 주는 쪽이었다. 본인 확인은 한 번만 하면 된다. 결정은 넷이었다 "서류 보내드릴게요"는 한 문장이다. 시스템으로 옮기니 결정이 넷으로 갈렸다. 어디로 보내나. 카톡으로. 뭘로 보내나. 파일이 아니라 링크로. 누가 여나. 그 번호의 주인이. 몇 개를 보내나. 폴더째. 처음부터 있던 건 어디로 하나였다. 나머지 셋은 만들고 나서야 보였고, 이틀 동안 세 번 고쳤다. 남길 것 세 번 고친 것을 나란히 놓고 보니 공통점이 하나 있었다. 셋 다 받는 사람 폰 화면에 있던 것들 이다. 파일이 안 붙는 것도, 링크만 알면 열리는 것도, 압축 하나로 주면 폰에서 풀어야 하는 것도. 나는 보내는 쪽부터 만들었다. 메시지를 고르고, 기록을 남기고, 팝빌로 내보내는 길까지 깔았다. 받는 사람 화면은 그리지 않았고, 그래서 그 화면에 있던 것들이 하나씩 뒤늦게 왔다. "보내드릴게요"를 시스템으로 옮기면 그 한 문장은 사라지고 결정 넷이 남는다. 넷은 받는 쪽에서 세야 한다.
1. input() 과 split() 질문 입력값을 특정 기준으로 나누려면 어떻게 해야 할까? 핵심 input() 은 문자열을 입력받고, split() 은 문자열을 나눈다. a, b = input().split() m, d, y = input().split('-') 기억할 것 input() # 문자열 입력 .split() # 공백 기준 분리 .split('-') # '-' 기준 분리 2. 여러 변수에 저장하기 질문 a, b = input().split() 은 어떻게 동작할까? 핵심 split() 으로 나눈 값을 각각의 변수에 저장할 수 있다. a, b = input().split() 입력이 10 20 이면 a = '10' b = '20' 기억할 것 변수 개수와 나눠진 값의 개수가 같아야 한다. 3. map() 과 list() 질문 왜 아래 코드는 잘못된 걸까? arr = [map(int, input().split())] 핵심 [map(...)] 은 map 객체 하나를 담은 리스트 다. 숫자들을 리스트로 만들려면: arr = list(map(int, input().split())) 흐름 input() → split() → map(int, ...) → list(...) 예: 10 20 30 arr = [10, 20, 30] 기억할 것 [map(...)] # map 객체 하나를 리스트에 저장 list(map(...)) # map의 결과를 리스트로 변환 4. 문자열을 숫자로 변환하기 질문 입력받은 숫자로 계산하려면? 핵심 input() 의 결과는 기본적으로 문자열이다. a = int(input()) 여러 개라면: a, b = map(int, input().split()) 리스트라면: arr = list(map(int, input().split())) 기억할 것 int() # 정수 변환 float() # 실수 변환 str() # 문자열 변환 list() # 리스트 변환 5. 리스트 기본 함수 핵심 arr = [10, 20, 30] sum(arr) # 60 len(arr) # 3 max(arr) # 30 min(arr) # 10 평균: sum(arr) / len(arr) 기억할 것 len() 은 마지막 인덱스가 아니라 원소 개수 를 반환한다. 6. / , // , % 질문 평균을 버림 처리하려면? 핵심 7 / 3 # 2.333... 7 // 3 # 2 7 % 3 # 1 기억할 것 / : 일반 나눗셈 // : 몫 % : 나머지 7. f-string 소수점 출력 질문 :.0f 는 버림일까? 핵심 아니다. :.0f 는 반올림해서 소수점 0자리까지 출력 한다. x = 3.8 print(f'{x:.0f}') # 4 print(f'{x:.1f}') # 3.8 기억할 것 출력 자릿수 지정과 버림은 다르다. 8. sep 과 end 질문 print() 에서 값 사이의 공백을 없애려면? 핵심 print(a, b, sep='') sep 은 값 사이 , end 는 출력 마지막 을 설정한다. print('A', 'B', sep='') # AB print('A', end='') print('B') # AB 기억할 것 sep : 출력값 사이 end : 출력 마지막 가장 중요하게 복습할 코드 arr = list(map(int, input().split())) 이 코드는 반드시 각 단계를 설명할 수 있어야 한다. input() → 문자열 입력 split() → 문자열 분리 map(int, ...) → 각 값을 정수로 변환 list(...) → 리스트로 변환 Python 기초에서는 우선 입력 → 자료형 변환 → 리스트 저장 → 계산 → 출력 흐름에 익숙해지는 것이 중요하다.