계좌 A에서 20을 빼고 B에 20을 더하는 상황을 가정하겠습니다. 첫 UPDATE만 성공하면 합계가 사라집니다. 이번에는 여러 변경을 한 작업으로 묶고, 취소하면 원래 상태를 유지하는 이유 를 살펴보겠습니다. 실제 금융 서비스 설계가 아닌 합성 데이터 예제입니다. 문장 하나씩 성공하는 것과 업무가 성공하는 것 트랜잭션은 여러 단계를 하나의 전부 또는 전무 작업으로 묶습니다. BEGIN 으로 시작해 성공한 변경을 COMMIT 하고, 취소하면 ROLLBACK 합니다. PostgreSQL의 기본 설명은 공식 트랜잭션 문서 를 참고했습니다. 그림은 논리적 변경 범위를 설명합니다. 디스크 쓰기 순서나 장애 복구 로그의 구현 그림은 아닙니다. 메모리 DB에서 취소를 확인하기 아래 SQL을 SQLite의 독립 메모리 DB 에서 실행했습니다. PostgreSQL 서버를 테스트한 결과는 아닙니다. 이 SQL 부분집합으로 취소되는 상태를 확인합니다. CREATE TABLE account (id INTEGER PRIMARY KEY, balance INTEGER NOT NULL); INSERT INTO account VALUES (1, 100), (2, 50); BEGIN; UPDATE account SET balance = balance - 20 WHERE id = 1; UPDATE account SET balance = balance + 20 WHERE id = 2; SELECT id, balance FROM account ORDER BY id; ROLLBACK; SELECT id, balance FROM account ORDER BY id; 트랜잭션 안에서는 (1, 80), (2, 70) 을 읽었습니다. ROLLBACK 뒤에는 (1, 100), (2, 50) 으로 돌아왔습니다. 실제 영구 저장, 장애 복구와 동시 접속은 이 실험의 검증 범위가 아닙니다. ACID를 보장 목록만으로 외우지 않기 원자성은 변경을 한 단위로 취급하는 성질, 일관성은 정의한 규칙을 만족하는 상태 유지, 격리는 동시 작업의 상호작용을 제한하는 성질, 지속성은 완료된 변경을 보존하는 성질로 생각할 수 있습니다. 하지만 업무 규칙은 제약과 애플리케이션에도 구현해야 합니다. 트랜잭션을 썼다고 “잔액 음수 금지”가 자동 생성되지는 않습니다. PostgreSQL의 Read Committed에서는 한 트랜잭션의 두 SELECT가 다른 시점의 커밋된 값을 볼 수 있습니다. 격리 수준에 따라 허용되는 현상이 다르므로 트랜잭션이면 모든 경쟁 문제가 사라진다고 보면 안 됩니다. PostgreSQL 18 격리 수준 확인 문제 두 UPDATE를 각각 자동 커밋하면 하나의 취소 단위일까요? 위 코드에서 ROLLBACK을 COMMIT으로 바꾸면 잔액은 어떻게 될까요? 메모리 DB의 ROLLBACK 성공으로 정전 후 지속성을 입증할 수 있을까요? 답과 해설 아닙니다. 업무 단위에 맞는 트랜잭션 경계를 정해야 합니다. 이 합성 예제에서는 80과 70을 유지합니다. 아닙니다. 영구 저장과 실제 장애 복구는 별도 검증 대상입니다. 오늘은 실패할 수 있는 단계와 취소 범위를 적어보겠습니다. 내일은 잔액 조건을 추가하고, 일주일 뒤에는 동시 작업의 격리 수준을 비교해보면 좋겠습니다. 다음은 조회할 행을 찾는 인덱스입니다. 자료 확인 기준: PostgreSQL 18 공식 문서, 2026-10-04. SQL 실행은 독립 SQLite 메모리 DB의 원자적 취소만 확인합니다. 예제 검증 환경: Python 3.13.13의 SQLite 3.51.2, 독립 :memory: DB.
재고 차감에 여러가지 고민을 했던 저로써는 Shopify가 Redis로 돌리던 재고 예약을 MySQL로 옮기고도 Black Friday를 버텼다는 엔지니어링 글 을 재밌게 읽었습니다. 해당 글을 읽고 정리해봤습니다. 🧐 Redis를 걷어낸 이유 E-commerce 구현할 때는 "재고 확보" 개념이 중요합니다. 재고가 없는데 주문이 되면 사과 메일을 보내야 하고, 재고가 있는데 품절이라고 하면 팔 수 있던 걸 놓칩니다. Black Friday 2025 피크에 Shopify 머천트 매출이 분당 5.1M 달러였고, 그 거래마다 재고를 확인합니다. 재고 예약은 두 동작으로 나뉩니다. 동작 시점 하는 일 Reserve 결제 시작 재고를 수 분 동안 잡아 둠 Claim 결제 성공 재고 원장(source of truth)에서 수량을 영구 차감 아이템마다 수량 키를 두고 reserve는 DECR , release는 INCR 입니다. Redis는 이걸 잘 처리했습니다. 걸림돌은 예약은 Redis에, 원장은 MySQL에 있다는 점이었습니다. 📌 Reserve, Release Reserve : 재고를 임시로 확보/예약하는 것 (재고 10개 -> 9개) Release : 예약했던 재고를 해제/반납하는 것 (재고 9개 -> 10개) Claim에서 문제가 생깁니다. MySQL 원장을 차감하는 일과 Redis 예약을 지우는 일을 한 번에 묶을 수 없습니다. 어느 쪽을 먼저 하느냐에 따라 팔렸는데 원장이 안 줄거나( oversell ), 원장은 줄었는데 예약이 남아 있습니다( undersell ). 다중 위치( multi-location ) 재고 [^1] 를 모르는 구조였고, Redis 클러스터 운영비용도 추가됩니다. 기존 동작 방식 예시 초기 값 MySQL 원장 : 1개 Redis 예약 가능 수량 : 1개 user A 의 결제 시작 user A 가 결제 시작하면 Redis 에서 reserve 를 합니다. Redis 에서 DECR 명령 MySQL 원장 : 1개 Redis 예약 가능 수량 : 0개 아직 결제가 확정되지 않았기 때문에 일단 먼저 Redis 에서만 DECR 명령을 통해 "재고 확보"를 합니다. 결제 확정 (Claim) MySQL 원장 : 1 -> 0개 Redis에 있던 임시 예약을 제거 MySQL, Redis 를 동시에 다루기 때문에 한 트랜잭션에서 처리할 수 없습니다. 즉, 순차적으로 처리하면 두 저장소 간에 상태 차이가 있을 수 밖에 없습니다. 그 사이 새로운 요청이 들어오거나 하면 undersell, oversell 이 발생할 수도 있습니다. 이어서 한 트랜잭션으로 묶지 못하기 때문에 실패 시 롤백도 자연스럽게 되지 못합니다. 🧱 아이템당 1행에서 재고 1개당 1행으로 아이템당 1행에 quantity 컬럼을 두는 모델이었습니다. 인기 상품이면 모든 예약 요청이 같은 행 하나의 락을 기다립니다. 기존 락 동작 방식 3 명의 유저가 (거의) 동시에 A 상품을 3개씩 재고 확보 요청한다고 가정해보면, 아래와 같이 동작합니다. 테이블에는 아래와 같이 들어갈 것입니다. item 재고 A 10 유저들이 순차적으로 기다리고, 락을 걸고, 커밋되고, 반복됩니다. user1 : A 상품 행 LOCK -----> COMMIT user2 : ...... WAIT .......... A 상품 행 LOCK -----> COMMIT user3 : ...... WAIT ..................................... A 상품 행 LOCK 그래서 Shopify 는 발상을 바꿔서 재고 1개를 1행으로 만들었습니다. 재고가 10개면 10행이고, 3개를 예약하면 한 트랜잭션에서 3행을 골라 옮깁니다. 여기에 SKIP LOCKED 가 붙습니다. 잠긴 행은 기다리지 않고 결과에서 빼 버리는 옵션이라( MySQL 8.4 매뉴얼 ), 동시에 들어온 체크아웃이 서로 다른 행을 가져갑니다. 수정한 락 동작 방식 id item 1 A 2 A 3 A 4 A 5 A 6 A 7 A 8 A 9 A 10 A user1 : id 1,2,3 LOCK user2 : (SKIP LOCKED 에 의해) id 4,5,6 LOCK user3 : id 7,8,9 LOCK 이 발상은 37signals의 Solid Queue [^2] 에서 가져왔다고 합니다. Solid Queue도 Resque(Redis) 대신 DB를 택했고, 워커들이 FOR UPDATE SKIP LOCKED 로 서로 안 기다리고 잡을 가져갑니다( Introducing Solid Queue ). Shopify는 이 구조를 재고 예약에 적용해, 작업(Job) 대신 재고 1개를 하나의 행으로 만들고 여러 체크아웃이 서로 다른 재고 행을 나눠서 예약하도록 했습니다. Shopify가 올린 reserve 흐름입니다( gist ). 트랜잭션은 READ COMMITTED 로 시작합니다. -- MySQL 8.0.1+ BEGIN; SELECT id FROM available_units WHERE shop_id = ? AND inventory_item_id = ? AND inventory_group_id = ? ORDER BY shop_id ASC, inventory_item_id ASC, inventory_group_id ASC, id ASC LIMIT 3 FOR UPDATE SKIP LOCKED; INSERT INTO reserved_units (unit_id, cart_token, expires_at, ...); DELETE FROM available_units WHERE (shop_id, inventory_item_id, inventory_group_id, id) IN (?, ?, ?, ?); COMMIT; 📌 SQL 흐름 행이 너무 많아지면 재고가 5만 개인 아이템이 위치 10곳에 있으면 50만 행입니다. 예약 쿼리가 그만큼을 훑어야 하니 느려집니다. 그래서 item/location 조합마다 가용 행을 최대 1,000개만 두고, 예약이 행을 다 써 버리면 보충 프로세스가 원장에서 다시 채웁니다. 1,000은 플래시 세일 때 관측한 item/location별 피크 예약률을 기준으로 잡은 값이라고 합니다. 버스트를 받아낼 만큼은 크고, 스캔이 빠를 만큼은 작아야 합니다. 풀이 바닥나면 reserve 에서 바로 보충합니다. 이때 락을 잡아 한 트랜잭션만 보충하고, 같은 아이템의 다른 예약은 그게 끝나길 기다립니다. 다들 한꺼번에 INSERT하려고 달려드는 thundering herd를 막으려는 장치입니다. 즉, 풀이 비어있는 경우 첫 예약은 락을 걸고 보충을 합니다. 그 예약 하나는 느려지지만, 재고가 있는데 구매자가 돌아가는 일은 없습니다. 🔒 락 설계에서 만난 문제 네 가지 프로토타입은 Rails 없이 작은 Ruby 스크립트와 MySQL만으로 만들고, 다른 터미널에서 락을 들여다보며 고쳤다고 합니다. 1. auto-increment PK에서는 예약 1건에 행 락이 2개 처음에는 id 를 auto-increment PK로 사용했습니다. PRIMARY KEY (id) 하지만 실제 예약 쿼리는 id 가 아니라 아래 컬럼들로 재고를 찾습니다. shop_id inventory_item_id inventory_group_id 즉, MySQL은 먼저 이 컬럼들로 구성된 보조 인덱스에서 재고를 찾고 , 그곳에 저장된 id 를 이용해 다시 PK(clustered index) 로 실제 행을 찾아가야 했습니다. WHERE 조건 ↓ 보조 인덱스 🔒 ↓ PK(id)로 실제 행 조회 ↓ clustered index 🔒 FOR UPDATE 가 붙어 있기 때문에 이 과정에서 보조 인덱스 레코드와 PK 레코드가 각각 잠겼습니다. 결과적으로 재고 1개를 예약하는데 인덱스 락이 2개 필요했던 것 입니다. InnoDB의 행 락은 실제로는 "행 자체"가 아니라 인덱스 레코드에 걸리기 때문 입니다. 따라서 어떤 인덱스를 통해 행을 찾느냐에 따라 필요한 락 수도 달라질 수 있습니다. 이를 해결하기 위해 예약 쿼리에서 항상 사용하는 컬럼들을 PK에 포함시켰습니다. PRIMARY KEY ( shop_id, inventory_item_id, inventory_group_id, id ) 이제 예약 쿼리가 별도의 보조 인덱스를 거치지 않고 PK를 바로 사용해 재고 행을 찾을 수 있습니다. WHERE 조건 ↓ 복합 PK 🔒 따라서 Shopify가 관찰한 락 수가 기존: 재고 1개 → 락 2개 변경: 재고 1개 → 락 1개 로 줄었습니다. 핵심은 AUTO_INCREMENT 자체가 문제였던 것이 아니라, 예약할 때 사용하는 검색 조건과 PK 구조가 서로 맞지 않았다는 점 입니다. 재고 예약이 초당 대량으로 발생하는 환경에서는 이런 차이가 누적되기 때문에, PK와 인덱스 설계가 락 경합과 처리량에 직접적인 영향을 줍니다. 2. 빈 테이블에서 잡히는 gap lock 💡 용어 정리 예를 들어 인덱스 값이 10.....20......30....∞ 이라면, Record Lock 은 특정 값 (ex. 20) 에 Lock 을 거는 것, Gap Lock 은 사이에 있는 값 (10 과 20 사이에 새 값) INSERT 하는 것을 Lock 거는 것, Supremum 은 인덱스 끝을 나타내는 가상 레코드. 락이 걸리는 방법과 동작방식 by ChatGPT 보충이 필요할 만큼 가용 행이 비었을 때 SELECT ... FOR UPDATE SKIP LOCKED 를 실행하면 gap lock이 잡혔습니다. supremum에도 걸려 있었고, 이 락이 보충 트랜잭션의 INSERT를 막아 데드락까지 갈 수 있었습니다. 빈 테이블인데 무슨 락이 잡히는지가 처음에는 이상합니다. InnoDB는 인덱스 레코드 사이의 빈 구간도 잠급니다. 인덱스에 10, 11, 13, 20이 있을 때 next-key lock이 덮는 구간은 이렇습니다. (-inf, 10] (10, 11] (11, 13] (13, 20] (20, +inf) 맨 아래 구간이 20 뒤의 gap과 supremum(어떤 값보다도 큰 가상 레코드)입니다. 여기가 잠기면 더 큰 키의 INSERT는 전부 막힙니다. Jahfer Husain의 글 에는 가장 큰 author_id 행을 갱신했더니 그 뒤로 INSERT가 다 막히는 예제가 있고, Shopify도 이 글을 링크해 뒀습니다. 기본 격리 수준인 REPEATABLE READ 는 검색과 인덱스 스캔에 next-key lock을 씁니다. READ COMMITTED 에서는 잠금 읽기가 인덱스 레코드만 잠그고 gap은 안 잠급니다. gap 락은 외래 키 검사와 중복 키 검사에만 남습니다( Transaction Isolation Levels ). 그래서 이 트랜잭션만 READ COMMITTED 로 내렸습니다. 빈 범위에는 잠글 레코드가 없으니 검색 범위 전체가 gap 하나로 잡힌 것 같습니다. 기본값이 아닌 격리 수준을 쓴 건 이 코드베이스에서 처음이라, 트랜잭션별로 격리 수준을 지정하는 프레임워크 지원을 조금 붙여야 했다고 합니다. READ COMMITTED 는 같은 범위를 다시 읽으면 새 행이 보일 수 있는데, 이 흐름은 읽은 행을 다시 읽지 않고 바로 옮기니 문제 될 일이 적어 보입니다. 3. 두 테이블을 서로 다른 순서로 잠그면 데드락 reserve는 reserved_quantities 에 INSERT 한 다음 reservation_units 에서 DELETE 했고, claim은 reserved_quantities 에서 DELETE 했습니다. 두 트랜잭션이 테이블을 반대 순서로 잠그다가 서로를 기다렸습니다. reserve가 units 쪽 DELETE 를 먼저, reserved_quantities 쪽 INSERT 를 나중에 하도록 순서를 고정했습니다. claim은 reserved_quantities 만 건드리니 이제 잠그는 순서가 같아서 순환 대기가 생기지 않습니다. 📌 데드락이 발생하는 이유 서로 다른 트랜잭션이 같은 자원을 다른 순서로 락을 걸면 생길 수 있는 문제입니다. 예를 들어, T1, T2 라는 트랜잭션에서 A,B 라는 자원(테이블)에 락을 거는 상황에서 T1 : A -> B T2 : A -> B 위와 같이 락을 걸면 T1 이 A 를 잡고 있는 순간 T2 는 A 에 접근을 못하므로 대기하기 때문에 데드락이 발생할 수 없습니다. 하지만, T1 : A -> B T2 : B -> A 위와 같은 순서로 락을 건다면, T1 이 A 를 잡고 있을 때 T2 가 B 를 잡아버리면 T1 이 다음 B로 넘어갈 때 T2 때문에 접근을 못하고 T2 역시 T1 때문에 A 에 접근을 못하는 데드락이 발생합니다. 즉, 순환구조가 일어날 때 데드락이 발생합니다. 4. UNION ALL로 왕복 줄이기 DB 왕복도 비용입니다. 카트에 라인 아이템이 여러 개면 UNION ALL 로 필요한 행을 한 번에 가져옵니다( gist ). -- MySQL 8.0.1+ (SELECT id, inventory_item_id, inventory_group_id FROM available_units WHERE shop_id = 1 AND inventory_item_id = 100 AND inventory_group_id = 1 ORDER BY shop_id, inventory_item_id, inventory_group_id, id LIMIT 2 FOR UPDATE SKIP LOCKED) UNION ALL (SELECT id, inventory_item_id, inventory_group_id FROM available_units WHERE shop_id = 1 AND inventory_item_id = 200 AND inventory_group_id = 1 ORDER BY shop_id, inventory_item_id, inventory_group_id, id LIMIT 5 FOR UPDATE SKIP LOCKED) 📉 진짜 병목은 커넥션이었다 쿼리와 락을 다 손봤는데도 프로덕션 처리량은 목표보다 한참 아래에서 막혔습니다. P90 지연은 괜찮았고 CPU도 남았습니다. 부하 테스트를 돌리면 MySQL에 스레드가 쌓였고, 쌓인 작업이 풀릴 때 CPU가 튀었고, ProxySQL 쪽에서는 MySQL 백엔드 커넥션이 바닥났습니다. 여러 체크아웃의 예약을 한 SKIP LOCKED 쿼리로 묶어 커넥션을 아끼는 방법도 해 봤습니다. 부하 테스트에서는 효과가 있었지만 복잡해지기만 했습니다. 읽기 일부를 replica로 옮겨 봐도 계산이 안 맞았다고 합니다. 커넥션이 모자란다는 것만으로는 누가 쥐고 있는지 모릅니다. 그래서 앱에서 모든 SQL에 어떤 업무인지 알려 주는 주석을 붙이고, ProxySQL이 그 태그를 읽어 업무별로 커넥션을 쥐고 있던 시간을 합산하게 했습니다. /* conn_tag:checkout_completion */ SELECT ... 쿼리가 빠르든 느리든 상관없이, 커넥션을 오래 쥐는 업무가 그대로 드러났습니다. 예약이 특별히 무거웠던 건 아닙니다. 체크아웃 경로의 다른 코드들이 커넥션을 필요 이상으로 오래 잡고 있었고, 예약은 이미 바닥이 보이던 풀에 마지막으로 얹힌 부하였습니다. 체크아웃 path를 정리해서 primary DB의 읽기를 50%, 트랜잭션을 33% 덜어냈습니다. 수년 전에 보수적으로 잡아 둔 채 그대로였던 innodb_thread_concurrency 도 지금 워크로드에 맞게 올렸습니다. 플래시 세일 중에도 writer CPU는 50%, reader CPU는 16%를 넘지 않았다고 합니다. 저장소를 바꾸는 이유는 보통 성능일 거라고 생각했는데, 여기서는 속도는 문제가 아니었고 원장과 원자적으로 묶을 수 없다는 구조가 문제였습니다. 또한, Solid Queue 라는 해결방식도 매우 인상적이었습니다. Redis DECR 로 재고확보하는 기존 방식이 틀린 선택은 아닙니다. 대부분의 상황에서는 충분히 유용합니다. 게다가 리팩토링 수치에 대해서는 Shopify가 직접 쓴 것이라 다른 환경에서 그대로 나온다고 볼 수는 없습니다. 🔗 Ref https://shopify.engineering/scaling-inventory-reservations https://gist.github.com/CourtneySymons/cb5ecbe86331047aae166d5b1f1d555c https://gist.github.com/CourtneySymons/9f7ce64765a02fc417a6a85c68a48322 https://dev.37signals.com/introducing-solid-queue/ https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html https://dev.mysql.com/blog-archive/mysql-8-0-1-using-skip-locked-and-nowait-to-handle-hot-rows/ https://jahfer.com/posts/innodb-locks/ https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing [^1] 다중 위치( multi-location ) 재고 : 같은 상품의 재고가 여러 물리적 위치에 나뉘어 있는 경우 예를 들어, 서울 창고에는 3개, 부산창고에는 5개, 대구창고에는 2개 있어서 총 재고가 10개가 있는 경우입니다. [^2] Solid Queue : 37signals에서 개발한 Ruby on Rails용 데이터베이스 기반 Active Job 백엔드 아댑터입니다. Redis 인프라 없이도 RDBMS 만으로도 백그라운드 작업을 효율적으로 처리할 수 있도록 설계되었습니다. SELECT ... FOR UPDATE SKIP LOCKED 활용하여 여러 워커가 동시에 작동하더라도 락(Lock) 충돌이나 블로킹 없이 안전하고 빠르게 작업을 가져갑니다.
일반적으로 LLM의 훈련은 두 단계를 거친다. pre-training -> post-training pre-training pretraining 이란 대량의 텍스트 데이터를 통해서 next-token prediction 를 하는 것이다. 이 단계는 LLM 모델에게 일반적인 지식들을 주기 위해서 이루어진다. 이 과정에서 단순히 다음 단어 예측만 시켰는데, few-shot 만으로 여러 과제를 파라미터 업데이트 없이 수행할 수 있게 된다. (ICL) 이때 방법은 SSL (self-supervised learning)이다 (별도의 라벨링이 필요 없으므로) post-training 단순히 pre-trianing만 한다면 llm이 사용자의 지시를 일관되게 따르게 하기 어렵다. 따라서 특수한 목적에 맞게 수행하기 위해 하는 것이 post-training이다. 학습은 sft, rl 등 다양하게 한다. 예를 들어, 대화 능력을 가르치고 싶다고 하자. 그 중 한 가지 방법으로 대화 형식 데이터를 활용해서 sft를 할 수 있다. 모델마다 형식이 다른데 아래는 ChatML 형식이다. Attention 비슷한 단어들에 가중치를 두어 계산, softmax scaling (분산 1에 맞춤) Transformer 추론 시 인코딩 캐시 attention을 거칠수록 다른 의미의 동음이의어가 의미 벡터가 정해짐 (orange 예시)
책의 색인처럼 DB 인덱스도 원하는 행의 후보를 찾는 데 도움을 줍니다. 하지만 책의 대부분을 읽어야 한다면 색인을 왕복하는 일이 더 편하지 않을 수 있습니다. 이번에는 인덱스가 하는 일과 DB가 그것을 선택하는 일 을 구분해보겠습니다. 행을 찾는 보조 구조 PostgreSQL의 기본 B-tree 인덱스는 정렬 가능한 값의 동등·범위 조건을 지원합니다. 실제로 사용할지는 플래너가 비용을 비교해 선택합니다. 모든 조건과 모든 인덱스 종류가 같은 방식으로 동작하는 것은 아닙니다. PostgreSQL 18 인덱스 종류 그림은 조회 경로를 설명하는 모델입니다. 실제 B-tree 높이나 페이지 접근 횟수, 측정 시간을 나타내지 않습니다. EXPLAIN에서 달라지는 표시 보기 독립 SQLite 메모리 DB 에 합성 데이터 1,000행을 넣고 다음 SQL을 실행했습니다. PostgreSQL의 실행 계획을 테스트한 결과는 아닙니다. CREATE TABLE item (id INTEGER PRIMARY KEY, category INTEGER); WITH RECURSIVE seq(n) AS ( SELECT 0 UNION ALL SELECT n + 1 FROM seq WHERE n < 999 ) INSERT INTO item (id, category) SELECT n, n % 10 FROM seq; EXPLAIN QUERY PLAN SELECT id FROM item WHERE category = 7; CREATE INDEX item_category_idx ON item(category); EXPLAIN QUERY PLAN SELECT id FROM item WHERE category = 7; 이 실험에서는 인덱스 전 계획에 SCAN item , 생성 후 계획에 SEARCH item USING COVERING INDEX item_category_idx 가 표시됐습니다. SQLite 공식 EXPLAIN QUERY PLAN 설명 계획이 바뀐 사실을 확인한 것이지, 실제 실행 시간이 몇 배 줄었음을 측정한 것은 아닙니다. 다른 데이터 분포와 엔진 버전에서는 다른 선택이 가능합니다. 인덱스를 적용하기 전에는 조회 결과가 같은지도 함께 확인해야 합니다. 읽기만 있는 자료구조가 아니다 인덱스는 공간을 사용하고 데이터 변경 때 함께 관리됩니다. 많은 행을 읽는 조건이라면 전체 스캔이 유리할 수 있습니다. 인덱스가 있다고 항상 쓰는 것도, 계획에 인덱스가 나오면 전체 요청이 빨라지는 것도 아닙니다. 반환 행 수, 정렬, 조인과 다른 비용이 남습니다. 여러 열을 묶은 인덱스는 열의 순서와 조건 조합도 검토해야 합니다. 이번 글에서는 단일 열의 후보 검색까지만 다룹니다. 실서비스 인덱스를 만들기 전에 실제 쿼리와 데이터 분포를 기준으로 판단합니다. 확인 문제 인덱스가 있다는 사실과 플래너가 선택했다는 사실은 같을까요? 전체 행의 90%가 필요한 조회에서도 인덱스가 무조건 유리할까요? EXPLAIN 표시가 바뀌었다면 실행 시간이 줄었다고 확정할 수 있을까요? 답과 해설 아닙니다. 실제 실행 계획을 확인합니다. 아닙니다. 전체 스캔과 후보 조회의 비용을 비교해야 합니다. 안 됩니다. 동일한 조건에서 결과와 실행 비용을 별도로 측정해야 합니다. 오늘은 후보 찾기와 실제 행 확인을 나눠 설명해보겠습니다. 내일은 선택되는 행 비율을 바꾸고, 일주일 뒤에는 복합 인덱스의 열 순서와 연결해보면 좋겠습니다. 자료 확인 기준: PostgreSQL 18과 SQLite 공식 문서, 2026-10-04. 메모리 DB 계획·결과 검증이며 운영 DB의 성능 시험은 아닙니다. 예제 검증 환경: Python 3.13.13의 SQLite 3.51.2, 독립 :memory: DB.
음악을 들으면서 파일을 내려받고 글을 쓰면 컴퓨터가 여러 일을 동시에 하는 것처럼 보입니다. 이번에는 여러 작업이 진행 중이라는 말과, 같은 순간 계산한다는 말 을 구분해보겠습니다. 프로세스와 스레드의 기본 개념을 알고 있으면 읽기 편합니다. 한 코어의 시간표부터 보기 동시성은 여러 작업의 진행을 함께 다루는 성질입니다. 병렬성은 여러 작업이 같은 시간에 실제로 실행되는 성질입니다. 한 코어가 A와 B를 번갈아 실행해도 두 작업을 함께 진행할 수 있습니다. 여러 코어가 A와 B를 각각 실행하면 실제 실행 구간이 겹칠 수 있습니다. 스레드의 병렬 실행과 입출력 대기를 겹치는 이유는 OSTEP의 동시성 장 에서 확인할 수 있습니다. 그림의 칸은 설명용 구간입니다. 실제 밀리초나 측정한 성능을 뜻하지 않습니다. 상황 관찰할 것 한 코어에서 A, B 번갈아 실행 여러 작업이 진행되지만 그 코어의 실행은 번갈아 일어남 두 코어에서 A, B 실행 실제 실행 구간이 겹칠 수 있음 A가 파일 읽기를 기다리는 동안 B 실행 대기를 다른 유용한 작업과 겹침 비동기라면 계산도 빨라질까? 다음은 Node.js에서 비동기 콜백이 언제 실행되는지 보는 작은 예제입니다. const order = []; order.push("A:start"); Promise.resolve().then(() => order.push("B:callback")); order.push("A:end"); queueMicrotask(() => console.log(order.join(" -> "))); 검증한 출력은 A:start -> A:end -> B:callback 입니다. 이 예제는 마이크로태스크의 순서를 확인합니다. 코어 두 개를 사용하거나 CPU 계산을 병렬화한 실험은 아닙니다. Promise를 만들었다는 사실만으로 계산이 다른 코어로 이동하지 않습니다. Node.js의 queueMicrotask 설명 작업이 CPU 계산 때문에 오래 걸리는지, 응답을 기다리느라 오래 걸리는지부터 구분해야 합니다. 기다림을 겹칠 수 있는 작업과 계산을 나눌 수 있는 작업은 해결 방법이 달라집니다. 헷갈리지 않기 스레드 수, 비동기 함수 수, 코어 수는 서로 다른 숫자입니다. 스레드를 늘렸다고 실행 시간이 그 비율대로 줄어들지는 않습니다. 공유 상태를 보호하는 비용과 작업을 나누는 비용도 생깁니다. 이번 시간표는 실행 관계를 설명하는 모델이고 성능 예측표는 아닙니다. 확인 문제 한 코어에서 A와 B를 번갈아 실행하면 동시성인가요? 위 코드에서 Promise 콜백을 등록하면 A:end 보다 먼저 실행될까요? CPU 계산을 두 배 빨리 끝내려면 비동기 함수 두 개만 만들면 충분할까요? 답과 해설 네. 여러 작업을 함께 진행하지만 해당 코어에서 같은 순간 실행하는 것은 아닙니다. 아닙니다. 현재 동기 실행을 마친 뒤 등록한 콜백을 실행합니다. 아닙니다. 실제 병렬 실행 구조, 분할 가능한 계산, 전달 비용과 공유 자원 등을 확인해야 합니다. 복습과 다음 단계 오늘은 한 코어와 두 코어의 시간표를 직접 그려보겠습니다. 내일은 파일 읽기 대기 구간을 추가하고, 일주일 뒤에는 CPU 작업과 입출력 작업의 개선 방법을 각각 설명해보면 좋겠습니다. 다음에는 작업을 바꿀 때 실행 상태를 어떻게 이어 가는지 살펴봅니다. 자료 확인 기준: OSTEP 동시성 장과 Node.js 공식 API 문서, 2026-10-04. 예제 검증 환경: Node.js v24.13.1. 이전 기초 개념: 프로세스와 스레드 정리
화면을 새로고침했는데 개발자 도구에 304 가 보입니다. 본문을 다시 받지 않았다고 무조건 서버에 요청하지 않은 것은 아닙니다. 이번에는 저장, 재사용, 재검증 을 각각 나눠보겠습니다. 세 동작을 구분하기 저장은 응답을 캐시에 보관하는 일입니다. 재사용은 그 응답으로 다음 요청을 처리하는 일입니다. 재검증은 서버에 기존 응답이 여전히 유효한지 확인하는 일입니다. 이 구분과 지시자의 의미는 HTTP Caching RFC 9111 에서 확인할 수 있습니다. 그림은 저장 가능한 GET 응답의 기본 흐름입니다. 모든 상태 코드와 공유 캐시의 예외를 나타내지는 않습니다. 응답 지시자 핵심 의미 max-age=60 신선도를 판단하는 수명 기준 60초 no-cache 보관할 수 있지만 성공적인 재검증 없이 재사용하지 않음 no-store 해당 요청·응답에 대해 캐시가 저장하지 않도록 지시 private 공유 캐시는 저장하지 않음. 개인 캐시와 구분 ETag로 다시 확인하는 예시 서버가 응답에 ETag: "v1" 을 붙였다고 가정하겠습니다. 다음 요청은 If-None-Match: "v1" 로 현재 표현이 같은지 물을 수 있습니다. 조건이 맞으면 304 Not Modified 를 받아 저장된 본문을 재사용하고, 응답 헤더도 규칙에 맞게 갱신합니다. RFC 9111의 검증 const decide = (clientTag, serverTag) => clientTag === serverTag ? 304 : 200; console.log(decide('"v1"', '"v1"')); console.log(decide('"v1"', '"v2"')); 출력은 304 , 200 입니다. 태그 비교 분기만 검증한 단순 모형입니다. 실제 HTTP 구현은 메서드, 여러 태그, 약한 비교와 다른 조건부 요청 규칙도 처리해야 합니다. 네트워크 서버를 시험한 코드는 아닙니다. 캐시는 응답 하나만 보고 끝나지 않는다 요청의 인증 정보, Vary , 공유 캐시 여부와 저장 가능 조건을 함께 확인해야 합니다. 사용자별 응답을 공용으로 재사용하면 문제가 됩니다. 또 no-store 를 붙였다고 이미 모든 곳에 저장된 과거 복사본이 삭제되는 것은 아닙니다. “캐시를 껐다”보다 어느 계층에서 무엇을 금지했는지 설명하는 편이 정확합니다. 확인 문제 no-cache 는 저장 금지와 같은 말일까요? 304 라면 서버와 통신하지 않았다는 뜻일까요? 서버의 태그가 "v3" 로 바뀌면 위 모형의 결과는 무엇일까요? 답과 해설 아닙니다. 성공적인 재검증 없이 재사용하지 말라는 의미입니다. 아닙니다. 조건부 요청에 대한 서버 응답일 수 있습니다. 기존 클라이언트 태그와 다르므로 200입니다. 실제 응답 본문과 헤더 처리도 필요합니다. 오늘은 저장·재사용·재검증을 각각 한 문장으로 설명해보겠습니다. 내일은 개인 캐시와 공유 캐시를 나누고, 일주일 뒤에는 DNS TTL과 HTTP 신선도가 관리하는 대상을 비교해보면 좋겠습니다. 자료 확인 기준: RFC 9111, 2026-10-04. 코드의 출력은 모형 검증이며 실제 캐시 서버의 보안·성능 시험이 아닙니다. 예제 검증 환경: Node.js v24.13.1.
네트워크를 공부하면 “TCP는 안전하고 UDP는 빠르다”는 말을 자주 만납니다. 그런데 안전이 암호화를 뜻하는지, 전송 순서를 뜻하는지부터 모호합니다. 이번에는 프로그램이 받는 데이터의 형태와 전송 계층이 제공하는 성질 을 나눠보겠습니다. 바이트 흐름과 데이터그램 TCP는 애플리케이션에 신뢰성 있는 순서 있는 바이트 스트림을 제공합니다. UDP는 데이터그램 방식이며 전달, 중복 방지, 순서 보장을 기본으로 제공하지 않습니다. TCP RFC 9293 §2.2 , UDP RFC 768 그림의 “메시지 경계”는 프로그램이 정한 구분입니다. IP 패킷 크기나 실제 캡처 결과를 나타내지 않습니다. 확인할 것 TCP UDP 애플리케이션이 보는 기본 형태 바이트 스트림 데이터그램 보낸 메시지 단위의 유지 별도 프레이밍 필요 데이터그램 단위 누락·중복·순서 처리 TCP의 신뢰성 메커니즘 필요한 기능을 상위 계층에서 설계 두 번 보냈으면 두 번 받을까? TCP에 HELLO 와 WORLD 를 각각 보냈다고 가정하겠습니다. 수신 프로그램은 HELLOWORLD 를 한 번에 읽거나, 여러 조각으로 읽을 수 있습니다. 한 번의 send 와 한 번의 read 가 같은 업무 메시지라는 가정을 버려야 합니다. 줄바꿈을 메시지 구분자로 정한 단순 모형은 다음처럼 나눌 수 있습니다. const chunks = ["HEL", "LO\nWOR", "LD\n"]; const messages = chunks.join("").split("\n"); messages.pop(); console.log(messages.join(" / ")); 출력은 HELLO / WORLD 입니다. 합성 조각으로 경계 복원을 검증한 코드이며 실제 소켓 테스트는 아닙니다. 지속 연결에서는 남은 조각을 보관하고, 최대 길이와 문자 인코딩, 종료 상황도 처리해야 합니다. 신뢰성은 암호화와 다르다 TCP를 사용했다는 사실만으로 내용이 암호화되거나 상대를 인증하는 것은 아닙니다. TLS 같은 별도 계층을 확인해야 합니다. 또 UDP 위에 신뢰성 기능을 만들 수도 있으므로, UDP를 쓴다는 사실만으로 서비스 전체에 재전송이 없다고 단정할 수 없습니다. “항상 빠르다”는 결론도 실제 메시지 크기, 손실, 구현과 요구 조건 없이 내릴 수 없습니다. 확인 문제 TCP에서 읽은 한 조각이 항상 한 메시지일까요? TCP를 선택하면 자동으로 암호화될까요? UDP를 쓰는 서비스가 재전송을 구현할 수 있을까요? 답과 해설 아닙니다. 길이 헤더나 구분자 등 업무 메시지를 나누는 규칙이 필요합니다. 아닙니다. 전송 신뢰성과 암호화·인증은 다른 기능입니다. 가능합니다. 상위 프로토콜이나 애플리케이션이 기능을 추가할 수 있습니다. 오늘은 임의의 위치에서 문자열을 잘라 다시 합쳐보겠습니다. 내일은 구분자를 포함한 본문을 어떻게 처리할지 생각하고, 일주일 뒤에는 길이 헤더 방식과 비교해보면 좋겠습니다. 다음은 주소를 찾는 DNS입니다. 자료 확인 기준: RFC 9293의 핵심 TCP 성질, RFC 768의 기본 UDP 정의, 2026-10-04. 최신 확장 전체를 다루는 글은 아닙니다. 예제 검증 환경: Node.js v24.13.1.
작업 A는 락 L1을 잡고 L2를 기다립니다. 작업 B는 L2를 잡고 L1을 기다립니다. 둘 중 하나가 진행해야 다른 쪽도 진행할 수 있는데, 두 작업이 모두 멈췄습니다. 이번에는 이 작은 상황으로 교착 상태가 만들어지는 조건과 예방 방법 을 정리해보겠습니다. 기다림이 고리가 될 때 교착 상태를 설명하는 네 조건은 상호 배제, 점유하며 대기, 비선점, 순환 대기입니다. 락 예시에서는 각각 한 작업만 락을 가지며, 이미 잡은 락을 놓지 않고 다른 락을 기다리고, 다른 작업이 강제로 빼앗지 못하며, 대기가 고리로 이어집니다. OSTEP의 교착 상태 조건 이 그림은 각 락을 한 작업만 소유하는 모델입니다. 자원에 여러 인스턴스가 있는 일반적인 상황에서는 고리만 보고 결론을 내리면 안 됩니다. 작업 이미 소유 기다리는 자원 A L1 L2 B L2 L1 둘 다 상대가 놓아야 하는 락을 기다립니다. 단순히 실행 속도가 느린 상황과 구분해야 합니다. 공통 순서를 정해보기 모든 경로가 L1 → L2 순서로 락을 잡도록 정하면 이 두 락의 순환 대기를 피할 수 있습니다. “함수 인자 순서대로 잡는다”는 규칙은 충분하지 않습니다. 호출자가 인자를 뒤집으면 전체 프로그램의 순서는 다시 달라집니다. OSTEP의 락 순서 예방 설명 const order = (locks) => [...locks].sort((a, b) => a - b); console.log(order([2, 1]).join(" -> ")); console.log(order([1, 2]).join(" -> ")); 두 출력 모두 1 -> 2 입니다. 이 코드는 순서 규칙만 검증합니다. 실제 락을 획득하거나 운영체제의 교착 상태를 발생시킨 실험은 아닙니다. 실제 구현에서는 같은 락을 중복 획득하는지, 예외 때 해제하는지, 다른 경로도 규칙을 따르는지 확인해야 합니다. 타임아웃만 넣으면 해결일까? 대기를 중단할 수는 있지만, 잡아 둔 자원을 해제하고 부분 작업을 복구하는 절차가 필요합니다. 두 작업이 같은 패턴으로 계속 재시도하면 진행하지 못하는 다른 문제가 생길 수도 있습니다. 예방, 탐지, 복구를 한 단어로 묶지 말고 각각 어떤 규칙을 제공하는지 확인합니다. 확인 문제 A와 B가 모두 L1부터 잡으면 위 두 락의 순환 대기가 만들어질까요? 함수마다 인자 순서대로 잡는 규칙은 전체 순서를 보장할까요? 대기가 길다는 사실만으로 교착 상태를 확정할 수 있을까요? 답과 해설 이 모델에서는 아닙니다. 먼저 L1을 잡은 작업이 L2로 진행하고, 다른 작업은 L1을 잡기 전에 기다립니다. 아닙니다. 모든 호출 경로가 공유하는 자원 순서가 필요합니다. 아닙니다. 자원 소유자, 대기 관계와 진행 가능성을 확인해야 합니다. 오늘은 소유와 대기 화살표를 따로 그려보겠습니다. 내일은 락 세 개의 순서를 정하고, 일주일 뒤에는 타임아웃 뒤 복구할 상태를 설명해보면 좋겠습니다. 자료 확인 기준: OSTEP 교착 상태 절의 2026-10-04 확인본. 코드와 그림은 설명용 모형입니다. 예제 검증 환경: Node.js v24.13.1.
동영상을 보면서 편집기를 사용하는 상황을 생각해보겠습니다. CPU가 실행할 작업을 바꿨다가 돌아왔을 때, 편집기는 처음부터 시작하지 않습니다. 멈춘 위치와 실행 상태를 보관하고 복원하기 때문 입니다. 선택하는 일과 바꾸는 일 스케줄링은 다음에 실행할 작업을 선택하는 일입니다. 문맥 전환은 현재 실행 상태를 저장하고 다른 작업의 상태를 복원하는 일입니다. 두 용어를 같은 뜻으로 외우면 “누구를 선택했나”와 “어떻게 이어 갔나”가 섞입니다. 스레드의 문맥에는 실행 위치와 레지스터 상태 등이 포함됩니다. 같은 프로세스의 스레드 사이에서는 주소 공간을 공유하지만 실행 상태는 각각 관리합니다. OSTEP의 스레드 문맥 설명 그림은 핵심 절차를 줄인 모델입니다. 실제 커널 함수 호출 순서나 모든 저장 항목을 나타내지는 않습니다. 시간 조각을 나눠 주는 예시 라운드 로빈은 실행 가능한 작업을 시간 조각 단위로 번갈아 실행하는 대표적인 설명 모델입니다. OSTEP의 스케줄링 장 A와 B가 각각 계산 3단위가 필요하고, 시간 조각을 1단위라고 가정하겠습니다. 도착 시각은 같고, 입출력 대기와 전환 비용은 이번 계산에서 제외합니다. const remaining = { A: 3, B: 3 }; const trace = []; while (remaining.A + remaining.B > 0) { for (const job of ["A", "B"]) { if (remaining[job] > 0) { remaining[job] -= 1; trace.push(job); } } } console.log(trace.join(" ")); 검증한 출력은 A B A B A B 입니다. 이것은 JavaScript로 작성한 스케줄링 모형 이며, 운영체제의 실제 스케줄러를 실행하거나 측정한 결과는 아닙니다. 조각을 작게 만들면 무조건 좋을까? 작은 시간 조각은 다른 작업이 첫 실행 기회를 받기까지의 시간을 줄일 수 있습니다. 하지만 전환이 잦아지면 상태 저장·복원과 캐시 등에 영향을 주는 비용도 고려해야 합니다. 응답성과 총 처리량은 같은 목표가 아닙니다. OSTEP의 시간 조각과 전환 비용 설명 모든 인터럽트가 작업 교체로 이어지는 것도 아닙니다. 이벤트가 발생했다는 사실과 실제로 다른 실행 흐름으로 전환했는지는 구분해서 봐야 합니다. 확인 문제 다음 작업을 고르는 것은 스케줄링일까요, 상태 복원일까요? B가 이미 끝났다면 위 모형은 B를 계속 실행할까요? 전환 비용을 무시한 시간표로 실제 처리량을 단정해도 될까요? 답과 해설 스케줄링입니다. 선택한 작업으로 상태를 바꾸는 과정과 구분합니다. 아닙니다. 남은 계산이 0이면 실행하지 않습니다. 안 됩니다. 실제 작업과 전환 비용, 캐시, 입출력 등을 측정해야 합니다. 복습 오늘은 “선택 → 저장·복원 → 실행”을 말로 설명해보겠습니다. 내일은 A가 4단위, B가 2단위인 시간표를 그리고, 일주일 뒤에는 응답 시간과 완료 시간을 구분해보면 좋겠습니다. 다음에는 여러 작업이 서로 자원을 기다리는 문제를 살펴봅니다. 자료 확인 기준: OSTEP 스레드·스케줄링 장, 2026-10-04. 예제의 시간 단위는 설명용이며 실제 성능 수치가 아닙니다. 예제 검증 환경: Node.js v24.13.1.
“이 코드는 O(n)이라 빠르다”는 설명만으로 실제 시간을 알 수는 없습니다. 이번에는 입력 개수와 반복 횟수를 따로 세면서 증가율을 설명하는 표기와 측정 시간을 구분 해보겠습니다. 무엇을 세는지 먼저 정하기 Big-O는 입력 크기에 따른 비용의 점근적 상한을 설명합니다. 상수와 작은 항을 생략하므로 같은 O(n)인 코드라도 실제 실행 시간은 달라질 수 있습니다. 또 최악의 경우를 분석하는지, 평균 경우를 분석하는지는 별도 선택입니다. Open Data Structures의 수학적 배경 그림의 단위는 연산 횟수 모형 입니다. 초나 밀리초를 측정한 그래프가 아닙니다. 두 반복문을 실제로 세기 for (const n of [4, 8, 16]) { let linear = 0; let quadratic = 0; for (let i = 0; i < n; i += 1) linear += 1; for (let i = 0; i < n; i += 1) { for (let j = 0; j < n; j += 1) quadratic += 1; } console.log(`${n}: ${linear}, ${quadratic}`); } 검증한 출력은 다음과 같습니다. n 첫 반복문 중첩 반복문 4 4 16 8 8 64 16 16 256 여기서는 표시한 증가 연산을 셌습니다. 모든 CPU 명령어 수나 전체 실행 시간을 센 것은 아닙니다. n을 두 배로 늘리면 첫 값은 두 배, 두 번째 값은 네 배가 됩니다. 중첩이면 모두 O(n2)일까? 안쪽 반복문이 항상 n번 실행하는지 봐야 합니다. 안쪽이 고정 3번이면 전체 반복은 3n입니다. 안쪽 변수가 매번 두 배가 되는 경우는 증가 방식이 다릅니다. 중첩된 모양보다 각 반복 횟수의 합을 확인하는 편이 좋습니다. O(n)은 “정확히 n번”이라는 뜻도 아닙니다. 이 예제의 정확한 횟수와 Big-O 표기를 구분합니다. 작은 입력에서의 상수 비용, 캐시와 입출력은 실제 실행 측정으로 보완해야 합니다. 확인 문제 n을 32로 바꾸면 위 두 값은 무엇일까요? 바깥 n번, 안쪽 3번이면 O(n2)일까요? 같은 O(n)이면 실제 실행 시간도 같을까요? 답과 해설 32와 1024입니다. 각 반복문의 횟수를 대입합니다. 아닙니다. 3n이므로 O(n)입니다. 아닙니다. 상수 비용과 구현, 입력 특성, 실행 환경이 다릅니다. 오늘은 연산을 무엇으로 정의했는지 적어보겠습니다. 내일은 고정 3번 반복을 추가하고, 일주일 뒤에는 이진 탐색의 입력 절반 줄이기와 비교해보면 좋겠습니다. 자료 확인 기준: Open Data Structures의 Big-Oh 설명, 2026-10-04. 표는 코드로 확인한 증가 연산 횟수이며 실행 시간 벤치마크가 아닙니다. 예제 검증 환경: Node.js v24.13.1.
해시 테이블은 키로 값을 빠르게 찾는 데 자주 쓰입니다. 그런데 해시값만 같으면 같은 키라고 봐도 될까요? 이번에는 서로 다른 키가 같은 위치에 들어가는 충돌 을 작은 숫자로 확인해보겠습니다. 해시는 후보 위치를 정한다 해시 함수는 키를 저장할 위치를 계산하는 데 사용됩니다. 서로 다른 키가 같은 버킷으로 갈 수 있으므로 충돌을 처리해야 합니다. 체이닝은 버킷에 여러 항목을 보관하고, 조회할 때 실제 키를 비교하는 방법입니다. Open Data Structures의 체이닝 해시 테이블 그림의 key % 4 는 충돌을 쉽게 보여 주기 위한 함수입니다. 실제 문자열 해시나 보안용 해시 함수가 아닙니다. 충돌해도 두 값을 보관하기 const buckets = Array.from({ length: 4 }, () => []); for (const [key, value] of [[1, "one"], [5, "five"]]) { buckets[key % 4].push([key, value]); } const found = buckets[5 % 4].find(([key]) => key === 5); console.log(buckets[1].length); console.log(found[1]); 검증한 출력은 2 , five 입니다. 두 키가 같은 버킷에 있지만 실제 키 비교로 값을 구분했습니다. 이 예제는 양의 정수 키의 삽입·조회만 다루며, 삭제와 재삽입, 갱신, 크기 확장은 구현하지 않았습니다. O(1)에는 조건이 있다 해시가 항목을 적절히 분산하고 적재율을 관리하면 기대 조회 비용을 작게 유지할 수 있습니다. 하지만 충돌이 한곳에 몰리면 체이닝 버킷의 선형 탐색이 길어집니다. 크기를 늘릴 때 재배치 비용도 생깁니다. 평균·기대 비용, 최악 비용, 여러 삽입에 나눠 보는 상각 비용을 구분해야 합니다. 같은 자료의 성능 조건 이 모형만으로 JavaScript Map 이나 특정 언어의 딕셔너리가 같은 방식으로 구현되어 있다고 주장하지 않습니다. 자료구조의 개념과 실제 런타임 구현은 별도로 확인합니다. 확인 문제 해시값이 같으면 키도 같을까요? 위 코드에 키 9를 추가하면 어느 버킷에 들어갈까요? 한 버킷에 모든 항목이 들어가면 조회 비용은 어떻게 될까요? 답과 해설 아닙니다. 충돌할 수 있으므로 키를 확인합니다. 9 % 4 = 1 이므로 버킷 1입니다. 이 체이닝 모형에서는 해당 리스트를 순서대로 찾아야 해서 항목 수에 따라 비용이 커집니다. 오늘은 키 1, 5, 9를 넣어 그림을 다시 그려보겠습니다. 내일은 버킷 개수를 8로 바꾸고, 일주일 뒤에는 열린 주소 방식과 체이닝의 차이를 찾아보면 좋겠습니다. 다음은 입력 크기에 따라 연산 수가 어떻게 늘어나는지입니다. 자료 확인 기준: Open Data Structures의 ChainedHashTable 절, 2026-10-04. 실행 결과는 교육용 구현의 출력이며 상용 런타임 벤치마크가 아닙니다. 예제 검증 환경: Node.js v24.13.1.
도메인의 주소를 바꿨는데 일부 사용자는 예전 서버로 접속합니다. “DNS가 전파되는 중”이라고만 말하면 무엇을 기다리는지 알기 어렵습니다. 이번에는 누가 답을 가지고 있고, 언제까지 재사용할 수 있는지 를 중심으로 보겠습니다. 이름을 묻는 곳과 답을 관리하는 곳 일반적인 이름 조회에서 클라이언트는 재귀 리졸버에 질문합니다. 리졸버가 유효한 캐시를 가지고 있으면 재사용하고, 필요하면 이름 서버를 찾아 답을 구합니다. 권한 있는 이름 서버는 자신이 담당하는 영역의 정보를 제공합니다. DNS의 리졸버·이름 서버·캐시 역할은 RFC 1034 에서 설명합니다. 그림은 중간의 루트·최상위 도메인 서버 조회를 접은 개념 흐름입니다. 모든 요청이 권한 서버까지 가는 것은 아닙니다. TTL은 재사용 시간의 기준 TTL은 캐시한 레코드를 재사용할 수 있는 시간의 기준입니다. TTL=60 인 답을 시각 0에 받았다면, 다음 표처럼 생각할 수 있습니다. 여기서는 전송 시간, 시계 차이와 별도 캐시 정책은 제외합니다. 경과 시간 이 모형에서의 판단 20초 40초 남음 59초 1초 남음 60초 만료, 새 조회가 필요함 const ttl = 60; for (const elapsed of [20, 59, 60]) { const remaining = Math.max(0, ttl - elapsed); console.log(`${elapsed}:remaining=${remaining}`); } 출력은 20:remaining=40 , 59:remaining=1 , 60:remaining=0 입니다. TTL 계산 모형을 검증했고 실제 도메인 설정은 변경하지 않았습니다. TTL을 낮췄는데 바로 안 바뀌는 이유 이전에 더 긴 TTL로 받은 캐시는 그 이전 답의 시간을 기준으로 남아 있을 수 있습니다. 지금 권한 서버의 TTL을 낮췄다고 이미 배포된 캐시의 시간을 소급해서 줄이는 것은 아닙니다. 브라우저, 운영체제, 리졸버 등 어느 계층의 답인지도 구분해야 합니다. TTL을 “전체 인터넷이 이 시각에 바뀐다”는 약속으로 보지 않는 편이 좋습니다. 오류 응답 캐시나 만료된 답의 제한적인 제공처럼 추가 규칙도 있습니다. 이 글은 기본 정상 조회만 설명합니다. 확인 문제 캐시가 유효한데 매번 권한 서버에 물어야 할까요? 기존 답의 TTL이 600초였는데 새 TTL을 60초로 바꾸면 기존 캐시도 바로 줄어들까요? TTL 90초인 답을 받은 뒤 35초가 지나면 이 모형에서 얼마나 남을까요? 답과 해설 기본적으로 유효한 캐시를 재사용할 수 있습니다. 아닙니다. 이미 받은 답의 TTL과 새로 받은 답을 구분해야 합니다. 55초입니다. 실제 운영에서는 조회 계층과 추가 정책도 확인합니다. 오늘은 “내가 보는 답은 어느 계층의 캐시인가?”를 적어보겠습니다. 내일은 TTL을 바꾸기 전후의 시간표를 그리고, 일주일 뒤에는 HTTP 캐시와 무엇이 다른지 비교해보면 좋겠습니다. 자료 확인 기준: RFC 1034의 정상 조회와 캐시 개념, 2026-10-04. 합성 TTL 계산을 제외한 실제 DNS 질의·변경 실험은 하지 않았습니다. 예제 검증 환경: Node.js v24.13.1.