<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>나만의 코딩기록</title>
    <link>https://cdchan.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 6 Aug 2026 17:42:22 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>chanyoun</managingEditor>
    <image>
      <title>나만의 코딩기록</title>
      <url>https://tistory1.daumcdn.net/tistory/4827024/attach/96f0289dc1584f9ca84aa82b53eb183e</url>
      <link>https://cdchan.tistory.com</link>
    </image>
    <item>
      <title>DB Lock</title>
      <link>https://cdchan.tistory.com/292</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;DB Lock&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div class=&quot;typora-post&quot;&gt;
  &lt;h2 id='db-lock'&gt;DB Lock&lt;/h2&gt;
&lt;p&gt;여러 사용자가 재고 1개인 상품을 동시에 구매하려고 하면, 재고를 확인하고 차감하는 사이에 서로의 작업이 겹칠 수 있습니다. 이러한 상황을 Race Condition 이 발생했다 라고 합니다. &lt;strong&gt;Lock은 이런 상황에서 데이터가 틀어지지 않도록 접근 순서를 지키게 하는 장치입니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 글은 MySQL 8.0 InnoDB, 격리 수준은 기본값인 &lt;code&gt;REPEATABLE READ&lt;/code&gt;를 전제로 합니다. 격리 수준이 &lt;code&gt;READ COMMITTED&lt;/code&gt;이면 뒤에서 설명할 Gap Lock의 동작이 달라지므로, 재현이 되지 않는다면 먼저 격리 수준을 확인하는 것을 추천드립니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- 격리수준을 확인할수 있는 명령어
SELECT @@transaction_isolation;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='shared-lock-s-lock'&gt;Shared Lock (S Lock)&lt;/h2&gt;
&lt;p&gt;Shared Lock은 여러 트랜잭션이 함께 읽을 수 있게 하는 잠금입니다. S Lock을 사용하는 이유는, &lt;strong&gt;읽은 값이 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 바뀌지 않도록 고정하기 위함입니다&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;잠금 없는 일반 SELECT와의 차이는 아래 쿼리에서 확인할수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- [A]
START TRANSACTION;
SELECT * FROM child WHERE id = 10;        -- name 확인, 스냅샷 생성

-- [B]
START TRANSACTION;
UPDATE child SET name = &amp;#39;changed&amp;#39; WHERE id = 10;
COMMIT;

-- [A]
SELECT * FROM child WHERE id = 10;            -- 예전 값
SELECT * FROM child WHERE id = 10 FOR SHARE;  -- &amp;#39;changed&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;마지막 A 트랜잭션의 두 SELECT가 서로 다른 값을 반환하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;MySQL InnoDB 기준, 하나의 트랜잭션 안에서 일반 SELECT은 MVCC를 통해 최초 스냅샷의 정보를 그대로 가져옵니다. 이는 REPEATABLE READ (Mysql InnoDB 의 기본값 격리수준), 즉 하나의 트랜잭션에서 한 번 읽은 값은 계속 같은 값으로 보인다는 격리 수준의 특성입니다.&lt;/p&gt;
&lt;p&gt;그러나 S Lock을 사용해 SELECT 하는 순간, 스냅샷이 아닌 미커밋 변경이 있으면 기다린 뒤 가장 최근 커밋 상태를 읽어, 트랜잭션 B가 변경한 &lt;code&gt;changed&lt;/code&gt;를 가져오게 됩니다. 그리고 그 시점부터 다른 트랜잭션의 X Lock을 막으므로, 방금 읽은 값은 커밋할 때까지 유효하다는 것이 보장됩니다.&lt;/p&gt;
&lt;p&gt;결론적으로 &lt;strong&gt;S Lock으로 읽은 데이터는 읽는 시점의 최신값이며, 해당 트랜잭션이 커밋될 때까지 그 값이 유지됩니다.&lt;/strong&gt; &lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- [A] S Lock 획득
START TRANSACTION;

SELECT * FROM child
WHERE id = 10
FOR SHARE;


-- [B] 다른 세션에서 실행

START TRANSACTION;

SELECT * FROM child
WHERE id = 10; -- 즉시 성공: 일반 SELECT는 잠금을 요청하지 않음

SELECT * FROM child
WHERE id = 10
FOR SHARE; -- 즉시 성공: S Lock끼리는 함께 획득 가능

SELECT * FROM child
WHERE id = 10
FOR UPDATE; -- 대기: X Lock은 S Lock과 충돌

UPDATE child
SET name = &amp;#39;blocked&amp;#39;
WHERE id = 10; -- 대기: UPDATE는 X Lock이 필요
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 쿼리를 통해 다른 트랜잭션도 같은 데이터에 S Lock을 획득해 함께 읽을 수 있지만, X Lock을 획득해 수정하려 하면 대기하는것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='exclusive-lock-x-lock'&gt;Exclusive Lock (X Lock) &lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Exclusive Lock은 한 트랜잭션이 행을 수정할 수 있게 하는 배타 잠금입니다.&lt;/strong&gt; &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;로 직접 요청할 수도 있고, UPDATE와 DELETE를 실행하면 자동으로 획득됩니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;  S Lock과 X Lock의 차이는 &lt;strong&gt;읽기 보호인지, 수정 독점인지&lt;/strong&gt;에 있습니다.&lt;br/&gt;S Lock은 읽은 데이터를 기준으로 작업하는 동안 값이 변경되지 않도록 보호하는 잠금입니다. 따라서 다른 트랜잭션도 함께 읽을 수 있지만, 수정은 할 수 없습니다.&lt;br/&gt;X Lock은 해당 데이터를 직접 수정하기 위해 획득하는 잠금입니다. 수정 중인 데이터에 다른 트랜잭션이 잠금 읽기하거나 수정하는 것까지 막아, 수정 작업을 한 트랜잭션만 수행하도록 보장합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;S Lock이 여러 트랜잭션에 공유되는 것과 달리, X Lock은 한 트랜잭션만 가질 수 있습니다. 다만 모든 접근을 막는 것은 아니고, 무엇이 막히고 무엇이 통과하는지는 아래 쿼리에서 확인할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- [A] X Lock 획득

START TRANSACTION;
SELECT * FROM child WHERE id = 10 FOR UPDATE;


-- [B]

START TRANSACTION;
SELECT * FROM child WHERE id = 10; -- 즉시 성공
SELECT * FROM child WHERE id = 10 FOR SHARE; -- 대기
SELECT * FROM child WHERE id = 10 FOR UPDATE; -- 대기
UPDATE child SET name = &amp;#39;blocked&amp;#39; WHERE id = 10; -- 대기

-- dbeaver 의 서로다른 스크립트 작성 페이지를 열어 테스트하면 대기 하는것을 확인할 수 있습니다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;B의 네 문장 중 첫 번째만 통과하고 나머지 세 개는 A가 커밋할 때까지 대기합니다. 즉 X Lock이 걸린 행에 대해 다른 트랜잭션의 S Lock과 X Lock은 모두 불가능하지만, 잠금 없는 일반 SELECT는 영향을 받지 않습니다.&lt;/p&gt;
&lt;p&gt;잠금 없는 SELECT가 통과하는 것은 MVCC 덕분입니다. 잠긴 행을 기다리는 대신 최초 스냅샷의 값을 읽기 때문에, A가 무엇을 하고 있든 상관없이 즉시 결과를 반환합니다. 앞서 S Lock에서 본 것과 같은 원리입니다.&lt;/p&gt;
&lt;p&gt;결론적으로 &lt;strong&gt;X Lock은 수정할 행을 다른 트랜잭션이 수정하거나, S Lock, X Lock 하는 것을 커밋 시점까지 차단합니다.&lt;/strong&gt; &lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='intention-lock-is-lock-ix-lock'&gt;Intention Lock (IS Lock, IX Lock)&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Intention Lock은 행에 S Lock 또는 X Lock을 잡으려는 의도를 테이블 수준에 표시하는 잠금입니다.&lt;/strong&gt; 쿼리를 따로 직접 요청하는 것이 아니라 InnoDB가 자동으로 획득합니다. &lt;code&gt;SELECT ... FOR SHARE&lt;/code&gt;는 IS Lock을, &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;는 IX Lock을 설정합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;행에 S Lock을 얻으려면 먼저 테이블에 IS Lock 을 획득해야 하고, 행에 X Lock을 얻으려면 먼저 IX Lock을 획득해야 합니다. 즉 모든 행 잠금 앞에는 반드시 테이블 수준의 의도 표시가 먼저 붙습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Intention Lock 이 필요한 이유는 테이블 전체를 잠그려는 트랜잭션 때문입니다. Intention Lock이 없다면, 테이블을 잠그려는 트랜잭션은 그 테이블의 모든 행을 하나씩 확인해서 잠긴 행이 있는지 검사해야 합니다. 테이블 수준에 IS, IX 표시를 남겨두면 이 검사가 한 번으로 끝납니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;주의할 점은 Intention Lock 자체가 무언가를 막는 잠금이 아니라는 것입니다. Intention lock은 &lt;code&gt;LOCK TABLES ... WRITE&lt;/code&gt; 같은 테이블 전체 요청 외에는 아무것도 막지 않습니다.
따라서 &lt;strong&gt;IS Lock끼리는 서로 충돌하지 않고, IX Lock끼리도 서로 충돌하지 않습니다.&lt;/strong&gt; IX는 이 테이블의 어떤 행을 잠글 것이라는 사실만 담고 있을 뿐, 그것이 어느 행인지에 대한 정보는 없기 때문입니다. 만약 IX끼리 충돌한다면 서로 다른 행을 수정하는 트랜잭션들이 전부 대기하게 되어(락 전에 IX Lock 을 하므로), 행 수준 잠금이 존재할 이유가 사라집니다. 실제 충돌 판정은 다음 단계인 행 수준 잠금에서 일어납니다.&lt;/p&gt;
&lt;p&gt;결과적으로 &lt;strong&gt;Intention Lock은 평소에 아무것도 막지 않고 기록만 남기다가, 테이블 전체를 잠그려는 요청이 들어왔을 때 빠르게 판정하는 역할을 합니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;다음과 같은 쿼리를 통해 IS , IX Lock 을 확인해볼수있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;START TRANSACTION;
SELECT * FROM child WHERE id = 10 FOR SHARE;

-- 서로다른 세션에서 (dbeaver 라면 서로 다른 스크립트 창에서 진행하면 됩니다.)

SELECT OBJECT_NAME, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_NAME = &amp;#39;child&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-07-18%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.38.39.png&quot; alt=&quot;스크린샷 2026-07-18 오후 4.38.39&quot;&gt;&lt;/p&gt;
&lt;p&gt;해당 결과를 통해 TABLE, RECORD 에 락이 걸렸고 저희는 &lt;code&gt;FOR SHARE&lt;/code&gt; 즉 S LOCK 을 획득했기에 IS Lock 이 걸린것을 확인할수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='record-lock'&gt;Record Lock&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Record Lock은 인덱스 레코드 하나를 잠그는 잠금입니다.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;SELECT id FROM product WHERE id = 10 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 쿼리는 &lt;code&gt;id = 10&lt;/code&gt;인 행에 대해 다른 트랜잭션의 수정, 삭제, lock 을 막습니다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 점은 Record Lock이 &lt;strong&gt;행이 아니라 인덱스 레코드를 잠근다&lt;/strong&gt;는 것입니다. InnoDB는 내부적으로는 항상 클러스터드 인덱스를 사용해 행을 관리합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Record locks always lock index records, even if a table is defined with no indexes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;기본 키가 있으면 기본 키가 클러스터드 인덱스가 됩니다. 기본 키와 적절한 유니크 인덱스도 없다면, InnoDB는 행을 관리하기 위한 숨겨진 클러스터드 인덱스를 내부적으로 만듭니다. 따라서 &lt;strong&gt;사용자가 만든 인덱스가 없어도 Record Lock이 잠글 인덱스 레코드는 항상 존재합니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;위의 인덱스를 만든다는 말이 &lt;code&gt;WHERE&lt;/code&gt; 조건에 사용한 컬럼의 인덱스를 자동으로 만든다는 말은 아닙니다. 예를 들어 &lt;code&gt;name&lt;/code&gt; 컬럼에 인덱스가 없다면, &lt;code&gt;WHERE name = &amp;#39;A&amp;#39;&lt;/code&gt;를 실행해도 InnoDB는 &lt;code&gt;name&lt;/code&gt; 인덱스를 새로 만들지 않습니다. 대신 기존 클러스터드 인덱스를 처음부터 스캔하며 조건을 확인합니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;REPEATABLE READ&lt;/code&gt;에서 이런 잠금 읽기나 수정 쿼리를 실행하면, 스캔한 인덱스 레코드와 그 사이의 gap까지 넓게 잠길 수 있습니다. 겉으로는 테이블 전체가 막힌 것처럼 보이지만, 많은 Record Lock과 Gap Lock, 즉 Next-Key Lock이 잡힌 것입니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If you have no indexes suitable for your statement and MySQL must scan the entire table to process the statement, &lt;strong&gt;every row of the table becomes locked&lt;/strong&gt;, which in turn blocks all inserts by other users to the table.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- name 컬럼에 인덱스가 없다면
UPDATE product SET stock = stock - 1 WHERE name = &amp;#39;A&amp;#39;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;의도는 한 행을 수정하는 것이지만, 인덱스가 없으면 스캔 과정에서 다른 행들까지 잠기게 됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='gap-lock'&gt;Gap Lock&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Gap Lock은 이미 존재하는 행이 아니라, 인덱스 레코드 사이의 아직 없는 값이 들어올 자리를 잠급니다.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;SELECT * FROM child WHERE id BETWEEN 10 AND 20 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 잠금은 다른 트랜잭션이 10과 20 사이에 새 값을 INSERT 하는 것을 막습니다. 같은 조건으로 두 번 조회했을 때 없던 행이 생기는 팬텀 현상을 막기 위한 장치입니다.&lt;/p&gt;
&lt;p&gt;Gap Lock은 X Lock, S Lock 에서만 걸립니다. &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;, &lt;code&gt;SELECT ... FOR SHARE&lt;/code&gt;, UPDATE, DELETE가 대상이며, 일반 SELECT는 애초에 Gap Lock도 없습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='gap-lock이-걸리는-기준'&gt;Gap Lock이 걸리는 기준&lt;/h3&gt;
&lt;p&gt;Gap Lock 이 걸리는 기준은  &lt;strong&gt;같은 조건의 결과가 나중에 늘어날 수 있는가&lt;/strong&gt;입니다. 늘어날 수 있으면 그 자리를 막아야 하므로 Gap Lock이 걸리고, 늘어날 수 없으면 걸리지 않습니다.&lt;/p&gt;
&lt;p&gt;늘어날 수 없는 경우는 하나뿐입니다. 유니크 인덱스를 &lt;code&gt;=&lt;/code&gt; 로 조회했고, 그 값의 행이 실제로 존재할 때입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;SELECT * FROM child WHERE id = 11 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;id&lt;/code&gt;가 기본 키이고 값이 11인 행이 존재한다면, 유니크 제약 때문에 11이 하나 더 생길 수 없습니다. 결과가 이미 확정되어 있으므로 레코드 하나에만 잠금이 걸립니다.&lt;/p&gt;
&lt;p&gt;그외의 대부분 경우 Gap Lock이 걸립니다. 범위 조회, 논유니크 인덱스, 인덱스를 타지 못하는 조건, 기본 키로 조회했더라도 해당 행이 없으면 걸립니다. 이 섹션 첫 쿼리 &lt;code&gt;WHERE id BETWEEN 10 AND 20 FOR UPDATE&lt;/code&gt; 예시가 기본 키인데도 Gap Lock이 걸리는 이유도 범위 조회이기 때문입니다.&lt;/p&gt;
&lt;p&gt;여기서 기준이 되는 것은 테이블에 인덱스가 있는지가 아니라 &lt;strong&gt;WHERE 조건이 어떤 인덱스를 타는지&lt;/strong&gt;입니다. 기본 키가 있는 테이블이라도 인덱스 없는 컬럼으로 조회하면 전체 스캔이 발생하고, 그 과정에서 만난 레코드와 gap이 모두 잠깁니다. 인덱스가 있더라도 옵티마이저가 전체 스캔을 선택하면 마찬가지입니다. 실제 잠금 범위가 궁금하다면 &lt;code&gt;EXPLAIN&lt;/code&gt;으로 실행 계획을 확인하는 것이 명확한 락의 범위를 파악할수 있습니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;READ COMMITTED&lt;/code&gt;로 격리 수준을 낮추면 검색과 인덱스 스캔에서 Gap Lock이 비활성화됩니다. 이 경우 Gap Lock은 외래 키 제약 검사와 중복 키 검사에만 사용됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='next-key-lock'&gt;Next-Key Lock&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Next-Key Lock은 인덱스 레코드에 대한 Record Lock과, 그 레코드 앞의 Gap Lock을 합친 잠금입니다.&lt;/strong&gt; &lt;code&gt;REPEATABLE READ&lt;/code&gt;에서 InnoDB가 검색과 인덱스 스캔에 사용하는 기본 잠금 방식이며, 이를 통해 팬텀 현상을 방지합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;공식 문서의 예시를 그대로 가져오면, 인덱스에 &lt;strong&gt;10, 11, 13, 20&lt;/strong&gt; 값이 있을 때 잠금 구간은 다음과 같이 나뉩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;(negative infinity, 10]
(10, 11]
(11, 13]
(13, 20]
(20, positive infinity)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;구간 표기가 &lt;code&gt;(이전 값, 현재 값]&lt;/code&gt; 형태인데, 즉 왼쪽은 열려 있고 오른쪽은 닫혀 있습니다. (&lt;code&gt;(10, 11]&lt;/code&gt;은 10은 포함하지 않고 11은 포함한다는 뜻입니다.) 
이것이 &amp;quot;레코드 + 그 앞의 gap&amp;quot;이라는 정의를 그대로 나타낸 것입니다. ( 인덱스 11 을 기준으로 보면 10 초과 11이하를 막으므로 레코드 + 그앞 gap 을 막는걸 확인할수 있습니다.) &lt;/p&gt;
&lt;p&gt;공식 문서는 이를 다음과 같이 설명합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If one session has a shared or exclusive lock on record R in an index, another session cannot insert a new index record in the gap immediately before R in the index order.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;마지막 구간에는 supremum이라는 개념이 등장합니다. 인덱스의 가장 큰 값보다 위쪽 구간을 잠글 때 사용하는 가상의 레코드입니다. supremum은 실제 인덱스 레코드가 아니므로, 이 next-key lock은 사실상 가장 큰 값 이후의 gap만 잠그는 효과를 냅니다. 위 구간에서 마지막 줄만 양쪽이 열려 있는 이유입니다 (20, positive infinity).&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id='기본은-next-key-lock이고-나머지는-축소된-형태입니다'&gt;기본은 Next-Key Lock이고, 나머지는 축소된 형태입니다&lt;/h4&gt;
&lt;p&gt;공식 문서는 &lt;code&gt;REPEATABLE READ&lt;/code&gt;에서 InnoDB가 검색과 인덱스 스캔에 next-key lock을 사용한다고 설명합니다. 즉 이것이 기본값이고, 앞서 본 Record Lock만 걸리는 경우가 오히려 예외입니다.&lt;/p&gt;
&lt;p&gt;문서에 명시된 축소 조건은 두 가지입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;유니크 인덱스로 단일 행을 검색해 잠그는 경우에는 gap locking이 필요하지 않습니다&lt;/li&gt;
&lt;li&gt;격리 수준을 &lt;code&gt;READ COMMITTED&lt;/code&gt;로 낮추면 검색과 인덱스 스캔에서 gap locking이 비활성화됩니다&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;정리하면 &lt;strong&gt;InnoDB는 일단 레코드와 그 앞의 gap을 함께 잠그고, 팬텀이 발생할 수 없다는 것이 보장될 때만 gap을 떼어냅니다.&lt;/strong&gt; 앞의 Gap Lock 섹션에서 정리한 기준이 곧 이 축소 조건입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;기본                          → Next-Key Lock (레코드 + 앞 gap)
  ├─ 유니크 인덱스 = 조회로 단일 행 특정  → Record Lock (gap lock 생략)
  └─ READ COMMITTED                    → Record Lock (gap lock 생략)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='insert-intention-lock'&gt;Insert Intention Lock&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Insert Intention Lock은 INSERT가 행을 넣기 직전에 잡는 특수한 형태의 Gap Lock입니다.&lt;/strong&gt; 같은 gap에 INSERT 하려는 여러 트랜잭션이, 서로 다른 위치에 넣는 것이라면 굳이 기다리지 않아도 되도록 의도를 표시하는 역할을 합니다.&lt;/p&gt;
