[{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/cassandra/","section":"Tags","summary":"","title":"Cassandra","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/elasticsearch/","section":"Tags","summary":"","title":"Elasticsearch","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/i/o/","section":"Tags","summary":"","title":"I/O","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/lsm-tree/","section":"Tags","summary":"","title":"LSM Tree","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/mysql/","section":"Tags","summary":"","title":"MySQL","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/","section":"NineKoo9's story","summary":"","title":"NineKoo9's story","type":"page"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/nosql/","section":"Tags","summary":"","title":"NoSQL","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/rocksdb/","section":"Tags","summary":"","title":"RocksDB","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/storage/","section":"Tags","summary":"","title":"Storage","type":"tags"},{"content":"","date":"2026년 4월 18일","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. NoSQL과 Elasticsearch가 특정 워크로드에서 유리해지는 이유를 LSM 트리, 역색인, HDD·SSD I/O 관점에서 정리합니다.\n왜 NoSQL과 Elasticsearch는 특정 워크로드에서 유리할까? LSM 트리와 I/O까지 # 1. NoSQL은 왜 RDBMS보다 빠른가? # \u0026ldquo;NoSQL이 RDBMS보다 빠르다\u0026quot;는 말은 조건부로만 맞다.\n제품마다 데이터 모델, 저장 엔진, 일관성 모델, 분산 구조가 다르기 때문이다.\n그래서 아래 항목들은 \u0026ldquo;일부 NoSQL이 특정 워크로드에서 유리해질 수 있는 이유\u0026quot;로 이해하는 편이 정확하다.\n1-1. 조인이 없다 (가장 큰 이유 중 하나) # RDBMS는 정규화된 테이블을 런타임에 조인해서 데이터를 조합한다.\nSELECT u.name, o.total, p.name FROM Users u JOIN Orders o ON ... JOIN Products p ON ... Document DB에서는 관련 데이터를 한 도큐먼트에 임베드해서 저장하는 모델을 선택할 수 있다.\n{ \u0026#34;user\u0026#34;: \u0026#34;건홍\u0026#34;, \u0026#34;orders\u0026#34;: [{ \u0026#34;total\u0026#34;: 5000, \u0026#34;product\u0026#34;: \u0026#34;책\u0026#34; }] } MongoDB 문서는 임베드된 데이터 모델이 관련 데이터를 한 번의 데이터베이스 작업으로 가져오게 해 줄 수 있고, 읽기 성능 이점이 있을 수 있다고 설명한다.\n다만 이것은 MongoDB의 모든 모델이 자동으로 빠르다는 뜻이 아니라, 임베딩을 선택한 모델링 방식이 읽기 경로를 단순하게 만들 수 있다는 뜻이다.\n1-2. 스키마가 없어서 쓰기가 빠르다 # 이 표현은 그대로 두면 너무 단정적이다.\nMongoDB 문서는 기본적으로 유연한 스키마 모델을 사용해, 같은 컬렉션의 문서들이 기본적으로 같은 필드와 데이터 타입을 가질 필요는 없다고 설명한다. 동시에 schema validation으로 제약을 줄 수도 있다고 설명한다.\n즉, 더 정확한 표현은 다음에 가깝다.\n일부 document DB는 기본 스키마가 더 유연하다. 하지만 \u0026ldquo;스키마가 없어서 무조건 쓰기가 빠르다\u0026quot;라고 일반화할 수는 없다. 실제 쓰기 비용은 검증 규칙, 인덱스 수, 복제, durability 설정 같은 요소에도 크게 좌우된다. 1-3. 수평 확장(Sharding)이 설계에 내재되어 있다 # 이 역시 제품별 차이를 봐야 한다.\nApache Cassandra 문서는 Cassandra가 consistent hashing 기반의 dataset partitioning, multi-master replication, incremental scale-out을 사용한다고 설명한다. 이런 시스템은 분산과 파티셔닝이 아키텍처 중심에 있기 때문에 노드 증설이 더 자연스럽다.\n다만 이것을 \u0026ldquo;노드를 늘리면 처리량이 항상 선형 증가한다\u0026rdquo; 로 이해하면 과하다.\n실제 성능은 파티션 키 분포, 복제 수, 네트워크, 쿼리 패턴에 따라 달라진다.\n1-4. ACID를 포기한 대가 # 이 문장도 범위가 너무 넓다.\n일부 분산 NoSQL 시스템은 관계형 DB와 다른 일관성/트랜잭션 모델을 택한다. 예를 들어 Cassandra는 tunable consistency와 last-write-wins 기반 충돌 해소를 설명하고, cross-partition transaction을 관계형 DB처럼 일반적인 기본 모델로 두지 않는다.\n하지만 모든 NoSQL이 ACID를 \u0026ldquo;포기\u0026quot;한다고 쓰는 것은 부정확하다.\n따라서 이 항목은 다음처럼 이해하는 편이 맞다.\n일부 NoSQL은 강한 일관성이나 범용 트랜잭션보다 가용성, 분산성, 단순한 쓰기 경로 쪽에 더 무게를 둔다. 그 선택이 특정 워크로드에서 성능 이점으로 이어질 수 있다. 1-5. 디스크 저장 방식 (Column-family DB 한정) # 원문에서 Column DB (Cassandra, HBase)라고 묶은 부분은 조금 조심해야 한다.\nCassandra는 분석용 columnar DB라기보다 wide-column / column-family 계열로 보는 편이 더 정확하다.\n구분 전형적인 행 중심 저장 Column-family 계열의 일부 접근 저장/접근 관점 행 전체 중심 파티션·컬럼 그룹 중심 특화 상황 행 단위 조회/갱신 모델링된 접근 패턴에 맞는 키 기반 조회 즉, 이 항목의 핵심은 \u0026ldquo;모든 NoSQL이 컬럼 저장이라 빠르다\u0026quot;가 아니라, 데이터 모델과 저장 구조가 접근 패턴에 더 잘 맞을 수 있다는 점이다.\n1-6. LSM 트리 (쓰기 성능의 핵심) # → 2절에서 상세 설명\n요약 비교표 # 이유 핵심 효과 임베딩 기반 모델링 가능 읽기 경로 단순화 가능 LSM 계열 저장 엔진 작은 쓰기를 append/flush 중심으로 처리하기 쉬움 분산 구조 내장 제품 존재 파티셔닝과 증설이 더 자연스러움 일관성/트랜잭션 모델 차이 일부 워크로드에서 오버헤드 절감 가능 데이터 모델 최적화 접근 패턴에 더 잘 맞을 수 있음 주의: 복잡한 조인, 강한 일관성, 범용 트랜잭션이 중요한 경우에는 RDBMS가 더 적합하거나 더 빠를 수 있다.\n2. LSM 트리 (Log-Structured Merge Tree) # 2-1. 핵심 아이디어 # 작은 랜덤 갱신을 메모리에 모았다가, 정렬된 파일로 내려 쓰는 구조\nB-Tree 계열 저장 엔진은 논리적으로는 기존 페이지를 찾아가 갱신하는 모델에 가깝다.\nLSM 계열은 작은 쓰기를 메모리에 모아 두었다가 새 sorted file을 만들어 flush/merge 하는 쪽에 가깝다.\n이때 운영체제의 page cache나 스토리지 컨트롤러가 실제 I/O를 완충할 수 있으므로,\n\u0026ldquo;B-Tree = 항상 물리 랜덤 쓰기\u0026rdquo; 로 단정하면 과하다.\n다만 저장 엔진의 논리적 쓰기 패턴이 다르다는 점은 분명하다.\n2-2. 쓰기 흐름 단계별 설명 # 쓰기 요청 ↓ ① MemTable (메모리에 정렬된 구조로 쌓음) ↓ ② SSTable (디스크에 새 sorted file로 flush) ↓ ③ Compaction (여러 SSTable을 병합·정리) ① MemTable (메모리 단계) # RocksDB 문서 기준으로 새 쓰기는 먼저 memtable에 들어간다. reads도 memtable을 먼저 본다. 최신 데이터가 여기에 있기 때문이다. memtable이 가득 차면 더 이상 쓰지 않는 immutable memtable이 된다. ② SSTable (디스크 단계) # 백그라운드 작업이 immutable memtable을 SST file로 flush한다. Cassandra 문서도 memtable이 한계에 도달하면 디스크에 flush되어 immutable SSTable이 된다고 설명한다. 핵심은 기존 파일을 제자리 수정하기보다 새 정렬 파일을 만들어 내리는 구조라는 점이다. ③ Compaction (백그라운드 정리) # 여러 SSTable이 쌓이면 compaction이 이를 병합한다. RocksDB 문서는 compaction이 어떤 sorted run을 merge할지, 그리고 읽기 시 어떤 sorted run들을 봐야 하는지에 직접 영향을 준다고 설명한다. 즉, compaction은 단순 청소가 아니라 LSM의 읽기/쓰기 절충을 관리하는 핵심 동작이다. 2-3. 왜 순차 쓰기가 유리한가? # 저장 장치 핵심 차이 의미 HDD 헤드 이동과 회전 지연이 큼 순차 접근이 특히 유리 SSD 헤드는 없지만 erase/write 단위와 GC 비용이 있음 순차적 패턴이 write amplification을 줄이기 쉬움 HDD에서는 연속된 구간을 읽고 쓰는 편이 물리적으로 유리하다.\nSSD에서는 이유가 다르다. Samsung 자료는 NAND SSD가 erase-before-write 특성을 가지며, garbage collection으로 인한 write amplification이 발생한다고 설명한다. 또한 host write pattern이 sequential할 때 SSD WAF가 1에 가깝고, random pattern은 WAF를 높일 수 있다고 설명한다.\n2-4. LSM 트리를 사용하는 대표 예시 # 시스템 비고 Cassandra 공식 문서에서 storage engine이 LSM 기반이라고 설명 RocksDB memtable → SST → compaction 구조를 공식 위키에서 설명 원문에 있던 MongoDB, HBase, LevelDB까지 한 줄로 같은 범주에 묶어 적는 방식은 설명이 거칠어질 수 있어서, 여기서는 이번 검증에서 직접 확인한 시스템만 적는다.\n2-5. LSM 트리의 단점 # 쓰기가 빠른 대신 읽기와 백그라운드 정리 비용이 따라온다.\nCassandra 문서는 LSM 기반 write path가 write-oriented workload에 최적화되어 있지만, read performance와 write amplification tradeoff가 있다고 설명한다. Cassandra는 읽기 보완을 위해 Bloom filter를 사용한다고 설명한다. compaction 자체도 여러 SSTable을 다시 읽고 새로 써야 하므로 백그라운드 I/O를 발생시킨다. 2-6. 한 줄 요약 # \u0026ldquo;제자리에서 계속 고치기보다, 메모리에 모았다가 새 sorted file로 내리고 나중에 병합한다\u0026quot;는 전략이 LSM의 핵심이다.\n3. Elasticsearch는 왜 검색이 빠른가? # 3-1. 역색인 (Inverted Index) — 핵심 # 일반적인 행 스캔 방식에서는 \u0026quot;Java\u0026quot;를 찾으려면 문서를 하나씩 확인해야 한다.\ndoc1: \u0026#34;Java Spring 개발자 구합니다\u0026#34; doc2: \u0026#34;Python 데이터 엔지니어 모집\u0026#34; doc3: \u0026#34;Java 백엔드 3년 이상\u0026#34; 역색인은 반대로 용어 → 문서 목록 구조를 만든다.\n\u0026#34;Java\u0026#34; → [doc1, doc3] \u0026#34;Spring\u0026#34; → [doc1] \u0026#34;Python\u0026#34; → [doc2] 그래서 검색 시 전체 문서를 처음부터 끝까지 훑는 대신,\n해당 용어의 postings list로 바로 접근할 수 있다.\n즉, 핵심은 전수 스캔을 피하게 해 주는 색인 구조에 있다.\n3-2. 샤드(Shard) 병렬 검색 # 인덱스를 여러 샤드로 나누면 검색 일을 분산할 수 있다.\n검색 요청 ├──→ Shard 0 ├──→ Shard 1 └──→ Shard 2 ↓ 결과 병합 → 응답 다만 원문처럼 \u0026ldquo;샤드 3개면 검색 시간이 이론상 1/3\u0026rdquo; 이라고 말하면 과하다.\nElastic 문서는 각 샤드가 검색을 single CPU thread에서 수행한다고 설명하면서도, 동시에 너무 많은 샤드는 검색 성능을 떨어뜨릴 수 있다고 설명한다.\n즉, 더 정확한 표현은 다음과 같다.\n샤드는 병렬 처리의 단위가 될 수 있다. 하지만 샤드 수를 늘린다고 무조건 빨라지지는 않는다. 실제 shard size와 개수는 벤치마크로 조정해야 한다. 3-3. 세그먼트(Segment) — LSM과 닮은 점이 있는 부분 # 각 샤드 내부는 Lucene 세그먼트들로 구성된다.\n연산 처리 방식 추가 새 세그먼트로 반영 삭제 즉시 물리 삭제가 아니라 이후 merge에서 정리 수정 내부적으로는 삭제 + 새 문서 추가에 가깝게 처리 Elastic 문서는 Elasticsearch가 near real-time search를 제공하며, 문서가 인메모리 인덱싱 버퍼에서 새 세그먼트로 기록되고, 이 세그먼트가 먼저 filesystem cache에 올라간 뒤 검색 가능해진다고 설명한다. 이 과정을 refresh라고 부른다.\n또한 Elastic 문서는 세그먼트가 immutable이며, 작은 세그먼트들이 나중에 더 큰 세그먼트로 merge된다고 설명한다.\n그래서 Elasticsearch는 검색 엔진 관점에서 다음처럼 이해하면 된다.\n검색 가능한 세그먼트를 빠르게 연다. 삭제와 정리는 merge가 백그라운드에서 담당한다. 덕분에 검색 경로와 인덱싱 경로를 비교적 분리해 운영할 수 있다. 3-4. 요약 # Elasticsearch가 빠른 이유는 역색인 + 샤드 단위 분산 + immutable 세그먼트와 refresh/merge 모델이 함께 맞물리기 때문이다.\n추가로 Elastic은 다음도 공식적으로 제공한다.\ntext 필드용 analyzer 기본 유사도 BM25 Levenshtein edit distance 기반 fuzzy query 즉, Elasticsearch는 단순한 색인 라이브러리가 아니라 검색 기능 표면 자체가 넓은 분산 검색 엔진이다.\n4. MySQL 전문검색 vs Elasticsearch # 질문: MySQL도 전문검색(Full-Text Search)에 역색인을 쓰는데, 왜 Elasticsearch보다 느리거나 불편하게 느껴질 때가 있는가?\n핵심은 역색인 유무 자체가 아니라, 어떤 문제를 주목적으로 설계했느냐이다.\n4-1. 샤딩 없음 — 단일 노드 # 원문처럼 \u0026ldquo;MySQL은 기본적으로 한 서버 안에서 혼자 다 처리한다\u0026quot;라고 단순화하면 너무 거칠다.\n애플리케이션 레벨 샤딩이나 다른 분산 구성이 가능하기 때문이다.\n다만 MySQL의 FULLTEXT 검색은 본질적으로 트랜잭션 DB 안의 기능이고,\nElasticsearch는 샤드 분산을 기본 개념으로 둔 검색 엔진이라는 차이는 크다.\n즉, 대규모 분산 검색이라는 문제에서는 Elasticsearch 쪽이 더 직접적인 해법을 제공한다.\n4-2. 역색인 품질이 다르다 # 여기서 가장 중요한 정정이 필요하다.\nMySQL InnoDB full-text index도 공식 문서상 inverted index design이다.\n또한 MySQL은 relevance 계산, boolean search, query expansion, CJK용 built-in ngram parser도 제공한다.\n따라서 차이는 \u0026ldquo;MySQL은 역색인이 없고 Elasticsearch만 있다\u0026quot;가 아니다.\n보다 정확한 차이는 텍스트 분석과 검색 기능 표면의 폭에 있다.\n기능 MySQL Elasticsearch 역색인 지원 (FULLTEXT) 지원 관련도 계산 지원 지원 CJK 처리 ngram parser 지원 analyzer 조합으로 풍부하게 설정 가능 오타 허용 제한적 fuzzy query 제공 분석기 구성 비교적 제한적 analyzer 중심으로 매우 유연 4-3. 쓰기/읽기 경쟁 (구조 차이) # MySQL 쪽은 InnoDB full-text index를 유지하기 위해 auxiliary index tables와 full-text index cache를 사용하고, 최근 삽입된 토큰을 캐시에 모았다가 batch flush한다.\nElasticsearch 쪽은 refresh로 새 세그먼트를 열고, 이후 merge로 정리한다.\n따라서 차이를 이렇게 이해하는 편이 낫다.\nMySQL 전문검색은 트랜잭션 DB 내부에 통합된 검색 기능이다. Elasticsearch는 검색을 중심으로 세그먼트/refresh/merge 모델을 운영한다. 즉, 둘 다 색인을 유지하지만 운영 모델이 다르다.\n4-4. 메모리 활용 전략이 다르다 # 이 항목도 원문 표현을 조정할 필요가 있다.\nElasticsearch는 공식 문서에서 filesystem cache 위에 세그먼트를 빠르게 열어 검색 가능하게 만드는 near real-time 모델을 설명한다. MySQL InnoDB는 공식 문서에서 full-text index cache를 두어 최근 삽입 토큰을 임시 저장하고 batch flush한다고 설명한다. 즉, 둘 다 캐시를 활용하지만 무엇을, 어떤 목적으로, 어떤 검색 구조에 맞춰 캐시하느냐가 다르다.\n4-5. 결론 # 역색인이라는 아이디어 자체는 양쪽 모두에 있다.\n차이는 트랜잭션 DB에 포함된 전문검색 기능이냐,\n아니면 분산 검색을 주목적으로 만든 검색 엔진이냐에 있다.\n상황 더 자연스러운 선택 관계형 데이터 안에서 비교적 단순한 검색 MySQL 전문검색 분산 검색, analyzer 조정, fuzzy search, 검색 전용 운영 Elasticsearch 5. 랜덤 I/O vs 순차 I/O — 하드웨어까지 # 질문: 결국 OS 입장에서는 파일을 여는 것인데, 왜 속도 차이가 생기는가?\n질문: B-Tree와 LSM Tree의 차이가 하드웨어 레벨에서 어떻게 적용되는가?\n5-1. I/O 소프트웨어 스택 — 레이어 전체 흐름 # file.write(data)를 호출했을 때의 흐름을 단순화하면 다음과 같다.\n애플리케이션 (B-Tree / LSM Tree) ↓ OS 페이지 캐시 ↓ 파일시스템 (ext4 / xfs 등) ↓ 블록 계층 / 디바이스 드라이버 ↓ HDD / SSD Linux kernel 문서는 normal reads, writes and mmaps go through the page cache라고 설명한다.\n또한 ext4 문서는 구조의 위치가 절대 디스크 오프셋이 아니라 block number 기준으로 저장된다고 설명한다.\n따라서 핵심은 \u0026ldquo;파일을 연다\u0026quot;는 사실 자체보다,\n저장 엔진이 어떤 논리적 쓰기 패턴을 만들고, 파일시스템과 장치가 그것을 어떤 블록 배치로 실현하느냐에 있다.\n5-2. HDD 하드웨어 — 랜덤 vs 순차의 물리적 차이 # HDD 성능은 대략 다음 비용으로 설명할 수 있다.\n요소 설명 예시 Seek time 헤드가 원하는 트랙으로 이동 Seagate 예시에서 평균 \u0026lt;12ms Rotational latency 원하는 섹터가 헤드 아래로 올 때까지 대기 Seagate 예시에서 평균 5.1ms Transfer time 실제 데이터를 읽거나 쓰는 시간 블록 크기·전송률에 따라 달라짐 핵심은 작은 랜덤 I/O에서는 실제 전송 시간보다 위치를 찾아가는 비용이 훨씬 크다는 점이다.\n랜덤 I/O: seek + 회전 대기가 여러 번 반복됨 순차 I/O: 한 번 위치를 잡은 뒤 연속 전송하기 쉬움 그래서 HDD에서는 순차 패턴의 이점이 특히 크게 나타난다.\n5-3. B-Tree vs LSM Tree — 블록 접근 패턴 비교 # B-Tree 쓰기 (기존 페이지 갱신 중심) # ① 관련 페이지 탐색 ② 메모리에서 갱신 ③ 변경 페이지를 다시 써서 반영 논리적으로는 기존 위치의 페이지를 찾아가 갱신하는 모델에 가깝다. 그래서 작은 갱신이 많아질수록 저장 계층에 흩어진 쓰기 압력을 만들기 쉽다. 다만 이 문장을 **\u0026ldquo;B-Tree는 무조건 매번 물리 랜덤 쓰기\u0026rdquo;**로 이해하면 안 된다.\npage cache, filesystem allocation, SSD FTL이 중간에서 완충하고 재배치할 수 있기 때문이다.\nLSM Tree 쓰기 (새 파일 append/flush 중심) # ① 메모리 MemTable에 먼저 반영 ② 꽉 차면 SSTable로 flush ③ 이후 compaction으로 병합 논리적으로는 새 sorted file을 만들어 내리는 구조다. 그래서 작은 쓰기를 더 큰 단위의 flush/merge 작업으로 묶기 쉽다. 이 패턴이 HDD의 순차 I/O나 SSD의 write amplification 관리와 더 잘 맞는 경우가 많다. SSD에서도 순차가 유리한 이유 # 항목 내용 Erase-before-write NAND는 overwrite를 직접 하지 않고 지우기-쓰기 특성을 가진다 Garbage collection 유효 페이지를 옮기며 block을 비우는 과정이 필요하다 Write Amplification GC 때문에 host write보다 더 많은 내부 write가 생길 수 있다 순차 패턴의 이점 Samsung 자료 기준으로 sequential host write는 SSD WAF를 1에 가깝게 만들 수 있다 즉, SSD에서는 HDD처럼 헤드 이동이 병목은 아니지만,\nGC와 WAF 때문에 쓰기 패턴이 여전히 중요하다.\n5-4. 한 줄 요약 # B-Tree 계열은 논리적으로 기존 페이지를 갱신하는 구조에 가깝고,\nLSM 계열은 새 sorted file을 만들어 내리는 구조에 가깝다.\n실제 물리 I/O는 커널과 장치가 완충하지만, 저장 엔진의 쓰기 패턴 차이는 성능 특성으로 드러난다.\n전체 개념 연결 지도 # NoSQL이 특정 워크로드에서 유리해지는 이유 │ ├── 읽기 측면 │ ├── 임베딩 기반 모델링 (일부 Document DB) │ ├── 역색인 (Elasticsearch, MySQL FTS) │ ├── 샤드 단위 분산 검색 (Elasticsearch) │ └── immutable 세그먼트 + refresh/merge (Elasticsearch) │ └── 쓰기 측면 ├── LSM 트리 → memtable + SSTable + compaction ├── 분산 제품별 consistency/transaction 모델 차이 └── 유연한 스키마/모델링 선택지 (일부 Document DB) 랜덤 vs 순차 I/O │ ├── OS 레이어: page cache 경유 ├── 파일시스템: block number 기준 구조 해석 ├── HDD: seek + rotational latency가 큰 비용 └── SSD: garbage collection + write amplification이 핵심 비용 B-Tree vs LSM Tree (I/O 관점) │ ├── B-Tree: 기존 페이지 갱신 중심 └── LSM Tree: 새 sorted file flush/merge 중심 참고 자료 # MongoDB Docs — Embedded Data Models: https://www.mongodb.com/docs/manual/data-modeling/embedding/ MongoDB Docs — Schema Validation: https://www.mongodb.com/docs/manual/core/schema-validation/ Apache Cassandra Docs — Dynamo: https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html Apache Cassandra Docs — Storage Engine: https://cassandra.apache.org/doc/stable/cassandra/architecture/storage-engine.html RocksDB Wiki — MemTable: https://github.com/facebook/rocksdb/wiki/Memtable RocksDB Wiki — Compaction: https://github.com/facebook/rocksdb/wiki/Compaction Elastic Docs — Near real-time search: https://www.elastic.co/docs/manage-data/data-store/near-real-time-search Elastic Docs — Merge settings: https://www.elastic.co/docs/reference/elasticsearch/index-settings/merge Elastic Docs — Size your shards: https://www.elastic.co/docs/deploy-manage/production-guidance/optimize-performance/size-shards Elastic Docs — analyzer: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/analyzer Elastic Docs — similarity: https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/similarity Elastic Docs — Fuzzy query: https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-fuzzy-query MySQL 8.4 Reference Manual — Full-Text Search Functions: https://dev.mysql.com/doc/refman/8.4/en/fulltext-search.html MySQL 8.4 Reference Manual — Natural Language Full-Text Searches: https://dev.mysql.com/doc/refman/8.4/en/fulltext-natural-language.html MySQL 8.4 Reference Manual — Boolean Full-Text Searches: https://dev.mysql.com/doc/refman/8.4/en/fulltext-boolean.html MySQL 8.4 Reference Manual — ngram Full-Text Parser: https://dev.mysql.com/doc/refman/8.4/en/fulltext-search-ngram.html MySQL 8.4 Reference Manual — InnoDB Full-Text Indexes: https://dev.mysql.com/doc/refman/8.4/en/innodb-fulltext-index.html Linux Kernel Docs — Page Cache: https://docs.kernel.org/next/mm/page_cache.html Linux Kernel Docs — ext4 Blocks: https://docs.kernel.org/next/filesystems/ext4/blocks.html Samsung Semiconductor — A Brief History of Data Placement Technologies: https://semiconductor.samsung.com/news-events/tech-blog/a-brief-history-of-data-placement-technologies/ Seagate Archive HDD Product Manual: https://www.seagate.com/files/www-content/support-content/enterprise-servers-storage/nearline-storage/archive-hdd/_shared/masters/archive-sata-hdd-%20100743737-product-manual.pdf ","date":"2026년 4월 18일","externalUrl":null,"permalink":"/posts/nosql-elasticsearch-deep-dive/","section":"Posts","summary":"NoSQL이 항상 빠른 것은 아니라는 전제에서 출발해, LSM 트리의 쓰기 경로, Elasticsearch의 역색인과 세그먼트, MySQL 전문검색과의 차이, HDD·SSD I/O 패턴까지 원래 구조를 유지해 정리합니다.","title":"왜 NoSQL과 Elasticsearch는 특정 워크로드에서 유리할까? LSM 트리와 I/O까지","type":"posts"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. L4와 L7 로드밸런싱의 차이, TLS 종료와 라우팅 기준, Logstash와 Elasticsearch 앞단에서의 선택 포인트를 정리합니다.\nL4 vs L7 로드밸런싱 완전 정리 # 1. L4 vs L7 로드밸런싱 기본 개념 # L4 로드밸런싱 (Transport Layer) # L4 로드밸런싱은 TCP/UDP 같은 전송 계층 기준으로 연결을 분산한다. 대표적으로 IP, Port, 프로토콜, 연결 단위 정보를 바탕으로 대상 서버를 고른다.\n장점\n애플리케이션 요청 내용을 직접 해석하지 않아 처리 경로가 단순하다 TCP/UDP 기반 다양한 트래픽에 적용하기 쉽다 단순 분산, 정적 IP, 대량 연결 처리 같은 요구사항에 잘 맞는다 단점\nURL, 헤더, 쿠키, gRPC 메서드 같은 애플리케이션 정보 기반 라우팅은 불가하다 세밀한 요청 단위 정책은 L7보다 제한적이다 참고: TLS 종료는 L7에서 흔하지만, AWS NLB처럼 L4 계열에서도 TLS listener로 지원하는 제품이 있다. 따라서 \u0026ldquo;L4면 무조건 TLS 종료 불가\u0026quot;라고 단정하면 틀릴 수 있다.\nL7 로드밸런싱 (Application Layer) # L7 로드밸런싱은 애플리케이션 계층 요청을 해석해서 분산한다. 웹 트래픽 기준으로는 URL, 헤더, 쿠키, 메서드, 쿼리 문자열 등을 기준으로 라우팅할 수 있다.\n장점\nURL 경로, 호스트, 헤더, 쿼리 문자열 기반 라우팅 가능 쿠키 기반 sticky session 구현 가능 TLS 종료와 HTTP/gRPC 수준 정책 적용이 쉽다 카나리 배포, A/B 테스트, 인증, 리다이렉트 같은 고급 제어에 유리하다 단점\n요청을 해석해야 하므로 L4보다 일반적으로 처리 경로가 복잡하다 제품이 이해하는 애플리케이션 프로토콜에 의존한다 기능이 많아질수록 설정과 장애 포인트가 늘어난다 참고: 많은 L7 제품은 HTTP/HTTPS 중심이지만, 제품에 따라 HTTP/2, gRPC, WebSocket도 지원한다. 따라서 \u0026ldquo;L7 = 오직 일반 HTTP만\u0026quot;이라고 보는 것도 과한 단순화다.\n한눈에 비교 # 항목 L4 L7 기준 연결/전송 계층 정보 요청 내용 대표 판단 요소 IP, Port, 프로토콜, 연결 해시 URL, Header, Cookie, Method, Query String 속도/복잡도 단순하고 가벼운 편 상대적으로 복잡 TLS 종료 제품 의존 일반적으로 많이 지원 경로 기반 라우팅 불가 가능 대표 예시 AWS NLB, NGINX stream AWS ALB, NGINX HTTP/gRPC 2. 언제 L4를, 언제 L7을 써야 하는가? # 핵심 원칙 # L4는 연결을 어디로 보낼지 결정하는 데 강하고, L7은 요청 내용을 보고 어떻게 나눌지 결정하는 데 강하다.\nL7도 여러 서버에 트래픽을 분산할 수 있다. 차이는 요청 내용을 읽는 기능이 실제로 필요한가 에 있다.\n단순 분산 → L4가 자연스러운 이유 # 상황: 동일한 Spring 서버 3대에 트래픽 분산 # 클라이언트 → [로드밸런서] → Server A → Server B → Server C 세 서버가 완전히 동일한 역할을 하고, 요청마다 별도 정책이 필요 없다면 굳이 URL이나 헤더를 읽을 이유가 없다.\nL4: 연결 단위로 분산 → 보통 더 단순하고 가볍다 L7: HTTP 요청을 해석한 뒤 분산 → 기능은 많지만 필요 없는 경우도 많다 즉, 이 경우에는 \u0026ldquo;L7이 불가능해서\u0026quot;가 아니라 \u0026ldquo;L4로 충분해서\u0026rdquo; L4가 더 자연스러운 선택이 된다.\nMSA 환경 → L7이 필요한 이유 # 상황: 쇼핑몰 MSA # /api/users → User Service (포트 8081) /api/orders → Order Service (포트 8082) /api/products → Product Service (포트 8083) 하나의 진입점에서 경로별로 다른 서비스로 보내려면 요청 내용을 읽어야 한다.\n클라이언트가 GET /api/orders 요청 L4: 연결 정보만 보므로 URL 경로 자체는 판단 근거가 아님 ❌ L7: /api/orders 경로를 읽고 Order Service로 라우팅 ✅ 이 구조에서는 L7 프록시, API Gateway, Ingress Controller 같은 요청 인식형(L7) 구성이 필요하다.\n카나리 배포도 구분해서 봐야 한다 # /api/orders 요청 중 → 특정 헤더가 있으면 v2로 → 나머지는 v1로 이처럼 헤더, 쿠키, 경로, 메서드 기준으로 트래픽을 나누는 카나리/A-B 라우팅은 L7이 필요하다.\n반면 단순 가중치 기반 분산은 일부 L4 제품도 지원한다. 예를 들어 AWS Network Load Balancer는 여러 target group에 weight를 줄 수 있다.\n그래서 정확히 말하면, \u0026ldquo;정교한 요청 기반 카나리\u0026quot;는 L7의 영역이고, \u0026ldquo;단순 비율 분할\u0026quot;은 제품에 따라 L4에서도 가능할 수 있다.\n정리 # 상황 적합한 LB 이유 동일한 서버 N대에 단순 분산 L4 요청 내용을 읽지 않아도 됨 URL 경로별 다른 서비스로 라우팅 L7 요청 내용을 읽어야 함 헤더/쿠키/경로 기반 카나리·A/B L7 요청 속성을 기준으로 분기 단순 TCP/UDP 서비스 분산 L4 애플리케이션 내용 해석 불필요 TLS 종료 + HTTP 정책/인증/리다이렉트 L7이 흔함 요청 단위 정책과 잘 맞음 3. ELK 스택에서 왜 L4 로드밸런싱을 쓰는가? # ELK 데이터 흐름 # 애플리케이션 서버들 ↓ (로그 전송) [Logstash 클러스터 - 3대] ← 여기 앞에 LB를 둘 수 있음 ↓ Elasticsearch ↓ Kibana Logstash 앞에 로드밸런서를 두는 이유는, 입력 트래픽을 여러 Logstash 인스턴스로 분산하기 위해서다.\n핵심은 \u0026ldquo;Logstash 입력 프로토콜\u0026quot;이다 # Logstash 자체는 HTTP 전용 제품이 아니다. 어떤 input plugin 을 쓰느냐에 따라 앞단에서 다뤄야 하는 프로토콜이 달라진다.\nBeats input: Logstash가 Beats framework의 연결을 받는다 Filebeat의 Logstash output: lumberjack protocol을 사용하며 TCP 위에서 동작한다 UDP input: UDP로 이벤트를 받는다 HTTP input: HTTP(S) 요청을 이벤트로 받는다 즉, \u0026ldquo;Logstash 앞은 무조건 L4\u0026rdquo; 도 아니고 \u0026ldquo;무조건 L7\u0026rdquo; 도 아니다.\n실제로 쓰는 입력이 Beats/TCP/UDP 계열이면 L4가 자연스럽고, HTTP input을 쓰면 L7도 가능하다.\n왜 L4가 자주 어울리는가? # 로그 수집 경로에서 다음 조건이 많기 때문이다.\n입력이 Beats, TCP, UDP, syslog처럼 HTTP가 아닌 경우가 많다 여러 Logstash 인스턴스가 동일한 역할을 하므로 경로 기반 라우팅이 필요 없는 경우가 많다 단순 분산이 목적이라면 요청 내용을 굳이 해석할 이유가 적다 Filebeat → LB → Logstash A → Logstash B → Logstash C 이 구조에서는 \u0026ldquo;어느 Logstash가 이 로그를 처리해도 되는가?\u0026rdquo; 가 핵심이고, 대부분은 그렇다.\n그래서 HTTP 기능보다 TCP/UDP 수준 분산이 더 중요한 경우가 많다.\nL7으로 하면 안 되나? # 안 되는 것은 아니다. Logstash에는 http input plugin 이 있으므로, HTTP로 이벤트를 받는 구성이라면 L7도 사용할 수 있다.\n다만 이 경우에도 확인할 질문은 단순하다.\n입력이 정말 HTTP인가? URL/헤더 기반 라우팅이 필요한가? 여러 Logstash 인스턴스가 동일 역할이라면 L7 기능이 실질적으로 필요한가? 결론: Logstash 앞단 선택은 \u0026ldquo;ELK라서\u0026quot;가 아니라 \u0026ldquo;입력 프로토콜과 라우팅 요구사항이 무엇인가\u0026quot;로 결정하는 게 맞다.\n4. Elasticsearch 샤드 구성과 로드밸런싱 # 중요한 개념 구분 # 샤드 라우팅과 외부 로드밸런서는 다른 레이어의 이야기다.\n외부 로드밸런서가 샤드를 직접 분산시키는 게 아니다.\nElasticsearch 클러스터는 각 노드가 클러스터 상태를 공유하고, 적절한 노드로 요청을 전달할 수 있다.\nES 내부 샤드 라우팅 메커니즘 # 클라이언트 ↓ HTTP 요청 [Node 1] ← coordinating 역할 ↓ ┌─────────────────┐ ↓ ↓ [Node 2] [Node 3] Shard 0, 1 Shard 2, 3 클라이언트 요청을 받은 노드는 클러스터 상태를 바탕으로 적절한 샤드가 있는 노드로 요청을 전달하고, 응답을 모아 반환할 수 있다.\nElastic 문서에서도 coordinating-only node를 smart load balancer 처럼 동작한다고 설명한다.\n즉, 외부 LB가 하는 일은 보통 \u0026ldquo;첫 진입 노드를 고르는 것\u0026rdquo; 이지, \u0026ldquo;샤드를 직접 나누는 것\u0026rdquo; 이 아니다.\n그럼 ES 앞에 두는 로드밸런서는 뭘 하는가? # 노드 레벨 분산이다.\n클라이언트들 ↓ [외부 LB] → Node 1 → Node 2 → Node 3 ↓ (이후 샤드 단위 조정은 ES 내부에서 처리) 어느 노드로 먼저 들어가든, 그 다음의 샤드 단위 분산과 reduce는 Elasticsearch 클러스터가 처리한다.\nES 앞단에서 왜 L4가 자주 거론되는가? # 이유 1: 외부 LB와 내부 transport 포트는 구분해야 한다 # Elasticsearch는 HTTP 인터페이스와 transport 인터페이스를 따로 둔다.\n9200대 → HTTP client communication 9300대 → node-to-node transport TCP 여기서 중요한 점은, 9300대 transport 포트가 존재한다고 해서 외부 클라이언트용 LB가 반드시 9300도 다뤄야 한다는 뜻은 아니라는 것이다.\n보통 클라이언트 진입점은 HTTP(9200대)이고, 9300대는 클러스터 내부 통신용으로 본다.\n즉, \u0026ldquo;9300 때문에 외부 ES 앞단은 무조건 L4\u0026rdquo; 라고 말하면 과하다.\n이유 2: URL 기반 라우팅이 보통 필요 없다 # GET /my-index/_search POST /my-index/_doc Elasticsearch API는 경로가 다르더라도, 일반적인 다중 노드 클러스터 앞단에서는 특정 URL을 특정 노드로 보내야 하는 경우가 흔하지 않다.\n즉, 외부 LB 입장에서는 보통:\n어느 healthy node로 먼저 보낼지만 결정하면 되고 이후의 샤드 배치와 내부 전달은 Elasticsearch가 처리한다 이 때문에 요청 내용을 읽는 L7 기능이 꼭 필요하지 않은 경우가 많다.\n이유 3: 단순 노드 분산만 원하면 L4가 충분하다 # 외부에서 필요한 것이:\n정적 진입점 여러 ES 노드로의 기본 분산 TCP/TLS 수준의 단순 전달 정도라면 L4가 자연스러운 선택이 된다.\nES에서 L7을 쓰면 안 되나? # 가능하다. 9200대 HTTP API 앞단에는 L7을 둘 수 있고, 실제로 AWS ALB처럼 HTTP/HTTPS 기반 L7 로드밸런서로 구성할 수도 있다.\n다만 이 경우에도 질문은 같다.\npath/header 기반 정책이 필요한가? 인증/리다이렉트/WAF 같은 HTTP 기능이 필요한가? 단순 노드 분산 이상의 요구사항이 있는가? 없다면 L4가 충분할 수 있고, 있다면 L7이 의미가 생긴다.\n5. 최종 요약 # 선택 기준 한 문장 정리 # L7이 더 많은 요청 정보를 이해하지만, 그 정보가 필요 없으면 L4가 더 단순한 선택이 된다.\n전체 케이스 정리 # 상황 적합한 LB 핵심 이유 동일한 서버 N대 단순 분산 L4 요청 내용을 읽을 필요 없음 MSA URL 경로별 라우팅 L7 요청 경로/헤더 기반 분기 필요 헤더·쿠키·경로 기반 카나리/A-B L7 요청 단위 정책 필요 단순 TCP/UDP 서비스 분산 L4 연결 수준 분산으로 충분 Logstash 앞단 (Beats/TCP/UDP 입력) L4가 자연스러움 입력 프로토콜이 HTTP가 아닐 수 있음 Elasticsearch 노드 앞단 요구사항에 따라 다름 단순 노드 분산이면 L4로 충분한 경우가 많음 참고 자료 # AWS - Application Load Balancer AWS Docs - Listeners for your Application Load Balancers AWS Docs - Condition types for listener rules AWS Docs - Action types for listener rules AWS Docs - Target groups for your Application Load Balancers AWS Docs - What is a Network Load Balancer? AWS Docs - Listeners for your Network Load Balancers AWS Docs - Health checks for Network Load Balancer target groups AWS Docs - Target groups for your Network Load Balancers Elastic Docs - Beats input plugin Elastic Docs - Udp input plugin Elastic Docs - Http input plugin Elastic Docs - Configure the Logstash output (Filebeat) Elastic Docs - Networking settings Elastic Docs - Node roles Elastic Docs - Search shard routing NGINX Docs - HTTP Load Balancing NGINX Docs - TCP and UDP Load Balancing ","date":"2026년 4월 13일","externalUrl":null,"permalink":"/posts/l4-vs-l7-load-balancing/","section":"Posts","summary":"L4와 L7의 기준 차이만이 아니라, TLS 종료가 왜 절대 구분선이 아닌지, Logstash와 Elasticsearch 앞단에서는 왜 보통 L4 또는 단순 분산이 많이 쓰이는지 정리합니다.","title":"[Network] L4 vs L7 로드밸런싱 정리 - ALB/NLB, Logstash, Elasticsearch","type":"posts"},{"content":"","date":"2026년 4월 13일","externalUrl":null,"permalink":"/tags/aws/","section":"Tags","summary":"","title":"AWS","type":"tags"},{"content":"","date":"2026년 4월 13일","externalUrl":null,"permalink":"/tags/l4/","section":"Tags","summary":"","title":"L4","type":"tags"},{"content":"","date":"2026년 4월 13일","externalUrl":null,"permalink":"/tags/l7/","section":"Tags","summary":"","title":"L7","type":"tags"},{"content":"","date":"2026년 4월 13일","externalUrl":null,"permalink":"/tags/load-balancer/","section":"Tags","summary":"","title":"Load Balancer","type":"tags"},{"content":"","date":"2026년 4월 13일","externalUrl":null,"permalink":"/tags/logstash/","section":"Tags","summary":"","title":"Logstash","type":"tags"},{"content":"","date":"2026년 4월 13일","externalUrl":null,"permalink":"/tags/network/","section":"Tags","summary":"","title":"Network","type":"tags"},{"content":"","date":"2026년 4월 8일","externalUrl":null,"permalink":"/tags/arp/","section":"Tags","summary":"","title":"ARP","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. IP와 MAC의 역할 차이부터 ARP, TCP/UDP, NAT/PAT까지 실제 패킷 흐름에 맞춰 정리합니다.\n목차 # 기초 — IP 주소 vs MAC 주소 ARP (Address Resolution Protocol) TCP — 연결 지향 프로토콜 TCP 3-Way Handshake (연결 수립) TCP 4-Way Handshake (연결 종료) UDP NAT (Network Address Translation) PAT 포트 번호 변환 메커니즘 1. 기초 — IP 주소 vs MAC 주소 # 비유로 이해하기 # 개념 비유 IP 주소 집의 도로명 주소 (논리 주소, 네트워크에 따라 바뀔 수 있음) MAC 주소 같은 LAN에서 프레임을 전달할 때 참고하는 인터페이스 주소 (보통 NIC에 할당되지만 변경될 수도 있음) IP 주소: 192.168.0.10 같은 형태. 네트워크 상에서 \u0026ldquo;어디로 보낼지\u0026rdquo; 경로를 찾는 데 사용 MAC 주소: AA:BB:CC:DD:EE:FF 같은 형태. 같은 네트워크(LAN) 안에서 이더넷 프레임을 어느 인터페이스로 보낼지 식별할 때 사용 💡 핵심: 인터넷 전체의 라우팅은 IP 기준이고, 이더넷 같은 로컬 링크에서 다음 홉까지 프레임을 보낼 때 MAC 주소가 쓰인다.\n2. ARP (Address Resolution Protocol) # ARP가 왜 필요한가? # 이더넷 LAN에서 같은 링크의 상대나 다음 홉에게 프레임을 보내려면 그 대상의 MAC 주소가 필요하다.\n애플리케이션이나 운영체제는 보통 먼저 IP 주소를 알고 있으므로, 이 IP를 MAC 주소로 해석하는 과정이 필요하다.\n\u0026ldquo;IP는 알겠는데, MAC 주소는 어떻게 알지?\u0026rdquo; → ARP가 해결해준다.\nARP 동작 과정 # 시나리오 1: PC가 처음 외부 네트워크로 나가려 할 때 (Gateway MAC 주소 찾기) # [내 PC] ─────────── [Gateway/공유기] ─────── [인터넷] Step 1. PC가 외부 통신을 시도\nPC는 외부 네트워크로 나가려는 시점에 Gateway(공유기)의 MAC 주소가 필요해질 수 있음 하지만 Gateway의 MAC 주소를 모름! Step 2. ARP Request (요청) — Broadcast\nPC가 네트워크 전체에 외침: \u0026#34;나는 192.168.0.1 (Gateway IP)를 가진 장치를 찾고 있어! MAC 주소 가진 분 대답해주세요!\u0026#34; 이걸 Broadcast 라고 함 → 같은 네트워크의 모든 장치에게 전송 Broadcast 주소: FF:FF:FF:FF:FF:FF Step 3. ARP Reply (응답) — 보통 Unicast\nGateway가 응답: \u0026#34;나야! 내 MAC 주소는 AA:BB:CC:DD:EE:FF 야!\u0026#34; 일반적인 응답은 Unicast → 요청한 호스트에게 1:1 전송 Step 4. ARP 캐시 저장\nPC는 받은 MAC 주소를 ARP 캐시 테이블에 저장 다음번엔 또 물어보지 않아도 됨 (일정 시간 후 만료) # Windows에서 ARP 캐시 확인하는 명령어 arp -a 시나리오 2: 네이버에 접속할 때 # [내 PC] → [Gateway/공유기] → [인터넷] → [네이버 서버] 핵심 질문: \u0026ldquo;네이버 서버의 MAC 주소로 직접 보내야 하나?\u0026rdquo;\n❌ 아니다! 이유:\n네이버 서버는 다른 네트워크에 있음 MAC 주소는 같은 링크/LAN 안에서만 직접 의미가 있음 다른 네트워크로 나가려면 Gateway(공유기) 를 거쳐야 함 따라서 PC가 실제로 하는 일:\n목적지 IP → 네이버 서버 IP (203.xxx.xxx.xxx) 목적지 MAC → Gateway의 MAC 주소 ← 핵심 포인트! 📌 다른 서브넷으로 나가는 패킷은 원격 서버의 MAC이 아니라, 먼저 기본 게이트웨이의 MAC으로 전달한다.\nGateway는 받은 패킷을 보고:\n목적지 IP를 확인 어디로 라우팅할지 결정 다음 경로로 전달 같은 서브넷 안의 다른 호스트에게 보내는 경우라면, 이때는 상대 호스트의 MAC 주소를 ARP로 찾는다.\n시나리오 3: DHCP + ARP 결합 # PC가 부팅 시 IP를 자동으로 받는 과정을 단순화해서 보면:\n1. PC 부팅 2. DHCPDISCOVER → Broadcast 3. DHCPOFFER ← DHCP 서버 응답 4. DHCPREQUEST → 사용할 주소 요청 5. DHCPACK ← IP 주소와 설정 확정 6. 이후 같은 링크에서 Gateway의 MAC 주소가 필요하면 ARP 수행 💡 DHCP는 기본 게이트웨이의 IP 주소를 옵션으로 줄 수 있지만, 게이트웨이의 MAC 주소까지 주는 건 아니다.\n그래서 로컬 링크에서 실제 프레임을 보내기 전에는 여전히 ARP가 필요하다.\n매번 ARP를 하면 낭비니까 ARP 캐싱으로 저장해두고 재사용한다.\n3. TCP — 연결 지향 프로토콜 # TCP vs UDP 비교 # 특성 TCP UDP 연결 연결 후 통신 (Connection) 연결 없이 바로 전송 신뢰성 높음 (재전송, 순서 보장) 낮음 속도 상대적으로 느림 빠름 사용처 웹, 이메일, 파일 전송 스트리밍, 게임, DNS 헤더 크기 보통 20~60 byte 8 byte TCP의 핵심 개념: 시퀀스 번호 (Sequence Number) # TCP는 데이터를 조각(Segment) 으로 나눠서 보낸다.\n시퀀스 번호 1, 크기: 1460 byte → ACK 번호 1461 시퀀스 번호 1461, 크기: 1460 byte → ACK 번호 2921 이렇게 동작하는 이유:\n패킷이 중간에 유실되거나 순서가 바뀔 수 있음 시퀀스 번호로 \u0026ldquo;몇 번째 바이트까지 잘 받았는지\u0026rdquo; 추적 가능 빠진 번호가 있으면 → 재전송 요청 TCP 연결 자체는 보통 출발지 IP/포트 + 목적지 IP/포트 조합으로 구분되고, Sequence 번호는 그 연결 안에서 바이트 스트림의 위치를 추적한다.\n4. TCP 3-Way Handshake (연결 수립) # 왜 3번이나 주고받나? # 양쪽이 서로 확인해야 한다:\n\u0026ldquo;나 보낼 수 있어?\u0026rdquo; → \u0026ldquo;응, 너 보내는 거 받았어\u0026rdquo; \u0026ldquo;나도 보낼 수 있어?\u0026rdquo; → \u0026ldquo;응, 너 보내는 거 받았어\u0026rdquo; 이걸 효율적으로 합치면 3번이 된다.\n상세 과정 # Client Server | | | ① SYN (seq=100) | | ─────────────────────────────────\u0026gt; | | | | ② SYN+ACK (seq=300, ack=101) | | \u0026lt;───────────────────────────────── | | | | ③ ACK (seq=101, ack=301) | | ─────────────────────────────────\u0026gt; | | | | [연결 수립 완료!] | ① Client → Server: SYN # SYN: Synchronize (동기화) 플래그 Client가 말함: \u0026ldquo;나 seq=100에서 시작할게. 연결하자!\u0026rdquo; ② Server → Client: SYN + ACK # SYN: \u0026ldquo;나는 seq=300에서 시작할게!\u0026rdquo; ACK: \u0026ldquo;너의 100번 잘 받았어. 다음엔 101번 줘\u0026rdquo; (ack=100+1=101) 두 개를 한 번에 보냄 → 효율적! ③ Client → Server: ACK # \u0026ldquo;네 300번 잘 받았어. 다음엔 301번 줘\u0026rdquo; (ack=300+1=301) 이 시점부터 연결 완료! 데이터 전송 가능 3-Way Handshake에서 교환하는 것 # TCP 3-way handshake의 핵심은 초기 시퀀스 번호 동기화이고, SYN 구간에서 필요하면 MSS 같은 TCP 옵션도 함께 전달된다.\n1. Sequence Number 교환\n서로의 초기 시퀀스 번호 공유 이걸로 앞으로 주고받는 바이트를 추적 2. MSS (Maximum Segment Size)\n\u0026ldquo;나는 이 연결에서 이 크기까지의 TCP 데이터 조각을 받을 수 있어\u0026rdquo; MSS는 흔히 \u0026ldquo;협상\u0026quot;처럼 설명되지만, 정확히는 각 방향에서 자신의 수신 가능 크기를 알리는 값 실제 전송 크기는 상대가 광고한 MSS와 경로 MTU 등을 함께 고려해 결정됨 5. TCP 4-Way Handshake (연결 종료) # 왜 4번이나 필요한가? # 3-Way와 달리, 종료는 단방향씩 따로 닫을 수 있기 때문이다.\n이유: TCP는 양방향 통신이라 각 방향을 독립적으로 종료할 수 있음.\n아래 예시는 Client가 먼저 종료를 시작하는 Active Close 상황이다. 실제로는 어느 쪽이든 먼저 종료를 시작할 수 있다.\n상세 과정 # Client (Active Close) Server (Passive Close) | | | ① FIN (seq=1000) | | ─────────────────────────────────\u0026gt; | | | | ② ACK (ack=1001) | | \u0026lt;───────────────────────────────── | | | | (FIN_WAIT_2 상태로 대기) | ← 서버가 남은 데이터 | | 마저 전송하는 시간 | ③ FIN (seq=2000) | | \u0026lt;───────────────────────────────── | | | | ④ ACK (ack=2001) | | ─────────────────────────────────\u0026gt; | | | | (TIME_WAIT 대기 후 완전 종료) | 완전 종료 ① Client → Server: FIN # \u0026ldquo;나는 더 보낼 데이터 없어. 연결 끊자\u0026rdquo; 하지만 아직 받는 건 가능한 상태 (Half-Close) ② Server → Client: ACK # \u0026ldquo;알겠어, 네 FIN 받았어\u0026rdquo; Server는 아직 보낼 데이터가 남아있을 수 있음 이 사이에 Server가 남은 데이터를 마저 전송 ③ Server → Client: FIN # \u0026ldquo;나도 이제 다 보냈어. 연결 끊자\u0026rdquo; ④ Client → Server: ACK # \u0026ldquo;알겠어, 완전히 종료할게\u0026rdquo; Client는 바로 종료 안 하고 TIME_WAIT 상태로 잠시 대기 이유: 마지막 ACK 재전송이 필요할 수 있고, 지연된 중복 세그먼트가 네트워크에 남아 있을 수 있어서 FIN + ACK가 왜 자주 따로따로 보이나? # 설명 편의상 FIN → ACK → FIN → ACK 4단계를 많이 그리지만, 항상 네 개의 개별 패킷으로 분리되는 것은 아니다.\n3-Way에서 SYN+ACK를 한 번에 합쳤던 것과 달리, 종료 구간은 다음처럼 동작할 수 있다.\nServer는 Client의 FIN을 받으면 우선 ACK 로 수신 사실을 알려야 함 하지만 Server 쪽에 아직 보낼 데이터가 남아 있다면, 자기 FIN은 나중에 보냄 반대로 보낼 데이터가 더 없다면, ACK와 FIN을 같은 세그먼트에 실어 보낼 수도 있음 즉, 대표적인 상태 전이는 4-way close로 설명하지만, 실제 패킷 수는 상황에 따라 합쳐질 수 있다.\n6. UDP # UDP 헤더가 간단한 이유 # TCP 헤더: 출발지 포트, 목적지 포트, 순서번호, ACK번호, 플래그, 윈도우 크기, 체크섬, 긴급 포인터... (보통 20~60 byte) UDP 헤더: 출발지 포트, 목적지 포트, 길이, 체크섬 (8 byte) UDP 특징 # 내장된 재전송/순서 보장/흐름 제어가 없음: TCP처럼 상대가 얼마나 받을 수 있는지 확인하며 전송 속도를 조절하는 기능이 프로토콜에 기본 포함되어 있지 않음 필요한 제어는 애플리케이션 레벨에서 구현해야 함 멀티미디어에 많이 쓰이는 이유: 영상/음성 스트리밍은 약간의 손실보다 실시간성이 더 중요할 때가 많음 7. NAT (Network Address Translation) # NAT가 필요한 이유 — IPv4 주소 부족 문제 # IPv4 주소 총 개수: 약 43억 개 전 세계 인터넷 기기: 수십억 대 이상 IPv4 주소가 한정적이라 나온 대표적인 해결책 중 하나: \u0026ldquo;공인 IP 1개로 여러 기기가 인터넷을 쓰자\u0026rdquo; → NAT\n💡 여기서 말하는 NAT/PAT는 주로 가정용 IPv4 환경 기준 설명이다.\n공인 IP vs 사설 IP # 구분 공인 IP (Public IP) 사설 IP (Private IP) 부여 주체 ISP (KT, SKT 등) 보통 내부 DHCP 서버/라우터 인터넷 통신 글로벌 인터넷에서 직접 라우팅 가능 글로벌 인터넷에서 직접 라우팅되지 않음 유일성 전 세계에서 유일 같은 사설망 안에서만 유일 예시 203.245.10.5 192.168.0.x, 10.x.x.x, 172.16.x.x ~ 172.31.x.x [인터넷] | | ← 공인 IP: 203.245.10.5 (ISP가 우리 집에 부여) | [공유기/Gateway] ← 공인 IP + 사설 IP 둘 다 가짐! | |── 192.168.0.2 (내 노트북) |── 192.168.0.3 (내 폰) |── 192.168.0.4 (내 TV) NAT 동작 원리 # 📤 나갈 때 (내부 → 외부) # [노트북 192.168.0.2 : 포트 5000] ↓ [공유기] 출발지 IP/포트를 바꿔치기! ↓ 출발지: 192.168.0.2:5000 → 203.245.10.5:12345 (공인 IP) 목적지: 네이버 서버 공유기가 NAT 테이블에 기록:\n사설 IP:포트 공인 IP:포트 192.168.0.2:5000 ↔ 203.245.10.5:12345 192.168.0.3:6000 ↔ 203.245.10.5:12346 192.168.0.4:7000 ↔ 203.245.10.5:12347 📥 들어올 때 (외부 → 내부) # [네이버 서버] → 203.245.10.5:12345 로 응답 전송 ↓ [공유기] NAT 테이블 조회! \u0026#34;12345 포트는 192.168.0.2:5000 이었지\u0026#34; ↓ 목적지를 192.168.0.2:5000 으로 복원해서 전달 💡 포트 번호로 어느 기기의 요청인지 구분하는 게 핵심!\n이걸 PAT (Port Address Translation) 또는 NAPT 라고도 부름\nNAT의 종류 # 종류 설명 사용 예 Static NAT 사설 IP 1개 ↔ 공인 IP 1개 고정 매핑 사내 서버 외부 노출 Dynamic NAT 공인 IP 풀에서 동적으로 할당 기업 환경 PAT (가장 흔함) 공인 IP 1개 + 포트로 여러 기기 구분 가정용 공유기 NAT의 장단점 # ✅ 장점\nIP 주소 절약 — 공인 IP 1개로 여러 기기 인터넷 사용 주소 은닉 — 내부 사설 IP 체계가 외부에 직접 드러나지 않음 ❌ 단점\n외부에서 내부로 먼저 접근하기 어려움 전통적인 outbound-initiated NAT에선, NAT 테이블이나 정적 매핑이 없으면 외부에서 내부 호스트로 먼저 들어오기 어려움 이게 게임할 때 \u0026ldquo;포트 포워딩\u0026rdquo; 이 필요한 대표적인 이유 추적 어려움 — 같은 공인 IP에서 여러 사용자가 나오니 로그 분석이 복잡 📌 NAT는 주소 변환 기능이다. 실무에선 상태 기반 필터링과 함께 동작하는 경우가 많지만, NAT 자체가 방화벽과 동일한 개념은 아니다.\n포트 포워딩 (NAT의 응용) # \u0026ldquo;외부에서 내 집 서버에 접근하고 싶어\u0026rdquo;\n외부 사용자 → 203.245.10.5:8080 으로 접근 ↓ 공유기 설정: \u0026#34;8080 포트로 오는 건 192.168.0.2:8080으로 전달해\u0026#34; ↓ 내 노트북 서버에 도달 ✅ 공유기 관리 페이지에서 포트 포워딩 규칙을 직접 만들어두면 된다.\n8. PAT 포트 번호 변환 메커니즘 # 핵심 문제 상황 # 노트북 192.168.0.2:51234 → 네이버:443 폰 192.168.0.3:51234 → 네이버:443 ← 포트까지 같음! TV 192.168.0.4:51234 → 네이버:443 공유기 입장에서 이걸 공인 IP 하나로 내보내면 누구 거지? → 응답이 돌아왔을 때 누구한테 줘야 할지 모름\n클라이언트 포트 번호의 특성 # TCP/UDP에서 클라이언트가 사용하는 포트는 보통 Ephemeral Port (임시 포트):\nWell-Known Port: 0 ~ 1023 (HTTP:80, HTTPS:443 등 서버용) Registered Port: 1024 ~ 49151 (등록된 애플리케이션용) Ephemeral Port: 49152 ~ 65535 ← 클라이언트가 동적으로 사용 클라이언트는 서버에 연결할 때 이 범위에서 OS가 동적으로 포트를 하나 골라 쓴다.\n포트 변환 방식 — 3가지 케이스 # ✅ Case 1: 충돌이 없는 경우 (그냥 통과) # 노트북 192.168.0.2:51234 → 공유기 203.245.10.5:51234 → 네이버 폰 192.168.0.3:62891 → 공유기 203.245.10.5:62891 → 네이버 출발지 포트가 다르면 그대로 같은 외부 포트로 매핑될 수도 있음.\nNAT 테이블에 기록만 해두면 끝.\nNAT 테이블: 사설측 공인측 192.168.0.2:51234 ↔ 203.245.10.5:51234 192.168.0.3:62891 ↔ 203.245.10.5:62891 🔥 Case 2: 포트 충돌 발생 (핵심!) # 노트북 192.168.0.2:51234 → 이미 NAT 테이블에 있음! 폰 192.168.0.3:51234 → 같은 외부 포트를 쓰려는데 이미 사용 중! 이때 공유기가 하는 일:\n1. NAT 테이블에서 해당 외부 포트 사용 여부 확인 2. 충돌 감지 3. 사용 가능한 다른 외부 포트 선택 4. 예: 51235가 비어있다 → 폰의 패킷 포트를 51235로 교체 5. 변환해서 내보냄 NAT 테이블: 사설측 공인측 192.168.0.2:51234 ↔ 203.245.10.5:51234 (원본 유지) 192.168.0.3:51234 ↔ 203.245.10.5:51235 ← 포트 번호 변경됨 어떤 외부 포트를 고르는지는 NAT 구현마다 다르다.\nCase 3: NAT 테이블은 생각보다 더 많은 정보를 본다 # 실제 NAT 구현은 포트만 보는 게 아니라 세션 정보를 더 정밀하게 추적한다.\nRFC 4787은 UDP NAT 동작을 설명할 때 세션을 다음과 같은 endpoint tuple 관점으로 본다.\n(출발지 IP : 출발지 포트 : 목적지 IP : 목적지 포트) 예를 들어:\n192.168.0.2:51234 → 네이버:443 192.168.0.2:51234 → 구글:443 같은 내부 포트를 쓰더라도 목적지가 다르면 별도 세션으로 관리될 수 있다.\n다만 외부 포트 재사용 방식은 endpoint-independent / address-dependent / address-and-port-dependent 처럼 NAT 구현마다 다를 수 있다.\n전체 흐름 예시 # ① 노트북(192.168.0.2:51234) → 네이버:443 요청 공유기: 테이블 확인 → 비어있음 → 그대로 51234 사용 NAT 테이블 등록: 0.2:51234 ↔ 공인:51234 → 네이버:443 ② 폰(192.168.0.3:51234) → 네이버:443 요청 공유기: 테이블 확인 → 공인:51234→네이버:443 이미 사용 중 → 다른 외부 포트 선택 → 예: 51235로 변환 NAT 테이블 등록: 0.3:51234 ↔ 공인:51235 → 네이버:443 ③ 네이버 → 공인:51234 응답 공유기: 51234 → 노트북(192.168.0.2:51234) 으로 전달 ✅ ④ 네이버 → 공인:51235 응답 공유기: 51235 → 폰(192.168.0.3:51234) 으로 전달 ✅ 한계 — 포트 공간은 유한하다 # 사용 가능한 포트 번호 범위: 0 ~ 65535 공인 IP 1개만으로 유지할 수 있는 동시 세션 수에는 포트 공간의 한계가 있다.\n실제 사용 가능 수는 예약 포트, 구현 방식, 프로토콜별 분리 등에 따라 달라질 수 있다.\n대형 기업/ISP 환경에선 이것도 부족해서:\n공인 IP 여러 개를 풀로 운영 또는 Carrier-Grade NAT (CGNAT) 처럼 NAT를 대규모로 운용 전체 그림 최종 정리 # PC 부팅 └─→ DHCP로 IP 받기 └─→ ARP로 Gateway MAC 주소 찾기 └─→ NAT로 사설 IP → 공인 IP 변환 └─→ TCP 3-Way Handshake로 서버와 연결 └─→ 데이터 주고받기 (Seq/ACK로 신뢰성 보장) └─→ TCP 4-Way Handshake로 연결 종료 전 세계 인터넷 | 공인 IP (203.245.10.5) | [공유기] ← NAT/PAT 수행 / | \\ 폰 PC TV 192. 192. 192. 168. 168. 168. 0.3 0.2 0.4 (사설 IP들) 핵심 요약 카드 # 개념 한 줄 요약 ARP IP 주소로 링크 계층 주소를 찾아내는 프로토콜 MAC 주소 같은 LAN 안에서 프레임을 전달할 때 참고하는 인터페이스 주소 TCP 3-Way SYN → SYN+ACK → ACK 로 연결 수립, 초기 Seq 번호 동기화와 SYN 옵션 교환 TCP 4-Way 대표적으로 FIN → ACK → FIN → ACK 로 연결 종료, 실제 패킷은 상황에 따라 합쳐질 수 있음 NAT 사설 IP ↔ 공인 IP 변환으로 IPv4 사설망이 외부와 통신하도록 돕는 방식 PAT 포트 번호까지 함께 변환해 여러 사설 호스트를 공인 IP 1개에 매핑 포트 충돌 공유기가 다른 외부 포트를 선택해 NAT 테이블에 기록 UDP 연결 없이 최소한의 헤더로 전송하며, 신뢰성 제어는 애플리케이션이 담당 참고 자료 # RFC 826 - An Ethernet Address Resolution Protocol RFC 2131 - Dynamic Host Configuration Protocol RFC 2132 - DHCP Options and BOOTP Vendor Extensions RFC 768 - User Datagram Protocol RFC 9293 - Transmission Control Protocol (TCP) RFC 1918 - Address Allocation for Private Internets RFC 3022 - Traditional IP Network Address Translator (Traditional NAT) RFC 4787 - Network Address Translation (NAT) Behavioral Requirements for Unicast UDP RFC 6335 - Service Name and Port Number Registry Procedures ","date":"2026년 4월 8일","externalUrl":null,"permalink":"/posts/network-fundamentals-arp-tcp-udp-nat/","section":"Posts","summary":"IP와 MAC의 역할 차이, ARP 동작, TCP 연결 수립과 종료, UDP 특성, NAT/PAT의 포트 변환 개념을 공식 문서 기준으로 보수적으로 정리합니다.","title":"IP, MAC, ARP, TCP/UDP, NAT/PAT는 실제 통신에서 어떻게 이어질까?","type":"posts"},{"content":"","date":"2026년 4월 8일","externalUrl":null,"permalink":"/tags/nat/","section":"Tags","summary":"","title":"NAT","type":"tags"},{"content":"","date":"2026년 4월 8일","externalUrl":null,"permalink":"/tags/pat/","section":"Tags","summary":"","title":"PAT","type":"tags"},{"content":"","date":"2026년 4월 8일","externalUrl":null,"permalink":"/tags/tcp/","section":"Tags","summary":"","title":"TCP","type":"tags"},{"content":"","date":"2026년 4월 8일","externalUrl":null,"permalink":"/tags/udp/","section":"Tags","summary":"","title":"UDP","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. InnoDB에서 일반 SELECT와 locking read의 차이, MVCC와 2PL의 관계, next-key lock과 undo/redo의 역할을 정리합니다.\n목차 # MVCC 2PL MVCC와 2PL의 관계 Isolation Level 심층 비교 Non-Repeatable Read vs Phantom Read Locking Read와 Next-Key Lock Steal/No-Force 정책과 Redo/Undo Log의 관계 핵심 요약 1. MVCC # 개념 # MVCC는 데이터의 여러 버전을 활용해 읽기와 쓰기가 서로를 불필요하게 막지 않도록 하는 방식이다.\nInnoDB의 일반 SELECT 는 READ COMMITTED 와 REPEATABLE READ 에서 기본적으로 consistent nonlocking read 로 처리된다. 즉, 보통은 현재 최신 값을 바로 읽기보다, 특정 시점의 스냅샷을 기준으로 보이는 버전을 읽는다.\nInnoDB의 구현 - 숨겨진 컬럼과 undo log # MySQL 공식 문서 기준으로 InnoDB는 각 행에 내부 필드를 유지한다.\n숨김 컬럼 역할 DB_TRX_ID 이 행을 마지막으로 INSERT 또는 UPDATE 한 트랜잭션 ID DB_ROLL_PTR rollback segment의 undo log record를 가리키는 포인터 DB_ROW_ID 내부 row id 현재 row DB_TRX_ID = 110 DB_ROLL_PTR ──▶ undo log record 이전 버전 정보 핵심은 \u0026ldquo;행을 통째로 여러 개 복사해 둔다\u0026quot;기보다:\n현재 row에는 최신 버전이 있고 필요하면 DB_ROLL_PTR 를 따라 undo 정보를 이용해 더 오래된 버전을 재구성한다 고 이해하는 편이 정확하다.\n공식 문서는 삭제도 내부적으로는 delete mark가 붙는 update처럼 다룬다고 설명한다.\nRead View # Read View는 \u0026ldquo;무엇이 보이고 무엇이 안 보이는지\u0026quot;를 결정하는 스냅샷 메타데이터다.\n엄밀히는 활성 트랜잭션 목록과 visibility boundary를 기준으로 판단하므로, 흔히 말하는 \u0026ldquo;내 TRX_ID보다 작은 건 다 보인다\u0026rdquo; 정도로 단순화하면 부족하다. 다만 개념적으로는 다음처럼 이해해도 된다.\n스냅샷 시점보다 이전에 커밋된 버전 은 보인다 그 시점에 아직 활성 중이거나 이후에 커밋된 버전 은 보이지 않는다 현재 row가 보이지 않으면 InnoDB는 필요할 경우 undo를 이용해 더 오래된 버전을 재구성한다 예외적으로, 현재 트랜잭션이 earlier statements에서 직접 만든 변경은 현재 트랜잭션에서 보인다 Isolation Level별 스냅샷 생성 시점 # Isolation Level 일반 SELECT 기준 동작 READ UNCOMMITTED nonlocking read이지만 일관된 스냅샷 읽기는 아님 READ COMMITTED 각 consistent read마다 fresh snapshot 생성 REPEATABLE READ 첫 consistent read가 만든 snapshot 재사용 SERIALIZABLE autocommit=0 이면 plain SELECT 가 사실상 FOR SHARE 로 처리 MVCC가 주는 효과 # READ COMMITTED 와 REPEATABLE READ 의 일반 SELECT 에서는 다음 성질이 핵심이다.\n커밋되지 않았거나 나중에 커밋된 변경은 보이지 않는다 consistent read는 읽는 동안 잠금을 잡지 않으므로 읽기-쓰기 충돌을 크게 줄인다 REPEATABLE READ 에서는 같은 트랜잭션 안의 repeated plain SELECT 가 서로 일관된 결과를 본다 2. 2PL # 개념 # 2PL(Two-Phase Locking)은 락 획득 구간과 락 해제 구간을 분리하는 고전적 동시성 제어 모델이다.\nGrowing Phase Shrinking Phase (락 획득만 가능) (락 해제만 가능) ────────────────────────────────────────── Lock A Lock B Lock C ──▶ Unlock A Unlock B Unlock C Growing Phase: 필요한 락을 획득하기만 하고 해제하지 않음 Shrinking Phase: 락을 해제하기 시작하면 새로운 락을 획득하지 않음 InnoDB에서는 어떻게 봐야 하나 # MySQL 공식 문서는 InnoDB transaction model이 multi-versioning database의 장점과 traditional two-phase locking을 결합 하려 한다고 설명한다.\n따라서 InnoDB 전체를 곧바로 \u0026ldquo;Strict 2PL 데이터베이스\u0026quot;라고 부르면 과하지만, locking read와 쓰기 구간의 락 동작 만 떼어 보면 strict 2PL에 가까운 성격이 있다.\n예를 들어 FOR UPDATE, FOR SHARE, UPDATE, DELETE 는 락을 잡고 트랜잭션 종료 시점까지 유지한다.\nBEGIN; SELECT * FROM orders WHERE id = 1 FOR UPDATE; UPDATE orders SET status = \u0026#39;DONE\u0026#39; WHERE id = 1; COMMIT; 공식 문서는 FOR SHARE 와 FOR UPDATE 로 잡은 락이 COMMIT 또는 ROLLBACK 시 해제된다고 명시한다.\nInnoDB의 주요 락 종류 # 락 종류 설명 Shared Lock (S) 읽기 보호용 락 Exclusive Lock (X) 수정/삭제용 락 Intention Lock (IS / IX) 테이블 레벨 의도 표시 Gap Lock 인덱스 레코드 사이 gap에 대한 락 Next-Key Lock index-record lock + gap lock Insert Intention Lock INSERT 직전 gap에 잡히는 의도 락 락의 실제 범위는 격리 수준과 검색 조건, 사용한 인덱스에 따라 달라진다.\nDeadlock # 락 기반 동시성 제어의 대표적인 부작용은 데드락이다.\nT1: Lock A → Lock B 시도 T2: Lock B → Lock A 시도 InnoDB는 deadlock detection을 통해 이런 순환 대기를 감지하고, 한 트랜잭션을 롤백해 문제를 푼다.\n3. MVCC와 2PL의 관계 # InnoDB는 둘 중 하나만 쓰는 엔진이 아니라, 읽기 경로와 잠금 경로를 분리해 함께 사용 한다.\n작업 사용 방식 일반 SELECT (READ COMMITTED, REPEATABLE READ) MVCC 기반 consistent nonlocking read SELECT ... FOR UPDATE / SELECT ... FOR SHARE locking read, 최신 상태 + 락 UPDATE / DELETE 최신 상태 + 락 INSERT insert intention lock + record lock 그래서 InnoDB를 가장 무리 없이 설명하는 방식은 다음이다.\n일반 읽기 는 MVCC locking read와 DML 은 락 기반 락 기반 구간은 strict 2PL에 가까운 성격 공식 문서가 REPEATABLE READ 안에서 nonlocking SELECT 와 locking statement를 섞는 것을 권장하지 않는 이유도 여기 있다. 한 트랜잭션 안에 snapshot world와 current world가 공존 하기 때문이다.\n4. Isolation Level 심층 비교 # Read Committed - SELECT를 실행한 그 순간 기준 # T1: BEGIN T1: (t=1) SELECT ... -\u0026gt; snapshot A 생성 T2: UPDATE ... ; COMMIT (t=2) T1: (t=3) SELECT ... -\u0026gt; snapshot B 생성 -\u0026gt; T2 변경이 반영될 수 있음 포인트는 단순하다.\n각 consistent read마다 새 snapshot을 만든다 같은 트랜잭션 안에서도 repeated read 결과가 달라질 수 있다 locking read / UPDATE / DELETE 는 보통 gap이 아니라 index record만 잠근다 gap locking은 주로 foreign key check와 duplicate key check에서만 사용된다 즉, READ COMMITTED 에서는 range 기반 locking read에서 phantom row 문제가 남을 수 있다.\nRepeatable Read - 첫 consistent read 기준 # T1: BEGIN T1: (t=1) SELECT ... -\u0026gt; snapshot A 생성 T2: UPDATE ... ; COMMIT (t=2) T1: (t=3) SELECT ... -\u0026gt; snapshot A 재사용 -\u0026gt; plain SELECT 결과는 일관됨 포인트는 다음과 같다.\n첫 consistent read가 만든 snapshot을 재사용한다 같은 트랜잭션 안의 plain SELECT 는 서로 일관된 결과를 본다 range 조건의 locking read, UPDATE, DELETE 는 gap lock 또는 next-key lock을 사용할 수 있다 다만 여기서도 주의할 점이 있다.\nplain SELECT 는 read view 기반 snapshot을 본다 locking read와 DML은 latest state 를 기준으로 락을 사용한다 그래서 같은 트랜잭션 안에서 nonlocking SELECT 와 locking statement를 섞으면 해석이 어려워진다.\nSerializable - \u0026ldquo;MVCC 완전 포기\u0026quot;라고 단정하면 과하다 # 원문처럼 SERIALIZABLE 을 \u0026ldquo;MVCC를 완전히 버린다\u0026quot;라고 쓰면 과장이다.\nMySQL 8.4 공식 문서는 SERIALIZABLE 을 REPEATABLE READ 와 유사하지만, autocommit 이 꺼져 있을 때 plain SELECT 를 암묵적으로 SELECT ... FOR SHARE 로 바꾼다고 설명한다.\n즉:\n수동 트랜잭션에서는 plain SELECT 도 locking read처럼 동작한다 하지만 autocommit 이 켜져 있으면 plain SELECT 는 자기 자신이 하나의 read-only transaction이므로 consistent nonlocking read로 직렬화될 수 있다 따라서 더 정확한 이해는 이렇다.\nSERIALIZABLE = \u0026ldquo;항상 MVCC 완전 포기\u0026rdquo; 가 아니라 \u0026ldquo;RR보다 더 엄격하고, 특히 autocommit off의 plain SELECT 는 locking read처럼 다룬다\u0026rdquo; Isolation Level 전체 비교 # Level plain SELECT locking read / DML 특징 READ UNCOMMITTED nonlocking read, but not consistent 대체로 RC와 유사한 locking dirty read 가능 READ COMMITTED 각 consistent read마다 fresh snapshot gap locking 대부분 비활성 repeated read 불안정 REPEATABLE READ 첫 consistent read snapshot 재사용 range scan 시 gap/next-key lock 사용 가능 InnoDB 기본값 SERIALIZABLE autocommit off면 plain SELECT 가 사실상 FOR SHARE 가장 보수적 locking 동시성 저하 가능 5. Non-Repeatable Read vs Phantom Read # 공통 전제 - plain SELECT 와 locking read를 구분해야 한다 # 이 두 이상 현상은 교과서적으로는 비슷해 보여도, InnoDB에서는 일반 consistent read인지, locking read인지 에 따라 설명이 달라진다.\n5-1. Non-Repeatable Read - update된 기존 행 # T1: BEGIN T1: SELECT * FROM users WHERE id = 1 -\u0026gt; \u0026#39;Alice\u0026#39; T2: UPDATE users SET name=\u0026#39;Bob\u0026#39; WHERE id=1; T2: COMMIT T1: SELECT * FROM users WHERE id = 1 -\u0026gt; ? 현재 row가 이미 바뀌어 있어도, REPEATABLE READ 의 plain SELECT 는 snapshot 기준에 맞는 더 오래된 버전을 찾아야 할 수 있다.\n[현재 Row] id=1, name=\u0026#39;Bob\u0026#39; DB_TRX_ID = 110 DB_ROLL_PTR ──────────▶ [Undo Log] id=1, name=\u0026#39;Alice\u0026#39; READ COMMITTED 에서는 두 번째 SELECT 가 새 snapshot을 잡으므로 Bob 을 볼 수 있다 REPEATABLE READ 에서는 첫 snapshot을 재사용하므로 update undo를 이용해 더 오래된 버전을 재구성할 수 있다 즉, 기존 행의 update 는 update undo 체인이 중요한 사례다.\n5-2. Phantom Read - 범위 안에 새 행이 생기는 경우 # T1: BEGIN T1: SELECT * FROM users WHERE age \u0026gt; 20 T2: INSERT INTO users VALUES (3, \u0026#39;Charlie\u0026#39;, 28); T2: COMMIT T1: SELECT * FROM users WHERE age \u0026gt; 20 -\u0026gt; ? 여기서 주의할 점은 다음 두 가지다.\nREPEATABLE READ 의 plain SELECT 는 같은 snapshot을 재사용하므로, repeated read 결과 집합이 안정적이다. MySQL 공식 문서가 phantom problem을 설명할 때 사용하는 대표 예시는 SELECT ... FOR UPDATE 같은 locking read 다. 즉, \u0026ldquo;phantom은 insert된 행에 undo log가 없어서 생긴다\u0026quot;처럼 설명하면 부정확하다.\n더 정확하게 말하면:\n새로 insert된 행은 내 snapshot 이후에 생긴 row라면 visible하지 않다 insert undo는 rollback 용도이고 commit 후 버릴 수 있다 consistent read에서 과거 버전 재구성에 핵심적으로 쓰이는 것은 update undo 다 locking read의 phantom 방지는 next-key lock 이 담당한다 5-3. 두 차이의 핵심 비교 # 구분 Non-Repeatable Read Phantom Read 관찰 대상 같은 row의 값이 바뀜 같은 predicate의 결과 집합이 바뀜 대표 원인 기존 행 UPDATE 범위 안으로 새 행 INSERT 또는 기존 행 이동 REPEATABLE READ plain SELECT snapshot + update undo로 안정적 같은 snapshot 재사용으로 결과 집합 안정적 locking read에서의 방어 최신 상태에 대해 row lock 사용 range scan에 next-key/gap lock 사용 핵심은 이렇다.\nplain SELECT 의 반복 가능성 은 snapshot이 담당한다 locking read의 phantom 방지 는 next-key lock이 담당한다 6. Locking Read와 Next-Key Lock # Locking Read는 최신 상태와 락을 함께 쓴다 # SELECT ... FOR UPDATE 와 SELECT ... FOR SHARE 는 plain SELECT 와 다르게 locking read다.\n최신 상태를 기준으로 읽는다 검색 과정에서 만난 index record들에 락을 건다 트랜잭션 종료 시까지 락을 유지한다 공식 문서는 old version은 잠글 수 없고, old version은 undo log를 적용해 메모리 상에서 재구성한다고 설명한다.\n또한 locking read는 명시적 트랜잭션 안에서 써야 한다. 문서도 START TRANSACTION 이나 autocommit=0 상태를 전제로 설명한다.\nNext-Key Lock이 필요한 이유 # MySQL 공식 문서의 phantom 예시는 다음과 같다.\nSELECT * FROM child WHERE id \u0026gt; 100 FOR UPDATE; 인덱스에 90, 102 만 있다고 하자.\nT1: SELECT * FROM child WHERE id \u0026gt; 100 FOR UPDATE; T2: INSERT INTO child VALUES (101); -- gap이 안 잠기면 phantom 가능 이때 단순 row lock만으로는 (90, 102) 사이에 101 이 새로 들어오는 것을 막지 못한다. 그래서 InnoDB는 next-key locking 을 사용한다.\nNext-Key Lock의 의미 # next-key lock은:\nindex-record lock 그 레코드 앞의 gap lock 의 조합이다.\n(90, 102] \u0026lt;- 102 record + 그 앞 gap (102, +∞) \u0026lt;- 마지막 record 뒤 gap 공식 문서는 기본 REPEATABLE READ 에서 InnoDB가 search와 index scan에 next-key lock을 사용해 phantom row를 막는다고 설명한다.\n반대로 READ COMMITTED 에서는 gap locking이 대부분 비활성화되므로, range 조건 locking read에서는 phantom row 문제가 다시 가능해진다.\n7. Steal/No-Force 정책과 Redo/Undo Log의 관계 # 배경 - Buffer Pool, Dirty Page, Checkpoint # InnoDB는 table과 index 데이터를 메모리의 Buffer Pool 에 캐시한다. 수정된 페이지는 곧바로 data file에 쓰이지 않을 수 있고, 이런 페이지를 dirty page라고 부른다.\n[Disk] ──읽기──▶ [Buffer Pool] └─ dirty page └─ background flush / checkpoint 공식 문서는:\ndirty page가 background에서 flush될 수 있고 page cleaner가 이를 담당하며 checkpoint는 fuzzy checkpointing 으로 동작해 전체 버퍼 풀을 한 번에 flush할 필요가 없다고 설명한다 즉, commit과 data file flush 시점은 항상 1:1로 맞물려 있지 않다.\nRedo Log # MySQL 8.4 공식 문서에서 redo log는:\ndisk-based data structure crash recovery 때 data file에 끝까지 반영되지 못한 변경을 보정하기 위한 구조 로 설명된다.\n즉, 예기치 않은 종료 전에 data file 갱신이 끝나지 못한 변경은 재시작 시 redo를 replay해 복원한다.\nUndo Log # undo log record는 clustered index record의 최신 변경을 되돌리기 위한 정보 를 담는다.\n공식 문서는 또 다음을 명확히 구분한다.\ninsert undo: rollback에만 필요하고 commit 후 버릴 수 있다 update undo: rollback뿐 아니라 consistent read에도 쓰이며, 더 이상 어떤 snapshot도 필요로 하지 않을 때만 버릴 수 있다 따라서 undo log의 역할은 두 갈래다.\nrollback을 위한 복구 정보 consistent read를 위한 과거 버전 재구성 정보 Steal/No-Force라는 표현은 해석이다 # 여기서 원문의 Steal + No-Force 설명은 교과서적 해석으로는 꽤 유용하지만, MySQL 공식 문서가 InnoDB를 그 용어로 직접 규정하는 것은 아니다.\n다만 공식 문서가 보여 주는 사실을 묶어 보면:\ndirty page는 background에서 나중에 flush될 수 있고 redo는 crash recovery에 필요하며 undo는 rollback과 consistent read에 필요하다 는 점에서, 이론적으로는 steal/no-force에 가까운 운영 모델로 이해할 수 있다 고 말하는 정도는 가능하다.\n즉 이 부분은 다음처럼 표현하는 편이 안전하다.\n\u0026ldquo;InnoDB는 공식 문서상 Steal + No-Force라고 규정된다\u0026rdquo; 보다는 \u0026ldquo;공식 문서가 설명하는 flush/recovery 동작을 교과서 용어로 읽으면 steal/no-force에 가깝게 이해할 수 있다\u0026rdquo; 8. 핵심 요약 # plain SELECT in RC/RR -\u0026gt; MVCC 기반 consistent nonlocking read READ COMMITTED -\u0026gt; consistent read마다 fresh snapshot REPEATABLE READ -\u0026gt; 첫 consistent read snapshot 재사용 FOR UPDATE / FOR SHARE / UPDATE / DELETE -\u0026gt; 최신 상태 + 락 -\u0026gt; locking part는 strict 2PL에 가까움 phantom 방지 -\u0026gt; plain SELECT의 반복 가능성은 snapshot이 담당 -\u0026gt; locking read의 phantom 방지는 next-key lock이 담당 SERIALIZABLE -\u0026gt; RR보다 더 엄격 -\u0026gt; autocommit off의 plain SELECT는 사실상 FOR SHARE처럼 동작 redo / undo -\u0026gt; redo는 crash recovery replay -\u0026gt; undo는 rollback + old-version reconstruction 한 줄로 줄이면 이렇다.\nInnoDB는 읽기를 전부 락으로 처리하는 엔진도 아니고, 전부 MVCC만 쓰는 엔진도 아니다. plain SELECT 는 snapshot을 읽고, locking read와 쓰기 작업은 최신 상태를 기준으로 락을 사용한다.\n참고 자료 # MySQL 8.4 Reference Manual, InnoDB Transaction Model\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-model.html MySQL 8.4 Reference Manual, InnoDB Multi-Versioning\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html MySQL 8.4 Reference Manual, Consistent Nonlocking Reads\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html MySQL 8.4 Reference Manual, Transaction Isolation Levels\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html MySQL 8.4 Reference Manual, Locking Reads\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html MySQL 8.4 Reference Manual, InnoDB Locking\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html MySQL 8.4 Reference Manual, Phantom Rows\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-next-key-locking.html MySQL 8.4 Reference Manual, Undo Logs\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-undo-logs.html MySQL 8.4 Reference Manual, Redo Log\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html MySQL 8.4 Reference Manual, Configuring Buffer Pool Flushing\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool-flushing.html MySQL 8.4 Reference Manual, InnoDB Checkpoints\nhttps://dev.mysql.com/doc/refman/8.4/en/innodb-checkpoints.html ","date":"2026년 4월 2일","externalUrl":null,"permalink":"/posts/mysql-mvcc-2pl/","section":"Posts","summary":"InnoDB에서 일반 SELECT와 locking read가 어떻게 다르게 동작하는지, REPEATABLE READ와 READ COMMITTED의 차이, phantom과 next-key lock, undo/redo의 역할을 한 번에 정리합니다.","title":"[MySQL] 동시성 제어 정리 — MVCC, 2PL, Locking Read, Next-Key Lock","type":"posts"},{"content":"","date":"2026년 4월 2일","externalUrl":null,"permalink":"/tags/2pl/","section":"Tags","summary":"","title":"2PL","type":"tags"},{"content":"","date":"2026년 4월 2일","externalUrl":null,"permalink":"/tags/database/","section":"Tags","summary":"","title":"Database","type":"tags"},{"content":"","date":"2026년 4월 2일","externalUrl":null,"permalink":"/tags/innodb/","section":"Tags","summary":"","title":"InnoDB","type":"tags"},{"content":"","date":"2026년 4월 2일","externalUrl":null,"permalink":"/tags/isolation-level/","section":"Tags","summary":"","title":"Isolation Level","type":"tags"},{"content":"","date":"2026년 4월 2일","externalUrl":null,"permalink":"/tags/mvcc/","section":"Tags","summary":"","title":"MVCC","type":"tags"},{"content":"","date":"2026년 3월 31일","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"DevOps","type":"tags"},{"content":"","date":"2026년 3월 31일","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"2026년 3월 31일","externalUrl":null,"permalink":"/tags/docker-compose/","section":"Tags","summary":"","title":"Docker Compose","type":"tags"},{"content":"","date":"2026년 3월 31일","externalUrl":null,"permalink":"/tags/gcp/","section":"Tags","summary":"","title":"GCP","type":"tags"},{"content":"팀 프로젝트에서 GCP VM에 Docker Compose로 서비스를 배포하면서 겪은 삽질과 해결 과정을 Claude와 함께 정리한 글입니다. 공식 문서에서는 놓치기 쉬운, 초보자가 겪기 쉬운 이슈들(Debian/Ubuntu 혼동, ECDSA 인증서 호환성, 디스크 용량 부족 등)을 중심으로 기록했습니다.\n1. 목표 # GCP 가상서버(VM) 하나에 DB, 백엔드, 프론트엔드, Nginx를 Docker Compose로 함께 띄워서 외부에서 HTTPS로 접속 가능하게 한다. iOS 모바일 앱 개발자도 동일한 백엔드를 사용한다.\n2. 최종 구성도 # 브라우저 / iOS 앱 (https://ainiinu.kr) │ ▼ ┌──────────────────────────────────────┐ │ GCP VM (Debian 12 bookworm) │ │ │ │ Docker Compose │ │ ┌────────────────────────────────┐ │ │ │ nginx (:80/:443 외부 노출) │ │ │ │ ├─ / → frontend │ │ │ │ ├─ /api/v1/* → backend │ │ │ │ └─ /ws/* → backend │ │ │ ├────────────────────────────────┤ │ │ │ frontend (:3000 내부만) │ │ │ ├────────────────────────────────┤ │ │ │ backend (:8080 내부만) │ │ │ ├────────────────────────────────┤ │ │ │ postgres (:5432 내부만) │ │ │ └────────────────────────────────┘ │ │ │ │ 호스트에 설치: │ │ - certbot (SSL 인증서 발급/갱신) │ │ - /etc/letsencrypt → nginx에 마운트 │ └──────────────────────────────────────┘ 외부에는 Nginx의 80/443 포트만 노출 나머지 서비스는 Docker 내부 네트워크에서만 통신 Nginx가 리버스 프록시 + SSL 터미네이션 역할 수행 SSL 인증서는 호스트에서 certbot으로 직접 관리 (Docker certbot 컨테이너는 사용하지 않음) 3. 사전 준비 # 3.1 GCP Compute Engine 인스턴스 생성 # 항목 내용 GCP VM Compute Engine 인스턴스 생성 OS Debian 12 (bookworm) — GCP 기본 이미지 디스크 최소 20GB 이상 권장 (기본 10GB는 Docker 빌드 시 용량 부족) 고정 IP 외부 IP를 \u0026ldquo;고정 외부 IP\u0026quot;로 승격 (VM 재시작해도 IP 유지) SSH 접속 GCP 콘솔에서 \u0026ldquo;SSH\u0026rdquo; 버튼 클릭 (브라우저에서 바로 터미널 열림) 디스크 크기를 반드시 20GB 이상으로 설정하자!\nGCP 기본 디스크는 10GB인데, Docker 이미지 빌드 시 다음 용량이 필요하다:\nDocker 엔진 + 이미지 레이어 캐시: ~2GB 백엔드 빌드 (Gradle + JDK + 의존성): ~3GB 프론트엔드 빌드 (Node.js + node_modules): ~2GB PostgreSQL 데이터: ~500MB OS + 시스템: ~2GB 10GB로는 빌드 도중 no space left on device 에러가 발생한다. 인스턴스 생성 시 20~30GB로 설정하면 안전하다.\n이미 만든 인스턴스의 디스크를 늘리는 방법은 10.5절 참고.\n3.2 도메인 DNS 설정 # 도메인 구매 후 GCP VM 외부 IP에 DNS A 레코드 연결. DNS 제공자는 어디든 상관없음 (Route 53, Cloudflare, 가비아 등).\nainiinu.kr → 34.47.72.28 (GCP VM 외부 IP) 확인 방법 (VM에서):\nsudo apt install -y dnsutils # nslookup이 없으면 설치 nslookup ainiinu.kr # 도메인이 어떤 IP로 해석되는지 확인 curl -s ifconfig.me # VM의 외부 IP 확인 nslookup은 DNS 조회 도구다.\n쉽게 말하면 \u0026ldquo;이 도메인 이름이 지금 어느 IP 주소를 가리키고 있나?\u0026ldquo;를 DNS 서버에 물어보는 명령이다.\n여기서 이 명령이 중요한 이유는, 도메인 설정이 아직 VM에 반영되지 않았는데도 서버 설정 문제라고 착각하기 쉽기 때문이다.\n예를 들어 Nginx 설정, 방화벽, certbot 명령이 모두 정상이어도 DNS A 레코드가 아직 다른 IP를 가리키면 외부 접속이나 SSL 발급이 실패한다.\nnslookup ainiinu.kr를 실행했을 때 아래쪽의 Address 값이 내 GCP VM의 외부 IP와 같아야 한다.\nServer: 169.254.169.254 Address: 169.254.169.254#53 Non-authoritative answer: Name: ainiinu.kr Address: 34.47.72.28 위쪽 Server / Address는 조회에 사용한 DNS 서버 정보 아래쪽 Name / Address가 실제로 도메인이 해석된 결과 즉, 이 단계에서는 아래쪽 결과 IP만 보면 된다.\n이 값이 VM 외부 IP와 다르면 DNS 레코드가 잘못됐거나 아직 전파(propagation)가 덜 된 상태다.\n3.3 GCP 방화벽 설정 # 이것을 빠뜨리면 외부에서 접속이 안 되고, SSL 인증서 발급도 실패한다!\nGCP 콘솔 → VPC 네트워크 → 방화벽 → 방화벽 규칙 만들기:\n항목 값 이름 allow-http-https 유형 인그레스 대상 전체 적용 소스 IP 범위 0.0.0.0/0 프로토콜/포트 TCP: 80, 443 작업 허용 우선순위 1000 기본으로 존재하는 default-allow-internal은 GCP 내부 통신용이라 외부 접속에는 해당 없음. default-allow-ssh (TCP 22)는 SSH 접속용이므로 건드리지 않는다.\n3.4 비공개 Organization 레포지토리 clone # Organization 비공개 레포는 일반 git clone이 안 된다. GitHub Personal Access Token (PAT) 이 필요하다.\n토큰 생성 절차:\nGitHub → 우측 상단 프로필 → Settings Developer settings → Personal access tokens → Tokens (classic) Generate new token (classic) 클릭 Select scopes 에서 repo 체크 — 이것은 \u0026ldquo;이 토큰이 비공개 레포에 접근할 수 있는 권한\u0026quot;을 의미 생성 후 ghp_xxxx... 형태의 토큰 복사 (이 화면을 벗어나면 다시 볼 수 없음!) repo 스코프란? GitHub 토큰은 할 수 있는 일의 범위(scope)를 지정한다. repo를 체크하면 비공개 레포 읽기/쓰기 권한이 부여된다. clone만 할 거면 이것 하나만 체크하면 충분하다.\nOrganization 권한 부여 (중요!):\nPersonal 토큰을 만들어도 Organization 레포에는 바로 접근이 안 된다. 토큰 생성 후 추가 승인이 필요하다:\nGitHub → Settings → Developer settings → Personal access tokens 생성한 토큰 클릭 하단 Organization access 섹션에서 해당 org 옆 \u0026ldquo;Grant\u0026rdquo; 또는 \u0026ldquo;Authorize\u0026rdquo; 클릭 만약 Grant 버튼이 안 보이면, Organization 설정에서 PAT 접근을 허용해야 한다:\nGitHub → Organization 페이지 → Settings → Third-party access → Personal access tokens → 정책을 **\u0026ldquo;Allow\u0026rdquo;**로 변경 clone 명령:\ngit clone https://ghp_xxxx@github.com/\u0026lt;org\u0026gt;/\u0026lt;repo\u0026gt;.git 4. Docker \u0026amp; Docker Compose 설치 # 4.1 GCP VM의 OS 확인 # GCP Compute Engine의 기본 이미지는 Ubuntu가 아니라 Debian이다! 이것을 모르면 Docker 설치에서 삽질하게 된다.\nlsb_release -cs # → bookworm (Debian 12) lsb_release 란? Linux Standard Base release. OS 배포판 정보를 확인하는 명령어. -c는 코드명(codename), -s는 짧은 출력(short). bookworm이 나오면 Debian 12.\n4.2 Docker 설치 시 주의사항 # 흔한 실수와 에러:\n시도한 방법 결과 원인 sudo apt install docker.io Docker는 설치되지만 docker compose 명령 없음 docker.io는 Debian/Ubuntu 기본 패키지로, compose 플러그인 미포함 sudo apt install docker-compose-plugin Unable to locate package Docker 공식 저장소가 등록되지 않은 상태 Ubuntu용 Docker 저장소 추가 후 설치 Package 'docker-ce' has no installation candidate GCP VM이 Ubuntu가 아니라 Debian인데 Ubuntu 저장소를 추가함 Docker Desktop은 설치 불가 — GCP VM은 GUI가 없는 서버이므로 Docker Desktop(Mac/Windows 데스크탑용)은 사용할 수 없다.\ndocker.io vs docker-ce 차이:\ndocker.io — Debian/Ubuntu 공식 패키지 저장소에서 제공하는 Docker. 버전이 느리고 compose 플러그인 미포함. docker-ce — Docker 공식 저장소에서 제공하는 Community Edition. 최신 버전 + compose 플러그인 포함. 결론: 항상 docker-ce를 사용하자. 4.3 올바른 설치 방법 (Debian 12 bookworm) # # 1. 필수 도구 설치 sudo apt update sudo apt install -y ca-certificates curl gnupg # 2. Docker 공식 GPG 키 추가 # GPG 키 = 패키지가 진짜 Docker에서 만든 것인지 검증하는 서명 키 sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg # 3. Debian용 Docker 저장소 추가 (Ubuntu가 아님에 주의!) # 이 줄이 apt에게 \u0026#34;Docker 패키지는 이 URL에서 다운받아라\u0026#34;고 알려줌 echo \u0026#34;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list \u0026gt; /dev/null # 4. Docker + Compose 플러그인 설치 sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 5. 현재 유저에게 docker 실행 권한 부여 # 이걸 안 하면 매번 sudo docker ... 로 실행해야 함 sudo usermod -aG docker $USER # 6. SSH 재접속 (권한 적용 — 재접속해야 그룹 변경이 반영됨) exit # GCP 콘솔에서 다시 SSH 접속 # 7. 설치 확인 docker --version # Docker 버전 출력 docker compose version # Docker Compose 버전 출력 각 패키지 역할:\ndocker-ce — Docker 엔진 (컨테이너 실행의 핵심) docker-ce-cli — docker 명령어 (CLI 도구) containerd.io — 컨테이너 런타임 (Docker 내부에서 실제로 컨테이너를 관리) docker-buildx-plugin — docker buildx 명령어 (멀티 플랫폼 이미지 빌드 지원) docker-compose-plugin — docker compose 명령어 (여러 컨테이너를 한번에 관리) 4.4 AWS EC2 기준 (참고) # Ubuntu 기준:\n# Docker 공식 저장소 추가 후 설치 (docker.io가 아닌 docker-ce 사용) sudo apt update sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \u0026#34;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list \u0026gt; /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo usermod -aG docker $USER # SSH 재접속 후 적용 Amazon Linux 2023 기준:\nsudo yum update -y sudo yum install -y docker sudo systemctl enable --now docker sudo usermod -aG docker $USER # Compose 플러그인 설치 sudo mkdir -p /usr/local/lib/docker/cli-plugins sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose # SSH 재접속 후 적용 주의: docker-compose(하이픈, v1)는 2023년 7월에 EOL되었다. 현재는 docker compose(스페이스, v2 플러그인)를 사용해야 한다. Ubuntu에서도 docker.io + docker-compose 대신 Docker 공식 저장소의 docker-ce + docker-compose-plugin을 설치하자.\nDocker 설치 이후의 모든 과정 (git clone, docker compose up, Nginx, SSL 등)은 GCP/AWS 동일하다.\n5. 프로젝트에서 생성한 파일들 # 5.1 프론트엔드 Dockerfile # aini-inu-frontend/Dockerfile:\nFROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_PUBLIC_ENABLE_MSW=false ENV NEXT_PUBLIC_API_PROXY_TARGET=http://backend:8080 ENV NEXT_PUBLIC_WS_URL=ws://backend:8080 RUN npm run build FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 RUN addgroup --system --gid 1001 nodejs \u0026amp;\u0026amp; \\ adduser --system --uid 1001 nextjs COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public USER nextjs EXPOSE 3000 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] 멀티스테이지 빌드란? 하나의 Dockerfile 안에서 여러 단계(FROM)를 거치는 빌드 방식. 빌드에 필요한 도구(node_modules 전체, 빌드 도구 등)는 builder 단계에서만 쓰고, 최종 이미지(runner)에는 실행에 필요한 최소한만 복사한다. 결과: 이미지 크기가 수 GB → 수백 MB로 줄어듦.\nnpm ci vs npm install 차이:\nnpm install — package.json 기준으로 의존성 해석, package-lock.json 수정 가능 npm ci — package-lock.json을 정확히 따름. 더 빠르고 재현 가능. CI/Docker 환경에 적합. standalone 빌드 주의사항: Next.js의 output: 'standalone'은 서버 코드만 포함한다. .next/static (JS/CSS 번들)과 public (정적 파일)은 자동 포함되지 않으므로 반드시 별도 COPY 해야 한다. 이걸 빠뜨리면 페이지는 뜨는데 CSS가 없거나 이미지가 안 보인다.\n5.2 백엔드 Dockerfile # aini-inu-backend/Dockerfile:\nFROM gradle:8.14.3-jdk21 AS builder WORKDIR /workspace COPY . . RUN ./gradlew bootJar --no-daemon FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=builder /workspace/build/libs/*.jar /app/app.jar EXPOSE 8080 ENTRYPOINT [\u0026#34;java\u0026#34;, \u0026#34;-Duser.timezone=Asia/Seoul\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;/app/app.jar\u0026#34;] 백엔드도 멀티스테이지 빌드를 사용한다.\nbuilder 단계 — Gradle + JDK 21 전체가 포함된 이미지(gradle:8.14.3-jdk21)에서 bootJar를 실행한다. bootJar는 Spring Boot 앱을 하나의 실행 가능한 .jar 파일로 패키징하는 Gradle 태스크다. --no-daemon은 Gradle 데몬 프로세스를 띄우지 않는 옵션. Docker 빌드처럼 일회성 실행에서는 데몬이 불필요하므로 메모리를 절약한다.\nrunner 단계 — eclipse-temurin:21-jre는 Java 실행 환경(JRE)만 포함된 경량 이미지다. builder에서 만든 .jar 파일 하나만 복사해오므로, Gradle, 소스코드, 의존성 캐시 등 빌드 도구는 최종 이미지에 포함되지 않는다. 결과: 빌드 이미지(~2.5GB) → 실행 이미지(~300MB)로 경량화.\n-Duser.timezone=Asia/Seoul — JVM의 기본 타임존을 한국 시간으로 설정. 이걸 안 하면 컨테이너 내부가 UTC(+0시간)로 동작하여 DB 저장 시각, 로그 시각이 9시간 차이 남.\n5.3 프론트엔드 .dockerignore # aini-inu-frontend/.dockerignore:\nnode_modules .next .env* .git .gitignore .claude *.md npm-debug.log* .DS_Store .dockerignore란? Docker 빌드 시 컨텍스트(빌드에 보내는 파일들)에서 제외할 패턴. .gitignore와 같은 문법. node_modules를 제외하면 빌드 컨텍스트 전송이 수초 → 수밀리초로 줄어든다.\n5.4 Nginx 설정 파일 # nginx/nginx.conf:\n# Rate Limiting: IP당 초당 요청 수 제한 limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=ws:10m rate=5r/s; # --- HTTP → HTTPS 리다이렉트 --- server { listen 80; server_name ${DOMAIN}; # Let\u0026#39;s Encrypt 인증서 발급/갱신용 location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } # --- HTTPS 메인 서버 --- server { listen 443 ssl; server_name ${DOMAIN}; # SSL 인증서 (호스트의 /etc/letsencrypt를 마운트) ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; # SSL 보안 설정 ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; # 공통 프록시 헤더 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Swagger UI → backend (API 문서) location /swagger-ui/ { proxy_pass http://backend:8080; } location /v3/api-docs { proxy_pass http://backend:8080; } # REST API → backend location /api/v1/ { limit_req zone=api burst=20 nodelay; proxy_pass http://backend:8080; client_max_body_size 10m; # 이미지 업로드용 } # WebSocket → backend location /ws/ { limit_req zone=ws burst=10 nodelay; proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection \u0026#34;upgrade\u0026#34;; proxy_read_timeout 3600s; # WebSocket 연결 유지 proxy_send_timeout 3600s; } # 그 외 전부 → frontend location / { proxy_pass http://frontend:3000; } } Nginx 설정 해설:\nlimit_req_zone — 악의적인 요청 폭탄 방어. IP별로 초당 허용 요청 수를 제한한다.\n$binary_remote_addr — 클라이언트 IP 주소 (바이너리 형태, 메모리 효율적) zone=api:10m — \u0026ldquo;api\u0026quot;라는 이름의 10MB 공유 메모리 영역 (약 16만 IP 추적 가능) rate=10r/s — 초당 10개 요청 허용 burst=20 — 순간적으로 20개까지 허용 (초과 시 429 Too Many Requests) nodelay — burst 범위 내 요청은 지연 없이 즉시 처리 proxy_pass — 요청을 다른 서버로 전달 (리버스 프록시의 핵심). http://backend:8080에서 backend는 Docker Compose 서비스 이름 = Docker 내부 DNS로 자동 해석.\nWebSocket 프록시 필수 헤더:\nproxy_http_version 1.1 — WebSocket은 HTTP/1.1 필수 Upgrade: websocket — \u0026ldquo;이 연결을 WebSocket으로 업그레이드해줘\u0026rdquo; Connection: upgrade — \u0026ldquo;연결을 유지하면서 프로토콜을 변경해줘\u0026rdquo; 이 3줄이 없으면 WebSocket 연결이 즉시 끊어진다. proxy_read_timeout 3600s — Nginx는 기본 60초 동안 응답이 없으면 연결을 끊는다. WebSocket은 오래 유지되는 연결이므로 1시간(3600초)으로 늘림.\nclient_max_body_size 10m — 요청 body 최대 크기. 기본값은 1MB라서 이미지 업로드 시 413 Request Entity Too Large 에러가 난다. 10MB로 설정.\n${DOMAIN} 환경변수 치환: nginx.conf를 /etc/nginx/templates/default.conf.template로 마운트하면 Nginx 공식 Docker 이미지가 시작 시 ${DOMAIN} 같은 환경변수를 자동 치환해준다. 이 덕분에 설정 파일에 도메인을 하드코딩하지 않아도 된다.\n5.5 루트 docker-compose.yml # services: postgres: image: pgvector/pgvector:pg16 container_name: aini-inu-postgres restart: unless-stopped environment: TZ: Asia/Seoul POSTGRES_DB: ${POSTGRES_DB:-ainiinu} POSTGRES_USER: ${POSTGRES_USER:-ainiinu} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ainiinu} volumes: - aini_inu_postgres_data:/var/lib/postgresql/data - ./aini-inu-backend/docker/postgres/init:/docker-entrypoint-initdb.d:ro healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U ${POSTGRES_USER:-ainiinu} -d ${POSTGRES_DB:-ainiinu}\u0026#34;] interval: 5s timeout: 5s retries: 30 backend: build: context: ./aini-inu-backend dockerfile: Dockerfile container_name: aini-inu-backend restart: unless-stopped depends_on: postgres: condition: service_healthy env_file: - .env.docker environment: TZ: Asia/Seoul SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-ainiinu} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-ainiinu} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-ainiinu} COMMUNITY_STORAGE_LOCAL_BASE_DIR: /app/var/uploads COMMUNITY_STORAGE_PUBLIC_BASE_URL: https://${DOMAIN} volumes: - aini_inu_backend_uploads:/app/var/uploads frontend: build: context: ./aini-inu-frontend dockerfile: Dockerfile container_name: aini-inu-frontend restart: unless-stopped depends_on: - backend nginx: image: nginx:alpine container_name: aini-inu-nginx restart: unless-stopped depends_on: - frontend - backend ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - ./nginx/nginx.conf:/etc/nginx/templates/default.conf.template:ro - /etc/letsencrypt:/etc/letsencrypt:ro - certbot_webroot:/var/www/certbot:ro environment: DOMAIN: ${DOMAIN} volumes: aini_inu_postgres_data: aini_inu_backend_uploads: certbot_webroot: docker-compose.yml 용어 해설:\nimage: pgvector/pgvector:pg16 — Docker Hub에서 이미지를 다운받아 사용. build: context: ./aini-inu-backend — 해당 디렉토리의 Dockerfile로 직접 빌드.\nrestart: unless-stopped — 컨테이너가 죽으면 자동 재시작. 단, docker compose stop으로 수동 중지한 경우는 재시작 안 함.\ndepends_on + condition: service_healthy — postgres가 healthcheck를 통과해야 backend가 시작됨. 이게 없으면 DB가 아직 준비 안 됐는데 backend가 먼저 시작해서 연결 실패.\nenv_file: .env.docker — 이 파일의 환경변수를 컨테이너 안에 주입. 주의: docker compose 자체가 읽는 .env와는 별개. .env는 compose 파일 내 ${변수} 치환용, env_file은 컨테이너 내부 환경변수 주입용.\n${POSTGRES_DB:-ainiinu} — .env 파일에 POSTGRES_DB가 있으면 그 값, 없으면 기본값 ainiinu 사용.\nvolumes (서비스 레벨):\naini_inu_postgres_data:/var/lib/postgresql/data — DB 데이터를 Docker 볼륨에 영속 저장. 컨테이너가 삭제되어도 데이터는 유지됨. ./nginx/nginx.conf:/etc/nginx/templates/default.conf.template:ro — 호스트 파일을 컨테이너 안에 읽기전용(:ro)으로 마운트. /etc/letsencrypt:/etc/letsencrypt:ro — 호스트의 SSL 인증서 디렉토리를 nginx에 마운트. ports: \u0026quot;80:80\u0026quot; — 호스트의 80포트 → 컨테이너의 80포트로 매핑. 외부에서 접속 가능. ports가 없는 서비스(postgres, backend, frontend)는 Docker 내부에서만 접근 가능.\nvolumes (최하단, 탑레벨): 여기서 선언된 볼륨은 Docker가 관리하는 영속 저장소. docker compose down해도 유지됨. docker compose down -v하면 삭제됨 (주의!).\n5.6 환경변수 파일 # .env.docker.example (커밋 대상 — 템플릿):\n# 도메인 DOMAIN=your-domain.com # DB (Docker 내부 통신용) POSTGRES_DB=ainiinu POSTGRES_USER=ainiinu POSTGRES_PASSWORD=change-me-in-production # JWT JWT_SECRET=your-super-secret-key-must-be-at-least-32-characters-long-for-hs256-algorithm # Gemini AI GEMINI_API_KEY= GEMINI_EMBEDDING_MODEL=gemini-embedding-001 # Lost Pet AI (optional) LOSTPET_AI_VECTOR_TOP_K=50 LOSTPET_SEARCH_SESSION_TTL_HOURS=24 LOSTPET_SEARCH_TOP_N=20 # PgVector (optional) SPRING_AI_PGVECTOR_INITIALIZE_SCHEMA=false SPRING_AI_PGVECTOR_TABLE_NAME=lostpet_vector_store SPRING_AI_PGVECTOR_SCHEMA_NAME=public SPRING_AI_PGVECTOR_DIMENSIONS=768 SPRING_AI_PGVECTOR_DISTANCE_TYPE=COSINE_DISTANCE SPRING_AI_PGVECTOR_INDEX_TYPE=HNSW # Community Storage (optional) COMMUNITY_STORAGE_PRESIGNED_EXPIRES_SECONDS=300 중요: .env.docker와 .env는 .gitignore에 추가하여 절대 커밋하지 않는다.\n.env vs .env.docker vs env_file의 관계:\n파일 누가 읽나 용도 .env docker compose CLI compose 파일 내 ${변수} 치환 (예: ${DOMAIN}, ${POSTGRES_DB}) .env.docker backend 컨테이너 env_file: 지시어로 컨테이너 내부 환경변수 주입 (JWT_SECRET, GEMINI_API_KEY 등) 우리 프로젝트에서는 둘의 내용이 동일하므로 cp .env.docker .env로 복사한다. .env를 만들지 않으면 WARN: The \u0026quot;DOMAIN\u0026quot; variable is not set 경고가 발생한다.\n6. VM에서 배포하기 (실전 순서) # 6.1 레포지토리 clone # git clone https://ghp_xxxx@github.com/\u0026lt;org\u0026gt;/aini-inu-monorepo.git cd aini-inu-monorepo 6.2 환경변수 설정 # cp .env.docker.example .env.docker nano .env.docker # 실제 값으로 편집 (DOMAIN, JWT_SECRET, GEMINI_API_KEY 등) cp .env.docker .env # docker compose가 .env를 기본으로 읽으므로 복사 필요 nano 편집기 단축키는 12.2절 참고.\n6.3 SSL 인증서 발급 (최초 1회) # 반드시 docker compose up 전에 실행해야 한다! (80 포트가 비어있어야 함)\ncertbot은 Let\u0026rsquo;s Encrypt 인증서를 발급하고 갱신해주는 ACME 클라이언트 도구다.\n쉽게 말하면 HTTPS에 필요한 SSL 인증서를 자동으로 만들어 주고, 만료 전에 갱신까지 처리해 주는 프로그램이다.\n여기서는 certbot이 Let\u0026rsquo;s Encrypt 서버와 통신하면서 \u0026ldquo;이 도메인을 정말 네가 제어하고 있나?\u0026ldquo;를 검증한 뒤 /etc/letsencrypt/ 아래에 인증서와 개인키를 저장한다.\n그 다음 nginx가 이 파일을 읽어서 https://도메인 접속을 처리한다.\n이 글에서 Docker certbot 컨테이너 대신 호스트에 직접 설치하는 이유는 단순하다.\n설정이 더 직관적이고 디버깅이 쉽다 /etc/letsencrypt 경로를 nginx에 그대로 마운트해서 쓰기 편하다 standalone 모드로 실행할 때 80 포트 점유 문제를 파악하기 쉽다 # 1. certbot 설치 sudo apt install -y certbot # 2. 모든 컨테이너 중지 (80 포트 확보) docker compose down # 3. 인증서 발급 (반드시 --key-type rsa 포함!) # certbot 2.0.0부터 기본 키 타입이 ECDSA(secp256r1)로 변경되었다. # 특정 OpenSSL 버전 + cipher 조합에서 호환성 문제가 발생할 수 있으므로 # 호환성 문제를 겪은 경우 RSA로 전환하면 해결된다. (10.7절 참고) sudo certbot certonly --standalone -d ainiinu.kr --agree-tos --key-type rsa -m your@email.com certbot 옵션 설명:\ncertonly — 인증서만 발급 (웹서버 설정 자동 수정 안 함) --standalone — certbot이 임시 웹서버를 직접 띄워서 Let\u0026rsquo;s Encrypt 검증 수행 -d ainiinu.kr — 인증서를 발급받을 도메인 --agree-tos — Let\u0026rsquo;s Encrypt 이용약관 자동 동의 -m — 인증서 만료 알림을 받을 이메일 성공하면:\nSuccessfully received certificate. Certificate is saved at: /etc/letsencrypt/live/ainiinu.kr/fullchain.pem Key is saved at: /etc/letsencrypt/live/ainiinu.kr/privkey.pem This certificate expires on 2026-06-07. SSL 인증서 발급이 안 되는 경우 체크리스트:\n증상 원인 해결 certbot 명령어 자체가 Creating 에서 멈춤 Docker certbot 컨테이너 방식의 문제 호스트에 직접 sudo apt install certbot 후 standalone 모드 사용 certbot이 무한 대기 80 포트를 nginx가 점유 중 docker compose down으로 모든 컨테이너 중지 후 재시도 Let\u0026rsquo;s Encrypt 검증 실패 GCP 방화벽에서 80 포트 미허용 VPC 방화벽에 TCP 80, 443 인바운드 규칙 추가 DNS 검증 실패 도메인이 VM IP를 가리키지 않음 nslookup ainiinu.kr 결과가 VM 외부 IP와 일치하는지 확인 6.4 전체 서비스 기동 # docker compose up -d --build 옵션 설명:\n-d (detached) — 백그라운드에서 실행. 이거 없으면 터미널에 로그가 계속 출력되고, 터미널 닫으면 서비스도 종료됨. --build — 이미지를 다시 빌드. 코드 변경 후에는 반드시 필요. 변경 없으면 캐시 사용해서 빠름. 첫 빌드는 시간이 걸린다 (백엔드 Gradle 빌드 + 프론트엔드 npm build).\n6.5 상태 확인 # # 서비스 상태 확인 — 4개 모두 Up이면 성공 docker compose ps # 문제 시 로그 확인 docker compose logs -f --tail=50 # 특정 서비스 로그만 보기 docker compose logs -f backend docker compose logs -f nginx docker compose logs -f frontend 로그 옵션 설명:\n-f (follow) — 실시간으로 새 로그가 계속 출력됨. tail -f와 같은 개념. --tail=50 — 최근 50줄부터 보여줌. 이거 없으면 처음부터 전부 출력되어 스크롤 폭탄. 로그 보다가 나가려면 Ctrl+C 긴 로그가 화면을 넘어갈 때: 스크롤이 아니라 less 형태의 페이저가 나올 수 있음 → q를 눌러서 나감 6.6 접속 확인 # https://ainiinu.kr → 프론트엔드 (웹 페이지) https://ainiinu.kr/api/v1/ → 백엔드 API https://ainiinu.kr/swagger-ui/index.html → Swagger UI (API 문서) wss://ainiinu.kr/ws/ → WebSocket 포트번호 없이 도메인만으로 접속 가능 (nginx가 80/443 표준 포트 사용).\nSwagger UI가 안 뜨는 경우: Nginx 라우팅 규칙 추가가 필요하다. 상세는 10.6절 참고.\n왜 포트번호가 필요 없나? HTTP의 기본 포트는 80, HTTPS의 기본 포트는 443이다. 브라우저는 https://ainiinu.kr을 https://ainiinu.kr:443으로 자동 해석한다. nginx가 443 포트를 점유하고 있으므로 포트 지정 없이 바로 접속 가능.\n6.7 인증서 자동 갱신 # certbot을 sudo apt install로 설치하면 systemd 타이머로 자동 갱신이 설정된다.\n주의: standalone 모드로 발급했으므로 갱신 시에도 80 포트가 필요하다. nginx가 80을 점유하고 있으면 자동 갱신이 실패한다.\n해결 방법 1 — renewal hook 설정 (권장): certbot이 갱신 전후에 자동으로 nginx를 중지/시작하도록 hook을 설정한다.\n# 갱신 설정 파일에 hook 추가 sudo nano /etc/letsencrypt/renewal/ainiinu.kr.conf # [renewalparams] 섹션 아래에 다음 두 줄 추가: pre_hook = docker compose -f /home/\u0026lt;user\u0026gt;/aini-inu-monorepo/docker-compose.yml stop nginx post_hook = docker compose -f /home/\u0026lt;user\u0026gt;/aini-inu-monorepo/docker-compose.yml start nginx 해결 방법 2 — webroot 방식으로 전환: nginx를 멈추지 않고 갱신할 수 있다. nginx.conf에 이미 /.well-known/acme-challenge/ 경로가 설정되어 있으므로:\nsudo certbot certonly --webroot -w /var/lib/docker/volumes/\u0026lt;project\u0026gt;_certbot_webroot/_data -d ainiinu.kr --force-renewal --key-type rsa 수동 갱신이 필요한 경우:\n# nginx 중지 → 갱신 → nginx 재시작 docker compose stop nginx sudo certbot renew docker compose start nginx Let\u0026rsquo;s Encrypt 인증서는 90일 유효. certbot은 만료 30일 전부터 자동 갱신을 시도한다.\n7. 소스코드 점검 결과 # 7.1 CORS — 문제 없음 # WebConfig.java: setAllowedOriginPatterns(\u0026quot;*\u0026quot;) + allowCredentials(true) SecurityConfig.java: Security 필터 레벨에서도 동일한 CORS 설정 적용 WebSocketConfig.java: WebSocket도 setAllowedOriginPatterns(\u0026quot;*\u0026quot;) Docker/GCP 배포 시 도메인이 바뀌어도 코드 수정 불필요 7.2 WebSocket (채팅) — 문제 없음 # STOMP 엔드포인트: /ws/chat-rooms/{roomId} 인증: STOMP CONNECT 시 Authorization: Bearer \u0026lt;JWT\u0026gt; 헤더로 처리 메시지 브로커: 인메모리 (/topic, /queue) 이벤트 발행: /topic/chat-rooms/{roomId}/events 프론트엔드: NEXT_PUBLIC_WS_URL 환경변수로 WebSocket URL 결정 프로토콜 자동 감지: HTTPS → wss://, HTTP → ws:// 실패 시 HTTP 폴링 fallback 구현되어 있음 Nginx에서 /ws/* 프록시 시 upgrade 헤더만 설정하면 동작 7.3 이미지 업로드/조회 — 문제 없음 (주의사항 있음) # 동작 방식:\n프론트엔드 → POST /api/v1/images/presigned-url (presigned URL 요청) 프론트엔드 → PUT /api/v1/images/presigned-upload/{token} (파일 업로드) DB에 상대경로 저장: /api/v1/images/local?key=community/post/... 이미지 조회: GET /api/v1/images/local?key=... (인증 불필요, @Public) Docker 환경:\napplication.properties 기본값: ../common-docs/storage (로컬 개발용 상대경로) Docker compose에서 COMMUNITY_STORAGE_LOCAL_BASE_DIR=/app/var/uploads로 오버라이드 Docker volume으로 영속화 — 올바르게 처리되어 있음 주의: 이미지 URL이 상대경로\n백엔드가 /api/v1/images/local?key=... 형태의 상대경로를 반환 웹: Next.js rewrites → Nginx를 통해 정상 동작 iOS: base URL(https://ainiinu.kr)을 앞에 붙여서 조합 필요 8. iOS 모바일 앱 개발자 연동 가이드 # 접근 방식 # iOS 앱 → https://ainiinu.kr/api/v1/... → Nginx → backend iOS 앱 → wss://ainiinu.kr/ws/... → Nginx → backend Nginx가 /api/v1/*과 /ws/*를 백엔드로 라우팅하므로, iOS 앱은 웹 프론트엔드와 동일한 도메인의 동일한 경로로 API를 호출하면 된다.\n고려사항 # 항목 내용 HTTPS 필수 iOS ATS 정책상 HTTP 차단 (HTTPS 배포이므로 문제 없음) CORS 불필요 네이티브 앱은 CORS 제약 없음 API 문서 (Swagger) https://ainiinu.kr/swagger-ui/index.html — 브라우저에서 바로 확인 가능 API 문서 (JSON) https://ainiinu.kr/v3/api-docs — OpenAPI JSON 스펙 다운로드 API 문서 (파일) common-docs/openapi/openapi.v1.json — 레포 내 스냅샷 인증 JWT 토큰 발급/갱신 흐름 동일하게 구현 이미지 URL 백엔드 응답의 상대경로에 base URL 조합 필요 WebSocket wss://ainiinu.kr/ws/chat-rooms/{roomId} + STOMP + JWT 헤더 푸시 알림 추후 필요 시 백엔드에 APNs 연동 추가 9. 디버깅 가이드 # 9.1 502 Bad Gateway # 브라우저에서 502 Bad Gateway가 뜨면 = Nginx가 백엔드/프론트엔드에 연결을 못 하는 것.\n디버깅 순서:\n# 1. 모든 서비스 상태 확인 docker compose ps # → STATUS가 \u0026#34;Up\u0026#34;이 아닌 서비스가 있으면 그게 원인 # 2. 죽은 서비스의 로그 확인 docker compose logs --tail=100 backend # 또는 frontend # 3. 실시간 로그 보면서 요청 재시도 docker compose logs -f backend # → 다른 브라우저 탭에서 회원가입 등 요청 시도 # → 로그에 에러가 찍히는지 확인 # 4. 백엔드 로그에 아무것도 안 찍히면 nginx 문제 docker compose logs --tail=50 nginx 실제 겪은 사례:\n백엔드가 Application run failed로 기동 실패 → nginx가 backend:8080에 연결 불가 → 502 반환. 원인: 삭제된 SQL 시드 파일(10_core_sample_seed.sql)을 application.properties에서 여전히 참조. 해결: spring.sql.init.data-locations에서 해당 파일 참조 제거.\n9.2 컨테이너가 계속 재시작될 때 # # 재시작 횟수 확인 docker compose ps # → RESTARTS 컬럼이 계속 증가하면 기동 실패 + 자동 재시작 반복 중 # 최근 로그에서 에러 찾기 docker compose logs --tail=200 backend | grep -i \u0026#34;error\\|exception\\|failed\u0026#34; grep -i 란? 대소문자 구분 없이(-i = ignore case) 패턴 검색. | (파이프) = 앞 명령의 출력을 뒤 명령의 입력으로 전달. \\| = grep에서 OR 조건 (\u0026ldquo;error\u0026rdquo; 또는 \u0026ldquo;exception\u0026rdquo; 또는 \u0026ldquo;failed\u0026rdquo;).\n9.3 특정 서비스만 재빌드/재시작 # # 백엔드만 재빌드 + 재시작 (다른 서비스는 건드리지 않음) docker compose up -d --build backend # nginx 설정 변경 후 nginx만 재시작 docker compose restart nginx # 프론트엔드만 재빌드 docker compose up -d --build frontend 9.4 컨테이너 안에 들어가서 직접 확인 # # backend 컨테이너 안에서 bash 실행 docker compose exec backend sh # 컨테이너 안에서 환경변수 확인 env | grep SPRING env | grep JWT # 컨테이너 안에서 나가기 exit docker compose exec vs docker compose run:\nexec — 이미 실행 중인 컨테이너 안에서 명령 실행 run — 새 컨테이너를 만들어서 명령 실행 (일회용) 9.5 DB 직접 접속 # # postgres 컨테이너에서 psql 실행 docker compose exec postgres psql -U ainiinu -d ainiinu # SQL 실행 예시 SELECT count(*) FROM member; \\dt -- 테이블 목록 \\d member -- member 테이블 구조 \\q -- psql 종료 9.6 네트워크/포트 관련 # # 80/443 포트를 누가 점유하고 있는지 확인 sudo ss -tlnp | grep \u0026#39;:80\\|:443\u0026#39; # Docker 내부 네트워크 확인 (컨테이너 간 통신 문제 시) docker network ls docker network inspect aini-inu-monorepo_default ss -tlnp 설명:\nss — socket statistics (네트워크 소켓 정보, netstat의 현대적 대체) -t — TCP만 -l — LISTEN 상태만 (연결 대기 중인 포트) -n — 숫자로 표시 (이름 해석 안 함, 빠름) -p — 어떤 프로세스가 점유하는지 표시 9.7 디스크/리소스 관련 # # 디스크 사용량 확인 df -h # Docker가 사용하는 디스크 용량 docker system df # 안 쓰는 이미지/컨테이너/볼륨 정리 (공간 확보) docker system prune -f # 중지된 컨테이너, 미사용 이미지 삭제 docker image prune -a -f # 모든 미사용 이미지 삭제 (주의: 빌드 캐시도 삭제됨) VM 디스크가 꽉 차면? Docker 이미지와 빌드 캐시가 빠르게 쌓인다. docker system df로 확인하고, docker system prune으로 정리. 빌드할 때마다 이전 이미지가 남으므로 주기적으로 정리해야 한다.\n9.8 DB 스키마 직접 변경 (DDL 마이그레이션) # 엔티티에서 필드를 삭제했지만 DB 테이블에 컬럼이 남아있는 경우, Docker 재시작 없이 실행 중인 PostgreSQL에 직접 SQL을 실행할 수 있다.\n왜 필요한가? Hibernate ddl-auto=update는 컬럼 추가만 하고 컬럼 삭제는 하지 않는다. 따라서 Java 엔티티에서 필드를 제거해도, DB에는 해당 컬럼이 NOT NULL 제약조건과 함께 남아 있어서 INSERT 시 null value in column \u0026quot;xxx\u0026quot; violates not-null constraint 에러가 발생한다.\n방법 1: 한 줄 명령으로 SQL 실행 (권장)\n# docker exec로 실행 중인 postgres 컨테이너에 SQL 직접 실행 docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c \u0026#34;ALTER TABLE pet DROP COLUMN IF EXISTS is_certified;\u0026#34; 명령어 해설:\ndocker exec -i — 실행 중인 컨테이너 안에서 명령 실행 (-i는 stdin 유지) aini-inu-postgres — 컨테이너 이름 (docker-compose.yml의 container_name) psql -U ainiinu -d ainiinu — ainiinu 유저로 ainiinu 데이터베이스에 접속 -c \u0026quot;SQL문\u0026quot; — SQL 한 줄 실행 후 종료 방법 2: DDL 파일을 통째로 실행\n프로젝트의 db/ddl/ 디렉토리에 마이그레이션 SQL 파일이 있는 경우:\n# 로컬 SQL 파일을 컨테이너의 psql로 파이프 docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu \u0026lt; aini-inu-backend/src/main/resources/db/ddl/13_pet_certification_removal.sql \u0026lt; (리다이렉션) 설명: 파일의 내용을 명령어의 입력(stdin)으로 전달한다. 즉, SQL 파일 안의 모든 SQL문이 순서대로 psql에서 실행된다.\n방법 3: psql 대화형 세션에서 실행\n# 1. postgres 컨테이너의 psql에 접속 docker exec -it aini-inu-postgres psql -U ainiinu -d ainiinu # 2. SQL 직접 입력 ALTER TABLE pet DROP COLUMN IF EXISTS is_certified; ALTER TABLE pet DROP COLUMN IF EXISTS certification_number; # 3. 변경 확인 \\d pet -- pet 테이블 구조 확인 (삭제된 컬럼이 없어야 정상) # 4. 종료 \\q -i vs -it 차이:\n-i (interactive) — stdin을 열어둠. 파이프(\u0026lt;)로 입력을 넣을 때 사용. -it (interactive + tty) — 터미널 세션을 열어서 직접 타이핑할 때 사용. 파일 실행은 -i, 대화형 접속은 -it을 사용한다. 실행 전 확인 / 실행 후 검증:\n# 실행 전: 해당 컬럼이 존재하는지 확인 docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c \u0026#34;\\d pet\u0026#34; | grep is_certified # 실행 후: 컬럼이 삭제되었는지 확인 (출력 없으면 성공) docker exec -i aini-inu-postgres psql -U ainiinu -d ainiinu -c \u0026#34;\\d pet\u0026#34; | grep is_certified IF EXISTS 를 항상 붙이자: DROP COLUMN IF EXISTS는 컬럼이 없어도 에러를 내지 않는다. 이미 적용된 DDL을 실수로 다시 실행해도 안전하다.\n10. 트러블슈팅 모음 # 10.1 Docker 설치 관련 # Q: sudo apt install docker.io 했는데 docker compose 명령이 없다?\ndocker.io는 Debian/Ubuntu 기본 패키지로 Docker Compose 플러그인이 포함되지 않는다. Docker 공식 저장소에서 docker-ce + docker-compose-plugin을 설치해야 한다. (4장 참고)\nQ: Unable to locate package docker-compose-plugin?\nDocker 공식 저장소가 시스템에 등록되지 않은 상태. 4.3절의 GPG 키 + 저장소 추가 과정을 먼저 수행해야 한다.\nQ: Package 'docker-ce' has no installation candidate?\nUbuntu용 저장소를 추가했는데 실제 OS가 Debian인 경우. lsb_release -cs로 OS를 확인하고, 결과가 bookworm이면 Debian 12이므로 저장소 URL을 https://download.docker.com/linux/debian으로 변경해야 한다.\nQ: Docker Desktop을 설치하면 안 되나?\nGCP VM은 GUI가 없는 서버이므로 Docker Desktop은 설치할 수 없다. Docker Desktop은 Mac/Windows 데스크탑 환경 전용이다.\n10.2 비공개 레포 clone 관련 # Q: Organization 비공개 레포를 clone하면 권한 에러가 난다?\nGitHub PAT (Personal Access Token)을 생성하고, Organization access에서 해당 org에 \u0026ldquo;Grant\u0026rdquo; 버튼을 클릭해야 한다. 개인 토큰을 만들었다고 자동으로 org 레포에 접근할 수 있는 것이 아니다.\n10.3 환경변수 관련 # Q: WARN: The \u0026quot;DOMAIN\u0026quot; variable is not set. Defaulting to a blank string.?\ndocker compose는 기본적으로 프로젝트 루트의 .env 파일을 읽는다. .env.docker만 만들고 .env를 만들지 않으면 이 경고가 발생한다.\ncp .env.docker .env 10.4 SSL 인증서 관련 # Q: docker compose run --rm certbot ... 이 Creating에서 멈춘다?\nDocker certbot 컨테이너 방식은 여러 이유로 멈출 수 있다. 호스트에 직접 certbot을 설치하는 것이 가장 확실하다:\ndocker compose down # 80 포트 해제 sudo apt install -y certbot # 호스트에 직접 설치 sudo certbot certonly --standalone -d ainiinu.kr --agree-tos -m your@email.com Q: certbot standalone도 멈춘다?\nGCP 방화벽에서 TCP 80 포트가 열려있는지 확인. Let\u0026rsquo;s Encrypt 서버가 http://도메인:80으로 검증 요청을 보내는데, 방화벽에서 차단되면 무한 대기한다.\n10.5 디스크 용량 부족 (no space left on device) # 증상: Docker 빌드 중 에러 발생:\ntarget backend: failed to solve: ResourceExhausted: failed to copy files: copy file range failed: no space left on device 원인: GCP 기본 디스크가 10GB인데, Gradle 빌드(JDK + 의존성)와 Next.js 빌드(node_modules)가 동시에 진행되면 디스크가 가득 참.\n즉시 해결 — Docker 캐시 정리:\n# 미사용 이미지, 빌드 캐시, 중지된 컨테이너 전부 삭제 docker system prune -a -f # 정리된 용량 + 남은 용량 확인 df -h docker system prune -a -f 설명:\nprune — 미사용 리소스 정리 -a (all) — 현재 실행 중이 아닌 모든 이미지도 삭제 (태그된 이미지 포함) -f (force) — 확인 질문 없이 바로 삭제 주의: 다음 docker compose up --build 시 처음부터 다시 빌드하므로 시간이 더 걸림 근본 해결 — 디스크 크기 늘리기:\nGCP 콘솔 → Compute Engine → 디스크 메뉴 VM에 연결된 디스크 클릭 → 상단 수정 버튼 크기를 20GB 이상으로 변경 → 저장 VM에서 파티션 확장 (VM 재시작 없이 가능): # 1. 파티션 확장 (growpart가 없으면 설치) sudo apt install -y cloud-guest-utils sudo growpart /dev/sda 1 # 2. 파일시스템 확장 (ext4 기준) sudo resize2fs /dev/sda1 # 확장 확인 df -h # → /dev/sda1 의 Size가 늘어났는지 확인 growpart란? 디스크의 파티션 테이블을 확장하는 명령어. GCP에서 디스크 크기를 늘려도 파티션 크기는 자동으로 변하지 않으므로, growpart로 파티션을 먼저 확장한 뒤 resize2fs로 파일시스템을 확장해야 한다.\nresize2fs란? ext4 파일시스템의 크기를 파티션에 맞게 확장하는 명령어. growpart 없이 resize2fs만 실행하면 파티션 크기가 그대로여서 효과가 없을 수 있다. 데이터 손실 없이 온라인(서비스 중단 없이) 확장 가능.\n디스크 용량 모니터링:\n# 전체 디스크 사용량 df -h # Docker가 사용하는 용량 상세 docker system df # 어떤 디렉토리가 용량을 많이 차지하는지 확인 sudo du -sh /* 2\u0026gt;/dev/null | sort -rh | head -10 df -h 읽는 법:\nFilesystem Size Used Avail Use% Mounted on /dev/sda1 20G 3.8G 15G 21% / Size — 전체 디스크 크기 Used — 사용 중인 용량 Avail — 남은 용량 Use% — 사용률 (80% 넘으면 주의, 90% 넘으면 위험) Mounted on — /가 루트 파티션 (가장 중요) 빌드별 예상 디스크 사용량:\n항목 용량 OS + 시스템 패키지 ~2GB Docker 엔진 ~500MB 백엔드 빌드 이미지 (Gradle + JDK) ~2.5GB 프론트엔드 빌드 이미지 (Node.js + npm) ~1.5GB 최종 실행 이미지 (backend + frontend + nginx + postgres) ~1.5GB PostgreSQL 데이터 ~500MB (데이터 양에 따라 증가) 업로드 이미지 (사용자 데이터) 가변 합계 (여유 포함) 최소 15GB, 권장 20GB 10.6 Swagger UI가 안 뜨고 프론트엔드 페이지가 나옴 # 증상: https://ainiinu.kr/swagger-ui/index.html 접속 시 Swagger가 아니라 프론트엔드 빈 페이지가 나옴.\n원인: Nginx의 location /이 매칭되지 않는 모든 경로를 프론트엔드로 보냄. /swagger-ui/도 프론트엔드로 가버림.\n해결: nginx.conf에 Swagger 경로를 백엔드로 라우팅하는 규칙 추가:\nlocation /swagger-ui/ { proxy_pass http://backend:8080; } location /v3/api-docs { proxy_pass http://backend:8080; } 교훈: 백엔드가 직접 서빙하는 경로가 새로 추가되면, nginx.conf에도 해당 경로의 라우팅 규칙을 반드시 추가해야 한다. Nginx는 가장 구체적인(longest prefix) location부터 매칭하므로, /swagger-ui/는 /보다 우선 매칭된다.\n10.7 ERR_SSL_PROTOCOL_ERROR (ECDSA 인증서 호환성 문제) # 증상: 브라우저에서 https://ainiinu.kr 접속 시 ERR_SSL_PROTOCOL_ERROR 에러가 나타남. Nginx 로그에 다음 에러가 반복됨:\nSSL_do_handshake() failed (SSL: error:0A000119:SSL routines:ssl_do_handshake:no suitable signature algorithm) iOS 앱에서는 API 호출이 정상 동작하는데, 웹 브라우저에서만 SSL 연결이 실패하는 상황.\n원인:\ncertbot 2.0.0(2023년 1월 릴리스)부터 인증서를 발급할 때 기본 키 타입이 ECDSA secp256r1(P-256)로 변경되었다.\nRSA vs ECDSA란?\nSSL 인증서는 내부에 \u0026ldquo;키(key)\u0026ldquo;를 가지고 있는데, 이 키를 만드는 암호화 방식이 두 가지 있다:\n방식 설명 특징 RSA 오래된 표준 방식. 거의 모든 환경에서 지원. 호환성 최고, 키 크기가 큼 (2048~4096 bit) ECDSA 새로운 타원곡선 방식. 더 짧은 키로 같은 보안 수준. 성능 좋음, 일부 환경에서 호환성 문제 비유하면: RSA는 모든 자물쇠에 맞는 만능 열쇠, ECDSA는 최신 스마트 잠금장치 — 더 효율적이지만 구형 문에는 안 맞을 수 있음.\nECDSA 인증서가 문제를 일으키는 이유:\nnginx:alpine 이미지의 OpenSSL 버전과 ECDSA P-256 키 + SHA384 서명 조합에서 일부 클라이언트와 서명 알고리즘 협상(negotiation)에 실패 에러 메시지의 no suitable signature algorithm = \u0026ldquo;서버와 클라이언트가 서로 합의할 수 있는 서명 방식이 없다\u0026quot;는 뜻 iOS의 네이티브 HTTP 클라이언트는 이 조합을 처리할 수 있지만, 일부 브라우저에서는 핸드셰이크 실패 SSL 핸드셰이크(handshake)란?\n브라우저가 HTTPS 서버에 접속할 때 가장 먼저 하는 \u0026ldquo;악수\u0026rdquo; 과정:\n브라우저: \u0026ldquo;나는 이런 암호화 방식들을 지원해\u0026rdquo; (지원 목록 전송) 서버: \u0026ldquo;그 중에 이걸로 하자\u0026rdquo; (방식 선택) 양쪽이 합의하면 → 암호화된 연결 성립 합의 실패 → ERR_SSL_PROTOCOL_ERROR 진단 방법:\n# 1. 현재 인증서의 키 타입 확인 sudo certbot certificates # → Key Type: ECDSA ← 이것이 문제의 원인 # 2. 인증서 상세 정보 확인 sudo openssl x509 -in /etc/letsencrypt/live/ainiinu.kr/fullchain.pem -text -noout | head -20 # → Signature Algorithm: ecdsa-with-SHA384 ← ECDSA 서명 확인 # → Public Key Algorithm: id-ecPublicKey ← ECDSA 키 확인 # 3. SSL 연결 테스트 (VM에서 직접) openssl s_client -connect ainiinu.kr:443 -servername ainiinu.kr 2\u0026gt;\u0026amp;1 | head -30 # → verify return:1 이면 인증서 자체는 유효하지만, 브라우저 호환성 문제 해결 — RSA 키 타입으로 인증서 재발급:\n# 1. nginx 중지 (80 포트 해제 — certbot standalone이 80 포트 필요) docker compose stop nginx # 2. RSA 키 타입으로 인증서 강제 재발급 sudo certbot certonly --standalone -d ainiinu.kr --agree-tos --force-renewal --key-type rsa # 3. nginx 컨테이너 완전 재생성 (새 인증서 반영) docker compose up -d --force-recreate nginx 각 옵션 설명:\n--force-renewal — 기존 인증서가 유효해도 강제로 새로 발급 --key-type rsa — 이것이 핵심! ECDSA 대신 RSA 키로 인증서 생성 --force-recreate — 컨테이너를 삭제 후 새로 생성 (단순 restart보다 확실하게 인증서를 다시 읽음) 재발급 후 확인:\n# 키 타입이 RSA로 변경되었는지 확인 sudo certbot certificates # → Key Type: RSA ← 이렇게 나와야 정상 # nginx 로그에 SSL 에러가 없는지 확인 docker compose logs --tail=10 nginx 브라우저 캐시 문제: 인증서를 교체해도 브라우저가 이전 SSL 세션을 캐싱하고 있을 수 있다.\n시크릿/프라이빗 모드로 먼저 접속 테스트 또는 브라우저 캐시 삭제 (Cmd+Shift+Delete 또는 Ctrl+Shift+Delete) 후 재시도 교훈: certbot 2.0.0부터 기본 키 타입이 ECDSA로 바뀌었다. ECDSA 자체에 문제가 있는 것은 아니지만, 특정 서버/클라이언트 조합에서 호환성 문제가 발생할 수 있으므로, 호환성 이슈를 겪은 경우 --key-type rsa로 전환하면 해결된다. 향후 인증서 갱신 시에도 RSA로 유지하려면:\n# /etc/letsencrypt/renewal/ainiinu.kr.conf 파일에 다음이 있는지 확인 key_type = rsa --key-type rsa로 발급하면 certbot이 자동으로 갱신 설정에도 RSA를 기록하므로, 이후 자동 갱신 시에도 RSA로 갱신된다.\n10.8 백엔드 기동 실패 # Q: No data scripts found at location 'classpath:db/seed/xxx.sql'?\napplication.properties의 spring.sql.init.data-locations에서 참조하는 SQL 파일이 실제로 존재하지 않는 경우. 파일이 삭제/이동되었으면 설정에서도 해당 참조를 제거해야 한다.\nQ: 백엔드 로그에서 Application run failed가 보인다?\nSpring Boot가 시작에 실패한 것. 로그에서 Caused by: 또는 Error를 찾으면 근본 원인이 나온다:\ndocker compose logs backend | grep -i \u0026#34;caused by\\|error creating bean\\|application run failed\u0026#34; 11. HTTPS 방식 비교 (참고) # 방법 장점 단점 Nginx + Let\u0026rsquo;s Encrypt (채택) compose로 함께 관리, 자동갱신 가능 Nginx 설정 필요 Caddy 설정 2~3줄, 인증서 자동 발급/갱신 Nginx보다 덜 익숙할 수 있음 GCP Load Balancer 인프라 레벨 처리, 컨테이너 수정 없음 GCP 비용 추가, 설정 복잡 Cloudflare Proxy DNS만 변경하면 끝, 무료 SSL 외부 서비스 의존 Nginx를 Docker Compose 안에 포함하는 방식을 채택한 이유:\nVM에 Docker + certbot 외 설치할 것이 없음 docker compose up -d 한 번으로 전체 기동 서버 이전 시 compose 파일만 가져가면 끝 컨테이너 간 통신이 Docker 내부 네트워크로 깔끔 12. 리눅스/터미널 필수 명령어 # GCP VM에 SSH로 접속하면 터미널(CLI)만 사용할 수 있다. 자주 쓰는 명령어 정리:\n12.1 파일/디렉토리 # ls # 현재 디렉토리 파일 목록 ls -la # 숨김 파일 포함, 상세 정보 (권한, 크기, 날짜) pwd # 현재 위치 (Print Working Directory) cd /path/to/dir # 디렉토리 이동 cd .. # 상위 디렉토리 cd ~ # 홈 디렉토리 cat filename # 파일 내용 전체 출력 less filename # 파일 내용을 페이지 단위로 보기 (q로 나감) head -n 20 file # 처음 20줄만 보기 tail -n 20 file # 마지막 20줄만 보기 tail -f file # 파일에 추가되는 내용을 실시간으로 보기 (로그 모니터링) 12.2 파일 편집 # nano filename # nano 편집기 (초보자용, 하단에 단축키 표시됨) vi filename # vi 편집기 (익숙해지면 빠름) nano 단축키: Ctrl+O → Enter = 저장, Ctrl+X = 나가기, Ctrl+K = 줄 삭제, Ctrl+W = 검색\nvi 최소 생존 가이드:\ni → 입력 모드 (글자 입력 가능) Esc → 명령 모드 (입력 끝) :wq + Enter → 저장 후 종료 :q! + Enter → 저장 안 하고 종료 /검색어 + Enter → 검색, n으로 다음 결과 12.3 프로세스/시스템 # ps aux # 실행 중인 모든 프로세스 ps aux | grep docker # docker 관련 프로세스만 필터 kill \u0026lt;PID\u0026gt; # 프로세스 종료 sudo systemctl status docker # Docker 서비스 상태 확인 12.4 네트워크 # curl https://ainiinu.kr # URL에 HTTP 요청 보내기 curl -I https://ainiinu.kr # 응답 헤더만 보기 curl -s ifconfig.me # 내 외부 IP 확인 nslookup ainiinu.kr # DNS 조회 sudo ss -tlnp # 열려있는 포트 확인 12.5 터미널 제어 # Ctrl+C # 실행 중인 명령 강제 중지 (로그 보기, 서버 실행 등을 멈출 때) Ctrl+D # 입력 종료 / 터미널 세션 종료 Ctrl+L # 화면 지우기 (clear와 동일) q # less, git log 등 페이저에서 나가기 ↑/↓ # 이전에 입력한 명령어 히스토리 탐색 Tab # 파일명/명령어 자동완성 q로 나가는 상황들:\ndocker compose logs (페이저 모드일 때) git log (커밋 이력이 길 때) less 명령으로 파일 볼 때 man 명령으로 매뉴얼 볼 때 화면에 : 또는 (END)가 보이면 q를 눌러 나가면 된다. Ctrl+C가 안 먹히고 화면이 멈춰보이면 거의 q로 나갈 수 있다.\n12.6 유용한 조합 # # 파이프 (|) — 앞 명령의 출력을 뒤 명령의 입력으로 전달 docker compose logs backend | grep ERROR # 로그에서 ERROR만 필터 ps aux | grep nginx # nginx 프로세스 찾기 # \u0026amp;\u0026amp; — 앞 명령 성공 시에만 뒤 명령 실행 git pull \u0026amp;\u0026amp; docker compose up -d --build # pull 성공해야 빌드 시작 # ; — 앞 명령 결과와 무관하게 뒤 명령도 실행 docker compose down; docker compose up -d # 항상 둘 다 실행 # \u0026gt; — 출력을 파일로 저장 (덮어쓰기) docker compose logs backend \u0026gt; backend.log # 로그를 파일로 저장 # \u0026gt;\u0026gt; — 출력을 파일에 추가 (이어쓰기) echo \u0026#34;메모\u0026#34; \u0026gt;\u0026gt; notes.txt 13. 운영 명령어 치트시트 # 복붙용 빠른 참조. 각 명령어의 상세 설명은 괄호 안의 절을 참고.\n# ── 일상 운영 (6.4절) ── docker compose up -d --build # 전체 서비스 시작 (빌드 포함) docker compose down # 전체 서비스 중지 docker compose ps # 서비스 상태 확인 git pull \u0026amp;\u0026amp; docker compose up -d --build # 코드 업데이트 후 재배포 # ── 로그 확인 (6.5절) ── docker compose logs -f --tail=50 # 전체 로그 (최근 50줄부터 실시간) docker compose logs -f backend # 특정 서비스 로그 docker compose logs backend | grep -i error # 에러만 필터 # ── 서비스 관리 (9.3절) ── docker compose restart nginx # 특정 서비스만 재시작 docker compose up -d --build backend # 특정 서비스만 재빌드 # ── SSL 인증서 (6.7절) ── sudo certbot certificates # 인증서 만료일 확인 docker compose stop nginx \u0026amp;\u0026amp; sudo certbot renew \u0026amp;\u0026amp; docker compose start nginx # 수동 갱신 # ── 데이터 관리 ── docker compose down \u0026amp;\u0026amp; docker compose up -d --build # DB 유지, 컨테이너 재생성 docker compose down -v # 모든 것 초기화 (DB 데이터 포함 삭제 — 주의!) # ── 디스크 정리 (9.7절) ── docker system df # Docker 디스크 사용량 확인 docker system prune -f # 미사용 리소스 정리 docker image prune -a -f # 모든 미사용 이미지 삭제 (빌드 캐시 포함) 14. 결론 # 현재 소스코드에서 코드 수정 최소 (next.config.ts에 output: 'standalone' 한 줄)로 Docker 배포 가능 프로젝트에 추가한 파일: 프론트엔드 Dockerfile, .dockerignore, nginx.conf, 루트 docker-compose.yml, .env.docker.example GCP VM(Debian 12)에 Docker 공식 저장소에서 설치, SSL은 호스트 certbot으로 발급 docker compose up -d --build로 전체 서비스 기동 https://ainiinu.kr로 웹과 iOS 모두 동일한 도메인으로 접속 ","date":"2026년 3월 31일","externalUrl":null,"permalink":"/posts/gcp-deployment-guide/","section":"Posts","summary":"GCP VM에 Docker Compose로 백엔드·프론트엔드·DB·Nginx를 배포하고 HTTPS를 적용하는 실전 가이드","title":"GCP Docker 배포 \u0026 운영 가이드","type":"posts"},{"content":"","date":"2026년 3월 31일","externalUrl":null,"permalink":"/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. InnoDB 인덱스와 페이지 구조, 락과 MVCC, 세션과 커넥션 풀까지 MySQL 핵심 개념을 한 번에 정리합니다.\n1. 랜덤 액세스(Random Access) # 1-1. 의미 # 랜덤 액세스란 저장된 데이터의 위치와 상관없이 원하는 위치의 데이터에 직접 접근할 수 있는 방식을 의미합니다.\n예를 들어 배열에서 a[100]에 바로 접근하거나, DB에서 인덱스를 통해 특정 키 값을 빠르게 찾는 것이 랜덤 액세스의 대표적 예시입니다.\n1-2. 데이터베이스에서 왜 중요한가 # DBMS는 대량의 데이터를 저장하고 검색합니다. 이때 모든 데이터를 처음부터 끝까지 순차적으로 읽는다면 비효율적입니다. 그래서 DBMS는 인덱스를 사용해 **\u0026ldquo;어디쯤에 원하는 값이 있을지\u0026rdquo;**를 빠르게 찾고, 그 위치로 바로 이동합니다.\n즉, 인덱스는 랜덤 액세스를 효율적으로 가능하게 해주는 핵심 도구입니다.\n2. InnoDB에서 기본 키(PK)가 물리적 저장 위치를 결정한다는 뜻 # 2-1. 클러스터형 인덱스(Clustered Index) # InnoDB에서는 테이블 데이터가 기본 키 순서대로 저장됩니다. 즉, 기본 키 자체가 단순 식별자일 뿐만 아니라 클러스터드 인덱스 리프 페이지에서의 배치 기준이 됩니다.\n이것을 흔히 다음처럼 표현합니다.\nInnoDB 테이블 = 기본 키 기반의 클러스터형 인덱스 리프 페이지 = 실제 행(row) 데이터 저장 2-2. 무슨 뜻인가 # 예를 들어 PK가 다음과 같다고 해봅시다.\nid = 1, 2, 3, 4, 5 InnoDB는 이 데이터를 대체로 id 순으로 정렬된 상태로 페이지에 저장합니다. 여기서 말하는 \u0026ldquo;물리적 저장 위치\u0026quot;는 파일 안의 오프셋이 영구히 고정된다는 뜻보다는, B-tree 리프 페이지가 PK 순서로 구성된다는 뜻에 가깝습니다.\n즉:\nPK가 작으면 앞쪽 페이지 PK가 크면 뒤쪽 페이지 에 배치되는 경향이 있습니다.\n2-3. 왜 중요한가 # 기본 키 기준으로 데이터가 정렬되어 있으므로:\nPK 조회가 매우 빠름 PK 범위 조회가 매우 유리함 보조 인덱스가 PK를 참조하므로 PK 크기가 전체 인덱스 크기에 영향 줌 3. \u0026ldquo;자주 변하는 값을 PK로 두면 성능이 나빠진다\u0026quot;의 의미 # 3-1. 이유 # InnoDB는 데이터를 PK 순서로 저장하므로, PK 값이 바뀌면 그 행의 저장 위치가 달라질 수 있습니다.\n즉, UPDATE pk = ...는 사실상 내부적으로:\n기존 레코드 삭제 새 PK 위치에 다시 삽입 에 가까운 비용을 유발할 수 있습니다.\n3-2. 부작용 # 페이지 재배치 비용 발생 페이지 분할(page split) 가능성 증가 보조 인덱스도 함께 정비 필요 디스크 I/O 증가 락 경쟁 가능성 증가 3-3. 실무 관점 # 그래서 PK는 보통 다음 특성을 갖는 값이 좋습니다.\n짧고 고정적이며 절대 바뀌지 않고 가능하면 단조 증가(auto increment 등) 4. 기본 키가 실제로 바뀌는 경우의 예시 # 보통 PK는 잘 안 바뀌는 것이 맞습니다. 하지만 잘못된 모델링을 하면 PK 변경이 필요해질 수 있습니다.\n예시 # 4-1. 자연키를 PK로 둔 경우 # 예:\n이메일 주소 주민번호 사번 사용자명(username) 이런 값은 \u0026ldquo;변하지 않을 것 같지만\u0026rdquo; 실제 서비스에서는 바뀔 수 있습니다.\n예:\n이메일 변경 사용자명 변경 정책 허용 외부 시스템 통합으로 ID 규칙 변경 4-2. 시스템 통합 # A 시스템과 B 시스템을 합치는데 PK 체계가 충돌하면 PK 재정비가 필요해질 수 있습니다.\n4-3. 업무 규칙 변경 # 예전에는 상품코드를 PK로 사용했는데, 상품코드 규칙 자체가 바뀌는 경우입니다.\n5. B+트리 탐색 복잡도와 디스크 접근 # 5-1. 탐색 복잡도 # B+트리는 높이를 h라고 할 때 탐색 비용이 대략 O(h)입니다.\n5-2. 왜 디스크 접근 횟수와 연결되는가 # B+트리의 각 노드는 보통 한 페이지(page) 단위로 저장됩니다. 따라서 루트 → 내부 노드 → 리프 노드로 내려갈 때 노드 하나를 읽을 때마다 페이지 하나를 읽는 셈이 됩니다.\n즉:\n트리 높이 = 최악의 경우 읽어야 하는 페이지 수 페이지가 버퍼 풀에 없다면 = 디스크 I/O 발생 5-3. 한 번에 리프까지 못 가는 이유 # 한 페이지 안에는 전체 트리가 들어있지 않습니다. 한 페이지에는 일부 키와 자식 페이지 주소만 들어있습니다.\n그래서 검색 과정은:\n루트 페이지 읽기 그 안에서 어느 자식 페이지로 갈지 결정 자식 페이지 읽기 다시 다음 자식 결정 리프 페이지 도달 형태가 됩니다.\n6. MySQL에서 \u0026ldquo;접근\u0026quot;은 누가 하는가 # MySQL에서 디스크 접근은 사용자가 직접 하는 것이 아니라 스토리지 엔진과 OS가 협력하여 수행합니다.\n흐름을 단순화하면 다음과 같습니다.\n클라이언트가 SQL 전송 MySQL 서버가 파싱/최적화 스토리지 엔진(InnoDB 등)이 필요한 페이지 요청 버퍼 풀에 없으면 OS를 통해 디스크에서 읽음 페이지를 메모리에 적재 후 사용 즉, 실제 SQL은 MySQL이 처리하지만 물리적 읽기/쓰기 요청은 결국 OS와 디스크 서브시스템이 담당합니다.\n7. B+트리 페이지 안에는 무엇이 들어 있나 # 7-1. 내부 노드 # 내부 노드에는 대체로 다음이 있습니다.\n여러 개의 키 값 각 키 범위에 대응하는 자식 페이지 포인터 예를 들어:\nkey \u0026lt; 10 이면 page A 10 \u0026lt;= key \u0026lt; 20 이면 page B key \u0026gt;= 20 이면 page C 7-2. 리프 노드 # 리프 노드에는 실제 검색 대상이 있습니다.\nInnoDB의 클러스터드 인덱스 리프: 실제 row 데이터 보조 인덱스 리프: 인덱스 키 + PK 값 7-3. 페이지 간 이동 # 현재 페이지에서 키 값을 비교한 뒤, 그 결과에 맞는 다음 페이지 번호(page id) 를 찾아 해당 페이지를 읽습니다.\n즉, \u0026ldquo;다른 페이지로 간다\u0026quot;는 말은 결국 페이지 번호를 가지고 그 페이지를 메모리/디스크에서 읽는 것입니다.\n8. 버퍼 관리 정책: STEAL / NO-STEAL, FORCE / NO-FORCE # 트랜잭션 중 수정된 페이지(더티 페이지)를 언제 디스크에 쓸지 기준으로 나누는 대표적인 정책입니다.\n다만 이 용어들은 데이터베이스 교과서에서 자주 쓰는 분류이고, MySQL 공식 문서가 InnoDB를 이 네 용어로 직접 규정하는 것은 아닙니다. InnoDB의 버퍼 풀 flush, redo, checkpoint 동작을 이해하기 위한 개념적 프레임으로 보는 편이 안전합니다.\n8-1. STEAL # 커밋 전이라도 더티 페이지를 디스크에 쓸 수 있음\n의미 # 버퍼 공간이 부족하면, 아직 커밋되지 않은 트랜잭션이 수정한 페이지도 디스크에 내보낼 수 있습니다.\n장점 # 버퍼 관리 유연 메모리 사용 효율 좋음 단점 # 커밋 전 데이터가 디스크에 기록될 수 있으므로, 장애 시 UNDO 필요 8-2. NO-STEAL # 커밋 전 더티 페이지를 디스크에 쓰지 않음\n장점 # 장애 시 UNDO 부담이 줄어듦 단점 # 버퍼 압박이 큼 메모리 관리가 어렵고 성능상 불리할 수 있음 8-3. FORCE # 커밋 시 해당 트랜잭션의 수정 내용을 디스크 페이지까지 강제로 기록\n장점 # 장애 후 REDO 필요성 감소 단점 # 커밋마다 디스크 쓰기 부담이 큼 성능 저하 가능성 큼 8-4. NO-FORCE # 커밋 시 로그만 보장하고, 데이터 페이지는 나중에 써도 됨\n장점 # 커밋이 빠름 현대적인 WAL 계열 DBMS에서 흔한 방향 단점 # 장애 시 REDO 필요 9. REDO와 UNDO # 9-1. REDO # 이미 커밋된 변경을 다시 적용해서 복구하는 것\n예:\n커밋은 되었는데 데이터 페이지 반영 전에 장애 발생 로그를 보고 변경사항을 다시 적용 즉, 내구성(Durability) 확보를 위한 복구\n9-2. UNDO # 커밋되지 않은 변경을 되돌리는 것\n예:\n트랜잭션이 중간에 실패 이미 일부 변경이 반영되었으면 원래 상태로 복원해야 함 즉, 원자성(Atomicity) 확보를 위한 복구\n9-3. 차이 정리 # REDO: \u0026ldquo;해야 했던 일을 다시 함\u0026rdquo; UNDO: \u0026ldquo;하면 안 되는 일을 취소함\u0026rdquo; 10. InnoDB의 UNDO 로그와 REDO 로그의 성격 # 10-1. UNDO 로그 # InnoDB의 UNDO는 논리적 성격이 강합니다.\n특히:\nUPDATE 이전 값 복원 DELETE 취소 INSERT 취소(삽입된 행 제거) 등의 목적으로 사용됩니다.\nMVCC에서도 과거 버전을 재구성하는 데 사용됩니다. 다만 INSERT의 UNDO는 MVCC용 과거 버전 제공에는 직접 사용되지 않고, 주로 롤백 시 \u0026ldquo;삽입된 행을 제거하기 위한 정보\u0026quot;로 쓰입니다.\n10-2. REDO 로그 # REDO는 일반적으로 low-level 변경 복구 정보의 성격이 강합니다.\n즉, SQL 문장을 다시 실행하는 것이라기보다, 데이터 파일에 아직 반영되지 못한 변경을 복구 시점에 다시 적용할 수 있도록 기록합니다.\n11. \u0026ldquo;INSERT 전에는 데이터가 없는데 UNDO 로그가 왜 필요한가?\u0026rdquo; # 매우 중요한 포인트입니다.\n11-1. 핵심 # INSERT 전에는 \u0026ldquo;이전 값\u0026quot;이 없으므로, UPDATE처럼 before image를 저장하지는 않습니다. 하지만 롤백을 하려면 **\u0026ldquo;이 행을 삭제해야 한다\u0026rdquo;**는 정보는 필요합니다.\n즉, INSERT의 UNDO는 보통:\n이전 값을 복원하는 용도라기보다 \u0026ldquo;이 INSERT를 취소하려면 어떤 레코드를 제거해야 하는가\u0026quot;를 나타내는 정보 입니다.\n11-2. MVCC와의 관계 # INSERT된 새 행은 원래 과거 버전이 없기 때문에, 다른 트랜잭션의 일관된 읽기에서는 아예 안 보이면 그만입니다.\n그래서 INSERT UNDO는 MVCC에서 과거 버전을 만드는 역할보다는, 주로 롤백 / 복구용 의미가 큽니다.\n12. 물리적 로그 vs 논리적 로그 # 12-1. 물리적 로그 # \u0026ldquo;페이지의 어디가 어떻게 바뀌었는지\u0026quot;를 기록\n장점 # 재적용이 명확 REDO에 적합 단점 # 로그량이 커질 수 있음 페이지 구조 의존적 동시성/유연성 측면에서 불리할 수 있음 12-2. 논리적 로그 # \u0026ldquo;무슨 연산을 했는지\u0026quot;를 기록\n예:\nA의 잔액에서 100 차감 B의 잔액에 100 증가 장점 # 더 추상적 일부 상황에서 동시성과 유연성에 유리 단점 # 재실행 시 문맥이 필요할 수 있음 13. REPEATABLE READ, READ COMMITTED, MVCC에 대한 이해 # 13-1. REPEATABLE READ의 핵심 # InnoDB의 REPEATABLE READ 에서 일반 SELECT(consistent read) 는 보통 트랜잭션 안의 첫 consistent read가 만든 읽기 스냅샷(read view) 을 기준으로 조회합니다.\n그래서 같은 트랜잭션 내에서는 나중에 다른 트랜잭션이 커밋한 데이터라도 처음 스냅샷에 없었다면 보이지 않을 수 있습니다.\n즉:\n트랜잭션2가 시작된 뒤 트랜잭션1이 커밋했더라도 트랜잭션2의 일반 SELECT는 여전히 예전 스냅샷 기준으로 읽음 이것이 사용자가 물어본 \u0026ldquo;트랜잭션2는 본인 트랜잭션 ID 이전의 값만 읽는 것으로 아는데, 왜 트랜잭션1이 기록한 데이터는 못 읽지?\u0026rdquo; 에 대한 핵심 이유입니다.\n정확한 이해 # 중요한 것은 단순히 \u0026ldquo;트랜잭션 ID가 더 작으냐\u0026quot;가 아니라, 트랜잭션2가 생성한 Read View에서 그 트랜잭션이 가시한지 여부입니다.\n또한 SELECT ... FOR UPDATE, SELECT ... FOR SHARE, UPDATE, DELETE 같은 잠금 경로는 항상 같은 규칙으로 snapshot을 읽는 것이 아니라 최신 상태와 락 규칙을 함께 사용하므로, 일반 SELECT 와 구분해서 이해해야 합니다.\n13-2. READ COMMITTED의 핵심 # 각 SELECT 문장이 실행될 때마다 새 Read View를 만듭니다.\n그래서 일반적으로:\n한 번 조회했을 때 안 보였던 값이 나중에 다른 트랜잭션 커밋 후 다시 조회하면 보일 수 있습니다. 다만 실제 실험에서는 다음을 주의해야 합니다.\n첫 번째 SELECT와 두 번째 SELECT가 정말 각각 독립된 consistent read였는가 잠금 읽기(SELECT ... FOR UPDATE)였는가 애플리케이션/프레임워크가 트랜잭션 경계를 어떻게 잡았는가 즉, 이론상 READ COMMITTED는 \u0026ldquo;문장 단위 최신 커밋 반영\u0026quot;이지만, 실험 상황의 쿼리 종류와 트랜잭션 흐름에 따라 관찰 결과 해석을 신중히 해야 합니다.\n14. 락(lock)의 종류와 레코드 락 # 14-1. 레코드 락(Record Lock) # 인덱스 레코드 하나를 잠그는 락입니다.\n즉 \u0026ldquo;행 전체\u0026quot;를 추상적으로 잠그는 것이 아니라, 실제로는 인덱스 엔트리 기준으로 락이 걸린다고 이해해야 합니다.\n14-2. 언제 사용되나 # UPDATE DELETE SELECT ... FOR UPDATE SELECT ... FOR SHARE (LOCK IN SHARE MODE 와 동등한 구문) 등에서 사용됩니다.\n15. INSERT 시 락은 어떻게 동작하나 # 사용자가 특히 많이 궁금해한 부분입니다.\n15-1. \u0026ldquo;없는 데이터에 어떻게 배타 락을 거나?\u0026rdquo; # 정확히는 없는 행 자체를 잠그는 것이 아니라, 그 행이 들어갈 인덱스 구간 / 위치 와 삽입될 레코드에 대해 적절한 락 메커니즘을 사용합니다.\n관련 개념:\ninsert intention lock gap lock next-key lock record lock 15-2. Insert Intention Lock # 삽입하려는 인덱스 구간에 대해 \u0026ldquo;나는 여기에 insert하려고 한다\u0026quot;는 의도를 표시하는 락입니다.\n여러 트랜잭션이 같은 gap에 insert하려 해도, 실제 동일 위치/동일 키 충돌이 아니면 어느 정도 병행 가능성을 가집니다.\n15-3. Gap Lock # 존재하는 인덱스 레코드 사이의 \u0026ldquo;간격\u0026quot;을 잠급니다.\n주된 목적:\n팬텀 읽기 방지 범위 내 새로운 레코드 삽입 방지 15-4. Next-Key Lock # Record Lock + Gap Lock\n즉:\n어떤 인덱스 레코드와 그 앞의 gap 을 함께 잠급니다.\n16. InnoDB는 PK 인덱스에만 락을 거는가? # 아닙니다. InnoDB는 인덱스 기반으로 락을 관리하며, 이것은 PK(클러스터드 인덱스)뿐 아니라 보조 인덱스(secondary index) 에도 적용됩니다.\n즉:\n어떤 인덱스를 통해 탐색하느냐 어떤 조건으로 조회/수정하느냐 에 따라 락이 걸리는 인덱스가 달라질 수 있습니다.\n다만 InnoDB에서 실제 레코드는 PK 인덱스 리프에 있으므로, 보조 인덱스를 통해 접근하더라도 최종적으로 PK 쪽 확인이 동반될 수 있습니다.\n17. Unique 인덱스 vs 일반 세컨더리 인덱스 # 17-1. 흔한 설명 # \u0026ldquo;유니크 인덱스는 1건만 읽으면 되지만, 유니크하지 않은 인덱스는 한 건 더 읽어야 해서 느리다\u0026rdquo;\n이 설명은 완전히 틀렸다고 보긴 어렵지만, 지나치게 단순화된 설명입니다.\n17-2. 정확히는 # 유니크 인덱스는 조건이 일치하면 최대 1건만 존재함이 보장되므로, 옵티마이저는 탐색 결과를 빠르게 확정할 수 있습니다.\n반면 일반 인덱스는 같은 키가 여러 개일 수 있어\n추가 레코드 존재 가능성 확인 여러 엔트리 순회 등이 필요할 수 있습니다.\n하지만 성능 차이는 대개 아주 미세하며, 실제 병목은 보통 다음이 더 큽니다.\n커버링 인덱스 여부 랜덤 I/O 여부 페이지 캐시 히트율 반환 컬럼 수 읽어야 하는 실제 row 수 18. 특정 이메일 존재 여부 확인 쿼리 비교 # 대상 테이블:\nCREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE ); 비교 대상:\nSELECT * FROM users WHERE email = :user_email; SELECT email FROM users WHERE email = :user_email; SELECT count(*) FROM users WHERE email = :user_email; 18-1. SELECT * # 가장 비효율적일 가능성이 큽니다.\n이유 # email 인덱스로 위치는 찾더라도 * 때문에 전체 row를 읽어야 함 즉, 인덱스만으로 끝나지 않음 보통 커버링 인덱스 아님 실행계획 관점:\n인덱스 탐색 후 실제 clustered index row 접근 필요 18-2. SELECT email # 이 쿼리는 email 인덱스만으로 결과를 반환할 수 있습니다.\n장점 # 커버링 인덱스 가능 테이블 row 전체를 안 읽어도 될 수 있음 네트워크 반환 데이터도 적음 18-3. SELECT count(*) # 존재 여부만 보고 싶다면 동작 자체는 맞습니다. 다만 이 예시는 email 이 UNIQUE 라서 차이가 작을 수 있어도, 존재 여부만 필요하다면 보통 EXISTS 나 SELECT 1 ... LIMIT 1 처럼 \u0026ldquo;있느냐\u0026quot;를 바로 표현하는 형태를 더 많이 권장합니다.\n18-4. 존재 여부만 필요하다면 가장 추천되는 형태 # SELECT EXISTS( SELECT 1 FROM users WHERE email = :user_email ) AS email_exists; 이유 # 존재 확인이 목적임이 명확 첫 행을 찾으면 더 볼 필요가 없음 읽는 사람에게도 의도가 분명함 19. SELECT *가 비효율적인 이유를 옵티마이저와 실행계획 관점에서 보면 # 19-1. 커버링 인덱스 불가 # SELECT *는 모든 컬럼이 필요하므로 email 유니크 인덱스만으로는 쿼리를 끝낼 수 없습니다.\n즉:\nemail 인덱스에서 조건 일치 엔트리 찾기 그 엔트리가 가리키는 실제 row를 다시 읽기 19-2. 결과 전송량 증가 # 존재 여부만 필요한데도:\nid username email 모두 반환하면 불필요한 I/O와 네트워크 비용이 생깁니다.\n19-3. 실무 관점 결론 # \u0026ldquo;필요한 컬럼만 조회하라\u0026rdquo; 는 원칙이 매우 중요합니다.\n20. Unique 제약 조건과 인덱스 # 20-1. UNIQUE를 걸면 인덱스가 생기나? # 일반적으로 그렇습니다. MySQL은 UNIQUE 제약 조건을 위해 유니크 인덱스를 생성합니다.\n20-2. 해당 컬럼으로 WHERE 검색 시 빨라지나? # 네, 보통 빨라집니다.\n예:\nSELECT * FROM users WHERE email = \u0026#39;a@b.com\u0026#39;; 여기서 email에 UNIQUE 인덱스가 있으면, MySQL은 인덱스를 사용해 빠르게 탐색할 수 있습니다.\n20-3. UNIQUE인데도 NULL은? # MySQL에서는 UNIQUE 인덱스라도 NULL 가능한 컬럼이면 여러 NULL 값을 허용합니다. 즉, \u0026ldquo;UNIQUE 이니까 NULL 도 하나만 된다\u0026quot;라고 이해하면 틀립니다.\n21. 조인 문법 두 가지 # SELECT * FROM employees e JOIN salaries s ON e.emp_no = s.emp_no; SELECT * FROM employees e, salaries s WHERE e.emp_no = s.emp_no; 차이 # 기능적으로는 같은 inner join 결과를 냅니다.\n권장 방식 # 첫 번째처럼 JOIN ... ON을 사용하는 것이 좋습니다.\n이유 # 가독성 좋음 조인 조건이 명확 복잡한 쿼리에서 실수 줄임 22. Undo 로그와 Undo 페이지의 차이 # Undo 로그 # 트랜잭션 변경을 되돌리기 위한 논리적 정보\nUndo 페이지 # 그 Undo 로그들이 실제 저장되는 물리적 페이지 단위\n즉:\nUndo 로그 = 내용 Undo 페이지 = 그 내용을 담는 저장 공간 23. 트랜잭션은 DB ACID를 보장할 뿐, 자바 코드의 원자성을 보장하지 않는다 # 이 문장은 핵심적으로 맞습니다.\n의미 # DB 트랜잭션은 데이터베이스 내부 변경에 대해 ACID를 보장하지만, 애플리케이션 코드 전체가 자동으로 원자화되는 것은 아닙니다.\nSpring의 @Transactional 도 기본적으로 현재 실행 스레드에 바인딩된 트랜잭션 경계를 다루는 것이지, 메서드 안에서 새로 만든 스레드까지 자동으로 전파되는 모델은 아닙니다.\n예:\nDB 조회 자바 조건 분기 없으면 생성 저장 이 전체가 애플리케이션 레벨 경쟁 조건(race condition)에 노출될 수 있습니다.\nsynchronized로 막는 것이 왜 한계가 있나 # 애플리케이션 인스턴스가 여러 대면 JVM 내부 lock은 무력 DB 레벨 경쟁 상태는 DB 제약/락으로 해결해야 함 대표적 해결책: UNIQUE 제약 적절한 트랜잭션과 락 upsert 패턴 24. 미디어 복구(Media Recovery) # 미디어 복구는 디스크 손상, 파일 손상, 저장 매체 장애 등으로 인해 손실된 데이터를 백업과 로그를 이용해 복원하는 작업입니다.\n즉:\n단순 트랜잭션 롤백/크래시 복구보다 더 큰 장애 범위 백업 + 아카이브 로그 + redo 적용 등의 절차가 중요 25. PK 크기가 커질수록 인덱스 크기가 커지는 이유 # InnoDB에서 보조 인덱스의 리프 엔트리에는 단순히 \u0026ldquo;보조 인덱스 값\u0026quot;만 있는 것이 아니라 해당 row를 찾기 위한 PK 값도 함께 저장됩니다.\n예:\nPK = INT (4 bytes) 보조 인덱스 하나당 각 엔트리가 PK 4 bytes를 포함 그런데 PK가:\n긴 문자열 복합 키 UUID 문자열 형태 라면 모든 보조 인덱스 엔트리가 그만큼 더 커집니다.\n영향 # 인덱스 크기 증가 페이지당 저장 가능한 엔트리 수 감소 B+트리 높이 증가 가능성 캐시 효율 하락 따라서 PK는 짧을수록 유리합니다.\n26. MySQL 스레드와 OS 커널 스레드의 관계 # MySQL 서버는 내부적으로:\n클라이언트 연결마다 연결되는 요청 처리용 스레드 백그라운드 작업용 스레드 를 사용합니다.\n기본 connection thread model에서는 클라이언트 연결마다 전용 스레드가 붙고, 이 스레드들은 운영체제가 스케줄링하는 실행 단위 위에서 동작합니다.\n동기화가 왜 필요한가 # 여러 스레드가 동시에:\n버퍼 풀 락 테이블 인덱스 구조 로그 버퍼 같은 공유 자원에 접근하면 충돌이 발생할 수 있습니다.\n그래서:\nmutex semaphore latch rw-lock 같은 동기화 도구가 사용됩니다.\n즉, 운영체제는 스레드 실행과 기본 동기화 원시 기능을 제공하고, MySQL은 이를 이용해 DB 내부 공유 자원을 보호합니다.\n27. B-tree 인덱스의 \u0026ldquo;고유번호\u0026quot;와 실제 키 값 # 대화 중 이 부분은 표현상 혼동이 있었습니다. 실무/교재에서 일반적으로 중요한 것은 다음입니다.\n실제 키 값: 인덱스를 구성하는 컬럼 값 행 식별 정보: InnoDB 보조 인덱스라면 PK 값 MyISAM이라면 데이터 파일 offset 같은 위치 정보 즉, 보통 학습에서 중요한 구분은 \u0026ldquo;인덱스 키\u0026rdquo; vs \u0026ldquo;그 키가 가리키는 실제 레코드 식별자\u0026rdquo; 입니다.\n28. MyISAM vs InnoDB # 28-1. MyISAM의 특징 # 트랜잭션 미지원 테이블 락 사용 read-mostly 워크로드에 주로 사용 데이터와 인덱스를 별도 저장 28-2. MyISAM의 테이블 락 # MyISAM은 row lock이 아니라 table lock 기반입니다.\n장점 # 구현 단순 오버헤드 적음 단점 # 동시 쓰기에 취약 쓰기 작업이 많으면 병목 28-3. 빠른 읽기 연산 # 트랜잭션과 MVCC가 없고 테이블 락 기반이기 때문에, 역사적으로는 읽기 비중이 매우 큰 환경에서 쓰이곤 했습니다.\n28-4. InnoDB는 느린가? # 단순 읽기 구조만 보면 MyISAM이 유리해 보일 수 있지만, 실제 현대 서비스 환경에서는 InnoDB의 장점이 훨씬 큽니다.\n특히:\nPK 조회는 InnoDB 클러스터드 인덱스가 매우 빠름 트랜잭션 지원 row-level locking crash recovery MVCC 그래서 일반 서비스에서는 거의 InnoDB가 기본 선택입니다.\n29. Spring에서 MyISAM을 쓰면 트랜잭션을 못 쓰는가? # DB 엔진 수준에서는 사실상 그렇습니다.\nSpring의 @Transactional은 프레임워크 레벨에서 트랜잭션 경계를 잡아 주지만, 실제 DB가 트랜잭션을 지원하지 않으면 ACID 보장을 받을 수 없습니다.\n즉:\nSpring이 어노테이션을 붙여도 MyISAM 변경은 COMMIT/ROLLBACK 기반의 진짜 트랜잭션으로 보호되지 않음 따라서 롤백을 호출해도 MyISAM 변경은 되돌려지지 않을 수 있음 따라서 트랜잭션이 필요한 애플리케이션이라면 InnoDB가 필요합니다.\n30. wait_timeout의 global 과 session 차이 # global # 새로 생성되는 세션의 기본값\nsession # 현재 연결된 특정 세션에만 적용되는 값\n즉:\nSET GLOBAL wait_timeout = ... → 새 연결부터 영향 SET SESSION wait_timeout = ... → 현재 연결에만 영향 MySQL은 연결 스레드가 시작될 때 session wait_timeout 값을 global wait_timeout 또는 interactive_timeout 에서 초기화합니다.\n31. MySQL의 세션(Session)은 무엇인가 # 세션은 스키마 단위가 아니라, 클라이언트와 서버 간의 하나의 연결(connection) 을 의미합니다.\n즉:\n웹 서버의 DB 커넥션 하나 = DB 세션 하나 이 세션은 다음을 가집니다.\n세션 변수 트랜잭션 상태 현재 default schema temporary table 등 32. Spring 서버에 100명이 요청을 보내면 세션은 하나인가? # 아닙니다. 보통은 DB 커넥션 풀(HikariCP 등)을 사용합니다.\n즉:\n요청 100개가 들어와도 DB 세션은 커넥션 풀 크기만큼 운영될 수 있음 각 요청은 필요 시 풀에서 커넥션 하나를 빌려 사용 따라서 \u0026ldquo;Spring 서버와 DB 사이에 단 하나의 세션만 있다\u0026quot;는 것은 틀립니다.\n33. In-memory DBMS vs 큰 페이지 캐시를 가진 디스크 기반 DBMS # 사용자가 제기한 질문은 매우 본질적입니다.\n\u0026ldquo;페이지 전체를 메모리에 캐시한다면 디스크 기반 DBMS도 인메모리 DBMS와 똑같지 않나?\u0026rdquo;\n겉보기에는 비슷해 보일 수 있지만, 구조적으로 다릅니다.\n33-1. 단순 페이지 캐시와 인메모리 DB의 차이 # 디스크 기반 DBMS는 기본적으로:\n디스크 페이지 구조 버퍼 매니저 페이지 flush WAL 기반 recovery 페이지 포맷 유지 를 전제로 설계됩니다.\n반면 인메모리 DBMS는 애초에:\n포인터 기반 자료구조 캐시 친화적 레이아웃 디스크 페이지 형식 최소화 직렬화/역직렬화 최소화 등을 목표로 설계됩니다.\n33-2. 직렬화 오버헤드 관점 # 디스크 기반 DBMS는 데이터를 디스크에 영속화하기 위해 페이지 포맷, 로그 포맷, flush 규칙 등을 관리해야 합니다.\n즉 메모리에 올라와 있어도:\n디스크에 맞는 구조 유지 페이지 dirty 관리 체크포인트 직렬화 형태 고려 같은 오버헤드가 있습니다.\n인메모리 DBMS는 이런 전통적 디스크 페이지 중심 제약이 훨씬 약합니다.\n33-3. 데이터 레이아웃 유지 오버헤드 # 디스크 기반 DBMS는 보통 페이지 단위 정합성을 유지해야 합니다.\n예:\n슬롯 디렉터리 페이지 헤더 free space 관리 page split page compaction 이런 레이아웃 관리 비용이 큽니다.\n반면 인메모리 DBMS는 디스크 페이지 레이아웃에 덜 얽매여 CPU cache 친화적인 구조를 선택할 자유가 더 큽니다.\n즉:\n\u0026ldquo;전부 메모리에 올려둔다\u0026quot;는 사실만 같을 뿐 내부 자료구조와 실행 엔진 최적화가 다릅니다. 34. 선택도(Selectivity) / 기수성(Cardinality) # 인덱스 키 값의 중복이 많아질수록:\n기수성(cardinality) 은 낮아지고 선택도(selectivity) 도 낮아집니다 왜 중요하나 # 선택도가 낮으면 특정 값으로 검색해도 많은 row가 나올 가능성이 크므로, 옵티마이저는 인덱스 사용의 이점을 낮게 평가할 수 있습니다.\n예:\n성별 컬럼 (M, F) 인덱스는 선택도가 매우 낮음 이메일 컬럼은 거의 유일 → 선택도 매우 높음 35. 데이터베이스는 데이터를 어떻게 읽어오나 # 전체 흐름을 정리하면 다음과 같습니다.\n35-1. 파싱 # SQL 문법 분석\n35-2. 최적화 # 옵티마이저가 실행 계획 선택\n35-3. 실행 # 인덱스 탐색 or 풀스캔 필요한 페이지를 버퍼 풀에서 찾음 없으면 디스크에서 읽음 35-4. 결과 생성 # 필터, 조인, 정렬, 집계 수행\n35-5. 반환 # 클라이언트에 결과 전송\n핵심은 결국:\nDBMS는 \u0026ldquo;페이지\u0026rdquo; 단위로 데이터를 읽고, 버퍼 풀/인덱스/옵티마이저를 이용해 디스크 I/O를 최소화한다.\n36. 마지막으로 다시 정리하는 핵심 개념들 # 36-1. InnoDB에서 중요한 것 # PK = 클러스터드 인덱스 보조 인덱스 리프에는 PK가 들어감 트랜잭션/MVCC/UNDO/REDO 지원 락은 인덱스 기반으로 동작 REPEATABLE READ에서 스냅샷 읽기 중요 36-2. 쿼리 최적화 관점 핵심 # SELECT * 지양 필요한 컬럼만 조회 존재 여부는 EXISTS 고려 커버링 인덱스 여부 중요 PK는 짧고 안정적일수록 좋음 36-3. 애플리케이션 관점 핵심 # DB 트랜잭션이 자바 코드 전체의 원자성을 보장하는 것은 아님 멀티 인스턴스 환경에서는 JVM synchronized 만으로 충분하지 않음 데이터 무결성은 DB 제약조건과 트랜잭션 설계로 확보해야 함 37. 학습 포인트 요약 체크리스트 # 아래 항목을 스스로 설명할 수 있으면 이번 대화의 핵심을 잘 이해한 것입니다.\n랜덤 액세스가 무엇인지 설명할 수 있다. InnoDB에서 PK가 왜 물리적 저장 순서에 영향을 주는지 설명할 수 있다. PK가 커지면 왜 보조 인덱스도 커지는지 설명할 수 있다. B+트리 높이와 디스크 I/O 횟수의 관계를 설명할 수 있다. STEAL / NO-STEAL, FORCE / NO-FORCE를 교과서 개념으로 구분할 수 있다. REDO와 UNDO의 차이를 설명할 수 있다. INSERT의 UNDO가 왜 필요한지 설명할 수 있다. REPEATABLE READ에서 왜 다른 트랜잭션의 커밋을 못 볼 수 있는지 설명할 수 있다. 레코드 락, 갭 락, 넥스트 키 락을 구분할 수 있다. SELECT *, SELECT email, COUNT(*), EXISTS 중 존재 여부 확인에 무엇이 적절한지 설명할 수 있다. MyISAM과 InnoDB 차이를 설명할 수 있다. Spring의 요청과 DB 세션(커넥션 풀)의 관계를 설명할 수 있다. 인메모리 DB와 디스크 기반 DB+큰 캐시의 차이를 설명할 수 있다. 38. 마무리 # 이번 대화는 단순 문법보다 더 중요한, DBMS가 내부적으로 어떻게 동작하는지 를 이해하는 방향으로 진행되었습니다. 백엔드 개발자에게 특히 중요한 관점은 다음 세 가지입니다.\n성능: 인덱스, 페이지, 랜덤 I/O, 커버링 인덱스 정합성: 트랜잭션, 락, MVCC, UNIQUE 제약 설계: PK 선택, 쿼리 형태, 애플리케이션-DB 역할 분리 이 세 가지를 계속 연결해서 공부하면 MySQL을 훨씬 깊이 이해할 수 있습니다.\n참고 자료 # MySQL 8.4 Reference Manual, Clustered and Secondary Indexes https://dev.mysql.com/doc/refman/8.4/en/innodb-index-types.html MySQL 8.4 Reference Manual, The Physical Structure of an InnoDB Index https://dev.mysql.com/doc/refman/8.4/en/innodb-physical-structure.html MySQL 8.4 Reference Manual, File Space Management https://dev.mysql.com/doc/refman/8.4/en/innodb-file-space.html MySQL 8.4 Reference Manual, Buffer Pool https://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool.html MySQL 8.4 Reference Manual, Transaction Isolation Levels https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html MySQL 8.4 Reference Manual, Consistent Nonlocking Reads https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html MySQL 8.4 Reference Manual, Locking Reads https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html MySQL 8.4 Reference Manual, Locks Set by Different SQL Statements in InnoDB https://dev.mysql.com/doc/refman/8.4/en/innodb-locks-set.html MySQL 8.4 Reference Manual, Undo Logs https://dev.mysql.com/doc/refman/8.4/en/innodb-undo-logs.html MySQL 8.4 Reference Manual, Redo Log https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html MySQL 8.4 Reference Manual, Server System Variables (wait_timeout) https://dev.mysql.com/doc/refman/8.4/en/server-system-variables.html MySQL 8.4 Reference Manual, The MyISAM Storage Engine https://dev.mysql.com/doc/refman/8.4/en/myisam-storage-engine.html MySQL 8.4 Reference Manual, Connection Interfaces https://dev.mysql.com/doc/refman/8.4/en/connection-interfaces.html MySQL 8.4 Reference Manual, CREATE INDEX Statement https://dev.mysql.com/doc/refman/8.4/en/create-index.html Spring Framework Javadoc, @Transactional https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/annotation/Transactional.html ","date":"2026년 3월 27일","externalUrl":null,"permalink":"/posts/mysql-database-session-summary/","section":"Posts","summary":"클러스터드 인덱스, 보조 인덱스, undo/redo, REPEATABLE READ, READ COMMITTED, MyISAM 차이, wait_timeout, DB 세션과 커넥션 풀까지 한 번에 훑는 MySQL 정리 노트입니다.","title":"[MySQL] 데이터베이스 학습 정리 - 세션, 인덱스, 락, MVCC","type":"posts"},{"content":"","date":"2026년 3월 27일","externalUrl":null,"permalink":"/tags/lock/","section":"Tags","summary":"","title":"Lock","type":"tags"},{"content":"","date":"2026년 3월 27일","externalUrl":null,"permalink":"/tags/session/","section":"Tags","summary":"","title":"Session","type":"tags"},{"content":"","date":"2026년 3월 27일","externalUrl":null,"permalink":"/tags/spring/","section":"Tags","summary":"","title":"Spring","type":"tags"},{"content":"","date":"2026년 3월 27일","externalUrl":null,"permalink":"/tags/transaction/","section":"Tags","summary":"","title":"Transaction","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Nested Loop Join을 중심으로 드라이빙 테이블과 드리븐 테이블이 왜 중요하고, 실행 계획에서 어떻게 읽는지 정리합니다.\n목차 # 핵심 개념 정리 JOIN의 내부 동작 원리 왜 드라이빙 테이블 선택이 중요한가? 좋은 드라이빙/드리븐 테이블 선택 기준 인덱스와의 관계 JOIN 알고리즘별 드라이빙/드리븐 역할 차이 옵티마이저가 드라이빙 테이블을 결정하는 방법 강제로 드라이빙 테이블 지정하는 방법 실무 체크리스트 한 줄 요약 1. 핵심 개념 정리 # JOIN을 실행할 때 옵티마이저는 두 테이블 중 어느 쪽을 먼저 읽을지 결정한다.\n구분 다른 이름 설명 드라이빙 테이블 Driving Table / Outer Table 먼저 읽히는 테이블. JOIN의 기준점이 됨 드리븐 테이블 Driven Table / Inner Table 드라이빙 테이블의 각 행마다 탐색당하는 테이블 이 선택은 단순한 순서의 문제가 아니라, 쿼리 전체 성능을 크게 좌우하는 핵심 요소다.\n다만 이 용어는 특히 JOIN 순서 나 Nested Loop Join(NLJ) 을 설명할 때 가장 직관적이다.\nHash Join이나 Merge Join에서는 outer/inner, build/probe 같은 표현이 더 정확할 때도 있다.\n2. JOIN의 내부 동작 원리 # 2-1. Nested Loop Join (NLJ) - 드라이빙/드리븐 개념을 이해하기 가장 쉬운 모델 # 드라이빙/드리븐 개념은 NLJ에서 가장 선명하게 드러난다.\n// 드라이빙 = A, 드리븐 = B라고 가정 for each row in A (드라이빙): // A를 한 번 순회 for each matching row in B (드리븐): // B를 A의 row 수만큼 반복 탐색 output(A.row + B.row) 이 구조를 보면 핵심 비대칭이 명확해진다.\n드라이빙 테이블은 한 번 읽힌다 드리븐 테이블은 드라이빙 결과 row 수만큼 반복 탐색된다 따라서 NLJ에서는 드리븐 테이블의 탐색 비용이 전체 JOIN 성능을 지배하기 쉽다.\n2-2. 탐색 횟수로 보는 비용 # 드라이빙 결과 = N건 드리븐 탐색 비용 = 1회당 C 전체 비용 = N × C N을 줄이려면 -\u0026gt; 드라이빙 테이블에 필터 효과가 큰 WHERE 조건 C를 줄이려면 -\u0026gt; 드리븐 테이블 JOIN 컬럼에 인덱스 3. 왜 드라이빙 테이블 선택이 중요한가? # 예시 시나리오 # SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; 테이블 현황 가정:\n테이블 전체 건수 조건 필터 후 건수 orders 1,000,000 500건 (status = 'PENDING') customers 100,000 조건 없음 케이스 1: orders가 드라이빙 (좋은 경우)\n1. orders에서 status = \u0026#39;PENDING\u0026#39; 필터 -\u0026gt; 500건 추출 2. 500건의 customer_id로 customers 테이블 인덱스 탐색 -\u0026gt; 500회 총 탐색 횟수 = 500회 케이스 2: customers가 드라이빙 (나쁜 경우)\n1. customers 전체 읽기 -\u0026gt; 100,000건 2. 100,000건 각각으로 orders 테이블 탐색 -\u0026gt; 100,000회 총 탐색 횟수 = 100,000회 같은 논리의 쿼리여도 조인 순서에 따라 비용이 크게 벌어진다.\n4. 좋은 드라이빙/드리븐 테이블 선택 기준 # 4-1. 드라이빙 테이블로 적합한 것 # ✅ WHERE 조건 적용 후 결과 건수가 적은 테이블 ✅ 필터 효과가 큰 조건을 가진 테이블 ✅ 조인 전에 row 수를 많이 줄일 수 있는 테이블 필터 효과 계산 예시 # orders 테이블: 1,000,000건 status = \u0026#39;PENDING\u0026#39; 조건 후 결과: 500건 조건 일치 비율 = 500 / 1,000,000 = 0.0005 (0.05%) -\u0026gt; 남는 row 비율이 매우 낮으므로 필터 효과가 큼 문헌에 따라 selectivity 용어를 \u0026ldquo;남는 비율\u0026quot;로 정의하기도 하고, 실무에서는 반대로 \u0026ldquo;필터가 잘 먹는다\u0026quot;는 뜻으로 느슨하게 쓰기도 한다.\n실전에서는 용어보다 조건 적용 후 몇 건이 남는가 를 보는 편이 안전하다.\n4-2. 드리븐 테이블로 적합한 것 # ✅ JOIN 컬럼(ON 절 컬럼)에 인덱스가 걸려 있는 테이블 ✅ 단건 또는 소수건 lookup 역할을 하는 테이블 ✅ 테이블이 커도 JOIN 컬럼 인덱스로 빠르게 찾을 수 있는 테이블 특히 NLJ에서는 드리븐 테이블의 JOIN 컬럼 인덱스가 사실상 성능의 핵심이다.\n황금 법칙: 드라이빙은 \u0026ldquo;작게\u0026rdquo;, 드리븐은 \u0026ldquo;인덱스 있게\u0026rdquo;\n5. 인덱스와의 관계 - 가장 중요한 부분 # 5-1. 이상적인 인덱스 구성 예시 # -- 테이블 구조 CREATE TABLE orders ( id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL ); CREATE TABLE customers ( id BIGINT PRIMARY KEY, name VARCHAR(100), email VARCHAR(200) ); -- 인덱스 추가 CREATE INDEX idx_orders_status ON orders(status); -- WHERE 조건용 CREATE INDEX idx_orders_customer_id ON orders(customer_id); -- JOIN/FK 컬럼용 5-2. 이상적인 실행 흐름 # SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; Step 1. orders에서 idx_orders_status 사용 -\u0026gt; status = \u0026#39;PENDING\u0026#39; 에 해당하는 row만 빠르게 추출 (드라이빙) Step 2. 추출된 N건의 customer_id 각각으로 customers의 PK 인덱스 탐색 (드리븐) -\u0026gt; 매 반복마다 index seek 결과: 전체 비용 ≈ N × lookup_cost 5-3. 드리븐 테이블에 인덱스가 없는 경우 # -- 최악에 가까운 시나리오 SELECT * FROM small_ref_table s JOIN huge_table h ON h.non_indexed_col = s.id; -\u0026gt; small_ref_table 결과가 N건이라면 -\u0026gt; huge_table을 N번 Full Table Scan -\u0026gt; 전체 비용 = N × M (M = huge_table 전체 건수) -\u0026gt; N=1,000, M=5,000,000이면 row 비교 수가 폭증 결론: NLJ에서 드리븐 테이블의 JOIN 컬럼에 인덱스가 없으면, 전체 JOIN 비용이 급격히 나빠질 수 있다.\n5-4. FK 컬럼 인덱스 주의사항 # FK 컬럼 인덱스는 DBMS마다 자동 생성 여부가 다르다.\nMySQL InnoDB: 참조하는 쪽(referencing table)에 필요한 인덱스가 없으면 자동으로 생성될 수 있다 PostgreSQL: FK 선언만으로 referencing columns 인덱스를 자동 생성하지 않는다 SQL Server: FK 선언만으로 corresponding index를 자동 생성하지 않는다 따라서 실무에서는 다음 원칙이 안전하다.\nFK가 있으니 인덱스도 있겠지 -\u0026gt; 위험한 가정 실제 인덱스 정의 + EXPLAIN 결과를 확인 -\u0026gt; 안전한 접근 -- 필요 시 명시적으로 생성 CREATE INDEX idx_orders_customer_id ON orders(customer_id); 6. JOIN 알고리즘별 드라이빙/드리븐 역할 차이 # 6-1. Nested Loop Join (NLJ) # 드라이빙/드리븐 개념이 가장 직접적으로 적용되는 알고리즘이다.\n특징: - 드라이빙 테이블 한 번 순회 - 드리븐 테이블을 드라이빙 row 수만큼 반복 탐색 최적 조건: - 드라이빙: 소량 결과, 필터 효과 큼 - 드리븐: JOIN 컬럼에 인덱스가 매우 중요 적합 상황: OLTP, 비교적 작은 결과 집합, 인덱스 잘 갖춰진 환경 -- NLJ 동작 의사코드 for each row r1 in driving_table: lookup driven_table where join_col = r1.join_col if found: emit(r1, matched_row) 6-2. Hash Join (MySQL 8.4, PostgreSQL, SQL Server 등) # 인덱스가 약한 상황에서도 대용량 JOIN을 처리할 수 있는 알고리즘이다.\n동작 방식: Phase 1 (Build): 보통 더 작은 입력을 해시 테이블로 적재 Phase 2 (Probe): 다른 입력을 스캔하며 해시 테이블과 매칭 특징: - 드리븐 인덱스가 없어도 동작 가능 - 메모리 사용량이 큼 - 메모리를 초과하면 디스크 spill이 발생할 수 있음 적합 상황: 대용량 데이터, equi-join 중심, 인덱스가 약한 환경 PostgreSQL 문서는 Hash Join에서 한쪽 입력이 Hash 노드로 해시 테이블이 되고, 다른 쪽 입력이 이를 probe한다고 설명한다.\nMySQL 8.4 문서도 EXPLAIN FORMAT=TREE 에 Hash 와 Inner hash join 노드가 표시된다고 설명한다.\n-- MySQL 8.4에서 Hash Join 동작 확인 EXPLAIN FORMAT=TREE SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id; -- 출력에 \u0026#34;Hash\u0026#34; 또는 \u0026#34;Inner hash join\u0026#34; 이 보이면 Hash Join 사용 중 -- MySQL: hash join 메모리와 관련된 대표 설정 SET join_buffer_size = 256 * 1024 * 1024; -- 256MB MySQL 8.4 문서 기준으로 hash join 메모리 사용량은 join_buffer_size 와 관련이 있고, 부족하면 디스크 파일을 사용할 수 있다.\n6-3. Sort Merge Join (PostgreSQL, Oracle, SQL Server) # 양쪽 입력이 JOIN 컬럼 기준으로 정렬되어 있을 때 유리한 알고리즘이다.\n동작 방식: 1. 양쪽 입력을 JOIN 컬럼 기준으로 정렬 2. 정렬된 두 목록을 순차 스캔하며 병합 특징: - 양쪽 입력이 정렬돼 있어야 함 - 정렬 비용이 들 수 있음 - 범위 조건이나 정렬된 입력 활용에 유리할 수 있음 - 드라이빙/드리븐 개념이 NLJ보다 덜 중요함 PostgreSQL 공식 문서도 merge join은 입력 데이터가 join key 기준으로 정렬되어 있어야 한다고 설명한다.\n6-4. 알고리즘 선택 요약 # 알고리즘 인덱스 필요성 메모리 사용 적합 환경 Nested Loop Join 드리븐 인덱스가 매우 중요 낮음 OLTP, 소량 결과 Hash Join 인덱스 의존도 낮음 높음 대량 데이터, equi-join Sort Merge Join 정렬된 입력이 중요 중간 대량 데이터, 정렬 활용 7. 옵티마이저가 드라이빙 테이블을 결정하는 방법 # 옵티마이저는 통계 정보(테이블 건수, 인덱스 분포, 컬럼 분포 등)를 기반으로 비용을 추정해 조인 순서를 결정한다.\n7-1. 실행계획(EXPLAIN) 읽는 법 # -- MySQL EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; +----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | +----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+ | 1 | SIMPLE | o | ref | idx_orders_status | idx_orders_status | 22 | const | 500 | | 1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 8 | mydb.o.customer_id| 1 | +----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+ MySQL 공식 문서는 EXPLAIN 이 테이블이 어떤 순서로 조인되는지 보여 준다고 설명한다.\n따라서 tabular EXPLAIN 에서는 보통 위에서 아래 순서가 조인 순서를 반영한다.\n다만 다음 예외는 기억해야 한다.\nconst 또는 system 테이블은 최적화 단계에서 먼저 읽혀 계획 맨 앞에 나타날 수 있다 Hash Join 같은 경우는 FORMAT=TREE 로 보는 편이 실제 build/probe 구조를 더 잘 보여 준다 7-2. type 컬럼 - MySQL에서 보는 주요 접근 방식 # type 값 의미 system 테이블에 행이 1건뿐인 경우 const PK/Unique = 상수값, 1건 접근 eq_ref PK/Unique를 이용한 조인 lookup ref 일반 인덱스 lookup range 인덱스 범위 탐색 index 인덱스 전체 스캔 ALL 테이블 전체 스캔 대체로 위에서 아래로 갈수록 비용이 커지는 경향이 있다.\n드리븐 테이블의 type 이 ALL 이면 인덱스 추가나 조인 순서 재검토를 먼저 의심하는 편이 좋다.\n7-3. 추가로 봐야 할 컬럼들 # 컬럼 의미 rows 옵티마이저가 예측한 탐색 건수 filtered WHERE 조건으로 걸러질 비율 추정치 Extra Using index, Using filesort, Using join buffer (hash join) 등 부가 정보 7-4. 통계 정보 갱신 # 옵티마이저 판단이 이상할 때는 통계가 오래됐을 가능성도 확인해야 한다.\n-- MySQL ANALYZE TABLE orders; ANALYZE TABLE customers; -- PostgreSQL ANALYZE orders; ANALYZE customers; -- Oracle EXEC DBMS_STATS.GATHER_TABLE_STATS(\u0026#39;schema_name\u0026#39;, \u0026#39;orders\u0026#39;); 8. 강제로 드라이빙 테이블 지정하는 방법 # 옵티마이저가 잘못된 판단을 내릴 때는 힌트나 planner 설정으로 영향을 줄 수 있다.\n다만 힌트는 어디까지나 예외적 수단 이고, 먼저 인덱스와 통계부터 바로잡는 편이 안전하다.\n8-1. MySQL # -- STRAIGHT_JOIN: FROM 절 순서를 고정 SELECT STRAIGHT_JOIN * FROM orders o JOIN customers c ON o.customer_id = c.id; -- JOIN_ORDER: 더 세밀한 조인 순서 지정 SELECT /*+ JOIN_ORDER(o, c) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; MySQL 8.4 공식 문서 기준으로:\nSTRAIGHT_JOIN 은 FROM 절의 순서를 따르게 한다 JOIN_FIXED_ORDER 는 STRAIGHT_JOIN 과 같은 효과다 JOIN_ORDER 는 특정 테이블들의 조인 순서를 지정한다 Hash Join 제어도 주의가 필요하다.\n-- MySQL 8.4에서는 HASH_JOIN/NO_HASH_JOIN 대신 BNL/NO_BNL 사용 SELECT /*+ NO_BNL(c) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; MySQL 8.4 문서는 HASH_JOIN / NO_HASH_JOIN 힌트가 효과가 없고, hash join 제어에는 BNL / NO_BNL 을 쓰라고 설명한다.\n8-2. PostgreSQL # -- 조인 재정렬을 강하게 줄이기 SET join_collapse_limit = 1; -- 특정 알고리즘 비선호 SET enable_hashjoin = off; SET enable_mergejoin = off; PostgreSQL 공식 문서 기준으로:\njoin_collapse_limit 은 planner가 JOIN 순서를 얼마나 재배치할지에 영향을 준다 enable_hashjoin, enable_mergejoin, enable_nestloop 는 해당 조인 방식을 비활성화라기보다 비선호 하게 만든다 특히 nested loop는 다른 대안이 없으면 완전히 배제되지는 않는다 8-3. Oracle # -- LEADING 힌트: 조인 순서 시작점 지정 SELECT /*+ LEADING(o) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; -- USE_NL: 지정한 테이블을 inner table로 nested loop 사용 SELECT /*+ LEADING(o) USE_NL(c) */ * FROM orders o JOIN customers c ON o.customer_id = c.id; Oracle 공식 문서는:\nLEADING 을 조인 순서를 잡는 multitable hint로 USE_NL 을 지정한 테이블을 inner table로 nested loops join 하도록 지시하는 hint로 설명한다 8-4. SQL Server # SELECT * FROM orders o INNER LOOP JOIN customers c ON o.customer_id = c.id OPTION (FORCE ORDER); SQL Server 공식 문서는:\nLOOP, HASH, MERGE 를 join hint로 FORCE ORDER 를 query hint로 제공한다 하나의 join hint를 주면 쿼리 전체 조인 순서에도 영향이 생길 수 있다고 설명한다 ⚠️ 힌트 사용 시 주의사항\n데이터 분포, 통계, 인덱스가 바뀌면 힌트가 오히려 성능을 악화시킬 수 있다.\n가능하면 인덱스 설계와 통계 정보 갱신으로 먼저 해결하자.\n9. 실무 체크리스트 # 9-1. 쿼리 작성 시 # ✅ WHERE 조건으로 결과가 많이 줄어드는 테이블을 드라이빙 후보로 본다 ✅ 드리븐 테이블 JOIN 컬럼(ON 절)에 인덱스가 있는지 확인한다 ✅ FK 컬럼은 \u0026#34;자동 인덱스가 있을 것\u0026#34;이라고 가정하지 말고 실제 인덱스를 확인한다 ✅ 3개 이상 JOIN 시 EXPLAIN으로 실행 순서를 확인한다 ✅ SELECT * 를 피하고 필요한 컬럼만 선택해 커버링 인덱스 가능성을 높인다 9-2. 성능 문제 의심 시 진단 절차 # Step 1. EXPLAIN 실행 -\u0026gt; JOIN 순서와 access type 확인 -\u0026gt; 드리븐 쪽이 ALL인지 먼저 본다 Step 2. rows / filtered 확인 -\u0026gt; 드라이빙 후보의 결과 건수가 비정상적으로 큰지 점검 Step 3. 통계 정보 갱신 -\u0026gt; ANALYZE 또는 DBMS_STATS 후 실행계획 재확인 Step 4. 인덱스 추가 -\u0026gt; 드리븐 JOIN 컬럼 인덱스 -\u0026gt; 필요 시 드라이빙 필터 컬럼 인덱스 Step 5. 그래도 느리면 -\u0026gt; DBMS별 힌트 또는 planner 설정으로 예외적으로 보정 9-3. 자주 하는 실수 \u0026amp; 올바른 해결책 # 실수 1: 드리븐 테이블 JOIN 컬럼에 인덱스 없음 # -- 나쁜 예 SELECT * FROM small_table s JOIN huge_table h ON h.non_indexed_col = s.id; -- 해결 CREATE INDEX idx_huge_non_indexed ON huge_table(non_indexed_col); 실수 2: 대형 테이블이 드라이빙으로 선택됨 # -- 나쁜 예 SELECT * FROM customers c JOIN orders o ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; -- 확인 후 조인 순서 보정 SELECT STRAIGHT_JOIN * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = \u0026#39;PENDING\u0026#39;; 다만 이런 경우도 힌트 전에 인덱스와 통계부터 확인 하는 것이 우선이다.\n실수 3: FK라서 인덱스가 자동으로 있을 거라고 가정 # ALTER TABLE orders ADD CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers(id); 이 선언만으로 모든 DBMS가 자동 인덱스를 만드는 것은 아니다.\nPostgreSQL, SQL Server는 referencing columns 인덱스를 자동 생성하지 않는다 MySQL InnoDB는 필요한 인덱스를 자동 생성할 수 있지만, 실제로 어떤 인덱스가 있는지는 확인해야 한다 -- 실제 스키마 확인 후 필요 시 명시적으로 생성 CREATE INDEX idx_orders_customer_id ON orders(customer_id); 실수 4: 3개 이상 테이블 JOIN 시 순서 방치 # SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id JOIN products p ON o.product_id = p.id JOIN categories cat ON p.category_id = cat.id WHERE o.created_at BETWEEN \u0026#39;2024-01-01\u0026#39; AND \u0026#39;2024-03-31\u0026#39;; 이런 쿼리는 반드시 실행계획을 보고:\n어떤 테이블이 먼저 읽히는지 중간 단계에서 row 수가 얼마나 줄어드는지 어느 지점에서 Full Scan이나 Hash/Merge가 붙는지 를 확인해야 한다.\n9-4. 인덱스 설계 가이드 # -- 드라이빙 후보용: WHERE 조건 컬럼 CREATE INDEX idx_orders_status_created ON orders(status, created_at); -- 드리븐 후보용: JOIN 컬럼 CREATE INDEX idx_orders_customer_id ON orders(customer_id); -- 커버링 인덱스 예시 CREATE INDEX idx_orders_covering ON orders(customer_id, status, created_at); MySQL의 경우 EXPLAIN 의 Extra 에 Using index 가 보이면 커버링 인덱스 활용 여부를 판단하는 데 도움이 된다.\n10. 한 줄 요약 # 특히 NLJ에서는, 드라이빙 테이블은 \u0026ldquo;필터 후 결과가 적은 것\u0026quot;으로, 드리븐 테이블은 \u0026ldquo;JOIN 컬럼에 인덱스가 있는 것\u0026quot;으로 잡혀야 JOIN이 빨라진다.\n작은 드라이빙 × 인덱스 있는 드리븐 = 빠른 JOIN 큰 드라이빙 × 인덱스 없는 드리븐 = 느린 JOIN 참고 - DB별 빠른 레퍼런스 # 목적 MySQL PostgreSQL Oracle 실행계획 확인 EXPLAIN, EXPLAIN ANALYZE, FORMAT=TREE EXPLAIN, EXPLAIN ANALYZE EXPLAIN PLAN FOR 통계 갱신 ANALYZE TABLE t ANALYZE t DBMS_STATS.GATHER_TABLE_STATS 조인 순서 영향 주기 STRAIGHT_JOIN, JOIN_ORDER, JOIN_FIXED_ORDER join_collapse_limit LEADING Hash Join 제어 BNL, NO_BNL enable_hashjoin USE_HASH 참고 자료 # MySQL 8.4 Reference Manual, SELECT Statement (STRAIGHT_JOIN)\nhttps://dev.mysql.com/doc/refman/8.4/en/select.html MySQL 8.4 Reference Manual, EXPLAIN Statement\nhttps://dev.mysql.com/doc/refman/8.4/en/explain.html MySQL 8.4 Reference Manual, Hash Join Optimization\nhttps://dev.mysql.com/doc/refman/8.4/en/hash-joins.html MySQL 8.4 Reference Manual, Optimizer Hints\nhttps://dev.mysql.com/doc/refman/8.4/en/optimizer-hints.html MySQL 8.4 Reference Manual, FOREIGN KEY Constraints\nhttps://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html PostgreSQL 18 Documentation, Using EXPLAIN\nhttps://www.postgresql.org/docs/18/using-explain.html PostgreSQL 18 Documentation, Query Planning\nhttps://www.postgresql.org/docs/18/runtime-config-query.html PostgreSQL 18 Documentation, Constraints\nhttps://www.postgresql.org/docs/18/ddl-constraints.html PostgreSQL 18 Documentation, CREATE TABLE\nhttps://www.postgresql.org/docs/18/sql-createtable.html Oracle Database, Influencing the Optimizer\nhttps://docs.oracle.com/en/database/oracle/oracle-database/21/tgsql/influencing-the-optimizer.html Oracle Database, DBMS_STATS\nhttps://docs.oracle.com/en/database/oracle/oracle-database/19/arpls/DBMS_STATS.html SQL Server, Join hints (Transact-SQL)\nhttps://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-join?view=sql-server-ver16 SQL Server, Query hints (Transact-SQL)\nhttps://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=azuresqldb-current SQL Server, Primary and foreign key constraints\nhttps://learn.microsoft.com/en-us/sql/relational-databases/tables/primary-and-foreign-key-constraints?view=sql-server-ver16 ","date":"2026년 3월 21일","externalUrl":null,"permalink":"/posts/rdbms-join-driving-driven-table/","section":"Posts","summary":"드라이빙 테이블과 드리븐 테이블 개념이 특히 Nested Loop Join에서 왜 중요한지, EXPLAIN으로 어떻게 읽고 DBMS별로 어디까지 제어할 수 있는지 정리합니다.","title":"[RDBMS] JOIN의 드라이빙 테이블과 드리븐 테이블 정리","type":"posts"},{"content":"","date":"2026년 3월 21일","externalUrl":null,"permalink":"/tags/join/","section":"Tags","summary":"","title":"JOIN","type":"tags"},{"content":"","date":"2026년 3월 21일","externalUrl":null,"permalink":"/tags/postgresql/","section":"Tags","summary":"","title":"PostgreSQL","type":"tags"},{"content":"","date":"2026년 3월 21일","externalUrl":null,"permalink":"/tags/query-optimization/","section":"Tags","summary":"","title":"Query Optimization","type":"tags"},{"content":"","date":"2026년 3월 21일","externalUrl":null,"permalink":"/tags/rdbms/","section":"Tags","summary":"","title":"RDBMS","type":"tags"},{"content":"","date":"2026년 3월 21일","externalUrl":null,"permalink":"/tags/sql/","section":"Tags","summary":"","title":"SQL","type":"tags"},{"content":"","date":"2026년 3월 15일","externalUrl":null,"permalink":"/tags/configuration/","section":"Tags","summary":"","title":"Configuration","type":"tags"},{"content":"","date":"2026년 3월 15일","externalUrl":null,"permalink":"/tags/entitymanager/","section":"Tags","summary":"","title":"EntityManager","type":"tags"},{"content":"","date":"2026년 3월 15일","externalUrl":null,"permalink":"/tags/jpa/","section":"Tags","summary":"","title":"JPA","type":"tags"},{"content":"","date":"2026년 3월 15일","externalUrl":null,"permalink":"/tags/jvm/","section":"Tags","summary":"","title":"JVM","type":"tags"},{"content":"","date":"2026년 3월 15일","externalUrl":null,"permalink":"/tags/singleton/","section":"Tags","summary":"","title":"Singleton","type":"tags"},{"content":"","date":"2026년 3월 15일","externalUrl":null,"permalink":"/tags/thread-safety/","section":"Tags","summary":"","title":"Thread Safety","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Spring 싱글톤 빈이 무상태여야 하는 이유와 지역 변수, Entity, @Configuration 동작을 함께 정리합니다.\n왜 Spring 싱글톤 빈은 무상태여야 할까? 스레드, Entity, @Configuration까지 # 💡 Spring의 싱글톤 빈, JVM 스레드 구조, DTO/Entity의 생명주기에 대한 의문과 정리\n1. 싱글톤 빈은 왜 무상태(Stateless)여야 하는가? # 의문 # 싱글톤은 무상태로 사용해야 한다. 멀티스레드로 동작하면 값이 변경될 여지가 있기 때문이다. 만약 값을 설정해놓고 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안 된다.\n답변 # Spring 컨테이너는 기본적으로 빈을 싱글톤으로 관리한다. 정확히는 보통 Spring 컨테이너 안에서 빈 정의(bean definition)마다 하나의 인스턴스를 공유한다는 뜻이고, 일반적인 Spring MVC 서버 애플리케이션에서는 여러 요청 스레드가 이 인스턴스를 함께 사용한다.\n@Service public class OrderService { // ❌ 절대 이렇게 하면 안 됨 - 인스턴스 변수 = 공유 상태 private int orderCount = 0; public void createOrder() { orderCount++; // 스레드 A와 B가 동시에 접근하면? -\u0026gt; 레이스 컨디션 } } 스레드 A가 orderCount를 읽고 1을 더하려는 사이에 스레드 B도 같은 값을 읽어버리면, 두 번 호출했는데 1만 증가하는 문제가 발생한다. 그래서 싱글톤 빈에는 상태를 변경할 수 있는 인스턴스 필드가 있으면 안 된다. 꼭 필요하다면 ThreadLocal 같은 더 좁은 저장 범위를 고려할 수 있다. 다만 스레드 풀 환경에서는 정리(remove)가 필요하다. @Service public class UserContextHolder { // ✅ 각 스레드마다 독립된 저장공간 private static final ThreadLocal\u0026lt;Long\u0026gt; currentUserId = new ThreadLocal\u0026lt;\u0026gt;(); public void set(Long userId) { currentUserId.set(userId); // 스레드 A가 set해도 스레드 B에는 영향 없음 } public Long get() { return currentUserId.get(); } public void clear() { currentUserId.remove(); } } 핵심 정리 # 싱글톤 빈 = 보통 컨테이너 안에 인스턴스가 하나 -\u0026gt; 여러 스레드가 공유 인스턴스 변수에 상태를 저장하면 레이스 컨디션 발생 해결책: 무상태로 설계하거나, 불가피한 경우 더 좁은 scope나 ThreadLocal 등을 고려 2. 일반적인 도메인 객체는 왜 이 규칙에 해당하지 않는가? # 의문 # 우리가 일반적으로 정의한 도메인은 왜 적용 안 됨…? 싱글톤으로 관리하는 것이 아니니까 그렇지.\n답변 # 대체로 맞다. 도메인 객체(Entity, VO 등)는 Spring 컨테이너가 관리하는 기본 싱글톤 빈이 아니다. 보통은 애플리케이션 코드가 new로 생성하거나, JPA가 영속성 컨텍스트 안에서 관리한다.\n// 도메인 객체 - Spring이 관리하지 않음 (싱글톤 아님) Order order = new Order(1L, \u0026#34;치킨\u0026#34;, 20000); // Spring 빈 - 싱글톤으로 관리됨 @Service public class OrderService { ... } 싱글톤으로 관리되는 것들은 Spring IoC 컨테이너에 의해 DI(의존성 주입)되는 것들이다. @Component, @Service, @Repository, @Controller 등의 어노테이션이 붙은 클래스가 여기에 해당한다.\n다만 \u0026ldquo;도메인 객체는 무조건 요청마다 생성되고 해당 스레드에서만 쓰인다\u0026quot;처럼 일반화하면 과하다. 실제 생명주기는 어디서 생성했고, 어디에 참조를 보관하느냐에 따라 달라진다. 그래도 일반적인 웹 요청 처리 코드에서는 Spring 싱글톤 빈처럼 공유되는 객체로 취급하지 않는 경우가 대부분이다.\n3. StringBuffer를 안 쓰는 이유 - 메서드 지역 변수의 스레드 안전성 # 의문 # StringBuffer와 같이 동기화가 적용된 자료구조를 실제로 안 쓴다는 것은… 보통 메서드 안에서 하나의 스레드가 처리하는 지역변수로써 사용되기 때문이구나?\n답변 # 정확하다. StringBuffer는 synchronized가 걸려 있어서 멀티스레드에서 안전하지만, Spring 환경에서 문자열 조작은 거의 항상 메서드 내 지역 변수로 이루어진다.\n@Service public class ReportService { public String generateReport(List\u0026lt;Item\u0026gt; items) { // ✅ 이 sb는 이 스레드의 실행 흐름에서만 사용됨 // 다른 스레드가 접근할 방법이 없음 StringBuilder sb = new StringBuilder(); for (Item item : items) { sb.append(item.getName()).append(\u0026#34;\\n\u0026#34;); } return sb.toString(); } } 지역 변수이므로 애초에 공유가 안 된다. 더 정확히는 StringBuilder 객체는 힙에 생성되더라도, 그 참조가 다른 스레드에 공유되지 않는다. 동기화할 이유 자체가 없으니, 불필요한 synchronized 오버헤드가 있는 StringBuffer보다 StringBuilder를 쓴다. 4. 싱글톤 객체인데 메서드 호출은 왜 안전한가 - JVM 스택의 비밀 # 의문 # 싱글톤 객체라 할지라도 하나만 생성될 뿐이지 실행될 때는 각자 JVM에서 각 스레드마다 할당된 스택에서 연산이 이루어지니까 그런 것이구나?\n답변 # 정확하다. 핵심은 JVM의 메모리 구조다. 다만 조금만 더 정확히 말하면, 객체 자체는 힙에 있고 메서드의 지역 변수와 매개변수 참조가 각 스레드의 스택 프레임에 놓인다.\n┌──────────────────────────────────────────────┐ │ Heap │ │ │ │ ┌────────────────┐ │ │ │ OrderService │ ← 싱글톤, 딱 하나 │ │ │ (인스턴스) │ │ │ └────────────────┘ │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │DTO-1 │ │DTO-2 │ │DTO-3 │ ← 요청마다 생성 │ │ └──────┘ └──────┘ └──────┘ │ └──────────────────────────────────────────────┘ ┌───────────────┐ ┌───────────────┐ │ Thread A │ │ Thread B │ │ Stack │ │ Stack │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │createOrder│ │ │ │createOrder│ │ │ │price=1000 │ │ │ │price=2000 │ │ │ │dto (ref) │ │ │ │dto (ref) │ │ │ └───────────┘ │ │ └───────────┘ │ └───────────────┘ └───────────────┘ OrderService는 힙(Heap) 에 하나만 존재한다. 그러나 createOrder() 메서드가 호출될 때, 그 안의 지역 변수와 매개변수는 각 스레드의 스택 프레임에 독립적으로 생성된다. 싱글톤 객체라 해도, 메서드 내 지역 변수는 스레드마다 격리되어 전혀 문제가 없다. @Service public class OrderService { public OrderResponse createOrder(OrderRequest request) { // price는 스레드 A의 스택 프레임에서만 사용 -\u0026gt; 안전 int price = request.getPrice(); // dto 객체는 힙에 생성되지만, 현재 스레드의 참조로만 다루면 안전 OrderDto dto = new OrderDto(price); return new OrderResponse(dto); } } 5. 웹 요청 스레드 = JVM 스레드인가? # 의문 # 이 웹 요청을 위한 스레드와 JVM의 이 스레드가 같은 녀석인가…? 같은 녀석이구나? 각자 콜 스택을 가진다.\n답변 # 전통적인 동기식 Spring MVC/Servlet 처리 기준으로는 맞다. Tomcat의 스레드 풀에 있는 스레드는 java.lang.Thread의 인스턴스이고, 각각 JVM이 관리하는 독립된 콜 스택을 가진다.\nHTTP 요청 도착 → Tomcat 스레드 풀에서 java.lang.Thread 하나 할당 → 이 Thread가 Filter → DispatcherServlet → Controller → Service → Repository 호출 → 모두 이 Thread의 콜 스택 위에서 실행 → 응답 반환 후 Thread는 풀에 반납 웹 서버(Tomcat)의 스레드와 JVM의 스레드는 별개의 존재가 아니라, Tomcat이 JVM의 스레드를 풀로 관리하며 요청에 할당하는 구조다.\n다만 이것은 동기식 요청 처리 설명으로 이해하는 것이 맞다.\nServlet async (Callable, DeferredResult, WebAsyncTask)를 사용하면 처리 스레드가 바뀔 수 있다. @Async나 reactive 흐름에서는 한 요청이 여러 스레드에 걸칠 수 있다. 그래서 ThreadLocal 기반 상태는 비동기 경계를 넘을 때 추가 고려가 필요하다. 6. Entity는 상태를 변경하는데 왜 괜찮은가? # 의문 # 엔티티들은 우째 처리되는 것인가? 값을 변경하기도 할 텐데… 왜…? 인스턴스 변수로 사용되지? 아… 그냥 싱글톤이 아니라서 그런 거야.\n답변 # 맞다. Entity는 싱글톤이 아니기 때문에 상태를 변경해도 괜찮다.\n@Entity public class Order { @Id @GeneratedValue private Long id; private String status; // 인스턴스 변수지만, 싱글톤이 아니므로 OK public void cancel() { this.status = \u0026#34;CANCELLED\u0026#34;; // 이 인스턴스의 상태 변경 } } 요청 A에서 조회한 Order 인스턴스와 요청 B에서 조회한 Order 인스턴스는 서로 다른 객체인 경우가 일반적이다. 같은 DB 행(row)을 조회하더라도, 일반적인 transaction-scoped persistence context에서는 각 트랜잭션이 별도의 영속성 컨텍스트를 가지므로 각자 다른 Entity 인스턴스를 다루게 된다. 동시에 같은 행을 수정하는 동시성 문제는 Entity의 인스턴스 공유 문제가 아니라, DB 레벨의 락/트랜잭션 격리 문제다. 즉, Entity의 상태 변경 자체는 자연스럽다. 주의할 점은 EntityManager와 영속성 컨텍스트의 thread-safety를 전제로 공유하면 안 된다는 것이다.\n7. 왜 Controller, Service, Repository는 싱글톤이고, DTO와 Entity는 아닌가? # 의문 # 왜 리포지토리와 컨트롤러 서비스 이런 녀석들은 싱글톤으로 관리하고… DTO와 같은 녀석이나 도메인 객체는…? 이 기준은 뭐지…?\n답변 # 실무적으로 자주 쓰는 기준은 \u0026ldquo;이 객체가 행위를 제공하는가, 데이터를 담는가\u0026rdquo; 다. 이 구분은 학습용으로 꽤 유용하다. 다만 더 엄밀한 Spring 기준은 bean으로 등록되었는가, 그리고 어떤 scope를 가졌는가다.\n싱글톤으로 관리하는 것: 행위(로직) 중심 객체 # @Controller // 싱글톤 - 요청을 라우팅하는 \u0026#34;행위\u0026#34; @Service // 싱글톤 - 비즈니스 로직을 수행하는 \u0026#34;행위\u0026#34; @Repository // 싱글톤 - 데이터 접근을 수행하는 \u0026#34;행위\u0026#34; @Component // 싱글톤 - 기타 인프라 로직 이들의 공통점은 보통 상태 없이 행위만 제공한다는 것이다. 요청이 100개 들어와도 OrderService의 createOrder() 로직 자체는 동일하다. 그래서 하나만 만들어두고 공유해도 된다.\n싱글톤으로 관리하면 안 되는 것: 데이터(상태) 중심 객체 # // DTO - 요청마다 다른 데이터를 담아야 함 OrderRequest request1 = new OrderRequest(\u0026#34;치킨\u0026#34;, 20000); // 스레드 A의 요청 OrderRequest request2 = new OrderRequest(\u0026#34;피자\u0026#34;, 30000); // 스레드 B의 요청 // Entity - 각각 다른 행(row)을 표현 Order order1 = new Order(1L, \u0026#34;치킨\u0026#34;, 20000); Order order2 = new Order(2L, \u0026#34;피자\u0026#34;, 30000); 만약 OrderRequest를 싱글톤으로 만들면?\n// ❌ 절대로 이러면 안 됨 @Component // 싱글톤으로 등록되면... public class OrderRequest { private String itemName; private int price; // 스레드 A: setItemName(\u0026#34;치킨\u0026#34;) // 스레드 B: setItemName(\u0026#34;피자\u0026#34;) // 스레드 A: getItemName() -\u0026gt; \u0026#34;피자\u0026#34; ??? -\u0026gt; 치킨 주문했는데 피자가 나옴 } 비교 정리표 # 구분 싱글톤 빈 매번 생성하는 객체 본질 행위를 제공 데이터를 담음 상태 무상태 (stateless) 상태가 존재 (stateful) 예시 Controller, Service, Repository DTO, Entity, VO 생명주기 앱 시작 ~ 종료 요청 처리 중 또는 필요 시점에만 존재 생성 비용 한 번만 만들어서 절약 가볍고, 요청마다 달라야 하므로 매번 생성 8. 매번 생성하면 메모리 낭비 아닌가? # 의문 # 싱글톤으로 관리되지 않는 것들은 계속해서 생성될 여지가 있다는 것인데… DTO나 도메인 객체는…? 이 정도는 허락하자는 것인가…? 이 기준은 뭐지…?\n답변 # DTO나 Entity는 대부분 수명이 매우 짧다. JVM GC는 이런 단명(short-lived) 객체를 처리하는 데 최적화되어 있기 때문에, 요청마다 DTO 몇 개 만드는 건 성능에 거의 영향이 없다.\npublic OrderResponse createOrder(OrderRequest request) { Order order = Order.create(request); // 생성 orderRepository.save(order); // 저장 return OrderResponse.from(order); // 응답 DTO 생성 } // 메서드 종료 후 더 이상 참조되지 않으면 GC 대상 이 객체들은 대개 짧게 사용되고, JVM의 Young Generation GC에 의해 빠르게 수거된다. 반면 Service, Repository 같은 빈은 내부에 다른 빈에 대한 참조, 커넥션 풀, 설정 값 등을 들고 있어서 초기화 비용이 훨씬 크다. 이런 걸 요청마다 만들면 진짜 낭비다. 불변 객체로 만들면 더 좋다 # // ✅ 불변 DTO - record 사용 (Java 16+) public record OrderRequest(String itemName, int price) {} // ✅ 불변 DTO - 클래스 사용 public class OrderResponse { private final Long orderId; private final String status; public OrderResponse(Long orderId, String status) { this.orderId = orderId; this.status = status; } // getter만 제공, setter 없음 } 불변 객체는 생성 이후 상태가 변경되지 않으므로, 설사 공유되더라도 안전하다. DTO를 가능한 한 불변으로 만드는 것이 좋은 습관이다.\n9. @Configuration과 CGLIB 프록시 - 싱글톤 보장의 비밀 # 의문 # AppConfig는 CGLIB을 이용한 프록시 객체네? @Bean만 사용하면 스프링 빈으로 등록은 되지만 싱글톤을 보장하지는 않음.\n답변 # @Configuration public class AppConfig { @Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); } @Bean public MemberService memberService() { return new MemberServiceImpl(memberRepository()); // memberRepository() 호출 } @Bean public OrderService orderService() { return new OrderServiceImpl(memberRepository()); // memberRepository() 또 호출 } } 자바 코드만 보면 memberRepository()가 두 번 호출되니까 인스턴스가 2개 생길 것 같지만, Spring은 기본적으로 @Configuration(proxyBeanMethods = true) 클래스를 CGLIB으로 프록시한다.\nCGLIB 프록시의 동작 원리 # memberRepository() 호출 시: if (스프링 컨테이너에 memberRepository 빈이 이미 있으면) return 기존 빈; ← 싱글톤 보장 else 새로 생성 → 컨테이너에 등록 → return; memberService()와 orderService()가 받는 memberRepository는 동일한 인스턴스다. Spring이 AppConfig 클래스를 그대로 사용하지 않고, CGLIB 라이브러리로 AppConfig를 상속한 프록시 클래스를 동적으로 생성한다. 이 프록시가 @Bean 메서드를 가로채서, 이미 컨테이너에 등록된 빈이면 기존 인스턴스를 반환한다. @Configuration 없이 @Bean만 쓰면? # // ⚠️ @Configuration 없이 @Bean만 사용 @Component // @Configuration이 아님 public class AppConfig { @Bean public MemberRepository memberRepository() { return new MemoryMemberRepository(); } @Bean public MemberService memberService() { // CGLIB 프록시가 없으므로 순수 자바 메서드 호출 // -\u0026gt; 여기서 memberRepository()를 직접 호출하면 새 객체가 생성될 수 있음 return new MemberServiceImpl(memberRepository()); } } 이 경우에도 각 @Bean이 등록되는 bean 자체의 기본 scope는 여전히 singleton이다. 다만 CGLIB 프록시가 적용되지 않아서, 같은 설정 클래스 안에서 memberRepository()를 직접 호출하면 컨테이너를 우회하는 일반 메서드 호출이 된다. 즉, 엄밀히 말하면 \u0026ldquo;싱글톤 기본값 자체가 사라진다\u0026quot;기보다, @Bean 메서드 간 직접 호출에 대한 컨테이너 보장이 깨지는 것이다. 최종 정리: 한눈에 보는 핵심 요약 # Spring 컨테이너 (Heap) ┌─────────────────────────────┐ 싱글톤 빈 │ Controller ─── Service ─── Repository │ (행위 중심, │ (무상태, 하나만 존재, 모든 스레드 공유) │ @Component 계열) │ │ └─────────────────────────────┘ ↑ 공유 사용 ↑ ┌───────┴───────────┴───────┐ │ │ ┌───────────────┐ ┌───────────────┐ │ Thread A │ │ Thread B │ │ (요청 #1) │ │ (요청 #2) │ │ │ │ │ │ 지역변수: │ │ 지역변수: │ │ - DTO-A │ │ - DTO-B │ │ - Entity-A │ │ - Entity-B │ │ (이 스레드만 │ │ (이 스레드만 │ │ 사용, GC 대상)│ │ 사용, GC 대상)│ └───────────────┘ └───────────────┘ 데이터 객체 데이터 객체 (상태 중심, (상태 중심, 매번 new 생성) 매번 new 생성) 핵심 한 문장 # Spring이 싱글톤으로 관리하는 기준은 보통 \u0026ldquo;공유해도 되는 로직 빈인가\u0026quot;에 가깝고, 스레드 안전성의 핵심은 결국 공유 가변 상태를 두지 않는 것이다.\n의문 -\u0026gt; 결론 흐름 정리 # 싱글톤은 왜 무상태? -\u0026gt; 모든 스레드가 공유하므로 상태를 가지면 레이스 컨디션 발생 도메인 객체는 왜 해당 안 됨? -\u0026gt; 기본 Spring 싱글톤 빈이 아니기 때문 StringBuffer를 왜 안 씀? -\u0026gt; 메서드 내 지역 변수로 사용되므로 애초에 공유가 안 됨 싱글톤인데 메서드는 왜 안전? -\u0026gt; 메서드 실행은 각 스레드의 스택에서 독립적으로 이루어짐 웹 스레드 = JVM 스레드? -\u0026gt; 동기식 MVC에서는 대체로 동일하다. 다만 async/reactive까지 일반화하면 안 됨 Entity는 상태 변경하는데 괜찮음? -\u0026gt; 싱글톤이 아니니까 괜찮고, DB 동시성은 트랜잭션과 락으로 해결 싱글톤의 기준? -\u0026gt; 실무적으로는 행위 객체(Service 등) vs 데이터 객체(DTO, Entity) 구분이 유용하고, 엄밀히는 bean 등록 여부와 scope가 기준 매번 생성하면 낭비? -\u0026gt; 단명 객체는 Young Gen GC에 최적화. 불변으로 만들면 더 좋음 @Configuration의 CGLIB? -\u0026gt; @Bean 메서드 간 직접 호출에서도 컨테이너 semantics를 유지하게 해 줌 참고 자료 # Spring Framework Reference - Bean Scopes: https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html Spring Framework Reference - Basic Concepts: @Bean and @Configuration: https://docs.spring.io/spring-framework/reference/core/beans/java/basic-concepts.html Spring Framework Reference - @Configuration Classes: https://docs.spring.io/spring-framework/reference/core/beans/java/configuration-annotation.html Spring Framework Reference - Asynchronous Requests: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html The Java Virtual Machine Specification, Chapter 2: https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-2.html Java API - ThreadLocal: https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/lang/ThreadLocal.html Java API - StringBuffer: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuffer.html Java API - StringBuilder: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuilder.html Jakarta Persistence API - EntityManager: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/entitymanager Jakarta Persistence API - PersistenceContextType: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/persistencecontexttype ","date":"2026년 3월 15일","externalUrl":null,"permalink":"/posts/spring-singleton-thread-safety/","section":"Posts","summary":"Spring 싱글톤 빈의 무상태 설계, 지역 변수와 스레드 안전성, Entity와 persistence context, @Configuration의 CGLIB 동작을 한 번에 정리합니다.","title":"왜 Spring 싱글톤 빈은 무상태여야 할까? 스레드, Entity, @Configuration까지","type":"posts"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. 손님과 바리스타 예시를 통해 메시지 패싱, 역할과 책임, 메서드 이름과 결합도의 관계를 정리합니다.\n객체지향 설계: 메시지 패싱과 역할·책임 # 손님(Customer)과 바리스타(Barista)의 관계를 통해 객체지향 설계 원칙을 이해해 본다.\nQ1. 바리스타가 손님에게 주문을 받는다는 형태로 표현(메시지)이 가능하지 않을까? # 우아한형제들 기술 블로그 객체지향과 메시지에서는 customer.orderCoffee(barista) 형태로,\n손님이 바리스타에게 메시지를 보내는 방향으로 설계되어 있다.\n그런데 현실에서 바리스타가 손님의 주문을 받는 것도 사실인데,\n이를 뒤집어서 barista.takeOrderFrom(customer) 처럼\n바리스타가 손님으로부터 주문을 받아오는 방향으로 설계하면 안 되는 걸까?\n두 방향 모두 같은 상황을 표현하는 것 같은데, 어떤 차이가 있는가?\n핵심적인 질문이다. 이 질문은 단순히 코드 문법을 넘어, 객체의 역할, 책임, 협력을 어떻게 나눌 것인지와 연결된다.\n결론부터 말하면, 이 예시에서는 \u0026lsquo;손님이 바리스타에게 커피를 주문한다\u0026rsquo;는 방향이 더 자연스럽다.\nbarista.takeOrderFrom(customer) 같은 형태가 문법적으로 불가능한 것은 아니지만, 보통은 결합도와 책임 분리를 불리하게 만들기 쉽다.\n왜 \u0026lsquo;손님\u0026rsquo;이 메시지를 보내는 주체인가? # 가장 중요한 질문은 \u0026lsquo;누가 요청하고, 누가 그 요청을 처리하는가\u0026rsquo; 이다.\n현실 세계의 카페를 떠올려보자.\n요청의 시작: 커피가 필요한 쪽은 손님이다. 손님은 \u0026lsquo;커피를 만들어 달라\u0026rsquo;는 요청(Request) 을 보낸다. 요청의 처리: 바리스타는 요청을 받아 커피를 준비하는 역할을 맡는다. GNU Smalltalk 문서는 객체를 바깥에서 보면 상태와 연산만 드러나는 블랙박스로 설명하고, 어떤 일을 시키려면 그 객체에 메시지를 보내야 한다고 설명한다. Java 문서도 객체는 메서드를 통해 외부와 상호작용한다고 설명한다.\n이 기준으로 보면, 이 시나리오는 커피가 필요한 손님이 바리스타에게 요청을 보내는 협력으로 모델링하는 편이 읽기 쉽다.\n손님 객체: 주문한다(order)라는 메시지를 보낼 책임을 가진다. 바리스타 객체: 커피를 만든다(makeCoffee)라는 메시지를 받아 처리할 책임을 가진다. 코드로 표현하면, 손님 객체의 메서드 내부에서 barista.makeCoffee(...)를 호출하는 형태가 된다.\npublic class Customer { public void orderCoffee(Barista barista, String coffeeName) { barista.makeCoffee(coffeeName); } } 이 구조가 자연스럽게 느껴지는 이유는 요청을 시작하는 배경이 손님의 필요에 있기 때문이다.\n만약 \u0026lsquo;바리스타가 주문을 받는다면\u0026rsquo; 무엇이 문제일까? # barista.takeOrderFrom(customer) 와 같은 코드를 상상해볼 수 있다. 이 코드가 늘 틀렸다고 말할 수는 없지만, 보통은 다음과 같은 문제가 생기기 쉽다.\n1. 구체 타입 의존과 높은 결합도 # 바리스타는 원래 손님이 누구인지 몰라도 된다. 그저 \u0026lsquo;커피 주문\u0026rsquo;이라는 요청만 받아 처리하면 된다. 하지만 takeOrderFrom(customer) 형태가 되면, 바리스타가 손님이라는 구체 타입을 알아야 한다. 더 나아가, 손님이 무엇을 원하는지(customer.getWants()) 직접 물어봐야 할 수도 있다. 이렇게 되면 바리스타는 손님이라는 특정 타입에 의존하게 된다. 나중에 손님이 아니라 키오스크나 모바일 앱으로 주문을 받는다면\n→ takeOrderFrom(kiosk), takeOrderFrom(mobileApp) 같은 형태로 퍼질 가능성이 커진다.\n→ 변경에 취약한 구조가 되기 쉽다. 2. 불필요한 책임의 증가 # 바리스타의 핵심 책임은 \u0026lsquo;커피를 준비하는 것\u0026rsquo; 이다. 그런데 주문 정보를 능동적으로 수집하는 책임까지 함께 지면, 바리스타의 역할 경계가 흐려질 수 있다. 이런 구조는 SRP 관점에서도 책임 분리를 어렵게 만들 수 있다. 3. 협력 흐름이 덜 자연스럽다 # 출발 요구는 손님 쪽에 있는데, 코드의 중심은 바리스타 객체가 된다. 그러면 요청자와 처리자의 구분이 흐려져, 이 예시의 협력 구조를 읽기 어렵게 만들 수 있다. 두 관점 비교 # 항목 👍 손님이 주문한다 (Customer → Barista) 👎 바리스타가 주문받는다 (Barista → Customer) 메시지 주체 손님(Client): 요청하는 쪽 바리스타가 주문자 정보를 읽어 와야 함 결합도 낮음: 바리스타는 손님을 직접 몰라도 협력 가능 높아지기 쉬움: 바리스타가 손님 타입을 알아야 할 수 있음 유연성/확장성 높음: 키오스크가 주문해도 요청 형식을 유지하기 쉬움 낮아지기 쉬움: 주문 주체가 늘수록 바리스타 코드가 흔들릴 수 있음 책임 분리 명확함: 손님(주문), 바리스타(제조) 모호해지기 쉬움: 바리스타가 주문과 제조 책임을 함께 가질 수 있음 자연스러움 현실의 요청 방향과 협력 구조가 잘 맞음 요청자와 처리자의 역할이 덜 선명함 정리하면, 객체지향 설계의 핵심은 현실 세계의 역할과 책임을 코드로 기계적으로 복사하는 것이 아니라,\n누가 무엇을 요청하고 누가 그것을 처리하는가를 자연스럽게 표현하는 것이다.\n이 예시에서는 서비스를 요청하는 손님이 메시지를 보내고,\n서비스를 제공하는 바리스타가 그 메시지를 수신해 처리하는 구조가 더 읽기 쉽고 변경에도 유리하다.\nQ2. 왜 손님이 바리스타에게 \u0026ldquo;커피를 제조하라\u0026quot;는 메시지를 보내는 것인가? # 앞서 손님이 바리스타에게 메시지를 보내는 주체라고 했다.\n그런데 손님도 마찬가지로, 외부에서 누군가가 손님에게\n\u0026ldquo;커피를 주문해라\u0026quot;라고 메시지를 보내서 시작되는 게 아니라\nmain이나 다른 실행 주체가 직접 손님의 메서드를 호출해서 시작된다.\n그렇다면 굳이 손님을 거칠 필요 없이, main이 바리스타에게\nbarista.makeCoffee()를 직접 호출하면 안 되는 건가?\n왜 손님 객체가 바리스타에게 makeCoffee()를 보내는 흐름으로 보는 것이 자연스럽고,\n또 그 메시지 이름이 왜 \u0026ldquo;제조하라(makeCoffee)\u0026ldquo;인가?\n이 질문은 \u0026lsquo;객체에게 어떤 메시지를 보낼 것인가\u0026rsquo; 를 고민하는 과정과 직결된다.\n결론부터 말하면, 이 예시에서는 손님이 바리스타에게 makeCoffee()를 요청하는 흐름으로 보는 것이 자연스럽다.\n다만 이것을 \u0026ldquo;손님이 명령한다\u0026quot;는 뜻으로 이해하면 어색하다.\n객체지향에서의 메시지 전송은 보통 요청(Request) 으로 이해하는 편이 더 정확하다.\n왜 main이 barista.makeCoffee()를 직접 호출하면 안 되는가? # 엄밀히 말하면, main이 barista.makeCoffee()를 직접 호출하는 코드 자체가 금지되는 것은 아니다.\n다만 이 예시를 객체 협력 관점에서 모델링할 때는, main이 두 객체의 관계를 모두 알고 조율하는 것보다 손님 객체가 자신의 책임 안에서 바리스타에게 요청을 보내는 구조가 더 자연스럽다.\n그 이유는 다음과 같다.\nmain이 직접 조율하면, \u0026ldquo;손님은 커피가 필요하고 그 요청은 바리스타에게 가야 한다\u0026quot;는 도메인 규칙을 main이 대신 떠안게 된다. 그러면 Customer가 가진 \u0026ldquo;주문한다\u0026rdquo; 는 책임이 약해질 수 있다. 협력이 복잡해질수록 외부 조율자가 모든 객체 관계를 알고 있어야 해서 변경 지점이 커지기 쉽다. 여기서 중요한 점은 실행의 첫 호출자와 도메인 모델에서 요청을 시작하는 객체를 구분하는 것이다.\n실제 런타임에서는 main, 컨트롤러, 서비스, 테스트 코드가 첫 호출자일 수 있다. 하지만 이 시나리오의 도메인 모델에서는 손님이 커피를 원해서 바리스타에게 요청한다고 해석하는 편이 자연스럽다. 핵심: 메시지의 이름은 메시지를 \u0026lsquo;받는\u0026rsquo; 객체의 책임을 기준으로 결정된다 # 손님이 바리스타에게 메시지를 보낼 때, 그 메시지의 이름(makeCoffee)은\n손님이 마음대로 정하는 것이 아니다.\n그것은 바리스타가 외부에 공개해 둔 연산 중 하나를 호출하는 것에 가깝다.\nOracle Java 문서는 객체의 메서드가 바깥 세계와 상호작용하는 주된 수단이라고 설명하고, 인터페이스는 클래스와 바깥 세계 사이의 계약이라고 설명한다.\n즉, 메시지 이름은 보통 받는 객체가 어떤 책임을 외부에 약속했는가를 기준으로 잡는 편이 자연스럽다.\n바리스타 객체의 입장에서 자신의 핵심 책임을 생각해보자 # 바리스타의 가장 본질적인 책임은 \u0026lsquo;커피를 준비하는 것\u0026rsquo; 이다. 따라서 바리스타는 그 책임을 잘 드러내는 이름으로 기능을 외부에 공개하는 편이 자연스럽다. makeCoffee() createCoffee() provideCoffee() 반대로 takeOrder()는 바리스타가 처리 과정 중 일부로 가질 수는 있어도,\n이 예시에서 바리스타의 핵심 결과 책임을 가장 잘 드러내는 이름이라고 보기는 어렵다.\n손님은 \u0026lsquo;주문 접수\u0026rsquo; 자체를 원하는 것이 아니라, 그 결과인 만들어진 커피를 원한다.\n그래서 손님은 바리스타의 핵심 기능인 makeCoffee()를 요청(Request) 한다고 보는 편이 자연스럽다.\n관점의 차이: \u0026lsquo;주문\u0026rsquo;과 \u0026lsquo;제조\u0026rsquo; # 관점 설명 코드 표현 손님의 관점 손님은 \u0026lsquo;주문\u0026rsquo;이라는 행동을 한다. 목적은 커피를 얻는 것이다. customer.orderCoffee(barista, menuItem) 바리스타의 관점 바리스타는 외부의 요청을 받아 \u0026lsquo;커피를 제조\u0026rsquo;하는 책임을 수행한다. public Coffee makeCoffee(MenuItem item) 즉, 손님의 orderCoffee(...) 메서드 내부에서 바리스타의 makeCoffee(...)를 호출하는 흐름이 된다.\npublic class Customer { public void orderCoffee(Barista barista, String coffeeName) { System.out.println(\u0026#34;손님: \u0026#39;\u0026#34; + coffeeName + \u0026#34;\u0026#39; 한 잔 주세요.\u0026#34;); barista.makeCoffee(coffeeName); } } public class Barista { public Coffee makeCoffee(String coffeeName) { System.out.println(\u0026#34;바리스타: 네, \u0026#39;\u0026#34; + coffeeName + \u0026#34;\u0026#39; 만들겠습니다.\u0026#34;); return new Coffee(coffeeName); } } \u0026ldquo;묻지 말고 시켜라 (Tell, Don\u0026rsquo;t Ask)\u0026rdquo; 관점 # 이 개념은 객체지향에서 자주 언급되는 \u0026ldquo;Tell, Don\u0026rsquo;t Ask\u0026rdquo; 가이드와도 연결해 볼 수 있다.\n❌ 상태를 묻고 외부에서 판단하는 방식 # if (barista.isAvailable()) { barista.takeThisOrder(order); } 호출자가 바리스타의 상태를 더 많이 알아야 한다. 다음 행동을 호출자 쪽에서 조합하게 되므로, 판단 책임이 바깥으로 새기 쉽다. 물론 isAvailable() 같은 메서드가 항상 잘못된 것은 아니다.\n다만 호출자가 수신 객체의 내부 사정을 많이 알아야 다음 행동을 결정할 수 있다면, 그만큼 결합도는 커지기 쉽다.\n✅ 원하는 바를 직접 요청하는 방식 # barista.makeCoffee(order); 호출자는 원하는 결과를 요청한다. 구체적인 처리 과정과 내부 판단은 바리스타가 책임진다. 이 방식이 항상 정답이라고 할 수는 없지만, 이 예시에서는 캡슐화와 책임 분리에 더 잘 맞는다.\n최종 결론 요약 # 메시지(메서드)의 이름은 보통 \u0026lsquo;받는 객체\u0026rsquo;의 책임을 가장 잘 나타내는 쪽으로 정해진다. 이 예시에서 바리스타의 핵심 책임은 \u0026lsquo;제조\u0026rsquo;이므로, 공개 인터페이스로 makeCoffee()를 두는 해석이 자연스럽다. 손님은 바리스타의 공개된 기능인 makeCoffee()를 요청하는 것이다.\n이는 명령이라기보다, 객체 간 협력에서의 요청으로 이해하는 편이 정확하다. barista.takeOrderFrom(customer) 같은 구조는 구체 타입 의존과 책임 혼합을 키우기 쉬워서, 보통은 손님이 요청을 보내는 방향이 더 읽기 쉽고 변경에도 유리하다. 이처럼 메시지 하나를 정할 때도 각 객체의 역할과 책임을 함께 고민하는 것이\n좋은 객체지향 설계를 만드는 데 중요하다.\n참고 자료 # 우아한형제들 기술 블로그 — 객체지향과 메시지: https://techblog.woowahan.com/2502/ GNU Smalltalk User’s Guide — Overview: https://www.gnu.org/software/smalltalk/manual/html_node/Overview.html Oracle Java Tutorials — What Is an Object?: https://docs.oracle.com/javase/tutorial/java/concepts/object.html Oracle Java Tutorials — What Is an Interface?: https://docs.oracle.com/javase/tutorial/java/concepts/interface.html Oracle Java Tutorials — Defining an Interface: https://docs.oracle.com/javase/tutorial/java/IandI/interfaceDef.html ","date":"2026년 3월 9일","externalUrl":null,"permalink":"/posts/oop-message-passing/","section":"Posts","summary":"손님과 바리스타 예시를 통해 누가 요청을 시작하는지, 왜 메서드 이름이 수신 객체의 책임을 기준으로 잡히는지, 왜 구체 타입 의존이 결합을 키우기 쉬운지 정리합니다.","title":"[OOP] 객체지향 설계: 메시지 패싱과 역할·책임","type":"posts"},{"content":"","date":"2026년 3월 9일","externalUrl":null,"permalink":"/tags/design/","section":"Tags","summary":"","title":"Design","type":"tags"},{"content":"","date":"2026년 3월 9일","externalUrl":null,"permalink":"/tags/encapsulation/","section":"Tags","summary":"","title":"Encapsulation","type":"tags"},{"content":"","date":"2026년 3월 9일","externalUrl":null,"permalink":"/tags/message-passing/","section":"Tags","summary":"","title":"Message Passing","type":"tags"},{"content":"","date":"2026년 3월 9일","externalUrl":null,"permalink":"/tags/object-oriented-programming/","section":"Tags","summary":"","title":"Object-Oriented Programming","type":"tags"},{"content":"","date":"2026년 3월 9일","externalUrl":null,"permalink":"/tags/oop/","section":"Tags","summary":"","title":"OOP","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/memory-management/","section":"Tags","summary":"","title":"Memory Management","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/mmu/","section":"Tags","summary":"","title":"MMU","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/os/","section":"Tags","summary":"","title":"OS","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/page-fault/","section":"Tags","summary":"","title":"Page Fault","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/page-table/","section":"Tags","summary":"","title":"Page Table","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/tlb/","section":"Tags","summary":"","title":"TLB","type":"tags"},{"content":"","date":"2026년 3월 3일","externalUrl":null,"permalink":"/tags/virtual-memory/","section":"Tags","summary":"","title":"Virtual Memory","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. 가상 주소가 물리 주소로 변환되는 흐름을 기준으로 MMU, TLB, 페이지 테이블, page fault를 정리합니다.\n가상 주소는 어떻게 물리 주소가 될까? # TLB / MMU / OS 메모리 관리자 (MMS)\n목차 # MMU와 TLB의 역할과 차이 페이징 시스템과 페이지 테이블의 문제 TLB 상세 설명 Context Switch와 TLB MMU vs TLB 비교 정리 OS 메모리 관리자 (MMS) 1. MMU와 TLB의 역할과 차이 # 큰 그림 — 하드웨어 구조 # CPU ┌─────────────────────────────────────┐ │ ALU / Register / Control Unit │ │ │ │ ┌─────────────────────────────┐ │ │ │ MMU │ │ │ │ ┌───────────────────────┐ │ │ │ │ │ TLB (번역 캐시) │ │ │ │ │ └───────────────────────┘ │ │ │ │ 페이지 테이블 워크 로직 │ │ │ └─────────────────────────────┘ │ │ │ │ ┌───────────────┐ │ │ │ L1/L2 Cache │ │ │ └───────────────┘ │ └──────────────┬──────────────────────┘ │ 물리 주소 ▼ [ RAM (DRAM) ] MMU는 CPU 쪽의 하드웨어 주소 변환 유닛이다.\n설명 편의상 \u0026ldquo;CPU와 메모리 사이\u0026quot;라고 말하곤 하지만, 실제로는 CPU에 통합된 주소 변환 하드웨어로 이해하는 편이 가깝다.\nTLB는 그 안에서 최근 번역 결과를 캐싱하는 작은 하드웨어 캐시다.\n2. 페이징 시스템과 페이지 테이블의 문제 # 페이지 크기를 작게 하면? # 비연속적인 메모리 할당 정책에서 페이징 시스템을 사용할 때,\n페이지 크기를 작게 하면 내부 단편화는 줄어들지만 페이지 테이블이 거대해지는 문제가 발생한다.\n계산 예시 # 아래는 32bit 주소 공간, 4KB 페이지, 엔트리 4byte, 단일 레벨 페이지 테이블을 단순 가정한 계산이다.\n32bit 주소 공간, 4KB 페이지 기준 → 페이지 수 = 2^32 / 2^12 = 2^20 = 약 100만 개 엔트리 → 엔트리당 4byte → 페이지 테이블 하나 = 4MB → 프로세스 100개 = 400MB가 페이지 테이블만으로 소비 현대 시스템은 주소 공간이 더 크고, 실제로는 사용하지 않는 구간도 많다.\n그래서 계층형(다단계) 페이지 테이블을 사용해 페이지 테이블 메모리 낭비를 줄인다.\n성능 문제 — page table walk 비용 # 페이지 테이블 자체는 메모리에 있다.\n따라서 TLB miss가 나면 MMU는 page table walk를 수행해 필요한 엔트리를 읽어 와야 한다.\n단일 레벨 테이블만 가정하면 \u0026ldquo;페이지 테이블 1번 + 실제 데이터 1번\u0026quot;처럼 설명할 수 있다. 하지만 현대 CPU는 다단계 페이지 테이블을 쓰므로, TLB miss 시 추가 메모리 참조가 더 필요할 수 있다. 실제 비용은 CPU cache, page walk cache 유무에 따라 달라진다. 즉, 핵심은 \u0026ldquo;메모리 접근이 항상 정확히 2배\u0026rdquo; 가 아니라\n\u0026ldquo;TLB miss가 비싸고, 그래서 번역 캐시가 중요하다\u0026rdquo; 는 점이다.\n3. TLB (Translation Lookaside Buffer) # 역할 # 최근에 사용된 가상 주소 → 물리 주소 변환 결과를 캐싱하는 하드웨어 캐시.\n동작 흐름 # CPU가 가상 주소 생성 │ ▼ ┌───────────┐ │ TLB 조회 │──── TLB Hit ──→ 물리 주소 즉시 반환 └───────────┘ │ TLB Miss ▼ MMU가 Page Table Walk (메모리에 있는 페이지 테이블 순회) │ ▼ 물리 주소 획득 → TLB에 저장 → 반환 TLB 특징 # 항목 내용 저장 대상 최근에 사용된 가상 주소 → 물리 주소 변환 결과 위치 MMU가 활용하는 작은 하드웨어 번역 캐시 크기/구조 구현 의존. 일반적으로 매우 작고 빠름 효과 page table walk를 줄여 주소 변환 지연을 낮춤 구현에 따라 instruction TLB / data TLB 가 분리되거나,\n여러 단계의 TLB가 존재할 수도 있다.\nTLB가 효과적인 이유 # 시간적 지역성 + 공간적 지역성 덕분이다.\n같은 페이지나 인접 페이지를 반복해서 접근하는 패턴이 많기 때문에,\n아주 작은 번역 캐시만으로도 page table walk를 크게 줄일 수 있다.\n4. Context Switch시 TLB는? # 프로세스나 주소 공간이 바뀌면, 기존 TLB 엔트리를 그대로 쓰면 안 되는 상황이 생긴다.\n다만 이때 항상 전체 TLB를 flush해야 하는 것은 아니다.\n처리 방식 두 가지 # 방식 설명 단점 TLB Flush 주소 공간 전환 시 관련 엔트리를 비움 전환 직후 TLB miss가 늘어남 ASID (Address Space ID) TLB 엔트리에 주소 공간 식별자를 태깅 식별자 수와 관리 정책 제약이 있음 즉, 주소 공간 전환 = 무조건 전체 TLB 초기화로 외우기보다,\n아키텍처가 주소 공간 식별자를 지원하느냐에 따라 flush 전략이 달라진다고 이해하는 편이 정확하다.\n5. MMU vs TLB 한 줄 정리 # MMU TLB 역할 주소 변환 전체 담당 자주 쓰는 변환 결과 캐싱 위치 CPU의 주소 변환 하드웨어 MMU가 활용하는 번역 캐시 구현 성격 table walk, 권한 검사, fault 발생 등 포함 구현 의존적인 작은 온칩 캐시 없으면 가상 주소를 물리 주소로 자동 변환하기 어려움 page table walk 비용이 커져 성능 저하 결론: MMU는 주소 변환이라는 기능 전체를 담당하는 유닛이고,\nTLB는 그 안에서 성능 병목을 해결하기 위해 존재하는 캐시 컴포넌트이다.\nTLB는 MMU의 일부로 이해하면 된다.\n6. OS 메모리 관리자 (MMS) # 표준 용어 정리 # \u0026ldquo;MMS\u0026quot;는 Linux/Windows 공식 문서에서 대표 표준 약어로 널리 쓰인다기보다,\n문맥상 OS의 메모리 관리 서브시스템 정도를 가리키는 표현으로 이해하는 편이 안전하다.\nLinux는 보통 Memory Management (MM) 또는 VM subsystem 맥락으로 설명한다. Windows는 Memory Manager 라는 명칭을 쓴다. 하드웨어 vs 소프트웨어 메모리 관리 구분 # [ 프로세스 ] → 가상 주소 생성 │ ▼ [ MMU / TLB ] ← 하드웨어 주소 변환 담당 │ │ Page Fault 발생 시 예외 ▼ [ OS 메모리 관리자 ] ← 소프트웨어, 커널 내부 페이지 테이블 관리, 페이지 교체, 스왑, fault 처리 담당 │ ▼ [ 물리 메모리 (RAM) + 스왑 영역 (Disk) ] OS 메모리 관리자가 하는 일 # 1. 페이지 테이블 생성 및 관리 # 프로세스별 주소 공간에 맞춰 페이지 테이블을 만들고 유지한다. 일반적인 범용 OS에서는 OS나 하이퍼바이저가 테이블을 관리하고, MMU는 그 결과를 사용해 번역을 수행한다. 2. Page Fault 처리 # MMU가 비상주 페이지, 접근 권한 위반 같은 조건에서 page fault 예외를 발생시킬 수 있다. OS는 그 원인을 보고 페이지를 적재하거나, 권한 오류라면 예외를 전달하거나 프로세스를 종료한다. 정상적인 경우에는 페이지 테이블을 갱신한 뒤 명령을 재실행하게 만든다. 3. 스왑(Swap) 관리 # 물리 메모리가 부족하면 어떤 페이지를 디스크로 내보낼지 결정한다. 페이지 교체 알고리즘을 사용해 메모리 압박을 완화한다. 이 부분은 성능에 큰 영향을 주는 운영체제 정책 영역이다. 4. 메모리 할당 (malloc, mmap) 뒤편 # 사용자 프로그램이 메모리를 요청했을 때, 커널은 가상 주소 공간을 잡고 매핑 정책을 정한다. 실제 물리 프레임 할당은 첫 접근 시점까지 미뤄질 수 있다. 이것이 demand paging 맥락이다. 5. 공유 메모리 / Copy-on-Write (CoW) # fork() 같은 동작에서는 처음부터 모든 페이지를 복사하지 않고 공유할 수 있다. 이후 어느 한쪽이 쓰기를 시도할 때만 복사해서 메모리 낭비를 줄인다. OS별 메모리 관리자 명칭 # OS 메모리 관리자 명칭 Linux MM subsystem / VM subsystem (mm/) Windows Memory Manager (Mm, MmXxx 함수군) Linux 커널 소스의 mm/ 디렉터리에는\npage_alloc.c, swapfile.c, mmap.c 같은 코드가 들어 있다.\nMMU vs OS 메모리 관리자 비교 # MMU OS 메모리 관리자 종류 하드웨어 소프트웨어 (커널) 역할 주소 변환 실행, 권한 검사, fault 신호 변환 규칙(페이지 테이블) 생성, 교체/스왑 정책 결정, fault 처리 시간 특성 메모리 접근 경로의 매우 빠른 하드웨어 처리 fault 발생 시 훨씬 무거운 커널 처리 비유 통역사 통역사에게 사전과 규칙을 제공하는 사람 핵심 요약:\nMMU는 메커니즘(Mechanism) 을 담당하고,\nOS 메모리 관리자는 정책(Policy) 을 담당한다.\n참고 자료 # Arm Learn the Architecture - Memory management: https://developer.arm.com/-/media/Arm%20Developer%20Community/PDF/Learn%20the%20Architecture/LearnTheArchitecture-MemoryManagement-101811_0100_00_en.pdf Linux Kernel Documentation - Page Tables: https://docs.kernel.org/6.9/mm/page_tables.html Linux Kernel Documentation - Cache and TLB Flushing Under Linux: https://www.kernel.org/doc/html/latest/core-api/cachetlb.html Intel - Machine Check Error Avoidance on Page Size Change: https://www.intel.com/content/www/us/en/developer/articles/technical/software-security-guidance/technical-documentation/machine-check-error-avoidance-on-page-size-change.html Microsoft Learn - Windows kernel-mode memory manager: https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/windows-kernel-mode-memory-manager ","date":"2026년 3월 3일","externalUrl":null,"permalink":"/posts/memory-management-study/","section":"Posts","summary":"MMU와 TLB의 관계, 계층형 페이지 테이블이 필요한 이유, context switch와 TLB 무효화, page fault와 OS 메모리 관리자의 역할을 한 번에 정리합니다.","title":"가상 주소는 어떻게 물리 주소가 될까? MMU, TLB, 페이지 테이블, page fault","type":"posts"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. HTTP의 무상태성과 연결 재사용, gRPC의 HTTP/2 사용 이유, HTTP/2 멀티플렉싱과 Java NIO 멀티플렉싱의 차이를 함께 정리합니다.\nHTTP / gRPC / 멀티플렉싱 정리 # 1. HTTP 무상태성과 TCP 연결 관리의 관계 # HTTP의 무상태성이란? # RFC 기준으로 HTTP의 핵심 성질은 무상태성(statelessness) 이다.\n즉, 각 요청은 이전 요청과 독립적으로 이해될 수 있어야 하며, 서버가 이전 요청의 문맥을 반드시 기억할 필요는 없다.\n이 말은 TCP 연결을 매번 끊는다는 뜻이 아니다.\nHTTP의 무상태성은 애플리케이션 의미론이고, TCP 연결 재사용 여부는 연결 관리(connection management) 문제다.\nTCP 연결 생명주기 — HTTP 버전별 # HTTP/1.0 (초기) # 클라이언트 서버 |-- TCP 3-way handshake --\u0026gt;| |-- HTTP 요청 ------------\u0026gt;| |\u0026lt;-- HTTP 응답 ------------| |-- TCP 종료 (4-way) -----\u0026gt;| ← 매 요청마다 연결/해제 요청/응답 1쌍마다 TCP를 새로 맺고 끊었다. 비효율적.\nHTTP/1.1 — Persistent Connection이 기본값 # 클라이언트 서버 |-- TCP 3-way handshake --\u0026gt;| |-- 요청 1 ---------------\u0026gt;| |\u0026lt;-- 응답 1 ---------------| |-- 요청 2 ---------------\u0026gt;| ← 같은 TCP 연결 재사용 |\u0026lt;-- 응답 2 ---------------| |-- 요청 3 ---------------\u0026gt;| |\u0026lt;-- 응답 3 ---------------| |-- TCP 종료 -----------\u0026gt; | ← 일정 시간 후 또는 명시적 종료 HTTP/1.1부터는 persistent connection 이 기본값이다.\n별도로 Connection: close를 보내지 않으면 TCP 연결을 유지한다.\nHTTP/2, HTTP/3 # HTTP/2: 하나의 TCP 연결에서 멀티플렉싱 (여러 요청을 병렬로) HTTP/3: TCP 대신 QUIC(UDP 기반)을 사용, 연결 오버헤드 더 줄임 버전별 비교 # HTTP/1.0 HTTP/1.1 HTTP/2 기본 동작 요청마다 TCP 재생성 TCP 재사용 (keep-alive 기본) 하나의 TCP로 다중 요청 Keep-Alive 옵션 (헤더 명시 필요) 기본값 별도 Connection: keep-alive 개념 아님, 지속 연결이 기본 핵심 포인트 # 무상태성 = 서버가 각 요청을 독립적으로 처리한다 (stateless) Keep-Alive = TCP 물리 연결을 재사용한다 (persistent connection) 둘은 다른 차원의 개념이다. 무상태성(HTTP 의미론)과 Keep-Alive(TCP 연결 관리)는 서로 독립적이다. 2. gRPC의 통신 프로토콜 # 레이어 구조 # ┌─────────────────────────────┐ │ gRPC (애플리케이션) │ ← 서비스/메서드 정의 (proto) ├─────────────────────────────┤ │ HTTP/2 │ ← gRPC를 실어 나르는 애플리케이션 프로토콜 ├─────────────────────────────┤ │ TLS (선택적, 보통 권장) │ ← 암호화 ├─────────────────────────────┤ │ TCP │ ← 연결 └─────────────────────────────┘ 표준 gRPC는 HTTP/2 위에 매핑되는 RPC(Remote Procedure Call) 프레임워크다.\nTLS는 실무에서 매우 흔하지만, gRPC 자체가 TLS를 강제하는 것은 아니다.\ngRPC가 HTTP/2를 쓰는 이유 # ① 멀티플렉싱\n하나의 TCP 연결에서: Stream 1: 요청A ─────────────► 응답A Stream 3: 요청B ──────────────────────► 응답B Stream 5: 요청C ───────────────────────────► 응답C 여러 RPC 호출이 동시에 하나의 연결을 공유한다.\n참고로 클라이언트 라이브러리의 channel 은 구현에 따라 여러 HTTP/2 연결을 관리할 수 있다.\n다만 개별 RPC 호출은 HTTP/2 stream 으로 전달된다고 이해하면 된다.\n② 스트리밍 지원\nHTTP/2의 스트림 개념 덕분에 gRPC만의 4가지 통신 패턴이 가능하다.\ngRPC의 4가지 통신 패턴 # 1. Unary (일반 요청/응답) # 클라이언트 ──요청 1개──► 서버 ◄─응답 1개── 형태상 가장 익숙한 요청/응답 패턴이다.\n2. Server Streaming # 클라이언트 ──요청 1개──► 서버 ◄─응답 1── ◄─응답 2── ◄─응답 3── ◄─END── 예: 실시간 주가, 로그 스트리밍\n3. Client Streaming # 클라이언트 ──요청 1──► 서버 ──요청 2──► ──요청 3──► ◄─응답 1개── 예: 파일 업로드, 센서 데이터 배치 전송\n4. Bidirectional Streaming # 클라이언트 ──요청 1──► 서버 ◄─응답 1── ──요청 2──► ◄─응답 2── (양방향 자유롭게) 예: 채팅, 실시간 게임, 자율주행 텔레메트리\n데이터 직렬화: Protocol Buffers # 실무에서 REST가 JSON과 함께 자주 쓰이듯, gRPC는 기본적으로 Protobuf를 사용한다.\n다만 REST가 JSON에만 묶이지 않듯, gRPC도 다른 데이터 형식으로 확장될 수 있다.\nservice TelemetryService { rpc SendData (stream TelemetryData) returns (Ack); } message TelemetryData { float speed = 1; float latitude = 2; float longitude = 3; } JSON 대비 일반적인 장점:\n크기 가 더 작을 가능성이 큼 (바이너리) 파싱/직렬화 성능이 더 유리한 경우가 많음 스키마 기반이라 타입 안정성 과 코드 생성에 유리 REST vs gRPC 비교 # 주의: REST는 프로토콜 이름이 아니라 아키텍처 스타일이다. 아래 표는 엄밀한 정의 비교라기보다, 실무에서 자주 만나는 조합을 비교한 것이다.\nREST gRPC 프로토콜 HTTP 의미론 위 (HTTP/1.1, HTTP/2, HTTP/3 등) 표준 gRPC는 HTTP/2 데이터 형식 JSON이 흔함 Protobuf가 기본값 통신 패턴 요청/응답 중심 4가지 패턴 인터페이스 정의 OpenAPI 등 선택 .proto 사용이 일반적 브라우저 지원 넓음 원본 gRPC는 제한적, 보통 grpc-web 사용 사용처 공개 API, 범용 연동 서비스 간 통신, 저지연/스트리밍 3. HTTP/2 멀티플렉싱 상세 # HTTP/1.1의 문제: HOL Blocking # HTTP/1.1에서는 같은 연결 위에서 응답을 요청 순서에 맞춰 돌려줘야 하므로, 앞선 요청이 지연되면 뒤 요청도 지연되기 쉽다.\n과거의 pipelining 도 이 문제를 근본적으로 해결하지 못했다.\nTCP 연결 1: 요청A ──► [응답A 기다리는 중...] ──► 요청B ──► 요청C 요청A 응답이 늦으면 B, C가 전부 블로킹된다. 이게 HOL Blocking이다.\n브라우저는 이를 우회하기 위해 보통 여러 개의 병렬 TCP 연결을 열어 완화해 왔다.\nHTTP/2의 핵심 개념 3가지 # 1. Stream (스트림) # 하나의 TCP 연결 안에 생기는 독립적인 가상 채널.\n각 스트림은 고유한 Stream ID를 가진다 (홀수: 클라이언트 시작, 짝수: 서버 시작) 동시에 여러 스트림이 존재할 수 있다 2. Frame (프레임) # HTTP/2의 최소 전송 단위. 모든 데이터는 프레임으로 쪼개져서 전송된다.\n┌──────────────────────────────────────┐ │ Length (3B) │ Type (1B) │ Flags (1B)│ ├──────────────────────────────────────┤ │ Stream ID (4B) │ ├──────────────────────────────────────┤ │ Payload ... │ └──────────────────────────────────────┘ 주요 프레임 타입:\nHEADERS : HTTP 헤더 전송 DATA : 실제 body 데이터 SETTINGS : 연결 설정 WINDOW_UPDATE : 흐름 제어 3. Message (메시지) # 하나의 HTTP 요청 또는 응답. 여러 프레임의 묶음으로 표현된다.\n요청 한 건 = HEADERS 프레임 + DATA 프레임(들) + 필요 시 trailing HEADERS 멀티플렉싱 동작 원리 # 하나의 TCP 연결에서 프레임들이 뒤섞여서 전송되고, Stream ID로 어느 요청의 프레임인지 구분한다.\n시간 → ──[S1:HEADERS]──[S3:HEADERS]──[S1:DATA]──[S5:HEADERS]──[S3:DATA]──[S1:DATA]──► ↑ ↑ ↑ ↑ 요청A 시작 요청B 시작 요청A 본문 요청C 시작 수신 측은 Stream ID를 보고 각 요청으로 재조립한다.\nHOL Blocking 해결 # HTTP/1.1: 요청A [==============느린 응답==============] 요청B는 대기... HTTP/2: Stream1(요청A): [==============느린 응답==============] Stream3(요청B): [==빠른 응답==] ← 요청A 안 기다림! Stream5(요청C): [====응답====] ← 독립적으로 처리 추가 기능 # 우선순위 신호: 과거에는 스트림마다 가중치와 의존 관계를 보낼 수 있었지만, 이 priority signaling은 최신 HTTP/2 RFC에서 deprecated 되었다.\n흐름 제어 (Flow Control): 스트림 단위뿐 아니라 연결 전체 단위로도 수신 윈도우를 조절한다. WINDOW_UPDATE 프레임으로 제어한다.\n한계: TCP 레벨 HOL Blocking은 여전히 존재 # TCP 패킷 손실 발생 시: 패킷 1 (Stream1 DATA) ✓ 패킷 2 (Stream3 DATA) ✗ ← 손실! 패킷 3 (Stream5 DATA) ✓ ← 재전송 대기... 모든 스트림 블로킹 HTTP/2는 HTTP 레이어의 HOL Blocking은 해결 TCP 패킷 손실 시 전체 스트림이 멈추는 문제는 여전히 존재 이걸 해결한 게 HTTP/3 + QUIC(UDP 기반) 4. HTTP/2 멀티플렉싱 vs Java NIO I/O 멀티플렉싱 # 공통된 철학 # \u0026ldquo;하나의 주체가 여러 채널을 동시에 처리한다\u0026rdquo;\n이름은 같지만 다른 레이어의 개념이다.\n비교 # HTTP/2 멀티플렉싱 Java NIO I/O 멀티플렉싱 레이어 애플리케이션 레이어 (HTTP/2가 TCP 위에서 동작) OS/런타임 레이어 다중화 대상 하나의 TCP 연결 안의 스트림들 여러 TCP 연결(소켓)들 핵심 문제 여러 요청을 하나의 연결로 처리 여러 연결을 하나의 스레드로 처리 Java NIO I/O 멀티플렉싱 # 기존 방식 (Blocking I/O) # 스레드1 ──────► 소켓A (연결 기다리는 중... blocked) 스레드2 ──────► 소켓B (데이터 기다리는 중... blocked) 스레드3 ──────► 소켓C (데이터 기다리는 중... blocked) 연결 1개당 스레드 1개. 연결 1만 개면 스레드 1만 개 → 메모리/컨텍스트 스위칭 폭발\nNIO 방식 # ┌── 소켓A (이벤트 있을 때만 알림) Selector(1개) ────┼── 소켓B (이벤트 있을 때만 알림) 스레드 1개 └── 소켓C (이벤트 있을 때만 알림) Selector는 SelectableChannel 을 다루는 Java의 멀티플렉서다. 내부적으로는 플랫폼별 I/O 멀티플렉싱 메커니즘(select, poll, epoll, kqueue 등)에 기대어 동작한다.\n소켓들을 등록해두면, 이벤트가 생긴 소켓만 골라서 알려준다.\nSelector selector = Selector.open(); socketA.register(selector, SelectionKey.OP_READ); socketB.register(selector, SelectionKey.OP_READ); socketC.register(selector, SelectionKey.OP_READ); while (true) { selector.select(); // 이벤트 있는 소켓이 생길 때까지 대기 for (SelectionKey key : selector.selectedKeys()) { if (key.isReadable()) { SocketChannel ch = (SocketChannel) key.channel(); ch.read(buffer); } } } 스레드 1개로 수천 개의 소켓을 처리할 수 있다.\n두 개념의 위치 — 구체적인 시나리오 # 클라이언트 3명이 서버에 접속해서 각각 여러 요청을 보내는 상황:\n① TCP 연결(소켓)이 3개 생긴다\n클라이언트1 ──────── 소켓A ──┐ 클라이언트2 ──────── 소켓B ──┤── 서버 클라이언트3 ──────── 소켓C ──┘ ② NIO 멀티플렉싱 = 소켓 3개를 감시하는 문제\n소켓A에 데이터 왔나? 소켓B에 데이터 왔나? ← 이걸 스레드 1개로 감시 소켓C에 데이터 왔나? NIO 멀티플렉싱의 관심사 = 소켓(연결) 여러 개를 어떻게 효율적으로 감시하나\n③ HTTP/2 멀티플렉싱 = 각 소켓 안의 문제\n소켓A (클라이언트1과의 TCP 연결) 안에: ├── Stream 1: GET /user ├── Stream 3: GET /posts └── Stream 5: GET /images HTTP/2 멀티플렉싱의 관심사 = 하나의 소켓 안에서 요청 여러 개를 어떻게 동시에 처리하나\n④ 전체 그림 (실제 프레임워크에서 두 개념이 함께 동작)\n서버 Selector (스레드 1개로 소켓 3개 감시) ← NIO 멀티플렉싱 │ ├── 소켓A 안 → Stream1, Stream3, Stream5 ← HTTP/2 멀티플렉싱 ├── 소켓B 안 → Stream1, Stream3 └── 소켓C 안 → Stream1 한 줄 정리 # 대상 역할 NIO 멀티플렉싱 소켓(연결) 여러 개 스레드 1개로 여러 소켓을 바깥에서 감시 HTTP/2 멀티플렉싱 스트림(요청) 여러 개 하나의 소켓 안을 여러 레인으로 나눔 Netty, Spring WebFlux 같은 프레임워크는 이 두 개념을 함께 활용한다.\n참고 자료 # RFC 9110 — HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110 RFC 9112 — HTTP/1.1: https://www.rfc-editor.org/rfc/rfc9112.html RFC 9113 — HTTP/2: https://www.rfc-editor.org/rfc/rfc9113 RFC 9114 — HTTP/3: https://www.rfc-editor.org/rfc/rfc9114 gRPC Docs — Core concepts, architecture and lifecycle: https://grpc.io/docs/what-is-grpc/core-concepts/ gRPC Blog — gRPC on HTTP/2: https://grpc.io/blog/grpc-on-http2/ gRPC Docs — Basics tutorial for Web: https://grpc.io/docs/platforms/web/basics/ gRPC Blog — The state of gRPC in the browser: https://grpc.io/blog/state-of-grpc-web/ Oracle Java API — SelectableChannel: https://docs.oracle.com/javase/8/docs/api/java/nio/channels/SelectableChannel.html ","date":"2026년 2월 25일","externalUrl":null,"permalink":"/posts/http-grpc-multiplexing/","section":"Posts","summary":"HTTP의 무상태성과 연결 재사용은 어떻게 다른지, gRPC는 왜 HTTP/2를 쓰는지, HTTP/2 멀티플렉싱과 Java NIO I/O 멀티플렉싱은 무엇이 다른지 한 번에 정리합니다.","title":"[Network] HTTP, gRPC, 멀티플렉싱 정리 — 무상태성, HTTP/2, Java NIO","type":"posts"},{"content":"","date":"2026년 2월 25일","externalUrl":null,"permalink":"/tags/grpc/","section":"Tags","summary":"","title":"GRPC","type":"tags"},{"content":"","date":"2026년 2월 25일","externalUrl":null,"permalink":"/tags/http/","section":"Tags","summary":"","title":"HTTP","type":"tags"},{"content":"","date":"2026년 2월 25일","externalUrl":null,"permalink":"/tags/http/2/","section":"Tags","summary":"","title":"HTTP/2","type":"tags"},{"content":"","date":"2026년 2월 25일","externalUrl":null,"permalink":"/tags/http/3/","section":"Tags","summary":"","title":"HTTP/3","type":"tags"},{"content":"","date":"2026년 2월 25일","externalUrl":null,"permalink":"/tags/java-nio/","section":"Tags","summary":"","title":"Java NIO","type":"tags"},{"content":"","date":"2026년 2월 25일","externalUrl":null,"permalink":"/tags/multiplexing/","section":"Tags","summary":"","title":"Multiplexing","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Java가 플랫폼 독립성을 얻는 방식과 JVM, GC, JIT, JDK/JRE의 역할을 한 흐름으로 정리합니다.\nJava는 왜 한 번 컴파일하면 여러 OS에서 실행될까? # 1. Java가 JVM으로 얻는 장점 # \u0026ldquo;Write Once, Run Anywhere\u0026rdquo; # Java 소스코드를 컴파일하면 .class 파일(바이트코드)이 생성된다.\n이 바이트코드는 특정 OS/CPU에 종속되지 않은 중간 언어다.\nJava 소스코드(.java) ↓ javac 컴파일 바이트코드(.class) ← OS와 무관한 중간 언어 ↓ JVM 실행 각 OS에서 동작 2. \u0026ldquo;JVM도 OS에 맞게 설치해야 하지 않아?\u0026rdquo; → 맞다! # 여기서 핵심 개념이 나온다.\n역할 누가 부담? OS 의존성 처리 JVM (JVM이 OS별로 따로 존재) OS 신경 쓰는 주체 개발자 → JVM 으로 이전됨 즉, 개발자가 OS를 신경 쓰던 부담을 JVM 개발사(Oracle 등)가 대신 져주는 구조다.\n[Windows JVM] ┐ [Mac JVM] ├──── 동일한 .class 바이트코드를 실행 [Linux JVM] ┘ 단순한 예제 수준에서는 .class 파일 하나를 들고 다닌다고 생각해도 된다.\n다만 실제 배포에서는 호환 Java 버전, 의존 라이브러리, 네이티브 모듈 여부도 함께 맞춰야 한다.\n즉, \u0026ldquo;OS 독립성\u0026quot;은 크게 얻지만 \u0026ldquo;아무 환경에서나 무조건 실행\u0026quot;까지 의미하는 것은 아니다.\n3. Python, C는? 그냥 실행되지 않아? # C의 경우 — 플랫폼마다 다시 컴파일 필요 # 같은 C 코드라도: → Windows에서 컴파일 → Windows 실행파일(.exe) → Mac에서 실행 불가 → Mac에서 컴파일 → Mac 실행파일 → Windows에서 실행 불가 C는 소스코드를 각 OS/CPU에 맞는 기계어로 직접 변환하기 때문에,\n배포할 때 플랫폼마다 따로 빌드해야 한다.\n오픈소스 프로그램에서 \u0026ldquo;Windows 버전 / Mac 버전\u0026rdquo; 을 따로 제공하는 이유가 이것이다.\nPython의 경우 — 인터프리터/런타임이 실행을 담당 # Python 소스코드(.py) ↓ Python 구현체(CPython 등) 파이썬 바이트코드(.pyc 등, 내부 캐시) ↓ 인터프리터 실행 각 OS에서 동작 Python도 플랫폼별 인터프리터를 설치해야 한다는 점에서는 Java와 비슷하다.\n하지만 구조가 \u0026ldquo;거의 동일\u0026quot;하다고 보긴 어렵다.\nCPython은 소스를 내부적으로 바이트코드로 컴파일한 뒤 실행한다. 이 바이트코드는 CPython 구현과 버전에 의존하는 내부 표현에 가깝다. 반면 Java의 .class 바이트코드는 JVM 명세에 의해 표준화된 배포 포맷이다. 그래서 Python은 보통 소스코드(.py)나 wheel 같은 패키지를 배포하고,\nJava는 .class/.jar 같은 JVM 바이트코드 산출물을 배포하는 경우가 많다.\n언어별 비교 정리 # 배포 단위 OS 의존성 처리 특징 C 플랫폼별 실행파일 개발자가 직접 가장 빠름, 이식성 낮음 Java 바이트코드 (.class) JVM이 처리 이식성 높음, JVM 설치 필요 Python 소스코드 (.py), 패키지 인터프리터/런타임이 처리 편리하지만 구현체별 차이 존재, 인터프리터 설치 필요 결국 \u0026ldquo;OS 의존성을 누가 처리하느냐\u0026quot;의 차이다.\nJava의 혁신은 \u0026ldquo;개발자가 플랫폼을 신경 쓰지 않아도 되도록 그 책임을 JVM으로 옮긴 것\u0026quot;이다.\n4. JVM의 혁신들 # 플랫폼 독립성이 가장 유명하지만, JVM이 가져온 혁신은 여러 가지다.\n혁신 1. 플랫폼 독립성 (가장 유명) # \u0026ldquo;Write Once, Run Anywhere\u0026rdquo;\n위에서 설명한 것과 동일.\n혁신 2. 자동 메모리 관리 (Garbage Collection) # C/C++ 개발자가 겪던 가장 큰 고통 중 하나가 메모리를 직접 관리해야 한다는 것이었다.\n// C에서는 개발자가 직접 해제해야 함 int* arr = malloc(100); // ... 쓰다가 free(arr); // 이걸 까먹으면? → 메모리 누수(Memory Leak) JVM은 GC(Garbage Collector) 가 안 쓰는 메모리를 자동으로 회수한다.\n개발자가 free() 같은 걸 신경 쓸 필요가 없어진다.\n생산성과 안정성 면에서 엄청난 혁신이었다.\n혁신 3. 바이트코드 검증과 역사적 샌드박스 모델 # 바이트코드는 JVM 위에서만 실행되기 때문에, JVM은 로드/링크 과정에서 class 파일을 검증할 수 있다.\nclass 파일 로드 ↓ 형식 검사 + 바이트코드 검증 타입 안정성/기본 무결성 확인 ↓ 실행 이 검증 과정은 잘못된 바이트코드, 잘못된 타입 사용, 스택 언더플로/오버플로 같은 문제를 줄이는 데 중요했다.\n즉, Java 보안의 한 축은 \u0026ldquo;JVM이 아무 바이트코드나 그대로 실행하지 않는다\u0026quot;는 점이다.\n초기 Java에는 여기에 더해 Security Manager 기반 샌드박스가 있었고,\nApplet 같은 원격 코드를 제한된 권한으로 실행할 수 있었다.\n다만 이건 역사적 맥락으로 이해하는 게 맞다.\nJava 9 (2017): Applet API deprecated JDK 11 (2018): 브라우저 플러그인, Applet Viewer, Java Web Start 제거 Java 17 (2021): Applet API / Security Manager 제거 예정(deprecated for removal) JDK 24 (2025): Security Manager 영구 비활성화 그래서 오늘날의 Java를 설명하면서 \u0026ldquo;샌드박스가 핵심\u0026rdquo; 이라고 말하면 다소 옛 설명에 가깝다.\n현재도 중요한 건 바이트코드 검증, 타입 안전성, 메모리 안전성 쪽이다.\n혁신 4. JIT 컴파일 (Just-In-Time) # 초창기 JVM은 바이트코드를 한 줄씩 해석해서 느렸는데, 나중에 JIT 컴파일러가 도입됐다.\n바이트코드 ↓ 인터프리터로 실행 + 프로파일링 hot code 발견 ↓ JIT가 기계어로 컴파일 이후 더 빠르게 실행 \u0026ldquo;자주 실행되는 코드는 실행 중에 기계어로 최적화하자\u0026quot;는 아이디어다.\n현대 JVM은 여기에 tiered compilation 같은 기법도 사용한다.\n덕분에 Java는 많은 워크로드에서 높은 성능을 낼 수 있게 됐다.\n즉, 이식성 + 런타임 최적화를 함께 가져간 셈이다.\n혁신 5. JVM 자체가 생태계가 됨 # JVM이라는 실행 환경이 표준화되면서, Java 말고 다른 언어들도 JVM 위에서 돌아가도록 만들어지기 시작했다.\nKotlin ┐ Scala ├──── 모두 JVM 위에서 실행됨 (Java 생태계 그대로 활용) Groovy ┘ 특히 Kotlin/JVM은 Java와 높은 수준의 상호운용성을 제공하므로 Java 라이브러리를 그대로 활용할 수 있다.\n그래서 JVM이 하나의 범용 실행 플랫폼이 된 것이다.\n다만 Android는 한 가지 구분이 필요하다.\nGoogle은 Kotlin을 Android 개발에서 공식 지원한다. 하지만 Android 앱의 최종 런타임은 표준 JVM이 아니라 ART(Android Runtime) 다. 빌드 과정에서 Java/Kotlin의 .class 바이트코드는 DEX 바이트코드로 변환된다. 즉, \u0026ldquo;Kotlin이 Android에서 JVM 위에서 그대로 실행된다\u0026quot;는 말은 정확히는 아니다.\n그래도 Java와의 상호운용성 + 기존 생태계 재사용성이 매우 크다는 점은 맞다.\n혁신 우선순위 정리 # 순위 혁신 한 줄 요약 ★ 플랫폼 독립성 개발자가 OS를 신경 안 써도 됨 ★ GC (자동 메모리 관리) 메모리 버그에서 해방 ★ JVM 생태계화 Kotlin 등 다른 언어도 Java 자산 활용 ☆ 바이트코드 검증/역사적 샌드박스 타입 안전성과 제한 실행 모델 ☆ JIT 컴파일 이식성과 런타임 성능 최적화 플랫폼 독립성이 가장 유명하고 상징적인 혁신인 건 맞지만,\n실제 실무에서 Java를 선택하는 이유는 GC + 성숙한 생태계 + JIT 성능이 복합적으로 작용한다.\n5. JVM 설치 방식 # brew install openjdk@21 하면 뭐가 설치되냐? # openjdk@21 └── JDK ├── JVM ← 바이트코드 실행 엔진 ├── 표준 클래스 라이브러리/런타임 구성요소 └── 개발 도구 ← javac, javadoc, jdb 등 Homebrew의 openjdk@21은 JDK 21 배포판이다.\n예전 자료처럼 JVM / JRE / JDK를 따로 박스로 나누는 설명은 개념 구분용으로는 유효하다.\n하지만 현재 기준으로는 주의가 필요하다.\nJDK는 JVM을 포함하는 상위 개념이다. JRE는 JVM + 실행용 라이브러리라는 전통적 개념이다. 그러나 JDK 11(2018)부터는 별도 JRE 이미지가 사라졌다. 그래서 openjdk@21을 설치할 때는\n\u0026ldquo;JDK 안에 JVM과 실행 구성요소, 개발 도구가 함께 들어온다\u0026rdquo; 고 이해하는 편이 더 정확하다.\nbrew로 설치하는 것 자체는 맞다.\nJVM은 OS에 기본으로 깔려있지 않으며, 보통은 JDK 배포판을 설치해서 함께 받는다.\n\u0026ldquo;OS별로 설치된다\u0026quot;는 말의 의미 # brew로 설치할 때 내부적으로 내 OS/아키텍처에 맞는 바이너리를 받아온다.\nbrew install openjdk@21 → Mac(ARM)용 OpenJDK 바이너리 다운로드 → Linux라면 Linux용 바이너리 다운로드 JVM 자체가 OS별로 다르게 만들어져 있고, 사용자는 자기 OS에 맞는 걸 설치하는 것이다.\n이 과정을 brew가 자동으로 처리해준다.\n단, 여기서도 표현을 정확히 해야 한다.\nHomebrew는 공식적으로 macOS와 Linux에서 쓰는 패키지 관리자다. 그래서 이 문맥에서 brew install openjdk@21은 Mac/Linux 기준 설명이다. Windows는 보통 다른 설치 경로를 사용한다. 전체 흐름 정리 # [Oracle / OpenJDK 개발팀] → Mac용 JVM 빌드 → Windows용 JVM 빌드 ← JVM 자체는 OS별로 존재 → Linux용 JVM 빌드 [개발자] → brew install openjdk@21 ← Mac/Linux에서 내 환경에 맞는 JDK를 설치 → javac Hello.java ← 바이트코드 생성 → java Hello ← 설치된 JVM이 실행 \u0026ldquo;JVM이 OS 의존성을 처리해준다\u0026rdquo; 는 말은,\nJVM 개발팀이 OS별로 JVM을 따로 만들어서 배포하고, 그 수고를 개발자 대신 해주는 것을 의미한다.\n개발자는 그 구현체를 설치한 뒤 공통 바이트코드를 올려 실행한다는 뜻이다.\n즉, 이후에는 .class / .jar 가 OS마다 다시 컴파일될 필요는 없지만,\n여전히 Java 버전 호환성, 라이브러리 의존성, 배포 방식(jar, jlink runtime, container 등) 은 따로 신경 써야 한다.\n참고 자료 # The Java Virtual Machine Specification, Java SE 24: https://docs.oracle.com/javase/specs/jvms/se24/html/ Oracle Java Security Overview: https://docs.oracle.com/en/java/javase/24/security/java-security-overview1.html Java HotSpot Virtual Machine Performance Enhancements: https://docs.oracle.com/en/java/javase/24/vm/java-hotspot-virtual-machine-performance-enhancements.html JEP 411 — Deprecate the Security Manager for Removal: https://openjdk.org/jeps/411 JEP 486 — Permanently Disable the Security Manager: https://openjdk.org/jeps/486 Oracle JDK Migration Guide, Release 11: https://docs.oracle.com/en/java/javase/11/migrate/ Homebrew Formulae — openjdk@21: https://formulae.brew.sh/formula/openjdk%4021 Python Documentation — dis: https://docs.python.org/3/library/dis.html Python Documentation — The import system: https://docs.python.org/3/reference/import.html Kotlin Documentation — Calling Java from Kotlin: https://kotlinlang.org/docs/java-interop.html Android Developers — Kotlin and Android: https://developer.android.com/kotlin Android Developers — d8: https://developer.android.com/tools/d8 Android Open Source Project — Android runtime and Dalvik: https://source.android.com/docs/core/runtime ","date":"2026년 2월 19일","externalUrl":null,"permalink":"/posts/jvm-complete-guide/","section":"Posts","summary":"Java가 왜 플랫폼 독립성을 얻는지, Python/C와 무엇이 다른지, 현대 JVM에서 GC와 JIT, JDK/JRE 설치 개념을 한 번에 정리합니다.","title":"[Java] Java는 왜 한 번 컴파일하면 여러 OS에서 실행될까? JVM, GC, JIT, JDK/JRE","type":"posts"},{"content":"","date":"2026년 2월 19일","externalUrl":null,"permalink":"/tags/bytecode/","section":"Tags","summary":"","title":"Bytecode","type":"tags"},{"content":"","date":"2026년 2월 19일","externalUrl":null,"permalink":"/tags/gc/","section":"Tags","summary":"","title":"GC","type":"tags"},{"content":"","date":"2026년 2월 19일","externalUrl":null,"permalink":"/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"2026년 2월 19일","externalUrl":null,"permalink":"/tags/jdk/","section":"Tags","summary":"","title":"JDK","type":"tags"},{"content":"","date":"2026년 2월 19일","externalUrl":null,"permalink":"/tags/jit/","section":"Tags","summary":"","title":"JIT","type":"tags"},{"content":"","date":"2026년 2월 19일","externalUrl":null,"permalink":"/tags/jre/","section":"Tags","summary":"","title":"JRE","type":"tags"},{"content":"","date":"2026년 2월 14일","externalUrl":null,"permalink":"/tags/concurrency/","section":"Tags","summary":"","title":"Concurrency","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. 멀티스레드 환경에서 Singleton 구현이 왜 깨질 수 있는지, Holder 패턴과 enum 방식, Spring singleton 개념을 함께 정리합니다.\nSingleton, 그냥 구현하면 안 되나요? # \u0026ldquo;그냥 null 체크하면 되는 거 아닌가?\u0026rdquo; # Singleton을 처음 배우면 대부분 이렇게 구현합니다.\npublic class Settings { private static Settings instance; private Settings() { } public static Settings getInstance() { if (instance == null) { instance = new Settings(); } return instance; } } 논리적으로는 완벽해 보입니다.\n단일 스레드라면 instance가 없을 때 만들고, 있으면 그걸 반환하니 인스턴스는 하나만 생기는 것처럼 보입니다.\n그런데 이 코드는 멀티스레드 환경에서는 Singleton을 보장하지 못합니다.\n어디서 문제가 생기는 걸까? # Thread A와 Thread B가 동시에 getInstance()를 호출하는 상황을 생각해봅시다.\nThread A → if (instance == null) 체크 → null이다, true! Thread B → if (instance == null) 체크 → null이다, true! ← 동시에 통과 Thread A → instance = new Settings() 실행 Thread B → instance = new Settings() 실행 ← 두 번째 인스턴스 생성! 두 스레드가 동시에 null 체크를 통과하면 각자 인스턴스를 만들어버립니다.\n공유 필드 instance에 대한 동기화가 없기 때문에 Singleton 보장이 깨질 수 있는 거죠.\n\u0026ldquo;그럼 synchronized 걸면 되지 않나요?\u0026rdquo; # 맞습니다. 동기화하면 Thread-safe해집니다.\npublic static synchronized Settings getInstance() { if (instance == null) { instance = new Settings(); } return instance; } 하지만 이 방식은 getInstance()의 모든 호출이 같은 monitor를 거치게 됩니다.\n인스턴스가 이미 만들어진 이후에도 매번 동기화 경로를 통과해야 하므로, 호출이 많고 경합이 있으면 불필요한 contention이 생길 수 있습니다.\n\u0026ldquo;그럼 필요한 순간에만 락을 걸면 어떨까?\u0026rdquo; # 그 아이디어가 Double-Checked Locking입니다.\npublic static Settings getInstance() { if (instance == null) { // 1차 체크 — 락 없음 synchronized (Settings.class) { if (instance == null) { // 2차 체크 — 락 있음 instance = new Settings(); } } } return instance; } 인스턴스가 생성된 이후엔 락을 걸지 않으니 호출 경로는 가벼워집니다.\n하지만 이 코드도 volatile 없이는 안전하지 않습니다.\nJava 메모리 모델은 동기화되지 않은 공유 읽기/쓰기에 대해 예상 밖의 실행을 허용합니다.\n객체 생성은 개념적으로 다음 순서로 생각할 수 있습니다.\n1. 메모리 공간 할당 2. 생성자 실행 3. instance 변수에 참조 공개 volatile 없이 DCL을 쓰면 instance에 대한 쓰기와 이후 읽기 사이에 필요한 happens-before 관계가 없어,\n다른 스레드가 생성이 완전히 끝나기 전의 참조를 관찰하는 실행이 허용될 수 있습니다.\n이를 막으려면 volatile 키워드가 필요합니다.\nprivate static volatile Settings instance; // DCL에서는 volatile 필요 결국 코드가 점점 복잡해집니다. 실수하기도 쉽고요.\n더 좋은 방법이 있습니다 — Holder 패턴 # public class Settings { private Settings() { } private static class SettingsHolder { private static final Settings INSTANCE = new Settings(); } public static Settings getInstance() { return SettingsHolder.INSTANCE; } } synchronized도 없고, volatile도 없습니다.\n그런데 어떻게 Thread-safe를 보장하는 걸까요?\n핵심은 JVM의 클래스 초기화 메커니즘입니다 # JVM은 클래스나 인터페이스를 처음 능동적으로 사용하는 시점에 초기화하고,\n그 초기화 절차를 동기화합니다. 이건 JLS §12.4.1, §12.4.2가 보장하는 동작입니다.\nHolder 패턴은 이 메커니즘을 그대로 활용합니다.\nSettings 클래스는 로드될 수 있어도 SettingsHolder는 아직 초기화되지 않음 ↓ getInstance() 최초 호출 ↓ SettingsHolder.INSTANCE 에 처음 접근 ↓ SettingsHolder 초기화 시작 ↓ INSTANCE = new Settings() — 초기화 절차 안에서 한 번만 실행 ↓ 이후 getInstance() 호출 → 그냥 반환만 함 (추가 락 없음) 락 없이도 안전하고, 실제로 필요한 시점까지 초기화를 미루며, 구현 복잡도도 낮습니다.\n방식별 비교 # 구현 방식 Lazy 초기화 Thread-safe 성능 구현 복잡도 기본 null 체크 ✅ ❌ ✅ 낮음 synchronized ✅ ✅ ❌ 낮음 Double-Checked Locking ✅ ✅ (volatile 필수) ✅ 높음 Holder 패턴 ✅ ✅ ✅ 낮음 그 외 알아두면 좋은 것들 # Enum Singleton # 《Effective Java》에서 자주 소개되는 방식입니다.\npublic enum Settings { INSTANCE; } // 사용 Settings.INSTANCE; 가장 짧고, enum 상수는 직렬화 시 이름 기반으로 특별 취급되므로 별도의 readResolve 같은 고민이 줄어듭니다.\n또한 일반적인 reflective instantiation도 허용되지 않습니다.\n다만 \u0026ldquo;애플리케이션 시작과 동시에 무조건 생성된다\u0026quot;고 일반화하면 정확하지 않습니다.\nenum 상수도 해당 enum 타입이 초기화될 때 생성되므로, Holder 패턴처럼 getInstance() 호출 직전까지 미루는 형태와는 다릅니다.\nSpring 환경이라면? # Spring을 쓴다면 직접 Singleton 패턴을 구현하기보다, 컨테이너가 관리하는 bean scope를 활용하는 경우가 많습니다.\n@Component public class Settings { // Spring IoC 컨테이너가 기본 singleton scope로 관리합니다. } Spring Bean의 기본 스코프는 singleton입니다.\n다만 이것은 GoF Singleton처럼 JVM 전체에서 클래스당 하나라는 뜻이 아니라, Spring IoC 컨테이너 안에서 bean definition마다 하나라는 의미입니다.\n그래서 Spring을 쓴다고 해서 상태 공유 문제가 사라지는 것은 아닙니다.\n상태를 가지는 필드를 두면 여전히 동시성 문제가 생길 수 있으므로, 보통은 빈을 무상태에 가깝게 설계합니다.\n정리 # 처음 봤던 코드가 틀린 이유는 로직이 단순해서가 아니라,\n공유 상태를 여러 스레드가 동시에 읽고 쓸 수 있다는 점을 고려하지 않았기 때문입니다.\n직접 Singleton 클래스를 구현해야 하고 지연 초기화가 필요하다면 Holder 패턴을 우선 검토할 만합니다.\nJVM이 보장하는 클래스 초기화 절차를 활용하기 때문에, synchronized나 DCL보다 구현 실수가 적습니다.\n지연 초기화가 굳이 필요 없고 enum의 제약이 괜찮다면 enum 방식도 좋은 선택지입니다.\n\u0026ldquo;복잡한 문제를 복잡하게 푸는 건 실력이 아니다.\n이미 있는 보장을 영리하게 활용하는 것이 진짜 실력이다.\u0026rdquo;\n참고 자료 # Java Language Specification, SE 21, §12.4.1 When Initialization Occurs: https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4.1 Java Language Specification, SE 21, §12.4.2 Detailed Initialization Procedure: https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4.2 Java Language Specification, SE 21, §17.1 Synchronization: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.1 Java Language Specification, SE 21, §17.4.5 Happens-before Order: https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4.5 Java Object Serialization Specification, §1.12 Serialization of Enum Constants: https://docs.oracle.com/en/java/javase/25/docs/specs/serialization/serial-arch.html#serialization-of-enum-constants Oracle Java Tutorials, Reflection API, Troubleshooting: IllegalArgumentException When Attempting to Instantiate an Enum Type: https://docs.oracle.com/javase/tutorial/reflect/special/enumTrouble.html Spring Framework Reference, Bean Scopes / The Singleton Scope: https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html#beans-factory-scopes-singleton ","date":"2026년 2월 14일","externalUrl":null,"permalink":"/posts/java-singleton-patterns/","section":"Posts","summary":"멀티스레드 환경에서 Singleton이 왜 깨질 수 있는지, 왜 Holder 패턴이 안전한지, enum과 Spring singleton을 어디까지 같은 개념으로 봐야 하는지 정리합니다.","title":"Singleton, 그냥 구현하면 안 되나요?","type":"posts"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Auto Scaling 환경에서 JPA 1차 캐시와 2차 캐시의 범위가 어떻게 다르고, 어떤 경우에 데이터 정합성 문제가 생길 수 있는지 정리합니다.\n결론부터 # 캐시 범위 Auto Scaling 시 정합성 1차 캐시 (영속성 컨텍스트) Session / EntityManager 범위 (일반적인 Spring 앱에서는 보통 트랜잭션 범위) 노드 간 공유되지 않으므로 분산 환경 정합성 문제의 직접 원인은 아님 2차 캐시 (로컬 — Ehcache 등) 서버 JVM 깨질 수 있음 2차 캐시 (공유/분산 — Redis 등) 여러 서버가 같은 캐시를 공유 로컬 캐시보다 유리하지만, 구성/무효화/외부 수정 여부에 따라 stale data 가능 1차 캐시 — 영속성 컨텍스트 (Persistence Context) # 정의 # Hibernate 공식 문서에 따르면:\nA persistence context acts as a cache, often called the \u0026lsquo;first-level cache\u0026rsquo;. It maintains a unique mapping of entity identifiers to entity instances that have been read or made persistent within its scope.\n영속성 컨텍스트는 엔티티 ID → 엔티티 인스턴스의 맵으로 동작하는 1차 캐시다.\n엔티티의 생명주기 # 엔티티 lifecycle은 보통 4가지 상태로 설명한다:\n┌──────────┐ persist() ┌──────────┐ detach()/clear() ┌──────────┐ │ Transient │ ───────────\u0026gt; │ Managed │ ─────────────────\u0026gt; │ Detached │ │ (비영속) │ │ (영속) │ │ (준영속) │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ remove() │ merge() │ ▼ │ │ ┌──────────┐ │ │ │ Removed │ │ │ │ (삭제) │ │ │ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────┘ merge()로 다시 영속 상태로 상태 설명 Transient new로 생성만 한 상태. 영속성 컨텍스트와 무관 Managed persist() 또는 조회로 영속성 컨텍스트에 들어온 상태 Detached 영속성 컨텍스트에서 분리된 상태 (트랜잭션 종료 등) Removed remove() 호출. 커밋 시 DELETE 실행 핵심: 일반적인 Spring 앱에서는 트랜잭션 범위로 이해해도 된다 # @Service public class UserService { @Transactional // ← 트랜잭션 시작 = 영속성 컨텍스트 생성 public void updateUser(Long id) { User user = userRepository.findById(id).get(); // DB 조회 → 1차 캐시에 저장 User same = userRepository.findById(id).get(); // 1차 캐시에서 반환 (SQL 안 나감) System.out.println(user == same); // true (같은 인스턴스) user.setName(\u0026#34;김철수\u0026#34;); // 더티 체킹 대상 } // ← 트랜잭션 종료 = flush + commit + 영속성 컨텍스트 소멸 } 일반적인 Spring Boot + @Transactional 요청 처리에서는 트랜잭션이 끝날 때 해당 영속성 컨텍스트도 함께 정리된다고 이해해도 된다.\n다만 정확히 말하면 1차 캐시의 범위는 현재 Session / EntityManager 다. 즉, 보통은 트랜잭션 범위로 짧게 쓰이지만, extended persistence context 같은 예외적인 구성도 가능하다.\n1차 캐시가 제공하는 기능 # Hibernate 공식 문서:\nPersistence contexts offer benefits such as preventing data aliasing, enabling automatic dirty checking, improving performance by caching entities, and facilitating transparent batching of database operations.\n기능 설명 동일성 보장 같은 트랜잭션 내에서 같은 ID의 엔티티는 항상 같은 인스턴스 반복 읽기 같은 엔티티를 여러 번 조회해도 SQL은 1번만 실행 더티 체킹 엔티티 필드 변경 시 자동으로 UPDATE 감지 쓰기 지연 flush 시점까지 SQL을 모아서 배치 실행 Auto Scaling에서 왜 직접적인 정합성 문제의 원인이 아닌가 # [Server A] [Server B] 요청 1 도착 요청 2 도착 │ │ ▼ ▼ 트랜잭션 시작 트랜잭션 시작 영속성 컨텍스트 A 생성 영속성 컨텍스트 B 생성 │ │ User(id=1) 조회 → DB SELECT User(id=1) 조회 → DB SELECT 1차 캐시에 저장 1차 캐시에 저장 │ │ 트랜잭션 종료 트랜잭션 종료 영속성 컨텍스트 A 소멸 ← 여기서 사라짐 영속성 컨텍스트 B 소멸 각 서버의 1차 캐시는 완전히 독립적 보통 요청/트랜잭션 단위로 생성되고 정리되므로 서버 간 공유할 것 자체가 없음 따라서 \u0026ldquo;Server A의 1차 캐시 때문에 Server B가 옛값을 본다\u0026rdquo; 같은 문제는 생기지 않는다 다만 이것이 곧 \u0026ldquo;항상 최신 데이터를 본다\u0026rdquo; 는 뜻은 아니다. 같은 영속성 컨텍스트 안에서는 이미 관리 중인 엔티티를 다시 반환할 수 있고, 최신성은 결국 트랜잭션 격리 수준 / 조회 시점 / refresh() 여부 에도 영향을 받는다.\n2차 캐시 — 서버 레벨 캐시 # 정의 # 1차 캐시와 달리 트랜잭션이 끝나도 살아있는 캐시. SessionFactory(애플리케이션) 레벨에서 동작한다.\n요청 → 1차 캐시(영속성 컨텍스트) → miss → 2차 캐시 → miss → DB hit ↓ 바로 반환 중요한 제약이 하나 있다. Hibernate 2차 캐시는 Hibernate 바깥에서 일어난 DB 변경을 자동으로 알지 못한다.\nJDBC로 직접 수정 배치 프로그램이 직접 수정 다른 애플리케이션이 같은 테이블 수정 이런 변경은 캐시에 자동 반영되지 않으므로, 필요한 경우 명시적 무효화나 만료 정책이 필요하다.\n설정 방법 # Hibernate에서 2차 캐시를 활성화하려면:\n# application.properties spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=jcache spring.jpa.properties.hibernate.javax.cache.uri=/ehcache.xml 엔티티에 @Cache 어노테이션 적용:\n@Entity @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id private Long id; private String name; private int price; } 동시성 전략 (CacheConcurrencyStrategy) # Hibernate 공식 문서:\nThe second-level cache offers four concurrency strategies: READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, and TRANSACTIONAL. READ_WRITE is generally recommended as a default for strong consistency.\n전략 설명 적합한 경우 READ_ONLY 읽기 전용. 변경 시 예외 코드 테이블, 설정값 등 불변 데이터 NONSTRICT_READ_WRITE 변경 후 캐시 무효화 (약한 일관성) 간헐적 변경, 약간의 불일치 허용 READ_WRITE soft lock 기반. Hibernate가 관리하는 수정에 대해 비교적 강한 일관성을 제공 일반적 추천 TRANSACTIONAL JTA와 통합되는 transactional cache provider 전제 특수한 엔터프라이즈 환경 캐시 관리 API # // SessionFactory에서 캐시 직접 제어 Cache cache = sessionFactory.getCache(); cache.evict(Product.class, 1L); // 특정 엔티티 제거 cache.evict(Product.class); // 해당 타입 전체 제거 cache.evictAllRegions(); // 전체 캐시 초기화 boolean cached = cache.contains(Product.class, 1L); // 캐시 존재 여부 2차 캐시 + Auto Scaling = 정합성 문제 # 문제 시나리오 (로컬 캐시 사용 시) # 시간순서 Server A (Ehcache) Server B (Ehcache) DB ──────────────────────────────────────────────────────────────────────────────── T1 Product(id=1, price=1000) Product(id=1, price=1000) price=1000 2차 캐시에 저장 2차 캐시에 저장 T2 price를 2000으로 UPDATE (변경 모름) price=2000 자기 캐시 갱신: price=2000 캐시: price=1000 ← 오래된 값! T3 Product(id=1) 조회 → 2차 캐시 hit → price=1000 반환 ← 틀린 값! 핵심: 로컬 2차 캐시는 각 JVM 안에서만 존재하므로, 다른 서버의 변경을 알 수 없다.\n코드로 보는 문제 상황 # // Server A에서 실행 @Transactional public void updatePrice(Long productId, int newPrice) { Product product = productRepository.findById(productId).get(); product.setPrice(newPrice); // DB 반영 + Server A의 2차 캐시 갱신 // Server B의 2차 캐시는? → 그대로 옛날 값 } // Server B에서 실행 (직후) @Transactional(readOnly = true) public Product getProduct(Long productId) { return productRepository.findById(productId).get(); // 2차 캐시 hit → 옛날 가격 반환! } 이건 Auto Scaling만의 문제가 아니다 # 서버가 2대 이상이면 동일한 문제가 발생한다. Auto Scaling은 서버 수가 동적으로 변하면서 문제가 더 예측하기 어려워질 뿐이다.\n해결 방법 # 방법 1: 2차 캐시를 안 쓴다 (가장 단순) # # provider를 구성했더라도 전역적으로 끄고 싶을 때 spring.jpa.properties.hibernate.cache.use_second_level_cache=false 대부분의 서비스는 1차 캐시 + 적절한 쿼리 최적화만으로 충분하다. Hibernate는 provider 설정과 엔티티 매핑(@Cache)이 있어야 실제로 2차 캐시를 활용하므로, 많은 프로젝트에서는 애초에 적극적으로 사용하지 않는다.\n방법 2: 공유 캐시 사용 (Redis 등) # 모든 서버가 하나의 외부 캐시를 바라보게 하면, 로컬 캐시보다 노드 간 stale data 가능성을 줄일 수 있다.\nServer A ──┐ Server B ──┼──\u0026gt; Redis (공유 캐시) ──\u0026gt; DB Server C ──┘ // Redis를 2차 캐시로 사용 (redisson-hibernate 등) @Entity @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) public class Product { @Id private Long id; private String name; private int price; } # Redisson을 Hibernate 2차 캐시로 설정 spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory spring.jpa.properties.hibernate.cache.redisson.config=redisson.yaml 시간순서 Server A Server B Redis DB ────────────────────────────────────────────────────────────────────────────────────── T1 Product(id=1) 조회 Product(id=1) 조회 → Redis에 캐시 → Redis 캐시 hit price=1000 price=1000 T2 price=2000 UPDATE → Redis 캐시 갱신 price=2000 price=2000 T3 Product(id=1) 조회 → Redis 캐시 hit → price=2000 반환 가능 price=2000 단, 이 결론은 다음 전제가 있을 때에만 비교적 잘 성립한다.\n모든 쓰기가 Hibernate를 통해 일어난다 cache provider의 무효화/락 전략이 올바르게 설정되어 있다 Hibernate 바깥에서 DB를 직접 수정하는 경로가 없거나, 별도 무효화 전략이 있다 즉, 공유 Redis를 쓴다고 해서 무조건 \u0026ldquo;문제없음\u0026quot;이 되는 것은 아니다. 그래도 서버마다 로컬 캐시를 따로 두는 것보다는 일반적으로 안전하다.\n방법 3: 짧은 TTL + 허용 가능한 불일치 # \u0026lt;!-- ehcache.xml --\u0026gt; \u0026lt;cache alias=\u0026#34;products\u0026#34;\u0026gt; \u0026lt;expiry\u0026gt; \u0026lt;ttl unit=\u0026#34;seconds\u0026#34;\u0026gt;30\u0026lt;/ttl\u0026gt; \u0026lt;!-- 30초 후 만료 --\u0026gt; \u0026lt;/expiry\u0026gt; \u0026lt;heap unit=\u0026#34;entries\u0026#34;\u0026gt;1000\u0026lt;/heap\u0026gt; \u0026lt;/cache\u0026gt; 최대 30초간 오래된 데이터를 볼 수 있음 상품 목록, 게시글 조회수 등 약간의 불일치가 허용되는 경우에 적합 이는 정합성 보장이 아니라 stale data의 지속 시간을 줄이는 완화책이다 방법 4: Spring @Cacheable + Redis (2차 캐시 대신 애플리케이션 레벨) # JPA 2차 캐시 대신 Spring 캐시 추상화를 사용하는 방법도 있다.\n@Service public class ProductService { @Cacheable(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public Product getProduct(Long id) { return productRepository.findById(id).get(); // 캐시 miss 시에만 실행 } @Transactional @CacheEvict(value = \u0026#34;products\u0026#34;, key = \u0026#34;#id\u0026#34;) public void updateProduct(Long id, int newPrice) { Product product = productRepository.findById(id).get(); product.setPrice(newPrice); } } spring.cache.type=redis @CacheEvict로 변경 시 캐시 무효화 Redis 같은 공유 캐시를 쓰면 서버별 로컬 캐시 불일치를 줄일 수 있다 다만 Spring Cache는 추상화 계층 이므로 TTL, 전파 방식, 즉시성은 Redis/CacheManager 설정에 달려 있다 각 방법 비교 # 방법 정합성 성능 복잡도 적합한 경우 2차 캐시 안 쓰기 캐시로 인한 stale risk 가장 낮음 보통 낮음 대부분의 서비스 공유 캐시 (Redis 등) 적절히 구성하면 높음 높음 중간 읽기 비율 높은 서비스 짧은 TTL 약한 불일치 높음 낮음 조회수, 목록 등 허용 가능한 경우 @Cacheable + Redis 적절히 구성하면 높음 높음 중간 JPA 2차 캐시보다 직관적 전체 그림 # 1차 캐시 2차 캐시 (트랜잭션 범위) (서버/공유 범위) │ │ [Server A] ── 요청 → 영속성 컨텍스트 → miss → 로컬 or Redis → miss → DB │ │ 트랜잭션 끝나면 소멸 서버 살아있는 동안 유지 │ │ 서버 간 공유 X 로컬: 서버 간 공유 X Redis: 서버 간 공유 O 정리 # 1차 캐시(영속성 컨텍스트)는 현재 Session / EntityManager 범위이며, 일반적인 Spring 앱에서는 보통 트랜잭션 범위로 짧게 사용된다 따라서 1차 캐시는 노드 간 공유 캐시 불일치의 직접 원인 이 되지 않는다 2차 캐시를 로컬(Ehcache 등)로 사용하면 서버가 2대 이상일 때 stale data가 생길 수 있다 공유 캐시(Redis 등) 는 이 문제를 줄일 수 있지만, 외부 DB 수정·무효화·provider 구성에 따라 stale data 가능성은 남는다 그래서 실무적인 해법은 2차 캐시를 안 쓰거나, 공유 캐시를 신중히 구성하거나, TTL/무효화 전략을 명시적으로 가져가는 것이다 ","date":"2026년 2월 13일","externalUrl":null,"permalink":"/posts/jpa-cache-autoscaling-consistency/","section":"Posts","summary":"Auto Scaling으로 서버가 여러 대일 때 JPA 영속성 컨텍스트와 2차 캐시가 데이터 정합성을 깨뜨리는지, 각 캐시의 범위와 해결 방법을 코드 예시와 함께 정리합니다.","title":"[JPA] 영속성 컨텍스트와 캐시 — Auto Scaling 환경에서 데이터 정합성은 괜찮을까?","type":"posts"},{"content":"","date":"2026년 2월 13일","externalUrl":null,"permalink":"/tags/auto-scaling/","section":"Tags","summary":"","title":"Auto Scaling","type":"tags"},{"content":"","date":"2026년 2월 13일","externalUrl":null,"permalink":"/tags/cache/","section":"Tags","summary":"","title":"Cache","type":"tags"},{"content":"","date":"2026년 2월 13일","externalUrl":null,"permalink":"/tags/hibernate/","section":"Tags","summary":"","title":"Hibernate","type":"tags"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. InnoDB 테이블이 디스크에 저장되는 구조를 tablespace, segment, extent, page, row 순서로 정리합니다.\n먼저 전제 # 이 글은 innodb_file_per_table=ON 인 일반적인 InnoDB 비파티션 테이블을 기준으로 설명한다. 이 전제가 있으면 .ibd 파일 하나를 중심으로 구조를 이해하기 쉽다.\n다만 아래 경우에는 설명이 달라진다.\ninnodb_file_per_table=OFF 이면 새 테이블은 시스템 tablespace에 저장된다. general tablespace를 쓰면 여러 테이블이 하나의 tablespace를 공유할 수 있다. partitioned table은 파티션별로 별도 tablespace/file이 생길 수 있다. 각 테이블은 하나의 파일인가? # 보통은 그렇지만, 정확히는 \u0026ldquo;테이블은 tablespace에 저장된다\u0026quot;가 맞다.\ninnodb_file_per_table=ON 일 때, 일반적인 InnoDB 테이블은 single-table tablespace에 저장되고, 그 tablespace가 보통 하나의 .ibd 파일로 존재한다.\n/var/lib/mysql/mydb/ ├── user.ibd ├── orders.ibd └── product.ibd 이 모드에서는 .ibd 하나 안에 해당 테이블의 클러스터드 인덱스(실제 행 데이터) 와 세컨더리 인덱스 가 함께 들어간다.\n핵심은 다음이다.\nTable = File 은 엄밀한 원리가 아니라 file-per-table 구성에서 흔히 보이는 결과다. 더 정확한 표현은 Table -\u0026gt; Tablespace -\u0026gt; backing file(.ibd) 이다. 하나의 파일 안에는 여러 페이지가 있는가? # Yes. .ibd 파일은 동일 크기의 database page가 연속으로 배치된 구조다.\nuser.ibd ┌─────────┬─────────┬─────────┬─────────┬─────┐ │ Page 0 │ Page 1 │ Page 2 │ Page 3 │ ... │ └─────────┴─────────┴─────────┴─────────┴─────┘ page 크기 기본값은 16KB innodb_page_size 에 따라 4KB, 8KB, 16KB, 32KB, 64KB가 가능 디스크 I/O와 Buffer Pool 캐싱도 결국 이 page 단위로 일어난다 전체 계층 구조 # file-per-table 기준으로 보면 다음 식으로 이해하는 게 가장 덜 헷갈린다.\nTable / Index └── Tablespace ├── backing file: .ibd └── Segment └── Extent └── Page └── Record(Row) 여기서 중요한 점은 다음 두 가지다.\ntablespace가 상위 개념이고, 파일은 그 tablespace를 담는 물리적 저장소다. segment / extent / page 는 tablespace 내부 공간 관리 구조다. 각 계층 상세 # 1) Tablespace와 File # InnoDB는 데이터를 먼저 tablespace에 배치하고, 그 tablespace를 하나 이상의 파일로 유지한다.\nsingle-table tablespace: 테이블 1개 전용 system tablespace: 여러 내부 구조와 테이블이 공유 general tablespace: 사용자가 여러 테이블을 함께 넣을 수 있음 undo tablespace: undo 로그용 즉, .ibd 파일을 중심으로 설명할 수는 있지만, 본질은 파일보다 tablespace 다.\n2) Segment # segment는 tablespace 내부의 논리적 저장 단위다. 여기서 헷갈리기 쉬운 포인트가 있다.\nInnoDB는 각 인덱스마다 2개의 segment 를 둔다. 하나는 non-leaf node용, 다른 하나는 leaf node용 이다. 여기서 말하는 segment는 rollback segment와 다른 개념이다. 즉, 기존 설명처럼 rollback segment를 같은 .ibd 안의 일반 segment로 묶어 말하면 부정확하다.\n또한 leaf node에 저장되는 내용도 인덱스 종류에 따라 다르다.\n인덱스 leaf page에 저장되는 것 Clustered Index 실제 행 데이터 Secondary Index 보조 인덱스 키 + 기본 키 값 그래서 \u0026ldquo;leaf node = 실제 row\u0026quot;는 클러스터드 인덱스에 대해서만 정확한 표현이다.\n3) Extent # extent는 InnoDB가 공간을 묶어서 관리하는 단위다.\nPage 크기 Extent 크기 구성 4KB 1MB 256 pages 8KB 1MB 128 pages 16KB 1MB 64 pages 32KB 2MB 64 pages 64KB 4MB 64 pages 보완할 점은 두 가지다.\nextent는 tablespace 안에서 연속된 page 묶음이지, 파일시스템/디스크 물리 섹터까지 반드시 연속이라는 뜻은 아니다. segment는 처음부터 extent 단위로만 커지지 않는다. 처음 32 page는 한 장씩, 그 이후부터 extent 단위 할당이 시작된다. 즉, \u0026ldquo;extent = 항상 실제 물리 디스크에서 완전히 연속된 큰 덩어리\u0026quot;라고 이해하면 과하다. 다만 InnoDB는 extent 단위 관리를 통해 논리적 지역성(locality) 과 순차 접근 가능성을 높이려 한다.\n4) Page # page는 InnoDB의 기본 디스크 I/O 단위이자 Buffer Pool 캐싱 단위다.\n정확성을 위해 page 구조는 \u0026ldquo;페이지 종류에 따라 조금씩 다르다\u0026quot;는 전제를 두는 게 맞다. 아래는 index page 기준의 단순화된 그림이다.\n┌──────────────────────────────────┐ │ FIL Header (38 bytes) │ ├──────────────────────────────────┤ │ Page Header (index page 전용) │ ├──────────────────────────────────┤ │ Infimum / Supremum records │ ├──────────────────────────────────┤ │ User Records │ ├──────────────────────────────────┤ │ Free Space │ ├──────────────────────────────────┤ │ Page Directory │ ├──────────────────────────────────┤ │ FIL Trailer (8 bytes) │ └──────────────────────────────────┘ 자주 보는 page 타입은 다음 정도다.\n타입 역할 FIL_PAGE_INDEX B-tree node page FIL_PAGE_TYPE_FSP_HDR tablespace header page FIL_PAGE_IBUF_BITMAP change buffer / allocation bitmap FIL_PAGE_INODE segment inode 정보 FIL_PAGE_UNDO_LOG undo log page 5) Row / Record # InnoDB에서 \u0026ldquo;row\u0026quot;는 결국 clustered index leaf record 로 저장된다.\n큰 가변 길이 컬럼은 항상 같은 방식으로 처리되지 않는다.\nrow format과 page size에 따라 일부는 페이지 내부, 일부는 off-page/overflow page 로 갈 수 있다. DYNAMIC / COMPRESSED row format에서는 긴 컬럼이 20-byte pointer만 남기고 외부 page로 빠질 수 있다. COMPACT / REDUNDANT 는 앞부분 768 byte를 inline으로 두고 나머지를 외부 page에 저장할 수 있다. 즉, \u0026ldquo;BLOB/TEXT는 무조건 overflow page로 간다\u0026rdquo; 혹은 \u0026ldquo;항상 20byte 포인터만 남는다\u0026quot;는 식으로 단정하면 row format 차이를 놓치게 된다.\n실제 쿼리 실행 흐름 # SELECT * FROM user WHERE id = 5; 대략적인 흐름은 다음과 같다.\n1. Buffer Pool에 필요한 page가 있는지 확인 2. 없으면 clustered index의 root/branch/leaf page를 따라 내려감 3. 필요한 page를 디스크에서 읽어 Buffer Pool에 적재 4. leaf page 안의 record를 찾아 반환 여기서 핵심은 쿼리가 row 단위로 보이더라도, 실제 저장 엔진은 page 단위로 읽고 page 단위로 캐시한다는 점이다.\n정리 # 단위 성격 핵심 역할 Table 논리 사용자 관점의 테이블 Tablespace 논리+물리 연결 테이블/인덱스가 배치되는 저장 공간 File (.ibd) 물리 single-table tablespace의 backing file Segment 논리 인덱스 leaf/non-leaf 공간 관리 Extent 공간 할당 단위 page 묶음 Page I/O 단위 디스크 읽기/쓰기와 Buffer Pool 캐싱의 최소 단위 Record(Row) 데이터 단위 실제 행 레코드 핵심 포인트만 다시 묶으면:\n\u0026ldquo;테이블 = 파일\u0026ldquo;은 file-per-table 환경에서 자주 보이는 결과이지 본질 그 자체는 아니다. InnoDB는 실제로 tablespace -\u0026gt; segment -\u0026gt; extent -\u0026gt; page 구조로 공간을 관리한다. 성능 관점에서 가장 중요한 단위는 결국 page 다. References # MySQL 8.4 Reference Manual - File-Per-Table Tablespaces MySQL 8.4 Reference Manual - InnoDB Disk I/O and File Space Management MySQL 8.4 Reference Manual - The Physical Structure of an InnoDB Index MySQL 8.4 Reference Manual - InnoDB Row Formats ","date":"2026년 2월 7일","externalUrl":null,"permalink":"/posts/mysql-innodb-storage-structure/","section":"Posts","summary":"InnoDB에서 테이블이 디스크에 어떻게 저장되는지 — File, Segment, Extent, Page, Row 각 계층의 역할과 크기를 정리합니다.","title":"[MySQL] InnoDB에서 테이블은 정말 파일 하나로 저장될까? Tablespace, Segment, Extent, Page, Row","type":"posts"},{"content":"이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. TCP 커넥션이 바로 끊기지 않는 이유와 TCP keepalive, HTTP keep-alive, HikariCP keepalive의 차이를 정리합니다.\n먼저 구분해야 할 것 # 이 문서에서 \u0026ldquo;keepalive\u0026quot;라는 단어는 사실 서로 다른 세 가지를 가리킨다.\n이름 레이어 실제 의미 TCP keepalive L4 / OS 유휴 TCP 소켓에 대해 커널이 생존 확인 probe를 보냄 HTTP keep-alive L7 / HTTP 응답 후 TCP 연결을 닫지 않고 다음 HTTP 요청에도 재사용 Pool keepalive 애플리케이션 HikariCP 같은 풀 구현이 idle connection을 주기적으로 ping 이 셋을 섞어서 이해하면 \u0026ldquo;TCP keepalive = HikariCP keepalive = Nginx keepalive\u0026ldquo;처럼 보이는데, 실제로는 담당하는 레이어와 목적이 다르다.\nTCP 커넥션은 아무것도 안 보내도 유지되는가? # 프로토콜 차원에서는 Yes.\nTCP 커넥션은 3-way handshake가 끝나고 ESTABLISHED 상태에 들어가면, 데이터가 없다는 이유만으로 저절로 끊어지지는 않는다. 한쪽이 FIN 또는 RST를 보내거나, 커널/애플리케이션/중간 장비가 timeout 정책으로 정리할 때 끊어진다.\n즉, \u0026ldquo;아무 패킷도 안 보내면 TCP가 알아서 종료한다\u0026quot;는 설명은 틀리다.\n하지만 실무에서는 아래 주체들이 idle connection을 정리한다.\nDB 서버 자체의 idle timeout 프록시 / 로드밸런서의 idle timeout NAT / 방화벽의 connection tracking timeout 애플리케이션 또는 connection pool의 자체 수명 정책 그래서 TCP는 유지할 수 있지만, 실제 서비스 경로 전체가 유지해 준다고 볼 수는 없다.\n실제로 idle connection을 끊는 주체들 # 유휴 커넥션을 누가 끊는지는 환경마다 다르다. 숫자를 하나 외워서 모든 구간에 적용하면 틀리기 쉽다.\n대표 예시는 이 정도다.\n주체 예시 DB 서버 MySQL wait_timeout 기본값은 8시간 Application Load Balancer connection idle timeout 기본값 60초 Network/Gateway Load Balancer TCP idle timeout 기본 350초 NAT Gateway 350초 idle 후 timeout AWS Security Group connection tracking Nitro 기반 인스턴스에서 설정 가능하며, Nitrov6는 기본 350초이고 다른 인스턴스 타입은 기본 5일(432000초)일 수 있음 특히 ALB는 connection idle timeout(기본 60초)과 HTTP client keepalive duration(기본 1시간)을 따로 가진다.\nTCP Keepalive # TCP keepalive는 OS 레벨 기능이다. idle 상태의 TCP socket에 대해 커널이 probe를 보내서 연결이 아직 살아 있는지 확인한다.\n중요한 포인트는 다음이다.\nTCP keepalive는 자동으로 항상 켜져 있지 않다. socket에 SO_KEEPALIVE 가 켜져 있어야 동작한다. probe는 애플리케이션 데이터가 아니라 커널 레벨의 TCP probe 다. Linux 기본 sysctl 값은 보통 아래와 같다.\ntcp_keepalive_time = 7200 # 2시간 idle 후 시작 tcp_keepalive_intvl = 75 # 75초 간격 tcp_keepalive_probes = 9 # 9번 실패 시 dead 판단 이 기본값은 대부분의 프록시/LB/NAT idle timeout보다 길다. 그래서 OS 기본값을 그대로 두면, TCP keepalive가 동작하기도 전에 중간 장비가 먼저 idle connection을 정리해 버릴 수 있다.\n여기서 말하는 중간 장비 timeout 은 애플리케이션과 서버 사이에 있는 NAT, 로드밸런서, 방화벽, 프록시 같은 장비가 \u0026ldquo;너무 오래 조용한 연결\u0026quot;을 자체 정책으로 정리하는 시간을 뜻한다.\n예를 들어 NAT가 350초 동안 아무 트래픽이 없으면 연결 상태를 삭제하는데, Linux 기본 TCP keepalive는 7200초(2시간) 가 지나야 첫 probe를 보낸다. 그러면 keepalive probe를 보내기도 전에 NAT가 먼저 \u0026ldquo;이 연결은 오래 idle이었다\u0026quot;고 판단해 정리할 수 있다.\n또 하나 중요하다.\nTCP keepalive가 많은 NAT/방화벽/NLB 계층에는 도움을 줄 수 있지만 모든 제품이 keepalive probe를 \u0026ldquo;유효한 트래픽\u0026quot;으로 보고 idle timer를 리셋하는 것은 아니다 즉, \u0026ldquo;TCP keepalive만 켜면 어떤 middlebox(중간 장비) idle timeout도 무조건 안 끊긴다\u0026quot;는 식으로 이해하면 안 된다. 제품마다 keepalive probe를 어떻게 처리하는지가 다르기 때문이다.\nHikariCP는 무엇을 담당하나? # HikariCP는 TCP 기능이 아니라 JDBC connection pool 이다. 담당 역할은 크게 두 가지다.\n이미 열어 둔 DB connection을 재사용한다. 죽은 connection을 감지하거나 오래된 connection을 교체한다. 즉, HikariCP는 커넥션을 \u0026ldquo;계속 들고 있을지\u0026rdquo;, \u0026ldquo;언제 버릴지\u0026rdquo;, \u0026ldquo;언제 ping할지\u0026quot;를 관리하는 쪽이다.\n핵심 설정 # 설정 의미 보완 설명 maxLifetime 풀 안에서 connection이 머무를 수 있는 최대 수명 생존 확인 기능이 아니다. DB/LB timeout보다 조금 짧게 두는 게 권장된다. keepaliveTime idle connection에 대해 주기적 ping 수행 maxLifetime 보다 작아야 한다. connectionTestQuery 커스텀 validation SQL JDBC4 isValid() 를 지원하지 않는 legacy driver 에 주로 필요하다. idleTimeout idle connection 제거 기준 minimumIdle \u0026lt; maximumPoolSize 일 때만 의미가 있다. connectionTimeout 풀에서 connection 대기 시간 이 시간 안에 connection을 얻지 못하면 실패한다. 생존 유지와는 다른 설정이다. 현재 HikariCP README 기준으로는 connectionTimeout=30s, idleTimeout=10m, maxLifetime=30m, keepaliveTime=2m 이 기본값으로 문서화되어 있다. 다만 keepaliveTime 은 예전 버전 문서나 글에서 0으로 설명되는 경우가 있으므로, 운영 중인 라이브러리 버전 문서를 함께 확인하는 편이 안전하다.\nHikariCP가 실제로 ping하는 방법 # HikariCP는 idle connection을 ping할 때 아래 둘 중 하나를 쓴다.\nJDBC4 Connection.isValid() connectionTestQuery 즉, \u0026ldquo;HikariCP는 validation query 없이 전혀 검사하지 않는다\u0026quot;라고 하면 부정확하다. 더 정확한 설명은 이렇다.\n최신 JDBC driver라면 보통 isValid() 를 쓴다. legacy driver면 connectionTestQuery 를 쓸 수 있다. keepaliveTime 이나 풀에서 꺼내는 시점의 검증에서 필요할 때 ping이 발생한다. maxLifetime=30분 이라서 30분 유지되는가? # 아니다.\nmaxLifetime 은 \u0026ldquo;30분 동안 살아 있게 보장\u0026quot;하는 값이 아니라, 풀에서 connection을 최대 얼마 동안 보관할지를 정하는 값이다. 즉, HikariCP의 보관 정책이지, 네트워크나 DB가 실제 connection을 30분 동안 보존해 준다는 보장은 아니다.\n예를 들어 DB connection 경로가 아래처럼 생겼다고 해보자.\nApp -\u0026gt; NAT -\u0026gt; DB 그리고 정책이 이렇다고 하자.\nNAT: 350초 idle이면 상태 삭제 DB 서버: 10분 idle이면 connection 종료 HikariCP: maxLifetime=30분 이 경우 실제 connection의 수명은 30분이 아니라, 가장 먼저 끊는 쪽에 의해 결정된다.\n애플리케이션이 DB connection을 하나 만들어 pool에 넣는다. 한동안 쿼리가 전혀 없다. 350초쯤 지나면 NAT가 먼저 상태를 지울 수 있다. 10분쯤 지나면 DB 서버도 idle connection을 닫을 수 있다. HikariCP는 30분까지 보관하려 했더라도, 실제 connection은 그 전에 이미 죽어 있을 수 있다. 반대로 HTTP connection 경로에서는 ALB의 60초 idle timeout 같은 별도 정책이 적용된다.\n그래서 운영에서는 보통 이렇게 맞춘다.\n해당 connection이 실제로 지나는 DB / LB / NAT / 방화벽 timeout을 확인한다. maxLifetime 을 그보다 약간 짧게 둔다. 필요하면 keepaliveTime 이나 드라이버/OS의 TCP keepalive를 함께 설정한다. 또한 HikariCP 공식 README는 풀 안정성을 위해 TCP keepalive도 별도로 구성하라고 권장한다. 즉, HikariCP 설정만으로 OS socket keepalive가 자동 구성되는 것은 아니다.\nHTTP Keep-Alive vs TCP Keepalive # 이름이 비슷할 뿐, 실제로는 완전히 다른 메커니즘이다.\nHTTP keep-alive TCP keepalive 레이어 L7 L4 / OS 목적 같은 TCP 연결로 여러 HTTP 요청/응답 재사용 idle TCP 연결이 아직 살아 있는지 확인 기본 동작 HTTP/1.1에서는 지속 연결이 기본이고 Connection: close 로 종료 socket에 SO_KEEPALIVE 가 켜졌을 때만 동작 누가 담당하나 브라우저, Nginx, WAS, 프록시 커널 / socket 옵션 정리하면:\nHTTP keep-alive 는 \u0026ldquo;응답 후 연결을 닫지 않는 것\u0026rdquo; TCP keepalive 는 \u0026ldquo;오래 idle인 연결에 probe를 보내는 것\u0026rdquo; 이다.\nNginx Keepalive 구조 # Client ──[A]──\u0026gt; Nginx ──[B]──\u0026gt; WAS 여기서 A와 B는 서로 다른 keepalive 정책을 가진다.\n1) Downstream: Client ↔ Nginx # 이 구간은 보통 HTTP persistent connection 을 뜻한다.\nkeepalive_timeout 75; keepalive_requests 1000; keepalive_timeout 은 클라이언트와의 idle HTTP 연결을 얼마나 열어 둘지 keepalive_requests 는 하나의 연결로 몇 개 요청까지 처리할지 를 정한다.\n2) Upstream: Nginx ↔ WAS # 이 구간은 Nginx가 백엔드로 가는 연결을 캐시해서 재사용하는 구조다.\n여기서 말하는 upstream connection reuse 는 아주 단순하게 말하면:\n요청 하나를 처리할 때마다 Nginx -\u0026gt; WAS TCP connection을 매번 새로 만들고 바로 끊는 대신 요청 처리가 끝난 뒤에도 그 connection을 잠시 idle 상태로 남겨 두었다가 다음 요청이 같은 upstream 서버로 가야 할 때 그 connection을 다시 쓰는 것 이다.\n즉, 핵심은 \u0026ldquo;살아 있는 TCP connection을 다음 HTTP 요청에도 재사용한다\u0026rdquo; 는 것이다.\n이건 성능 최적화와 연결 재사용 정책에 가깝고, 뒤에서 나오는 TCP keepalive와는 목적이 다르다.\nupstream backend { server 127.0.0.1:8080; keepalive 32; } location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection \u0026#34;\u0026#34;; } 여기서 주의할 점:\nkeepalive 32 에서 32라는 숫자는 \u0026ldquo;지금 처리 중인 요청 수\u0026quot;를 세는 것도 아니고, \u0026ldquo;Nginx에서 WAS로 가는 전체 연결 수\u0026quot;를 세는 것도 아니다. 이 숫자는 각 worker가 요청 처리를 마친 뒤 끊지 않고 idle 상태로 남겨 둘 upstream connection의 최대 개수를 뜻한다. 즉, 새 요청이 들어왔을 때 다시 쓸 수 있도록 재사용용으로 보관해 두는 connection 을 worker 하나당 최대 32개까지 들고 있겠다는 의미다. 예를 들어 worker가 4개라면, idle upstream connection은 worker마다 최대 32개씩 잡힐 수 있으므로 전체 idle connection 수는 32보다 더 많아질 수 있다. 오래된 설명처럼 keepalive 32 = 백엔드 연결 총 32개 라고 이해하면 틀리다. 예를 들어 worker 1이 127.0.0.1:8080 으로 열린 connection을 20개 idle 상태로 들고 있다고 해보자. 그 상태에서 새 요청이 들어와 같은 upstream 서버로 보내야 하면, Nginx는 이미 열려 있던 idle connection 중 하나를 꺼내 다시 사용할 수 있다. 반대로 idle connection이 하나도 없으면 그때 새 connection을 만든다.\n즉, keepalive 32 는 \u0026ldquo;worker 하나가 다음 요청에 재사용하려고 안 끊고 들고 있는 idle upstream connection을 최대 32개까지 보관한다\u0026rdquo; 는 뜻으로 읽으면 된다.\n또 하나는 버전 차이다.\n오랫동안 Nginx는 upstream HTTP 프록시 기본값이 HTTP/1.0 이었기 때문에 proxy_http_version 1.1 과 proxy_set_header Connection \u0026quot;\u0026quot; 가 사실상 필수였다. NGINX 1.29.7부터는 HTTP upstream keep-alive가 기본 동작으로 바뀌었다. 그래도 설정 파일을 읽는 사람 입장에서는 위 두 줄을 명시적으로 유지하는 편이 여전히 이해하기 쉽다.\n3) Nginx의 TCP keepalive는 또 별개 # Nginx의 upstream connection reuse와 OS TCP keepalive는 다른 문제다.\n둘의 차이를 먼저 분리해서 보면 이해가 쉽다.\nupstream connection reuse: Nginx가 요청 처리가 끝난 뒤에도 Nginx -\u0026gt; WAS connection을 닫지 않고 남겨 두었다가, 다음 요청에 다시 쓰는 것 TCP keepalive: 아주 오래 idle 상태인 TCP socket이 아직 살아 있는지 커널이 probe로 확인하는 것 즉, 앞의 것은 \u0026ldquo;다음 요청에 재사용할까?\u0026rdquo; 에 대한 정책이고, 뒤의 것은 \u0026ldquo;이 socket이 아직 살아 있나?\u0026rdquo; 를 확인하는 OS 기능이다.\n필요하면 별도로:\nproxy_socket_keepalive on; 처럼 socket의 SO_KEEPALIVE 를 켤 수 있다.\n여기서 SO_KEEPALIVE 는 이 TCP socket에 대해 OS의 TCP keepalive 기능을 켜라는 socket 옵션이다.\n좀 더 풀면:\nNginx가 백엔드(WAS)와 TCP connection을 하나 연다. 그 connection은 운영체제 입장에서는 하나의 socket 이다. proxy_socket_keepalive on; 을 주면 Nginx는 그 socket에 SO_KEEPALIVE 옵션을 켠다. 그러면 이후의 keepalive probe 전송 여부와 타이밍은 커널의 TCP keepalive 설정을 따른다. 중요한 점은 proxy_socket_keepalive on; 이라고 해서 Nginx가 애플리케이션 데이터를 주기적으로 보내는 것은 아니라는 점이다. 이 설정은 OS에게 \u0026ldquo;이 소켓에는 TCP keepalive를 적용해라\u0026rdquo; 라고 알려 주는 것이다.\n즉:\nkeepalive 32 는 worker 하나가 요청이 끝난 뒤에도 안 끊고 남겨 둘 upstream connection을 몇 개까지 보관할지 proxy_socket_keepalive on 은 그 TCP socket에 OS keepalive 기능을 켤지 를 정한다.\n즉,\nkeepalive, keepalive_timeout 은 HTTP 연결 재사용 정책 proxy_socket_keepalive 는 TCP socket keepalive 다.\n\u0026ldquo;커넥션을 유지하는 기술\u0026quot;의 실제 의미 # 특별한 마법이 있는 게 아니라, 각 레이어에서 연결을 닫지 않고 재사용하려는 정책을 두는 것이다.\n1. 요청/쿼리 처리 완료 2. 응답 후 즉시 FIN을 보내지 않음 3. 일정 시간 idle 상태로 connection을 보관 4. 다음 요청이 오면 같은 connection 재사용 5. timeout 또는 수명 정책이 지나면 정리 다만 실제 생존은 다음이 함께 맞아야 한다.\n애플리케이션/풀의 수명 정책 DB 서버의 idle timeout 프록시/LB/NAT/방화벽 timeout 필요 시 TCP keepalive 또는 application-level ping Keepalive의 단점 # 단점 설명 메모리 소비 idle connection도 socket buffer와 각종 per-connection state를 점유 파일 디스크립터 고갈 유휴 연결이 많아지면 새 연결 수용이 어려워질 수 있음 오래된 connection 보관 상대가 비정상 종료했는데 다음 사용 시점까지 모를 수 있음 백엔드 연결 쏠림 upstream keepalive가 특정 인스턴스에 connection을 오래 붙잡을 수 있음 설정 충돌 maxLifetime, wait_timeout, LB idle timeout이 서로 안 맞으면 간헐적 오류가 남 예를 들어:\n동시 유휴 커넥션 100,000개 x 20KB = 약 2GB 처럼 idle connection 자체가 리소스 비용이 될 수 있다. 이 수치는 어디까지나 대략적인 예시이며, 실제 메모리 사용량은 커널/애플리케이션/프로토콜 구현에 따라 달라진다.\n전 구간 Keepalive 전체 그림 # Client ─── Nginx ─── WAS ─── HikariCP ─── MySQL │ │ │ │ │ │ │ └─ DB idle timeout (예: wait_timeout) │ │ └─ pool 재사용 / keepalive / maxLifetime │ └─ downstream/upstream HTTP keep-alive └─ client-side persistent connection 추가로 각 TCP socket에는 필요 시 OS TCP keepalive를 켤 수 있음 요약하면:\nHTTP keep-alive 는 요청 간 재사용 HikariCP 는 DB connection 재사용과 교체 정책 TCP keepalive 는 idle socket 생존 확인 을 담당한다.\n이 셋은 협력할 수는 있지만, 서로 같은 기능은 아니다.\n참고 자료 # Linux Kernel Documentation - IP Sysctl (tcp_keepalive_time, tcp_keepalive_intvl, tcp_keepalive_probes) HikariCP README - Configuration NGINX Official Docs - keepalive_timeout, keepalive_requests NGINX Official Docs - ngx_http_upstream_module keepalive NGINX Official Docs - proxy_socket_keepalive NGINX Official Docs - CHANGES (1.29.7) MySQL 8.4 Reference Manual - wait_timeout AWS Docs - Application Load Balancer connection idle timeout AWS Docs - Network Load Balancer TCP idle timeout AWS Docs - Troubleshoot NAT gateways (350초 idle timeout) AWS Docs - EC2 security group connection tracking AWS Docs - Classic Load Balancer idle timeout (TCP keep-alive probes do not prevent termination) ","date":"2026년 2월 1일","externalUrl":null,"permalink":"/posts/tcp-connection-keepalive/","section":"Posts","summary":"TCP 커넥션은 왜 안 끊기는지, HikariCP는 validation query 없이 어떻게 30분을 버티는지, Nginx/LB의 Keepalive는 뭐가 다른지 정리합니다.","title":"[Network] TCP 커넥션은 어떻게 유지되는가 — TCP Keepalive, HTTP Keepalive, HikariCP","type":"posts"},{"content":"","date":"2026년 2월 1일","externalUrl":null,"permalink":"/tags/hikaricp/","section":"Tags","summary":"","title":"HikariCP","type":"tags"},{"content":"","date":"2026년 2월 1일","externalUrl":null,"permalink":"/tags/keepalive/","section":"Tags","summary":"","title":"Keepalive","type":"tags"},{"content":"","date":"2025년 10월 31일","externalUrl":null,"permalink":"/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"이 글은 생활코딩의 Linux 강의를 수강하며, 별도로 궁금했던 부분을 조사하고 내용을 추가하여 정리한 학습 노트입니다.\n리눅스 시스템의 구조를 이해하고, 터미널에서 자유롭게 명령어를 다루기 위한 기초 학습 자료입니다.\n1. 리눅스 파일 시스템의 구조 (Directory Structure) # 리눅스는 윈도우와 달리 드라이브(C:, D:) 개념이 없고, 루트(/) 라는 최상위 디렉토리에서 모든 것이 시작되는 트리(Tree) 구조를 가집니다.\n디렉토리 설명 및 용도 / (Root) 파일 시스템의 최상위 시작점입니다. /bin User Binaries. 필수적인 기본 명령어들이 위치합니다. 최신 시스템에서는 /usr/bin의 링크인 경우가 많습니다. /sbin System Binaries. 시스템 관리용 필수 명령어들이 위치합니다. 최신 시스템에서는 /usr/sbin의 링크인 경우가 많습니다. /lib Libraries. 시스템과 프로그램들이 실행되는 데 필요한 공유 라이브러리 파일들이 위치합니다. /home 사용자 홈 디렉토리. 윈도우의 C:\\Users\\와 비슷합니다. cd ~ 명령어로 바로 이동할 수 있습니다. /etc 설정 파일(Configuration). 프로그램이나 시스템의 전반적인 설정 파일들이 모여 있습니다. /var 가변 데이터(Variable). 로그 파일(log), 데이터베이스 등 내용이 계속 바뀌거나 늘어나는 파일들이 저장됩니다. /tmp 임시 파일(Temporary). 많은 배포판에서 부팅 시 또는 주기적으로 정리되지만 항상 보장되지는 않으므로 민감한 데이터를 두지 마세요. /usr User System Resources. 배포판이 제공하는 대부분의 사용자용 프로그램과 라이브러리가 설치됩니다. 수동 설치는 보통 /usr/local(또는 /opt)에 둡니다. 2. 필수 기본 명령어 (Navigation \u0026amp; File Operation) # 터미널에서 현재 위치를 파악하고 파일/폴더를 조작하는 명령어들입니다.\n위치 확인 및 이동 # pwd (Print Working Directory): 현재 내가 어떤 경로에 위치해 있는지 보여줍니다. pwd # 출력 예시: /home/ubuntu cd (Change Directory): 디렉토리를 이동합니다. cd ~: 내 홈 디렉토리로 즉시 이동. cd ..: 상위(부모) 디렉토리로 이동. Tip: 경로를 입력할 때 Tab 키를 누르면 자동 완성이 됩니다. (오타 방지 및 속도 향상) 파일 및 디렉토리 조작 # ls (List): 현재 디렉토리의 파일 목록을 보여줍니다. ls -a: 숨김 파일(점으로 시작하는 파일)까지 모두 표시. ls -l: 파일의 권한, 소유자, 크기 등 자세한 정보를 리스트 형태로 표시. ls -al: 위 두 옵션을 합쳐서, 숨김 파일을 포함한 모든 파일의 상세 정보를 표시. mkdir (Make Directory): 새로운 디렉토리를 만듭니다. mkdir -p dir1/dir2: 부모 디렉토리까지 한 번에 생성합니다. (-p는 parents) cp (Copy): 파일이나 디렉토리를 복사합니다. cp [원본] [대상] cp -r [원본_디렉토리] [대상_디렉토리] # 디렉토리 복사 시 -r 옵션 필수 mv (Move): 파일을 이동하거나 이름을 변경할 때 사용합니다. mv old_name.txt new_name.txt # 이름 변경 mv file.txt /home/user/docs/ # 파일 이동 rm (Remove): 파일을 삭제합니다. rm -r [디렉토리]: 디렉토리를 삭제합니다. (내부 파일 포함) rm -f [파일]: 확인 메시지 없이 강제로 삭제합니다. (-f는 force) 매우 위험: rm -rf / 같은 명령어는 시스템 전체를 삭제할 수 있으니 절대 함부로 사용해서는 안 됩니다. 파일 내용 확인 및 생성 # touch: 비어있는 새 파일을 생성하거나, 기존 파일의 최종 수정 시간을 현재 시간으로 변경합니다. touch new_file.txt cat: 파일의 전체 내용을 터미널에 한 번에 출력합니다. 짧은 파일에 유용합니다. less: cat과 달리, 긴 파일의 내용을 페이지 단위로 편하게 볼 수 있게 해줍니다. (이동: 방향키, 종료: q) less large_log_file.log head / tail: 파일의 앞부분 또는 뒷부분 10줄을 기본으로 출력합니다. head -n 5 log.txt: 앞부분 5줄만 보기 tail -f log.txt: 파일에 내용이 추가될 때마다 실시간으로 계속 보여주기 (-f는 follow) file: 파일의 종류(텍스트, 이미지, 실행 파일 등)를 알려줍니다. file my_script.sh -\u0026gt; Bourne-Again shell script, ASCII text executable 3. 시스템 권한과 패키지 관리 # 권한 (Permissions) # sudo (SuperUser Do): 관리자(Root) 권한으로 명령을 실행합니다. 시스템 설정 변경이나 프로그램 설치 시 필수입니다. 왜 권한이 필요한지에 대해서는 14장에서 자세히 다룹니다. 패키지 매니저 (Package Manager) # 앱스토어처럼 프로그램을 손쉽게 설치, 업데이트, 삭제해주는 도구입니다. OS별로 명령어가 다릅니다.\n계열 대표 OS 패키지 매니저 주요 명령어 Debian Ubuntu, Debian, Mint apt sudo apt update\nsudo apt install [패키지]\n(apt-get도 널리 사용됨) Red Hat RHEL, Fedora, CentOS dnf sudo dnf install [패키지]\n(구형 시스템에서는 yum 사용) Arch Arch Linux, Manjaro pacman sudo pacman -Syu\nsudo pacman -S [패키지] macOS - Homebrew (brew) brew install [패키지]\nbrew upgrade Java 개발자 Tip: JDK 설치 Java(JDK)는 패키지 매니저로 간편하게 설치할 수 있습니다.\n# Ubuntu에서 OpenJDK 17 설치 sudo apt install openjdk-17-jdk # 여러 Java 버전이 설치된 경우, 사용할 버전 선택 sudo update-alternatives --config java 또 다른 방법: SDKMAN! 여러 JDK 버전을 자주 바꿔야 한다면 sdkman이 편리합니다.\n# 설치 curl -s \u0026#34;https://get.sdkman.io\u0026#34; | bash # 예) Temurin 21 설치 및 기본 설정 sdk install java 21-tem sdk default java 21-tem 기업/보안망에서는 설치 스크립트 내용을 먼저 확인하고 사내 정책을 준수하세요.\n파일 다운로드와 API 테스트 # wget # wget은 URL을 통해 파일을 다운로드하는 간단하고 직관적인 도구입니다.\nwget [URL]: URL의 파일을 현재 디렉토리에 원본 파일명 그대로 다운로드합니다. wget -O [저장할파일명] [URL]: 다운로드한 파일의 이름을 지정해서 저장합니다. curl (웹 개발자의 필수품) # curl(cURL)은 HTTP, HTTPS, FTP 등 다양한 프로토콜을 이용해 데이터를 전송하는 강력한 도구입니다. 단순 파일 다운로드뿐만 아니라, 웹 개발 시 API와 상호작용하는 거의 모든 상황을 흉내 낼 수 있어 \u0026lsquo;터미널 계의 Postman/Insomnia\u0026rsquo;로 불립니다.\n기본 GET 요청 (API End-point 확인) API가 잘 작동하는지 가장 간단하게 확인할 때 사용합니다.\n# 특정 사용자 정보를 요청하는 API를 터미널에서 바로 테스트 curl https://jsonplaceholder.typicode.com/users/1 헤더 정보 확인 (-i, -I) HTTP 상태 코드(200 OK, 404 Not Found 등)나 응답 헤더(Content-Type, Cache-Control 등)를 확인할 때 매우 유용합니다.\n# 응답 본문과 헤더를 함께 보기 curl -i https://example.com # 헤더 정보만 보기 (HEAD 요청) curl -I https://example.com POST 요청 (API에 데이터 전송) 로그인, 회원가입, 데이터 생성 등 클라이언트가 서버로 데이터를 보낼 때의 상황을 테스트합니다.\n# JSON 데이터를 API 서버로 전송하는 예제 curl -X POST \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;title\u0026#34;: \u0026#34;foo\u0026#34;, \u0026#34;body\u0026#34;: \u0026#34;bar\u0026#34;, \u0026#34;userId\u0026#34;: 1}\u0026#39; \\ https://jsonplaceholder.typicode.com/posts -X POST: HTTP 요청 메소드를 \u0026lsquo;POST\u0026rsquo;로 지정합니다. (-X GET, -X PUT, -X DELETE 등도 가능) -H \u0026quot;Content-Type: ...\u0026quot;: 요청 헤더를 설정합니다. REST API는 대부분 JSON을 사용하므로 필수적인 옵션입니다. -d '{\u0026quot;json\u0026quot;: \u0026quot;data\u0026quot;}': 요청에 담아 보낼 데이터(Request Body)를 지정합니다. 리다이렉트 따라가기 (-L) 요청한 URL이 다른 주소로 리다이렉트 될 때, 그 최종 목적지까지 따라가서 내용을 가져옵니다.\n# http://t.co/... 와 같은 단축 URL의 실제 내용을 볼 때 사용 curl -L http://t.co/I5YYd9dDA 4. 입출력 제어와 파이프라인(I/O Redirection) # 명령어의 결과(출력)를 화면이 아닌 파일로 저장하거나, 파일의 내용을 명령어의 입력으로 넣을 때 사용합니다.\n핵심 개념: 표준 스트림 # Standard Input (stdin, 0): 입력 (주로 키보드) Standard Output (stdout, 1): 출력 (주로 모니터) Standard Error (stderr, 2): 에러 메시지 명령어는 실행될 때 기본적으로 이 세 가지 입출력 통로를 가지고 시작합니다. 운영체제는 이 통로들에 0, 1, 2라는 번호를 붙여 관리하는데, 이를 파일 디스크립터(file descriptor, fd) 라고 부릅니다.\n여기서 \u0026ldquo;file\u0026quot;이라는 단어 때문에 실제 파일만 가리키는 것처럼 보이지만, 꼭 그렇지는 않습니다. 터미널, 파일, 파이프, 소켓 같은 입출력 대상 전체를 가리키는 번호표에 가깝습니다.\n그래서 쉘의 리다이렉션 문법은 결국 이렇게 읽으면 됩니다.\n0 = 입력 통로(stdin) 1 = 일반 출력 통로(stdout) 2 = 에러 출력 통로(stderr) 즉, 2\u0026gt; 같은 문법은 \u0026ldquo;2번 통로(stderr)를 다른 곳으로 보내라\u0026rdquo; 는 뜻입니다.\n왜 숫자와 \u0026gt;가 같이 붙을까? # 쉘은 \u0026ldquo;몇 번 통로를 어디로 보낼지\u0026rdquo; 를 숫자와 기호로 표현합니다.\n\u0026gt; 는 사실 1\u0026gt; 의 축약형입니다. 즉, ls \u0026gt; result.txt 는 ls 1\u0026gt; result.txt 와 같은 뜻입니다. 2\u0026gt; 는 2번 통로(stderr) 를 파일로 보냅니다. \u0026lt; 는 파일 내용을 0번 통로(stdin) 로 연결합니다. 예를 들면:\nls \u0026gt; result.txt # stdout(1) -\u0026gt; result.txt rm nofile.txt 2\u0026gt; error.log # stderr(2) -\u0026gt; error.log sort \u0026lt; list.txt # list.txt -\u0026gt; stdin(0) \u0026amp;는 무엇일까? 왜 2\u0026gt;\u0026amp;1처럼 쓰일까? # 초보자가 가장 많이 헷갈리는 부분이 바로 여기입니다.\n먼저 명령어 끝에 붙는 \u0026amp; 와 2\u0026gt;\u0026amp;1 안의 \u0026amp; 는 같은 모양이지만 문맥이 다릅니다.\nsleep 100 \u0026amp; 의 \u0026amp; 는 백그라운드 실행을 뜻합니다. 2\u0026gt;\u0026amp;1 의 \u0026amp; 는 \u0026ldquo;파일 이름이 아니라, 다른 파일 디스크립터를 가리킨다\u0026rdquo; 는 뜻입니다. 즉, 2\u0026gt;\u0026amp;1 은 이렇게 읽으면 됩니다.\n2\u0026gt;: 2번 통로(stderr)를 대상으로 한다 \u0026amp;1: 파일 이름 1이 아니라, 1번 통로(stdout)가 현재 향하는 곳을 가리킨다 그래서 2\u0026gt;\u0026amp;1 의 뜻은:\n\u0026ldquo;stderr(2)도 stdout(1)이 현재 가는 곳으로 보내라\u0026rdquo;\n입니다.\n이 차이를 꼭 구분해야 합니다.\nrm nofile.txt 2\u0026gt;1 위 명령은 에러를 1이라는 이름의 파일에 저장합니다.\n즉, 1을 파일 이름으로 본 것입니다.\n반면:\nrm nofile.txt 2\u0026gt;\u0026amp;1 이 명령은 에러(stderr)를 stdout과 같은 곳으로 보냅니다.\n여기서 \u0026amp;1은 파일이 아니라 1번 출력 통로를 뜻합니다.\n리다이렉션 기호 # \u0026gt;: 실행 결과(stdout)를 파일에 덮어쓰기로 저장합니다. (1\u0026gt;의 축약형) ls -l \u0026gt; result.txt \u0026gt;\u0026gt;: 실행 결과를 파일의 끝에 추가합니다. ls -al \u0026gt;\u0026gt; result.txt \u0026lt;: 파일의 내용을 명령어의 입력(stdin)으로 사용합니다. sort \u0026lt; list.txt # list.txt의 내용을 정렬하여 화면에 출력 2\u0026gt;: 에러(stderr)만 따로 파일에 저장합니다. rm nofile.txt 2\u0026gt; error.log \u0026amp;\u0026gt; 또는 \u0026gt; ... 2\u0026gt;\u0026amp;1: 출력(stdout)과 에러(stderr)를 모두 같은 곳으로 보냅니다. 자동화 스크립트에서 로그를 남길 때 자주 씁니다. # 명령어의 모든 결과(출력+에러)를 all.log에 덮어쓰기 # \u0026amp;\u0026gt; all.log은 \u0026gt; all.log 2\u0026gt;\u0026amp;1의 단축 표현(Bash/Zsh 전용)입니다. ls -l /nonexistent \u0026amp;\u0026gt; all.log # POSIX 호환까지 고려하면 이 형태가 더 보편적입니다. ls -l /nonexistent \u0026gt; all.log 2\u0026gt;\u0026amp;1 # 명령어의 모든 결과를 all.log에 추가하기 ls -l /nonexistent \u0026gt;\u0026gt; all.log 2\u0026gt;\u0026amp;1 여기서 중요한 포인트가 하나 더 있습니다. 2\u0026gt;\u0026amp;1 은 \u0026ldquo;stderr를 stdout과 같은 곳으로 보내라\u0026rdquo; 는 뜻이지, 파일을 직접 지정하는 문법이 아닙니다. 그래서 보통은 \u0026gt; 파일명 또는 \u0026gt;\u0026gt; 파일명 과 함께 사용합니다.\n예를 들어:\ncommand \u0026gt; all.log 2\u0026gt;\u0026amp;1 이 문장은 이렇게 읽습니다.\nstdout(1)을 먼저 all.log로 보낸다. stderr(2)도 stdout이 현재 가는 곳(all.log) 으로 보낸다. 즉, 최종적으로 stdout과 stderr가 둘 다 all.log로 들어갑니다.\n반대로 순서를 바꾸면 결과가 달라질 수 있습니다.\ncommand 2\u0026gt;\u0026amp;1 \u0026gt; all.log 이 경우에는 먼저 stderr가 현재 stdout이 향하는 곳(보통 터미널) 을 따라가고, 그 다음에야 stdout만 all.log 로 바뀝니다.\n즉, 초보자 입장에서는 \u0026gt; all.log 2\u0026gt;\u0026amp;1 순서를 외우는 편이 안전합니다.\n파이프라인 (Pipeline, |) # 하나의 프로그램의 **출력(stdout)**을 다른 프로그램의 **입력(stdin)**으로 연결하는 컨베이어 벨트입니다.\n활용 예시: ps aux | grep apache: 현재 실행 중인 모든 프로세스(ps) 중에서 \u0026ldquo;apache\u0026quot;만 필터링해서 봅니다. ls -l | wc -l: 파일 리스트의 줄 수를 세어 현재 디렉토리의 파일/폴더 개수를 확인합니다. grep (필터링) # 파이프라인과 자주 함께 쓰이며, 원하는 문자열이 포함된 행(Line)만 검색하는 강력한 도구입니다.\n자주 쓰는 옵션: -i (Ignore case): 대소문자를 구분하지 않고 찾습니다. -r (Recursive): 하위 디렉토리까지 모두 검색합니다. -n (Line number): 결과가 몇 번째 줄에 있는지 함께 출력합니다. -w (Word): 정확히 그 단어만 검색합니다. (예: grep -w \u0026quot;is\u0026quot;는 \u0026ldquo;this\u0026quot;를 제외) -v (Invert): 해당 단어가 포함되지 않은 줄만 출력합니다. 5. 쉘(Shell)과 커널(Kernel) # 사용자가 입력한 명령어가 하드웨어까지 전달되는 과정입니다.\nUser: 명령어를 입력 (예: ls) Shell: 사용자의 명령어를 해석하여 커널로 전달 (번역가 역할) Kernel: 하드웨어를 제어하여 실제 작업을 수행 Hardware: 물리적인 연산 수행 쉘의 종류 # bash: 대부분의 리눅스 시스템에 기본으로 설치된 가장 대중적인 쉘. zsh: bash와 호환되면서, 강력한 플러그인(oh-my-zsh)과 테마, 자동완성 기능으로 생산성을 크게 높여주는 쉘. fish: \u0026lsquo;Friendly Interactive SHell\u0026rsquo;의 약자로, 별도 설정 없이도 유용한 기능(자동 제안, 문법 강조 등)을 바로 쓸 수 있어 초보자에게 친화적인 쉘. echo $0 또는 ps -p $$: 현재 사용 중인 쉘을 확인하는 명령어. 쉘 스크립트 (Shell Script) # 반복적인 작업을 자동화하기 위해 명령어들을 파일에 모아두고 한 번에 실행하는 \u0026lsquo;대본\u0026rsquo;입니다.\nShebang (#!): 스크립트 파일의 가장 첫 줄에 작성하며, 이 파일을 어떤 쉘로 해석할지 지정합니다. #!/bin/bash # 위 코드는 아래 내용을 /bin/bash 쉘을 통해 실행하라는 의미 echo \u0026#34;Hello Shell Script!\u0026#34; 쉘 스크립트 실행 # 실행 권한 부여: chmod +x my_script.sh 스크립트 실행: ./my_script.sh 6. 리눅스 파일 검색 명령어 정리 # 1. locate (데이터베이스 기반 빠른 검색) # 미리 만들어진 파일 목록 데이터베이스(DB)를 검색하여 매우 빠르지만, 방금 생성한 파일은 못 찾을 수 있습니다.\n사용법: locate [파일이름] DB 업데이트(필요시): sudo updatedb 2. find (실시간 정밀 검색) # 느리지만 현재 파일 시스템을 직접 탐색하여, 이름, 종류, 크기, 시간 등 매우 다양한 조건으로 검색할 수 있습니다.\n기본 구조: find [경로] [옵션] [표현식] 경로: / (전체), . (현재 디렉토리), ~ (홈 디렉토리) 주요 옵션: 이름: -name \u0026quot;*.log\u0026quot; (대소문자 구분), -iname \u0026quot;*.log\u0026quot; (대소문자 무시) 타입: -type f (파일), -type d (디렉토리) 크기: -size +100M (100MB 초과), -size -10k (10KB 미만) 시간: -mtime -7 (7일 이내 수정), -mtime +7 (7일 이전에 수정) 검색 후 작업 수행 (-exec, -delete): -exec는 검색된 파일에 대해 다른 명령을 실행하는 강력한 기능입니다. {}는 검색된 파일을 의미하며, \\;는 명령어의 끝을 알립니다. # .tmp 파일을 찾아 삭제 전 확인 메시지 띄우기 (안전) find . -name \u0026#34;*.tmp\u0026#34; -exec rm -i {} \\; # .tmp 파일을 그냥 바로 삭제 (효율적) find . -name \u0026#34;*.tmp\u0026#34; -delete 7. 파일의 위치와 실행 원리 # 7.1. 명령어의 실제 위치 찾기 (which, whereis) # which [명령어]: $PATH 환경 변수에 설정된 경로만 검색하여, 실행될 명령어의 절대 경로를 찾아줍니다. $ which ls /bin/ls whereis [명령어]: 실행 파일뿐 아니라, 소스 코드, 매뉴얼 파일의 위치까지 알려줍니다. 7.2. 명령어 실행의 비밀 ($PATH) # ls나 java처럼 짧게만 입력해도 명령이 실행되는 이유는 쉘이 $PATH 라는 환경 변수에 등록된 디렉토리들을 순서대로 검색해 실행 파일을 찾아주기 때문입니다.\n확인 방법: echo $PATH Java 개발자 Tip: JAVA_HOME과 PATH Java 설치 후 JAVA_HOME 환경 변수를 설정하고, 이 변수를 이용해 $PATH에 JDK의 bin 디렉토리를 추가하는 것이 표준적인 방법입니다. 이렇게 해야 java, javac, jps 등 JDK의 모든 명령어를 어디서든 사용할 수 있습니다.\n# ~/.bashrc 또는 ~/.zshrc 에 추가 export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 export PATH=$JAVA_HOME/bin:$PATH 8. 컴퓨터 구조와 프로세스의 이해 # 8.1. 프로그램 vs 프로세스 # 프로그램 (Program): 스토리지(HDD, SSD)에 저장된, 실행되기 전의 정적인 코드 덩어리입니다. (예: /bin/ls 실행 파일) 프로세스 (Process): 프로그램이 실행되어 메모리에 올라가고, CPU에 의해 처리되고 있는 동적인 상태입니다. 모든 프로세스는 고유한 ID(PID)를 가집니다. 8.2. 부모와 자식 프로세스 (Parent-Child Process) # 리눅스의 모든 프로세스는 \u0026lsquo;가족 관계\u0026rsquo;처럼 계층 구조를 이룹니다. 이 개념을 이해하면 export가 왜 중요한지 완벽하게 이해할 수 있습니다.\n비유: \u0026ldquo;붕어빵 틀과 붕어빵\u0026rdquo;\n부모 프로세스 (Parent Process): 현재 실행 중인 프로세스입니다. (예: 여러분이 명령어를 입력하는 터미널(쉘)) 자식 프로세스 (Child Process): 부모 프로세스가 생성한 새로운 프로세스입니다. 어떻게 만들어지나요? (Fork \u0026amp; Exec)\n여러분이 터미널(부모)에서 java -jar my-app.jar 라는 명령어를 실행하면, 내부적으로 두 가지 일이 순식간에 일어납니다.\nFork (복제): 터미널(부모)이 자기 자신과 거의 똑같은 복사본, 즉 \u0026lsquo;자식\u0026rsquo;을 만듭니다. 마치 붕어빵 틀로 똑같은 모양을 찍어내는 것과 같습니다. 이 시점의 자식은 부모와 거의 모든 것을 공유합니다. (특히 환경 변수!) Exec (실행): 복제된 \u0026lsquo;자식\u0026rsquo;이 자신의 모습을 버리고, java라는 새로운 프로그램으로 갈아입습니다. 이제 이 자식은 JVM 프로세스가 되어 my-app.jar를 실행하기 시작합니다. 붕어빵 틀에서 나온 반죽에 팥소를 넣어 진짜 붕어빵으로 완성하는 과정과 같습니다. Java 개발자에게 왜 중요한가요? 바로 이 Fork(복제) 단계에서 부모의 \u0026lsquo;환경 변수\u0026rsquo;가 자식(JVM)에게 상속되기 때문입니다. 터미널(부모)에서 export SPRING_PROFILES_ACTIVE=prod 와 같이 설정하면, 자식인 java 프로세스는 이 값을 그대로 물려받습니다. Spring Boot 애플리케이션은 System.getenv()를 통해 이 환경 변수를 읽어들여, prod 프로파일용 application-prod.yml 설정을 로드하게 됩니다. export를 쓰지 않은 \u0026lsquo;쉘 변수\u0026rsquo;는 부모의 방에서만 쓰이는 비밀번호라 자식에게 상속되지 않는 것입니다.\n프로세스 관계 확인하기 # ps 명령어에 옵션을 주면 부모-자식 관계를 직접 확인할 수 있습니다. PPID가 바로 부모 프로세스의 ID입니다.\n# 현재 내가 실행한 프로세스들의 ID(PID)와 부모 ID(PPID)를 함께 보기 ps -o pid,ppid,comm # PID PPID COMMAND # 12345 1234 bash (내 터미널, 부모) # 54321 12345 ps (ps 명령어를 실행한 자식) pstree 명령어를 사용하면 이 관계를 나무 구조로 훨씬 더 명확하게 볼 수 있습니다. (설치 필요: sudo apt install psmisc)\npstree 이 부모-자식 관계와 상속의 개념은 리눅스 시스템 동작 방식의 핵심 중 하나입니다.\n9. 프로세스 모니터링과 관리 # 9.1. 프로세스 목록 확인 (ps) # ps -ef: 시스템의 모든 프로세스를 표준 포맷으로 상세하게 조회합니다. ps aux와 유사하지만, System V 계열(많은 리눅스 배포판)에서 더 표준으로 사용됩니다. ps -ef | grep java: 시스템에서 실행 중인 모든 java 프로세스를 필터링하여 확인합니다. 9.2. 실시간 모니터링 (top \u0026amp; htop) # top: 전통적인 실시간 프로세스 모니터링 도구. htop: top보다 시각적으로 보기 편하고 사용하기 쉬운 업그레이드 버전. (설치 필요: sudo apt install htop) Load Average: 시스템 부하 평균. CPU 코어 수보다 높으면 과부하 상태로 볼 수 있습니다. Java 개발자 Tip: JDK 내장 모니터링 도구 ps 외에 JDK가 제공하는 도구를 사용하면 JVM에 특화된 정보를 얻을 수 있습니다.\njps: 현재 실행 중인 Java 프로세스의 PID와 Main 클래스 이름을 보여줍니다. ps -ef | grep java 보다 훨씬 깔끔합니다. jstat -gc [PID] 1000: 해당 PID의 Java 프로세스에 대해 GC(Garbage Collection) 상태를 1초마다 보여줍니다. 메모리 누수나 GC 튜닝 시 유용합니다. 10. 멀티태스킹과 백그라운드 실행 # 10.1. 포그라운드 vs 백그라운드 # 포그라운드 (Foreground): 명령어를 실행하면 터미널의 제어권을 갖고 사용자 입력을 기다리는 상태. 백그라운드 (Background): 터미널 제어권 없이 뒤에서 조용히 실행되는 상태. 10.2. 작업 제어 # 프로세스 중단 (Ctrl + C): 포그라운드에서 실행 중인 프로세스에 **종료 신호(SIGINT)**를 보내 중단시킵니다. 프로세스 일시 정지 (Ctrl + Z): 실행 중인 프로세스를 멈추고 백그라운드로 보냅니다. (종료가 아님) 백그라운드 작업 확인 (jobs): 백그라운드에 있는 작업들의 목록과 번호(Job ID)를 확인합니다. 백그라운드에서 실행 (\u0026amp;): 명령어 뒤에 \u0026amp;를 붙이면 처음부터 백그라운드에서 실행됩니다. # 터미널을 종료해도 프로세스가 죽지 않도록 nohup과 함께 사용 nohup java -jar my-app.jar \u0026amp; Tip: \u0026amp;만 사용하면 터미널 세션이 끊길 때 프로세스도 함께 종료됩니다. nohup (No Hang Up) 명령어와 함께 사용해야 터미널 연결이 끊겨도 프로세스를 계속 실행시킬 수 있습니다. 출력과 에러는 nohup.out 파일에 자동으로 저장됩니다. 더 안정적인 운영을 위해서는 nohup보다 11장에서 배울 systemd를 사용하는 것이 좋습니다. 작업 전환 (fg, bg): fg %[작업번호]: 백그라운드 작업을 다시 포그라운드로 가져옵니다. (Foreground) bg %[작업번호]: 일시 정지된 작업을 백그라운드에서 계속 실행시킵니다. (Background) 10.3. 프로세스 종료: kill의 기술 # kill은 단순히 프로세스를 \u0026lsquo;죽이는\u0026rsquo; 것이 아니라, 특정 **\u0026lsquo;신호(Signal)\u0026rsquo;**를 보내는 섬세한 명령어입니다.\n1단계: PID 찾기 (feat. jps, pgrep) # 프로세스를 제어하려면 먼저 고유 번호인 **PID(Process ID)**를 알아야 합니다.\n# 1. jps로 깔끔하게 찾기 (추천) jps # 2. pgrep으로 이름으로 찾기 pgrep -f \u0026#34;my-app.jar\u0026#34; # 출력 예시: 12345 2단계: 신호 보내기 (상황에 맞게) # kill -15 [PID] 또는 kill [PID] (가장 일반적이고 안전한 종료)\nSIGTERM (15) 신호를 보냅니다. 이는 \u0026ldquo;애플리케이션아, 이제 정리하고 곱게 종료해줘\u0026rdquo; 라는 정중한 요청입니다. Java 개발자 Tip: 잘 만든 Spring Boot 애플리케이션은 이 신호를 받으면, 진행 중인 HTTP 요청을 안전하게 마무리하고, DB 커넥션 풀을 닫고, 메시지 큐 리스너를 중지하는 등 우아한 종료(Graceful Shutdown) 과정을 수행합니다. 항상 이 방법을 먼저 시도해야 합니다. kill -9 [PID] (최후의 수단, 강제 종료)\nSIGKILL (9) 신호를 보냅니다. 이는 프로세스가 무시할 수 없는 강제 종료 명령입니다. \u0026ldquo;지금 당장 모든 걸 멈추고 사라져!\u0026rdquo; 라는 의미입니다. Java 개발자 Tip: kill -15로도 종료되지 않는 응답 불가(Hang) 프로세스를 정리할 때만 사용하세요. 이미 종료된 뒤 부모가 수거하지 못한 좀비 프로세스는 kill -9로도 사라지지 않으니, 부모 프로세스를 종료하거나 systemd가 회수하도록 처리해야 합니다. JVM이 정리 작업(Shutdown Hook)을 할 시간조차 주지 않고 즉시 사라지므로, 데이터가 손상되거나 불완전한 상태로 남을 수 있습니다. kill -1 [PID] (설정 리로드)\nSIGHUP (1) 신호를 보냅니다. 많은 서버 프로그램들이 이 신호를 받으면 설정 파일을 다시 읽어들입니다(reload). 웹 개발자 Tip: Nginx 같은 웹 서버의 설정을 변경한 뒤, 서버를 중단시키지 않고 변경사항을 적용하고 싶을 때 사용합니다. (Java 애플리케이션 자체는 보통 이 신호를 직접 처리하지 않으므로, Java 앱 재시작이 필요 없는 Nginx 재설정에 주로 사용됩니다.) 3단계: 이름으로 한번에 제어 (pkill) # PID를 일일이 찾기 번거로울 때, 프로세스 이름을 이용해 신호를 보냅니다.\n# \u0026#34;my-app.jar\u0026#34;를 실행하는 모든 java 프로세스에 정중한 종료 신호 보내기 pkill -f \u0026#34;my-app.jar\u0026#34; # \u0026#34;hung-app.jar\u0026#34;처럼 응답하지 않는 프로세스에 강제 종료 신호 보내기 pkill -9 -f \u0026#34;hung-app.jar\u0026#34; 11. 데몬(Daemon)과 서비스 관리 (systemd) # 11.1. 데몬의 개념과 systemd # 데몬(Daemon): 시스템 백그라운드에서 항상 실행되며 특정 요청을 처리하기 위해 대기하는 프로그램. (예: 웹 서버, DB 서버) systemd: 현대 리눅스 배포판의 표준 시스템 및 서비스 관리자입니다. 과거의 init.d 스크립트나 service 명령어를 대체하여 데몬(서비스)을 관리합니다. 11.2. 서비스 제어 (systemctl) # systemctl은 systemd를 제어하는 핵심 명령어입니다. (대부분 sudo 필요)\n서비스 시작/중지: sudo systemctl start apache2.service sudo systemctl stop apache2.service 서비스 재시작/상태 확인: sudo systemctl restart apache2.service sudo systemctl status apache2.service (.service 확장자는 생략 가능) 11.3. 부팅 시 자동 실행 설정 # 자동 실행 활성화: sudo systemctl enable apache2 자동 실행 비활성화: sudo systemctl disable apache2 활성화 여부 확인: sudo systemctl is-enabled apache2 이 명령어들은 과거 /etc/rc.d/ 디렉토리에 심볼릭 링크를 만들던 복잡한 과정을 대체합니다. 11.4. Java 애플리케이션을 위한 systemd 서비스 등록 (실무 핵심) # nohup은 간단하지만, 서버가 재부팅되면 앱이 자동으로 실행되지 않고 로그 관리도 불편합니다. 현대 리눅스에서는 systemd를 사용해 Java 애플리케이션을 안정적인 \u0026lsquo;서비스\u0026rsquo;로 등록하고 관리합니다.\n서비스 파일 생성: /etc/systemd/system/ 경로에 [서비스명].service 파일을 생성합니다. (예: my-app.service)\nsudo vim /etc/systemd/system/my-app.service 서비스 파일 작성:\n[Unit] Description=My Spring Boot Application After=network.target [Service] User=myuser Group=mygroup ExecStart=/usr/bin/java -jar /home/myuser/app/my-app-0.0.1.jar --spring.profiles.active=prod SuccessExitStatus=143 Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target Description: 서비스에 대한 설명. User/Group: 서비스를 실행할 사용자 및 그룹. 보안을 위해 root가 아닌 전용 계정을 만드는 것이 좋습니다. ExecStart: 애플리케이션을 실행하는 명령어. 반드시 절대 경로를 사용해야 합니다. SuccessExitStatus=143: kill -15 (SIGTERM)로 정상 종료되었을 때의 종료 코드(143)를 성공으로 간주합니다. Restart=on-failure: 서비스가 비정상적으로 종료되면 10초 후에 자동으로 재시작합니다. WantedBy=multi-user.target: 시스템이 부팅될 때 이 서비스를 시작하도록 설정합니다. 환경 변수 관리 팁: 서비스 파일에 Environment=\u0026quot;SPRING_PROFILES_ACTIVE=prod\u0026quot;를 추가하거나 EnvironmentFile=/etc/default/my-app로 외부 파일을 읽게 하면, 코드와 비밀 값을 분리해 운영할 수 있습니다. (systemctl edit my-app으로 드롭인 파일을 만드는 것이 업데이트 시 충돌을 줄이는 안전한 방법입니다.) 서비스 적용 및 시작:\nsudo systemctl daemon-reload: 새로 만든 서비스 파일을 systemd가 인식하도록 리로드합니다. sudo systemctl start my-app: 서비스를 시작합니다. sudo systemctl status my-app: 서비스 상태를 확인합니다. (초록색 active (running) 확인) sudo journalctl -u my-app -f: my-app 서비스의 로그를 실시간으로 확인합니다. (tail -f와 유사) 부팅 시 자동 실행 설정:\nsudo systemctl enable my-app: 시스템이 부팅될 때 자동으로 서비스가 시작되도록 활성화합니다. sudo systemctl disable my-app: 자동 시작을 비활성화합니다. 12. 정기적 작업 스케줄링 (Cron) # 12.1. Cron의 개념 및 설정 # 정기적으로 반복되는 작업(백업, 로그 정리 등)을 자동화하는 도구입니다.\n설정 파일 수정: crontab -e 설정 목록 확인: crontab -l Tip: crontab -e 실행 시 기본 편집기가 불편하다면 export EDITOR=nano 명령으로 편집기를 변경한 후 사용해 보세요. 12.2. 작성 문법 # 형식: 분 시 일 월 요일 명령어 (왼쪽부터 순서대로)\n필드 의미 범위 분 몇 분에 0-59 시 몇 시에 0-23 일 매달 며칠에 1-31 월 몇 월에 1-12 요일 무슨 요일에 0-7 (0과 7이 일요일) *: \u0026lsquo;매번\u0026rsquo;을 의미합니다. 예시: # 매일 새벽 2시 10분에 백업 스크립트 실행 10 2 * * * /home/user/backup.sh \u0026gt; /var/log/backup.log 2\u0026gt;\u0026amp;1 13. 쉘 설정 자동화: 나만의 개발 환경 구축하기 # 개발을 하다 보면 git status를 수시로 입력하거나, cd ~/projects/my-awesome-project처럼 긴 경로로 계속 이동하는 등 매번 반복하는 작업이 많습니다. 또한 DATABASE_URL이나 API_KEY 같은 정보를 터미널에 임시로 입력하기도 합니다.\n매번 터미널을 열 때마다 이런 환경이 자동으로 설정된다면 훨씬 편하겠죠? 그 역할을 하는 것이 바로 \u0026lsquo;쉘 시작 스크립트\u0026rsquo;를 이용한 설정 자동화입니다.\n13.1. 쉘 설정의 핵심: 시작 파일, 변수, 그리고 별칭 # 1) 쉘 시작 파일: .bashrc 와 .zshrc (설정 레시피) # 새 터미널을 실행하면, 쉘(요리사)은 ~/.bashrc나 ~/.zshrc 같은 \u0026lsquo;레시피 노트\u0026rsquo;를 읽어 적힌 명령어들을 실행하며 작업 환경을 구성합니다.\n.bashrc: BASH 쉘이 시작될 때마다 읽는 개인 맞춤 설정집입니다. .zshrc: ZSH 쉘이 시작될 때마다 읽는 개인 맞춤 설정집입니다. 둘의 용도는 완벽히 동일하며, 내가 어떤 쉘을 쓰느냐에 따라 사용하는 \u0026lsquo;설정집\u0026rsquo; 이름만 달라집니다. 아래 명령어로 현재 사용 중인 쉘을 확인할 수 있습니다.\n# 내가 지금 쓰는 쉘 확인하기 (\u0026#34;bash\u0026#34; 또는 \u0026#34;zsh\u0026#34; 등이 출력됨) echo $0 2) 변수의 두 종류: 쉘 변수 vs 환경 변수 (내 방 vs 우리 집) # export는 쉘 설정에서 가장 중요하고 헷갈리는 개념입니다. **\u0026ldquo;우리 집\u0026rdquo;**과 \u0026ldquo;내 방\u0026rdquo; 비유로 확실히 이해해 봅시다.\n터미널(쉘) = \u0026lsquo;나\u0026rsquo; 쉘 변수 (API_KEY=\u0026quot;12345\u0026quot;) = \u0026lsquo;내 방\u0026rsquo;에서만 쓰는 비밀 암호. 오직 \u0026lsquo;나\u0026rsquo;(현재 터미널)만 알고 있으며, \u0026lsquo;내\u0026rsquo;가 실행하는 다른 프로그램(자식 프로세스)은 알 수 없습니다. 환경 변수 (export API_KEY=\u0026quot;12345\u0026quot;) = \u0026lsquo;우리 집\u0026rsquo; 모든 가족이 아는 공용 와이파이 비밀번호. \u0026lsquo;나\u0026rsquo;와 \u0026lsquo;내\u0026rsquo;가 실행하는 모든 자식들(프로그램)이 함께 사용하도록 상속됩니다. export 명령어 = \u0026lsquo;내 방 비밀 암호\u0026rsquo;를 \u0026lsquo;가족 공용 암호\u0026rsquo;로 승격시켜 모두에게 알려주는 확성기 역할. 여기서 초보자가 가장 많이 하는 질문이 바로 이것입니다.\n\u0026ldquo;~/.bashrc에 그냥 MY_VAR=hello라고만 적어도, 어차피 쉘이 자식을 만들 때 fork 하니까 자식도 그 값을 복사받는 것 아닌가요?\u0026rdquo;\n헷갈릴 만한 질문이고, 핵심은 fork만 있는 것이 아니라 실제 외부 명령 실행은 보통 fork + exec로 이어진다는 점입니다.\n조금 더 정확히 풀면:\n현재 쉘이 자식 프로세스를 만들 때 먼저 fork()를 한다. 그 다음 자식은 exec()로 java, python, env 같은 다른 프로그램으로 갈아탄다. 이때 새 프로그램에게 전달되는 것은 쉘 내부 상태 전체가 아니라, export된 환경 변수 목록이다. 즉, MY_VAR=hello처럼 export 없이 만든 쉘 변수는 현재 쉘 안에서는 보이지만, exec()되어 실행된 외부 프로그램에게는 기본적으로 전달되지 않습니다.\n그래서 아래 두 줄은 전혀 다르게 동작합니다.\nMY_VAR=hello export OTHER_VAR=world MY_VAR 는 현재 쉘 내부에서만 아는 값 OTHER_VAR 는 현재 쉘 + 이후 실행할 자식 프로세스도 아는 값 즉, 차이는 단순히 \u0026ldquo;변수냐 환경 변수냐\u0026quot;가 맞습니다. 다만 실무에서는 이 차이가 자식 프로세스에게 전달되느냐 아니냐를 결정하므로 매우 중요합니다.\nJava 개발자에게 export가 필수인 이유 터미널에서 java -jar my-app.jar로 Spring Boot 애플리케이션을 실행하면, 이 java 프로세스는 터미널의 \u0026lsquo;자식\u0026rsquo; 프로세스가 됩니다. Spring Boot 코드의 application.yml에서 ${MY_API_KEY}와 같이 환경 변수를 사용하거나, 코드 내에서 System.getenv(\u0026quot;MY_API_KEY\u0026quot;)를 통해 값을 읽으려고 할 때, 부모인 터미널이 export로 MY_API_KEY를 설정해주지 않았다면 자식인 Java 프로세스는 이 값을 절대 알 수 없습니다. 그래서 \u0026ldquo;API 키가 정의되지 않았습니다\u0026rdquo; 같은 에러가 발생하거나 설정값이 비어있게 됩니다. 외부 자식 프로세스에 값을 전달하려면 반드시 export를 사용해야 합니다.\n3) 변수의 영속성: 임시 변수 vs 영구 변수 (포스트잇 vs 레시피) # 터미널에서 설정한 변수는 왜 창을 닫으면 사라질까요? 임시 저장소(메모리)와 영구 저장소(파일)의 차이 때문입니다.\n터미널에서 직접 export (임시, 포스트잇) export MY_VAR=\u0026quot;hello\u0026quot;를 터미널에 입력하는 것은, 현재 터미널의 메모리(RAM)에 내용을 임시로 기록하는 것과 같습니다. 마치 모니터에 포스트잇을 붙이는 것과 같아서, 터미널을 닫으면 함께 사라집니다.\n설정 파일에 export 작성 (영구, 레시피) 변수를 영구적으로 만들려면, export MY_VAR=\u0026quot;hello\u0026quot;라는 명령어 자체를 ~/.bashrc 같은 설정 파일에 기록해야 합니다. 이 파일은 \u0026ldquo;쉘이 시작될 때마다 이 명령들을 실행해서 환경을 재구성해라\u0026quot;라고 알려주는 **\u0026lsquo;설정 레시피\u0026rsquo;**입니다. 새 터미널이 열릴 때마다 쉘은 이 레시피를 읽어 다시 \u0026lsquo;포스트잇\u0026rsquo;을 붙여주는 셈입니다.\n여기서도 중요한 차이가 있습니다.\n# ~/.bashrc MY_VAR=\u0026#34;hello\u0026#34; 이렇게만 적으면, 새 터미널이 열릴 때마다 그 쉘 자신은 MY_VAR를 알게 됩니다.\n즉, 터미널 안에서 echo \u0026quot;$MY_VAR\u0026quot; 는 잘 동작할 수 있습니다.\n하지만 그 터미널에서 실행한 외부 프로그램은 다릅니다.\npython3 -c \u0026#39;import os; print(os.getenv(\u0026#34;MY_VAR\u0026#34;))\u0026#39; 이런 프로그램은 MY_VAR를 못 볼 수 있습니다. 왜냐하면 MY_VAR는 아직 쉘 변수일 뿐이고, 환경 변수로 export되지 않았기 때문입니다.\n반대로:\n# ~/.bashrc export MY_VAR=\u0026#34;hello\u0026#34; 이렇게 적어두면:\n새 터미널이 열릴 때 쉘이 MY_VAR를 만든다. 동시에 이 값을 환경 변수로 등록한다. 그 이후 이 쉘이 실행하는 java, python, node, env 같은 자식 프로세스도 그 값을 전달받을 수 있다. 즉, ~/.bashrc에 적는다는 사실만으로 자식 프로세스 전달이 보장되는 것은 아닙니다.\n\u0026ldquo;설정 파일에 적었는가?\u0026rdquo; 와 \u0026quot;export 했는가?\u0026rdquo; 는 서로 다른 문제입니다.\n정리하면:\n~/.bashrc에 MY_VAR=hello 를 적는다 새로 열린 쉘 자신이 그 값을 사용 가능 ~/.bashrc에 export MY_VAR=hello 를 적는다 새로 열린 쉘 + 그 쉘이 실행하는 자식 프로세스도 사용 가능 또 하나 자주 헷갈리는 점이 있습니다. 새 터미널이 MY_VAR를 알고 있는 이유는, 부모 쉘이 무조건 그 값을 넘겨줬기 때문이 아니라 새 쉘이 자기 시작 파일(~/.bashrc)을 다시 읽어 실행했기 때문일 수도 있습니다.\n즉, \u0026ldquo;새 터미널에서도 보인다\u0026quot;와 \u0026ldquo;외부 자식 프로세스에게 전달된다\u0026quot;는 같은 이야기가 아닙니다.\n4) 나만의 명령어: 별칭(Alias) 만들기 # 자주 사용하는 길고 복잡한 명령어를 간단한 단축키처럼 만들 수 있습니다.\n# 별칭(alias) 설정 예시 alias gs=\u0026#34;git status\u0026#34; alias ll=\u0026#34;ls -alF\u0026#34; alias bootRun=\u0026#34;./gradlew bootRun\u0026#34; 이런 alias 설정들을 ~/.bashrc나 ~/.zshrc 파일에 추가해두면, 새 터미널부터 바로 사용할 수 있습니다.\n5) 현재 설정된 변수 확인하기 # \u0026ldquo;그래서 지금 내 터미널(메모리)에 어떤 \u0026lsquo;포스트잇\u0026rsquo;들이 붙어있지?\u0026ldquo;가 궁금할 때 사용하는 명령어입니다.\nenv 또는 printenv: 현재 쉘의 환경 변수(\u0026lsquo;가족 공용 암호\u0026rsquo;)만 골라서 보여줍니다. set: 환경 변수와 쉘 변수(\u0026lsquo;가족 공용 암호\u0026rsquo;와 \u0026lsquo;내 방 비밀 암호\u0026rsquo;)를 모두 보여줍니다. 13.2. 심화: 로그인 쉘과 인터랙티브 쉘 (.bash_profile vs .bashrc) # 쉘은 시작 방식에 따라 다른 설정 파일을 읽습니다. 이 미묘한 차이를 알면 좋습니다.\n.bash_profile (로그인 쉘)\n언제?: 시스템에 로그인할 때 딱 한 번 실행됩니다. (예: ssh로 원격 서버에 처음 접속할 때) 무엇을?: 한 번만 설정하면 되는 중요한 환경 변수 (JAVA_HOME 등)를 주로 설정합니다. .bashrc (인터랙티브 쉘)\n언제?: 로그인이 아닌, 새로운 터미널 창을 열 때마다 매번 실행됩니다. 무엇을?: 터미널을 편리하게 쓰기 위한 설정 (alias, 프롬프트 모양 등)을 주로 설정합니다. Best Practice (이렇게만 쓰세요!) # 위 구분이 헷갈린다면, 대부분의 리눅스 시스템에서 사용하는 모범 사례는 다음과 같습니다.\n모든 설정을 .bashrc에 몰아넣습니다. (alias, export 등)\n.bash_profile에서는 .bashrc를 불러오기만 합니다. 아래 코드를 ~/.bash_profile에 추가하거나, 이미 있다면 주석을 해제하세요.\n# ~/.bash_profile if [ -f ~/.bashrc ]; then source ~/.bashrc fi 이유: 이렇게 하면 ssh로 로그인할 때(.bash_profile 실행)도, 새 터미널을 열 때(.bashrc 실행)도 항상 동일하고 일관된 환경에서 작업할 수 있게 됩니다.\n13.3. 설정 적용하기: source 명령어 (새로고침) # .bashrc 파일을 수정해도, 이미 열려 있는 터미널 창에는 변경사항이 적용되지 않습니다. 터미널은 시작될 때만 설정 파일을 읽기 때문입니다.\nsource는 **현재 쉘에 스크립트 파일의 내용을 바로 적용(새로고침)**하라는 명령어입니다.\n# 1. ~/.bashrc 에 새로운 alias 추가 (예: alias gp=\u0026#34;git push\u0026#34;) # 2. 터미널을 껐다 켤 필요 없이, 아래 명령어로 바로 적용! source ~/.bashrc # 이제 새로운 alias \u0026#39;gp\u0026#39; 를 바로 사용할 수 있습니다. source는 터미널을 재시작하지 않고도 설정을 즉시 반영할 수 있는, 매우 유용한 명령어입니다.\n14. 다중 사용자와 권한 관리 # 리눅스는 여러 사용자가 동시에 접속해서 사용하는 \u0026lsquo;다중 사용자 시스템\u0026rsquo;입니다. 따라서 누가 어떤 파일에 접근하고 수정할 수 있는지를 통제하는 \u0026lsquo;권한\u0026rsquo;의 개념이 매우 중요합니다.\n14.1. 사용자 확인 및 전환 # id: **나(현재 사용자)**의 시스템 정보(UID, GID, 소속 그룹)를 확인합니다. who: 현재 시스템에 로그인해 있는 모든 사용자 목록을 보여줍니다. su - [ID]: 해당 사용자로 완전히 전환(로그인)합니다. (-는 해당 유저의 환경 설정까지 모두 불러오는 중요한 옵션) 14.2. 사용자 추가 및 관리 # sudo useradd -m [ID]: 새로운 사용자를 추가합니다. (-m은 홈 디렉토리 생성 옵션) sudo passwd [ID]: 사용자의 비밀번호를 설정하거나 변경합니다. sudo usermod -aG [그룹명] [ID]: 사용자를 특정 그룹에 추가합니다. (예: sudo usermod -aG sudo myuser는 myuser에게 관리자 권한 부여) sudo userdel -r [ID]: 사용자를 삭제합니다. (-r은 홈 디렉토리까지 모두 삭제) 14.3. 파일 권한(Permission) 완벽 이해 # 파일 권한은 특정 파일이나 디렉토리를 누가 읽고, 쓰고, 실행할 수 있는지 지정하는 규칙입니다. 권한은 세 종류의 사용자에 대해 정의됩니다.\nOwner (User): 파일의 소유자. Group: 소유자가 속한 그룹. (예: 개발팀, 관리자 그룹 등) Other: 소유자도 아니고 그룹에도 속하지 않은 제3자(나머지 모든 사용자). 권한 정보 해석하기 (ls -l) # ls -l 명령은 파일과 디렉토리의 상세 정보를 보여주며, 이 중 권한 정보가 가장 중요합니다.\ndrwxr-x--- 8 keonhongkoo staff 256 1 15 2023 .android\n파일 타입 (첫 글자)\nd: 디렉토리 (Directory) -: 일반 파일 (File) l: 심볼릭 링크 (Symbolic Link) 권한 모드 (다음 9자리 문자열: rwxr-x---)\nrwx (Owner): 첫 3자리는 소유자의 권한입니다. 읽기(r), 쓰기(w), 실행(x) 모두 가능합니다. r-x (Group): 중간 3자리는 그룹의 권한입니다. 읽기(r)와 실행(x)만 가능합니다. --- (Other): 마지막 3자리는 그 외 사용자의 권한입니다. 아무 권한도 없습니다. 기타 항목\n8: 하드 링크 수 keonhongkoo: 소유자 이름 staff: 그룹 이름 256: 파일 크기 (Byte) 1 15 2023: 마지막 수정 날짜 .android: 파일 또는 디렉토리 이름 권한의 의미: 파일 vs 디렉토리 # r, w, x 권한은 대상이 파일이냐 디렉토리냐에 따라 의미가 달라집니다.\n권한 기호 파일(File)에서의 의미 디렉토리(Directory)에서의 의미 읽기 r 파일의 내용 보기 (cat, vim) 디렉토리 내부의 파일 목록 조회 (ls) 쓰기 w 파일 내용 수정/저장 디렉토리 내 파일 생성, 삭제, 이름 변경 (touch, rm, mv) 실행 x 파일을 프로그램으로 실행 디렉토리 내부로 진입(접근) (cd) 주의: 디렉토리에 x 권한이 없으면 cd로 들어갈 수 없습니다. 또한, 내 파일이라도 해당 파일이 담긴 디렉토리에 w 권한이 없으면 파일을 삭제하거나 이름을 바꿀 수 없습니다. 권한 변경: chmod (Change Mode) # chmod는 파일이나 디렉토리의 권한을 변경하는 명령어입니다. 두 가지 방식(Symbolic, Octal)이 있습니다.\n1) 심볼릭(Symbolic) 방식: 직관적인 문자 사용\nchmod [대상][연산자][권한] [파일명] 형태로 사용합니다.\n대상: u(user), g(group), o(other), a(all) 연산자: +(추가), -(제거), =(지정) 권한: r(read), w(write), x(execute) # my_script.sh 파일에 대해... # 소유자(u)에게 실행(x) 권한을 추가(+) chmod u+x my_script.sh # 그룹(g)에게 쓰기(w) 권한을 제거(-) chmod g-w my_script.sh # 모든 사용자(a)에게 읽기(r) 권한만 있도록 지정(=) chmod a=r my_script.sh 2) 숫자(Octal) 방식: 빠르고 정확한 숫자 사용\n권한을 숫자로 표현하여 조합합니다.\nr (Read) = 4 w (Write) = 2 x (Execute) = 1 세 자리 숫자를 소유자-그룹-기타 순서로 지정합니다.\nrwx = 4+2+1 = 7 rw- = 4+2 = 6 r-x = 4+1 = 5 r-- = 4 # 소유자는 rwx(7), 그룹은 r-x(5), 기타 사용자는 r-x(5) 권한을 부여 # 일반적인 실행 파일이나 웹 디렉토리 권한으로 많이 사용됨 chmod 755 my_script.sh # 소유자는 rw-(6), 그룹은 r--(4), 기타 사용자는 r--(4) 권한을 부여 # 일반적인 파일 권한으로 많이 사용됨 chmod 644 my_document.txt # 모든 사용자에게 모든 권한을 부여 (보안상 주의 필요) chmod 777 shared_folder 재귀적 변경 (-R): 디렉토리와 그 안의 모든 하위 파일/폴더 권한을 한 번에 바꾸려면 -R 옵션을 사용합니다. # my_dir 내부의 모든 파일과 폴더에 755 권한 적용 chmod -R 755 my_dir 소유권 변경: chown (Change Owner) # chown은 파일이나 디렉토리의 소유자 및 소유 그룹을 변경하는 명령어입니다. (주로 sudo 필요)\n# my_file.txt의 소유자를 new_user로 변경 sudo chown new_user my_file.txt # my_file.txt의 소유 그룹을 new_group으로 변경 sudo chown :new_group my_file.txt # my_file.txt의 소유자와 그룹을 한 번에 변경 sudo chown new_user:new_group my_file.txt # my_dir 디렉토리와 그 안의 모든 내용(-R)의 소유자를 new_user로 변경 sudo chown -R new_user my_dir 15. 네트워크 기초와 인터넷의 원리 (Network Basics) # 서버를 다루기 위해서는 인터넷이 어떻게 연결되는지, 내 컴퓨터의 주소는 무엇인지 아는 것이 필수입니다.\n15.1. 클라이언트와 서버 (Client \u0026amp; Server) # 인터넷은 거대한 대화입니다. 이 대화는 정보를 요청하고 응답하는 두 주체로 이루어집니다.\n클라이언트 (Client, 손님): 웹 브라우저(Chrome 등)나 모바일 앱. 정보를 **요청(Request)**합니다. 서버 (Server, 제공자): 리눅스 시스템 위에서 돌아가는 웹 서버 프로그램(Apache, Nginx, Node.js 등). 요청받은 정보를 찾아 **응답(Response)**합니다. 동작 과정: 클라이언트: google.com 입력 (요청) 서버: index.html 파일 찾아서 전송 (응답) 클라이언트: 코드를 해석하여 화면에 그림 15.2. 주소 체계: IP와 도메인, 그리고 DNS # IP 주소 (IP Address): 컴퓨터가 통신을 위해 갖는 실제 숫자 주소 (예: 172.217.25.78). 전화번호에 비유됩니다. 도메인 네임 (Domain Name): 사람이 기억하기 쉬운 문자 주소 (예: google.com). 전화번호부의 이름에 비유됩니다. DNS (Domain Name System): 도메인 이름을 IP 주소로 바꿔주는 시스템입니다. Tip: 터미널에서 ping google.com을 입력하면 해당 도메인의 실제 IP를 확인할 수 있습니다. 15.3. 공인 IP vs 사설 IP (중요) # 서버 개발자가 가장 많이 헷갈리는 부분입니다. ip addr로 확인한 내 IP와 외부에서 보는 내 IP가 다를 수 있습니다.\n확인 방법: 내부 IP: ip addr (결과 중 inet 뒤의 숫자, 예: 10.0.x.x, 192.168.x.x) 외부 IP: curl ipinfo.io/ip (외부 인터넷 세상에서 나를 바라볼 때 보이는 IP) 종류 설명 비유 특징 공인 IP (Public IP) 통신사가 부여한 전 세계 유일한 주소 회사의 대표 전화번호 외부에서 직접 접속 가능 사설 IP (Private IP) 공유기(Router)가 내부 기기들에 부여한 주소 회사의 내선 번호 외부에서 직접 접속 불가 백엔드 개발자 Tip: 클라우드(AWS) 환경에서는? AWS EC2 같은 클라우드 서버도 똑같습니다. 인스턴스를 생성하면 Public IP(외부 접속용)와 Private IP(VPC 내부 통신용)를 각각 할당받습니다. DB 서버는 보안을 위해 Private IP로만 통신하도록 설정하는 것이 일반적입니다.\n16. 웹 서버 구축과 운영 (Web Server) # 내 리눅스 컴퓨터를 전 세계에 정보를 제공하는 서버로 만드는 과정입니다. 전통적인 Apache 서버를 기준으로 설명하지만, 원리는 Nginx 등 다른 서버도 동일합니다.\n16.1. 설치 및 실행 (Apache2) # 우분투(Ubuntu) 기준 명령어입니다.\n설치: sudo apt update \u0026amp;\u0026amp; sudo apt install apache2 서비스 관리 (systemctl): 실행: sudo systemctl start apache2 상태 확인: sudo systemctl status apache2 (초록색 active (running)이 떠야 정상) 접속 테스트: 브라우저 주소창에 http://localhost 또는 http://127.0.0.1 입력. \u0026ldquo;Apache2 Default Page\u0026quot;가 보이면 성공입니다. 16.2. 설정 파일과 DocumentRoot # 웹 서버가 \u0026ldquo;어디에 있는 파일을 보여줄지\u0026rdquo; 결정하는 설정입니다.\n설정 파일 위치: /etc/apache2/sites-enabled/000-default.conf DocumentRoot: 사용자가 접속했을 때 보여줄 웹 페이지 파일(index.html)이 위치한 최상위 디렉터리입니다. (기본값: /var/www/html) 실습: /var/www/html/index.html 파일을 sudo nano로 열어 내용을 수정해 보세요. 새로고침하면 내용이 바뀝니다. 16.3. 로그(Log) 분석: 서버의 블랙박스 # 서버에 문제가 생겼거나 해킹 시도가 의심될 때 가장 먼저 봐야 할 곳입니다.\n위치: /var/log/apache2/ access.log: 누가(IP), 언제, 무엇을 요청했는지 기록됩니다. error.log: 서버 내부 오류나 실행 실패 원인이 기록됩니다. 실시간 모니터링: tail -f /var/log/apache2/access.log Java 개발자 Tip: Apache vs Nginx (Reverse Proxy) 최근 실무에서는 Apache보다 Nginx를 더 많이 사용하는 추세이며, 특히 Java 진영에서는 거의 표준처럼 사용됩니다.\nNginx의 역할: Nginx는 가볍고 대용량 트래픽 처리에 매우 효율적인 웹 서버입니다. 사용자의 요청(80, 443 포트)을 직접 받는 \u0026lsquo;문지기\u0026rsquo; 역할을 하고, 내부에서 실제 비즈니스 로직을 처리하는 Spring Boot 애플리케이션(8080 포트)으로 요청을 안전하게 전달해주는 **리버스 프록시(Reverse Proxy)**로 주로 활약합니다. 왜 필요한가?: 성능 및 부하 분산: 정적 파일(이미지, CSS 등)은 Nginx가 빠르게 처리하고, 동적인 API 요청만 Spring Boot 서버로 넘겨 부하를 줄입니다. 여러 대의 Spring Boot 서버로 요청을 분산(로드 밸런싱)할 수도 있습니다. 보안: 애플리케이션 서버(WAS)를 외부에 직접 노출하지 않아 보안이 향상됩니다. 무중단 배포: 배포 시 Nginx가 트래픽을 구버전 앱에서 신버전 앱으로 점진적으로 전환시켜 서비스 중단 없이 배포가 가능합니다. 17. 원격 접속과 보안 (SSH) # 서버실(데이터 센터)에 직접 가지 않고도, 집이나 카페에서 서버 컴퓨터를 제어하는 기술입니다.\n17.1. SSH (Secure Shell) 개념 # 리눅스 서버 관리의 표준입니다. 모든 데이터가 암호화되어 전송되므로 안전합니다. SSH Server: 제어 받는 컴퓨터(서버)에 설치 (openssh-server). SSH Client: 제어 하는 컴퓨터(내 맥북/노트북)에 설치. 17.2. 접속 방법 # 기본 명령어: ssh [사용자ID]@[서버IP] ssh ubuntu@192.168.0.65 포트 지정: 보안을 위해 기본 포트(22)를 다른 번호로 바꾼 경우 -p 옵션을 사용합니다. ssh -p 2222 ubuntu@192.168.0.65 백엔드 개발자 Tip: 비밀번호 대신 \u0026lsquo;키 파일(.pem)\u0026rsquo; 사용 실무(AWS 등)에서는 보안상 비밀번호 로그인을 막아두고, **SSH 키 파일(Key Pair)**을 사용합니다.\n# -i 옵션으로 키 파일 지정 ssh -i my-key.pem ubuntu@3.12.34.56 이때 키 파일의 권한은 반드시 400(chmod 400 my-key.pem)이어야 합니다. (너무 공개된 권한이면 SSH가 접속을 거부함)\n18. 포트, 방화벽과 외부 접속 # \u0026ldquo;서버를 켰는데 외부에서 접속이 안 돼요!\u0026ldquo;라는 문제의 99%는 이 부분 설정 문제입니다.\n18.1. 포트(Port)란? # IP가 컴퓨터(건물)를 찾는 주소라면, 포트는 그 안의 **프로그램(방 번호)**을 찾는 주소입니다. (0 ~ 65535번)\n주요 포트 (Well-Known Ports):\n22: SSH (원격 접속) 80: HTTP (웹) 443: HTTPS (보안 웹) 3306: MySQL / MariaDB 5432: PostgreSQL 6379: Redis 8080: Tomcat, Spring Boot 등 WAS의 대체/개발용 포트 하나의 포트는 하나의 프로그램만 사용할 수 있습니다. (이미 80번을 Apache가 쓰고 있다면 Nginx는 80번을 쓸 수 없음)\n포트 점유 확인: 특정 포트(예: 8080)를 누가 쓰는지 알고 싶다면 sudo ss -ltnp | grep 8080 또는 sudo lsof -i :8080으로 확인합니다. Spring Boot 서버가 안 뜨거나 포트 충돌이 날 때 가장 먼저 확인하세요.\n18.2. 방화벽 (Firewall) # 서버로 들어오고 나가는 네트워크 트래픽을 통제하는 보안 시스템으로, 허가된 포트가 아니면 모든 접근을 차단합니다. 외부 접속 문제의 또 다른 주요 원인입니다.\nufw (Ubuntu 기본 방화벽) 명령어: # 방화벽 활성화 sudo ufw enable # 방화벽 상태 확인 sudo ufw status # 특정 포트 허용 (Nginx, SSH, Spring Boot App) sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw allow 22/tcp sudo ufw allow 8080/tcp # 규칙 삭제 sudo ufw delete allow 8080/tcp 18.3. 포트 포워딩 (Port Forwarding) # 공유기를 사용하는 환경(집, 사무실)에서 외부 접속을 가능하게 해주는 기술입니다.\n문제: 외부에서는 공유기의 공인 IP까지만 접근 가능하고, 내 컴퓨터의 사설 IP(192.168...)로는 들어올 수 없습니다. 해결: 공유기에게 이정표를 세워줍니다. \u0026ldquo;외부에서 공인 IP의 9000번 포트로 들어오면 -\u0026gt; 내 컴퓨터(192.168.0.65)의 8080번 포트로 연결해줘!\u0026rdquo; 18.4. 설정 및 테스트 단계 # 방화벽 확인: 서버 자체의 방화벽(ufw 등)이 원하는 포트(예: 80, 8080)를 허용하는지 확인합니다. 공유기 설정 페이지 접속: 보통 192.168.0.1 등의 주소로 접속 (Default Gateway 확인). NAT/포트포워딩 설정: 내부 IP: 서버 컴퓨터의 사설 IP 외부 포트: 9000 (외부에 노출할 번호) 내부 포트: 8080 (실제 서버 프로그램 포트) 접속 테스트: 스마트폰(LTE/5G 모드)에서 http://[내 공인IP]:9000 접속 시도. (Wi-Fi 환경에서는 테스트가 제대로 안 될 수 있음) 백엔드 개발자 Tip: 클라우드와 Docker\n클라우드 (AWS, GCP, Azure 등): 클라우드 환경에서는 공유기 대신 **\u0026lsquo;보안 그룹(Security Group)\u0026rsquo;**이나 **\u0026lsquo;방화벽 규칙(Firewall Rules)\u0026rsquo;**에서 인바운드(Inbound) 규칙을 설정해줘야 외부 접속이 가능합니다. (예: 80, 443, 22, 8080 포트 허용) Docker: docker run -p 8080:8080 my-app 에서 -p 옵션이 바로 포트 포워딩 개념입니다. \u0026ldquo;호스트(리눅스) 서버의 8080번 포트로 들어온 요청을 -\u0026gt; 컨테이너 내부의 8080번 포트로 연결하라\u0026quot;는 의미입니다. ","date":"2025년 10월 31일","externalUrl":null,"permalink":"/posts/linux-practice/","section":"Posts","summary":"리눅스 파일 시스템, 터미널 명령어, 권한, 프로세스, 네트워크, SSH까지 핵심을 정리한 입문 가이드입니다.","title":"Linux 입문자를 위한 핵심 가이드","type":"posts"},{"content":"","date":"2025년 10월 31일","externalUrl":null,"permalink":"/tags/shell/","section":"Tags","summary":"","title":"Shell","type":"tags"},{"content":"","date":"2025년 10월 31일","externalUrl":null,"permalink":"/tags/ssh/","section":"Tags","summary":"","title":"SSH","type":"tags"},{"content":"","date":"2025년 10월 31일","externalUrl":null,"permalink":"/tags/systemd/","section":"Tags","summary":"","title":"Systemd","type":"tags"},{"content":"","date":"2025년 4월 12일","externalUrl":null,"permalink":"/tags/datasource/","section":"Tags","summary":"","title":"DataSource","type":"tags"},{"content":"","date":"2025년 4월 12일","externalUrl":null,"permalink":"/tags/h2/","section":"Tags","summary":"","title":"H2","type":"tags"},{"content":"","date":"2025년 4월 12일","externalUrl":null,"permalink":"/tags/jdbc/","section":"Tags","summary":"","title":"JDBC","type":"tags"},{"content":"이 글은 Spring 6 스터디를 진행하며 작성한 코드 위주의 실습 기록입니다.\n1. JDBC 기초 # 1.1 JDBC API 개요 # JDBC가 등장한 배경과 필요성 # 애플리케이션을 개발할 때 중요한 데이터는 대부분 데이터베이스에 보관한다.\n클라이언트가 애플리케이션 서버를 통해 데이터를 저장하거나 조회하면, 애플리케이션은 데이터베이스와 아래의 통신 과정을 통해 데이터를 주고받는다.\n문제는? 각각의 데이터베이스마다 커넥션을 연결하는 방법, SQL을 전달하는 방법, 그리고 결과를 전달 받는 방법 모두 다르다는 것.\n따라서 데이터베이스를 다른 종류의 데이터베이스로 변경하면 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 함께 변경해야 한다.\n이러한 문제를 해결하기 위해 1997년 JDBC라는 자바 표준이 등장했다.\nJDBC 핵심 클래스와 인터페이스 # JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 접속할 수 있도록 하는 Java API.\n대표적으로 Connection, Statement, ResultSet을 인터페이스로 정의해서 제공한다.\n그런데 인터페이스 만으로는 동작하지 않는다. 각각의 데이터베이스 회사는 자신의 DB에 맞도록 JDBC의 인터페이스를 구현해서 제공하는데, 이것을 JDBC 드라이버라 한다.\nJDBC를 이용하는 작업의 일반적인 순서는 다음과 같다.\nDB 연결을 위한 Connection을 가져온다. SQL을 담은 Statement(또는 PreparedStatement)를 만든다. 만들어진 Statement을 실행한다. 조회의 경우 SQL 쿼리의 실행 결과를 ResultSet으로 받아서 정보를 저장할 오브젝트(User, Post, …) 에 옮겨준다. 작업 중에 생성된 Connection, Statement, ResultSet 같은 리소스는 작업을 마친 후 반드시 닫아준다. JDBC API가 만들어내는 예외(Exception)를 잡아서 직접 처리하거나, 메소드에 throws를 선언해서 예외가 발생하면 메소드 밖으로 던지게 한다. DB 커넥션이라는 제한적인 리소스를 공유해 사용하는 서버에서 동작하는 JDBC 코드에는 반드시 지켜야 할 원칙이 있다. 바로 예외처리다.\n정상적인 JDBC 코드의 흐름을 따르지 않고 중간에 어떤 이유로든 예외가 발생했을 경우에도 사용한 리소스를 반드시 반환하도록 만들어야 하기 때문이다. 그렇지 않으면 시스템에 심각한 문제를 일으킬 수 있다.\nStatement vs PreparedStatement\nPreparedStatement는 Statement의 자식 타입인데, 물음표(?)를 통한 파라미터 바인딩을 가능하게 해준다.\nSQL Injection 공격을 예방하려면 PreparedStatement를 통한 파라미터 바인딩 방식을 사용해야한다.\nDriverManager는 사용할 애플리케이션에 대해 라이브러리에 등록된 JDBC(Java Database Connectivity) 드라이버 세트를 관리하고 커넥션을 획득하는 기능을 제공한다.\nJDBC가 제공하는 DriverManager 클래스\n출처: 김영한의 Spring DB 1편\n정리\nJDBC의 등장으로 애플리케이션 로직은 이제 JDBC 표준 인터페이스에만 의존한다.\n따라서 데이터베이스를 다른 종류의 데이터베이스로 변경하고 싶으면 JDBC 구현 라이브러리만 변경하면 된다.\nJDBC를 이용한 H2 DB 접근 # H2 데이터 베이스 다운로드\nhttps://www.h2database.com/html/download-archive.html\nMAC 사용자\n디렉토리 이동: cd bin\n권한 설정: chmod 755 h2.sh\n실행: ./h2.sh\nWINDOW 사용자\n실행: h2.bat\n데이터베이스 파일 생성 방법\nJDBC URL: jdbc:h2:~/spring6\n사용자명: sa\n비밀번호: 1234\n데이터베이스 파일 생성 후 접속 방법\nJDBC URL: jdbc:h2:tcp://localhost/~/spring6\n테이블 생성과 데이터 삽입\ndrop table post if exists cascade; create table post ( post_id bigint primary key, title varchar(255) not null, content varchar(255) not null, likes int not null ); Post 클래스 생성\n@Getter @EqualsAndHashCode public class Post { private final Long postId; private final String title; private final String content; private int likes; private Post(Long postId, String title, String content, int likes) { this.postId = postId; this.title = title; this.content = content; this.likes = likes; } // 신규 생성 시 사용 (좋아요 기본값 0 강제) public static Post create(Long postId, String title, String content) { return new Post(postId, title, content, 0); } // DB 등에서 로딩할 때 사용 public static Post of(Long postId, String title, String content, int likes) { return new Post(postId, title, content, likes); } } PostRepository 클래스 생성\n@Slf4j public class PostRepository { public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } PostRepositoryTest 생성\nimport static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest class PostRepositoryTest { @AfterEach void clear() throws SQLException { PostRepository postRepository = new PostRepository(); postRepository.deleteAll(); } @DisplayName(\u0026#34;PostRepository save 테스트\u0026#34;) @Test void save() throws Exception { PostRepository postRepository = new PostRepository(); Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); } @DisplayName(\u0026#34;PostRepository get 테스트\u0026#34;) @Test void get() throws Exception { // given PostRepository postRepository = new PostRepository(); Long postId = 1L; Post post = Post.create(postId, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when Post savedPost = postRepository.findById(postId); // then assertEquals(post, savedPost); } @DisplayName(\u0026#34;PostRepository deleteAll 테스트\u0026#34;) @Test void deleteAll() throws Exception { // given PostRepository postRepository = new PostRepository(); Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when postRepository.deleteAll(); // then assertThrows(Exception.class, () -\u0026gt; postRepository.findById(1L)); } } 1.2 DataSource 설정 # DataSource 개념과 필요성 # 기존에는 PostRepository가 H2를 직접 사용하기 위해 DB URL, USERNAME, PASSWORD 등의 구체적인 정보에 의존하고 있다.\n따라서 H2가 아닌 MySQL로 데이터베이스를 변경한다면 PostRepository의 코드를 변경해 줘야한다.\n또한 커넥션을 얻는 방법은 커넥션 풀을 사용할 수도 있다. 이 경우에도 PostRepository을 직접 변경해야한다.\n이러한 문제를 해결하기 위해, DB 커넥션을 가져오는 기능을 ConnectionMaker 인터페이스를 도입하여 추상화를 진행해보자.\nConnectionMaker 인터페이스 도입으로 PostRepository는 구체적인 DB 또는 커넥션 획득 방법에 대해 알 필요 없이 해당 인터페이스에만 의존하면 된다.\nConnectionMaker\npublic interface ConnectionMaker { Connection makeConnection() throws SQLException; } DBConnectionMaker\npublic class DBConnectionMaker implements ConnectionMaker { @Override public Connection makeConnection() throws SQLException { return DriverManager.getConnection(\u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;, \u0026#34;sa\u0026#34;, \u0026#34;1234\u0026#34;); } } PostRepository\n@Slf4j public class PostRepository { private final ConnectionMaker connectionMaker; public PostRepository(ConnectionMaker connectionMaker) { this.connectionMaker = connectionMaker; } public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = connectionMaker.makeConnection(); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = connectionMaker.makeConnection(); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = connectionMaker.makeConnection(); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } BeanFactory\n@Configuration public class BeanFactory { @Bean public PostRepository postRepository() { return new PostRepository(connectionMaker()); } @Bean public ConnectionMaker connectionMaker() { return new DBConnectionMaker(); } } PostRepositoryTest\nimport static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest class PostRepositoryTest { @Autowired private PostRepository postRepository; @AfterEach void clear() throws SQLException { postRepository.deleteAll(); } @DisplayName(\u0026#34;PostRepository save 테스트\u0026#34;) @Test void save() throws Exception { Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); } @DisplayName(\u0026#34;PostRepository get 테스트\u0026#34;) @Test void get() throws Exception { // given Long postId = 1L; Post post = Post.create(postId, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when Post savedPost = postRepository.findById(postId); // then assertEquals(post, savedPost); } @DisplayName(\u0026#34;PostRepository deleteAll 테스트\u0026#34;) @Test void deleteAll() throws Exception { // given Post post = Post.create(1L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;); postRepository.save(post); // when postRepository.deleteAll(); // then assertThrows(Exception.class, () -\u0026gt; postRepository.findById(1L)); } } 사실 자바에서는 DB 커넥션을 가져오는 오브젝트의 기능을 추상화해서 비슷한 용도로 사용할 수 있게 만들어진 DataSource라는 인터페이스가 이미 존재한다.\n따라서 실전에서 ConnectionMaker와 같은 인터페이스를 만들어서 사용할 일은 없을 것이다.\nDriverManager 와 DataSource\nJDBC를 사용할 때 DriverManager와 DataSource는 “DB 커넥션을 어떻게 관리할 것인가?”라는 같은 문제를 다루지만, 역할과 추상화 레벨이 서로 다르다.\nDriverManager은 커넥션 풀이나 고급 관리 로직을 포함하지 않는, **“**JDBC 드라이버를 어떻게 호출하느냐” 정도를 추상화한 셈. 그리고 클래스다.\nDataSource 인터페이스는 “엔터프라이즈 환경에서 커넥션을 안정적으로 획득하는 방법”에 대한 추상화로서, 구현체에 따라 커넥션 풀까지 포함할 수 있다.\nSpring은 DriverManager도 DataSource를 통해서 사용할 수 있도록 DriverManagerDataSource라는 DataSource를 구현한 클래스를 제공한다.\n이제 애플리케이션 로직은 DataSource인터페이스에만 의존하면 된다. 덕분에 DriverManagerDataSource를 통해서 DriverManager를 사용하다가 커넥션 풀을 사용하도록 코드를 변경해도 애플리케이션 로직은 변경하지 않아도 된다.\nConnectionConst\npublic class ConnectionConst { public static final String URL = \u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;; public static final String USERNAME = \u0026#34;sa\u0026#34;; public static final String PASSWORD = \u0026#34;1234\u0026#34;; } BeanFactory\n@Configuration public class BeanFactory { @Bean public PostRepository postRepository() { return new PostRepository(dataSource()); } @Bean public DataSource dataSource() { return new DriverManagerDataSource(URL, USERNAME, PASSWORD); } } PostRepository\n@Slf4j public class PostRepository { private final DataSource dataSource; public PostRepository(DataSource dataSource) { this.dataSource = dataSource; } public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } Spring Boot에서 Connection Pool과 DataSource 설정하기 # DriverManager는 예전부터 존재하던 JDBC 초창기 클래스이다. 따라서 커넥션 풀(Connection Pooling)과 같은 고급 기능은 포함되어 있지 않다.\nDriverManagerDataSource 는 Spring이 제공하는 DataSource 인터페이스 구현체 중 하나로, 내부적으로 DriverManager를 호출하여 매번 새로운 커넥션을 생성한다.\n데이터베이스 커넥션을 획득할 때는 다음과 같은 복잡한 과정을 거친다.\n애플리케이션 로직은 DB 드라이버를 통해 커넥선을 조회한다. DB 드라이버는 DB와 TCP/IP 커넥션을 연결한다. 물론 이 과정에서 3way handshake 같은 네트워크 동작이 발생한다. DB 드라이버는 TCP/IP 커넥션이 연결되면 ID, PW와 같은 기타 부가정보를 DB에 전달한다. DB는 ID, PW를 통해 내부 인증을 완료하고, 내부에 DB 세션을 생성한다. DB 커넥션은 생성이 완료되었다는 응답을 보낸다. DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환한다. 이렇게 커넥션을 새로 만드는 것은 시간도 많이 소모되며 TCP/IP 연결을 위해 네트워크 리소스가 매번 사용된다.\n→ 사용자에게 좋지 않은 경험을 줄 수 있다.\n이러한 문제를 해결하기 위해 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 아이디어가 나타났다.\nSpring Boot 2.0 부터 기본 DataSource로 설정되는 HikariCP는 직접 DriverManager.getConnection()을 호출하지 않고, 내부적으로 JDBC Driver의 connect() 메서드를 통해 물리 커넥션을 획득하고 관리한다.\n커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다.\nHikariCP 커넥션 획득 테스트\n@SpringBootTest public class DataSourceConnectionPoolTest { private static final String URL = \u0026#34;jdbc:h2:tcp://localhost/~/spring6\u0026#34;; private static final String USERNAME = \u0026#34;sa\u0026#34;; private static final String PASSWORD = \u0026#34;1234\u0026#34;; @DisplayName(\u0026#34;커넥션 풀 획득 테스트\u0026#34;) @Test void dataSourceConnectionPool() throws InterruptedException { HikariDataSource dataSource = new HikariDataSource(); dataSource.setJdbcUrl(URL); dataSource.setUsername(USERNAME); dataSource.setPassword(PASSWORD); dataSource.setMaximumPoolSize(10); // 커넥션 풀 최대 사이즈 10개로 설정 dataSource.setPoolName(\u0026#34;MyPool\u0026#34;); Thread.sleep(1000); //커넥션 풀에서 커넥션 생성 시간 대기 // 멀티스레드 구성 (20개 쓰레드) int taskCount = 20; CountDownLatch latch = new CountDownLatch(taskCount); ExecutorService executorService = Executors.newFixedThreadPool(taskCount); // 20개 쓰레드를 실행하여 커넥션 빌리고, 로그 출력 for (int i = 0; i \u0026lt; taskCount; i++) { executorService.submit(() -\u0026gt; { // try-with-resources 구문을 사용하여 커넥션 풀에서 커넥션을 빌림 (try 블록 종료 시 자동으로 반납) try (Connection connection = dataSource.getConnection()) { // 커넥션 정보 확인 System.out.println(\u0026#34;Thread: \u0026#34; + Thread.currentThread().getName() + \u0026#34;, Connection: \u0026#34; + connection); Thread.sleep(500); } catch (SQLException e) { throw new RuntimeException(e); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { latch.countDown(); } }); } // 모든 쓰레드 종료 대기 latch.await(); executorService.shutdown(); // DataSource 종료 dataSource.close(); } } application.yml 에서 DataSource 설정\nspring: datasource: url: jdbc:h2:tcp://localhost/~/spring6 username: sa password: 1234 driver-class-name: org.h2.Driver hikari: maximum-pool-size: 10 스프링 부트가 애플리케이션을 시작할 때, DataSourceAutoConfiguration 내부에서 클래스패스에 있는 HikariCP 라이브러리를 감지하여 spring.datasource.*, spring.datasource.hikari.* 등으로 시작하는 설정은 DataSourceProperties 및 HikariDataSource에 바인딩한다.\n// BeanFactory, ConnectionConst 등은 삭제한다. @Slf4j @Repository // 컴포넌트 스캔으로 빈을 등록하고 자동으로 DataSource를 주입받음 public class PostRepository { private final DataSource dataSource; public PostRepository(DataSource dataSource) { this.dataSource = dataSource; } ... } HikariCP Connection Pool Configuration # HikariCP의 대표 설정값으로는 maximumPoolSize, minimumIdle, idleTimeout, connectionTimeout, maxLifetime 등이 있다.\nmaximum-pool-size(default = 10)\nidle(inActive)-connection과 사용 중인 in-use(active)-connection 을 모두 포함하여 풀이 도달할 수 있는 최대 크기를 제어한다.\n기본적으로 이 값에 따라 데이터베이스와 서버의 커넥션에 대한 실제 최대 연결 수가 결정된다.\n커넥션풀이 이 크기에 도달하고 사용 가능한 idle-connection이 없는 경우, getConnection() 호출은 최대 connection-timeout 밀리초 동안 차단된다.\n적절한 커넥션 풀의 사이즈\n커넥션풀의 사이즈는 작게 잡되, 필요 시 충분히 확보는 것이 핵심이다.\nHikariCP의 권장 공식\nconnections = (core_count * 2) + effective_spindle_count\ncore_count**:** 실제 물리적 CPU 코어 수 (하이퍼스레딩 코어는 포함하지 않음)\neffective_spindle_count**:** 캐시가 활성화되어 있다면 0, 그렇지 않으면 실제 디스크 스핀들의 수에 근접한 값\n예를 들어, 하드 디스크가 하나 있는 작은 4코어 i7 서버\n→ 9 = ((4 * 2) + 1)\nPool-Locking 문제를 피하기 위한 공식\npool-locking이란? 하나의 스레드가 여러 커넥션을 동시에 필요로 하는 경우, 풀의 크기가 너무 작으면 데드락(deadlock) 상태에 빠질 위험이 있다\npool-size = T * (C - 1) + 1\nT: 최대 동시 실행가능한 스레드 수\nC: 단일 스레드가 동시에 보유할 필요가 있는 커넥션 수\n예를 들어, 3개의 스레드가 각각 4개의 커넥션을 필요로 한다면\n→ 10 = 3 * (4 − 1) + 1\n하지만 실제로 배포 환경에서 적절한 커넥션 수\n모니터링 환경을 구축하고(서버 리소스, 서버 스레드, DBCP 등등) 백엔드 시스템에 부하테스트를 가하여 적절한 수를 찾는다.\nminimum-idle(default = maximum-pool-size)\npool에서 유지하는 최소한의 idle-connection수를 제어한다. idle-connection 수가 minimum-idle 설정 값보다 작고, 전체 connection 수도 maximum-pool-size 설정값 보다 작다면 신속하게 추가로 connection을 만든다.\nmaximum-pool-size와 minimum-idle을 같게 유지할려면 maximum-pool-size만 설정하면 된다.\n애플리케이션이 실행되고 HikariCP가 초기화될 때, minimum-idle 설정값만큼 커넥션을 미리 만들어 둔다.\n애플리케이션이 DB 작업(쿼리 실행 등)을 위해 커넥션을 요청하면, HikariCP는 풀 안에 미리 준비된 idle-connection을 하나 꺼내서 반환한다.\n이 커넥션은 in-use(사용 중) 상태가 되고, DB 쿼리를 수행하거나 트랜잭션 처리를 하게 된다.\n만약 트래픽이 발생하여 커넥션 하나가 in-use(사용중) 상태가 된다면 idle-connection은 1개가 남게 된다.\n이때 HikariCP는 내부적으로 idle-connection을 minimum-idle 이상 유지하기위해\nmaximum-pool-size 범위 내에서 새로운 커넥션을 만든다.\nidle-timeout\n풀에서 connection이 idle-connection상태로 유지될 수 있는 최대 시간을 제어한다.\n만약, minimum-idle 보다 더 많은 idle-connection이 있다면 HikariCP는 idle-timeout보다 더 오래된 idle-connection을 커넥션 풀에서 제거하여 커넥션 풀안에 있는 커넥션 수를 minimum-idle 로 맞춘다.\nconnection-timeout\n클라이언트(즉, 사용자)가 풀에서 연결을 대기하는 최대 밀리초 수를 제어한다. 연결을 사용할 수 없는 상태에서 이 시간이 초과되면 예외가 발생한다.\n위와 같은 경우 Thread6가 connection-timeout으로 설정된 시간동안 커넥션을 얻지 못하면 예외가 발생한다.\napplication.yml\nspring: datasource: url: jdbc:h2:tcp://localhost/~/spring6 username: sa password: 1234 driver-class-name: org.h2.Driver # Hikari설정을 추가한다. hikari: maximum-pool-size: 5 connection-timeout: 10000 PostRepository\n@Slf4j @Repository public class PostRepository { private final DataSource dataSource; public PostRepository(DataSource dataSource) { this.dataSource = dataSource; } public void save(Post post) throws SQLException { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;); pstmt.setLong(1, post.getPostId()); pstmt.setString(2, post.getTitle()); pstmt.setString(3, post.getContent()); pstmt.setInt(4, post.getLikes()); pstmt.executeUpdate(); Thread.sleep(20000); // 20초 대기 추가 } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } catch (InterruptedException e) { throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public Post findById(Long id) throws SQLException { Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;select * from post where post_id = ?\u0026#34;); pstmt.setLong(1, id); rs = pstmt.executeQuery(); rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.error(\u0026#34;resultSet close error\u0026#34;, e); throw e; } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } public void deleteAll() { Connection con = null; PreparedStatement pstmt = null; try { con = dataSource.getConnection(); pstmt = con.prepareStatement(\u0026#34;delete from post\u0026#34;); pstmt.executeUpdate(); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw new RuntimeException(e); } finally { if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { log.error(\u0026#34;prepareStatement close error\u0026#34;, e); throw new RuntimeException(e); } } if (con != null) { try { con.close(); } catch (SQLException e) { log.error(\u0026#34;connection close error\u0026#34;, e); throw new RuntimeException(e); } } } } } HikariTest\n@SpringBootTest public class HikariTest { @Autowired private PostRepository postRepository; @BeforeEach void init() { postRepository.deleteAll(); } @AfterEach void clear() { postRepository.deleteAll(); } @Test void testConnectionTimeout() throws InterruptedException { final int THREAD_COUNT = 6; // 최대 풀 크기가 5이므로 6개 이상의 스레드를 동시에 돌려 타임아웃을 유도 ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch latch = new CountDownLatch(THREAD_COUNT); for (int i = 0; i \u0026lt; THREAD_COUNT; i++) { int index = i; executorService.submit(() -\u0026gt; { try { // 단순히 postId를 인덱스로 해서 Post 객체 생성 Post post = Post.create((long) index, \u0026#34;title \u0026#34; + index, \u0026#34;content \u0026#34; + index); System.out.println(\u0026#34;Thread \u0026#34; + index + \u0026#34; starts saving post...\u0026#34;); // save 메서드 내에서 20초(20000ms) 대기 발생 postRepository.save(post); System.out.println(\u0026#34;Thread \u0026#34; + index + \u0026#34; finished saving post.\u0026#34;); } catch (Exception e) { // 여기서 connection-timeout 발생 시 로그 확인 가능 System.out.println(\u0026#34;Thread \u0026#34; + index + \u0026#34; error: \u0026#34; + e.getMessage()); } finally { latch.countDown(); } }); } latch.await(); // 모든 스레드 작업이 끝날 때까지 대기 executorService.shutdown(); System.out.println(\u0026#34;All threads finished.\u0026#34;); } } max-lifetime\n커넥션풀에서 커넥션의 최대 수명을 제어한다. idle-connection이 max-lifetime을 넘기면 커넥션 풀에서 바로 제거하고, active-connection이 max-lifetime을 넘긴 경우 경우 커넥션 풀로 반환된 후 제거된다.\n단, 커넥션 풀로 반환되지 않으면 max-lifetime이 동작하지 않는다. → max-lifetime을 넘겨서 계속 커넥션이 살아있을 수 있다.\nDB의 wait-timeout\nDB에는 wait-timeout이라는 속성이 있다. 해당 속성은 DB에서 커넥션이 inactive한 상황일 때, 다시 요청이 오기까지 얼마의 시간을 기다린 뒤 커넥션(세션)을 닫을 것인지를 제어한다. 만약 애플리케이션에서 비정상적인 커넥션 종료나 반환이 안되는 문제가 발생하면 TCP 연결을 유지한 채 하염없이 기다리는 커넥션이 쌓이게 된다. 이러한 문제를 방지하기 위해 wait-timeout 이 사용된다.\n이때, MySQL의 wait-timeout이 60초로 설정되어 있다면? DB에서는 커넥션을 끊어버리게 된다.\n그리고 그때 커넥션이 pool로 반환된다면? 그리고 그 커넥션이 다시 재사용되어 DB로 요청을 보낸다면 예외가 터지게 된다.\n따라서 다 쓴 커넥션은 pool로 반환하는 것이 중요하며 HikariCP의 max-lifetime은 DB의 wait-timeout보다 (2~3초 혹은 5초정도)로 짧게 설정해야 한다.\n2. Spring JDBC # 2.1 JdbcTemplate 사용법 # JdbcTemplate 구조와 동작 원리 # 기존의 PostRepository에는 심각한 문제점이 있다. 바로 예외 상황에 대한 처리다.\ntry/catch/finally 블록이 2중으로 중첩까지 되어 나오는데다, 모든 메소드마다 반복된다.\n이런 코드를 효과적으로 다룰 수 있는 방법은 없을까? 문제의 핵심은 변하지 않는, 그러나 많은 곳에서 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해내는 작업이다.\n분리와 재사용을 위해 템플릿 콜백 패턴 적용\n변하지 않는 부분: try/catch/finally 블록\n변하는 부분: PrepareStatement을 실행하는 부분\nMyJdbcTemplate\n@Slf4j public class MyJdbcTemplate { private final DataSource dataSource; public MyJdbcTemplate(DataSource dataSource) { this.dataSource = dataSource; } public \u0026lt;T\u0026gt; T execute(String sql, MyPreparedStatementCallback\u0026lt;T\u0026gt; callback) throws SQLException { Connection con = null; PreparedStatement ps = null; try { con = dataSource.getConnection(); ps = con.prepareStatement(sql); return callback.doInPreparedStatement(ps); } catch (SQLException e) { log.error(\u0026#34;db error\u0026#34;, e); throw e; } finally { if (ps != null) { try { ps.close(); } catch (SQLException e) { log.info(\u0026#34;prepareStatement close error\u0026#34;, e); throw e; } } if (con != null) { try { con.close(); } catch (SQLException e) { log.info(\u0026#34;connection close error\u0026#34;, e); throw e; } } } } } PreparedStatementCallback\npublic interface MyPreparedStatementCallback\u0026lt;T\u0026gt; { T doInPreparedStatement(PreparedStatement ps) throws SQLException; } PostRepository\n@Repository public class PostRepository { private final MyJdbcTemplate myJdbcTemplate; public PostRepository(DataSource dataSource) { this.myJdbcTemplate = new MyJdbcTemplate(dataSource); } public void save(Post post) throws SQLException { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; myJdbcTemplate.execute(sql, ps -\u0026gt; { ps.setLong(1, post.getPostId()); ps.setString(2, post.getTitle()); ps.setString(3, post.getContent()); ps.setInt(4, post.getLikes()); return ps.executeUpdate(); }); } public Post findById(Long id) throws SQLException { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return myJdbcTemplate.execute(sql, ps -\u0026gt; { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); } }); } public void deleteAll() throws SQLException { String sql = \u0026#34;delete from post\u0026#34;; myJdbcTemplate.execute(sql, ps -\u0026gt; ps.executeUpdate()); } } Spring이 제공하는 JdbcTemplate\nJdbcTemplate은 스프링이 제공하는 기능으로, JDBC를 래핑(wrapping)하여 JDBC를 직접 사용할 때 필요한 정형화되고 중복된 처리를 대신해준다.\n→ 자바 표준인 JDBC를 좀 더 쉽게 사용할 수 있는 방법을 제공한다.\nJdbcTemplate은 템플릿 콜백 패턴을 사용해서, JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 대신 처리해준다.\n개발자는 SQL을 작성하고, 전달할 파라미터를 정의하고, 응답 값을 매핑하기만 하면 된다.\n우리가 생각할 수 있는 대부분의 반복 작업:\nConnection 획득 statement를 준비하고 실행 결과를 반복하도록 루프를 실행 connection, statement, resultSet종료 트랜잭션 다루기 위한 커넥션 동기화 예외 발생시 스프링 예외 변환기 실행 PostRepository\n@Repository public class PostRepository { private final JdbcTemplate jdbcTemplate; public PostRepository(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void save(Post post) { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); } public Post findById(Long id) { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return jdbcTemplate.query(sql, ps -\u0026gt; ps.setLong(1, id), rs -\u0026gt; { rs.next(); return Post.of(rs.getLong(\u0026#34;post_id\u0026#34;), rs.getString(\u0026#34;title\u0026#34;), rs.getString(\u0026#34;content\u0026#34;), rs.getInt(\u0026#34;likes\u0026#34;)); }); } public void deleteAll() { String sql = \u0026#34;delete from post\u0026#34;; jdbcTemplate.update(sql); } } 검색 계열 처리 # 검색 계열 처리를 다음 3가지로 나누어 살펴보자.\n하나의 컬럼을 가져오는 경우 레코드를 Map 객체로 변환하여 가져오는 경우 레코드를 Entity 객체로 변환하여 가져오는 경우 @SpringBootTest public class JdbcTemplateTest { @Autowired private PostRepository postRepository; @Autowired private JdbcTemplate jdbcTemplate; @AfterEach void clear() { postRepository.deleteAll(); } @DisplayName(\u0026#34;한 레코드의 한 컬럼 가져오기\u0026#34;) @Test void queryForObject() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); /** * 두 번째 인수에는 반환 값으로 반환할 객체의 타입을 지정한다. * 세 번째 인수는 ?기호에 바인딩할 값이다. */ String title = jdbcTemplate.queryForObject(\u0026#34;select title from post where post_id = ?\u0026#34;, String.class, 1l); // then Assertions.assertThat(title).isEqualTo(\u0026#34;제목1\u0026#34;); } @DisplayName(\u0026#34;한 레코드를 Map 객체로 변환해서 가져오기\u0026#34;) @Test void queryForMap() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // when Map\u0026lt;String, Object\u0026gt; map = jdbcTemplate.queryForMap(\u0026#34;select * from post where post_id = ?\u0026#34;, 1l); // then System.out.println(map); } @DisplayName(\u0026#34;레코드를 Entity 객체로 변환해서 가져오기\u0026#34;) @Test void queryFonEntity() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // when Post savedPost = jdbcTemplate.queryForObject(\u0026#34;select * from post where post_id = ?\u0026#34; , new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), 1l); // then assertEquals(savedPost, post); } } BeanPropertyRowMapper vs DataClassRowMapper\nBeanPropertyRowMapper을 사용하는 경우 setter와 기본 생성자가 필요함.\nDataClassRowMapper은 생성자 주입 기반으로 동작하기 때문에, setter나 NoArgsConstructor 없이도 불변 객체(immutable object)를 쉽게 매핑할 수 있다.\n전반적으로, 불변 객체를 선호하거나 코드의 간결성을 중시한다면 DataClassRowMapper가 매력적인 선택이 될 수 있다.\n갱신 계열 처리 # 이제부터는 갱신 계열(추가, 변경, 삭제) 처리에 대해 다음 3가지로 나누어 설명한다.\nINSERT 문 실행하기 UPDATE 문 실행하기 DELETE 문 실행하기 /** * INSERT 문을 실행할 때는 update 메서드를 사용한다. SQL 문은 INSERT 지만 메서드 이름은 update이므로 주의. * 이어서 UPDATE, DELETE 문을 실행할 때도 update 메서드를 사용한다. */ @DisplayName(\u0026#34;insert 쿼리 실행\u0026#34;) @Test void insert() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); jdbcTemplate.update(\u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); // when Post savedPost = jdbcTemplate.queryForObject(\u0026#34;select * from post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class)); // then assertEquals(savedPost, post); } @DisplayName(\u0026#34;update 쿼리 실행\u0026#34;) @Test void update() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // when jdbcTemplate.update(\u0026#34;update post set title = ?, content = ? where post_id = ?\u0026#34;, \u0026#34;제목2\u0026#34;, \u0026#34;내용2\u0026#34;, 1L); // then Post updatedPost = jdbcTemplate.queryForObject(\u0026#34;select * from post where post_id = ?\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), 1L); assertEquals(updatedPost.getTitle(), \u0026#34;제목2\u0026#34;); assertEquals(updatedPost.getContent(), \u0026#34;내용2\u0026#34;); } @DisplayName(\u0026#34;delete 쿼리 실행\u0026#34;) @Test void delete() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // when jdbcTemplate.update(\u0026#34;delete from post where post_id = ?\u0026#34;, 1L); // then List\u0026lt;Post\u0026gt; posts = jdbcTemplate.query(\u0026#34;select * from post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class)); assertEquals(posts.size(), 0); } 2.2 NamedParameterJdbcTemplate 활용 # NamedParameterJdbcTemplate를 쓰는 이유 # NamedParameterJdbcTemplate은 Spring에서 제공하는 클래스로, SQL 쿼리에서 \u0026lsquo;?\u0026lsquo;기호를 활용한 바인딩 대신 이름이 지정된 파라미터를 사용할 수 있도록 지원한다. 이 클래스는 내부적으로 JdbcTemplate을 활용하여, 실행 시점에 이름이 지정된 파라미터를 JDBC 스타일의 \u0026lsquo;?\u0026lsquo;로 대체한다.\n파라미터 바인딩 방식 # NamedParameterJdbcTemplate을 사용할 때 파라미터를 바인딩하는 방법은 대표적으로 다음 세 가지가 있다.\nMapSqlParameterSource BeanPropertySqlParameterSource Collections.singletonMap MapSqlParameterSource\n장점\n파라미터를 Map과 유사하게 key-value 형식으로 넣는다. 값에 추가적인 메타데이터(type 등)를 명시할 수 있다. 단점\n모든 파라미터를 수동으로 설정해야 하므로 파라미터가 많아지면 코드가 길어질 수 있는 단점이 존재. MapSqlParameterSource params = new MapSqlParameterSource() .addValue(\u0026#34;id\u0026#34;, 1L) .addValue(\u0026#34;title\u0026#34;, \u0026#34;제목\u0026#34;); MapSqlParameterSource params = new MapSqlParameterSource() .addValue(\u0026#34;id\u0026#34;, 1L, Types.BIGINT) .addValue(\u0026#34;title\u0026#34;, \u0026#34;제목1\u0026#34;, Types.VARCHAR) .addValue(\u0026#34;created_at\u0026#34;, LocalDate.now(), Types.DATE); BeanPropertySqlParameterSource\n장점\n객체 프로퍼티 이름과 SQL의 파라미터 이름이 일치하면 매우 간결하고 유지보수하기 편리하다. 코드를 줄이고, 객체와 데이터베이스 간 매핑을 직관적으로 처리할 수 있어 생산성이 좋다. 단점\n프로퍼티 이름이 바뀌면 SQL 쿼리까지 수정이 필요하여 밀접한 결합이 발생한다. 프로퍼티와 SQL 파라미터 이름이 정확히 일치하지 않으면 오류가 발생하여 유지보수가 까다로울 수 있다. Post post = Post.create(1L, \u0026#34;제목\u0026#34;, \u0026#34;내용\u0026#34;); BeanPropertySqlParameterSource params = new BeanPropertySqlParameterSource(post); Map.of() 또는 Collections.singletonMap\n장점\n코드를 최소한으로 간결하게 유지한다. 파라미터가 적을 때 직관적으로 보인다. 단점\n타입에 대한 추가적인 메타정보 지정이 불가능하다. 파라미터가 많아지면 가독성이 떨어질 수 있다. Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;id\u0026#34;, 1L); Map\u0026lt;String, Object\u0026gt; params = Map.of(\u0026#34;id\u0026#34;, 1L, \u0026#34;title\u0026#34;, \u0026#34;제목\u0026#34;); 실습 예시 # @SpringBootTest public class NamedParameterJdbcTemplateTest { @Autowired private PostRepository postRepository; @Autowired private NamedParameterJdbcTemplate namedParameterJdbcTemplate; @AfterEach void clear() { postRepository.deleteAll(); } @DisplayName(\u0026#34;한 레코드의 한 컬럼 가져오기\u0026#34;) @Test void queryForObject() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // 파라미터 설정 MapSqlParameterSource params = new MapSqlParameterSource(\u0026#34;postId\u0026#34;, 1L); // SQL 실행 String title = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT title FROM post WHERE post_id = :postId\u0026#34;, params, String.class ); // then Assertions.assertThat(title).isEqualTo(\u0026#34;제목1\u0026#34;); } @DisplayName(\u0026#34;한 레코드를 Map 객체로 변환해서 가져오기\u0026#34;) @Test void queryForMap() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // 파라미터 설정 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L); // SQL 실행 Map\u0026lt;String, Object\u0026gt; map = namedParameterJdbcTemplate.queryForMap( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, params ); // then System.out.println(map); } @DisplayName(\u0026#34;레코드를 Entity 객체로 변환해서 가져오기\u0026#34;) @Test void queryForEntity() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // 파라미터 설정 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L); // SQL 실행 Post savedPost = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, params, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); // then assertEquals(savedPost, post); } @DisplayName(\u0026#34;여러 레코드를 Entity 객체로 변환해서 가져오기\u0026#34;) @Test void queryForEntityList() throws Exception { // given Post post1 = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); Post post2 = Post.create(2L, \u0026#34;제목2\u0026#34;, \u0026#34;내용2\u0026#34;); Post post3 = Post.create(3L, \u0026#34;제목3\u0026#34;, \u0026#34;내용3\u0026#34;); postRepository.save(post1); postRepository.save(post2); postRepository.save(post3); // SQL 실행 List\u0026lt;Post\u0026gt; posts = namedParameterJdbcTemplate.query( \u0026#34;SELECT * FROM post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); // then assertEquals(posts.size(), 3); assertEquals(posts, List.of(post1, post2, post3)); } @DisplayName(\u0026#34;insert 쿼리 실행\u0026#34;) @Test void insert() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); // 파라미터 설정 SqlParameterSource params = new BeanPropertySqlParameterSource(post); // SQL 실행 namedParameterJdbcTemplate.update( \u0026#34;INSERT INTO post(post_id, title, content, likes) VALUES(:postId, :title, :content, :likes)\u0026#34;, params ); // when Post savedPost = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, Collections.singletonMap(\u0026#34;postId\u0026#34;, post.getPostId()), new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); // then assertEquals(savedPost, post); } @DisplayName(\u0026#34;update 쿼리 실행\u0026#34;) @Test void update() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // 파라미터 설정 Map\u0026lt;String, Object\u0026gt; params = new HashMap\u0026lt;\u0026gt;(); params.put(\u0026#34;title\u0026#34;, \u0026#34;제목2\u0026#34;); params.put(\u0026#34;content\u0026#34;, \u0026#34;내용2\u0026#34;); params.put(\u0026#34;postId\u0026#34;, 1L); // SQL 실행 namedParameterJdbcTemplate.update( \u0026#34;UPDATE post SET title = :title, content = :content WHERE post_id = :postId\u0026#34;, params ); // then Post updatedPost = namedParameterJdbcTemplate.queryForObject( \u0026#34;SELECT * FROM post WHERE post_id = :postId\u0026#34;, Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L), new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); assertEquals(updatedPost.getTitle(), \u0026#34;제목2\u0026#34;); assertEquals(updatedPost.getContent(), \u0026#34;내용2\u0026#34;); } @DisplayName(\u0026#34;delete 쿼리 실행\u0026#34;) @Test void delete() throws Exception { // given Post post = Post.create(1L, \u0026#34;제목1\u0026#34;, \u0026#34;내용1\u0026#34;); postRepository.save(post); // 파라미터 설정 Map\u0026lt;String, Object\u0026gt; params = Collections.singletonMap(\u0026#34;postId\u0026#34;, 1L); // SQL 실행 namedParameterJdbcTemplate.update( \u0026#34;DELETE FROM post WHERE post_id = :postId\u0026#34;, params ); // then List\u0026lt;Post\u0026gt; posts = namedParameterJdbcTemplate.query( \u0026#34;SELECT * FROM post\u0026#34;, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class) ); assertEquals(posts.size(), 0); } } 3. 트랜잭션 관리 # 3.1 @Transactional 어노테이션 사용 # 트랜잭션이란? # 더 이상 나눌 수 없는 단위 작업.\n하나의 SQL 명령어를 처리하는 경우는 DB가 트랜잭션을 보장해준다고 믿을 수 있다.\n하지만 여러 개의 SQL이 사용되는 작업을 하나의 트랜잭션으로 취급해야 하는 경우도 있다. 이를 통해 데이터의 불일치 상태를 방지할 수 있다.\n트랜잭션이 시작되면 여러 개의 SQL을 실행하고, 모든 SQL이 성공하면 마지막으로 커밋한다. 커밋하면 데이터베이스에 데이터가 저장된다.\n트랜잭션의 시작과 종료는 기본적으로 Connection 오브젝트를 통해 이뤄진다.\n(JPA/Hibernate은 Connection을 직접 사용하지 않고 EntityManager/Session을 이용하고 독자적인 트랜잭션 관리 API를 사용한다.)\nJDBC에서 트랜잭션을 시작하려면 자동 커밋 옵션을 false 로 만들어주면 된다. JDBC의 기본 설정은 DB 작업을 수행한 직후에 자동으로 커밋이 되도록 되어 있다.\n트랜잭션이 한 번 시작되면 commit() 또는 rollback() 메소드가 호출될 때까지의 작업이 하나의 트랜잭션으로 묶인다.\n트랜잭션 작업 중에 예외가 발생하면 트랜잭션을 롤백한다.\n데이터베이스에 post_like 스키마 생성\ndrop table post_like if exists cascade; CREATE TABLE post_like ( post_id BIGINT, user_id BIGINT, liked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (post_id, user_id) ); Post\n@Getter @EqualsAndHashCode public class Post { private final Long postId; private final String title; private final String content; private int likes; private Post(Long postId, String title, String content, int likes) { this.postId = postId; this.title = title; this.content = content; this.likes = likes; } public static Post create(Long postId, String title, String content) { return new Post(postId, title, content, 0); } public static Post of(Long postId, String title, String content, int likes) { return new Post(postId, title, content, likes); } // 추가 public void increaseLikes() { this.likes++; } } PostLike\n@Getter @EqualsAndHashCode @AllArgsConstructor public class PostLike { private Long postId; private Long userId; private LocalDateTime likedAt; } PostLikeRepository\n@Repository public class PostLikeRepository { private final JdbcTemplate jdbcTemplate; public PostLikeRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void save(PostLike postLike) { String sql = \u0026#34;insert into post_like(post_id, user_id, liked_at) values (?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, postLike.getPostId(), postLike.getUserId(), Timestamp.valueOf(postLike.getLikedAt())); } public boolean existsByPostIdAndUserId(Long postId, Long userId) { String sql = \u0026#34;select count(*) from post_like where post_id = ? and user_id = ?\u0026#34;; Integer count = jdbcTemplate.queryForObject(sql, Integer.class, postId, userId); return count != null \u0026amp;\u0026amp; count \u0026gt; 0; } public void deleteAll() { jdbcTemplate.update(\u0026#34;delete from post_like\u0026#34;); } } PostRepository\n@Repository public class PostRepository { private final JdbcTemplate jdbcTemplate; public PostRepository(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void save(Post post) { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); } public void incrementLikes(Long postId) { String sql = \u0026#34;update post set likes = likes + 1 where post_id = ?\u0026#34;; jdbcTemplate.update(sql, postId); } public Post findById(Long id) { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), id); } public void deleteAll() { String sql = \u0026#34;delete from post\u0026#34;; jdbcTemplate.update(sql); } } PostLikeService\n@Service @RequiredArgsConstructor public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final DataSource dataSource; public void likePost(Long postId, Long userId) throws SQLException { Connection connection = dataSource.getConnection(); connection.setAutoCommit(false); try { // 먼저 좋아요 수를 증가시키고 postRepository.incrementLikes(postId); // 그 다음 중복 체크에서 예외를 발생시키면, // 예외 이후 rollback으로 인해 likes 수가 되돌아갔는지 검증이 가능함 if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); connection.commit(); } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(true); connection.close(); } } } PostLikeServiceTest\nimport static org.junit.jupiter.api.Assertions.*; @SpringBootTest class PostLikeServiceTest { @Autowired PostRepository postRepository; @Autowired PostLikeRepository postLikeRepository; @Autowired PostLikeService postLikeService; @BeforeEach void init() throws SQLException { postRepository.save(Post.create(1L, \u0026#34;트랜잭션 테스트\u0026#34;, \u0026#34;좋아요 기능 테스트\u0026#34;)); } @AfterEach void clear() { postLikeRepository.deleteAll(); postRepository.deleteAll(); } @DisplayName(\u0026#34;게시글 좋아요 테스트\u0026#34;) @Test void postLikeSave() throws SQLException { // given Long postId = 1L; Long userId = 100L; // when postLikeService.likePost(postId, userId); // then Post post = postRepository.findById(postId); assertEquals(1, post.getLikes()); assertTrue(postLikeRepository.existsByPostIdAndUserId(postId, userId)); } @DisplayName(\u0026#34;게시글에 중복해서 좋아요를 할 수 없다\u0026#34;) @Test void duplicatePostLikeSave() throws SQLException { // given Long postId = 1L; Long userId = 100L; postLikeService.likePost(postId, userId); // 첫 번째 좋아요 성공 // when, then assertThrows(IllegalStateException.class, () -\u0026gt; { postLikeService.likePost(postId, userId); // 중복 좋아요 시도 }); // 롤백 확인: 좋아요 수는 증가하지 않음 Post post = postRepository.findById(postId); assertEquals(1, post.getLikes()); } } 위의 코드를 실행하면 2번째 테스트는 실패하게 된다.\n그 이유는 PostLikeService에서 사용된 Connection이 PostRepository, PostLikeRepository에서 사용된 Connection과 서로 다른 커넥션이기 때문이다.\n테스트를 성공시키기 위해서는 PostLikeService와 각각의 Repository에서 동일한 Connection을 사용할 필요가 있다.\n트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야한다.\n결국 같은 커넥션을 동기화하기 위해서 가장 간단하게는 파라미터로 커넥션을 전달할 수 있다.\n하지만, 파라미터로 커넥션을 전달하는 방법은 코드가 지저분해지는 것은 물론이고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야 하는 등 여러가지 단점들이 많다. 또한 DB 커넥션을 비롯한 리소스의 깔끔한 처리를 가능하게 했던 JdbcTemplate 도 더 이상 활용할 수 없다.\n이 문제를 해결하기 위해 스프링이 제안하는 방법은 독립적인 트랜잭션 동기화 방식이다.\n스프링이 제공하는 트랜잭션 # 트랜잭션 동기화란?\nPostLikeService에서 트랜잭션을 시작하기 위해 만든 Connection 오브젝트를 특별한 저장소(ThreadLocal)에 보관해두고,\n이후에 호출되는 Repository의 메소드에서는(정확히는 JdbcTemplate) 저장된 Connection을 가져다가 사용하게 하는 것이다.\n스프링에서는 이를 위해 PlatformTransactionManager 이용한 트랜잭션 추상화 기능을 제공한다.\n트랜잭션 동기화가 되어 있는 채로 JdbcTemplate 을 사용하면 JdbcTemplate의 작업에서 동기화시킨 DB커넥션을 사용하게 된다.\nPostLikeService\n@Service @RequiredArgsConstructor public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PlatformTransactionManager transactionManager; public void likePost(Long postId, Long userId) throws SQLException { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // 먼저 좋아요 수를 증가시키고 postRepository.incrementLikes(postId); // 그 다음 중복 체크에서 예외를 발생시키면, // 예외 이후 rollback으로 인해 likes 수가 되돌아갔는지 검증이 가능함 if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } } 트랜잭션을 사용하는 로직을 살펴보면 다음과 같은 패턴이 반복되는 것을 확인할 수 있다.\n다른 서비스에서 트랜잭션을 시작하려면 try , catch , finally를 포함한 성공시 커밋, 실패시 롤백 코드가 반복될 것이다.\n스프링은 반복 문제를 해결하기 위해 템플릿 콜백 패턴을 적용한 TransactionTemplate을 제공한다.\nPostLikeService\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final TransactionTemplate transactionTemplate; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository, PlatformTransactionManager txManager) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; this.transactionTemplate = new TransactionTemplate(txManager); } public void likePost(Long postId, Long userId) { transactionTemplate.executeWithoutResult(status -\u0026gt; { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); }); } } 서비스 로직은 가급적 핵심 비즈니스 로직만 있어야 한다. 하지만 트랜잭션 기술을 사용하려면 어쩔 수 없이 트랜잭션 코드가 나와야 한다. 어떻게 하면 이 문제를 해결할 수 있을까?\n@Transactional을 사용하면 스프링이 AOP를 적용해서 이 문제를 해결해준다.\nPostLikeService\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public void likePost(Long postId, Long userId) { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } @Transactional을 활성화 하는 설정\n@Transactional이 붙은 클래스를 탐지하여 Proxy 객체를 자동으로 생성하기 위해서는 @EnableTransactionManagement가 활성화되어야 한다.\n만약, 이 설정을 잊어버리면 메서드 또는 클래스에 @Transactional을 붙여도 Proxy가 생성되지 않고 트랜잭션 제어가 되지 않으니 주의해야 한다.\n하지만 스프링 부트를 사용할 때는 자동 구성(Auto-Configuration) 메커니즘에 의해 트랜잭션 관리가 활성화된다.\n스프링 부트는 @SpringBootApplication에 포함된 @EnableAutoConfiguration을 통해 여러 Auto-Configuration 클래스를 읽어들이는데, 그 중 하나인 TransactionAutoConfiguration 클래스에서 트랜잭션 관리를 자동으로 설정한다.\n따라서 @EnableTransactionManagement를 직접 명시하지 않아도 된다.\n3.2 선언적 트랜잭션 vs 프로그래밍적 트랜잭션 # 선언적 트랜잭션과 프로그래밍적 트랜잭션 # 선언적 트랜잭션이란?\nSpring 프레임워크의 선언적 트랜잭션 관리는 Spring AOP을 통해 가능하다.\n과거에는 xml을 이용해서 작성하기도 했다.\n동작방식\n스프링이 프록시(proxy) 객체를 생성하여, @Transactional이 붙은 메서드가 호출될 때 트랜잭션을 시작하고, 메서드가 종료될 때 트랜잭션을 커밋하거나 롤백한다.\n예외 발생, 메서드 종료 시점 등에 따라 트랜잭션을 자동 관리함.\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public void likePost(Long postId, Long userId) { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } 장점\n간결성: 트랜잭션 제어 코드를 직접 작성할 필요 없이, 어노테이션만 붙이면 된다. 관심사 분리: 비즈니스 로직과 트랜잭션 로직이 분리. 코드 가독성 및 유지보수성 향상. 일관성: 프레임워크가 일관된 방식으로 트랜잭션을 처리해주므로, 실수로 인한 누락이나 중복이 적다. 단점\n세밀한 제어가 어려움: 기본 동작 외에 특수한 트랜잭션 경계(부분 커밋, 매우 복잡한 흐름)를 제어하기 까다롭다. 프록시 제한: 내부 메서드 호출에는 @Transactional이 적용되지 않는 등 AOP 프록시 한계가 있다. 기술적 이해 필요: 동적 프록시, AOP, 예외 롤백 규칙 등에 대한 이해가 부족하면 예상치 못한 동작이 발생할 수 있다. 프로그래밍적 트랜잭션이란?\n코드 레벨에서 명시적으로 트랜잭션 시작, 커밋, 롤백을 수행하는 방식\n트랜잭션 관련 코드를 직접 작성하는 것으로 스프링 프레임워크는 프로그래밍적 트랜잭션 관리를 위한 두 가지 수단을 제공한다.\nTransactionTemplate or TransactionalOperator TransactionManager를 이용 Spring 팀은 일반적으로 프로그래밍 방식의 트랜잭션 관리를 위해 TransactionTemplate을, 리액티브 코드에는 TransactionalOperator를 권장한다.\nTransactionTemplate 예시\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final TransactionTemplate transactionTemplate; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository, PlatformTransactionManager txManager) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; this.transactionTemplate = new TransactionTemplate(txManager); } public void likePost(Long postId, Long userId) { transactionTemplate.executeWithoutResult(status -\u0026gt; { postRepository.incrementLikes(postId); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); }); } } transactionTemplate.execute() 블록 안의 코드를 트랜잭션 범위로 설정하고, 종료 시점에 커밋 또는 롤백을 수행한다.\nTransactionManager 예시\n@Service @RequiredArgsConstructor public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PlatformTransactionManager transactionManager; public void likePost(Long postId, Long userId) throws SQLException { TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { // 먼저 좋아요 수를 증가시키고 postRepository.incrementLikes(postId); // 그 다음 중복 체크에서 예외를 발생시키면, // 예외 이후 rollback으로 인해 likes 수가 되돌아갔는지 검증이 가능함 if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } } 장점\n세밀한 제어: 트랜잭션 중간에 커밋, 롤백을 결정하거나 특정 조건에 따라 동적으로 트랜잭션 범위를 조정할 수 있다. 명시적 흐름: 로직에서 트랜잭션 경계가 어디인지 직관적으로 알 수 있어 디버깅이 쉬울 수 있다. 단점\n코드 중복: 트랜잭션 시작, 커밋, 롤백 같은 코드가 여러 군데에 반복될 수 있다. 비즈니스 로직과 결합: 트랜잭션 로직이 비즈니스 코드와 섞여서 가독성이 떨어질 수 있다. 추가 학습 곡선: PlatformTransactionManager, TransactionDefinition, TransactionStatus 등 스프링 트랜잭션 API에 대한 학습이 필요하다. 선언적 트랜잭션 vs 프로그래밍적 트랜잭션\n프로그래밍적 트랜잭션 관리는 일반적으로 트랜잭션 작업 수가 적은 경우에만 사용하는 것이 좋다.\n예를 들어 특정 업데이트 작업에 대해서만 트랜잭션이 필요한 웹 애플리케이션이 있는 경우 Spring AOP나 다른 기술을 사용하여 프록시를 설정하고 싶지 않을 수 있다.\n이 경우 TransactionTemplate을 사용하는 것이 좋은 방법일 수 있다.\n반면에 애플리케이션에 수많은 트랜잭션 작업이 있는 경우 선언적 트랜잭션 관리가 일반적으로 가치가 있다. 트랜잭션 관리를 비즈니스 로직과 분리하여 구성할 수 있다.\nSpring 프레임워크를 사용하면 선언적 트랜잭션 관리의 구성 비용이 크게 줄어든다.\n일반적인 웹/서비스 개발\n대부분의 경우, 간단히 @Transactional을 사용하는 선언적 트랜잭션이 더 효율적.\n코드가 깔끔하고, 유지보수하기 쉬우며, 스프링이 일관적으로 관리해주기 때문.\n특수한 트랜잭션 흐름\n예를 들어, 한 메서드에서 여러 번 부분 커밋을 하거나, 특정 조건에서만 롤백을 해야 하는 등의 복잡한 시나리오에서는 프로그래밍적 트랜잭션이 유연할 수 있다.\n혼합 사용\n대부분 선언적 트랜잭션을 사용하되, 아주 세밀한 트랜잭션 경계가 필요한 일부 구간에서 프로그래밍적 트랜잭션을 사용하는 방식도 가능하다.\n세밀한 트랜잭션 제어 예시 # PostLikeProgrammaticService\n@Slf4j @Service @RequiredArgsConstructor public class PostLikeProgrammaticService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; private final PlatformTransactionManager transactionManager; /** * 여러 게시글에 좋아요를 누르는 메서드 * @param partialAllowed true이면 일부 게시글 실패 시 롤백하지 않고 나머지 성공 허용 */ public void likeMultiplePosts(List\u0026lt;Long\u0026gt; postIds, Long userId, boolean partialAllowed) { TransactionTemplate txTemplate = new TransactionTemplate(transactionManager); if (!partialAllowed) { txTemplate.executeWithoutResult(status -\u0026gt; { for (Long postId : postIds) { // 좋아요 로직에서 예외가 발생하면 트랜잭션 전체 롤백 likeSinglePostOrThrow(postId, userId); } }); return; } // partialAllowed == true인 경우 -\u0026gt; 부분 성공 허용 // \u0026#34;각 게시글마다 별도의 트랜잭션\u0026#34;으로 처리 (중첩 트랜잭션 or 반복 트랜잭션) for (Long postId : postIds) { try { txTemplate.executeWithoutResult(status -\u0026gt; { likeSinglePostOrThrow(postId, userId); }); } catch (Exception e) { // 특정 게시글이 실패해도 로그만 남기고 넘어감 log.info(\u0026#34;게시글 {} 좋아요 실패: {}\u0026#34;, postId, e.getMessage()); } } } /** * 단일 게시글에 좋아요 수행하고, 중복 좋아요면 예외 발생 * (중복 좋아요 시도 등으로 예외가 발생해도 그대로 throw해서 롤백 유도) */ private void likeSinglePostOrThrow(Long postId, Long userId) { if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다. (postId=\u0026#34; + postId + \u0026#34;)\u0026#34;); } postRepository.incrementLikes(postId); postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } PostLikeProgrammaticServiceTest\n@SpringBootTest class PostLikeProgrammaticServiceTest { @Autowired PostLikeProgrammaticService postLikeProgrammaticService; @Autowired PostRepository postRepository; @Autowired PostLikeRepository postLikeRepository; @BeforeEach void init() throws SQLException { // 3개의 게시글을 미리 생성 postRepository.save(Post.create(1L, \u0026#34;Title1\u0026#34;, \u0026#34;Content1\u0026#34;)); postRepository.save(Post.create(2L, \u0026#34;Title2\u0026#34;, \u0026#34;Content2\u0026#34;)); postRepository.save(Post.create(3L, \u0026#34;Title3\u0026#34;, \u0026#34;Content3\u0026#34;)); } @AfterEach void clear() { postLikeRepository.deleteAll(); postRepository.deleteAll(); } @DisplayName(\u0026#34;All-or-Nothing 모드에서 하나라도 실패 시 전체 롤백\u0026#34;) @Test void likeMultiplePostsAllOrNothingFail() throws SQLException { // given List\u0026lt;Long\u0026gt; postIds = Arrays.asList(1L, 2L, 3L); Long userId = 100L; // 2번 게시글에 좋아요 넣어 중복 상황을 만들기 postLikeRepository.save(new PostLike(2L, userId, LocalDateTime.now())); // when - then // 2번 게시글에서 예외 -\u0026gt; 전체 롤백 assertThrows(IllegalStateException.class, () -\u0026gt; postLikeProgrammaticService.likeMultiplePosts(postIds, userId, false) // partialAllowed = false ); // then: 1, 3번 게시글 좋아요도 실패해야 함 -\u0026gt; likes=0, post_like 테이블도 변화 없음 assertEquals(0, postRepository.findById(1L).getLikes()); assertEquals(0, postRepository.findById(2L).getLikes()); // 기존에 있는 likes만 반영 (지금은 0) assertEquals(0, postRepository.findById(3L).getLikes()); // 2번 게시글 좋아요는 이미 있음 assertTrue(postLikeRepository.existsByPostIdAndUserId(2L, userId)); // 나머지 게시글 좋아요는 없음 assertFalse(postLikeRepository.existsByPostIdAndUserId(1L, userId)); assertFalse(postLikeRepository.existsByPostIdAndUserId(3L, userId)); } @Test @DisplayName(\u0026#34;PartialAllowed=true -\u0026gt; 실패한 게시글만 롤백, 나머지는 성공\u0026#34;) void likeMultiplePostsPartialSuccess() throws SQLException { // given List\u0026lt;Long\u0026gt; postIds = Arrays.asList(1L, 2L, 3L); Long userId = 100L; // 이미 1번 게시글에 좋아요 -\u0026gt; 1번 게시글에서 예외 예상 postLikeRepository.save(new PostLike(1L, userId, LocalDateTime.now())); // when postLikeProgrammaticService.likeMultiplePosts(postIds, userId, true); // partialAllowed = true // then // 1) 1번 게시글 -\u0026gt; 실패(중복), 롤백됨 (likes 변화 없음) assertEquals(0, postRepository.findById(1L).getLikes()); // 2) 2번, 3번 게시글 -\u0026gt; 성공 (likes=1) assertEquals(1, postRepository.findById(2L).getLikes()); assertEquals(1, postRepository.findById(3L).getLikes()); // post_like -\u0026gt; 1번은 기존 1개 레코드만, 2번과 3번 추가됨 assertTrue(postLikeRepository.existsByPostIdAndUserId(1L, userId)); assertTrue(postLikeRepository.existsByPostIdAndUserId(2L, userId)); assertTrue(postLikeRepository.existsByPostIdAndUserId(3L, userId)); } } All-or-Nothing vs PartialSuccess\n한 메서드에서 모든 게시글 좋아요를 처리하되, 일부 실패 시 전부 롤백할지, 실패한 게시글만 롤백할지를 동적으로 제어한다.\n부분 성공 허용 시, 게시글마다 새로운 트랜잭션을 열어 좋아요를 시도함으로써 개별 롤백이 가능해진다.\n내부에서 중복 예외(IllegalStateException)가 발생하면 트랜잭션을 롤백하도록 하고, 그 예외를 받아 필요 시 로그만 남기고 넘어갈 수 있다.\n만약, 선언적 트랜잭션(@Transactional)으로 단일 메서드에 묶기만 하면 “All-or-Nothing”이 되어버려 부분 성공과 같은 트랜잭션 범위를 유연하게 조절하는 것이 어렵다.\n3.3 트랜잭션의 내부 구조 # 트랜잭션 매니저와 트랜잭션 동기화 매니저 # PlatformTransactionManager(트랜잭션 매니저)\n출처: 김영한의 스프링 DB1\n구현 기술에 따른 트랜잭션 사용법\n트랜잭션은 구현 기술마다 사용하는 방법이 다르다.\nJDBC:\nconnection.setAutoCommit(false), connection.commit(), connection.rollback()\nJPA:\ntx = entityManager.getTransaction()\ntx.begin(), tx.commit(), tx.rollback()\nHibernate:\ntx = session.beginTransaction()\ntx.commit(), tx.rollback()\n만약 데이터베이스 접근 기술을 JDBC에서 JPA로 변경한다면 서비스 계층의 트랜잭션 시작 코드를 수정해야한다.\n이러한 문제를 해결하기 위해 PlatformTransactionManager 을 이용한 트랜잭션 추상화 기술을 스프링은 제공하며, 주로 사용하는 데이터 접근 기술에 대한 트랜잭션 매니저의 구현체도 제공한다.\n여기에 더해서 스프링 부트는 어떤 데이터 접근 기술을 사용하는지를 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링빈으로 등록해주기 때문에 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다.\n예를 들어서 JdbcTemplate , MyBatis를 사용하면 DataSourceTransactionManager(JdbcTransactionManager) 를 스프링빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 스프링 빈으로 등록해준다.\nTransactionSynchronizationManager(트랜잭션 동기화 매니저)와 DataSourceUtils\n스프링은 멀티스레드 환경에서도 안전한 트랜잭션 동기화 방법을 구현하기 위해 TransactionSynchronizationManager를 제공한다. 이것은 쓰레드 로컬( ThreadLocal )을 사용해서 커넥션을 동기화해준다.\n**“트랜잭션 매니저”**는 내부에서 이 “**트랜잭션 동기화 매니저”**를 사용한다.\n트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 상황에 안전하게 커넥션을 동기화 할 수 있다.\n커넥션이 필요하면 DataSourceUtils 에서 제공하는 getConnection() 메소드를 통해 DB 커넥션을 가져올 수 있다.\nDataSource에서 커넥션을 직접 가져오지 않고, 스프링이 제공하는 유틸리티 메소드를 쓰는 이유는 이 DataSourceUtils의 getConnection() 메소드는 Connection 오브젝트를 DataSource에서 가져올 뿐만 아니라 트랜잭션 동기화에 사용하도록 저장소(ThreadLocal)에 바인딩해주기 때문이다.\n트랜잭션 예시의 내부 구조 # TransactionTemplate을 사용한 경우 내부 구조\nJdbcTemplate은 내부적으로 DataSourceUtils을 이용한다.\n@Transactional을 사용한 경우 내부 구조\n트랜잭션 적용 범위(메서드, 클래스 단위) # 스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다.\n예를 들어서 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다.\n인터페이스와 해당 인터페이스를 구현한 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다.\n// test lombok testCompileOnly(\u0026#39;org.projectlombok:lombok\u0026#39;) testAnnotationProcessor(\u0026#39;org.projectlombok:lombok\u0026#39;) @SpringBootTest public class TxLevelTest { @Autowired LevelService service; @Test void orderTest() { service.write(); service.read(); } @TestConfiguration static class TxApplyLevelConfig { @Bean LevelService levelService() { return new LevelService(); } } @Slf4j @Transactional(readOnly = true) static class LevelService { @Transactional(readOnly = false) public void write() { log.info(\u0026#34;call write\u0026#34;); printTxInfo(); } public void read() { log.info(\u0026#34;call read\u0026#34;); printTxInfo(); } private void printTxInfo() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;tx active={}\u0026#34;, txActive); boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); log.info(\u0026#34;tx readOnly={}\u0026#34;, readOnly); } } } 클래스 레벨: @Transactional(readOnly = true)\n기본적으로 이 클래스 내 모든 메서드가 “읽기 전용” 트랜잭션으로 동작하게 된다.\n메서드 레벨: @Transactional(readOnly = false)\n클래스 레벨에서 읽기 전용을 설정했더라도, 메서드 수준에서 별도의 설정(여기서는 readOnly = false)을 선언하면 이 메서드가 우선 적용된다.\n결과적으로, write() 메서드는 읽기 전용이 아닌 트랜잭션으로 동작한다.\n트랜잭션 AOP의 주의 사항1 # PostLikeService\n@Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public void likePost(Long postId, Long userId) { // postRepository.incrementLikes(postId); Post post = postRepository.findById(postId); post.increaseLikes(); postRepository.update(post); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } PostRepository\n@Repository public class PostRepository { private final JdbcTemplate jdbcTemplate; public PostRepository(DataSource dataSource) { this.jdbcTemplate = new JdbcTemplate(dataSource); } public void save(Post post) { String sql = \u0026#34;insert into post(post_id, title, content, likes) values(?, ?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getPostId(), post.getTitle(), post.getContent(), post.getLikes()); } public void incrementLikes(Long postId) { String sql = \u0026#34;update post set likes = likes + 1 where post_id = ?\u0026#34;; jdbcTemplate.update(sql, postId); } // 추가 public void update(Post post) { String sql = \u0026#34;update post set title = ?, content = ?, likes = ? where post_id = ?\u0026#34;; jdbcTemplate.update(sql, post.getTitle(), post.getContent(), post.getLikes(), post.getPostId()); } public Post findById(Long id) { String sql = \u0026#34;select * from post where post_id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, new DataClassRowMapper\u0026lt;\u0026gt;(Post.class), id); } public void deleteAll() { String sql = \u0026#34;delete from post\u0026#34;; jdbcTemplate.update(sql); } } PostLikeSyncTest\n@SpringBootTest public class PostLikeSyncTest { @Autowired private PostRepository postRepository; @Autowired private PostLikeService postLikeService; @Autowired private PostLikeRepository postLikeRepository; @BeforeEach void init() { postRepository.deleteAll(); postLikeRepository.deleteAll(); // 초기 게시글 생성 postRepository.save(Post.create(10L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;)); } @AfterEach void clear() { postRepository.deleteAll(); postLikeRepository.deleteAll(); } @DisplayName(\u0026#34;게시글 좋아요 동시성 테스트\u0026#34;) @Test void unLikeSync() throws Exception { // given int threadCount = 400; ExecutorService executorService = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); // when for (int i = 0; i \u0026lt; threadCount; i++) { Long userId = (long) i; executorService.submit(() -\u0026gt; { try { postLikeService.likePost(10L, userId); } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); // then Post post = postRepository.findById(10L); assertThat(post.getLikes()).isEqualTo(400); } } 위의 코드는 likes 컬럼에 경쟁조건이 일어나는 동시성 예제이며 테스트는 실패한다.\n그 이유는 동시에 여러 스레드가 같은 게시글에 대해 좋아요를 누르는 과정에서, DB로부터 읽어 온 “likes”를 각각 메모리에 올려서 1씩 증가한 뒤 다시 업데이트하기 때문이다.\n즉, 다음과 같은 전형적인 레이스 컨디션(race condition) 문제가 발생하게 된다.\n스레드 A와 스레드 B가 각각 거의 동시에 postRepository.findById(10L)를 통해 게시글을 읽어옴.\n예를 들어, 두 스레드 모두 likes = 0인 상태의 Post 객체를 가져온다고 가정.\n각 스레드는 가져온 Post 객체의 likes를 1 증가시킨 뒤(post.increaseLikes()) postRepository.update(post)를 호출함.\nA 스레드는 DB에 likes = 1로 업데이트\nB 스레드는 DB에 likes = 1로 업데이트\n결과적으로 B가 A 다음에 DB 업데이트를 수행하면 최종 결과가 1로만 반영되어, 두 번(스레드 수만큼)의 증가가 누락된다.\n주석으로 변경한 기존의 incrementLikes() 메서드는 DB 수준에서 “likes” 컬럼을 직접 원자적(atomic)으로 증가시키는 쿼리를 사용했다.\n따라서 “읽고 → 메모리에서 +1 → DB에 다시 쓰기” 과정에서 발생하는 레이스 컨디션을 피할 수 있었다.\n하지만 또 다른 방법으로 synchronized 키워드를 사용하는 방법을 생각할 수 있다.\n동일 JVM 내에서 동시에 들어오는 여러 스레드가 이 메서드를 동시에 진입할 수 없으므로(하나의 스레드가 종료될 때까지 다른 스레드 대기), 레이스 컨디션을 피할 수 있다.\nPostLikeService\n@Service public class PostLikeServiceProxy { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeServiceProxy(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } public void likePost(Long postId, Long userId) { // 트랜잭션 시작 // 여러 스레드가 대기 // PostLikeService.likePost() 호출 // 트랜잭션 종료 } } @Service public class PostLikeService { private final PostRepository postRepository; private final PostLikeRepository postLikeRepository; public PostLikeService(PostRepository postRepository, PostLikeRepository postLikeRepository) { this.postRepository = postRepository; this.postLikeRepository = postLikeRepository; } @Transactional public synchronized void likePost(Long postId, Long userId) { // 비즈니스 시작 // postRepository.incrementLikes(postId); Post post = postRepository.findById(postId); post.increaseLikes(); postRepository.update(post); if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) { throw new IllegalStateException(\u0026#34;이미 좋아요를 누른 게시글입니다.\u0026#34;); } postLikeRepository.save(new PostLike(postId, userId, LocalDateTime.now())); } } 하지만, 그럼에도 테스트 케이스가 실패하는 것을 볼 수 있다.\n이것은 스프링의 @Transactional 애너테이션의 동작방식 때문이다.\n프록시 방식을 사용하기 때문에 프록시 클래스의 likePost() 메서드에는 synchronized가 적용되어 있지 않다.\n따라서 프록시 클래스의 likePost() 메서드 안에서 트랜잭션의 커밋 또는 롤백이 종료되기 전에 다른 스레드가 postLikeService.likePost() 메서드에 접근하여 이전 스레드가 커밋하기 전 상태의 자원을 바라볼 수 있다.\n참고로 @Transactional 애너테이션을 없애면 테스트는 통과한다.\n트랜잭션 AOP의 주의 사항2 # @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 객체를 호출해준다.\n따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다.\n이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고, 이후에 대상 객체를 호출하게 된다.\n만약, 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.\n트랜잭션 제어 로그 확인\nlogging: level: org: springframework: transaction: interceptor: TRACE jdbc: support: JdbcTransactionManager: TRACE TxInternalCallTest\n@Slf4j @SpringBootTest public class TxInternalCallTest { @Autowired private TxInternalCallService txInternalCallService; @DisplayName(\u0026#34;프록시 확인\u0026#34;) @Test void printProxy() { log.info(\u0026#34;callService class={}\u0026#34;, txInternalCallService.getClass()); } @DisplayName(\u0026#34;externalMethod에 의한 internalMethod 호출 테스트\u0026#34;) @Test void external() throws Exception { txInternalCallService.externalMethod(); } @DisplayName(\u0026#34;internalMethod 단독 호출 테스트\u0026#34;) @Test void internal() throws Exception { txInternalCallService.internalMethod(); } @TestConfiguration static class TxInternalCallTestConfig { @Bean public TxInternalCallService txInternalCallService() { return new TxInternalCallService(); } } @Service static class TxInternalCallService { public void externalMethod() { log.info(\u0026#34;call externalMethod()\u0026#34;); printTx(); internalMethod(); } @Transactional public void internalMethod() { log.info(\u0026#34;call internalMethod()\u0026#34;); printTx(); } private void printTx() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;txActive: {}\u0026#34;, txActive); } } } 예상대로 internalMethod()에서 트랜잭션이 전혀 적용되지 않았다.\n왜 이런 문제가 발생하는 것일까?\n@Transactional이 하나라도 있으면 트랜잭션 프록시 객체가 만들어진다.\n그리고 TxInternalCallService 빈을 주입 받으면 트랜잭션 프록시 객체가 대신 주입된다.\n그렇다면 어떻게 해결할 것인가?\n가장 간단한 방법은 internalMethod()을 별도의 클래스로 분리하는 것이다.\n@Slf4j @SpringBootTest public class TxInternalCallTest { @Autowired private ExternalCallService externalCallService; @DisplayName(\u0026#34;프록시 확인\u0026#34;) @Test void printProxy() { log.info(\u0026#34;callService class={}\u0026#34;, externalCallService.getClass()); } @DisplayName(\u0026#34;externalMethod에 의한 internalMethod 호출 테스트\u0026#34;) @Test void external() throws Exception { externalCallService.externalMethod(); } @TestConfiguration static class TxInternalCallTestConfig { @Bean public ExternalCallService txInternalCallService() { return new ExternalCallService(internalCall()); } @Bean public TxInternalCall internalCall() { return new TxInternalCall(); } } static class ExternalCallService { private final TxInternalCall txInternalCall; public ExternalCallService(TxInternalCall txInternalCall) { this.txInternalCall = txInternalCall; } public void externalMethod() { log.info(\u0026#34;call externalMethod()\u0026#34;); printTx(); txInternalCall.internalMethod(); } private void printTx() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;txActive: {}\u0026#34;, txActive); } } static class TxInternalCall { @Transactional public void internalMethod() { log.info(\u0026#34;call internalMethod()\u0026#34;); printTx(); } private void printTx() { boolean txActive = TransactionSynchronizationManager.isActualTransactionActive(); log.info(\u0026#34;txActive: {}\u0026#34;, txActive); } } } 이 방법 외에도 SpringAOP를 프록시 기반이 아닌 컴파일 시점이나 클래스로드 시점에 Java 코드를 조작하는 방법도 있다.\n3.4 트랜잭션의 전파(Propagation) 속성 # 트랜잭션의 전파와 속성 # 트랜잭션 전파(Propagation)는 하나의 트랜잭션 안에서 또 다른 트랜잭션 메서드를 호출할 때, 이들의 관계를 어떻게 관리할지를 결정하기 위한 메커니즘이다.\n여러 레이어(컨트롤러, 서비스, 리포지토리 등)나 여러 모듈에 걸쳐서 트랜잭션이 걸쳐 있을 수 있기 때문에, “현재 트랜잭션이 이미 존재하는 경우에는 합류할지, 새로운 트랜잭션을 열지, 아니면 에러로 처리할지” 등을 명시적으로 설정할 필요가 있다.\n스프링은 다양한 트랜잭션 전파 옵션을 제공한다. 전파 옵션에 별도의 설정을 하지 않으면 REQUIRED가 기본으로 사용된다.\nREQUIRED\n가장 많이 사용하는 기본 설정이다. 기존 트랜잭션이 없으면 생성하고, 있으면 참여한다. 트랜잭션이 필수라는 의미로 이해하면 된다.\nREQUIRES_NEW\n항상 독립적인 새 트랜잭션을 생성한다. 기존 트랜잭션이 있으면 잠시 중단(suspend)시키고, 내부 작업을 별도로 커밋 또는 롤백한다.\nSUPPORTS\n트랜잭션을 지원한다는 뜻이다. 기존 트랜잭션이 없으면, 없는대로 진행하고, 있으면 참여한다.\nNOT_SUPPORTED\n트랜잭션을 지원하지 않는다는 의미이다.\nMANDATORY\n의무사항이다. 트랜잭션이 반드시 있어야 한다. 기존 트랜잭션이 없으면 예외가 발생한다.\nNEVER\n트랜잭션을 사용하지 않는다는 의미이다. 기존 트랜잭션이 있으면 예외가 발생한다. 기존 트랜잭션도 허용하지 않는다.\nNESTED\n기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다. 기존 트랜잭션 있음: 중첩 트랜잭션을 만든다.\n전파 속성 사용 예시 # Log 스키마 생성\ndrop table log if exists cascade; create table log ( log_id bigint primary key auto_increment, msg varchar(255), created_at timestamp ); Log\n@ToString @NoArgsConstructor @Getter public class Log { private Long logId; private String msg; private LocalDateTime createdAt; private Log(String msg, LocalDateTime createdAt) { this.msg = msg; this.createdAt = createdAt; } private Log(Long logId, String msg, LocalDateTime createdAt) { this.logId = logId; this.msg = msg; this.createdAt = createdAt; } public static Log create(String msg) { return new Log(msg, LocalDateTime.now()); } } LogRepository\n@Slf4j @RequiredArgsConstructor @Repository public class LogRepository { private final JdbcTemplate jdbcTemplate; @Transactional(propagation = Propagation.REQUIRES_NEW) public void save(Log lg) { log.info(\u0026#34;save log: {}\u0026#34;, lg); String sql = \u0026#34;insert into log(msg, created_at) values(?, ?)\u0026#34;; jdbcTemplate.update(sql, lg.getMsg(), Timestamp.valueOf(lg.getCreatedAt())); } public List\u0026lt;Log\u0026gt; findAll() { log.info(\u0026#34;find all logs\u0026#34;); String sql = \u0026#34;select * from log\u0026#34;; return jdbcTemplate.query(sql, new DataClassRowMapper\u0026lt;\u0026gt;(Log.class)); } public void deleteAll() { log.info(\u0026#34;delete all logs\u0026#34;); String sql = \u0026#34;delete from log\u0026#34;; jdbcTemplate.update(sql); } } PostLikeLogService\n@Service public class PostLikeLogService { private final PostLikeService postLikeService; private final LogRepository logRepository; public PostLikeLogService(PostLikeService postLikeService, LogRepository logRepository) { this.postLikeService = postLikeService; this.logRepository = logRepository; } @Transactional public void likePost(Long postId, Long userId) { postLikeService.likePost(postId, userId); logRepository.save(Log.create(\u0026#34;Post liked. postId=\u0026#34; + postId + \u0026#34;, userId=\u0026#34; + userId)); throw new RuntimeException(\u0026#34;강제 예외 발생!\u0026#34;); } } PostLikeLogServiceTest\n@SpringBootTest class PostLikeLogServiceTest { @Autowired private PostLikeLogService postLikeLogService; @Autowired private PostRepository postRepository; @Autowired private PostLikeRepository postLikeRepository; @Autowired private LogRepository logRepository; @BeforeEach void before() { postRepository.deleteAll(); postLikeRepository.deleteAll(); logRepository.deleteAll(); postRepository.save(Post.create(10L, \u0026#34;title\u0026#34;, \u0026#34;content\u0026#34;)); } @AfterEach void clear() { postRepository.deleteAll(); postLikeRepository.deleteAll(); logRepository.deleteAll(); } @DisplayName(\u0026#34;Post 좋아요 로그 테스트\u0026#34;) @Test void postLikeLog() throws Exception { assertThrows(RuntimeException.class, () -\u0026gt; postLikeLogService.likePost(10L, 1L)); assertEquals(1, logRepository.findAll().size()); } } 핵심은 같은 트랜잭션 내에서 발생한 예외로 인해 전체가 롤백되더라도, REQUIRES_NEW를 사용한 트랜잭션 작업은 커밋되어 데이터베이스에 남는다는 것이다.\n그리고 이 글처럼 JdbcTemplate과 DataSourceTransactionManager를 사용하는 환경에서는, 바깥 트랜잭션이 이미 커넥션(Connection1)을 점유한 상태에서 REQUIRES_NEW가 내부 트랜잭션용 커넥션(Connection2)을 추가로 가져오게 된다.\n즉, 하나의 HTTP 요청 안에서도 이 구간에서는 커넥션이 최대 2개까지 동시에 점유될 수 있다.\n전체 흐름\n클라이언트가 PostLikeLogService.likePost()를 호출한다.\n이 메서드는 @Transactional(기본값: REQUIRED)이므로 논리 트랜잭션1(→ 물리 트랜잭션1)으로 묶인다.\nPostLikeService.likePost()를 호출하여 게시글 좋아요를 처리한다.\nPostLikeService.likePost() 역시 @Transactional(기본값: REQUIRED)로 동일 트랜잭션에 합류한다.\n쿼리는 동일한 Connection( Connection1 )을 통해 DB에 반영된다.\nLogRepository.save()를 호출한다.\n이 메서드는 @Transactional(propagation = Propagation.REQUIRES_NEW) 이므로 새로운 논리 트랜잭션2(→ 물리 트랜잭션2)를 생성하고, 별도 Connection( Connection2 )을 가져온다.\n이때 바깥 트랜잭션의 Connection1은 종료되는 것이 아니라 잠시 중단된 채 유지되고, 내부 트랜잭션은 Connection2로 실행된다.\n로그를 insert 한 뒤, 이 REQUIRES_NEW 트랜잭션은 즉시 커밋된다.\nPostLikeLogService.likePost() 안에서 RuntimeException을 강제 발생시킨다.\n이 예외로 인해 물리 트랜잭션1은 롤백된다.\n하지만 이미 REQUIRES_NEW로 처리된 로그 저장은 별도의 물리 트랜잭션에서 커밋이 끝났으므로, 롤백되지 않고 남아있다.\n왜 로그는 남고, 좋아요는 롤백될까?\n기본( REQUIRED ) 트랜잭션에 속한 좋아요 작업은 예외 발생 시 같이 롤백된다.\n그러나 REQUIRES_NEW는 “부모 트랜잭션을 잠시 중단(suspend)하고, 완전히 독립된 새 트랜잭션으로 동작”하기 때문에, 로그 저장이 이미 다른 물리 트랜잭션에서 커밋이 확정된다.\n결과적으로, 부모 트랜잭션이 롤백되더라도 로그 데이터는 롤백되지 않고 DB에 남게 된다.\n따라서 REQUIRES_NEW를 자주 사용하면 동시에 필요한 커넥션 수가 증가하므로, 커넥션 풀 크기와 트랜잭션 설계를 함께 고려해야 한다.\n반대로, 바깥 트랜잭션이 없는 상태에서 REQUIRES_NEW 메서드만 단독 호출된다면 결국 트랜잭션 1개, 커넥션 1개만 사용한다.\n만약 REQUIRES_NEW를 사용하지 않았다면?\nLogRepository.save()에 @Transactional(propagation = Propagation.REQUIRED)와 같이 별도 전파 설정 없이 부모 트랜잭션과 동일하게 동작한다면, 다음과 같은 결과가 나온다.\nPostLikeLogService.likePost() → postLikeService.likePost() → logRepository.save()가 동일 트랜잭션으로 묶인다. RuntimeException으로 인해, 모든 작업이 한 트랜잭션 내에서 롤백된다. 따라서 좋아요도 롤백되고, 로그 또한 DB에 남지 않는다. 4. 실습: Spring JDBC를 이용한 CRUD 구현 # 4.1 프로젝트 구성 # MySQL 데이터베이스 설치\nMAC 사용자:\nbrew update brew install mysql mysql.server start WINDOW 사용자:\nhttps://dev.mysql.com/downloads/mysql/\nIntelliJ Database 연결\nmirco-blog 스키마 생성\n프로젝트 생성\napplication.yml\nspring: datasource: url: jdbc:mysql://localhost:3306/micro-blog username: root password: driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 4.2 CRUD와 Join을 활용한 마이크로 블로그 예제 작성 # DB 스키마 생성\nDROP TABLE IF EXISTS comment; DROP TABLE IF EXISTS post; CREATE TABLE post ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, post_id BIGINT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_comment_post FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE ); -- 샘플 데이터 INSERT INTO post (title, content) VALUES (\u0026#39;첫 번째 게시글\u0026#39;, \u0026#39;블로그 예제 시작!\u0026#39;), (\u0026#39;두 번째 게시글\u0026#39;, \u0026#39;thymeleaf와 jdbc를 이용한 예제\u0026#39;); INSERT INTO comment (post_id, content) VALUES (1, \u0026#39;첫 댓글!\u0026#39;), (1, \u0026#39;두 번째 댓글\u0026#39;), (2, \u0026#39;두 번째 게시글의 첫 댓글\u0026#39;); Post\n@ToString @Getter @Setter @NoArgsConstructor @EqualsAndHashCode public class Post { private Long id; private String title; private String content; private LocalDateTime createdAt; private Post(Long id, String title, String content, LocalDateTime createdAt) { this.id = id; this.title = title; this.content = content; this.createdAt = createdAt; } public static Post create(String title, String content) { return new Post(null, title, content, LocalDateTime.now()); } public static Post of(Long id, String title, String content, LocalDateTime createdAt) { return new Post(id, title, content, createdAt); } } Comment\n@ToString @Getter @Setter @NoArgsConstructor @EqualsAndHashCode public class Comment { private Long id; private Long postId; private String content; private LocalDateTime createdAt; private Comment(Long id, Long postId, String content, LocalDateTime createdAt) { this.id = id; this.postId = postId; this.content = content; this.createdAt = createdAt; } public static Comment create(Long postId, String content) { return new Comment(null, postId, content, LocalDateTime.now()); } } PostRepository\n@Repository @RequiredArgsConstructor public class PostRepository { private final JdbcTemplate jdbcTemplate; // 게시글 저장 public void save(Post post) { String sql = \u0026#34;insert into post (title, content, created_at) values (?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, post.getTitle(), post.getContent(), post.getCreatedAt()); } // 게시글 수정 public void update(Post post) { String sql = \u0026#34;update post set title = ?, content = ? where id = ?\u0026#34;; jdbcTemplate.update(sql, post.getTitle(), post.getContent(), post.getId()); } // 게시글 단건 조회 public Post findById(Long id) { String sql = \u0026#34;select * from post where id = ?\u0026#34;; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper\u0026lt;\u0026gt;(Post.class), id); } // 모든 게시글 조회 (댓글 개수 Join) public List\u0026lt;PostWithCommentCount\u0026gt; findAllWithCommentCount() { String sql = \u0026#34;\u0026#34;\u0026#34; select p.*, count(c.id) as comment_count from post p left join comment c on p.id = c.post_id group by p.id order by p.id desc \u0026#34;\u0026#34;\u0026#34;; return jdbcTemplate.query(sql, new BeanPropertyRowMapper\u0026lt;\u0026gt;(PostWithCommentCount.class)); } // 게시글 삭제 public void deleteById(Long id) { String sql = \u0026#34;delete from post where id = ?\u0026#34;; jdbcTemplate.update(sql, id); } } CommentRepository\n@Repository @RequiredArgsConstructor public class CommentRepository { private final JdbcTemplate jdbcTemplate; // 댓글 저장 public void save(Comment comment) { String sql = \u0026#34;insert into comment (post_id, content, created_at) values (?, ?, ?)\u0026#34;; jdbcTemplate.update(sql, comment.getPostId(), comment.getContent(), comment.getCreatedAt()); } // 특정 게시글의 모든 댓글 조회 public List\u0026lt;Comment\u0026gt; findByPostId(Long postId) { String sql = \u0026#34;select * from comment where post_id = ? order by id desc\u0026#34;; return jdbcTemplate.query(sql, new BeanPropertyRowMapper\u0026lt;\u0026gt;(Comment.class), postId); } // 댓글 삭제 public void deleteById(Long id) { String sql = \u0026#34;delete from comment where id = ?\u0026#34;; jdbcTemplate.update(sql, id); } } PostService\n@Transactional @RequiredArgsConstructor @Service public class PostService { private final PostRepository postRepository; private final CommentRepository commentRepository; public List\u0026lt;PostWithCommentCount\u0026gt; getPostsWithCommentCount() { return postRepository.findAllWithCommentCount(); } public PostDetailDto getPostDetail(Long id) { Post post = postRepository.findById(id); List\u0026lt;Comment\u0026gt; comments = commentRepository.findByPostId(id); return PostDetailDto.of(post, comments); } public void createPost(Post post) { postRepository.save(post); } public void updatePost(Post post) { postRepository.update(post); } public void deletePost(Long id) { postRepository.deleteById(id); } } CommentService\n@Transactional @RequiredArgsConstructor @Service public class CommentService { private final CommentRepository commentRepository; public void createComment(Comment comment) { commentRepository.save(comment); } public void deleteComment(Long id) { commentRepository.deleteById(id); } } PostController\n@Controller @RequiredArgsConstructor @RequestMapping(\u0026#34;/posts\u0026#34;) public class PostController { private final PostService postService; @GetMapping public String list(Model model) { List\u0026lt;PostWithCommentCount\u0026gt; posts = postService.getPostsWithCommentCount(); model.addAttribute(\u0026#34;posts\u0026#34;, posts); return \u0026#34;post_list\u0026#34;; } @GetMapping(\u0026#34;/{id}\u0026#34;) public String detail(@PathVariable(name = \u0026#34;id\u0026#34;) Long id, Model model) { PostDetailDto postDetailDto = postService.getPostDetail(id); model.addAttribute(\u0026#34;postDetail\u0026#34;, postDetailDto); return \u0026#34;post_detail\u0026#34;; } @GetMapping(\u0026#34;/new\u0026#34;) public String newForm(Model model) { model.addAttribute(\u0026#34;post\u0026#34;, new Post()); // 폼 바인딩용 return \u0026#34;post_form\u0026#34;; } @PostMapping public String create(@RequestParam String title, @RequestParam String content) { Post post = Post.create(title, content); postService.createPost(post); return \u0026#34;redirect:/posts\u0026#34;; } @GetMapping(\u0026#34;/{id}/edit\u0026#34;) public String editForm(@PathVariable(name = \u0026#34;id\u0026#34;) Long id, Model model) { Post post = postService.getPostDetail(id).toPost(); model.addAttribute(\u0026#34;post\u0026#34;, post); return \u0026#34;post_form\u0026#34;; } @PostMapping(\u0026#34;/{id}/edit\u0026#34;) public String update(@PathVariable(name = \u0026#34;id\u0026#34;) Long id, @RequestParam String title, @RequestParam String content) { Post post = Post.of(id, title, content, null); postService.updatePost(post); return \u0026#34;redirect:/posts/\u0026#34; + id; } @PostMapping(\u0026#34;/{id}/delete\u0026#34;) public String delete(@PathVariable(name = \u0026#34;id\u0026#34;) Long id) { postService.deletePost(id); return \u0026#34;redirect:/posts\u0026#34;; } } CommentController\n@Controller @RequiredArgsConstructor @RequestMapping(\u0026#34;/posts/{postId}/comments\u0026#34;) public class CommentController { private final CommentService commentService; @PostMapping public String createComment(@PathVariable(name = \u0026#34;postId\u0026#34;) Long postId, @RequestParam String content) { Comment comment = Comment.create(postId, content); commentService.createComment(comment); return \u0026#34;redirect:/posts/\u0026#34; + postId; } @PostMapping(\u0026#34;/{commentId}/delete\u0026#34;) public String deleteComment(@PathVariable(name = \u0026#34;postId\u0026#34;) Long postId, @PathVariable(name = \u0026#34;commentId\u0026#34;) Long commentId) { commentService.deleteComment(commentId); return \u0026#34;redirect:/posts/\u0026#34; + postId; } } PostDetailDto\n@ToString @Getter @EqualsAndHashCode public class PostDetailDto { private final Long id; private final String title; private final String content; private final LocalDateTime createdAt; private final List\u0026lt;Comment\u0026gt; comments; private PostDetailDto(Long id, String title, String content, LocalDateTime createdAt, List\u0026lt;Comment\u0026gt; comments) { this.id = id; this.title = title; this.content = content; this.createdAt = createdAt; this.comments = comments; } public static PostDetailDto of(Post post, List\u0026lt;Comment\u0026gt; comments) { return new PostDetailDto( post.getId(), post.getTitle(), post.getContent(), post.getCreatedAt(), comments ); } public Post toPost() { return Post.of(id, title, content, createdAt); } } PostWithCommentCount\n@ToString @Getter @Setter @NoArgsConstructor @EqualsAndHashCode public class PostWithCommentCount { private Long id; private String title; private String content; private LocalDateTime createdAt; private int commentCount; private PostWithCommentCount(Long id, String title, String content, int commentCount) { this.id = id; this.title = title; this.content = content; this.commentCount = commentCount; } } post_list.html\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html xmlns:th=\u0026#34;http://www.thymeleaf.org\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;게시글 목록\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;게시글 목록\u0026lt;/h1\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts/new}\u0026#34;\u0026gt;새 글 작성\u0026lt;/a\u0026gt; \u0026lt;table border=\u0026#34;1\u0026#34;\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;th\u0026gt;ID\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;제목\u0026lt;/th\u0026gt; \u0026lt;th\u0026gt;댓글 수\u0026lt;/th\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;tr th:each=\u0026#34;post : ${posts}\u0026#34;\u0026gt; \u0026lt;td th:text=\u0026#34;${post.id}\u0026#34;\u0026gt;\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts/{id}(id=${post.id})}\u0026#34; th:text=\u0026#34;${post.title}\u0026#34;\u0026gt;제목\u0026lt;/a\u0026gt; \u0026lt;/td\u0026gt; \u0026lt;td th:text=\u0026#34;${post.commentCount}\u0026#34;\u0026gt;\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; post_detail.html\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html xmlns:th=\u0026#34;http://www.thymeleaf.org\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;게시글 상세\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1 th:text=\u0026#34;${postDetail.title}\u0026#34;\u0026gt;제목\u0026lt;/h1\u0026gt; \u0026lt;p th:text=\u0026#34;${postDetail.content}\u0026#34;\u0026gt;내용\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;작성일: \u0026lt;span th:text=\u0026#34;${#temporals.format(postDetail.createdAt, \u0026#39;yyyy-MM-dd HH:mm\u0026#39;)}\u0026#34;\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;/p\u0026gt; \u0026lt;hr/\u0026gt; \u0026lt;h2\u0026gt;댓글\u0026lt;/h2\u0026gt; \u0026lt;div th:each=\u0026#34;comment : ${postDetail.comments}\u0026#34;\u0026gt; \u0026lt;p th:text=\u0026#34;${comment.content}\u0026#34;\u0026gt;댓글내용\u0026lt;/p\u0026gt; \u0026lt;small th:text=\u0026#34;${#temporals.format(comment.createdAt, \u0026#39;yyyy-MM-dd HH:mm\u0026#39;)}\u0026#34;\u0026gt;\u0026lt;/small\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{postId}/comments/{commentId}/delete(postId=${postDetail.id}, commentId=${comment.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;삭제\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;hr/\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- 댓글 작성 --\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{id}/comments(id=${postDetail.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;textarea name=\u0026#34;content\u0026#34; rows=\u0026#34;3\u0026#34; cols=\u0026#34;40\u0026#34;\u0026gt;\u0026lt;/textarea\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;댓글 작성\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;hr/\u0026gt; \u0026lt;!-- 게시글 수정, 삭제 --\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{id}/edit(id=${postDetail.id})}\u0026#34; method=\u0026#34;get\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;수정하기\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;form th:action=\u0026#34;@{/posts/{id}/delete(id=${postDetail.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;삭제하기\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts}\u0026#34;\u0026gt;목록으로 돌아가기\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; post_form.html\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html xmlns:th=\u0026#34;http://www.thymeleaf.org\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;게시글 작성/수정\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;게시글 작성/수정\u0026lt;/h1\u0026gt; \u0026lt;form th:if=\u0026#34;${post.id == null}\u0026#34; th:action=\u0026#34;@{/posts}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;p\u0026gt;제목: \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;title\u0026#34;/\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;내용: \u0026lt;textarea name=\u0026#34;content\u0026#34; rows=\u0026#34;5\u0026#34; cols=\u0026#34;40\u0026#34;\u0026gt;\u0026lt;/textarea\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;작성\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;form th:if=\u0026#34;${post.id != null}\u0026#34; th:action=\u0026#34;@{/posts/{id}/edit(id=${post.id})}\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;p\u0026gt;제목: \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;title\u0026#34; th:value=\u0026#34;${post.title}\u0026#34;/\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;내용: \u0026lt;textarea name=\u0026#34;content\u0026#34; rows=\u0026#34;5\u0026#34; cols=\u0026#34;40\u0026#34; th:text=\u0026#34;${post.content}\u0026#34;\u0026gt;\u0026lt;/textarea\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;수정\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;a th:href=\u0026#34;@{/posts}\u0026#34;\u0026gt;목록으로 돌아가기\u0026lt;/a\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 출처:\n토비의 스프링\n그림으로 배우는 스프링6 입문\n김영한의 스프링 DB 1, 2\nhttps://docs.spring.io/spring-framework/reference/data-access.html\nhttps://github.com/brettwooldridge/HikariCP?tab=readme-ov-file\n","date":"2025년 4월 12일","externalUrl":null,"permalink":"/posts/jdbc-transaction/","section":"Posts","summary":"JDBC API 개요, DataSource 설정, JdbcTemplate 사용법, @Transactional 트랜잭션 관리와 전파 속성까지 정리합니다.","title":"JDBC 기초와 트랜잭션 관리","type":"posts"},{"content":"","date":"2025년 4월 12일","externalUrl":null,"permalink":"/tags/jdbctemplate/","section":"Tags","summary":"","title":"JdbcTemplate","type":"tags"},{"content":"","date":"2025년 2월 18일","externalUrl":null,"permalink":"/tags/nio-connector/","section":"Tags","summary":"","title":"NIO Connector","type":"tags"},{"content":"","date":"2025년 2월 18일","externalUrl":null,"permalink":"/tags/performance/","section":"Tags","summary":"","title":"Performance","type":"tags"},{"content":"","date":"2025년 2월 18일","externalUrl":null,"permalink":"/tags/thread-pool/","section":"Tags","summary":"","title":"Thread Pool","type":"tags"},{"content":"","date":"2025년 2월 18일","externalUrl":null,"permalink":"/tags/tomcat/","section":"Tags","summary":"","title":"Tomcat","type":"tags"},{"content":"이전 글: Spring Boot Tomcat threads.max와 HikariCP maximum-pool-size를 기본으로 설정하면 나타나는 현상\n앞선 글에서 Tomcat의 threads.max를 200에서 60으로 줄이고, HikariCP의 maximum-pool-size를 10에서 40으로 늘려 응답 시간을 5~10배 개선했습니다.\n그런데 하나의 의문이 남았습니다.\n가상 유저(VUser)가 300명이 동시에 트래픽을 발생시키는데, Tomcat의 Thread Pool 사이즈를 60개로 줄이면 나머지 240개의 요청은 스레드를 얻기 위해 경합하지 않을까?\n스레드를 줄였는데 성능이 좋아졌다면, 줄어든 스레드로 어떻게 동일한 트래픽을 처리할 수 있었는지 이해해야 합니다.\n흔한 오해 — \u0026ldquo;1 Connection = 1 Thread\u0026rdquo; # 클라이언트가 연결되거나 요청을 보내면 → Tomcat이 곧바로 Worker Thread를 하나 할당하고 → 해당 스레드가 커넥션의 전체 생명주기를 담당한다.\nSpring MVC의 동기 서블릿 처리에서는 실제로 실행 중인 요청 하나가 Worker Thread 하나를 점유하는 것은 맞습니다. Controller, Service, DB I/O를 수행하는 동안 해당 요청은 하나의 Worker Thread 위에서 처리됩니다.\n하지만 이것을 연결된 클라이언트 수나 도착한 요청 수만큼 Worker Thread가 즉시 필요하다는 뜻으로 이해하면 오해가 됩니다. 이 모델에서는 VUser 300명이 동시에 연결되면 Worker Thread도 300개가 필요하다고 보게 되고, 스레드가 60개면 나머지 240개의 요청은 거부되거나 실패해야 합니다.\n하지만 실제로는 그렇지 않았습니다. VUser 300명의 부하에서도 요청이 사라지지 않았고, Error Rate는 0%였습니다.\n이 차이를 이해하려면 Tomcat이 커넥션(Connection) 과 스레드(Thread) 를 어떻게 관리하는지 알아야 합니다.\nTomcat의 큰 구조 — Coyote와 Catalina # Spring Boot의 내장 Tomcat은 크게 두 컴포넌트로 나뉩니다.\nCoyote (커넥터) 는 소켓 I/O와 HTTP 프로토콜 처리를 담당합니다. 브라우저가 연결을 맺으면 소켓에서 바이트 스트림을 읽어 HTTP를 파싱하고, HttpServletRequest / HttpServletResponse 객체를 생성합니다. 이후 Catalina의 invoke()를 호출해 제어권을 넘기고, 응답이 완성되면 다시 바이트로 직렬화해 소켓으로 전송합니다.\nCatalina (서블릿 컨테이너) 는 서블릿의 생명주기와 요청 라우팅을 관리합니다. Spring Boot에서는 앱 하나가 Catalina의 Context 하나에 해당합니다. DispatcherServlet은 Spring MVC의 프론트 컨트롤러이며, urlPatterns = \u0026quot;/\u0026quot;로 이 Context에 등록되어 있기 때문에 애플리케이션으로 들어오는 대부분의 웹 요청이 먼저 이 서블릿을 통과합니다.\n브라우저 → TCP 소켓 연결 → Coyote: 소켓 I/O → HTTP 파싱 → Req/Res 객체 생성 → invoke() → Catalina (Context = Spring Boot 앱) → DispatcherServlet → HandlerMapping → @Controller → 응답 → Coyote → 소켓 → 브라우저 이 구조를 이해하면, 아래에서 다룰 NIO Connector가 Coyote 내부의 소켓 I/O 처리 방식임을 알 수 있습니다.\nTomcat NIO Connector 아키텍처 # Spring Boot 3.x에서 사용하는 임베디드 Tomcat은 기본적으로 NIO(Non-blocking I/O) Connector를 사용합니다. NIO Connector는 요청을 처리하기 위해 3단계 파이프라인 구조로 동작합니다.\n1단계: Acceptor Thread # Acceptor는 1~2개의 스레드로 구성됩니다. OS의 accept queue에서 TCP 연결을 수락하고, 수락된 소켓 채널(SocketChannel)을 Poller에 등록하는 역할을 합니다.\nClient → TCP 3-way handshake → OS accept queue → Acceptor.accept() → Poller에 등록 Acceptor는 연결을 수락만 할 뿐, 요청 데이터를 읽거나 비즈니스 로직을 실행하지 않습니다.\n2단계: Poller Thread # Poller는 Java NIO의 Selector를 사용하여 소수의 스레드로 수천 개의 커넥션을 감시합니다.\nPoller의 역할은 등록된 소켓 채널들을 모니터링하다가, 특정 채널에 읽을 수 있는 데이터가 도착하면 해당 소켓을 Worker Thread Pool에 전달하는 것입니다.\n// Poller의 핵심 동작 (개념적 코드) Selector selector = Selector.open(); while (running) { // 등록된 수천 개의 채널 중 데이터가 준비된 채널을 감지 selector.select(); Set\u0026lt;SelectionKey\u0026gt; selectedKeys = selector.selectedKeys(); for (SelectionKey key : selectedKeys) { if (key.isReadable()) { // 데이터가 도착한 소켓만 Worker Thread Pool로 전달 workerThreadPool.execute(new SocketProcessor(key)); } } } 여기서 selector.select()가 어떻게 단 하나의 호출로 수천 개의 커넥션을 감시할 수 있는지 좀 더 자세히 살펴보겠습니다.\nAcceptor가 새로운 커넥션을 수락하면, 해당 소켓 채널은 selector.register(channel, OP_READ)를 통해 Selector에 등록됩니다. Selector는 내부적으로 등록된 모든 채널의 파일 디스크립터(fd)를 관리합니다.\nselector.select()가 호출되면, Java는 이 작업을 OS 커널의 I/O 멀티플렉싱 시스템 콜에 위임합니다.\nLinux: epoll_wait() — 커널이 등록된 fd들의 상태 변화를 감시하고, 데이터가 도착한 fd만 반환 macOS: kqueue — 동일한 원리로 이벤트가 발생한 fd만 반환 이 시스템 콜들은 **이벤트 기반(event-driven)**으로 동작합니다. 등록된 수천 개의 커넥션을 하나씩 순회하며 확인하는 것이 아니라, 커널이 네트워크 인터럽트를 통해 데이터가 도착한 소켓을 이미 알고 있기 때문에 준비된 커넥션만 즉시 반환할 수 있습니다.\nPoller 스레드의 동작 흐름: selector.select() ← 준비된 채널이 없으면 여기서 블로킹 (CPU 소비 없음) │ ├── 채널 A에 데이터 도착 → Worker Thread Pool에 전달 ├── 채널 B에 데이터 도착 → Worker Thread Pool에 전달 │ (나머지 수백 개의 채널은 여전히 Selector에 등록된 채 대기) │ └── 다시 selector.select() 호출 → 다음 이벤트 대기 즉, Poller 스레드 자체가 곧 감시 스레드입니다. 커넥션마다 별도의 감시 스레드가 할당되는 것이 아니라, 1~2개의 Poller 스레드가 selector.select() → 이벤트 처리 → selector.select() 루프를 반복하면서 모든 커넥션을 관리합니다. 준비된 채널이 없으면 select()에서 블로킹되어 CPU를 소비하지 않고, 이벤트가 발생하면 깨어나서 해당 채널만 Worker Thread Pool에 전달합니다.\n핵심: Poller는 1~2개의 스레드만으로 수천 개의 커넥션을 감시합니다. 이것이 가능한 이유는 selector.select()가 OS 커널의 I/O 멀티플렉싱(epoll/kqueue)을 활용하여, 하나의 호출로 등록된 모든 채널의 상태를 확인하기 때문입니다. 커넥션을 유지하는 데 별도의 Worker 스레드가 필요하지 않습니다.\n3단계: Worker Thread Pool # Worker Thread Pool은 threads.max(기본값 200, 현재 설정 60)만큼의 스레드로 구성됩니다. Poller로부터 전달받은 소켓에서 HTTP 요청을 읽고, DispatcherServlet을 호출하여 실제 비즈니스 로직을 실행합니다.\nPoller → Worker Thread 할당 → HTTP 요청 파싱 → DispatcherServlet → Controller → Service → DB I/O → 응답 작성 → Worker Thread 반환 Worker 스레드는 비즈니스 로직 실행이 끝나면 풀에 반환되고, 다음 요청을 처리할 준비를 합니다.\n그래서 나머지 240개의 요청은 어디로 갔을까? # VUser 300명이 동시에 요청을 보내고, Worker Thread가 60개일 때의 흐름을 정리하면 다음과 같습니다.\n300개의 TCP 커넥션이 Tomcat에 수립됩니다. max-connections의 기본값은 8192(NIO/NIO2)이므로, 300개는 전체 용량의 3.7%에 불과합니다. 모든 커넥션이 문제없이 수용됩니다.\nPoller가 300개의 커넥션을 Selector에 등록하고 감시합니다. 이때 Worker 스레드는 소비되지 않습니다.\n300개의 커넥션에서 데이터가 도착하면, Poller는 이를 감지하고 Worker Thread Pool에 작업을 전달합니다.\n60개의 Worker 스레드가 먼저 도착한 60개의 요청을 처리합니다. 나머지 240개의 요청은 Worker Thread Pool의 TaskQueue에서 대기합니다.\nWorker 스레드가 하나의 요청 처리를 완료하면, TaskQueue에서 대기 중인 다음 요청을 가져와 처리합니다.\n결론: 240개의 요청은 유실된 것이 아니라, NIO Poller가 커넥션을 유지한 상태에서 Worker Thread Pool의 큐에서 순서를 기다리고 있었습니다.\n요청이 대기하는 3가지 계층 # Tomcat NIO Connector에는 요청이 대기할 수 있는 3개의 계층이 존재합니다. 각 계층은 서로 다른 설정값으로 제어됩니다.\n계층 설정값 기본값 역할 OS TCP backlog server.tomcat.accept-count 100 max-connections 도달 시 OS 커널 레벨 TCP 연결 대기열 Tomcat NIO server.tomcat.max-connections 8192 (NIO/NIO2) 서버가 수락하고 처리하는 최대 커넥션 수 Worker Thread Pool server.tomcat.threads.max 200 실제 요청을 처리하는 워커 스레드 수 각 계층의 동작 # 1계층 — Worker Thread Pool (threads.max)\n모든 Worker 스레드가 바쁘면, 새로운 요청은 Worker Thread Pool의 내부 TaskQueue에 쌓입니다. 스레드가 반환되면 큐에서 대기 중인 작업을 하나씩 처리합니다.\n2계층 — Tomcat NIO (max-connections)\nTomcat이 수락하고 Poller에 등록할 수 있는 최대 커넥션 수입니다. Tomcat 공식 문서에 따르면 NIO/NIO2의 기본값은 8192입니다. 이 값에 도달하면 Acceptor는 새 커넥션 수락을 일시 중단하고, 기존 커넥션이 닫힐 때까지 기다립니다.\n3계층 — OS TCP backlog (accept-count)\nmax-connections에 도달하여 Acceptor가 수락을 중단하면, 이후 들어오는 TCP 연결 요청은 OS 커널의 TCP backlog 큐에 쌓입니다. 이 큐의 크기가 accept-count(기본값 100)입니다. 이 큐마저 가득 차면 OS는 새로운 연결 요청을 거부(Connection Refused) 합니다.\n[VUser 300] │ ▼ [OS TCP Backlog] ─── accept-count: 100 (max-connections 도달 시에만 사용) │ ▼ [Acceptor Thread] ─── 커넥션 수락 │ ▼ [Poller (Selector)] ─── max-connections: 8192 (커넥션 감시, 스레드 소비 없음) │ ▼ [Worker Thread Pool] ─── threads.max: 60 (실제 요청 처리) │ ▼ [DispatcherServlet → Controller → Service → DB] 그렇다면 Tomcat이 거부하지 않고 받아들일 수 있는 최대 연결 수는? # 3계층의 설정값을 합산하면 Tomcat이 Connection Refused 없이 수용할 수 있는 총 TCP 연결 수를 계산할 수 있습니다.\n수용 가능한 최대 연결 = max-connections + accept-count = 8192 + 100 = 8292개 여기서 주의할 점은, 이 8292개의 연결이 모두 동시에 \u0026ldquo;처리\u0026quot;되는 것은 아니라는 것입니다. 각 계층에서의 상태가 다릅니다.\n계층 연결 수 상태 Worker Thread Pool threads.max개 (60개) 실제로 비즈니스 로직을 실행 중 Tomcat NIO Poller 최대 8192개 Selector에 등록되어 감시 중 (스레드 소비 없음) OS TCP Backlog 최대 100개 OS 커널에서 TCP 연결만 수립된 채 대기 즉, 8292개의 연결을 거부 없이 받아들일 수 있지만, 그 중 실제로 요청을 동시에 처리하는 것은 threads.max개뿐입니다. 나머지는 Poller에서 감시되거나 Worker Thread Pool의 TaskQueue에서 대기하는 상태입니다.\n8293번째 TCP 연결부터는 OS가 Connection Refused를 반환합니다. 3계층 모두가 가득 찬 상태에서 더 이상 연결을 수용할 공간이 없기 때문입니다.\n우리의 상황에서는? # VUser 300명의 경우:\n300 커넥션 \u0026laquo; 8192 (max-connections) → 모든 커넥션 수용 가능, 전체 용량의 3.7%에 불과 OS TCP backlog(accept-count: 100)는 max-connections에 도달했을 때만 사용되는 대기열이므로, 이 시나리오에서는 큐가 비어 있는 상태 60개의 Worker 스레드가 300개의 요청을 순차적으로 처리, 나머지 240개는 TaskQueue에서 대기 BIO vs NIO — 왜 예전 모델에서는 이것이 불가능했을까? # Tomcat 7 이전에는 BIO(Blocking I/O) Connector가 기본이었습니다. BIO에서는 커넥션과 스레드가 1:1로 매핑됩니다.\n항목 BIO (Blocking I/O) NIO (Non-blocking I/O) Connection : Thread 비율 1:1 N:1 max-connections 기본값 = threads.max 8192 유휴 커넥션 비용 스레드 1개 점유 (비쌈) Selector 등록 (저렴) threads.max=60일 때 최대 60개 커넥션만 가능 8192개 커넥션 가능 BIO에서는 threads.max=60이면 동시에 60개의 커넥션만 유지할 수 있었습니다. 61번째 연결은 스레드가 반환될 때까지 대기해야 했습니다. 이 구조에서는 스레드를 줄이면 곧바로 처리 가능한 커넥션 수가 줄어들기 때문에, 동일 트래픽에서 성능이 저하될 수 있었습니다.\n하지만 NIO에서는 커넥션 관리와 요청 처리가 분리되어 있습니다. Poller가 수천 개의 커넥션을 스레드 없이 유지하므로, threads.max를 줄여도 커넥션 수용 능력에는 영향이 없습니다.\nSpring Boot 3.x의 임베디드 Tomcat은 NIO Connector(Http11NioProtocol)를 기본으로 사용합니다.\n코드로 직접 확인하기 # 이론만으로는 확신하기 어렵습니다. 실제로 커넥션 수와 스레드 수가 독립적으로 동작하는지 코드로 확인해 보겠습니다.\n테스트 실행 환경: 아래 테스트들은 애플리케이션을 먼저 실행한 상태에서 localhost:8080으로 직접 HTTP 요청을 보내는 방식입니다. @SpringBootTest를 사용한 통합 테스트가 아니므로, 테스트 전에 application.yml 설정을 변경한 뒤 서버를 실행해야 합니다.\n테스트 1: Slow Endpoint를 이용한 배치 처리 확인 # 먼저, 의도적으로 느린 엔드포인트를 만들어 Worker 스레드가 점유된 상태를 유지합니다.\n@RestController public class SlowEndpointController { @GetMapping(\u0026#34;/test/slow\u0026#34;) public String slowEndpoint() throws InterruptedException { String threadName = Thread.currentThread().getName(); long startTime = System.currentTimeMillis(); Thread.sleep(3000); // 3초간 스레드 점유 long elapsed = System.currentTimeMillis() - startTime; return String.format(\u0026#34;Thread: %s, Elapsed: %dms\u0026#34;, threadName, elapsed); } } 그리고 application.yml에서 threads.max=5로 설정한 뒤, 동시에 20개의 요청을 보냅니다.\n# application.yml server: tomcat: threads: max: 5 @Test @DisplayName(\u0026#34;threads.max보다 많은 동시 요청을 보내도 모든 요청이 처리된다 - Connection ≠ Thread 확인\u0026#34;) void batchProcessing() throws Exception { // given - 스레드(5개)보다 4배 많은 동시 요청 준비 int totalRequests = 20; ExecutorService executor = Executors.newFixedThreadPool(totalRequests); CountDownLatch latch = new CountDownLatch(totalRequests); List\u0026lt;Future\u0026lt;String\u0026gt;\u0026gt; futures = new ArrayList\u0026lt;\u0026gt;(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); long testStart = System.currentTimeMillis(); // when - 20개의 요청을 동시에 전송 for (int i = 0; i \u0026lt; totalRequests; i++) { futures.add(executor.submit(() -\u0026gt; { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/test/slow\u0026#34;)) .GET() .build(); HttpResponse\u0026lt;String\u0026gt; response = client.send(request, HttpResponse.BodyHandlers.ofString()); long elapsed = System.currentTimeMillis() - testStart; return String.format(\u0026#34;[%5dms] Status: %d, Body: %s\u0026#34;, elapsed, response.statusCode(), response.body()); } finally { latch.countDown(); } })); } latch.await(30, TimeUnit.SECONDS); // then - 모든 요청이 5개씩 배치로 처리되어 응답을 받는다 for (Future\u0026lt;String\u0026gt; future : futures) { System.out.println(future.get()); } executor.shutdown(); } 결과 # 결과를 보면 정확히 5개씩 같은 시간에 응답이 돌아오지 않고, 같은 배치 내에서도 수 밀리초의 차이가 발생합니다. 이는 20개의 클라이언트 스레드가 요청을 보내는 시점이 미세하게 다르고, Worker 스레드가 작업을 완료한 뒤 TaskQueue에서 다음 요청을 가져가는 타이밍도 OS의 스레드 스케줄링에 따라 달라지기 때문입니다. 중요한 것은 전체적인 패턴 — ~3초, ~6초, ~9초, ~12초 구간에 걸쳐 5개씩 묶여서 처리되고, 20개의 요청이 하나도 유실되지 않았다는 점입니다. Connection ≠ Thread임을 확인할 수 있습니다.\n테스트 2: Tomcat 내부 상태 조회 # Tomcat의 내부 상태를 코드로 직접 확인할 수도 있습니다. 먼저 Tomcat의 커넥션 수와 스레드 풀 상태를 JSON으로 반환하는 엔드포인트를 만듭니다. AbstractProtocol의 getConnectionCount() 메서드는 Tomcat 공식 API에서 제공하며, JMX를 통해서도 접근 가능합니다.\n@RestController @RequiredArgsConstructor public class TomcatInternalController { private final WebServerApplicationContext webServerAppContext; @GetMapping(\u0026#34;/tomcat/internals\u0026#34;) public Map\u0026lt;String, Object\u0026gt; getTomcatInternals() { TomcatWebServer tomcatWebServer = (TomcatWebServer) webServerAppContext.getWebServer(); Connector connector = tomcatWebServer.getTomcat() .getConnector(); AbstractProtocol\u0026lt;?\u0026gt; protocol = (AbstractProtocol\u0026lt;?\u0026gt;) connector.getProtocolHandler(); ThreadPoolExecutor executor = (ThreadPoolExecutor) protocol.getExecutor(); Map\u0026lt;String, Object\u0026gt; info = new LinkedHashMap\u0026lt;\u0026gt;(); // Connector 레벨 info.put(\u0026#34;maxConnections\u0026#34;, protocol.getMaxConnections()); info.put(\u0026#34;currentConnections\u0026#34;, protocol.getConnectionCount()); info.put(\u0026#34;acceptCount\u0026#34;, protocol.getAcceptCount()); // Thread Pool 레벨 info.put(\u0026#34;maxThreads\u0026#34;, executor.getMaximumPoolSize()); info.put(\u0026#34;currentThreads\u0026#34;, executor.getPoolSize()); info.put(\u0026#34;activeThreads\u0026#34;, executor.getActiveCount()); info.put(\u0026#34;queueSize\u0026#34;, executor.getQueue().size()); return info; } } 테스트 1과 동일한 threads.max=5 설정에서, 20개의 slow 요청으로 스레드를 점유시킨 뒤 내부 상태를 조회합니다.\n@Test @DisplayName(\u0026#34;커넥션 수와 활성 스레드 수가 독립적으로 관리됨을 확인한다\u0026#34;) void tomcatInternals() throws Exception { int totalRequests = 20; ExecutorService executor = Executors.newFixedThreadPool(totalRequests); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); // 20개의 slow 요청을 동시에 전송 (각 3초 소요) for (int i = 0; i \u0026lt; totalRequests; i++) { executor.submit(() -\u0026gt; { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/test/slow\u0026#34;)) .GET() .build(); client.send(request, HttpResponse.BodyHandlers.ofString()); } catch (Exception e) { e.printStackTrace(); } }); } // 요청들이 서버에 도달할 시간을 확보한 뒤 내부 상태 조회 Thread.sleep(1000); HttpRequest internalsRequest = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/tomcat/internals\u0026#34;)) .GET() .build(); HttpResponse\u0026lt;String\u0026gt; response = client.send(internalsRequest, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); executor.shutdown(); } 결과 # 두 번 실행한 결과 모두 currentConnections: 22, currentThreads: 5로 동일하지만, activeThreads는 1차에서 2개, 2차에서 4개로 달라졌습니다. 이는 /tomcat/internals를 조회하는 시점이 배치 간 전환 타이밍에 따라 달라지기 때문입니다. queueSize가 0인 것도 같은 이유로, 조회 시점에 이미 대기 중인 요청이 Worker 스레드에 전달된 뒤였기 때문입니다.\n핵심은 커넥션이 22개 유지되는 동안에도 스레드는 최대 5개만 사용된다는 점입니다. 나머지 커넥션은 Poller가 스레드 없이 관리하고 있으며, 이것이 NIO Connector의 핵심 동작입니다.\n테스트 3: 커넥션 한계를 초과하면? # NIO에서도 한계는 존재합니다. max-connections와 accept-count를 극단적으로 줄여 한계를 확인해 보겠습니다.\n# application.yml server: tomcat: max-connections: 10 accept-count: 5 threads: max: 3 이 설정에서 20개의 동시 요청을 보내면:\n구간 커넥션 수 상태 1~10번째 10개 Tomcat이 수락, Poller에서 관리 11~15번째 5개 OS TCP backlog 큐에서 대기 16~20번째 5개 수용 불가 — OS가 연결 거부 또는 타임아웃 @Test @DisplayName(\u0026#34;max-connections + accept-count를 초과하는 요청은 실패한다\u0026#34;) void connectionLimit() throws Exception { // given - max-connections(10) + accept-count(5) = 15개를 초과하는 20개 요청 준비 int totalRequests = 20; ExecutorService executor = Executors.newFixedThreadPool(totalRequests); CountDownLatch latch = new CountDownLatch(totalRequests); AtomicInteger successCount = new AtomicInteger(0); AtomicInteger failCount = new AtomicInteger(0); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); long testStart = System.currentTimeMillis(); // when - 20개의 요청을 동시에 전송 for (int i = 0; i \u0026lt; totalRequests; i++) { final int requestId = i; executor.submit(() -\u0026gt; { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(\u0026#34;http://localhost:8080/test/slow\u0026#34;)) .GET() .build(); HttpResponse\u0026lt;String\u0026gt; response = client.send(request, HttpResponse.BodyHandlers.ofString()); long elapsed = System.currentTimeMillis() - testStart; successCount.incrementAndGet(); System.out.printf(\u0026#34;[%5dms] Request %d: SUCCESS (status=%d)%n\u0026#34;, elapsed, requestId, response.statusCode()); } catch (Exception e) { long elapsed = System.currentTimeMillis() - testStart; failCount.incrementAndGet(); System.out.printf(\u0026#34;[%5dms] Request %d: FAILED (%s)%n\u0026#34;, elapsed, requestId, e.getMessage()); } finally { latch.countDown(); } }); } latch.await(120, TimeUnit.SECONDS); // then System.out.printf(\u0026#34;%n성공: %d, 실패: %d%n\u0026#34;, successCount.get(), failCount.get()); executor.shutdown(); } 결과 # 타임스탬프를 기준으로 결과를 분석하면:\n시점 수 상태 ~3초 3개 SUCCESS 1번째 배치 (threads.max=3) ~5초 5개 FAILED connectTimeout(5초) 초과로 타임아웃 ~6초 3개 SUCCESS 2번째 배치 ~9초 3개 SUCCESS 3번째 배치 ~12초 1개 SUCCESS 4번째 배치 (첫 10개 중 마지막) ~35초 3개 SUCCESS backlog 요청 — keep-alive 만료 후 수락 ~38초 2개 SUCCESS backlog 요청 — keep-alive 만료 후 수락 최종 결과는 성공 15개, 실패 5개입니다.\n먼저, Tomcat이 수락한 10개의 커넥션은 threads.max: 3에 의해 3개씩 배치로 처리되어 ~3초, ~6초, ~9초, ~12초에 걸쳐 모두 성공합니다. 이 동안 TCP backlog 큐의 요청 중 5개는 클라이언트의 connectTimeout(5초)를 초과하여 클라이언트가 먼저 타임아웃으로 연결을 포기합니다.\n흥미로운 점은 ~12초에 첫 10개 처리가 모두 끝났는데, 나머지 backlog 요청이 ~35초에야 처리된다는 것입니다. 약 23초의 공백이 발생한 이유는 HTTP/1.1 keep-alive 때문입니다. 응답이 끝나도 커넥션이 바로 닫히지 않아 max-connections: 10 자리가 계속 차 있는 상태이므로, Tomcat의 기본 keepAliveTimeout(20초)이 만료되어 기존 커넥션이 해제된 후에야 backlog의 요청이 수락됩니다.\n이 테스트는 NIO에서도 커넥션 수용 한계가 존재함을 보여줍니다. 기본값(max-connections=8192, accept-count=100)에서는 총 8292개의 동시 커넥션을 수용할 수 있으므로, VUser 300은 전혀 문제가 되지 않았던 것입니다.\n이전 글의 결과를 다시 해석하기 # 이제 NIO Connector의 구조를 이해했으니, 이전 글의 결과를 다시 살펴보겠습니다.\nthreads.max=200일 때 (이전 글의 기본 설정) # 이전 글에서 확인한 Grafana 대시보드를 NIO 관점에서 다시 해석해보겠습니다.\n200개의 Worker 스레드가 모두 활성화되어 요청을 처리 HikariCP 커넥션은 10개뿐 → 200개 스레드가 10개 커넥션을 경합 190개의 스레드가 timed-waiting 상태로 커넥션을 대기 2개의 vCPU에서 200개 스레드 → 과도한 컨텍스트 스위칭 결과: GET 1초, POST 1.2초 threads.max=60일 때 (이전 글의 최적 설정) # 60개의 Worker 스레드만 활성화 나머지 240개 커넥션은 NIO Poller가 스레드 없이 관리 HikariCP 커넥션은 40개 → 60개 스레드가 40개 커넥션을 사용 (훨씬 적은 경합) 2개의 vCPU에서 60개 스레드 → 컨텍스트 스위칭 비용 감소 결과: GET 200ms, POST 125ms 스레드를 줄여도 성능이 좋아진 진짜 이유는, NIO가 커넥션 관리와 요청 처리를 분리해주었기 때문입니다. 덕분에 적은 수의 스레드가 자원 경합 없이 효율적으로 일할 수 있었습니다.\n성능 향상은 두 가지 요인이 함께 작용한 결과입니다:\nNIO가 커넥션 관리를 분리: 스레드를 줄여도 300개의 커넥션은 모두 유지됨 스레드-커넥션 풀 비율 개선: 200:10에서 60:40으로 → timed-waiting 경합 대폭 감소 정리 # 이 글에서는 \u0026ldquo;스레드를 줄였는데 왜 성능이 좋아졌을까?\u0026ldquo;라는 질문에 대해 Tomcat NIO Connector의 아키텍처를 통해 답을 찾았습니다.\n핵심 내용을 정리하면:\nConnection ≠ Thread: NIO Connector에서 커넥션과 스레드는 1:1로 매핑되지 않습니다. Poller가 Selector를 사용하여 소수의 스레드로 수천 개의 커넥션을 관리합니다.\n3계층 큐잉 모델: 요청은 OS TCP backlog → Tomcat NIO(Poller) → Worker Thread Pool의 3계층을 거쳐 처리됩니다. 각 계층은 accept-count, max-connections, threads.max로 제어됩니다.\nthreads.max는 병렬 처리 수를 제어하는 것이지, 서버가 수용할 수 있는 커넥션 수를 제어하는 것이 아닙니다. 커넥션 수용 능력은 max-connections(기본값 8192)가 결정합니다.\n스레드 수 감소 → 성능 향상의 메커니즘: NIO가 커넥션 관리를 분리해주었기 때문에, 스레드를 줄여도 커넥션은 유지되면서 컨텍스트 스위칭과 자원 경합만 줄어들었습니다.\n처음에는 단순히 설정값을 조정하는 것이 성능에 어떤 영향을 미치는지만 궁금했습니다. 하지만 그 원인을 소켓 수준까지 파고들면서 Tomcat이 어떻게 동시성을 관리하는지, 그리고 왜 Spring MVC 기반의 블로킹 모델에서도 적은 수의 스레드로 많은 요청을 처리할 수 있는지를 이해할 수 있었습니다.\n","date":"2025년 2월 18일","externalUrl":null,"permalink":"/posts/tomcat-nio-connector-vuser-thread-mystery/","section":"Posts","summary":"Tomcat NIO Connector의 Acceptor-Poller-Worker 구조를 분석하여, 60개 스레드로 300명의 동시 요청을 처리하는 원리를 설명합니다.","title":"VUser 300명, Tomcat Thread 60개 — 나머지 240개의 요청은 어디로 갔을까?","type":"posts"},{"content":"","date":"2025년 1월 8일","externalUrl":null,"permalink":"/tags/performance-tuning/","section":"Tags","summary":"","title":"Performance Tuning","type":"tags"},{"content":"","date":"2025년 1월 8일","externalUrl":null,"permalink":"/tags/spring-boot/","section":"Tags","summary":"","title":"Spring Boot","type":"tags"},{"content":"이전 글: MySQL 전문 검색의 한계, Elasticsearch 도입으로 검색 API 성능 개선하기\n앞선 글에서 Elasticsearch를 도입하여 검색 API의 성능을 크게 향상시켰습니다.\n설정한 성능 목표는 평균 응답 시간 \u0026lt; 200ms, Error Rate \u0026lt; 0.1%이었습니다. ES 도입으로 MySQL 전문 검색의 구조적 한계는 극복했지만, VUser 300명 부하에서 GET 876ms, POST 1,210ms로 목표에는 미달한 상태입니다.\n성능 테스트를 하며 트래픽이 몰리는 상황에서 요청을 하나 직접 날려보았을 때도 1초 정도 걸렸고, 목표인 200ms와는 거리가 멀었습니다. 특히 POST 요청(1200ms)은 목표의 6배에 달했습니다.\n원인을 파악하기 위해 Grafana 대시보드의 여러가지 지표들을 살펴보았습니다.\n이때 몇 가지 눈에 띄는 지표들이 보였습니다. 바로 JVM의 Thread 상태와 HikariCP의 커넥션 상태였습니다.\ntimed-waiting 상태의 스레드: 197개 HikariCP의 커넥션을 기다리는 스레드: 189개 Tomcat 스레드 풀의 최대 사이즈 기본값은 200개, HikariCP 커넥션 풀의 최대 사이즈 기본값은 10개입니다.\n그리고 이 값들을 모두 기본 값으로 세팅하여 사용했을 때, 위의 대시보드와 같은 결과가 나타났습니다.\n먼저 결론부터: 기본값에서 어떤 병목이 생겼을까? # 이 글의 핵심은 아래 세 가지입니다.\nTomcat threads.max=200 과 HikariCP maximum-pool-size=10 조합에서는 많은 요청 스레드가 DB 커넥션을 기다리며 timed-waiting 상태로 묶였습니다. 이 테스트 환경(vCPU 2개, Memory 8GB)에서는 Tomcat 60, HikariCP 40 조합이 가장 빠른 응답 시간을 보였습니다. 다만 스레드 수를 지나치게 줄이거나 늘리면 각각 처리량 저하, 외부 I/O 경합 증가 같은 다른 병목이 나타났습니다. 스레드들은 실제로 어디에서 대기하고 있을까? # Connections의 Pending 값을 보면 대략적으로 timed-waiting 상태에 있는 스레드들은 커넥션 풀을 얻기 위해 대기하고 있는 것으로 추정됩니다.\n하지만 100퍼센트 확신하기가 어려웠습니다. 왜냐하면, 테스트 중인 API에서는 Elasticsearch도 사용하고 있었습니다.\nElasticsearch client의 설정 코드를 살펴보면:\nDEFAULT_MAX_CONN_PER_ROUTE: 단일 노드에 허용되는 최대 동시 연결 10개 DEFAULT_MAX_CONN_TOTAL: 모든 노드에 허용되는 총 동시 연결 30개 위의 값을 기본으로 사용하고 있습니다.\n커넥션을 얻기 위해 Pending하고 있는 스레드는 189개인데, timed-waiting 스레드의 개수는 197개입니다. 8개 정도 차이가 납니다. 그리고 waiting 상태에 있는 스레드도 9개 정도 있습니다. 이 스레드들은 뭐하는 것인지 알기가 어려웠습니다.\n따라서 Thread dump을 이용하여 각 스레드들의 상태를 조금 더 자세하게 살펴볼 필요가 있었습니다.\nThread dump로 보면 실제 원인은 무엇일까? # Thread dump는 jcmd 명령어로 획득했고, 분석은 fastthread.io 사이트를 이용했습니다.\nrunnable 상태의 스레드는 무엇을 하고 있었을까? # 3개의 스레드 덤프를 획득하여 비교해보니 runnable 상태의 스레드는 대부분 JVM 내부의 작업과 GC를 처리하는 스레드로 구성되어 있고 실제로 커넥션에서 데이터를 읽고 있는 스레드는 2개정도 있었습니다.\nMySQL를 읽는 스레드 1개, Elasticsearch를 읽는 스레드 1개 또는 MySQL를 읽는 스레드 2개, Elasticsearch를 읽는 스레드 0개인 경우가 있었습니다.\nwaiting 상태의 스레드는 왜 생겼을까? # waiting에 해당하는 스레드는 총 11개이며 그 중에서 8개의 스레드가 Elasticsearch의 데이터를 waiting 상태에서 기다리고 있었습니다.\n소스 코드를 살펴보면 Elasticsearch는 요청을 처리할 때 비동기로 호출하고 결과를 기다린다고 적혀있습니다.\ntimed-waiting 상태의 스레드는 왜 많았을까? # 총 합 190개의 스레드가 HikariCP의 커넥션을 얻기 위해 timed-waiting 상태에 있습니다.\n예를 들어 Thread1 ~ Thread9+ 이 동시에 HikariDataSource.getConnection() 메서드를 호출했다면 Thread1 ~ Thread5만 커넥션을 획득하고 나머지는 timed-waiting 상태에서 지정된 시간(hikari.connection_timeout default 30s)동안 반환된 커넥션을 얻기 위해 대기합니다.\n왜 Tomcat 200개 스레드와 HikariCP 10개 커넥션이 병목이 되었을까? # 현재 테스트를 위해 사용하고 있는 서버의 스펙은 vCPU 2개, Memory 8GB 단일 인스턴스입니다.\n서버 인스턴스의 CPU 개수에 비해서 Tomcat 스레드의 갯수가 너무 많습니다. 기본적으로 애플리케이션 서버가 I/O Bound 작업이 대부분이지만 2개의 CPU 갯수에 비해서 200개의 스레드는 너무 많다고 생각됩니다. 요청을 처리하는 스레드는 200개인데 반해 HikariCP의 커넥션은 10개가 최대입니다. 커넥션을 얻지 못한 수많은 나머지 스레드는 대기하게 되고 이는 결국 요청과 응답 사이의 처리 시간이 증가하는 문제로 이어집니다. 설정을 바꾸면 얼마나 개선될까? # 적절한 Thread 수와 Connection 수를 어떻게 정할까 찾았지만 정해진 규칙은 없는 것 같았습니다.\nI/O Bound 작업은 일반적으로 CPU 사용량보다 네트워크 I/O와 같은 대기 시간이 많은 작업에 의존합니다. 따라서 CPU 코어 개수보다 많은 스레드를 생성해도 컨텍스트 스위칭 비용이 상대적으로 적습니다.\n그렇다면 커넥션 대기 시간을 줄이기 위해 스레드와 커넥션 풀의 크기 차이를 조절하고 여러번의 테스트를 통해 비교하겠습니다.\n결과를 확인하면:\nTomcat의 max-thread: 60개 HikariCP의 max-connection-pool: 40개 위의 설정값을 적용했을 때 응답 시간이 약 200ms으로 가장 빠른 응답 시간이 나왔습니다.\n목표 대비 분석: GET 200ms는 목표인 평균 응답 시간 \u0026lt; 200ms에 도달했습니다. 기본 설정(Tomcat 200, HikariCP 10) 대비 5배 개선입니다.\n이미지에는 추가하지 않았지만 Tomcat의 max-thread: 30개, HikariCP의 max-connection-pool: 20개의 경우도 테스트를 진행해 보았습니다. 하지만 다른 문제점이 있어 아래에서 따로 살펴보겠습니다.\n커넥션 풀을 늘리면 DB는 괜찮을까? # HikariCP의 커넥션 풀 사이즈를 증가시켜 DB의 커넥션 수를 높게 유지하는 것은 기본적으로 DB에 부하가 갑니다.\n하지만 보시는 것과 같이 크게 유의미한 차이는 없었습니다. 대부분의 running에 해당하는 커넥션은 1~2개 정도만 필요했으며 DB 인스턴스의 CPU 사용량이 약간 증가했습니다.\n응답 시간 개선의 대가로 무엇이 늘어났을까? # 응답 시간은 감소했으나, runnable 상태의 스레드 수와 애플리케이션 서버의 CPU 부하가 조금 증가했습니다.\nPOST 요청은 왜 아직 500ms였을까? # 지금까지 실제 상황을 가정하여 요청 비율은 GET 90%, POST 10%로 설정했습니다. 그리고 product을 저장할 때 MySQL과 Elasticsearch의 데이터 동기화는 애플리케이션에서 직접 관리하는 가장 단순한 방식을 선택했습니다.\n코드 레벨에서는 하나의 스레드가 Elasticsearch에 데이터를 저장하고 뒤이어 MySQL에 데이터를 저장합니다.\n만약 Elasticsearch 데이터 저장 시 즉각적인 응답이 필요하지 않다면, 이 작업을 비동기 처리를 통해 응답 시간을 단축할 수 있을 것으로 보입니다.\n결과를 확인하면 GET 요청은 2번의 테스트에서 모두 200ms 정도를 유지하였고 POST 요청은 약 500ms에서 125ms까지 줄어들었습니다.\n최종 결과는 어떻게 달라졌을까? # 요청 개선 전 개선 후 목표 개선율 GET 요청 1초 200ms \u0026lt; 200ms 5배 개선, 목표 달성 POST 요청 1200ms 125ms \u0026lt; 200ms 10배 개선, 목표 달성 VUser 300명이라는 극한 부하에서도 GET 200ms, POST 125ms를 달성했습니다. 현실적 피크(15 RPS) 기준으로는 목표를 충분히 초과 달성할 수 있는 수준입니다.\n3편에 걸친 성능 개선은 어떻게 이어졌을까? # 단계 GET 응답시간 POST 응답시간 TPS Error Rate MySQL FullText (2편) 30초+ - 2.1 51.3% ES 도입 (2편) 876ms 1,210ms 179.6 0% 스레드/커넥션 튜닝 (3편) 200ms 125ms ~300 0% 목표 \u0026lt; 200ms \u0026lt; 200ms \u0026gt; 15 \u0026lt; 0.1% 추가로 확인한 트레이드오프와 후속 과제 # 여기까지가 핵심 개선 결과이고, 아래는 테스트를 반복하면서 추가로 확인한 트레이드오프와 후속 과제입니다.\n무거운 API는 왜 다른 API까지 느리게 만들까? # 이 현상의 원인은 Grafana 대시보드 누락(단절?) 현상 글에서 다루었습니다.\n윗글의 원인을 파악하였더니 성능 테스트 과정에서 모든 스레드가 요청을 처리하는 데 사용되면서 스레드 반환에도 시간이 증가했습니다.\n이로 인해 Prometheus의 메트릭을 가져오는 요청에 대한 스레드 획득 시간이 증가하고 결국 응답 시간도 증가하는 현상이 나타났습니다.\n이러한 현상은 Prometheus의 요청뿐만 아니라 다른 API의 요청에도 나타날 것이라 생각하여 테스트를 진행해 봤습니다.\n기존에는 Elasticsearch 관련 자원인 product에 대한 GET과 POST 요청만 처리했으나, 이번에는 이와 무관한 다른 요청들을 시나리오에 추가했습니다.\n결과는 모든 요청의 응답 시간이 증가하여 800ms 이상 소요되었습니다. (스레드 풀과 HikariCP 커넥션 풀을 조절하기 이전의 테스트 결과입니다.)\n빨간색으로 박스친 GET /store, GET /store/{store_id} 요청은 단독으로 진행한 테스트에서 100~200ms 정도의 평균 응답 시간을 보이는 매우 가벼운 요청입니다.\n하지만 상대적으로 무거운 작업인 GET /product 요청과 POST /product/new 요청과 함께 테스트를 진행했더니 결국 요청의 응답 시간이 늘어나는 문제로 이어졌습니다.\n이러한 현상이 나타난 이유는 Spring MVC + Tomcat이 블로킹(Blocking) I/O 방식으로 동작하기 때문입니다.\n요청을 처리할 때 톰캣 스레드가 서블릿 컨테이너에 들어오는 요청을 하나씩 맡아 처리하고, 해당 로직이 끝날 때까지 스레드를 점유하고 있습니다. 따라서 해당 스레드는 Elasticsearch와 DB의 I/O작업이 끝날 때까지 다른 요청 처리에 사용되지 못하고 대기하게 됩니다.\n이 때문에 최악의 경우 특정 API의 요청이 오랜 시간 I/O 작업에 묶여 있으면 톰캣의 스레드 풀이 고갈되고, 다른 요청까지 대기해야 하는 상황이 벌어집니다.\n이를 해결하기 위해서는:\n논블로킹(Non-Blocking) I/O 기반의 서버 애플리케이션(Spring Webflux)을 구성 스레드 풀 분리 서버를 분리 하는 방법들을 고려할 수 있습니다.\n스레드를 30개로 줄이면 왜 RPS가 낮아질까? # Tomcat의 max-thread: 30개, HikariCP의 max-connection-pool: 20개 설정값을 적용했을 때 응답시간이 조금 더 빨랐습니다.\n그렇지만 이상한 점이 하나 있었습니다.\nmax-thread 30개, HikariCP의 max-connection-pool이 20개인 경우 초당 처리량(RPS)은 오히려 더 낮게 나타났습니다.\n간단히 설명하면 쓰레드 수를 줄이면 컨텍스트 스위칭이나 커넥션 경합 같은 오버헤드가 줄어듭니다. 따라서 CPU의 사용량도 줄어들고 적은 수의 요청만이 서버 내부 자원을 넉넉하게 사용하기 때문에 단건 응답 시간은 빠르게 나올 수 있었습니다.\n하지만 동시에 받을 수 있는 요청이 제한적이므로, 많은 요청이 들어오면 스레드 풀이 꽉 차서 새 요청을 빨리 처리하지 못합니다. 그 결과 RPS가 낮은 결과가 나타났습니다. 트레이드 오프 현상입니다.\n스레드를 200개로 늘리면 왜 다시 느려질까? # Tomcat의 max-thread: 200개 HikariCP의 max-connection-pool: 180개 여기서는 스레드만 200개로 늘린 것이 아니라, 앞선 병목 원인이었던 Tomcat 스레드 수와 HikariCP 커넥션 풀 크기의 큰 차이를 줄이기 위해 HikariCP 커넥션 풀도 180개로 함께 늘렸습니다. 즉, 기본 설정처럼 200개의 요청 스레드가 10개의 커넥션을 두고 대기하는 상황이 아니라, 커넥션 획득 대기 자체는 최대한 줄인 상태에서 다시 느려진 경우입니다.\n이 경우는 RPS의 차이는 거의 없었지만 응답 시간이 상당히 길게 나왔습니다.\n그리고 그 이유를 위의 트레이드 오프 상황과 유사하게 스레드가 증가하여 CPU 등 리소스 경합이 더 빈번해지고, OS 레벨에서의 컨텍스트 스위칭 비용이 증가하여 응답 시간이 길게 나왔다고 생각했습니다.\n하지만 조금 더 살펴보니 기존의 문제였던 timed-waiting 상태의 스레드는 줄었지만 오히려 waiting 상태의 스레드는 증가했습니다.\n스레드 덤프를 분석해 보았을 때 180개의 waiting 스레드 중에서 140개의 스레드가 Elasticsearch의 비동기 응답을 받기 위해 이 상태에 있었습니다. Elasticsearch의 총 동시 연결은 30개로 위에서 소스 코드를 보여드렸습니다. 140개의 스레드가 30개의 연결을 차지하기 위해 경합이 발생한 것으로 보입니다.\n또한 Connection Usage Time이 매우 높게 나왔습니다. 커넥션 풀의 개수가 여유로워 Connection Acquire Time은 낮게 나왔는데 그 뒤 사용 시간이 크게 나왔습니다.\nrunnable 스레드를 살펴보면 MySQL에서 데이터를 읽어오고 있는 스레드는 다른 경우와 유사하게 최대 4개 정도 나타났습니다. 커넥션을 얻은 이후로 무슨 이유로 빨리 처리하지 못하는 것인지 waiting 스레드를 포함하여 관계를 조금 더 살펴볼 필요가 있습니다.\n결론: 기본값은 출발점일 뿐이었다 # 처음 API를 개발할 때는 단순히 리뷰 개수를 product와 review 테이블을 JOIN해서 원하는 결과만 가져오면 충분하다고 생각했습니다.\n하지만 전문 검색 인덱스를 사용하면서 쿼리 수행 시간이 크게 늘어나는 문제가 발생했습니다. 실행 계획을 분석하고 쿼리 구조를 개선하여 수행 시간을 단축했지만, 여전히 문제가 남아있었습니다.\nAPI에서 제공해야 할 요구사항이 늘어날수록 이를 억지로 쿼리에 추가하다 보니 쿼리가 복잡해지는 문제였습니다.\n또한, product 테이블을 탐색하는 것은 전문 검색을 사용하는 경우뿐만 아니라 다양한 경우에도 사용될 수 있었습니다.\n이때마다 복잡한 서브 쿼리나 조인을 통해서 review의 갯수와 tag를 가져오는 쿼리를 작성하는 것은 비효율적이라 판단했고, 이에 따라 프로세스와 테이블 구조를 더 유연하게 재사용할 수 있도록 개선할 필요성을 느꼈습니다.\n하지만 테이블 구조를 변경한다면 새로운 데이터를 넣어야 하고, 이전 테이블 구조와 다른 데이터를 가진 상태에서 쿼리 시간을 비교하는 것은 부적절하다고 판단했습니다. 더 정확한 기준을 잡기 위해 고민하던 중, 처음부터 해결하고자 했던 문제가 무엇이었는지 돌이켜 보았고 해당 쿼리를 포함한 API의 성능을 개선하는 것이 원하던 목표였음을 깨달았습니다.\n처음에는 쿼리 튜닝으로만 문제를 해결하려 했습니다. 그러나 쿼리 튜닝은 문제를 해결하기 위한 여러 도구 중 하나의 방법이였고 문제 그 자체가 아니였습니다. 이후 더 넓게 문제를 정의하니 고려할 수 있는 선택지가 많아졌고 기존 API 개발 방식의 문제점도 파악할 수 있었습니다.\n그리고 결과적으로 사용 가능한 수준의 API를 개발할 수 있게 되었습니다.\n다만 부하 테스트를 살펴보는 과정에서 아직 풀리지 않은 의문이 하나 남았습니다. 가상 유저(VUser) 300명이 동시에 트래픽을 발생시키는데 Tomcat의 Thread Pool 사이즈를 60개로 줄이면, 나머지 240개의 요청은 스레드를 얻기 위해 경합하지 않을까요? 이 질문은 이 글의 실험을 이해하는 데 이어지는 중요한 지점이었고, 그래서 다음 글에서 Tomcat NIO Connector 관점으로 이어서 살펴보았습니다.\n다음 글: VUser 300명, Tomcat Thread 60개 — 나머지 240개의 요청은 어디로 갔을까?\n","date":"2025년 1월 8일","externalUrl":null,"permalink":"/posts/tomcat-thread-hikaricp-pool-tuning/","section":"Posts","summary":"Tomcat 스레드 200개와 HikariCP 커넥션 10개의 기본 설정이 성능 병목을 일으키는 원인을 분석하고 최적 설정을 찾은 과정을 정리합니다.","title":"Spring Boot Tomcat threads.max와 HikariCP maximum-pool-size를 기본으로 설정하면 나타나는 현상","type":"posts"},{"content":"","date":"2025년 1월 5일","externalUrl":null,"permalink":"/tags/grafana/","section":"Tags","summary":"","title":"Grafana","type":"tags"},{"content":" 성능 테스트를 진행하던 도중 Grafana의 모든 대시보드에서 끊김? 단절?이 나타나며 위 이미지와 같이 데이터가 누락되는 현상이 계속 발견되었습니다.\n처음에는 애플리케이션의 Target DB 서버에 CPU부하가 100% 걸리는 테스트를 하던 도중 발생한 문제여서\nDB 문제인가?\n라는 생각을 했지만, DB 서버의 부하 문제를 해결한 후에도 테스트 진행중 같은 현상이 계속 나타났습니다.\n게다가 Prometheus의 원리를 고려해보면, DB 접근이 없는데도 이런 현상이 발생한 것으로 보아 다른 원인이 있을 것입니다.\n우선 성능 테스트 도중 Actuator의 metric 정보가 누락되어 데이터가 표시되지 않았는지 그리고 이것을 확인하기 위해 actuator/metrics URL로 직접 접속을 해보았습니다.\n그런데 이때 metrics 값들이 정상적으로 도착했지만, 응답 시간이 대략 1~2초 정도로 지연되는 것을 느겼습니다. 이러한 지연이 의심스러워 성능 테스트를 종료한 후 다시 요청했을 때는 응답이 즉각적으로 이루어졌습니다.\n아 !이거 타임아웃이 발생한거 같다.\nPrometheus의 네트워크 설정을 살펴보면 타임아웃 값(scrape_timeout)은 기본적으로 10초이지만, 스크랩 하는 주기 값(scrape_interval)보다 클 수 없다고 합니다.\n따라서 prometheus.yml 파일을 살펴보니 scrape_interval값을 1초로 설정한 것을 보았고 그 결과 타임아웃이 1초로 적용된 것이었습니다.\nPrometheus 초기 설정 시, 빠른 변화를 확인하기 위해 scrape_interval 주기를 짧게 설정했습니다. 그러나 성능 테스트 과정에서 모든 스레드가 성능 테스트 요청을 처리하는 데 사용되면서 스레드 반환에도 시간이 증가했습니다. 이로 인해 Prometheus의 메트릭 요청을 처리하기 위한 스레드 획득 시간이 증가하고 응답 시간도 증가해 결국 1초라는 시간을 초과하여 타임아웃이 발생했습니다.\n이후 이 값을 10초로 변경하여 설정하니 Grafana 대시보드에 데이터가 정상적으로 나타났습니다.\n","date":"2025년 1월 5일","externalUrl":null,"permalink":"/posts/grafana-dashboard-missing-data/","section":"Posts","summary":"성능 테스트 중 Grafana 대시보드에서 데이터가 누락되는 현상의 원인과 해결 방법을 정리합니다.","title":"Grafana 대시보드 누락(단절?) 현상","type":"posts"},{"content":"","date":"2025년 1월 5일","externalUrl":null,"permalink":"/tags/infrastructure/","section":"Tags","summary":"","title":"Infrastructure","type":"tags"},{"content":"","date":"2025년 1월 5일","externalUrl":null,"permalink":"/tags/monitoring/","section":"Tags","summary":"","title":"Monitoring","type":"tags"},{"content":"","date":"2025년 1월 5일","externalUrl":null,"permalink":"/tags/prometheus/","section":"Tags","summary":"","title":"Prometheus","type":"tags"},{"content":"이전 글: [MySQL] Left Join에서 Subquery로 변경 후 쿼리 성능 30배 향상하기\n앞선 글에서 전문 검색(FullText Search)을 사용하는 쿼리의 구조를 변경하여 쿼리의 수행시간을 30배 단축했습니다.\n하지만 리뷰 개수와 사용자들이 많이 선택한 상위 태그 등 추가 정보를 보여줘야 한다는 요구사항을 반영하기 위한 쿼리를 작성했을 때, 수행시간이 조금 증가하는 단점이 있었습니다.\n그리고 무엇보다 이러한 비즈니스적 요구사항이 쿼리 안에 녹아져 있어 DB접근 기술을 변경하거나 비즈니스 로직을 수정할 필요가 생겼을 때, 쿼리를 직접 수정해줘야 하는 만큼 유지보수 관점에서 유연성이 떨어진다는 문제점이 존재합니다.\n하지만 그전에 해결해야 할 문제를 명확히 정의할 필요가 있었습니다.\n어떤 문제를 해결하고 싶은가? # 개선해야할 사항은 단순히 쿼리의 수행시간을 단축시키는 부분이 아닙니다. 쿼리의 수행시간 개선은 API의 성능을 향상시키는 도구 중 하나일 뿐입니다.\n조금 더 크게 보아서 문제를 정의하면, 기존 API는 쿼리의 수행시간으로 인해 Postman으로 요청 시 응답까지 대략 6.85초가 걸렸지만, 결국 API의 성능이 나오지 않는 것을 문제로 정의할 수 있습니다.\n그리고 지금부터 목표는 API의 성능이 나오지 않는 것을 해결하는 것입니다.\n먼저 비즈니스 요구사항을 정의합니다 # /product?search=\u0026quot;xxx\u0026quot; 검색을 요청합니다. 또한 page와 size 파라미터로 페이징을 적용합니다. 각 product에 해당하는 review의 갯수를 표시합니다. 각 product에 대한 상위 3개 태그를 표시합니다. 사용자들이 리뷰 작성 시 자유롭게 선택한 태그들 중 가장 많이 선택된 3개를 보여줍니다. 성능 목표 설정 # API의 성능을 개선하기 위해서는 먼저 목표가 필요합니다. 목표 없이 테스트를 진행하면 결과가 좋은 것인지 나쁜 것인지 판단할 기준이 없기 때문입니다.\n데이터 규모로부터 서비스 규모 추정 # 현재 테스트 환경에 저장된 데이터는 아래와 같습니다.\nProduct: 150만건 Review: 1,000만건 ProductDietTag: 2,000만건 Product는 시드 데이터와 사용자 등록이 혼합되어 있다고 가정하면, DAU와 직접 비례하지 않습니다. 반면 Review는 사용자가 직접 작성하는 데이터이므로 역으로 DAU를 추정할 수 있습니다.\n운영 기간 1년 가정: 10,000,000건 / 365일 ≈ 일 평균 27,000건 리뷰 생성 음식 기록 앱 특성상 DAU 중 리뷰를 작성하는 비율은 높은 편 (기록 행위 자체가 핵심 기능) 인당 일 평균 리뷰 수: 약 0.6~1.35건 (활성 사용자 기준) DAU ≈ 27,000 / 0.6 ~ 27,000 / 1.35 ≈ 약 20,000~45,000명 보수적으로 DAU 20,000명을 기준으로 설정합니다.\n트래픽 추정 # 식사 기록 서비스 특성상 검색 API는 하루 15~25회/인 호출됩니다. (각 식사 기록 시 제품 검색 + 탐색 5 ~ 8회 가정)\n일일 총 호출: 20,000명 × 20회 = 400,000 req/day 피크 시간대(점심 11:30~13:30, 저녁 18:00~20:00): 전체의 50% 이상이 4시간에 집중 피크 RPS: 약 15 RPS 순간 버스트(피크의 3~5배): 약 45~75 RPS 응답 시간 목표 # 지표 목표 근거 평균 응답 시간 \u0026lt; 200ms 사용자가 지연을 느끼지 않는 수준 Error Rate \u0026lt; 0.1% 검색 실패 시 사용자 이탈 높음 테스트 부하 설정 # 이번 테스트에서는 VUser 300명을 사용합니다. 이는 현실적 피크(15 RPS)보다 높은 부하로, 시스템의 한계점을 파악하기 위한 스트레스 테스트 성격입니다. 목표 부하에서의 성능이 아닌, 극한 상황에서 시스템이 어디까지 버틸 수 있는지를 확인하는 데 목적이 있습니다.\n어떻게 API의 성능을 비교할까? # 위의 요구사항을 만족하는 API를 만들기 위해 기존에는 DataGrip을 사용하여 쿼리의 수행시간을 분석했습니다. 하지만 프로세스 구현 방식이나 테이블 구조가 변경될 경우 데이터 구조도 함께 달라지기 때문에, 단순히 몇백만 개의 데이터로 쿼리 수행시간을 비교하는 것은 정확한 기준이 되기 어렵습니다.\n따라서 더 정확한 성능 측정을 위한 기준이 필요해졌습니다.\nAPI 성능을 제대로 테스트하기 위해서는 Postman으로 단일 응답 시간을 측정하는 것만으로는 부족했고, 더 많은 요청이 필요했습니다. 또한 검색 기능을 테스트할 때 동일한 키워드만으로는 한계가 있었습니다. MySQL InnoDB의 캐시 기능을 고려하면 다양한 데이터에 대한 접근이 필요했기 때문입니다. 이러한 이유로 nGrinder를 사용해서 다양한 데이터에 접근하는 트래픽을 발생시키고, Prometheus와 Grafana를 활용한 모니터링을 통해 API 성능을 평가하기로 결정했습니다.\n테스트 구성 # 1. DB의 스키마 구성 # 앞선 쿼리 구조 변경 글에서 가져온 DDL을 그대로 사용합니다.\n-- 150만개의 데이터 -- n-gram 알고리즘을 적용한 전문 검색 인덱스 사용 CREATE TABLE product ( product_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(255) NOT NULL, product_corp VARCHAR(255), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, FULLTEXT INDEX fulltext_idx (product_name, product_corp) WITH PARSER ngram ); -- 1000만개의 데이터 CREATE TABLE review ( review_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, content VARCHAR(255), rating TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON review (product_id); -- 2000만개의 데이터 CREATE TABLE product_diet_tag ( product_diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT, diet_tag_id BIGINT, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON product_diet_tag (product_id); -- 10개의 데이터 CREATE TABLE diet_tag ( diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, diet_tag_name VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); 현재 MySQL DB에는:\nproduct 테이블 150만 rows review 테이블 1000만 rows product_diet_tag 테이블 2000만 rows diet_tag 테이블 10개의 rows 저장되어 있습니다.\n2. 모니터링과 nGrinder을 위한 클라우드 구축 # 최적의 리소스를 할당하기 위해 서버는 각각의 인스턴스에 배치했고, Prometheus와 Grafana만 동일한 인스턴스에 배치했습니다.\n인스턴스 스펙 Target 애플리케이션 서버 vCPU 2EA, Memory 8GB Target MySQL vCPU 2EA, Memory 8GB 모니터링 vCPU 2EA, Memory 8GB nGrinder Controller vCPU 2EA, Memory 8GB nGrinder Agent vCPU 2EA, Memory 8GB 3. 테스트 시나리오 # 부하 테스트는 5분동안 진행하였고 가상 유저는 최대 300명으로 아래의 이미지처럼 30초마다 점진적으로 증가시켰습니다.\nAPI 호출 시나리오에서는 검색 키워드, size, page 값을 랜덤한 문자열이나 숫자로 다양하게 적용했습니다.\n첫 번째 테스트: 전문 검색(FullText Search)만을 이용한 테스트 # 요구사항을 모두 만족하는 API를 테스트하기 전에, 전문 검색 기능만 단독으로 사용했을 때 어느 정도 API 성능이 나올지 궁금해서 테스트를 진행해 보았습니다.\n아래의 코드는 API 테스트에 사용된 레포지토리 클래스입니다. 한번의 API 호출에서 페이징을 위해 쿼리는 2가지가 사용되며, 이번 테스트에서 product 테이블을 제외한 다른 테이블은 건드리지 않았습니다.\n@Repository @RequiredArgsConstructor @Transactional public class JdbcTemplateProductSearchRepository { private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final DataClassRowMapper\u0026lt;ProductSearchResponse\u0026gt; beanPropertyRowMapper = new DataClassRowMapper\u0026lt;\u0026gt;(ProductSearchResponse.class); public Page\u0026lt;ProductSearchResponse\u0026gt; findFullTextSearch(String keyword, Pageable pageable) { Integer total = getTotalCount(keyword); // OFFSET과 LIMIT을 적용했습니다. String sql = \u0026#34;SELECT \u0026#34; + \u0026#34;p.product_id, p.product_name, p.product_corp \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34; + \u0026#34;LIMIT :offset, :limit\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword) .addValue(\u0026#34;offset\u0026#34;, pageable.getOffset()) .addValue(\u0026#34;limit\u0026#34;, pageable.getPageSize()); List\u0026lt;ProductSearchResponse\u0026gt; queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper); return new PageImpl\u0026lt;\u0026gt;(queried, pageable, total); } // 페이징 처리를 위해 전체 갯수를 조회하는 COUNT(*) 쿼리. private Integer getTotalCount(String keyword) { String sql = \u0026#34;SELECT COUNT(*) \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword); return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class); } } 테스트 결과 # 테스트의 결과는 매우 심각했습니다.\n평균 TPS(Transaction Per Second)가 2.1 나왔고, 응답 시간은 점차 증가하여 30초가 지속되었습니다. 또한 에러율이 51.3% (958개 중에 491개 실패)가 나왔습니다.\n에러의 원인은 애플리케이션 로그를 확인해보니 connection timeout 에러가 발생했습니다. 이는 HikariCP 커넥션 풀의 모든 커넥션이 사용 중인 상황에서, 새로운 커넥션을 얻기 위한 대기 시간이 HikariCP의 기본 설정값 30초를 초과했기 때문입니다.\nNCP에서 제공하는 DB 인스턴스 모니터링을 통해 문제를 분석한 결과, CPU 사용량이 최대치에 도달했으며 이것이 성능 병목 지점임을 확인했습니다.\n목표 대비 분석: 목표인 평균 응답 시간 \u0026lt; 200ms, Error Rate \u0026lt; 0.1%에 비해 압도적으로 낮은 성능입니다. VUser 300명이라는 높은 부하에서 MySQL의 전문 검색이 CPU를 100% 점유하며 TPS가 2.1밖에 나오지 않았습니다. 현실적 피크(15 RPS)에서도 안정적인 서비스가 어려울 것으로 판단됩니다.\n번외 테스트 # API의 실제 성능을 측정하기 위해 HikariCP의 connection_timeout을 여유롭게 300초로 변경한 후, 커넥션 획득 타임 아웃 에러가 발생하지 않는 테스트를 진행했습니다.\nAPI의 응답은 모두 성공하였지만, 평균 TPS가 1.9, 평균 응답 시간은 대략 1.67분을 유지, 최대 1.79분이 나왔습니다.\n목표 대비 분석: 에러는 사라졌지만 평균 응답 시간이 약 1분입니다. 목표 200ms의 약 300배에 달하므로, 타임아웃 완화로는 근본적인 문제를 해결할 수 없습니다.\n그리고 다음 번외 테스트로 DB 인스턴스의 스펙을 단계적으로 Scale Up 해보았습니다.\n단계 스펙 기존 vCPU 2EA, Memory 8GB 첫 번째 Scale Up vCPU 8EA, Memory 32GB 두 번째 Scale Up vCPU 32EA, Memory 128GB 테스트 결과, 두 번째 Scale Up 스펙(vCPU 32EA, Memory 128GB)으로 테스트를 진행해도 평균 TPS 10.5, 평균 응답 시간은 대략 15~20초가 걸렸습니다. 다만 CPU 사용량은 30%까지 감소했습니다.\n목표 대비 분석: 16배 Scale Up(vCPU 2→32)으로도 TPS 10.5, 응답 시간 15초입니다. 목표 피크 RPS 15에도 미달하며, 응답 시간은 목표의 75배입니다. 따라서 MySQL 전문 검색만으로는 이 규모의 데이터에서 목표 성능 달성이 어렵다고 판단했습니다. 다만 Elasticsearch로 넘어가기 전에, 실제 서비스 요구사항을 모두 포함한 MySQL 기준선도 한 번 더 측정해보겠습니다.\n(Grafana 대시보드에서 중간에 값이 비어있는 현상이 계속 발견되었습니다. 이에 대한 글은 Grafana 대시보드 누락(단절?) 현상에서 다루었습니다.)\n두 번째 테스트: 모든 요구사항을 만족한 테스트 # Elasticsearch로 전환했을 때의 개선 폭을 비교하기 위해, 이전 글에서 사용했던 쿼리를 적용하여 모든 요구사항을 만족하는 MySQL 기준선 테스트를 한 번 더 진행했습니다.\nSELECT p.product_id, p.product_name, p.product_corp, (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, (SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT diet_tag_name FROM product_diet_tag pdt WHERE pdt.product_id = p.product_id GROUP BY diet_tag_name ORDER BY COUNT(diet_tag_name) DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names FROM product p WHERE MATCH(p.product_name, p.product_corp) AGAINST(:keyword) LIMIT :offset, :limit; 예상했던 대로 테스트 결과는 처참했으며, 전문 검색을 단독으로 사용했을 때와 비교해봐도 크게 달라진 점이 없습니다.\n왜 이렇게 낮은 성능이 나오는 것일까요? # 이는 전문 검색 인덱스의 특징을 고려해야 합니다.\n전문 검색에는 기존의 MySQL InnoDB 스토리지 엔진에서 제공하는 일반적인 용도의 B-tree 인덱스를 사용할 수 없습니다.\n전문 검색(Full Text search) 인덱스는 문서 전체를 분석하고 검색하기 위한 역색인 구조의 인덱싱 알고리즘입니다.\n그리고 인덱싱 기법에 따라 크게 \u0026ldquo;단어의 어근 분석\u0026rdquo; 과 \u0026ldquo;n-gram 분석\u0026rdquo; 알고리즘으로 나눌 수 있습니다.\n\u0026ldquo;단어의 어근 분석\u0026rdquo; 은 영어와 같이 단어의 변형이 있는 경우 그 단어의 뿌리인 명사 또는 어근을 찾아 인덱싱 합니다.\n하지만 한국어, 중국어, 일본어는 이러한 단어 변형이 거의 없으므로, 본문을 일정 길이로 잘라서 토큰으로 인덱싱하는 \u0026ldquo;n-gram 분석\u0026rdquo; 을 활용하면 정확성과 효율성을 향상시킬 수 있습니다.\n그리고 product 테이블에는 n-gram 분석을 활용한 전문 검색 인덱스가 생성되어 있습니다.\n하지만 이러한 방식에도 단점들이 있습니다.\nMySQL 서버는 전문 검색 쿼리가 오면 인덱싱할 때와 동일하게 검색어를 토큰 사이즈에 맞게 잘라냅니다. 그리고 잘려진 토큰들에 대해 일치하는 단어의 갯수, 빈도 등을 확인해서 일치율을 계산합니다.\n그리고 각 토큰들의 결과에 대해 비교 연산이 수행되며, 이 과정에서 가중치 계산과 정렬이 이루어집니다.\n따라서 검색어가 길수록 더 많은 토큰이 생성되고 각 토큰의 결과를 모아 비교 연산을 수행하니 CPU에 가해지는 부하가 더 커지기도 합니다.\n또한, 실행계획을 분석해보면 OFFSET이 2000, LIMIT이 10인 경우 2010개의 row를 읽은 것으로 나타납니다.\nSELECT p.product_id, p.product_name, p.product_corp FROM product p WHERE MATCH (p.product_name, p.product_corp) AGAINST (\u0026#39;닭가슴살\u0026#39;) LIMIT 2000, 10; 하지만 실제 내부적으로 계산되는 행의 수는 단순히 OFFSET과 LIMIT으로 지정한 개수만큼이 아닙니다.\nMySQL은 전문 검색으로 매칭되는 모든 rows에 대해 가중치를 계산하고 정렬한 뒤, 정렬 결과에서 상위 2000번째부터 2010번째까지 반환합니다.\n따라서 페이징을 최적화하기 위한 방법들인 No Offset, 커버링 인덱스, Total Count 최적화 같은 기법들을 사용하기에도 어려움이 있었습니다.\n실제로 OFFSET을 0으로 설정한 경우와 비교해보았으나 성능상 유의미한 차이는 없었고, 페이징을 위한 COUNT(*) 쿼리를 제거했을 때도 미미한 성능 개선만 있었습니다. (단순히 하나의 트랜잭션에서 2개의 쿼리가 나가던 것을 1개의 쿼리만 나가도록 바꾸니 성능이 대략 2배 정도 좋아지는 매우 당연한 현상…)\nTPS: 2.1 -\u0026gt; 3.7 에러율: 51.3% -\u0026gt; 28.6% (1372개 중에 393개 실패) Elasticsearch 엔진을 도입 # 기존에는 Elasticsearch 도입을 비용 부담이 크다는 단점 때문에 선택하지 않았고 MySQL에서 제공하는 전문 검색을 선택했습니다.\n하지만 전문 검색을 활용한 경우 100만개가 넘는 데이터에서 성능이 제대로 나오지 않는 치명적인 단점이 발견되었습니다.\n또한 애플리케이션에서 사용자가 호출할 API패턴을 고려해 보았을 때, 식품 검색 API는 사용자가 매우 자주 호출하는 API로 서비스에서 핵심적인 역할을 한다는 판단이 들었습니다. 따라서 비용을 투자해서라도 성능을 끌어올릴 필요가 생겼습니다.\n이전 테스트에서 DB 엔진을 Scale UP했지만 여전히 TPS는 평균 10 정도, 응답시간은 15초에 가까웠습니다. 제한된 비용 내에서는 Scale UP보다 Elasticsearch 도입이 더 효과적일 것으로 판단했습니다.\n초기에는 외부 저장소를 활용한 캐시도 고려했지만, 캐시는 검색 조건이 너무 다양하여 적용이 어려웠고 데이터의 특성과 사용 패턴 분석이 선행되어야 효과적인 부분 캐싱이 가능할 것으로 판단해서 선택하지 않았습니다.\nElasticsearch 엔진은 GCP에서 제공하는 Elastic Cloud 서비스를 이용 # 인스턴스 스펙: vCPU 5개, Memory 4GB KIBANA를 위한 1GB 메모리 무료 제공 총 비용: 1시간 0.3321$ -\u0026gt; 0.3321 × 24 × 30 = 239$ = 345,594원 (환율 1446원) 현재 프로젝트의 클라우드 서버를 구성한 NCP도 Search Engine Service라는 서비스를 제공하지만, 이상하게 클러스터 접속이 안 되는 문제가 계속 발생했습니다.\nNCP는 Search Engine Service 클러스터를 구성하는데 최소 4대의 인스턴스가 필요하며 vCPU 2EA, Memory 8GB 인스턴스 4개를 사용한다면 가격은 GCP의 Elastic Cloud와 비슷하지만 성능은 더 뛰어날 것으로 예상됩니다. (다만 UI와 클러스터 구성의 편의성은 Elastic Cloud가 압도적으로 편합니다. 또한, 새롭게 가입하면 40만원의 크레딧도 지급받을 수 있습니다.)\n직접 인스턴스에 Elasticsearch을 구축하는 방법도 고려해보았지만 처음 사용해보는 기술에 대해서 아무것도 모르는 채로 파이프라인을 구성하는데 시간과 어려움이 많이 들것 같다는 생각이 들었습니다.\n따라서 우선적으로 Elasticsearch 엔진을 사용하면 얼만큼 API 성능이 나오는지 확인하는데 중점을 두어, 빠르게 구성할 수 있는 클라우드 서비스를 선택했습니다.\nElasticsearch 엔진을 이용한 3번째 테스트 # 위의 테스트 결과는 리뷰 개수와 상위 태그 표시와 같은 요구사항 없이 product에 대한 검색(GET 요청)만을 진행한 테스트 결과입니다.\n테스트 결과를 보면 MySQL의 전문 검색을 이용한 경우에 비해서 (connection timeout 에러 제거한 경우):\n평균 TPS: 1.9 -\u0026gt; 192.5 (약 100배 향상) 응답 시간: 대략 1.67분 -\u0026gt; 800ms (800ms/100200ms = 1/125 줄어듬) 목표 대비 분석: MySQL 대비 TPS 100배, 응답 시간 1/100 수준으로 극적으로 개선되었습니다. VUser 300명 부하에서 평균 926ms이므로, 현실적 피크(15 RPS)에서는 평균 200ms 이내 목표 달성이 충분히 가능한 수준입니다.\n쿼리에 있던 요구사항을 비즈니스 레이어로 이동 # 이전 글에서는 쿼리 안에 모든 요구사항이 들어 있었습니다.\n요구사항들은 다음과 같습니다.\n각 product에 해당하는 review의 갯수를 표시합니다. 각 product에 대한 상위 3개 태그를 표시합니다. 사용자들이 리뷰 작성 시 자유롭게 선택한 태그들 중 가장 많이 선택된 3개를 보여줍니다. 기존 테이블 구조의 문제점 # product와 diet_tag는 다대다 관계를 가지며, 중간에 product_diet_tag 테이블을 두어 각 product에 연관된 diet_tag를 저장합니다.\n사용자는 product에 대한 review를 작성할 때, 원하는 diet_tag를 여러개 선택해서 저장합니다. 따라서 하나의 review가 작성될 때 product_diet_tag의 데이터는 여러개가 저장됩니다.\n예를 들어, product가 100만 개가 있고 각 product마다 100개의 review가 있다고 가정해 보겠습니다. 그리고 사용자들이 각 review마다 3개의 diet_tag를 골고루 지정한다면, product_diet_tag의 데이터는 대략 3억 개가 저장됩니다.\n이러한 설계는 문제점이 있다고 판단하여 테이블 구조와 저장 프로세스의 개선이 필요하다고 생각했습니다.\n개선된 테이블 구조 # 사용자들이 review를 작성할때 product_diet_tag에 tag_count 필드를 만들어 해당 값을 증가시키면 데이터 용량을 훨씬 줄일 수 있습니다.\n다시 예를 들어, product가 100만 개 있고 diet_tag가 10개라고 가정해보겠습니다. 이 경우 product_diet_tag의 데이터는 최대 1000만 개로 줄어듭니다. product_diet_tag의 수는 product 수와 diet_tag 수에만 의존하며, review 수에는 영향을 받지 않습니다. 사용자가 review를 엄청 많이 등록하더라도 tag_count 값만 증가시키면 됩니다.\n기존 코드 (한방 쿼리) # 기존의 코드는 아래와 같이 JdbcTemplate을 이용하여 Repository에서 모든 로직을 처리하고 있었습니다.\n@Repository @RequiredArgsConstructor @Transactional public class JdbcTemplateProductSearchRepository { private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final DataClassRowMapper\u0026lt;ProductSearchResponse\u0026gt; beanPropertyRowMapper = new DataClassRowMapper\u0026lt;\u0026gt;(ProductSearchResponse.class); public Page\u0026lt;ProductSearchResponse\u0026gt; findFullTextSearch(String keyword, Pageable pageable) { Integer total = getTotalCount(keyword); String sql = \u0026#34;SELECT \u0026#34; + \u0026#34;p.product_id, p.product_name, p.product_corp, \u0026#34; + \u0026#34;(SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, \u0026#34; + \u0026#34;(SELECT AVG(r.rating) FROM review r WHERE r.product_id = p.product_id) AS review_avg_rating, \u0026#34; + \u0026#34;(SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT dt.diet_tag_name FROM product_diet_tag pdt JOIN diet_tag dt ON pdt.diet_tag_id = dt.diet_tag_id where pdt.product_id = p.product_id ORDER BY pdt.tag_count DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34; + \u0026#34;LIMIT :offset, :limit\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword) .addValue(\u0026#34;offset\u0026#34;, pageable.getOffset()) .addValue(\u0026#34;limit\u0026#34;, pageable.getPageSize()); List\u0026lt;ProductSearchResponse\u0026gt; queried = namedParameterJdbcTemplate.query(sql, parameters, beanPropertyRowMapper); return new PageImpl\u0026lt;\u0026gt;(queried, pageable, total); } private Integer getTotalCount(String keyword) { String sql = \u0026#34;SELECT COUNT(*) \u0026#34; + \u0026#34;FROM product p \u0026#34; + \u0026#34;WHERE MATCH (p.product_name, p.product_corp) AGAINST (:keyword)\u0026#34;; MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue(\u0026#34;keyword\u0026#34;, keyword); return namedParameterJdbcTemplate.queryForObject(sql, parameters, Integer.class); } } 리팩터링된 코드 # 위의 코드를 리팩터링하여 아래의 코드로 변경했습니다.\n// 쿼리에 IN절을 사용해서 각 productId에 해당하는 리뷰들의 갯수를 한번에 조회합니다. public interface ReviewRepository extends CrudRepository\u0026lt;Review, Long\u0026gt; { @Query(\u0026#34;SELECT product_id, COUNT(*) AS review_count FROM review WHERE product_id IN (:productIds) GROUP BY product_id\u0026#34;) List\u0026lt;ProductReviewCount\u0026gt; countReviewsByProductIds(@Param(\u0026#34;productIds\u0026#34;) List\u0026lt;Long\u0026gt; productIds); } // 쿼리에 IN절을 사용해서 각 productId에 해당하는 product_diet_tag를 모두 조회합니다. // diet_tag 테이블과 조인하여 diet_tag_name을 가져옵니다. public interface ProductDietTagRepository extends CrudRepository\u0026lt;ProductDietTag, Long\u0026gt; { @Query(\u0026#34;SELECT pdt.product_id, dt.diet_tag_name, pdt.tag_count \u0026#34; + \u0026#34;FROM product_diet_tag pdt \u0026#34; + \u0026#34;LEFT JOIN diet_tag dt on pdt.diet_tag_id = dt.diet_tag_id \u0026#34; + \u0026#34;WHERE product_id IN (:productIds)\u0026#34; ) List\u0026lt;ProductDietTagDto\u0026gt; findByProductIds(@Param(\u0026#34;productIds\u0026#34;) List\u0026lt;Long\u0026gt; productIds); } @RequiredArgsConstructor @Transactional(readOnly = true) @Service public class ProductSearchService { // spring data elasticsearch 리포지토리 private final ProductDocumentRepository productDocumentRepository; private final ReviewRepository reviewRepository; private final ProductDietTagRepository productDietTagRepository; private final ProductSearchResponseMapper productSearchResponseMapper; public Page\u0026lt;ProductSearchResponse\u0026gt; search(String keyword, Pageable pageable) { // ProductDocument는 Elasticsearch에 저장된 각 데이터를 매핑합니다. // Elasticsearch을 이용해서 keyword에 해당하는 ProductDocument를 검색합니다. Page\u0026lt;ProductDocument\u0026gt; productDocuments = productDocumentRepository.findByProductName(keyword, pageable); // 검색 결과가 없으면 예외를 반환합니다. if (productDocuments.isEmpty()) { throw new BusinessException(PRODUCT_NOT_FOUND); } // Product의 id값을 추출합니다. List\u0026lt;Long\u0026gt; productIds = productDocuments.stream() .map(ProductDocument::getId) .toList(); List\u0026lt;ProductDietTagDto\u0026gt; tags = productDietTagRepository.findByProductIds(productIds); List\u0026lt;ProductReviewCount\u0026gt; productReviewCounts = reviewRepository.countReviewsByProductIds(productIds); // productSearchResponseMapper클래스가 // 각 productId에 맞게 tags를 count값 순으로 내림차순으로 정렬한 후 3개를 선택. // 또한 각 productId에 맞게 review 갯수도 매핑하여 결과 반환. return productSearchResponseMapper.toResponseList(productDocuments, tags, productReviewCounts); } } 결과적으로 한방 쿼리로 해결하던 것을 3번의 DB 접근으로 나눴지만, 더이상 복잡한 쿼리가 필요 없어졌고 비즈니스 로직과 데이터 접근 로직이 강하게 결합되어 있던 것을 분리하여 코드 가독성이 향상되었습니다.\n리팩터링된 코드는 각 역할(제품 조회, 리뷰 조회, 태그 조회)을 개별적으로 처리하고, 서비스 계층에서 결과를 조합하는 방식으로 재사용성 또한 증가했습니다.\n마지막 테스트 진행 # 이전 테스트와의 차이점 # 위의 요구사항들을 모두 반영. 기존의 GET(검색)요청과 함께 POST(저장)요청을 추가했습니다. 실제 상황을 가정하여 요청 비율은 GET 90%, POST 10%로 설정했습니다. 데이터 저장 시 MySQL과 Elasticsearch의 데이터 동기화는 애플리케이션에서 직접 관리하는 가장 단순한 방식을 선택했습니다. 테스트 시간을 25분으로 증가. 앞선 테스트에서 요구사항이 없는 Elasticsearch 단독 테스트에서 이미 어느 정도 성능이 나오는 것을 확인했기에 조금 더 긴 시간 동안 테스트를 진행했습니다. 사용자는 점진적으로 증가하다 4분 정도 지난 후 300명을 유지했습니다.\n테스트 결과 # 요구사항이 없는 검색(GET) 요청 테스트와 비교했을 때, TPS가 192.5에서 179.6으로 조금 감소했습니다.\n각 API 요청 별로 평균 응답 시간을 살펴보면 GET 요청은 876ms, POST 요청은 GET 요청보다 약 400ms 정도 더 소요된 1.21초(1210ms) 의 결과가 나타났습니다. POST 요청은 Elasticsearch 엔진에 쓰기 작업(읽기에 비해서 상대적으로 느림)과 MySQL에 쓰기 작업을 동기적으로 처리하는데 이 부분에서 더 많은 시간이 소요된 것으로 판단됩니다.\n애플리케이션 서버의 CPU 사용량은 대략 20~30%정도로 나타났습니다.\n그리고 전문 검색만을 이용할 때 가볍게 100%을 찍던 MySQL 서버의 CPU 사용량도 20~25% 정도로 줄어들었습니다.\n목표 대비 분석: GET 876ms, POST 1,210ms로 VUser 300명 부하 기준입니다. 응답 시간의 최솟값이 GET 144ms, POST 254ms인 점을 보면 부하가 낮은 상황에서는 목표 근접이 가능합니다. 다만 VUser 300명에서의 평균 응답 시간은 아직 목표를 초과하므로, 추가 개선이 필요합니다.\n이번 테스트에서 부족했던 점 # 이번 테스트를 진행하면서 한 가지 아쉬운 점이 있었습니다.\n목표를 설정할 때 현실적 피크 RPS를 약 15로 추정했지만, 정작 해당 부하 수준에서의 테스트는 진행하지 않았습니다. 모든 테스트를 VUser 300명이라는 높은 부하로만 진행했기 때문에, 목표 부하에서 실제로 어느 정도 성능이 나오는지 확인하지 못했습니다.\n스트레스 테스트를 통해 시스템의 한계점을 파악한 것은 의미가 있었지만, 올바른 성능 테스트 순서는 다음과 같았어야 합니다.\n목표 부하 테스트: 현실적 피크(15 RPS)에서 목표 응답 시간을 달성하는지 확인 스트레스 테스트: 점진적으로 부하를 높여 시스템이 어디서 한계에 도달하는지 파악 한계 분석 및 개선: 병목 지점을 분석하고 개선 후 다시 1번부터 반복 이번에는 2번부터 바로 진행한 셈입니다. 앞으로 유사한 성능 테스트를 진행한다면, 목표 부하에서의 검증을 먼저 수행한 뒤 점진적으로 부하를 높이는 순서로 접근하는 편이 더 적절하다고 판단합니다.\n다음 글: Spring Boot Tomcat threads.max와 HikariCP maximum-pool-size를 기본으로 설정하면 나타나는 현상\n","date":"2024년 12월 22일","externalUrl":null,"permalink":"/posts/mysql-fulltext-to-elasticsearch-performance/","section":"Posts","summary":"MySQL FullText Search의 한계를 분석하고 Elasticsearch를 도입하여 검색 API 성능을 개선한 과정을 정리합니다.","title":"MySQL 전문 검색의 한계, Elasticsearch 도입으로 검색 API 성능 개선하기","type":"posts"},{"content":"","date":"2024년 12월 22일","externalUrl":null,"permalink":"/tags/ngrinder/","section":"Tags","summary":"","title":"NGrinder","type":"tags"},{"content":"","date":"2024년 12월 22일","externalUrl":null,"permalink":"/tags/performance-testing/","section":"Tags","summary":"","title":"Performance Testing","type":"tags"},{"content":" 문제 상황 # MySQL의 전문 검색(FullText Search)을 활용한 검색 API를 구현하고 Postman으로 테스트를 진행했습니다.\n그런데 테스트 결과 응답 시간이 무려 6.85초나 소요되었습니다. 검색 API는 사용자가 식사를 기록할 때마다 호출하는 핵심 API입니다. 일반적으로 검색 API의 응답 시간은 200~500ms 이내가 적절하다고 알려져 있는데, 6.85초는 이 기준을 크게 초과하는 수치입니다.\n즉시 전문 검색 인덱스 적용 여부를 확인했으나, 전문 검색 인덱스는 정상적으로 생성되어 있었습니다.\n인덱스는 생성되었지만 인덱스를 제대로 타지 않았나?\n하는 의문이 들었고, 이제부터 그 원인을 추적해보고자 합니다.\nAPI의 요구사항과 DDL # API의 요구 사항으로는 product을 검색할 때, 검색 결과와 함께 각 product와 연관된 review들의 총 갯수도 보여줘야 합니다.\n현재 product 테이블의 데이터는 100만개, review 테이블의 데이터는 1000만개 저장되어있습니다.\n각 테이블의 DDL은 아래와 같습니다. 외래키는 사용하지 않았고 대신 인덱스는 별도로 설정했습니다.\n-- ngram 분석 알고리즘을 활용하는 전문 검색 인덱스 생성 CREATE TABLE product ( product_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(255) NOT NULL, product_corp VARCHAR(255), created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, FULLTEXT INDEX fulltext_idx (product_name, product_corp) WITH PARSER ngram ); CREATE TABLE review ( review_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, content VARCHAR(255), rating TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON review (product_id); 문제의 쿼리 # SELECT p.product_id, p.product_name, p.product_corp, IFNULL(review_count, 0) AS review_count FROM product p LEFT JOIN ( SELECT product_id, COUNT(*) AS review_count FROM review GROUP BY product_id ) r ON p.product_id = r.product_id WHERE MATCH (p.product_name, p.product_corp) AGAINST (\u0026#39;크리스피\u0026#39;) LIMIT 1, 20; 위의 요구사항을 위해 작성한 쿼리는 위와 같고, 예시를 위해 \u0026ldquo;크리스피\u0026quot;라는 단어로 전문 검색을 실행했습니다.\n하지만 위의 쿼리를 적용하고 테스트를 해봤을 때 3913(ms) 초의 시간이 걸리는 만큼 심각하게 느린 속도를 보입니다.\n실행 계획 분석 # 실행 순서는 빨간 번호 순이며, 다음 기준으로 읽으면 됩니다.\n들여쓰기(-\u0026gt; 모양의 화살표)가 같은 들여쓰기 레벨에서는 상단에 위치한 라인이 먼저 실행. (2번과 6번 중에서 2번이 먼저 실행) 들여쓰기가 다른 레벨에서는 가장 오른쪽에 위치한 라인이 먼저 실행. (2번과 1번 중에서 1번이 먼저 실행) 제일 첫 실행 작업인 \u0026ldquo;Full-text index search on p using fulltext_idx\u0026rdquo; 기준으로 실행계획의 용어들을 설명하면:\ncost: 총 쿼리 비용에서 이 쿼리의 기여도. rows: 예상 결과의 레코드 수를 말한다. actual time=n..m: n은 각 loop에서 첫번째 레코드를 읽기까지의 평균 시간, m은 마지막 레코드를 읽기까지의 평균 시간. actual rows: 한번의 loop에서 product_name=\u0026ldquo;크리스피\u0026quot;을 이용하여 실제 읽은 평균 레코드 수. actual loops: product_name=\u0026ldquo;크리스피\u0026quot;을 이용하여 product 테이블에서 레코드를 찾는 작업을 반복한 횟수. 이제, 실행계획을 따라가 보겠습니다.\n먼저, product 테이블에 전문 검색을 수행합니다. 이 부분에서는 26개의 레코드를 가져오는데 평균 22(ms)가 걸리는 만큼 성능 저하에 영향을 미치지 않습니다.\n그리고 review 테이블에서 Covering index scan을 수행하는데, 이때 테이블에서 읽은 rows가 1000만개(10e+6) 이고 마지막 row를 가져오는데 걸린 시간이 무려 3220(ms) 나 걸렸습니다.\nGROUP BY product_id와 COUNT()을 활용하여 연관된 review의 갯수를 집계할 때, 앞선 작업에서 모든 review의 product_id 인덱스를 탐색하느라 시간이 많이 소모되었습니다.\n이후 리뷰 갯수에 대한 집계연산을 수행하고 LEFT JOIN을 처리하는데 대략 4418(ms) 의 시간이 걸렸습니다. (EXPLAIN ANALYZE을 사용해서 실제 쿼리보다 시간이 조금 더 걸렸습니다.)\n첫번째 쿼리 수정 # 문제점을 확인해 보았을 때 review 테이블에서 index full scan이 처리됩니다. 이 문제를 해결하기 위해서는 전문 검색으로 탐색된 product에 연관된 review들만 선택한다면 많은 rows의 값을 줄일 수 있을 것입니다.\nSELECT p.product_id, p.product_name, p.product_corp, COUNT(*) AS review_count FROM product p LEFT JOIN review r ON p.product_id = r.product_id WHERE MATCH(p.product_name, p.product_corp) AGAINST(\u0026#39;크리스피\u0026#39;) GROUP BY p.product_id LIMIT 1, 20; LEFT JOIN 안에 있던 서브쿼리를 바깥으로 빼내고 review 테이블을 조인하도록 변경했습니다. 그리고 GROUP BY를 전문 검색 이후에 하도록 변경을 했습니다.\n하지만 여전히 쿼리가 3642(ms) 정도로 매우 낮은 성능을 보였습니다.\n다시 실행계획을 분석해 보았을 때, 예상한 대로 전문 검색으로 탐색된 product의 레코드에 대해서만 review 테이블을 탐색하는 것을 볼 수 있습니다.\n하지만 문제가 있습니다.\n전문검색에서 탐색되어 읽어온 product의 데이터가 90911개 입니다.\nproduct 한개당 읽어온 review 레코드는 평균 10개로 Nested loop left join을 할 때 대략 90만개의 데이터를 처리하게 되고, 마지막 레코드를 읽는데 3065(ms)이 걸리는 만큼 여전히 성능 저하가 나타납니다.\n실행 계획을 조금 더 살펴보면 908796개의 레코드를 조인 결과로 얻고 GROUP BY와 같은 집계 연산을 위해 임시 테이블을 만든 후 LIMIT 작업을 수행합니다.\n변경 전 쿼리는 limit Optimization가 이루어져서 전문 검색을 수행하며 필요한 레코드의 개수만 읽었으나 이번에는 이 작업이 이루어지지 않았습니다.\nlimit Optimization 관련 참고 자료:\nhttps://www.percona.com/blog/mysql-explain-limits-and-errors/ https://dev.mysql.com/doc/refman/8.4/en/limit-optimization.html WHERE LIKE 문과의 비교 # SELECT p.product_id, p.product_name, p.product_corp, COUNT(*) AS review_count FROM product p LEFT JOIN review r ON p.product_id = r.product_id WHERE p.product_name LIKE \u0026#34;%크리스피%\u0026#34; GROUP BY p.product_id LIMIT 1, 20; 추가로 한가지 궁금증이 생겨 똑같은 구조의 쿼리에서 전문 검색 대신 WHERE LIKE 문을 사용해 봤습니다. 그런데 놀랍게도 35(ms) 로 매우 빠른 성능이 나왔습니다.\n제일 먼저 product 테이블에 기본 키(PRIMARY)를 활용한 인덱스 스캔이 수행되었습니다. product_name에는 인덱스가 걸려있지 않습니다. (인덱스를 설정하더라도 와일드카드(%)가 앞에 있으면 인덱스를 활용할 수 없음) 따라서 기본 키를 활용하여 레코드를 읽으며 \u0026ldquo;크리스피\u0026quot;가 들어가있는 레코드를 필터링합니다.\n여기서 MySQL은 순차적으로 레코드를 스캔하면서 limit 20에 약간의 여유를 두고 22개의 레코드를 찾는 즉시 탐색을 종료합니다. 그 과정에서 총 240개의 레코드를 탐색했습니다. 즉, limit optimization이 이루어졌고 별도의 임시테이블 생성 없이 GROUP BY 집계연산이 수행되었습니다.\n전문 검색을 활용할 때는 MATCH AGAINST로 매칭된 모든 레코드를 먼저 평가한 후 GROUP BY 집계가 완료되어야 LIMIT를 적용할 수 있기 때문에, 순차 스캔처럼 중간에 탐색을 중단하는 limit optimization이 이루어지지 못합니다.\n하지만 그렇다고 이런 방식의 검색을 활용하자는 것은 아닙니다. 만약 테이블의 레코드가 매우 많고 찾고자 하는 데이터가 매우 뒤에 있다면 Index full scan을 할 때 매우 많은 시간이 걸릴 수 있습니다.\n사실 이러한 현상은 더미데이터를 삽입할 때 JdbcTemplate의 batch insert를 사용하면서 데이터에 규칙성이 생겼고, 이로 인해 limit 개수가 빠르게 충족되었습니다.\n또한 전문 검색 시에는 이러한 규칙성으로 인해 하나의 전문 검색 인덱스에 연관된 데이터가 과도하게 많이 저장되어(예시의 경우 90911개) 스캔 대상이 증가하는 문제가 발생했습니다.\n두번째 쿼리 수정 # SELECT p.product_id, p.product_name, p.product_corp, (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count FROM product p WHERE MATCH(p.product_name, p.product_corp) AGAINST(\u0026#39;크리스피\u0026#39;) LIMIT 1, 20; 여기서는 JOIN을 사용하지 않고, Subquery를 사용했습니다. 먼저, product 테이블에 전문검색 결과를 탐색하고 그 결과에 해당하는 review만 갯수를 구했습니다.\n실행계획을 살펴봤을 때, 전문검색의 결과에서 limit 20에 근접하는 레코드 갯수를 읽어왔습니다. 그리고 여기서 총 쿼리의 실행시간은 84(ms) 정도 나왔습니다.\n결과적으로 인덱스를 수정하기 보단, 쿼리의 구조를 조인에서 서브쿼리로 변경하는 것으로 실행시간이 3000~4000(ms)에서 100(ms)미만으로 30배 이상 향상되었습니다.\n요구사항 추가 # 사실 요구사항이 하나 더 존재했습니다.\n각 product에 대한 상위 3개 태그를 표시합니다. 사용자들이 리뷰 작성 시 자유롭게 선택한 태그들 중 가장 많이 선택된 3개를 보여줍니다.\nCREATE TABLE product_diet_tag ( product_diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT, diet_tag_id BIGINT, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX idx_product_id ON product_diet_tag (product_id); CREATE TABLE diet_tag ( diet_tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, diet_tag_name VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); diet_tag 테이블에는 미리 정해진 10개의 데이터가 존재하고 product_diet_tag 테이블에는 2000만개의 데이터가 저장되어 있습니다.\nDML은 위와 같이 작성했으나 요구사항을 만족하는 쿼리를 작성하는 도중 너무 복잡하여 diet_tag 테이블에 있던 diet_tag_name 컬럼을 product_diet_tag 테이블에 역정규화를 진행했습니다.\nSELECT p.product_id, p.product_name, p.product_corp, (SELECT COUNT(*) FROM review r WHERE r.product_id = p.product_id) AS review_count, (SELECT GROUP_CONCAT(diet_tag_name) FROM (SELECT diet_tag_name FROM product_diet_tag pdt WHERE pdt.product_id = p.product_id GROUP BY diet_tag_name ORDER BY COUNT(diet_tag_name) DESC LIMIT 3) AS top3_diet_tag) AS top3_diet_tag_names FROM product p WHERE MATCH(p.product_name, p.product_corp) AGAINST(\u0026#39;크리스피\u0026#39;) LIMIT 1, 20; 앞선 쿼리와 유사하게 여기서도 Subquery로 풀어나갔습니다.\n쿼리의 수행시간은 평균적으로 150~250(ms) 가 나왔습니다. 작업 하나 하나가 큰 비용이 들지는 않지만, 여러 과정이 누적되니 대략 2~3배정도 시간이 늘어났습니다.\n더 나은 방법은 없을까요? # 여기서 쿼리를 더욱 최적화를 할 방법을 찾아볼 수 있겠지만, 과연 이러한 한방 쿼리가 적절한지 생각해볼 필요가 있습니다.\n여기서 문제점은 수행시간이 증가한 부분도 있지만, 서비스의 비즈니스 로직들이 쿼리안에 녹아 들어있습니다.\n예를 들어, \u0026ldquo;평점 4점 이상의 리뷰만 집계한다\u0026quot;거나 \u0026ldquo;상위 태그를 3개에서 5개로 변경한다\u0026quot;는 요구사항이 생기면 SQL 서브쿼리도 함께 수정해야 합니다. 반면, 이 로직을 애플리케이션 계층(서비스 또는 도메인 레이어)으로 옮겨서 — 예를 들어 product 검색 결과를 받아온 뒤 리뷰 수와 태그 정보를 별도로 조회하고 가공하는 방식으로 처리한다면, 비즈니스 로직 변경이 SQL이 아닌 Java 코드에만 국한되어 유지보수가 더 수월합니다.\n또한, 테스트 코드를 작성하기도 어렵습니다. 레포지토리의 테스트 코드를 작성한다면 하나의 테스트에 검색 여부, 리뷰 개수, tag정보 이 모든 걸 반영한 테스트를 작성해야 하고 요구 사항이 늘어날수록 레포지토리의 테스트 코드도 계속 수정해 나가야 합니다.\n다음 편에서는 쿼리에 녹아있는 비즈니스 로직을 도메인 영역과 비즈니스 영역으로 옮기며 쿼리를 개선해 보겠습니다.\n다음 글: MySQL 전문 검색의 한계, Elasticsearch 도입으로 검색 API 성능 개선하기\n","date":"2024년 10월 31일","externalUrl":null,"permalink":"/posts/mysql-left-join-to-subquery-optimization/","section":"Posts","summary":"MySQL 전문 검색 API의 응답 시간을 6.85초에서 84ms로, Left Join을 Subquery로 변경하여 약 30배 향상한 과정을 정리합니다.","title":"[MySQL] Left Join에서 Subquery로 변경 후 쿼리 성능 30배 향상하기","type":"posts"},{"content":"","date":"2024년 10월 31일","externalUrl":null,"permalink":"/tags/fulltext-search/","section":"Tags","summary":"","title":"FullText Search","type":"tags"},{"content":"","date":"2024년 10월 23일","externalUrl":null,"permalink":"/tags/ci/cd/","section":"Tags","summary":"","title":"CI/CD","type":"tags"},{"content":"","date":"2024년 10월 23일","externalUrl":null,"permalink":"/tags/jenkins/","section":"Tags","summary":"","title":"Jenkins","type":"tags"},{"content":"","date":"2024년 10월 23일","externalUrl":null,"permalink":"/tags/naver-cloud/","section":"Tags","summary":"","title":"Naver Cloud","type":"tags"},{"content":"","date":"2024년 10월 23일","externalUrl":null,"permalink":"/tags/ncp/","section":"Tags","summary":"","title":"NCP","type":"tags"},{"content":" 1편에 이어서 2편을 작성하겠습니다.\n1편은 링크를 클릭해 주세요!\nJenkins 서버 생성. # NCP의 Server로 들어가서 Jenkins용으로 사용할 새로운 서버를 생성합니다.\n새롭게 생성한 Jenkins서버에서는 Github의 webhook요청을 받을텐데 이를 허용하기 위해서 Github webhook의 요청 IP를 ACG에 설정 해야합니다.\nhttps://api.github.com/meta\n위의 링크로 들어가서 hooks을 검색하고 4개의 ipv4주소를 inbound 규칙에 추가해줍니다.\n그리고 생성한 Jenkins용 서버에 접속을 합니다.\nhttps://www.jenkins.io/doc/book/installing/linux/#debianubuntu\n위의 링크를 참고해서 Jenkins을 설치해줍니다.\nsudo systemctl status jenkins\n서버를 설치 후 위의 명령어 실행으로 초록색의 active(running)이 나타났다면 Jenkins 설치는 성공입니다.\n그리고 나중에 Jenkins가 프로젝트를 빌드하기 위해서 본인이 프로젝트에 사용하는 JDK 버전에 맞게 JDK도 설치해 줍니다.\n저는 Jenkins설치할 때와 동일하게 JDK17을 사용해서 별도로 설치하지는 않겠습니다.\nObject Storage Bucket 생성 # https://console.ncloud.com/objectStorage/objectStorageList\n위의 링크를 들어가서 버킷을 생성해줍니다.\nJenkins 서버에 AWS CLI 설치 # NCP의 Object Storage는 AWS S3위에서 만들어진 플랫폼이라 AWS S3의 CLI와 호완이 가능합니다.\nhttps://cli.ncloud-docs.com/docs/guide-objectstorage\n위의 링크를 참고해서 설치까지만 해줍니다. 인증을 함께 해두어도 되지만, 나중에 SourceDeploy을 사용할 때도 NCP 인증정보가 필요해서 뒤로 미루겠습니다.\n참고로 Ubuntu22.04는 기본으로 설치된 Python이 3.10 버전이라 AWS CLI을 사용하기 위해서는 python 3.8이하를 설치해주어야 합니다.\nSourceDeploy 생성 # SourceDeploy을 이용하기 위해서는 SourceDeploy agent가 배포할려는 서버에 설치되어 있어야 합니다. 이 부분은 1편에서 알맞게 설치했을 것이라 생각하겠습니다.\nSourceDeploy에 들어가서 배포 프로젝트 생성을 클릭합니다.\n원하시는 이름으로 지정하고\n배포 환경설정에서 배포 타겟을 Auto Scaling으로 선택합니다.\n그리고 1편에서 생성한 Auto Scaling Group을 선택합니다. 저의 경우 이전에 만들었던 프로젝트라 Auto Scaling Group 이름이 1편과는 다른 점 양해바랍니다:)\n배포 프로젝트가 생성되었으면 클릭 후 배포 시나리오를 생성합니다.\n무중단 배포 전략을 선택합니다. Auto Scaling에 적용하는 무중단 배포 전략은 기본(Rolling) 또는 블루/그린 배포전략만 가능합니다. Canary배포 전략은 아마 ncloud kubernetes 에서 가능한 것으로 알고있습니다.\n자세한건 여기 공식문서 링크를 클릭해주세요!\n그리고 배포 후 기존의 Auto Scaling Group을 처리할 방법을 선택할 수 있는데 편의상 유지하도록 하겠습니다.\n배포 파일을 가져올 위치를 선택합니다. 여기서 저는 Jenkins가 빌드 후 Object Storage에 빌드된 파일을 업로드할 것이기에 Object Storage을 선택합니다.\n그리고 배포 파일을 선택하도록 하는데\n이때 설정을 하다보니 배포파일을 미리 업로드 해놓을 필요가 있어서 zip으로 압축한 jar파일을 미리 올려두었습니다.\nzip -r nutridiary.zip build/libs/nutridiary-0.0.1-SNAPSHOT.jar 압축 명령어는 위의 명령어를 참고해 주세요!\n각 단계 별로 명령어에 대한 설명을 하면\n“배포전 실행”은 Blue Green 배포 방식으로인해 새롭게 생성된 Auto Scaling Group 서버에 적용됩니다. 서버가 새롭게 생성되었을 때 앞서 만들었던 init script가 실행되어 기존의 배포 전 jar파일이 실행되고 있을 것입니다. 따라서 이것을 중지시킬 필요가 있으며 새로운 jar 파일과의 중복 방지를 위해 기존의 jar파일을 삭제합니다.\nkill -15 $(pgrep -f nutridiary-0.0.1-SNAPSHOT.jar) \u0026amp;\u0026amp; rm /root/deploy/nutridiary-0.0.1-SNAPSHOT.jar 두번째 “파일 배포”는 Object Storage에서 서버로 파일을 전송하는 단계입니다. object storage에서 zip으로 압축된 폴더 기준으로 본인의 jar 파일 위치를 정확히 적어주시고 서버에 저장할 jar파일의 위치를 지정해주세요.\n세번째 “배포 후 실행\u0026quot;에서 이제 새로운 jar파일을 백그라운드에서 실행합니다.\nnohup java -jar /root/deploy/nutridiary-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod \u0026gt; /root/deploy/output.log 2\u0026gt;\u0026amp;1 \u0026amp; 따라서 위의 명령어를 입력해 줍니다.\n그리고 마지막으로 배포 시나리오를 생성합니다.\n자 이제 다시 Jenkins로 가봅시다!\nGithub Access Token 발급 # Jenkins 서버에서 github repository에 접근할려면 access token을 발급받아야 합니다. 또는 젠킨스 서버에서 ssh키를 생성해서 인증하는 방법도 있는데, 여기서는 access token 형식으로 하겠습니다.\n본인 깃허브 계정의 Settings에서 Developer Settings을 클릭해 줍니다.\n우측의 Generate new token 버튼을 클릭해 줍니다.\naccess token명을 지정하고 repo와 admin:repo_hook 을 선택해줍니다.\n그리고 하단에 Generate token을 선택해주면, 위의 토큰값이 나타납니다.\n토큰 값은 한번만 나타나기 때문에 안전한 곳에 복사해두고, 이제 젠킨스에서 Github Repository에 접근할 수 있도록 Credential을 등록해야 합니다\nJenkins에 Github 토큰 등록 # 이제 웹브라우저에서 “젠킨스서버의 공인ip주소:8080” 으로 접속해 봅니다.\n그리고 위에 빨간 부분으로 나타난 url을 참고하여 Jenkins의 초기 비밀번호를 복사 붙여넣기를 합니다.\n저는 첫번째 버튼을 선택했고, 필요한 플러그인을 모두 설치해 줍니다.\n이후 회원가입을 진행하고\nJenkins 화면에 접속한다음 Jenkins 관리를 클릭하고 Credential 을 클릭합니다.\n빨간 박스의 global을 클릭해 줍니다.\n오른쪽 상단의 Add Credential을 클릭해 줍니다.\nkind는 Username with password을 선택해주고 Username에 github 아이디, Password에 발급받은 토큰값을 붙여넣습니다. ID 값은 임의로 지정합니다.\n추가로 NCP 정보도 Credential에 미리 등록합니다. # 링크로 들어가서 Access key와 Secret key을 복사합니다.\n그리고 kind는 Secret text로 선택하고 Secret에 각각 복사한 NCP의 값을 입력하고 ID에는 본인이 임의로 정합니다.\n나중에 Jenkins pipeline script에서 해당 ID로 환경변수를 넣어줄것을 미리 참고해 주세요!\n모두 등록하면 위의 이미지와 같습니다.\n사실 이렇게 NCP의 정보를 Jenkins 서버에 저장하는 것은 위험할 수 있습니다. 만약 Jenkins 서버가 해킹당한다면 Access값들이 모두 털리는 것이죠.\n그래서 AWS 같은 경우 AMI 기반의 Role을 가지고 Jenkins서버에서 AWS 서비스에 접속하는 것을 허용할 수 있습니다. 하지만 NCP는 문의 결과 그런게 지원이 안되는 군요ㅠ\nGithub webhook 설정 # 젠킨스 관리에 들어가서 Plugins 을 클릭해 봅니다.\ngithub integration 플러그인을 검색해서 선택하고 설치해줍니다.\n이제 Github 프로젝트의 repository에서 Settings에 들어갑니다. 그리고 Webhooks을 클릭하고 Add webhook을 클릭합니다.\nPayload URL에 본인의 Jenkins 서버 주소와 뒤에 “/github-webhook/ “을 추가해줍니다.\n이제 본인의 깃허브 레포지토리에서 커밋이 발생하면 Jenkins 서버로 webhook이 발생합니다.\nJenkins 파이프라인 구성 # Jenkins 서버에 접속해서 새로운 Item을 클릭해줍니다.\n이름을 지정하고 Pipeline을 선택해줍니다.\n본인의 Github repository 주소를 적어줍니다. 안 적어줘도 되지만 입력해 놓으면, 오른쪽 이미지 처럼 Jenkins 프로젝트 탭에서 프로젝트 Github repository로 이동하는 버튼이 생성됩니다.\n각 항목들을 위와 같이 구성하고 Credential은 앞에서 등록했던 것을 사용합니다.\n빌드할 브랜치는 main으로 작성해 줍니다.\n그리고 마지막으로 Script Path는 Jenkinsfile로 적어줍니다.\nJenkins script 작성 # pipeline { agent any environment { API_ACCESS_KEY = credentials(\u0026#39;ncp-api-access-key\u0026#39;) API_SECRET_KEY = credentials(\u0026#39;ncp-api-secret-key\u0026#39;) } stages { stage(\u0026#39;Prepare\u0026#39;) { steps { echo \u0026#39;Preparing...\u0026#39; git branch: \u0026#39;main\u0026#39;, url: \u0026#39;https://github.com/f-lab-edu/nutri-diary.git\u0026#39; } post { success { echo \u0026#39;Preparation completed successfully!\u0026#39; } failure { echo \u0026#39;Preparation failed!\u0026#39; } } } stage(\u0026#39;Build\u0026#39;) { steps { sh \u0026#39;./gradlew clean build\u0026#39; } post { success { echo \u0026#39;Build completed successfully!\u0026#39; } failure { echo \u0026#39;Build failed!\u0026#39; } } } stage(\u0026#39;Upload\u0026#39;) { steps { sh \u0026#39;chmod +x ./script/upload.sh\u0026#39; sh \u0026#39;./script/upload.sh\u0026#39; } post { success { echo \u0026#39;Upload completed successfully!\u0026#39; } failure { echo \u0026#39;Upload failed!\u0026#39; } } } stage(\u0026#39;Deploy\u0026#39;) { steps { sh \u0026#39;chmod +x ./script/deploy.sh\u0026#39; sh \u0026#39;./script/deploy.sh\u0026#39; } post { success { echo \u0026#39;Deploy completed successfully!\u0026#39; } failure { echo \u0026#39;Deploy failed!\u0026#39; } } } } post { success { echo \u0026#39;Build, Test, and Deploy completed successfully!\u0026#39; } failure { echo \u0026#39;Build or Deploy failed!\u0026#39; } } } 첫 라인에서는 Jenkins에 등록했던 NCP의 Credential 정보를 환경변수로 넣어줍니다.\n그리고 각 단계별로 빌드 후 upload.sh, deploy.sh을 실행합니다. (upload.sh, deploy.sh 스크립트는 아래에 있습니다.)\nJenkins pipeline script는 위와 같이 작성하고 파일명은 “Jenkinsfile” (확장자 없습니다) 로 지정합니다. 그리고 Jenkinsfile은 프로젝트 최상단에 두고 각 upload.sh, deploy.sh 은 script 폴더를 만들고 그 안에 두었습니다.\nupload.sh # #!/bin/bash zip -r nutridiary.zip build/libs/nutridiary-0.0.1-SNAPSHOT.jar aws configure set aws_access_key_id ${API_ACCESS_KEY} aws configure set aws_secret_access_key ${API_SECRET_KEY} aws --endpoint-url=https://kr.object.ncloudstorage.com s3 rm s3://${your_bucket_name}/nutridiary.zip aws --endpoint-url=https://kr.object.ncloudstorage.com s3 cp nutridiary.zip s3://${your_bucket_name}/nutridiary.zip 먼저 빌드된 파일을 압축해줍니다.\n이때 파일 압축 경로와 SourceDeploy명령어 경로를 유의해 주세요!\n2~3 명령어는 환경변수로 등록한 accesskey와 secretkey로 인증을 수행합니다.\n4~5 명령어는 각각 기존의 ObjectStorage에 있는 zip파일을 제거하고 새로운 zip 파일을 업로드합니다.\ndeploy.sh # #!/bin/bash # Naver Cloud Platform API 기본 설정 SOURCEDEPLOY_API_URL=\u0026#34;https://vpcsourcedeploy.apigw.ntruss.com\u0026#34; PROJECT_NAME=\u0026#34;nutridiary\u0026#34; # 헤더 설정 (API 인증) get_auth_headers() { local method=\u0026#34;$1\u0026#34; local uri=\u0026#34;$2\u0026#34; local api_timestamp=$(perl -MTime::HiRes -e \u0026#39;printf(\u0026#34;%d\\n\u0026#34;, Time::HiRes::time()*1000)\u0026#39;) local signature=$(generate_signature \u0026#34;${method}\u0026#34; \u0026#34;${uri}\u0026#34; \u0026#34;${api_timestamp}\u0026#34;) # 헤더를 명시적으로 배열로 관리하여 curl에 전달 headers=( -H \u0026#34;x-ncp-apigw-timestamp: ${api_timestamp}\u0026#34; -H \u0026#34;x-ncp-iam-access-key: ${API_ACCESS_KEY}\u0026#34; -H \u0026#34;x-ncp-apigw-signature-v2: ${signature}\u0026#34; ) } # Signature 생성 함수 (API 호출에 필요) generate_signature() { local method=\u0026#34;$1\u0026#34; local uri=\u0026#34;$2\u0026#34; local time_stamp=\u0026#34;$3\u0026#34; local nl=$\u0026#39;\\\\n\u0026#39; SIG=\u0026#34;${method}\u0026#34;\u0026#39; \u0026#39;\u0026#34;${uri}\u0026#34;${nl} SIG+=\u0026#34;${time_stamp}\u0026#34;${nl} SIG+=\u0026#34;${API_ACCESS_KEY}\u0026#34; SIGNATURE=$(echo -n -e \u0026#34;${SIG}\u0026#34;|iconv -t utf8 |openssl dgst -sha256 -hmac ${API_SECRET_KEY} -binary|openssl enc -base64) echo \u0026#34;${SIGNATURE}\u0026#34; } # 1. 프로젝트 ID 가져오기 get_project_id() { local project_name=\u0026#34;$1\u0026#34; local uri=\u0026#34;/api/v1/project?projectName=${project_name}\u0026#34; # 헤더 준비 get_auth_headers \u0026#34;GET\u0026#34; \u0026#34;${uri}\u0026#34; # 프로젝트 목록 가져오기 response=$(curl -s -X GET \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # 프로젝트 ID 파싱 project_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.projectList[0].id\u0026#39;) # 에러 처리: project_id가 없을 경우 에러 메시지 출력 if [[ -z \u0026#34;${project_id}\u0026#34; || \u0026#34;${project_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: 프로젝트 ID를 찾을 수 없습니다.\u0026#34; exit 1 fi # project_id 리턴 echo \u0026#34;${project_id}\u0026#34; } # 2. 스테이지 아이디 가져오기 (프로젝트 ID 필요) get_stage_id() { local project_id=\u0026#34;$1\u0026#34; local uri=\u0026#34;/api/v1/project/${project_id}/stage\u0026#34; # 헤더 준비 get_auth_headers \u0026#34;GET\u0026#34; \u0026#34;${uri}\u0026#34; # 스테이지 목록 가져오기 response=$(curl -s -X GET \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # 스테이지 ID 파싱 stage_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.stageList[0].id\u0026#39;) # 에러 처리: stage_id가 없을 경우 에러 메시지 출력 if [[ -z \u0026#34;${stage_id}\u0026#34; || \u0026#34;${stage_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: 스테이지 ID를 찾을 수 없습니다.\u0026#34; exit 1 fi # stage_id 리턴 echo \u0026#34;${stage_id}\u0026#34; } # 3. 시나리오 아이디 가져오기 get_scenario_id() { local project_id=\u0026#34;$1\u0026#34; local stage_id=\u0026#34;$2\u0026#34; local uri=\u0026#34;/api/v1/project/${project_id}/stage/${stage_id}/scenario\u0026#34; # 헤더 준비 get_auth_headers \u0026#34;GET\u0026#34; \u0026#34;${uri}\u0026#34; # 시나리오 목록 가져오기 response=$(curl -s -X GET \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # 시나리오 ID 파싱 scenario_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.scenarioList[0].id\u0026#39;) # 에러 처리: scenario_id가 없을 경우 에러 메시지 출력 if [[ -z \u0026#34;${scenario_id}\u0026#34; || \u0026#34;${scenario_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: 시나리오 ID를 찾을 수 없습니다.\u0026#34; exit 1 fi # scenario_id 리턴 echo \u0026#34;${scenario_id}\u0026#34; } # 4. 배포 시작 요청 start_deploy() { local project_id=\u0026#34;$1\u0026#34; local stage_id=\u0026#34;$2\u0026#34; local scenario_id=\u0026#34;$3\u0026#34; local uri=\u0026#34;/api/v1/project/${project_id}/stage/${stage_id}/scenario/${scenario_id}/deploy\u0026#34; # 헤더 준비 get_auth_headers \u0026#34;POST\u0026#34; \u0026#34;${uri}\u0026#34; response=$(curl -s -X POST \u0026#34;${SOURCEDEPLOY_API_URL}${uri}\u0026#34; \u0026#34;${headers[@]}\u0026#34;) # 응답에서 historyId 추출 history_id=$(echo \u0026#34;${response}\u0026#34; | jq -r \u0026#39;.result.historyId\u0026#39;) # 에러 처리: history_id가 없을 경우 에러 메시지 출력 if [[ -z \u0026#34;${history_id}\u0026#34; || \u0026#34;${history_id}\u0026#34; == \u0026#34;null\u0026#34; ]]; then echo \u0026#34;Error: 배포 요청에 실패했습니다.\u0026#34; exit 1 fi # 배포 성공 echo \u0026#34;배포가 시작되었습니다. History ID: ${history_id}\u0026#34; } # 실행 흐름 PROJECT_ID=$(get_project_id \u0026#34;${PROJECT_NAME}\u0026#34;) STAGE_ID=$(get_stage_id \u0026#34;${PROJECT_ID}\u0026#34;) SCENARIO_ID=$(get_scenario_id \u0026#34;${PROJECT_ID}\u0026#34; \u0026#34;${STAGE_ID}\u0026#34;) ## 배포 시작 start_deploy \u0026#34;${PROJECT_ID}\u0026#34; \u0026#34;${STAGE_ID}\u0026#34; \u0026#34;${SCENARIO_ID}\u0026#34; https://api.ncloud-docs.com/docs/devtools-sourcedeploy\ndeploy.sh에서 실행하는 SourceDeploy API의 자세한 정보는 위의 링크를 참고해 주세요!\n추가로 위의 script에서 json값을 파싱하기 위해서 “jq”라이브러리를 사용하는데 젠킨스 서버에 접속하셔서 apt-get install jq 로 설치해줍니다!\n마무리 # 이제 모든 것이 끝났습니다. 테스트해보기 위해서 github 프로젝트에 임의의 커밋을 적용하면 NCP의 SourceDeploy을 활용한 무중단 배포까지 적용될 것입니다.\n혹시 부족한 부분이 있으면 댓글로 남겨주세요!\n","date":"2024년 10월 23일","externalUrl":null,"permalink":"/posts/ncp-infra-jenkins-cicd-sourcedeploy/","section":"Posts","summary":"NCP에서 Jenkins 서버를 구축하고 SourceDeploy를 연동하여 CI/CD 파이프라인과 무중단 배포를 구성하는 방법을 정리합니다.","title":"NCP(Naver Cloud Platform) 를 이용한 인프라 구성하기 2편. Jenkins와 SourceDeploy을 이용한 CI/CD와 무중단 배포 구성","type":"posts"},{"content":"","date":"2024년 10월 23일","externalUrl":null,"permalink":"/tags/sourcedeploy/","section":"Tags","summary":"","title":"SourceDeploy","type":"tags"},{"content":" CI/CD 와 무중단 배포 인프라를 NCP을 이용해 구성해보았습니다. 공식문서와 여러 블로그도 참고해 보았지만, NCP 관련 정보가 AWS에 비해 부족함이 많아 NCP을 이용한 인프라를 구성하는 분들께 도움이 되고자 합니다.\n특히, Docker 없이 전통적 하이퍼바이저 기반의 인프라를 구성하고자 하는 분들께 참고가 되길 바라며, NCP의 Auto Scaling과 Load Balancer, SourceDeploy, Object Storage CLI을 Jenkins에서 사용하는 방법을 구성해 보겠습니다.\n1. VPC 구성. # 가장 먼저 시작해야할 것은 VPC을 구성하는 것입니다.\nVPC의 IP 대역은 임의로 172.16.0.0/16으로 설정했습니다.\nVPC를 생성하면 자동으로 Network ACL이 생성됩니다. 일종의 방화벽과 같은 역할을 하며, 기본적으로는 모든 트래픽을 허용하기에 따로 설정을 하지는 않겠습니다.\n2. Public Subnet 구성. # 이제 VPC 를 세분화 하며 서브넷을 만들어줍니다. 보안을 위해 Private 서브넷을 구성해도 되지만 편의를 위해 우선 Public 서브넷을 생성합니다. VPC의 IP 대역 172.16.0.0/16 을 세분화해서 서브넷 영역은 172.16.1.0/24로 설정했습니다.\n3. Server 생성. # 이젠 서버를 생성할 차례이지만\u0026hellip; 여기서 생성할 서버는 나중에 Auto Scling Group을 위한 서버의 이미지를 만들기 위해 임시로 서버를 생성합니다.\n서버 이미지의 운영체제는 원하시는 것으로 선택하면 됩니다. 저는 여기서 Ubuntu 22.04를 선택했습니다.\nVPC와 Subnet은 앞에서 만들었던 것들을 선택합니다.\n인증키를 생성해서 다운로드 받습니다.\nACG을 설정하기 위해서, ACG을 생성해줍니다.\n저는 이미 만들어놓은 ACG가 존재해서 기존의 것을 사용했습니다.\n여기서 중요한 것은 inboud 규칙과 outbound 규칙을 설정해줘야 합니다. ACG의 기본정책은 Network ACL과 다르게 모든 트래픽을 차단합니다. 따라서 허용할 IP 대역을 설정해 줍니다. 저는 Public Subnet 대역인 172.16.0.0/16과 저의 로컬 IP 주소를 Inboud, OutBound 모두 동일하게 설정했습니다.\nACG을 생성하고 선택한 다음 최종적으로 서버를 생성해줍니다.\n4. 배포할 프로젝트 준비. # @RestController public class HealthCheckController { @GetMapping(\u0026#34;/\u0026#34;) public ResponseEntity\u0026lt;String\u0026gt; healthCheck() { return new ResponseEntity\u0026lt;\u0026gt;(\u0026#34;OK\u0026#34;, HttpStatus.OK); } } 여기서 주의할 점. # Spring 프로젝트에서 반드시 로드밸런스의 헬스 체크를 위한 API가 존재해야 합니다. 저는 위의 엔드포인트를 따로 구성했습니다. URL로 설정한 “/” 주소를 기억하고 나중에 로드밸런스를 설정할 때 등록해 주어야 합니다.\n본인의 로컬 컴퓨터에서 Spring 프로젝트를 빌드합니다.\n이제 빌드한 파일을 생성한 서버로 전달해야하지만… 이때, 생성한 NCP 서버의 관리자 비밀번호가 필요합니다.\nNCP의 서버를 생성할 때 다운로드 되었던 ???.pem 파일을 붙여넣습니다.\n자, 이제 파일을 전송해 봅시다.\nscp ${your_project_path}/${file_name} 관리자이름@공인IP:가상서버에 저장할 위치\n위의 명령어를 본인의 상황에 맞게 입력해줍니다. 가상서버의 주소는 프로젝트 생성할 때 만든 공인IP주소입니다.\n오른쪽 하단에 100%가 나타나면 정상적으로 전송이 되었고 만약, 아무것도 나타나지 않았다면 전송에 실패한 것이니 다시 한번 살펴보시기 바랍니다.\n5. 생성한 서버에 접속 # 이제 생성한 서버의 공인 IP주소를 통해 서버에 접속해 봅니다.\n터미널에서 ssh root@${공인IP주소} 를 입력합니다.\n파일을 확인해보면 로컬PC에서 전송한 프로젝트 파일이 있는 것을 확인할 수 있습니다.\n테스트겸 프로젝트 파일을 실행해 보았으나, 설치된 Java가 없다고 나타납니다. 본인 프로젝트에서 설정한 Java를 설치하고 테스트겸 실행해 봅니다.\n아마 스프링 프로젝트면 데이터베이스 연결이 필요할텐데, 이 부분은 따로 설정해주셔야 에러가 안 나타납니다..!\n추가로 SourceDeploy을 사용하기 위함. # https://guide.ncloud-docs.com/docs/devtools-devtools-4-4#2%EC%84%A4%EC%B9%98%EB%AA%85%EB%A0%B9%EC%96%B4%EC%9E%91%EC%84%B1\n나중에 SourceDeploy을 사용할려면 꼭 필요한 부분이라 위의 링크로 접속하셔서 SourceDeploy용 Agent을 설치해줍니다.!\n6. 로드밸런스 생성. # 우리는 프로젝트에서 http을 이용한 통신을 할 것 이기에 애플리케이션 로드밸런서를 선택합니다.\n로드밸런스는 기존의 Public 서브넷과 별도의 로드밸런스용 서브넷이 필요합니다. 서브넷 생성버튼을 눌러 앞에서 했던것 처럼 로드밸런스용 서브넷을 생성합니다.\n로드밸런스가 처리할 Http 프로토콜의 포트번호인 80번을 열어줍니다.\n다음 단계로 넘어가니 Target Group이 필요합니다. 버튼을 클릭해서 Target Group을 생성해 줍니다.\n프로토콜은 Http로 설정하고 Spring 프로젝트는 주로 8080포트번호를 사용하니 위와 같이 설정합니다.\n이제 헬스 체크를 설정합니다. 헬스 체크 역시 http 프로토콜을 선택하고 spring 프로젝트의 8080 포트를 선택합니다. 여기서 URl Path는 프로젝트의 헬스 체크용 엔드포인트 주소를 입력합니다.\n이 글을 따라오셨다면, 아마 생성했던 서버하나가 나타날 것입니다. 하지만 저희는 Auto Scaling Group에 로드밸런스를 설정할 것이기에 선택하지 않고 넘어가도 됩니다. 이 부분은 나중에 Auto Scaling Group을 생성하고 설정하겠습니다.\n6. 이제 Auto Sacling 을 적용해 봅시다. # 6.1 Launch Configuration 생성 # Java 와 프로젝트 파일이 설정된 NCP 서버를 선택하고 “내 서버 이미지 생성\u0026quot;을 해줍니다.\nAuto Scaling을 사용하기 위해서는 템플릿 역할을 하는 Lanuch Configuration을 생성해야합니다.\n내 서버 이미지 탭으로 들어가서 방금전 만들었던 서버 이미지를 선택합니다.\n이때 스프링 프로젝트를 실행할 init Script가 필요해서 init Script을 생성해줍니다.\nnohup java -jar /root/deploy/nutridiary-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod \u0026gt; /root/deploy/output.log 2\u0026gt;\u0026amp;1 \u0026amp; 스프링 프로젝트를 백그라운드에서 실행하도록 스크립트를 작성합니다.\nLaunch Configuration 까지 생성했다면, 이제 Auto Scaling Group을 생성합니다.\n6.2 Auto Scaling Group 생성 # 이름을 설정하고 지금까지 만들었던 VPC, Subnet 그리고 원하는 인스턴의 용량을 설정해 줍니다. 그리고 헬스 체크 유형에는 로드밸런서를 선택하고 앞에서 만들었던 Target Group을 선택합니다.\n스케일아웃 정책이나 알림 통보는 원하시는 대로 하셔도 됩니다. 저는 우선 나중에 설정으로 모두 선택했습니다.\n7. 로드밸런스 Target Group 연결 # 이제 생성된 Auto Scaling Group의 인스턴스를 로드밸런스 Target Group 설정에 들어가서 추가해 줍니다.\n잠시후 헬스체크를 확인했을 때 Target Status와 Target Response가 위와 같이 나타나면 연결이 완료되었습니다.\n그리고 로드밸런스의 접속정보를 도메인으로 API요청을 보내면 정상적으로 작동하는 것을 확인할 수 있습니다. (공인 IP 주소로 접근해도 됩니다.)\nNCP를 이용한 인프라 구성하기 2편. Jenkins와 SourceDeploy을 이용한 CI/CD와 무중단 배포 구성\n","date":"2024년 9월 25일","externalUrl":null,"permalink":"/posts/ncp-infra-auto-scaling-load-balancer/","section":"Posts","summary":"NCP에서 Auto Scaling과 Load Balancer를 구성하여 CI/CD와 무중단 배포 인프라를 구축하는 과정을 정리합니다.","title":"NCP(Naver Cloud Platform) 를 이용한 인프라 구성하기 1편. Auto Scaling과 Load Balancer 구성","type":"posts"},{"content":" 핵심 요약 # H2와 MySQL은 JSON 타입 데이터를 JDBC 드라이버 레벨에서 서로 다른 Java 타입으로 매핑합니다. H2는 byte[], MySQL은 String입니다. 이 차이 때문에 MySQL 기준으로 작성한 String \u0026lt;-\u0026gt;객체 Converter가 H2 테스트 환경에서 동작하지 않습니다.\n문제 상황 # 프로젝트를 테스트하는 도중 아래의 그림과 같이 컨버터를 찾을 수 없다는 에러사항이 발생했습니다.\n저는 현재 Spring Data JDBC를 사용하는 중이며, 혹시나 컨버터가 제대로 등록되지 않았는지 체크해 보았지만 컨버터는 제대로 등록되어 있었습니다.\n상황을 조금 더 구체적으로 파악하기 위해 Data JPA, Data JDBC, H2, MySQL을 번갈아 가며 살펴봤더니, 이러한 에러는 MySQL로 개발할 때는 나타나지 않았으며 H2와 연결된 테스트 코드 실행 시에만 나타났습니다.\nData JPA 를 사용하는 경우\nError attempting to apply AttributeConverter … Caused by: java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.MismatchedInputException\nData JDBC 를 사용하는 경우\nNo converter found capable of converting from type [byte[]] to type ~~\n와 같은 에러 메시지가 나타납니다.\n예시 상황을 재구성하기 위해 아래와 같은 코드를 구성해 보았습니다.\npublic class Member { @Id @Column(\u0026#34;MEMBER_ID\u0026#34;) private Long id; private String username; private Address address; public Member(String username, Address address) { this.username = username; this.address = address; } } // Data JPA의 컨버터와 Data JDBC의 컨버터에서 // Jackson 라이브러리의 ObjectMapper.writeValueAsString()를 이용하여 // Address \u0026lt;-\u0026gt; String 컨버팅 public class Address { private String street; private String city; private String state; private String zip; public Address(String street, String city, String state, String zip) { this.street = street; this.city = city; this.state = state; this.zip = zip; } } -- Address 객체를 JSON 타입으로 저장. CREATE TABLE MEMBER ( MEMBER_ID BIGINT PRIMARY KEY AUTO_INCREMENT, USERNAME VARCHAR(255) NOT NULL, ADDRESS JSON ); 결론부터 이야기하면 이는 H2 데이터베이스에서 JSON 타입을 처리하는 방식이 MySQL 과 달라서 나타나는 에러였습니다.\nH2와 MySQL의 JSON 타입 처리 방식 # H2의 JSON 타입 처리 # -- String 리터럴로 저장 → escaped string(JSON String) 형태로 저장됨 INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(\u0026#39;테스트이름\u0026#39;, \u0026#39;{\u0026#34;city\u0026#34;:\u0026#34;seoul\u0026#34;, \u0026#34;street\u0026#34;:\u0026#34;nowon\u0026#34;}\u0026#39;); -- FORMAT JSON 명시 → 깔끔한 JSON Object 형태로 저장됨 INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(\u0026#39;테스트이름\u0026#39;, JSON \u0026#39;{\u0026#34;city\u0026#34;:\u0026#34;seoul\u0026#34;, \u0026#34;street\u0026#34;:\u0026#34;nowon\u0026#34;}\u0026#39;); H2는 위와 같이 첫번째 쿼리를 실행하는 것으로 MEMBER_ID 3번 row와 같이 ADDRESS 컬럼에 escaped string 모양의 JSON이 저장됩니다.\n(지금부터는 MEMBER_ID를 생략하고 편하게 1, 2, 3번이라 하겠습니다.)\n그리고 두번째 쿼리와 같이 JSON이라는 포맷을 지정해주면 1, 2번 row와 같이 깔끔한 형식으로 저장이 됩니다.\n3번과 같은 escaped string JSON 형식을 H2 에서는 JSON String 이라고 부릅니다.\nH2에서는 이 동작을 다음과 같이 설명하고 있습니다.\nFORMAT JSON 없이 String을 저장하면 문자열 리터럴 안에 JSON 문자열이 들어있는 형식으로 암묵적으로 변환됩니다. (\u0026rsquo;text\u0026rsquo; -\u0026gt; JSON \u0026lsquo;\u0026ldquo;text\u0026rdquo;\u0026rsquo;).\n만약 JSON 텍스트를 포함한 String 리터럴을 전달하려면, 명시적으로 FORMAT JSON을 표시해야 합니다.\nINSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?); -- 2번째 바인딩 변수에 FORMAT JSON 명시 INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON); 그리고 Data JPA, Data JDBC를 사용하여 데이터 접근 메서드를 실행하면 첫번째 쿼리와 같이 바인딩 변수 형식으로 쿼리가 날라가는 것을 본 적 있으실 겁니다.\n첫번째 쿼리는 3번 row와 같이 escaped string 형태의 JSON 데이터(JSON String)로 저장됩니다.\n두번째 쿼리는 바인딩 변수 뒤에 FORMAT JSON을 명시했습니다. 이 경우 1, 2번 row처럼 깔끔하게 저장됩니다.\n추가로 H2 문서에는 아래와 같이 방법이 적혀있습니다.\nTo set a JSON value with java.lang.String in a PreparedStatement use a FORMAT JSON data format (INSERT INTO TEST(ID, DATA) VALUES (?, ? FORMAT JSON)) or use setObject(parameter, jsonText, H2Type.JSON) instead of setString().\n— H2 Documentation\n요약하면, 순수 JDBC의 PreparedStatement를 직접 사용하는 경우라면 아래 두 방법으로 이 문제를 해결할 수 있습니다.\n// 방법 1: SQL에 FORMAT JSON 명시 PreparedStatement ps = conn.prepareStatement( \u0026#34;INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ? FORMAT JSON)\u0026#34; ); ps.setString(2, jsonString); // 방법 2: setObject()에 타입 힌트 지정 PreparedStatement ps = conn.prepareStatement( \u0026#34;INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?)\u0026#34; ); ps.setObject(2, jsonString, H2Type.JSON); 하지만 Spring Data JPA나 Data JDBC는 PreparedStatement를 프레임워크가 내부적으로 생성하고 파라미터 바인딩까지 관리하기 때문에, 개발자가 특정 파라미터에 대해 FORMAT JSON이나 setObject()의 타입 힌트를 직접 지정할 수 없습니다. 결국 Spring Data 환경에서는 위의 두 방법 모두 적용하기 어렵습니다.\n아래의 SQL 로그를 보시면 Spring Data는 Address 값을 java.lang.String 타입으로 바인딩하고 있습니다. 이 때문에 H2에서는 escaped string 형태의 JSON String으로 저장됩니다.\n그리고 이 데이터를 SELECT하여 가져와보면 아래와 같이 escaped string 리터럴이 또 한번 감싸진 것을 볼 수 있습니다.\nMySQL의 JSON 타입 처리 # INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(\u0026#39;테스트이름\u0026#39;, \u0026#39;{\u0026#34;city\u0026#34;:\u0026#34;seoul\u0026#34;, \u0026#34;street\u0026#34;:\u0026#34;nowon\u0026#34;}\u0026#39;); INSERT INTO MEMBER(USERNAME, ADDRESS) VALUES(?, ?); MySQL 에서 JSON 타입을 다루는 방법은 많지만, 위의 쿼리들과 같이 특정한 포맷 형식을 지정해주지 않더라도 아래의 이미지처럼 Escaped String 형식이 아닌 깔끔한 JSON 형태로 DB에 저장됩니다.\n따라서 Spring Data 기술을 사용해도 문제가 없습니다.\nbyte[] vs String — 저장 방식이 다른 이유 # 먼저, escaped 처리된 데이터도 JSON 타입이 맞습니다. 실제로 JSON이 아닌 일반적인 String(varchar)은 유효한 JSON이 아니어서 JSON 타입으로 지정한 H2의 컬럼에 INSERT가 불가능합니다.\nH2에서는 JSON을 byte[] 또는 String으로 다룹니다. 그리고 저장된 결과에도 차이가 나는데, byte[] 로 H2 JSON 컬럼에 저장하면 JSON Object(escaped 되지 않은 깔끔한 형태)가 저장됩니다.\n하지만, Address 객체와 같이 Jackson 라이브러리의 ObjectMapper.writeValueAsString()을 이용해서 String으로 변환하여 JSON 텍스트 형식의 리터럴을 H2 JSON 컬럼에 저장한다면 String 리터럴에 escaped 처리가 되어 저장됩니다.\n이 차이가 SELECT 시점에서 Converter 에러의 직접적인 원인이 됩니다. 이제 Data JDBC와 Data JPA 각각에서 어떤 방식으로 문제가 발생하는지 살펴보겠습니다.\nData JDBC에서의 에러 원인과 해결 # 에러 원인 — H2 드라이버의 byte[] 매핑 # Data JDBC는 컨버터(DB source value -\u0026gt; Address)를 선택할 때, 각 DB Driver에서 제공하는 타입의 포맷을 가지고 컨버터를 선택합니다. 이게 무슨 말이냐면, H2는 저장된 값과 Java Object의 매핑 방법에서 JSON 타입은 byte[] 타입으로 매핑합니다.\nH2 드라이버 내부에 있는 ValueToObjectConverter를 보면, JSON 타입은 value.getBytes()를 호출하여 byte[] 타입으로 반환하는 것을 확인할 수 있습니다.\n그리고 Data JDBC는 쿼리 실행 후 얻은 결과(H2 ResultSet)에서 엔티티로 변환하고자 할 때 Converter가 필요하고, 이때 적절한 Converter의 타입을 찾는 데 있어서 H2 ResultSet에 저장된 값의 타입을 힌트로 얻습니다. H2는 이 과정에서 JSON 값인 경우 byte[] 타입을 전달합니다.\n그림으로 대략 표현하면 아래와 같은 흐름입니다. (실제보다 많이 단순화한 것이라 이해를 위해 간단히 참고바랍니다.)\n그래서 아래와 같은 String -\u0026gt; Address Converter를 등록했더라도, 그것을 찾을 수 없다는 에러가 나타납니다.\n// Data JDBC의 컨버터 @Slf4j @RequiredArgsConstructor @ReadingConverter public class JsonToAddressConverter implements Converter\u0026lt;String, Address\u0026gt; { private final ObjectMapper objectMapper; @Override public Address convert(String source) { try { return objectMapper.readValue(source, Address.class); } catch (IOException e) { log.info(\u0026#34;JSON 타입을 Address 객체로 변경에 실패했습니다.\u0026#34;); throw new RuntimeException(e); } } } H2는 JSON 데이터를 byte[]로 매핑하기 때문에 INSERT 시 String 타입이 아닌 byte[]로 매핑하면 JSON 타입의 읽기와 쓰기가 모두 정상적으로 동작합니다. 하지만 이 경우 MySQL에서는 INSERT할 때 에러가 나타납니다. 그리고 H2만을 위해서 JSON 타입으로 다루어질 객체를 byte[]로 바꾸는 것은 적절한 해결책이 되지 못합니다.\nMySQL에서는 에러가 없는 이유 # MySQL은 이 과정에서 JSON 값인 경우 String 타입을 매핑합니다. 따라서 예외가 발생하지 않습니다. (추가로 INSERT할 때도 String 타입으로 넣어야 합니다. 그래서 byte[] 타입은 에러가 발생합니다.)\n해결 방법 # 아직 Data JDBC에서 이 문제가 해결되지는 못한 것 같습니다. 개인적으로 오픈소스에 기여하고 싶은 욕심이 생겨서 원인을 찾아보았지만,\n데이터를 INSERT할 때 H2는 JSON 포맷을 지정해줘야 합니다. 아니면 byte[] 타입으로 컨버팅하여 저장해야 하는데 → MySQL은 에러 발생 H2, MySQL Driver에서 JSON 타입을 서로 다른 포맷으로 전달합니다 → 매칭해야 하는 컨버터의 타입이 달라집니다. H2는 byte[]와 객체간 컨버팅, MySQL은 String과 객체간 컨버팅 전략을 설정해야 합니다. 이러한 여러 DB 엔진의 타입 변환 차이를 범용적으로 처리하기 위해서는 단순히 소스 코드 몇 줄 수정한다고 해결될 것이 아니었습니다.\n그래서 현재 선택할 수 있는 최선의 방법은 3가지가 있습니다.\n먼저, Spring Boot Profile을 분리하여 테스트 코드에서는 H2 전용 컨버터를 등록하는 방법을 적용합니다. 이 컨버터는 byte[] ↔ Address를 변환합니다.\n두번째는, H2와 MySQL의 JSON 타입으로 설정한 필드를 VARCHAR 또는 TEXT 타입으로 변경합니다. TEXT 타입으로 지정한 컬럼에 JSON 형태의 문자열을 저장하는 것으로 위의 이슈를 해결할 수 있습니다. 다만 이 방법은 DB 레벨의 JSON 유효성 검증을 잃게 됩니다. JSON_EXTRACT() 같은 JSON 함수도 사용할 수 없게 되지만, 애플리케이션에서 JSON 데이터에 대한 DB 쿼리(검색, 필터링 등)를 수행할 필요가 없고 단순히 직렬화된 객체를 저장/조회하는 용도라면 이 트레이드오프는 충분히 감수할 수 있습니다.\n세번째로는 Docker 컨테이너 위에서 테스트 코드를 수행하는 방법도 있습니다. TestContainers를 이용하면 Docker 컨테이너에서 MySQL DB 서버를 띄우고 그 위에서 테스트 코드가 수행됩니다. 이 방법은 운영환경과 테스트 환경을 일치시킬 수 있다는 점에서 장점이 되지만, 테스트 코드를 한번 수행하는 데 있어서 시간이 오래걸린다는 단점이 있습니다.\n저의 경우는 두번째 방법을 선택했는데, 첫번째 방법인 Profile을 분리하여 H2용의 컨버터를 생성하는 방법은 추후 관리가 복잡해질 우려가 있다고 판단했습니다. 만약 DB에 JSON으로 변환해서 관리해야 할 클래스가 많아질수록 그 클래스들에 대해 모두 별도의 테스트용 컨버터를 준비해야 합니다. 프로젝트에서 JSON 컬럼에 대해 DB 레벨의 쿼리가 필요하지 않았기 때문에, 저는 H2와 MySQL 모두에게서 차이를 타협할 수 있는 TEXT 타입으로 저장하는 두번째 방법을 선택했습니다.\nData JPA에서의 에러 원인과 해결 # 에러 원인 — escaped string 역직렬화 실패 # JPA는 컨버터를 선택할 때 Data JDBC와 다른 방법으로 작동합니다. Data JDBC는 연결된 DB의 Driver에게 타입을 묻는 반면, JPA는 변환하고자 하는 타입을 미리 가지고 있습니다. 그리고 그 타입에 맞게 ResultSet에서 값을 추출합니다.\n그림으로 간단히 표현하면 아래와 같습니다.\n이러한 이유로 Data JPA는 Data JDBC와 다르게 컨버터를 찾을 수 없다는 에러가 나타나지 않습니다. JPA는 AttributeConverter\u0026lt;Address, String\u0026gt;에서 DB 컬럼 타입이 String임을 이미 알고 있으므로, ResultSet에서 getString()을 호출하여 값을 추출합니다.\n하지만 DB로부터 읽어온 값을 String 값으로 변환 후 아래와 같은 컨버터를 사용하게 되는데,\n// Data JPA의 컨버터 @Slf4j @RequiredArgsConstructor @Converter(autoApply = true) public class AddressConverter implements AttributeConverter\u0026lt;Address, String\u0026gt; { private final ObjectMapper objectMapper; @Override public String convertToDatabaseColumn(Address address) { try { return objectMapper.writeValueAsString(address); } catch (Exception e) { log.info(\u0026#34;Address 타입을 Json 으로 변환할 수 없습니다.\u0026#34;); throw new RuntimeException(e); } } @Override public Address convertToEntityAttribute(String source) { try { return objectMapper.readValue(source, Address.class); } catch (Exception e) { log.info(\u0026#34;Json 타입을 Address 타입으로 변환할 수 없습니다.\u0026#34;); throw new RuntimeException(e); } } } 이 과정에서 DB에서 읽어온 String 타입의 value는 아래와 같습니다.\nJackson 라이브러리의 ObjectMapper은 escaped 처리가 된 문자열을 한번 더 감싼 String 리터럴을 Address 객체로 변환하지 못합니다.\n따라서 Address 객체를 생성하는 과정에서 MismatchedInputException 에러 메시지가 출력되었습니다.\n추가적으로, JPA는 1, 2번 row와 같이 깔끔하게 저장되어 있는 JSON 데이터를 컨버터의 source 타입을 String으로 하던 byte[]로 하던 모두 읽어올 수 있습니다. JPA의 JdbcValueExtractor가 Converter에 선언된 타입에 따라 getString() 또는 getBytes()를 적절히 호출하고, H2 드라이버가 JSON 데이터에 대해 두 메서드 모두를 지원하기 때문입니다.\n하지만 Data JDBC는 1, 2번 row와 같이 깔끔하게 저장된 JSON 데이터조차 Address ↔ String 컨버팅 전략인 경우 byte[]과의 컨버팅 전략이 아니란 이유로 컨버팅할 수 없습니다.\n해결 방법 — hypersistence-utils # JPA에서는 2021년에 MySQL, Oracle, PostgreSQL, H2와 같은 DB를 사용할 때 JSON 타입 변환을 가능하게 한 hypersistence-utils를 Vlad Mihalcea님이 만들어 주셨습니다.\n@Getter @NoArgsConstructor @ToString @Table(name = \u0026#34;MEMBER\u0026#34;) @Entity public class JpaMember { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = \u0026#34;MEMBER_ID\u0026#34;) private Long id; private String username; @Type(JsonType.class) // 이 애너테이션 하나 달아주면 H2에서도 JSON 타입으로 저장 private Address address; public JpaMember(String username, Address address) { this.username = username; this.address = address; } } @Type(JsonType.class)를 선언하면 Hibernate가 JsonType 내부에서 직렬화/역직렬화를 처리하므로, 별도의 @Convert(converter = AddressConverter.class)를 함께 사용할 필요가 없습니다. JsonType이 각 DB 엔진에 맞는 저장/조회 전략을 내부적으로 분기 처리하기 때문입니다.\n아래의 링크를 참고해 주세요!\nHow to map a JSON column with H2, JPA, and Hibernate\nhypersistence-utils 내부 분해 # 직접 디버깅으로 JsonType의 내부를 들여다보니, 핵심은 JsonJdbcTypeDescriptor 내부의 resolveJdbcTypeDescriptor() 메서드였습니다.\n이 메서드는 연결된 DB의 방언(Dialect)을 감지하여 JDBC 타입 처리 전략을 분기합니다. H2Dialect인 경우 JsonBytesJdbcTypeDescriptor.INSTANCE를 반환하는데, 이것이 앞서 확인한 \u0026ldquo;H2는 byte[]로 저장해야 escaped string 없이 깔끔하게 저장된다\u0026quot;는 원리를 그대로 적용한 것입니다.\n결국 hypersistence-utils는 DB 방언을 감지해서 H2라면 byte[] 기반 전략을, 그 외 DB라면 각 환경에 맞는 전략을 선택하는 방식으로 DB 엔진마다 다른 JSON 처리 차이를 내부에서 처리하고 있었습니다.\n","date":"2024년 8월 21일","externalUrl":null,"permalink":"/posts/h2-json-column-converter-error/","section":"Posts","summary":"H2와 MySQL의 JSON 타입 매핑 차이(byte[] vs String)로 인한 Converter 에러 원인과 해결 방법을 정리합니다.","title":"[H2 Database] JSON 컬럼에 대한 Converter 에러 상황","type":"posts"},{"content":"","date":"2024년 8월 21일","externalUrl":null,"permalink":"/tags/converter/","section":"Tags","summary":"","title":"Converter","type":"tags"},{"content":"","date":"2024년 8월 21일","externalUrl":null,"permalink":"/tags/spring-data/","section":"Tags","summary":"","title":"Spring Data","type":"tags"},{"content":"지난 프로젝트를 진행할 때, 회원가입과 로그인 처리를 위하여 Firebase을 이용한 적이 있습니다.\n안드로이드 앱에서 인증을 완료하면 백엔드는 로그인과 회원가입을 간단히 처리할 수 있고, 이 과정에서 Spring의 Interceptor와 ArgumentResolver을 이용하여 인증을 처리했습니다.\n구현 요구사항 # 백엔드에서 로그인이나 회원가입 API를 만들 필요가 없었습니다.\n프론트엔드에서 Firebase를 이용하여 이메일 또는 구글 로그인으로 인증을 진행하고, 그 결과로 얻은 idToken만 Authorization 헤더에 담아서 백엔드 서버에 요청을 보냅니다.\n그러면 서버에서 토큰을 추출 후, Firebase SDK을 이용하여 유효한 토큰인지 확인합니다. 토큰이 유효하면 이후 DB에 요청을 보내서 해당 회원이 있으면 회원을 반환(로그인), 없으면 회원 생성 후 반환(자동으로 회원가입) 합니다.\n그리고 이후 로그인된 사용자 정보가 필요하면 ArgumentResolver를 통해 주입했습니다.\n여기서 핵심인 부분을 코드로 설명하면\nRepository의 find메서드로 Optional\u0026lt;회원\u0026gt; 객체를 반환하고, 없으면 orElseGet()을 통해서 새로운 회원객체의 생성과 저장 그리고 반환 로직을 수행합니다.\n이 로직이 하나의 과정으로 묶여(원자화) 자동으로 회원가입이 이루어지고 로그인 처리가 되기를 원했습니다.\n하지만 구현 도중 회원가입이 중복으로 처리되어 동일한 회원이 DB에 중복해서 저장되는 문제가 발생했고 이 문제를 트랜잭션 처리를 하지 않아 발생한 것으로 생각했습니다.\n그러나 트랜잭션 처리를 했음에도 여전히 중복 저장 문제는 발생했습니다. 분명 같은 트랜잭션 내에서 조회와 저장을 수행했는데도 왜 이 과정이 원자성을 보장받지 못했는지 그리고 어떤 이유 때문인지 살펴보도록 하겠습니다.\n시스템 구조 — Interceptor를 통한 Firebase 토큰 검증 # @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = \u0026#34;member_id\u0026#34;) private Long id; @Column private String username; @Column(nullable = false) private String email; @Column private String uid; } Member 엔티티는 위와 같이 구성했습니다.\nuid 필드는 Firebase 가 사용자를 식별하기 위해 제공하는 id 값입니다. 프로젝트의 DB에서도 uid 를 통해 각 멤버를 구분합니다.\npublic class FirebaseTokenInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 헤더값 추출 String token = getAuthorizationToken(request); // 토큰 검증 FirebaseToken decodedToken = decodeToken(token); // 검증된 토큰을 HttpServletRequest 객체에 저장함. request.setAttribute(\u0026#34;decodedToken\u0026#34;, decodedToken); return true; } // 요청에서 Authorization 헤더 값(idToken)을 추출합니다. private static String getAuthorizationToken(HttpServletRequest request) { String header = request.getHeader(\u0026#34;Authorization\u0026#34;); if (header == null || !header.startsWith(\u0026#34;Bearer \u0026#34;)) { throw new IllegalArgumentException(\u0026#34;No Token or Invalid Token.\u0026#34;); } return header.split(\u0026#34; \u0026#34;)[1]; } // firebase 서버에 토큰을 보내어 검증합니다. private static FirebaseToken decodeToken(String token) { FirebaseToken decodedToken; try{ // idToken 검증. decodedToken = FirebaseAuth.getInstance().verifyIdToken(token); } catch (FirebaseAuthException e) { throw new IllegalArgumentException(\u0026#34;Invalid Token.\u0026#34;); } return decodedToken; // 검증된 토큰의 결과 반환. } } Interceptor에서 Firebase SDK를 이용하여 Authorization헤더에 있는 토큰의 유효성을 검증합니다.\n검증된 FirebaseToken타입의 decodedToken 에는 유저의 정보가 들어있고 그 값을 HttpServletRequest 에 저장합니다. HttpServletRequest 에 저장한 decodedToken 은 ArgumentResolver 에서 필요하면 꺼내쓰기 위해 저장하였습니다.\n@RequiredArgsConstructor public class LoginMemberArgResolver implements HandlerMethodArgumentResolver { private final MemberRepository memberRepository; // @Login 애너테이션이 있으면 값을 주입한다. @Override public boolean supportsParameter(MethodParameter parameter) { boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType()); return hasLoginAnnotation \u0026amp;\u0026amp; hasMemberType; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); // request 객체에서 decodedToken 획득 FirebaseToken decodedToken = (FirebaseToken) request.getAttribute(\u0026#34;decodedToken\u0026#34;); // AuthService를 호출 return authService.joinAndLogin(decodedToken); } } @RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uid 로 멤버를 조회하고 DB에 등록되어 있지 않은 uid 라면, // 새로운 멤버를 만들어 자동 회원가입이 됩니다. return memberRepository.findByUid(uid) .orElseGet(() -\u0026gt; memberRepository.save(Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid) .build())); } } // Controller // 파라미터에 @Login 애너테이션이 있으니 // HandlerMethodArgumentResolver을 이용하여 Member을 초기화한다. @PostMapping(\u0026#34;/post/new\u0026#34;) public Post createPost(@RequestBody Post post, @Login Member member) { ... } ArgumentResolver 은 위와 같이 컨트롤러의 매개변수에 @Login 애너테이션이 있으면 로그인된 멤버를 주입하도록 했습니다.\ndecodedToken에는 Firebase에서 관리하는 유저식별 Id(앞서 설명한 uid) 정보가 있습니다.\n이것을 이용하여 프로젝트의 DB에서 사용자를 조회하고 반환. 만약, 해당하는 사용자가 없다면 새로운 Member 엔티티를 생성하여 DB 테이블에 멤버를 저장하고 반환합니다.\n즉, 사용자가 애플리케이션을 “처음\u0026quot; 사용할 때 회원가입이 자동으로 이루어집니다.\n애플리케이션은 모든 요청에 로그인 된 멤버만 사용할 수 있다는 요구사항을 가집니다. 따라서 모든 API 요청에는 Authorization 헤더값이 있습니다.\n문제 발생 — 동일 회원이 중복 저장되다 # 안드로이드 client 와 연결해서 애플리케이션을 실제로 사용하는 도중 때때로 로그인이 제대로 안되는 문제가 발생했습니다.\n왜 그런지 요청 로그를 모두 살펴보았을 때 위와 같은 로그가 나타났습니다.\n(이미지는 과거 프로젝트의 로그라서 현재의 구성과 조금 다릅니다!)\n문제의 상황은\n“한명의 사용자가 첫 애플리케이션 실행에서 여러개의 요청을 동시에 발생시켰을 때” 였습니다.\n그리고 그 결과\nDB 에 멤버가 존재하면 그대로 반환하고 없으면 해당 멤버 엔티티를 생성, DB저장 후 반환한다.\n라는 작업이 하나의 작업으로 처리되지 못했기 때문에 데이터베이스에는 아래와 같이 2개의 동일한 uid를 가진 멤버가 저장되었습니다.\n이후 사용자가 새로운 요청을 보내면 MemberRepository의 findByUid() 메서드는 한명의 멤버가 아닌, 2개의 멤버 레코드를 조회하여 로그인이 제대로 안되는 에러상황이 발생했습니다.\n해결 시도 — @Transactional, synchronized # 아래의 코드는 위에서 보여드린 AuthService 클래스입니다.\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // memberRepository에서 uid 로 멤버를 조회하고 DB에 등록되어 있지 않은 uid 라면, // 새로운 멤버를 만들어 자동 회원가입이 됩니다. return memberRepository.findByUid(uid) .orElseGet(() -\u0026gt; memberRepository.save(Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build())); } } Spring 프레임워크는 하나의 request 요청을 하나의 스레드가 담당합니다. 따라서 앞선 스크린 샷에 나타난 2개의 요청은 서로 다른 스레드가 처리를 합니다.\n2개의 요청이 모두 동일한 클라이언트의 요청인 경우 예를 들겠습니다.\n첫 번째 스레드가 memberRepository.findByUid() 메서드를 통해 데이터를 조회하는 동안 다른 스레드도 memberRepository.findByUid() 메서드를 실행하고 동일한 uid로 findByUid() 을 수행할 수 있습니다.\n이를 방지하기 위해서는\nDB 에 멤버가 존재하면 그대로 반환하고 없으면 해당 멤버 엔티티를 생성, DB저장 후 반환한다.\n과정을 하나의 트랜잭션으로 묶고 uid컬럼은 멤버를 유일하게 식별하기위해 Uique 제약이 필요하다는 판단이 들었습니다.\n그렇다면 @Transactional 애너테이션을 붙이면 되겠구나?\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // @Transactional 추가! @Transactional public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uid 로 멤버를 조회하고 DB에 등록되어 있지 않은 uid 라면, // 새로운 멤버를 만들어 자동 회원가입이 됩니다. return memberRepository.findByUid(uid) .orElseGet(() -\u0026gt; memberRepository.save(Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build())); } } 하지만 동시성을 테스트해보니 여전히 똑같은 문제가 해결되지 않았습니다.\n트랜잭션이 먼저 원자적으로 처리되는 것을 확인하기 위해 Unique 제약조건은 아직 추가하지 않았습니다. Unique 제약 조건이 없더라도 원자적으로 처리된다면 중복해서 저장되지 않을 것이라 생각했습니다.\n트랜잭션이 동작하는 것은 분명히 확인했지만, 문제가 해결되지 않았습니다.\n아! 트랜잭션이 원자성을 제공하지만 이것이 하나의 스레드가 findByUid() 를 호출할 때, 다른 스레드가 findByUid() 호출을 막는 것은 아니였지!\n라는 생각도 들어 synchronized 키워드도 추가해 보았습니다.\n그리고 지금부터 로그를 찍어보며 자세히 살펴보기 위해 findByUid() ~ orElseGet() 메서드를 아래와 같이 Optional로 풀어서 작성하겠습니다.\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // syschronized 적용 @Transactional public synchronized Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uid 로 멤버를 조회하고 DB에 등록되어 있지 않은 uid 라면, // 새로운 멤버를 만들어 자동 회원가입이 됩니다. Optional\u0026lt;Member\u0026gt; memberOptional = memberRepository.findByUid(uid); if (memberOptional.isPresent()) { log.info(\u0026#34;기존 회원입니다.\u0026#34;); return memberOptional.get(); } log.info(\u0026#34;새로운 회원입니다.\u0026#34;); Member member = Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build(); memberRepository.save(member); return member; } } synchronized 추가해도 여전히 해결되지 않다 # MySQL 은 레코드 기반으로 락(lock)을 거는데, 레코드가 없으면 락 자체를 못거는 것이 아닌가? 쉽게 말해, 첫 findByUid() 에 해당하는 레코드가 없으니 트랜잭션을 시작하더라도 락을 걸거나 레코드의 변경 로그를 저장할 대상 자체가 없는 것이 아닌가?\nfindByUid() 메서드를 호출할 때 트랜잭션이 시작한다고 생각했는데… 로그가 찍힌 것을 보니 무언가 이상하다… 그러면 트랜잭션의 시작이 어디지? 5개의 요청을 동시에 보냈을 때, 트랜잭션이 동시에 시작하는건가? 그렇다면… JPA 는 DB의 기본 트랜잭션 Isolation level을 선택하고 나는 MySQL 을 사용하니까 Repeatable Read 에 해당하는 Isolation Level 이 기본으로 적용되었을 것이고…\n트랜잭션의 시작 시점이 AuthService 의 joinAndLogin() 이 아니라 AOP 프록시의 invoke() 에 의해 트랜잭션이 실행된다면… 실제 joinAndLogin() 메서드가 실행되기 전에 5개의 요청이 모두 동시에 진행되다가 synchronized 키워드에 의해 실제 객체의 메서드만 순차적으로 실행된 것인가…?\n원인 분석 # 애플리케이션 원자성과 트랜잭션 원자성은 다르다 # 이 말은 곧, Java 애플리케이션 코드의 원자성을 보장하지는 않습니다.\nDB 에 멤버가 존재하면 그대로 반환하고 없으면 해당 멤버 엔티티를 생성, DB저장 후 반환한다.\n위의 Java 코드의 메서드를 synchronized 키워드로 원자화하는 것은 DB 트랜잭션의 원자성과는 별개의 과정입니다.\nsynchronized 없는 경우, 아래와 같은 상황이 발생합니다.\nMySQL의 Repeatable Read Isolation Level에서는 MVCC(Multi Version Concurrency Control)를 이용합니다. 이때 consistent read view(스냅샷)는 트랜잭션이 시작(5번)되는 시점이 아니라, 해당 트랜잭션 내에서 첫 번째 SELECT가 실행되는 시점(8번의 findByUid()) 에 생성됩니다. 단, START TRANSACTION WITH CONSISTENT SNAPSHOT 구문을 사용하면 트랜잭션 시작과 동시에 스냅샷을 생성할 수 있지만, Spring의 @Transactional은 이 구문을 사용하지 않습니다.\n5개의 트랜잭션이 거의 동시에 시작(5번)하고, 각 트랜잭션의 첫 번째 SELECT인 findByUid()(8번)도 거의 동시에 실행됩니다. 스냅샷은 이 첫 번째 SELECT 시점에 생성되므로, 아직 어떤 트랜잭션도 INSERT를 수행하지 않은 상태에서 모든 트랜잭션이 동일하게 “해당하는 레코드가 존재하지 않습니다” 라는 결과를 받게 됩니다.\n따라서 findByUid() 메서드의 결과는 “해당하는 레코드가 존재하지 않습니다” 라는 동일한 결과를 나타낼 것입니다.\nMySQL의 MVCC는 하나의 트랜잭션이 먼저 조회 후 멤버 객체를 저장하고 커밋을 하더라도, 다른 트랜잭션들의 결과는 본인의 첫 SELECT 시점 기준으로 그 이전에 기록된 데이터들의 스냅샷을 바라봅니다.\nsynchronized 적용 시에도 MySQL의 MVCC와 트랜잭션 고립 레벨로 인해 동일한 문제가 발생합니다.\n@Transactional에 의해 트랜잭션의 시작과 커밋은 프록시 객체에서 수행되지만, synchronized 키워드는 프록시의 메서드에 상속되지 않고 실제 joinAndLogin() 메서드에만 적용됩니다.\n따라서 스레드1이 실제 joinAndLogin() 메서드의 임계영역(critical section)을 빠져나오면, 프록시 객체에서 트랜잭션을 커밋하기 전에 또 다른 스레드가 실제 joinAndLogin() 메서드에 진입할 수 있습니다.\n여기서 두 트랜잭션의 BEGIN 순서나 synchronized 진입 시도 순서는 문제 발생 여부에 영향을 주지 않습니다. 스냅샷은 BEGIN이 아닌 첫 번째 SELECT 시점에 생성되기 때문에, T2가 T1보다 먼저 BEGIN하거나 먼저 synchronized 진입을 시도하더라도 결과는 동일합니다. 중요한 것은 T2의 첫 SELECT가 T1의 COMMIT 이전에 실행되는가 입니다.\n트랜잭션2의 SELECT 시점에 트랜잭션1의 INSERT는 아직 커밋되지 않은 데이터입니다. 커밋되지 않은 데이터는 Repeatable Read는 물론 Read Committed에서도 보이지 않습니다. Read Committed는 “커밋된 데이터를 읽는다”는 격리 수준이지, 커밋 전 데이터를 읽는 것이 아니기 때문입니다.\n만약 그럼에도 데이터가 보이는 것을 확인해 보고 싶다면, 제일 낮은 트랜잭션 고립 레벨인 Read Uncommitted Isolation Level을 적용하면 됩니다. 이 경우 커밋하지 않았더라도 다른 트랜잭션에서 저장된 데이터를 바로 볼 수 있습니다. 다만 dirty read로 인해 롤백된 데이터를 읽는 위험이 있으므로 적절한 해결책은 아닙니다.\n또한 synchronized 키워드는 단일 JVM 인스턴스 내에서만 유효합니다. 서버를 2대 이상 운영하는 분산 환경에서는 synchronized로 동시성을 제어할 수 없으므로, 이 방법 자체가 근본적인 해결책이 될 수 없습니다.\nDB Lock으로는 해결할 수 없는가? # MySQL 의 InnoDB 는 조회 시 기본적으로 락(Lock)이 아닌 MVCC 로 Repeatable Read 을 처리하지만 아래와 같이 락을 걸 수 있습니다.\nSELECT * FROM MEMBER WHERE uid=\u0026#34;ASDW12SD3\u0026#34; FOR UPDATE; // 쓰기 잠금 SELECT * FROM MEMBER WHERE uid=\u0026#34;ASDW12SD3\u0026#34; FOR SHARE; // 읽기 잠금 따라서 현재 트랜잭션에서 락을 걸고 조회를 할 때, 다른 트랜잭션에서 임의의 데이터가 추가되지 않도록 MySQL의 레코드락, 넥스트키락등이 적용될 수 있습니다. InnoDB의 락은 인덱스를 기준으로 동작합니다. uid 컬럼에 인덱스(또는 Unique 제약)가 없는 상태에서 SELECT ... FOR UPDATE를 실행하면, 의도한 uid 값만 정밀하게 잠그는 것이 어렵습니다. 참고로 InnoDB는 레코드가 존재하지 않더라도 gap lock을 통해 해당 범위에 대한 삽입을 차단할 수 있지만, 인덱스가 없으면 테이블 전체에 락이 걸리는 등 의도하지 않은 범위가 잠길 수 있어 실용적이지 않습니다.\n해결 방법 — Unique 제약 조건과 재시도 로직 # 사실 지금까지의 과정은 모든 것이 정상적으로 동작하면서 발생한 것입니다. 단지, 프로젝트의 요구사항을 만족시키는 과정에서 애플리케이션 레벨에서 특정 로직을 하나의 과정으로 처리하려 했으나 의도한 결과가 나타나지 않았습니다. 그리고 그 과정을 단순히 @Transactional을 추가하여 해결하려 했던 것이 문제였습니다.\n약간의 테스트로 애플리케이션 레벨에서 원자성을 보장하는 것을 확인하려면, AuthService클래스에 @Transactional 애너테이션을 제거하여 Spring AOP가 적용되지 않도록하고 synchronized 키워드를 사용하여 애플리케이션 코드를 원자화하면 됩니다.\n@RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // synchronized 적용 public synchronized Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uid 로 멤버를 조회하고 DB에 등록되어 있지 않은 uid 라면, // 새로운 멤버를 만들어 자동 회원가입이 됩니다. Optional\u0026lt;Member\u0026gt; memberOptional = memberRepository.findByUid(uid); if (memberOptional.isPresent()) { log.info(\u0026#34;기존 회원입니다.\u0026#34;); return memberOptional.get(); } log.info(\u0026#34;새로운 회원입니다.\u0026#34;); Member member = Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build(); memberRepository.save(member); return member; } } 이 경우 트랜잭션을 사용하지 않고도 대부분 정상 동작하겠지만, 예기치 않은 상황에서 데이터 무결성을 보장하기 위해 트랜잭션을 사용하는게 좋습니다. 적절한 해결책은 아닙니다.\n저의 경우 간단한 해결책으로 uid 로 멤버를 구분한다는 요구사항에 맞게 데이터베이스 “uid 컬럼”에 대한 유니크(Unique) 제약 조건을 추가했습니다.\n그리고 중복 uid값을 갖는 멤버 저장이 발생하면 예외를 던지도록 처리를 했습니다. 이후 예외 발생 시 재시도 하는 방식으로 처리하여 에러상황 없이 자동 회원가입과 로그인이 정상적으로 작동하도록 했습니다.\n추가로 Unique 제약조건은 조회 시 인덱스를 활용하여 더 빠른 성능을 낼 수 있습니다!\n// build.gradle 에 추가 implementation \u0026#34;org.springframework.retry:spring-retry\u0026#34; // FirebaseApplication.java @EnableRetry // 이 부분 추가 @SpringBootApplication public class FirebaseApplication {} // AuthService.java @RequiredArgsConstructor @Service public class AuthService { private final MemberRepository memberRepository; // 요 부분도 추가! @Retryable( retryFor = {DataIntegrityViolationException.class}, backoff = @Backoff(delay = 1000) ) @Transactional public Member joinAndLogin(FirebaseToken decodedToken) throws InterruptedException { String uid = decodedToken.getUid(); // uid 로 멤버를 조회하고 DB에 등록되어 있지 않은 uid 라면, // 새로운 멤버를 만들어 자동 회원가입이 됩니다. Optional\u0026lt;Member\u0026gt; memberOptional = memberRepository.findByUid(uid); if (memberOptional.isPresent()) { log.info(\u0026#34;기존 회원입니다.\u0026#34;); return memberOptional.get(); } log.info(\u0026#34;새로운 회원입니다.\u0026#34;); Member member = Member.builder() .username(decodedToken.getName()) .email(decodedToken.getEmail()) .uid(uid).build(); memberRepository.save(member); return member; } } 이때 주의할 점은 @Retryable과 @Transactional의 AOP 적용 순서입니다. @Retryable이 @Transactional보다 외부에서 감싸야(먼저 적용되어야) 재시도할 때마다 새로운 트랜잭션이 열립니다. 순서가 반대라면 이미 롤백된 트랜잭션 안에서 재시도가 발생하므로 의미가 없습니다. @Retryable의 기본 order는 Ordered.LOWEST_PRECEDENCE이므로, 필요에 따라 @EnableRetry(order = ...)로 순서를 명시적으로 지정해야 합니다.\n진짜 해결이 된 것인가? # 재시도 로직의 문제점 # DataIntegrityViolationException은 Unique 키 위반 예외가 아닌 다른 예외의 경우도 발생할 수 있습니다.\n예를 들어 외래키 제약 조건 위반, 데이터 타입 불일치, Not NULL 제약 조건 위반 등에서도 DataIntegrityViolationException이 발생할 수 있습니다.\n이 경우 계속해서 재시도 로직이 수행될 수 있습니다.\n더 근본적인 문제 — 잘못된 프로세스 # 위의 과정은 프로세스 자체가 잘못되었습니다. ArgumentResolver는 컨트롤러 파라미터를 해석하고 주입하는 역할이지, 회원 생성과 같은 비즈니스 로직(부수 효과)을 수행하는 곳이 아닙니다. 부수 효과가 있는 로직을 파라미터 해석 단계에 넣었기 때문에, 모든 API 호출마다 회원가입이 시도되는 비정상적인 구조가 만들어진 것입니다.\n문제가 되는 상황에서 어떻게든 요구사항을 구현 레벨에서 풀려고 하다보니 불필요한 재시도 로직까지 처리하게 되었습니다.\n지금의 API 들은 회원을 검증하는 부분과 비즈니스를 수행하는 부분이 하나의 API에서 처리되고 있습니다. 따라서 검증에 실패하면 회원 검증을 재시도하고 비즈니스 로직을 수행하도록 되어있습니다.\n이 부분에서 프로세스를 분리한다면 불필요한 재시도 로직도 필요하지 않았을 것입니다.\n먼저 정상적인 케이스로 프로세스를 바꾼다면, 기존에는 자동 회원가입 또는 로그인 API가 필요없다 생각하여 만들지 않았지만 이제는 만들어야합니다.\n그리고 자동 회원가입 또는 로그인 API를 호출하고, 동시에 들어온다면 하나는 실패처리를 하면 됩니다.\n그 후 비즈니스 로직 API를 호출하면 되는 문제였습니다.\n수 개월이 지난 프로젝트에서 나타난 문제지만 해결을 위해 트랜잭션과 DB 등을 공부해 나가다 뒤늦게 근본적인 문제점까지 발견했을 땐, 너무나 비정상적인 프로세스여서 그 때 당시 제대로 만들지 못한 것에 대한 아쉬움이 남기도 합니다.\n그럼에도 의의를 두자면 애플리케이션 레벨의 원자성과 트랜잭션에 대해서 조금 더 이해하게된 계기를 만들어준 문제였습니다.\n참고자료 # 자바 ORM 표준 JPA 프로그래밍\nReal MySQL\n김영한의 스프링 DB 1, 2편\nFirebase 인증 REST API\nSpring @Transactional mistakes everyone did\n","date":"2024년 8월 1일","externalUrl":null,"permalink":"/posts/spring-transactional-atomicity/","section":"Posts","summary":"애플리케이션 레벨의 원자성과 DB 트랜잭션의 원자성 차이를 @Transactional을 통해 분석하고, 동시성 문제 해결 방법을 다룹니다.","title":"[Spring] 애플리케이션의 원자성과 트랜잭션의 원자성은 다르다 (Feat. @Transactional)","type":"posts"},{"content":"","date":"2024년 8월 1일","externalUrl":null,"permalink":"/tags/atomicity/","section":"Tags","summary":"","title":"Atomicity","type":"tags"},{"content":"","date":"2024년 8월 1일","externalUrl":null,"permalink":"/tags/transactional/","section":"Tags","summary":"","title":"Transactional","type":"tags"},{"content":"프로젝트를 진행하며 쿼리를 살펴보던 중 이상한 점을 발견한 적이 있습니다. 분명 A와 B 라는 서로 다른 엔티티의 연관관계 로딩전략을 Lazy 로 하였는데, 왜 A객체를 조회할 때 B객체도 함께 로딩하는 쿼리가 나가는 것일까요?\n~~ToOne 관계의 기본 로딩 전략 # JPA 를 사용할 경우 ~~ToOne 관계인 경우 로딩전략의 기본 값은 FetchType.EAGER 로 되어있습니다. 하지만 이런 전략은 N+1 문제와 같이 의도하지 않은 쿼리가 나가는 경우가 있어서 FetchType.LAZY 로 설정해서 사용하는 것을 권장합니다. 하지만 분명 FetchType.LAZY 로 설정하였음에도 의도하지 않은 쿼리가 발생했습니다. 도대체 왜 그런걸까요? 이 문제는 JPA가 지연로딩(LazyLoading) 을 위하여 프록시 객체를 사용하기 때문에 발생합니다.\n사전 지식 # DB와 객체의 연관관계 패러다임 불일치 # 본격적인 분석에 앞서, DB 테이블과 Java 객체의 연관관계 패러다임 불일치 문제를 짚고 넘어가겠습니다.\nDB테이블은 주 테이블이든 대상 테이블이든 어느 한쪽에라도 외래 키가 있으면 양쪽으로 조회할 수 있습니다. 이러한 관계를 양방향 관계라고 합니다. 아래 두개의 SQL 문은 모두 가능합니다.\nselect * from species s join mission m on s.species_id = m.species_id select * from mission m join species s on m.species_id = s.species_id 하지만 객체는 참조를 사용해서 다른 객체와 연관관계를 가지고 참조에 접근해서 연관된 객체를 조회합니다. Species 객체에 Mission 객체의 참조가 존재하면 Mission 객체로 접근할 수 있지만, Mission 객체에 Species 객체에 해당하는 참조가 존재하지 않으면 Mission 객체는 Species 객체로 접근할 수 없습니다. 이러한 단방향 관계에서 양쪽 객체에 서로의 참조를 설정하고 한 객체에서 외래키를 관리한다면 두 객체는 데이터베이스와 같이 양방향 관계로 표현할 수 있습니다. (실제로는 단방향 관계 2개입니다.)\nJPA 와 같은 ORM 기술은 이러한 테이블과 객체간의 연관관계 패러다임 불일치와 같은 여러 문제들을 해결하기 위해 나온 기술입니다.\n연관관계의 주인, @JoinColumn, mappedBy # JPA에서 양방향 관계를 설정할 때는 \u0026ldquo;누가 FK(외래키)를 관리할 것인가?\u0026rdquo; 를 반드시 지정해야 합니다. 이를 연관관계의 주인이라 하며, 항상 FK를 가진 쪽이 주인이 됩니다.\n아래는 이해를 돕기 위해 일대다(1:N) 관계인 팀(Team)과 학생(Student)을 예시로 설명합니다.\n@JoinColumn 은 주인 쪽에 붙이며, \u0026ldquo;내가 이 FK 컬럼을 관리한다\u0026quot;는 선언입니다. 주인 쪽에서 값을 세팅해야만 DB에 반영됩니다.\n@ManyToOne @JoinColumn(name = \u0026#34;team_id\u0026#34;) // STUDENT 테이블의 team_id 컬럼이 FK private Team team; mappedBy 는 주인이 아닌 쪽에 붙이며, \u0026ldquo;상대방 필드가 이 관계를 관리한다\u0026quot;는 뜻입니다. 여기서 값을 세팅해도 DB에는 반영되지 않습니다. mappedBy에 지정하는 값은 상대 엔티티의 필드 이름입니다.\n@OneToMany(mappedBy = \u0026#34;team\u0026#34;) // Student 클래스의 \u0026#39;team\u0026#39; 필드가 주인 private List\u0026lt;Student\u0026gt; students = new ArrayList\u0026lt;\u0026gt;(); 한쪽이 @JoinColumn이면 반대쪽은 반드시 mappedBy입니다. 양쪽 모두 @JoinColumn을 사용하면 FK가 두 개 생성되므로 주의하세요.\n예제 구성 # 설명을 위해 살펴볼 예제 DB 테이블은 아래와 같습니다. MISSION 테이블에서 SPECIES 의 기본 키 값을 외래 키로 지니고 있습니다.\nJPA 엔티티는 아래와 같이 정의하였습니다. Mission 엔티티에서 @JoinColumn 을 이용하여 species_id 값을 외래 키로 설정합니다. 그리고 서로 양방향 참조를 가지고 있습니다.\n@Entity public class Species { @Id @Column(name = \u0026#34;species_id\u0026#34;) private Long id; // FetchType.LAZY 적용. // Mission 엔티티의 species 필드를 mappedBy 해서 연관관계의 주인으로 적용. @OneToOne(fetch = FetchType.LAZY, mappedBy = \u0026#34;species\u0026#34;) private Mission mission; private String speciesName; } @Entity public class Mission { @Id @Column(name = \u0026#34;mission_id\u0026#34;) private Long id; @OneToOne(fetch = FetchType.LAZY) // FetchType.LAZY 적용 @JoinColumn(name = \u0026#34;species_id\u0026#34;) private Species species; private String missionName; } 양쪽 객체는 서로 참조하는 필드를 가지고 있고 테이블은 일대일 관계를 맺고 있습니다. Mission 객체가 연관관계의 주인으로서 외래 키를 관리하고 MISSION 테이블과 매핑되어 있습니다.\n객체는 객체 그래프(서로 다른 객체간의 참조를 통해 연결된 체인이 만드는 네트워크)로 연관된 객체들을 탐색할 수 있습니다. 그런데 객체가 데이터베이스에 저장되어 있으므로 연관된 객체를 마음껏 탐색하기는 어렵습니다. 메모리 위의 객체라면 참조를 따라 자유롭게 이동할 수 있지만, DB에 저장된 데이터는 실제로 조회 쿼리를 실행하기 전까지는 가져올 수 없기 때문입니다. 즉, mission.getSpecies()를 호출하는 순간 DB에 SELECT 쿼리가 나가지 않으면 Species 데이터를 얻을 방법이 없습니다.\n프록시와 지연 로딩 # JPA는 이 문제를 해결하기 위해서 연관관계에 있는 객체들을 한 번에 DB에서 조회하여 서로 탐색이 가능하게 최적화를 합니다. 하지만 이러한 방법은 예기치 못한 쿼리가 발생할 수 있으며, 때로는 조회하는 데 비용이 클 수 있습니다. JPA 는 이 문제를 해결하기 위해서 프록시라는 기술을 사용합니다. 그리고 연관된 객체를 처음부터 데이터베이스에서 조회하는 것이 아니라 실제 사용하는 시점에 데이터베이스에서 조회할 수 있습니다. 이러한 방식을 지연로딩(Lazy Loading) 이라 합니다. 지연 로딩 기능을 사용하려면 실제 엔티티 객체 대신에 DB 조회를 지연할 수 있는 가짜 객체가 필요한데 이것을 프록시 객체라 합니다. 프록시 객체는 DB 접근을 위임 받았으며 실제 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성합니다.\n문제 확인 # 조회되는 객체가 실제 엔티티인지 프록시인지 테스트하기 위하여 아래와 같은 코드를 작성 후 실행해 보았습니다. 먼저, 연관관계의 주인인 Mission 엔티티를 DB에서 조회하고 Species 엔티티는 참조(reference)를 통해 조회해 보았습니다.\nLong missionId = 1001L; Long speciesId = 9001L; Mission newMission = new Mission(missionId,\u0026#34;미션1\u0026#34;); // id, missionName Species newSpecies = new Species(speciesId, \u0026#34;종1\u0026#34;); // id, speciesName newMission.setSpecies(newSpecies); speciesRepository.save(newSpecies); // species 을 먼저 저장해야 한다. Mission 이 species_id(FK)를 참조하므로, Species 가 먼저 DB에 존재해야 외래키 제약조건 위반이 발생하지 않는다. missionRepository.save(newMission); entityManager.clear(); // 1차 캐시를 비워 이후 조회 시 DB에서 실제로 SELECT 쿼리가 발생하도록 강제한다. 비우지 않으면 캐시에서 바로 반환되어 프록시 생성 여부를 확인할 수 없다. Mission mission = missionRepository.findById(missionId).get(); String missionClassName = mission.getClass().getName(); String speciesClassName = mission.getSpecies().getClass().getName(); System.out.println(\u0026#34;missionClassName = \u0026#34; + missionClassName); System.out.println(\u0026#34;speciesClassName = \u0026#34; + speciesClassName); Species 엔티티는 프록시 객체가 생성된 것을 확인할 수 있습니다. 이번에는 반대로, 연관관계의 주인이 아닌 Species 엔티티를 DB에서 조회하고 Mission 엔티티를 참조를 통해 조회해 보겠습니다.\nSpecies species = speciesRepository.findById(species.getId()).get(); String speciesClassName = species.getClass().getName(); String missionClassName = species.getMission().getClass().getName(); System.out.println(\u0026#34;speciesClassName = \u0026#34; + speciesClassName); System.out.println(\u0026#34;missionClassName = \u0026#34; + missionClassName); 두 실행 코드는 서로 다른 엔티티를 DB에서 조회한다는 점을 제외하고 구조가 완전히 동일합니다. 각 엔티티는 OneToOne 연관관계를 매핑할 때 FetchType.LAZY 을 적용하였지만, Species 엔티티를 DB에서 조회할 때 Mission 엔티티의 조회 쿼리도 함께 발생하였고 Mission 객체는 프록시가 아닌 실제 엔티티가 생성되었습니다.\n원인 분석: 프록시의 한계 # 프록시를 사용하여 지연로딩을 적용할 때, 외래 키를 직접 관리하지 않는 객체의 일대일 관계는 지연 로딩으로 설정해도 즉시 로딩합니다. 이 문제는 프록시의 한계 때문에 발생하는 문제입니다.\n진짜 엔티티 객체의 가짜 객체 프록시를 생성할 때는 진짜 엔티티에 대한 정보를 가지고 있어야 합니다. 가짜 객체가 진짜 객체인 척 하기위해 프록시를 사용하는데, 진짜 객체가 존재하지 않는다면 가짜 객체의 존재 의미는 사라지게 됩니다. 따라서 엔티티를 프록시로 조회할 때 진짜 엔티티의 식별자 값(PK)을 파라미터로 전달하는데 프록시 객체는 이 식별자 값(PK) 을 정보로 가지고 있습니다.\n실제로\nMission mission = missionRepository.findById(missionId).get() 코드를 이용하여 조회한 Mission 엔티티를 디버거를 통해 살펴보면 위에서 speciesId 값(PK)으로 정의한 9001L 을 Species 프록시 객체가 가지고 있는 것을 확인할 수 있습니다. 이 정보를 이용하여 실제 사용될 때 Species 엔티티를 초기화합니다.\n다시 한번 객체와 테이블 관계를 살펴보겠습니다.\nMission 엔티티는 실제 사용할 때 DB 에서 MISSION 테이블을 조회합니다. MISSION 테이블에는 외래 키를 관리하는 컬럼이 존재해서 테이블 조회 만으로 SPECIES 테이블과 연관관계가 있는 것을 알 수 있습니다. 이를 통해 species_id 값을 얻거나 연관된 데이터가 없음(null) 을 알 수 있습니다. (참고로 @OneToOne의 optional 속성은 연관된 엔티티의 존재 여부를 강제할지 지정합니다. 기본값은 true로, 연관된 엔티티가 없어도 되며 FK 컬럼에 null이 허용됩니다. optional = false로 설정하면 연관된 엔티티가 반드시 존재해야 하며 FK 컬럼이 NOT NULL로 처리됩니다.)\n하지만 Species 엔티티는 실제 사용될 때 SPECIES 테이블을 조회하는 것으로 MISSION 테이블과 연관관계가 있는지 알 수 없습니다. FK 컬럼과 같이 다른 테이블과의 연관 관계 존재 여부를 확인할 컬럼이 없기 때문에 null 인지조차 알 수 없습니다. 따라서 MISSION 테이블과의 연관관계를 확인하기 위해서 MISSION 테이블에 SELECT 쿼리를 보내야 합니다.\nJPA 는 species_id 와 관계를 맺고 있는 MISSION 테이블의 데이터를 찾습니다. SELECT 쿼리로 데이터 존재 여부만 확인하는 것은 비효율적이므로, JPA 는 SELECT 쿼리와 함께 데이터도 가져와서 프록시 대신 실제 엔티티를 생성합니다. 이렇게 결과적으로 FetchType.LAZY 가 무시되고 FetchType.EAGER 가 적용됩니다.\n그리고 SELECT 쿼리를 날렸는데 연관된 데이터가 없으면 아래와 같이 null 이 됩니다.\n@OneToMany에서는 왜 이 문제가 발생하지 않을까? # 일(1)대다(N) 인 경우 항상 다(N) 쪽이 외래키를 가집니다. 이 경우 일(1)에 해당하는 객체는 연관관계의 주인이 아니며 매핑된 테이블을 조회했을 때, 앞선 경우와 마찬가지로 연관관계 여부를 확인할 컬럼이 존재하지 않습니다. 즉 이 경우도 SELECT 쿼리를 다(N) 에 해당하는 테이블로 보내야 하지 않나? 생각할 수 있습니다.\n일(1)에 해당하는 엔티티의 다(N) 에 해당하는 연관 관계 필드는 컬렉션 타입입니다. 지연 로딩을 적용할 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 하이버네이트는 컬렉션 래퍼라는 것을 제공합니다. 이것이 컬렉션에 대한 프록시 역할을 합니다. 따라서 SELECT 쿼리를 이용하여 존재 여부를 확인할 필요 없이 지연 로딩이 정상적으로 작동하게 됩니다.\n해결 방법 # 사실 해결 방법은 크게 없습니다. 그냥 즉시 로딩이 되는 것을 견디며 사용하거나 불필요하게 양방향 연관 관계를 설정해야 하는 것이 아니라면 아래와 같이 단방향 연관 관계로 설정해도 됩니다. 또는 프록시 대신에 바이트코드를 조작하는 라이브러리를 사용하는 방법도 존재하는 것으로 알고 있습니다.\n그리고 실제로 단방향 매핑만으로도 테이블과 객체의 연관관계 매핑은 완료되었습니다. 양방향 매핑은 단방향 매핑과 비교해서 연관관계의 주인도 정해야 하고, 두 개의 단방향 연관관계를 양방향으로 만들기 위한 로직도 잘 관리해야 할 만큼 복잡합니다. 양방향의 장점은 연관관계의 주인이 아닌 방향에서 연관관계의 주인 방향으로 객체 그래프 탐색 기능이 추가된 것뿐입니다.\n따라서 반대방향으로 탐색 기능이 필요한 것이 아니라면 단방향을 우선적으로 사용하는 것을 권장합니다.\n[참고 자료]\n자바 ORM 표준 JPA 프로그래밍\nJPA @OneToOne 일대일 연관 관계 정리 및 LazyLoding 이슈\nJPA @OneToOne은 FetchType.LAZY가 안 먹힐 수 있다?\nHow can I make a JPA OneToOne relation lazy\nSome explanations on lazy loading (one-to-one)\n","date":"2024년 7월 23일","externalUrl":null,"permalink":"/posts/jpa-onetoone-lazy-loading-issue/","section":"Posts","summary":"JPA @OneToOne 관계에서 지연 로딩이 동작하지 않고 즉시 로딩되는 원인을 프록시 메커니즘을 통해 분석합니다.","title":"[JPA] @OneToOne 에 지연 로딩을 적용했지만, 왜 지연 로딩이 안되고 즉시 로딩이 되는 걸까요?","type":"posts"},{"content":"","date":"2024년 7월 23일","externalUrl":null,"permalink":"/tags/lazy-loading/","section":"Tags","summary":"","title":"Lazy Loading","type":"tags"},{"content":"","date":"2024년 7월 23일","externalUrl":null,"permalink":"/tags/onetoone/","section":"Tags","summary":"","title":"OneToOne","type":"tags"},{"content":"Spring 프로젝트에서 DTO와 Entity 클래스를 만들 때, Lombok 어노테이션을 습관적으로 붙이고 있진 않으신가요?\n@Getter, @Setter, @NoArgsConstructor, @RequiredArgsConstructor 등 편리한 어노테이션이 많지만, DTO와 Entity는 역할이 다르기 때문에 적용해야 할 어노테이션도 다릅니다.\n이 글에서는 각 어노테이션의 동작을 정리하고, DTO와 Entity에 어떻게 적용하는 것이 적절한지 코드 예시와 함께 살펴보겠습니다.\n먼저, 각 어노테이션이 하는 일을 정리하자 # @Getter # 모든 필드에 대해 getter 메서드를 생성합니다.\n@Getter public class Product { private String name; private double price; } // 아래 코드가 자동 생성됨 // public String getName() { return this.name; } // public double getPrice() { return this.price; } @Setter # 모든 필드에 대해 setter 메서드를 생성합니다.\n@Setter public class Product { private String name; private double price; } // 아래 코드가 자동 생성됨 // public void setName(String name) { this.name = name; } // public void setPrice(double price) { this.price = price; } @NoArgsConstructor # 파라미터가 없는 기본 생성자를 생성합니다.\n@NoArgsConstructor public class Product { private String name; private int price; } // 아래 코드가 자동 생성됨 // public Product() {} access 옵션으로 접근 제한자를 설정할 수 있습니다.\n@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Product { private String name; private int price; } // 아래 코드가 자동 생성됨 // protected Product() {} @RequiredArgsConstructor # final 필드만을 파라미터로 받는 생성자를 생성합니다.\n@RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final PaymentService paymentService; private String tempValue; // final이 아니므로 생성자 파라미터에 포함되지 않음 } // 아래 코드가 자동 생성됨 // public OrderService(OrderRepository orderRepository, PaymentService paymentService) { // this.orderRepository = orderRepository; // this.paymentService = paymentService; // } @AllArgsConstructor # 모든 필드를 파라미터로 받는 생성자를 생성합니다.\n@AllArgsConstructor public class User { private String username; private int age; private String email; } // 아래 코드가 자동 생성됨 // public User(String username, int age, String email) { // this.username = username; // this.age = age; // this.email = email; // } Entity에는 어떤 어노테이션을 써야 할까? # 권장 조합 # @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String email; @Column(nullable = false) private String name; @Builder public Member(String email, String name) { this.email = email; this.name = name; } } 왜 이 조합인가? # 1. @Getter - 사용\nService, Controller 등에서 Entity의 데이터를 조회할 때 필요합니다.\n2. @Setter - 사용하지 않음\nEntity에 @Setter를 열어두면 어디서든 Entity의 상태를 변경할 수 있게 됩니다.\n// @Setter가 있으면 이런 코드가 가능해짐 - 위험! member.setName(\u0026#34;newName\u0026#34;); member.setEmail(\u0026#34;new@email.com\u0026#34;); 이렇게 되면 Entity의 상태가 언제, 어디서, 왜 변경되었는지 추적하기 어렵습니다.\n대신, 의미 있는 비즈니스 메서드를 통해 상태를 변경해야 합니다.\n@Entity public class Member { // ... 필드 생략 // setter 대신 의미 있는 메서드를 정의 public void updateProfile(String name, String email) { this.name = name; this.email = email; } public void deactivate() { this.active = false; this.deactivatedAt = LocalDateTime.now(); } } setName()이라는 이름만으로는 이 변경이 어떤 비즈니스 맥락에서 일어나는지 알 수 없지만, updateProfile()이나 deactivate()는 의도가 명확합니다.\n3. @NoArgsConstructor(access = AccessLevel.PROTECTED) - 사용\nJPA 스펙상 Entity 클래스는 기본 생성자(no-args constructor)가 반드시 필요합니다. JPA가 리플렉션을 통해 객체를 생성하기 때문입니다.\n하지만 public으로 열어두면 아무 곳에서나 빈 Entity 객체를 만들 수 있으므로, AccessLevel.PROTECTED로 제한합니다.\n// public이면 이런 코드가 가능 - 불완전한 객체 생성 위험 Member member = new Member(); // 모든 필드가 null // protected로 제한하면 외부에서 직접 호출 불가 // 같은 패키지 또는 상속 관계에서만 사용 가능 (JPA가 사용하는 범위) 4. @RequiredArgsConstructor - 사용하지 않음\nEntity는 Spring Bean이 아니기 때문에 의존성 주입이 필요 없습니다. final 필드를 쓸 일도 거의 없으므로 @RequiredArgsConstructor는 Entity에 적합하지 않습니다.\n또한 JPA Entity의 필드를 final로 선언하면, JPA가 리플렉션으로 객체를 생성한 뒤 필드 값을 세팅할 수 없게 됩니다.\n5. @AllArgsConstructor - 사용하지 않음\n모든 필드에 대한 생성자를 열어두면 id처럼 자동 생성되어야 하는 필드까지 외부에서 주입할 수 있게 됩니다. 필드 순서가 바뀌면 컴파일 에러 없이 값이 뒤바뀌는 버그도 발생할 수 있습니다.\n대신 @Builder를 사용하여 필요한 필드만 명시적으로 설정하는 것이 안전합니다.\nDTO에는 어떤 어노테이션을 써야 할까? # DTO는 데이터를 전달하는 것이 목적인 객체입니다. Entity와는 성격이 다르므로 다른 애너테이션이 필요합니다.\n요청(Request) DTO # @Getter @NoArgsConstructor public class MemberCreateRequest { @NotBlank(message = \u0026#34;이메일은 필수입니다\u0026#34;) private String email; @NotBlank(message = \u0026#34;이름은 필수입니다\u0026#34;) private String name; } 왜 이 조합인가?\n@Getter - O: Controller에서 Service로 데이터를 전달할 때 필드 값을 읽어야 합니다. @Setter - X: 요청 데이터는 한번 바인딩되면 변경될 이유가 없습니다. @NoArgsConstructor - O: Jackson이 기본 생성자로 객체를 생성한 뒤, 리플렉션으로 필드 값을 바인딩합니다. public 기본 생성자가 필요합니다. Jackson의 역직렬화 과정:\n기본 생성자로 빈 객체 생성 JSON 필드와 매칭되는 Java 필드에 리플렉션으로 값 주입 그런데 @Setter가 필요한 경우도 있다 - @ModelAttribute # 위에서 @RequestBody로 JSON을 받을 때는 @Setter가 필요 없다고 했습니다. 하지만 @ModelAttribute로 폼 데이터나 쿼리 파라미터를 바인딩할 때는 이야기가 다릅니다.\nSpring의 DataBinder는 기본 생성자로 객체를 만든 뒤, setter 메서드를 호출하여 값을 주입합니다. 리플렉션으로 필드에 직접 접근하는 Jackson과는 동작 방식이 다릅니다.\n// GET /members?name=홍길동\u0026amp;email=hong@test.com @GetMapping(\u0026#34;/members\u0026#34;) public ResponseEntity search(@ModelAttribute MemberSearchRequest request) { // ... } @Getter @Setter // @ModelAttribute 바인딩에 필요! @NoArgsConstructor public class MemberSearchRequest { private String name; private String email; } @Setter가 없으면 DataBinder가 값을 주입할 수 없어 모든 필드가 null로 남게 됩니다.\n요청 DTO에서 @Setter를 쓸지 말지는 어떤 방식으로 데이터를 받느냐에 따라 달라집니다. 무조건 @Setter를 빼는 것이 아니라, 바인딩 메커니즘을 이해하고 필요한 경우에 적용하는 것이 중요합니다.\n응답(Response) DTO # @Getter @RequiredArgsConstructor public class MemberResponse { private final Long id; private final String email; private final String name; public static MemberResponse from(Member member) { return new MemberResponse( member.getId(), member.getEmail(), member.getName() ); } } 또는 @Builder 패턴을 활용할 수도 있습니다.\n@Getter @Builder public class MemberResponse { private final Long id; private final String email; private final String name; public static MemberResponse from(Member member) { return MemberResponse.builder() .id(member.getId()) .email(member.getEmail()) .name(member.getName()) .build(); } } 왜 이 조합인가?\n@Getter - O: 응답 JSON으로 직렬화할 때 필요합니다. @RequiredArgsConstructor - O: 응답 DTO는 생성 시점에 모든 데이터가 확정되므로, final 필드 + @RequiredArgsConstructor로 불변 객체를 만드는 것이 좋습니다. @Setter - X: 응답 데이터는 한번 생성되면 변경되면 안 됩니다. @NoArgsConstructor - X: 응답 DTO는 Jackson 역직렬화 대상이 아니므로 기본 생성자가 필요 없습니다. Java 16+ 라면 Record를 고려하자 # Java 16부터 도입된 record를 사용하면 Lombok 없이도 불변 DTO를 간결하게 만들 수 있습니다.\npublic record MemberResponse( Long id, String email, String name ) { public static MemberResponse from(Member member) { return new MemberResponse( member.getId(), member.getEmail(), member.getName() ); } } record는 자동으로 getter, equals(), hashCode(), toString()을 생성하며, 모든 필드가 final입니다. Spring Data JPA에서도 DTO Projection으로 record를 지원합니다.\npublic record UserDto(String firstname, String lastname) {} interface UserRepository extends Repository\u0026lt;User, Long\u0026gt; { @Query(\u0026#34;SELECT u FROM User u WHERE u.lastname = :lastname\u0026#34;) List\u0026lt;UserDto\u0026gt; findByLastname(String lastname); } ","date":"2024년 5월 2일","externalUrl":null,"permalink":"/posts/lombok-annotations-dto-entity/","section":"Posts","summary":"DTO와 Entity의 역할 차이에 따라 Lombok 어노테이션(@Getter, @Setter, @NoArgsConstructor, @RequiredArgsConstructor)을 어떻게 선택해야 하는지 코드 예시와 함께 정리합니다.","title":"[Spring] DTO와 Entity에 @Getter, @Setter, @RequiredArgsConstructor, @NoArgsConstructor 뭘 써야 할까?","type":"posts"},{"content":"","date":"2024년 5월 2일","externalUrl":null,"permalink":"/tags/dto/","section":"Tags","summary":"","title":"DTO","type":"tags"},{"content":"","date":"2024년 5월 2일","externalUrl":null,"permalink":"/tags/entity/","section":"Tags","summary":"","title":"Entity","type":"tags"},{"content":"","date":"2024년 5월 2일","externalUrl":null,"permalink":"/tags/lombok/","section":"Tags","summary":"","title":"Lombok","type":"tags"},{"content":"","date":"2024년 2월 18일","externalUrl":null,"permalink":"/tags/homebrew/","section":"Tags","summary":"","title":"Homebrew","type":"tags"},{"content":"","date":"2024년 2월 18일","externalUrl":null,"permalink":"/tags/macos/","section":"Tags","summary":"","title":"MacOS","type":"tags"},{"content":"macOS에서 MySQL을 띄우려고 보면 brew services start mysql도 보이고 mysql.server start도 보여서 처음에는 뭐가 다른지 꽤 헷갈렸습니다.\n둘 다 결국 MySQL 서버를 실행하는 건 맞지만, 누가 프로세스를 관리하느냐와 어떤 상황에서 더 편하냐가 다릅니다. 정리해보면 아래처럼 이해하는 게 가장 편했습니다.\n평소 로컬 개발용으로 계속 켜둘 거면 brew services start mysql, 필요할 때만 직접 켜고 끄고 싶으면 mysql.server start\n두 명령어 차이부터 보면 # 항목 brew services start mysql mysql.server start 관리 주체 Homebrew + macOS launchd MySQL 제공 스크립트 실행 방식 백그라운드 서비스 등록 후 실행 수동 실행 자동 시작 가능 기본적으로 없음 관리 명령어 brew services list, stop, restart mysql.server status, stop 제 기준 추천 상황 평소 로컬 개발용 잠깐 켜서 확인하거나 테스트할 때 brew services start mysql는 언제 쓰면 편할까? # 이건 Homebrew가 macOS의 launchd에 MySQL을 서비스로 등록해서 관리하는 방식입니다.\n한 번 켜두고 계속 쓸 거라면 이쪽이 훨씬 편합니다. 로그인 이후에도 다시 실행되게 관리할 수 있고, brew services list로 다른 로컬 서비스와 함께 상태를 확인할 수 있어서 관리 자체가 단순합니다.\n제가 로컬에서 MySQL을 자주 쓰는 상황이라면 보통 이 방식을 먼저 씁니다.\nbrew services start mysql brew services stop mysql brew services restart mysql brew services list 장점도 분명합니다.\n매번 터미널에서 직접 켤 필요가 없습니다. Homebrew로 설치한 다른 서비스와 함께 관리하기 편합니다. 평소 개발 환경을 유지할 때 손이 덜 갑니다. mysql.server start는 언제 더 나을까? # 이 명령은 MySQL이 제공하는 실행 스크립트를 직접 호출하는 방식입니다.\n느낌상 서비스 등록이라기보다 \u0026ldquo;지금 필요해서 직접 켠다\u0026quot;에 더 가깝습니다. 그래서 자동 시작보다는 필요할 때 켜고, 끝나면 끄는 흐름에 잘 맞습니다.\nmysql.server start mysql.server stop mysql.server status 이 방식이 더 편한 경우도 있습니다.\nMySQL을 항상 켜둘 필요는 없을 때 시작과 종료를 직접 제어하고 싶을 때 실행 상태를 보면서 테스트하거나 확인할 때 다만 설치 방식이나 PATH 설정에 따라 mysql.server 명령이 바로 안 잡히는 경우는 있습니다. 그런 점까지 생각하면 Homebrew로 설치한 환경에서는 brew services 쪽이 좀 더 덜 헷갈렸습니다.\n그럼 mysqld_safe는 뭘까? # 가끔 mysqld_safe \u0026amp; 같은 명령도 같이 보입니다.\n이건 더 낮은 레벨에서 MySQL 데몬을 실행하는 방식이고, mysql.server가 내부적으로 활용하는 쪽에 가깝습니다. 평소 로컬 개발에서는 굳이 이 단계까지 직접 만질 일은 많지 않았습니다.\n제 기준에서는 이렇게만 구분해도 충분했습니다.\n계속 써야 하는 로컬 DB면 brew services 잠깐 띄우고 테스트할 거면 mysql.server 실행 흐름을 아주 직접적으로 건드릴 때만 mysqld_safe 마무리 # 결론만 다시 적으면, macOS에서 Homebrew로 MySQL을 설치했다면 보통은 brew services start mysql이 가장 편합니다.\n반대로 MySQL을 가끔만 쓰거나 직접 켜고 끄는 흐름이 더 편하면 mysql.server start도 충분히 괜찮습니다.\n","date":"2024년 2월 18일","externalUrl":null,"permalink":"/posts/mysql-mac-commands/","section":"Posts","summary":"macOS에서 MySQL을 실행할 때 brew services와 mysql.server가 어떻게 다른지, 어떤 상황에서 무엇을 쓰면 되는지 정리합니다.","title":"macOS에서 MySQL 실행할 때 `brew services`와 `mysql.server` 차이 정리","type":"posts"},{"content":"","date":"2023년 12월 3일","externalUrl":null,"permalink":"/tags/gradle/","section":"Tags","summary":"","title":"Gradle","type":"tags"},{"content":"프로젝트 세팅을 하다가 QueryDSL 설정 글들을 몇 개 찾아봤는데, 글마다 설정이 조금씩 달랐습니다.\n어떤 글은 annotationProcessor 설정이 있었고, 어떤 글은 플러그인 버전 이야기를 먼저 했습니다. 그러다 보니 자연스럽게 이런 생각이 들었습니다.\n왜 Spring이나 QueryDSL을 쓰는데 Gradle 버전까지 같이 봐야 하지?\n처음에는 저도 Spring이 Gradle에 직접 의존하는 줄 알았는데, 정리해보니 그건 아니었습니다.\n이 글은 그때 헷갈렸던 내용을 제가 이해한 기준으로 간단히 정리한 기록입니다.\nGradle은 결국 뭘 해주는 걸까? # Gradle은 빌드 도구입니다.\nJava 프로젝트를 실행 가능하게 만들기까지 필요한 작업을 대신 처리해 줍니다.\n제 기준에서는 라이브러리 받아오고, Java 코드를 컴파일하고, 테스트를 돌리고, 마지막에 jar 같은 결과물로 묶는 것까지 맡는 도구라고 이해하니까 조금 정리가 됐습니다.\n예를 들어 build.gradle에 아래처럼 적어두면\ndependencies { implementation \u0026#39;org.springframework.boot:spring-boot-starter-web\u0026#39; } Gradle이 저장소에서 해당 라이브러리를 받아오고, 필요한 하위 의존성까지 같이 가져옵니다.\n그리고 아래 명령어를 실행하면\n./gradlew test 테스트도 알아서 실행해 줍니다.\n결국 개발자가 매번 수동으로 하던 작업을 순서에 맞게 실행해 주는 도구라고 보면 이해하기 편했습니다.\n왜 설정 글마다 Gradle 버전 이야기가 같이 나올까? # 제가 헷갈렸던 지점은 여기였습니다.\n애플리케이션은 Spring Boot로 만들고 있는데, 왜 Gradle 버전 호환성 이야기가 같이 나오는 걸까 싶었습니다.\n이유는 애플리케이션이 Gradle에 의존하는 게 아니라, 빌드 과정에서 사용하는 플러그인과 설정이 Gradle 버전에 영향을 받기 때문입니다.\n예를 들어 Spring Boot를 Gradle에서 사용할 때는 보통 플러그인을 추가합니다.\nplugins { id \u0026#39;org.springframework.boot\u0026#39; version \u0026#39;3.2.0\u0026#39; } 이 플러그인은 Gradle 위에서 동작합니다.\n즉, Gradle이 제공하는 기능과 방식에 맞춰서 만들어져 있습니다. 그래서 Gradle 메이저 버전이 바뀌면, 예전 방식으로 작성된 플러그인이나 설정 코드가 더 이상 동작하지 않는 경우가 생깁니다.\n그래서 Spring Boot 공식 문서에서도 지원하는 Gradle 버전을 같이 확인하게 되는 것 같습니다.\nQueryDSL은 왜 더 민감해 보였을까? # QueryDSL은 특히 설정 글마다 차이가 더 크게 느껴졌습니다.\n이유는 QueryDSL이 단순히 라이브러리만 추가해서 끝나는 게 아니라, 빌드 시점에 QClass를 생성하는 과정이 들어가기 때문입니다.\n예를 들면 Member 엔티티가 있으면 빌드할 때 QMember 같은 클래스가 생성됩니다.\n이 작업은 보통 annotation processing 과정에서 처리되는데, 이 부분이 Gradle 버전에 따라 설정 방식이 조금씩 달라집니다.\n그래서 예전에 작성된 글을 그대로 따라 하면 현재 프로젝트에서는 동작하지 않는 경우가 생깁니다.\n실제로 제가 찾아본 글들도 Gradle 버전이 다르다 보니 설정 코드가 조금씩 달랐습니다.\n마무리 # 결국 제가 이해한 건 이렇습니다.\nSpring이나 QueryDSL이 실행 중에 Gradle을 쓰는 것은 아닙니다. Gradle은 어디까지나 빌드할 때 필요한 도구입니다.\n다만 빌드 과정에 들어가는 플러그인이나 annotation processing 설정은 Gradle 버전에 영향을 받습니다. 그래서 프로젝트 세팅할 때 라이브러리 버전만 볼 게 아니라 Gradle 버전도 같이 봐야 했던 것이었습니다.\n결국 배포된 jar 파일 안에 Gradle이 들어가는 것은 아닙니다.\nGradle은 빌드가 끝나면 역할이 끝나고, 실제 실행은 Spring 애플리케이션이 담당합니다.\n저는 이걸 이해하고 나서야 왜 공식 문서나 설정 글에서 \u0026ldquo;Spring Boot 버전\u0026rdquo;, \u0026ldquo;Gradle 버전\u0026rdquo;, \u0026ldquo;Java 버전\u0026quot;을 같이 보라고 하는지 조금 감이 왔습니다.\n","date":"2023년 12월 3일","externalUrl":null,"permalink":"/posts/gradle-explained/","section":"Posts","summary":"프로젝트 세팅 중 헷갈렸던 Gradle의 역할과 Spring Boot, QueryDSL 설정에서 Gradle 버전을 같이 확인해야 하는 이유를 정리합니다.","title":"Gradle이란 무엇일까? 역할 정리","type":"posts"},{"content":"","date":"2023년 12월 3일","externalUrl":null,"permalink":"/tags/querydsl/","section":"Tags","summary":"","title":"QueryDSL","type":"tags"},{"content":"","date":"2023년 11월 2일","externalUrl":null,"permalink":"/tags/ide/","section":"Tags","summary":"","title":"IDE","type":"tags"},{"content":"","date":"2023년 11월 2일","externalUrl":null,"permalink":"/tags/intellij/","section":"Tags","summary":"","title":"IntelliJ","type":"tags"},{"content":" 탐색 / 이동 # 단축키 기능 Command + B 또는 Command + Click 선언부로 이동 (Go to Declaration) Command + Option + B 또는 Command + Option + Click 구현체로 이동 (Go to Implementation) Command + O 프로젝트 내 클래스 검색 Command + O (두 번) 라이브러리 포함 전체 클래스 검색 Command + F12 현재 클래스 내 메서드 목록 조회 (File Structure) Command + E 최근 열었던 파일 히스토리 Shift + F4 현재 파일 탭을 새 창으로 분리해서 열기 (Open Source in New Window) Command + 1 왼쪽 Project 창 열기/닫기 Shift + F2 이전 에러 위치로 이동 F2 다음 에러 위치로 이동 편집 # 단축키 기능 Option + Enter Quick Fix / 오류 수정 제안 / Auto import / Static import 등 Command + N 또는 Control + Enter 코드 생성 (getter, setter, constructor 등) Control + I 인터페이스 구현 메서드 목록 표시 Command + D 현재 줄 아래에 복사 붙여넣기 (Duplicate Line) Shift + Option + Up / Down 현재 줄 위/아래로 이동 Command + Shift + Enter 현재 구문 자동 완성 (Complete Current Statement) Control + Space 코드 자동 완성 (Basic Completion) Command + P 파라미터 정보 표시 리팩토링 # 단축키 기능 Shift + F6 변수 / 메서드 / 클래스 이름 일괄 변경 (Rename) Rename 예시 (Shift + F6)\n변경 전 — 변수명이 하이라이트되어 일괄 수정 대상을 표시:\n변경 후 — 모든 참조가 한 번에 수정됨:\n단축키 기능 Command + Option + V 변수 추출 (Extract Variable) Command + Option + M 메서드 추출 (Extract Method) Command + Option + N 인라인 변환 — 변수를 직접 반환으로 (Inline) Command + Option + P 파라미터 추출 (Extract Parameter) Command + Option + C 상수 추출 (Extract Constant) Control + Option + O 불필요한 import 정리 (Optimize Imports) 검색 # 단축키 기능 Command + Shift + F 전체 소스코드에서 텍스트 검색 (Find in Files) 테스트 / 실행 # 단축키 기능 Command + Shift + T 현재 클래스의 테스트 클래스 이동 / 생성 Control + Shift + R 현재 파일 실행 (Run context configuration) 프로젝트 설정 # 단축키 기능 Command + ; 프로젝트 구조 화면 열기 (Project Structure) Project Structure 화면 (Command + ;)\nLive Template (자동 완성 코드 조각) # 템플릿 생성 코드 psvm + Tab public static void main(String[] args) {} sout + Tab System.out.println() soutv + Tab System.out.println(\u0026quot;변수명 = \u0026quot; + 변수명) soutm + Tab System.out.println(\u0026quot;클래스명.메서드명\u0026quot;) iter + Tab enhanced for문 생성 참고 링크 # JetBrains 공식 - 자주 쓰는 단축키 TOP 15 IntelliJ 단축키 모음 블로그 control+space 자동완성 안될 때 해결법 ","date":"2023년 11월 2일","externalUrl":null,"permalink":"/posts/intellij-shortcuts/","section":"Posts","summary":"macOS 기준 IntelliJ IDEA 필수 단축키 모음 — 탐색, 편집, 리팩토링, 검색, 테스트, Live Template까지 한눈에 정리","title":"IntelliJ 단축키 정리 (macOS 기준)","type":"posts"},{"content":"","date":"2023년 11월 2일","externalUrl":null,"permalink":"/tags/%EB%8B%A8%EC%B6%95%ED%82%A4/","section":"Tags","summary":"","title":"단축키","type":"tags"},{"content":"","date":"2023年11月2日","externalUrl":null,"permalink":"/ja/tags/%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88/","section":"Tags","summary":"","title":"ショートカット","type":"tags"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","externalUrl":null,"permalink":"/search/","section":"검색","summary":"","title":"검색","type":"search"}]