1. Big-M Big-M은 특정 조건에서만 제약식을 활성화하기 위해 사용하는 큰 값이다. 예를 들어, x = 1일 때 제약 적용 x = 0일 때 제약 완화 와 같은 조건을 MILP에서 표현할 때 사용한다. 주의 Big-M을 너무 크게 설정하면 LP Relaxation 약화 → Solver 성능 저하 → MIP Gap 증가 가 발생할 수 있다. 따라서 가능한 범위에서 작고 타이트한 M 값 을 사용하는 것이 중요하다. 2. Feasible Solution과 Optimal Solution Feasible Solution 모든 제약조건을 만족하는 해. Optimal Solution Feasible Solution 중 목적함수가 가장 좋은 해. Feasible → 조건을 만족하는 해 Optimal → 조건을 만족하면서 가장 좋은 해 Solver는 먼저 feasible solution을 찾고, 이후 더 좋은 해가 존재하는지 계속 탐색한다. 3. Incumbent, Best Bound, MIP Gap Gurobi 로그에서 중요하게 확인한 개념이다. Incumbent 현재까지 발견한 가장 좋은 feasible solution . Best Bound 아직 탐색하지 않은 영역까지 고려했을 때 최적해가 가질 수 있는 이론적 경계값. MIP Gap Incumbent와 Best Bound 사이의 차이를 나타낸다. Gap ↓ → 현재 해가 최적해에 가까워짐 기억할 것 Gap이 크다 ≠ Infeasible 예를 들어 Gap이 92%라는 것은 해가 없다는 의미가 아니라, 현재 해와 최적해의 경계 사이 차이가 아직 크다는 의미 다. 4. Branch-and-Bound MILP Solver가 최적해를 찾는 대표적인 방법이다. 문제를 작은 문제로 분할 → 각 영역의 Bound 계산 → 가능성 없는 영역 제거 → 좋은 해가 존재할 영역 탐색 Gurobi 로그 주요 항목 Incumbent : 현재 가장 좋은 해 BestBd : 최적해의 Bound Gap : Incumbent와 BestBd의 차이 Expl : 탐색 완료한 Node Unexpl : 아직 탐색하지 않은 Node Solver는 단순히 좋은 해를 찾는 것뿐 아니라 그 해보다 더 좋은 해가 존재하지 않는다는 것까지 증명해야 Optimal 이 된다. 5. Exact Method와 Heuristic Exact Method 최적해를 보장할 수 있는 방법. 예: MILP Branch-and-Bound 장점: Optimality 보장 가능 단점: 문제 규모 증가 → 계산시간 급증 Heuristic / Metaheuristic 최적해 보장보다는 제한시간 안에 좋은 해를 빠르게 찾는 방법. 예: GA ALNS SA 2-opt 6. Genetic Algorithm GA는 생물의 진화 과정을 모방한 탐색 알고리즘이다. 초기해 생성 → 선택 → 교차 → 돌연변이 → 새로운 해 생성 한계 좋은 해를 빠르게 찾을 수 있지만 탐색이 특정 영역에 집중되면 Local Optimum 에 빠질 수 있다. 7. ALNS Adaptive Large Neighborhood Search 현재 해의 일부를 크게 제거한 뒤 다시 복구하면서 새로운 해를 탐색한다. 현재 해 ↓ Destroy ↓ 일부 고객 제거 ↓ Repair ↓ 고객 재삽입 ↓ 새로운 해 평가 사용한 Destroy Operator Worst Removal Route Removal Worst Removal 현재 해에서 비용에 큰 영향을 주는 고객을 제거한다. Route Removal 특정 Route의 고객들을 제거해 경로 구조를 크게 변경한다. 8. Repair Operator Greedy Insertion 고객을 삽입했을 때 비용 증가가 가장 작은 위치 에 삽입한다. Regret-2 고객의 두 번째로 좋은 삽입 비용 - 가장 좋은 삽입 비용 을 계산한다. 이 차이가 큰 고객을 먼저 삽입한다. 핵심 지금 삽입하지 않으면 나중에 비용이 크게 증가할 고객을 우선 처리 9. Simulated Annealing ALNS에서 새로운 해를 받아들일지 결정할 때 사용할 수 있는 방법이다. 더 좋은 해 → Accept 더 나쁜 해 → 일정 확률로 Accept 이유 좋은 해만 계속 선택하면 Local Optimum에 빠질 수 있다. 나쁜 해를 일정 확률로 받아들여 현재 지역을 벗어나 더 넓은 영역을 탐색 할 수 있다. 10. 2-opt Route 내부의 두 연결을 끊고 경로 순서를 변경하여 더 짧은 경로를 찾는 Local Search 방법이다. 기존 경로 A-B-C-D 일부 연결 변경 A-C-B-D 차량 경로를 부분적으로 개선할 때 자주 사용하는 기본적인 Local Search 기법이다. 11. 최적화 문제의 크기 MILP에서는 변수 수와 제약식 수가 증가하면 계산량도 크게 증가한다. 특히 차량, 고객, Dock, Time Slot 등의 조합이 많아지면 문제 규모 증가 → 변수 및 제약식 증가 → 탐색 공간 증가 → Solver 계산시간 증가 가 발생한다. 핵심 모델을 만들 때는 정확한 모델 + 계산 가능한 모델 을 함께 고려해야 한다. 12. Instance와 모델 검증 처음부터 큰 데이터를 사용하는 것보다 작은 Instance부터 모델을 검증하는 것이 중요하다. 소규모 Instance → 모델 동작 검증 → Optimal Solution 확인 → 데이터 규모 확대 → 계산시간 및 Gap 확인 → 필요 시 Heuristic 적용 중요한 점 코드가 실행된다 ≠ 모델이 올바르다 결과가 실제로 의도한 제약과 운영 방식을 만족하는지 직접 확인해야 한다. 9월 최적화 공부 핵심 흐름 수리모델 구축 ↓ 소규모 Instance 검증 ↓ Gurobi로 Exact Solution 탐색 ↓ Incumbent / Best Bound / MIP Gap 확인 ↓ 문제 규모 증가 ↓ 계산시간 증가 ↓ Heuristic / Metaheuristic 필요 ↓ GA / ALNS / SA / Local Search 적용 핵심은 최적해를 찾는 것뿐 아니라, 문제 규모와 제한시간을 고려해 적절한 해결 방법을 선택하는 것 이다.
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 옵션 을 학습과 서빙에서 일치시킨다. 평가는 안정성·목표 능력·기존 능력·사람 판단 네 갈래로 나눈다.