&lt;p&gt;먼저 이 잠금이 왜 필요한지 보겠습니다. 레코드가 10과 20만 있고 그 사이가 비어 있다고 하겠습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- [A]
START TRANSACTION;
INSERT INTO child (id) VALUES (12);

-- [B]
START TRANSACTION;
INSERT INTO child (id) VALUES (15);   -- 대기 없이 성공
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;둘 다 같은 gap에 INSERT 하지만 넣으려는 위치가 다르므로 서로 기다릴 이유가 없습니다. &lt;strong&gt;Insert Intention Lock끼리는 충돌하지 않기 때문에 이렇게 동시에 통과할 수 있습니다.&lt;/strong&gt; 만약 INSERT가 해당 구간을 배타적으로 잡아버렸다면 뒤에 오는 INSERT가 모두 대기하게 됩니다.&lt;/p&gt;
&lt;p&gt;반대로 이미 다른 트랜잭션이 그 구간에 Gap Lock을 잡고 있다면 대기합니다. 공식 문서의 예시를 보면, 먼저 클라이언트 A가 범위 조회로 gap에 잠금을 잡습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- child 테이블에 id 값 90, 102가 존재
-- 클라이언트 A
START TRANSACTION;
SELECT * FROM child WHERE id &amp;gt; 100 FOR UPDATE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 상태에서 클라이언트 B가 그 구간에 INSERT를 시도하면, INSERT 전 Insert Intention Gap Lock을 요청, &lt;strong&gt;A의 Gap Lock과 충돌해서&lt;/strong&gt; 대기하게 됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- 클라이언트 B
START TRANSACTION;
INSERT INTO child (id) VALUES (101);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;정리하면 Insert Intention Lock끼리는 서로 막지 않고, 다른 트랜잭션이 잡아둔 Gap Lock에 의해서만 막힙니다. Gap Lock의 목적이 해당 구간으로의 INSERT를 막는 것이므로, &lt;strong&gt;사실상 Insert Intention Lock이 Gap Lock에 걸리는 유일한 대상입니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 잠금을 알아둬야 하는 실질적인 이유는 데드락 로그에서 보일수 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;-- 데드락 예시

-- [A]
SELECT * FROM child WHERE id BETWEEN 10 AND 20 FOR UPDATE;

-- [B]
SELECT * FROM child WHERE id BETWEEN 10 AND 20 FOR UPDATE; -- 통과! gap lock끼리 충돌 안 함

-- [A]
INSERT INTO child (id) VALUES (12); -- B의 gap lock에 막혀 대기

-- [B]
INSERT INTO child (id) VALUES (15); -- A의 gap lock에 막혀 대기 → 데드락
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;의 출력에 아래와 같은 문구가 있다면, 다른 트랜잭션이 잡고 있는 gap에 INSERT를 시도하다 대기에 걸린 상황입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;lock_mode X locks gap before rec insert intention waiting
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='참조'&gt;참조&lt;/h2&gt;
&lt;p&gt;&lt;a href='https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html'&gt;Mysql 공식문서&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/DB</category>
      <category>gap lock</category>
      <category>Intention Lock</category>
      <category>next key lock</category>
      <category>record lock</category>
      <category>S Lock</category>
      <category>X Lock</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/292</guid>
      <comments>https://cdchan.tistory.com/292#entry292comment</comments>
      <pubDate>Mon, 20 Jul 2026 11:02:37 +0900</pubDate>
    </item>
    <item>
      <title>Clustering, Replication, Sharding</title>
      <link>https://cdchan.tistory.com/291</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;Clustering, Replication, Sharding&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div class=&quot;typora-post&quot;&gt;
  &lt;p&gt;단일 DB 서버로 구성된 상황에서 트래픽이 몰리거나, 더 이상 단일 서버로 운영하기 어려워질 수 있습니다. 상황에 따라 필요한 방법은 다르지만, 대표적으로 쓰이는 &lt;strong&gt;Clustering, Replication, Sharding&lt;/strong&gt;에 대해 설명합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='clustering'&gt;Clustering&lt;/h2&gt;
&lt;p&gt;클러스터링은 하나가 아닌 여러 개의 서버로 구성하는 것을 말합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;주목적&lt;/strong&gt;: 가용성 + 부하 분산
&lt;strong&gt;예시&lt;/strong&gt;: 서버 A가 죽어도 B가 요청을 이어받아 서비스가 멈추지 않고(가용성), 평소에는 두 서버가 요청을 나눠 처리한다(부하 분산).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/clustering-single-db.svg&quot; alt=&quot;clustering-single-db&quot;&gt;&lt;/p&gt;
&lt;p&gt;위 상황은 단일 DB 서버를 통해 DB 스토리지에서 원하는 데이터를 서빙하는 상황입니다.
이때 DB 서버에 문제가 생길 경우, 해당 문제는 곧 서비스 전체 장애로 이어질 수 있습니다.&lt;/p&gt;
&lt;p&gt;따라서 위와 같은 상황을 막기 위해 단일 DB 서버가 아닌, 2개 이상의 DB 서버를 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='active--active'&gt;Active &amp;amp; Active&lt;/h3&gt;
&lt;p&gt;2개 이상의 DB 서버를 동시에 운영한다면, A 서버에 장애가 발생해도 B 서버가 요청을 처리하여 서비스를 이어갈 수 있습니다.&lt;/p&gt;
&lt;p&gt;또한 두 서버가 요청을 나누어 처리하므로, 기존 하나의 DB 서버가 받던 부하를 분산시킬 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/clustering-active-and-active.svg&quot; alt=&quot;clustering-active-and-active&quot;&gt;&lt;/p&gt;
&lt;p&gt;단점 또한 존재합니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;두 서버를 동시에 운영하는 만큼 서버 비용이 증가합니다.&lt;/li&gt;
&lt;li&gt;그림처럼 A 서버와 B 서버가 하나의 DB 스토리지를 공유한다면, 두 서버를 운영하더라도 같은 데이터에 대한 Lock 경합과 스토리지 부하가 남아 있을 수 있습니다. 즉 DB 서버를 늘려도 공유 스토리지가 병목 지점이 될 수 있습니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='active--stand-by'&gt;Active &amp;amp; Stand-By&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Active &amp;amp; Stand-By&lt;/strong&gt;는 평소에는 하나의 서버만 요청을 처리하고, 다른 서버는 장애에 대비해 준비해 두는 방식입니다. Active &amp;amp; Active처럼 두 서버가 동시에 트래픽을 나누어 처리하지는 못하지만, Active 서버에 장애가 발생하면 Stand-By 서버로 Failover하여 서비스를 이어갈 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/clustering-active-and-standby.svg&quot; alt=&quot;clustering-active-and-standby&quot;&gt;&lt;/p&gt;
&lt;p&gt;A 서버가 정상적으로 운영 중일 때는 B 서버가 사용자 요청을 처리하지 않습니다. A 서버에 문제가 생기면 Failover하여 B 서버가 Active 역할을 맡을 수 있습니다.&lt;/p&gt;
&lt;p&gt;다만 Stand-By 서버가 항상 꺼져 있다는 뜻은 아닙니다. 장애를 얼마나 빨리 넘길지에 따라 준비 방식이 나뉩니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cold Standby&lt;/strong&gt;: B 서버를 꺼 두거나 즉시 요청을 처리할 준비가 되지 않은 상태로 둡니다. A 서버에 문제가 생긴 뒤 B 서버를 시작하고 필요한 준비를 해야 하므로, 그 시간만큼 서비스가 중단될 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hot Standby&lt;/strong&gt;: B 서버를 이미 실행해 두고, 장애 발생 시 빠르게 Active 역할로 전환할 수 있도록 준비합니다. 전환 시간은 짧아지지만, B 서버를 계속 운영하는 비용이 필요합니다.&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;즉 Cold Standby는 비용을 줄이는 대신 장애 전환 시간이 길어질 수 있고, Hot Standby는 빠른 전환을 위해 비용을 더 지불하는 방식입니다.&lt;/p&gt;
&lt;p&gt;또한 Active &amp;amp; Stand-By에서는 Stand-By 서버가 평소 사용자 요청을 처리하지 않으므로, 트래픽을 분산하는 목적으로는 사용할 수 없습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='replication'&gt;Replication&lt;/h2&gt;
&lt;p&gt;Replication은 말 그대로 데이터를 복제하는 것을 의미합니다. 일반적으로 Master(Primary) 서버의 변경 데이터를 Slave(Replica) 서버에 전달하여, 여러 DB가 같은 데이터 사본을 유지하도록 합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;주목적&lt;/strong&gt;: 읽기 확장 (부수적으로 가용성)
&lt;strong&gt;예시&lt;/strong&gt;: 쓰기는 Master가, 조회는 여러 Slave가 나눠 맡아 조회 트래픽이 몰려도 견딤!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/replication-master-slave.svg&quot; alt=&quot;replication-master-slave&quot;&gt;&lt;/p&gt;
&lt;p&gt;위 그림과 같이 쓰기를 처리하는 Master 서버와, Master 서버의 데이터를 복제한 Slave 서버로 나뉘게 됩니다.&lt;/p&gt;
&lt;p&gt;대부분의 서비스는 글 작성보다 글 조회가 훨씬 많습니다. 따라서 Master가 모든 쓰기와 조회를 처리하는 대신, Slave에게 조회를 맡기면 Master는 데이터 변경에 집중할 수 있습니다.&lt;/p&gt;
&lt;p&gt;또한 Master DB에 문제가 생겼을 때 Slave DB를 새로운 Master로 승격하여 Failover할 수 있도록 구성할 수 있습니다. 다만 승격과 애플리케이션의 연결 전환은 별도로 준비해야 합니다.&lt;/p&gt;
&lt;p&gt;비동기로 복제하는 경우 Master의 변경 내용이 Slave에 반영되기까지 시간이 걸릴 수 있습니다. 이를 &lt;strong&gt;Replication Lag&lt;/strong&gt; 라고 부릅니다.&lt;/p&gt;
&lt;p&gt;즉 Replication은 조회 성능과 읽기 확장에는 좋지만, Slave에서 조회한 데이터가 항상 가장 최신 데이터는 아닐 수 있다는 점을 고려해야 합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='sharding'&gt;Sharding&lt;/h2&gt;
&lt;p&gt;Sharding은 데이터를 복사하는 것이 아니라, 여러 DB에 나누어 저장하는 방식입니다. 어떤 DB에 저장할지는 &lt;code&gt;userId&lt;/code&gt;와 같은 &lt;strong&gt;Sharding Key&lt;/strong&gt;를 기준으로 결정합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;주목적&lt;/strong&gt;: 쓰기·저장 용량 확장
&lt;strong&gt;예시&lt;/strong&gt;: 회원 200만 명 데이터를 한 DB가 감당 못 할 때, userId 기준으로 Shard 1·2에 나눠 저장합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/sharding-by-user-id.svg&quot; alt=&quot;sharding-by-user-id&quot;&gt;&lt;/p&gt;
&lt;p&gt;위 예시에서는 &lt;code&gt;userId&lt;/code&gt;가 1부터 1,000,000까지인 데이터는 Shard 1에, 1,000,001부터 2,000,000까지인 데이터는 Shard 2에 저장합니다.&lt;/p&gt;
&lt;p&gt;앞서 다룬 Replication에서는 Master와 Slave가 같은 회원 정보 전체를 들고 있지만, Sharding에서는 각 DB가 자기 몫의 데이터만 들고 있습니다. 따라서 데이터가 커, 하나의 DB 저장 공간이나 쓰기 처리량만으로 감당하기 어려울 때 Sharding을 고려할 수 있습니다.&lt;/p&gt;
&lt;p&gt;다만 Sharding에도 단점이 존재합니다. 애플리케이션은 어떤 사용자의 데이터가 어느 Shard에 있는지 알아야 하며, 서로 분리된 Shard의 데이터를 함께 조회하거나 Join해야 하면 처리가 복잡하고 비용이 커집니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='partitioning'&gt;Partitioning&lt;/h3&gt;
&lt;p&gt;Partitioning도 데이터를 나누는 방법이지만, 하나의 DB 서버 안에서 테이블을 여러 조각으로 나누는 방식입니다. 반면 Sharding은 데이터를 물리적으로 여러 DB 서버에 나누어 저장합니다.&lt;/p&gt;
&lt;p&gt;따라서 하나의 DB 서버 자원으로 감당 가능하고, 파티션 키를 기준으로 조회 범위를 줄일 수 있다면 Partitioning을 고려할 수 있습니다. 반면 저장 용량이나 쓰기 처리량 자체가 서버 한계에 도달했다면 Sharding을 고려해야 합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='결론'&gt;결론&lt;/h2&gt;
&lt;p&gt;결론적으로 &lt;strong&gt;Clustering은 서버 장애·부하&lt;/strong&gt;, &lt;strong&gt;Replication은 읽기 부하&lt;/strong&gt;, &lt;strong&gt;Sharding은 쓰기·용량 한계&lt;/strong&gt;를 담당합니다.&lt;/p&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;상황&lt;/th&gt;&lt;th&gt;더 어울리는 방법&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;조회 요청이 너무 많음&lt;/td&gt;&lt;td&gt;Replication&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;데이터 용량, 쓰기 요청이 한 DB 한계를 넘음&lt;/td&gt;&lt;td&gt;Sharding&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;서버 장애에도 서비스를 유지하고 싶음&lt;/td&gt;&lt;td&gt;Active &amp;amp; Stand-By 또는 Active &amp;amp; Active&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Shard 하나가 장애 나도 대비하고 싶음&lt;/td&gt;&lt;td&gt;각 Shard에 Replication 적용&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/DB</category>
      <category>레플리케이션</category>
      <category>샤딩</category>
      <category>클러스터링</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/291</guid>
      <comments>https://cdchan.tistory.com/291#entry291comment</comments>
      <pubDate>Thu, 16 Jul 2026 13:14:08 +0900</pubDate>
    </item>
    <item>
      <title>다중 인스턴스 환경에 ShedLock 적용하기</title>
      <link>https://cdchan.tistory.com/290</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;ᅵ&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class=&quot;typora-post&quot;&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='상황'&gt;상황&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;@Scheduled&lt;/code&gt;가 붙은 메서드는 애플리케이션 인스턴스마다 등록됩니다. 따라서 다중 인스턴스 환경에서는 같은 스케줄러가 여러 번 실행되어 기대했던 동작과 다른 결과를 만들 수 있습니다.&lt;/p&gt;
