<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Joo's Story</title>
    <link>https://blog.joostory.net/</link>
    <description>개인적인 관심사에 관해서 이런저런 이야기를 하고 있습니다.
#집밥 #개발자 #Ubuntu</description>
    <language>ko</language>
    <pubDate>Fri, 30 Oct 2026 11:54:45 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Joo</managingEditor>
    <image>
      <title>Joo's Story</title>
      <url>https://tistory1.daumcdn.net/tistory/22084/attach/b99d6461ad6441cdad8f21ccbd483ba6</url>
      <link>https://blog.joostory.net</link>
    </image>
    <item>
      <title>theme preview 제작기 (티스토리, 텀블러)</title>
      <link>https://blog.joostory.net/634</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;블로그를 운영하다 보면 내 마음에 쏙 드는 테마(스킨)를 직접 만들거나 수정하고 싶어질 때가 있다. 나 역시 텀블러(Tumblr)와 티스토리(Tistory) 블로그 테마를 직접 입맛에 맞게 고쳐 쓰곤 하는데, 막상 테마를 수정하려고 하면 &lt;b&gt;'코드를 고치고 눈으로 확인하는 과정'&lt;/b&gt;이 생각보다 꽤 번거롭다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;텀블러&lt;/b&gt;는 테마 편집기 화면에 HTML을 통째로 붙여넣고 프리뷰가 뜰 때까지 기다려야 한다. 외부 CSS/JS 파일이라도 분리해 두면 캐시나 CSP(Content Security Policy) 문제로 Resource Override 같은 브라우저 확장 프로그램과 씨름해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;티스토리&lt;/b&gt;도 마찬가지로 스킨 편집 화면에서 일일이 코드를 고쳐 적용해 보거나, 로컬에서 수정한 &lt;code&gt;skin.html&lt;/code&gt;, &lt;code&gt;style.css&lt;/code&gt; 파일들을 매번 관리자 페이지에 업로드하며 확인해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;*&quot;그냥 프론트엔드 개발할 때처럼 로컬에서 코드를 저장하면 브라우저에 바로 반영되게 할 수는 없을까?&quot;*&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단순한 갈증에서 출발해 로컬 테마 프리뷰 도구인 &lt;b&gt;&lt;a href=&quot;https://github.com/joostory/tumblr-theme-preview&quot;&gt;tumblr-theme-preview&lt;/a&gt;&lt;/b&gt;와 &lt;b&gt;&lt;a href=&quot;https://github.com/joostory/tistory-theme-preview&quot;&gt;tistory-theme-preview&lt;/a&gt;&lt;/b&gt;를 만들게 되었다. 이번 글에서는 두 도구의 핵심인 &lt;b&gt;테마 문법 파서&lt;/b&gt;를 어떻게 만들었는지, 그리고 &lt;b&gt;테마 수정을 편하게 해주는 프리뷰 앱의 구조와 기능&lt;/b&gt;을 실제 코드와 함께 가볍게 정리해 본다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 테마 문법 파서 만들기: 공식 문서를 코드로 옮기는 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텀블러와 티스토리의 테마 파일은 일반 HTML처럼 보이지만, 실제로는 각 플랫폼 고유의 &lt;b&gt;템플릿 치환자 문법&lt;/b&gt;이 가득 들어 있다. 로컬에서 테마를 제대로 미리 보려면 브라우저에 HTML을 내려주기 전에 이 치환자들을 실제 데이터로 바꿔주는 &lt;b&gt;템플릿 엔진(파서)&lt;/b&gt;이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 정규식(Regex)과 &lt;code&gt;String.replace()&lt;/code&gt;로 치환하면 되지 않을까 생각했지만, 곧 한계에 부딪혔다. &lt;b&gt;블록 안에 또 다른 블록이 중첩&lt;/b&gt;되기 때문이다. 예를 들어 '포스트 반복 블록' 안에 '태그 반복 블록'이 있고, 그 안에 또 '조건문 블록'이 들어가는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 두 프로젝트 모두 가벼운 &lt;b&gt;3단계 컴파일 파이프라인(&lt;code&gt;tokenize&lt;/code&gt; &amp;rarr; &lt;code&gt;buildAST&lt;/code&gt; &amp;rarr; &lt;code&gt;evaluateAST&lt;/code&gt;)&lt;/b&gt; 구조로 파서를 설계했다.&lt;/p&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;function compile(template, context = {}) {
  const tokens = tokenize(template);   // 1. 문자열을 토큰 단위로 분리
  const ast = buildAST(tokens);        // 2. 중첩 블록을 트리(AST) 구조로 변환
  return evaluateAST(ast, context);    // 3. Mock 데이터를 대입해 최종 HTML 렌더링
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1-1. Tumblr 테마 문법 파싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.tumblr.com/docs/en/custom_themes&quot;&gt;텀블러 커스텀 테마 공식 문서&lt;/a&gt;를 보면 텀블러의 문법은 중괄호(&lt;code&gt;{ }&lt;/code&gt;) 하나로 모든 것을 표현한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;블록 문법&lt;/b&gt;: &lt;code&gt;{block:Posts} ... {/block:Posts}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;변수 치환자&lt;/b&gt;: &lt;code&gt;{Title}&lt;/code&gt;, &lt;code&gt;{Body}&lt;/code&gt;, &lt;code&gt;{Permalink}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;커스텀 메타 변수&lt;/b&gt;: &lt;code&gt;{text:Theme Version}&lt;/code&gt;, &lt;code&gt;{color:Background}&lt;/code&gt;, &lt;code&gt;{select:Layout Width}&lt;/code&gt;, &lt;code&gt;{lang:Search results for SearchQuery}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 토크나이저(&lt;code&gt;tokenize&lt;/code&gt;)에서는 정규식으로 블록의 시작(&lt;code&gt;{block:...}&lt;/code&gt;), 블록의 끝(&lt;code&gt;{/block:...}&lt;/code&gt;), 그리고 일반 변수(&lt;code&gt;{...}&lt;/code&gt;)를 추출해 토큰 배열로 만든다.&lt;/p&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;// tumblr-theme-preview/lib/parser.js
function tokenize(template) {
  const tokens = [];
  let lastIndex = 0;
  let match;

  // {block:Name}, {/block:Name}, {Variable} 등 텀블러 예약어 토큰 추출 정규식
  const regex = /\{(\/?block:[A-Za-z0-9_:\-]+|(?:text|select|color|font|image|if|lang):[A-Za-z0-9_:\- ]+|[A-Za-z0-9_:\-]+)\}/g;

  while ((match = regex.exec(template)) !== null) {
    const textBefore = template.substring(lastIndex, match.index);
    if (textBefore) tokens.push({ type: 'text', content: textBefore });

    const tokenVal = match[1];
    if (tokenVal.startsWith('block:')) {
      tokens.push({ type: 'block_start', name: tokenVal.substring(6) });
    } else if (tokenVal.startsWith('/block:')) {
      tokens.push({ type: 'block_end', name: tokenVal.substring(7) });
    } else {
      tokens.push({ type: 'variable', name: tokenVal });
    }
    lastIndex = regex.lastIndex;
  }
  // ... 남은 텍스트 처리
  return tokens;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 뽑아낸 토큰들은 스택(Stack)을 이용해 부모-자식 관계를 갖는 트리(AST)로 조립한다. &lt;code&gt;block_start&lt;/code&gt;를 만나면 새 블록 노드를 만들어 스택에 &lt;code&gt;push&lt;/code&gt;하고, 짝이 맞는 &lt;code&gt;block_end&lt;/code&gt;를 만나면 &lt;code&gt;pop&lt;/code&gt;하는 식이다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;function buildAST(tokens) {
  const root = { type: 'root', children: [] };
  const stack = [root];

  for (const token of tokens) {
    const currentParent = stack[stack.length - 1];

    if (token.type === 'text' || token.type === 'variable') {
      currentParent.children.push(token);
    } else if (token.type === 'block_start') {
      const newBlock = { type: 'block', name: token.name, children: [] };
      currentParent.children.push(newBlock);
      stack.push(newBlock);
    } else if (token.type === 'block_end') {
      if (stack.length &amp;gt; 1 &amp;amp;&amp;amp; stack[stack.length - 1].name === token.name) {
        stack.pop();
      }
    }
  }
  return root;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 평가기(&lt;code&gt;evaluateAST&lt;/code&gt;)가 트리를 순회하며 HTML을 만들어낸다. 텀블러 공식 문서를 보며 파서에 신경 써서 반영한 포인트는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;조건형 블록과 반복형 블록 구분&lt;/b&gt;: 같은 &lt;code&gt;{block:Name}&lt;/code&gt; 형태라도 어떤 것은 조건문(&lt;code&gt;{block:IndexPage}&lt;/code&gt;, &lt;code&gt;{block:IfEndlessScroll}&lt;/code&gt;, &lt;code&gt;{block:Text}&lt;/code&gt;)이고, 어떤 것은 배열을 순회하는 반복문(&lt;code&gt;{block:Posts}&lt;/code&gt;, &lt;code&gt;{block:Tags}&lt;/code&gt;)이다. 예를 들어 &lt;code&gt;{block:Posts}&lt;/code&gt;를 만나면 Mock 데이터의 &lt;code&gt;posts&lt;/code&gt; 배열을 순회하며 자식 컨텍스트에 &lt;code&gt;currentPost&lt;/code&gt;를 주입해 렌더링한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유연한 커스텀 변수 매칭&lt;/b&gt;: 텀블러에서는 &lt;code&gt;&amp;lt;meta name=&quot;if:Show Header&quot; content=&quot;1&quot;&amp;gt;&lt;/code&gt;처럼 띄어쓰기가 포함된 변수를 본문에서 &lt;code&gt;{block:IfShowHeader}&lt;/code&gt;나 &lt;code&gt;{block:IfNotShowHeader}&lt;/code&gt;로 공백 없이 붙여 쓴다. 이를 위해 대소문자와 공백을 제거해 비교하는 헬퍼 함수를 두어 어떤 형태로 작성하든 매칭되도록 했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;20여 가지 날짜&amp;middot;시간 변수의 동적 생성&lt;/b&gt;: 텀블러 문서에는 &lt;code&gt;{DayOfMonth}&lt;/code&gt;, &lt;code&gt;{DayOfWeekShort}&lt;/code&gt;, &lt;code&gt;{TimeAgo}&lt;/code&gt;, &lt;code&gt;{Timestamp}&lt;/code&gt; 등 날짜 관련 치환자가 무척 많다. Mock 데이터에 이를 일일이 적는 대신, 기본 날짜(&lt;code&gt;Year&lt;/code&gt;, &lt;code&gt;ShortMonth&lt;/code&gt;, &lt;code&gt;DayOfMonth&lt;/code&gt;)만 있으면 나머지 20여 개 변수를 자동으로 계산해 반환하도록 구현했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1-2. Tistory 스킨 문법 파싱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://tistory.github.io/document-tistory-skin/&quot;&gt;티스토리 스킨 가이드 공식 문서&lt;/a&gt;를 참고해 만든 티스토리 파서는 텀블러와는 또 다른 문법 구조를 다룬다. 티스토리는 두 가지 문법을 조합해서 사용한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;그룹 치환자 (블록)&lt;/b&gt;: &lt;code&gt;&amp;lt;s_t3&amp;gt; ... &amp;lt;/s_t3&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;s_article_rep&amp;gt; ... &amp;lt;/s_article_rep&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;값 치환자 (변수)&lt;/b&gt;: &lt;code&gt;[##_title_## ]&lt;/code&gt;, &lt;code&gt;[##_article_rep_title_## ]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;티스토리 토크나이저는 &lt;code&gt;&amp;lt;s_태그명 속성&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;/s_태그명&amp;gt;&lt;/code&gt;, &lt;code&gt;[##_변수명_## ]&lt;/code&gt; 세 가지 패턴을 한 번에 파싱한다. 특히 홈 커버에서 쓰이는 &lt;code&gt;&amp;lt;s_cover name='featured'&amp;gt;&lt;/code&gt;처럼 &lt;b&gt;그룹 치환자에 HTML 속성(Attribute)이 들어가는 경우&lt;/b&gt;까지 처리해야 했다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// tistory-theme-preview/lib/parser.js
function tokenize(template) {
  const tokens = [];
  let lastIndex = 0;

  // 1. 그룹 시작: &amp;lt;s_TAG attrs&amp;gt; | 2. 그룹 종료: &amp;lt;/s_TAG&amp;gt; | 3. 값 치환자: [##_VAR_## ]
  const regex = /&amp;lt;s_([a-zA-Z0-9_\-]+)((?:\s+[^&amp;gt;]*)?)&amp;gt;|&amp;lt;\/s_([a-zA-Z0-9_\-]+)&amp;gt;|\[##_([a-zA-Z0-9_\-]+)_##\]/gi;

  let match;
  while ((match = regex.exec(template)) !== null) {
    // ... 이전 텍스트 추가
    if (match[1] !== undefined) {
      const name = match[1].toLowerCase();
      const rawAttrs = match[2] || '';
      const attrs = {};
      const attrRegex = /([a-zA-Z0-9_\-]+)=[&quot;']([^&quot;']*)[&quot;']/g;
      let attrMatch;
      while ((attrMatch = attrRegex.exec(rawAttrs)) !== null) {
        attrs[attrMatch[1].toLowerCase()] = attrMatch[2];
      }
      tokens.push({ type: 'block_start', name, attrs });
    } else if (match[3] !== undefined) {
      tokens.push({ type: 'block_end', name: match[3].toLowerCase() });
    } else if (match[4] !== undefined) {
      tokens.push({ type: 'variable', name: match[4] });
    }
    lastIndex = regex.lastIndex;
  }
  return tokens;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;티스토리 파서를 구현하면서 챙긴 핵심 포인트는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;홈 커버와 아티클 영역의 상호배타적 렌더링&lt;/b&gt;: 티스토리에서는 홈 화면(&lt;code&gt;index&lt;/code&gt;)에 홈 커버(&lt;code&gt;&amp;lt;s_cover_group&amp;gt;&lt;/code&gt;)가 활성화되어 있으면 기본 글 목록(&lt;code&gt;&amp;lt;s_article_rep&amp;gt;&lt;/code&gt;) 대신 커버 아이템들이 렌더링된다. 평가기에서 현재 &lt;code&gt;viewType&lt;/code&gt;과 &lt;code&gt;enableCovers&lt;/code&gt; 옵션을 확인해 실제 티스토리와 똑같이 분기되도록 맞췄다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xquery&quot;&gt;&lt;code&gt;// 평가기(evaluateAST) 내부의 그룹 치환자 처리 예시
if (name === 'cover_rep') {
  if (context.viewType !== 'index' || !context.enableCovers) return '';
  return (context.covers || []).map(cov =&amp;gt;
    renderChildren(node, { ...context, currentCover: cov })
  ).join('');
}

if (name === 'cover') {
  // &amp;lt;s_cover name=&quot;featured&quot;&amp;gt; 속성과 현재 커버 데이터의 name이 일치할 때만 렌더링
  const coverNameAttr = node.attrs &amp;amp;&amp;amp; node.attrs.name;
  if (context.currentCover &amp;amp;&amp;amp; coverNameAttr === context.currentCover.name) {
    return renderChildren(node, context);
  }
  return '';
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;index.xml&lt;/code&gt; 파서 연동&lt;/b&gt;: 티스토리 스킨은 &lt;code&gt;skin.html&lt;/code&gt; 혼자 동작하지 않고 스킨 설정 파일인 &lt;code&gt;index.xml&lt;/code&gt;과 짝을 이룬다. 별도의 XML 파서(&lt;code&gt;xml_parser.js&lt;/code&gt;)를 만들어 &lt;code&gt;index.xml&lt;/code&gt;에 정의된 스킨 옵션(&lt;code&gt;&amp;lt;variables&amp;gt;&lt;/code&gt;), 홈 커버 정의(&lt;code&gt;&amp;lt;cover&amp;gt;&lt;/code&gt;), 리스트 스타일 등을 읽어오고, 이를 스킨의 &lt;code&gt;&amp;lt;s_if_var_...&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;s_not_var_...&amp;gt;&lt;/code&gt; 조건문이나 &lt;code&gt;[##_var_..._## ]&lt;/code&gt; 치환자와 연결했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최신 위젯과 레거시 문법 동시 지원&lt;/b&gt;: 예전 티스토리 스킨들은 &lt;code&gt;&amp;lt;s_rp_rep&amp;gt;&lt;/code&gt; 그룹 치환자로 댓글 HTML 구조를 직접 짰지만, 최근 스킨들은 &lt;code&gt;[##_comment_group_## ]&lt;/code&gt;이나 &lt;code&gt;[##_guestbook_group_## ]&lt;/code&gt; 치환자 하나로 React 기반의 최신 댓글&amp;middot;방명록 위젯을 통째로 불러온다. 어떤 스킨을 테스트하든 어색하지 않도록 두 방식 모두 실제 티스토리 화면과 유사하게 렌더링되도록 구현했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 프리뷰 앱 구조: 테마 수정을 쾌적하게 만드는 기능들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파서가 엔진이라면, 프리뷰 앱은 테마 제작자가 실제로 다루는 환경이다. 테마를 수정할 때 불편했던 점들을 하나씩 해결하다 보니 자연스럽게 아래와 같은 구조가 만들어졌다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;preview-architecture.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CFadX/dJMcag1GCIY/NhoRwe5x0lGm61mkDj7Ij0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CFadX/dJMcag1GCIY/NhoRwe5x0lGm61mkDj7Ij0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CFadX/dJMcag1GCIY/NhoRwe5x0lGm61mkDj7Ij0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCFadX%2FdJMcag1GCIY%2FNhoRwe5x0lGm61mkDj7Ij0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1200&quot; height=&quot;680&quot; data-filename=&quot;preview-architecture.png&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;680&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;① 설정이 필요 없는 자동 탐색 (Zero-Config CLI)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 설정 파일을 만들 필요 없이, 작업 중인 테마 폴더에서 터미널을 열고 명령어 한 줄만 입력하면 바로 실행되도록 만들었다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 텀블러 테마 폴더에서
npx tumblr-theme-preview

# 티스토리 스킨 폴더에서
npx tistory-theme-preview&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CLI가 실행되면 현재 폴더 안의 파일들을 스캔해 &lt;code&gt;{block:Posts}&lt;/code&gt;나 &lt;code&gt;&amp;lt;s_t3&amp;gt;&lt;/code&gt;, &lt;code&gt;[##_&lt;/code&gt; 같은 고유 치환자가 들어 있는 HTML 파일을 알아서 찾아내고, 함께 있는 &lt;code&gt;style.css&lt;/code&gt;, &lt;code&gt;index.xml&lt;/code&gt;, &lt;code&gt;images/&lt;/code&gt; 폴더를 자동으로 매핑해 로컬 서버를 띄운다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;② 원격 CDN 주소를 로컬 파일로 바꿔치기 (Smart Asset Replacer)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테마를 만들다 보면 HTML 안에 &lt;code&gt;&amp;lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdn.jsdelivr.net/.../main.css&quot;&amp;gt;&lt;/code&gt;처럼 이미 배포된 원격 CDN 주소를 적어두는 경우가 많다. 로컬에서 수정할 때마다 이 주소를 &lt;code&gt;./css/main.css&lt;/code&gt;로 바꿨다가 배포할 때 다시 원복하는 건 꽤 번거롭고 실수하기도 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 렌더링 직전에 HTML 안의 원격 &lt;code&gt;http://&lt;/code&gt;, &lt;code&gt;https://&lt;/code&gt; 에셋 경로를 스캔한 뒤, &lt;b&gt;파일 이름이 내 로컬 폴더(&lt;code&gt;css/&lt;/code&gt;, &lt;code&gt;js/&lt;/code&gt;, &lt;code&gt;images/&lt;/code&gt;)에 있는 파일과 같다면 자동으로 로컬 파일을 바라보도록 우회(Rewrite)&lt;/b&gt;하는 기능을 넣었다.&lt;/p&gt;
&lt;pre class=&quot;qml&quot;&gt;&lt;code&gt;// server.js - 원격 CDN 에셋을 로컬 파일로 자동 우회
function applySmartAssetReplacements(html) {
  const assetRegex = /(href|src)=[&quot;'](https?:\/\/[^&quot;']+\.(css|js|png|jpg|jpeg|gif|svg|webp))[&quot;']/g;

  return html.replace(assetRegex, (match, attr, url, ext) =&amp;gt; {
    const filename = path.basename(url);
    const checkPaths = [
      path.join(imagesDir, filename),
      path.join(skinDir, filename),
      path.join(skinDir, ext === 'css' ? 'css' : 'js', filename)
    ];

    for (const checkPath of checkPaths) {
      if (fs.existsSync(checkPath)) {
        // 로컬에 동일한 파일명이 존재하면 로컬 경로로 즉시 치환
        return checkPath.startsWith(imagesDir)
          ? `${attr}=&quot;/images/${filename}&quot;`
          : `${attr}=&quot;/${filename}&quot;`;
      }
    }
    return match;
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 HTML 코드는 한 줄도 건드리지 않고 로컬 CSS와 JS를 수정하며 바로 결과를 확인할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;③ 저장하면 바로 반영되는 라이브 리로드 (Live Reload)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 수정하고 저장했을 때 브라우저를 일일이 새로고침하는 수고를 덜기 위해, Node.js의 &lt;code&gt;fs.watch&lt;/code&gt;와 &lt;b&gt;SSE(Server-Sent Events)&lt;/b&gt;를 활용한 라이브 리로드를 적용했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 파일 변경 감지 시 연결된 브라우저로 reload 이벤트 전송
fs.watch(targetPath, { recursive: true }, (eventType, filename) =&amp;gt; {
  clearTimeout(reloadTimeout);
  reloadTimeout = setTimeout(() =&amp;gt; {
    liveReloadClients.forEach(client =&amp;gt; client.res.write('data: reload\n\n'));
  }, 150);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;렌더링되는 프리뷰 HTML 하단에는 &lt;code&gt;EventSource('/api/live-reload')&lt;/code&gt; 스크립트가 자동으로 주입되어, 에디터에서 &lt;code&gt;skin.html&lt;/code&gt;이나 &lt;code&gt;style.css&lt;/code&gt;를 수정하고 저장하면 0.15초 만에 화면이 자동 갱신된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;④ 모든 화면과 옵션을 한눈에 테스트하는 대시보드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;에 접속하면 단순히 렌더링된 결과물만 뜨는 것이 아니라, 테마의 구석구석을 테스트할 수 있는 &lt;b&gt;컨트롤 대시보드&lt;/b&gt;가 함께 열린다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;다양한 뷰 모드 전환&lt;/b&gt;: 홈(인덱스) 화면뿐만 아니라 글 본문 상세, 카테고리 목록, 태그 페이지, 검색 결과, &lt;b&gt;'검색 결과가 없을 때'&lt;/b&gt;의 화면, 공지사항, 보호글(비밀번호 입력 화면), 방명록까지 버튼 클릭 한 번으로 전환하며 레이아웃이 깨지는 곳이 없는지 확인할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;반응형 뷰포트 프리셋&lt;/b&gt;: 데스크톱, 랩톱(1280px), 태블릿, 모바일 화면 크기를 즉시 오가며 반응형 CSS를 점검할 수 있고, 가로/세로 회전 기능도 지원한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테마&amp;middot;스킨 옵션의 동적 UI 생성&lt;/b&gt;: 가장 공을 들인 기능 중 하나다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;텀블러&lt;/b&gt; 테마 HTML 상단에 &lt;code&gt;&amp;lt;meta name=&quot;color:Accent&quot; ...&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;meta name=&quot;if:Endless Scroll&quot; ...&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;meta name=&quot;select:Layout Width&quot; ...&amp;gt;&lt;/code&gt;를 선언해 두면, 서버가 이를 추출해 대시보드 왼쪽에 컬러 피커, 토글 스위치, 드롭다운 메뉴를 자동으로 만들어 준다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;티스토리&lt;/b&gt; 역시 &lt;code&gt;index.xml&lt;/code&gt;에 정의된 &lt;code&gt;&amp;lt;variables&amp;gt;&lt;/code&gt;(COLOR, BOOL, SELECT, STRING 등)와 홈 커버(&lt;code&gt;&amp;lt;s_cover_group&amp;gt;&lt;/code&gt;) 유무를 분석해 동일한 조작 패널을 생성한다. 실제 블로그 관리자의 '스킨 편집' 화면에서 옵션을 바꿔보는 경험을 로컬에 그대로 옮겨온 셈이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⑤ 풍성한 기본 Mock 데이터와 커스텀 오버라이드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 실행했을 때 빈 화면이 나오면 테스트할 맛이 나지 않는다. 그래서 실행 즉시 확인해 볼 수 있도록 다양한 포스트 타입(텀블러의 텍스트&amp;middot;사진&amp;middot;인용구&amp;middot;채팅&amp;middot;비디오&amp;middot;Q&amp;amp;A, 티스토리의 본문&amp;middot;코드블록&amp;middot;댓글과 대댓글&amp;middot;홈 커버&amp;middot;태그 클라우드 등)이 담긴 기본 Mock 데이터를 내장했다. 또한 텀블러에서 무한 스크롤을 구현할 때 자주 쓰는 &lt;code&gt;/page/:num&lt;/code&gt; 엔드포인트도 가상 API로 지원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 내 실제 블로그의 글 제목이나 카테고리 구조로 테스트해 보고 싶다면, 프로젝트 폴더에 &lt;code&gt;tumblr-mock.json&lt;/code&gt;이나 &lt;code&gt;tistory-mock.json&lt;/code&gt; 파일 하나만 만들어 원하는 항목을 덮어쓰면 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 텀블러와 티스토리 테마를 고치면서 느꼈던 작은 불편함을 해결하려고 시작한 프로젝트였는데, 만들어 두고 나니 이제는 테마를 수정할 때 없어서는 안 될 도구가 되었다. 예전에는 색상 하나, 여백 몇 픽셀 고치는 데도 스킨을 압축해서 올리고 확인하느라 진이 빠졌다면, 이제는 로컬에서 뷰포트와 스킨 옵션을 이리저리 바꿔가며 훨씬 즐겁게 작업하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;티스토리 스킨이나 텀블러 커스텀 테마를 직접 만들고 다듬는다면 한 번쯤 가볍게 사용해 보길 추천한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Tumblr 테마 프리뷰어&lt;/b&gt;: &lt;code&gt;npx tumblr-theme-preview&lt;/code&gt; (&lt;a href=&quot;https://github.com/joostory/tumblr-theme-preview&quot;&gt;GitHub&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Tistory 스킨 프리뷰어&lt;/b&gt;: &lt;code&gt;npx tistory-theme-preview&lt;/code&gt; (&lt;a href=&quot;https://github.com/joostory/tistory-theme-preview&quot;&gt;GitHub&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Dev</category>
      <category>Tistory</category>
      <category>tumblr</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/634</guid>
      <comments>https://blog.joostory.net/634#entry634comment</comments>
      <pubDate>Thu, 29 Oct 2026 06:56:58 +0900</pubDate>
    </item>
    <item>
      <title>nvm 으로 nodejs 버전 관리</title>
      <link>https://blog.joostory.net/633</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;2026년 10월 28일이면 nodejs 26이 lts 버전이 된다. 24의 EOL이 2028년이라 여유 있지만 그래도 새로운 lts를 사용하는게 좋다. nodejs를 설치하는 여러가지 방법이 있지만 나는 nvm에 정착한 상태다. nvm은 한번에 최신 lts 버전으로 업그레이드 할 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;--lts, --reinstall-packages-from=node 라는 옵션을 사용하면 가자 최근 버전의 lts를 설치해주고 그동안 사용했던 global module을 그대로 install 해준다. 버전만 바뀌고 이전에 사용하던 그대로 사용할 수 있다. 그런데 옵션이 좀 길기 때문에 alias로 등록해서 사용하고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1792919095760&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;alias nvm-update-lts=&quot;nvm install --lts --reinstall-packages-from=node&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 언제든 가장 최신버전을 유지할 수 있다. 이따금 실행만 한번씩 하면 된다.&lt;/p&gt;</description>
      <category>Dev</category>
      <category>nodejs</category>
      <category>nvm</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/633</guid>
      <comments>https://blog.joostory.net/633#entry633comment</comments>
      <pubDate>Sun, 25 Oct 2026 18:08:30 +0900</pubDate>
    </item>
    <item>
      <title>venv로 프로젝트 환경관리</title>
      <link>https://blog.joostory.net/632</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;python 버전관리는 &lt;a href=&quot;https://blog.joostory.net/623&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;pyenv를 사용&lt;/a&gt;하고 있는데 프로젝트마다 환경격리를 위해 virtualenv를 사용하고 있었다. 근데 뭔가 pyenv 로 새로운 버전을 설치할 때마다 개별 환경도 그 버전을 따라가고 싶은 마음이 생겨 이번기회에 방법을 정리해보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;virtualenv, venv, uv&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭘 해야할지 몰라서 예전부터 사용하던 virtualenv를 사용하고 있었는데 알아보니 굳이 설치할 필요없이 venv를 사용하면 되는거였다. virtualenv가 좀 더 세부적인 옵션이 있다고 하나 나는 매우 심플하게 사용하기 때문에 venv로 충분한거 같다. 그런데 또 요즘엔 uv가 대세라고 한다. uv는 rust로 만들어진 툴인데 어떤 버전의 python이 설치되어 있는지 상관없이 필요한 버전을 설치해서 사용한다고 한다. 가만 보니 pyenv + venv 조합을 한번에 제공하면서 좀 더 편의기능이 추가된 사실상 내가 원하는 기능의 종합세트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 그걸 사용하면 되는거 아닌가 싶은데. 뭔가 아직은 uv가 익숙하지 않다. 일단은 사용하던 virtualenv를 venv로 전환하는 것만 하려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;venv 환경 업그레이드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;python은 메이저버전은 말할 것도 없고 마이너버전도 호환이 안된다고 봐야한다. 그래서 마이너버전은 아예 다른 버전이라 생각하고 관리를 한다. 그래서 패치버전의 새로운 버전이 나오면 그걸로 모두 업그레이드를 하고 싶다. 근데 글로벌 버전으로 새로운 버전을 설치하고 기존 버전을 지워버리면 개별 프로젝트에서 사용하던 환경이 모두 깨져버린다. 그래서 또 각 환경마다 업그레이드를 해줘야한다. 사실상 업그레이드보다는 재설치라고 해야한다. 그 방법이 최선으로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nodejs는 글로벌 버전이 변경되어도 module만 재설치해주면 되었는데 python은 참 쉽지가 않다. 그래서 계속 가상환경 관리 프로그램이 나오나부다. 어쨌든 내가 선택한 방법은 새로운 글로벌 버전을 설치했을때 개별 프로젝트의 환경을 지우고 재설치하는 것이다. 그럼 이걸 편하게 하기 위해서 스크립트가 필요하다.&lt;/p&gt;
&lt;pre id=&quot;code_1792883396866&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# venv를 재설치
recreate-venv() {
    local target=&quot;${1:-venv}&quot;
    local py_bin=&quot;$(which python)&quot;

    echo &quot;Python venv 재생성: '${target}' using ${py_bin} ($(&quot;${py_bin}&quot; -V))...&quot;
    rm -rf &quot;${target}&quot;
    &quot;${py_bin}&quot; -m venv &quot;${target}&quot;
    &quot;${target}/bin/python&quot; -m pip install --quiet --upgrade pip

    if [[ -f &quot;requirements.txt&quot; ]]; then
        &quot;${target}/bin/pip&quot; install -r requirements.txt
    fi
    source &quot;${target}/bin/activate&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 &lt;a href=&quot;https://blog.joostory.net/628&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;전에 만들었던 ~/.zsh/zshrc&lt;/a&gt; 안에 넣어두면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1792883568374&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;❯ recreate-venv
Python venv 재생성: 'venv' using /Users/joo.yoon/.pyenv/shims/python (Python 3.14.9)...
Collecting requests (from -r requirements.txt (line 1))
  Using cached requests-2.34.2-py3-none-any.whl.metadata (4.8 kB)
Collecting urllib3 (from -r requirements.txt (line 2))
  Using cached urllib3-2.8.0-py3-none-any.whl.metadata (7.4 kB)
...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단은 이렇게 사용해보고 나중에 uv를 좀 더 알아봐야겠다.&lt;/p&gt;</description>
      <category>Dev</category>
      <category>Python</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/632</guid>
      <comments>https://blog.joostory.net/632#entry632comment</comments>
      <pubDate>Sun, 25 Oct 2026 08:15:02 +0900</pubDate>
    </item>
    <item>
      <title>Docker Hub 이미지(JDK, Node.js, Gradle) 최신 태그 확인하기</title>
      <link>https://blog.joostory.net/631</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;보안 취약점(CVE) 대응이나 정기 업데이트를 진행할 때, 브라우저에서 Docker Hub 페이지를 일일이 뒤지는 대신 터미널에서 &lt;code&gt;curl&lt;/code&gt;과 &lt;code&gt;jq&lt;/code&gt;만으로 공식 베이스 이미지(&lt;code&gt;eclipse-temurin&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;gradle&lt;/code&gt;)의 최신 태그와 릴리스 여부를 빠르게 확인하는 방법을 정리했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Docker Hub 태그 API 기본 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Hub v2 API를 활용하면 별도의 인증 없이도 공식 이미지의 태그 목록을 조회할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;https://hub.docker.com/v2/repositories/library/&amp;lt;image&amp;gt;/tags?page_size=100&amp;amp;name=&amp;lt;검색어&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;주요 응답 필드&lt;/b&gt;: &lt;code&gt;.results[].name&lt;/code&gt;(태그명), &lt;code&gt;.results[].last_updated&lt;/code&gt;(마지막 push 시각)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조회 범위 제한 (&lt;code&gt;name=&lt;/code&gt;)&lt;/b&gt;: &lt;code&gt;page_size&lt;/code&gt;의 최댓값은 &lt;code&gt;100&lt;/code&gt;입니다. 태그 수가 많은 공식 이미지는 반드시 &lt;code&gt;name=&lt;/code&gt; 파라미터로 버전이나 OS variant 범위를 좁혀야 누락 없이 조회할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정렬 주의사항 (&lt;code&gt;ordering=last_updated&lt;/code&gt;)&lt;/b&gt;: 최근 갱신순 정렬 옵션을 붙일 수 있지만, 기존 버전의 메타데이터만 다시 push되어도 갱신 시각이 바뀌므로 &lt;b&gt;날짜만 보고 최신 버전으로 판단해서는 안 됩니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. JDK (&lt;code&gt;eclipse-temurin&lt;/code&gt;)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;태그 규칙 및 특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기본 형식&lt;/b&gt;: &lt;code&gt;&amp;lt;버전&amp;gt;_&amp;lt;빌드번호&amp;gt;-&amp;lt;jdk|jre&amp;gt;-&amp;lt;OS variant&amp;gt;&lt;/code&gt; (예: &lt;code&gt;21.0.12_8-jdk-alpine&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Respin(재빌드) 버전&lt;/b&gt;: 패치나 재빌드가 발생하면 버전 끝에 &lt;code&gt;.1&lt;/code&gt;이 추가됩니다. (예: &lt;code&gt;21.0.12.1_1-jdk-alpine&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OS Variant&lt;/b&gt;: &lt;code&gt;alpine&lt;/code&gt;(floating), &lt;code&gt;alpine-3.24&lt;/code&gt;(버전 고정), &lt;code&gt;noble&lt;/code&gt;(Ubuntu 24.04), &lt;code&gt;ubi10-minimal&lt;/code&gt; 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 조회 명령어&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;LTS 라인별(17 / 21 / 25) 최신 태그 확인&lt;/b&gt;&lt;br /&gt;단순히 &lt;code&gt;sort -V&lt;/code&gt;만 사용하면 respin 태그(&lt;code&gt;21.0.12.1_1&lt;/code&gt;)가 일반 태그(&lt;code&gt;21.0.12_8&lt;/code&gt;)보다 앞에 정렬될 수 있어 정렬 기준을 보정해 조회합니다. (&lt;code&gt;jdk-noble&lt;/code&gt;, &lt;code&gt;jre-alpine&lt;/code&gt; 등으로 변경 가능)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;for v in 17 21 25; do
  printf &quot;JDK $v: &quot;
  curl -s &quot;https://hub.docker.com/v2/repositories/library/eclipse-temurin/tags?page_size=100&amp;amp;name=${v}.0.&quot; \
    | jq -r '.results[].name | select(endswith(&quot;-jdk-alpine&quot;))' \
    | awk -F'_' '{ if ($1 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) print $1&quot;.0_&quot;$2; else print $0 }' \
    | sort -V | tail -n 1 | sed 's/\.0_/_/'
done&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특정 버전 출시 여부 확인&lt;/b&gt;&lt;br /&gt;결과가 비어 있으면 아직 Docker Hub에 퍼블리시되지 않은 상태입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;curl -s &quot;https://hub.docker.com/v2/repositories/library/eclipse-temurin/tags?page_size=100&amp;amp;name=21.0.13&quot; \
  | jq -r '.results[].name' | sort -V&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Docker Hub 반영 전 공식 출시 여부 먼저 확인하기 (Adoptium API)&lt;/b&gt;&lt;br /&gt;Temurin은 Adoptium에 GA가 먼저 릴리스되고 보통 며칠 뒤 Docker Hub에 반영됩니다. Adoptium의 &lt;code&gt;jdk-21.0.13+9&lt;/code&gt;는 Docker 태그 &lt;code&gt;21.0.13_9&lt;/code&gt;에 대응하며, 경로의 &lt;code&gt;/ga&lt;/code&gt;를 &lt;code&gt;/ea&lt;/code&gt;로 바꾸면 출시 전 Early Access 빌드도 확인할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;for v in 17 21 25; do
  echo &quot;== JDK $v ==&quot;
  curl -s &quot;https://api.adoptium.net/v3/assets/feature_releases/$v/ga?image_type=jdk&amp;amp;os=alpine-linux&amp;amp;architecture=x64&amp;amp;page_size=1&quot; \
    | jq -r '.[].release_name'
done&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Node.js (&lt;code&gt;node&lt;/code&gt;)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;태그 규칙 및 특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기본 형식&lt;/b&gt;: &lt;code&gt;&amp;lt;버전 또는 코드네임&amp;gt;-&amp;lt;OS variant&amp;gt;&lt;/code&gt; (예: &lt;code&gt;24.22.1-alpine3.24&lt;/code&gt;, &lt;code&gt;krypton-alpine3.24&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Floating 태그 주의&lt;/b&gt;: 코드네임(&lt;code&gt;krypton&lt;/code&gt;=24, &lt;code&gt;jod&lt;/code&gt;=22)이나 메이저 버전만 적힌 태그(&lt;code&gt;24-alpine&lt;/code&gt;)는 계속 가리키는 대상이 바뀌는 floating 태그이므로, 빌드 재현성을 위해 전체 버전(Full version)으로 고정하는 것이 안전합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Alpine 표기 차이&lt;/b&gt;: Temurin(&lt;code&gt;-alpine-3.24&lt;/code&gt;)과 달리 하이픈 없이 &lt;code&gt;-alpine3.24&lt;/code&gt; 형태로 표기합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 조회 명령어&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특정 메이저 라인의 최신 태그 확인&lt;/b&gt; (다른 라인은 &lt;code&gt;24.&lt;/code&gt;와 &lt;code&gt;^24\.&lt;/code&gt;를 &lt;code&gt;22.&lt;/code&gt; 등으로 변경)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;curl -s &quot;https://hub.docker.com/v2/repositories/library/node/tags?page_size=100&amp;amp;name=24.&quot; \
  | jq -r '.results[].name | select(test(&quot;^24\\.[0-9]+\\.[0-9]+-alpine[0-9.]+$&quot;))' \
  | sort -V | tail -n 1&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;보안 릴리스 여부 및 내장 OpenSSL 버전 확인 (&lt;code&gt;nodejs.org&lt;/code&gt;)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;curl -s https://nodejs.org/dist/index.json \
  | jq -r '[.[] | select(.version|startswith(&quot;v24.&quot;))][0] | &quot;\(.version) \(.date) security=\(.security) openssl=\(.openssl)&quot;'&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;LTS 및 EOL 지원 일정 확인&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;curl -s https://raw.githubusercontent.com/nodejs/Release/main/schedule.json \
  | jq -r 'to_entries[] | select(.key|test(&quot;^v(20|22|24)$&quot;)) | &quot;\(.key): LTS=\(.value.lts // &quot;N/A&quot;) ~ EOL=\(.value.end)&quot;'&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 빌드용 Gradle (&lt;code&gt;gradle&lt;/code&gt;)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;태그 규칙 및 특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기본 형식&lt;/b&gt;: &lt;code&gt;&amp;lt;Gradle버전&amp;gt;-jdk&amp;lt;자바버전&amp;gt;-&amp;lt;variant&amp;gt;&lt;/code&gt; (예: &lt;code&gt;9.8.2-jdk21-alpine&lt;/code&gt;, &lt;code&gt;8.14.6-jdk21-noble&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OS Variant&lt;/b&gt;: &lt;code&gt;alpine&lt;/code&gt;, &lt;code&gt;noble&lt;/code&gt;, &lt;code&gt;jammy&lt;/code&gt;, &lt;code&gt;corretto&lt;/code&gt;, &lt;code&gt;graal&lt;/code&gt;, &lt;code&gt;ubi9&lt;/code&gt; 및 variant 생략 태그(&lt;code&gt;-jdk21&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;베이스 이미지 의존성&lt;/b&gt;: Gradle 공식 이미지의 기본 JDK 베이스는 &lt;code&gt;eclipse-temurin&lt;/code&gt;입니다. 따라서 JDK 보안 취약점을 패치하려면 Temurin 이미지가 먼저 업데이트된 후 Gradle 이미지가 재빌드되어야 반영됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 조회 명령어&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;JDK 라인별 최신 Gradle 태그 확인&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;for v in 17 21 25; do
  printf &quot;jdk$v: &quot;
  curl -s &quot;https://hub.docker.com/v2/repositories/library/gradle/tags?page_size=100&amp;amp;name=jdk${v}-alpine&quot; \
    | jq -r '.results[].name | select(test(&quot;^[0-9]+\\.[0-9]+(\\.[0-9]+)?-jdk'&quot;$v&quot;'-alpine$&quot;))' \
    | sort -V | tail -n 1
done&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특정 Gradle 버전의 세부 태그 목록 조회&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;curl -s &quot;https://hub.docker.com/v2/repositories/library/gradle/tags?page_size=100&amp;amp;name=8.14.6-jdk21&quot; \
  | jq -r '.results[].name' | sort -V&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Gradle 공식 최신 릴리스 확인 (&lt;code&gt;services.gradle.org&lt;/code&gt;)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;# 현재 최신 버전
curl -s https://services.gradle.org/versions/current | jq -r '&quot;\(.version) (\(.buildTime))&quot;'

# 메이저 라인별(8 / 9) 최신 정식 버전
curl -s https://services.gradle.org/versions/all \
  | jq -r '[.[] | select(.snapshot==false and .nightly==false and .releaseNightly==false and .rcFor==&quot;&quot;)] | group_by(.version | split(&quot;.&quot;)[0])[] | first | &quot;\(.version | split(&quot;.&quot;)[0]): \(.version)&quot;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dev</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/631</guid>
      <comments>https://blog.joostory.net/631#entry631comment</comments>
      <pubDate>Sat, 24 Oct 2026 22:05:36 +0900</pubDate>
    </item>
    <item>
      <title>작업 완료된 브랜치 제거</title>
      <link>https://blog.joostory.net/630</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;브랜치를 만들어 작업을 완료한 후에 pr이 머지되면 remote 브랜치가 삭제되도록 하고 있다. 그런데 여전히 로컬에는 브랜치들이 남아있어서 일일이 확인하는게 일이었다. 그래서 이런 alias를 만들어서 머지된 브랜치를 제거하고 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1791971880646&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;!f() { git fetch -p &amp;amp;&amp;amp; git branch -vv | grep &quot;: gone]&quot; | awk &quot;{print \$1}&quot; | xargs -r git branch -D; }; f&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 어쩐일인지 그 alias가 오류가 났다. 확인해보니 worktree 때문이었다. 내가 사용한 적은 거의 없지만 클로드를 사용하면 이따금 worktree를 사용하게 되는데 이게 남아 있어서 오류가 나는거다. 그래서 gone alias도 이렇게 변경했다.&lt;/p&gt;
&lt;pre id=&quot;code_1791971862236&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;!f() { git fetch -p &amp;amp;&amp;amp; git for-each-ref --format=&quot;%(refname:short) %(upstream:track)&quot; refs/heads | awk &quot;\$2 == \&quot;[gone]\&quot; {print \$1}&quot; | xargs -r git branch -D; }; f&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 맡기는 방법도 있지만 그래도 이 정도는 내가 하고 싶다.&lt;/p&gt;</description>
      <category>Dev</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/630</guid>
      <comments>https://blog.joostory.net/630#entry630comment</comments>
      <pubDate>Wed, 14 Oct 2026 19:02:18 +0900</pubDate>
    </item>
    <item>
      <title>서버개발자를 위한 운영툴 개발</title>
      <link>https://blog.joostory.net/629</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(2022/6/30 사내 테크톡에서 발표한 내용을 블로그에도 작성)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요. 카카오 N TF에서 서버 개발을 담당하고 있는 Joo 입니다.&lt;br /&gt;저는 다음에서 한때 한메일 익스프레스였던 다음메일 개발을 시작으로 브런치, 티스토리, My구독을 거쳐 최근에는 N TF에서 새로운 서비스를 개발하고 있는데요. 서비스 개발을 10여년, 운영툴 개발도 10여년 한 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;서버 개발자를 위한 운영툴 개발&amp;rdquo;이라는 주제로 그동안 운영툴 개발을 하면서 고민했던 부분들에 대한 이야기를 나누어 보고자 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;운영툴 어떻게 만들고 계신가요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇달을 고생해서 드디어 서비스를 오픈합니다. 서비스를 오픈했다는 기쁨도 잠시, 바로 운영이슈가 쏟아지기 시작합니다. &amp;ldquo;공지사항 수정해주세요.&amp;rdquo; &amp;ldquo;배너 다른 이미지로 교체해주세요.&amp;rdquo; &amp;ldquo;OOO 통계 뽑아주실 수 있나요?&amp;rdquo;, &amp;ldquo;이 사용자 정보 확인해주세요.&amp;rdquo; 운영 이슈만 있는 것도 아닙니다. 미처 발견하지 못했던 자잘한 버그들도 수정해야하고 서비스 로직 수정이 바로 필요한 경우도 생기게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer01.png&quot; data-origin-width=&quot;1846&quot; data-origin-height=&quot;348&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTfun7/dJMcajqhiLd/ymLrBcwGFJXJtm9HW1WhAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTfun7/dJMcajqhiLd/ymLrBcwGFJXJtm9HW1WhAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTfun7/dJMcajqhiLd/ymLrBcwGFJXJtm9HW1WhAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTfun7%2FdJMcajqhiLd%2FymLrBcwGFJXJtm9HW1WhAk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1846&quot; height=&quot;348&quot; data-filename=&quot;admin-for-server-developer01.png&quot; data-origin-width=&quot;1846&quot; data-origin-height=&quot;348&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 잘 만든 운영툴이 있다면 수많은 운영이슈를 개발자가 직접 처리하지 않아도 됩니다. 운영자(보통은 기획자)도 운영이슈를 직접 처리할 수 있어 빠르게 해결할 수 있습니다. 운영툴은 운영-개발 사이의 커뮤니케이션 비용을 줄이는 데 큰 역할을 합니다. 운영 속도는 빨라지고, 개발자는 운영이슈를 직접 처리하지 않아도 되어 서비스 개선 이슈에 집중할 수 있습니다. 잘만들면 잘 만들수록 커뮤니케이션 비용은 낮아지고 개발자의 시간은 늘어납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;어떤 운영툴이 좋은 운영툴일까요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 서비스 기획단계에서 만들어진 운영툴 기획안의 요구사항을 충족해야합니다. 운영툴 기획안이 없다면 운영 요구사항을 파악하고 추가되는 요구사항을 충족하도록 해야합니다. 거기다 사용하기 편한 UX, UI를 가진다면 더 좋겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 마치 하나의 서비스를 만드는 것과 비슷합니다. 운영자를 위한 서비스를 개발하는 것입니다. 조금 너그러운 사용자를 위한 풀스택 개발자가 되었다는 생각으로 운영툴을 만든다면 좋은 운영툴이 만들어지리라 생각합니다. 여기서 말하는 풀스택은 기획, 디자인, 프론트엔드 개발, 백엔드 개발 모두를 포함합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 프론트엔드 개발, 백엔드 개발, 사용하기 편리한 UX를 만드는 과정으로 나눠 저의 경험을 토대로 이야기를 나눠보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프론트엔드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 프론트엔드 프레임워크의 선택과 UI 디자인에 대해 이야기 해보려고 합니다. 운영툴을 잘 만들자고 했지만 운영툴에 많은 시간을 투자할 수는 없습니다. 최소한의 노력으로 최대의 효과를 얻는 방법을 찾아봐야합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프론트엔드 프레임워크 선택&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer04.png&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;792&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfPGII/dJMcabTrsra/t1pXyAZJ4CRfwEYFOQTFG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfPGII/dJMcabTrsra/t1pXyAZJ4CRfwEYFOQTFG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfPGII/dJMcabTrsra/t1pXyAZJ4CRfwEYFOQTFG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfPGII%2FdJMcabTrsra%2Ft1pXyAZJ4CRfwEYFOQTFG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1948&quot; height=&quot;792&quot; data-filename=&quot;admin-for-server-developer04.png&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;792&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영툴 만들때 많이 사용하는 방법이 운영툴 서버를 mvc로 만드는 것입니다. 템플릿엔진을 통해서 값을 전달하고 jQuery로 브라우저에 표시된 UI를 조작하고 css로 UI를 디자인합니다. 여기서 주목해볼 점은 jQuery인데요. jQuery는 파편화된 브라우저 API를 추상화해서 제공하기 위해 만들어져 한때 프론트엔드 개발의 표준언어처럼 사용되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer05.png&quot; data-origin-width=&quot;2430&quot; data-origin-height=&quot;1436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/voSCz/dJMcab0l9a9/CD6efBMKooxNSRKbwixMk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/voSCz/dJMcab0l9a9/CD6efBMKooxNSRKbwixMk1/img.png&quot; data-alt=&quot;( https://gs.statcounter.com/browser-market-share/all/south-korea )&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/voSCz/dJMcab0l9a9/CD6efBMKooxNSRKbwixMk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvoSCz%2FdJMcab0l9a9%2FCD6efBMKooxNSRKbwixMk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2430&quot; height=&quot;1436&quot; data-filename=&quot;admin-for-server-developer05.png&quot; data-origin-width=&quot;2430&quot; data-origin-height=&quot;1436&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;( https://gs.statcounter.com/browser-market-share/all/south-korea )&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이제는 브라우저의 파편화는 거의 사라졌다고 할 정도로 다양한 브라우저가 표준화된 API를 지원하고 있습니다. 또한 대부분 webkit 엔진, 크롬을 베이스로 하고 있어서 거의 차이가 없다고 볼 수 있습니다. 파편화의 주범인 IE의 점유율이 1% 미만으로 떨어졌습니다. 더이상 jQuery를 사용할 필요가 없어졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 어떤 프론트엔드 프레임워크를 사용해야할까요? 사용하지 않아도 된다지만 그래도 편리한 jQuery? 아니면 브라우저가 지원하는 표준화된 Javascript를 사용할 수도 있겠죠. 이런 것은 어떨까요? UI를 컴포넌트 단위로 관리하는 &lt;a href=&quot;https://reactjs.org/&quot;&gt;React&lt;/a&gt;, &lt;a href=&quot;https://vuejs.org/&quot;&gt;Vuejs&lt;/a&gt;, &lt;a href=&quot;https://angularjs.org/&quot;&gt;Angular&lt;/a&gt;, &lt;a href=&quot;https://svelte.dev/&quot;&gt;Svelte&lt;/a&gt;와 같은 프레임워크가 있습니다. 먼저 프론트엔드 프레임워크가 얼마나 사용되고 있는지 살펴봅시다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/assets/posts/admin-for-server-developer/admin-for-server-developer06.png&quot; alt=&quot;&quot; /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer06.png&quot; data-origin-width=&quot;2586&quot; data-origin-height=&quot;906&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqYGrh/dJMb9987APp/CYAQQyhgpR5XzOx3KxU7dk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqYGrh/dJMb9987APp/CYAQQyhgpR5XzOx3KxU7dk/img.png&quot; data-alt=&quot;( https://www.npmtrends.com/react-vs-jquery-vs-vue-vs-@angular/core-vs-svelte )&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqYGrh/dJMb9987APp/CYAQQyhgpR5XzOx3KxU7dk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqYGrh%2FdJMb9987APp%2FCYAQQyhgpR5XzOx3KxU7dk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2586&quot; height=&quot;906&quot; data-filename=&quot;admin-for-server-developer06.png&quot; data-origin-width=&quot;2586&quot; data-origin-height=&quot;906&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;( https://www.npmtrends.com/react-vs-jquery-vs-vue-vs-@angular/core-vs-svelte )&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jQuery가 아직도 사용되고 있긴하지만 React가 압도적으로 가장 많이 사용되고 있습니다. 이것을 근거로 저는 React를 선택했습니다. 가장 많이 사용된다는 것은 가장 많은 정보가 있다는 것이고 가장 많은 정보가 있다는 것은 문제가 생기면 도움을 얻기 쉽다는 것이기도 하기 때문입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;React를 사용한 운영툴 개발환경&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer07.png&quot; data-origin-width=&quot;1570&quot; data-origin-height=&quot;626&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HGUjl/dJMcablIV3R/9cbCKAXVV6wwlrKy7HoXik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HGUjl/dJMcablIV3R/9cbCKAXVV6wwlrKy7HoXik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HGUjl/dJMcablIV3R/9cbCKAXVV6wwlrKy7HoXik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHGUjl%2FdJMcablIV3R%2F9cbCKAXVV6wwlrKy7HoXik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1570&quot; height=&quot;626&quot; data-filename=&quot;admin-for-server-developer07.png&quot; data-origin-width=&quot;1570&quot; data-origin-height=&quot;626&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 React를 사용한 운영툴 서버 구성에 대해서 살펴보겠습니다. 먼저 운영툴 서버는 API만 제공하고 static 리소스를 제공하는 프론트엔드 서버에서 이 API를 사용하도록 구성합니다. 이런 구성을 쉽게 할 수 있도록 react는 create-react-app이라는 툴체인을 제공합니다. create-react-app으로 앱을 생성한 후에 start를 실행하면 3000포트로 node 서버가 react 앱을 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;&amp;gt; brew install node # node 설치

&amp;gt; npx create-react-app admin-ui # ui앱 생성
&amp;gt; cd admin-ui
&amp;gt; npm run start # 개발환경 앱 실행
localhost:3000...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 앱은 hot reload가 적용되어 있어서 코드를 수정하면 즉시 앱에 반영해줍니다. 운영툴 서버 API에 접근할 수 있도록 운영툴 서버에 CORS 설정만 적용해주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이대로 운영툴 서버를 운영해도 물론 문제는 없겠지만 그래도 프로덕션으로 배포를 하는 방법도 알아보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer08.png&quot; data-origin-width=&quot;1598&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ba6p4s/dJMb99OKnTs/Qv7mkpCnedKEMKxqCL5wbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ba6p4s/dJMb99OKnTs/Qv7mkpCnedKEMKxqCL5wbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ba6p4s/dJMb99OKnTs/Qv7mkpCnedKEMKxqCL5wbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fba6p4s%2FdJMb99OKnTs%2FQv7mkpCnedKEMKxqCL5wbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1598&quot; height=&quot;548&quot; data-filename=&quot;admin-for-server-developer08.png&quot; data-origin-width=&quot;1598&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;&amp;gt; npm run build
&amp;gt; cp -R build ../src/main/resource/static&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;create-react-app에서 미리 만들어둔 빌드스크립트를 사용하면 되는데요. 빌드 스크립트를 수행하면 모든 static 리소스가 build 디렉토리에 만들어집니다. 이 디렉토리를 운영툴의 static 리소스에 그대로 복사하면 완료입니다.&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;// index.js
import React from 'react'
import { createRoot } from 'react-dom
import App from './container/App'

const root = createRoot(document.getElementById('root'))
root.render(
    &amp;lt;App /&amp;gt;
)

// /container/App.js
function App() {
    return (
        &amp;lt;div&amp;gt;Hello, World!&amp;lt;/div&amp;gt;
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 귀찮고 어려운 개발환경설정이 완료되었습니다. 이제 React Component로 운영툴 UI를 만들어가면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UI 디자인 프레임워크 선택&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React가 디자인을 해결해주진 않습니다. 단지 UI를 컴포넌트 단위로 관리하기 편하게 만들어줄 뿐이죠. 이왕 만드는거 조금 더 보기 좋게 만들고 싶은데 디자이너도 없고 마크업 개발자도 없습니다. 결국 쉽게 사용할 수 있는 디자인 템플릿을 찾아야 한다는 결론에 이르렀습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer09.png&quot; data-origin-width=&quot;2622&quot; data-origin-height=&quot;910&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pUZEF/dJMcahTwFUV/MZQI3Uua3YY879YCKERpkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pUZEF/dJMcahTwFUV/MZQI3Uua3YY879YCKERpkK/img.png&quot; data-alt=&quot;( https://www.npmtrends.com/bootstrap-vs-tailwindcss-vs-@material-ui/core-vs-@mui/material-vs-antd )&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pUZEF/dJMcahTwFUV/MZQI3Uua3YY879YCKERpkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpUZEF%2FdJMcahTwFUV%2FMZQI3Uua3YY879YCKERpkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2622&quot; height=&quot;910&quot; data-filename=&quot;admin-for-server-developer09.png&quot; data-origin-width=&quot;2622&quot; data-origin-height=&quot;910&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;( https://www.npmtrends.com/bootstrap-vs-tailwindcss-vs-@material-ui/core-vs-@mui/material-vs-antd )&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 프레임워크와 달리 UI 디자인을 제공하는 프레임워크는 절대 강자가 존재하지 않았습니다. 물론 부트스트랩이 여전히 가장 많이 사용되긴 하지만 tailwind 의 사용자도 만만치 않습니다. 그래서 하나씩 살펴보면서 어떤 것을 사용하는게 좋을지 살펴보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer10.png&quot; data-origin-width=&quot;2322&quot; data-origin-height=&quot;562&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdrKxn/dJMcaf9flQZ/z6LQ684N0DX8TZNscxPGi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdrKxn/dJMcaf9flQZ/z6LQ684N0DX8TZNscxPGi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdrKxn/dJMcaf9flQZ/z6LQ684N0DX8TZNscxPGi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdrKxn%2FdJMcaf9flQZ%2Fz6LQ684N0DX8TZNscxPGi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2322&quot; height=&quot;562&quot; data-filename=&quot;admin-for-server-developer10.png&quot; data-origin-width=&quot;2322&quot; data-origin-height=&quot;562&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://getbootstrap.com/&quot;&gt;bootstrap&lt;/a&gt;은 반응형 웹디자인을 위한 css 추상화를 목표로 개발되었습니다. 컴포넌트 단위의 class를 제공해서 html에 class만 지정하면 원하는 디자인을 만들 수 있습니다. 주로 jQuery와 같이 사용되었지만 지금은 관계가 끝어지고 있고 React를 위한 버전도 존재합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer11.png&quot; data-origin-width=&quot;2320&quot; data-origin-height=&quot;606&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNVCSh/dJMcaiZcE0x/Rk4Wtv9VSzWlXDGReHbhX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNVCSh/dJMcaiZcE0x/Rk4Wtv9VSzWlXDGReHbhX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNVCSh/dJMcaiZcE0x/Rk4Wtv9VSzWlXDGReHbhX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNVCSh%2FdJMcaiZcE0x%2FRk4Wtv9VSzWlXDGReHbhX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2320&quot; height=&quot;606&quot; data-filename=&quot;admin-for-server-developer11.png&quot; data-origin-width=&quot;2320&quot; data-origin-height=&quot;606&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 &lt;a href=&quot;https://tailwindcss.com/&quot;&gt;tailwind&lt;/a&gt;입니다. tailwind는 모던 웹디자인을 위한 css 추상화를 제공합니다. bootstrap과 유사하게 html에 class를 지정하는 것으로 디자인을 만듭니다. 다른 점은 컴포넌트 단위의 디자인을 외부에 위임했다는 것입니다. tailwind는 css 단위의 기능만 제공합니다. 그리고 빌드 결과물에 bootstrap이 모든 css파일을 포함했다면 tailwind는 사용한 css만 포함해 결과물 용량을 절약할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer12.png&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;978&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cb8UdU/dJMcaf2xXa8/5p3PDoDKqJsIYmKp2AAtbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cb8UdU/dJMcaf2xXa8/5p3PDoDKqJsIYmKp2AAtbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cb8UdU/dJMcaf2xXa8/5p3PDoDKqJsIYmKp2AAtbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcb8UdU%2FdJMcaf2xXa8%2F5p3PDoDKqJsIYmKp2AAtbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1792&quot; height=&quot;978&quot; data-filename=&quot;admin-for-server-developer12.png&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;978&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://material.io/&quot;&gt;Material Design&lt;/a&gt;에 대해서는 들어보셨을겁니다. 구글이 만든 디자인 가이드라인인데요. 대부분의 구글 프로덕트에 사용되고 있죠. &lt;a href=&quot;https://mui.com/&quot;&gt;Material UI&lt;/a&gt;는 이 디자인의 React 구현체입니다. 컴포넌트 단위로 디자인을 제공하고 이를 배치만 하면 됩니다. html과 css는 거의 몰라도 되고 Material UI에서 제공하는 방식을 알아야합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer13.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;710&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZN4Kh/dJMcafIrNIH/xXqSjURwyBqZau3Zn0XWlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZN4Kh/dJMcafIrNIH/xXqSjURwyBqZau3Zn0XWlk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZN4Kh/dJMcafIrNIH/xXqSjURwyBqZau3Zn0XWlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZN4Kh%2FdJMcafIrNIH%2FxXqSjURwyBqZau3Zn0XWlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;710&quot; data-filename=&quot;admin-for-server-developer13.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;710&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 &lt;a href=&quot;https://ant.design/&quot;&gt;Ant Design&lt;/a&gt;에 대해서 알아보겠습니다. Ant Design은 알리페이를 만든 중국의 앤트그룹이 만든 디자인 가이드입니다. 같은 이름으로 React 구현체를 제공하고 있습니다. Material UI와 마찬가지로 컴포넌트 단위의 디자인을 제공합니다. 특이한 점은 디자인 뿐만 아니라 데이터 처리에 대한 인터페이스도 제공을 하고 있습니다. 테이블 컴포넌트에 데이터 소스만 제공하면 테이블 UI가 만들어지는 식입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer14.png&quot; data-origin-width=&quot;1836&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ddi96/dJMcadX5wvZ/qWyYByKGIxz7t3FNsMgHm1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ddi96/dJMcadX5wvZ/qWyYByKGIxz7t3FNsMgHm1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ddi96/dJMcadX5wvZ/qWyYByKGIxz7t3FNsMgHm1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDdi96%2FdJMcadX5wvZ%2FqWyYByKGIxz7t3FNsMgHm1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1836&quot; height=&quot;838&quot; data-filename=&quot;admin-for-server-developer14.png&quot; data-origin-width=&quot;1836&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 어떤 UI 프레임워크를 사용해야할지 선택을 해야합니다. bootstrap, tailwind는 직접 디자인을 해야합니다. Material UI는 styled component 방식이라 학습이 어렵습니다. 최소한의 노력으로 최대의 효과를 보고자 한다면 아무래도 디자인 템플릿을 제공하고 사용이 편리한 Ant Design이 좋다는 생각입니다. 물론 서비스 프론트엔드 개발을 한다면 tailwind가 더 좋지 않을까 하는 생각이 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;And Design&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ant Design의 사용에 대해 간략히 알아보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;&amp;gt; npm i antd @ant-design/icons&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;And Design을 사용하려면 antd와 icons 모듈을 설치합니다. 이제 간단한 UI를 만들어보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer15.png&quot; data-origin-width=&quot;2132&quot; data-origin-height=&quot;1120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfyhZf/dJMcaiLJwsK/lTIfHfWyKN4IzYR2kskxt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfyhZf/dJMcaiLJwsK/lTIfHfWyKN4IzYR2kskxt0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfyhZf/dJMcaiLJwsK/lTIfHfWyKN4IzYR2kskxt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfyhZf%2FdJMcaiLJwsK%2FlTIfHfWyKN4IzYR2kskxt0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2132&quot; height=&quot;1120&quot; data-filename=&quot;admin-for-server-developer15.png&quot; data-origin-width=&quot;2132&quot; data-origin-height=&quot;1120&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;export default function AdminLayout({children}) {
  return (
    &amp;lt;Layout&amp;gt;
      &amp;lt;Layout.Header&amp;gt;
        &amp;lt;div style={{ color:'#fff' }}&amp;gt;
          운영툴
        &amp;lt;/div&amp;gt;
      &amp;lt;/Layout.Header&amp;gt;
      &amp;lt;Layout&amp;gt;
        &amp;lt;Layout.Sider&amp;gt;
          &amp;lt;AdminSideMenus /&amp;gt;
        &amp;lt;/Layout.Sider&amp;gt;
        &amp;lt;Layout&amp;gt;
          &amp;lt;Layout.Content style={{ margin: '24px 16px 0' }}&amp;gt;
            {children}
          &amp;lt;/Layout.Content&amp;gt;
          &amp;lt;Layout.Footer style={{ textAlign:'center'}}&amp;gt;
            N TF
          &amp;lt;/Layout.Footer&amp;gt;
        &amp;lt;/Layout&amp;gt;
      &amp;lt;/Layout&amp;gt;
    &amp;lt;/Layout&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 레이아웃을 만들어보았는데요. 운영툴에서 흔히 사용하게 되는 GNB, LNB, 컨텐츠, 푸터가 있는 레이아웃을 Layout이라는 컴포넌트로 제공합니다.&lt;/p&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;const ADMIN_MENU = [
  {
    key: 'members-group',
    icon: &amp;lt;UserOutlined /&amp;gt;,
    label: '회원',
    children: [
      {
        key: '/members',
        label: &amp;lt;NavLink to={&quot;/members&quot;}&amp;gt;회원관리&amp;lt;/NavLink&amp;gt;
      }
    ]
  }
]

export default function AdminSideMenus() {
  const location = useLocation()
  const [selectedKey, setSelectedKey] = useState(&quot;/&quot;)

  useEffect(() =&amp;gt; {
    function handleLocationChange(pathname) {
      let names = pathname.split(&quot;/&quot;)
      setSelectedKey((names != null) ? `/${names[1]}` : '/')
    }

    handleLocationChange(location.pathname)
  }, [location])

  return (
    &amp;lt;Menu
      mode='inline' selectedKeys={[selectedKey]}
      items={ADMIN_MENU}
    /&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LNB의 메뉴도 Menu라는 컴포넌트에 메뉴 데이터와 선택된 메뉴 키를 넘겨주는 것만으로 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;function MemberList() {
  const [members, setMembers] = useState([])
  useEffect(() =&amp;gt; {
    graphQlQuery(`
      query {
        members {
          id
          name
        }
      }
    `)
      .then(data =&amp;gt; setMembers(data.data.members))
  }, [])


  return (
    &amp;lt;Table dataSource={members} pagination={false} rowKey={'id'}&amp;gt;
      &amp;lt;Table.Column title={'id'} dataIndex={'id'} /&amp;gt;
      &amp;lt;Table.Column title={'name'} dataIndex={'name'} /&amp;gt;
    &amp;lt;/Table&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본문에 사용된 컴포넌트도 살펴보겠습니다. 서버로부터 회원 데이터를 요청해서 Table 컴포넌트에 그대로 넘겨주면 회원 테이블 표시도 완료됩니다. 간략하게 살펴봤지만 코드 몇줄로 그럴듯한 UI가 만들지는 것을 알 수 있습니다. 이런 쉬운 사용이 Ant Design의 장점입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드와 디자인은 프레임워크의 도움을 받아 해결했으니 이제 API에 대해서 알아보겠습니다. API의 선택은 서버 개발자에게는 너무나 쉬운 선택입니다. REST API를 사용하고 Gateway나 프록시 설정을 하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rest API로 운영툴을 개발하는 과정은 UI 컴포넌트 개발, CRUD API 개발의 반복입니다. UI 에서 표시하는 값을 바꾸면 그에 따라 API도 바꾸고 연관된 값을 표시하기 위해서 API를 추가하고, 또 UI를 추가하고 또 API를 추가하고&amp;hellip; 이렇게 계속 반복하다보니 문득 &amp;ldquo;누가 UI에 맞게 API 만들어주면 좋겠다&quot;라는 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분을 GraphQL이 해결해줄 수 있겠다는 생각이 들었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GraphQL&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer16.png&quot; data-origin-width=&quot;1248&quot; data-origin-height=&quot;402&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wsazG/dJMcacdJGF8/8OclhkjbMmGrlmEWvikhU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wsazG/dJMcacdJGF8/8OclhkjbMmGrlmEWvikhU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wsazG/dJMcacdJGF8/8OclhkjbMmGrlmEWvikhU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwsazG%2FdJMcacdJGF8%2F8OclhkjbMmGrlmEWvikhU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1248&quot; height=&quot;402&quot; data-filename=&quot;admin-for-server-developer16.png&quot; data-origin-width=&quot;1248&quot; data-origin-height=&quot;402&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://graphql.org/&quot;&gt;GraphQL&lt;/a&gt;은 Facebook이 2012년 개발해서 2015년에 공개한 query language입니다. 주요한 특징으로 필요한 데이터만 받아올 수 있다는 점과 한번에 필요한 데이터를 모두 요철할 수 있다는 점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GraphQL을 운영툴에 사용해보면 어떨까요? 운영툴 기능이 추가/변경되어도 API를 추가하거나 변경하지 않아도 될 수 있습니다. 그리고 하나의 엔드포인트로 데이터 제공이 가능해서 API Gateway를 위한 여러 설정을 하지 않아도 됩니다. 게다가 때마침 GraphQL Java를 베이스로 한 &lt;a href=&quot;https://spring.io/projects/spring-graphql&quot;&gt;Spring for GraphQL의 1.0 릴리즈가 개발&lt;/a&gt;되어 도입을 해보았습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GraphQL의 사용&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;type Query {
    searchProfile(q: String, size: Int, pivot: Int): SearchProfileResponse
    profile(sid: String): Profile
    profileByUserName(userName: String): Profile
    room(roomSid: String): Room
    startedRooms(profileSid: String, from: DateTime, to: DateTime): [Room]
    profileBadges: [ProfileBadge]
    profileBadge(profileBadgeNid: Int): ProfileBadge
}

type Mutation {
    attachRelatedProfile(profileBadgeNid: Int, profileSid: String): Profile
    detachRelatedProfile(profileBadgeNid: Int, profileSid: String): Profile
}

scalar DateTime

type Profile {
    profileNid: Int
    profileSid: ID
    userNid: Int
    userName: String
    displayName: String
    badges: [ProfileBadge]
    createdAt: DateTime
}

type ProfileBadge {
    profileBadgeNid: Int
    description: String
    keyword: String
    createdAt: DateTime
    profiles: [Profile]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 간단히 Spring for GraphQL로 API를 만든 과정을 소개하겠습니다. GraphQL API를 제공하기 위해서는 먼저 스키마 정의를 해야합니다. .graphqls 파일에 Query, Mutation, Scalar, Type을 정의합니다. Query, Mutation 타입은 하나만 존재해야하는데 모든 요청의 시작점이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@QueryMapping(&quot;profile&quot;)
fun profile(@Argument sid: String): InternalProfileResponse {
    val profile = profileService.getProfile(sid)
    val restriction = BanDTO(bannedUserChecker.isBannedUser(profile.userNid))
    val involvedList: List&amp;lt;Room&amp;gt; = profileScheduleService.getInvolvedRoomList(sid)
    val supportResponse = IntegrationSupportResponse(supportReceiverUtils.isSupportReceiver(profile), profile.supports)
    return InternalProfileResponse.from(profile, restriction, involvedList, supportResponse)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Query와 Mutation 타입은 각각 QueryMapping, MutationMapping으로 구현과 연결합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;type Profile {
    profileNid: Int
    profileSid: ID

    badges: [ProfileBadge] # Profile Entity/DTO에는 존재하지 않는다.

    createdAt: DateTime
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@SchemaMapping(typeName = &quot;Profile&quot;, field = &quot;badges&quot;)
fun profileBadgesByProfile(profile: InternalProfileResponse): List&amp;lt;ProfileBadge&amp;gt; {
    return profileBadgeService.getProfileBadgeListByProfileSid(profile.profileSid).badgeList
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API와의 비교에서 보여드렸던 것 처럼 각 스키마를 연결할 수 있는 있는데요. SchemaMapping으로 Entity변경이나 DTO 추가없이 type을 연결할 수 있습니다. 클라이언트가 해당 필드를 요청하면 연결된 메소드가 호출되게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;scalar DateTime&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private val scalarDateTimeFormat: DateTimeFormatter = DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;)
private val scalarDateTimeZoneId: ZoneId = ZoneId.of(&quot;Asia/Seoul&quot;)

private class DateTimeCoercing : Coercing&amp;lt;ZonedDateTime, String&amp;gt; {
    // ZonedDateTime -&amp;gt; String
    override fun serialize(value: Any): String? = when(value) {
        is ZonedDateTime -&amp;gt; value.format(scalarDateTimeFormat)
        else -&amp;gt; null
    }

    // String -&amp;gt; ZonedDateTime
    override fun parseValue(value: Any): ZonedDateTime = parseLiteral(value)

    override fun parseLiteral(value: Any): ZonedDateTime = when(value) {
        is StringValue -&amp;gt; LocalDateTime.parse(value.value, scalarDateTimeFormat).atZone(scalarDateTimeZoneId)
        else -&amp;gt; throw CoercingParseLiteralException(&quot;Unknown Literal&quot;)
    }
}

@Component
class CustomScalarConfigurer : RuntimeWiringConfigurer {
    override fun configure(builder: RuntimeWiring.Builder) {
        builder.scalar(GraphQLScalarType.newScalar() // scalar의 coercing등록
                        .name(&quot;DateTime&quot;)
                        .coercing(DateTimeCoercing()).build())
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GraphQL에서 기본 제공하는 Scalar type은 Int, Float, String, Boolean, ID입니다. ZonedDateTime과 같은 값을 사용하기 위해서는 custom scalar로 정의해서 사용했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer17.png&quot; data-origin-width=&quot;1896&quot; data-origin-height=&quot;1158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c65NN4/dJMcahF5V8B/LIfKWsGySW8qTLtObMEnU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c65NN4/dJMcahF5V8B/LIfKWsGySW8qTLtObMEnU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c65NN4/dJMcahF5V8B/LIfKWsGySW8qTLtObMEnU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc65NN4%2FdJMcahF5V8B%2FLIfKWsGySW8qTLtObMEnU1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1896&quot; height=&quot;1158&quot; data-filename=&quot;admin-for-server-developer17.png&quot; data-origin-width=&quot;1896&quot; data-origin-height=&quot;1158&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GraphQL API도 swagger와 같은 API 문서 및 테스트 도구가 있습니다. GraphiQL 이라는 GraphQL IDE 입니다. 쿼리 요청도 가능하고 schema 탐색기능은 문서화 기능으로도 활용할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이렇게 좋은 GraphQL 왜 서비스에 사용하지 않나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GraphQL이 클라이언트에서 사용하기 좋고 적용하기도 어렵지 않다면서도 서비스에서 사용하지 않은 이유가 있습니다. 먼저 한번도 써본 적이 없기 때문입니다. 어떻게 구성하면 더 효율적일지 성능은 어느 정도일지에 대한 경험치가 전혀 없습니다. 그리고 Spring for GraphQL도 이제 막 1.0 버전이 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영툴에 먼저 적용한 이유가 바로 이 이유입니다. 서비스에 사용하기에는 조금 불안하지만 운영툴에 적용해보면서 익숙해지는 시간을 가져보는 것이죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;UX&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적인 내용은 모두 다룬 것 같습니다. 이제 UX, 그러니까 편리한 운영툴을 만들기 위해서 노력했던 부분에 대한 경험을 나눠보려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영툴은 필요하긴 하지만 우선순위는 낮습니다. 그래서 디자인이나 UX에 소홀해지기 쉽습니다. 조금 불편하더라도 운영을 할 수 있기는 하다고 넘어가게 됩니다. 그리고 회원관리는 테이블로, 대화방은 카드로, 일관되지 않은 방식으로 만들어 지기도 하죠. 만들때도 어떻게 만들어야 하나 고민을 하게 되고 사용하는 분들도 어떻게 사용해야하는지를 문의하기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 좀 더 일관되고 유지보수가 쉬운 운영툴을 만들기 위해 UX 가이드를 만들었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;admin-for-server-developer02.png&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;766&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0H8Sr/dJMcahF5V8G/5XHC8x4LDrb136MOCVlDk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0H8Sr/dJMcahF5V8G/5XHC8x4LDrb136MOCVlDk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0H8Sr/dJMcahF5V8G/5XHC8x4LDrb136MOCVlDk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0H8Sr%2FdJMcahF5V8G%2F5XHC8x4LDrb136MOCVlDk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1258&quot; height=&quot;766&quot; data-filename=&quot;admin-for-server-developer02.png&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;766&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UX 가이드에는 이런 내용을 담았습니다. 공통 레이아웃인 GNB에는 어떤 내용이 담겨야하는지, LNB의 메뉴는 어떤 기준으로 그룹핑하는지, depth는 어디까지 허용할 것인지. 컨텐츠의 Body는 1단으로 할지, 2단으로 할지. 테이블로 표시할 내용이 있다면 페이징은 어떤 식으로 구성할 것인지 각 로우를 수정할때 수정버튼은 어디에 위치할 것인지와 같은 세부적인 내용을 담았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URL 설계에 대한 가이드도 작성했는데요. 퍼머링크 URL을 사용해 같은 URL이면 같은 화면이 보여야 한다는, 심지어 무엇인가가 선택되어 있었다면 그런 부분도 똑같이 보여야 한다고 정해두었습니다. 이 부분은 운영 데이터에 대한 URL로 커뮤니케이션을 할때 편리하게 사용했던 것 같습니다. 또, 메뉴 이름 기반으로 url segment depth를 설계해 url만 봐도 어떤 기능인지 짐작 가능하도록 가이드를 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영툴은 서비스 발전의 동력이라고 생각합니다. 개발자와 운영자 사이에서 커뮤니케이션을 돕고 서로의 시간을 절약해줍니다. 운영자를 위한 최고의 서비스를 개발한다는 마음으로 개발한다면 그만큼 서비스 운영에 큰 힘이 될거라 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 개발자에게는 운영툴을 개발하면서 얻는 또다른 좋은 점이 있습니다. 바로 프론트엔드 기술을 익힐 수 있는 기회가 된다는 것입니다. 대부분의 서비스는 프론트엔드 개발자 서버개발자로 나누어 었어서 프론트엔드 개발이 어떤 식으로 이뤄지는지 잘 알지 못할텐데요. 프론트엔드 개발을 통해서 더 나은 API 설계에 대해 생각해볼 기회도 될 것입니다. 그리고 GraphQL과 같은 잘 써보지 않았던 기술을 적용해 검증을 해볼 수 있다는 것도 아주 좋은 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 그동안 운영툴 개발을 통해 경험했던 부분에 대한 생각을 프론트엔드, API, UX관점에서 정리해보았는데요. 이런 부분에서 고민을 했던 분들이 계시다면 도움이 되었으면 좋겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;감사합니다.&lt;/p&gt;</description>
      <category>Dev</category>
      <category>graphql</category>
      <category>react</category>
      <category>UX</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/629</guid>
      <comments>https://blog.joostory.net/629#entry629comment</comments>
      <pubDate>Sun, 4 Oct 2026 19:05:17 +0900</pubDate>
    </item>
    <item>
      <title>zsh 환경을 위한 zshrc 설정</title>
      <link>https://blog.joostory.net/628</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;zsh 설정을 할때마다 헷갈려서 정리해본다. 원래 zshrc에 때려 넣곤 했는데 AI 사용하는 대화형 쉘이나 스크립트에서는 설정이 로드가 되지 않는 일이 종종 있었다. 알아보다보니 zshenv, zshrc 등에 적절히 넣어야하는 것을 알았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;zsh 로드순서&lt;/h3&gt;
&lt;table style=&quot;color: #bbbebf; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;4&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;순서&lt;/td&gt;
&lt;td&gt;파일&lt;/td&gt;
&lt;td&gt;로그인쉘&lt;br /&gt;(zsh)&lt;/td&gt;
&lt;td&gt;비로그인 대화형 쉘&lt;br /&gt;(zsh -l)&lt;/td&gt;
&lt;td&gt;비대화형 스크립트&lt;br /&gt;(zsh script.sh)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;6&quot;&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;/etc/zshenv&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;7&quot;&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;~/.zshenv&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;8&quot;&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;/etc/zprofile&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;9&quot;&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;~/.zprofile&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;10&quot;&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;/etc/zshrc&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;11&quot;&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;~/.zshrc&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;12&quot;&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;~/.zlogin&lt;br /&gt;(로그아웃시: ~/.zlogout)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 순서로 로드가 되고 있기 때문에 다음의 기준으로 설정을 배치해야한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;zshenv: 최소한의 필수 환경변수 (PATH 등)&lt;/li&gt;
&lt;li&gt;zprofile: 로그인 쉘 초기화 (brew shellenv 등)&lt;/li&gt;
&lt;li&gt;zshrc: 프롬프트, alias, 자동완성 등&lt;/li&gt;
&lt;li&gt;zlogin: 로그인 환영 메시지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;.zshenv&lt;/h3&gt;
&lt;pre id=&quot;code_1790987054834&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# ==========================================
#   zsh 모듈형 설정 로더 (.zshenv)
# ==========================================

if [ -d &quot;$HOME/.zsh&quot; ]; then
  for config_file in &quot;$HOME/.zsh&quot;/zshenv/*.zsh; do
    [ -r &quot;$config_file&quot; ] &amp;amp;&amp;amp; source &quot;$config_file&quot;
  done
  unset config_file
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;env에는 PATH, JAVA_HOME 등의 환경변수, secret에는 각 cli에서 사용할 token을 두었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;.zprofile&lt;/h3&gt;
&lt;pre id=&quot;code_1790987103446&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# ==========================================
#   zsh 모듈형 설정 로더 (.zprofile)
# ==========================================

if [ -d &quot;$HOME/.zsh&quot; ]; then
  for config_file in &quot;$HOME/.zsh&quot;/zprofile/*.zsh; do
    [ -r &quot;$config_file&quot; ] &amp;amp;&amp;amp; source &quot;$config_file&quot;
  done
  unset config_file
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;macos는 /etc/zprofile에서 path_helper 같은 것이 /usr/bin 같은 것을 PATH 최상위로 변경하고 있음을 알았다. 이렇게 하면 아무리&amp;nbsp; brew로 뭔가를 설치해도 사용할 수가 없다. 그래서 여기서 brew 같은 앱의 path를 최상위로 설정한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;.zshrc&lt;/h3&gt;
&lt;pre id=&quot;code_1790987121886&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# ==========================================
#   zsh 모듈형 설정 로더 (.zshrc)
# ==========================================

if [ -d &quot;$HOME/.zsh&quot; ]; then
  for config_file in &quot;$HOME/.zsh&quot;/zshrc/*.zsh; do
    [ -r &quot;$config_file&quot; ] &amp;amp;&amp;amp; source &quot;$config_file&quot;
  done
  unset config_file
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에는 alias, prompt, nvm 같은 필요한 것들을 모두 넣는다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제는 헷갈리지 않고 설정 잘 할 수 있게 되기를...&lt;/p&gt;</description>
      <category>Dev</category>
      <category>zsh</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/628</guid>
      <comments>https://blog.joostory.net/628#entry628comment</comments>
      <pubDate>Sat, 3 Oct 2026 09:31:41 +0900</pubDate>
    </item>
    <item>
      <title>TypeScript 7 &amp;amp; Next.js 16 환경에서 ESLint 대신 Biome(바이옴) 도입 후기</title>
      <link>https://blog.joostory.net/627</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 프로젝트에서 &lt;b&gt;TypeScript 7.0&lt;/b&gt;과 &lt;b&gt;Next.js 16.3&lt;/b&gt; 버전 업그레이드를 진행하면서 기존 정적 분석 및 포맷팅 도구였던 ESLint 생태계와의 호환성 문제와 느린 실행 속도를 해결하기 위해 Rust 기반 초고속 툴체인인 &lt;b&gt;Biome&lt;/b&gt;으로 도구를 교체했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 과정에서 경험한 설정 노하우와 도입 후기를 공유합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Biome을 도입하게 된 이유&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;최신 TypeScript 7 호환성&lt;/b&gt;: `@typescript-eslint` 패키지의 공식 지원을 기다리는 대신, 자체 파서로 TypeScript/TSX를 직접 분석하는 독립적인 도구가 필요했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;통합 툴체인 (Linter + Formatter)&lt;/b&gt;: ESLint와 Prettier를 조합하는 복잡한 플러그인 설정 없이 단일 바이너리로 린팅과 코드 포맷팅을 한 번에 처리할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;압도적인 실행 속도&lt;/b&gt;: 100여 개가 넘는 프로젝트 파일의 정적 분석 및 포맷팅이 불과 &lt;b&gt;30~40ms&lt;/b&gt; 만에 완료됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 프로젝트 설정 방법 (`biome.json`)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`npm install -D &amp;ndash;save-exact @biomejs/biome` 설치 후 `npx biome init`을 실행하여 설정 파일을 생성합니다. Next.js 및 Tailwind CSS 프로젝트에 맞춘 실전 설정 팁입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;`biome.json` 핵심 설정&lt;/h3&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;$schema&quot;: &quot;https://biomejs.dev/schemas/2.5.6/schema.json&quot;,
  &quot;vcs&quot;: {
    &quot;enabled&quot;: true,
    &quot;clientKind&quot;: &quot;git&quot;,
    &quot;useIgnoreFile&quot;: true
  },
  &quot;formatter&quot;: {
    &quot;enabled&quot;: true,
    &quot;indentStyle&quot;: &quot;space&quot;,
    &quot;indentWidth&quot;: 2
  },
  &quot;linter&quot;: {
    &quot;enabled&quot;: true,
    &quot;rules&quot;: {
      &quot;preset&quot;: &quot;recommended&quot;,
      &quot;a11y&quot;: {
        &quot;recommended&quot;: false
      },
      &quot;performance&quot;: {
        &quot;noImgElement&quot;: &quot;off&quot;
      }
    }
  },
  &quot;javascript&quot;: {
    &quot;formatter&quot;: {
      &quot;quoteStyle&quot;: &quot;double&quot;,
      &quot;semicolons&quot;: &quot;asNeeded&quot;
    }
  },
  &quot;css&quot;: {
    &quot;parser&quot;: {
      &quot;tailwindDirectives&quot;: true
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주요 커스텀 옵션 포인트:&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Tailwind CSS v4 지원&lt;/b&gt;: `css.parser.tailwindDirectives: true`를 추가하여 `@ theme`, `@ utility`, `@ apply` 지시어 파싱 오류를 방지했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Static Export 환경 예외 처리&lt;/b&gt;: Static Site Export(`output: &amp;lsquo;export&amp;rsquo;`)를 사용하는 경우 dynamic image optimization이 불가능하므로 `performance.noImgElement: &amp;ldquo;off&amp;rdquo;`로 설정하여 &amp;ldquo; 태그 관련 경고를 예외 처리했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;세미콜론 안 붙이기 (Semi-less)&lt;/b&gt;: `javascript.formatter.semicolons: &quot;asNeeded&amp;rdquo;`로 필요 시에만 세미콜론을 붙이도록 설정했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. NPM 스크립트 및 CI 연동&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`package.json`에 다음 스크립트를 추가하여 간편하게 실행합니다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`npm run lint` -&amp;gt; `biome check .`&lt;/li&gt;
&lt;li&gt;`npm run lint:fix` -&amp;gt; `biome check &amp;ndash;write .`&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 GitHub Actions(`ci.yaml`)에 `npm run lint` 및 `npx tsc &amp;ndash;noEmit` 검증 단계를 추가하여 모든 PR에 대해 정적 분석과 타입 검사를 자동으로 통과하도록 구축했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Biome으로 전환한 후 개발 환경이 훨씬 깔끔해졌고, 코드 체크 및 포맷팅 속도가 비약적으로 향상되었습니다. 최신 TypeScript/Next.js 프로젝트를 진행 중이라면 Biome 도입을 강력히 추천합니다!&lt;/p&gt;</description>
      <category>Dev</category>
      <category>BIOME</category>
      <category>nextjs</category>
      <category>TypeScript</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/627</guid>
      <comments>https://blog.joostory.net/627#entry627comment</comments>
      <pubDate>Sat, 15 Aug 2026 08:18:33 +0900</pubDate>
    </item>
    <item>
      <title>이것이 vibe 코딩인가</title>
      <link>https://blog.joostory.net/626</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 AI 서비스가 계속 발전해서 chatgpt, copilot, grok, gemini 등이 나왔고 이것이 개발도구에 들어오기 시작했다. 많은 사람들이 너무 좋다고 계속 추천을 하는데 나는 유료버전을 안써서 그런지 별로 좋은 경험은 아니었다. intelliJ, vscode에서 copilot agent, gemini agent를 사용했었는데 ide 문제인지 내가 익숙하지 않아서 그런지 모르겠지만 뭔가 모르게 불편했고 그냥 내가 원하는대로 되지가 않았다. 그래서 좋다는 사람들은 어떻게 쓰나 궁금했는데 vibe코딩이라는 말이 나오기 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vibe코딩은 뭔가 정확한 지시를 하는게 아니라 기획초기 아이디어 던지듯이 요구사항을 툭툭 던지면서 하는 코딩을 말하는 것 같았다. (사람들이 하는거 보니까 그렇게 하더라) 사실 이것도 그냥 그러려니 했는데 gemini-cli를 소개하는 영상을 보면서 조금 끌리기 시작했다. 그동안의 ai 코딩은 ide와 통합이 필요했고, 그래서 뭐 cursor니 뭐니 잔뜩 나왔었다. 그런데 gemini-cli는 달랐다. ide는 상관없다. 그 안에 터미널만 열면 됐다. 아니 ide 내부 터미널이 아니라도 된다. 아무 터미널이면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;project 디렉토리에서 터미널을 열고 이것저것 요구사항을 말하면 뚝딱뚝딱 만들어서 diff를 보여주면서 코드를 막 수정한다. 아주 간단한 작업을 시켜보니까... 어라? 굉장히 깔끔하게 작업을 마무리 한다. 뭔가 원하는대로 동작을 안하고 있어서 해결해달라고 했는데 코드를 쭉 읽어보더니 문제점을 찾아서 해결해주었다. 단순히 알려주는 것만이 아니고 파일 수정까지도 한다. 무엇보다도 그 과정에서 나에게 설명해주는 부분이 좋았다. 개요, 본론, 결론 나눠서 군더더기 없는 커뮤니케이션을 한다. 마치 굉장히 똑똑한 동료와 페어코딩하는 느낌. 때마침 성경앱에 검색기능이 필요한 상황이어서 gemini-cli를 사용해서 해결해보기로 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;검색기능 해줘&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 어떻게 시작해야할지 몰라서 최대한 자세히 설명했던 것 같다. (터미널 기록이 없네 ;;;)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;nextjs로 github page로 publish를 하고 있어. data/bible.json을 읽어서 페이지에 표시하고 있는데 모든 페이지에서 이 성경책을 검색하도록 하고 싶어. ctrl+k, 맥에선 cmd+k로 검색하고 header에도 검색버튼을 추가해줘. ui는 dialog로 표시하고 검색어를 입력하면 리스트가 나오도록 하면 좋겠어.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이정도 느낌이었다. 여기서 첫번째 감동을 받았다. 바로 실행하지 않고 분석을 한 후에 실행계획을 순서대로 알려주고 이후에 순서대로 수행을 하는데 이 부분이 뭔가 나에게 믿음을 주는 행동이었다. 뭐랄까 회사에서 동료에게 뭔가를 부탁했을때 이런 응답이 오기를 바랬는데 완벽한 응답이었다. 이제 계획을 읽고 나는 ok만 하면 되는거였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수행과정도 아주 맘에 든다. 뭔가를 할때마다 '하겠습니다', '하고 있습니다', '했습니다' 이런식으로 알려주면서 한다. 실제 동료와 페어로 일을 하면 커뮤니케이션에 말로, 화면으로 여러가지 수단을 활용해야하는데 gemini-cli는 한 화면에서 수행과정까지 보고서 형식으로 하니 정말 편했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;723&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6YwyW/btsO5eQiqvt/3KcGSw7RfViXxZ1Xmu8fPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6YwyW/btsO5eQiqvt/3KcGSw7RfViXxZ1Xmu8fPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6YwyW/btsO5eQiqvt/3KcGSw7RfViXxZ1Xmu8fPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6YwyW%2FbtsO5eQiqvt%2F3KcGSw7RfViXxZ1Xmu8fPK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1764&quot; height=&quot;723&quot; data-origin-width=&quot;1764&quot; data-origin-height=&quot;723&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 기능을 만들어달라는 요구였기 때문에 다소 작업이 많았는데 수행과정은 자세히 설명하고 계획 설명, 수행 결과 요약은 간결했다. 이건 뭐 너무나 완벽한 동료개발자의 모습이다. 심지어 내가 직접했다면 한시간은 걸렸을 작업을 몇마디 말에 몇분만에 완료했다. 물론 100% 완벽한 것은 아니다 ui 작업이기 때문에 실제로 결과를 눈으로 확인하고 조정해야하는 것은 내 몫이었다. 개발 과정이 완벽했다는 뜻이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;고쳐줘&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과를 확인해보니 오류가 발생하고 있다. 흠... 이제 두번째 요구인데 어떻게 말할까 하다가 간략하게 상황만 설명하기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1705&quot; data-origin-height=&quot;502&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xmC5E/btsO7yTus6O/mmqLOJLkXUEQlMxwZp0PG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xmC5E/btsO7yTus6O/mmqLOJLkXUEQlMxwZp0PG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xmC5E/btsO7yTus6O/mmqLOJLkXUEQlMxwZp0PG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxmC5E%2FbtsO7yTus6O%2FmmqLOJLkXUEQlMxwZp0PG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1705&quot; height=&quot;502&quot; data-origin-width=&quot;1705&quot; data-origin-height=&quot;502&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세상에. 원인파악, 해결계획, 수행 모두 완벽하다. 저렇게 잘 알면서 왜 처음부터 잘 만들지 않았지라는 생각은 뒤로 하고 다음 요구를 말했다. daisyui를 쓰고 있는데 radix dialog를 쓰고 있어서 이걸 바꿔달라는 말이었다. 이제 진짜 동료대하듯 친철하게 말한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1759&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZlQLP/btsO5x9TFHJ/xZ3DR2Xjs0Ygufh9WcuAYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZlQLP/btsO5x9TFHJ/xZ3DR2Xjs0Ygufh9WcuAYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZlQLP/btsO5x9TFHJ/xZ3DR2Xjs0Ygufh9WcuAYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZlQLP%2FbtsO5x9TFHJ%2FxZ3DR2Xjs0Ygufh9WcuAYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1759&quot; height=&quot;522&quot; data-origin-width=&quot;1759&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ref를 사용해서 브라우저 api를 직접 사용하는 부분은 귀찮은 작업인데 이것도 별문제 아니라는 듯 뚝딱해결해주었다. 이젠 좀 더 친근하게 다른 문제의 해결을 말하기 시작했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1707&quot; data-origin-height=&quot;287&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0zez9/btsO5lWaxHz/sL6ks0z7XsN51Vo7KxNvJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0zez9/btsO5lWaxHz/sL6ks0z7XsN51Vo7KxNvJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0zez9/btsO5lWaxHz/sL6ks0z7XsN51Vo7KxNvJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0zez9%2FbtsO5lWaxHz%2FsL6ks0z7XsN51Vo7KxNvJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1707&quot; height=&quot;287&quot; data-origin-width=&quot;1707&quot; data-origin-height=&quot;287&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조에 대한 조언과 함께 은근히 변경해주기를 돌려말해도 해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1733&quot; data-origin-height=&quot;679&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxJj5B/btsO5l9JpPM/YhpLYfe3766FDKPpx0tkk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxJj5B/btsO5l9JpPM/YhpLYfe3766FDKPpx0tkk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxJj5B/btsO5l9JpPM/YhpLYfe3766FDKPpx0tkk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxJj5B%2FbtsO5l9JpPM%2FYhpLYfe3766FDKPpx0tkk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1733&quot; height=&quot;679&quot; data-origin-width=&quot;1733&quot; data-origin-height=&quot;679&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얘도 날 칭찬해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1695&quot; data-origin-height=&quot;209&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/duZQd3/btsO55SwsJt/lp3kTtKV3wVqU6pyszme11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/duZQd3/btsO55SwsJt/lp3kTtKV3wVqU6pyszme11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/duZQd3/btsO55SwsJt/lp3kTtKV3wVqU6pyszme11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FduZQd3%2FbtsO55SwsJt%2Flp3kTtKV3wVqU6pyszme11%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1695&quot; height=&quot;209&quot; data-origin-width=&quot;1695&quot; data-origin-height=&quot;209&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가 요구사항도 말해본다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1657&quot; data-origin-height=&quot;395&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lB1Hg/btsO688A0Z4/kcaiExGvFDNKj7HCOyaH8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lB1Hg/btsO688A0Z4/kcaiExGvFDNKj7HCOyaH8K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lB1Hg/btsO688A0Z4/kcaiExGvFDNKj7HCOyaH8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlB1Hg%2FbtsO688A0Z4%2FkcaiExGvFDNKj7HCOyaH8K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1657&quot; height=&quot;395&quot; data-origin-width=&quot;1657&quot; data-origin-height=&quot;395&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지적하면 또 귀신같이 원인을 파악해서 고친다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1739&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5HsNO/btsO67u4E2l/eL8ZU7fGr3P1eRBL2YKma0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5HsNO/btsO67u4E2l/eL8ZU7fGr3P1eRBL2YKma0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5HsNO/btsO67u4E2l/eL8ZU7fGr3P1eRBL2YKma0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5HsNO%2FbtsO67u4E2l%2FeL8ZU7fGr3P1eRBL2YKma0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1739&quot; height=&quot;504&quot; data-origin-width=&quot;1739&quot; data-origin-height=&quot;504&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 몇몇 핑퐁을 거치긴 했지만 나는 요구사항 전달과 코드리뷰한 것만으로도 새로운 기능을 얻게 되었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과물&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수행한 commit: &lt;a href=&quot;https://github.com/joostory/holybible/commit/2679783dd1e37a55e046652ca38a0517636f5eaf&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/joostory/holybible/commit/2679783dd1e37a55e046652ca38a0517636f5eaf&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1878&quot; data-origin-height=&quot;587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ekESCz/btsO5dw8rGh/sJW8rU97LkdNy7RUkv9kxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ekESCz/btsO5dw8rGh/sJW8rU97LkdNy7RUkv9kxk/img.png&quot; data-alt=&quot;gemini agent 사용 요약&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ekESCz/btsO5dw8rGh/sJW8rU97LkdNy7RUkv9kxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FekESCz%2FbtsO5dw8rGh%2FsJW8rU97LkdNy7RUkv9kxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1878&quot; height=&quot;587&quot; data-origin-width=&quot;1878&quot; data-origin-height=&quot;587&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;gemini agent 사용 요약&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;vibe코딩&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 걸 vibe코딩이라고 하는거겠지? 요구사항을 툭툭 던지고 개발은 ai가 하고 나는 그저 검토만 하는 것. ai가 코딩을 한다고 해서 개발자가 할 일이 없어지는 것은 아닌 것 같다. 내가 모르는 코드에 대해서 ai가 개발을 해준다고 했을때 나는 아무런 조언을 할 수가 없었을 것이다. 이 작업은 나도 익숙하게 할 수 있는 작업이고 한눈에 작업상황 파악, 코드리뷰가 가능했기때문에 무리없이 가능했던 것 같다. (내가 모르는 분야에 대해서도 이렇게 한번 해보면 확실히 알 수 있겠지.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쨌든 이번 기회에 ai로 개발하는 것이 어떤 것인지 잘 알았고 ai는 훌륭한 개발동료가 될 수 있다는 생각이 들었다. 자주 보자. gemini.&lt;/p&gt;</description>
      <category>Dev</category>
      <category>gemini-cli</category>
      <category>vibe코딩</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/626</guid>
      <comments>https://blog.joostory.net/626#entry626comment</comments>
      <pubDate>Sun, 6 Jul 2025 10:17:04 +0900</pubDate>
    </item>
    <item>
      <title>Ubuntu grub 복구</title>
      <link>https://blog.joostory.net/625</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;갑자기 Ubuntu로 부팅이 되지 않는 문제가 생겼다. 먼저 내 상황을 설명하자면... 오래 전에 고장나버린 노트북이 고장나서 거기서 SSD를 뽑아두었다. 그걸 게임하려고 산 노트북에 넣어서 우분투를 쓰고 싶을때 사용하고 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1732421517246&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/dev/nvmen1n1 - Windows
- /dev/nvmen1n1p1 : Windows Boot Manager (UEFI partition)
- /dev/nvmen1n1p2
- /dev/nvmen1n1p3 : Windows
- /dev/nvmen1n1p4
/dev/nvmen0n1 - Ubuntu
- /dev/nvmen0n1p1 : Ubuntu&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 상태다. Windows의 부트매니저가 window, ubuntu중 선택하도록 하고 ubuntu의 grub이 다시 뭘로 부팅할지 물어보는 두번에 걸친 과정을 거친다. 우분투는 어찌되었든 메인이 아니고 기본값을 윈도우로 두고 싶어서였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;첫번째 문제 - grub 진입 안됨&lt;/h3&gt;
&lt;pre id=&quot;code_1732421740321&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Failed to open \EFI\UBUNTU\grubx64.efi - Not Found  
Failed to load image \EFI\UBUNTU\grubx64.efi: Not Found    
start_image() returned Not Found&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;윈도우 부트매니저에서 우분투를 선택하면 이런 메시지만 나오고 더이상 진행이 되지 않았다. 이건 윈도우 업데이트를 할 때 생길 수 있는 문제라고 했다. 그리고 EFI/UBUNTU 에 grubx64.efi 라는 것을 복사만 하면 해결된다고 했다. 실제로 이게 맞다. 하지만 나는 여기서 고생을 많이 했는데 왜냐하면 저 복사해야할 디렉토리가 어디인지를 못찾았기 때문이다. 지금은 알고 있어서 허탈하지만 몇시간을 고생한지 모르겠다.&lt;/p&gt;
&lt;figure id=&quot;og_1732421996069&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Failed to open \EFI\BOOT\grubx64.efi - Not Found (Dual Boot Ubuntu with Windows 10)&quot; data-og-description=&quot;Background I have a Windows 10 machine with a SSD where I have installed Ubuntu LTS 20.04. This setup worked wonderfully for months. When I turn my machine on I get to choose which operative system...&quot; data-og-host=&quot;askubuntu.com&quot; data-og-source-url=&quot;https://askubuntu.com/questions/1356544/failed-to-open-efi-boot-grubx64-efi-not-found-dual-boot-ubuntu-with-windows&quot; data-og-url=&quot;https://askubuntu.com/questions/1356544/failed-to-open-efi-boot-grubx64-efi-not-found-dual-boot-ubuntu-with-windows&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bFEGqJ/hyXzMYjibZ/CicUOWp01eclutT52Mj5b0/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316&quot;&gt;&lt;a href=&quot;https://askubuntu.com/questions/1356544/failed-to-open-efi-boot-grubx64-efi-not-found-dual-boot-ubuntu-with-windows&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://askubuntu.com/questions/1356544/failed-to-open-efi-boot-grubx64-efi-not-found-dual-boot-ubuntu-with-windows&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bFEGqJ/hyXzMYjibZ/CicUOWp01eclutT52Mj5b0/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Failed to open \EFI\BOOT\grubx64.efi - Not Found (Dual Boot Ubuntu with Windows 10)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Background I have a Windows 10 machine with a SSD where I have installed Ubuntu LTS 20.04. This setup worked wonderfully for months. When I turn my machine on I get to choose which operative system...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;askubuntu.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문서에 설명한 것처럼 우분투 설치 usb같은 것으로 부팅한 후에 UEFI 파티션의 디렉토리에 복사를 하면 된다. 그 UEFI 파티션은 바로 윈도우 부트매니저가 있는 곳을 말한다. 그러니까 나의 경우는 /dev/nvme1n1p1 이다. 이걸 이해 못해서 너무 고생했다. 심지어 다시 설치하려고 usb 메모리까지 구매했었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;두번째 문제 - grub console&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 grub으로 진입은 했지만 문제가 있었다. grub 메뉴가 안나오고 console만 나오는거다. grub 환경설정을 정상적으로 불러오지 못하는 것 같았다. grub... 을 진작에 좀 공부해서 알고 있었으면 좋았으련만 그동안 직접 설정을 만지거나하지를 않아서 몰랐다. 이제 어떻게 해야하는지를...&lt;/p&gt;
&lt;figure id=&quot;og_1732422375658&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Classic SysAdmin: How to Rescue a Non-booting GRUB 2 on Linux - Linux Foundation&quot; data-og-description=&quot;Once upon a time we had legacy GRUB, the Grand Unified Linux Bootloader version 0.97. Learn how to rescue a non-booting GRUB 2 on Linux.&quot; data-og-host=&quot;www.linuxfoundation.org&quot; data-og-source-url=&quot;https://www.linuxfoundation.org/blog/blog/classic-sysadmin-how-to-rescue-a-non-booting-grub-2-on-linux&quot; data-og-url=&quot;https://www.linuxfoundation.org/blog/blog/classic-sysadmin-how-to-rescue-a-non-booting-grub-2-on-linux&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cEHzUc/hyXDeFzvrI/qTS7aHGCFCiv5SA842Dtf1/img.jpg?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/buDeGP/hyXDhoLN9D/v57HRIZ7pgiTiVFnUkVFEk/img.jpg?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/eSaawY/hyXDdzTtSm/Gvm5jh3lQFOTR0dIqkQR60/img.jpg?width=550&amp;amp;height=282&amp;amp;face=0_0_550_282&quot;&gt;&lt;a href=&quot;https://www.linuxfoundation.org/blog/blog/classic-sysadmin-how-to-rescue-a-non-booting-grub-2-on-linux&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.linuxfoundation.org/blog/blog/classic-sysadmin-how-to-rescue-a-non-booting-grub-2-on-linux&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cEHzUc/hyXDeFzvrI/qTS7aHGCFCiv5SA842Dtf1/img.jpg?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/buDeGP/hyXDhoLN9D/v57HRIZ7pgiTiVFnUkVFEk/img.jpg?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/eSaawY/hyXDdzTtSm/Gvm5jh3lQFOTR0dIqkQR60/img.jpg?width=550&amp;amp;height=282&amp;amp;face=0_0_550_282');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Classic SysAdmin: How to Rescue a Non-booting GRUB 2 on Linux - Linux Foundation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Once upon a time we had legacy GRUB, the Grand Unified Linux Bootloader version 0.97. Learn how to rescue a non-booting GRUB 2 on Linux.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.linuxfoundation.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러가지 방법이 제시되는데 나에게는 이 방법으로 해결이 되었다.&lt;/p&gt;
&lt;pre id=&quot;code_1732422466562&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;gt; ls
(hd0) (hd0,gpt1) (hd1) (hd1,gpt1) (hd1,gpt2) (hd1,gpt3)
&amp;gt; set prefix=(hd0,gpt1)/boot/grub
&amp;gt; set root=(hd0,gpt1)
&amp;gt; insmod linux
&amp;gt; insmod normal
&amp;gt; normal&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것으로 며칠동안 고생하며 보지 못했던 우분투의 화면을 만났다. 그리고 이 설정은 저장되는 것이 아니라서 매번 이렇게 실행을 해야하는데 다시 grub의 설정을 정상적으로 복구하려면 우분투로 부팅한 후에 update-grub을 실행하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1732422553114&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;$ sudo update-grub&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우분투를 새로 다시 설치해야하나 고민도 하고 너무 괴로웠는데 다행히 해결되었다. ㅠㅠ 정말 다행이다.&lt;/p&gt;</description>
      <category>잡담</category>
      <author>Joo</author>
      <guid isPermaLink="true">https://blog.joostory.net/625</guid>
      <comments>https://blog.joostory.net/625#entry625comment</comments>
      <pubDate>Sun, 24 Nov 2024 13:30:29 +0900</pubDate>
    </item>
  </channel>
</rss>