日本破獲境內北韓筆電農場,揭露北韓IT工作者遠端接案模式
iThome 新聞
日本警方近日破獲境內首起北韓「筆電農場」(Laptop Farm),發現北韓協助者在日本境內架設電腦及伺服器,再由境外的北韓IT工作者遠端操作,藉此隱藏實際工作地點。日本警察廳表示,相關調查發現已有數億日圓、包括加密貨幣資產被轉往海外。
Score: 55.7Confidence: 54%
View offerLoading the catalog…
THE AI OPPORTUNITY INDEX
Find your next AI tool. Explore free access, trials, and credits — all in one place.
iThome 新聞
日本警方近日破獲境內首起北韓「筆電農場」(Laptop Farm),發現北韓協助者在日本境內架設電腦及伺服器,再由境外的北韓IT工作者遠端操作,藉此隱藏實際工作地點。日本警察廳表示,相關調查發現已有數億日圓、包括加密貨幣資產被轉往海外。
Score: 55.7Confidence: 54%
View offervelog
Đèn LED nhà xưởng VINALED được ứng dụng rộng rãi trong nhiều lĩnh vực sản xuất và lưu trữ hàng hóa. Ánh sáng chất lượng cao giúp cải thiện khả năng quan sát, hỗ trợ quá trình vận hành và quản lý kho hiệu quả hơn. Sản phẩm có nhiều lựa chọn công suất và kiểu dáng phù hợp với từng quy mô công trình khác nhau. Mua đèn LED nhà xưởng VINALED chính hãng tại: https://vinaled.com/den-chieu-sang-trong-nha/den-led-nha-xuong-den-highbay #denLEDnhaxuong #dennhaxuong #denhighbay #denledhighbay #VINALED
Score: 54.4Confidence: 49%
View offervelog
인코딩(Encoding) 인코딩이란 사람이 이해할 수 있는 형태의 정보를 컴퓨터나 시스템이 이해할 수 있는 표현법을 따르는 데이터로 변환하는 과정을 의미한다. 이 반대의 과정을 디코딩(Decoding)이라고 하고, 다양한 표현법이 있지만 여기에서는 파이썬으로 ASCII, Hex, Base64를 다루려고 한다. 암호 공부하다가 인코딩을 다루는 이유는 암호 문제에서는 같은 데이터가 여러 형태로 표현되는 일이 빈번하기 때문이다. 파이썬에서 ASCII 변환하기 아스키 코드는 문자나 기호를 7비트 숫자로 표현한다. chr(97) # 결과:'a' : ASCII -> 문자 ord('a') # 결과:97 : 문자 -> ASCII 파이썬에서 Hex 변환하기 Hex는 그냥 16진수(0~F)이다. 한 자리가 4비트이기 때문에 두 자리는 1바이트 크기로, RGB 색상 표현에도 사용되고 데이터 값 표현이 압축된다는 장점이 있다. bytes.fromhex("48656c6c6f") # 결과:b'Hello' : Hex -> Byte b"Hello".hex() # 결과:48656c6c6f : Byte -> Hex 파이썬에서 Base64 변환하기 Base64는 한 자리가 6bit로, 4자리가 3바이트 크기이다. a-z 26개, A-Z 26개, 0-9 10개에 +와 / 두 개를 더한 ASCII 문자 64개를 사용한다. 이미지나 파일 같은 binary 데이터를 텍스트 기반 환경에서 전달할 때 사용한다. 입력이 3바이트 단위로 딱 떨어지지 않으면 패딩(=)을 붙여야 된다. = 하나는 두 바이트로만 디코딩된다는 뜻이고, == 는 한 바이트로만 디코딩된다는 뜻이다. 예를 들어 "Hello"(5바이트) 입력이라면 마지막에 = 하나가 붙는다. import base64 base64.b64encode(b"Hello") # Byte -> Base64 파이썬에서 Hex에서 10진수로 변환하기 from Crypto.Util.number import bytes_to_long, long_to_bytes bytes_to_long(b'Hello') # Byte -> Integer long_to_bytes(310939249775) # Integer -> Byte 문제에서는 원래 메시지가 문자열 형태가 아니라 하나의 매우 큰 정수로 주어진다. XOR XOR은 암호 문제에서 단골 bitwise 연산이다. 두 bit가 같으면 0, 다르면 1이다. 문자끼리 XOR할 수는 없기 때문에 먼저 문자를 정수로 변환하는 과정이 필요하다. 예: chr(ord('A') ^ 13) Self-inverse 성질이 있다. (자기 자신과 XOR하면 0) 예: (M ⊕ K) ⊕ K = M 이 특성은 암호화 과정에서 매우 많이 사용되기 때문에 필수로 알아둬야 한다. 유용한 함수 # 바이트로 표현된 크기가 같은 a,b를 XOR 연산하는 함수 def xor_bytes(a, b): return bytes(x ^ y for x, y in zip(a, b)) # 반복되는 key로 data와 XOR 연산하는 함수 def xor_with_repeating_key(data: bytes, key: bytes) -> bytes: result = [] for i, byte in enumerate(data): # key의 인덱스를 돌려가며 XOR 연산 result.append(byte ^ key[i % len(key)]) return bytes(result) 실제로 문제풀이에서 알차게 써먹은 함수들이다. Brute Force 가능한 key의 수가 작다면 key를 모르더라도 모든 값을 하나씩 시도하면서 찾아나갈 수 있다. 이렇게 가능한 모든 조합을 무차별적으로 시도하여 암호를 해독하는 공격을 Brute Force라고 한다. # 1-byte key (256가지 경우의 수) 조건이라면 for key in range(256): # 가능한 Key를 전부 돌려보기 result = bytes(b ^ key for b in ciphertext) # 이미 알고 있는 FLAG 형식으로 걸러내기 if result.startswith(b"crypto{"): print("key =", key) print("result =", result) 코드 블럭에서 if문을 사용한 것처럼, 실제 문제에서는 알고 있는 평문의 특징을 이용해 후보를 걸러내면 훨씬 편리해진다.
Score: 54.39Confidence: 49%
View offervelog
2026.10.05 문제 풀이 나의 코드 소요 시간: 12분 시간 복잡도: $O(√V)$ class Solution { public boolean isPrime(long n) { if (n <= 1) { return false; } for (int i = 2; i <= Math.sqrt(n); i++) { if (n % i == 0) { return false; } } return true; } public int solution(int n, int k) { int cnt = 0; String s = Integer.toString(n, k); // n을 k진수로 변환 StringBuilder sb = new StringBuilder(); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (c == '0') { if (!sb.isEmpty() && isPrime(Long.parseLong(sb.toString()))) { cnt++; } sb.setLength(0); } else { sb.append(c); } } if (!sb.isEmpty() && isPrime(Long.parseLong(sb.toString()))) { cnt++; sb.setLength(0); } return cnt; } } AI 코드 시간 복잡도: $O(√V)$ 코드 분석 split("0") 으로 미리 숫자 부분을 분리했다. 소수 판별에서는 2와 3으로 나눠지는 수를 미리 판별하고, 6k + 1 로 나눠지는 수에 대해서만 검사를 진행했다. class Solution { public int solution(int n, int k) { int cnt = 0; for (String part : Integer.toString(n, k).split("0")) { if (!part.isEmpty() && isPrime(Long.parseLong(part))) cnt++; } return cnt; } private boolean isPrime(long v) { if (v < 2) return false; if (v < 4) return true; if (v % 2 == 0 || v % 3 == 0) return false; for (long i = 5; i * i <= v; i += 6) { if (v % i == 0 || v % (i + 2) == 0) return false; } return true; } } 문제 풀이 후기 이제부터 1주일이라는 일정 기간을 두고 이전 주에 틀렸던 문제들을 복습하는 시간을 가지기로 했다. 적당히 기억나면서도 적당히 기억나지 않는 이 타이밍에 내가 이 문제에 대해 잘 기억하고 있나 확인하기 위해서이다.
Score: 54.39Confidence: 49%
View offervelog
Overlapped 확장 구조체 IOCP에서 WSARecv()나 WSASend()를 비동기로 호출하면 함수는 I/O가 끝날 때까지 기다리지 않고 바로 반환된다. 그래서 나중에 I/O가 완료되었을 때, 이 작업이 Recv였는지 Send였는지 어떤 버퍼를 사용했는지 어떤 작업의 완료인지 를 다시 알아낼 방법이 필요하다. Windows API(WSARecv, GetQueuedCompletionStatus)는 WSAOVERLAPPED 구조체의 주소만 안다. Windows에서는 이를 위해 WSAOVERLAPPED 구조체를 사용한다. typedef struct _WSAOVERLAPPED { ULONG_PTR Internal; ULONG_PTR InternalHigh; union { struct { DWORD Offset; DWORD OffsetHigh; }; PVOID Pointer; }; WSAEVENT hEvent; } WSAOVERLAPPED; 하지만 WSAOVERLAPPED 자체에는 우리가 필요한 Recv/Send 작업 종류, 버퍼 같은 정보가 없다. 그래서 실제 IOCP 서버에서는 WSAOVERLAPPED를 포함하는 별도의 구조체를 만들어 사용하는 경우가 많다. enum IO_OPERATION { IO_RECV, IO_SEND, IO_ACCEPT }; struct OverlappedContext { // 반드시 첫 번째 멤버로 배치 WSAOVERLAPPED overlapped; // 개발자가 필요한 추가 정보 IO_OPERATION operation; WSABUF wsaBuf; char buffer[1024]; }; 왜 WSAOVERLAPPED를 첫 번째 멤버로 둘까? OverlappedContext의 시작 주소(0x1000)와 첫 번째 멤버인 overlapped의 시작 주소(0x1000)는 일치한다. OS에 넘길 때는 &overlapped(0x1000)를 넘겨주고 나중에 OS에서 돌려받을 때는 OverlappedContext*)0x1000으로 형변환(Casting)해서 내부의 변수 데이터들을 불러오는 방식을 사용한다. 코드 struct IocpEvent // 또는 OverlappedEx { WSAOVERLAPPED overlapped = {}; // [0바이트 위치] 반드시 맨 앞에 둔다 EventType eventType; // Recv / Send 등의 구분값 std::shared_ptr<IocpObject> owner; // 이 작업을 요청한 주인(세션) }; OverlappedContext 와 같은 확장 구조체이다. 코드를 보면 owner 라는 변수가 있다. 비동기 작업은 요청한 시점과 완료되는 시점에 차이가 있다. 왜 shared_ptr<IocpObject> owner 를 보관할까? 만약 유저가 비동기 수신(WSARecv) 중에 갑자기 랜선을 뽑아버리고 메인 스레드가 해당 세션을 메모리에서 delete해 버린다면? 뒤늦게 완료 통지가 왔을 때 워커 스레드는 이미 삭제된 세션 메모리에 접근하여 서버가 크래시(Crash) 발생한다. void Session::RegisterRecv() { // 1. OVERLAPPED 메모리를 깨끗하게 0으로 초기화 _recvEvent.Init(); // 2. 비동기 작업이 끝날 때까지 내 자신이 파괴되지 않도록 소유권 보관 (Ref Count +1) _recvEvent.owner = shared_from_this(); WSABUF wsaBuf; wsaBuf.buf = _recvBuffer; wsaBuf.len = sizeof(_recvBuffer); DWORD numOfBytes = 0; DWORD flags = 0; // 3. OS에게 비동기 수신 요청! (내 확장 구조체의 맨 앞 주소를 넘김) ::WSARecv(_socket, &wsaBuf, 1, &numOfBytes, &flags, &_recvEvent.overlapped, nullptr); } 이를 막기 위해 비동기 요청을 걸 때 owner = shared_from_this()로 참조 카운트를 1 올려서 OS 작업이 끝날 때 까지 객체가 죽지 않게 붙잡아 둔다. 전체 동작 흐름 정리 IOCP에서 OVERLAPPED는 단순히 비동기 작업 하나를 식별하는 중요한 단서가 된다. 하지만 기본 OVERLAPPED만으로는 해당 작업이 Recv, Send, Accept 중 무엇인지, 어떤 객체가 요청한 작업인지 알기 어렵다. 따라서 실제 서버에서는 OVERLAPPED를 포함하는 확장 구조체를 만들어 추가 정보를 함께 관리한다. IocpEvent ├─ WSAOVERLAPPED → Windows가 사용 ├─ EventType → 어떤 I/O인지 구분 └─ owner → 작업을 요청한 객체 관리 비동기 작업을 요청할 때는 OVERLAPPED 만 OS에 전달하고, 완료 시 돌려받은 OVERLAPPED 를 다시 IocpEvent*로 해석하여 필요한 정보를 복구한다.
Score: 54.38Confidence: 49%
View offervelog
증상 시뮬레이션으로 카메라 영상으로 물체 위치를 파악하고 보드를 잡아 옮기는 작업을 반복하였을 때 ArUco 마커가 있는 위치까지의 도달은 문제가 없었다. 하지만 물체를 집어서 옮기고 내려 놓는 작업을 반복할 때 처음에는 그리퍼가 대상물을 잘 잡아서 옮기는 것으로 보였지만 반복해서 실행시키다 보면 그리퍼가 물체를 잡지 못하였는데 완료로 판단하거나 물체를 잡을 때 그리퍼가 매우 느리게 움직이거나 하는 이상한 동작이 발생하였다. 그리퍼 동작 테스트 처음에는 물리 엔진에서 내가 모르는 마찰이나 충돌이 발생하는 것으로 생각했었다. 그래서, 테스트를 위해서 보드를 잡기 위해 하강하기 직전 위치의 허공에서 그리퍼를 열었다 닫는 작업을 수행시켰다. 예상 했던 것과는 달리 공중에서도 그리퍼를 다 열거다 닫지 않았는데 작업이 완료되거나, 지정한 값까지만 그리퍼를 닫아야 하는데 다 닫혀버리거나, 아예 움직이지 않는 상황 등 예기치 못했던 상황들이 발생하였다. 즉, 다른 물체와의 충돌이나 마찰이 없어도 발생한다는 것이었다. Gripper URDF 인자 변경 arm_gripper.urdf.xacro 파일에서 effort, velocity, lower 값을 바꿔가면서 테스트를 해 보았다. 그리퍼가 움직이지 못하고 멈추는 것을 보고 effort를 높여 보았다. 그랬더니 그리퍼의 손가락이 서로 충돌해서 튕겨 나가고 끝에서 부딛혀 다시 되돌아 오는 증상이 발생하였다. 너무 빠르게 움직여서 문제가 생기는 것으로 예상하고 velocity 를 낮춰 보았다. 이제는 손가락이 튕겨서 열렸다 닫히는 현상은 사라졌지만 손가락이 움직이지 못해서 그리퍼가 닫히지 않는 현상이 발생하였다. 단순히 인자를 변경하는 것으로는 해결되지 않을 것으로 판단되었다. URDF에서 SDF로의 변환 확인 런치 파일 arm_study_bringup.launch.py는 xacro로 만든 URDF를 그대로 Gazebo에 넘기고, Gazebo가 이것을 SDF로 변환해서 읽는다. 동일한 변환을 직접 실행하여 mimic joint 부분을 확인하였다. xacro src/arm_description/urdf/ur5e.urdf.xacro ur_type:=ur5e \ controllers_yaml:=install/arm_bringup/share/arm_bringup/config/arm_controllers.yaml \ > /tmp/arm.urdf gz sdf -p /tmp/arm.urdf > /tmp/arm.sdf 변환된 SDF의 finger2_joint 에는 <axis> 아래에 <mimic> 이 들어 있었다. <axis> <xyz>0 1 0</xyz> <mimic joint='finger1_joint'> <multiplier>1</multiplier> <offset>0</offset> <reference>0</reference> </mimic> Gazebo 물리 엔진 테스트 원래 bullet-featherstone을 사용한 이유는 Gazebo의 기본 물리 엔진이 mimic joint를 지원하지 않는다고 하여서 방법을 찾던 중에 https://github.com/ros-controls/gz_ros2_control/issues/340 링크의 글을 읽고 적용한 것이다. 이 물리 엔진은 launch 파일에서 직접 인자로 넘겨 주는데 이 엔진을 지우면 어떻게 될까하는 생각에 인자로 넘겨주던 엔진을 삭제하고 시뮬레이션을 실행해 보았다. 그랬더니, 여전히 그리퍼가 비슷하게 동작을 한다!!!? 가제보 로그를 살펴 보니 다음과 같은 로그가 출력되고 있었다. [gazebo-10] [INFO] [1791162200.186989363] [gz_ros_control]: Joint 'finger2_joint'is mimicking joint 'finger1_joint' with multiplier: 1 and offset: 0 [gazebo-10] [Err] [Physics.cc:1808] Attempting to create a mimic constraint for joint [finger2_joint] but the chosen physics engine does not support mimic constraints, so no constraint will be created. 즉, gz_ros_control이 <mimic> 태그를 읽어서 뭔가를 하고 있다는 말이다. 소스 코드에 해당 부분을 찾아보니 다음과 같은 부분이 있다. gz_system.cpp double position_error = position_mimic_joint - position_mimicked_joint * mimic_joint.multiplier; double velocity_sp = (-1.0) * position_error * this->dataPtr->update_rate; 위치 오류에 비례해서 속도 명령을 계산하고 있다. 결론적으로 mimic joint에 bullet-featherstone 의 물리 제약과 gz_ros2_control 의 속도 명령이 동시에 실행되고 있었던 것이다. ros2_control의 개입을 막음 둘 중 하나를 없애야 하는데 Gazebo의 로그에서 보듯이 기본 물리 엔진인 dartsim 이 mimic 제약을 지원하지 않는다는 내용이 있다. 실제 시뮬레이션 화면에서도 그리퍼 동작은 어색하다. 앞서 언급했던 github issue 내용이 맞다는 뜻이므로 물리 제약을 남기고 ros2_control 쪽이 개입하지 않도록 해야 한다. ros2_control 문서 에 방법이 있다. If someone wants to deactivate the mimic joint behavior for whatever reason without changing the URDF, it can be done by setting the attribute mimic=false of the joint tag in the <ros2_control> section of the XML. gripper_joint_control.xacro 파일의 해당 항목을 아래와 같이 수정하였다. <joint name="${tf_prefix}finger2_joint" mimic="false"> 수정 사항을 적용하고 앞서 증상을 잡으려고 바꿨던 값들은 모두 원래대로 되돌렸다. 이후 테스트에서는 그리퍼가 보드를 정상적으로 잡아서 이동시켰고 반복 테스트에도 문제가 발생하지 않았다.
velog
1. 버퍼 풀 기본 Q1. 버퍼 풀이 무엇이고, 어디에 위치하나요? 버퍼 풀은 InnoDB가 테이블과 인덱스의 데이터 페이지를 메모리에 캐싱해 두는 공간 입니다. 디스크가 아니라 메모리 에 있습니다. 읽기 캐시 역할도 하고, 변경된 페이지를 모아 두었다가 나중에 디스크에 한꺼번에 쓰는 쓰기 버퍼 역할도 합니다. 그래서 InnoDB 성능에 가장 큰 영향을 주는 메모리 설정이 innodb_buffer_pool_size 입니다. Q2. 버퍼 풀이 없다면 어떤 문제가 생기나요? 왜 필요한가요? 모든 읽기와 쓰기가 매번 디스크 I/O로 이어집니다. 디스크, 특히 랜덤 I/O는 메모리보다 훨씬 느리기 때문에 쿼리 응답 시간과 처리량이 크게 떨어집니다. 읽기 : 자주 쓰는 페이지를 메모리에서 바로 제공해 디스크 읽기를 줄입니다. 쓰기 : 변경 내용을 메모리에 모았다가 쓰기 때문에, 같은 페이지가 여러 번 바뀌어도 디스크에는 한 번만 쓰면 됩니다. Q3. 버퍼 풀은 어떤 단위로 캐싱하나요? 페이지 단위 로 캐싱하며, 기본 크기는 16KB 입니다( innodb_page_size ). 레코드 한 건만 필요해도 그 레코드가 들어 있는 16KB 페이지 전체를 읽어 옵니다. 2. 캐시 히트 · 미스와 LRU Q4. 캐시 히트와 미스는 무엇이고, 미스가 나면 내부적으로 어떤 일이 일어나나요? 히트(hit) : 필요한 페이지가 이미 버퍼 풀에 있어서 메모리에서 바로 읽는 경우 미스(miss) : 버퍼 풀에 없어서 디스크에서 읽어 와야 하는 경우 미스가 나면 다음 순서로 처리합니다. Free 리스트에서 빈 페이지를 하나 받습니다. 빈 페이지가 없으면 LRU 리스트 끝에서 오래 안 쓴 페이지를 제거해 공간을 만듭니다. 그 페이지가 dirty라면 먼저 디스크에 flush합니다. 디스크에서 읽은 페이지를 LRU 리스트의 Old 서브리스트 맨 앞(중간점) 에 넣습니다. Q5. LRU 리스트의 Old 서브리스트란 무엇인가요? LRU 리스트 는 버퍼 풀에 올라온 페이지들을 한 줄로 세워 둔 목록입니다. 앞쪽은 최근에 쓴 페이지, 뒤쪽은 오래 안 쓴 페이지이고, 공간이 부족하면 맨 뒤의 페이지부터 버립니다. InnoDB는 이 리스트를 두 구역으로 나눕니다. [ 머리 ←─── New(Young) 서브리스트 ───→ | ←── Old 서브리스트 ──→ 꼬리 ] 자주 쓰이는 페이지 (약 63%) 중간점 새로 들어온 페이지 (약 37%) ↑ 여기부터 버려짐 Old 서브리스트는 LRU 리스트의 뒤쪽 약 37% 구역 ( innodb_old_blocks_pct )으로, 디스크에서 새로 읽어 온 페이지가 처음 들어가는 대기실 입니다. 새 페이지는 리스트 맨 앞이 아니라 Old 구역의 맨 앞(중간점) 에 들어갑니다. Old 구역에 있는 동안 다시 사용되지 않으면 뒤로 밀리다가 꼬리에서 버려집니다. 일정 시간( innodb_old_blocks_time , 기본 1초)이 지난 뒤 다시 사용되면 New 구역으로 승격 됩니다. 이렇게 하는 이유는 풀 스캔처럼 한 번 읽고 다시 안 쓸 페이지가 대량으로 들어와도, 그 페이지들이 Old 구역에서만 돌다가 버려지게 하기 위해서입니다. 그래서 자주 쓰는 New 구역의 페이지가 밀려나지 않습니다. 면접 답변 Old 서브리스트는 InnoDB LRU 리스트의 뒤쪽, 기본 37% 구간입니다. 디스크에서 새로 읽은 페이지가 처음 들어가는 영역으로, 여기서 다시 접근되면 New 구간으로 승격되고 접근되지 않으면 꼬리에서 가장 먼저 제거됩니다. 풀 스캔 같은 일회성 읽기가 자주 쓰는 페이지를 밀어내지 않게 하는 장치입니다. Q6. 캐시 미스는 왜 발생하나요? 처음 접근하는 데이터라서 아직 캐싱된 적이 없는 경우 자주 쓰는 데이터(워킹 셋)가 버퍼 풀보다 커서 페이지가 계속 밀려나는 경우 대량 스캔이나 배치 작업이 캐시를 오염시키는 경우 서버를 재시작해서 버퍼 풀이 비어 있는 경우 (버퍼 풀 덤프·로드 기능으로 완화할 수 있음) Q7. 데이터가 버퍼 풀보다 클 때 공간은 어떻게 확보하나요? (eviction) LRU 리스트의 끝, 즉 Old 서브리스트 꼬리에 있는 가장 오래 안 쓴 페이지부터 제거합니다. clean 페이지는 그냥 버리고, dirty 페이지는 먼저 디스크에 쓴 뒤 비웁니다. Page Cleaner 스레드가 백그라운드에서 LRU 끝부분을 미리 정리해 두기 때문에, 미스가 날 때마다 즉시 flush하는 상황을 최대한 피합니다. 3. Dirty Page와 Flush Q8. dirty page란 무엇이고, 변경 데이터는 언제 어떻게 디스크에 반영되나요? 버퍼 풀에서는 변경됐지만 아직 디스크에 쓰이지 않은 페이지 를 dirty page라고 합니다. 커밋할 때 바로 디스크에 쓰지 않고, Page Cleaner 스레드가 백그라운드에서 flush 합니다. flush가 일어나는 시점은 다음과 같습니다. 리두 로그 공간을 확보해야 할 때 (체크포인트 진행) dirty 페이지 비율이 설정값( innodb_max_dirty_pages_pct )에 가까워질 때 LRU에서 페이지를 제거해야 할 때 서버를 종료할 때 InnoDB는 어댑티브 플러시로 이 속도를 자동 조절합니다. Q9. 버퍼 풀의 세 가지 리스트(free / LRU / flush)는 각각 어떤 역할을 하나요? 리스트 역할 Free list 아직 사용하지 않은 빈 페이지 목록. 디스크에서 새 페이지를 읽어 올 때 여기서 공간을 받음 LRU list 디스크에서 읽어 온 페이지 목록. 최근 사용 순으로 관리하며, 어떤 페이지를 버릴지 정하는 기준 Flush list dirty page 목록. 처음 변경된 시점의 LSN 순으로 정렬되며, 체크포인트 때 어떤 페이지부터 쓸지 정하는 기준 LRU 리스트와 flush 리스트에 같은 페이지가 동시에 들어 있을 수 있습니다. 4. 리두 로그와 WAL Q10. 디스크에 바로 쓰지 않는데, 장애가 나도 데이터가 안전한 이유는? (redo log / WAL) WAL(Write-Ahead Logging) 원칙 때문입니다. 데이터 페이지보다 변경 내역인 리두 로그를 먼저 디스크에 기록 합니다. 커밋 시점에는 리두 로그만 디스크에 확실히 써 두면 됩니다(fsync). 장애로 버퍼 풀의 dirty page가 사라져도, 재시작할 때 리두 로그를 다시 적용해 커밋된 변경을 복구할 수 있습니다. Q11. 버퍼 풀과 리두 로그는 왜 둘 다 필요한가요? 역할이 다르기 때문입니다. 버퍼 풀은 성능 담당입니다. 데이터 페이지는 디스크 여기저기에 흩어져 있어서 바로 쓰면 랜덤 I/O가 되므로, 변경을 메모리에 모아 두었다가 나중에 몰아서 씁니다. 리두 로그는 내구성 담당입니다. 순차 append 방식이라 쓰기가 빠르고, 커밋된 내용이 사라지지 않게 보장합니다. 버퍼 풀만 있으면 장애가 났을 때 데이터를 잃고, 리두 로그만 있으면 커밋할 때마다 랜덤 I/O가 발생합니다. 둘을 함께 써서 빠르면서도 안전한 구조를 만듭니다. Q12. 커밋 시 디스크에 보장되는 것은 데이터 페이지인가요, 리두 로그인가요? 리두 로그 입니다. innodb_flush_log_at_trx_commit=1 (기본값)이면 커밋할 때마다 리두 로그를 디스크에 fsync합니다. 데이터 페이지는 나중에 비동기로 flush됩니다. 이 설정을 0이나 2로 바꾸면 성능은 좋아지지만, 장애 시 최대 약 1초 분량의 커밋을 잃을 수 있습니다. Q13. LSN이란 무엇이고, 버퍼 풀·리두 로그와 어떻게 연결되나요? 리두 로그에 기록이 쌓일 때마다 계속 커지기만 하는 일련번호 로, 실제로는 리두 로그상의 누적 바이트 위치입니다. 각 데이터 페이지는 자신을 마지막으로 변경한 LSN 을 페이지 헤더에 저장합니다. flush list는 페이지가 처음 변경된 LSN 순서로 정렬됩니다. 체크포인트 LSN 은 "이 지점까지의 변경은 모두 디스크에 반영됐다"는 기준선입니다. 복구할 때는 페이지에 저장된 LSN과 리두 레코드의 LSN을 비교해서, 아직 반영되지 않은 변경만 다시 적용합니다. Q14. 체크포인트란 무엇인가요? "이 LSN 이전의 변경은 모두 데이터 파일에 반영됐다"고 표시하는 지점 입니다. flush list에서 가장 오래된 dirty page부터 디스크에 쓰면 체크포인트가 앞으로 이동합니다. 체크포인트 이전의 리두 로그는 더 이상 필요 없으므로 그 공간을 재사용할 수 있고, 크래시 복구도 체크포인트 이후부터만 리두를 적용하면 됩니다. InnoDB는 서비스를 멈추지 않고 조금씩 진행하는 퍼지(fuzzy) 체크포인트 방식을 씁니다. Q15. 리두 로그가 너무 작으면(또는 꽉 차면) 어떻게 되나요? 리두 로그는 순환 구조라서, 체크포인트가 따라오지 못하면 새 로그를 쓸 공간이 없어집니다. 그러면 InnoDB가 dirty page를 급하게 강제 flush(sync flush) 하고, 그동안 쓰기 트랜잭션이 대기하면서 처리량이 갑자기 떨어지는 스톨 이 발생합니다. 반대로 리두 로그가 너무 크면 크래시 복구 시간이 길어질 수 있으므로 쓰기 부하에 맞게 크기를 정해야 합니다(8.0.30부터 innodb_redo_log_capacity ). Q16. 크래시가 났을 때 버퍼 풀은 비어 있는데, 복구는 어떻게 이뤄지나요? 마지막 체크포인트 LSN 을 찾습니다. 그 이후의 리두 로그를 순서대로 읽으면서 해당 페이지를 디스크에서 버퍼 풀로 읽어 옵니다. 페이지 LSN이 리두 레코드의 LSN보다 작은 경우에만 변경을 다시 적용합니다 ( Redo, roll-forward ). 그다음 언두 로그 로 커밋되지 않은 트랜잭션을 롤백합니다 ( Undo ). 결과적으로 커밋된 것은 살리고, 커밋 안 된 것은 되돌린 상태가 됩니다. Q17. 리두 로그와 바이너리 로그(binlog)는 어떻게 다른가요? 구분 리두 로그 바이너리 로그 계층 InnoDB 엔진 레벨 MySQL 서버 레벨 (모든 엔진) 내용 페이지 변경 (물리적) SQL문 또는 Row 변경 (논리적) 구조 고정 크기, 순환 재사용 파일 단위로 계속 추가 목적 크래시 복구 복제, 시점 복구(PITR) 면접 답변 두 로그는 소속 계층과 목적이 다릅니다. 리두 로그는 InnoDB 엔진 이 남기는 로그입니다. '어느 페이지를 어떻게 바꿨다'는 물리적인 변경 기록 이고, 목적은 크래시 복구 입니다. 크기가 고정돼 있어서 체크포인트가 지난 부분은 덮어쓰며 순환 재사용합니다. 바이너리 로그는 MySQL 서버 레벨 에서 엔진과 상관없이 남기는 로그입니다. 실행된 SQL이나 변경된 행 같은 논리적인 기록 이고, 목적은 복제 와 특정 시점 복구 입니다. 파일이 계속 추가되는 구조입니다. 커밋할 때 둘 중 하나만 기록되면 원본 서버와 복제 서버의 데이터가 어긋나기 때문에, MySQL은 내부적으로 2단계 커밋 으로 두 로그의 일관성을 맞춥니다. 한 줄 요약 : 리두 로그는 "이 서버를 살리기 위한 로그", 바이너리 로그는 "다른 서버에 똑같이 전달하거나 과거 시점으로 돌리기 위한 로그"입니다. 꼬리 질문: 2단계 커밋은 어떻게 동작하나요? 먼저 리두 로그에 prepare 상태로 기록하고, 이어서 바이너리 로그를 기록한 뒤, 마지막으로 리두 로그를 commit 상태로 표시합니다. 장애가 나면 바이너리 로그에 기록이 있는지를 기준으로 커밋할지 롤백할지 결정합니다. 5. 어댑티브 해시 인덱스 Q18. B-Tree 인덱스란 무엇인가요? 루트, 브랜치, 리프로 이루어진 균형 트리 입니다. 키가 정렬된 상태로 저장되고 리프 노드끼리 연결되어 있습니다. 어떤 키를 찾든 트리 높이만큼만 탐색하면 되므로 O(log N)이고, 정렬과 범위 검색에도 유리합니다. Q19. 어댑티브 해시 인덱스란 무엇이고, B-Tree와 어떤 관계인가요? InnoDB가 자주 조회되는 B-Tree 페이지를 감지해서 메모리에 자동으로 만드는 해시 인덱스 입니다. 키 값으로 버퍼 풀의 레코드 위치를 바로 찾기 때문에, 루트부터 리프까지 내려가는 탐색을 건너뜁니다. B-Tree를 대체하는 것이 아니라 B-Tree 위에 얹는 캐시 이며, 디스크에는 저장되지 않습니다. 동등 비교( = ) 조회에서만 효과가 있고 범위 검색에는 쓰이지 않습니다. 쓰기가 많은 환경에서는 오히려 락 경합이 생길 수 있어서 끄기도 합니다. 6. Doublewrite Buffer Q20. Doublewrite Buffer란 무엇인가요? 어떤 문제를 해결하나요? 면접 답변 더블라이트 버퍼는 InnoDB가 dirty page를 데이터 파일에 쓰기 전에, 같은 페이지를 별도 영역에 먼저 한 번 더 써 두는 안전장치 입니다. InnoDB 페이지는 16KB인데 OS나 디스크는 보통 4KB 단위로 씁니다. 그래서 쓰는 도중에 장애가 나면 페이지의 일부만 새 내용으로 바뀐 깨진 페이지(torn page) 가 생길 수 있습니다. 더블라이트 버퍼는 이런 경우에 대비해 온전한 사본을 남겨 두고, 장애가 나면 그 사본으로 페이지를 먼저 복원합니다. Q21. 리두 로그가 있는데 왜 더블라이트 버퍼가 따로 필요한가요? 리두 로그는 페이지 전체 이미지가 아니라 "이 페이지의 이 위치를 이렇게 바꿔라"라는 변경 기록 입니다. 그래서 원본 페이지가 온전하다는 전제 에서만 적용할 수 있습니다. 페이지 자체가 반쯤 깨져 있으면 리두를 적용할 기준이 없으므로, 더블라이트 버퍼가 온전한 페이지 사본 을 제공해야 리두 복구가 가능해집니다. Q22. 더블라이트 버퍼는 어떻게 동작하나요? 쓰기 순서로 설명해 보세요. 면접 답변 dirty page를 디스크에 쓸 때 두 단계로 나눠서 씁니다. 1단계 로, flush할 페이지들을 데이터 파일에 바로 쓰지 않고 먼저 더블라이트 영역에 여러 페이지를 묶어서 순차적으로 쓰고, fsync로 디스크 기록을 확정 합니다. 이제 온전한 사본이 디스크에 안전하게 남은 상태입니다. 2단계 로, 같은 페이지들을 데이터 파일의 원래 위치에 쓰고 다시 fsync 합니다. 이렇게 하면 어느 시점에 장애가 나도 둘 중 하나는 반드시 온전합니다. 1단계 도중에 장애가 나면 원본 데이터 파일은 손대지 않은 상태라 원본이 온전하고, 2단계 도중에 장애가 나서 원본이 깨지면 더블라이트 영역의 사본으로 복원합니다. 참고로 MySQL 8.0.20부터 더블라이트 영역은 시스템 테이블스페이스가 아닌 별도 파일( #ib_*.dblwr )로 분리됐습니다. 버퍼 풀의 dirty page │ ├─1 더블라이트 영역에 묶어서 순차 쓰기 → fsync (사본 확보) │ └─2 데이터 파일 원래 위치에 쓰기 → fsync (실제 반영) Q23. 데이터를 두 번 쓰니 성능이 절반으로 떨어지지 않나요? 절반까지 떨어지지는 않습니다. 더블라이트 쓰기는 여러 페이지를 묶어 한 번에 쓰는 순차 I/O 이고 fsync도 묶음 단위로 한 번만 합니다. 실제 비용이 큰 것은 원래 위치에 쓰는 랜덤 I/O 쪽입니다. 그래서 오버헤드는 보통 수 %에서 10% 정도 입니다. 파일 시스템이나 스토리지가 원자적 쓰기를 보장한다면(ZFS 등) innodb_doublewrite=OFF 로 끌 수도 있습니다. Q24. 크래시 복구 시 더블라이트 버퍼와 리두 로그는 어떤 순서로 동작하나요? 더블라이트 단계 : 데이터 파일의 페이지 체크섬을 검사해서 깨진 페이지를 찾고, 더블라이트 영역의 온전한 사본으로 교체합니다. 더블라이트 영역에 쓰는 도중에 크래시가 났다면 원본 페이지는 아직 손대지 않은 상태이므로 원본을 그대로 씁니다. 리두 단계 : 이제 모든 페이지가 온전하므로, 체크포인트 이후의 리두 로그를 적용해 커밋된 변경을 복구합니다. 언두 단계 : 커밋되지 않은 트랜잭션을 롤백합니다. 정리하면 페이지를 먼저 온전하게 만들고 → 리두로 변경을 다시 적용하고 → 언두로 미완료 트랜잭션을 정리 하는 순서입니다. 7. 추가 질문 Q25. InnoDB란 무엇이고, 어떻게 읽나요? "이노디비" 라고 읽습니다. 핀란드 회사 Innobase가 만들어서 붙은 이름입니다. InnoDB는 MySQL의 기본 스토리지 엔진 입니다. MySQL은 두 층으로 나눠 볼 수 있습니다. MySQL 서버 층 : SQL을 해석하고, 실행 계획을 세우고, 권한을 확인합니다. 스토리지 엔진 층 : 데이터를 실제로 디스크에 저장하고 읽습니다. InnoDB, MyISAM 등이 여기에 해당합니다. InnoDB는 트랜잭션, 행 단위 잠금, 장애 복구, 외래 키를 지원합니다. 버퍼 풀, 리두 로그, 언두 로그, 더블라이트 버퍼는 모두 InnoDB 안의 구성 요소입니다. Q26. 리두(Redo)와 언두(Undo)는 각각 무엇인가요? 이름 그대로 Redo는 "다시 하기", Undo는 "되돌리기" 입니다. 리두 로그 (Redo) 언두 로그 (Undo) 기록 내용 변경 후 내용 ("이렇게 바꿨다") 변경 전 내용 ("원래는 이랬다") 용도 장애 시 커밋된 변경을 다시 적용 커밋 안 된 변경을 되돌림 (롤백) 추가 용도 – MVCC: 다른 트랜잭션에게 변경 전 데이터를 보여줌 예를 들어 잔액을 100에서 50으로 바꾸면, 리두 로그: "잔액을 50으로 바꿨다" → 장애가 나면 다시 50으로 만듭니다. 언두 로그: "원래 100이었다" → 롤백하면 다시 100으로 되돌립니다. Q27. 데이터 페이지란 무엇인가요? InnoDB가 디스크와 메모리 사이에서 데이터를 주고받는 최소 단위 묶음이며, 기본 크기는 16KB입니다. 책에 비유하면 레코드(행)는 문장이고, 페이지는 책의 한 쪽입니다. 문장 하나만 읽고 싶어도 그 쪽을 통째로 펼쳐야 하듯이, 행 하나가 필요해도 InnoDB는 그 행이 들어 있는 16KB 페이지 전체를 읽어서 버퍼 풀에 올립니다. 테이블 데이터와 인덱스는 모두 이런 페이지들로 나뉘어 디스크 파일(.ibd)에 저장됩니다. Q28. LSN이 "단조 증가하는 일련번호"라는 것은 무슨 뜻인가요? 단조 증가 : 계속 커지기만 하고 절대 줄어들거나 되돌아가지 않는다는 뜻입니다. LSN(Log Sequence Number) : 리두 로그에 지금까지 몇 바이트를 기록했는지를 나타내는 누적 위치값입니다. 은행 대기표처럼 먼저 생긴 기록일수록 번호가 작습니다. 변경 A 기록 → LSN 1000 변경 B 기록 → LSN 1200 변경 C 기록 → LSN 1500 번호가 항상 커지기 때문에 어떤 일이 먼저 일어났는지 숫자 비교만으로 알 수 있습니다. 페이지에 "마지막 변경 LSN = 1200"이 적혀 있다면 변경 B까지는 반영돼 있고 변경 C(1500)는 아직이라는 뜻이므로, 복구할 때 변경 C만 다시 적용하면 됩니다. Q29. 퍼지 체크포인트(Fuzzy Checkpoint)란 무엇인가요? 체크포인트를 만드는 방식에는 두 가지가 있습니다. 샤프 체크포인트(Sharp) : 모든 dirty page를 한 번에 디스크에 씁니다. 그동안 다른 작업이 멈추며, 서버 종료 시 사용합니다. 퍼지 체크포인트(Fuzzy) : dirty page를 조금씩, 오래된 것부터 백그라운드에서 나눠 씁니다. 서비스를 멈추지 않습니다. 샤프는 손님을 다 내보내고 한 번에 대청소하는 방식이고, 퍼지는 영업 중에 틈틈이 조금씩 치우는 방식입니다. 운영 중인 InnoDB는 퍼지 방식을 쓰며, flush list에서 가장 오래된 변경부터 디스크에 쓰고 그만큼 체크포인트 LSN을 앞으로 옮깁니다. Q30. 클러스터드 인덱스와 세컨더리 인덱스는 무엇인가요? 클러스터드 인덱스 (Clustered Index) PK 기준으로 실제 데이터(행 전체)를 정렬해서 저장하는 인덱스 입니다. B-Tree의 리프 노드에 행 데이터 자체 가 들어 있습니다. 인덱스가 곧 테이블입니다. 테이블당 하나만 존재합니다. PK가 없으면 Unique 컬럼을 쓰고, 그것도 없으면 숨겨진 키를 만들어 씁니다. 세컨더리 인덱스 (Secondary Index) PK가 아닌 컬럼에 따로 만든 인덱스입니다 (예: email ). 리프 노드에 인덱스 컬럼 값과 PK 값 만 들어 있습니다. 그래서 조회가 두 단계입니다. 세컨더리 인덱스에서 PK를 찾은 뒤, 그 PK로 클러스터드 인덱스를 한 번 더 탐색해 실제 행을 가져옵니다. 책에 비유하면 클러스터드 인덱스는 쪽 번호 순서대로 정렬된 본문 자체 이고, 세컨더리 인덱스는 책 뒤의 찾아보기 입니다. Q31. flush와 fsync는 각각 무엇인가요? flush : 버퍼 풀(메모리)에서 바뀐 dirty page를 디스크로 내려 쓰는 것 입니다. fsync : OS에게 "방금 쓴 데이터를 OS 캐시에 두지 말고 실제 디스크에 기록하라" 고 강제하는 시스템 호출입니다. 프로그램이 write를 호출해도 데이터는 보통 OS의 메모리
velog
광고성 정보 수신 동의가 이렇게 저장돼 있었다. marketing_agreed TINYINT(1) 참이면 보내고 거짓이면 안 보낸다. 동작은 맞다. 문제는 이 칸이 "동의하셨습니까"에만 답할 수 있다 는 것이다. 실제로 들어온 질문들은 이랬다. 이 사람은 언제 동의했나? 어느 화면 에서 받은 동의인가? 동의했다가 철회한 적 이 있나? 거짓인 사람은 거절한 것 인가, 물어본 적이 없는 것 인가? 한 칸으로는 하나도 답할 수 없다. 그리고 이 질문들은 보통 답할 수 없을 때 문제가 되는 질문 들이다. 이걸 고치는 데 네 번의 PR 이 걸렸다. 한 번에 설계한 게 아니라, 질문이 올 때마다 "아 이것도 답을 못 하네"를 발견하면서 한 겹씩 쌓았다. 1 거짓은 두 가지다 제일 먼저 깨진 건 false 의 의미였다. 가입할 때 체크박스를 안 누른 사람과, 동의했다가 수신 거부한 사람이 DB 에서 똑같이 0 이었다. 둘은 완전히 다른 사람이다. 전자에게는 다시 물어볼 수 있고, 후자에게 다시 물으면 안 된다. 그리고 애초에 동의 항목을 보여준 적도 없는 사람 이 있었다. 그 기능이 생기기 전에 가입한 사람들. 이들도 0 이었다. type MarketingConsent = 'UNKNOWN' | 'CONSENTED' | 'NOT_CONSENTED'; 셋으로 나눴다. UNKNOWN 은 "확인 불가" — 묻거나 기록한 적이 없다는 뜻이다. 이게 중요한 이유는, UNKNOWN 과 NOT_CONSENTED 의 처리가 다르기 때문이다. 둘 다 발송 대상은 아니지만, 전자는 동의를 받을 여지가 있고 후자는 없다. 여기서 하나 배웠다. 열거형에 "모름"을 넣는 걸 주저하지 말 것. 모름을 거짓으로 접으면, 그 순간부터 거짓이 두 가지를 뜻하게 된다. 2 상태가 아니라 변화를 기록한다 상태를 셋으로 나눠도 "언제"에는 답할 수 없다. 현재 값만 있으니까. 이력 테이블을 만들었다. 단, 상태가 실제로 바뀐 순간만 기록한다. 같은 값으로 저장하는 요청(사용자가 마이페이지에서 아무것도 안 바꾸고 저장 누르는 경우)은 이력을 남기지 않는다. 안 그러면 이력이 노이즈로 가득 차서 진짜 전환을 못 찾는다. 그리고 파생 값 두 개를 뒀다. 철회일 — 동의 → 미동의로 바뀐 시점에만 기록. 처음부터 미동의인 사람에게는 없다 최초 동의일 — 한 번만 기록. 동의 → 철회 → 재동의 해도 처음 값을 유지 백필의 한계를 유의사항에 적었다 기존 데이터를 채우면서 알게 된 건, 과거는 완전히 복원되지 않는다 는 것이다. - 과거의 '최초 미동의' 와 '철회 후 미동의' 는 원본 시각만으로 구별할 수 없다. 그래서 과거 철회일은 백필하지 않는다. - 최초 동의일은 '현재 보존된 이력에서 확인 가능한 가장 이른 동의 시각' 이다. 더 오래된 이력이 없으면 절대 최초 시각을 보장하지 않는다. 이걸 PR 유의사항에 쓴 이유는, 나중에 누군가 이 값을 법적 근거로 쓸 수 있기 때문이다. "최초 동의일"이라는 컬럼 이름만 보면 당연히 진짜 최초라고 생각한다. 아니라는 걸 아는 사람은 지금 나뿐이고, 나는 6개월 뒤에 잊는다. 추정으로 채운 값은 추정이라고 적어야 한다. 컬럼 이름은 그걸 못 적는다. 3 신청과 동의를 같은 트랜잭션에 묶었다 "어디서 받은 동의인가"를 위해 접점( source )을 기록하기로 했다. 이벤트 신청, 입학설명회, 입학 신청, 무료 체험, 결제 페이지... 12종. 처음 구현은 이랬다. await submitApplication(form); // 신청 저장 await patchMyInfo({ marketing: true }); // 동의 갱신 ← 별도 요청 신청이 성공하고 두 번째 요청이 실패하면 신청은 됐는데 동의 기록만 없다. 사용자는 체크했다고 기억하는데 DB 에는 없다. 분쟁이 나면 우리가 진다. 신청 API 안으로 넣고 같은 트랜잭션 으로 묶었다. 둘은 함께 확정되거나 함께 실패한다. 그리고 규칙을 하나 더 못 박았다. UI 에 보였다고 기록하지 않는다. 체크박스가 화면에 떴다는 사실이 아니라, 사용자가 제출한 선택 만 기록한다. 이미 전역 동의를 해서 체크박스가 아예 안 보인 사용자는 서버에서도 아무것도 기록하지 않는다. 보이지 않은 항목에 대한 "선택"은 존재하지 않는다. 미체크 신청도 중요했다. 체크 안 하고 신청한 경우, 전역 동의 상태를 바꾸지 않는다. 예전에 동의한 사람이 이벤트 신청에서 체크를 안 했다고 해서 기존 동의가 철회되면 안 된다. 다만 "이 신청에서는 미동의를 선택했다"는 응답은 남긴다. 4 빠뜨리면 컴파일이 실패하게 접점이 12종이 되고, 두 개의 서비스(두 브랜드)가 섞이기 시작했다. "이 접점은 어느 서비스 것인가"를 매핑해야 했다. 가장 쉬운 방법은 이거다. function getService(source?: Source) { if (source === 'PURPLE_ENGLISH_PAYMENT') return 'PURPLE_ENGLISH'; return 'PURPLE_ACADEMY'; // 나머지 전부 } 이러면 새 접점을 추가할 때 아무 일도 안 일어난다. 조용히 기본값으로 분류되고, 몇 달 뒤 집계가 이상하다는 말이 나온다. 그래서 전체 Record 로 바꿨다. const SOURCE_SERVICE: Record<MarketingConsentSource, MarketingConsentService> = { EVENT: 'PURPLE_ACADEMY', ADMISSION: 'PURPLE_ACADEMY', // ... 12개 전부 PE_PAYMENT: 'PURPLE_ENGLISH', }; Record<K, V> 는 K 의 모든 멤버를 요구한다. 접점 enum 에 하나를 추가하는 순간, 이 객체가 불완전해져서 타입 에러가 난다. 서비스를 정하지 않으면 배포가 안 된다. 이게 이번 작업에서 제일 마음에 드는 부분이다. 문서에 "새 접점을 추가하면 서비스도 지정하세요"라고 적는 대신, 지정하지 않으면 컴파일이 안 되게 만들었다. 문서는 안 읽히지만 컴파일 에러는 반드시 읽힌다. 규칙을 지키게 하는 가장 싼 방법은 안 지키면 빌드가 깨지게 하는 것이다. 네 번에 나눠서 한 게 맞았나 한 번에 설계했으면 더 깔끔했을까. 아마 아니다. 1을 할 때는 접점이 필요한 줄 몰랐다. 3을 할 때는 서비스가 둘로 갈릴 줄 몰랐다. 미리 다 상상해서 만들었으면 안 쓰는 칸이 잔뜩 있는 테이블 이 나왔을 것이고, 정작 필요한 칸은 또 없었을 것이다. 대신 각 단계에서 지킨 게 있다. 다음 겹을 올릴 수 있게 남겨두기. 상태를 enum 으로 뺐기 때문에 값이 늘어나도 괜찮았고, 이력을 별도 테이블로 뒀기 때문에 접점 컬럼을 거기 붙일 수 있었다. 스키마 변경은 전부 컬럼 추가( NULL 허용)로만 했고, 기존 컬럼은 하나도 안 지웠다. 개인정보 관련 데이터는 특히 그렇다. "나중에 물어볼 질문"을 지금 다 알 수 없다. 알 수 없다는 걸 전제로, 질문이 왔을 때 한 겹 올릴 수 있는 모양으로 두는 게 최선이다. 다음 글은 전혀 다른 쪽이다. 우리 사이트가 검색엔진에게는 로딩 화면으로만 보이고 있었던 이야기, 그리고 2026년에 AI 크롤러를 어디까지 허용할지 정한 기준.
velog
1. 지금 무엇이 문제인가 최근 국내 금융권 연쇄 침해사고에서 공격 서버에 중국어권 오픈소스 AI 자율 침투 도구 ‘ARTEX’의 흔적이 확인됐다는 보도가 나왔다. “AI가 해킹을 한다”는 우려가 커지면서, 그 능력이 실제로 어느 정도이고 어떤 도구들이 있는지에 대한 관심이 높다. 이 글은 국내외 자율 보안 에이전트의 현황과 수준을 정리한 것이다. 먼저 짚을 것 : 이번 사고는 ‘AI가 혼자 공격한 것’이 아니라 ‘공격자가 자율 에이전트를 도구로 쓴 것’이다. 금융보안원도 같은 취지로 설명했다. 새로운 것은 공격 수법이 아니라 공격의 문턱 이다 — 전문 인력 없이, 공개 도구와 공개 모델만으로 여러 표적을 동시에 칠 수 있게 됐다. 2. 자율 보안 에이전트란 기존 취약점 스캐너는 정해진 규칙을 순차 실행한다. 자율 에이전트는 다르다. LLM이 스스로 목표를 쪼개고(계획), 실제 도구를 실행하고(행동), 발견한 자산·취약점을 쌓아가며(기억), 결과를 보고 다음 수를 바꾼다(피드백). 정찰 → 취약점 탐색 → 침투 경로 설계 → 도구 실행 → 검증의 전 과정을 사람 개입 없이 끌고 갈 수 있다. 핵심은 능력이 모델이 아니라 ‘구성’에서 나온다는 점 이다. 같은 모델도 어떤 에이전트 구조(다중 에이전트·도구·메모리)에 얹느냐에 따라 사이버보안 평가 점수가 크게 달라진다. 그래서 특정 모델을 막거나 개발 속도를 늦추는 것으로는 이 위험을 다루기 어렵다. 3. 이번 사건의 ARTEX, 무엇인가 ARTEX(아르텍스)는 중국어권 개발자가 깃허브에 오픈소스로 공개한 LLM 기반 자율 침투 테스트 시스템이다. 2026년 7월 말 공개돼, 한 달 뒤인 9월 3일 중국 바이두 보안대응센터(BSRC)가 연 ‘Agent+ 공격·방어 능력 챌린지’(150여 팀 참가, 8팀 결선)에서 종합 우승했다. 구조 — 여러 에이전트가 스스로 목표를 쪼개고, 실제 보안 도구를 실행하며, 발견한 자산·취약점을 그래프로 쌓아간다. Go 단일 바이너리에 웹 UI가 내장돼 있고 데이터는 PostgreSQL에 저장된다. 모델 교체형 — 특정 모델에 묶이지 않는다. Claude·GPT·DeepSeek 등 어떤 LLM이든 API나 OpenAI 호환 엔드포인트로 붙여 쓸 수 있다. 어떤 모델을 붙였는지는 사용자가 정한다. 누구나 사용 — 오픈소스라 코드를 그대로 내려받을 수 있고, 이미 한국어판 포크가 깃허브에 여럿 올라와 있다. 이번 사건과의 관계 : 금융보안원이 신한은행 로그와 공격 IP를 역추적한 결과 ARTEX 관련 흔적이 확인됐다고 밝혔다. 다만 “AI가 사람 없이 독자적으로 공격한 것이 아니라 공격자가 ARTEX라는 AI 도구를 이용한 것”이라는 설명이다. 오픈소스 도구인 만큼 특정 국가·세력의 전용 도구로 단정할 수 없고, 공격자 신원도 미확정이다(2026.10 기준). 4. 왜 중요한가 — 모델이 아니라 ‘모델+에이전트’ 같은 모델이라도 에이전트를 붙이면 능력이 크게 뛴다. 이것이 이번 사안의 핵심이다. 아래는 같은 모델(Claude Opus 4.6)에 에이전트 구성을 얹었을 때의 변화다. ARTEX 도 같다 — 개별 평가 점수는 중위권(바이두 챌린지 실전 7위)이었으나 종합 1위에 올랐다. 구성이 승부를 갈랐다. 함의: 에이전트는 오픈소스이고, 모델은 공개 가중치이거나 API로 누구나 쓸 수 있다. 그 조합이 승인 기관에만 제공되는 보안특화 폐쇄 모델과 대등하거나 앞선다(사이버짐 공식 보드 상위권에 공개 모델이 섞여 있고, 에이전트 구성이 그 위에 있다). 즉 모델을 통제해서 능력 확산을 막는다는 전제는 이미 무너졌다. 전문 인력도 고가 장비도 필요 없으니, 이런 공격은 앞으로 흔해진다. 평가와 통제의 단위를 ‘모델’이 아니라 ‘모델+구성(에이전트)’으로, 그리고 운영 단계로 옮겨야 하는 이유다. 5. 국내외 자율 보안 에이전트 국내 동향 — 과기정통부는 사이버보안 특화 파운데이션 모델을 개발 중이다(네이버클라우드 컨소시엄, 41개 기관). 이는 에이전트가 아니라 ‘모델’로, 700B MoE 2개를 병렬 개발한다 — 방어(하이퍼클로바X 기반)·공격(엑사원 기반). 공격 모델 목표는 사이버짐에서 GLM-5.3 대비 1단계 80%(2027.2)·최종 100%(2027.7)이며 2027 하반기부터 4대 기반시설에 적용될 예정이다. 다만 이 모델도 결국 에이전트에 얹혀 쓰이므로, 능력은 공개 시점의 모델 점수가 아니라 구성까지 포함해 보아야 한다. 6. 실제 능력은 어느 정도인가 — 점수로 보기 취약점 발굴 벤치마크 ‘사이버짐(CyberGym)’ 공식 리더보드 성공률(%)이다(2026.10 기준). 위 두 개는 에이전트 구성(Agent-focused), 아래 다섯은 모델 단독(Model-focused) 상위 5개다. 모델 단독 최고(88.9)보다 에이전트 구성(91.0)이 위에 있다. * 사이버짐 공식 리더보드 — Agent-focused, 90% 이상 선도 그룹 17개 * (2026.10 캡처, 무작위 순서) 사이버짐 공식 리더보드 — Model-focused 상위 5 (2026.10 캡처) 읽는 법 : 1 모델 단독으로는 공개 모델(알리바바 27B·GLM·DeepSeek)과 폐쇄 보안특화 모델(Gemini Cyber·GPT Cyber)이 5점 안에 섞여 있다 — 공개 모델이 통제 모델과 대등하다. 2 에이전트를 얹으면 그 위로 올라간다 — 센터 mneme은 같은 Opus 4.6을 단독 66.6에서 91.0으로 끌어올렸고, Microsoft의 MDASH(상용 모델 GPT-5.4·Claude를 100여 개 전문 에이전트로 엮은 하네스)도 91.0으로 동률이다. Microsoft가 MDASH를 내놓으며(2026.5) 한 말이 이 그래프의 요지다 — “지속 가능한 우위는 어느 한 모델이 아니라 모델을 둘러싼 에이전트 시스템에 있다.” 전용 모델 없이 상용 모델들을 엮어 당시 단일 모델 Mythos(83.1)·GPT-5.5(81.8)를 제쳤고, Windows 커널에서 치명 RCE 4건 등 16건을 찾았다. 90% 이상 선도 그룹은 17개 시스템이다 . 그중 확인된 중국 팀만 9개(NSFOCUS·天工·Alibaba·Fangcun·DARKNAVY·Alipay·Sangfor·Huawei·Fudan)이고, 쓰는 모델은 압도적으로 DeepSeek-V4·GLM-5.3 같은 공개 가중치다. 비중국은 Microsoft MDASH·Wiz, 그리고 한국은 mneme 하나다. 접근이 쉬운 공개 모델 위에 에이전트를 얹으면 누구나 선도 그룹에 든다는 뜻이다. 단, 더 어려운 과제(실제 익스플로잇 생성, ExploitBench)에서는 보안특화 폐쇄 모델이 아직 앞선다. ‘사이버보안 능력’을 단일 점수로 말할 수 없다는 뜻이다. 자율 침투 실전에서도 구성이 승부를 가른다 — ARTEX 종합 1위, XBOW HackerOne 美 1위(1,060건). ※ 리더보드 점수는 각 팀이 제출한 자체 측정값이며, 선도 그룹은 무작위 순서로 표시되고 사이트 주석대로 작은 차이는 의미 있는 능력 차가 아니다. 벤더가 별도 보고한 수치(예: 샤오미 MiMo 95.1)는 공식 보드에 없어 제외했다. 침투 에이전트의 점수는? — 공개 리더보드가 없다 사이버짐은** 코드 취약점 발굴** 벤치마크다. ARTEX·XBOW 같은 침투 에이전트는 다른 과제라 사이버짐 점수가 없고, 침투 쪽에는 사이버짐 같은 공개 리더보드 자체가 없다. 벤치마크는 여럿이지만 논문마다 각자 재서 보고하는 구조라 한 보드에서 줄 세울 수 없다. 있는 근거는 성격이 다른 세 종류다. 함의: 코드에서 취약점을 찾는 능력은 90%대로 성숙했지만, 살아 있는 시스템을 뚫는 완전 자율 침투는 현실적 조건에서 아직 사람 보조가 필요하다. 이번 사건이 ‘AI 혼자’가 아니라 ‘사람이 ARTEX를 운용’한 것과 맞아떨어진다. 다만 그 ‘사람’이 더 이상 전문가일 필요가 없다는 것이 바뀐 점이다. 7. 정리 — 세 가지 사실 1 누구나 통제 없이 사이버 능력을 손에 넣을 수 있다. 에이전트는 오픈소스(ARTEX 등), 모델은 공개 가중치나 API — 승인도 심사도 없이 내려받아 결합하면 된다. 그 조합이 보안특화 폐쇄 모델과 대등하거나 앞선다(사이버짐 선도 그룹 17개 중 대부분이 공개 모델 기반 에이전트). 접근을 통제하는 모델을 만들어도, 통제하지 않는 공개 모델로 같은 능력을 얻을 수 있어 모델 통제 자체가 의미를 잃는다. ** 2 발견·열거는 사람 수준에 근접했으나, 검증·판단은 아직 사람 몫이다.** 업계 공통 평가는 “자율 AI가 침투 테스트에서 진짜로 경쟁력 있지만 아직 사람을 대체하지 못한다”는 것이다. ‘사람 없는 공격’은 아직이지만 ‘비전문가도 쓰는 공격’은 이미 왔다. ** 3 능력은 모델이 아니라 구성에서 나오고, 공개 모델이 통제 모델을 이미 앞서는 영역이 있다.** 따라서 대응은 모델 규제나 개발 속도 조절이 아니라 운영 단계의 통제 — 능력 검증(모델+구성 단위), 실시간 감시·차단, 사고 추적 — 으로 가야 한다. 문의: 숭실대학교 AI안전성연구센터
velog
경영진이 매일 보는 대시보드를 만들었다. 카드마다 숫자가 큼직하게 박혀 있고, 그 숫자를 보고 사람이 움직인다. "반 배정 대기 227명"이면 누군가는 오늘 227명을 배정해야 한다. 그런데 그 227명을 실제로 배정하려던 담당자가 물었다. "이 사람들 명단은 어디서 봐요?" 명단을 뽑아봤다. 조건에 맞는 사람은 0명 이었다. 이 글은 그 뒤로 카드를 하나씩 운영 DB에서 직접 세어보며 찾은 것들에 대한 기록이다. 결론부터 말하면, 틀린 이유가 카드마다 전부 달랐다. 1. 전칭을 MAX 로 쓰면 조건이 통째로 죽는다 「반 배정 대기」의 정의는 이랬다. 정식 계정이고, 수강권 결제가 끝났고, 환불되지 않았고, 신청한 학기가 아직 진행 중이고, 반 배정만 안 된 사람 이 다섯 개를 모두 만족하는 사람은 운영 DB에 0명이었다. 그럼 227명은 어디서 왔나. 분류 로직은 이렇게 생겼다. 자녀 한 명을 여러 유형 중 하나로 보내고, 어디에도 안 걸리면 맨 끝 catch-all 로 떨어뜨린다. 그 catch-all 이 하필 「반 배정 대기」였다. 그리고 그 앞에 있어야 할 제외 규칙이 운영에서 한 번도 발동하지 않고 있었다. // 의도: "이 자녀 말고 다른 정상 자녀가 있는 경우는 제외 대상에서 빼자" // 부모 단위로 모아 집계한다 MAX(CASE WHEN child.isNormal THEN 1 ELSE 0 END) AS hasOtherNormalChild // ... if (signals.hasOtherNormalChild !== 1) { exclude(child); // ← 운영에서 단 한 번도 실행되지 않았다 } 문제는 MAX 가 판정 대상 자녀 본인까지 포함해서 집계한다는 것이다. "다른(other)"이라는 단어가 변수 이름에만 있고 쿼리에는 없었다. 대상 자녀가 정식 계정이면 본인 때문에 이 값이 항상 1 이 된다. 가드는 !== 1 일 때만 제외하므로, 제외는 영원히 일어나지 않는다. 그렇게 걸러졌어야 할 사람들이 전부 catch-all 로 흘러들어 227명이 됐다. 재밌는 건, 일부 계정은 우연히 살아남았다 는 것이다. 계정 번호 접두사가 다른 유형들은 isNormal 이 0 이라 MAX 도 0 이 됐고, 그래서 가드를 통과해 제외됐다. 같은 버그가 데이터 모양에 따라 어떤 줄은 통과시키고 어떤 줄은 안 통과시킨 것이다. "일부는 맞게 나온다"가 로직이 맞다는 증거가 되지 않는다. 고친 방법 제외 신호 세 개를 전부 MIN 집계로 바꿨다. // "자녀 전원이 그 유형일 때만 제외한다" — 전칭을 전칭으로 표현 MIN(CASE WHEN child.isMigratedInactive THEN 1 ELSE 0 END) AS allMigratedInactive 그리고 의미가 없어진 hasOtherNormalChild 가드를 지웠다. catch-all 도 옮겼다. 나머지를 받아내는 칸은 하나여야 하고, 「반 배정 대기」가 그 역할을 겸하면 항목의 뜻 자체가 흐려진다. 그래서 catch-all 은 「미등록」으로 보냈다. 운영 DB 실측으로 227명을 분해해보니 이렇게 나뉘었다. 실제로는 인원 원래 가야 할 곳 학기 종료·미배정 88명 전역 집계에서 제외 이관·무활동 69명 전역 집계에서 제외 신청만 하고 결제 미완료 69명 임시 계정 분류 나머지 1명 미등록 반 배정 대기 0명 — 교훈 — 전칭(∀, 모두)과 존재(∃, 하나라도)를 SQL 집계로 옮길 때 MIN/MAX 를 거꾸로 쓰면 조건이 소리 없이 죽는다. 에러도 안 나고 로그도 안 남는다. 그냥 평생 참이거나 평생 거짓일 뿐이다. 2. 주문 상태와 구독 상태는 다른 것이다 「자동결제 실패」 카드는 운영에서 30건을 보여주고 있었다. 담당자가 30명에게 연락해야 한다는 뜻이다. 처음 고칠 때는 이렇게 생각했다. *"이미 닫힌 주문은 빼면 되겠지."* 그래서 주문 상태가 취소·실패인 건을 뺐다. 그래도 숫자가 이상해서 구독 테이블을 직접 조인해 세어봤다. 구독 상태 건수 조치 대상인가 진행 중 6 ✅ 일시 중지 3 ✅ 운영자가 연락해야 풀림 해지 19 ❌ 총 회차 완료 1 ❌ 구독 없음 1 ❌ 구독이 해지돼도 주문 상태는 결제 완료로 남아 있었다. 30건 중 21건이 이미 끝난 구독이었던 것이다. 주문은 "그때 결제가 어떻게 됐나"의 기록이고, 구독은 "지금 살아 있나"의 상태다. 둘이 같은 질문에 답한다고 가정한 게 틀렸다. 여기서 하나 더 배운 게 있다. 일시 중지(3건)를 포함할지 말지 가 애매했는데, 코드를 따라가 보니 구독이 일시 중지되는 경로는 하나뿐이었다. 학부모가 자동결제 카드를 삭제했는데 대체 카드가 없을 때. 스케줄러는 진행 중인 구독만 결제하므로 그 상태로는 저절로 풀리지 않는다. 운영자가 연락해야 하는 건이라 카드에 포함했다. 그리고 이 판단을 도메인 문서에 「살아 있는 구독 = 진행 중 + 일시 중지」라고 정의로 적어두고 , 같은 판정을 쓰는 다른 코드들과 공용 상수로 묶었다. 다음 사람이 같은 고민을 반복하지 않게. 3. 시안의 글자가 그대로 배포돼 있었다 세 번째 카드의 부제는 「본원 결재 대기 3건」이었다. <CardSubtitle>본원 결재 대기 3건</CardSubtitle> 하드코딩된 문자열이었다. 실제 건수와 무관하게 영원히 3건 이었다. 변명의 여지가 있긴 하다. 퍼블리싱 단계에서 서버에 해당 값이 없었고, 그때 PR 에 "서버 값이 없어 시안 그대로 둔다"고 적어두긴 했다. 그 메모는 PR 에만 남았고, 화면에는 아무 표시도 없었다. 그리고 몇 주가 지났다. 지금은 집계를 붙여 실제 값을 넣었다. 하지만 진짜 교훈은 다른 데 있다. "나중에 연결할 값"을 진짜처럼 생기게 두면 안 된다. 세 가지 중 하나를 했어야 했다. 값을 — 로 두고 "연동 전"을 화면에 표시한다 그 영역을 아예 렌더링하지 않는다 최소한 // TODO: 가 아니라 실패하는 테스트 를 남긴다 PR 본문의 메모는 그 PR 을 읽는 사람에게만 보인다. 몇 주 뒤 그 화면을 보는 사람에게는 안 보인다. 네 번째는 안 고쳤다 카드가 하나 더 있었다. 재택 강사 미답변 건수. 이것도 당연히 틀렸을 거라 생각하고 운영 DB 에서 세어봤는데 — 화면 값과 정확히 일치했다. 그래서 안 건드렸다. 이것도 기록해둘 만하다고 생각한다. 세 개가 틀렸다고 네 번째도 틀렸을 거라 단정하고 "개선"했으면, 맞던 걸 망가뜨렸을 것이다. 남은 세 가지 1 지표는 출처가 전부다. 카드에 적힌 숫자가 아니라 그 숫자가 어느 테이블의 어느 조건에서 나왔는지 가 지표의 정의다. 「미답변」, 「대기」, 「실패」 같은 단어는 사람마다 다르게 읽히고, 코드는 그중 하나만 구현한다. 어느 하나인지를 문서에 적지 않으면 아무도 모른다. 2 화면을 믿지 말고 DB 에서 세어봐라. 이번에 고친 것 중 코드만 읽어서 찾은 건 하나도 없다. 전부 운영 DB 에서 직접 세어보다가 "어? 이 숫자 왜 이러지" 하고 발견했다. 집계 코드는 조용히 틀린다. 3 숫자가 사실인지는 그 숫자로 일하는 사람이 가장 먼저 안다. 이 모든 건 "명단 어디서 봐요?"라는 질문에서 시작됐다. 대시보드를 만들었으면, 그 숫자로 실제로 일을 해보거나 일하는 사람 옆에 앉아 있어야 한다. 집계 카드 체크리스트 다음에 집계 화면을 만들 때 쓰려고 정리해뒀다. 이 숫자의 명단 을 뽑을 수 있나? 못 뽑으면 그 숫자는 검증된 적이 없는 것이다 전칭/존재 조건을 집계 함수로 옮길 때 MIN/MAX 가 맞나? 판정 대상 본인이 집계에 섞이지 않나? 상태를 세고 있나, 지금 살아 있는 것 을 세고 있나? 둘은 다른 테이블일 수 있다 화면에 "아직 연동 안 됨"을 표시할 자리가 있나? 없으면 가짜 값이 진짜로 보인다 세부 항목의 합이 큰 숫자와 항상 같은가? 이 불변조건을 테스트로 고정했나? 안 틀린 카드도 확인은 했나? 다음 글에서는 이 작업을 하다가 만난 더 불편한 문제를 쓰려고 한다. 집계가 틀린 게 아니라, 데이터 자체가 틀어져 있던 계정 334개를 되살리면서 내가 쓴 분석을 스스로 뒤집은 이야기다.
velog
세상과 끼어맞추기식으로 살아야해서 너무우울할때가있다.. 왜세상은 공존은모르고 파괴만 되풀이할까.... 생각보다 세상은 상대의배려에 배려로 오는경우가 드물다는것도 느꼈다.
Score: 54.37Confidence: 49%
View offervelog
CS 실시간 대시보드 요청서가 왔다. 위젯 5종. 전체 미처리 건수 경로별 미답변 문의 유형별 분포 담당자별 처리 부하(%) SLA 경보 데이터를 뒤져보고 2종만 만들었다. 나머지 3종은 "왜 못 만드는지"를 PR 본문에 표로 적어서 냈다. 이 글은 그 표에 대한 이야기다. 먼저 데이터를 본다 요청서를 받으면 바로 화면부터 그리고 싶어진다. 위젯 5개면 카드 5개니까. 그런데 대시보드 위젯은 데이터가 있어야 존재할 수 있다. 그래서 순서를 뒤집었다. 5개 각각에 대해 "이 값을 지금 데이터로 계산할 수 있나"를 먼저 확인했다. 1 전체 미처리 건수 — 이미 있다 화면에 이미 있는 카드가 정확히 그 값이었다. 요청서를 쓴 사람이 다른 이름으로 불렀을 뿐이다. 만들지 않고, 기존 카드가 그 값이라고 답했다. 같은 숫자를 두 번 보여주는 카드를 추가하면, 둘이 어긋나는 날 아무도 어느 쪽이 맞는지 모른다. 2 경로별 미답변 — 만들었다 (단, 예외를 뒀다) 이건 만들었는데, 설계에서 한 가지를 어겼다. 이 값만 화면 필터를 따르지 않는다. 대시보드의 다른 값들은 전부 "지금 보고 있는 범위"를 따른다. 그게 일관성이고, 보통은 그게 맞다. 그런데 이 값의 목적은 "세 경로 중 어디가 밀려 있나" 를 보는 것이다. 탭으로 한 경로를 선택하면 나머지 두 경로가 항상 0이 되고, 그러면 값이 존재할 이유가 사라진다. 일관성을 지키면 기능이 없어지는 경우다. 그래서 예외를 두되, 예외라는 사실을 카드 라벨에 적었다. 경로별 미답변 · 탭·필터 무관 이 한 줄이 없으면 사용자는 "필터를 걸었는데 숫자가 안 변하네? 버그네"라고 생각한다. 의도된 예외와 버그는 화면에서 구별되지 않는다. 구별해주는 건 라벨뿐이다. 단, 권한 범위는 지켰다. 필터는 무시해도 자기가 볼 수 없는 건은 여전히 안 보인다. 편의를 위한 예외가 권한을 뚫으면 그건 예외가 아니라 사고다. 3 문의 유형별 분포 — 만들었는데, 쓰려던 컬럼을 못 썼다 유형별로 세면 되는 간단한 일이었다. 통합 테이블에 category 컬럼이 있었다. 세어봤더니 대상 12,259건이 전부 같은 값 이었다. 색인할 때 기본값으로 OTHER 를 넣고 있었고, 아무도 실제 값으로 채우지 않았다. 컬럼이 있다고 데이터가 있는 게 아니다. 그래서 원본 세 테이블의 유형 컬럼을 합쳐서 셌다. 경로마다 enum 이 달라서, 서버는 원본 값을 그대로 내려주고 화면이 기존 유형 필터와 같은 표 로 이름을 붙이게 했다. 필터에서 고르는 이름과 분포에 뜨는 이름이 달라지면 안 되니까. 상위 5개만 내려준다. 세 경로를 합치면 유형이 22종을 넘어서 카드에 안 들어간다. 못 만든 두 개 여기서부터가 이 글의 본론이다. 4 담당자별 처리 부하 — 구조적으로 불가능 "담당자별로 몇 건씩 들고 있나"를 보려면 문의마다 담당자가 있어야 한다. handler_id 라는 컬럼이 있었다. 이름만 보면 담당자다. 코드를 따라가 보니 이 값이 채워지는 시점은 답변을 등록할 때 였다. 즉 "담당자"가 아니라 "답변한 사람" 이다. 그래서: 미처리 건에는 구조적으로 담당자가 없다 — 아직 아무도 답을 안 했으니까 "담당자별 미처리 건수"로 범위를 낮춰도 항상 0 이다 진짜로 만들려면 문의를 담당자에게 배정하는 절차 가 먼저 있어야 한다. 그건 위젯이 아니라 업무 흐름이다 이건 "데이터가 없다"가 아니라 "그 개념이 제품에 없다" 였다. 5 SLA 경보 — 기준이 0건 "마감 임박 건을 경고로 띄워 달라." 마감 시각 컬럼 sla_due_at 이 있었다. 대상 12,259건 중 sla_due_at 이 채워진 건: 0 시작 시각( sla_started_at )은 전부 있었다. 시작은 자동으로 찍히는데 마감 기준을 정한 사람이 없었던 것이다. 몇 시간 안에 답해야 하는지가 어디에도 정의돼 있지 않으니, 경보를 띄울 선이 없다. 표로 적어 낸 이유 PR 본문에 이렇게 넣었다. 위젯 판단 근거 전체 미처리 건수 이미 있음 기존 카드가 그대로 그 값 담당자별 처리 부하 불가 배정 절차가 없음. handler_id 는 답변자 SLA 경보 불가 대상 12,259건 중 sla_due_at 이 0건 세 가지를 노린 것이다. 첫째, 요청자가 다음 결정을 할 수 있다. "못 만듭니다"로 끝나면 대화가 끝난다. "배정 절차가 없어서 못 만듭니다"라고 하면, 요청자는 *"그럼 배정 절차를 만들까?"* 또는 *"그건 나중에 하고 다른 걸 먼저"* 를 고를 수 있다. 공을 되돌려주는 게 아니라 선택지를 주는 것이다. 둘째, 6개월 뒤 같은 요청이 다시 온다. 그때 이 표가 없으면 또 같은 조사를 한다. 있으면 "그때 이랬는데 지금은 바뀌었나?"만 확인하면 된다. 조사 결과는 코드보다 오래 산다. 셋째, 숫자를 적어야 믿는다. "SLA 데이터가 부실합니다"는 의견이고, "12,259건 중 0건"은 사실이다. 전자는 반박당하고 후자는 다음 행동으로 이어진다. 안 만드는 것도 결정이다 주니어 때 나는 "못 한다"를 말하는 게 능력 부족을 인정하는 거라고 생각했다. 그래서 어떻게든 만들었다. 담당자별 부하 같은 걸 요청받으면, handler_id 로 대충 그룹핑해서 뭔가 그럴듯한 숫자가 나오는 카드 를 만들었을 것이다. 그게 제일 나쁘다. 아무도 그 숫자가 "답변한 사람 기준이라 미처리 건에는 해당 없음"이라는 걸 모른 채, 그 숫자로 사람을 평가하게 된다. 데이터가 없는 위젯을 만들면, 없는 데이터가 있는 것처럼 보인다. 빈 카드보다 나쁘다. 그래서 요즘은 요청서를 받으면 화면보다 데이터를 먼저 본다. 그리고 못 만드는 게 나오면, 못 만든다는 말 대신 무엇이 있어야 만들 수 있는지 를 적는다. 다음 글은 "없던 개념을 만든" 쪽 이야기다. 마케팅 수신 동의가 참/거짓 한 칸 으로만 저장돼 있던 걸, 분쟁에서 증명 가능한 기록으로 바꾸는 데 네 번의 PR 이 걸렸다.
Score: 54.38Confidence: 49%
Score: 54.38Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%
Score: 54.37Confidence: 49%