1. Scope란? Scope는 변수에 접근할 수 있는 범위 다. const a = 10; function test() { const b = 20; console.log(a); // 가능 console.log(b); // 가능 } console.log(b); // 불가능 JS에는 크게 다음 범위가 있다. Global Scope Function Scope Block Scope 2. Block Scope let , const 는 {} 단위로 범위가 나뉜다. if (true) { const a = 10; } console.log(a); // Error 반면 var 는 block scope가 아니라 function scope 를 따른다. if (true) { var a = 10; } console.log(a); // 10 이것도 var 를 잘 쓰지 않는 이유 중 하나다. 3. Scope Chain 현재 범위에 변수가 없으면 바깥 범위로 올라가며 찾는다. const a = 10; function outer() { const b = 20; function inner() { console.log(a); console.log(b); } inner(); } inner() 는 자신의 scope → outer → global 순서로 변수를 찾는다. 이 구조를 Scope Chain 이라고 한다. 4. Lexical Scope JS는 함수가 어디에서 호출됐는지가 아니라, 어디에서 선언됐는지 를 기준으로 바깥 scope를 결정한다. const x = 10; function test() { console.log(x); } function run() { const x = 20; test(); } run(); // 10 test() 가 run() 안에서 호출됐지만, 선언된 위치가 global이므로 x = 10 을 본다. 이걸 Lexical Scope 라고 한다. 5. Execution Context JS는 코드를 실행할 때 Execution Context(실행 컨텍스트) 를 만든다. 쉽게 말하면: 현재 실행 중인 코드의 변수, 함수, scope 정보를 관리하는 실행 환경 함수가 호출될 때마다 새로운 실행 컨텍스트가 생성된다. function a() { b(); } function b() { console.log("hello"); } a(); 개념적으로: Global Execution Context ↓ a() Execution Context ↓ b() Execution Context 가 만들어진다. 6. Call Stack Execution Context는 Call Stack 에 쌓인다. function first() { second(); } function second() { console.log("hello"); } first(); 실행 흐름: Global ↓ first() ↓ second() ↓ second 종료 ↓ first 종료 Stack이기 때문에 LIFO(Last In First Out) 구조다. 이 Call Stack 개념은 나중에 싱글 스레드와 Event Loop 를 이해할 때 핵심이 된다. 핵심 정리 Scope → 변수에 접근할 수 있는 범위 let / const → block scope var → function scope Scope Chain → 변수를 현재 scope부터 바깥으로 찾아감 Lexical Scope → 함수가 선언된 위치 기준으로 scope 결정 Execution Context → 현재 실행 중인 코드의 환경 정보 Call Stack → 실행 컨텍스트가 쌓이는 구조 → LIFO
> F. Momoyo and the Network time limit per test3 seconds memory limit per test256 megabytes Where Is That Bustling Marketplace Now— Unconnected Marketeers The Underground Great Line Network is a grand transit system connecting all corners of Gensokyo. Momoyo noticed that the network's layout resembled a tree∗ structure. She couldn't help but imagine the most effective way to dismantle that tree. Given a tree with n nodes where node i has weight ai, select a simple path of exactly k edges and remove all edges on it. This splits the tree into k+1 connected components, each with weight equal to the sum of its nodes' weights. You need to maximize the minimum component weight, or output −1 if no simple path of exactly k edges exists. ∗A tree is a connected graph without cycles. Input Each test contains multiple test cases. The first line contains the number of test cases t (1≤t≤104). The description of the test cases follows. The first line of each test case contains two integers n and k (1≤k≤n−1, 2≤n≤2⋅105). The second line contains n integers, where the i-th integer represents ai (1≤ai≤109). The next n−1 lines each contain two integers u and v, representing an edge of the tree. It is guaranteed that the sum of n over all test cases does not exceed 2⋅105. Output > For each test case, output the maximum possible minimum component weight, or −1 if no such path exists. c# using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; class Program { static BufferedStream sr = new BufferedStream(Console.OpenStandardInput()); static StreamWriter sw = new StreamWriter(new BufferedStream(Console.OpenStandardOutput()), Encoding.Default); static int[] arr=new int [200001]; static List<int>[] edge = new List<int>[200001]; static long[] edge_v = new long[200001], edge_min = new long[200001], edge_len = new long[200001]; static long total = 0,k=0; struct Pair:IComparable<Pair> { public long Key, Value; public Pair(long Key,long Value) { this.Key = Key; this.Value = Value; } public int CompareTo(Pair other) { return other.Key.CompareTo(this.Key); } } static Pair[][] temps = new Pair[200001][]; static int[][] temp = new int[200001][]; static int[] temp_cnt = new int[200001]; static void Set_Edge(int idx,int parent) { temp_cnt[idx] = 0; edge_v[idx] = arr[idx]; edge_min[idx] = 0; var list = edge[idx]; int temp_i = 0; for(int i=0;i< list.Count;i++) { int next=list[i]; if (next==parent) continue; Set_Edge(next,idx); edge_v[idx] += edge_v[next]; temp_cnt[idx]++; temp[idx][temp_i++] = next; } var t_list = temp[idx]; for (int i=0;i< temp_i;i++) { int next = t_list[i]; edge_min[next] = Math.Min(edge_v[next], edge_v[idx] - edge_v[next]); } } static bool DFS(int idx,long mid) { edge_len[idx] = 0; int temps_i = 0; var list = temp[idx]; int size = temp_cnt[idx]; for (int i = 0; i < size;i++) { int next=list[i]; if (DFS(next, mid)) return true; if (mid <= edge_v[next]) { temps[idx][temps_i++]=new Pair( edge_v[next], edge_len[next]); } if (mid<=edge_min[next]) { edge_len[idx] = Math.Max(1 + edge_len[next], edge_len[idx] ) ; } if(edge_len[next] +1>=k&&total- edge_v[next] >=mid&& edge_v[next] >=mid) { return true; } } if (temps_i >1) Array.Sort(temps[idx], 0, temps_i); int ii = -1; long l = -1, r = -1; //아 무조건 l,r범위가 커지도록 하려면 작은쪽에서 커지도록 해야함;; var t_list = temps[idx]; for (int j=0;j<temps_i;j++) { var i = t_list[j]; while (ii + 1 < temps_i && total - t_list[ii + 1].Key- i.Key >= mid) { ii++; if (l <= t_list[ii].Value) { r = l; l = t_list[ii].Value; } else if( r<= t_list[ii].Value) r = t_list[ii].Value; } if (ii ==-1) continue; if (total - 2 * i.Key >= mid && l == i.Value) { if (i.Value + r + 2 >= k) return true; } else if (l + i.Value+ 2 >= k) return true; } return false; } static int ReadInt() { int c = sr.ReadByte(); while (c <= 32) { if (c == -1) return -1; c = sr.ReadByte(); } bool neg = false; if (c == '-') { neg = true; c = sr.ReadByte(); } int val = 0; while (c > 32) { val = val * 10 + (c - '0'); c = sr.ReadByte(); } return neg ? -val : val; } static void Solve() { int t = ReadInt(); for (int i = 0; i < 200001; i++) { edge[i] = new List<int>(); } for (int i = 0; i < t; i++) { int n = ReadInt(); k = ReadInt(); total = 0; for (int j = 0; j < n; j++) { arr[j] = ReadInt(); total += arr[j]; edge[j].Clear(); } for (int j = 0; j < n - 1; j++) { int u = ReadInt() - 1; int v = ReadInt() - 1; edge[u].Add(v); edge[v].Add(u); } for (int j = 0; j < n; j++) { temp[j] = new int[edge[j].Count]; temps[j] = new Pair[edge[j].Count]; } Set_Edge(0, -1); long l = 0, r = total, answer = -1; while (l <= r) { long mid = (l + r) >> 1; if (DFS(0, mid)) { l = mid + 1; answer = mid; } else { r = mid - 1; } } sw.WriteLine(answer); } sw.Flush(); } static void Main(string[] args) { Thread thread = new Thread(Solve, 1024 * 1024 * 64); thread.Start(); thread.Join(); } } cpp코드 #include<iostream> #include<vector> #include<algorithm> using namespace std; long long arr[200001]; int new_edge_cnt[200001]; vector<int> edge[200001]; long long edge_v[200001]; long long edge_min[200001]; long long edge_len[200001]; vector<int> new_edge[200001]; vector<pair<long long, long long>> edges[200001]; long long n, k, t,edge_total=0; bool dfs_find(int idx,long long mid) { edge_len[idx] = 0; edges[idx].clear(); for (int i : new_edge[idx]) { if (dfs_find(i, mid)) return true; if (mid <= edge_v[i]) { edges[idx].push_back({ edge_v[i], edge_len[i] }); } if (edge_min[i] >= mid) { edge_len[idx] = max(edge_len[i] + 1, edge_len[idx]); } if (edge_len[i] + 1 >= k && edge_total - edge_v[i] >= mid && edge_v[i] >= mid) { //자를 수 있음. return true; } } sort(edges[idx].begin(), edges[idx].end()); int ii= -1; int l =-1, r = -1; int cnt = edges[idx].size(); for (int j = cnt- 1; j >= 0; j--) { while (ii + 1 <cnt && edge_total- edges[idx][j].first - edges[idx][ii + 1].first >= mid) { ii++; if (edges[idx][ii].second > l) {//l값이 크고 r값이 작음 r =l; l = edges[idx][ii].second; } else if (edges[idx][ii].second > r) { r = edges[idx][ii].second; } //이 과정이 잘 이해가 안갔는데 결국 k 값 이상되는 길이를 찾는 과정임. } if (ii == -1) { continue; } if (edge_total - 2 * edges[idx][j].first >= mid &&l== edges[idx][j].second) { //어 지금 제일 큰 길이와 동일하면 r을 써야함 if (r + edges[idx][j].second + 2 >= k) { return true; } } else { if (l + edges[idx][j].second + 2 >= k) { return true; } } } return false; } void dfs(int idx,int parent) { edge_v[idx] = arr[idx]; edge_min[idx] = 0; new_edge[idx].clear(); for (int i : edge[idx]) { if (i==parent)continue; dfs(i,idx); edge_v[idx] += edge_v[i]; new_edge[idx].push_back(i); } for (int i: new_edge[idx]) { edge_min[i] = min(edge_v[idx] - edge_v[i], edge_v[i]); } } int main() { ios_base::sync_with_stdio(false); cin.tie(NULL); cout.tie(NULL); int u,v; cin >> t; for (int a = 0; a < t; a++) { cin >> n >> k; edge_total = 0; for (int i = 0; i < n; i++) { cin >> arr[i]; edge_total += arr[i]; edge[i].clear(); } for (int i = 0; i < n-1; i++) { cin >> u >> v; u--; v--; edge[u].push_back(v); edge[v].push_back(u); } dfs(0,-1); long long l = 0, r = edge_total; long long result = -1; while(l<=r) { long long mid = (l + r) >> 1; if( dfs_find(0, mid)) { l=mid+1; result = mid; } else { r=mid-1; } } cout << result << "\n"; } return 0; } pypy3 import sys input = sys.stdin.readline data = sys.stdin.buffer.read() ndata = len(data) ii = 0 def read_int(): global ii, ndata, data while ii < ndata and data[ii] <= 32: ii += 1 if ii >= ndata: return 0 sign = 1 if data[ii] == 45: # ASCII 45 == '-' sign = -1 ii += 1 num = 0 while ii < ndata and data[ii] > 32: num = num * 10 + (data[ii] - 48) ii += 1 return num * sign t=read_int() for i in range(t): n=read_int() edge= [[] for _ in range(n) ] weight=[0]*n k=read_int() for j in range(n): weight[j]= read_int() for j in range(n-1): u=read_int()-1 v=read_int()-1 edge [u] .append(v); edge [v] .append(u); st= [0] bottom_up_edge= [] parent= [-1] *n while st: current=st.pop() bottom_up_edge.append(current) for x in edge[current] : if x== parent [current] : continue parent [x] =current st.append(x) bottom_up_edge.reverse() #보니까 일단 bfs형식으로 트리를 미리 만들어주고 #만들 때부터 정렬까지 진행해버리기 # 부모 노드에 자식 노드 가중치를 더해서 값을 세팅해줌. tree = weight[:] for node in bottom_up_edge: if node != 0: tree[parent[node]] += tree[node] 일단은 dp라는게 나와서 len,v,min으로 쪼개서 사용했던 것을 단순히 mid값 충족된 해당 노드의 길이를 담은게 dp가 된다는데 일단 루트노드에서 잘려진 서브트리 노드들의 mid충족이 되는걸 파악하면서 dp를 업데이트함. 이게 좀 이해가 잘 안갔는데 투포인터로 조건검사 하는 부분이 two_pointer 인덱스를 첨에 늘리면서 현재 child 값 과 투포인터 인덱스로 나온 트리값을 전체에서 빼서 mid보다 안되면 다시 줄이면 mid조건이 달성되니까 올리고 줄이고 하면서 i는 계속 증가하는데 투포인터는 왔다갔다 조절하면서 찾는건가?라고 생각했는데 맞는 것 같다. 이해가 안가는 부분이 max_from_smaller라는 건데..이게 좀 이해가 안갔다. 자식들 값을 최대값 dp로 초기화하고 있는 부분 근데 그림을보면 부모의 양쪽 간선 제거 후에도 mid값이 유지되는 것을 찾는 것인데 그 mid값이 유지되면서 k값도 조건만족해야하니까 dp길이가 제일 큰 것을 찾아야함. 그래서 방식을 보면 이미 오름차순 가중치로 진행되면서 i가 현재라면 최대값으로 이미 지나온 자식들은 i보다 무조건 작거나 같음.그래서 최대값으로 초기화해서 찾는 시간을 줄이는 효율적인 방법이라고 지금 이해중.. 솔직히 비슷한 문제 대회에 나온다고하면 맨땅으로 못짤 것 같다.. 원리를 완전 이해못한 듯? 트리 문제나 그래프 문제에서 어떤 조건을 달성할 최대값을 찾는다면 이분탐색을 생각해보고, 일단은 이중포문으로 안될 것 같으면, 투포인터 활용하기 투포인터도 역순으로 찾는게 있고 같은 시작지점에서 타겟팅 변수는 현재 변수보다 낮게 설정하여 찾는 방법도 있고 여기서는 초기화로 시간을 단축시킬 방법도 있다. 가중치라는 부분에서 정렬로 노드의 자식들을 미리 정렬하는 기술도 쓰일 수 있다. 이렇게 정리를 했는데. 일단 찾는 방향도 정리해보면 미드값에 충족되는 자식을 제외한 현재 노드의 가중치 값을 파악하고 길이 값을 초기화하고, 투포인터 인덱스를 조건에 따라 위아래로 인덱스 값 조절해주고 부모를 기준으로 잘려진 두 자식 노드의 가중치를 합쳐서 전체 가중치에서 제외했을 때 mid값을 모두 충족하는지 판단. 부모노드를 기준으로 간선 두개를 제거해도 mid값이 유지되는지 보는 것. 근데 의문이 자식 두개가 합쳐서든 따로든 mid가 유지되는지는 보지 않네? for node in bottom_up_edge:#리프노드부터 순서대로 방문 if tree[node] < mid: 아 여기서 이미 맨 하단 자식부터 올라온 서브트리의 mid충족을 검사했기 때문에 투포인터 검사하는 자식들은 이미 mid값이 충족된 상태 지금 cpp과 c#은 이형태가 아니니까 나중에 또 머리아프고 싶거나 도전해보고 싶을때 한번 맨땅 구현해보면 좋을 듯 싶다.
김영한님의 자바 ORM 표준 JPA 프로그래밍 강의를 듣고 정리한 글입니다. 실습 환경: PostgreSQL + Hibernate 6(jakarta) 0. 엔티티 매핑이란? JPA가 "이 클래스는 어떤 테이블이고, 이 필드는 어떤 컬럼이야"를 알 수 있도록 어노테이션으로 알려주는 것 입니다. 구분 어노테이션 객체와 테이블 매핑 @Entity , @Table 필드와 컬럼 매핑 @Column 기본 키 매핑 @Id 연관관계 매핑 @ManyToOne , @JoinColumn → 다음 글에서 다룸 1. 객체와 테이블 매핑 @Entity @Entity 가 붙은 클래스는 JPA가 관리하는 엔티티 가 됩니다. 테이블과 매핑할 클래스에는 반드시 붙여야 합니다. 주의사항 규칙 이유 기본 생성자 필수 ( public 또는 protected ) JPA가 리플렉션으로 객체를 만들기 때문 final 클래스, enum , interface , inner 클래스 사용 X 프록시(상속) 객체를 만들 수 없음 저장할 필드에 final 사용 X JPA가 값을 채워 넣을 수 없음 @Entity public class Member { protected Member() {} // JPA용 기본 생성자 public Member(Long id, String name) { this.id = id; this.name = name; } } 💡 기본 생성자를 protected 로 두면 JPA는 쓸 수 있지만 다른 개발자가 무분별하게 new Member() 를 하는 것은 막을 수 있습니다. 실무에서 자주 쓰는 방식입니다. @Entity의 name 속성 @Entity(name = "Member") JPA 내부에서 쓸 엔티티 이름 입니다. (JPQL에서 select m from Member m 의 Member ) 기본값은 클래스 이름 입니다. 다른 패키지에 같은 이름의 클래스가 있는 경우가 아니면 기본값을 그대로 쓰면 됩니다. ⚠️ @Entity(name) 은 테이블 이름이 아닙니다. 테이블 이름은 @Table 로 지정합니다. @Table 엔티티와 매핑할 테이블 을 지정합니다. @Entity @Table(name = "MBR") // member 엔티티를 MBR 테이블에 매핑 public class Member { ... } 속성 기능 기본값 name 매핑할 테이블 이름 엔티티 이름 catalog DB catalog 매핑 schema DB schema 매핑 uniqueConstraints (DDL) DDL 생성 시 유니크 제약 조건 생성 💡 실무에서는 테이블 이름 규칙이 따로 있는 경우가 많습니다. (예: TB_MEMBER ) 이럴 때 클래스 이름은 Member 로 깔끔하게 두고 @Table(name = "TB_MEMBER") 로 연결하면 됩니다. 2. 데이터베이스 스키마 자동 생성 무엇을 해주나? 애플리케이션이 실행될 때 엔티티를 보고 테이블을 자동으로 만들어 줍니다. 테이블을 먼저 만들고 객체를 맞추는 방식 → 객체를 먼저 만들고 테이블이 따라오는 방식 으로 바뀜 방언 을 사용해서 DB에 맞는 DDL을 만들어 줌 이렇게 만든 DDL은 개발 장비에서만 사용합니다. 운영 서버에서는 사용하지 않거나, 생성된 DDL을 다듬어서 사용합니다. hibernate.hbm2ddl.auto 옵션 <property name="hibernate.hbm2ddl.auto" value="create"/> 옵션 설명 create 기존 테이블 삭제 후 다시 생성 (DROP + CREATE) create-drop create와 같지만 종료 시점에 DROP (테스트에서 사용) update 변경분만 반영 ( 운영 DB에 사용하면 안 됨 ) validate 엔티티와 테이블이 정상 매핑되었는지만 확인 none 사용하지 않음 (사실 없는 값이라 아무거나 쓰면 무시됨) 💡 update 는 컬럼 추가만 반영합니다. 필드를 지워도 컬럼은 삭제되지 않습니다. 실수로 운영 데이터가 날아가는 것을 막기 위해서입니다. 💡 validate 로 띄웠는데 매핑이 맞지 않으면 애플리케이션 실행 시 에러가 납니다. 예: 엔티티에는 age 필드가 있는데 테이블에는 컬럼이 없는 경우 방언별로 DDL이 달라진다 같은 String name 필드도 DB마다 다른 타입으로 만들어집니다. DB 생성되는 타입 H2 / PostgreSQL / MySQL varchar(255) Oracle varchar2(255 char) 방언 설정만 바꾸면 DDL이 알아서 바뀝니다. ⚠️ 운영 환경에서 주의 (가장 중요!) 운영 장비에는 절대 create , create-drop , update 를 사용하면 안 됩니다. 환경 권장 옵션 개발 초기 단계 create 또는 update 테스트 서버 update 또는 validate 스테이징, 운영 서버 validate 또는 none 💡 사실 테스트 서버나 개발 서버도 여러 명이 같이 쓰는 DB라면 update 를 쓰지 않는 것이 좋습니다. 수백만 건 테이블에 ALTER가 자동으로 나가면 락이 걸려 서비스가 멈출 수 있습니다. 결국 DDL은 직접 스크립트로 작성해서 검토 후 적용 하는 것이 가장 안전합니다. 자동 생성된 DDL은 그 스크립트를 만들 때 참고용 으로 쓰면 좋습니다. DDL 생성 기능 @Column(nullable = false, length = 10) // not null, varchar(10) private String name; @Table(uniqueConstraints = { @UniqueConstraint(name = "NAME_AGE_UNIQUE", columnNames = {"name", "age"}) }) ⚠️ DDL 생성 기능은 DDL을 만들 때만 사용됩니다. JPA의 실행 로직에는 아무 영향을 주지 않습니다. 즉 length = 10 이라고 써도 JPA가 저장 전에 길이를 검사해 주지 않습니다. 검사는 DB가 합니다. 그래도 엔티티만 보고 제약 조건을 알 수 있어서 문서 역할을 해 주기 때문에 써 두는 것이 좋습니다. 3. 필드와 컬럼 매핑 요구사항 추가 회원은 일반 회원과 관리자 로 구분해야 한다. 회원 가입일과 수정일 이 있어야 한다. 회원을 설명할 수 있는 필드가 있어야 한다. 이 필드는 길이 제한이 없다. 예제 코드 (Hibernate 6 + PostgreSQL 기준) package hellojpa; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity public class Member { @Id private Long id; @Column(name = "name") // 필드명은 username, 컬럼명은 name private String username; private Integer age; @Enumerated(EnumType.STRING) // enum 이름을 저장 private RoleType roleType; private LocalDateTime createdDate; // @Temporal 생략 가능 private LocalDateTime lastModifiedDate; @Column(columnDefinition = "TEXT") // 길이 제한 없는 문자열 (PostgreSQL) private String description; @Transient // DB와 매핑하지 않음 private Integer temp; protected Member() {} } public enum RoleType { USER, ADMIN } 매핑 어노테이션 한눈에 보기 어노테이션 설명 @Column 컬럼 매핑 @Temporal 날짜 타입 매핑 ( java.util.Date 용, 요즘은 거의 안 씀) @Enumerated enum 타입 매핑 @Lob BLOB, CLOB 매핑 @Transient 특정 필드를 컬럼에 매핑하지 않음 (매핑 무시) @Column 속성 설명 기본값 name 매핑할 컬럼 이름 필드 이름 insertable , updatable 등록, 변경 가능 여부. false 면 INSERT/UPDATE 쿼리에서 제외 true nullable (DDL) false 면 not null 제약 조건 생성 true unique (DDL) 한 컬럼에 간단히 유니크 제약 조건 생성 false columnDefinition (DDL) 컬럼 정보를 직접 지정. 예: varchar(100) default 'EMPTY' 자바 타입 + 방언으로 결정 length (DDL) 문자 길이 제약. String 타입에만 사용 255 precision , scale (DDL) BigDecimal , BigInteger 용. 전체 자릿수, 소수점 자릿수 💡 unique = true 는 잘 안 씁니다. 제약 조건 이름이 uk_8a3... 같은 랜덤 값으로 만들어져서, 에러가 났을 때 어떤 제약 조건인지 알아보기 어렵습니다. 이름을 지정할 수 있는 @Table(uniqueConstraints = ...) 를 쓰는 것이 낫습니다. 여러 컬럼을 묶을 수도 있습니다. 💡 updatable = false 는 실무에서 유용합니다. 예를 들어 createdDate 는 한 번 저장되면 바뀌면 안 됩니다. @Column(updatable = false) 를 붙이면 실수로 값을 바꿔도 UPDATE 쿼리에 포함되지 않습니다. 💡 금액은 BigDecimal 을 쓰세요. double , float 은 소수점 계산에서 오차가 생깁니다. ( 0.1 + 0.2 = 0.30000000000000004 ) precision , scale 은 double , float 에는 적용되지 않습니다. @Enumerated 자바 enum을 매핑할 때 사용합니다. 값 저장되는 값 EnumType.ORDINAL (기본값) enum의 순서 (0, 1, 2...) EnumType.STRING enum의 이름 ("USER", "ADMIN") ⚠️ ORDINAL은 절대 쓰면 안 됩니다. 무조건 STRING을 쓰세요. 왜 ORDINAL이 위험할까? // 처음 public enum RoleType { USER, ADMIN } // USER = 0, ADMIN = 1 로 DB에 저장됨 나중에 요구사항이 바뀌어서 GUEST 를 맨 앞에 추가했다고 해 봅시다. // 변경 후 public enum RoleType { GUEST, USER, ADMIN } // GUEST = 0, USER = 1, ADMIN = 2 기존에 0 으로 저장된 회원들은 원래 USER 였는데, 이제는 GUEST 로 읽힙니다. 기존에 1 로 저장된 관리자들은 일반 USER 가 됩니다. 데이터는 그대로인데 의미가 바뀌는 끔찍한 버그 가 생기고, 에러도 나지 않아 찾기도 어렵습니다. STRING 은 공간을 조금 더 쓰지만 이런 문제가 전혀 없습니다. @Temporal → 이제는 LocalDate, LocalDateTime @Temporal 은 예전 날짜 타입( java.util.Date , java.util.Calendar )을 매핑할 때 썼습니다. 값 DB 타입 예 TemporalType.DATE date 2013-10-11 TemporalType.TIME time 11:11:11 TemporalType.TIMESTAMP timestamp 2013-10-11 11:11:11 자바 8부터는 LocalDate , LocalDateTime 을 쓰면 되고, 어노테이션도 필요 없습니다. private LocalDate testLocalDate; // → date private LocalDateTime testLocalDateTime; // → timestamp(6) 💡 Hibernate 6에서는 @Temporal 이 deprecated되었습니다. 새 코드에서는 LocalDate , LocalDateTime 만 쓰면 됩니다. @Lob DB의 BLOB, CLOB 타입과 매핑합니다. 지정할 수 있는 속성이 없습니다. 필드 타입이 문자 면 CLOB, 나머지 는 BLOB으로 매핑됩니다. CLOB: String , char[] , java.sql.Clob BLOB: byte[] , java.sql.Blob ⚠️ PostgreSQL에서는 @Lob String 을 쓰지 마세요! PostgreSQL에는 CLOB 타입이 없습니다. 그래서 Hibernate가 @Lob String 을 oid 타입 (대용량 객체를 별도 저장소에 두고 번호만 저장)으로 만듭니다. 이렇게 되면 DBeaver에서 내용 대신 숫자만 보이고, 조회 시 트랜잭션 관련 에러가 나기도 합니다. PostgreSQL에서 길이 제한 없는 문자열은 TEXT 타입 을 쓰면 됩니다. @Column(columnDefinition = "TEXT") private String description; @Transient @Transient private Integer temp; 필드를 DB에 매핑하지 않습니다. 저장도, 조회도 안 됩니다. 메모리에서만 임시로 값을 들고 있고 싶을 때 사용합니다. 예: 화면에 보여줄 계산 결과, 비밀번호 확인 입력값 등 4. 기본 키 매핑 기본 키 매핑 방법 방법 설명 직접 할당 @Id 만 사용. 개발자가 직접 setId() 자동 생성 @Id + @GeneratedValue 자동 생성 전략은 4가지입니다. 전략 설명 주로 쓰는 DB IDENTITY 기본 키 생성을 DB에 위임 MySQL, PostgreSQL, SQL Server SEQUENCE DB 시퀀스 오브젝트 사용 Oracle, PostgreSQL, H2 TABLE 키 생성용 테이블 을 만들어서 사용 모든 DB AUTO 방언에 따라 자동 선택 (기본값) @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; 💡 PostgreSQL에서 AUTO를 쓰면? Hibernate 6은 SEQUENCE 전략 을 선택하고 member_seq 시퀀스를 만듭니다. IDENTITY 전략 @Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; } PostgreSQL에서는 이렇게 만들어집니다. create table member ( id bigint generated by default as identity, ... ) 특징: persist() 시점에 바로 INSERT가 나간다 영속성 컨텍스트에서 배운 내용을 떠올려 보면 이상한 점이 있습니다. 영속 상태가 되려면 1차 캐시에 @Id 값이 key로 들어가야 합니다. 그런데 IDENTITY는 DB에 INSERT를 해야 ID 값을 알 수 있습니다. 커밋 시점까지 기다리면 1차 캐시에 넣을 key가 없습니다. 그래서 IDENTITY 전략만 예외적으로 em.persist() 시점에 즉시 INSERT를 실행 하고, DB에서 생성된 ID를 받아옵니다. Member member = new Member(); member.setUsername("C"); System.out.println("==========="); em.persist(member); // 여기서 INSERT 쿼리 실행! System.out.println("member.id = " + member.getId()); // 바로 ID 확인 가능 System.out.println("==========="); tx.commit(); 💡 INSERT 후 다시 SELECT를 하는 것은 아닙니다. JDBC가 INSERT 결과로 생성된 키를 돌려주는 기능( getGeneratedKeys )을 씁니다. 단점 : 쓰기 지연이 안 되므로 INSERT를 모아서 보내는 최적화를 할 수 없습니다. 하지만 한 트랜잭션에서 INSERT를 수십 개씩 하는 경우가 아니면 성능 차이는 거의 느낄 수 없습니다. SEQUENCE 전략 시퀀스 는 유일한 값을 순서대로 만들어 주는 DB 오브젝트입니다. (Oracle, PostgreSQL, H2 등) @Entity @SequenceGenerator( name = "MEMBER_SEQ_GENERATOR", sequenceName = "MEMBER_SEQ", // 매핑할 DB 시퀀스 이름 initialValue = 1, allocationSize = 1) public class Member { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR") private Long id; } -- PostgreSQL에서 생성되는 DDL create sequence member_seq start with 1 increment by 1; 동작 방식 em.persist(member); // 1. DB 시퀀스에서 다음 값을 가져옴: select nextval('member_seq') // 2. 그 값을 member.id에 세팅 // 3. 1차 캐시에 저장 (영속) // 4. INSERT는 커밋 시점에! → 쓰기 지연 가능 IDENTITY와 달리 ID를 먼저 받아오고 INSERT는 나중에 하므로 쓰기 지연이 가능합니다. @SequenceGenerator 속성 속성 설명 기본값 name 식별자 생성기 이름 필수 sequenceName DB에 등록된 시퀀스 이름 {엔티티명}_seq (Hibernate 6) initialValue DDL 생성 시에만 사용. 시퀀스 시작 값 1 allocationSize 시퀀스 한 번 호출에 증가하는 수 ( 성능 최적화용 ) 50 catalog , schema DB catalog, schema 이름 ⚠️ allocationSize 기본값이 50입니다! DB 시퀀스가 1씩 증가하도록 설정되어 있다면 반드시 1로 맞춰야 합니다. 안 그러면 ID가 꼬이거나 중복 에러가 날 수 있습니다. allocationSize = 50이 왜 기본값일까? (성능 최적화) 시퀀스 방식은 persist할 때마다 nextval 을 호출하느라 DB에 한 번씩 더 다녀와야 합니다. 회원 100명을 저장하면 nextval 100번 + INSERT 100번입니다. 이걸 줄이는 방법이 allocationSize 입니다. allocationSize = 50 DB 시퀀스: increment by 50 1. 첫 nextval 호출 → DB 시퀀스 1 → 51로 증가 → 애플리케이션 메모리에 "1~50번까지는 내가 써도 된다"고 확보 2. 회원 1~50번까지는 DB 호출 없이 메모리에서 ID 할당 3. 51번째 회원 저장 시 → 다시 nextval 호출 → 51~100번 확보 50번 저장할 때 시퀀스 호출은 한 번 으로 줄어듭니다. 💡 서버가 여러 대여도 괜찮습니다. 서버 A가 1 50을 가져가면 DB 시퀀스는 이미 51이 되어 있습니다. 서버 B는 51 100을 가져갑니다. 겹치지 않습니다. 대신 서버가 재시작되면 메모리에 남아 있던 번호는 버려지므로 ID에 구멍(1, 2, 3, 51, 52...)이 생깁니다. 이건 문제가 되지 않습니다. 💡 너무 크게 잡으면 구멍이 많이 생기니 50~100 정도가 적당합니다. TABLE 전략 키 생성 전용 테이블 을 만들어서 시퀀스를 흉내 내는 전략입니다. 장점 : 모든 DB에서 사용 가능 단점 : 성능 (테이블에 락을 걸고 값을 읽고 갱신해야 함) create table MY_SEQUENCES ( sequence_name varchar(255) not null, next_val bigint, primary key (sequence_name) ) @Entity @TableGenerator( name = "MEMBER_SEQ_GENERATOR", table = "MY_SEQUENCES", pkColumnValue = "MEMBER_SEQ", allocationSize = 1) public class Member { @Id @GeneratedValue(strategy = GenerationType.TABLE, generator = "MEMBER_SEQ_GENERATOR") private Long id; } 속성 설명 기본값 name 식별자 생성기 이름 필수 table 키 생성 테이블명 hibernate_sequences pkColumnName 시퀀스 컬럼명 sequence_name valueColumnName 시퀀스 값 컬럼명 next_val pkColumnValue 키로 사용할 값 이름 엔티티 이름 initialValue 초기 값. 마지막으로 생성
이번 실습에서는 ROS 2의 Topic 통신 방식과 rosbag을 이용한 데이터 기록 및 재생을 실습했다. TurtleSim을 활용하여 Topic을 통해 거북이의 움직임을 제어하고, 해당 Topic의 메시지를 Bag 파일로 저장한 뒤 다시 재생해 보았다. 1. ROS 2 Topic이란? ROS 2에서 Topic은 노드(Node) 사이에서 메시지를 주고받기 위한 통신 방식이다. Topic 통신에서는 다음과 같은 역할로 구분된다. Publisher: Topic에 메시지를 발행하는 노드 Subscriber: Topic을 구독하여 메시지를 전달받는 노드 Topic: Publisher와 Subscriber 사이에서 메시지가 전달되는 통신 경로 Message: Topic을 통해 실제로 전달되는 데이터 하나의 노드는 Publisher와 Subscriber 역할을 동시에 수행할 수도 있으며, 여러 개의 Topic을 사용하여 다른 노드와 통신할 수도 있다. Topic 통신 구조 Publisher Node │ │ Message ▼ Topic │ ▼ Subscriber Node TurtleSim에서는 거북이의 속도와 방향을 제어할 때 다음 Topic을 사용한다. /turtle1/cmd_vel 해당 Topic의 메시지 타입은 다음과 같다. geometry_msgs/msg/Twist Twist 메시지는 선속도와 각속도 정보를 전달하여 거북이의 이동 및 회전을 제어한다. 2. ROS 2 Topic 확인 현재 실행 중인 Topic 목록과 메시지 타입은 다음 명령어로 확인할 수 있다. ros2 topic list -t 예를 들어 TurtleSim을 실행한 상태에서는 다음과 같이 /turtle1/cmd_vel Topic을 확인할 수 있다. /turtle1/cmd_vel [geometry_msgs/msg/Twist] 또한 rqt_graph 를 이용하면 현재 실행 중인 노드와 Topic 사이의 연결 관계를 그래프로 확인할 수 있다. rqt_graph 이를 통해 어떤 노드가 Topic을 발행하고 어떤 노드가 구독하고 있는지 시각적으로 확인할 수 있다. 3. Topic 직접 발행하기 ros2 topic pub 명령어를 사용하면 특정 Topic에 직접 메시지를 발행할 수 있다. 한 번만 메시지 발행 ros2 topic pub --once /turtle1/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 2.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 1.8}}" --once 옵션을 사용했기 때문에 메시지를 한 번만 발행한다. 이때 linear.x 는 전진 속도를, angular.z 는 회전 속도를 의미한다. 따라서 TurtleSim에서는 앞으로 이동하면서 회전하는 움직임이 나타난다. 일정한 주기로 메시지 발행 ros2 topic pub --rate 1 /turtle1/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 2.0, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 1.8}}" --rate 1 을 사용하면 초당 1번씩 동일한 메시지를 지속적으로 발행한다. 이를 통해 Topic에 메시지를 지속적으로 발행하면서 TurtleSim의 움직임이 계속 제어되는 것을 확인했다. 4. ROS 2 Bag이란? rosbag은 ROS에서 발생하는 Topic 메시지를 기록하고 나중에 다시 재생할 수 있도록 저장하는 기능이다. 실제 로봇에서는 센서 데이터나 제어 데이터를 기록해 두었다가 나중에 동일한 데이터를 다시 재생하면서 테스트하거나 분석할 수 있다. ROS 2에서는 다음과 같은 ros2 bag 명령어를 사용한다. ros2 bag record ros2 bag info ros2 bag play 각 명령어의 역할은 다음과 같다. 명령어 기능 ros2 bag record Topic 메시지를 기록 ros2 bag info 기록된 Bag 정보 확인 ros2 bag play 기록된 메시지를 다시 재생 5. Topic 메시지 기록하기 TurtleSim의 이동 명령이 전달되는 /turtle1/cmd_vel Topic을 기록했다. ros2 bag record /turtle1/cmd_vel 이 상태에서 TurtleSim을 움직이면 /turtle1/cmd_vel 을 통해 전달되는 메시지가 Bag 파일에 저장된다. 실습에서는 약 93초 동안 데이터를 기록했으며, 총 28개의 메시지가 저장되었다. 6. 기록된 Bag 정보 확인 기록이 끝난 후 다음 명령어를 사용하여 Bag의 정보를 확인했다. ros2 bag info rosbag2_2026_10_01-10_19_24/ 확인 결과 다음과 같이 기록된 것을 확인할 수 있었다. Duration: 93.717029682s Messages: 28 Topic information: Topic: /turtle1/cmd_vel Type: geometry_msgs/msg/Twist Count: 28 이를 통해 /turtle1/cmd_vel Topic의 geometry_msgs/msg/Twist 메시지가 정상적으로 기록된 것을 확인했다. 7. Bag 데이터 재생하기 기록한 Bag 파일은 ros2 bag play 명령어를 이용하여 다시 재생할 수 있다. ros2 bag play rosbag2_2026_10_01-10_19_24/ Bag을 재생하면 저장되어 있던 /turtle1/cmd_vel 메시지가 다시 TurtleSim으로 전달된다. 실습 결과 TurtleSim에서 기록 당시 거북이가 이동했던 경로가 다시 재현되는 것을 확인했다. 실습 화면에서는 ros2 bag play 를 통해 Bag 파일을 정상적으로 재생하고 있으며, TurtleSim 화면에서 저장된 이동 명령에 따라 거북이가 움직인 경로를 확인할 수 있다. 마무리 이번 실습을 통해 ROS 2에서 노드 간 통신에 사용되는 Topic의 기본 개념과 Publisher/Subscriber 구조를 확인했다. 또한 /turtle1/cmd_vel Topic에 Twist 메시지를 직접 발행하여 TurtleSim을 제어하고, 해당 Topic의 메시지를 ROS 2 Bag으로 기록한 후 다시 재생하는 과정까지 실습했다. 정리하면 다음과 같다. Topic → 노드 간 메시지 통신 ros2 topic pub → Topic에 메시지 직접 발행 ros2 bag record → Topic 메시지 저장 ros2 bag info → 저장된 Bag 정보 확인 ros2 bag play → 저장된 메시지 재생 이번 실습을 통해 ROS 2의 Topic 통신과 데이터 기록·재생 과정을 직접 확인할 수 있었다.
팀 프로젝트 잇다(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의 오래된 특이사항