ROS 2에서는 노드 간 데이터를 주고받기 위해 Topic, Service, Action 등의 통신 방식을 사용한다. 이때 통신 과정에서 어떤 형태의 데이터를 주고받을 것인지 정의하는 것이 ROS Interface(인터페이스)다. ROS 2에서는 주로 다음과 같은 인터페이스를 사용한다. msg : Topic에서 사용 srv : Service에서 사용 action : Action에서 사용 1. ROS 인터페이스란? ROS 인터페이스는 노드 간 통신에 사용되는 데이터의 구조와 형태를 정의한 것이다. 정수(integer), 부동 소수점(float), Boolean과 같은 기본 자료형을 사용할 수 있으며, 하나의 메시지 안에 다른 메시지를 포함하거나 배열 형태의 데이터도 사용할 수 있다. 예를 들어 geometry_msgs/msg/Twist 에서는 Vector3 형태의 데이터를 linear , angular 라는 이름으로 포함하고 있다. Vector3 linear Vector3 angular 그리고 Vector3 는 다시 다음과 같이 x , y , z 값을 가진다. float64 x float64 y float64 z 즉, 인터페이스는 단순한 하나의 데이터뿐만 아니라 여러 데이터를 하나의 구조로 묶어서 전달할 수도 있다. 2. Message Interface (msg) msg 인터페이스는 Topic에서 사용하는 데이터 형식이다. 파일 확장자는 다음과 같다. *.msg 우리가 앞서 TurtleSim 실습에서 사용했던 /turtle1/cmd_vel Topic이 대표적인 예이다. /turtle1/cmd_vel 이 Topic은 다음과 같은 인터페이스를 사용한다. geometry_msgs/msg/Twist Twist 는 다음과 같이 구성되어 있다. Vector3 linear Vector3 angular linear 는 선속도, angular 는 각속도에 대한 정보를 나타내며 각각 x , y , z 값을 포함한다. 따라서 앞에서 실습했던 다음 명령어에서도 Twist 인터페이스를 사용한 것이다. ros2 topic pub --once /turtle1/cmd_vel geometry_msgs/msg/Twist ... 3. Service Interface (srv) srv 인터페이스는 Service에서 사용하는 데이터 형식이다. 파일 확장자는 다음과 같다. *.srv Service는 Topic과 달리 요청(Request)을 보내고 응답(Response)을 받는 구조로 동작한다. 예를 들어 TurtleSim의 /spawn 서비스에서는 다음과 같은 Spawn.srv 인터페이스를 사용한다. float32 x float32 y float32 theta string name --- string name 여기서 --- 는 Request와 Response를 구분하는 구분자이다. Request ├─ x ├─ y ├─ theta └─ name ↓ Service ↓ Response └─ name 인터페이스의 실제 구조는 다음 명령어로 확인할 수 있다. ros2 interface show turtlesim/srv/Spawn.srv 4. Action Interface (action) action 인터페이스는 Action에서 사용하는 데이터 형식이다. 파일 확장자는 다음과 같다. *.action Action은 하나의 작업을 요청하고 결과를 기다리는 것뿐만 아니라, 작업이 수행되는 동안 Feedback을 전달할 수 있는 구조를 가진다. 기본적인 구조는 다음과 같다. Goal --- Result --- Feedback 즉, Goal: 수행할 작업의 목표 Result: 작업이 완료된 후의 결과 Feedback: 작업 수행 중 전달되는 진행 상황 으로 구성된다. 5. msg / srv / action 비교 구분 Topic Service Action 인터페이스 msg srv action 확장자 .msg .srv .action 데이터 구조 Data Request / Response Goal / Result / Feedback 특징 지속적인 데이터 전달 요청에 대한 응답 장시간 작업 및 진행 상황 전달 정리하면 msg , srv , action 은 각각 Topic, Service, Action에서 사용되는 데이터 구조를 정의한다. 특히 --- 구분자를 기준으로 보면 차이를 쉽게 이해할 수 있다. msg Data srv Request --- Response action Goal --- Result --- Feedback 6. 인터페이스 확인 명령어 ROS 2에서는 ros2 interface show 명령어를 이용하여 특정 인터페이스의 구조를 확인할 수 있다. 예를 들어: ros2 interface show geometry_msgs/msg/Twist 또는: ros2 interface show turtlesim/srv/Spawn 와 같이 사용할 수 있다. 이를 통해 실제로 어떤 자료형과 필드가 인터페이스에 정의되어 있는지 확인할 수 있다. 마무리 ROS 2의 인터페이스는 노드 간 통신에서 주고받는 데이터의 구조를 정의하는 역할을 한다. Topic ↓ msg ↓ Data Service ↓ srv ↓ Request → Response Action ↓ action ↓ Goal → Result + Feedback 앞서 실습한 TurtleSim의 /turtle1/cmd_vel 역시 geometry_msgs/msg/Twist 라는 인터페이스를 사용한다. 따라서 ROS 2에서 Topic, Service, Action을 이해하기 위해서는 각각 어떤 인터페이스를 사용하고, 그 인터페이스가 어떤 데이터 구조를 가지고 있는지 함께 이해하는 것이 중요하다.
1. 컴퓨터 구조를 알아야 하는 이유 실력 있는 개발자가 되려면 반드시 알아야 할 기본 지식이기 때문이다. 컴퓨터 구조를 이해하고 있다면 문제 상황 을 빠르게 진단할 수 있고 문제 해결 의 실마리를 다양하게 찾을 수 있기 때문이다. 프로그래밍 언어 문법만으로는 알기 어려운 성능/용량/비용 을 고려하며 개발할 수 있기 때문이다. 2. 컴퓨터 구조 지식의 큰 틀 알아야 할 컴퓨터 구조 지식은 크게 두 가지이다. 컴퓨터가 이해하는 정보 컴퓨터의 4가지 핵심 부품 01) 컴퓨터가 이해하는 정보 컴퓨터는 0과 1로 표현된 정보만을 이해한다. 이렇게 0과 1로만 표현되는 정보에는 크게 두 종류가 있는데, 바로 데이터 와 명령어 이다. 컴퓨터 한마디 정의 : "컴퓨터는 명령어를 처리하는 기계이다." 02) 컴퓨터의 4가지 핵심 부품 중앙처리장치(CPU : Central Processing Unit) 주기억장치(Main Memory, 메모리) 보조기억장치(Secondary Storage) 입출력장치(I/O Device) 이 4가지 핵심 부품들은 모두 메인보드 에 연결되어지고 시스템 버스 를 통해 서로 정보를 주고 받는다.
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: 예측된 뇌 반응과 실제 관객 반응을 비교해봤습니다