&lt;p&gt;서버가 한 대일 때는 문제가 없지만, 무중단 배포를 위한 blue/green 환경, 오토 스케일링, 이중화 구성으로 애플리케이션 인스턴스가 둘 이상이 되면 모든 서버가 같은 시각에 같은 작업을 실행합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;00:00

서버 A: @Scheduled 작업 실행
서버 B: @Scheduled 작업 실행
서버 C: @Scheduled 작업 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이러한 중복 실행을 막기 위해 &lt;strong&gt;ShedLock&lt;/strong&gt;을 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='shedlock-이란'&gt;ShedLock 이란?&lt;/h2&gt;
&lt;p&gt;ShedLock은 여러 인스턴스가 같은 스케줄러를 동시에 실행하지 않도록 돕는 분산 락 라이브러리입니다.&lt;/p&gt;
&lt;p&gt;DB, Redis 같은 외부 저장소에 락 정보를 기록하고, 스케줄러 메서드를 실행하기 전에 락 획득을 시도합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;서버 A ─┐
서버 B ─┼─ &amp;quot;daily-batch&amp;quot; 락 획득 시도 ─ 공유 DB의 shedlock 테이블
서버 C ─┘

락 획득 성공: 메서드 본문 실행
락 획득 실패: 메서드 본문을 실행하지 않고 이번 주기 스킵
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락을 획득한 인스턴스만 스케줄러 본문을 실행하고, 나머지 인스턴스는 대기하지 않고 이번 실행을 건너뜁니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='설정'&gt;설정&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='1-의존성을-추가합니다'&gt;1. 의존성을 추가합니다&lt;/h3&gt;
&lt;p&gt;ShedLock은 Spring 연동 모듈과 락 저장소별 Provider를 함께 추가합니다. DB를 공유 저장소로 사용할 경우 JDBC Provider를 사용합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;dependencies {
    implementation &amp;quot;net.javacrumbs.shedlock:shedlock-spring:5.16.0&amp;quot;
    implementation &amp;quot;net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 라이브러리의 버전은 반드시 동일하게 맞춰야 합니다.&lt;/p&gt;
&lt;p&gt;현재 프로젝트는 Spring Boot 3.2를 사용하므로, 공식 호환표에서 Spring Boot 3.2 대상인 ShedLock 5.x 중 &lt;code&gt;5.16.0&lt;/code&gt;을 사용했습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/schedLock-version.png&quot; alt=&quot;schedLock-version.png|419&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='2-락-테이블을-생성합니다'&gt;2. 락 테이블을 생성합니다&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;CREATE TABLE shedlock (
    name VARCHAR(64) NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL,
    locked_by VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;ShedLock은 이 테이블의 한 행으로 락을 관리합니다. 각 필드의 역할은 다음과 같습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;: 락의 식별자입니다. 같은 이름의 작업끼리만 경쟁하므로 PK여야 합니다. 여러 인스턴스가 동시에 같은 락을 만들려 해도 DB는 한 행만 보장합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lock_until&lt;/code&gt;: 이 시각 전까지 다른 인스턴스는 같은 락을 얻을 수 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;locked_at&lt;/code&gt;: 락을 획득한 시각입니다. 락이 언제부터 유지됐는지 확인할 때 사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;locked_by&lt;/code&gt;: 락을 획득한 인스턴스 식별자입니다. 어떤 서버가 현재 작업을 실행 중인지 운영 환경에서 확인할 수 있습니다.&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;예를 들어 서버 A가 &lt;code&gt;shed-lock-example-scheduler&lt;/code&gt; 락을 얻으면 &lt;code&gt;lock_until&lt;/code&gt;을 미래 시각으로 갱신합니다. 서버 B는 같은 이름의 락을 시도하지만 아직 만료 전이므로 획득하지 못하고, 메서드 본문을 실행하지 않습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='3-scheduler-lock을-활성화하고-lockprovider를-등록합니다'&gt;3. Scheduler Lock을 활성화하고 LockProvider를 등록합니다&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-java' lang='java'&gt;@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = &amp;quot;1h&amp;quot;)
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@EnableSchedulerLock(defaultLockAtMostFor = &amp;quot;1h&amp;quot;)&lt;/code&gt;의 &lt;code&gt;defaultLockAtMostFor&lt;/code&gt;는 각 &lt;code&gt;@SchedulerLock&lt;/code&gt;에서 값을 따로 주지 않았을 때 사용할 최대 락 유지 시간입니다. 서버가 작업 중 종료되더라도 이 시간이 지나면 락이 만료되어 다른 서버가 작업을 시작할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lockAtMostFor&lt;/code&gt;는 락을 반드시 그 시간만큼 유지한다는 뜻이 아닙니다. 작업이 정상 종료되면 락은 바로 해제됩니다. 이 값은 서버 장애 시 락이 영구히 남지 않도록 하는 최대 만료 시간이며, 실제 작업의 &lt;strong&gt;최장 실행 시간보다 충분히 길어야&lt;/strong&gt; 합니다. 작업이 끝나기 전에 시간이 만료되면 다른 서버도 락을 획득해 중복 실행될 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;usingDbTime()&lt;/code&gt;은 앱 서버 시간이 아닌 DB 시간을 기준으로 락을 판단하게 합니다. 여러 서버의 시스템 시간이 다른 경우가 있을수 있기에, DB 의 시간을 기준으로 사용합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='4-중복-실행을-막을-메서드에-락-이름을-부여합니다'&gt;4. 중복 실행을 막을 메서드에 락 이름을 부여합니다&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-java' lang='java'&gt;@Component
@RequiredArgsConstructor
public class ShedLockExampleScheduler {

    private final SchedulerLoggerProvider schedulerLoggerProvider;
    private final Environment environment;

    @Scheduled(fixedRate = 60000)
    @SchedulerLock(
        name = &amp;quot;shed-lock-example-scheduler&amp;quot;,
        lockAtMostFor = &amp;quot;1m&amp;quot;,
        lockAtLeastFor = &amp;quot;59s&amp;quot;
    )
    public void logLockAcquisition() {
		 // 로직
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;name&lt;/code&gt;은 프로젝트 전체에서 고유해야 합니다. 서로 다른 스케줄러가 같은 이름을 쓰면 의도치 않게 서로의 실행을 막습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lockAtMostFor = &amp;quot;1m&amp;quot;&lt;/code&gt;은 이 예시 작업에만 적용하는 최대 락 유지 시간입니다. 서버가 작업 중 종료되면 최대 1분 뒤 다른 서버가 락을 얻을 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lockAtLeastFor = &amp;quot;59s&amp;quot;&lt;/code&gt;는 락을 획득한 시점부터 락을 &lt;strong&gt;최소로 유지할 시간&lt;/strong&gt;입니다. 작업이 바로 끝나도 ShedLock은 &lt;code&gt;lock_until&lt;/code&gt;을 59초 미래로 설정하여 작업이 끝나더라도 다른 스케줄러가 중복해서 스케줄러를 실행하지 않도록 막습니다.&lt;/p&gt;
&lt;p&gt;따라서 &lt;code&gt;lockAtLeastFor&lt;/code&gt;는 “정확히 1분마다 실행”을 보장하기보다, &lt;strong&gt;같은 락 이름의 작업이 최소 59초 동안 다시 실행되지 않도록 보장&lt;/strong&gt;합니다. 어느 인스턴스가 락을 먼저 얻을지는 실행 타이밍에 따라 달라질 수 있지만, 같은 주기에 두 인스턴스가 모두 실행되는 문제는 막을 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='검증'&gt;검증&lt;/h2&gt;
&lt;pre&gt;&lt;code class='language-java' lang='java'&gt;@Component
@RequiredArgsConstructor
public class ShedLockExampleScheduler {

    private final SchedulerLoggerProvider schedulerLoggerProvider;
    private final Environment environment;

    @Scheduled(fixedRate = 60000)
    @SchedulerLock(
        name = &amp;quot;shed-lock-example-scheduler&amp;quot;,
        lockAtMostFor = &amp;quot;1m&amp;quot;,
        lockAtLeastFor = &amp;quot;59s&amp;quot;
    )
    public void logLockAcquisition() {
        long startTime = System.currentTimeMillis();
        String serverPort = environment.getProperty(&amp;quot;server.port&amp;quot;);

        schedulerLoggerProvider.getLogger()
            .info(&amp;quot;[ShedLock Example] Lock acquired. serverPort: {}, activeProfiles: {}&amp;quot;, serverPort,
                String.join(&amp;quot;,&amp;quot;, environment.getActiveProfiles()));

        long endTime = System.currentTimeMillis();
        schedulerLoggerProvider.getLogger()
            .info(&amp;quot;[ShedLock Example] Finished in {} ms&amp;quot;, endTime - startTime);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;intelljj 에서 compound 를 활용하여 2개의 서버를 띄워
실제로 하나의 서버에서만 스케줄러가 돌아가는지 검증해보겠습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-07-13%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%205.35.23.png&quot; alt=&quot;스크린샷 2026-07-13 오후 5.35.23.png&quot;&gt;
&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-07-13%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%205.35.45.png&quot; alt=&quot;스크린샷 2026-07-13 오후 5.35.23.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;결과를 보면 2개의 Application이 동시에 떳지만, 9002 번포트를 사용한 Application 에서만 스케줄러 lock 이 실행된것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;usingDbTime()&lt;/code&gt;을 사용하는 MySQL ShedLock은 &lt;code&gt;UTC_TIMESTAMP(3)&lt;/code&gt;을 기준으로 락 시간을 계산합니다. 아래 조회는 DB 세션이 UTC인 상태에서 &lt;code&gt;CONVERT_TZ&lt;/code&gt;를 사용해, 조회 결과만 KST로 변환한 것입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-sql' lang='sql'&gt;SELECT
    name,
    CONVERT_TZ(locked_at, &amp;#39;+00:00&amp;#39;, &amp;#39;+09:00&amp;#39;) AS locked_at_kst,
    CONVERT_TZ(lock_until, &amp;#39;+00:00&amp;#39;, &amp;#39;+09:00&amp;#39;) AS lock_until_kst,
    locked_by
FROM shedlock;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-07-13%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%205.36.54.png&quot; alt=&quot;스크린샷 2026-07-13 오후 5.36.54.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;조회 결과에서 &lt;code&gt;lockAtLeastFor = &amp;quot;59s&amp;quot;&lt;/code&gt;에 따라 17:34:50에 락이 시작됐고, 59초 뒤인 17:35:49까지 &lt;code&gt;lock_until&lt;/code&gt;이 적용된 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='reference'&gt;Reference&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href='https://github.com/lukas-krecan/ShedLock'&gt;ShedLock 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href='https://github.com/lukas-krecan/ShedLock#jdbc-template'&gt;ShedLock JDBC LockProvider 문서&lt;/a&gt;&lt;/li&gt;

&lt;/ul&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Framework/Spring</category>
      <category>ShedLock</category>
      <category>다중환경</category>
      <category>스케줄러</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/290</guid>
      <comments>https://cdchan.tistory.com/290#entry290comment</comments>
      <pubDate>Mon, 13 Jul 2026 18:03:05 +0900</pubDate>
    </item>
    <item>
      <title>앱 본인인증에서 전용 쿠키를 따로 만든 이유</title>
      <link>https://cdchan.tistory.com/289</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;div class=&quot;typora-post&quot;&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;앱 본인인증에서 전용 쿠키를 따로 만든 이유&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;&lt;h1 id='앱-본인인증에서-전용-쿠키를-따로-만든-이유'&gt;앱 본인인증에서 전용 쿠키를 따로 만든 이유&lt;/h1&gt;
&lt;p&gt;외부 본인인증 서비스를 앱 및 웹(WebView) 방식으로 연동하면서, 앱 방식에서만 전용 쿠키(&lt;code&gt;CERT_SESSION_KEY&lt;/code&gt;)를 별도로 두게 된 이유와, 그 과정에서 CORS와 SameSite가 어떻게 맞물리는지 실제로 검증한 기록입니다.&lt;/p&gt;
&lt;p&gt;현재 저희 서버는 앱, 웹 방식을 모두 지원하고 있으며, 앱 및 웹 모두 하나의 웹뷰를 통해 구현이 되어있는 상태입니다. 또한 회원은 cookie 에 존재하는 jwt 토큰을 통해 식별합니다.&lt;/p&gt;
&lt;p&gt;한 줄 결론: &lt;strong&gt;앱 방식의 콜백은 외부 도메인에서 오기 때문에 JWT(&lt;code&gt;SameSite=Lax&lt;/code&gt;)가 실리지 않습니다.&lt;/strong&gt; 그래서 &lt;code&gt;SameSite=None&lt;/code&gt;으로 설정한 전용 임시 토큰 쿠키를 별도로 두게 됐습니다.&lt;/p&gt;
&lt;p&gt;그리고 이 콜백이 실제로 서버에 &lt;em&gt;도달&lt;/em&gt;하려면 CORS allowlist도 함께 필요합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 id='배경-앱-방식-vs-웹-방식-본인인증'&gt;배경: 앱 방식 vs 웹 방식 본인인증&lt;/h1&gt;
&lt;p&gt;본인인증 결과(&lt;code&gt;data&lt;/code&gt;)를 &lt;strong&gt;누가 저희 서버로 가져오느냐&lt;/strong&gt;가 두 방식의 핵심 차이입니다.&lt;/p&gt;
&lt;h2 id='웹-방식-spa브라우저'&gt;웹 방식 (SPA/브라우저)&lt;/h2&gt;
&lt;ol start='' &gt;
&lt;li&gt;FE가 인증된 상태로 토큰 발급 API 호출 → &lt;code&gt;@AuthenticationPrincipal&lt;/code&gt;로 &lt;code&gt;memberId&lt;/code&gt; 식별&lt;/li&gt;
&lt;li&gt;FE가 표준창을 띄우고 인증 결과를 &lt;strong&gt;FE가 직접 받음&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;FE가 다시 인증된 상태로 결과 처리 API 호출 → JWT로 식별&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;여기서 &amp;quot;JWT를 붙인다&amp;quot;는 건 헤더 토큰이 아니라 &lt;strong&gt;쿠키&lt;/strong&gt;입니다. 웹 클라이언트는 로그인 시 JWT(&lt;code&gt;accessToken&lt;/code&gt;/&lt;code&gt;refreshToken&lt;/code&gt;)를 &lt;code&gt;Set-Cookie&lt;/code&gt;로 받고, 이후 모든 API 호출에서 이 쿠키가 자동으로 실려갑니다. 핵심은 이 호출들이 &lt;strong&gt;저희 FE → 저희 API&lt;/strong&gt;, 즉 &lt;strong&gt;same-site&lt;/strong&gt;라는 점입니다. same-site 요청엔 &lt;code&gt;SameSite=Lax&lt;/code&gt; 쿠키도 항상 실리므로 별도 처리가 필요 없습니다.&lt;/p&gt;
&lt;h2 id='앱-방식-webview'&gt;앱 방식 (WebView)&lt;/h2&gt;
&lt;p&gt;앱 방식에서는 &lt;strong&gt;인증 완료 후 외부 인증 도메인이 직접 저희 서버 콜백으로 POST&lt;/strong&gt;합니다. 즉, 결과를 가져오는 주체가 저희 FE가 아닌 외부 도메인입니다.&lt;/p&gt;
&lt;p&gt;이 콜백은 cross-site 요청이므로 &lt;code&gt;SameSite=Lax&lt;/code&gt;로 설정된 JWT 쿠키는 실리지 않습니다. JWT로 회원을 식별하는 웹 방식과 달리, 앱 방식에서는 JWT를 사용할 수 없습니다.&lt;/p&gt;
&lt;p&gt;따라서 &lt;strong&gt;임시 토큰 발급 패턴&lt;/strong&gt;을 사용합니다. 인증 시작 시점에 임시 토큰을 발급해 &lt;code&gt;SameSite=None&lt;/code&gt; 쿠키로 미리 심어두고, 콜백에서 그 토큰으로 Redis를 역조회해 &lt;code&gt;memberId&lt;/code&gt;를 식별하는 방식입니다.&lt;/p&gt;
&lt;p&gt;흐름은 다음과 같습니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;WebView가 view 엔드포인트 호출 → 서버가 Redis에 임시토큰·&lt;code&gt;memberId&lt;/code&gt; 매핑 저장 + &lt;code&gt;CERT_SESSION_KEY&lt;/code&gt; 쿠키를 &lt;code&gt;Set-Cookie&lt;/code&gt;로 내려줌&lt;/li&gt;
&lt;li&gt;표준창에서 본인인증 진행&lt;/li&gt;
&lt;li&gt;인증 완료 후 &lt;strong&gt;표준창(&lt;code&gt;cert.example.com&lt;/code&gt;)이 returnUrl로 직접 POST&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;그 콜백에서 &lt;code&gt;@CookieValue CERT_SESSION_KEY&lt;/code&gt;로 &lt;code&gt;memberId&lt;/code&gt; 역조회&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;이 쿠키는 cross-site POST에도 실려올 수 있도록 의도적으로 아래와 같이 설정되어 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-java' lang='java'&gt;ResponseCookie.from(CERT_SESSION_KEY, tempToken)
    .httpOnly(true)
    .secure(true)
    .path(&amp;quot;/api/cert/authentication&amp;quot;)
    .sameSite(&amp;quot;None&amp;quot;)   // cross-site로도 따라오게
    .build();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 id='처음의-오판-navigation은-cors-대상이-아니다'&gt;처음의 오판: &amp;quot;navigation은 CORS 대상이 아니다&amp;quot;&lt;/h1&gt;
&lt;p&gt;콜백 핸들러는 대략 이렇게 생겼습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-java' lang='java'&gt;@PostMapping(&amp;quot;/cert/authentication&amp;quot;)
public ResponseEntity&amp;lt;Void&amp;gt; getCertResultForApp(
        @RequestParam(DATA) String data,
        @CookieValue(value = CERT_SESSION_KEY, required = false) String token) {
    certFacade.processCertResultForApp(token, data);
    return ResponseEntity.status(HttpStatus.FOUND)   // 302 redirect
        .location(...)
        .build();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음엔 이렇게 추론했습니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&amp;quot;이건 표준창이 저희 returnUrl로 보내는 &lt;strong&gt;top-level form POST navigation&lt;/strong&gt;이다. 브라우저 스펙상 navigation은 CORS의 응답검사 대상이 아니다(폼은 cross-origin으로 자유롭게 POST된다 — 그래서 CSRF가 존재한다). 따라서 CORS와 무관하고, 동작하는 진짜 이유는 SameSite=None 쿠키다.&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;이 추론의 &lt;strong&gt;앞부분(브라우저 레벨)은 맞지만, 결론은 틀렸습니다.&lt;/strong&gt; 빠뜨린 게 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spring &lt;code&gt;CorsFilter&lt;/code&gt;는 브라우저 CORS와 별개의 서버측 게이트입니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;CorsFilter&lt;/code&gt;는 보안 필터 체인 앞단에 걸려 있고, 요청에 &lt;code&gt;Origin&lt;/code&gt; 헤더가 있고 그 값이 allowlist에 없으면 &lt;strong&gt;navigation이든 XHR이든 상관없이 컨트롤러 도달 전에 403으로 잘라버립니다.&lt;/strong&gt; Spring은 &lt;code&gt;Sec-Fetch-Mode&lt;/code&gt; 같은 값을 보지 않습니다. 오직 &lt;code&gt;Origin&lt;/code&gt; 헤더 + allowlist만 확인합니다.&lt;/p&gt;
&lt;p&gt;즉 &amp;quot;브라우저가 navigation을 안 막는다&amp;quot;와 &amp;quot;저희 서버 CorsFilter가 Origin을 보고 막는다&amp;quot;는 완전히 다른 층위의 이야기였습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 id='검증-1-t1-콜백-요청의-실체-관측'&gt;검증 1 (T1): 콜백 요청의 실체 관측&lt;/h1&gt;
&lt;p&gt;위 사항을 확인하기 위해 콜백 진입 시 실제 요청 헤더를 로깅했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;method=POST
origin=https://cert.example.com      ← Origin 헤더가 실제로 붙어 옴
referer=https://cert.example.com/
secFetchSite=cross-site
secFetchMode=navigate                    ← XHR(cors)이 아니라 top-level navigation
contentType=application/x-www-form-urlencoded
cookiePresent=true
cookieHeader=certSessionKey=...;
responseStatus=302 (성공)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;읽어낸 사실들입니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Origin 헤더가 붙습니다.&lt;/strong&gt; POST form 제출은 스펙상 &lt;code&gt;Origin&lt;/code&gt;을 포함합니다. Spring &lt;code&gt;CorsFilter&lt;/code&gt;가 관여할 수밖에 없습니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;navigation입니다.&lt;/strong&gt; &lt;code&gt;Sec-Fetch-Mode: navigate&lt;/code&gt; + &lt;code&gt;Content-Type: x-www-form-urlencoded&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;simple request라 preflight가 없습니다.&lt;/strong&gt; &lt;code&gt;form-urlencoded&lt;/code&gt; POST는 preflight 대상이 아니므로 본 요청이 바로 서버에 닿습니다.&lt;/li&gt;
&lt;li&gt;이 시점엔 &lt;code&gt;cert.example.com&lt;/code&gt;이 allowlist에 &lt;strong&gt;있었기 때문에&lt;/strong&gt; 302로 통과했습니다.&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 id='검증-2-t2-cors를-빼면-정말-막히는가'&gt;검증 2 (T2): CORS를 빼면 정말 막히는가&lt;/h1&gt;
&lt;p&gt;T1에서 &amp;quot;Origin 헤더가 붙는다&amp;quot;는 걸 확인했으므로, &lt;code&gt;CorsFilter&lt;/code&gt;가 게이트라는 건 거의 확실했습니다. 못 박기 위해 dev allowlist에서 &lt;code&gt;cert.example.com&lt;/code&gt;만 제거하고 동일하게 본인인증을 돌렸습니다.&lt;/p&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;요청&lt;/th&gt;&lt;th&gt;결과&lt;/th&gt;&lt;th&gt;해석&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/cert/web/authentication&lt;/code&gt; (웹)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;200 정상&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;호출 주체가 저희 FE → allowlist에 있음 → 통과&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;/cert/authentication&lt;/code&gt; (앱 콜백)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;403 &amp;quot;Invalid CORS request&amp;quot;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;호출 주체가 &lt;code&gt;cert.example.com&lt;/code&gt; → allowlist에서 빠짐 → 차단&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;p&gt;결정적 증거 두 가지입니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;&lt;strong&gt;T1 로깅이 안 찍혔다&lt;/strong&gt; + &lt;code&gt;requestBody&lt;/code&gt;가 비어있었습니다 → 컨트롤러 진입 자체를 못 했습니다. &lt;code&gt;CorsFilter&lt;/code&gt;가 앞단에서 잘랐다는 직접 증거입니다.&lt;/li&gt;
&lt;li&gt;같은 흐름에서 &lt;strong&gt;웹은 멀쩡히 200, 앱만 403&lt;/strong&gt; → 차이는 오직 &lt;strong&gt;호출 Origin&lt;/strong&gt;이 allowlist에 있느냐 하나뿐입니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 id='최종-정리'&gt;최종 정리&lt;/h1&gt;
&lt;p&gt;cross-site POST 콜백이 동작하려면 &lt;strong&gt;두 조건이 동시에&lt;/strong&gt; 필요합니다. 하나라도 빠지면 실패하지만, 실패 양상이 다릅니다.&lt;/p&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;메커니즘&lt;/th&gt;&lt;th&gt;역할&lt;/th&gt;&lt;th&gt;빠지면&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;CORS allowlist&lt;/strong&gt; (&lt;code&gt;cert.example.com&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;요청이 컨트롤러까지 &lt;em&gt;도달&lt;/em&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;403&lt;/strong&gt; — 컨트롤러 진입조차 못 함&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;SameSite=None 쿠키&lt;/strong&gt; (&lt;code&gt;CERT_SESSION_KEY&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;회원 식별 쿠키가 요청에 &lt;em&gt;실려옴&lt;/em&gt;&lt;/td&gt;&lt;td&gt;도달은 하나 &lt;code&gt;token=null&lt;/code&gt; → 식별 실패&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;p&gt;작동 메커니즘 요약입니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;외부 표준창이 결과를 저희 returnUrl로 &lt;strong&gt;cross-site form POST(navigate)&lt;/strong&gt; 합니다.&lt;/li&gt;
&lt;li&gt;그 요청엔 &lt;code&gt;Origin: ...cert.com&lt;/code&gt; 헤더가 붙습니다.&lt;/li&gt;
&lt;li&gt;Spring &lt;code&gt;CorsFilter&lt;/code&gt;가 Origin을 검사합니다 → allowlist에 있어야 통과(없으면 403). &lt;strong&gt;← CORS 필요&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;통과한 요청엔 &lt;code&gt;SameSite=None&lt;/code&gt; 쿠키만 실려옵니다(&lt;code&gt;Lax&lt;/code&gt; 쿠키는 cross-site POST에 미전송). &lt;strong&gt;← None 쿠키 필요&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;컨트롤러가 쿠키 토큰으로 Redis에서 &lt;code&gt;memberId&lt;/code&gt;를 역조회해 처리합니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 id='결론'&gt;결론&lt;/h1&gt;
&lt;p&gt;웹 방식은 저희 FE가 인증 결과를 받아 API를 호출하므로 same-site 요청입니다. &lt;code&gt;SameSite=Lax&lt;/code&gt; JWT 쿠키가 그대로 실려오기 때문에 별도 처리가 필요 없습니다.&lt;/p&gt;
&lt;p&gt;앱 방식은 외부 인증 도메인이 콜백을 직접 POST하므로 cross-site 요청입니다. &lt;code&gt;SameSite=Lax&lt;/code&gt; 쿠키는 cross-site POST에 실리지 않기 때문에 JWT로 회원을 식별할 수 없습니다. 그래서 인증 시작 시점에 임시 토큰을 발급해 &lt;code&gt;SameSite=None&lt;/code&gt; 쿠키로 미리 심어두는 전용 쿠키가 필요했습니다.&lt;/p&gt;
&lt;p&gt;여기에 더해, 그 콜백이 서버에 도달하려면 Spring &lt;code&gt;CorsFilter&lt;/code&gt; allowlist에 외부 인증 도메인이 등록돼 있어야 합니다. 브라우저는 form navigation을 막지 않지만, 서버 CorsFilter는 &lt;code&gt;Origin&lt;/code&gt; 헤더만 보고 막기 때문입니다.&lt;/p&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/구현</category>
      <category>LAX</category>
      <category>NONE</category>
      <category>same-site</category>
      <category>쿠키</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/289</guid>
      <comments>https://cdchan.tistory.com/289#entry289comment</comments>
      <pubDate>Mon, 29 Jun 2026 13:18:53 +0900</pubDate>
    </item>
    <item>
      <title>Grafana 커스텀 대시보드 구축</title>
      <link>https://cdchan.tistory.com/288</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;Grafana 커스텀 대시보드 구축&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;&lt;h1 id='grafana-커스텀-대시보드-구축'&gt;Grafana 커스텀 대시보드 구축&lt;/h1&gt;
&lt;p&gt;Grafana + Prometheus + Spring Boot Actuator를 함께 사용하고 있었습니다. Grafana에서 Spring Boot를 모니터링할 때 많이 쓰는 JVM (Micrometer) 대시보드를 가져와서 쓰고 있었는데, 이 대시보드는 JVM 내부 메트릭에 집중되어 있어서 정작 궁금한 것들에 대한 답을 주지 못했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;어떤 API가 느린지&lt;/li&gt;
&lt;li&gt;5xx 에러가 어디서 발생하는지&lt;/li&gt;
&lt;li&gt;DB 커넥션 풀은 괜찮은지&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;이런 질문들에 답하려면 매번 Explore에서 쿼리를 직접 날려야 했고, 문제가 발생했을 때 한눈에 파악이 안 되는 게 가장 불편한 점이었습니다.&lt;/p&gt;
&lt;p&gt;그래서 직접 커스텀 대시보드를 만들었습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='전제-조건'&gt;전제 조건&lt;/h2&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;구성 요소&lt;/th&gt;&lt;th&gt;상태&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Grafana v11.1.0&lt;/td&gt;&lt;td&gt;설치 및 접속 가능&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Prometheus&lt;/td&gt;&lt;td&gt;Docker 네트워크 내부에서 동작&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Spring Boot 3.2.x&lt;/td&gt;&lt;td&gt;Actuator + Micrometer로 &lt;code&gt;/management/prometheus&lt;/code&gt; 엔드포인트 노출&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Prometheus → Spring Boot 스크래핑&lt;/td&gt;&lt;td&gt;설정 완료&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='1-수집-중인-메트릭-확인'&gt;1. 수집 중인 메트릭 확인&lt;/h2&gt;
&lt;p&gt;대시보드를 만들려면 Prometheus가 어떤 메트릭을 수집하고 있는지부터 알아야 합니다. Grafana의 &lt;strong&gt;Explore&lt;/strong&gt;에서 확인할 수 있습니다.
Explore에 들어가서 데이터소스를 Prometheus로 선택하고, 쿼리 입력 영역 왼쪽의 &lt;strong&gt;Metrics browser&lt;/strong&gt;를 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1단계 - 메트릭 선택&lt;/strong&gt;: 수집 중인 전체 메트릭 목록이 나옵니다. 검색창에 &lt;code&gt;http_server_requests&lt;/code&gt;를 입력하면 관련 메트릭만 남습니다. 여기서 사용할 메트릭을 하나 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2단계 - 라벨 선택&lt;/strong&gt;: 메트릭을 클릭하면, 해당 메트릭에 붙어있는 라벨만 필터링되어 표시됩니다. 예를 들어 &lt;code&gt;http_server_requests_seconds_count&lt;/code&gt;를 클릭하면 다음 라벨들이 나타납니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;라벨&lt;/th&gt;&lt;th style='text-align:center;' &gt;값 수&lt;/th&gt;&lt;th&gt;설명&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;error&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;3&lt;/td&gt;&lt;td&gt;에러 종류&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;exception&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;3&lt;/td&gt;&lt;td&gt;예외 클래스명&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;instance&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;1&lt;/td&gt;&lt;td&gt;서버 인스턴스&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;job&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;1&lt;/td&gt;&lt;td&gt;Prometheus job 이름&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;method&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;6&lt;/td&gt;&lt;td&gt;HTTP 메서드 (GET, POST, PUT 등)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;outcome&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;4&lt;/td&gt;&lt;td&gt;결과 (SUCCESS, CLIENT_ERROR, SERVER_ERROR 등)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;9&lt;/td&gt;&lt;td&gt;HTTP 상태코드 (200, 400, 500 등)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;uri&lt;/code&gt;&lt;/td&gt;&lt;td style='text-align:center;' &gt;100&lt;/td&gt;&lt;td&gt;API 엔드포인트 경로&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;괄호 안 숫자는 해당 라벨의 고유 값 개수입니다. &lt;code&gt;uri (100)&lt;/code&gt;이면 100개 엔드포인트가 기록되어 있다는 뜻입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3단계 - 라벨 값 선택&lt;/strong&gt;: 라벨을 클릭하면 실제 값을 선택할 수 있습니다. 예를 들어 &lt;code&gt;status&lt;/code&gt;를 선택하면 &lt;code&gt;200&lt;/code&gt;, &lt;code&gt;400&lt;/code&gt;, &lt;code&gt;500&lt;/code&gt; 등이 나타나고, 원하는 값을 골라 필터를 만들 수 있습니다.&lt;/p&gt;
&lt;p&gt;아래 &lt;strong&gt;4. Resulting selector&lt;/strong&gt;에 완성된 쿼리가 표시됩니다. &lt;strong&gt;Use query&lt;/strong&gt;를 누르면 바로 결과를 볼 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='11-주요-메트릭-카테고리'&gt;1.1 주요 메트릭 카테고리&lt;/h3&gt;
&lt;p&gt;Spring Boot Actuator + Micrometer가 자동으로 노출하는 메트릭 중, 대시보드에서 활용할 항목입니다.&lt;/p&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;카테고리&lt;/th&gt;&lt;th&gt;대표 메트릭&lt;/th&gt;&lt;th&gt;설명&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;HTTP 요청&lt;/td&gt;&lt;td&gt;&lt;code&gt;http_server_requests_seconds_*&lt;/code&gt;&lt;/td&gt;&lt;td&gt;요청 수, 응답 시간, 상태 코드&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JVM 메모리&lt;/td&gt;&lt;td&gt;&lt;code&gt;jvm_memory_used_bytes&lt;/code&gt;, &lt;code&gt;jvm_memory_max_bytes&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Heap/Non-Heap 메모리 사용량&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;GC&lt;/td&gt;&lt;td&gt;&lt;code&gt;jvm_gc_pause_seconds_*&lt;/code&gt;&lt;/td&gt;&lt;td&gt;GC 일시 정지 시간&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;JVM 스레드&lt;/td&gt;&lt;td&gt;&lt;code&gt;jvm_threads_live_threads&lt;/code&gt;, &lt;code&gt;jvm_threads_daemon_threads&lt;/code&gt;&lt;/td&gt;&lt;td&gt;스레드 수&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HikariCP&lt;/td&gt;&lt;td&gt;&lt;code&gt;hikaricp_connections_*&lt;/code&gt;&lt;/td&gt;&lt;td&gt;DB 커넥션 풀 상태&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;디스크&lt;/td&gt;&lt;td&gt;&lt;code&gt;disk_free_bytes&lt;/code&gt;, &lt;code&gt;disk_total_bytes&lt;/code&gt;&lt;/td&gt;&lt;td&gt;디스크 용량&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;프로세스&lt;/td&gt;&lt;td&gt;&lt;code&gt;process_cpu_usage&lt;/code&gt;, &lt;code&gt;system_cpu_usage&lt;/code&gt;&lt;/td&gt;&lt;td&gt;CPU 사용률&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;blockquote&gt;&lt;p&gt;테이블의 &lt;code&gt;_*&lt;/code&gt; 표기는 편의상 공통 접두사를 묶어 쓴 것입니다. 실제 Prometheus에는 개별 메트릭이 따로 존재합니다. 예를 들어 &lt;code&gt;http_server_requests_seconds_*&lt;/code&gt;는 하나의 히스토그램 메트릭에서 자동 생성된 &lt;code&gt;_count&lt;/code&gt;(요청 횟수), &lt;code&gt;_sum&lt;/code&gt;(응답 시간 합계), &lt;code&gt;_bucket&lt;/code&gt;(구간별 분포) 세 개를 의미합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='2-대시보드-설계'&gt;2. 대시보드 설계&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='21-구성-목표'&gt;2.1 구성 목표&lt;/h3&gt;
&lt;p&gt;대시보드를 통해 다음 질문에 답할 수 있어야 합니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;&lt;strong&gt;어떤 API가 느린가?&lt;/strong&gt; - 비즈니스 로직 최적화 대상 선정&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;에러가 얼마나 발생하는가?&lt;/strong&gt; - 5xx 에러율 추적&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;서버 리소스는 괜찮은가?&lt;/strong&gt; - JVM 메모리, CPU, 커넥션 풀&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DB 커넥션은 부족하지 않은가?&lt;/strong&gt; - HikariCP 상태 확인&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='22-패널-구성'&gt;2.2 패널 구성&lt;/h3&gt;
&lt;p&gt;대시보드를 열었을 때 위에서부터 아래로 자연스럽게 훑을 수 있도록 4개 Row로 나눴습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row 1&lt;/strong&gt;은 지금 서비스가 괜찮은지 아닌지를 5초 안에 판단하는 요약 줄입니다. Uptime, 5xx 에러율, 평균 응답시간, Heap 사용률, DB 커넥션 사용률, 디스크 사용률 — 6개 숫자가 한 줄에 나란히 있고, 정상이면 초록, 문제면 빨간색입니다. 여기서 빨간색이 보이면 아래 Row로 내려가서 원인을 찾는 흐름입니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row 2&lt;/strong&gt;는 HTTP 요청에 대한 상세 지표입니다. 느린 엔드포인트 Top 10 테이블, 전체 RPS, 현재 처리 중인 요청 수, 5xx 에러 누적 건수, 에러가 발생한 엔드포인트 테이블, 에러 추이 그래프, 느린 API 응답시간 추이, 상태코드별/method별 요청 분포를 배치했습니다. &amp;quot;어떤 API가 느리고, 어디서 에러가 나는지&amp;quot;를 이 Row에서 전부 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row 3&lt;/strong&gt;은 JVM 상태입니다. Heap 메모리 추이, GC 일시정지 시간, CPU 사용률, 스레드 수와 상태별 분포, 로그 이벤트 빈도를 넣었습니다. Row 1에서 Heap 사용률이 높으면 여기로 내려와서 메모리 누수인지, GC 문제인지를 확인합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row 4&lt;/strong&gt;는 HikariCP 커넥션 풀입니다. Active/Idle/Pending/Max 상태, 커넥션 획득 시간, 사용 시간, 타임아웃 누적을 모아뒀습니다. 느린 API가 커넥션을 오래 물고 있으면 여기서 먼저 징후가 보입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='3-대시보드-생성-방법'&gt;3. 대시보드 생성 방법&lt;/h2&gt;
&lt;p&gt;Grafana UI에서 패널을 하나씩 추가할 수도 있지만, JSON으로 전체 대시보드를 한 번에 import하는 방법이 더 편리합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dashboards &amp;gt; New &amp;gt; Import dashboard&lt;/strong&gt; 페이지에서 3가지 방법을 제공합니다.&lt;/p&gt;
&lt;figure class='table-figure'&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;방법&lt;/th&gt;&lt;th&gt;설명&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Upload dashboard JSON file&lt;/td&gt;&lt;td&gt;JSON 파일을 드래그 앤 드롭으로 업로드&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;grafana.com dashboard URL or ID&lt;/td&gt;&lt;td&gt;커뮤니티 대시보드 ID를 입력하여 가져오기&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Import via dashboard JSON model&lt;/td&gt;&lt;td&gt;JSON을 직접 텍스트로 붙여넣기&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
&lt;/table&gt;&lt;/figure&gt;
&lt;p&gt;커스텀 대시보드는 세 번째 방법을 사용합니다. JSON을 작성해서 텍스트 영역에 붙여넣고 &lt;strong&gt;Load&lt;/strong&gt; 버튼을 누르면 됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='31-대시보드-json-기본-구조'&gt;3.1 대시보드 JSON 기본 구조&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-json' lang='json'&gt;{
  &amp;quot;title&amp;quot;: &amp;quot;{서비스명} Spring Boot Overview&amp;quot;,
  &amp;quot;tags&amp;quot;: [&amp;quot;spring-boot&amp;quot;],
  &amp;quot;timezone&amp;quot;: &amp;quot;Asia/Seoul&amp;quot;,
  &amp;quot;refresh&amp;quot;: &amp;quot;30s&amp;quot;,
  &amp;quot;panels&amp;quot;: [ ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='32-패널-json-구조'&gt;3.2 패널 JSON 구조&lt;/h3&gt;
&lt;p&gt;각 패널은 아래와 같은 형식을 따릅니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-json' lang='json'&gt;{
  &amp;quot;type&amp;quot;: &amp;quot;timeseries&amp;quot;,
  &amp;quot;title&amp;quot;: &amp;quot;Request Rate (req/s)&amp;quot;,
  &amp;quot;gridPos&amp;quot;: { &amp;quot;h&amp;quot;: 8, &amp;quot;w&amp;quot;: 8, &amp;quot;x&amp;quot;: 0, &amp;quot;y&amp;quot;: 1 },
  &amp;quot;datasource&amp;quot;: { &amp;quot;type&amp;quot;: &amp;quot;prometheus&amp;quot;, &amp;quot;uid&amp;quot;: &amp;quot;&amp;lt;DATASOURCE_UID&amp;gt;&amp;quot; },
  &amp;quot;targets&amp;quot;: [
    {
      &amp;quot;expr&amp;quot;: &amp;quot;&amp;lt;PromQL 쿼리&amp;gt;&amp;quot;,
      &amp;quot;legendFormat&amp;quot;: &amp;quot;{{method}}&amp;quot;
    }
  ],
  &amp;quot;fieldConfig&amp;quot;: {
    &amp;quot;defaults&amp;quot;: { &amp;quot;unit&amp;quot;: &amp;quot;reqps&amp;quot; }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;type&lt;/code&gt;: 패널 종류. &lt;code&gt;timeseries&lt;/code&gt;(시계열 그래프), &lt;code&gt;stat&lt;/code&gt;(숫자 하나), &lt;code&gt;table&lt;/code&gt;(테이블) 등&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gridPos&lt;/code&gt;: 대시보드 내 위치와 크기. &lt;code&gt;w&lt;/code&gt;는 너비(최대 24), &lt;code&gt;h&lt;/code&gt;는 높이&lt;/li&gt;
&lt;li&gt;&lt;code&gt;targets.expr&lt;/code&gt;: 이 패널에서 사용할 PromQL 쿼리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;datasource.uid&lt;/code&gt;: Prometheus 데이터소스의 UID&lt;/li&gt;

&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;&amp;lt;DATASOURCE_UID&amp;gt;&lt;/code&gt;는 Grafana &amp;gt; Connections &amp;gt; Data Sources에서 Prometheus를 클릭하면 URL에서 확인할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='4-대시보드-ux-커스터마이징'&gt;4. 대시보드 UX 커스터마이징&lt;/h2&gt;
&lt;p&gt;패널을 다 만들고 나서 기본 설정 그대로 두니까 정상인지 이상인지 구분이 잘 안 됐습니다. 몇 가지 설정을 바꿔서 해결했습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='41-stat-패널-색상-모드'&gt;4.1 Stat 패널 색상 모드&lt;/h3&gt;
&lt;p&gt;Grafana Stat 패널의 &lt;code&gt;colorMode&lt;/code&gt;는 두 가지가 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;quot;value&amp;quot;&lt;/code&gt;: 배경은 어둡게, 숫자 텍스트에만 색상 적용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;quot;background&amp;quot;&lt;/code&gt;: 패널 전체 배경에 색상&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;colorMode&lt;/code&gt;를 &lt;code&gt;&amp;quot;value&amp;quot;&lt;/code&gt;로 설정하고, &lt;code&gt;thresholds&lt;/code&gt;에 임계값을 지정하면 값에 따라 텍스트 색상이 바뀝니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-json' lang='json'&gt;{
  &amp;quot;options&amp;quot;: { &amp;quot;colorMode&amp;quot;: &amp;quot;value&amp;quot; },
  &amp;quot;fieldConfig&amp;quot;: {
    &amp;quot;defaults&amp;quot;: {
      &amp;quot;color&amp;quot;: { &amp;quot;mode&amp;quot;: &amp;quot;thresholds&amp;quot; },
      &amp;quot;thresholds&amp;quot;: {
        &amp;quot;mode&amp;quot;: &amp;quot;absolute&amp;quot;,
        &amp;quot;steps&amp;quot;: [
          { &amp;quot;color&amp;quot;: &amp;quot;green&amp;quot;, &amp;quot;value&amp;quot;: null },
          { &amp;quot;color&amp;quot;: &amp;quot;red&amp;quot;, &amp;quot;value&amp;quot;: 80 }
        ]
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;배경 전체에 색상을 채우려면 &lt;code&gt;&amp;quot;background&amp;quot;&lt;/code&gt;로 변경합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-json' lang='json'&gt;{
  &amp;quot;options&amp;quot;: { &amp;quot;colorMode&amp;quot;: &amp;quot;background&amp;quot; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='42-패널-description-활용'&gt;4.2 패널 Description 활용&lt;/h3&gt;
&lt;p&gt;각 패널의 (i) 아이콘에 마우스를 올리면 설명이 표시됩니다. 여기에 임계값의 의미와 문제 발생 시 확인할 항목을 적어두면, 대시보드 자체가 런북(Runbook) 역할을 합니다.&lt;/p&gt;
&lt;p&gt;예시 (5xx 에러율 패널의 Description):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;최근 5분간 전체 HTTP 요청 대비 5xx 응답 비율 (rate 기반).

빨간색 (1% 초과): 서버 에러가 유의미하게 발생 중.
- 확인: 아래 &amp;#39;5xx 에러 발생 엔드포인트&amp;#39; 테이블에서 어떤 API인지 확인
- 확인: 서버 로그에서 해당 시간대 Exception 스택트레이스
- 확인: DB 커넥션 풀 고갈, 외부 API 타임아웃 여부
- 확인: 최근 배포 이후 발생했는지 (롤백 필요 여부)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='43-테이블-패널-노이즈-제거'&gt;4.3 테이블 패널 노이즈 제거&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;느린 엔드포인트&lt;/strong&gt;, &lt;strong&gt;5xx 에러 발생 엔드포인트&lt;/strong&gt; 테이블은 다음과 같이 조정했습니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;기본 셀 색상을 &lt;code&gt;transparent&lt;/code&gt;로 설정하여 배경과 동일하게 만들고, 빨간색/노란색 행만 눈에 띄게 했습니다.&lt;/li&gt;
&lt;li&gt;5xx 에러 테이블에서 Value가 0인 행을 제거하여, 에러가 실제로 발생한 엔드포인트만 표시합니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# 변경 전: 0건인 엔드포인트도 모두 표시
sum(increase(http_server_requests_seconds_count{status=~&amp;quot;5..&amp;quot;}[1h])) by (uri, method, status)

# 변경 후: 에러가 1건 이상인 것만 표시
sum(increase(http_server_requests_seconds_count{status=~&amp;quot;5..&amp;quot;}[1h])) by (uri, method, status) &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='44-rate와-increase를-함께-배치'&gt;4.4 rate와 increase를 함께 배치&lt;/h3&gt;
&lt;p&gt;대시보드에는 에러 관련 패널이 두 개 있습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;5xx 에러율&lt;/strong&gt; (Row 1): &lt;code&gt;rate&lt;/code&gt; 기반. 최근 5분간 초당 에러 비율을 계산합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;5xx 에러&lt;/strong&gt; (Row 2): &lt;code&gt;increase&lt;/code&gt; 기반. 최근 1시간 동안 에러가 총 몇 건 발생했는지를 누적합니다.&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;이 두 패널을 함께 배치하는 이유가 있습니다. &lt;strong&gt;5xx 에러율이 0%인데 5xx 에러가 30건&lt;/strong&gt;인 상황이 실제로 발생합니다. 30분 전에 에러가 집중 발생했지만 지금은 멈춘 상태라면, &lt;code&gt;rate&lt;/code&gt; 기반 지표는 0이 됩니다. 반면 &lt;code&gt;increase&lt;/code&gt; 기반 누적 지표는 1시간 동안의 총량을 보여줍니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rate&lt;/code&gt;는 &amp;quot;지금 에러가 나고 있는가?&amp;quot;, &lt;code&gt;increase&lt;/code&gt;는 &amp;quot;최근에 에러가 있었는가?&amp;quot;를 보는 것입니다. 둘 다 있어야 상황 파악이 됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='완성된-대시보드'&gt;완성된 대시보드&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-04-17.png&quot; alt=&quot;스크린샷 2026-04-17.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;Row 1을 훑으면 지금 서비스가 괜찮은지 5초 안에 알 수 있고, 빨간색이 보이면 아래 Row로 내려가서 어떤 API가 느린지, 어디서 에러가 나는지, 커넥션 풀은 괜찮은지를 바로 확인할 수 있습니다. 기존 JVM (Micrometer) 대시보드에서는 매번 Explore를 열어야 했던 질문들에 이제 대시보드 하나로 답할 수 있게 됐습니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;전체 대시보드 JSON은 첨부된 &lt;code&gt;dashboard.json&lt;/code&gt; 파일을 Grafana Import로 가져오면 동일하게 구성할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;다음 글에서는 Grafana Alerting을 활용해서 임계값 초과 시 Slack으로 자동 알림을 보내는 방법을 다룹니다.&lt;/p&gt;
&lt;div class=&quot;typora-post&quot;&gt;&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;
&lt;p&gt;&lt;figure class=&quot;fileblock&quot; data-ke-align=&quot;alignCenter&quot;&gt;&lt;a href=&quot;https://blog.kakaocdn.net/dn/bZr2O2/dJMb990o7vG/zK2N2daOuOt3Jy9IPuzHnk/dashboard.json?attach=1&amp;amp;knm=tfile.json&quot; class=&quot;&quot;&gt;
    &lt;div class=&quot;image&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;desc&quot;&gt;&lt;div class=&quot;filename&quot;&gt;&lt;span class=&quot;name&quot;&gt;dashboard.json&lt;/span&gt;&lt;/div&gt;
&lt;div class=&quot;size&quot;&gt;63.4 kB&lt;/div&gt;
&lt;/div&gt;
  &lt;/a&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>공부/인프라</category>
      <category>Dashboard</category>
      <category>Grafana</category>
      <category>springboot</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/288</guid>
      <comments>https://cdchan.tistory.com/288#entry288comment</comments>
      <pubDate>Fri, 17 Apr 2026 16:14:39 +0900</pubDate>
    </item>
    <item>
      <title>Blue-Green 배포 시 Graceful Shutdown 적용기</title>
      <link>https://cdchan.tistory.com/287</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;Blue-Green 배포 시 Graceful Shutdown 적용기-refactor-v1&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1 id='blue-green-배포-시-기존-컨테이너를-즉시-내리면-안-되는-이유-----graceful-shutdown-적용기'&gt;Blue-Green 배포 시 기존 컨테이너를 즉시 내리면 안 되는 이유 — Graceful Shutdown 적용기&lt;/h1&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='배포-구조'&gt;배포 구조&lt;/h2&gt;
&lt;p&gt;EC2 한 대에 Docker Compose로 Nginx + Spring Boot 앱을 올려서 사용하고 있습니다. 배포는 Blue-Green 방식이고, 흐름은 아래와 같습니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;Blue 컨테이너가 트래픽을 받고 있는 상태에서&lt;/li&gt;
&lt;li&gt;Green 컨테이너를 새로 띄우고&lt;/li&gt;
&lt;li&gt;Nginx upstream을 Green으로 전환하고&lt;/li&gt;
&lt;li&gt;Blue를 내립니다&lt;/li&gt;

&lt;/ol&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;┌─────────────────────────────────────────┐
│                  EC2                    │
│                                         │
│  ┌───────┐     ┌──────┐    ┌──────┐     │
│  │ Nginx │────▶│ Blue │ or │Green │     │
│  └───────┘     └──────┘    └──────┘     │
│           (Docker Compose)              │
└─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GitHub Actions에서 이미지를 빌드해서 Docker Hub에 올리고, EC2에 SSH로 접속해서 &lt;code&gt;deploy.sh&lt;/code&gt;를 실행하는 구조입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='문제-blue를-즉시-내리면-요청이-유실됩니다'&gt;문제: Blue를 즉시 내리면 요청이 유실됩니다&lt;/h2&gt;
&lt;p&gt;배포할 때 Nginx를 Green으로 전환하자마자 Blue를 &lt;code&gt;docker-compose down&lt;/code&gt;으로 즉시 종료하고 있었습니다. 평소에는 별 문제가 없었는데, Blue에서 처리 시간이 좀 걸리는 요청이 진행 중일 때 배포가 되면 문제가 생겼습니다.&lt;/p&gt;
&lt;p&gt;구체적으로는 &lt;code&gt;@Async&lt;/code&gt;로 비동기 처리를 처리한 뒤 트랜잭션이 커밋되기 전에 컨테이너가 죽어버리는 상황이었습니다. 비동기 로직 자체는 실행됐는데, 정작 DB에는 반영이 안 되는 불일치가 발생했습니다.&lt;/p&gt;
&lt;p&gt;기대하는 동작은 단순합니다. Nginx 전환 이후 새 요청은 Green으로 가되, Blue에서 이미 처리 중인 요청은 끝까지 완료된 다음에 컨테이너가 내려가야 합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='해결-방향-graceful-shutdown'&gt;해결 방향: Graceful Shutdown&lt;/h2&gt;
&lt;p&gt;결국 문제는 Blue 컨테이너가 &amp;quot;지금 처리 중인 요청이 있는지&amp;quot;를 신경 쓰지 않고 바로 꺼진다는 것입니다. 그러면 컨테이너를 종료할 때 &amp;quot;처리 중인 요청이 있으면 기다렸다가 꺼지게&amp;quot; 만들면 됩니다. 이게 Graceful Shutdown입니다.&lt;/p&gt;
&lt;p&gt;Spring Boot는 2.3부터 &lt;code&gt;server.shutdown=graceful&lt;/code&gt; 설정을 제공합니다. &lt;code&gt;SIGTERM&lt;/code&gt;을 받으면 새 요청은 거부하고, 이미 처리 중인 요청은 완료될 때까지 대기한 뒤 종료합니다.&lt;/p&gt;
&lt;p&gt;그리고 Docker의 &lt;code&gt;docker stop&lt;/code&gt; 명령은 컨테이너에 &lt;code&gt;SIGTERM&lt;/code&gt;을 보낸 뒤 일정 시간 기다려주는 구조입니다. 이 두 가지를 조합하면 배포 스크립트에 별도 &lt;code&gt;sleep&lt;/code&gt;을 넣지 않고도, Blue가 진행 중인 요청을 마무리한 뒤 알아서 종료되도록 만들 수 있습니다.&lt;/p&gt;
&lt;p&gt;정리하면 이런 흐름입니다:&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;&lt;strong&gt;Nginx 전환&lt;/strong&gt; — 새 요청은 Green으로 라우팅&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;docker stop -t&lt;/code&gt;로 Blue에 &lt;code&gt;SIGTERM&lt;/code&gt; 전송&lt;/strong&gt; — Spring Boot가 진행 중인 요청을 마저 처리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;요청 완료 후 Blue 자동 종료&lt;/strong&gt; — 처리가 끝나면 바로 꺼지고, 최대 대기 시간이 지나면 강제 종료&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='docker-stop--t의-동작-방식'&gt;&lt;code&gt;docker stop -t&lt;/code&gt;의 동작 방식&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;docker stop -t&lt;/code&gt;이 구체적으로 어떻게 동작하는지 살펴보겠습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;docker stop -t 150 app-blue
         │
         ▼
   1) SIGTERM 전송
         │
         ▼
   2) Spring Boot가 SIGTERM을 받으면
      └─ server.shutdown=graceful 설정이 있을 때:
         - 새 요청 수신을 거부 (서버 소켓을 닫음)
         - 이미 처리 중인 요청은 계속 처리
         │
         ▼
   3) 처리가 다 끝나면 → 바로 종료
      150초가 지나도 안 끝나면 → SIGKILL로 강제 종료
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 중요한 건 &lt;code&gt;-t 150&lt;/code&gt;이 &amp;quot;150초 동안 sleep 한다&amp;quot;가 아니라 &lt;strong&gt;&amp;quot;최대 150초까지 기다린다&amp;quot;&lt;/strong&gt;는 점입니다. 처리가 3초면 3초 만에, 30초면 30초 만에 컨테이너가 내려갑니다. 배포 스크립트에서 &lt;code&gt;sleep 60&lt;/code&gt; 같은 걸 넣을 필요가 없습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='nginx-reload-후-기존-요청은-어떻게-되나'&gt;Nginx reload 후 기존 요청은 어떻게 되나?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;nginx -s reload&lt;/code&gt;를 하면 새로운 worker를 띄우고, 기존 worker는 처리 중인 커넥션을 다 끝낸 다음에 종료됩니다. Blue로 이미 프록시된 요청은 TCP 커넥션이 유지되는 한 응답이 클라이언트까지 전달됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;[클라이언트] ←─ TCP 유지 ─→ [Nginx old worker] ←─ 커넥션 유지 ─→ [Blue]
                              │ reload 이후에도 이 파이프라인 유지
                              │ 응답 완료 후 old worker 종료

[새 클라이언트] ───────────→ [Nginx new worker] ──→ [Green]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단, Blue 컨테이너가 먼저 종료되면 old worker가 upstream 연결을 잃으면서 &lt;code&gt;502&lt;/code&gt;를 반환합니다. 그래서 &lt;strong&gt;반드시 Nginx 전환 → Blue graceful stop&lt;/strong&gt; 순서를 지켜야 합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='적용한-설정'&gt;적용한 설정&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='spring-boot-----applicationyml'&gt;Spring Boot — &lt;code&gt;application.yml&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 60s  # SIGTERM 후 최대 60초간 기존 HTTP 요청 처리 대기
  task:
    execution:
      shutdown:
        await-termination: true
        await-termination-period: 60s  # @Async 작업 완료 대기
    scheduling:
      shutdown:
        await-termination: true
        await-termination-period: 60s  # @Scheduled 작업 완료 대기
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;server.shutdown=graceful&lt;/code&gt;만 넣으면 HTTP 요청만 기다려줍니다. &lt;code&gt;@Async&lt;/code&gt;로 돌고 있는 비동기 작업이나 &lt;code&gt;@Scheduled&lt;/code&gt; 스케줄러는 &lt;code&gt;SIGTERM&lt;/code&gt;과 동시에 스레드풀이 바로 꺼져버립니다. 조회수 동기화 같은 스케줄러도 돌고 있어서 &lt;code&gt;spring.task&lt;/code&gt; 쪽 설정도 같이 추가했습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='docker-compose-----stopgraceperiod'&gt;Docker Compose — &lt;code&gt;stop_grace_period&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;services:
  app-blue:
    image: app:blue
    stop_grace_period: 150s  # SIGTERM → 150초 대기 → SIGKILL

  app-green:
    image: app:green
    stop_grace_period: 150s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;stop_grace_period&lt;/code&gt;는 Spring Boot의 전체 shutdown 시간보다 넉넉하게 잡아야 합니다.&lt;/p&gt;
&lt;p&gt;Spring Boot의 shutdown은 순차적으로 진행됩니다:&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;SIGTERM
  │
  ▼
1) Tomcat graceful shutdown (timeout-per-shutdown-phase: 60s)
   - 처리 중인 HTTP 요청 완료 대기
  │
  ▼
2) Spring Context 종료 → Bean destruction
   - TaskExecutor 종료 (await-termination-period: 60s)
   - TaskScheduler 종료 (await-termination-period: 60s)
  │
  ▼
3) JPA EntityManager, HikariPool 종료
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최악의 경우 &lt;strong&gt;60s(HTTP 대기) + 60s(Task 정리) = 120s&lt;/strong&gt;가 필요합니다. &lt;code&gt;stop_grace_period&lt;/code&gt;가 이보다 짧으면 Spring Boot가 아직 정리 중인데 Docker가 &lt;code&gt;SIGKILL&lt;/code&gt;을 보내서 graceful shutdown이 의미가 없어집니다. 여유를 두고 150s로 설정했습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='배포-스크립트-----deploysh'&gt;배포 스크립트 — &lt;code&gt;deploy.sh&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Blue → Green 전환 부분만 발췌하면 이런 흐름입니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;#!/bin/bash

IS_GREEN=$(sudo docker ps | grep green)

if [ -z &amp;quot;$IS_GREEN&amp;quot; ]; then
    echo &amp;quot;### BLUE =&amp;gt; GREEN ###&amp;quot;

    echo &amp;quot;1. get green image&amp;quot;
    sudo docker compose -f /home/ubuntu/docker-compose.yml pull dev-green

    echo &amp;quot;2. green container up&amp;quot;
    sudo docker compose -f /home/ubuntu/docker-compose.yml up -d dev-green

    while [ 1 = 1 ]; do
        echo &amp;quot;3. green health check...&amp;quot;
        sleep 5
        REQUEST=$(curl -s http://127.0.0.1:8082/health)
        if [ &amp;quot;$REQUEST&amp;quot; = &amp;quot;Service is up&amp;quot; ]; then
            echo &amp;quot;health check success&amp;quot;
            break
        fi
    done;

    echo &amp;quot;4. reload nginx&amp;quot;
    sudo cp ./nginx/constant/service-container-green.inc ./nginx/conf.d/service-container.inc
    sudo docker exec nginx nginx -s reload

    echo &amp;quot;5. blue container down (Background Graceful Shutdown)&amp;quot;
    nohup bash -c &amp;#39;sudo docker compose -f /home/ubuntu/docker-compose.yml stop dev-blue &amp;amp;&amp;amp; sudo docker compose -f /home/ubuntu/docker-compose.yml rm -f dev-blue&amp;#39; &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 &amp;amp;
    echo &amp;quot;Deployment completed. Blue container is shutting down in the background.&amp;quot;
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;몇 가지 포인트:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nohup&lt;/code&gt;으로 백그라운드 프로세스 보호&lt;/strong&gt;: GitHub Actions에서 SSH로 &lt;code&gt;deploy.sh&lt;/code&gt;를 실행하는 구조이기 때문에, 스크립트가 끝나면 SSH 세션이 종료됩니다. 이때 백그라운드 프로세스에 &lt;code&gt;SIGHUP&lt;/code&gt;이 날아가면서 &lt;code&gt;docker compose stop&lt;/code&gt;이 중간에 끊길 수 있습니다. &lt;code&gt;nohup&lt;/code&gt;을 붙이면 SSH 세션이 끝나도 프로세스가 유지됩니다.&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;배포 스크립트의 책임은 Green 시작 → 헬스체크 → Nginx 전환까지입니다. 이 시점에서 새 요청은 이미 Green으로만 가고 있으니, Blue 종료를 블로킹하면서 기다릴 이유가 없습니다. 백그라운드로 넘기면 워크플로우는 바로 끝나고, Blue는 진행 중인 요청을 마친 뒤 알아서 내려갑니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='시간-흐름'&gt;시간 흐름&lt;/h2&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;시간 ──────────────────────────────────────────────────────────────▶

Green 시작     헬스체크 OK       Nginx 전환       워크플로우 종료
   │               │              │               │
   ▼               ▼              ▼               ▼
[Green 부팅중] [Green Ready] [새 요청→Green]      [배포 끝]
                              │
                              │  (백그라운드)
                              ▼
                         [Blue SIGTERM]
                         [진행 중 요청 처리 완료]
                         [Blue 종료]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='검증'&gt;검증&lt;/h2&gt;
&lt;p&gt;실제로 동작하는지 확인하기 위해 30초짜리 느린 API를 하나 만들었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-java' lang='java'&gt;@GetMapping(&amp;quot;/api/health/slow&amp;quot;)
public String checkHealthSlow() throws InterruptedException {
    Thread.sleep(30000);
    return &amp;quot;Slow response completed&amp;quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Blue가 떠있는 상태에서 이 API를 호출하고, 응답이 오기 전에 배포를 실행합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;# 1. Blue에 30초짜리 요청을 보냄
curl https://&amp;lt;SERVER&amp;gt;/api/health/slow &amp;amp;

# 2. 배포 실행 (Green 시작 → 헬스체크 → Nginx 전환 → Blue SIGTERM)
./deploy.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 요청은 Nginx를 경유하기 때문에, Nginx reload 이후에도 old worker가 Blue와의 커넥션을 유지한 채 응답을 전달해주는지까지 함께 검증됩니다.&lt;/p&gt;
&lt;p&gt;Blue 컨테이너 로그를 보면 &lt;code&gt;SIGTERM&lt;/code&gt;을 받은 시점에 아래 로그가 먼저 뜹니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;INFO  --- [tomcat-shutdown] --- [o.s.b.w.e.tomcat.GracefulShutdown]: Commencing graceful shutdown. Waiting for active requests to complete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tomcat이 처리 중인 요청이 있다는 걸 감지하고, 새 요청은 거부하면서 기존 요청이 끝나기를 대기하는 상태입니다. 30초짜리 요청이 완료되면 아래 로그가 뜨면서 컨테이너가 종료됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-text' lang='text'&gt;INFO  --- [tomcat-shutdown] --- [o.s.b.w.e.tomcat.GracefulShutdown]: Graceful shutdown complete
INFO  --- [SpringApplicationShutdownHook] --- [o.s.o.j.LocalContainerEntityManagerFactoryBean]: Closing JPA EntityManagerFactory for persistence unit &amp;#39;default&amp;#39;
INFO  --- [SpringApplicationShutdownHook] --- [com.zaxxer.hikari.HikariDataSource]: HikariPool-1 - Shutdown initiated...
INFO  --- [SpringApplicationShutdownHook] --- [com.zaxxer.hikari.HikariDataSource]: HikariPool-1 - Shutdown completed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Graceful shutdown complete&lt;/code&gt; 이후에 JPA EntityManager → HikariPool 순서로 리소스가 정리되는 걸 확인할 수 있습니다. &lt;code&gt;curl&lt;/code&gt; 응답도 &lt;code&gt;200 OK&lt;/code&gt;로 정상 수신됩니다.&lt;/p&gt;
&lt;p&gt;반대로, 처리 중인 요청이 없는 상태에서 &lt;code&gt;SIGTERM&lt;/code&gt;을 받으면 &lt;code&gt;Commencing graceful shutdown&lt;/code&gt; 로그 없이 바로 종료됩니다. 대기할 요청이 없으니 즉시 꺼지는 것이 정상입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='reference'&gt;Reference&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href='https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html'&gt;Spring Boot 공식 문서 — Graceful Shutdown&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href='https://docs.docker.com/reference/compose-file/services/#stop_grace_period'&gt;Docker Compose — stop_grace_period&lt;/a&gt;&lt;/li&gt;

&lt;/ul&gt;
&lt;div class=&quot;typora-post&quot;&gt;&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/인프라</category>
      <category>graceful shutdown</category>
      <category>SIGTERM</category>
      <category>배포</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/287</guid>
      <comments>https://cdchan.tistory.com/287#entry287comment</comments>
      <pubDate>Mon, 6 Apr 2026 14:49:21 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot CI/CD 빌드 시간 단축하기 - Gradle 최적화 전략</title>
      <link>https://cdchan.tistory.com/286</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;CI-CD 빌드 시간 단축&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;&lt;h1 id='spring-boot-cicd-빌드-시간-단축하기---gradle-최적화-전략'&gt;Spring Boot CI/CD 빌드 시간 단축하기 - Gradle 최적화 전략&lt;/h1&gt;
&lt;p&gt;Spring Boot 프로젝트의 CI/CD 파이프라인에서 빌드와 테스트 단계가 과도하게 오래 걸리는 문제를 해결한 경험을 공유합니다.&lt;/p&gt;
&lt;p&gt;특히 RestDocs, Swagger 문서 생성을 포함한 복잡한 빌드 프로세스에서 6~7분 걸리던 &lt;strong&gt;CI/CD 시간을 절반 이하로 단축할 수 있었습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='1-문제상황'&gt;1. 문제상황&lt;/h2&gt;
&lt;p&gt;CI/CD 파이프라인에서 다음과 같은 문제들이 발생했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;GitHub Actions에서 빌드 완료까지 6~7분 이상 소요됩니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260204115719052.png&quot; alt=&quot;image-20260204115719052&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;특히 &lt;code&gt;test&lt;/code&gt; 단계와 &lt;code&gt;build&lt;/code&gt; 단계에서 대부분의 시간이 소비됩니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;코드 변경이 없어도 매번 전체 빌드를 새로 실행합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Docker 이미지 빌드도 매번 처음부터 다시 시작됩니다.&lt;/p&gt;
&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='2-원인'&gt;2. 원인&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='21-과도한-태스크-의존성'&gt;2.1 과도한 태스크 의존성&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;build.gradle&lt;/code&gt; 파일을 분석한 결과, &lt;code&gt;build&lt;/code&gt; 태스크가 11개의 의존 태스크를 &lt;strong&gt;순차적&lt;/strong&gt;으로 실행하고 있었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;tasks.named(&amp;quot;build&amp;quot;) {
    dependsOn &amp;quot;ensureDocsDir&amp;quot;, &amp;quot;cleanDocs&amp;quot;, &amp;quot;test&amp;quot;,
              &amp;quot;asciidoctorMember&amp;quot;, &amp;quot;asciidoctorAdmin&amp;quot;, &amp;quot;asciidoctorDirector&amp;quot;,
              &amp;quot;generateExceptionDocs&amp;quot;, &amp;quot;openapi3&amp;quot;, &amp;quot;generateSwaggerUI&amp;quot;,
              &amp;quot;copyDocs&amp;quot;, &amp;quot;bootJar&amp;quot;, &amp;quot;patchSwaggerUI&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 중 일부 태스크는 병렬로 실행 가능한데도 순차적으로 실행되고 있었습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='22-불필요한-clean-작업'&gt;2.2 불필요한 clean 작업&lt;/h3&gt;
&lt;p&gt;CI/CD 환경에서는 매번 새로운 환경에서 빌드가 실행되는데, &lt;code&gt;./gradlew clean build&lt;/code&gt; 명령어로 인해 이전 빌드 결과를 삭제하는 불필요한 작업이 수행되고 있었습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;# 기존 설정
- name: Build with Gradle
  run: ./gradlew clean build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='23-gradle-빌드-캐시-미사용'&gt;2.3 Gradle 빌드 캐시 미사용&lt;/h3&gt;
&lt;p&gt;Gradle은 빌드 캐시 기능을 제공하지만, 활성화되지 않아 의존성 다운로드와 컴파일을 매번 반복하고 있었습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='24-docker-레이어-캐싱-미사용'&gt;2.4 Docker 레이어 캐싱 미사용&lt;/h3&gt;
&lt;p&gt;Docker 이미지를 빌드할 때도 레이어 캐싱을 사용하지 않아, 변경되지 않은 레이어까지 매번 다시 빌드하고 있었습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='3-원인-검증'&gt;3. 원인 검증&lt;/h2&gt;
&lt;p&gt;최적화 전략을 세우기 위해 먼저 로컬 환경에서 빌드 프로세스를 분석했습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='31-빌드-태스크-분석'&gt;3.1 빌드 태스크 분석&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;./gradlew build --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;./gradlew build --dry-run&lt;/strong&gt;: 실제로 빌드를 실행하지 &lt;strong&gt;않고&lt;/strong&gt;, 어떤 태스크들이 어떤 순서로 실행될지만 보여줍니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;위 명령어로 태스크 실행 순서를 확인한 결과, 다음과 같이 순차 실행되는 것을 확인했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:jar SKIPPED
:ensureDocsDir SKIPPED
:cleanDocs SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:asciidoctorAdmin SKIPPED
:asciidoctorDirector SKIPPED
:asciidoctorMember SKIPPED
:generateExceptionMarkdown SKIPPED
:generateExceptionDocs SKIPPED
:check SKIPPED
:openapi3 SKIPPED
:generateSwaggerUISample SKIPPED
:generateSwaggerUI SKIPPED
:patchSwaggerUI SKIPPED
:copyDocs SKIPPED
:resolveMainClassName SKIPPED
:bootJar SKIPPED
:assemble SKIPPED
:build SKIPPED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;asciidoctorMember&lt;/code&gt;, &lt;code&gt;asciidoctorAdmin&lt;/code&gt;, &lt;code&gt;asciidoctorDirector&lt;/code&gt; 세 태스크는 서로 독립적이므로 병렬 실행이 가능합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='32-빌드-시간-프로파일링'&gt;3.2 빌드 시간 프로파일링&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;./gradlew build --profile
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;./gradlew build --profile&lt;/strong&gt;: 빌드를 실행하면서 각 태스크가 얼마나 시간이 걸렸는지 상세한 리포트를 생성합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;빌드가 완료되면 &lt;code&gt;be/build/reports/profile/profile-&amp;lt;timestamp&amp;gt;.html&lt;/code&gt; 파일이 생성됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;# 리포트 열기
open be/build/reports/profile/profile-*.html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260204123017957.png&quot; alt=&quot;image-20260204123017957&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;프로파일링 결과:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;test&lt;/code&gt;: 전체 시간의 68.2%&lt;/li&gt;
&lt;li&gt;&lt;code&gt;asciidoctor*&lt;/code&gt; 태스크들: 전체 시간의 9.9%&lt;/li&gt;
&lt;li&gt;의존성 다운로드: 전체 시간의 1%&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;따라서 의존성 다운로드 부분은 로컬에서는 큰 문제가 아닌 것을 확인했습니다. 먼저 &lt;code&gt;asciidoctor&lt;/code&gt; 태스크들을 병렬 실행하여 해당 부분을 최적화합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='4-해결방법'&gt;4. 해결방법&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='41-gradle-빌드-캐시-활성화'&gt;4.1 Gradle 빌드 캐시 활성화&lt;/h3&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id='로컬에서-먼저-테스트'&gt;로컬에서 먼저 테스트&lt;/h4&gt;
&lt;p&gt;최적화 옵션을 로컬에서 먼저 테스트합니다.&lt;/p&gt;
&lt;p&gt;기존 빌드 시간은 대략 1분 2.83초 정도 걸렸으니, 캐시 및 병렬 실행 시 얼마나 줄어드는지 확인합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;# 최적화된 빌드 시간 측정
time ./gradlew build --build-cache --parallel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--build-cache&lt;/code&gt; 옵션으로 빌드 캐시를 활성화하고, &lt;code&gt;--parallel&lt;/code&gt; 옵션으로 독립적인 태스크들을 병렬 실행합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260204123352606.png&quot; alt=&quot;image-20260204123352606&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;병렬 실행 시 대략 6~7초 정도 줄어든 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id='github-actions에-적용'&gt;GitHub Actions에 적용&lt;/h4&gt;
&lt;p&gt;로컬 테스트에서 효과가 확인되었으니, GitHub Actions workflow 파일에 Gradle 캐싱을 추가합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;- name: Setup Gradle
  uses: gradle/gradle-build-action@v2
  with:
    cache-read-only: false  # dev 브랜치에서도 캐시 쓰기 허용

- name: Build with Gradle
  run: ./gradlew build --build-cache --parallel
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;cache-read-only: false&lt;/strong&gt;: 기본적으로 main/master 브랜치가 아닌 경우 캐시를 읽기만 가능합니다. &lt;code&gt;cache-read-only: false&lt;/code&gt;를 설정하면 현재 브랜치에서도 캐시를 저장할 수 있어, 다음 빌드 시 활용할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='42-clean-작업-제거'&gt;4.2 clean 작업 제거&lt;/h3&gt;
&lt;p&gt;CI 환경에서는 매번 새로운 환경이므로 &lt;code&gt;clean&lt;/code&gt;이 불필요합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;# 변경 전
- name: Build with Gradle
  run: ./gradlew clean build

# 변경 후
- name: Build with Gradle
  run: ./gradlew build --build-cache --parallel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='43-docker-레이어-캐싱-활성화'&gt;4.3 Docker 레이어 캐싱 활성화&lt;/h3&gt;
&lt;p&gt;GitHub Actions의 캐시 기능을 활용하여 Docker 빌드를 최적화합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;# 4. docker hub로 build &amp;amp; push
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v2

- name: Login to Docker Hub
  uses: docker/login-action@v2
  with:
    username: ${{ secrets.DOCKER_USERNAME }}
    password: ${{ secrets.DOCKER_PASSWORD }}

- name: Docker build and push
  uses: docker/build-push-action@v4
  with:
    context: ${{ env.BE_WORKING_DIR }}      # ./be
    file: ${{ env.BE_WORKING_DIR }}/Dockerfile  # ./be/Dockerfile
    push: true
    tags: ${{ secrets.DOCKER_USERNAME }}/motd-dev-be:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cache-from&lt;/code&gt;과 &lt;code&gt;cache-to&lt;/code&gt; 옵션으로 GitHub Actions 캐시를 활용합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='5-검증'&gt;5. 검증&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='51-로컬-빌드-시간-비교'&gt;5.1 로컬 빌드 시간 비교&lt;/h3&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;# 최적화 전
time ./gradlew clean build
# 결과: 약 1분 5초

# 최적화 후 (첫 실행 - 캐시 생성)
time ./gradlew build --build-cache --parallel
# 결과: 약 59초

# 최적화 후 (두 번째 실행 - 캐시 히트)
time ./gradlew build --build-cache --parallel
# 결과: 약 57초
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='52-cicd-파이프라인-실행-시간'&gt;5.2 CI/CD 파이프라인 실행 시간&lt;/h3&gt;
&lt;p&gt;로컬 테스트에서 효과가 확인되어, GitHub Actions에도 동일한 최적화를 적용했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;최적화 전: 6분 10초&lt;/li&gt;
&lt;li&gt;최적화 후: 2분 31초&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260204115719052.png&quot; alt=&quot;image-20260204115719052&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260206151747727.png&quot; alt=&quot;image-20260206151747727&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260206152053148.png&quot; alt=&quot;image-20260206152053148&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;위 summary에서 확인할 수 있듯이, Gradle Build Action이 자동으로 캐시를 관리하고 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id='캐시-동작-분석'&gt;캐시 동작 분석&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;복원 단계 (Entries Restored)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;6개 항목, 511 MB를 18.8초에 복원&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;이전 빌드에서 저장한 다음 항목들을 재사용합니다:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;다운로드한 의존성 (dependencies)&lt;/li&gt;
&lt;li&gt;컴파일된 클래스 파일&lt;/li&gt;
&lt;li&gt;Gradle wrapper&lt;/li&gt;
&lt;li&gt;Build cache (이전 빌드 결과)&lt;/li&gt;

&lt;/ul&gt;
&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;저장 단계 (Entries Saved)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;4개 항목, 324 MB를 10.9초에 저장&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;변경된 부분만 업데이트하여 다음 빌드를 위해 저장합니다&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cache cleanup 효과&lt;/strong&gt;: 511 MB → 324 MB (37% 감소)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;오래된 버전의 라이브러리 제거&lt;/li&gt;
&lt;li&gt;더 이상 사용하지 않는 build cache 정리&lt;/li&gt;

&lt;/ul&gt;
&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='53-로컬-vs-cicd-개선-효과-차이-분석'&gt;5.3 로컬 vs CI/CD 개선 효과 차이 분석&lt;/h3&gt;
&lt;p&gt;로컬에서는 보통 &lt;code&gt;./gradlew clean build&lt;/code&gt;만 수행하고(배포 Job처럼 Docker build/push를 하지 않음), &lt;/p&gt;
&lt;p&gt;&lt;code&gt;~/.gradle/caches&lt;/code&gt;가 유지되기 때문에 의존성 다운로드 시간은 대부분 이미 상수에 가깝습니다. &lt;/p&gt;
&lt;p&gt;또한 &lt;code&gt;clean&lt;/code&gt;은 프로젝트 빌드 산출물만 지우고, Gradle 의존성 캐시까지 매번 비우진 않기 때문에 &lt;code&gt;--build-cache&lt;/code&gt;의 체감이 크지 않을 수 있습니다. &lt;/p&gt;
&lt;p&gt;그래서 로컬에서의 개선은 주로 &lt;code&gt;--parallel&lt;/code&gt;로 독립 태스크를 병렬 실행한 만큼(약 6~7초)만 나타났습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;반면 CI/CD 환경은 매 실행이 거의 “새 환경”에서 시작하므로, 캐시가 없으면 의존성/Gradle 관련 파일을 다시 받거나 빌드 결과물을 매번 새로 만들게 됩니다. &lt;/p&gt;
&lt;p&gt;여기서 Gradle 캐시(의존성/빌드 캐시) 복원 + Docker 레이어 캐시(&lt;code&gt;cache-from/to: gha&lt;/code&gt;)를 적용하면 &lt;strong&gt;의존성 준비 시간과 Docker 빌드 시간이 함께 줄어들어&lt;/strong&gt;, 전체 실행 시간이 6분 10초 → 2분 31초(약 59%)로 크게 감소할 수 있습니다.&lt;/p&gt;
&lt;div class=&quot;typora-post&quot;&gt;&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/인프라</category>
      <category>ci/cd</category>
      <category>배포 시간 감축</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/286</guid>
      <comments>https://cdchan.tistory.com/286#entry286comment</comments>
      <pubDate>Mon, 9 Feb 2026 14:30:10 +0900</pubDate>
    </item>
    <item>
      <title>DBeaver에서 MySQL Dump 시 소켓 연결 오류 해결</title>
      <link>https://cdchan.tistory.com/285</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;DBeaver에서 MySQL Dump 시 소켓 연결 오류 해결&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;&lt;div class=&quot;typora-post&quot;&gt;&lt;h1 id='dbeaver에서-mysql-dump-시-소켓-연결-오류-해결'&gt;DBeaver에서 MySQL Dump 시 소켓 연결 오류 해결&lt;/h1&gt;
&lt;p&gt;DBeaver에서 SSH 터널링으로 Docker 위에서 동작하는 MySQL에 접속해 쿼리는 정상 작동하는데, Dump(Data Transfer/Export)만 실행하면 &lt;code&gt;Can&amp;#39;t connect to local MySQL server through socket&lt;/code&gt; 오류가 발생하는 경우의 해결 방법을 정리합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-28%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%207.38.16.png&quot; alt=&quot;DBeaver dump 소켓 연결 오류&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='원인'&gt;원인&lt;/h2&gt;
&lt;p&gt;DBeaver가 AWS EC2에 있는 MySQL에 접속할 때, 직접 접속(3306 포트 개방)이 막혀 있다면 &lt;strong&gt;SSH 터널링&lt;/strong&gt;을 사용합니다.&lt;/p&gt;
&lt;p&gt;이때 데이터의 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;DBeaver가 SSH 접속을 통해 EC2와 연결을 맺습니다.&lt;/li&gt;
&lt;li&gt;DBeaver는 로컬 Mac의 특정 포트(예: &lt;code&gt;37492&lt;/code&gt;)를 리스닝(Listening) 상태로 엽니다.&lt;/li&gt;
&lt;li&gt;사용자가 쿼리를 날리면, DBeaver는 이 요청을 &lt;code&gt;localhost:37492&lt;/code&gt;로 보냅니다.&lt;/li&gt;
&lt;li&gt;이 신호는 SSH 터널을 타고 EC2로 넘어갑니다.&lt;/li&gt;
&lt;li&gt;EC2 내부에서 이 신호가 해독되어 &lt;code&gt;localhost:3306&lt;/code&gt;(MySQL)으로 전달됩니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;즉, &lt;strong&gt;로컬 Mac의 37492 포트&lt;/strong&gt;는 &lt;strong&gt;원격지 MySQL로 통하는 입구&lt;/strong&gt; 역할을 합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='dump-할-때만-오류가-나는-이유'&gt;Dump 할 때만 오류가 나는 이유&lt;/h3&gt;
&lt;p&gt;평소 쿼리 실행(&lt;code&gt;SELECT&lt;/code&gt; 등)은 DBeaver를 통해 이 터널을 잘 이용합니다. 하지만 &lt;strong&gt;Dump&lt;/strong&gt;는 다릅니다.&lt;/p&gt;
&lt;p&gt;Dump를 수행할 때 DBeaver는 로컬 Mac에 설치된 &lt;strong&gt;외부 프로그램인 &lt;code&gt;mysqldump&lt;/code&gt; 바이너리 파일&lt;/strong&gt;을 실행시킵니다. 이때 문제가 발생합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='문제의-상황'&gt;문제의 상황&lt;/h3&gt;
&lt;p&gt;DBeaver가 &lt;code&gt;mysqldump&lt;/code&gt;에게 명령을 내릴 때 대략 이렇게 명령합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&amp;quot;&lt;code&gt;localhost&lt;/code&gt;의 &lt;code&gt;37492&lt;/code&gt; 포트에 DB 있으니까 거기로 접속해서 데이터 가져와!&amp;quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;그런데 MySQL 계열의 클라이언트 프로그램(&lt;code&gt;mysql&lt;/code&gt;, &lt;code&gt;mysqldump&lt;/code&gt;)은 다음과 같이 작동합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;호스트가 IP(&lt;code&gt;127.0.0.1&lt;/code&gt;)로 주어지면 &lt;strong&gt;TCP/IP(네트워크)&lt;/strong&gt; 연결을 시도합니다.&lt;/li&gt;
&lt;li&gt;호스트가 명칭(&lt;code&gt;localhost&lt;/code&gt;)으로 주어지면 &lt;strong&gt;Unix Socket 파일&lt;/strong&gt; 연결을 시도합니다.&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;오류의 원인:&lt;/strong&gt; DBeaver가 터널링을 통해 열어둔 &lt;code&gt;37492&lt;/code&gt;는 &lt;strong&gt;TCP 포트&lt;/strong&gt;입니다. 하지만 &lt;code&gt;mysqldump&lt;/code&gt;는 &lt;code&gt;localhost&lt;/code&gt;라는 말을 듣고 로컬에 설치된 MySQL이라고 착각하여 &lt;code&gt;/tmp/mysql.sock&lt;/code&gt; 같은 &lt;strong&gt;소켓 파일&lt;/strong&gt;을 찾다가 연결을 못 하고 실패합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='mysql-클라이언트-연결-방식-확인'&gt;MySQL 클라이언트 연결 방식 확인&lt;/h3&gt;
&lt;p&gt;실제로 &lt;code&gt;localhost&lt;/code&gt;와 &lt;code&gt;127.0.0.1&lt;/code&gt;의 동작 차이를 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;localhost 사용 시 (Unix Socket 시도):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;$ mysql -h localhost -P 37492 -u test
ERROR 2002 (HY000): Can&amp;#39;t connect to local MySQL server through socket &amp;#39;/tmp/mysql.sock&amp;#39; (2)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;포트를 명시했음에도 &lt;strong&gt;socket 파일&lt;/strong&gt;을 찾으려고 시도합니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;127.0.0.1 사용 시 (TCP/IP 시도):&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-shell' lang='shell'&gt;$ mysql -h 127.0.0.1 -P 37492 -u test
ERROR 2003 (HY000): Can&amp;#39;t connect to MySQL server on &amp;#39;127.0.0.1:37492&amp;#39; (61)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;TCP/IP 연결&lt;/strong&gt;을 시도하며, 에러 메시지에 포트 번호(&lt;code&gt;37492&lt;/code&gt;)가 포함됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='해결-절차'&gt;해결 절차&lt;/h2&gt;
&lt;p&gt;DBeaver의 Dump 설정에서 호스트 설정을 명확하게 TCP로 강제해주면 해결됩니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;DBeaver에서 해당 DB 커넥션 우클릭 → &lt;strong&gt;Edit Connection&lt;/strong&gt;을 선택합니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.37.08.png&quot; alt=&quot;스크린샷 2026-01-29 오후 4.37.08&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;ol start='2' &gt;
&lt;li&gt;기존 &lt;code&gt;localhost&lt;/code&gt;였던 &lt;strong&gt;Server Host&lt;/strong&gt; 설정을 &lt;code&gt;127.0.0.1&lt;/code&gt;로 변경합니다.&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-29%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%204.39.22.png&quot; alt=&quot;스크린샷 2026-01-29 오후 4.39.22&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;설정 변경 후 Dump를 다시 실행하면 성공적으로 완료되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/DB</category>
      <category>Can't connect to local MySQL server through socket</category>
      <category>dbeaver</category>
      <category>mysql dump</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/285</guid>
      <comments>https://cdchan.tistory.com/285#entry285comment</comments>
      <pubDate>Thu, 29 Jan 2026 17:07:46 +0900</pubDate>
    </item>
    <item>
      <title>bluegreen-배포-환경에서-prometheus-메트릭이-끊겨-보이던-이유와-해결-방법</title>
      <link>https://cdchan.tistory.com/284</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;Blue:Green 배포 환경에서 Prometheus 메트릭이 끊겨 보이던 이유와 해결 방법&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;&lt;div class=&quot;typora-post&quot;&gt;&lt;h1 id='bluegreen-배포-환경에서-prometheus-메트릭이-끊겨-보이던-이유와-해결-방법'&gt;Blue/Green 배포 환경에서 Prometheus 메트릭이 끊겨 보이던 이유와 해결 방법&lt;/h1&gt;
&lt;p&gt;Blue/Green 배포 환경에서 Prometheus의 target을 인스턴스 기준으로 설정한 상태에서 모니터링을 하다 보니, Grafana 그래프가 배포 시점마다 끊겨 보이는 문제가 발생했습니다.&lt;/p&gt;
&lt;p&gt;현재 Spring Boot + Actuator를 사용해 메트릭을 노출하고, Prometheus와 Grafana를 통해 이를 모니터링하고 있습니다.&lt;/p&gt;
&lt;p&gt;서비스는 Blue/Green 배포 방식으로 운영 중이며, 배포 시점에 따라 Spring Boot 인스턴스가 blue 또는 green으로 전환되는 구조입니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;문제는 Prometheus가 특정 시점에는 &lt;code&gt;dev-blue&lt;/code&gt; 인스턴스를 바라보다가, 배포 이후에는 &lt;code&gt;dev-green&lt;/code&gt; 인스턴스를 바라보도록 설정이 변경된다는 점이었습니다.&lt;/p&gt;
&lt;p&gt;Prometheus 입장에서는 blue와 green을 &lt;strong&gt;서로 다른 서버&lt;/strong&gt;로 인식하게 되고, 이로 인해 Grafana에서는 동일한 서비스임에도 불구하고 메트릭이 연속되지 않는 것처럼 보이게 됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;이를 해결하기 위해 Prometheus가 개별 인스턴스(blue/green)를 직접 바라보는 방식이 아니라,&lt;strong&gt;현재 서비스 중인 엔드포인트 하나만을 바라보도록 구조를 변경&lt;/strong&gt;하기로 했습니다.&lt;/p&gt;
&lt;p&gt;현재 서비스 앞단에는 ELB(Application Load Balancer)가 존재하며, ELB가 blue 또는 green 중 실제로 트래픽을 처리하고 있는 인스턴스로 요청을 라우팅하고 있는 상태입니다.&lt;/p&gt;
&lt;p&gt;따라서 Prometheus가 개별 인스턴스가 아닌, &lt;strong&gt;ELB가 바라보고 있는 서비스 엔드포인트를 기준으로 메트릭을 수집하도록 변경&lt;/strong&gt;하면, Blue/Green 전환으로 인해 모니터링 그래프가 끊겨 보이는 문제를 해결할 수 있다고 판단했습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;아래 순서로 변경을 진행해 보겠습니다.&lt;/p&gt;
&lt;ol start='' &gt;
&lt;li&gt;&lt;strong&gt;Spring Boot Actuator의 Prometheus endpoint 경로 변경&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;변경된 endpoint가 ELB를 통해 blue/green 인스턴스로 라우팅되도록 리스너 규칙 추가&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prometheus 설정에서 scrape target을 인스턴스가 아닌 ELB DNS로 변경&lt;/strong&gt;&lt;/li&gt;

&lt;/ol&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='1-spring-boot-actuator의-prometheus-endpoint-경로-변경'&gt;1. Spring Boot Actuator의 Prometheus endpoint 경로 변경&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;기존 actuator.yml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;management:
  endpoints:
    web:
      base-path: /management
      exposure:
        include: prometheus
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존에는 &lt;code&gt;/management&lt;/code&gt; 경로를 통해 Actuator 메트릭을 수집하고 있었습니다.&lt;/p&gt;
&lt;p&gt;이번 변경에서는 Actuator의 base path를 &lt;code&gt;/management&lt;/code&gt;에서 &lt;code&gt;/internal/metrics&lt;/code&gt;으로 변경하여, Prometheus 전용 메트릭 엔드포인트를 별도로 분리해보겠습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;변경후 actuator.yml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;management:
  endpoints:
    web:
      base-path: /internal/metrics
      exposure:
        include: prometheus
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 &lt;code&gt;/internal/metrics/prometheus&lt;/code&gt; 경로를 통해 Prometheus가 메트릭을 수집할 수 있도록 변경되었습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='2-변경된-endpoint가-elb를-통해-bluegreen-인스턴스로-라우팅되도록-리스너-규칙-추가'&gt;2. 변경된 endpoint가 ELB를 통해 blue/green 인스턴스로 라우팅되도록 리스너 규칙 추가&lt;/h2&gt;
&lt;p&gt;현재 ELB 의 리스너 규칙은 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-02%20%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB%2010.22.17.png&quot; alt=&quot;스크린샷 2026-01-02 오전 10.22.17&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;기존 BE 트래픽은 &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/ws*&lt;/code&gt; 경로를 기준으로 &lt;code&gt;be-rule&lt;/code&gt;을 통해 blue/green 인스턴스로 라우팅되고 있습니다.&lt;/p&gt;
&lt;p&gt;이번에 추가한 &lt;code&gt;/internal/metrics/prometheus&lt;/code&gt; 엔드포인트 역시 동일한 BE 애플리케이션에서 제공되기 때문에, 우선은 별도의 리스너 규칙을 추가하지 않고 기존 &lt;code&gt;be-rule&lt;/code&gt;에 &lt;code&gt;/internal*&lt;/code&gt; 경로를 함께 포함하도록 구성해보겠습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-02%20%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB%2010.25.05.png&quot; alt=&quot;스크린샷 2026-01-02 오전 10.25.05&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;사진처럼 구성하게 되면 Prometheus가 &lt;code&gt;/internal/metrics/prometheus&lt;/code&gt;로 요청하더라도, 기존 BE 트래픽과 동일하게 현재 서비스 중인 blue 또는 green 인스턴스로 자동 라우팅됩니다.&lt;/p&gt;
&lt;p&gt;다만 &lt;code&gt;/internal/*&lt;/code&gt; 경로는 비즈니스 API와 성격이 다른 운영/관측용 엔드포인트이기 때문에, 운영 환경에서는 별도의 리스너 규칙으로 분리하거나, IP 제한 등의 추가적인 보안 설정을 적용하는 것이 더 적절할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='3-prometheus-설정에서-target을-인스턴스가-아닌-elb-dns로-변경'&gt;3. Prometheus 설정에서 target을 인스턴스가 아닌 ELB DNS로 변경&lt;/h2&gt;
&lt;p&gt;마지막으로 Prometheus 설정에서 scrape target을 개별 인스턴스가 아닌, ELB의 DNS를 바라보도록 변경합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;기존 prometheus.yml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;global:
  scrape_interval: 15s

scrape_configs:
  - job_name: prometheus
    metrics_path: &amp;quot;/management/prometheus&amp;quot;
    static_configs:
      - targets: [&amp;quot;dev-green:8082&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;변경후 prometheus.yml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;global:
  scrape_interval: 15s

scrape_configs:
  - job_name: prometheus
    scheme: https
    metrics_path: &amp;quot;/internal/metrics/prometheus&amp;quot;
    static_configs:
      - targets: [&amp;quot;로드 밸런서의 DNS 이름&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;이 변경을 통해 Prometheus는 더 이상 &lt;code&gt;dev-blue&lt;/code&gt; 또는 &lt;code&gt;dev-green&lt;/code&gt; 인스턴스를 직접 바라보지 않고, 항상 동일한 ELB DNS를 기준으로 메트릭을 수집하게 됩니다.&lt;/p&gt;
&lt;p&gt;따라서 Blue/Green 배포로 인해 인스턴스가 교체되더라도, Prometheus 설정을 다시 수정할 필요가 없게 됩니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='4-테스트'&gt;4. 테스트&lt;/h2&gt;
&lt;p&gt;Prometheus가 정상적으로 연결되어 메트릭을 수집하고 있다면, Grafana 대시보드에서 아래와 같이 메트릭이 정상적으로 표시되는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20260102114542516.png&quot; alt=&quot;image-20260102114542516&quot;&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;보다 빠르게 확인하고 싶다면, Prometheus의 &lt;strong&gt;Targets 페이지&lt;/strong&gt;를 통해 메트릭 수집 상태를 직접 확인할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://&amp;lt;prometheus-host&amp;gt;:9090/targets
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 페이지에서 대상(target)의 상태가 &lt;code&gt;UP&lt;/code&gt;으로 표시된다면, Prometheus가 해당 엔드포인트로부터 정상적으로 메트릭을 수집하고 있다는 의미입니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-02%20%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB%2011.11.33.png&quot; alt=&quot;스크린샷 2026-01-02 오전 11.11.33&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='41-tls-인증서-오류-발생'&gt;4.1 TLS 인증서 오류 발생&lt;/h3&gt;
&lt;p&gt;저의 경우, 위 Targets 페이지에서 아래와 같은 오류를 확인할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202026-01-02%20%E1%84%8B%E1%85%A9%E1%84%8C%E1%85%A5%E1%86%AB%2011.00.31.png&quot; alt=&quot;스크린샷 2026-01-02 오전 11.00.31&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;해당 오류는 Prometheus가 &lt;strong&gt;ELB의 DNS 주소로 HTTPS 요청&lt;/strong&gt;을 시도하면서 발생했습니다.&lt;/p&gt;
&lt;p&gt;ELB에는 제 서비스 도메인에 대한 TLS 인증서만 적용되어 있었고, ELB 고유의 DNS 주소에 대한 인증서는 존재하지 않았기 때문입니다.&lt;/p&gt;
&lt;p&gt;즉,&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;도메인(&lt;code&gt;example.com&lt;/code&gt;) → ELB → 정상&lt;/li&gt;
&lt;li&gt;ELB DNS(&lt;code&gt;xxx.elb.amazonaws.com&lt;/code&gt;) → 인증서 불일치 → 오류 발생&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;이라는 상황이었습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id='해결-방법'&gt;해결 방법&lt;/h4&gt;
&lt;p&gt;이를 해결하기 위해 Prometheus 설정에서 target을 &lt;strong&gt;ELB의 DNS 주소가 아닌, TLS 인증서가 적용된 서비스 도메인&lt;/strong&gt;으로 변경했습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class='language-yaml' lang='yaml'&gt;global:
  scrape_interval: 15s

scrape_configs:
  - job_name: prometheus
    scheme: https
    metrics_path: &amp;quot;/internal/metrics/prometheus&amp;quot;
    static_configs:
      - targets: [&amp;quot;도메인명 ex) example.com&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 설정하면 TLS 인증서 검증 문제 없이 정상적으로 메트릭을 수집할 수 있으며, Blue/Green 배포 환경에서도 Prometheus 설정을 변경할 필요가 없어집니다.&lt;/p&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/인프라</category>
      <category>blue green</category>
      <category>ELB</category>
      <category>Grafana</category>
      <category>Prometheus</category>
      <category>로드밸런서</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/284</guid>
      <comments>https://cdchan.tistory.com/284#entry284comment</comments>
      <pubDate>Fri, 2 Jan 2026 13:14:54 +0900</pubDate>
    </item>
    <item>
      <title>Grafana 및 prometheus 활용 #2</title>
      <link>https://cdchan.tistory.com/282</link>
      <description>&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;meta charset='UTF-8'&gt;&lt;meta name='viewport' content='width=device-width initial-scale=1'&gt;
&lt;title&gt;Grafana 및 prometheus 활용 #2&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;&lt;div class=&quot;typora-post&quot;&gt;&lt;h1 id='grafana-및-prometheus-활용-2'&gt;Grafana 및 prometheus 활용 #2&lt;/h1&gt;
&lt;p&gt;본 글에서는 Grafana와 Prometheus를 연결하는 방법과, 실제 모니터링에 바로 사용할 수 있는 대시보드 템플릿 적용 방법에 대해 다뤄보겠습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='1-prometheus-데이터-소스-추가'&gt;1. Prometheus 데이터 소스 추가&lt;/h2&gt;
&lt;p&gt;Grafana에서 Prometheus 메트릭을 조회하려면, 먼저 &lt;strong&gt;Prometheus를 Data Source로 등록&lt;/strong&gt;해야 합니다.&lt;/p&gt;
&lt;p&gt;Grafana에 접속한 후 아래 메뉴로 이동합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Connections → Data sources&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='11-prometheus-컨테이너-이름-확인'&gt;1.1 Prometheus 컨테이너 이름 확인&lt;/h3&gt;
&lt;p&gt;현재 &lt;code&gt;docker-compose&lt;/code&gt;를 통해 실행 중인 컨테이너는 다음과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-31%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%208.13.09.png&quot; alt=&quot;스크린샷 2025-12-31 오후 8.13.09&quot; style=&quot;zoom: 67%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker-compose&lt;/code&gt;를 사용하는 경우, &lt;strong&gt;같은 네트워크에 속한 컨테이너들은 서비스 이름을 hostname으로 사용&lt;/strong&gt;할 수 있습니다.&lt;/p&gt;
&lt;p&gt; 따라서 Prometheus 컨테이너의 이름이 &lt;code&gt;prometheus&lt;/code&gt;라면, Grafana에서는 해당 이름을 그대로 사용해 연결할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='12-data-source-설정'&gt;1.2 Data Source 설정&lt;/h3&gt;
&lt;p&gt;Data Source 추가 화면에서 &lt;strong&gt;Prometheus&lt;/strong&gt;를 선택한 뒤, Connection(URL)을 다음과 같이 설정합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-31%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%208.14.14.png&quot; alt=&quot;스크린샷 2025-12-31 오후 8.14.14&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;나머지 옵션은 기본값을 사용하거나, 각자의 환경에 맞게 조정해도 무방합니다.&lt;/p&gt;
&lt;p&gt;설정이 끝나면 &lt;strong&gt;Save &amp;amp; test&lt;/strong&gt; 버튼을 클릭합니다.&lt;/p&gt;
&lt;p&gt;아래와 같이 정상적으로 연결되었다는 메시지가 표시되면, Grafana와 Prometheus 간의 연동이 완료된 상태입니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20251231201528419.png&quot; alt=&quot;image-20251231201528419&quot; style=&quot;zoom:50%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id='2-대시보드-설정'&gt;2. 대시보드 설정&lt;/h2&gt;
&lt;p&gt;Prometheus 데이터 소스를 연결했다면, 이제 해당 데이터를 시각화할 &lt;strong&gt;Grafana Dashboard&lt;/strong&gt;를 선택해야 합니다.&lt;/p&gt;
&lt;p&gt;Grafana는 공식적으로 다양한 대시보드를 제공하고 있으며, 아래 사이트에서 원하는 대시보드를 검색하고 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;a href='https://grafana.com/grafana/dashboards/' target='_blank' class='url'&gt;https://grafana.com/grafana/dashboards/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='21-사용할-대시보드-선택'&gt;2.1 사용할 대시보드 선택&lt;/h3&gt;
&lt;p&gt;본 글에서는 &lt;strong&gt;JVM 및 Micrometer 기반 메트릭을 확인할 수 있는&lt;/strong&gt;다음 대시보드를 사용하여 모니터링을 구성해보겠습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href='https://grafana.com/grafana/dashboards/4701-jvm-micrometer/' target='_blank' class='url'&gt;https://grafana.com/grafana/dashboards/4701-jvm-micrometer/&lt;/a&gt;&lt;/li&gt;

&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-31%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%208.25.50.png&quot; alt=&quot;스크린샷 2025-12-31 오후 8.25.50&quot; style=&quot;zoom: 25%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;해당 페이지에 접속한 후, &lt;strong&gt;&lt;code&gt;Copy ID to Clipboard&lt;/code&gt;&lt;/strong&gt; 버튼을 눌러 대시보드 ID를 복사합니다.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='22-grafana에서-대시보드-import'&gt;2.2 Grafana에서 대시보드 Import&lt;/h3&gt;
&lt;p&gt;복사한 대시보드 ID를 사용하여 Grafana로 돌아온후 아래 경로로 이동합니다.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Dashboards → Create dashboard → Import a dashboard&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Import 화면에서 복사한 &lt;strong&gt;Dashboard ID&lt;/strong&gt;를 입력합니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/image-20251231202725730.png&quot; alt=&quot;image-20251231202725730&quot; style=&quot;zoom: 33%;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id='23-data-source-선택-및-import'&gt;2.3 Data Source 선택 및 Import&lt;/h3&gt;
&lt;p&gt;대시보드 정보를 불러오면, 사용할 Data Source를 선택하는 화면이 표시됩니다.&lt;/p&gt;
&lt;p&gt;여기서 앞 단계에서 생성한 &lt;strong&gt;Prometheus Data Source&lt;/strong&gt;를 선택한 후 &lt;strong&gt;Import&lt;/strong&gt; 버튼을 클릭합니다.&lt;/p&gt;
&lt;p&gt;Import가 완료되면, 아래와 같이 JVM 및 애플리케이션 메트릭을 확인할 수 있는 대시보드가 생성됩니다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/CDBchan/Typora-img/main/img/%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA%202025-12-31%20%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE%208.28.20.png&quot; alt=&quot;스크린샷 2025-12-31 오후 8.28.20&quot; style=&quot;zoom:33%;&quot; /&gt;&lt;/p&gt;
&lt;/div&gt;&lt;/body&gt;
&lt;/html&gt;</description>
      <category>공부/인프라</category>
      <category>Grafana</category>
      <category>Grafana Dashboard</category>
      <category>Prometheus</category>
      <category>모니터링</category>
      <author>chanyoun</author>
      <guid isPermaLink="true">https://cdchan.tistory.com/282</guid>
      <comments>https://cdchan.tistory.com/282#entry282comment</comments>
      <pubDate>Wed, 31 Dec 2025 20:47:25 +0900</pubDate>
    </item>
  </channel>
</rss>