<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>twdev 님의 블로그</title>
    <link>https://twdev.tistory.com/</link>
    <description>twdev 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Tue, 29 Sep 2026 02:53:08 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>twdev</managingEditor>
    <image>
      <title>twdev 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/7165244/attach/5e084b02025e4639ae5027bf0634f772</url>
      <link>https://twdev.tistory.com</link>
    </image>
    <item>
      <title>3. 중복 탐지 로직 최적화 (1)</title>
      <link>https://twdev.tistory.com/11</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Noto Sans KR', sans-serif;
    background: #ffffff;
    color: #37352f;
    line-height: 1.75;
    font-size: 15px;
    max-width: 7800px;
    margin: 0 auto;
    padding: 48px 24px 80px;
  }

  .page-title {
    font-size: 28px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 8px;
    line-height: 1.3;
  }
  .page-subtitle {
    font-size: 14px;
    color: #9b9b9b;
    margin-bottom: 40px;
  }

  h2 {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a1a;
    margin: 52px 0 16px;
    padding-bottom: 8px;
    border-bottom: 2px solid #f0f0f0;
  }
  h3 {
    font-size: 16px;
    font-weight: 600;
    color: #37352f;
    margin: 36px 0 12px;
  }
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #37352f;
    margin: 28px 0 10px;
  }

  .overview-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 16px 0 28px;
    font-size: 14px;
    color: #555;
    line-height: 1.85;
  }
  .overview-card strong { color: #37352f; }

  .file-label {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: #f0f0f0;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: #555;
    margin-bottom: 6px;
    font-family: 'JetBrains Mono', monospace;
  }
  .file-label::before { content: ' '; font-size: 11px; }

  .code-block {
    background: #1e1e2e;
    border-radius: 8px;
    padding: 20px;
    margin: 0 0 24px;
    overflow-x: auto;
    position: relative;
  }
  .code-block .lang-tag {
    position: absolute;
    top: 10px;
    right: 14px;
    font-size: 10px;
    color: #6c7086;
    font-family: 'JetBrains Mono', monospace;
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }
  .code-block pre {
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    line-height: 1.7;
    color: #cdd6f4;
    white-space: pre;
  }
  .code-block .kw  { color: #cba6f7; }
  .code-block .an  { color: #89b4fa; }
  .code-block .str { color: #a6e3a1; }
  .code-block .cm  { color: #6c7086; font-style: italic; }
  .code-block .ty  { color: #f9e2af; }
  .code-block .num { color: #fab387; }

  .callout {
    display: flex;
    gap: 12px;
    background: #f0f7ff;
    border-left: 4px solid #4a90e2;
    border-radius: 0 8px 8px 0;
    padding: 14px 16px;
    margin: 16px 0 24px;
    font-size: 14px;
    color: #37352f;
    line-height: 1.8;
  }
  .callout .icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
  .callout ul, .callout ol { padding-left: 20px; margin-top: 6px; }
  .callout li { margin-bottom: 4px; }
  .callout strong { color: #1a5fa8; }
  .callout code {
    background: #dbeafe;
    padding: 1px 5px;
    border-radius: 3px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #1d4ed8;
  }
  .callout.green  { background: #f0fdf4; border-left-color: #22c55e; }
  .callout.green strong { color: #15803d; }
  .callout.green code   { background: #dcfce7; color: #166534; }
  .callout.yellow { background: #fffbeb; border-left-color: #f59e0b; }
  .callout.yellow strong { color: #92400e; }
  .callout.gray   { background: #f7f6f3; border-left-color: #9ca3af; }
  .callout.purple { background: #faf5ff; border-left-color: #9333ea; }
  .callout.purple strong { color: #7c3aed; }
  .callout.red    { background: #fff1f2; border-left-color: #f43f5e; }
  .callout.red strong   { color: #be123c; }

  blockquote {
    border-left: 3px solid #e0e0e0;
    padding: 4px 16px;
    margin: 16px 0 20px;
    color: #6b7280;
    font-size: 14px;
    line-height: 1.8;
  }

  hr {
    border: none;
    border-top: 1px solid #f0f0f0;
    margin: 44px 0;
  }

  p { margin: 12px 0; font-size: 15px; color: #37352f; }
  ul, ol { padding-left: 22px; margin: 10px 0 16px; }
  li { margin-bottom: 6px; font-size: 14px; color: #37352f; }

  code {
    background: #f0f0f0;
    padding: 1px 5px;
    border-radius: 4px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #d44000;
  }

  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 20px;
    font-size: 11.5px;
    font-weight: 600;
    margin-right: 4px;
  }
  .badge-blue   { background: #dbeafe; color: #1d4ed8; }
  .badge-green  { background: #dcfce7; color: #166534; }
  .badge-purple { background: #ede9fe; color: #7c3aed; }
  .badge-orange { background: #fff7ed; color: #c2410c; }
  .badge-red    { background: #fff1f2; color: #be123c; }
  .badge-gray   { background: #f1f5f9; color: #475569; }

  .arrow-note {
    font-size: 13.5px;
    color: #4a90e2;
    margin: 12px 0 20px;
    font-weight: 500;
  }
  .arrow-note::before { content: '⇒ '; }

  /* ── 메서드 카드 ── */
  .method-card {
    border: 1px solid #e8e8e8;
    border-radius: 10px;
    margin: 20px 0;
    overflow: hidden;
  }
  .method-card-header {
    background: #f7f6f3;
    padding: 10px 16px;
    font-size: 13px;
    font-weight: 700;
    color: #37352f;
    display: flex;
    align-items: center;
    gap: 8px;
    border-bottom: 1px solid #e8e8e8;
  }
  .method-card-header .role {
    font-size: 11.5px;
    font-weight: 500;
    color: #9ca3af;
    margin-left: auto;
  }
  .method-card-body { padding: 14px 16px; }
  .method-card-body .code-block { margin-bottom: 0; }

  /* ── Before / After 박스 ── */
  .ba-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
    margin: 14px 0 24px;
  }
  .ba-box {
    border-radius: 8px;
    padding: 12px 14px;
    font-size: 13px;
  }
  .ba-box .ba-label {
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    margin-bottom: 6px;
  }
  .ba-box.before { background: #fff1f2; border: 1px solid #fecdd3; }
  .ba-box.before .ba-label { color: #be123c; }
  .ba-box.after  { background: #f0fdf4; border: 1px solid #bbf7d0; }
  .ba-box.after  .ba-label { color: #15803d; }
  .ba-box .ba-value {
    font-size: 22px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 2px;
  }
  .ba-box .ba-desc { font-size: 12px; color: #6b7280; }

  /* ── 측정 로그 블록 ── */
  .log-block {
    background: #0d1117;
    border-radius: 8px;
    padding: 16px 18px;
    margin: 0 0 24px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    line-height: 1.9;
    color: #e6edf3;
    overflow-x: auto;
  }
  .log-block .log-key  { color: #79c0ff; }
  .log-block .log-val  { color: #ffa657; font-weight: 600; }
  .log-block .log-warn { color: #ff7b72; font-weight: 600; }
  .log-block .log-ok   { color: #7ee787; font-weight: 600; }

  /* ── 성능 표 ── */
  .perf-table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0 24px;
    font-size: 13.5px;
  }
  .perf-table th {
    background: #f7f6f3;
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    border: 1px solid #e8e8e8;
    color: #37352f;
  }
  .perf-table td {
    padding: 9px 14px;
    border: 1px solid #e8e8e8;
    color: #555;
    vertical-align: middle;
  }
  .perf-table tr:nth-child(even) td { background: #fafafa; }
  .ok     { color: #16a34a; font-weight: 600; }
  .warn   { color: #d97706; font-weight: 600; }
  .danger { color: #dc2626; font-weight: 600; }

  /* ── 개선 흐름 ── */
  .flow-row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 16px 0 24px;
    flex-wrap: wrap;
  }
  .flow-step {
    background: #ede9fe;
    color: #7c3aed;
    border-radius: 6px;
    padding: 7px 16px;
    font-size: 13.5px;
    font-weight: 600;
  }
  .flow-step.highlight {
    background: #dcfce7;
    color: #15803d;
  }
  .flow-arrow { color: #c4b5fd; font-size: 18px; font-weight: 700; }

  /* ── 이미지 캡션 ── */
  .img-caption {
    font-size: 12.5px;
    color: #9ca3af;
    margin-bottom: 4px;
  }

  /* ── Todo 리스트 ── */
  .todo-list {
    list-style: none;
    padding: 0;
    margin: 16px 0;
  }
  .todo-list li {
    display: flex;
    gap: 10px;
    padding: 10px 0;
    border-bottom: 1px solid #f0f0f0;
    font-size: 14px;
    color: #37352f;
    align-items: flex-start;
  }
  .todo-list li:last-child { border-bottom: none; }
  .todo-dot {
    width: 8px; height: 8px;
    border-radius: 50%;
    background: #4a90e2;
    flex-shrink: 0;
    margin-top: 6px;
  }
  .todo-dot.gray { background: #d1d5db; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;!-- ── 타이틀 ── --&gt;
&lt;div class=&quot;page-title&quot;&gt;중복 탐지 구조 개선 &amp; CSV 파싱 성능 최적화&lt;/div&gt;
&lt;div class=&quot;page-subtitle&quot;&gt;SRP 기반 메서드 분리 · readAll() → readNext() · 5분 → 40초 · Spring Boot&lt;/div&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  0. 개요                                   --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;0. 개요&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  메모리 상에서 이루어지고 있는 &lt;strong&gt;중복 탐지 로직을 DB 관점에서 성능 개선&lt;/strong&gt;해보려고 한다.&lt;br&gt;&lt;br&gt;
  이 포스트에서는 현재 입력되는 DTO 간의 중복 탐지 로직의 코드를 개선하고,
  10만 건 입력 시 응답 시간이 오래 걸리는 &lt;strong&gt;원인을 발견한 후 이를 해결&lt;/strong&gt;해 나가고자 한다.
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  1. 현재 코드 살펴보기                     --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;1. 현재 코드 살펴보기&lt;/h2&gt;

&lt;div class=&quot;file-label&quot;&gt;ItemService.java — createCommonItem&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;// 아이템 이상 탐지 및 저장 서비스 메서드&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; createCommonItem(&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; reqDtos, &lt;span class=&quot;ty&quot;&gt;File&lt;/span&gt; savedFile) {

    &lt;span class=&quot;cm&quot;&gt;// 1. 검증 및 중복 매핑 결과 취득&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt; validationResult =
        itemDocumentDuplicateValidator.markDuplicatesForCommon(
            reqDtos, itemRepository.findAllByDeletedAtIsNullOrderByIdAsc()
        );

    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingItemsToUpdate = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Boolean&lt;/span&gt;&amp;gt; isDuplicateFlags = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();

    &lt;span class=&quot;cm&quot;&gt;// 2. DTO -&gt; Item 엔티티 및 DuplicatedGroup 연관관계 구성&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; itemsToSave = processItemsAndGroups(
        reqDtos, validationResult, savedFile, existingItemsToUpdate, isDuplicateFlags
    );

    &lt;span class=&quot;cm&quot;&gt;// 3. 기타 이상 탐지 및 이슈(Issue) 수집&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt; issues = detectIssues(itemsToSave, reqDtos, isDuplicateFlags);

    &lt;span class=&quot;cm&quot;&gt;// 4. 데이터 일괄 저장&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; saveAllEntities(itemsToSave, existingItemsToUpdate, issues);
}&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;
  &lt;code&gt;ItemDocumentDuplicateValidator&lt;/code&gt; 에서 중복 row 를 탐지하고,
  해당 정보를 &lt;code&gt;DuplicateValidationResult&lt;/code&gt; 에 담아서 전달하고 있는 모습이다.
&lt;/blockquote&gt;

&lt;div class=&quot;file-label&quot;&gt;ItemDocumentDuplicateValidator.java — markDuplicatesForCommon (기존)&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt; markDuplicatesForCommon(
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; dtos, &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; allExistingItems
) {
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (dtos.isEmpty()) {
        &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;.of(), &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;.of());
    }

    &lt;span class=&quot;cm&quot;&gt;// DB 기존 데이터 Key -&gt; Item 매핑&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingDbMap = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashMap&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; item : allExistingItems) {
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; key = generateKey(...);
        existingDbMap.putIfAbsent(key, item);
    }

    &lt;span class=&quot;cm&quot;&gt;// 현재 입력 데이터 처리 (DB 체크 + 자가 중복 체크가 혼재)&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; firstSeenMap = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashMap&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto : dtos) {
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; normalizedName = itemNameMapper.map(dto.getRawItemName());
        dto.setNormalizedItemName(normalizedName);

        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; key = generateKey(...); &lt;span class=&quot;cm&quot;&gt;// 모든 DTO에 대해 Key 생성&lt;/span&gt;

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (existingDbMap.containsKey(key) || firstSeenMap.containsKey(key)) {
            dto.setDuplicateGroupKey(key);
            &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (firstSeenMap.containsKey(key) &amp;&amp; firstSeenMap.get(key).getDuplicateGroupKey() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
                firstSeenMap.get(key).setDuplicateGroupKey(key);
            }
        } &lt;span class=&quot;kw&quot;&gt;else&lt;/span&gt; {
            firstSeenMap.put(key, dto);
        }
    }

    &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt;(existingDbMap, dtos);
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;⚠️&lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;문제점&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;하나의 메서드에서 &lt;strong&gt;&quot;DTO 간 자가 중복 체크&quot;&lt;/strong&gt; 와 &lt;strong&gt;&quot;DB 데이터 포함 중복 체크&quot;&lt;/strong&gt; 를 함께 진행하고 있어 역할이 혼재되어 있다.&lt;/li&gt;
      &lt;li&gt;모든 DTO 에 대해 &lt;code&gt;generateKey()&lt;/code&gt; 를 실행하고 있기 때문에, &lt;strong&gt;중복이 의심되는 경우에만 Key 를 생성&lt;/strong&gt;하도록 수정이 필요하다.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  2. 구조화                                 --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;2. 구조화 해보기&lt;/h2&gt;

&lt;p&gt;기존의 &lt;code&gt;markDuplicatesForCommon&lt;/code&gt; 메서드에서 DTO 체크와 DB 체크가 얽혀 있던 것을 역할에 따라 메서드로 분리한다.&lt;/p&gt;

&lt;div class=&quot;flow-row&quot;&gt;
  &lt;div class=&quot;flow-step&quot;&gt;① 정규화&lt;/div&gt;
  &lt;div class=&quot;flow-arrow&quot;&gt;→&lt;/div&gt;
  &lt;div class=&quot;flow-step&quot;&gt;② 파일 내 자가 중복 탐지&lt;/div&gt;
  &lt;div class=&quot;flow-arrow&quot;&gt;→&lt;/div&gt;
  &lt;div class=&quot;flow-step&quot;&gt;③ DB 포함 중복 탐지&lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      markDuplicatesForCommon
    &lt;span class=&quot;role&quot;&gt;리팩토링 후 — 3단계 위임 구조&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt; markDuplicatesForCommon(
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; dtos, &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; allExistingItems
) {
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (dtos.isEmpty()) {
        &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;.of(), &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;.of());
    }

    &lt;span class=&quot;cm&quot;&gt;// 1. DTO 품목명 정규화 (선행 조건)&lt;/span&gt;
    normalizeItemNames(dtos);

    &lt;span class=&quot;cm&quot;&gt;// 2. 파일 내부 (DTO 간) 중복 탐지 및 GroupKey 부여&lt;/span&gt;
    markFileSelfDuplicates(dtos);

    &lt;span class=&quot;cm&quot;&gt;// 3. DB 기존 데이터와의 중복 탐지&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingDbMap = markDbDuplicates(dtos, allExistingItems);

    &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt;(existingDbMap, dtos);
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      normalizeItemNames
    &lt;span class=&quot;role&quot;&gt;품목명 정규화&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private void&lt;/span&gt; normalizeItemNames(&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; dtos) {
    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto : dtos) {
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; normalizedName = itemNameMapper.map(dto.getRawItemName());
        dto.setNormalizedItemName(normalizedName);
    }
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      markFileSelfDuplicates
    &lt;span class=&quot;role&quot;&gt;파일 내부 DTO 간 중복 탐지 — Key 생성 최소화&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private void&lt;/span&gt; markFileSelfDuplicates(&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; dtos) {

    &lt;span class=&quot;cm&quot;&gt;// String Key 대신 DTO 자체를 Key로 사용 → generateKey() 호출 최소화&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; firstSeenMap =
        &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashMap&lt;/span&gt;&amp;lt;&amp;gt;(&lt;span class=&quot;num&quot;&gt;135_000&lt;/span&gt;);

    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto : dtos) {
        &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; firstSeenDto = firstSeenMap.get(dto);

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (firstSeenDto == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
            &lt;span class=&quot;cm&quot;&gt;// 최초 등장 → Map 에 등록&lt;/span&gt;
            firstSeenMap.put(dto, dto);
        } &lt;span class=&quot;kw&quot;&gt;else&lt;/span&gt; {
            &lt;span class=&quot;cm&quot;&gt;// 중복 발견 → 필요 시점에만 String Key를 1회 생성하여 공유&lt;/span&gt;
            &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (firstSeenDto.getDuplicateGroupKey() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
                &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; groupKey = generateKeyFromDto(firstSeenDto);
                firstSeenDto.setDuplicateGroupKey(groupKey);
            }
            dto.setDuplicateGroupKey(firstSeenDto.getDuplicateGroupKey());
        }
    }
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
     ️ markDbDuplicates
    &lt;span class=&quot;role&quot;&gt;DB 기존 데이터 매핑 + DB와의 중복 탐지&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; markDbDuplicates(
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; dtos, &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; allExistingItems
) {
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (allExistingItems == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt; || allExistingItems.isEmpty()) &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;.of();

    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingDbMap = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashMap&lt;/span&gt;&amp;lt;&amp;gt;(allExistingItems.size());
    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; item : allExistingItems) {
        existingDbMap.putIfAbsent(generateKeyFromItem(item), item);
    }

    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto : dtos) {
        &lt;span class=&quot;cm&quot;&gt;// 자가 중복 Key 가 이미 있으면 재활용, 없으면 신규 생성&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; dtoKey = (dto.getDuplicateGroupKey() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;)
            ? dto.getDuplicateGroupKey()
            : generateKeyFromDto(dto);

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (existingDbMap.containsKey(dtoKey)) {
            dto.setDuplicateGroupKey(dtoKey);
        }
    }

    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; existingDbMap;
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;callout green&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;✅&lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;개선 포인트&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;DTO 체크 / DB 체크를 메서드별로 분리하여 &lt;strong&gt;SRP (단일 책임 원칙)&lt;/strong&gt; 준수&lt;/li&gt;
      &lt;li&gt;기존 &lt;code&gt;String Key 기반 Map&lt;/code&gt; → &lt;strong&gt;DTO 객체 자체를 Key&lt;/strong&gt;로 사용하여 &lt;code&gt;generateKey()&lt;/code&gt; 호출을 중복이 감지된 시점에만 실행&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  3. 소요 시간 측정                         --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;3. 소요 시간 측정&lt;/h2&gt;

&lt;p&gt;시간 소요가 많은 부분을 파악하기 위해 &lt;code&gt;System.nanoTime()&lt;/code&gt; 을 이용하여 단계별로 측정하였다.&lt;/p&gt;

&lt;h3&gt;3-1. 중복 탐지 소요 시간 (10만 건 · DB 데이터 없는 경우)&lt;/h3&gt;

&lt;p class=&quot;img-caption&quot;&gt;전체 응답 — 약 6분 소요&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxV6uE/dJMcablicOo/2SYmyf8JMLaR69vAg1N1Tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxV6uE/dJMcablicOo/2SYmyf8JMLaR69vAg1N1Tk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxV6uE/dJMcablicOo/2SYmyf8JMLaR69vAg1N1Tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxV6uE%2FdJMcablicOo%2F2SYmyf8JMLaR69vAg1N1Tk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;br&gt;
  &lt;br&gt;
&lt;p class=&quot;img-caption&quot;&gt;단계별 측정 결과&lt;/p&gt;
  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tcEu6/dJMcaf8R6T6/dRcB8bcr95wknMUDnsvV0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tcEu6/dJMcaf8R6T6/dRcB8bcr95wknMUDnsvV0K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tcEu6/dJMcaf8R6T6/dRcB8bcr95wknMUDnsvV0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtcEu6%2FdJMcaf8R6T6%2FdRcB8bcr95wknMUDnsvV0K%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

    &lt;br&gt;
  &lt;br&gt;
&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    결과를 보고 놀라지 않을 수 없었다.&lt;br&gt;
    당연히 중복 탐지 때문에 오래 걸렸을 것이라 생각했는데,
    &lt;strong&gt;파싱 + 정규화 + 중복 탐지는 약 7초&lt;/strong&gt;에 불과했다!
    (정규화 프로세스가 가장 많이 소요)&lt;br&gt;&lt;br&gt;
    따라서 다른 부분의 소요 시간도 측정해보기로 했다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;3-2. 서비스 단 소요 시간 측정&lt;/h3&gt;

&lt;h4&gt;1,000 건&lt;/h4&gt;

&lt;div class=&quot;log-block&quot;&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 1. 검증 및 매핑:          &lt;span class=&quot;log-val&quot;&gt;528.59 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 2. 연관 관계 구성:        &lt;span class=&quot;log-val&quot;&gt;487.06 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 3. 이상 탐지 및 이슈 수집: &lt;span class=&quot;log-val&quot;&gt;112.38 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 4. DB 커밋:              &lt;span class=&quot;log-val&quot;&gt;396.85 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 5. 총 시간:              &lt;span class=&quot;log-val&quot;&gt;1526.00 ms&lt;/span&gt;&lt;br&gt;
&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-1. 파서 추출:      &lt;span class=&quot;log-val&quot;&gt;1.00 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-2. file DB 입력:   &lt;span class=&quot;log-val&quot;&gt;466.84 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-3. 파싱 수행:      &lt;span class=&quot;log-val&quot;&gt;130.75 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-4. itemService 수행: &lt;span class=&quot;log-val&quot;&gt;1530.46 ms&lt;/span&gt;&lt;br&gt;
&lt;/div&gt;

&lt;h4&gt;10만 건&lt;/h4&gt;

&lt;div class=&quot;log-block&quot;&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 1. 검증 및 매핑:          &lt;span class=&quot;log-val&quot;&gt;2949.00 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 2. 연관 관계 구성:        &lt;span class=&quot;log-val&quot;&gt;3436.36 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 3. 이상 탐지 및 이슈 수집: &lt;span class=&quot;log-val&quot;&gt;3226.39 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 4. DB 커밋:              &lt;span class=&quot;log-val&quot;&gt;16982.73 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 5. 총 시간:              &lt;span class=&quot;log-val&quot;&gt;26595.20 ms&lt;/span&gt;&lt;br&gt;
&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-1. 파서 추출:        &lt;span class=&quot;log-val&quot;&gt;9.16 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-2. file DB 입력:     &lt;span class=&quot;log-val&quot;&gt;610.82 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-3. 파싱 수행:        &lt;span class=&quot;log-warn&quot;&gt;285410.31 ms&lt;/span&gt;  ← 병목!&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-4. itemService 수행:  &lt;span class=&quot;log-val&quot;&gt;26599.11 ms&lt;/span&gt;&lt;br&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;&lt;strong&gt;파싱 시간이 전체의 대부분을 차지&lt;/strong&gt;한다는 것을 확인했다. 소요 시간이 큰 순서대로 성능 개선을 진행한다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  4. 파싱 시간 개선                         --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;4. 파싱 시간 개선하기&lt;/h2&gt;

&lt;div class=&quot;callout red&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;문제 원인 — &lt;code&gt;csvReader.readAll()&lt;/code&gt; 로 인한 메모리 폭증&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;code&gt;List&amp;lt;String[]&amp;gt; rows = csvReader.readAll();&lt;/code&gt; 로 전체 파일을 한번에 메모리에 올려 사용량이 급증한다.&lt;/li&gt;
      &lt;li&gt;Full GC 가 수초간 발생하여 CPU 점유도 높아진다.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;메모리 생성 최소화&lt;/strong&gt;가 필요하다.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;ba-grid&quot;&gt;
  &lt;div class=&quot;ba-box before&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;Before&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;readAll()&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;전체 파일을 List&amp;lt;String[]&amp;gt; 에 한번에 적재&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ba-box after&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;After&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;readNext()&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;한 줄씩 읽어 메모리 과부하 방지&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      CsvParser.java — Before
    &lt;span class=&quot;role&quot;&gt;readAll() 방식&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;[]&amp;gt; rows = csvReader.readAll(); &lt;span class=&quot;cm&quot;&gt;// ← 전체를 한번에 메모리에 적재&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (rows.isEmpty()) &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; list;

&lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;kw&quot;&gt;int&lt;/span&gt; i = &lt;span class=&quot;num&quot;&gt;1&lt;/span&gt;; i &amp;lt; rows.size(); i++) {
    &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;[] row = rows.get(i);
    &lt;span class=&quot;cm&quot;&gt;// ... DTO 생성&lt;/span&gt;
    list.add(dto);
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      CsvParser.java — After
    &lt;span class=&quot;role&quot;&gt;readNext() 방식 — 한 줄씩 스트리밍&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; list = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;(&lt;span class=&quot;num&quot;&gt;135_000&lt;/span&gt;);

&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;[] row;
&lt;span class=&quot;kw&quot;&gt;long&lt;/span&gt; rowNo = &lt;span class=&quot;num&quot;&gt;0&lt;/span&gt;;

csvReader.readNext(); &lt;span class=&quot;cm&quot;&gt;// 헤더 스킵&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;// readAll() 대신 한 줄씩 읽기&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;while&lt;/span&gt; ((row = csvReader.readNext()) != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
    rowNo++;
    &lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.&lt;span class=&quot;ty&quot;&gt;ParseContext&lt;/span&gt; context = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.&lt;span class=&quot;ty&quot;&gt;ParseContext&lt;/span&gt;();

    &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto = &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;.builder()
        .rowNo(rowNo)
        .docId(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseString(getValue(row, &lt;span class=&quot;num&quot;&gt;0&lt;/span&gt;)))
        .sourceType(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseString(getValue(row, &lt;span class=&quot;num&quot;&gt;1&lt;/span&gt;)))
        .supplierName(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseString(getValue(row, &lt;span class=&quot;num&quot;&gt;2&lt;/span&gt;)))
        .rawItemName(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseString(getValue(row, &lt;span class=&quot;num&quot;&gt;3&lt;/span&gt;)))
        .spec(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseString(getValue(row, &lt;span class=&quot;num&quot;&gt;4&lt;/span&gt;)))
        .unit(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseString(getValue(row, &lt;span class=&quot;num&quot;&gt;5&lt;/span&gt;)))
        .priceBefore(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseLong(getValue(row, &lt;span class=&quot;num&quot;&gt;6&lt;/span&gt;), context))
        .priceAfter(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseLong(getValue(row, &lt;span class=&quot;num&quot;&gt;7&lt;/span&gt;), context))
        .effectiveDate(&lt;span class=&quot;ty&quot;&gt;ParseCsvValueHelper&lt;/span&gt;.parseDate(getValue(row, &lt;span class=&quot;num&quot;&gt;8&lt;/span&gt;), context))
        .hasParseError(context.hasError())
        .build();

    list.add(dto);
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;개선 성능 측정 결과&lt;/h3&gt;

&lt;div class=&quot;log-block&quot;&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-3. 파싱 수행:       &lt;span class=&quot;log-ok&quot;&gt;2376.37 ms&lt;/span&gt;  ← 기존 285,410ms → 약 120배 개선&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt;    5. 총 시간:          &lt;span class=&quot;log-ok&quot;&gt;33170.79 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[파일서비스]&lt;/span&gt; 0-4. itemService 수행: &lt;span class=&quot;log-ok&quot;&gt;33177.03 ms&lt;/span&gt;&lt;br&gt;
&lt;/div&gt;

&lt;div class=&quot;ba-grid&quot;&gt;
  &lt;div class=&quot;ba-box before&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;Before (readAll)&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;약 5 분&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;파싱 단독 3분 포함&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ba-box after&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;After (readNext)&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;약 40 초&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;파싱 단독 2초로 감소&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;img-caption&quot;&gt;개선 후 응답 결과&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0Zfi4/dJMcaiklP7z/nmbSOfvTmlagEtmoaPdpPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0Zfi4/dJMcaiklP7z/nmbSOfvTmlagEtmoaPdpPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0Zfi4/dJMcaiklP7z/nmbSOfvTmlagEtmoaPdpPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0Zfi4%2FdJMcaiklP7z%2FnmbSOfvTmlagEtmoaPdpPK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;h3&gt;추가 개선 — Batch Size 별 파싱 + 저장 (Consumer 방식)&lt;/h3&gt;

&lt;p&gt;리스트에 모든 데이터를 담지 않고 정해진 Batch Size 별로 파싱 후 DB에 저장하는 방식이다.&lt;/p&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      parseAndConsume
    &lt;span class=&quot;role&quot;&gt;1,000 건 단위로 파싱 후 즉시 저장&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;public void&lt;/span&gt; parseAndConsume(&lt;span class=&quot;ty&quot;&gt;MultipartFile&lt;/span&gt; file,
    &lt;span class=&quot;ty&quot;&gt;Consumer&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt;&amp;gt; batchConsumer) {

    &lt;span class=&quot;kw&quot;&gt;int&lt;/span&gt; batchSize = &lt;span class=&quot;num&quot;&gt;1000&lt;/span&gt;;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; buffer = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;(batchSize);

    &lt;span class=&quot;kw&quot;&gt;try&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;CSVReader&lt;/span&gt; csvReader = ...) {
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;[] row;
        &lt;span class=&quot;kw&quot;&gt;long&lt;/span&gt; rowNo = &lt;span class=&quot;num&quot;&gt;0&lt;/span&gt;;
        csvReader.readNext(); &lt;span class=&quot;cm&quot;&gt;// 헤더 스킵&lt;/span&gt;

        &lt;span class=&quot;kw&quot;&gt;while&lt;/span&gt; ((row = csvReader.readNext()) != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
            rowNo++;
            &lt;span class=&quot;cm&quot;&gt;// ... DTO 생성 후 buffer 에 추가&lt;/span&gt;
            buffer.add(dto);

            &lt;span class=&quot;cm&quot;&gt;// 1,000개 모이면 즉시 저장 후 버퍼 비우기&lt;/span&gt;
            &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (buffer.size() &gt;= batchSize) {
                batchConsumer.accept(buffer);
                buffer.clear();
            }
        }

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!buffer.isEmpty()) batchConsumer.accept(buffer);
    }
}

&lt;span class=&quot;cm&quot;&gt;// ── 서비스 레이어에서 사용 ──&lt;/span&gt;
csvParser.parseAndConsume(file, batchList -&gt; {
    documentRepository.batchInsert(batchList); &lt;span class=&quot;cm&quot;&gt;// JdbcTemplate.batchUpdate 실행&lt;/span&gt;
});&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;callout purple&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    위 Consumer 방법을 사용하려면 현재 &lt;code&gt;List&amp;lt;&amp;gt;&lt;/code&gt; 에 모든 입력 데이터를 담고 중복 로직을 수행하는 방식을 함께 수정해야 적용이 가능하다.
    &lt;strong&gt;List 기반 중복 로직을 개선해 나가면서 적용해볼 예정이다.&lt;/strong&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  5. 앞으로 개선 사항                       --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;5. 앞으로 개선 사항&lt;/h2&gt;

&lt;div class=&quot;log-block&quot;&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 1. 검증 및 매핑:          &lt;span class=&quot;log-warn&quot;&gt;4380.66 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 2. 연관 관계 구성:        &lt;span class=&quot;log-warn&quot;&gt;3947.84 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 3. 이상 탐지 및 이슈 수집: &lt;span class=&quot;log-warn&quot;&gt;6501.81 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 4. DB 커밋:              &lt;span class=&quot;log-warn&quot;&gt;18339.77 ms&lt;/span&gt;&lt;br&gt;
&lt;span class=&quot;log-key&quot;&gt;[서비스]&lt;/span&gt; 5. 총 시간:              &lt;span class=&quot;log-warn&quot;&gt;33170.79 ms&lt;/span&gt;&lt;br&gt;
&lt;/div&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    파싱 개선 이후에도 10만 건 기준 전체 입력 시간이 &lt;strong&gt;약 40초&lt;/strong&gt; 정도 걸린다.
    이 응답 시간은 추가 개선이 필요하며, 계속해서 개선을 진행할 예정이다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  참고                                      --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;참고&lt;/h2&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    처음에는 입력 데이터 간의 중복 탐지를 &lt;strong&gt;DB 임시 테이블에 저장 후 탐지&lt;/strong&gt;하는 방법을 고려했다.&lt;br&gt;
    하지만 아래 문제가 발생할 수 있다고 판단하여 적용하지 않았다.
    &lt;ol style=&quot;margin-top:8px&quot;&gt;
      &lt;li&gt;변하지 않아야 하는 DB 데이터의 &lt;strong&gt;무결성이 깨진다.&lt;/strong&gt;&lt;/li&gt;
      &lt;li&gt;여러 클라이언트가 동시에 파일을 올릴 경우 데이터들 간의 &lt;strong&gt;정합성이 깨질 수 있다.&lt;/strong&gt;&lt;/li&gt;
    &lt;/ol&gt;
    따라서 입력 데이터 간 중복 탐지는 &lt;strong&gt;애플리케이션 메모리 레벨에서만 처리&lt;/strong&gt;하는 방향으로 개선하였다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>프로젝트/보살핌 프로젝트</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/11</guid>
      <comments>https://twdev.tistory.com/11#entry11comment</comments>
      <pubDate>Thu, 3 Sep 2026 12:41:53 +0900</pubDate>
    </item>
    <item>
      <title>2. 입력 최적화</title>
      <link>https://twdev.tistory.com/10</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Noto Sans KR', sans-serif;
    background: #ffffff;
    color: #37352f;
    line-height: 1.75;
    font-size: 15px;
    max-width: 7800px;
    margin: 0 auto;
    padding: 48px 24px 80px;
  }

  .page-title {
    font-size: 28px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 8px;
    line-height: 1.3;
  }
  .page-subtitle {
    font-size: 14px;
    color: #9b9b9b;
    margin-bottom: 40px;
  }

  h2 {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a1a;
    margin: 52px 0 16px;
    padding-bottom: 8px;
    border-bottom: 2px solid #f0f0f0;
  }
  h3 {
    font-size: 16px;
    font-weight: 600;
    color: #37352f;
    margin: 36px 0 12px;
  }
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #37352f;
    margin: 28px 0 10px;
  }

  .overview-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 16px 0 28px;
    font-size: 14px;
    color: #555;
    line-height: 1.85;
  }
  .overview-card strong { color: #37352f; }

  .file-label {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: #f0f0f0;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: #555;
    margin-bottom: 6px;
    font-family: 'JetBrains Mono', monospace;
  }
  .file-label::before { content: ' '; font-size: 11px; }

  .code-block {
    background: #1e1e2e;
    border-radius: 8px;
    padding: 20px;
    margin: 0 0 24px;
    overflow-x: auto;
    position: relative;
  }
  .code-block .lang-tag {
    position: absolute;
    top: 10px;
    right: 14px;
    font-size: 10px;
    color: #6c7086;
    font-family: 'JetBrains Mono', monospace;
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }
  .code-block pre {
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    line-height: 1.7;
    color: #cdd6f4;
    white-space: pre;
  }
  .code-block .kw  { color: #cba6f7; }
  .code-block .an  { color: #89b4fa; }
  .code-block .str { color: #a6e3a1; }
  .code-block .cm  { color: #6c7086; font-style: italic; }
  .code-block .ty  { color: #f9e2af; }
  .code-block .num { color: #fab387; }

  .callout {
    display: flex;
    gap: 12px;
    background: #f0f7ff;
    border-left: 4px solid #4a90e2;
    border-radius: 0 8px 8px 0;
    padding: 14px 16px;
    margin: 16px 0 24px;
    font-size: 14px;
    color: #37352f;
    line-height: 1.8;
  }
  .callout .icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
  .callout ul, .callout ol { padding-left: 20px; margin-top: 6px; }
  .callout li { margin-bottom: 4px; }
  .callout strong { color: #1a5fa8; }
  .callout code {
    background: #dbeafe;
    padding: 1px 5px;
    border-radius: 3px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #1d4ed8;
  }
  .callout.green  { background: #f0fdf4; border-left-color: #22c55e; }
  .callout.green strong { color: #15803d; }
  .callout.green code   { background: #dcfce7; color: #166534; }
  .callout.yellow { background: #fffbeb; border-left-color: #f59e0b; }
  .callout.yellow strong { color: #92400e; }
  .callout.gray   { background: #f7f6f3; border-left-color: #9ca3af; }
  .callout.purple { background: #faf5ff; border-left-color: #9333ea; }
  .callout.purple strong { color: #7c3aed; }
  .callout.red    { background: #fff1f2; border-left-color: #f43f5e; }
  .callout.red strong   { color: #be123c; }

  blockquote {
    border-left: 3px solid #e0e0e0;
    padding: 4px 16px;
    margin: 16px 0 20px;
    color: #6b7280;
    font-size: 14px;
    line-height: 1.8;
  }

  hr {
    border: none;
    border-top: 1px solid #f0f0f0;
    margin: 44px 0;
  }

  p { margin: 12px 0; font-size: 15px; color: #37352f; }
  ul, ol { padding-left: 22px; margin: 10px 0 16px; }
  li { margin-bottom: 6px; font-size: 14px; color: #37352f; }

  code {
    background: #f0f0f0;
    padding: 1px 5px;
    border-radius: 4px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #d44000;
  }

  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 20px;
    font-size: 11.5px;
    font-weight: 600;
    margin-right: 4px;
  }
  .badge-blue   { background: #dbeafe; color: #1d4ed8; }
  .badge-green  { background: #dcfce7; color: #166534; }
  .badge-purple { background: #ede9fe; color: #7c3aed; }
  .badge-orange { background: #fff7ed; color: #c2410c; }
  .badge-red    { background: #fff1f2; color: #be123c; }
  .badge-gray   { background: #f1f5f9; color: #475569; }

  .arrow-note {
    font-size: 13.5px;
    color: #4a90e2;
    margin: 12px 0 20px;
    font-weight: 500;
  }
  .arrow-note::before { content: '⇒ '; }

  /* ── 방법 비교 카드 그리드 ── */
  .method-compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
    margin: 16px 0 28px;
  }
  .method-compare-card {
    border-radius: 10px;
    padding: 16px 18px;
    font-size: 13.5px;
    line-height: 1.8;
    border: 1px solid transparent;
  }
  .method-compare-card .mc-title {
    font-size: 14px;
    font-weight: 700;
    margin-bottom: 10px;
    display: flex;
    align-items: center;
    gap: 6px;
  }
  .method-compare-card.bulk {
    background: #f0f7ff;
    border-color: #bfdbfe;
  }
  .method-compare-card.bulk .mc-title { color: #1d4ed8; }
  .method-compare-card.infile {
    background: #faf5ff;
    border-color: #e9d5ff;
  }
  .method-compare-card.infile .mc-title { color: #7c3aed; }
  .mc-row {
    display: flex;
    gap: 6px;
    margin-bottom: 4px;
    font-size: 13px;
  }
  .mc-row .mc-label {
    font-weight: 600;
    flex-shrink: 0;
    color: #6b7280;
    font-size: 11px;
    padding-top: 2px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
  }

  /* ── 성능 비교 표 ── */
  .perf-table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0 24px;
    font-size: 13.5px;
  }
  .perf-table th {
    background: #f7f6f3;
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    border: 1px solid #e8e8e8;
    color: #37352f;
  }
  .perf-table td {
    padding: 9px 14px;
    border: 1px solid #e8e8e8;
    color: #555;
    vertical-align: middle;
  }
  .perf-table tr:nth-child(even) td { background: #fafafa; }
  .ok     { color: #16a34a; font-weight: 600; }
  .warn   { color: #d97706; font-weight: 600; }
  .danger { color: #dc2626; font-weight: 600; }

  /* ── Before / After 비교 박스 ── */
  .ba-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
    margin: 14px 0 24px;
  }
  .ba-box {
    border-radius: 8px;
    padding: 12px 14px;
    font-size: 13px;
  }
  .ba-box .ba-label {
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    margin-bottom: 6px;
  }
  .ba-box.before {
    background: #fff1f2;
    border: 1px solid #fecdd3;
  }
  .ba-box.before .ba-label { color: #be123c; }
  .ba-box.after {
    background: #f0fdf4;
    border: 1px solid #bbf7d0;
  }
  .ba-box.after .ba-label { color: #15803d; }
  .ba-box .ba-value {
    font-size: 22px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 2px;
  }
  .ba-box .ba-desc {
    font-size: 12px;
    color: #6b7280;
  }

  /* ── 이미지 슬롯 ── */
  .img-caption {
    font-size: 12.5px;
    color: #9ca3af;
    margin-bottom: 4px;
  }

  /* ── NEXT TO DO 리스트 ── */
  .todo-list {
    list-style: none;
    padding: 0;
    margin: 16px 0;
  }
  .todo-list li {
    display: flex;
    gap: 10px;
    padding: 10px 0;
    border-bottom: 1px solid #f0f0f0;
    font-size: 14px;
    color: #37352f;
    align-items: flex-start;
  }
  .todo-list li:last-child { border-bottom: none; }
  .todo-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #4a90e2;
    flex-shrink: 0;
    margin-top: 6px;
  }

  /* ── 메서드 카드 (코드 래퍼) ── */
  .method-card {
    border: 1px solid #e8e8e8;
    border-radius: 10px;
    margin: 20px 0;
    overflow: hidden;
  }
  .method-card-header {
    background: #f7f6f3;
    padding: 10px 16px;
    font-size: 13px;
    font-weight: 700;
    color: #37352f;
    display: flex;
    align-items: center;
    gap: 8px;
    border-bottom: 1px solid #e8e8e8;
  }
  .method-card-header .role {
    font-size: 11.5px;
    font-weight: 500;
    color: #9ca3af;
    margin-left: auto;
  }
  .method-card-body {
    padding: 14px 16px;
  }
  .method-card-body .code-block { margin-bottom: 0; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;!-- ── 타이틀 ── --&gt;
&lt;div class=&quot;page-title&quot;&gt;Bulk Insert 적용으로 대용량 입력 성능 개선하기&lt;/div&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  1. 개요                                   --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;1. 개요&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  이전 포스트에서 본 것과 같이 수많은 row 를 가진 파일이 입력되는 경우,
  해당 row 개수 만큼 INSERT 문이 실행되어 &lt;strong&gt;입력에 시간이 매우 오래 걸리는 상황&lt;/strong&gt;이 발생했다.&lt;br&gt;&lt;br&gt;
  이를 해결하기 위해 노력한 과정을 공유하고자 한다.
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  2. 현재 상황 살펴보기                     --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;2. 현재 상황 살펴보기&lt;/h2&gt;

&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;&amp;gt; newGroupsToSave = itemsToSave.stream()
    .map(&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;::getDuplicatedGroup)
    .filter(group -&gt; group != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt; &amp;&amp; group.getId() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;)
    .distinct()
    .toList();

&lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!newGroupsToSave.isEmpty()) {
    duplicatedGroupRepository.saveAll(newGroupsToSave);
}

&lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!existingItemsToUpdate.isEmpty()) {
    itemRepository.saveAll(existingItemsToUpdate);
}

&lt;span class=&quot;cm&quot;&gt;// ← 이 부분!&lt;/span&gt;
&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; savedItems = itemRepository.saveAll(itemsToSave);

&lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!issues.isEmpty()) {
    issueRepository.saveAll(issues);
}

&lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; savedItems;&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;
  &lt;code&gt;saveAll()&lt;/code&gt; 은 한번에 저장하는 것처럼 보이지만,
  사실 리스트 내의 값을 &lt;strong&gt;하나씩 읽어와서 각각 INSERT&lt;/strong&gt; 한다.
&lt;/blockquote&gt;

&lt;p&gt;또한 해당 리스트 내부의 값은 아래 과정을 거치고 저장된다.&lt;/p&gt;

&lt;div class=&quot;code-block&quot;&gt;
  &lt;pre&gt;[파싱] → [컬렉션 메모리에 임시 저장] → [이상 탐지] → [DB insert]&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;10만 개와 같은 대용량 데이터에 대해 위 과정을 모두 적용한다면 적지 않은 메모리 사용과 시간이 소요될 것이다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  3. saveAll() 개선하기                     --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;3. saveAll() 개선하기&lt;/h2&gt;

&lt;p&gt;부분부분 개선이 가능한 부분부터 접근한다. &lt;code&gt;saveAll()&lt;/code&gt; 은 리스트 내부의 모든 요소에 대해 INSERT 문을 실행하기 때문에 이를 수정해서 성능을 개선해보고자 한다.&lt;/p&gt;

&lt;div class=&quot;method-compare&quot;&gt;
  &lt;div class=&quot;method-compare-card bulk&quot;&gt;
    &lt;div class=&quot;mc-title&quot;&gt;⚡ Bulk Insert&lt;/div&gt;
    &lt;div class=&quot;mc-row&quot;&gt;
      &lt;span class=&quot;mc-label&quot;&gt;개념&lt;/span&gt;
      &lt;span&gt;여러 행을 한 번의 INSERT 문으로 삽입&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;mc-row&quot;&gt;
      &lt;span class=&quot;mc-label&quot;&gt;장점&lt;/span&gt;
      &lt;span&gt;트랜잭션 수 감소, 리소스 사용 최적화&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;mc-row&quot;&gt;
      &lt;span class=&quot;mc-label&quot;&gt;단점&lt;/span&gt;
      &lt;span&gt;트랜잭션이 매우 커짐. 오류 발생 시 전체 삽입 실패 가능&lt;/span&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-compare-card infile&quot;&gt;
    &lt;div class=&quot;mc-title&quot;&gt; ️ LOAD DATA INFILE&lt;/div&gt;
    &lt;div class=&quot;mc-row&quot;&gt;
      &lt;span class=&quot;mc-label&quot;&gt;개념&lt;/span&gt;
      &lt;span&gt;외부 CSV 파일을 MySQL 테이블에 직접 로드&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;mc-row&quot;&gt;
      &lt;span class=&quot;mc-label&quot;&gt;장점&lt;/span&gt;
      &lt;span&gt;Bulk Insert 보다 빠름. 앱 메모리 거의 미사용 (OOM 없음)&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;mc-row&quot;&gt;
      &lt;span class=&quot;mc-label&quot;&gt;단점&lt;/span&gt;
      &lt;span&gt;트랜잭션 관리 어려움. 비즈니스 로직 적용 불가. 파일 형식 종속&lt;/span&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;의사 결정&lt;/strong&gt;&lt;br&gt;
    현재 프로젝트에서는 들어오는 데이터에 대해 &lt;strong&gt;비즈니스 로직을 수행함과 동시에&lt;/strong&gt;
    입력 시간 단축 및 메모리 절약도 필요하기 때문에 &lt;strong&gt;Bulk Insert&lt;/strong&gt; 를 적용해보기로 했다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;callout purple&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;추가 고려 사항&lt;/strong&gt;&lt;br&gt;
    CSV 파일의 경우는 LOAD DATA INFILE 로 미리 데이터를 받고,
    받은 데이터를 기반으로 비즈니스 로직을 수행하는 것도 고려해봤다.&lt;br&gt;
    수행 시 CSV 입력은 별도 서비스 코드로 분리될 것으로 예상된다. &lt;span class=&quot;badge badge-gray&quot;&gt;다른 branch 작업 예정&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  4. Bulk Insert 적용                       --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;4. Bulk Insert 적용해보기&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;테이블 간 여러 관계가 존재하기 때문에 이 또한 함께 고려해야 한다.&lt;/li&gt;
  &lt;li&gt;JDBC 와 JPA 를 같이 쓰게 되면 &lt;strong&gt;영속성 관리&lt;/strong&gt;에 더 신경을 써야 한다.&lt;/li&gt;
  &lt;li&gt;Item 의 자식 테이블인 Issue 역시 JDBC 를 사용해야 정상 적용이 된다.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      ItemBulkRepository.java
    &lt;span class=&quot;role&quot;&gt;JDBC batchUpdate — Item 벌크 입력&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Repository&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ItemBulkRepository&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;JdbcTemplate&lt;/span&gt; jdbcTemplate;

    &lt;span class=&quot;an&quot;&gt;@Transactional&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;public void&lt;/span&gt; saveAllItemsInBatch(&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; items, &lt;span class=&quot;kw&quot;&gt;int&lt;/span&gt; batchSize) {

        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; sql =
            &lt;span class=&quot;str&quot;&gt;&quot;INSERT INTO `items` (\n&quot;&lt;/span&gt;
            + &lt;span class=&quot;str&quot;&gt;&quot;  `effective_date`, `duplicated_group_id`, `file_id`, `price_after`, `price_before`,\n&quot;&lt;/span&gt;
            + &lt;span class=&quot;str&quot;&gt;&quot;  `row_no`, `doc_id`, `normalized_item_name`, `raw_item_name`, `spec`,\n&quot;&lt;/span&gt;
            + &lt;span class=&quot;str&quot;&gt;&quot;  `supplier_name`, `unit`, `review_status`, `source_type`, `created_at`\n&quot;&lt;/span&gt;
            + &lt;span class=&quot;str&quot;&gt;&quot;) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())&quot;&lt;/span&gt;;

        jdbcTemplate.batchUpdate(sql, items, batchSize,
            (&lt;span class=&quot;ty&quot;&gt;PreparedStatement&lt;/span&gt; ps, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; item) -&gt; {
                ps.setObject(&lt;span class=&quot;num&quot;&gt;1&lt;/span&gt;, item.getEffectiveDate()); &lt;span class=&quot;cm&quot;&gt;// LocalDate → setObject&lt;/span&gt;

                &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (item.getDuplicatedGroup() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;)
                    ps.setLong(&lt;span class=&quot;num&quot;&gt;2&lt;/span&gt;, item.getDuplicatedGroup().getId());
                &lt;span class=&quot;kw&quot;&gt;else&lt;/span&gt;
                    ps.setNull(&lt;span class=&quot;num&quot;&gt;2&lt;/span&gt;, java.sql.Types.BIGINT);

                &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (item.getFile() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;)
                    ps.setLong(&lt;span class=&quot;num&quot;&gt;3&lt;/span&gt;, item.getFile().getId());
                &lt;span class=&quot;kw&quot;&gt;else&lt;/span&gt;
                    ps.setNull(&lt;span class=&quot;num&quot;&gt;3&lt;/span&gt;, java.sql.Types.BIGINT);

                ps.setLong(&lt;span class=&quot;num&quot;&gt;4&lt;/span&gt;,  item.getPriceAfter());
                ps.setLong(&lt;span class=&quot;num&quot;&gt;5&lt;/span&gt;,  item.getPriceBefore());
                ps.setLong(&lt;span class=&quot;num&quot;&gt;6&lt;/span&gt;,  item.getRowNo());
                ps.setString(&lt;span class=&quot;num&quot;&gt;7&lt;/span&gt;,  item.getDocId());
                ps.setString(&lt;span class=&quot;num&quot;&gt;8&lt;/span&gt;,  item.getNormalizedItemName());
                ps.setString(&lt;span class=&quot;num&quot;&gt;9&lt;/span&gt;,  item.getRawItemName());
                ps.setString(&lt;span class=&quot;num&quot;&gt;10&lt;/span&gt;, item.getSpec());
                ps.setString(&lt;span class=&quot;num&quot;&gt;11&lt;/span&gt;, item.getSupplierName());
                ps.setString(&lt;span class=&quot;num&quot;&gt;12&lt;/span&gt;, item.getUnit());
                ps.setString(&lt;span class=&quot;num&quot;&gt;13&lt;/span&gt;, item.getReviewStatus() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;
                    ? item.getReviewStatus().name() : &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;);  &lt;span class=&quot;cm&quot;&gt;// Enum → setString&lt;/span&gt;
                ps.setString(&lt;span class=&quot;num&quot;&gt;14&lt;/span&gt;, item.getSourceType() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;
                    ? item.getSourceType().name() : &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;);
            }
        );
    }
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;blockquote&gt;
  &lt;code&gt;LocalDate&lt;/code&gt; 타입은 &lt;code&gt;setObject&lt;/code&gt; 로, Enum 타입은 &lt;code&gt;setString&lt;/code&gt; 으로 설정이 가능하다.&lt;br&gt;
  &lt;code&gt;created_at&lt;/code&gt; 을 직접 설정한 이유는, JDBC 로 입력하는 단계에서는 JPA 영속성 컨텍스트에 포함되지 않기 때문이다.
&lt;/blockquote&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;⚠️&lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;Issue 도 함께 JDBC Bulk Insert 를 적용해야 한다.&lt;/strong&gt;&lt;br&gt;
    Item 을 JDBC 로 입력하면 JPA 영속성 컨텍스트의 관리를 받지 않기 때문에,
    &lt;strong&gt;영속성 전이 (Cascade) 나 자동 FK 매핑을 사용할 수 없다.&lt;/strong&gt;&lt;br&gt;
    따라서 자식 객체인 Issue 또한 동일하게 JDBC Bulk Insert 방식으로 &lt;strong&gt;직접 부모 ID 를 참조시켜 저장&lt;/strong&gt;해야 한다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  5. 성능 테스트                            --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;5. 성능 테스트&lt;/h2&gt;

&lt;h3&gt;5-1. 응답 시간 테스트&lt;/h3&gt;

&lt;h4&gt;1,000 row 파일 입력&lt;/h4&gt;

&lt;div class=&quot;ba-grid&quot;&gt;
  &lt;div class=&quot;ba-box before&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;Before (JPA saveAll)&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;8 s&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;1,000 row 신규 입력&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ba-box after&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;After (Bulk Insert, batch=1000)&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;2 s&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;동일 파일 — 400% 속도 개선&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;img-caption&quot;&gt;Before&lt;/p&gt;
    &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l2v0B/dJMcahMpy5w/TUdQOqpBxugDhYzpTh5RAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l2v0B/dJMcahMpy5w/TUdQOqpBxugDhYzpTh5RAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l2v0B/dJMcahMpy5w/TUdQOqpBxugDhYzpTh5RAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl2v0B%2FdJMcahMpy5w%2FTUdQOqpBxugDhYzpTh5RAk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;img-caption&quot;&gt;After&lt;/p&gt;
  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Q0idC/dJMcaa0QEcQ/IVuJ4pApBAkonuJ3IOm4s1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Q0idC/dJMcaa0QEcQ/IVuJ4pApBAkonuJ3IOm4s1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Q0idC/dJMcaa0QEcQ/IVuJ4pApBAkonuJ3IOm4s1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQ0idC%2FdJMcaa0QEcQ%2FIVuJ4pApBAkonuJ3IOm4s1%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;div class=&quot;callout red&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;트러블 슈팅 : 동일 데이터를 한 번 더 넣으면 서버 에러 발생&lt;/strong&gt;&lt;br&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-top: 10px; margin-bottom: 8px;&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!existingItemsToUpdate.isEmpty()) {
    itemBulkRepository.saveAllInBatch(existingItemsToUpdate); &lt;span class=&quot;cm&quot;&gt;// ← 문제 지점&lt;/span&gt;
}&lt;/pre&gt;
    &lt;/div&gt;
    &lt;code&gt;existingItemsToUpdate&lt;/code&gt; 는 원래 JPA 변경 감지(Dirty Checking) 로 &lt;strong&gt;UPDATE&lt;/strong&gt; 되어야 하는 항목인데,
    &lt;code&gt;saveAllInBatch&lt;/code&gt; 는 &lt;strong&gt;INSERT&lt;/strong&gt; 를 실행하기 때문에 기존 데이터를 또 생성하려다 에러가 발생한 것이다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;h4&gt;10만 row 파일 입력&lt;/h4&gt;

&lt;table class=&quot;perf-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;조건&lt;/th&gt;
      &lt;th&gt;소요 시간&lt;/th&gt;
      &lt;th&gt;비고&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Before (JPA saveAll)&lt;/td&gt;
      &lt;td class=&quot;danger&quot;&gt;1시간 이상&lt;/td&gt;
      &lt;td&gt;측정 불가&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;After (batch = 1,000)&lt;/td&gt;
      &lt;td class=&quot;warn&quot;&gt;약 3 분&lt;/td&gt;
      &lt;td&gt;결과 확인 가능 수준&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;After (batch = 5,000)&lt;/td&gt;
      &lt;td class=&quot;ok&quot;&gt;약 2 분&lt;/td&gt;
      &lt;td&gt;1분 단축 — 단, 메모리 부하 및 롤백 어려움 증가&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

  &lt;br&gt;
&lt;p class=&quot;img-caption&quot;&gt;batch = 1,000&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dPeV6K/dJMcaf8PVmV/Ou5oy59f1JmqqsN9XMHy3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dPeV6K/dJMcaf8PVmV/Ou5oy59f1JmqqsN9XMHy3k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dPeV6K/dJMcaf8PVmV/Ou5oy59f1JmqqsN9XMHy3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdPeV6K%2FdJMcaf8PVmV%2FOu5oy59f1JmqqsN9XMHy3k%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

    &lt;br&gt;
&lt;p class=&quot;img-caption&quot;&gt;batch = 5,000&lt;/p&gt;
  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chwkXf/dJMcafVtcmC/0oGSDpRDuAEZoiq3iVvfdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chwkXf/dJMcafVtcmC/0oGSDpRDuAEZoiq3iVvfdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chwkXf/dJMcafVtcmC/0oGSDpRDuAEZoiq3iVvfdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchwkXf%2FdJMcafVtcmC%2F0oGSDpRDuAEZoiq3iVvfdk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  
&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    정상적인 JDBC 설정이면 10만 건은 약 5초 내에 완료되어야 한다고 한다.&lt;br&gt;
    현재 시간이 오래 걸리는 원인은 &lt;strong&gt;각 row 에 대한 중복 검사 로직&lt;/strong&gt; 때문이며, 이 또한 개선해야 할 사항이다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;h3&gt;5-2. 메모리 측정 (VisualVM)&lt;/h3&gt;

&lt;h4&gt;1,000 row 샘플 (Batch Size 5,000)&lt;/h4&gt;

&lt;div class=&quot;ba-grid&quot;&gt;
  &lt;div class=&quot;ba-box before&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;Before&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;최대 150 MB&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;Heap 점유&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ba-box after&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;After&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;최대 125 MB&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;Heap 점유&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;img-caption&quot;&gt;Before&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/niVCp/dJMcahFDH0Z/f4sCvuIbObK31K82kuMKzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/niVCp/dJMcahFDH0Z/f4sCvuIbObK31K82kuMKzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/niVCp/dJMcahFDH0Z/f4sCvuIbObK31K82kuMKzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FniVCp%2FdJMcahFDH0Z%2Ff4sCvuIbObK31K82kuMKzK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;br&gt;
&lt;p class=&quot;img-caption&quot;&gt;After&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ax5Db/dJMcabyEo9p/aWfgY41b0dVOTexnhuNcZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ax5Db/dJMcabyEo9p/aWfgY41b0dVOTexnhuNcZK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ax5Db/dJMcabyEo9p/aWfgY41b0dVOTexnhuNcZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAx5Db%2FdJMcabyEo9p%2FaWfgY41b0dVOTexnhuNcZK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  
&lt;p class=&quot;arrow-note&quot;&gt;최대 Heap 점유 용량과 점유 시간 모두 확실히 유의미한 개선 효과가 보인다.&lt;/p&gt;
&lt;p class=&quot;arrow-note&quot;&gt;Batch Size 가 크면 메모리도 많이 점유하기 때문에 적절한 조절이 필요하다.&lt;/p&gt;

&lt;h4&gt;10만 row 샘플 (Batch Size 5,000)&lt;/h4&gt;

&lt;div class=&quot;ba-grid&quot;&gt;
  &lt;div class=&quot;ba-box before&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;Before (측정 중단)&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;최대 650 MB&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;Heap 점유&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ba-box after&quot;&gt;
    &lt;div class=&quot;ba-label&quot;&gt;After&lt;/div&gt;
    &lt;div class=&quot;ba-value&quot;&gt;최대 350 MB&lt;/div&gt;
    &lt;div class=&quot;ba-desc&quot;&gt;Heap 점유 — &lt;strong&gt;약 46% 감소&lt;/strong&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;img-caption&quot;&gt;Before&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wpnUF/dJMcaidDgiI/kco8EklPm9KC2lkzUvKXu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wpnUF/dJMcaidDgiI/kco8EklPm9KC2lkzUvKXu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wpnUF/dJMcaidDgiI/kco8EklPm9KC2lkzUvKXu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwpnUF%2FdJMcaidDgiI%2Fkco8EklPm9KC2lkzUvKXu0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;br&gt;
&lt;p class=&quot;img-caption&quot;&gt;After&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eqwdXv/dJMcaidDgjj/cjb8iHbKfh0WLRJVR3eosk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eqwdXv/dJMcaidDgjj/cjb8iHbKfh0WLRJVR3eosk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eqwdXv/dJMcaidDgjj/cjb8iHbKfh0WLRJVR3eosk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeqwdXv%2FdJMcaidDgjj%2Fcjb8iHbKfh0WLRJVR3eosk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;p class=&quot;arrow-note&quot;&gt;메모리 점유 사용률이 약 46% 감소했다.&lt;/p&gt;
&lt;p class=&quot;arrow-note&quot;&gt;특정 구간에서 사용량이 올라간 것은 중복 탐지 로직의 Java Collection 사용으로 인한 현상으로 보인다.&lt;/p&gt;

&lt;div class=&quot;callout green&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    이렇게 메모리 테스트도 진행해 보았다.&lt;br&gt;
    비록 현업에서는 더 고도화되고 체계적으로 테스트하겠지만,
    &lt;strong&gt;성능 측정에 있어서도 더 배우고 개선해 나가는 개발자&lt;/strong&gt;로 성장하고 싶다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  NEXT TO DO                               --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;NEXT TO DO&lt;/h2&gt;

&lt;ul class=&quot;todo-list&quot;&gt;
  &lt;li&gt;
    &lt;div class=&quot;todo-dot&quot;&gt;&lt;/div&gt;
    &lt;div&gt;중복 검사 로직이 병목 원인임을 확인했으므로, &lt;strong&gt;다음 포스트에서 이를 개선&lt;/strong&gt;할 예정이다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;div class=&quot;todo-dot&quot;&gt;&lt;/div&gt;
    &lt;div&gt;Bulk Insert 를 처음 적용해보았지만 아직 부족한 점이 많아 &lt;strong&gt;추가 학습이 필요&lt;/strong&gt;하다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;div class=&quot;todo-dot&quot;&gt;&lt;/div&gt;
    &lt;div&gt;대용량 처리 프레임워크인 &lt;strong&gt;Spark, Hadoop, Kafka&lt;/strong&gt; 와 같은 기술도 공부해보면 좋을 것 같다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;div class=&quot;todo-dot&quot;&gt;&lt;/div&gt;
    &lt;div&gt;JPA 와 JDBC 를 함께 쓸 때 &lt;strong&gt;영속성 관리 측면에서 신경 써야 할 점&lt;/strong&gt;이 많다는 것을 느꼈고, 관련 학습이 필요하다.&lt;/div&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>프로젝트/보살핌 프로젝트</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/10</guid>
      <comments>https://twdev.tistory.com/10#entry10comment</comments>
      <pubDate>Mon, 31 Aug 2026 14:05:02 +0900</pubDate>
    </item>
    <item>
      <title>1. 이상 탐지 리팩토링</title>
      <link>https://twdev.tistory.com/9</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Noto Sans KR', sans-serif;
    background: #ffffff;
    color: #37352f;
    line-height: 1.75;
    font-size: 15px;
    max-width: 7800px;
    margin: 0 auto;
    padding: 48px 24px 80px;
  }

  /* ── 헤더 ── */
  .page-title {
    font-size: 28px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 8px;
    line-height: 1.3;
  }
  .page-subtitle {
    font-size: 14px;
    color: #9b9b9b;
    margin-bottom: 40px;
  }

  /* ── 섹션 헤더 ── */
  h2 {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a1a;
    margin: 52px 0 16px;
    padding-bottom: 8px;
    border-bottom: 2px solid #f0f0f0;
  }
  h3 {
    font-size: 16px;
    font-weight: 600;
    color: #37352f;
    margin: 36px 0 12px;
  }
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #37352f;
    margin: 28px 0 10px;
  }

  /* ── 개요 카드 ── */
  .overview-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 16px 0 28px;
    font-size: 14px;
    color: #555;
    line-height: 1.85;
  }
  .overview-card strong { color: #37352f; }

  /* ── 파일 라벨 ── */
  .file-label {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: #f0f0f0;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: #555;
    margin-bottom: 6px;
    font-family: 'JetBrains Mono', monospace;
  }
  .file-label::before { content: ' '; font-size: 11px; }

  /* ── 코드 블록 ── */
  .code-block {
    background: #1e1e2e;
    border-radius: 8px;
    padding: 20px;
    margin: 0 0 24px;
    overflow-x: auto;
    position: relative;
  }
  .code-block .lang-tag {
    position: absolute;
    top: 10px;
    right: 14px;
    font-size: 10px;
    color: #6c7086;
    font-family: 'JetBrains Mono', monospace;
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }
  .code-block pre {
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    line-height: 1.7;
    color: #cdd6f4;
    white-space: pre;
  }
  .code-block .kw  { color: #cba6f7; }
  .code-block .an  { color: #89b4fa; }
  .code-block .str { color: #a6e3a1; }
  .code-block .cm  { color: #6c7086; font-style: italic; }
  .code-block .ty  { color: #f9e2af; }
  .code-block .num { color: #fab387; }

  /* ── 콜아웃 ── */
  .callout {
    display: flex;
    gap: 12px;
    background: #f0f7ff;
    border-left: 4px solid #4a90e2;
    border-radius: 0 8px 8px 0;
    padding: 14px 16px;
    margin: 16px 0 24px;
    font-size: 14px;
    color: #37352f;
    line-height: 1.8;
  }
  .callout .icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
  .callout ul, .callout ol { padding-left: 20px; margin-top: 6px; }
  .callout li { margin-bottom: 4px; }
  .callout strong { color: #1a5fa8; }
  .callout code {
    background: #dbeafe;
    padding: 1px 5px;
    border-radius: 3px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #1d4ed8;
  }
  .callout.green  { background: #f0fdf4; border-left-color: #22c55e; }
  .callout.green strong { color: #15803d; }
  .callout.green code   { background: #dcfce7; color: #166534; }
  .callout.yellow { background: #fffbeb; border-left-color: #f59e0b; }
  .callout.yellow strong { color: #92400e; }
  .callout.gray   { background: #f7f6f3; border-left-color: #9ca3af; }
  .callout.purple { background: #faf5ff; border-left-color: #9333ea; }
  .callout.purple strong { color: #7c3aed; }
  .callout.red    { background: #fff1f2; border-left-color: #f43f5e; }
  .callout.red strong   { color: #be123c; }

  /* ── 인용 ── */
  blockquote {
    border-left: 3px solid #e0e0e0;
    padding: 4px 16px;
    margin: 16px 0 20px;
    color: #6b7280;
    font-size: 14px;
    line-height: 1.8;
  }

  /* ── 구분선 ── */
  hr {
    border: none;
    border-top: 1px solid #f0f0f0;
    margin: 44px 0;
  }

  /* ── 일반 텍스트 ── */
  p { margin: 12px 0; font-size: 15px; color: #37352f; }
  ul, ol { padding-left: 22px; margin: 10px 0 16px; }
  li { margin-bottom: 6px; font-size: 14px; color: #37352f; }

  code {
    background: #f0f0f0;
    padding: 1px 5px;
    border-radius: 4px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #d44000;
  }

  /* ── 배지 ── */
  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 20px;
    font-size: 11.5px;
    font-weight: 600;
    margin-right: 4px;
  }
  .badge-blue   { background: #dbeafe; color: #1d4ed8; }
  .badge-green  { background: #dcfce7; color: #166534; }
  .badge-purple { background: #ede9fe; color: #7c3aed; }
  .badge-orange { background: #fff7ed; color: #c2410c; }
  .badge-red    { background: #fff1f2; color: #be123c; }
  .badge-gray   { background: #f1f5f9; color: #475569; }

  /* ── arrow note ── */
  .arrow-note {
    font-size: 13.5px;
    color: #4a90e2;
    margin: 12px 0 20px;
    font-weight: 500;
  }
  .arrow-note::before { content: '⇒ '; }

  /* ── 이상 탐지 목록 태그 행 ── */
  .tag-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin: 12px 0 24px;
  }
  .tag {
    background: #f7f6f3;
    border: 1px solid #e5e7eb;
    border-radius: 6px;
    padding: 5px 12px;
    font-size: 13px;
    color: #37352f;
    font-weight: 500;
  }

  /* ── 번호 단계 설명 ── */
  .step-list {
    list-style: none;
    padding: 0;
    margin: 12px 0 24px;
  }
  .step-list li {
    display: flex;
    gap: 12px;
    padding: 10px 0;
    border-bottom: 1px solid #f0f0f0;
    font-size: 14px;
    color: #37352f;
    align-items: flex-start;
  }
  .step-list li:last-child { border-bottom: none; }
  .step-num {
    background: #4a90e2;
    color: #fff;
    border-radius: 50%;
    width: 22px;
    height: 22px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 11px;
    font-weight: 700;
    flex-shrink: 0;
    margin-top: 1px;
  }

  /* ── 메서드 카드 ── */
  .method-card {
    border: 1px solid #e8e8e8;
    border-radius: 10px;
    padding: 0;
    margin: 20px 0;
    overflow: hidden;
  }
  .method-card-header {
    background: #f7f6f3;
    padding: 10px 16px;
    font-size: 13px;
    font-weight: 700;
    color: #37352f;
    display: flex;
    align-items: center;
    gap: 8px;
    border-bottom: 1px solid #e8e8e8;
  }
  .method-card-header .role {
    font-size: 11.5px;
    font-weight: 500;
    color: #9ca3af;
    margin-left: auto;
  }
  .method-card-body {
    padding: 14px 16px;
    font-size: 13.5px;
    color: #555;
    line-height: 1.8;
  }
  .method-card-body .code-block { margin-bottom: 0; }

  /* ── 성능 측정 비교 표 ── */
  .perf-table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0 24px;
    font-size: 13.5px;
  }
  .perf-table th {
    background: #f7f6f3;
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    border: 1px solid #e8e8e8;
    color: #37352f;
  }
  .perf-table td {
    padding: 9px 14px;
    border: 1px solid #e8e8e8;
    color: #555;
    vertical-align: middle;
  }
  .perf-table tr:nth-child(even) td { background: #fafafa; }
  .perf-table .warn { color: #d97706; font-weight: 600; }
  .perf-table .danger { color: #dc2626; font-weight: 600; }
  .perf-table .ok { color: #16a34a; font-weight: 600; }

  /* ── 이미지 자리 ── */
  .img-slot {
    margin: 12px 0 24px;
    border-radius: 8px;
    overflow: hidden;
    border: 1px solid #ebebeb;
  }

  /* ── 외부 링크 ── */
  .ext-link {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    background: #f0f7ff;
    color: #4a90e2;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 13px;
    font-weight: 600;
    text-decoration: none;
    margin: 4px 0;
  }
  .ext-link:hover { text-decoration: underline; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;!-- ── 타이틀 ── --&gt;
&lt;div class=&quot;page-title&quot;&gt;이상 탐지 로직 리팩토링 &amp; 성능 테스트&lt;/div&gt;
&lt;div class=&quot;page-subtitle&quot;&gt;SRP 기반 메서드 분리 · 대용량 데이터 성능 측정 · VisualVM · Spring Boot&lt;/div&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  1. 개요                                   --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;1. 개요&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  현재 구매 물품 리스트에 대해서 이상 탐지 로직이 수행될 때 코드가 너무 &lt;strong&gt;가독성이 떨어지는 것 같아&lt;/strong&gt; 이를 리팩토링 하고자 한다.&lt;br&gt;&lt;br&gt;
  특히 중복 탐지에 대해서는 현재 들어오는 데이터와 DB 전체의 데이터를 조회해야 하기 때문에,
  만약 데이터가 많은 경우 &lt;strong&gt;성능 개선이 필요&lt;/strong&gt;할 것 같아 이를 리팩토링 하고자 한다.
&lt;/div&gt;

&lt;p&gt;이상 탐지 목록&lt;/p&gt;

&lt;div class=&quot;tag-row&quot;&gt;
  &lt;div class=&quot;tag&quot;&gt;  필수 값 누락&lt;/div&gt;
  &lt;div class=&quot;tag&quot;&gt;  규격 불일치&lt;/div&gt;
  &lt;div class=&quot;tag&quot;&gt;  단위 불일치&lt;/div&gt;
  &lt;div class=&quot;tag&quot;&gt;  중복&lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  2. 현재 코드                              --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;2. 현재 코드&lt;/h2&gt;

&lt;div class=&quot;file-label&quot;&gt;ItemService.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Transactional&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; createCommonItem(&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; reqDtos, &lt;span class=&quot;ty&quot;&gt;File&lt;/span&gt; savedFile) {
    &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt; validationResult =
        itemDocumentDuplicateValidator.markDuplicatesForCommon(reqDtos,
            itemRepository.findAllByDeletedAtIsNullOrderByIdAsc());

    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; processAndSaveItemsWithDbCheck(
        reqDtos.size(),
        i -&gt; reqDtos.get(i).getDuplicateGroupKey(),
        validationResult.existingDbMap(),
        (i, group, issueCollector, isDuplicate) -&gt; {
            &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto = reqDtos.get(i);

            &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt; reviewStatus = determineReviewStatus(dto.getSpec(), dto.getUnit(), isDuplicate);

            &lt;span class=&quot;ty&quot;&gt;Set&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;ConstraintViolation&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt;&amp;gt; violations = validator.validate(dto);
            &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; hasMissingField = !violations.isEmpty();
            &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; isDataLacking = dto.getNormalizedItemName() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;;

            &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (dto.isHasParseError() || isDataLacking
                    || (reviewStatus.equals(&lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt;.NEW)) &amp;&amp; hasMissingField) {
                reviewStatus = &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt;.NEEDS_REVIEW;
            }

            &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; item = &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;.CreateCommonItem(dto, savedFile, group, reviewStatus);
            collectIssuesIfNeeded(item, dto.getSpec(), dto.getUnit(), issueCollector,
                    hasMissingField, isDataLacking);

            &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; item;
        }
    );
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;processAndSaveItemsWithDbCheck&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; processAndSaveItemsWithDbCheck(
        &lt;span class=&quot;kw&quot;&gt;int&lt;/span&gt; size,
        &lt;span class=&quot;ty&quot;&gt;Function&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Integer&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;&amp;gt; keyExtractor,
        &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingDbMap,
        &lt;span class=&quot;ty&quot;&gt;QuadFunction&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Integer&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Consumer&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt;, &lt;span class=&quot;ty&quot;&gt;Boolean&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; itemMapper
) {
    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;&amp;gt; groupMap = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashMap&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;Set&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;&amp;gt; seenKeys = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashSet&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; itemsToSave = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingItemsToUpdate = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt; issues = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();

    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;kw&quot;&gt;int&lt;/span&gt; i = &lt;span class=&quot;num&quot;&gt;0&lt;/span&gt;; i &amp;lt; size; i++) {
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; duplicateKey = keyExtractor.apply(i);
        &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt; group = &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;;

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (duplicateKey != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
            group = groupMap.computeIfAbsent(duplicateKey, key -&gt; {
                &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; originalDbItem = existingDbMap.get(key);
                &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (originalDbItem != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
                    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (originalDbItem.getDuplicatedGroup() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
                        &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; originalDbItem.getDuplicatedGroup();
                    }
                    &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt; newGroup = &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;.create();
                    originalDbItem.updateDuplicatedGroup(newGroup);
                    existingItemsToUpdate.add(originalDbItem);
                    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; newGroup;
                }
                &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;.create();
            });
        }

        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; isDuplicate = (duplicateKey != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) &amp;&amp;
                (existingDbMap.containsKey(duplicateKey) || seenKeys.contains(duplicateKey));

        &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; item = itemMapper.apply(i, group, issues::add, isDuplicate);
        itemsToSave.add(item);

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (duplicateKey != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
            &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (isDuplicate) issues.add(&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;.create(&lt;span class=&quot;ty&quot;&gt;IssueType&lt;/span&gt;.DUPLICATE_SUSPECTED, &lt;span class=&quot;str&quot;&gt;&quot;중복 의심&quot;&lt;/span&gt;, &lt;span class=&quot;kw&quot;&gt;false&lt;/span&gt;, item));
            &lt;span class=&quot;kw&quot;&gt;else&lt;/span&gt; seenKeys.add(duplicateKey);
        }
    }

    &lt;span class=&quot;cm&quot;&gt;// ... 저장 로직 생략&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; savedItems;
}&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;봐도 봐도 한번에 알 수 있는 코드가 아닌 것은 확실하다. 부분 부분 살펴보면서 기능별로 분리해보고자 한다.&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;SRP (단일 책임 원칙)&lt;/strong&gt; 을 최대한 준수하면서 리팩토링 해보고자 한다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  3. 메서드 별 분석                         --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;3. 메서드 별 분석&lt;/h2&gt;

&lt;p class=&quot;arrow-note&quot;&gt;&lt;code&gt;ItemService&lt;/code&gt; 클래스 내부의 메서드를 확인하면서 각 기능을 분석하고 책임을 분리해보고자 한다.&lt;/p&gt;

&lt;h3&gt;createCommonItem 메서드 분석&lt;/h3&gt;

&lt;p&gt;CSV, XLSX 파일이 들어오면 해당 메서드에서 이상 탐지 및 필요한 테이블에 삽입하는 역할을 한다.&lt;/p&gt;

&lt;ul class=&quot;step-list&quot;&gt;
  &lt;li&gt;
    &lt;span class=&quot;step-num&quot;&gt;1&lt;/span&gt;
    &lt;div&gt;
      &lt;code&gt;DuplicateValidationResult&lt;/code&gt; 레코드 클래스를 통해 현재 DB에 저장된 item 의 중복 탐지를 위한 key 값 데이터를 가져온다.
      이 과정에서 입력 DTO 의 &lt;strong&gt;정규화 item 이름 값이 생성&lt;/strong&gt;된다.
    &lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;span class=&quot;step-num&quot;&gt;2&lt;/span&gt;
    &lt;div&gt;입력 DTO 의 규격(spec), 단위(unit), 중복 여부(isDuplicated) 를 판단하여 &lt;code&gt;ReviewStatus&lt;/code&gt; 값을 정한다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;span class=&quot;step-num&quot;&gt;3&lt;/span&gt;
    &lt;div&gt;validator 를 이용하여 필수값 누락을 확인, 그리고 ①에서 정해진 &quot;정규화 item 이름&quot; 이 없는지 확인하여 &lt;strong&gt;필수값 누락 탐지&lt;/strong&gt;를 수행한다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;span class=&quot;step-num&quot;&gt;4&lt;/span&gt;
    &lt;div&gt;파싱 실패 시 ③의 이상현상 탐지 결과와 같은 &lt;code&gt;ReviewStatus&lt;/code&gt; 값을 부여한다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;span class=&quot;step-num&quot;&gt;5&lt;/span&gt;
    &lt;div&gt;Issue 테이블 생성을 위하여 &lt;code&gt;Consumer&lt;/code&gt; (issueCollector) 를 활용한다.&lt;/div&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;Consumer 란?&lt;/strong&gt;&lt;br&gt;
    Java 8 부터 제공되는 &lt;code&gt;java.util.function&lt;/code&gt; 패키지의 표준 함수형 인터페이스.
    인자를 하나 받아서 로직을 수행하지만, 아무것도 반환하지 않는 (&lt;code&gt;void&lt;/code&gt;) 형태이다.
    &lt;div class=&quot;code-block&quot; style=&quot;margin-top: 10px; margin-bottom: 0;&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;ty&quot;&gt;Consumer&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;&amp;gt; printName = name -&gt; System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;Hello, &quot;&lt;/span&gt; + name);

printName.accept(&lt;span class=&quot;str&quot;&gt;&quot;Alice&quot;&lt;/span&gt;); &lt;span class=&quot;cm&quot;&gt;// Hello, Alice&lt;/span&gt;
printName.accept(&lt;span class=&quot;str&quot;&gt;&quot;Bob&quot;&lt;/span&gt;);   &lt;span class=&quot;cm&quot;&gt;// Hello, Bob&lt;/span&gt;&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;코드를 분석하면서도 위아래로 왔다갔다 하며 이해하는 데 시간이 오래 걸렸다. 지금부터는 기능별로 정리 후 가독성 높은 코드로 수정해보겠다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  4. 필요 기능 리스트 업                    --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;4. 필요 기능 리스트 업&lt;/h2&gt;

&lt;blockquote&gt;파일 업로드에 대한 필요 기능을 리스트 업 한다. 최대한 작은 단위로 기능을 정리할 것이다.&lt;/blockquote&gt;

&lt;ul class=&quot;step-list&quot;&gt;
  &lt;li&gt;&lt;span class=&quot;step-num&quot;&gt;1&lt;/span&gt;&lt;div&gt;아이템 이름 → 정규화 이름 매핑&lt;/div&gt;&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;step-num&quot;&gt;2&lt;/span&gt;&lt;div&gt;입력된 파일의 row 들과 기존 DB 의 row 들을 비교 (키 만들기)&lt;/div&gt;&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;step-num&quot;&gt;3&lt;/span&gt;&lt;div&gt;중복 탐지&lt;/div&gt;&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;step-num&quot;&gt;4&lt;/span&gt;&lt;div&gt;기타 이상 탐지 (필수 값 누락, 규격 및 단위 불일치)&lt;/div&gt;&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;step-num&quot;&gt;5&lt;/span&gt;&lt;div&gt;이상탐지에 대한 Issue 생성 및 DB 저장 (이슈는 여러 개 생성 가능)&lt;/div&gt;&lt;/li&gt;
  &lt;li&gt;&lt;span class=&quot;step-num&quot;&gt;6&lt;/span&gt;&lt;div&gt;Item 생성 및 DB 저장 (⑤와 함께 수행되어야 함)&lt;/div&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  5. 본격 리팩토링                          --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;5. 본격 리팩토링&lt;/h2&gt;

&lt;p&gt;최대한 서비스 메서드 내부에 간결한 메서드 호출로 작성하려고 노력하였다.&lt;/p&gt;

&lt;div class=&quot;file-label&quot;&gt;ItemService.java — 리팩토링 후 서비스 메서드&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Transactional&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; createCommonItem(&lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; reqDtos, &lt;span class=&quot;ty&quot;&gt;File&lt;/span&gt; savedFile) {

    &lt;span class=&quot;cm&quot;&gt;// 1. 검증 및 중복 매핑 결과 취득&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt; validationResult =
        itemDocumentDuplicateValidator.markDuplicatesForCommon(
            reqDtos, itemRepository.findAllByDeletedAtIsNullOrderByIdAsc()
        );

    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingItemsToUpdate = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Boolean&lt;/span&gt;&amp;gt; isDuplicateFlags = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();

    &lt;span class=&quot;cm&quot;&gt;// 2. DTO -&gt; Item 엔티티 및 DuplicatedGroup 연관관계 구성&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; itemsToSave = processItemsAndGroups(
        reqDtos, validationResult, savedFile, existingItemsToUpdate, isDuplicateFlags
    );

    &lt;span class=&quot;cm&quot;&gt;// 3. 기타 이상 탐지 및 이슈(Issue) 수집&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt; issues = detectIssues(itemsToSave, reqDtos, isDuplicateFlags);

    &lt;span class=&quot;cm&quot;&gt;// 4. 데이터 일괄 저장 (Item, Issue, Group 등)&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; saveAllEntities(itemsToSave, existingItemsToUpdate, issues);
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      processItemsAndGroups
    &lt;span class=&quot;role&quot;&gt;DTO → Item 변환 + 그룹 연관 관계 구성&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; processItemsAndGroups(
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; reqDtos,
        &lt;span class=&quot;ty&quot;&gt;DuplicateValidationResult&lt;/span&gt; validationResult,
        &lt;span class=&quot;ty&quot;&gt;File&lt;/span&gt; savedFile,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingItemsToUpdate,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Boolean&lt;/span&gt;&amp;gt; isDuplicateFlags
) {
    &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;&amp;gt; groupMap = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashMap&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; items = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();
    &lt;span class=&quot;ty&quot;&gt;Set&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;&amp;gt; seenKeys = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;HashSet&lt;/span&gt;&amp;lt;&amp;gt;();

    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto : reqDtos) {
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; groupKey = dto.getDuplicateGroupKey();
        &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt; group = resolveDuplicatedGroup(
            groupKey, groupMap, validationResult.existingDbMap(), existingItemsToUpdate
        );

        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; isDuplicated = (groupKey != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) &amp;&amp;
                (validationResult.existingDbMap().containsKey(groupKey) || seenKeys.contains(groupKey));

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (groupKey != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) seenKeys.add(groupKey);
        isDuplicateFlags.add(isDuplicated);

        &lt;span class=&quot;ty&quot;&gt;Set&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;ConstraintViolation&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt;&amp;gt; violations = validator.validate(dto);
        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; hasMissingFieldOrDataLacking =
            !violations.isEmpty() || (dto.getNormalizedItemName() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;);

        &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt; reviewStatus = determineReviewStatusV2(
            dto.getSpec(), dto.getUnit(), isDuplicated, hasMissingFieldOrDataLacking
        );

        items.add(&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;.CreateCommonItem(dto, savedFile, group, reviewStatus));
    }
    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; items;
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      resolveDuplicatedGroup
    &lt;span class=&quot;role&quot;&gt;중복 그룹 결정 (3가지 케이스)&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt; resolveDuplicatedGroup(
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; groupKey,
        &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;&amp;gt; groupMap,
        &lt;span class=&quot;ty&quot;&gt;Map&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;, &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingDbMap,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingItemsToUpdate
) {
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (groupKey == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;kw&quot;&gt;return null&lt;/span&gt;;

    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; groupMap.computeIfAbsent(groupKey, key -&gt; {
        &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; originalDbItem = existingDbMap.get(key);

        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (originalDbItem != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;) {
            &lt;span class=&quot;cm&quot;&gt;// Case 1: 기존 DB 항목에 이미 중복 그룹이 존재&lt;/span&gt;
            &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (originalDbItem.getDuplicatedGroup() != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;)
                &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; originalDbItem.getDuplicatedGroup();

            &lt;span class=&quot;cm&quot;&gt;// Case 2: DB 항목은 있지만 중복 그룹 없음 → 새 그룹 생성 후 업데이트&lt;/span&gt;
            &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt; newGroup = &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;.create();
            originalDbItem.updateDuplicatedGroup(newGroup);
            existingItemsToUpdate.add(originalDbItem);
            &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; newGroup;
        }

        &lt;span class=&quot;cm&quot;&gt;// Case 3: DB 항목 없이 요청 DTO 간 내부 중복&lt;/span&gt;
        &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;.create();
    });
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
    ⚠️ detectIssues
    &lt;span class=&quot;role&quot;&gt;4가지 이상 탐지 → Issue 수집&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt; detectIssues(
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; itemsToSave,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt;&amp;gt; reqDtos,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Boolean&lt;/span&gt;&amp;gt; isDuplicateFlags
) {
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt; issues = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ArrayList&lt;/span&gt;&amp;lt;&amp;gt;();

    &lt;span class=&quot;kw&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;kw&quot;&gt;int&lt;/span&gt; i = &lt;span class=&quot;num&quot;&gt;0&lt;/span&gt;; i &amp;lt; itemsToSave.size(); i++) {
        &lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt; item = itemsToSave.get(i);
        &lt;span class=&quot;ty&quot;&gt;CreateCommonItemDocumentReqDto&lt;/span&gt; dto = reqDtos.get(i);
        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; isDuplicated = isDuplicateFlags.get(i);

        &lt;span class=&quot;cm&quot;&gt;// 1. 규격(Spec) 불일치&lt;/span&gt;
        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (itemSpecAndUnitValidator.isSpecMismatch(dto.getSpec()))
            issues.add(&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;.create(&lt;span class=&quot;ty&quot;&gt;IssueType&lt;/span&gt;.SPEC_MISMATCH, &lt;span class=&quot;str&quot;&gt;&quot;규격 불일치&quot;&lt;/span&gt;, &lt;span class=&quot;kw&quot;&gt;false&lt;/span&gt;, item));

        &lt;span class=&quot;cm&quot;&gt;// 2. 단위(Unit) 불일치&lt;/span&gt;
        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (itemSpecAndUnitValidator.isUnitMismatch(dto.getUnit()))
            issues.add(&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;.create(&lt;span class=&quot;ty&quot;&gt;IssueType&lt;/span&gt;.UNIT_MISMATCH, &lt;span class=&quot;str&quot;&gt;&quot;단위 불일치&quot;&lt;/span&gt;, &lt;span class=&quot;kw&quot;&gt;false&lt;/span&gt;, item));

        &lt;span class=&quot;cm&quot;&gt;// 3. 필수값 누락&lt;/span&gt;
        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; hasMissingField = !validator.validate(dto).isEmpty();
        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; isDataLacking = (dto.getNormalizedItemName() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;);
        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (isDataLacking || hasMissingField)
            issues.add(&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;.create(&lt;span class=&quot;ty&quot;&gt;IssueType&lt;/span&gt;.MISSING_REQUIRED, &lt;span class=&quot;str&quot;&gt;&quot;필수값 누락&quot;&lt;/span&gt;, &lt;span class=&quot;kw&quot;&gt;false&lt;/span&gt;, item));

        &lt;span class=&quot;cm&quot;&gt;// 4. 중복 의심 (후속 중복 항목일 때만)&lt;/span&gt;
        &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (isDuplicated)
            issues.add(&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;.create(&lt;span class=&quot;ty&quot;&gt;IssueType&lt;/span&gt;.DUPLICATE_SUSPECTED, &lt;span class=&quot;str&quot;&gt;&quot;중복 의심&quot;&lt;/span&gt;, &lt;span class=&quot;kw&quot;&gt;false&lt;/span&gt;, item));
    }
    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; issues;
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
      saveAllEntities
    &lt;span class=&quot;role&quot;&gt;순서 보장 일괄 저장&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; saveAllEntities(
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; itemsToSave,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; existingItemsToUpdate,
        &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Issue&lt;/span&gt;&amp;gt; issues
) {
    &lt;span class=&quot;cm&quot;&gt;// 1. 아직 저장되지 않은(id == null) DuplicatedGroup 우선 저장&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;DuplicatedGroup&lt;/span&gt;&amp;gt; newGroupsToSave = itemsToSave.stream()
        .map(&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;::getDuplicatedGroup)
        .filter(g -&gt; g != &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt; &amp;&amp; g.getId() == &lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;)
        .distinct().toList();

    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!newGroupsToSave.isEmpty())
        duplicatedGroupRepository.saveAll(newGroupsToSave);

    &lt;span class=&quot;cm&quot;&gt;// 2. 새 그룹이 할당된 기존 DB Item 업데이트&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!existingItemsToUpdate.isEmpty())
        itemRepository.saveAll(existingItemsToUpdate);

    &lt;span class=&quot;cm&quot;&gt;// 3. 신규 Item 저장&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;List&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;Item&lt;/span&gt;&amp;gt; savedItems = itemRepository.saveAll(itemsToSave);

    &lt;span class=&quot;cm&quot;&gt;// 4. Issue 저장&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (!issues.isEmpty())
        issueRepository.saveAll(issues);

    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; savedItems;
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;method-card&quot;&gt;
  &lt;div class=&quot;method-card-header&quot;&gt;
     ️ determineReviewStatusV2
    &lt;span class=&quot;role&quot;&gt;ReviewStatus 우선순위 결정&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;method-card-body&quot;&gt;
    &lt;div class=&quot;code-block&quot; style=&quot;margin-bottom:0&quot;&gt;
      &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
      &lt;pre&gt;&lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt; determineReviewStatusV2(
        &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; spec, &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; unit,
        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; isDuplicated,
        &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; hasMissingFieldOrDataLacking
) {
    &lt;span class=&quot;cm&quot;&gt;// 1순위. 필수값 누락 → NEEDS_REVIEW&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (hasMissingFieldOrDataLacking) &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt;.NEEDS_REVIEW;

    &lt;span class=&quot;cm&quot;&gt;// 2순위. 규격/단위 불일치 또는 중복 → ON_HOLD&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;boolean&lt;/span&gt; hasSpecOrUnitIssue =
        itemSpecAndUnitValidator.isSpecMismatch(spec) || itemSpecAndUnitValidator.isUnitMismatch(unit);

    &lt;span class=&quot;kw&quot;&gt;if&lt;/span&gt; (hasSpecOrUnitIssue || isDuplicated) &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt;.ON_HOLD;

    &lt;span class=&quot;cm&quot;&gt;// 3순위. 이상 없음 → NEW&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;ReviewStatus&lt;/span&gt;.NEW;
}&lt;/pre&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;callout green&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;✅&lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;Function 사용을 지양&lt;/strong&gt;하고 일반 메서드 사용을 추구함으로써 가독성을 높이고자 노력하였다.&lt;br&gt;
    아직 코드적으로 개선 사항이 많지만, 아래 6번 성능 이슈로 인해 코드가 완전히 변경될 수 있어 이 정도 리팩토링으로 일단 마무리하고자 한다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  6. 성능 테스트 및 개선 사항               --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;6. 성능 테스트 및 개선 사항&lt;/h2&gt;

&lt;div class=&quot;callout red&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;기능 체크 사항&lt;/strong&gt;&lt;br&gt;
    대용량 파일 입력 (10만 row 이상) + 이상 탐지 프로세스&lt;br&gt;&lt;br&gt;
    응답 시간 성능 개선 및 중복 탐지 시 &lt;strong&gt;JVM 메모리 오버 가능성&lt;/strong&gt;이 있으므로 이를 해결해야 한다.
    (현재 Map 상에 모든 중복 Key 값이 들어가기 때문)
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;6-1. 응답 시간 측정&lt;/h3&gt;

&lt;table class=&quot;perf-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;데이터 규모&lt;/th&gt;
      &lt;th&gt;소요 시간&lt;/th&gt;
      &lt;th&gt;비고&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;20 row (CSV)&lt;/td&gt;
      &lt;td class=&quot;ok&quot;&gt;692 ms&lt;/td&gt;
      &lt;td&gt;파싱 + 중복 판단 포함&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1,000 row (1차)&lt;/td&gt;
      &lt;td class=&quot;warn&quot;&gt;약 8 s&lt;/td&gt;
      &lt;td&gt;신규 입력&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1,000 row (2차)&lt;/td&gt;
      &lt;td class=&quot;danger&quot;&gt;13.22 s&lt;/td&gt;
      &lt;td&gt;기존 1,000건과 중복 비교 발생 → 느려짐&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1,000 row (3차)&lt;/td&gt;
      &lt;td class=&quot;warn&quot;&gt;약 8 s&lt;/td&gt;
      &lt;td&gt;2차 입력분이 update 안 되어 그 시간만큼 단축&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10만 row&lt;/td&gt;
      &lt;td class=&quot;danger&quot;&gt;단순 계산 약 1시간 예상&lt;/td&gt;
      &lt;td&gt;메모리 오버 + 비효율 발생 확인&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;br&gt;
&lt;p&gt;20 row 입력 결과&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mF7Mx/dJMcafugqEd/YXgzAOlKvzbWZ5xTILuqRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mF7Mx/dJMcafugqEd/YXgzAOlKvzbWZ5xTILuqRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mF7Mx/dJMcafugqEd/YXgzAOlKvzbWZ5xTILuqRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmF7Mx%2FdJMcafugqEd%2FYXgzAOlKvzbWZ5xTILuqRk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;br&gt;
&lt;p&gt;1,000 row 입력 — application.yml 추가 설정 필요&lt;/p&gt;

&lt;div class=&quot;file-label&quot;&gt;application.yml&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;yaml&lt;/span&gt;
  &lt;pre&gt;servlet:
  multipart:
    max-file-size: 10MB
    max-request-size: 10MB&lt;/pre&gt;
&lt;/div&gt;
  &lt;br&gt;
  &lt;p&gt;⇒ 10만 개의 경우 단순 계산으로 약 1시간 정도 소요 되기에 일단은 1000개의 row 로 테스트 해보았다.&lt;/p&gt;
  
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6d1k2/dJMcadpEj6S/wpqWLVWPVBtkgBl6zPjI80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6d1k2/dJMcadpEj6S/wpqWLVWPVBtkgBl6zPjI80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6d1k2/dJMcadpEj6S/wpqWLVWPVBtkgBl6zPjI80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6d1k2%2FdJMcadpEj6S%2FwpqWLVWPVBtkgBl6zPjI80%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxNyLe/dJMcabZN2x4/4wdbifboNs5Sx01fRPP7xK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxNyLe/dJMcabZN2x4/4wdbifboNs5Sx01fRPP7xK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxNyLe/dJMcabZN2x4/4wdbifboNs5Sx01fRPP7xK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxNyLe%2FdJMcabZN2x4%2F4wdbifboNs5Sx01fRPP7xK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  
  &lt;br&gt;
   &lt;p&gt;⇒ 1000개의 row 정도는 약 8초 정도 걸렸다.&lt;/p&gt;
&lt;br&gt;
 &lt;p&gt;⇒ 이 이후에 1000개 정도의 데이터를 다시 입력하면 처음 입력보다 더 오래걸린다. (13.22s)&lt;/p&gt;
  
  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clQVQS/dJMcac5nrvQ/O3egMkYLYKPUqRCYw4y3f0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clQVQS/dJMcac5nrvQ/O3egMkYLYKPUqRCYw4y3f0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clQVQS/dJMcac5nrvQ/O3egMkYLYKPUqRCYw4y3f0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FclQVQS%2FdJMcac5nrvQ%2FO3egMkYLYKPUqRCYw4y3f0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  
  
 &lt;p&gt;⇒ 이 이후 동일 데이터를 넣어보면 처음과 비슷하게 8초 정도가 소요되는데 이는 처음 입력 데이터 1000개가 update 되지 않기 때문에 그 시간 만큼 덜 소요된 것이다.&lt;/p&gt;
&lt;br&gt;
&lt;p&gt;하지만 문제는 이 이후이다. 이 1000개의 데이터를 만약 중복 탐지를 위해 메로리 상에 모두 올리게 된다면?&lt;/p&gt;
&lt;br&gt;
&lt;h3&gt;6-2. 메모리 측정 (VisualVM)&lt;/h3&gt;

  &lt;p&gt;* VisualVM 사용법 참고 사이트&lt;/p&gt;
&lt;a class=&quot;ext-link&quot; href=&quot;https://codingdreamtree.tistory.com/96&quot; target=&quot;_blank&quot;&gt;  Java 성능 모니터링 — VisualVM 분석 및 연동&lt;/a&gt;

&lt;br&gt;&lt;br&gt;

&lt;p&gt;평소 상태&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beynHe/dJMcagzWluA/GVEPkLCg987OpkIzlciWr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beynHe/dJMcagzWluA/GVEPkLCg987OpkIzlciWr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beynHe/dJMcagzWluA/GVEPkLCg987OpkIzlciWr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeynHe%2FdJMcagzWluA%2FGVEPkLCg987OpkIzlciWr0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

  &lt;br&gt;
&lt;p&gt;1,000 row 입력 시&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ElKYN/dJMb991Wpqw/EDfG5010budm1Klt4j3Az0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ElKYN/dJMb991Wpqw/EDfG5010budm1Klt4j3Az0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ElKYN/dJMb991Wpqw/EDfG5010budm1Klt4j3Az0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FElKYN%2FdJMb991Wpqw%2FEDfG5010budm1Klt4j3Az0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    주황 부분이 &lt;strong&gt;할당된 Heap 사이즈&lt;/strong&gt;, 파란 부분이 &lt;strong&gt;사용된 Heap 사이즈&lt;/strong&gt;이다.&lt;br&gt;
    보통 50~70% 사이가 안정적인 구간이라고 하는데,
    현재 1,000개 정도의 &lt;strong&gt;적은 데이터임에도 최대 75% 에 육박하는&lt;/strong&gt; 그래프를 보인다.&lt;br&gt;
    DB 의 데이터가 증가될수록 사용량이 많아질 것이다.
  &lt;/div&gt;
&lt;/div&gt;

  &lt;br&gt;
&lt;p&gt;10만 row 입력 시&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxgguR/dJMcajwI7l8/ig3Yk1PrKy909EYZCNykak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxgguR/dJMcajwI7l8/ig3Yk1PrKy909EYZCNykak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxgguR/dJMcajwI7l8/ig3Yk1PrKy909EYZCNykak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxgguR%2FdJMcajwI7l8%2Fig3Yk1PrKy909EYZCNykak%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;⚠️&lt;/span&gt;
  &lt;div&gt;
    서버의 메모리 자원을 늘리면서 &lt;strong&gt;비효율적으로 작업이 진행&lt;/strong&gt;되는 것을 확인할 수 있다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;callout green&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    따라서 다음 포스트에서는 위 성능 테스트를 바탕으로 개선을 진행하고자 한다.&lt;br&gt;
    이 도전이 DB 관리에 있어서 의미 있는 도전이 되기를 바란다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>프로젝트/보살핌 프로젝트</category>
      <category>Java</category>
      <category>spring boot</category>
      <category>백엔드</category>
      <category>성능 개선</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/9</guid>
      <comments>https://twdev.tistory.com/9#entry9comment</comments>
      <pubDate>Thu, 27 Aug 2026 12:44:40 +0900</pubDate>
    </item>
    <item>
      <title>0. 해커톤 개요</title>
      <link>https://twdev.tistory.com/8</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Noto Sans KR', sans-serif;
    background: #ffffff;
    color: #37352f;
    line-height: 1.75;
    font-size: 15px;
    max-width: 7800px;
    margin: 0 auto;
    padding: 48px 24px 80px;
  }

  /* ── 헤더 ── */
  .page-title {
    font-size: 28px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 8px;
    line-height: 1.3;
  }
  .page-subtitle {
    font-size: 14px;
    color: #9b9b9b;
    margin-bottom: 40px;
  }

  /* ── 섹션 헤더 ── */
  h2 {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a1a;
    margin: 52px 0 16px;
    padding-bottom: 8px;
    border-bottom: 2px solid #f0f0f0;
  }
  h3 {
    font-size: 16px;
    font-weight: 600;
    color: #37352f;
    margin: 36px 0 12px;
  }
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #37352f;
    margin: 28px 0 10px;
  }

  /* ── 개요 카드 ── */
  .overview-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 16px 0 28px;
    font-size: 14px;
    color: #555;
    line-height: 1.85;
  }
  .overview-card strong { color: #37352f; }

  /* ── 콜아웃 ── */
  .callout {
    display: flex;
    gap: 12px;
    background: #f0f7ff;
    border-left: 4px solid #4a90e2;
    border-radius: 0 8px 8px 0;
    padding: 14px 16px;
    margin: 16px 0 24px;
    font-size: 14px;
    color: #37352f;
    line-height: 1.8;
  }
  .callout .icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
  .callout ul, .callout ol { padding-left: 20px; margin-top: 6px; }
  .callout li { margin-bottom: 4px; }
  .callout strong { color: #1a5fa8; }
  .callout code {
    background: #dbeafe;
    padding: 1px 5px;
    border-radius: 3px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #1d4ed8;
  }
  .callout.green {
    background: #f0fdf4;
    border-left-color: #22c55e;
  }
  .callout.green strong { color: #15803d; }
  .callout.yellow {
    background: #fffbeb;
    border-left-color: #f59e0b;
  }
  .callout.yellow strong { color: #92400e; }
  .callout.gray {
    background: #f7f6f3;
    border-left-color: #9ca3af;
  }
  .callout.purple {
    background: #faf5ff;
    border-left-color: #9333ea;
  }
  .callout.purple strong { color: #7c3aed; }

  /* ── 인용 ── */
  blockquote {
    border-left: 3px solid #e0e0e0;
    padding: 4px 16px;
    margin: 16px 0 20px;
    color: #6b7280;
    font-size: 14px;
    line-height: 1.8;
  }

  /* ── 구분선 ── */
  hr {
    border: none;
    border-top: 1px solid #f0f0f0;
    margin: 44px 0;
  }

  /* ── 일반 텍스트 ── */
  p {
    margin: 12px 0;
    font-size: 15px;
    color: #37352f;
  }
  ul, ol { padding-left: 22px; margin: 10px 0 16px; }
  li { margin-bottom: 6px; font-size: 14px; color: #37352f; }

  code {
    background: #f0f0f0;
    padding: 1px 5px;
    border-radius: 4px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #d44000;
  }

  /* ── 배지 ── */
  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 20px;
    font-size: 11.5px;
    font-weight: 600;
    margin-right: 4px;
  }
  .badge-blue   { background: #dbeafe; color: #1d4ed8; }
  .badge-green  { background: #dcfce7; color: #166534; }
  .badge-purple { background: #ede9fe; color: #7c3aed; }
  .badge-orange { background: #fff7ed; color: #c2410c; }
  .badge-gray   { background: #f1f5f9; color: #475569; }

  /* ── arrow note ── */
  .arrow-note {
    font-size: 13.5px;
    color: #4a90e2;
    margin: 12px 0 20px;
    font-weight: 500;
  }
  .arrow-note::before { content: '⇒ '; }

  /* ── 프로젝트 정보 그리드 ── */
  .info-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    margin: 16px 0 24px;
  }
  .info-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 14px 16px;
    font-size: 13.5px;
    color: #37352f;
  }
  .info-card .label {
    font-weight: 700;
    font-size: 11px;
    color: #9ca3af;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    margin-bottom: 5px;
  }
  .info-card .value {
    font-weight: 600;
    color: #37352f;
    font-size: 14px;
  }

  /* ── 흐름 단계 ── */
  .flow-row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 16px 0 24px;
    flex-wrap: wrap;
  }
  .flow-step {
    background: #ede9fe;
    color: #7c3aed;
    border-radius: 6px;
    padding: 7px 16px;
    font-size: 13.5px;
    font-weight: 600;
  }
  .flow-arrow {
    color: #c4b5fd;
    font-size: 18px;
    font-weight: 700;
  }

  /* ── 기술 스택 표 ── */
  .tech-table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0 24px;
    font-size: 13.5px;
  }
  .tech-table th {
    background: #f7f6f3;
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    border: 1px solid #e8e8e8;
    color: #37352f;
    width: 120px;
  }
  .tech-table td {
    padding: 9px 14px;
    border: 1px solid #e8e8e8;
    color: #555;
  }
  .tech-table tr:nth-child(even) td { background: #fafafa; }

  /* ── 담당 파트 카드 ── */
  .part-card {
    border: 1px solid #e8e8e8;
    border-radius: 10px;
    padding: 20px 22px;
    margin: 20px 0;
  }
  .part-card .part-title {
    font-size: 15px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 10px;
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .part-card .part-title .num {
    background: #4a90e2;
    color: #fff;
    border-radius: 50%;
    width: 22px;
    height: 22px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;
    font-weight: 700;
    flex-shrink: 0;
  }
  .part-card p {
    font-size: 14px;
    color: #555;
    line-height: 1.8;
    margin: 0;
  }

  /* ── 로드맵 리스트 ── */
  .roadmap-list {
    list-style: none;
    padding: 0;
    margin: 16px 0;
  }
  .roadmap-list li {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 10px 0;
    border-bottom: 1px solid #f0f0f0;
    font-size: 14px;
    color: #37352f;
  }
  .roadmap-list li:last-child { border-bottom: none; }
  .roadmap-list .dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #4a90e2;
    flex-shrink: 0;
    margin-top: 6px;
  }
  .roadmap-list .dot.gray { background: #d1d5db; }

  /* ── 링크 예정 뱃지 ── */
  .link-pending {
    display: inline-block;
    background: #f1f5f9;
    color: #94a3b8;
    border-radius: 5px;
    font-size: 12px;
    padding: 2px 8px;
    font-style: italic;
  }

  /* ── 외부 링크 ── */
  .ext-link {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    background: #f0f7ff;
    color: #4a90e2;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 13px;
    font-weight: 600;
    text-decoration: none;
    margin-top: 4px;
  }
  .ext-link:hover { text-decoration: underline; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;!-- ── 타이틀 ── --&gt;
&lt;div class=&quot;page-title&quot;&gt;블레이버스 해커톤 B파트 회고 — 프로젝트 개요&lt;/div&gt;
&lt;div class=&quot;page-subtitle&quot;&gt;2026.08.02 ~ 2026.08.20 · Spring Boot · OCR · S3&lt;/div&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  0. 해커톤 개요                            --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;0. 해커톤 개요&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  해당 해커톤은 앞서 진행된 &lt;strong&gt;A 파트 해커톤&lt;/strong&gt;이 있고 내가 참여한 &lt;strong&gt;B 파트 해커톤&lt;/strong&gt;이 있습니다.&lt;br&gt;&lt;br&gt;
  A 파트에서는 창업 아이템을 아이디어톤을 통해 우수한 팀 3팀을 선발하였습니다.&lt;br&gt;
  B 파트에서는 A 파트의 우수 3팀의 아이디어를 실제 요구 사항에 맞게 구현하고,
  추가적으로 개발 팀별로 아이디어를 적용하는 방식으로 진행되었습니다.
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  1. 프로젝트 소개                          --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;1. 프로젝트 소개&lt;/h2&gt;

&lt;h3&gt;프로젝트 개요&lt;/h3&gt;

&lt;blockquote&gt;
  저희는 3개의 아이디어 중 &lt;strong&gt;&quot;compozi&quot;&lt;/strong&gt; 라는 팀의 아이디어를 채택하였습니다.&lt;br&gt;
  해당 아이디어는 중소 프랜차이즈 기업의 &lt;strong&gt;식자재 유통 관련 아이디어&lt;/strong&gt;였습니다.&lt;br&gt;
  흩어져 있는 구매 증빙 자료를 한곳에 모으고, 해당 자료들의 유효성을 판단하는 부분을 맡게 되었습니다.
&lt;/blockquote&gt;

&lt;h3&gt;해커톤 배경&lt;/h3&gt;

&lt;div class=&quot;info-grid&quot;&gt;
  &lt;div class=&quot;info-card&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;진행 기간&lt;/div&gt;
    &lt;div class=&quot;value&quot;&gt;2026.08.02 ~ 2026.08.20&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;info-card&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;팀 구성&lt;/div&gt;
    &lt;div class=&quot;value&quot;&gt;6명 (PM 1 · UI/UX 1 · FE 2 · BE 2)&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;아이디어 선정 이유&lt;/h3&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    팀에서는 AI 관련 전문가가 있는 편이 아니었고,
    또한 &lt;strong&gt;기본기를 확장하고 싶어하는 사람이 많았기에&lt;/strong&gt;
    해당 아이디어를 구체화시키고 고도화시켜보고자 선정하게 되었습니다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;프로젝트 사이트&lt;/h3&gt;

&lt;a class=&quot;ext-link&quot; href=&quot;https://mvp-hackathon-front-end.vercel.app/&quot; target=&quot;_blank&quot;&gt;  mvp-hackathon-bosalpim&lt;/a&gt;

&lt;p class=&quot;arrow-note&quot; style=&quot;margin-top: 10px;&quot;&gt;현재 백엔드 서버는 내려가 있어서 기능적으로 실행되지는 않는 상태&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  2. 전체 아키텍처 개요                     --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;2. 전체 아키텍처 개요&lt;/h2&gt;

&lt;h3&gt;시스템 흐름&lt;/h3&gt;

&lt;div class=&quot;flow-row&quot;&gt;
  &lt;div class=&quot;flow-step&quot;&gt;정보 입력&lt;/div&gt;
  &lt;div class=&quot;flow-arrow&quot;&gt;→&lt;/div&gt;
  &lt;div class=&quot;flow-step&quot;&gt;유효성 검사&lt;/div&gt;
  &lt;div class=&quot;flow-arrow&quot;&gt;→&lt;/div&gt;
  &lt;div class=&quot;flow-step&quot;&gt;사용자 검사&lt;/div&gt;
  &lt;div class=&quot;flow-arrow&quot;&gt;→&lt;/div&gt;
  &lt;div class=&quot;flow-step&quot;&gt;상태 판단&lt;/div&gt;
&lt;/div&gt;

&lt;!-- 시스템 흐름 이미지 자리 --&gt;
  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pkzlR/dJMb998BTCt/WxPFzucMEF7JpF7YX5flVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pkzlR/dJMb998BTCt/WxPFzucMEF7JpF7YX5flVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pkzlR/dJMb998BTCt/WxPFzucMEF7JpF7YX5flVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpkzlR%2FdJMb998BTCt%2FWxPFzucMEF7JpF7YX5flVK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;h3&gt;ERD&lt;/h3&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; ️&lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;구매 품목 (item)&lt;/strong&gt; 기준으로 검사 및 수정에 따른 여러 테이블이 관계를 맺고 있습니다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;!-- ERD 이미지 자리 --&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qkrak/dJMb998BTAo/oefzFl0kGcIt7gWF3iFYKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qkrak/dJMb998BTAo/oefzFl0kGcIt7gWF3iFYKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qkrak/dJMb998BTAo/oefzFl0kGcIt7gWF3iFYKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqkrak%2FdJMb998BTAo%2FoefzFl0kGcIt7gWF3iFYKK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;

&lt;h3&gt;시연 영상 및 발표 자료&lt;/h3&gt;

&lt;a class=&quot;ext-link&quot; href=&quot;https://drive.google.com/drive/folders/1PDb1BiDxHTAJYym42vii4DPS5QOoE6H6?usp=drive_link&quot; target=&quot;_blank&quot;&gt;  블레이버스 해커톤 — Google Drive&lt;/a&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  3. 담당 파트                              --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;3. 담당 파트&lt;/h2&gt;

&lt;div class=&quot;part-card&quot;&gt;
  &lt;div class=&quot;part-title&quot;&gt;
    &lt;span class=&quot;num&quot;&gt;1&lt;/span&gt;
    파일 입력 시스템 구축
  &lt;/div&gt;
  &lt;p&gt;
    CSV, XLSX 파일을 입력받으면 해당 파일에서 &lt;strong&gt;구매 증빙 정보를 파싱하여 DB에 저장&lt;/strong&gt;합니다.&lt;br&gt;
    이 과정에서 각 정보 (row) 들의 유효성 (값 누락, 규격 및 단위 불일치, 중복) 을 파악하고
    해당 정보가 기록되도록 했습니다.
  &lt;/p&gt;
&lt;/div&gt;

&lt;div class=&quot;part-card&quot;&gt;
  &lt;div class=&quot;part-title&quot;&gt;
    &lt;span class=&quot;num&quot;&gt;2&lt;/span&gt;
    OCR 연동 및 파일 파싱 확장
  &lt;/div&gt;
  &lt;p&gt;
    CSV, XLSX 파일뿐만 아니라 &lt;strong&gt;PDF, PNG 파일&lt;/strong&gt;과 같은 파일도 해당 시스템에 많이 들어온다고 했습니다.&lt;br&gt;
    따라서 해당 기능을 기존 파일 파싱 프로세스에 추가함으로써 &lt;strong&gt;유연한 확장이 가능&lt;/strong&gt;하도록 하였습니다.
  &lt;/p&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  4. 기술 스택                              --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;4. 기술 스택&lt;/h2&gt;

&lt;table class=&quot;tech-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;영역&lt;/th&gt;
      &lt;th&gt;기술&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-blue&quot;&gt;Backend&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Spring Boot, JPA&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-purple&quot;&gt;외부 API&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Swagger, Naver Clova OCR, Amazon S3&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-orange&quot;&gt;DB&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;MySQL, Amazon RDS&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-gray&quot;&gt;CI/CD&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;Github Actions, Docker&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  5. 시리즈 로드맵                          --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;5. 시리즈 로드맵&lt;/h2&gt;

&lt;ul class=&quot;roadmap-list&quot;&gt;
  &lt;li&gt;
    &lt;div class=&quot;dot&quot;&gt;&lt;/div&gt;
    &lt;div&gt;앞으로의 포스트에서 각 기능 구현 과정을 설명할 것입니다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;div class=&quot;dot&quot;&gt;&lt;/div&gt;
    &lt;div&gt;이후 해당 기능에 대한 &lt;strong&gt;성능 개선 및 오류 개선&lt;/strong&gt; 관련 포스트도 작성 예정입니다.&lt;/div&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;div class=&quot;dot&quot;&gt;&lt;/div&gt;
    &lt;div style=&quot;color: #9ca3af;&quot;&gt;링크 --&gt;  &lt;span class=&quot;link-pending&quot;&gt;&lt;a class=&quot;ext-link&quot; href=&quot;https://twdev.tistory.com/9&quot; target=&quot;_blank&quot;&gt;1. 이상 탐지 리팩토링&lt;/a&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>프로젝트/보살핌 프로젝트</category>
      <category>JPA</category>
      <category>MySQL</category>
      <category>spring boot</category>
      <category>백엔드</category>
      <category>해커톤</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/8</guid>
      <comments>https://twdev.tistory.com/8#entry8comment</comments>
      <pubDate>Tue, 25 Aug 2026 12:51:55 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 테스트 코드 알아보기</title>
      <link>https://twdev.tistory.com/7</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Noto Sans KR', sans-serif;
    background: #ffffff;
    color: #37352f;
    line-height: 1.75;
    font-size: 15px;
    max-width: 7800px;
    margin: 0 auto;
    padding: 48px 24px 80px;
  }

  /* ── 헤더 ── */
  .page-title {
    font-size: 28px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 8px;
    line-height: 1.3;
  }
  .page-subtitle {
    font-size: 14px;
    color: #9b9b9b;
    margin-bottom: 40px;
  }

  /* ── 섹션 헤더 ── */
  h2 {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a1a;
    margin: 52px 0 16px;
    padding-bottom: 8px;
    border-bottom: 2px solid #f0f0f0;
  }
  h3 {
    font-size: 16px;
    font-weight: 600;
    color: #37352f;
    margin: 36px 0 12px;
  }
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #37352f;
    margin: 28px 0 10px;
  }

  /* ── 개요 카드 ── */
  .overview-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 16px 0 32px;
    font-size: 14px;
    color: #555;
    line-height: 1.8;
  }
  .overview-card strong { color: #37352f; }

  /* ── 파일 라벨 ── */
  .file-label {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: #f0f0f0;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: #555;
    margin-bottom: 6px;
    font-family: 'JetBrains Mono', monospace;
  }
  .file-label::before { content: ' '; font-size: 11px; }

  /* ── 코드 블록 ── */
  .code-block {
    background: #1e1e2e;
    border-radius: 8px;
    padding: 20px;
    margin: 0 0 24px;
    overflow-x: auto;
    position: relative;
  }
  .code-block .lang-tag {
    position: absolute;
    top: 10px;
    right: 14px;
    font-size: 10px;
    color: #6c7086;
    font-family: 'JetBrains Mono', monospace;
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }
  .code-block pre {
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    line-height: 1.7;
    color: #cdd6f4;
    white-space: pre;
  }
  .code-block .kw  { color: #cba6f7; }
  .code-block .an  { color: #89b4fa; }
  .code-block .str { color: #a6e3a1; }
  .code-block .cm  { color: #6c7086; font-style: italic; }
  .code-block .ty  { color: #f9e2af; }

  /* ── 콜아웃 ── */
  .callout {
    display: flex;
    gap: 12px;
    background: #f0f7ff;
    border-left: 4px solid #4a90e2;
    border-radius: 0 8px 8px 0;
    padding: 14px 16px;
    margin: 16px 0 24px;
    font-size: 14px;
    color: #37352f;
    line-height: 1.8;
  }
  .callout .icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
  .callout ul, .callout ol { padding-left: 20px; margin-top: 6px; }
  .callout li { margin-bottom: 4px; }
  .callout strong { color: #1a5fa8; }
  .callout code {
    background: #dbeafe;
    padding: 1px 5px;
    border-radius: 3px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #1d4ed8;
  }

  .callout.green {
    background: #f0fdf4;
    border-left-color: #22c55e;
  }
  .callout.green strong { color: #15803d; }
  .callout.green code { background: #dcfce7; color: #166534; }

  .callout.yellow {
    background: #fffbeb;
    border-left-color: #f59e0b;
  }
  .callout.yellow strong { color: #92400e; }

  .callout.gray {
    background: #f7f6f3;
    border-left-color: #9ca3af;
  }

  /* ── 인용 ── */
  blockquote {
    border-left: 3px solid #e0e0e0;
    padding: 4px 16px;
    margin: 16px 0 20px;
    color: #6b7280;
    font-size: 14px;
    line-height: 1.8;
  }

  /* ── 구분선 ── */
  hr {
    border: none;
    border-top: 1px solid #f0f0f0;
    margin: 44px 0;
  }

  /* ── 일반 텍스트 ── */
  p {
    margin: 12px 0;
    font-size: 15px;
    color: #37352f;
  }
  ul, ol { padding-left: 22px; margin: 10px 0 16px; }
  li { margin-bottom: 6px; font-size: 14px; color: #37352f; }

  code {
    background: #f0f0f0;
    padding: 1px 5px;
    border-radius: 4px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #d44000;
  }

  /* ── 배지 ── */
  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 20px;
    font-size: 11.5px;
    font-weight: 600;
    margin-right: 4px;
  }
  .badge-blue  { background: #dbeafe; color: #1d4ed8; }
  .badge-green { background: #dcfce7; color: #166534; }
  .badge-purple { background: #ede9fe; color: #7c3aed; }
  .badge-orange { background: #fff7ed; color: #c2410c; }

  /* ── arrow note ── */
  .arrow-note {
    font-size: 13.5px;
    color: #4a90e2;
    margin: 12px 0 20px;
    font-weight: 500;
  }
  .arrow-note::before { content: '⇒ '; }

  /* ── 참고 링크 ── */
  .ref-link {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 13px;
    color: #4a90e2;
    text-decoration: none;
    background: #f0f7ff;
    padding: 2px 8px;
    border-radius: 4px;
  }
  .ref-link:hover { text-decoration: underline; }

  /* ── FIRST 원칙 그리드 ── */
  .first-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    margin: 16px 0 24px;
  }
  .first-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 14px 16px;
    font-size: 13.5px;
    color: #37352f;
  }
  .first-card .label {
    font-weight: 700;
    font-size: 12px;
    color: #7c3aed;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    margin-bottom: 4px;
  }

  /* ── 테스트 종류 비교 표 ── */
  .compare-table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0 24px;
    font-size: 13.5px;
  }
  .compare-table th {
    background: #f7f6f3;
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    border: 1px solid #e8e8e8;
    color: #37352f;
  }
  .compare-table td {
    padding: 9px 14px;
    border: 1px solid #e8e8e8;
    color: #555;
    vertical-align: top;
  }
  .compare-table tr:nth-child(even) td { background: #fafafa; }

  /* ── 마치며 카드 ── */
  .closing-card {
    background: linear-gradient(135deg, #f0f7ff 0%, #f0fdf4 100%);
    border-radius: 10px;
    padding: 22px 24px;
    margin: 20px 0;
    font-size: 14.5px;
    color: #37352f;
    line-height: 1.85;
    border: 1px solid #e0eeff;
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;!-- 페이지 타이틀 --&gt;
&lt;div class=&quot;page-title&quot;&gt;테스트 코드의 종류와 작성 방법&lt;/div&gt;
&lt;div class=&quot;page-subtitle&quot;&gt;Unit · Integration · E2E · Acceptance Test · Spring Boot / JUnit&lt;/div&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  0. 들어가기 앞서                          --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;0. 들어가기 앞서&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  항상 테스트 코드 관련해서 테스트 코드의 필요성을 느끼지 못하고 무조건 작성하려고 했던 것 같다.
  또한 테스트의 여러 종류를 학습하고 해당 내용을 바탕으로 &lt;strong&gt;필요에 맞게 사용하고 싶어서&lt;/strong&gt; 공부하게 되었다.
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  1. 테스트 코드의 필요성                   --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;1. 테스트 코드의 필요성&lt;/h2&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    가장 큰 이유는 소프트웨어가 &lt;strong&gt;의도한 대로 정확하게 동작하는지 확인&lt;/strong&gt;하기 위함이다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;개발자는 Swagger, Postman 을 통해 직접 API를 호출하거나, QA 과정을 통해 완성된 소프트웨어를 테스트할 수도 있다.&lt;/p&gt;

&lt;blockquote&gt;하지만 프로젝트의 규모가 크고 새로운 기능 추가 시에는 비용이 많이 들게 된다.&lt;/blockquote&gt;

&lt;ul&gt;
  &lt;li&gt;테스트 코드를 사용하면, 새로운 기능 추가나 리팩토링 과정에서 기존 요구사항에 영향을 주는 부분을 &lt;strong&gt;빠르게 발견&lt;/strong&gt;할 수 있다.&lt;/li&gt;
  &lt;li&gt;단기적으로 개발 속도가 느릴 수 있어도, &lt;strong&gt;장기적으로는 굉장히 많은 비용을 아낄 수 있다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  2. 테스트 코드 작성 시 중요하게 생각해야 할 부분  --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;2. 테스트 코드 작성 시 중요하게 생각해야 할 부분&lt;/h2&gt;

&lt;h3&gt;1) 구현이 아닌 '행위 (Behavior)' 를 검증&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;테스트는 내부 코드 구조(How)가 아니라 &lt;strong&gt;외부로 드러나는 결과(What)&lt;/strong&gt;를 검증해야 한다.&lt;/li&gt;
  &lt;li&gt;구현 방식을 검증하면, 코드 리팩토링 시 기능은 정상 동작해도 테스트가 깨지는 현상 (&lt;strong&gt;Fragile Test&lt;/strong&gt;)이 발생한다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;2) 명확한 구조화 : Given - When - Then&lt;/h3&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Given (준비)&lt;/strong&gt; : 테스트에 필요한 상태/데이터 정의&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;When (실행)&lt;/strong&gt; : 검증할 동작/함수 호출&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Then (검증)&lt;/strong&gt; : 예상 결과와 실제 결과 비교&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;3) 경계값과 예외 상황 (Edge &amp; Negative Cases) 검증&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;성공하는 케이스보다 &lt;strong&gt;&quot;실패하거나 예외가 발생하는 상황&quot;&lt;/strong&gt;을 잘 검증해야 테스트의 가치가 높다.&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;null&lt;/code&gt;, 빈값, 최대/최소 범위, 권한 없음 등 예외 상황 시나리오를 반드시 포함해야 한다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;4) 테스트 간의 완전한 격리 (Independence)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;각 테스트는 &lt;strong&gt;독립적으로 실행 가능&lt;/strong&gt;해야 하며, 실행 순서에 영향을 받지 않아야 한다. (메서드 단위)&lt;/li&gt;
  &lt;li&gt;공통 데이터베이스나 전역 변수를 공유해 다른 테스트 결과에 영향을 주는 &lt;strong&gt;부작용을 방지&lt;/strong&gt;해야 한다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;5) F.I.R.S.T 원칙 체크&lt;/h3&gt;

&lt;div class=&quot;first-grid&quot;&gt;
  &lt;div class=&quot;first-card&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;Fast · 빠름&lt;/div&gt;
    피드백을 즉시 받을 수 있도록 실행 속도가 빨라야 함.
  &lt;/div&gt;
  &lt;div class=&quot;first-card&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;Isolated · 격리됨&lt;/div&gt;
    다른 테스트와 독립적이어야 함.
  &lt;/div&gt;
  &lt;div class=&quot;first-card&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;Repeatable · 반복 가능&lt;/div&gt;
    로컬, CI environment 등 어떤 환경에서도 동일한 결과를 내야 함.
  &lt;/div&gt;
  &lt;div class=&quot;first-card&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;Self-Validating · 자체 검증&lt;/div&gt;
    성공/실패 여부를 사람이 확인하지 않고 테스트 framework가 스스로 판단함.
  &lt;/div&gt;
  &lt;div class=&quot;first-card&quot; style=&quot;grid-column: 1 / -1;&quot;&gt;
    &lt;div class=&quot;label&quot;&gt;Timely · 적시성&lt;/div&gt;
    실제 production 코드를 작성하기 전이나 작성 직후 바로 테스트를 작성함.
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  4. 테스트의 종류                          --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;4. 테스트의 종류&lt;/h2&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;ol&gt;
      &lt;li&gt;단위 테스트 (Unit Test)&lt;/li&gt;
      &lt;li&gt;통합 테스트 (Integration Test)&lt;/li&gt;
      &lt;li&gt;E2E 테스트 (End-to-End Test)&lt;/li&gt;
      &lt;li&gt;인수 테스트 (Acceptance Test)&lt;/li&gt;
    &lt;/ol&gt;
    &lt;br&gt;
    &lt;strong&gt;⇒ 반드시 모든 종류의 테스트가 존재해야 하는 것은 아니다.&lt;/strong&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;table class=&quot;compare-table&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;종류&lt;/th&gt;
      &lt;th&gt;범위&lt;/th&gt;
      &lt;th&gt;속도&lt;/th&gt;
      &lt;th&gt;주요 도구&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-green&quot;&gt;단위 테스트&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;메서드 / 클래스&lt;/td&gt;
      &lt;td&gt;⚡ 빠름&lt;/td&gt;
      &lt;td&gt;JUnit, Mockito&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-blue&quot;&gt;통합 테스트&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;서비스 ↔ DB 레이어&lt;/td&gt;
      &lt;td&gt;  보통&lt;/td&gt;
      &lt;td&gt;@SpringBootTest&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-purple&quot;&gt;E2E 테스트&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;전체 시스템 Flow&lt;/td&gt;
      &lt;td&gt;  느림&lt;/td&gt;
      &lt;td&gt;TestRestTemplate&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;span class=&quot;badge badge-orange&quot;&gt;인수 테스트&lt;/span&gt;&lt;/td&gt;
      &lt;td&gt;비즈니스 요구사항&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;BDD 시나리오&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  5. 단위 테스트                            --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;5. 단위 테스트 (with JUnit)&lt;/h2&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;개념&lt;/strong&gt; : 시스템에서 가장 작은 단위 (메서드, 클래스) 가 의도대로 동작하는지 검증&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;주요 특징&lt;/strong&gt; : 외부 의존성 (DB, API, 네트워크 등) 을 가짜 객체 (Mock, Stub)로 대체하여 독립적으로 테스트함&lt;/li&gt;
      &lt;li&gt;실행 속도가 빠르며, 실시간 피드백 가능&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;blockquote&gt;단위 테스트의 경우는 현재 개인으로 진행 중인 프로젝트의 구현한 테스트 코드 일부를 가져옴 (완벽하지는 않을 수도 있다.)&lt;/blockquote&gt;

&lt;div class=&quot;file-label&quot;&gt;User 조회 테스트&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@DisplayName&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;예외: 존재하지 않거나 삭제된 회원이면 IllegalArgumentException 이 발생한다.&quot;&lt;/span&gt;)
&lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; 유저_조회_예외처리() {

    &lt;span class=&quot;cm&quot;&gt;// given&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Long&lt;/span&gt; userId = 999L;
    given(userRepository.findByIdAndDeletedAtIsNull(userId)).willReturn(&lt;span class=&quot;ty&quot;&gt;Optional&lt;/span&gt;.empty());

    &lt;span class=&quot;cm&quot;&gt;// when &amp; then&lt;/span&gt;
    assertThatThrownBy(() -&gt; userService.findUser(userId))
        .isInstanceOf(&lt;span class=&quot;ty&quot;&gt;IllegalArgumentException&lt;/span&gt;.&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt;)
        .hasMessage(&lt;span class=&quot;str&quot;&gt;&quot;사용자를 찾을 수 없습니다. ID : &quot;&lt;/span&gt; + userId);
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;실제 없는 userId 가 입력되면 조회 시 &lt;code&gt;Optional.empty()&lt;/code&gt; 인 빈 값이 나오도록 유도하였고, 해당 서비스의 findUser 메서드를 사용하여 제대로된 예외처리가 이루어지는지 확인하는 코드이다.&lt;/p&gt;

&lt;p class=&quot;arrow-note&quot;&gt;given 절에서 일반적인 &lt;code&gt;when&lt;/code&gt; 을 사용하는 경우와 달리 &lt;code&gt;given&lt;/code&gt; 이라는 키워드를 사용하였는데 이는 &lt;strong&gt;BDDMockito&lt;/strong&gt; 를 사용한 예시이다.&lt;/p&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    참고 :
    &lt;a class=&quot;ref-link&quot; href=&quot;https://velog.io/@lxxjn0/Mockito와-BDDMockito는-뭐가-다를까&quot; target=&quot;_blank&quot;&gt;
      Mockito 와 BDDMockito 는 뭐가 다를까
    &lt;/a&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h4&gt;참고 사항 — Mock 객체를 무조건 사용해야 하는 것은 아니다&lt;/h4&gt;

&lt;div class=&quot;file-label&quot;&gt;User 정보 수정 테스트 (실제 객체 사용)&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@DisplayName&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;성공 : 회원 정보 수정 정상 수행&quot;&lt;/span&gt;)
&lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; 유저_정보_수정() {

    &lt;span class=&quot;cm&quot;&gt;// given&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Long&lt;/span&gt; userId = 1L;
    &lt;span class=&quot;ty&quot;&gt;User&lt;/span&gt; realUser = &lt;span class=&quot;ty&quot;&gt;User&lt;/span&gt;.builder()
        .username(&lt;span class=&quot;str&quot;&gt;&quot;박태우&quot;&lt;/span&gt;)
        .phoneNumber(&lt;span class=&quot;str&quot;&gt;&quot;010-1234-5678&quot;&lt;/span&gt;)
        .email(&lt;span class=&quot;str&quot;&gt;&quot;wootaepark@naver.com&quot;&lt;/span&gt;)
        .build();

    given(userRepository.findByIdAndDeletedAtIsNull(userId)).willReturn(&lt;span class=&quot;ty&quot;&gt;Optional&lt;/span&gt;.of(realUser));

    &lt;span class=&quot;ty&quot;&gt;ModifyUserReqDto&lt;/span&gt; reqDto = &lt;span class=&quot;ty&quot;&gt;ModifyUserReqDto&lt;/span&gt;.builder()
        .phoneNumber(&lt;span class=&quot;str&quot;&gt;&quot;010-1111-2222&quot;&lt;/span&gt;)
        .build();

    &lt;span class=&quot;cm&quot;&gt;// when&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;UpdateUserResDto&lt;/span&gt; response = userService.modifyUser(userId, reqDto);

    &lt;span class=&quot;cm&quot;&gt;// then&lt;/span&gt;
    assertThat(realUser.getPhoneNumber()).isEqualTo(&lt;span class=&quot;str&quot;&gt;&quot;010-1111-2222&quot;&lt;/span&gt;);
    assertThat(response).isNotNull();
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;유저 정보를 수정하고 해당 객체의 수정이 제대로 이루어졌는지 확인하기 위해서는 mock 객체보다는 &lt;strong&gt;실제 객체 사용이 필요&lt;/strong&gt;하다.&lt;/p&gt;
  
  &lt;div&gt;
    이외 단위 테스트에 대한 자세한 공부는 (ex : JUnit, Mock) 다른 포스트에서 다룰것이다
  &lt;/div&gt;


&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  6. 통합 테스트                            --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;6. 통합 테스트&lt;/h2&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;개념&lt;/strong&gt; : 단위 모듈들이 서로 결합하여 함께 동작할 때 발생하는 &lt;strong&gt;상호작용 및 데이터 흐름&lt;/strong&gt;을 검증&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;주요 특징&lt;/strong&gt;
        &lt;ul&gt;
          &lt;li&gt;실제 DB 연동, 파일 시스템 접근, 외부 서비스와의 연결 부위에서 에러가 없는지 확인함.&lt;/li&gt;
          &lt;li&gt;단위 테스트보다 구성이 복잡하고 실행 시간이 상대적으로 오래 걸림&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;예시&lt;/strong&gt; : 회원 가입 서비스 실행 시, 실제 DB 테이블에 사용자 레코드가 올바르게 저장되는지 검증&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;통합 테스트 예시 코드&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@SpringBootTest&lt;/span&gt;     &lt;span class=&quot;cm&quot;&gt;// Spring Context 전체를 로드하여 통합 테스트 환경 구축&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@Transactional&lt;/span&gt;      &lt;span class=&quot;cm&quot;&gt;// 테스트 완료 후 DB 데이터를 자동으로 Rollback&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;UserServiceIntegrationTest&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;UserService&lt;/span&gt; userService;

    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;UserRepository&lt;/span&gt; userRepository;

    &lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
    &lt;span class=&quot;an&quot;&gt;@DisplayName&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;회원가입 신청 시 DB에 정상 저장되고 DTO가 반환되어야 한다&quot;&lt;/span&gt;)
    &lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; registerUser_Success() {
        &lt;span class=&quot;cm&quot;&gt;// Given&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;UserRegisterRequest&lt;/span&gt; request = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;UserRegisterRequest&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;hong&quot;&lt;/span&gt;, &lt;span class=&quot;str&quot;&gt;&quot;hong@example.com&quot;&lt;/span&gt;);

        &lt;span class=&quot;cm&quot;&gt;// When&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;UserResponse&lt;/span&gt; response = userService.registerUser(request);

        &lt;span class=&quot;cm&quot;&gt;// Then&lt;/span&gt;
        assertThat(response.getId()).isNotNull();
        assertThat(response.getName()).isEqualTo(&lt;span class=&quot;str&quot;&gt;&quot;hong&quot;&lt;/span&gt;);

        &lt;span class=&quot;cm&quot;&gt;// DB 직접 조회 검증&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;User&lt;/span&gt; savedUser = userRepository.findById(response.getId()).orElse(&lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;);
        assertThat(savedUser).isNotNull();
        assertThat(savedUser.getEmail()).isEqualTo(&lt;span class=&quot;str&quot;&gt;&quot;hong@example.com&quot;&lt;/span&gt;);
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;실제 데이터베이스와 연결하여 (Service → Repository → DB) 로 이어지는 레이어 간 연동을 검증한다.&lt;/p&gt;
&lt;p class=&quot;arrow-note&quot;&gt;실행 시 실제 Spring Boot 어플리케이션이 실행되며 실행 속도가 느린 편이다. (+ 실제 객체 사용)&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  7. E2E 테스트                             --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;7. E2E 테스트&lt;/h2&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;개념&lt;/strong&gt; : 사용자 관점에서 시스템의 처음부터 끝까지 전체 흐름(Flow)이 올바르게 작동하는지 검증&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;주요 특징&lt;/strong&gt;
        &lt;ul&gt;
          &lt;li&gt;UI, 백엔드 API, DB 등 실제 운영 환경과 유사한 조건에서 진행&lt;/li&gt;
          &lt;li&gt;시스템의 전체적인 신뢰도는 높여주지만, 실행 속도가 가장 느리고 테스트 유지보수 비용이 높다.&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;예시&lt;/strong&gt; : 로그인 → 아이디/비번 입력 → 대시보드 이동 → 장바구니 → 결제 까지의 전체 흐름을 브라우저 자동화 도구로 검증&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;E2E 테스트 코드 예시&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;// RANDOM_PORT: 실제로 톰캣(Embedded Tomcat) 서버를 포트에 띄움&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@SpringBootTest&lt;/span&gt;(webEnvironment = &lt;span class=&quot;ty&quot;&gt;SpringBootTest&lt;/span&gt;.&lt;span class=&quot;ty&quot;&gt;WebEnvironment&lt;/span&gt;.RANDOM_PORT)
&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;OrderE2ETest&lt;/span&gt; {

    &lt;span class=&quot;cm&quot;&gt;// 실제 HTTP 요청을 보낼 클라이언트 객체&lt;/span&gt;
    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;TestRestTemplate&lt;/span&gt; restTemplate;

    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;OrderRepository&lt;/span&gt; orderRepository;

    &lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
    &lt;span class=&quot;an&quot;&gt;@DisplayName&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;[E2E] 실제 HTTP POST 요청을 보내 주문을 생성하고 DB 저장까지 확인한다&quot;&lt;/span&gt;)
    &lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; createOrder_E2E_Success() {
        &lt;span class=&quot;cm&quot;&gt;// Given&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;OrderRequest&lt;/span&gt; request = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;OrderRequest&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;ITEM-001&quot;&lt;/span&gt;, 2, 30000);

        &lt;span class=&quot;cm&quot;&gt;// When: 실제 서버 엔드포인트 `/api/orders` 로 HTTP POST 요청 전송&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;ResponseEntity&lt;/span&gt;&amp;lt;&lt;span class=&quot;ty&quot;&gt;OrderResponse&lt;/span&gt;&amp;gt; response = restTemplate.postForEntity(
            &lt;span class=&quot;str&quot;&gt;&quot;/api/orders&quot;&lt;/span&gt;,
            request,
            &lt;span class=&quot;ty&quot;&gt;OrderResponse&lt;/span&gt;.&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt;
        );

        &lt;span class=&quot;cm&quot;&gt;// Then 1: HTTP 응답 상태 및 Body 검증&lt;/span&gt;
        assertThat(response.getStatusCode()).isEqualTo(&lt;span class=&quot;ty&quot;&gt;HttpStatus&lt;/span&gt;.CREATED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getItemCode()).isEqualTo(&lt;span class=&quot;str&quot;&gt;&quot;ITEM-001&quot;&lt;/span&gt;);
        assertThat(response.getBody().getTotalPrice()).isEqualTo(60000);

        &lt;span class=&quot;ty&quot;&gt;Long&lt;/span&gt; createdOrderId = response.getBody().getOrderId();

        &lt;span class=&quot;cm&quot;&gt;// Then 2: 실제 DB에 데이터가 올바르게 영속화되었는지 조회하여 확인&lt;/span&gt;
        &lt;span class=&quot;ty&quot;&gt;Order&lt;/span&gt; savedOrder = orderRepository.findById(createdOrderId).orElse(&lt;span class=&quot;kw&quot;&gt;null&lt;/span&gt;);
        assertThat(savedOrder).isNotNull();
        assertThat(savedOrder.getQuantity()).isEqualTo(2);
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;통합 테스트와 같이 실제 객체들로 테스트하며, 사용자 입장에서 하나의 서비스 flow 전체를 테스트하는 것과 같다.&lt;/p&gt;
&lt;p class=&quot;arrow-note&quot;&gt;여러 의존성 및 라이브러리를 가져와서 사용하기 때문에 &lt;strong&gt;제일 무거운 테스트&lt;/strong&gt;&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  8. 인수 테스트                            --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;8. 인수 테스트&lt;/h2&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;개념&lt;/strong&gt; : 시스템이 &lt;strong&gt;비즈니스 요구사항을 충족했는지&lt;/strong&gt; 사용자 또는 고객(PO/PM) 관점에서 검증&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;주요 특징&lt;/strong&gt;
        &lt;ul&gt;
          &lt;li&gt;&quot;기술적으로 잘 작동하는가?&quot; 보다 &lt;strong&gt;&quot;사용자가 요청한 기능 명세대로 동작하는가?&quot;&lt;/strong&gt; 에 초점&lt;/li&gt;
          &lt;li&gt;시나리오 기반(BDD)으로 작성하는 경우가 많으며, 해당 테스트 통과 시 배포/수락 가능한 상태로 판단&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;예시&lt;/strong&gt; : &quot;일반 회원이 쿠폰을 적용하면 최종 결제 금액에서 10% 가 할인되어야 한다&quot; 라는 기획 요구사항을 검증&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;코드로 작성되기보다는 비즈니스 테스트 관점이 더 크다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  9. 마치며                                 --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;9. 마치며&lt;/h2&gt;

&lt;div class=&quot;closing-card&quot;&gt;
  테스트 코드는 프로젝트 환경과 여건에 맞춰 &lt;strong&gt;유연하게 적용&lt;/strong&gt;해야 함을 배웠다.&lt;br&gt;&lt;br&gt;
  기존에는 무조건 단위 테스트 위주로 테스트를 진행해야겠다고 생각했지만,
  공부해보면서 무조건 테스트 코드를 작성하기보다는 &lt;strong&gt;꼭 필요한 곳에 대한 테스트를 적재적소에 사용&lt;/strong&gt;해야겠다는 생각이 들었다.&lt;br&gt;&lt;br&gt;
  앞으로도 더 많은 프로젝트를 진행하며 이번 이론을 바탕으로 실제 테스트 코드를 더 많이 경험하고 적용하고 싶다.&lt;br&gt;&lt;br&gt;
  
  다음 포스트에는 단위 테스트 중에서 제일 많이 사용되는 JUnit 사용 방법에 대해서 자세히 알아 볼 것이다.
&lt;/div&gt;

&lt;br&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;참고&lt;/strong&gt;
    &lt;ul style=&quot;margin-top: 6px;&quot;&gt;
      &lt;li&gt;Gemini&lt;/li&gt;
      &lt;li&gt;
        &lt;a class=&quot;ref-link&quot; href=&quot;https://jhzlo.tistory.com/28&quot; target=&quot;_blank&quot;&gt;
          https://jhzlo.tistory.com/28
        &lt;/a&gt;
      &lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Spring boot</category>
      <category>JUnit</category>
      <category>spring boot</category>
      <category>Test</category>
      <category>테스트</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/7</guid>
      <comments>https://twdev.tistory.com/7#entry7comment</comments>
      <pubDate>Wed, 22 Jul 2026 16:47:46 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 환경 변수 관리 (with @Value, @ConfigurationProperties)</title>
      <link>https://twdev.tistory.com/6</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Noto Sans KR', sans-serif;
    background: #ffffff;
    color: #37352f;
    line-height: 1.75;
    font-size: 15px;
    max-width: 7800px;
    margin: 0 auto;
    padding: 48px 24px 80px;
  }

  /* ── 헤더 ── */
  .page-title {
    font-size: 28px;
    font-weight: 700;
    color: #1a1a1a;
    margin-bottom: 8px;
    line-height: 1.3;
  }
  .page-subtitle {
    font-size: 14px;
    color: #9b9b9b;
    margin-bottom: 40px;
  }

  /* ── 섹션 헤더 ── */
  h2 {
    font-size: 20px;
    font-weight: 700;
    color: #1a1a1a;
    margin: 52px 0 16px;
    padding-bottom: 8px;
    border-bottom: 2px solid #f0f0f0;
  }
  h3 {
    font-size: 16px;
    font-weight: 600;
    color: #37352f;
    margin: 36px 0 12px;
  }
  h4 {
    font-size: 14px;
    font-weight: 600;
    color: #37352f;
    margin: 28px 0 10px;
  }

  /* ── 개요 카드 ── */
  .overview-card {
    background: #f7f6f3;
    border-radius: 8px;
    padding: 18px 20px;
    margin: 16px 0 32px;
    font-size: 14px;
    color: #555;
    line-height: 1.8;
  }
  .overview-card strong { color: #37352f; }

  /* ── 파일 라벨 ── */
  .file-label {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: #f0f0f0;
    border-radius: 5px;
    padding: 3px 10px;
    font-size: 12.5px;
    font-weight: 600;
    color: #555;
    margin-bottom: 6px;
    font-family: 'JetBrains Mono', monospace;
  }
  .file-label::before { content: ' '; font-size: 11px; }

  /* ── 코드 블록 ── */
  .code-block {
    background: #1e1e2e;
    border-radius: 8px;
    padding: 20px;
    margin: 0 0 20px;
    overflow-x: auto;
    position: relative;
  }
  .code-block .lang-tag {
    position: absolute;
    top: 10px;
    right: 14px;
    font-size: 10px;
    color: #6c7086;
    font-family: 'JetBrains Mono', monospace;
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }
  .code-block pre {
    font-family: 'JetBrains Mono', monospace;
    font-size: 13px;
    line-height: 1.7;
    color: #cdd6f4;
    white-space: pre;
  }
  /* 간단한 syntax coloring */
  .code-block .kw  { color: #cba6f7; } /* keyword */
  .code-block .an  { color: #89b4fa; } /* annotation */
  .code-block .str { color: #a6e3a1; } /* string */
  .code-block .cm  { color: #6c7086; font-style: italic; } /* comment */
  .code-block .ty  { color: #f9e2af; } /* type */

  /* ── 콜아웃 ── */
  .callout {
    display: flex;
    gap: 12px;
    background: #f0f7ff;
    border-left: 4px solid #4a90e2;
    border-radius: 0 8px 8px 0;
    padding: 14px 16px;
    margin: 16px 0;
    font-size: 14px;
    color: #37352f;
    line-height: 1.8;
  }
  .callout .icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
  .callout ul, .callout ol { padding-left: 20px; margin-top: 6px; }
  .callout li { margin-bottom: 4px; }
  .callout strong { color: #1a5fa8; }
  .callout code {
    background: #dbeafe;
    padding: 1px 5px;
    border-radius: 3px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #1d4ed8;
  }

  .callout.green {
    background: #f0fdf4;
    border-left-color: #22c55e;
  }
  .callout.green strong { color: #15803d; }
  .callout.green code { background: #dcfce7; color: #166534; }

  .callout.yellow {
    background: #fffbeb;
    border-left-color: #f59e0b;
  }
  .callout.yellow strong { color: #92400e; }

  .callout.gray {
    background: #f7f6f3;
    border-left-color: #9ca3af;
  }

  /* ── 인용 ── */
  blockquote {
    border-left: 3px solid #e0e0e0;
    padding: 4px 16px;
    margin: 12px 0;
    color: #6b7280;
    font-size: 14px;
    line-height: 1.8;
  }

  /* ── 결과 이미지 영역 ── */
  .result-box {
    background: #fafafa;
    border: 1px solid #ebebeb;
    border-radius: 8px;
    padding: 16px 20px;
    margin: 12px 0 20px;
    font-size: 13px;
    color: #9ca3af;
    text-align: center;
    font-style: italic;
  }
  .result-box::before { content: ' ️  결과 이미지'; display: block; margin-bottom: 2px; font-style: normal; }

  /* 실제 티스토리 이미지 태그 자리 */
  .img-placeholder {
    margin: 12px 0 20px;
    border-radius: 8px;
    overflow: hidden;
    border: 1px solid #ebebeb;
  }

  /* ── 구분선 ── */
  hr {
    border: none;
    border-top: 1px solid #f0f0f0;
    margin: 44px 0;
  }

  /* ── 일반 텍스트 ── */
  p {
    margin: 10px 0;
    font-size: 15px;
    color: #37352f;
  }
  ul, ol { padding-left: 22px; margin: 8px 0; }
  li { margin-bottom: 4px; font-size: 14px; color: #37352f; }

  code {
    background: #f0f0f0;
    padding: 1px 5px;
    border-radius: 4px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12.5px;
    color: #d44000;
  }

  /* ── 비교 표 ── */
  .compare-table {
    width: 100%;
    border-collapse: collapse;
    margin: 16px 0;
    font-size: 13.5px;
  }
  .compare-table th {
    background: #f7f6f3;
    padding: 10px 14px;
    text-align: left;
    font-weight: 600;
    border: 1px solid #e8e8e8;
    color: #37352f;
  }
  .compare-table td {
    padding: 9px 14px;
    border: 1px solid #e8e8e8;
    color: #555;
    vertical-align: top;
  }
  .compare-table tr:nth-child(even) td { background: #fafafa; }

  /* ── 배지 ── */
  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 20px;
    font-size: 11.5px;
    font-weight: 600;
    margin-right: 4px;
  }
  .badge-blue  { background: #dbeafe; color: #1d4ed8; }
  .badge-green { background: #dcfce7; color: #166534; }

  /* ── 단계 설명 (arrow) ── */
  .arrow-note {
    font-size: 13.5px;
    color: #4a90e2;
    margin: 10px 0 16px;
    font-weight: 500;
  }
  .arrow-note::before { content: '⇒ '; }

  /* ── 섹션 구분 태그 ── */
  .section-tag {
    display: inline-block;
    background: #ede9fe;
    color: #7c3aed;
    border-radius: 4px;
    font-size: 11px;
    font-weight: 700;
    padding: 2px 8px;
    margin-bottom: 6px;
    letter-spacing: 0.03em;
    text-transform: uppercase;
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  0. 들어가기 앞서                          --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;div class=&quot;page-title&quot;&gt;@Value vs @ConfigurationProperties&lt;/div&gt;

&lt;h2&gt;0. 들어가기 앞서&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  Spring 에서의 환경 변수 관리 방법이 &lt;strong&gt;@Value&lt;/strong&gt; 를 사용하는 방법만 알고 있었는데,
  공부하다 보니 좀 더 체계적으로 관리할 수 있는 방법이 있다는 것을 알게 되어
  비교하고 각각의 장단점을 정리해보고자 한다.
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- 0-1 --&gt;
&lt;h2&gt;0-1. 기본 환경 변수 설정&lt;/h2&gt;

&lt;div class=&quot;file-label&quot;&gt;application.properties&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;properties&lt;/span&gt;
  &lt;pre&gt;greeting-name=taewoo
greeting-coffee=${greeting-name} is drinking Cafe Cereza&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;application.yml&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;yaml&lt;/span&gt;
  &lt;pre&gt;greeting:
  name: taewoo
  coffee: ${greeting.name} is drinking Cafe Cereza&lt;/pre&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  1. @Value                                 --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;1. @Value 를 사용하는 경우&lt;/h2&gt;

&lt;p&gt;아래와 같이 &lt;strong&gt;POJO&lt;/strong&gt; 형태의 클래스를 먼저 만들었다.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;POJO : 특정 기술이나 프레임워크에 종속되지 않은, 순수한 형태의 기본 자바 객체&lt;/li&gt;
&lt;/ul&gt;

&lt;br&gt;

&lt;div class=&quot;file-label&quot;&gt;Coffee.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Component&lt;/span&gt; &lt;span class=&quot;cm&quot;&gt;// 빈 등록 필수&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Value&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;${greeting-name:someone}&quot;&lt;/span&gt;)
    &lt;span class=&quot;cm&quot;&gt;// @Value(&quot;${greeting.name:someone}&quot;) → yml 파일의 경우&lt;/span&gt;
    &lt;span class=&quot;cm&quot;&gt;// application.properties 의 greeting-name 변수 확인 → 없으면 someone 으로 설정&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name;

    &lt;span class=&quot;an&quot;&gt;@Value&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;${greeting-coffee:${greeting-name} is drinking ice coffee}&quot;&lt;/span&gt;)
    &lt;span class=&quot;cm&quot;&gt;// greeting-coffee 변수 확인 → 없으면 : 뒤의 값으로 설정&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; place;

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt;() { &lt;span class=&quot;cm&quot;&gt;// 우선순위 확인을 위한 기본 생성자&lt;/span&gt;
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;기본생성자&quot;&lt;/span&gt;, &lt;span class=&quot;str&quot;&gt;&quot;여기 있어요&quot;&lt;/span&gt;);
    }

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name, &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; place) {
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;.name = name;
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;.place = place;
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;테스트 코드&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@SpringBootTest&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeServiceTest&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeService&lt;/span&gt; coffeeService;

    &lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; getCoffee() {
        System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;커피 주문자 : &quot;&lt;/span&gt; + coffeeService.getCoffee().getName());
        System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;카페 장소 : &quot;&lt;/span&gt; + coffeeService.getCoffee().getPlace());
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;이렇게 기본 생성자가 정의되어 있어도 호출하는 방식에 따라 결과가 다르게 나온다. (개발자 입장에서 헷갈릴 수 있음)&lt;/blockquote&gt;

&lt;hr&gt;

&lt;!-- 1-1 --&gt;
&lt;h3&gt;1-1. Spring 컨테이너에 의해 빈으로 생성되는 경우&lt;/h3&gt;

&lt;div class=&quot;file-label&quot;&gt;CoffeeService.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Service&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeService&lt;/span&gt; {

    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; coffee;

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; getCoffee() {
        &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; coffee;
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;동작 과정&lt;/strong&gt;
    &lt;ol&gt;
      &lt;li&gt;&lt;code&gt;Coffee()&lt;/code&gt; &amp;lt;기본생성자&amp;gt; 호출&lt;/li&gt;
      &lt;li&gt;&lt;code&gt;@Value&lt;/code&gt; 가 붙은 필드에 설정파일(properties, yml) 의 값을 주입한다.&lt;/li&gt;
      &lt;li&gt;결과적으로 환경변수가 기존 생성자로 초기화된 값을 &lt;strong&gt;덮어쓰게&lt;/strong&gt; 된다.&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bo7dJM/dJMcabY9oGd/tUHIB6kI0Tq6DDR8JsaJ2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bo7dJM/dJMcabY9oGd/tUHIB6kI0Tq6DDR8JsaJ2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bo7dJM/dJMcabY9oGd/tUHIB6kI0Tq6DDR8JsaJ2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbo7dJM%2FdJMcabY9oGd%2FtUHIB6kI0Tq6DDR8JsaJ2K%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;hr&gt;

&lt;!-- 1-2 --&gt;
&lt;h3&gt;1-2. 직접 클래스 호출&lt;/h3&gt;

&lt;div class=&quot;file-label&quot;&gt;CoffeeService.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Service&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeService&lt;/span&gt; {

    &lt;span class=&quot;cm&quot;&gt;// bean 을 통해 주입되는 것이 아니라 직접 생성 (@Component 가 있으나 마나)&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; coffee = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt;();

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; getCoffee() {
        &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; coffee;
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;⚠️&lt;/span&gt;
  &lt;div&gt;
    Spring 컨테이너에서 주입받은 (DI) 객체가 아닌 &lt;strong&gt;새로 만든 객체&lt;/strong&gt;이기 때문에
    &lt;code&gt;@Value&lt;/code&gt; 가 동작하지 않는다.&lt;br&gt;
    따라서 일반적인 객체 생성과정과 동일하게 이루어지기 때문에 아래와 같은 결과가 나온다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q1rr5/dJMcabrg2WG/QhU2pDk06eFLS7CyehMqb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q1rr5/dJMcabrg2WG/QhU2pDk06eFLS7CyehMqb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q1rr5/dJMcabrg2WG/QhU2pDk06eFLS7CyehMqb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq1rr5%2FdJMcabrg2WG%2FQhU2pDk06eFLS7CyehMqb1%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;hr&gt;

&lt;!-- 1-3 --&gt;
&lt;h3&gt;1-3. @Value 방식의 단점&lt;/h3&gt;

&lt;div class=&quot;callout yellow&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;❗&lt;/span&gt;
  &lt;div&gt;
    &lt;ol&gt;
      &lt;li style=&quot;margin-bottom:10px&quot;&gt;
        &lt;strong&gt;테스트의 어려움&lt;/strong&gt;&lt;br&gt;
        단위 테스트를 위해 객체 생성 시 Spring 컨텍스트를 반드시 띄워야 한다.&lt;br&gt;
        단순 객체 테스트를 위해 Spring 프레임워크를 의존해야 하여 테스트 속도가 느려진다.
      &lt;/li&gt;
      &lt;li style=&quot;margin-bottom:10px&quot;&gt;
        &lt;strong&gt;final 키워드와의 충돌&lt;/strong&gt;&lt;br&gt;
        &lt;code&gt;private final String name;&lt;/code&gt; 과 같이 final 로 선언하고 싶어도
        &lt;code&gt;@Value&lt;/code&gt; 는 Spring 이 객체 생성 후 &lt;code&gt;리플렉션(Reflection API)&lt;/code&gt; 을 통해 억지로 집어넣는 방식이다.&lt;br&gt;
        final 은 생성자에서 초기화되어야 하는데 필드 주입은 객체 생성 이후에 발생 → final 사용 불가 또는 런타임 오류 발생 가능
      &lt;/li&gt;
      &lt;li&gt;
        &lt;strong&gt;순환 참조 및 초기화 시점 문제&lt;/strong&gt;&lt;br&gt;
        &lt;code&gt;@Value&lt;/code&gt; 주입 시점은 &lt;code&gt;Bean&lt;/code&gt; 생성 완료 이후이다.&lt;br&gt;
        생성자 내부 로직에서 &lt;code&gt;@Value&lt;/code&gt; 로 주입받은 변수를 사용하려 하면 주입 전이므로 &lt;strong&gt;null&lt;/strong&gt; 값이 나온다.
      &lt;/li&gt;
    &lt;/ol&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;하지만 이러한 단점 외에도 간결함 및 빠른 프로토타이핑 (복잡 설정 없이 빠르게 기능 구현하거나, 단순 설정 클래스 만들 때) 생산성이 높다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- 1-4 --&gt;
&lt;h3&gt;1-4. 단점 극복 방안 — @Value 를 이용한 생성자 주입&lt;/h3&gt;

&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Component&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name;
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; place;

    &lt;span class=&quot;cm&quot;&gt;// 생성자 주입&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt;(
        &lt;span class=&quot;an&quot;&gt;@Value&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;${greeting-name:someone}&quot;&lt;/span&gt;) &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name,
        &lt;span class=&quot;an&quot;&gt;@Value&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;${greeting-coffee:${greeting-name} is drinking ice coffee}&quot;&lt;/span&gt;) &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; place
    ) {
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;.name = name;
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;.place = place;
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;callout green&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt;✅&lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;효과&lt;/strong&gt;
    &lt;ol&gt;
      &lt;li&gt;&lt;strong&gt;테스트 용이&lt;/strong&gt; : &lt;code&gt;new Coffee(&quot;이름&quot;, &quot;장소&quot;);&lt;/code&gt; 로 간단히 단위 테스트 가능&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;불변성 보장&lt;/strong&gt; : final 필드를 안전하게 사용 가능&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;순환 참조 방지&lt;/strong&gt; : 객체 생성 시점에 모든 의존성이 주입되므로 초기화 오류 없음&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;DI 프레임워크 의존성 분리&lt;/strong&gt; : Spring 이 아닌 다른 환경에서도 독립적으로 동작 가능&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- 1-5 --&gt;
&lt;h3&gt;1-5. 생성자 주입의 결과&lt;/h3&gt;

&lt;p&gt;&lt;span class=&quot;badge badge-blue&quot;&gt;Value 미동작&lt;/span&gt; 일반 객체처럼 사용&lt;/p&gt;

&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Service&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeService&lt;/span&gt; {

    &lt;span class=&quot;cm&quot;&gt;// 기본생성자 호출 시 컴파일 에러&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; coffee = &lt;span class=&quot;kw&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;이름&quot;&lt;/span&gt;, &lt;span class=&quot;str&quot;&gt;&quot;장소&quot;&lt;/span&gt;);

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; getCoffee() { &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; coffee; }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OP9F0/dJMcag69g4l/s9HRHQcny4tykx03c4zqCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OP9F0/dJMcag69g4l/s9HRHQcny4tykx03c4zqCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OP9F0/dJMcag69g4l/s9HRHQcny4tykx03c4zqCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOP9F0%2FdJMcag69g4l%2Fs9HRHQcny4tykx03c4zqCK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;br&gt;

&lt;p&gt;&lt;span class=&quot;badge badge-green&quot;&gt;Value 동작&lt;/span&gt; 의존성 주입 사용&lt;/p&gt;

&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Service&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeService&lt;/span&gt; {

    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; coffee;

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Coffee&lt;/span&gt; getCoffee() { &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; coffee; }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTo3Va/dJMcafULOEq/UxsJWTAJl0VPvKW9Kiczhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTo3Va/dJMcafULOEq/UxsJWTAJl0VPvKW9Kiczhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTo3Va/dJMcafULOEq/UxsJWTAJl0VPvKW9Kiczhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTo3Va%2FdJMcafULOEq%2FUxsJWTAJl0VPvKW9Kiczhk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  2. @ConfigurationProperties              --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;2. @ConfigurationProperties&lt;/h2&gt;

&lt;div class=&quot;overview-card&quot;&gt;
  &lt;strong&gt;사용 의도&lt;/strong&gt; : 속성을 정의하고 관련 속성을 그룹화해서, 도구로 검증 가능하고 타입 세이프한 방식으로 속성을 참조하고 사용하기 위함
&lt;/div&gt;

&lt;h3&gt;2-1. 사용해보기&lt;/h3&gt;

&lt;div class=&quot;file-label&quot;&gt;Greeting.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;greeting&quot;&lt;/span&gt;)
&lt;span class=&quot;cm&quot;&gt;// greeting.name, greeting.coffee 처럼 application.properties 에 설정 가능&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Greeting&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; coffee;
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;TaepangApplication.java (main)&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@SpringBootApplication&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@ConfigurationPropertiesScan&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;// @ConfigurationProperties 를 스캔하기 위해 필요 (또는 Greeting 에 @Component 추가)&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;TaepangApplication&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;public static void&lt;/span&gt; main(&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt;[] args) {
        SpringApplication.run(&lt;span class=&quot;ty&quot;&gt;TaepangApplication&lt;/span&gt;.&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt;, args);
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;CoffeeService.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Service&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@RequiredArgsConstructor&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeService&lt;/span&gt; {

    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Greeting&lt;/span&gt; greeting; &lt;span class=&quot;cm&quot;&gt;// 의존성 주입&lt;/span&gt;

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Greeting&lt;/span&gt; getCoffee() { &lt;span class=&quot;kw&quot;&gt;return&lt;/span&gt; greeting; }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;build.gradle&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;groovy&lt;/span&gt;
  &lt;pre&gt;implementation &lt;span class=&quot;str&quot;&gt;'org.springframework.boot:spring-boot-configuration-processor'&lt;/span&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;application.properties&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;properties&lt;/span&gt;
  &lt;pre&gt;greeting.name=newName
greeting.coffee=helloCoffee
&lt;span class=&quot;cm&quot;&gt;// 위 의존성 추가 이후 재빌드 하면 위와 같이 기본 변수 설정이 가능하다.&lt;/span&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;테스트 코드&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@SpringBootTest&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeServiceTest&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Greeting&lt;/span&gt; greeting;

    &lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; getCoffee() {
        System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;커피 주문자 : &quot;&lt;/span&gt; + greeting.getName());
        System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;카페 장소 : &quot;&lt;/span&gt; + greeting.getCoffee());
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;테스트 결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tEPoo/dJMcag69g94/yNCA2hiyb3GP12B1EUGn9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tEPoo/dJMcag69g94/yNCA2hiyb3GP12B1EUGn9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tEPoo/dJMcag69g94/yNCA2hiyb3GP12B1EUGn9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtEPoo%2FdJMcag69g94%2FyNCA2hiyb3GP12B1EUGn9k%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;br&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;@Value 와의 차이&lt;/strong&gt;
    &lt;ol style=&quot;margin-top:8px&quot;&gt;
      &lt;li style=&quot;margin-bottom:8px&quot;&gt;설정(환경 변수)을 하나의 POJO 로 정의할 수 있다.&lt;/li&gt;
      &lt;li style=&quot;margin-bottom:8px&quot;&gt;IDE 의 자동완성 가능&lt;/li&gt;
      &lt;li style=&quot;margin-bottom:8px&quot;&gt;
        &lt;strong&gt;기본값 관리의 명확성&lt;/strong&gt; : 코드 내에서 직접 확인 가능
        &lt;div class=&quot;code-block&quot; style=&quot;margin-top:8px; margin-bottom:0&quot;&gt;
          &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
          &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;coffee&quot;&lt;/span&gt;)
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeProperties&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name = &lt;span class=&quot;str&quot;&gt;&quot;someone&quot;&lt;/span&gt;;       &lt;span class=&quot;cm&quot;&gt;// 자바 자체의 초기값&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; place = &lt;span class=&quot;str&quot;&gt;&quot;drinking ice coffee&quot;&lt;/span&gt;;
}&lt;/pre&gt;
        &lt;/div&gt;
        객체 생성 시점에 이미 초기값이 설정되므로, Spring 이 값을 주입하지 않아도 항상 안전하고 유효한 상태이다.
      &lt;/li&gt;
      &lt;li style=&quot;margin-bottom:8px&quot;&gt;List, Map, 중첩 객체 등 계층형 구조를 깔끔하게 매핑 가능하다.&lt;/li&gt;
      &lt;li&gt;
        &lt;strong&gt;@Valid 와 함께 사용하여 시작 시 설정값 검증 가능&lt;/strong&gt; (오류 발생 시 서버 중단) — &lt;strong&gt;제일 중요&lt;/strong&gt;
        &lt;div class=&quot;code-block&quot; style=&quot;margin-top:8px; margin-bottom:0&quot;&gt;
          &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
          &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;coffee&quot;&lt;/span&gt;)
&lt;span class=&quot;an&quot;&gt;@Validated&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeProperties&lt;/span&gt; {
    &lt;span class=&quot;an&quot;&gt;@NotBlank&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name;  &lt;span class=&quot;cm&quot;&gt;// 반드시 값이 있어야 함&lt;/span&gt;

    &lt;span class=&quot;an&quot;&gt;@Min&lt;/span&gt;(1)
    &lt;span class=&quot;kw&quot;&gt;private int&lt;/span&gt; price;    &lt;span class=&quot;cm&quot;&gt;// 가격은 최소 1 이상이어야 함&lt;/span&gt;
}&lt;/pre&gt;
        &lt;/div&gt;
      &lt;/li&gt;
    &lt;/ol&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  3. 서드 파티                              --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;3. @ConfigurationProperties 의 추가 기능 — 서드 파티 옵션&lt;/h2&gt;

&lt;h3&gt;3-1. 서드 파티란?&lt;/h3&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;서드 파티(Third-party)&lt;/strong&gt;는 '제3자'를 뜻한다. 원천 기술이나 서비스를 제공하는 당사자(퍼스트 파티)와
    이를 이용하는 소비자(세컨드 파티)가 아닌, &lt;strong&gt;외부의 다른 개발자나 기업&lt;/strong&gt;을 의미 (from 나무위키)&lt;br&gt;&lt;br&gt;
    &lt;strong&gt;Spring 관점의 서드 파티&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;JDBC / ORM&lt;/li&gt;
      &lt;li&gt;Auth0, Okta&lt;/li&gt;
      &lt;li&gt;Lombok, Swagger&lt;/li&gt;
      &lt;li&gt;Redis, Kafka, AWS SDK&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;Spring 프로젝트에 외부 의존성을 추가하고 해당 의존성(컴포넌트) 문서를 참조해 스프링 빈을 생성할 때 &lt;code&gt;@ConfigurationProperties&lt;/code&gt; 가 유용하게 사용된다.&lt;/p&gt;

&lt;hr&gt;

&lt;h3&gt;3-2. 예제 컴포넌트 생성해서 적용해보기&lt;/h3&gt;

&lt;div class=&quot;file-label&quot;&gt;Droid.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; { &lt;span class=&quot;cm&quot;&gt;// 외부에서 가져온 서드 파티 컴포넌트라고 가정&lt;/span&gt;
    &lt;span class=&quot;cm&quot;&gt;// getter, setter 를 통해 Reflection API 로 환경변수가 설정된다.&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; id, description;
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;SpringConfig.java — 빈 등록 (수동)&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Configuration&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;SpringConfig&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Bean&lt;/span&gt;
    &lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;droid&quot;&lt;/span&gt;)
    &lt;span class=&quot;cm&quot;&gt;// 외부 서드파티에 직접 붙일 수 없는 경우 사용 (원래는 클래스 위가 권장)&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; droid() {
        &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;(); &lt;span class=&quot;cm&quot;&gt;// Setter 를 통해 객체의 설정값 주입&lt;/span&gt;
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;application.properties&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;properties&lt;/span&gt;
  &lt;pre&gt;droid.id=BB-8
droid.description=Small, rolling android, Doesn't drink milk.&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;테스트 코드&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@SpringBootTest&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;CoffeeServiceTest&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Autowired&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; droid;

    &lt;span class=&quot;an&quot;&gt;@Test&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;void&lt;/span&gt; getCoffee() {
        System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;드로이드 id : &quot;&lt;/span&gt; + droid.getId());
        System.out.println(&lt;span class=&quot;str&quot;&gt;&quot;드로이드 설명 : &quot;&lt;/span&gt; + droid.getDescription());
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjVyrj/dJMb99718Kd/0ZHRnxCtKzxx1t1RyA8kO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjVyrj/dJMb99718Kd/0ZHRnxCtKzxx1t1RyA8kO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjVyrj/dJMb99718Kd/0ZHRnxCtKzxx1t1RyA8kO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjVyrj%2FdJMb99718Kd%2F0ZHRnxCtKzxx1t1RyA8kO0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;p class=&quot;arrow-note&quot;&gt;이런 식으로 &lt;code&gt;@ConfigurationProperties&lt;/code&gt; 를 이용하여 서드 파티 컴포넌트의 환경변수를 설정하고 사용 가능하다.&lt;/p&gt;

&lt;hr&gt;

&lt;!-- 3-3 --&gt;
&lt;h3&gt;3-3. Setter 없이 생성자로만 빈 등록해서 사용하기 (서드 파티)&lt;/h3&gt;

&lt;div class=&quot;file-label&quot;&gt;Droid.java — record 클래스로 변경&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;// record 클래스 (@Getter, @RequiredArgsConstructor 포함)&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public record&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; id, &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; description) {
    &lt;span class=&quot;cm&quot;&gt;// droid.id(); / droid.description(); 처럼 접근 가능&lt;/span&gt;
}

&lt;span class=&quot;cm&quot;&gt;// ─── 아래 코드와 동일함 ───&lt;/span&gt;

&lt;span class=&quot;an&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;private final&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; id, description;

    &lt;span class=&quot;kw&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; id, &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; description) {
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;.id = id;
        &lt;span class=&quot;kw&quot;&gt;this&lt;/span&gt;.description = description;
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;h4&gt;3-3-1. @Value 사용&lt;/h4&gt;

&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Configuration&lt;/span&gt;
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;SpringConfig&lt;/span&gt; {

    &lt;span class=&quot;an&quot;&gt;@Value&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;${droid.id:defaultId}&quot;&lt;/span&gt;)
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; id;

    &lt;span class=&quot;an&quot;&gt;@Value&lt;/span&gt;(&lt;span class=&quot;str&quot;&gt;&quot;${droid.description:defaultDesc}&quot;&lt;/span&gt;)
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; description;

    &lt;span class=&quot;an&quot;&gt;@Bean&lt;/span&gt;
    &lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;droid&quot;&lt;/span&gt;)
    &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; droid() {
        &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;(id, description);
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;callout&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;환경 변수를 가져와서 생성자를 통해 Bean 등록 (필요 변수)&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dil3te/dJMcaa0aXhB/wTrOKUwAK3afiAi4wfkh8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dil3te/dJMcaa0aXhB/wTrOKUwAK3afiAi4wfkh8K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dil3te/dJMcaa0aXhB/wTrOKUwAK3afiAi4wfkh8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdil3te%2FdJMcaa0aXhB%2FwTrOKUwAK3afiAi4wfkh8K%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;h4&gt;3-3-2. @ConfigurationProperties 사용&lt;/h4&gt;

&lt;div class=&quot;file-label&quot;&gt;Droid.java 수정&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;droid&quot;&lt;/span&gt;)
&lt;span class=&quot;kw&quot;&gt;public record&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; id, &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; description) { }&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;해당 클래스를 환경변수에 이용한다는 &lt;code&gt;@ConfigurationProperties&lt;/code&gt; 설정&lt;/p&gt;

&lt;div class=&quot;file-label&quot;&gt;SpringConfig.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Configuration&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;// 방법 1. Droid 를 빈 등록 (이것만 쓰는 경우 Setter 필수 / @ConfigurationPropertiesScan 없어도 됨)&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@EnableConfigurationProperties&lt;/span&gt;(&lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;.&lt;span class=&quot;kw&quot;&gt;class&lt;/span&gt;)
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;SpringConfig&lt;/span&gt; {

    &lt;span class=&quot;cm&quot;&gt;// 방법 2. 직접 빈 등록 (Setter 없는 경우 보통 이 방법 사용 / @ConfigurationPropertiesScan 필수)&lt;/span&gt;
    &lt;span class=&quot;an&quot;&gt;@Bean&lt;/span&gt;
    &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; droid(&lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt; dro) {
        &lt;span class=&quot;kw&quot;&gt;return new&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Droid&lt;/span&gt;(dro.id(), dro.description());
    }
}&lt;/pre&gt;
&lt;/div&gt;

&lt;p class=&quot;arrow-note&quot;&gt;방법 한 개만 써야 중복 빈 등록이 방지된다. (상황에 맞게 사용)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l3lSf/dJMcadWUcYc/zl7fjll3bIG1OwhdcOrFKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l3lSf/dJMcadWUcYc/zl7fjll3bIG1OwhdcOrFKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l3lSf/dJMcadWUcYc/zl7fjll3bIG1OwhdcOrFKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl3lSf%2FdJMcadWUcYc%2Fzl7fjll3bIG1OwhdcOrFKk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;br&gt;

&lt;div class=&quot;callout green&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    &lt;strong&gt;정리&lt;/strong&gt;
    &lt;ul style=&quot;margin-top:6px&quot;&gt;
      &lt;li&gt;&lt;strong&gt;Setter 가 있으면&lt;/strong&gt; : &lt;code&gt;@ConfigurationPropertiesScan&lt;/code&gt; 없어도 됨. 여러 방법 존재 (위 SpringConfig.java 참고)&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;생성자를 통해 초기화해야 하면&lt;/strong&gt; : &lt;code&gt;@ConfigurationPropertiesScan&lt;/code&gt; 필수 (환경 변수 주입이 필요하기 때문)&lt;/li&gt;
    &lt;/ul&gt;
    &lt;br&gt;
      &lt;strong&gt;결론 : 웬만하면 &lt;code&gt;@ConfigurationPropertiesScan&lt;/code&gt; 사용하면서 생성자를 통해 초기화하자!!&lt;/strong&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  4. .env TIP                              --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;4. .env 와 @ConfigurationProperties 사용 (TIP)&lt;/h2&gt;

&lt;div class=&quot;file-label&quot;&gt;Greeting.java&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;java&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;an&quot;&gt;@Getter&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@Setter&lt;/span&gt;
&lt;span class=&quot;an&quot;&gt;@ConfigurationProperties&lt;/span&gt;(prefix = &lt;span class=&quot;str&quot;&gt;&quot;greeting&quot;&lt;/span&gt;)
&lt;span class=&quot;kw&quot;&gt;public class&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;Greeting&lt;/span&gt; {
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; name;   &lt;span class=&quot;cm&quot;&gt;// GREETING_NAME 매핑&lt;/span&gt;
    &lt;span class=&quot;kw&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;ty&quot;&gt;String&lt;/span&gt; coffee; &lt;span class=&quot;cm&quot;&gt;// GREETING_COFFEE 매핑&lt;/span&gt;
}&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;.env&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;pre&gt;GREETING_NAME=envName
GREETING_COFFEE=envCoffee&lt;/pre&gt;
&lt;/div&gt;

&lt;div class=&quot;file-label&quot;&gt;application.properties&lt;/div&gt;
&lt;div class=&quot;code-block&quot;&gt;
  &lt;span class=&quot;lang-tag&quot;&gt;properties&lt;/span&gt;
  &lt;pre&gt;&lt;span class=&quot;cm&quot;&gt;# 실제 배포 시 환경 변수로 관리 (해당 파일이 GitHub 에 올라가야 하기 때문)&lt;/span&gt;
greeting.name=${GREETING_NAME:someone}
greeting.coffee=${GREETING_COFFEE:americano}&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;Intellij 설정 (화면 우측 상단의 editConfiguration 설정을 하면 된다.)&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qhOlC/dJMcagTEOLI/wHwQLYWPf9ZV1u489sHRjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qhOlC/dJMcagTEOLI/wHwQLYWPf9ZV1u489sHRjk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qhOlC/dJMcagTEOLI/wHwQLYWPf9ZV1u489sHRjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqhOlC%2FdJMcagTEOLI%2FwHwQLYWPf9ZV1u489sHRjk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;p&gt;&lt;strong&gt;결과&lt;/strong&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dj3JkQ/dJMcafmWMst/m4w0O9O2he08YXRyo2yLCK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dj3JkQ/dJMcafmWMst/m4w0O9O2he08YXRyo2yLCK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dj3JkQ/dJMcafmWMst/m4w0O9O2he08YXRyo2yLCK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdj3JkQ%2FdJMcafmWMst%2Fm4w0O9O2he08YXRyo2yLCK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;


&lt;hr&gt;

&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;!--  5. 후기                                  --&gt;
&lt;!-- ══════════════════════════════════════════ --&gt;
&lt;h2&gt;5. 후기&lt;/h2&gt;

&lt;div class=&quot;callout gray&quot;&gt;
  &lt;span class=&quot;icon&quot;&gt; &lt;/span&gt;
  &lt;div&gt;
    기존까지는 단순히 &lt;code&gt;@Value&lt;/code&gt; 를 쓴 경험뿐이었다.
    대부분의 프로젝트나 실습 때 환경변수를 단순히 가져다만 썼기 때문이라고 생각한다.&lt;br&gt;&lt;br&gt;
    &lt;code&gt;@ConfigurationProperties&lt;/code&gt; 를 쓰는 방법도 알았으니
    앞으로 더 복잡한 변수 관리가 필요할 때 유용하게 사용할 수 있을 것 같다.
  &lt;/div&gt;
&lt;/div&gt;

&lt;/body&gt;
&lt;/html&gt;</description>
      <category>Spring boot</category>
      <category>.env</category>
      <category>@ConfigurationProperties</category>
      <category>@Value</category>
      <category>Spring</category>
      <category>spring boot</category>
      <category>환경 변수</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/6</guid>
      <comments>https://twdev.tistory.com/6#entry6comment</comments>
      <pubDate>Fri, 3 Jul 2026 19:49:58 +0900</pubDate>
    </item>
    <item>
      <title>[Java] 추상클래스 &amp;amp; 인터페이스</title>
      <link>https://twdev.tistory.com/5</link>
      <description>&lt;h2&gt;0. 작성 목적&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Spring 프로젝트나 Java 프로젝트를 설계할 때마다 헷갈리는 개념이 추상 클래스와 인터페이스의 명확한 역할이었던 것 같다.&lt;br&gt;그래서 각각이 어떤 식으로 동작하고 사용되어야 하는지 알아보기 위해 작성한다.&lt;br&gt;또한 인터페이스에 대한 개념이 부족하다고 생각하여 이참에 정리하고자 작성한다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;1. 상속 vs 포함&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;본격적으로 둘의 차이를 알아보기 앞서 기본적으로 상속 관계와 포함 관계의 차이에 대해 알아보고자 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 두 경우 모두 코드 재사용성을 높이는 데 도움을 준다.

// 상속 관계
class Circle extends Point {
    int r;
}

// 포함 관계
class Circle {
    Point c = new Point();
    int r;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;상속 관계&lt;/strong&gt; : 기존의 클래스를 재사용하여 새로운 클래스를 작성. 공통적 관리 가능 및 코드 추가·변경에 용이하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;포함 관계&lt;/strong&gt; : 클래스 내부에 멤버 변수로 다른 클래스 타입의 참조 변수를 선언하는 것. 단위별로 여러 클래스를 작성하기 때문에 재사용성이 높고 클래스를 간결하게 작성할 수 있다.&lt;br&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;구별하는 법&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Circle is a Point. (원은 점이다.)   → 상속 관계
Circle has a Point. (원은 점을 가지고 있다.)  → 포함 관계&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;is-a&lt;/code&gt; 관계라면 → &lt;strong&gt;상속&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;has-a&lt;/code&gt; 관계라면 → &lt;strong&gt;포함&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;2. 각각의 정의&lt;/h2&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-1. 추상 클래스&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;개념&lt;/strong&gt; : 클래스 내에 추상 메서드가 선언되어 있는 클래스. &lt;code&gt;미완성 설계도&lt;/code&gt;라고 표현할 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;특징&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;추상 클래스도 생성자가 있어야 한다. (자동 생성 가능)&lt;/li&gt;
&lt;li&gt;일반 메서드도 추상 클래스 내부에 선언 및 구현 가능하다.&lt;/li&gt;
&lt;li&gt;메서드 강제 구현을 강요하기 위한 클래스이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;abstract class AbstractTest {
    abstract void move();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;br&gt;&lt;strong&gt;상속하는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;abstract class Parent {
    int x, y;
    abstract void methodA();
    abstract void methodB();
    void stop() { /* 메서드 구현 */ }
}

// 1. 일반 클래스: 모든 추상 메서드를 구현해야 함
class Child1 extends Parent {
    @Override
    void methodA() { /* 구현 */ }
    @Override
    void methodB() { /* 구현 */ }
}

// 2. 추상 클래스: 구현을 안 하거나 일부만 해도 됨
abstract class Child2 extends Parent {
    @Override
    void methodA() { /* methodA만 구현, methodB는 여전히 추상 상태 */ }
}

// 사용 예시
Child1 c1 = new Child1();
c1.methodA();
c1.stop(); // 일반 메서드 사용 가능&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;ul&gt;
&lt;li&gt;일반 클래스로 상속 시에는 모든 추상 메서드를 구현해야 한다.&lt;/li&gt;
&lt;li&gt;추상 클래스를 추상 클래스로 상속 시에는 구현을 안 하거나 일부만 해도 된다.&lt;/li&gt;
&lt;li&gt;공통된 부분은 미리 추상 클래스 내부에 일반 메서드로 구현함으로써 다형성을 유지할 수 있다. (필드도 마찬가지)&lt;/li&gt;
&lt;/ul&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-2. 인터페이스&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;개념&lt;/strong&gt; : 일종의 추상 클래스이다. 추상 클래스보다 추상화 정도가 높으며, 일반 메서드 또는 멤버 변수를 구성원으로 가질 수 없다. &lt;code&gt;기본 설계도&lt;/code&gt;라고 표현할 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;strong&gt;특징&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;오직 &lt;code&gt;추상 메서드&lt;/code&gt;와 &lt;code&gt;상수&lt;/code&gt;만을 멤버로 가질 수 있다.&lt;/li&gt;
&lt;li&gt;모든 멤버 변수는 &lt;code&gt;public static final&lt;/code&gt; (상수)이어야 하며, 이는 생략 가능하다.&lt;/li&gt;
&lt;li&gt;모든 메서드는 &lt;code&gt;public abstract&lt;/code&gt; (추상 메서드)이어야 하며, 이는 생략 가능하다.&lt;/li&gt;
&lt;li&gt;단, &lt;code&gt;static&lt;/code&gt; 메서드와 &lt;code&gt;default&lt;/code&gt; 메서드는 예외이다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;interface 인터페이스이름 {
    public static final double PI = 3.14; // 상수
    public abstract void methodA();       // 추상 메서드
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-3. 인터페이스의 상속&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;인터페이스는 인터페이스로부터만 상속이 가능하다. 클래스와 달리 &lt;strong&gt;다중 상속&lt;/strong&gt;이 가능하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;interface Movable {
    void move(int x, int y);
}

interface Attackable {
    void attack(Unit u);
}

interface Fightable extends Movable, Attackable { /* 구현 */ }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-4. 인터페이스의 구현&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;추상 클래스처럼 그 자체로는 인스턴스 생성이 불가하며, &lt;code&gt;implements&lt;/code&gt; 키워드를 이용하여 구현한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 일반 클래스로 구현
class Fighter implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    public void attack(Unit u) { /* 구현 */ }
}

// 일부만 구현하는 경우 → 추상 클래스로 선언
abstract class Fighter implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    // attack()은 미구현 → abstract 클래스여야 함
}

// 상속과 구현을 동시에
class Fighter extends Unit implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    public void attack(Unit u) { /* 구현 */ }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-5. 인터페이스의 다형성&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;자식 클래스의 인스턴스를 부모 타입의 변수로 참조하는 것 (업캐스팅)이 가능하듯이, 인터페이스 역시 구현 클래스의 조상이므로 참조가 가능하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;Fightable f = (Fightable) new Fighter();
// 또는
Fightable f = new Fighter(); // 업캐스팅

// 인터페이스 타입의 참조 변수(f)는 Fightable에 정의된 멤버들만 호출 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;br&gt;&lt;strong&gt;인터페이스의 여러 사용법&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 1. 매개변수로 사용되는 인터페이스
class Fighter extends Unit implements Fightable {
    public void move(int x, int y) { /* 구현 */ }
    public void attack(Fightable f) { /* 구현 */ }
}

// 2. 인터페이스 타입의 반환형
Fightable method() {
    Fighter f = new Fighter();
    return f; // Fighter가 Fightable을 구현한 클래스임을 알 수 있다.
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  &lt;strong&gt;핵심&lt;/strong&gt; : 리턴 타입이 인터페이스라는 것은 메서드가 해당 인터페이스를 구현한 클래스의 인스턴스를 반환한다는 의미이다.&lt;br&gt;나중에 Fighter를 리뉴얼한 StrongFighter 클래스로 변경되어도 해당 부분만 바꿔주면 된다. (관리 용이)&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-6. 인터페이스의 활용 (1) — StarCraft 건물 예시&lt;/h3&gt;
&lt;p&gt;Barrack, Factory는 건물을 들어 올리는 기능(Lift)이 있는데, 이 기능을 메서드로 추가하고자 한다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rK2UY/dJMcabdBaDR/BjPokwG9zySbk3MkQDOBj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rK2UY/dJMcabdBaDR/BjPokwG9zySbk3MkQDOBj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rK2UY/dJMcabdBaDR/BjPokwG9zySbk3MkQDOBj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrK2UY%2FdJMcabdBaDR%2FBjPokwG9zySbk3MkQDOBj1%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;strong&gt;문제점&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;void liftOff() { /* 구현 */ }
void move(int x, int y) { /* 구현 */ }
void stop() { /* 구현 */ }
void land() { /* 구현 */ }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Barrack, Factory 클래스 모두에 위 코드를 작성하면 → &lt;strong&gt;코드 중복&lt;/strong&gt; 문제&lt;/li&gt;
&lt;li&gt;부모 클래스인 Building에 추가하면 → Academy, Bunker 클래스도 해당 코드를 상속받는 &lt;strong&gt;불필요한 상속&lt;/strong&gt; 문제&lt;br&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;인터페이스로 해결&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;interface Liftable {
    void liftOff();
    void move(int x, int y);
    void stop();
    void land();
}

class LiftableImpl implements Liftable {
    public void liftOff() { /* 구현 */ }
    public void move(int x, int y) { /* 구현 */ }
    public void stop() { /* 구현 */ }
    public void land() { /* 구현 */ }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/coD4pb/dJMcabkutwt/PCKJmq6m0CvnCIxg5pHaIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/coD4pb/dJMcabkutwt/PCKJmq6m0CvnCIxg5pHaIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/coD4pb/dJMcabkutwt/PCKJmq6m0CvnCIxg5pHaIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcoD4pb%2FdJMcabkutwt%2FPCKJmq6m0CvnCIxg5pHaIK%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&amp;nbsp;&lt;br&gt;이후 위와 같이 Liftable 을 구현한 클래스를 각각에 적용하면된다.&lt;br&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;코드 적용&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class Barrack extends Building implements Liftable {

    LiftableImpl l = new LiftableImpl();

    public void liftOff() { l.liftOff(); }
    public void move(int x, int y) { l.move(x, y); }
    public void stop() { l.stop(); }
    public void land() { l.land(); }

    // 이하 기타 메서드...
}
// Factory도 동일하게 적용&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  &lt;strong&gt;왜 바로 Barrack에 구현하지 않고 LiftableImpl을 통해 구현했을까?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;기능 수정이 필요할 시 &lt;code&gt;LiftableImpl&lt;/code&gt; 수정만으로 적용된 전체 클래스 조정 가능&lt;/li&gt;
&lt;li&gt;클래스는 자신의 본래 목적(건물 생산)에만 집중하고, 부가 기능은 전담 객체에 위임&lt;/li&gt;
&lt;li&gt;바로 클래스에 구현 시 재사용성이 떨어짐&lt;/li&gt;
&lt;/ul&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-7. 인터페이스의 활용 (2) — 인터페이스의 본질&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;  &lt;strong&gt;핵심 본질&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;클래스를 사용하는 쪽(User)과 클래스를 제공하는 쪽(Provider)이 있다.&lt;/li&gt;
&lt;li&gt;메서드를 호출하는 쪽(User)에서는 사용하려는 메서드(Provider)의 &lt;strong&gt;선언부만&lt;/strong&gt; 알면 된다. (내용은 몰라도 된다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;인터페이스 없이 직접 의존하는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class A {
    public void methodA(B b) {
        b.methodB();
    }
}

class B {
    public void methodB() {
        System.out.println(&amp;quot;methodB()&amp;quot;);
    }
}

class InterfaceTest {
    public static void main(String[] args) {
        A a = new A();
        a.methodA(new B());
    }
}

// 실행 결과
// methodB()&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;A 클래스가 B 클래스에 직접 의존하고 있어, B가 변경되면 A도 함께 변경해야 하는 불편함이 발생한다.&lt;br&gt;이때 인터페이스를 통해 두 클래스 간의 관계를 &lt;strong&gt;느슨하게(간접적으로)&lt;/strong&gt; 변경할 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;인터페이스를 통해 결합도를 낮추는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class A {
    public void autoPlay(I i) {
        i.play();
    }
}

interface I {
    public abstract void play();
}

class B implements I {
    public void play() {
        System.out.println(&amp;quot;play in B class&amp;quot;);
    }
}

class C implements I {
    public void play() {
        System.out.println(&amp;quot;play in C class&amp;quot;);
    }
}

class InterfaceTest2 {
    public static void main(String[] args) {
        A a = new A();
        a.autoPlay(new B());
        a.autoPlay(new C());
    }
}

// 실행 결과
// play in B class
// play in C class&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;인터페이스 I를 통해 매개변수로 구현체를 동적으로 제공받을 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;제3의 클래스를 통해 제공받는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class InterfaceTest3 {
    public static void main(String[] args) {
        A a = new A();
        a.methodA();
    }
}

class A {
    void methodA() {
        I i = InstanceManager.getInstance(); // 제3의 클래스 메서드 사용 (원래는 new B())
        i.methodB();
        System.out.println(i.toString());    // Object 클래스 메서드 사용
    }
}

interface I {
    public abstract void methodB();
}

class B implements I {
    public void methodB() {
        System.out.println(&amp;quot;methodB in B class&amp;quot;);
    }
    public String toString() { return &amp;quot;class B&amp;quot;; }
}

class InstanceManager {
    public static I getInstance() {
        return new B(); // 다른 인스턴스로 변경 시 여기만 수정하면 된다.
    }
}

// 실행 결과
// methodB in B class
// class B&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;code&gt;getInstance()&lt;/code&gt; 메서드를 통해 인스턴스를 제공받으면, 나중에 다른 클래스로 변경되어도 A 클래스는 건드리지 않고 &lt;code&gt;getInstance()&lt;/code&gt;만 수정하면 된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3&gt;2-8. 디폴트 메서드와 static 메서드&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;static&lt;/code&gt; 메서드는 JDK 1.8 이후부터 인터페이스에 추가 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;디폴트 메서드란&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;인터페이스에 메서드를 추가한다는 것 = 해당 인터페이스를 구현한 기존 클래스들이 새로 메서드를 추가해야 한다는 것.&lt;br&gt;이런 문제를 해결하기 위해 등장한 것이 &lt;strong&gt;디폴트 메서드&lt;/strong&gt;이다.&lt;br&gt;추상 메서드의 기본적인 구현을 제공하며, 추가되어도 기존 구현체를 수정하지 않아도 된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;interface MyInterface {
    void method();              // 추상 메서드
    default void newMethod() {} // 디폴트 메서드
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;static 메서드와 디폴트 메서드 사용 예시&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class DefaultMethodTest {
    public static void main(String[] args) {
        // MyInterface inter = new Child();
        // inter.method2(); // child 가 오버라이딩 하지 않으면 MyInterface 의 method1() 도 호출 가능
        // 위 처럼 다형성을 통해 디폴트 메서드 호출 가능
        Child c = new Child();
        c.method1();
        c.method2();
        MyInterface.staticMethod();
        MyInterface2.staticMethod();
    }
}

class Child extends Parent implements MyInterface, MyInterface2 {
    public void method1() {
        MyInterface.super.method1(); // 인터페이스의 디폴트 메서드 명시적 호출
        System.out.println(&amp;quot;method1() in Child&amp;quot;); // 오버라이딩
    }
}

class Parent {
    public void method2() {
        System.out.println(&amp;quot;method2() in Parent&amp;quot;);
    }
}

interface MyInterface {
    default void method1() {
        System.out.println(&amp;quot;method1() in MyInterface&amp;quot;);
    }
    default void method2() {
        System.out.println(&amp;quot;method2() in MyInterface&amp;quot;);
    }
    static void staticMethod() {
        System.out.println(&amp;quot;staticMethod() in MyInterface&amp;quot;);
    }
}

interface MyInterface2 {
    default void method1() {
        System.out.println(&amp;quot;method1() in MyInterface2&amp;quot;);
    }
    static void staticMethod() {
        System.out.println(&amp;quot;staticMethod() in MyInterface2&amp;quot;);
    }
}

// 실행 결과
// method1() in MyInterface
// method1() in Child
// method2() in Parent   ← 디폴트 메서드 충돌 시 우선순위: 클래스 &amp;gt;&amp;gt; 인터페이스
// staticMethod() in MyInterface
// staticMethod() in MyInterface2&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;※ Java 9부터는 인터페이스 내부에 &lt;code&gt;private&lt;/code&gt; 메서드 구현이 가능하다. (디폴트 메서드 간 코드 공유 목적)&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;public interface Calculator {
    default void logStart() {
        commonLog(&amp;quot;작업 시작&amp;quot;);
    }

    default void logEnd() {
        commonLog(&amp;quot;작업 종료&amp;quot;);
    }

    // 외부에서는 호출 불가 — 인터페이스 내부에서만 사용
    private void commonLog(String message) {
        System.out.println(&amp;quot;로그 기록: &amp;quot; + message);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;3. 추상 클래스 vs 인터페이스&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;공부 이후에 느낀 것이지만, &lt;strong&gt;이 둘은 서로 비교할 대상이 아니다.&lt;/strong&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;추상 클래스 (abstract class)&lt;/th&gt;
&lt;th&gt;인터페이스 (interface)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;목적&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;상속을 통한 확장 (기능의 계층 구조)&lt;/td&gt;
&lt;td&gt;기능 구현 강제 (다형성과 행위 정의)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;핵심 키워드&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;quot;Is-a&amp;quot; (~이다)&lt;/td&gt;
&lt;td&gt;&amp;quot;Can-do&amp;quot; (~할 수 있다)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;멤버 변수&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;모든 형태 가능 (상수, 일반 변수 등)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public static final&lt;/code&gt; 상수만 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;메서드&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;구현부 존재 가능 (일반/추상 혼합)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;, &lt;code&gt;static&lt;/code&gt;, &lt;code&gt;private&lt;/code&gt; (Java 9+) 구현 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;상속&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;단일 상속만 가능&lt;/td&gt;
&lt;td&gt;다중 구현 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;주된 용도&lt;/strong&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;hr&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;참고 서적 : Java의 정석 3rd Edition (저자 : 남궁성)&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;</description>
      <category>java</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/5</guid>
      <comments>https://twdev.tistory.com/5#entry5comment</comments>
      <pubDate>Mon, 29 Jun 2026 17:23:21 +0900</pubDate>
    </item>
    <item>
      <title>[알고리즘] 유니온 파인드</title>
      <link>https://twdev.tistory.com/4</link>
      <description>&lt;h2&gt;개념&lt;/h2&gt;
&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
  &lt;div class=&quot;callout-content&quot;&gt;&lt;b&gt;개념&lt;/b&gt;: 여러 원소들이 어떤 집합에 속해있는지 관리하는 자료구조

&lt;pre&gt;&lt;code&gt;  - 핵심 두가지 연산

  &amp;quot;Find&amp;quot; : 특정 원소가 속한 집합의 대표자(루트)를 찾는다.
  &amp;quot;Union&amp;quot; : 두 원소가 속한 집합을 하나로 합친다.&lt;/code&gt;&lt;/pre&gt;  &lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;
&lt;h2&gt;문제 풀이를 통한 개념 익히기&lt;/h2&gt;
&lt;h3&gt;문제&lt;/h3&gt;
&lt;p&gt;초기에 {0}, {1}, {2}, ... {n} 이 각각 n+1개의 집합을 이루고 있다. 여기에 합집합 연산과, 두 원소가 같은 집합에 포함되어 있는지를 확인하는 연산을 수행하려고 한다.&lt;/p&gt;
&lt;p&gt;집합을 표현하는 프로그램을 작성하시오.&lt;/p&gt;
&lt;h3&gt;입력&lt;/h3&gt;
&lt;p&gt;첫째 줄에 n(1 ≤ n ≤ 1,000,000), m(1 ≤ m ≤ 100,000)이 주어진다. m은 입력으로 주어지는 연산의 개수이다. 다음 m개의 줄에는 각각의 연산이 주어진다. 합집합은 0 a b의 형태로 입력이 주어진다. 이는 a가 포함되어 있는 집합과, b가 포함되어 있는 집합을 합친다는 의미이다. 두 원소가 같은 집합에 포함되어 있는지를 확인하는 연산은 1 a b의 형태로 입력이 주어진다. 이는 a와 b가 같은 집합에 포함되어 있는지를 확인하는 연산이다. a와 b는 n 이하의 자연수 또는 0이며 같을 수도 있다.&lt;/p&gt;
&lt;h3&gt;출력&lt;/h3&gt;
&lt;p&gt;1로 시작하는 입력에 대해서 한 줄에 하나씩 YES/NO로 결과를 출력한다. (yes/no 를 출력해도 된다)&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dpghRt/dJMcaiDP5lH/vvlpkFkjeDfp8daAxZqKl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dpghRt/dJMcaiDP5lH/vvlpkFkjeDfp8daAxZqKl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dpghRt/dJMcaiDP5lH/vvlpkFkjeDfp8daAxZqKl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdpghRt%2FdJMcaiDP5lH%2FvvlpkFkjeDfp8daAxZqKl1%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;유니온 파인드 활용하기&lt;/h2&gt;
&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
    &lt;div class=&quot;callout-content&quot;&gt;
union(1,3) → union(2,4) → union(1,4) 과 isSame(1,2), isSame(1,4)

&lt;/div&gt;
&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;&lt;p&gt;초기에는 각 원소가 자기 자신을 부모로 가진다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qDoBx/dJMcaf1mNrb/oecjrRzBvJaaCKwkruxY61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qDoBx/dJMcaf1mNrb/oecjrRzBvJaaCKwkruxY61/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qDoBx/dJMcaf1mNrb/oecjrRzBvJaaCKwkruxY61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqDoBx%2FdJMcaf1mNrb%2FoecjrRzBvJaaCKwkruxY61%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;find(1)=1, find(3)=3 을 수행한 후 부모가 다르므로 합친다. (union(1, 3) : 3의 부모를 1로 설정)&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcVdJ5/dJMcadbmTh7/sePeknBLZ8CSrc1oakeOd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcVdJ5/dJMcadbmTh7/sePeknBLZ8CSrc1oakeOd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcVdJ5/dJMcadbmTh7/sePeknBLZ8CSrc1oakeOd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcVdJ5%2FdJMcadbmTh7%2FsePeknBLZ8CSrc1oakeOd0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;find(2)=2, find(4)=4 를 수행한 후 역시 부모가 다르므로 합친다. (union(2, 4) : 4의 부모를 2로 설정)&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dNmSeY/dJMcabY3t5s/jKOXLT91N3RsZaliAW0qZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dNmSeY/dJMcabY3t5s/jKOXLT91N3RsZaliAW0qZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dNmSeY/dJMcabY3t5s/jKOXLT91N3RsZaliAW0qZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdNmSeY%2FdJMcabY3t5s%2FjKOXLT91N3RsZaliAW0qZ0%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;find(1)=1, find(4)=find(2)=2 일 때 부모가 다르므로 합친다. (union(1, 4) : 2의 부모를 1로 설정)&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1ol0x/dJMcadvE4CZ/020orJ2AL7vrXlkKQFlMn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1ol0x/dJMcadvE4CZ/020orJ2AL7vrXlkKQFlMn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1ol0x/dJMcadvE4CZ/020orJ2AL7vrXlkKQFlMn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1ol0x%2FdJMcadvE4CZ%2F020orJ2AL7vrXlkKQFlMn1%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;find(1)=1, find(2)=1 로 부모가 같으므로 같은 집합 (isSame(1,2) == true)&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgStGQ/dJMcahZciak/Lkq6s4p9q6ZVWUcwKxgkkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgStGQ/dJMcahZciak/Lkq6s4p9q6ZVWUcwKxgkkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgStGQ/dJMcahZciak/Lkq6s4p9q6ZVWUcwKxgkkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgStGQ%2FdJMcahZciak%2FLkq6s4p9q6ZVWUcwKxgkkk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;find(1)=1, find(0)=0 로 부모가 다르므로 다른 집합 (isSame(1,0) == false)&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqCF9u/dJMcafmQRvh/YYxow9KoaZLRT9TxC2Vkj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqCF9u/dJMcafmQRvh/YYxow9KoaZLRT9TxC2Vkj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqCF9u/dJMcafmQRvh/YYxow9KoaZLRT9TxC2Vkj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcqCF9u%2FdJMcafmQRvh%2FYYxow9KoaZLRT9TxC2Vkj1%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2&gt;문제 풀어보기 (Java)&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;import java.io.*;
import java.util.*;

public class BOJ1717 {

    private static int[] parent;

    private static int find(int i){
        // 부모 반환
        if(parent[i] != i){
            parent[i] = find(parent[i]); // 경로 압축
        }
        return parent[i];
    }

    private static void union(int a, int b){
        // a와 b 합집합 연산
        int ra = find(a);
        int rb = find(b);
        if(ra != rb){
            parent[rb] = ra; // 루트 끼리 연결 
            // 바로 parent[b] = a; 를 하면 루트가 아닌것 끼리 적용되어 꼬일 수 있다.
        }
    }


    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());

        int N = Integer.parseInt(st.nextToken()); // 입력 숫자 범위 (최대 N)
        int M = Integer.parseInt(st.nextToken()); // 연산의 개수

        parent = new int[N + 1];
        for (int i = 1; i &amp;lt;= N; i++) {
            parent[i] = i;
        }

        for (int i = 0; i &amp;lt; M; i++) {
            st = new StringTokenizer(br.readLine());
            int op = Integer.parseInt(st.nextToken());
            int a = Integer.parseInt(st.nextToken());
            int b = Integer.parseInt(st.nextToken());

            if(op == 0){
                // 합집합 연산 수행
                union(a, b);

            }
            if(op == 1){
                // 포함 여부 확인
                System.out.println(find(a) == find(b) ? &amp;quot;YES&amp;quot; : &amp;quot;NO&amp;quot;);
            }


        }


    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
    &lt;div class=&quot;callout-content&quot;&gt;
&quot;유니온 파인드&quot; 알고리즘은 최소 스패닝 트리를 구현하는 크루스칼 알고리즘의 기반이 된다.
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>알고리즘/유니온 파인드</category>
      <category>Java</category>
      <category>알고리즘</category>
      <category>유니온 파인드</category>
      <category>자바</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/4</guid>
      <comments>https://twdev.tistory.com/4#entry4comment</comments>
      <pubDate>Fri, 26 Jun 2026 15:49:44 +0900</pubDate>
    </item>
    <item>
      <title>Anaconda 가상 환경 설정</title>
      <link>https://twdev.tistory.com/3</link>
      <description>&lt;ul&gt;
&lt;li&gt;해당 글은 python 3.11 버전 (윈도우 11) 기준으로 작성됨&lt;/li&gt;
&lt;li&gt;Anaconda 가 설치된 이후 Anaconda Prompt 를 이용하여 가상 환경을 설정함&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;0. 초기 Anaconda Prompt 실행 시&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;(base) C:\Users\taewoo&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;1. 기본 패키지 채널 설정&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;(base) $ conda config -- add channels conda-forge
(base) $ conda config --set channel_priority strict&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
  &lt;div class=&quot;callout-content&quot;&gt;위 두줄 코드의 의미 : &lt;b&gt;onda-forge&lt;/b&gt; 를 기본 패키지(1순위) 채널로 설정하고 패키지 설치 시 해당 채널에 있는 패키지들과 의존성이 완벽하게 맞는것들만 골라서 설치 한다. (버전 충돌 에러 방지)&lt;/div&gt;
&lt;/div&gt;


&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;h3&gt;Channel&lt;/h3&gt;
&lt;p&gt;아나콘다 패키지를 다운로드 하는 일종의 라이브러리(저장소)&lt;br&gt;기본적으로 아나콘다 공식 회사의 채널만 등록되어 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;h3&gt;conda-forge&lt;/h3&gt;
&lt;p&gt;파이썬 개발자들이 자발적으로 관리하는 거대한 커뮤니티 중심 오픈소스 채널&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;strict&lt;/td&gt;
&lt;td&gt;1순위 채널 무조건 맹신 및 호환성 엄격 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flexible&lt;/td&gt;
&lt;td&gt;1순위 채널 우선하되, 문제 해결을 위해 하위 채널과 마구 섞어씀 (default 값)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disabled&lt;/td&gt;
&lt;td&gt;채널 순위 상관없이 무조건 높은 버전의 패키지 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;2. 새로운 환경 생성 및 활성화&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;(base) $ conda create -y -n pydata-book python=3.11
(base) $ conda env list
# conda enviornments:
#
base                  *  C:\Users\taewoo\anaconda3
py100                    C:\Users\taewoo\anaconda3\envs\py100
pydata-book              C:\Users\taewoo\anaconda3\envs\pydata-book

(base) $ conda activate pydata-book
(pydata-book) $&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
  &lt;div class=&quot;callout-content&quot;&gt;&lt;b&gt;pydata-book&lt;/b&gt; 이라는 환경을 특정 파이썬 버전(3.11) 을 이용하여 생성 후 활성화 함&lt;/b&gt; 이다.&lt;/div&gt;
&lt;/div&gt;


&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;-y&lt;/td&gt;
&lt;td&gt;없으면 패키지 다운로드 중 &lt;strong&gt;Proceed([y]/n)?&lt;/strong&gt; 를 멈추며 물어보는데 이를 모두 yes 로 바로 설치&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-n&lt;/td&gt;
&lt;td&gt;name 으로서 바로 뒤의 단어를 가상환경의 이름으로 설정한다는 의미(--name 으로 풀어서 써도 됨)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;3. 필수 패키지 설치&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;(pydata-book) $ conda install -y pandas jupyter matplotlib&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
    &lt;div class=&quot;callout-content&quot;&gt;패키지 설치 시 &quot;conda install&quot; 또는 &quot;pip install&quot; 을 사용하는데 우선 순위는 &lt;b&gt;conda install&lt;/b&gt; 이다.&lt;/div&gt;
&lt;/div&gt;

&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;아나콘다는 &lt;strong&gt;pip&lt;/strong&gt; 가 한 일을 모르지만 &lt;strong&gt;conda&lt;/strong&gt; 로 설치하는 경우 기록이 남는다.&lt;br&gt;만약 둘을 같이 쓰면 pip 가 conda 의 기록을 무시하고 패키지를 빈곳에 집어 넣는다.&lt;br&gt;(결과적으로 패키지가 덮어씌워지거나 삭제 시 에러 발생 가능)&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&amp;nbsp;&lt;br&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;따라서 conda 를 우선순위로 사용하고 conda 에 없는 몇몇 패키지만 pip 로 설치하는 것이 올바르다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;4. IPython 실행해보기&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVISC0/dJMcaiRcMnZ/fvNH5hlpgIWqjxevPItdck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVISC0/dJMcaiRcMnZ/fvNH5hlpgIWqjxevPItdck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVISC0/dJMcaiRcMnZ/fvNH5hlpgIWqjxevPItdck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVISC0%2FdJMcaiRcMnZ%2FfvNH5hlpgIWqjxevPItdck%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-content&quot;&gt;코드 실행마다 [1], [2] 처럼 번호가 나온다.
  &quot;In&quot; 은 사용자 입력, &quot;Out&quot; 은 시스템 출력
  환경 종료 하려면 &quot;exit()&quot; 하면 됨&lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;
&lt;h2&gt;5. 주피터 노트북 실행하기&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;(pydata-book) $ jupyter notebook&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0HWRf/dJMcafmHqN2/aUn6jrCfAauIz8JXCrDaWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0HWRf/dJMcafmHqN2/aUn6jrCfAauIz8JXCrDaWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0HWRf/dJMcafmHqN2/aUn6jrCfAauIz8JXCrDaWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0HWRf%2FdJMcafmHqN2%2FaUn6jrCfAauIz8JXCrDaWk%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-content&quot;&gt;실행하면 다음과 같이 나온다. 우측 상단쪽의 'new' 를 눌러 새로운 프로젝트를 시작할 수 있다.&lt;/div&gt;
&lt;/div&gt;

&lt;hr&gt;
&lt;h2&gt;6. 파일 저장 디렉토리 변경&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;(pydata-book) C:\data-analysis&amp;gt;jupyter notebook&lt;/code&gt;&lt;/pre&gt;&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-content&quot;&gt;Anaconda Prompt 에서 cd 로 원하는 위치로 이동하고 환경 변경후 jupyter notebook 실행하면 된다.
  &quot;환경은 global 한 위치에 저장되므로 따로 또 설치할 필요 x&quot;&lt;/div&gt;
&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;변경후 깨끗한 곳에서 다시 실행해 보았다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXMmqB/dJMcahEJIZF/y3eStggmj8jIMQerT2OK1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXMmqB/dJMcahEJIZF/y3eStggmj8jIMQerT2OK1k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXMmqB/dJMcahEJIZF/y3eStggmj8jIMQerT2OK1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXMmqB%2FdJMcahEJIZF%2Fy3eStggmj8jIMQerT2OK1k%2Fimg.png&quot; width=&quot;100%&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;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;추가 팁&lt;/h3&gt;
&lt;div class=&quot;notion-callout&quot;&gt;
    &lt;div class=&quot;callout-emoji&quot;&gt; &lt;/div&gt;
    &lt;div class=&quot;callout-content&quot;&gt;만약 github 에 올릴 시에는 코드 실행마다 협업 시에 꼬일 수 있다. 
  Kernel -&gt; Restart &amp; Clear Output을 누른 뒤 저장하고 커밋해야 한다.
  다른 방법으로는 jupytext 를 활용하여 '.py' 파일로 변경하여 커밋하는 방법도 있다.&lt;/div&gt;
&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;gitignore (임시 파일 추적 제외)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;.ipynb_checkpoints/
*/.ipynb_checkpoints/&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Data Analysis/python</category>
      <category>Anaconda</category>
      <category>Python</category>
      <category>데이터 분석</category>
      <author>twdev</author>
      <guid isPermaLink="true">https://twdev.tistory.com/3</guid>
      <comments>https://twdev.tistory.com/3#entry3comment</comments>
      <pubDate>Mon, 15 Jun 2026 12:48:00 +0900</pubDate>
    </item>
  </channel>
</rss>