<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>빠르게 학습하고 빠르게 적용하자</title>
    <link>https://osoohynn.tistory.com/</link>
    <description>개발 블로그입니다. 늦게 시작했지만, 제 생각을 잘 담으려고 노력했으니 관심 부탁드립니다!</description>
    <language>ko</language>
    <pubDate>Mon, 8 Jun 2026 06:14:57 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>osoohynn</managingEditor>
    <image>
      <title>빠르게 학습하고 빠르게 적용하자</title>
      <url>https://tistory1.daumcdn.net/tistory/8106503/attach/bc9fa9102c1a46f1a80d4c1ca30f29d4</url>
      <link>https://osoohynn.tistory.com</link>
    </image>
    <item>
      <title>홈서버 Blue-Green 무중단 배포 구축하기</title>
      <link>https://osoohynn.tistory.com/17</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;무중단 배포 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 실험 전략을 선호하던 저는 Querify 서비스를 운영하며 여러 번 과감한 시도를 했습니다. Github Branch 전략을 사용하고 있었기 때문에 개발할 때는 배포 환경에서 이슈가 발생하면 빠르게 main브랜치에 올리는 방식을 이용했었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 사용자가 조금씩 생기다 보니.. 배포 환경에서의 문제를 사용자가 겪는 상황이 있었고, 이 상황에서 사용할 롤백 장치가 마련되어있지 않아 곤란했습니다. 또한 새로운 기능을 추가하거나 수정하는 배포 과정에서도 서버가 일시적으로 다운되기 때문에 사용자 경험이 많이 떨어졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 기존에 알고 있던 무중단 배포 전략을 활용하여 배포할때도 서버가 다운되지 않고, 급한 이슈를 롤백으로 해결할 수 있는 환경을 마련하고자 하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2834&quot; data-origin-height=&quot;1076&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BWxx3/dJMcadPlraf/VIVjXFY9dM7yhRlfwCpnX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BWxx3/dJMcadPlraf/VIVjXFY9dM7yhRlfwCpnX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BWxx3/dJMcadPlraf/VIVjXFY9dM7yhRlfwCpnX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBWxx3%2FdJMcadPlraf%2FVIVjXFY9dM7yhRlfwCpnX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2834&quot; height=&quot;1076&quot; data-origin-width=&quot;2834&quot; data-origin-height=&quot;1076&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배포 전략 선택&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블루 그린 방식은 리소스 두 배라는 약점이 있습니다. 하지만 홈서버에서 구축되어 있었기 때문에 클라우드 비용도 없고 남는 메모리로 충분히 감당이 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 장점은 실험 단계에서는 실수가 잦은데, 라우팅만 수정하면 롤백되어서 빠르게 이슈를 처리하기 좋습니다. Compose + Nginx upstream 전환이면 쉽게 구현할 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 확장성을 고려했습니다. MSA로 쪼개면 서비스마다 독립적으로 Blue-Green을 돌릴 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;            [ Nginx ]
                &amp;darr;
[ Blue (8082) ] or [ Green (8083) ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ngxwa/dJMcabD3B2s/Sx5RKIhknSP83kt4LzRbD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ngxwa/dJMcabD3B2s/Sx5RKIhknSP83kt4LzRbD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ngxwa/dJMcabD3B2s/Sx5RKIhknSP83kt4LzRbD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fngxwa%2FdJMcabD3B2s%2FSx5RKIhknSP83kt4LzRbD1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;409&quot; height=&quot;432&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1520&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블루 그린 중에서도 낮은 복잡도와 빠른 롤백의 장점을 극대화하기 위해 하나의 인스턴스(홈서버 한 대)에서 Docker compose로 앱 컨테이너를 관리하는 방식을 채택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;롤링 업데이트 방식은 교체 중에 v1과 v2가 같은 트래픽을 나눠 받는 구간이 있어서 API 호환성 문제가 생기기 쉽습니다. 롤백도 번거로워서 빠르게 롤백이 필요한 상황에 맞지 않아서 배제하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Canary는 새 버전을 일부 트래픽에만 노출시키는 방법인데, 실제 사용자 트래픽으로 검증할 수 있다는 게 핵심 장점입니다. 그러나 모니터링을 꼼꼼하게 붙혀야하며, 롤링 방식과 마찬가지로 버전 관리가 필요하여 배제하였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 기존 docker-compose.yml를 app과 분리하여 docker-compose.infra.yml를 새로 작성했습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# docker-compose.infra.yml
services:
  mysql:
    image: mysql:8.0
    container_name: soj-mysql
    # ... 기존 그대로
    networks: [soj-network]

  redis:
    image: redis:7-alpine
    container_name: soj-redis
    # ...
    networks: [soj-network]

  mongodb:
    image: mongo:4.4
    container_name: soj-mongodb
    # ...
    networks: [soj-network]

  kafka:
    image: apache/kafka:3.7.0
    container_name: soj-kafka
    # ...
    networks: [soj-network]

  # ... ES, Logstash, Kibana, mysql-sandbox 등

networks:
  soj-network:
    name: soj-network
    driver: bridge

volumes:
  mysql_data:
  redis_data:
  mongo_data:
  es_data:
  kafka_data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker-compose.blue.yml과 docker-compose.green.yml 두 파일로 나누었습니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# docker-compose.blue.yml
services:
  app-blue:
    image: ${APP_IMAGE:-soj-backend:latest}
    container_name: soj-app-blue
    env_file: .env
    expose:
      - &quot;8080&quot;
    networks:
      - soj-network
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;&amp;lt;http://localhost:8080/actuator/health&amp;gt;&quot;]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s
    restart: unless-stopped

networks:
  soj-network:
    external: true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# docker-compose.green.yml
services:
  app-green:
    image: ${APP_IMAGE:-soj-backend:latest}
    container_name: soj-app-green
    env_file: .env
    expose:
      - &quot;8080&quot;
    networks:
      - soj-network
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;&amp;lt;http://localhost:8080/actuator/health&amp;gt;&quot;]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s
    restart: unless-stopped

networks:
  soj-network:
    external: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Nginx upstream을 이용하도록 수정하였습니다!&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;server {
    server_name api.querify.dev;
    location / {
        proxy_pass http://soj_backend;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection &quot;&quot;;
    }
    listen [::]:443 ssl;
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/api.querify.dev/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.querify.dev/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
    if ($host = api.querify.dev) {
        return 301 https://$host$request_uri;
    }
    listen 80;
    listen [::]:80;
    server_name api.querify.dev;
    return 404;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;upstream soj_backend {
    server 127.0.0.1:8082;
    keepalive 32;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;deploy.sh파일로 배포 자동화를 구현하였습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;#!/bin/bash
# 무중단 배포 스크립트
set -euo pipefail

cd &quot;$(dirname &quot;$0&quot;)&quot;

NEW_IMAGE=&quot;${1:-soj-backend:latest}&quot;
UPSTREAM_FILE=&quot;/etc/nginx/conf.d/soj-upstream.conf&quot;

# 색상 출력
log()   { echo -e &quot;\\033[0;32m[$(date +%H:%M:%S)]\\033[0m $1&quot;; }
warn()  { echo -e &quot;\\033[1;33m[$(date +%H:%M:%S)] WARN:\\033[0m $1&quot;; }
error() { echo -e &quot;\\033[0;31m[$(date +%H:%M:%S)] ERROR:\\033[0m $1&quot;; exit 1; }

# === 1. 현재 색깔 확인 ===
[ -f .current_color ] || echo &quot;blue&quot; &amp;gt; .current_color
CURRENT=$(cat .current_color)

if [ &quot;$CURRENT&quot; = &quot;blue&quot; ]; then
    NEW=&quot;green&quot;
    NEW_PORT=&quot;8083&quot;
else
    NEW=&quot;blue&quot;
    NEW_PORT=&quot;8082&quot;
fi

log &quot;배포 시작: $CURRENT &amp;rarr; $NEW (port $NEW_PORT, image: $NEW_IMAGE)&quot;

# === 2. 새 컨테이너 띄우기 ===
log &quot;$NEW 컨테이너 시작...&quot;
export APP_IMAGE=&quot;$NEW_IMAGE&quot;
docker compose -f docker-compose.$NEW.yml up -d

.
.
.

# === 6. 외부 검증 ===
sleep 3
EXTERNAL=$(curl -sf -o /dev/null -w &quot;%{http_code}&quot; &amp;lt;https://api.querify.dev/actuator/health&amp;gt; || echo &quot;000&quot;)
if [ &quot;$EXTERNAL&quot; != &quot;200&quot; ]; then
    warn &quot;외부 응답 비정상 ($EXTERNAL), 롤백 검토 필요&quot;
fi

# === 7. 드레이닝 ===
log &quot;$CURRENT 드레이닝 (15초 대기)...&quot;
sleep 15

# === 8. 구버전 종료 ===
docker compose -f docker-compose.$CURRENT.yml down
echo &quot;$NEW&quot; &amp;gt; .current_color
log &quot;✅ 배포 완료: $CURRENT &amp;rarr; $NEW ($NEW_IMAGE)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;후기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무중단 배포를 구현할 때는 여러 개의 인스턴스를 돌려야만 하는 줄 알았는데, Docker Compose와 Nginx만으로도 구현할 수 있어서 간편했습니다. 배포시 다운타임을 없애는 것과 롤백할 수 있는 환경을 모두 만족했기 때문에 현재는 이 정도로 충분하고 나중에 트래픽이 커지거나 가용성이 진짜로 필요해지는 시점에 같은 Blue-Green 패턴을 로드 밸런스와 여러 대의 서버로 옮기고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;b&gt;다른 관점이나 잘못된 판단이 보이면 댓글 부탁드립니다!!&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;</description>
      <category>bluegreen</category>
      <category>무중단배포</category>
      <category>홈서버</category>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/17</guid>
      <comments>https://osoohynn.tistory.com/17#entry17comment</comments>
      <pubDate>Fri, 1 May 2026 15:49:13 +0900</pubDate>
    </item>
    <item>
      <title>JSON 문자열을 RDB에 넣었다가... MySQL&amp;rarr;MongoDB 메타데이터 마이그레이션기</title>
      <link>https://osoohynn.tistory.com/16</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 저장을 전부 RDB에 저장했습니다. 문제마다, 각 테스트 케이스마다 카디널리티와 애트리뷰트 수가 다르다보니 각각 데이터를 저장할 수 없었습니다. 따라서 우선적으로 메타데이터 라는 이름으로 Json 형식으로 저장했었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 서비스가 커지고 문제가 발생했었습니다. 이미 등록한 문제를 풀어보니 메타데이터에서 수정이 필요한 부분이 있었습니다. NULL값이 들어가야하는데 공백이 들어가있어 문제를 급하게 수정해야하는 상황이였습니다. 그런데 데이터 베이스에 JSON형태로 들어가 있다보니 수정이 필요한 부분만 딱 찝어서 수정이 어려웠고 결국 하나하나 읽은 후 새 데이터로 덮어써야했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 겪다보니.. 역시 JSON데이터를 문자열로 저장하는 것은 아니라고 판단했습니다. 따라서 NoSQL로 이전하는것이 바람직하다고 생각했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;저장 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Problem 테이블&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컬럼 타입 내용&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style7&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;schema_sql&lt;/td&gt;
&lt;td&gt;TEXT&lt;/td&gt;
&lt;td&gt;SqlGenerator가 생성한 CREATE TABLE SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;schema_metadata&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;SchemaMetadata 객체 그대로&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TestCase 테이블&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컬럼 타입 내용&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style7&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;init_sql&lt;/td&gt;
&lt;td&gt;TEXT&lt;/td&gt;
&lt;td&gt;생성된 INSERT SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;init_metadata&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;InitMetadata 객체 그대로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;answer&lt;/td&gt;
&lt;td&gt;TEXT&lt;/td&gt;
&lt;td&gt;탭 구분 TSV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;answer_metadata&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;AnswerMetadata 객체 그대로&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;metadata에서 SQL이나 TSV는 SqlGenerator로 언제든 재생성할 수 있습니다. 그런데 metadata와 SQL/TSV를 &lt;b&gt;둘 다&lt;/b&gt; 저장하고 있었습니다. 만약 SQL만 수동으로 수정하거나, 한쪽만 업데이트되면 metadata와 SQL 간 내용이 어긋나게 됩니다. 단일 진실 공급원(Single Source of Truth)이 깨지는 거죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 텍스트 데이터에서 데이터 자체에 탭(\\t)이나 개행(\\n)같은 파싱 방법이 불안정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 컬럼마다 ObjectMapper 인스턴스가 새로 생성되고 있었습니다. ObjectMapper는 스레드 세이프하므로 싱글턴으로 공유하는 것이 맞는데, 불필요한 인스턴스가 계속 만들어지고 있었던 거죠.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-31 22.16.38.png&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;1530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbaXKC/dJMcaare6Tf/pV0gZXV8Qz3us27h6fTwzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbaXKC/dJMcaare6Tf/pV0gZXV8Qz3us27h6fTwzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbaXKC/dJMcaare6Tf/pV0gZXV8Qz3us27h6fTwzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbaXKC%2FdJMcaare6Tf%2FpV0gZXV8Qz3us27h6fTwzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;474&quot; height=&quot;1530&quot; data-filename=&quot;스크린샷 2026-03-31 22.16.38.png&quot; data-origin-width=&quot;474&quot; data-origin-height=&quot;1530&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;NoSQL&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조가 유동적인 데이터를 문자열로 직렬화해서 RDB에 억지로 끼워 넣는 것 자체가 문제였습니다. NoSQL, 그중에서도 MongoDB로 이전하면 JSON 데이터를 네이티브 도큐먼트로 저장할 수 있습니다. 필드 단위 조회와 수정이 가능해지고, 타입 정보도 보존되며, 중복 저장의 필요성도 사라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis로 문제를 한 번 조회하고 전체를 Redis에 저장하기 때문에 성능상으로도 큰 문제는 없을 거라고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 과정이 너무 재밌을 것 같네요!!&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;아키텍처 결정: Service-Level&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB를 도입한다고 해서 MySQL을 버리는 건 아닙니다. 문제의 제목, 설명, 난이도 같은 정형 데이터는 여전히 MySQL이 적합합니다. 옮겨야 할 건 구조가 유동적인 메타데이터 세 가지뿐이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 원래 위치 이전 후&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SchemaMetadata&lt;/td&gt;
&lt;td&gt;problems.schema_metadata (JSON)&lt;/td&gt;
&lt;td&gt;MongoDB problem_metadata 컬렉션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InitMetadata&lt;/td&gt;
&lt;td&gt;test_cases.init_metadata (JSON)&lt;/td&gt;
&lt;td&gt;MongoDB testcase_metadata 컬렉션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AnswerMetadata&lt;/td&gt;
&lt;td&gt;test_cases.answer_metadata (JSON)&lt;/td&gt;
&lt;td&gt;MongoDB testcase_metadata 컬렉션&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심적인 설계 결정이 하나 필요했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL 데이터와 MongoDB 데이터를 어디서 조합할 것인가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선택지는 크게 두 가지였습니다. 하나는 Repository 레벨에서 두 저장소를 모두 알게 하는 방법, 다른 하나는 Service 레벨에서 조합하는 방법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Service-Level Joining&lt;/b&gt;을 선택했습니다. 이유는 Repository는 자기가 담당하는 저장소만 알아야 합니다. MySQL Repository가 MongoDB의 존재를 알게 되는 순간, 저장소 간 의존이 생기고, 테스트할 때 두 저장소를 동시에 모킹해야 하는 번거로움이 따라옵니다. 각 Repository는 자기 저장소만 책임지고, Service에서 양쪽 결과를 copy()로 조합하는 게 훨씬 깔끔합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 흐름을 정리하면 이렇습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL에서 Problem 조회 &amp;rarr; MongoDB에서 SchemaMetadata 조회 &amp;rarr; problem.copy(schemaMetadata = ...) &amp;rarr; 완전한 도메인 객체 &amp;rarr; Redis 캐싱&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 Redis 캐싱이 이미 걸려 있었기 때문에, 조합된 완전한 도메인 객체를 캐싱하면 두 번째 조회부터는 MongoDB에 요청조차 가지 않습니다. 즉, 저장소가 두 개로 늘어났지만 &lt;b&gt;캐시 히트 시 성능 영향은 거의 없을 것으로 예상&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MongoDB Document 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB 쪽 도큐먼트 구조는 최대한 단순하게 가져갔습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Document(collection = &quot;problem_metadata&quot;)
data class ProblemMetadataDocument(
    @Id val id: String? = null,
    @Indexed(unique = true) val problemId: Long,
    val schemaMetadata: SchemaMetadata
)

@Document(collection = &quot;testcase_metadata&quot;)
data class TestCaseMetadataDocument(
    @Id val id: String? = null,
    @Indexed(unique = true) val testCaseId: Long,
    val initMetadata: InitMetadata?,
    val answerMetadata: AnswerMetadata?
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계할 때 고민했던 포인트가 몇 가지 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;problemId / testCaseId에 unique index를 걸었습니다. MongoDB의 _id는 ObjectId로 자동 생성되기 때문에, MySQL의 PK와 매핑하려면 별도 필드가 필요합니다. 이 필드에 유니크 인덱스를 걸어두면 중복 삽입을 DB 레벨에서 막을 수 있고, findByProblemId() 같은 조회도 인덱스 스캔으로 처리됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TestCase 조회 시 배치 조회를 고려했습니다. 문제 하나에 딸린 테스트 케이스가 여러 개이기 때문에, findByTestCaseIdIn(testCaseIds) 메서드를 만들어서 N+1 문제를 방지했습니다. testCaseId 목록을 한 번에 넘기면 MongoDB에서 $in 쿼리로 한 번에 가져옵니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Service 레이어 변경&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// ProblemService.kt
class ProblemService(
    private val problemRepository: ProblemRepository,           // MySQL
    private val problemMetadataMongoRepository: ProblemMetadataMongoRepository  // MongoDB
) {
    fun findById(id: Long): Problem {
        val problem = problemRepository.findById(id)        // MySQL 조회
        val metadata = problemMetadataMongoRepository
            .findByProblemId(id)                             // MongoDB 조회
        return problem.copy(
            schemaMetadata = metadata?.schemaMetadata         // 조합
        )
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL Repository에서는 메타데이터 관련 파라미터를 전부 제거했습니다. save()에서 schemaMetadata 세팅 라인 삭제, update()에서 schemaMetadata 파라미터 삭제. Repository가 알아야 할 게 줄어든 거죠. 대신 Service의 create()와 update()에서 MongoDB 저장을 별도로 호출합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TestCase 쪽도 같은 패턴입니다만 findAll()에서는 앞서 만들어둔 배치 조회를 활용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun findAll(problemId: Long): List&amp;lt;TestCase&amp;gt; {
    val testCases = testCaseRepository.findAllByProblemId(problemId)
    val testCaseIds = testCases.map { it.id }
    val metadataMap = testCaseMetadataMongoRepository
        .findByTestCaseIdIn(testCaseIds)
        .associateBy { it.testCaseId }    // Map으로 변환하여 O(1) lookup

    return testCases.map { tc -&amp;gt;
        val meta = metadataMap[tc.id]
        tc.copy(
            initMetadata = meta?.initMetadata,
            answerMetadata = meta?.answerMetadata
        )
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;FlywayMigrationStrategy&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot가 제공하는 FlywayMigrationStrategy를 오버라이드해서, Flyway migrate() 호출 &lt;b&gt;직전에&lt;/b&gt; 데이터를 먼저 MongoDB로 옮겼습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@Configuration
class MetadataMigrationConfig(
    private val mongoTemplate: MongoTemplate,
    private val jdbcTemplate: JdbcTemplate
) {
    @Bean
    fun flywayMigrationStrategy(): FlywayMigrationStrategy =
        FlywayMigrationStrategy { flyway -&amp;gt;
            migrateMetadataToMongo()   // 1) JSON 컬럼 살아있을 때 읽어서 이전
            flyway.migrate()            // 2) 이제 컬럼 DROP해도 안전
        }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 순서를 정리하면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;앱 시작 &amp;rarr; DataSource 초기화 &amp;rarr; FlywayMigrationStrategy 실행
  └&amp;rarr; migrateMetadataToMongo() (JSON 컬럼 아직 살아있음 ✅)
  └&amp;rarr; flyway.migrate() (컬럼 DROP ✅)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JdbcTemplate을 직접 쓴 이유는, 이 시점에서 Exposed나 JPA가 완전히 초기화됐다는 보장이 없어서입니다. JdbcTemplate은 DataSource만 있으면 동작하니까 가장 안전했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멱등성 보장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 재시작할 때마다 중복 삽입되면 안 되니까, collectionExists()로 이미 컬렉션이 있으면 통째로 건너뛰게 했습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;private fun migrateMetadataToMongo() {
    if (mongoTemplate.collectionExists(&quot;problem_metadata&quot;)) return

    jdbcTemplate.queryForList(
        &quot;SELECT id, schema_metadata FROM problems WHERE schema_metadata IS NOT NULL&quot;
    ).forEach { row -&amp;gt;
        val doc = Document(&quot;problemId&quot;, row[&quot;id&quot;])
            .append(&quot;schemaMetadata&quot;, parseJson(row[&quot;schema_metadata&quot;] as String))
        mongoTemplate.insert(doc, &quot;problem_metadata&quot;)
    }

    jdbcTemplate.queryForList(
        &quot;SELECT id, init_metadata, answer_metadata FROM test_cases &quot; +
        &quot;WHERE init_metadata IS NOT NULL OR answer_metadata IS NOT NULL&quot;
    ).forEach { row -&amp;gt;
        val doc = Document(&quot;testCaseId&quot;, row[&quot;id&quot;])
        row[&quot;init_metadata&quot;]?.let { doc.append(&quot;initMetadata&quot;, parseJson(it as String)) }
        row[&quot;answer_metadata&quot;]?.let { doc.append(&quot;answerMetadata&quot;, parseJson(it as String)) }
        mongoTemplate.insert(doc, &quot;testcase_metadata&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MySQL 쪽 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 MongoDB로 넘어간 뒤, Flyway가 JSON 컬럼을 제거합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- V6__remove_json_metadata_columns.sql
ALTER TABLE problems DROP COLUMN schema_metadata;
ALTER TABLE test_cases DROP COLUMN init_metadata;
ALTER TABLE test_cases DROP COLUMN answer_metadata;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서 MySQL에는 정형 데이터만, MongoDB에는 메타데이터만 남습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동화된 마이그레이션은 수작업 대비 시간을 크게 단축시켰습니다. 데이터의 성격을 무시하고 하나의 DB에 모든 걸 맡기면, 결국 그 대가는 운영할 때 돌아온다는 점을 느꼈습니다.. &lt;/p&gt;</description>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/16</guid>
      <comments>https://osoohynn.tistory.com/16#entry16comment</comments>
      <pubDate>Tue, 31 Mar 2026 22:22:20 +0900</pubDate>
    </item>
    <item>
      <title>SQL 채점 서비스의 문제 추천 성장기</title>
      <link>https://osoohynn.tistory.com/15</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;SQL 채점 서비스 기획 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 마이스터고 학생으로, 백엔드 개발자를 목표로 공부하고 있습니다. 평소 SQL에 관심이 많아 꾸준히 학습해왔지만, 생각보다 SQL 실력을 체계적으로 향상시키는 것이 쉽지 않다는 것을 느꼈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 주변 동기나 후배들을 보면 API 개발에 집중하면서 ORM을 주로 사용하다 보니, 기본적인 JOIN 쿼리조차 직접 작성해본 경험이 부족한 경우가 많았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 모습을 보며 &amp;ldquo;SQL을 직접 연습할 수 있는 환경이 조금 더 쉽게 제공된다면 좋지 않을까?&amp;rdquo;라는 생각이 들었습니다. 그래서 별도의 환경 설정 없이 바로 SQL을 실행하고 채점까지 받을 수 있는 학습 서비스를 만들게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스가 궁금하다면? &lt;a href=&quot;https://querify.dev&quot;&gt;https://querify.dev&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 블로그가 궁금하다면? &lt;a href=&quot;https://osoohynn.tistory.com/14&quot;&gt;https://osoohynn.tistory.com/14&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SQL 실력을 향상시키려면&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL 실력을 향상시키려면 어떻게 해야할까요?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알고리즘 공부를 예로 들어보겠습니다. 알고리즘 실력을 키우기 위해서는 &lt;b&gt;자신의 실력보다 약간 어려운 문제를 반복적으로 푸는 과정&lt;/b&gt;이 중요합니다. 이는 알고리즘뿐 아니라 대부분의 학습 영역에서 공통적으로 적용되는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련 참고 자료&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브 스앤프 - 일반인이 '재능충' 소리 듣는 법&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=IPmOuKt4SR4&quot;&gt;https://www.youtube.com/watch?v=IPmOuKt4SR4&lt;/a&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;youtube&quot; data-video-url=&quot;https://www.youtube.com/watch?v=IPmOuKt4SR4&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/cdAzcx/dJMb86nWzSX/PDLQ81Fh0LLOop45qaKLI0/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=518_138_764_384,https://scrap.kakaocdn.net/dn/eweZJR/dJMb9lk6asU/VBkS0m6JjJ7K4vLuwD3k41/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=518_138_764_384&quot; data-video-width=&quot;860&quot; data-video-height=&quot;484&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;484&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-title=&quot;일반인이 &quot; data-original-url=&quot;&quot;&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/IPmOuKt4SR4&quot; width=&quot;860&quot; height=&quot;484&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책 대니얼 코일 - 탤런트 코드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000000408049&quot;&gt;https://product.kyobobook.co.kr/detail/S000000408049&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773877573792&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;탤런트 코드 | 대니얼 코일 - 교보문고&quot; data-og-description=&quot;탤런트 코드 | 타고난 유전자, 꾸준한 노력, 좋은 환경만으로 설명되지 않던 &amp;lsquo;재능 폭발&amp;rsquo;의 비밀을 밝히는 3가지 코드 ★ 2021 특별합본판 : 매뉴얼북 『재능을 폭발시키는 52가지 학습의 기술』&quot; data-og-host=&quot;product.kyobobook.co.kr&quot; data-og-source-url=&quot;https://product.kyobobook.co.kr/detail/S000000408049&quot; data-og-url=&quot;https://product.kyobobook.co.kr/detail/S000000408049&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/iwmbs/dJMb8ZvAuWd/zPsQlv6RMVEI5ydVZvI8lK/img.jpg?width=458&amp;amp;height=679&amp;amp;face=0_0_458_679,https://scrap.kakaocdn.net/dn/WqhFj/dJMb8YpUr9P/aMy5nJOV5gAkGpn0PBXxAk/img.jpg?width=458&amp;amp;height=679&amp;amp;face=0_0_458_679&quot;&gt;&lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000000408049&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://product.kyobobook.co.kr/detail/S000000408049&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/iwmbs/dJMb8ZvAuWd/zPsQlv6RMVEI5ydVZvI8lK/img.jpg?width=458&amp;amp;height=679&amp;amp;face=0_0_458_679,https://scrap.kakaocdn.net/dn/WqhFj/dJMb8YpUr9P/aMy5nJOV5gAkGpn0PBXxAk/img.jpg?width=458&amp;amp;height=679&amp;amp;face=0_0_458_679');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;탤런트 코드 | 대니얼 코일 - 교보문고&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;탤런트 코드 | 타고난 유전자, 꾸준한 노력, 좋은 환경만으로 설명되지 않던 &amp;lsquo;재능 폭발&amp;rsquo;의 비밀을 밝히는 3가지 코드 ★ 2021 특별합본판 : 매뉴얼북 『재능을 폭발시키는 52가지 학습의 기술』&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;product.kyobobook.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 자료들에서도 강조하는 개념이 바로 &lt;b&gt;심층 연습(Deliberate Practice)&lt;/b&gt; 입니다. 단순히 문제를 많이 푸는 것이 아니라, &lt;b&gt;자신의 수준에 맞는 문제를 지속적으로 해결하는 과정&lt;/b&gt;이 실력 향상에 큰 도움을 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 알고리즘으로 예시를 들어볼까요? 자신의 실력보다 약간 어려운 문제를 많이 풀면 풀수록 실력이 늡니다. 꼭 알고리즘이 아니라 어떤 분야든 그렇다고 생각합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 추천 시스템을 고민하게 된 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 알고리즘 문제를 풀면서 항상 느꼈던 고민이 하나 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;지금 내 실력에 맞는 문제를 어떻게 찾지?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;너무 쉬운 문제는 실력 향상에 도움이 되지 않고, 너무 어려운 문제는 해결까지 시간이 오래 걸려 학습 흐름이 끊어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL 학습에서도 비슷한 문제가 발생할 것이라 생각했습니다. 사용자가 자신에게 적절한 난이도의 문제를 찾는 데 시간을 쓰기보다는, 문제 풀이 자체에 집중할 수 있는 환경이 필요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;사용자의 수준에 맞는 문제를 추천&lt;/b&gt;해주는 시스템을 서비스에 도입해보자는 아이디어가 떠올랐습니다. 궁극적인 목표는 단순히 문제를 푸는 것이 아니라, 사용자가 꾸준히 SQL을 연습하며 실력을 향상시키는 것입니다.이를 위해 사용자가 고민 없이 다음 문제를 풀 수 있도록 하여 문제 풀이 흐름을 끊지 않는 학습 경험을 제공하고자 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 문제 추천 전에 유저 데이터를 분석했습니다. 우선 제가 예상했던 것처럼 유저 몇명의 행동 추적을 진행한 결과 문제를 연속적으로 풀지 않는 비율이 31%였고, 난이도가 비슷비슷한 문제만 계속 풀었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 문제 추천 기능을 만들어서 이 가설을 검증해보려고 합니다! 너무 재밌을 것 같지 않나요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 난이도순으로 다음 문제를 보여주는 건 너무 단순하고, 유저마다 실력도 다르고 문제를 얼마나 쉽게 풀었는지도 다릅니다. 그래서 유저의 실력을 판단하는 요소로 지금까지 어떤 문제들을 풀었는가(제출 전체 조회)와 이번 문제를 얼마나 수월하게 풀었는가(풀이에 걸린 시간과 틀린 횟수) 둘을 블렌딩해서 지금 이 유저에게 가장 적절한 난이도를 추정하고, 거기에 가까운 문제를 찾습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 처음에는 문제를 맞춘 경우만 추천 문제를 보여주려 했으나 문제를 포기했을 경우에도 보여주면 새로 도전할 동기가 생길 것 같다고 생각했습니다. 따라서 문제를 맞혔을 때는 비슷하거나 더 어려운 난이도의 문제들을, 못 풀고 나가려 할 때는 비슷하거나 더 쉬운 문제들을 추천해주고자 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;유저 실력 추정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 히스토리에서 가중 평균 난이도를 계산합니다. 우선 기존 문제의 난이도는 1~5였는데요, 이는 실제 실력은 분별하기에는 너무 좁은 범위였기에 추천 시스템이 잘 동작하지 않을 경우를 우려했습니다. 따라서 기존에 들어있던 문제의 난이도를 Claude 에게 분석하여 1~20으로 재정비하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;652&quot; data-origin-height=&quot;224&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cN5zeb/dJMcac3scnK/qS4Z4Wg7OZBKpHnlsNBBxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cN5zeb/dJMcac3scnK/qS4Z4Wg7OZBKpHnlsNBBxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cN5zeb/dJMcac3scnK/qS4Z4Wg7OZBKpHnlsNBBxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcN5zeb%2FdJMcac3scnK%2FqS4Z4Wg7OZBKpHnlsNBBxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;652&quot; height=&quot;224&quot; data-origin-width=&quot;652&quot; data-origin-height=&quot;224&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 분모의 1 + ln(wrongCount + 1)입니다. 틀린 횟수의 영향이 선형이 아니라 로그 스케일로 체감되기 때문에, 극단적인 케이스에서도 안정적으로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;난이도 5 문제 기준&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;틀린 횟수&lt;/td&gt;
&lt;td&gt;기여도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.5349%;&quot;&gt;한 번에 정답&lt;/td&gt;
&lt;td style=&quot;width: 29.1861%;&quot;&gt;0회&lt;/td&gt;
&lt;td style=&quot;width: 46.1628%;&quot;&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.5349%;&quot;&gt;어느 정도 시행착오&lt;/td&gt;
&lt;td style=&quot;width: 29.1861%;&quot;&gt;3회&lt;/td&gt;
&lt;td style=&quot;width: 46.1628%;&quot;&gt;3.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 24.5349%;&quot;&gt;많이 틀림&lt;/td&gt;
&lt;td style=&quot;width: 29.1861%;&quot;&gt;10회&lt;/td&gt;
&lt;td style=&quot;width: 46.1628%;&quot;&gt;2.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 전체 제출이 아닌 최근 100건으로 제한한 이유는 응답 속도 확보와 함께, 최근 풀이 맥락이 실력을 더 잘 반영하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;타겟 난이도 계산&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;u&gt;현재 문제 난이도 D, 이번 문제 틀린 횟수 W를 기준으로 조정값 &amp;Delta;를 계산합니다.&lt;/u&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정답 시 (SOLVED)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-19 08.49.06.png&quot; data-origin-width=&quot;1298&quot; data-origin-height=&quot;160&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bc9fsm/dJMcabjeeg6/ct3kuqoJfUcENnbgYkcjC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bc9fsm/dJMcabjeeg6/ct3kuqoJfUcENnbgYkcjC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bc9fsm/dJMcabjeeg6/ct3kuqoJfUcENnbgYkcjC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbc9fsm%2FdJMcabjeeg6%2Fct3kuqoJfUcENnbgYkcjC0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1298&quot; height=&quot;160&quot; data-filename=&quot;스크린샷 2026-03-19 08.49.06.png&quot; data-origin-width=&quot;1298&quot; data-origin-height=&quot;160&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.4186%;&quot;&gt;W&lt;/td&gt;
&lt;td style=&quot;width: 17.2094%;&quot;&gt;&amp;Delta;&lt;/td&gt;
&lt;td style=&quot;width: 63.2558%;&quot;&gt;의미&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.4186%;&quot;&gt;0&lt;/td&gt;
&lt;td style=&quot;width: 17.2094%;&quot;&gt;+2&lt;/td&gt;
&lt;td style=&quot;width: 63.2558%;&quot;&gt;완벽하게 풀었으니 두 단계 위로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.4186%;&quot;&gt;1~2&lt;/td&gt;
&lt;td style=&quot;width: 17.2094%;&quot;&gt;+2&lt;/td&gt;
&lt;td style=&quot;width: 63.2558%;&quot;&gt;완벽하진 않지만 잘 풀었기에 두 단계 위로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.4186%;&quot;&gt;3~5&lt;/td&gt;
&lt;td style=&quot;width: 17.2094%;&quot;&gt;+1&lt;/td&gt;
&lt;td style=&quot;width: 63.2558%;&quot;&gt;살짝 힘들었으니 한 단계만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.4186%;&quot;&gt;6+&lt;/td&gt;
&lt;td style=&quot;width: 17.2094%;&quot;&gt;+0&lt;/td&gt;
&lt;td style=&quot;width: 63.2558%;&quot;&gt;많이 버벅였으니 같은 레벨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;포기 시 (LEAVING)&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-19 08.50.55.png&quot; data-origin-width=&quot;1308&quot; data-origin-height=&quot;176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHa6rP/dJMcai3DKeU/cGcjDy78YPtXKFKRj76WZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHa6rP/dJMcai3DKeU/cGcjDy78YPtXKFKRj76WZ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHa6rP/dJMcai3DKeU/cGcjDy78YPtXKFKRj76WZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHa6rP%2FdJMcai3DKeU%2FcGcjDy78YPtXKFKRj76WZ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1308&quot; height=&quot;176&quot; data-filename=&quot;스크린샷 2026-03-19 08.50.55.png&quot; data-origin-width=&quot;1308&quot; data-origin-height=&quot;176&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 실력과 블렌딩해서 최종 타겟 난이도를 구합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-19 08.59.01.png&quot; data-origin-width=&quot;1394&quot; data-origin-height=&quot;184&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MhQDN/dJMcadg1byP/fYbYLhYzxRx9WIiQHzF4YK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MhQDN/dJMcadg1byP/fYbYLhYzxRx9WIiQHzF4YK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MhQDN/dJMcadg1byP/fYbYLhYzxRx9WIiQHzF4YK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMhQDN%2FdJMcadg1byP%2FfYbYLhYzxRx9WIiQHzF4YK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1394&quot; height=&quot;184&quot; data-filename=&quot;스크린샷 2026-03-19 08.59.01.png&quot; data-origin-width=&quot;1394&quot; data-origin-height=&quot;184&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div&gt;&lt;span style=&quot;background-color: #ffffff; color: #2c2c2b;&quot; data-token-index=&quot;0&quot;&gt;0.3 &amp;times; (skill - D) &lt;/span&gt;항이 현재 문제가 유저 실력 대비 너무 쉽거나 어려웠을 때를 보정합니다. 예를 들어 실력이 10인데 난이도 3짜리를 풀었다면 타겟이 위로 당겨집니다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;후보 문제 점수화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 맞춘 문제를 제외한 모든 문제에 대해 점수를 매깁니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;222&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Vy2mL/dJMcaiijOV7/6TmXAl21EYOFNfmJr5yax1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Vy2mL/dJMcaiijOV7/6TmXAl21EYOFNfmJr5yax1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Vy2mL/dJMcaiijOV7/6TmXAl21EYOFNfmJr5yax1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVy2mL%2FdJMcaiijOV7%2F6TmXAl21EYOFNfmJr5yax1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1390&quot; height=&quot;222&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;222&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;난이도 적합도&lt;/b&gt;: 타겟과 가까울수록 높은 점수. 지수 1.5를 적용하여 거리가 멀어질수록 급격히 감소&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인기도 보정&lt;/b&gt;: 다른 사용자가 많이 푼 문제에 약간의 가산점 (로그 스케일, 상한 20점)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재시도 패널티&lt;/b&gt;: 이전에 시도했다 실패한 문제는 후순위로 밀림 (완전 제외는 하지 않음)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 3문제를 단순 점수순으로 반환하지 않고, 타겟 난이도 근처 문제 2개 + 타겟보다 1~3단계 높은 도전 문제 1개로 구성합니다. 같은 난이도 내에서 동점인 후보들은 랜덤으로 선택하여 매번 다른 문제가 추천되도록 합니다. 도전 문제를 포함한 이유는 자신의 수준보다 약간 어려운 문제를 풀어야 실력이 향상된다는 심층 연습 원리에 기반합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트리거 시점&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /problems/{problemId}/recommendations?trigger=SOLVED
GET /problems/{problemId}/recommendations?trigger=LEAVING
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;SOLVED&lt;/b&gt;에는 채점 결과 정답을 받은 직후. 비슷하거나 더 어려운 문제를 추천&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LEAVING&lt;/b&gt;에는 유저가 문제 페이지를 벗어나려 할 때. 비슷하거나 더 쉬운 문제를 추천&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LEAVING은 문제를 구경만 하고 나가는 경우를 제외하기 위해, 1분 이상 체류한 경우에만 호출합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 ML 없이 세 가지 직관적인 신호(전체 실력, 현재 퍼포먼스, 문제 인기도)를 수식으로 조합했습니다. 실력 추정에 로그 스케일 페널티를 쓴 게 핵심인데, 틀린 횟수의 영향이 선형이 아니라 체감되도록 설계해서 극단적인 케이스(100번 틀리고 맞춤)에도 안정적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MlZWn/dJMcabwJp8D/15kNhSDKkjl4mrVxGcVAYK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MlZWn/dJMcabwJp8D/15kNhSDKkjl4mrVxGcVAYK/img.jpg&quot; data-origin-width=&quot;848&quot; data-origin-height=&quot;862&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.6425%; margin-right: 10px;&quot; data-widthpercent=&quot;50.23&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MlZWn/dJMcabwJp8D/15kNhSDKkjl4mrVxGcVAYK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMlZWn%2FdJMcabwJp8D%2F15kNhSDKkjl4mrVxGcVAYK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;848&quot; height=&quot;862&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sRwdZ/dJMcahwYFDF/vuPkNiNg18wYwDANEqJZJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sRwdZ/dJMcahwYFDF/vuPkNiNg18wYwDANEqJZJ0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;854&quot; data-origin-height=&quot;876&quot; data-filename=&quot;스크린샷 2026-03-19 18.08.01.png&quot; style=&quot;width: 49.1947%;&quot; data-widthpercent=&quot;49.77&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sRwdZ/dJMcahwYFDF/vuPkNiNg18wYwDANEqJZJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsRwdZ%2FdJMcahwYFDF%2FvuPkNiNg18wYwDANEqJZJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;854&quot; height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;며칠 후 구축해둔 로그 인프라를 통해 확인해보니 이탈율이 31% -&amp;gt; 21%로 감소하였습니다~!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;느낀 점 및 회고&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 추천 같은 경우에는 제 스스로 정말 좋다라고 느끼는 공식을 적용해서 만들었습니다. 이후에는 유저를 더 수집해보고 A/B테스트 환경을 구축하고 어떤 공식과 어떤 문제들이 실력향상에 유의미한 영향을 주는지 통계를 낼 것입니다. 이 통계는 유저에게 전달해주어도 동기부여 요소로서 흥미로운 포인트가 될 것 같네요!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꾸준히 운영하고 데이터를 수집하여 성과가 나오거나 재미있는 사실을 발견한다면 블로그 업데이트 하겠습니다. :) 저는 얼른 홍보 자료도 제작을 해서 SQL에 관심있는&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;더 많은&lt;/span&gt; 분들이 서비스를 통해 성장할 수 있었으면 좋겠습니다!&lt;/p&gt;</description>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/15</guid>
      <comments>https://osoohynn.tistory.com/15#entry15comment</comments>
      <pubDate>Thu, 19 Mar 2026 09:01:11 +0900</pubDate>
    </item>
    <item>
      <title>SQL 채점 서비스 부하 테스트로 발견한 Deadlock과 성능 병목 해결기</title>
      <link>https://osoohynn.tistory.com/14</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;서비스: &lt;a href=&quot;https://querify.dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://querify.dev&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 서비스 아키텍처&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 중인 Querify 서비스는 저희 집에 직접 구축한 미니PC 기반 온프레미스 서버에서 운영되고 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2364&quot; data-origin-height=&quot;1396&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B4zrL/dJMcaio4xSJ/1MTTzW6yyfvrKbyqoJu7z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B4zrL/dJMcaio4xSJ/1MTTzW6yyfvrKbyqoJu7z0/img.png&quot; data-alt=&quot;조금 어지럽긴 하지만.. 서비스 아키텍처입니다!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B4zrL/dJMcaio4xSJ/1MTTzW6yyfvrKbyqoJu7z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB4zrL%2FdJMcaio4xSJ%2F1MTTzW6yyfvrKbyqoJu7z0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2364&quot; height=&quot;1396&quot; data-origin-width=&quot;2364&quot; data-origin-height=&quot;1396&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;조금 어지럽긴 하지만.. 서비스 아키텍처입니다!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot API 서버가 클라이언트 요청을 받으면, 일반 조회는 메인 MySQL에서 처리하고, 채점 요청은 별도의 Sandbox MySQL에서 사용자 SQL을 실행하는 구조입니다. Sandbox DB는 채점마다 테이블을 생성하고 테스트 데이터를 삽입한 뒤, 사용자 쿼리를 실행하여 정답과 비교합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계하면서 가장 많이 고려한 점이 채점 로직입니다. 사용자가 제출한 SQL을 메인 DB에서 직접 실행하면 실제 서비스 데이터를 훼손할 수 있고, 무거운 쿼리가 다른 API의 응답 시간에 영향을 줄 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;채점 SQL 격리 및 위험 쿼리 제한&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Sandbox DB를 분리함으로써 채점용 SQL 실행이 서비스 데이터에 영향을 주지 않도록 격리하고, 메인 DB의 커넥션 풀을 채점 요청이 점유하지 않도록 부하를 분산했습니다.&lt;/li&gt;
&lt;li&gt;또한 Sandbox DB에 접근하기 이전에 JsqlParser를 이용해 SELECT문만 가능하도록 필터링했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1773704598167&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fun validate(query: String) {
    val statement = try {
        CCJSqlParserUtil.parse(query)
    } catch (e: Exception) {
        throw BusinessException(GradingErrorCode.INVALID_SQL_SYNTAX)
    }

    if (statement !is Select) {
        throw BusinessException(GradingErrorCode.FORBIDDEN_SQL_STATEMENT)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Sandbox권한은 readonly로 설정하여 SELECT 요청만 허용하도록 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1773704519185&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;CREATE USER 'sandbox_readonly'@'%' IDENTIFIED BY 'sandbox_readonly';
GRANT SELECT ON sandbox.* TO 'sandbox_readonly'@'%';
FLUSH PRIVILEGES;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;마지막으로 기존 존재하던 ELK 스택에서 채점 요청만 모아서, 매일 정해진 시간에 SQL Injection 의심 쿼리를 디스코드로 발송하는 n8n 워크플로우를 만들었습니다. 이를 통해 지속적으로 의심 쿼리를 제출하는 사용자를 탐지하고 제재할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;810&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKqRRs/dJMcaflzDiY/Pk5JlcHUr3kmoHm0D69Ub0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKqRRs/dJMcaflzDiY/Pk5JlcHUr3kmoHm0D69Ub0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKqRRs/dJMcaflzDiY/Pk5JlcHUr3kmoHm0D69Ub0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKqRRs%2FdJMcaflzDiY%2FPk5JlcHUr3kmoHm0D69Ub0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;375&quot; height=&quot;231&quot; data-origin-width=&quot;810&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트 배경&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;사용자가 늘어난다면 채점 요청에 병목이 발생하지 않을까?&lt;/i&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL 채점 서비스는 여러 사용자가 동시에 문제를 풀고 쿼리를 실행하는 환경입니다. 사용자가 제출 버튼을 눌렀는데 채점이 늦게 되거나 서비스가 멈춘다면 학습 경험이 크게 떨어질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 SQL 채점은 단순 요청이 아니라 DB 실행, 결과 비교, 채점 로직 처리까지 포함되기 때문에 동시에 많은 요청이 들어올 경우 서버와 데이터베이스에 큰 부하가 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 실제 사용자들이 동시에 문제를 제출하는 상황을 가정하여 부하테스트를 진행했고, 이를 통해 병목이 발생하는 지점을 미리 파악하고 개선하고자 했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;부하테스트 환경&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하테스트는 AWS EC2 + k6 환경에서 진행했습니다. 기존 미니PC에서는 다른 프로그램도 동작하고 있었고, 부하테스트 중 서버가 다운되더라도 빠르게 복구할 수 있는 EC2 환경이 더 적합하다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스는 미니PC와 비슷한 스펙의 t4g.medium을 고려했으나, 더 작은 환경에서도 버틸 수 있다면 프로덕션에서의 안정성을 더 높은 수준으로 보장할 수 있을 것 같아 t4g.small(vCPU 2개, 메모리 2GB)을 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 부하테스트는 퍼블릭 IP를 통해 요청을 보냈기 때문에 측정된 latency에 네트워크 구간이 포함되어 있습니다. 이번 부하테스트의 목적은 서버 내부 병목을 더 정확히 측정하는 것이였기 때문에, 같은 VPC 내 프라이빗 IP로 요청하는 것이 적절하다는 것을 알게되었습니다. 다음부터는 부하 테스트 목적에 맞게 적절한 네트워크 환경을 구축해야겠다는 점을 알게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2에 데이터를 마이그레이션한 후, Prometheus + Grafana로 모니터링 대시보드를 구성했습니다. Spring Boot Actuator + Micrometer를 통해 HTTP 응답 시간(p95/p99), RPS, 에러율과 함께 HikariCP 커넥션 풀 상태(active/pending)를 수집했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1차 부하테스트: 고부하 (초당 30~100 요청)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6 스크립트는 두 가지 시나리오로 구성했고 타깃 API는 문제 전체 조회와 제출, 제출 조회입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;export const options = {
  scenarios: {
    // 시나리오 1: 평상시 트래픽 (3분)
    normal_load: {
      executor: 'constant-arrival-rate',
      rate: 30,            // 초당 30 요청
      timeUnit: '1s',
      duration: '3m',
      preAllocatedVUs: 50,
      maxVUs: 100,
    },
    // 시나리오 2: 스파이크 (3분)
    exam_end_spike: {
      executor: 'ramping-arrival-rate',
      startRate: 30,
      timeUnit: '1s',
      stages: [
        { duration: '30s', target: 100 },  // 30초간 3배 급증
        { duration: '2m', target: 100 },   // 2분간 피크 유지
        { duration: '30s', target: 30 },   // 정상화
      ],
      preAllocatedVUs: 200,
      maxVUs: 300,
      startTime: '3m',
    },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평상시 초당 30 요청을 3분간 유지한 뒤, 초당 100 요청으로 급증시키는 시나리오입니다. 총 6분간의 테스트를 진행하려는 도중 스파이크 구간에서 서버가 다운되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&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;p95 latency&lt;/td&gt;
&lt;td&gt;4,901ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;10,000ms (타임아웃)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error rate&lt;/td&gt;
&lt;td&gt;26.28%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RPS&lt;/td&gt;
&lt;td&gt;41.8/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;총 처리 건수&lt;/td&gt;
&lt;td&gt;8,072건 (중간에 다운)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트 결과 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행계획 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 의심한 것은 DB 쿼리입니다. 채점 결과 조회는 가장 자주 호출되는 API 중 하나이므로, 이 쿼리에 EXPLAIN ANALYZE를 실행해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 인덱스 현황은 다음과 같았습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&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;PRIMARY&lt;/td&gt;
&lt;td&gt;id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idx_user_id&lt;/td&gt;
&lt;td&gt;user_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idx_problem_user&lt;/td&gt;
&lt;td&gt;problem_id, user_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;idx_deleted_at&lt;/td&gt;
&lt;td&gt;deleted_at&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(다른 용도로 만든 인덱스) idx_problem_user(problem_id, user_id)로 problem_id 필터는 성공했지만, created_at 정렬을 커버하지 못했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세히 살펴보면, 채점 결과 조회 요청에 페이지네이션을 적용하여서 최신 20건만 가져오면 되는데 인덱스에 정렬 정보가 없으니까 일단 전부(6,933행) 읽고 &amp;rarr; 정렬하고 &amp;rarr; 20개 자르는 비효율적인 방식으로 동작했습니다. 이는 데이터가 쌓일수록 느려지는 구조였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;커넥션 풀 포화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana를 확인해보니, HikariCP의 pending(커넥션 대기 수)이 180건 이상으로 치솟는 것이 보였습니다. 기본 커넥션 풀 크기인 10개로는 동시 요청을 감당하기 어려웠고, 커넥션을 얻지 못한 스레드가 기본 타임아웃 30초 동안 묶이면서 톰캣 스레드 풀까지 연쇄적으로 고갈되면서 최종적으로 서버가 다운되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;680&quot; data-origin-height=&quot;680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kTC3w/dJMcah4Lva8/7iAcscDtL0NHxbk9alcAoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kTC3w/dJMcah4Lva8/7iAcscDtL0NHxbk9alcAoK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kTC3w/dJMcah4Lva8/7iAcscDtL0NHxbk9alcAoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkTC3w%2FdJMcah4Lva8%2F7iAcscDtL0NHxbk9alcAoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;446&quot; height=&quot;446&quot; data-origin-width=&quot;680&quot; data-origin-height=&quot;680&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;max-pool-size 기본값 10개에 동시 요청이 몰리면, 10개의 커넥션이 전부 사용 중일 때 나머지 요청은 대기합니다. 이때 connection-timeout 기본값이 30초이므로, 커넥션을 못 얻은 스레드가 30초 동안 묶이면서 톰캣 스레드 풀까지 연쇄적으로 고갈, 최종적으로 서버가 다운된 것이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인덱스, 커넥션 풀 튜닝&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1030&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bScdt2/dJMcacbjgf7/vEm3s436efLnNs2ROrBo21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bScdt2/dJMcacbjgf7/vEm3s436efLnNs2ROrBo21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bScdt2/dJMcacbjgf7/vEm3s436efLnNs2ROrBo21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbScdt2%2FdJMcacbjgf7%2FvEm3s436efLnNs2ROrBo21%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1030&quot; height=&quot;440&quot; data-origin-width=&quot;1030&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Index 추가&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 20건을 판단하는 방법이 created_at이였기 때문에 해당 컬럼에 인덱스가 필요했습니다. 따라서 문제와 제출 시점을 순서로 idx_submission_problem_created 복합 인덱스를 생성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 커버링 인덱스도 검토했으나 현재 조회가 전체 컬럼을 반환하기 때문에 커버링 인덱스를 구성하려면 모든 컬럼을 인덱스에 포함해야 하며, 이는 인덱스 크기가 테이블과 거의 동일해져 오히려 INSERT 성능을 저하시킨다고 판단하고 배제했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;커넥션 풀 사이즈와 타임아웃 수정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커넥션 풀 사이즈는 HikariCP wiki의 풀 사이징 가이드라인 connections = ((core_count * 2) + effective_spindle_count)을 참고하여 튜닝했습니다. vCPU 2개 기준으로 약 5개가 권장값이지만, 채점 요청이 sandbox DB에서 SQL을 실행하는 동안 커넥션을 오래 점유하는 I/O 바운드 특성을 감안하여 20으로 설정했습니다.&lt;br /&gt;한편 커넥션을 획득하지 못한 요청이 장시간 대기하며 톰캣 스레드를 점유하지 않도록 &lt;code&gt;connectionTimeout&lt;/code&gt;을 3초로 설정해 빠르게 실패하도록 구성했습니다. 이를 통해 커넥션 풀 고갈 상황이 전체 요청 처리 지연으로 확산되는 것을 방지했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 추가로 쿼리 실행 시간이 26.2ms에서 0.05ms로 99.8% 단축되고, 스캔 행 수 6,933행에서 20행으로 감소하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RPS는 41.8/s에서 84.7/s로 103% 향상, 총 처리 건수도 8,072건에서 15,351건으로 90% 증가하였으며,&lt;br /&gt;p95 latency는 4,901ms에서 3,536ms로 28% 개선, p99는 10,000ms(타임아웃)에서 4,830ms로 52% 개선되었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SubmissionCount 증가 문제 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 추가와 커넥션 풀 튜닝을 적용한 뒤, 동일한 시나리오로 부하테스트를 재진행했습니다.&lt;/p&gt;
&lt;table style=&quot;height: 145px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;지표&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;Before&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;After (인덱스+풀)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;p95 latency&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;4,901ms&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;3,536ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;p99 latency&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;10,000ms (타임아웃)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;4,830ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;median&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;92ms&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;888ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;성공 요청 median&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;45ms&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;596ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Error rate&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;26.28%&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;31.02%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;RPS&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;41.8/s&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;84.7/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;처리 건수&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;8,072건 (다운)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;15,351건 (생존)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 다운되지 않고 끝까지 생존했고, RPS가 41.8에서 84.7로 약 2배 향상되었습니다. 에러율이 31%로 오히려 높아진 것처럼 보이지만, Before에서는 서버가 중간에 다운되어 이후 요청이 집계되지 않은 반면 After에서는 끝까지 살아남아 2배 많은 요청을 처리한 결과입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  그러나 에러율 31%는 여전히 높았습니다. 인덱스와 커넥션 풀 외에 다른 병목이 있다는 의미입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 에러 로그를 분석한 결과, Deadlock이 다수 발생하고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;MySQLTransactionRollbackException: 
Deadlock found when trying to get lock; try restarting transaction

at ProblemRepositoryImpl.incrementSubmittedCount()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 채점 제출 시 호출되는 &lt;code&gt;incrementSubmittedCount()&lt;/code&gt;에 있었습니다. 이 메서드는 매 요청마다 problem 테이블의 동일 행에 &lt;code&gt;UPDATE SET submissionCount = submissionCount + 1&lt;/code&gt;을 실행합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KhXKZ/dJMcadVzxf5/B9hKhWUYqAP0KnjU5KKSw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KhXKZ/dJMcadVzxf5/B9hKhWUYqAP0KnjU5KKSw1/img.png&quot; data-origin-width=&quot;1324&quot; data-origin-height=&quot;896&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.3728%; margin-right: 10px;&quot; data-widthpercent=&quot;48.94&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KhXKZ/dJMcadVzxf5/B9hKhWUYqAP0KnjU5KKSw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKhXKZ%2FdJMcadVzxf5%2FB9hKhWUYqAP0KnjU5KKSw1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1324&quot; height=&quot;896&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nERW8/dJMcadBeRsW/je51BRqXN1ibmQNw0OT290/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nERW8/dJMcadBeRsW/je51BRqXN1ibmQNw0OT290/img.png&quot; data-origin-width=&quot;1372&quot; data-origin-height=&quot;890&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.4644%;&quot; data-widthpercent=&quot;51.06&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nERW8/dJMcadBeRsW/je51BRqXN1ibmQNw0OT290/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnERW8%2FdJMcadBeRsW%2Fje51BRqXN1ibmQNw0OT290%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1372&quot; height=&quot;890&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 InnoDB의 FK 제약 체크 메커니즘에 있었습니다. INSERT INTO submissions 실행 시, InnoDB는 FK로 참조하는 problem 행의 존재를 확인하기 위해 해당 행에 S Lock(공유 락)을 자동으로 설정합니다. S Lock끼리는 호환되므로 동시에 여러 트랜잭션이 같은 problem 행에 S Lock을 획득할 수 있습니다. 문제는 그 다음입니다. 각 트랜잭션이 UPDATE problem SET submissionCount = submissionCount + 1을 실행하려면 같은 행에 X Lock(배타 락)이 필요한데, 상대방이 이미 잡고 있는 S Lock이 해제될 때까지 기다려야 합니다. 두 트랜잭션이 서로의 S Lock 해제를 기다리는 순환 대기가 형성되면서 Deadlock이 발생한 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4054&quot; data-origin-height=&quot;2524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JyM6M/dJMcaiWSqwo/vSmNYKyLYqm75FrKGKqbFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JyM6M/dJMcaiWSqwo/vSmNYKyLYqm75FrKGKqbFK/img.png&quot; data-alt=&quot;Deadlock 시퀀스 다이어그램&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JyM6M/dJMcaiWSqwo/vSmNYKyLYqm75FrKGKqbFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJyM6M%2FdJMcaiWSqwo%2FvSmNYKyLYqm75FrKGKqbFK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;4054&quot; height=&quot;2524&quot; data-origin-width=&quot;4054&quot; data-origin-height=&quot;2524&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Deadlock 시퀀스 다이어그램&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;트랜잭션 A:
  1. INSERT INTO submissions
     &amp;rarr; FK 제약 체크를 위해 problem 행에 S Lock 획득 ✅
  2. UPDATE problem SET submissionCount = submissionCount + 1
     &amp;rarr; 같은 problem 행에 X Lock 요청 &amp;rarr; B의 S Lock 때문에 대기

트랜잭션 B:
  1. INSERT INTO submissions
     &amp;rarr; FK 제약 체크를 위해 같은 problem 행에 S Lock 획득 ✅
     (S Lock끼리는 호환되므로 둘 다 성공)
  2. UPDATE problem SET submissionCount = submissionCount + 1
     &amp;rarr; 같은 problem 행에 X Lock 요청 &amp;rarr; A의 S Lock 때문에 대기

A는 B의 S Lock 해제를 기다리고, B는 A의 S Lock 해제를 기다림
&amp;rarr; 순환 대기 발생 &amp;rarr; Deadlock&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트나 코드 리뷰에서는 발견하기 어려운 문제였습니다. 실제로 동시 요청이 몰리는 환경에서만 드러나는 동시성 버그였고, 부하 테스트를 하지 않았다면 프로덕션에서 처음 마주쳤을 것으로 예상합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dead Lock 해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 실시간 COUNT 쿼리로 대체하는 방법을 고려했습니다. submissionCount 컬럼을 제거하고 필요할 때마다 &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;로 직접 계산하는 방법입니다. UPDATE 자체가 사라지므로 Deadlock은 해결이 됩니다. 다만 제출 데이터가 쌓일수록 COUNT 비용이 증가하고, 문제 목록에서 문제 20개의 문제에 제출 수를 보여주려면 COUNT를 20회 실행해야 한다. 데이터가 수십만 건 이상 쌓이면 목록 조회 한 번에 수 초가 소요될 수 있어 장기적으로는 좋지 않은 판단이라고 생각했습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 등 메시지 큐를 이용해 비동기 처리 하는 방법도 있었습니다. Deadlock 없이 정확한 카운트를 유지할 수 있지만, 정수 하나를 증가시키기 위해 메시지 브로커를 경유하는 것은 현재 상황에선 오버엔지니어링이라고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채택된 방법은 Redis입니다. Redis의 &lt;code&gt;INCR&lt;/code&gt;는 원자적으로 처리되므로, 동일 키에 대한 동시 증가 요청을 안전하게 누적할 수 있었습니다. 부가적으로 응답 시간이 1ms 미만이라 사용자 체감 지연도 없으며, 구현도 기존 UPDATE문 한 줄을 Redis INCR 한 줄로 교체하는 수준이어서 변경 범위가 최소화됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;896&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zJfxZ/dJMcafFSzEv/RUAOxJKsRijcIKvzY62lF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zJfxZ/dJMcafFSzEv/RUAOxJKsRijcIKvzY62lF0/img.png&quot; data-alt=&quot;제출 수 증가 방식 비교&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zJfxZ/dJMcafFSzEv/RUAOxJKsRijcIKvzY62lF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzJfxZ%2FdJMcafFSzEv%2FRUAOxJKsRijcIKvzY62lF0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1900&quot; height=&quot;896&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;896&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;제출 수 증가 방식 비교&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 제출 건수는 실제 제출 레코드에서 언제든 정확한 값을 복구할 수 있는 통계성 데이터이므로, 강한 정합성 대신 최종 정합성으로 충분하다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis로 제출 횟수를 저장하는 구조 설계&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1030&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q9esY/dJMcaaR7EEF/RJOOjXNK807PKlkH4u9761/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q9esY/dJMcaaR7EEF/RJOOjXNK807PKlkH4u9761/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q9esY/dJMcaaR7EEF/RJOOjXNK807PKlkH4u9761/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq9esY%2FdJMcaaR7EEF%2FRJOOjXNK807PKlkH4u9761%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1030&quot; height=&quot;504&quot; data-origin-width=&quot;1030&quot; data-origin-height=&quot;504&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;누적된 카운트는 스케줄러가 1분 주기로 DB에 동기화합니다.&lt;/li&gt;
&lt;li&gt;이때 get &amp;rarr; DB 반영 &amp;rarr; decrement 순서로 처리하여, DB 업데이트가 실패하더라도 Redis에 값이 남아 다음 주기에 재시도되도록 설계했습니다.&lt;/li&gt;
&lt;li&gt;getAndDelete로 한 번에 가져오고 삭제하는 방식도 검토했으나, Redis에서 삭제 후 DB 반영 전에 서버가 다운되어있다면 카운트가 유실되는 문제가 예상되어 배제했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis INCR 적용 후 동일 시나리오로 부하테스트를 재진행했는데요, Deadlock이 제거되어 에러율이 31%에서 19%로 감소했습니다. 성공한 요청의 median latency는 596ms에서 18ms로 97% 개선됐습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테이블 이름 중복 문제 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deadlock 해결 후에도 에러율이 19%로 남아있어 앱 에러 로그를 다시 확인했습니다. Deadlock 예외는 사라졌지만, sandbox DB 관련 에러가 발생하고 있었습니다. &lt;code&gt;QueryExecutor&lt;/code&gt; 코드를 확인한 결과, 모든 채점 요청이 같은 sandbox DB에서 동일한 테이블명으로 DROP &amp;rarr; CREATE &amp;rarr; INSERT &amp;rarr; SELECT를 실행하는 구조였습니다. 동시에 채점 A가 테이블을 생성하고 데이터를 삽입하는 사이에 채점 B가 같은 테이블을 DROP하면서 충돌이 발생한 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 에러 뿐만 아니라 데이터 정합성이였습니다. 기존 구조에서는 동시 채점 시 다른 사용자의 데이터가 섞여 &lt;b&gt;채점 결과가 틀릴 수 있는&lt;/b&gt; 치명적 문제가 존재한 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL의 DDL은 일반 DML과 달리 implicit commit을 유발하거나 트랜잭션 원자성을 깨뜨릴 수 있어, 단순 트랜잭션 처리만으로는 동시 채점 간 DDL 충돌을 안전하게 제어하기 어려웠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1840&quot; data-origin-height=&quot;1154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhUKH6/dJMcacoQwIe/db1MS9Z7TaRdmJ0As705Z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhUKH6/dJMcacoQwIe/db1MS9Z7TaRdmJ0As705Z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhUKH6/dJMcacoQwIe/db1MS9Z7TaRdmJ0As705Z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhUKH6%2FdJMcacoQwIe%2Fdb1MS9Z7TaRdmJ0As705Z0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1840&quot; height=&quot;1154&quot; data-origin-width=&quot;1840&quot; data-origin-height=&quot;1154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 방안 탐색&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 도입하기에는 채점 자체가 SQL 실행과 결과 비교를 포함하는 무거운 작업이라 직렬화 시 대기 시간이 크게 증가하여 부하 테스트의 개선 의미가 사라진다고 판단했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채점 요청마다 새 Docker 컨테이너를 띄워 완전히 격리하는 방법도 고려했으나, 컨테이너 기동 오버헤드가 수 초 단위로 발생하고 메모리 점유량도 커서 현재 인프라에서는 적합하지 않았습니다. 다만 컨테이너 기반 샌드박스는 보안 격리 측면에서 장점이 있으므로, 향후 스키마 분리 방식과 장단점을 비교하여 도입을 검토할 계획입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 채점 요청마다 고유한 스키마를 동적으로 생성하여 물리적으로 격리하는 방식을 선택했습니다. 각 채점은 자신만의 스키마에서 테이블 생성, 데이터 삽입, SQL 실행을 수행하고 완료 후 스키마를 삭제합니다. 서로 다른 공간에서 작업하므로 동시 100명이 채점해도 충돌이 구조적으로 발생하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 후 마지막으로 부하테스트를 진행하였습니다.&lt;/p&gt;
&lt;table style=&quot;height: 269px;&quot; width=&quot;443&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px; width: 126px;&quot;&gt;지표&lt;/th&gt;
&lt;th style=&quot;height: 20px; width: 88px;&quot;&gt;Before&lt;/th&gt;
&lt;th style=&quot;height: 20px; width: 79px;&quot;&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 126px;&quot;&gt;median&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 88px;&quot;&gt;92ms&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 79px;&quot;&gt;26ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 126px;&quot;&gt;성공 요청 median&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 88px;&quot;&gt;45ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 79px;&quot;&gt;17ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 126px;&quot;&gt;Error rate&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 88px;&quot;&gt;26.28%&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 79px;&quot;&gt;15.13%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 126px;&quot;&gt;RPS&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 88px;&quot;&gt;41.8/s&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 79px;&quot;&gt;48.5/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 126px;&quot;&gt;Deadlock&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 88px;&quot;&gt;다수&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 79px;&quot;&gt;0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 126px;&quot;&gt;sandbox 충돌&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 88px;&quot;&gt;발생&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 79px;&quot;&gt;0건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 126px;&quot;&gt;서버 상태&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 88px;&quot;&gt;다운&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 79px;&quot;&gt;생존&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수치만 비교하자면 첫 결과에서 드라마틱한 차이는 보이지 않습니다. 이는 첫 부하테스트에는 스파이크 테스트 직후 다운되어 처리되지 못한 요청이 많다는 점을 감안해야합니다. 인덱스, 커넥션 풀, 원자적 증가, schema 분리 등의 튜닝 후 정확성과 안정성 모두 확보하면서, 성능까지 개선된 성과가 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스키마 풀&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지의 개선을 통해 서버 다운, Deadlock, sandbox 충돌 문제는 해결할 수 있었고 성공 요청 latency도 크게 개선되었습니다. 하지만 최종 부하 테스트에서도 &lt;b&gt;에러율은 15%&lt;/b&gt;가 남아 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남아 있던 에러율의 주요 원인은 채점 작업의 동시 실행 수가 제어되지 않는 점에 있었습니다. 지금 방식에서는 적당한 동시 실행 개수를 구하는 공식이 따로 없기 때문에 확인해야할 부분이였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 기존 방식은 DDL 비용이 매 채점마다 반복된다는 한계가 있었습니다. 따라서 동시 실행 수를 제한하는 것만으로는 남아 있던 오버헤드를 줄이기 어렵다고 판단했고, 이를 해결하기 위해 미리 생성한 sandbox 스키마를 재사용하는 pool 구조를 선택했습니다. 따라서 Kafka consumer 측에서 동시 실행 가능한 채점 수를 sandbox 개수와 동일하게 제한했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1508&quot; data-origin-height=&quot;2532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ojPRr/dJMcafMFxcw/DSZttUufTOpyY8lBcqNNM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ojPRr/dJMcafMFxcw/DSZttUufTOpyY8lBcqNNM1/img.png&quot; data-alt=&quot;스키마 풀 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ojPRr/dJMcafMFxcw/DSZttUufTOpyY8lBcqNNM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FojPRr%2FdJMcafMFxcw%2FDSZttUufTOpyY8lBcqNNM1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;616&quot; height=&quot;1034&quot; data-origin-width=&quot;1508&quot; data-origin-height=&quot;2532&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;스키마 풀 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;val schemaName = &quot;sandbox_${Thread.currentThread().id}_${System.nanoTime()}&quot;
...
try {
    adminDataSource.connection.use { conn -&amp;gt;
        conn.createStatement().execute(&quot;DROP DATABASE IF EXISTS `$schemaName`&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;val schemaName = schemaPool.acquire()
...
try {
    schemaPool.release(schemaName)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 기동 시 &lt;code&gt;sandbox_pool_0&lt;/code&gt; ~ &lt;code&gt;sandbox_pool_N&lt;/code&gt; 스키마를 미리 생성해두고 &lt;code&gt;ArrayBlockingQueue&lt;/code&gt;로 관리합니다. 채점 요청이 들어오면 &lt;code&gt;acquire()&lt;/code&gt;로 유휴 스키마를 빌려 채점을 수행하고, 완료 후 &lt;code&gt;release()&lt;/code&gt;로 반납합니다. &lt;code&gt;CREATE/DROP DATABASE&lt;/code&gt;는 기동 시 1회만 수행되며, 이후 채점은 이전 테이블을 &lt;code&gt;DROP&lt;/code&gt;하고 새로 생성하는 테이블 레벨 초기화만 수행합니다. 또한 Kafka consumer의 &lt;code&gt;concurrency&lt;/code&gt;를 풀 사이즈와 동일하게 설정해, 풀이 고갈될 상황 자체를 구조적으로 차단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과 채점 실패율이 &lt;b&gt;15% &amp;rarr; 4.64%&lt;/b&gt; 로 감소했고, 전체 처리 건수도 무려 21,231으로 증가했습니다.&lt;/p&gt;
&lt;table style=&quot;height: 98px; width: 402px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px; width: 81px;&quot;&gt;지표&lt;/th&gt;
&lt;th style=&quot;height: 20px; width: 124px;&quot;&gt;스키마 생성 방식&lt;/th&gt;
&lt;th style=&quot;height: 20px; width: 101px;&quot;&gt;스키마 풀 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 81px;&quot;&gt;Error rate&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 124px;&quot;&gt;15.13%&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 101px;&quot;&gt;4.64%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 81px;&quot;&gt;RPS&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 124px;&quot;&gt;48.5/s&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 101px;&quot;&gt;70.31/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;느낀 점 &amp;amp; 회고&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 부하테스트를 할 때 같은 VPC내에서 진행하지 못한 점이 아쉬웠습니다. 이 경험을 통해 다음에는 부하테스트 목적에 맞게 환경을 구성할 수 있도록 노력할 예정입니다. 커넥션 풀 사이즈도 테스트해보고 개선이 되었다고 느꼈는데, 공식을 적용해보거나 임의의 숫자로 조정해보며 공식이 의미가 있는지 그리고 몇개가 가장 최적값인지 확인해보았더라면 더 좋을 것 같습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드만으로는 발견하기 어려운 운영 환경의 문제들을 간접적으로 경험할 수 있어 의미 있는 경험이었습니다. 문제를 발견했을때, 해결 방법을 가설로 세우고 여러 가지 시도를 해보며 어떤 방법이 가장 최선이였는지를 판단하는 과정들이 뿌듯했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 많이 부족하지만 읽어주셔서 감사합니다! 어색한 부분이나 더 좋은 의견이 있다면 편하게 댓글 남겨주세요. :)&lt;/p&gt;</description>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/14</guid>
      <comments>https://osoohynn.tistory.com/14#entry14comment</comments>
      <pubDate>Tue, 17 Mar 2026 08:54:02 +0900</pubDate>
    </item>
    <item>
      <title>백엔드 Push 한 번에 프론트 PR이 날아옵니다: 백엔드 개발자가 도전한 Claude+Git Action 웹 개발 자동화</title>
      <link>https://osoohynn.tistory.com/11</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;개발 중인 프로젝트 소개&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 개발하고 있는 SQL 학습자가 직접 쿼리를 작성하고 즉시 채점받을 수 있는 온라인 저지 서비스 Querify입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 SQL 학습은 로컬 DB를 직접 세팅해야 하거나, 정답 확인이 수동으로 이루어지는 불편함이 있었습니다. Querify는 문제별로 격리된 sandbox DB 환경을 제공하여 별도 환경 구축 없이 브라우저에서 SQL을 실행하고, 정답 결과셋과 비교하여 즉시 결과를 받을 수 있도록 설계했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SQL 실행 환경을 안전하게 분리하는 구조를 직접 설계해보고 싶었습니다.&lt;/li&gt;
&lt;li&gt;또한 평소 관심이 있던 SQL 학습 도메인으로 프로젝트를 만들고, &lt;b&gt;실제 데이터를 기반으로 사용자 경험을 개선해보고 싶었습니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;자동화의 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;웹 개발 자동화가 왜 필요했을까요?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 백엔드를 주로 하고 있는 개발자입니다. 웹개발보다는 백엔드를 위주로 집중해서 프로젝트를 개발해나가고 있었고, 웹의 경우에는 Claude Code를 사용하여 디자인 및 개발을 진행했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querify를 활발하게 개발하다보니 백엔드 API가 자주 변경되었고, 그때마다 프론트 코드를 반복적으로 수정해야 했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773228300214&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;API 변경 &amp;rarr; Claude 프롬프트 작성 &amp;rarr; Claude 계획 &amp;amp; 실행 &amp;rarr; 프론트 Code Push&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 API 사용흐름을 변경하게 된다면, 변경이 된 부분을 찾아서 다시 웹코드를 열고, Claude Code를 통해 수정해야한다는 번거로움이 있었습니다. &lt;b&gt;같은 작업을 10번 정도 반복하고 나니, &amp;ldquo;이 부분은 자동화를 해도 되겠는데?&amp;rdquo; 라는 생각이 들었습니다.&lt;/b&gt; 실제로 Git API를 사용해본 경험이 있었기에 충분히 가능할 것이라고 생각했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;계획&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 전체적인 흐름을 먼저 정의해보았습니다. n8n과 Git Actions로 CI/CD 파이프라인을 설계하던 경험을 살려, 다음과 같이 구성해보았습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773228246149&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[백엔드 커밋 &amp;amp; 푸쉬] &amp;rarr; [Claude: API 변경 내용 탐지]
&amp;rarr; [변동사항 있다면 Claude 계획 수립 및 개발] &amp;rarr; [웹 코드 Branch Push 및 변경 내용 알림]
&amp;rarr; [개발자 수락] &amp;rarr; [Branch Merge]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드에서 main Branch에 Push가 일어나면 Claude를 이용해 API 변경 내용이 있는지 탐지하고 &lt;b&gt;Pull Request를 생성하고 개발자에게 검토 요청을 보냅니다&lt;/b&gt;. 그다음 제가 그 요청을 수락하면 브랜치에 머지되는 구조로 생각했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;워크플로우 환경 구축&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 크게 파이프라인을 구축하는 툴을 선택해야했습니다. 만약 팀 프로젝트였다면 팀원 모두 유지보수가 가능하게 팀원간 상의가 필요했겠지만, 1인 프로젝트였기 때문에 기술 선택에 있어 다른 관점의 판단이 필요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub관련 자동화 파이프라인을 구축하는 방법으로 세가지를 생각했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;n8n, Git Actions, 코드 방식&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 n8n은 자동화를 할 때 가장 많이 사용하는 툴이였습니다. 기본으로 제공되는 API도 많고 GUI이기 때문에 편리하게 이용할 수 있습니다. n8n을 사용한다면 저희 집에 On-Premise Server에서 돌아갈 것이라 큰 위험은 없었지만 다운될 수 있다는 우려는 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Git Actions는 GitHub에서 공식적으로 제공하는 CI/CD 툴입니다. 따라서 별도의 인프라가 불필요하고, 처음 구축하는 시간이 단축된다는 장점이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 코드를 짜는 방식도 생각해보았습니다. API서버와 분리해야한다고 생각하여, 만약 구축한다면 별도의 프로젝트와 인프라가 필요하다고 판단했습니다. 커스텀하기에는 가장 좋은 방법이지만 workflow 자체가 비동기처리나 병렬처리 없이 순차적으로 실행하는 구조이기에 커스텀의 장점이 크게 매력적으로 다가오지 않았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1104&quot; data-origin-height=&quot;740&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUWAxs/dJMcahKqmlg/7LeHliWp4gKklsnBBZycFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUWAxs/dJMcahKqmlg/7LeHliWp4gKklsnBBZycFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUWAxs/dJMcahKqmlg/7LeHliWp4gKklsnBBZycFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUWAxs%2FdJMcahKqmlg%2F7LeHliWp4gKklsnBBZycFK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;499&quot; height=&quot;334&quot; data-origin-width=&quot;1104&quot; data-origin-height=&quot;740&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 n8n과 GitHub Actions 두 개 중에 고민하였습니다. GitHub Actions는 백엔드 레포에 만든다 가정했을때, 프론트 코드에 접근하는 방법이 안전하지 않고, 프론트에게 트리거를 주는 이중 트리거를 구성하기에는 코드 방식과 불편한 정도가 비슷하다고 느꼈습니다. 자주 사용하는 툴임도 고려했을 때 n8n이 가장 적합하다고 생각하여 n8n workflow를 구성하기로 정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;워크플로우 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;워크플로우를 설계하면서 막혔던 부분이 있습니다. 백엔드의 API가 어떤게 변경이 되었는지는 알겠는데 프론트에서 어디를 수정해야할지 모른다는 문제가 있었습니다. 매번 GitHub API로 코드베이스를 탐색하는 것은 무겁고, 대신 프론트 구조 요약을 해두자니, 바뀔 때마다 업데이트를 해야하고 정확한 파일내용까지는 읽을 수 없다는 문제가 예상됐습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;372&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfQedX/dJMcaiCwuWH/0665TKOKYYKCOOQUKBaKP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfQedX/dJMcaiCwuWH/0665TKOKYYKCOOQUKBaKP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfQedX/dJMcaiCwuWH/0665TKOKYYKCOOQUKBaKP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcfQedX%2FdJMcaiCwuWH%2F0665TKOKYYKCOOQUKBaKP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;372&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;372&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub API로 트리 조회 후 변경 필요해보이는 파일만 열어볼까? 라는 방법도 생각했는데 변경 필요해보이는 부분만 고르는 데에 또 한 스텝 더 생기고 정확도가 떨어진다고 판단했습니다. 그렇다면 모든 코드를 읽을 수 있으면서도 매번 API호출을 하지 않아도 되게 n8n내에 프로젝트를 클론해두기로 결정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;n8n 시도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로는 n8n을 사용하지 못했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2874&quot; data-origin-height=&quot;404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ld8ZX/dJMcabwDLB7/MesZaOGxwNcaIj0SoRtaG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ld8ZX/dJMcabwDLB7/MesZaOGxwNcaIj0SoRtaG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ld8ZX/dJMcabwDLB7/MesZaOGxwNcaIj0SoRtaG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fld8ZX%2FdJMcabwDLB7%2FMesZaOGxwNcaIj0SoRtaG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2874&quot; height=&quot;404&quot; data-origin-width=&quot;2874&quot; data-origin-height=&quot;404&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude를 통해 수정 또는 생성할 내용을 찾아 파일 목록을 추출하는 데까지는 성공했으나 n8n에서 제공하는 GitHub 노드가 Edit 또는 Create file만 제공하였고, 한 번에 한 파일만 다룰 수 있다는 한계가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적으로는 다음과 같습니다&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Claude API 응답 파싱이 까다로웠다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;n8n의 Claude 노드 응답은 { content: [{ type: &quot;text&quot;, text: &quot;...&quot; }] } 구조로 감싸져 있어서, 순수 JSON을 요청해도 한 번 벗겨내야 했습니다. 거기에 Claude가 마크다운 코드블록이나 설명을 붙이면 파싱이 깨졌고, 이걸 처리하는 Code 노드를 따로 만들어야 했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;여러 파일을 GitHub에 올리는 게 복잡했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude가 반환한 files 배열을 개별 아이템으로 쪼개는 Split 노드, 기존 파일의 sha를 가져오는 Get 노드, 실제로 수정하는 Edit 노드 파일 하나 올리는 데 노드가 3개씩 필요했습니다. 특히 GitHub API의 sha(파일의 버전 해시로, 수정 시 충돌 방지를 위해 필요) 때문에 빠르게 진행하기가 어려웠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Shell 명령어를 쓸 수 없었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정적이었습니다. Clone으로 저장한 프론트엔드 코드를 읽으려면 grep이나 fs.readFileSync 같은 걸 써야 하는데, n8n에선 불가능했습니다. 모든 파일 접근을 GitHub API의 HTTP Request로 해야 했고, 파일 트리 조회 &amp;rarr; 필터링 &amp;rarr; 각 파일 내용 가져오기를 전부 노드로 만들면 10개가 넘어갔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Github Actions로 전환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;n8n workflow가 적합할 것이라 생각했으나 막상 구현을 하니 까다로운 부분이 있었습니다. 만약 여러 방법으로 시도를 하더라도 유지보수 측면에서 적절치 않을 듯 하여 &lt;b&gt;차선안이였던 GitActions&lt;/b&gt;를 도전하기로 했습니다. 막상 도입을 해보니 단점이라고 우려했던 오히려 장점이 된듯하여 편리함과 개발 속도에서 좋은 느낌을 받았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 백엔드 Push Trigger를 설정했습니다. main Branch에 Push가 일어나면 github.event.before 과 github.event.after로 변경점을 찾아 프론트엔드에게 전달했습니다. 처음에 구상했던 개발자 수락 -&amp;gt; 머지 부분은 제외했는데요, 직접 코드리뷰를 하는게 간단하고 정확할 것 같아서 제외했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773312888429&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;name: Notify Frontend
on:
  push:
    branches: [main]

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Send to frontend
        env:
          GH_TOKEN: ${{ secrets.FRONTEND_DISPATCH_TOKEN }}
        run: |
          DIFF=$(git diff ${{ github.event.before }} ${{ github.event.after }})
          MSG=$(git log --oneline ${{ github.event.before }}..${{ github.event.after }} | tr '\n' ' ')
          gh api repos/sql-onlinejudge/soj-frontend/dispatches \
            -f event_type=&quot;backend-updated&quot; \
            -f &quot;client_payload[diff]=$DIFF&quot; \
            -f &quot;client_payload[commit_message]=$MSG&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음은 프론트엔드에서 backend-updated Trigger를 수신하여 이를 실행합니다. 해당 프로젝트에서는 repository_dispatch를 사용했습니다. repository_dispatch는 외부 시스템이 GitHub에 이벤트 보내서 실행하는 구조인인데요, 다른 자동화에서도 적용시켜보고 싶은 부분입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773312935228&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;name: Sync Frontend from Backend Changes
on:
  repository_dispatch:
    types: [backend-updated]
    # payload: { commit_message, diff, repo }

jobs:
  update:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4

      - name: Analyze &amp;amp; Update
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          BACKEND_DIFF: ${{ github.event.client_payload.diff }}
          COMMIT_MSG: ${{ github.event.client_payload.commit_message }}
        run: node scripts/sync-backend.js

      - name: Save commit msg
        run: echo &quot;COMMIT_MSG=${{ github.event.client_payload.commit_message }}&quot; &amp;gt;&amp;gt; $GITHUB_ENV

      - name: Create PR
        id: cpr
        uses: peter-evans/create-pull-request@v5
        with:
          branch: ai/update-api-${{ github.run_number }}
          title: &quot;AI: 백엔드 API 변경 반영&quot;
          body: |
            백엔드 변경에 따른 프론트엔드 자동 업데이트
            
            **커밋:** ${{ env.COMMIT_MSG }}
          commit-message: &quot;feat: backend API 변경 반영&quot;
            
      - name: Save PR URL
        if: ${{ steps.cpr.outputs.pull-request-url }}
        run: echo &quot;PR_URL=${{ steps.cpr.outputs.pull-request-url }}&quot; &amp;gt;&amp;gt; $GITHUB_ENV
            
      - name: Notify Discords
        if: ${{ env.PR_URL != '' }}
        run: |
          curl -H &quot;Content-Type: application/json&quot; \
            -d &quot;{\&quot;content\&quot;: \&quot;  **프론트엔드 자동 업데이트 PR 생성됨**\n커밋: ${{ github.event.inputs.commit_message }}\nPR: ${PR_URL}\n\n리뷰 후 머지해주세요.\&quot;}&quot; \
            ${{ secrets.DISCORD_WEBHOOK_URL }}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 구조는 workflow 내부에서 Claude API호출과 프롬프트 텍스트를 전부 저장하는 구조였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 두가지 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째로는 GitHub Actions에서 인라인 스크립트가 ESM/CommonJs 호환 문제를 겪어서 별도 스크립트 파일로 분리하는 게 깔끔하다고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째로는 사소하지만 Git Actions 파일에 모든 내용을 넣다보니 파일이 길어져서 가독성이 안 좋아졌고 유지보수하기에 어려움을 겪을 것으로 예상했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 scripts/sync-backend.js 파일을 제작했습니다. 백엔드에서 API 수정이 아닌 &amp;lsquo;생성&amp;rsquo;의 경우에는 그에 맞는 페이지와 디자인이 필요했기 때문에 이 파이프라인에서는 수정만 다루기로 결정했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773312996227&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function findMatchingBrace(text, startIndex) {
  let depth = 0;
  let inString = false;
  let escape = false;
  for (let i = startIndex; i &amp;lt; text.length; i++) {
    const ch = text[i];
    if (escape) { escape = false; continue; }
    if (ch === '\\' &amp;amp;&amp;amp; inString) { escape = true; continue; }
    if (ch === '&quot;') { inString = !inString; continue; }
    if (inString) continue;
    if (ch === '{') depth++;
    if (ch === '}') { depth--; if (depth === 0) return i; }
  }
  return -1;
}

import fs from 'fs';
import path from 'path';

try {
  console.log('=== START ===');
  console.log('COMMIT_MSG:', process.env.COMMIT_MSG);
  console.log('DIFF length:', process.env.BACKEND_DIFF?.length);

  function getFiles(dir) {
    let results = [];
    for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
      const full = path.join(dir, f.name);
      if (f.isDirectory() &amp;amp;&amp;amp; !f.name.includes('node_modules')) {
        results = results.concat(getFiles(full));
      } else if (f.name.endsWith('.ts') || f.name.endsWith('.tsx')) {
        results.push(full);
      }
    }
    return results;
  }

  const files = getFiles('src');
  console.log('Found files:', files.length);

  const fileContents = files.map(f =&amp;gt;
    `--- ${f} ---\n${fs.readFileSync(f, 'utf-8')}`
  ).join('\n\n');

  console.log('Total content length:', fileContents.length);
  console.log('Calling Claude API...');
    
  const prompt = `당신은 시니어 프론트엔드 개발자입니다.
백엔드 API가 변경되었습니다. 아래 diff를 분석하고 프론트엔드 코드를 수정하세요.

## 백엔드 커밋 메시지
${process.env.COMMIT_MSG}

## 백엔드 코드 변경사항 (git diff)
${process.env.BACKEND_DIFF}

## 현재 프론트엔드 코드
${fileContents}

## 분석 순서
1. diff에서 변경된 API 엔드포인트, 요청/응답 필드, 파라미터를 파악하세요
2. 프론트엔드 코드에서 해당 API를 사용하는 파일을 찾으세요
3. 타입 정의, API 호출 코드, 컴포넌트 순서로 수정하세요

## 규칙
- 기존 파일만 수정하세요. 새 파일을 만들지 마세요
- 기존 코드 스타일과 패턴을 그대로 유지하세요
- 사용자에게 노출되는 Page 파일도 수정이 필요한 경우 UX를 고려하여 수정합니다
  - 새로운 필드가 추가되었다면 사용자에게 보여주어야할 가능성이 있으니 검토하세요
- 외에도 변경이 필요한 부분은 찾아서 함께 수정하되 절대 오류를 범하지 마세요
- 변경이 필요 없는 파일은 포함하지 마세요
- API 변경과 무관한 코드는 절대 건드리지 마세요
- .github, 설정 파일, package.json 등은 절대 수정하지 마세요
- diff가 API 변경이 아니면 (CI 설정, 문서, 리팩토링 등) 빈 배열을 반환하세요
- 파일의 content는 해당 파일의 전체 내용이어야 합니다 (부분 수정 아님)

## 반환 형식 (순수 JSON만, 설명 없이)
{&quot;files&quot;: [{&quot;path&quot;: &quot;src/...&quot;, &quot;content&quot;: &quot;전체 파일 내용&quot;}]}

API 변경이 없으면:
{&quot;files&quot;: []}`;

  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': process.env.ANTHROPIC_API_KEY,
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: 'claude-sonnet-4-5-20250929',
      max_tokens: 8192,
      messages: [{ role: 'user', content: prompt }],
    }),
  });

  console.log('API status:', res.status);
  const data = await res.json();
  console.log('API response keys:', Object.keys(data));

  if (data.error) {
    console.error('API error:', JSON.stringify(data.error));
    process.exit(1);
  }

  let text = data.content[0].text;
  console.log('Response length:', text.length);
  console.log('Response preview:', text.substring(0, 300));

  text = text.replace(/```json\s*/g, '').replace(/```/g, '');
  const start = text.indexOf('{');
  const end = findMatchingBrace(text, start);
  const result = JSON.parse(text.substring(start, end + 1));
    
  console.log('Files to update:', result.files.length);

  if (result.files.length === 0) {
    console.log('No frontend changes needed. Skipping.');
  }

  for (const file of result.files) {
    fs.mkdirSync(path.dirname(file.path), { recursive: true });
    fs.writeFileSync(file.path, file.content);
    console.log('Updated:', file.path);
  }

  console.log('=== DONE ===');
} catch (e) {
  console.error('=== ERROR ===');
  console.error(e.message);
  console.error(e.stack);
  process.exit(1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 action 파일, 프론트 action 파일, 스크립트 파일 총 세개의 파일로 구성을 완료하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;통합 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 구현하면서 테스트를 진행했지만 실제로 적용이 얼마나 잘 되는지 궁금했습니다. 만약 몇몇 파일이 누락된다거나, 코드 수정 도중 문제가 생긴다면 직접 처리해야하고 결국 수동으로 진행하는 것과 마찬가지이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 백엔드 API에서 문제를 조회하면 submissionCount, solvedCount를 반환했습니다. 프론트에서는 이 두 정보를 표시하지만, 정답률 정보도 표시하면 좋을 것 같다고 생각했습니다. 백엔드와 프론트엔드 중에서는 백엔드에서 계산하는 편이 좋을 것 같아서 백엔드 작업으로 남겨두었었는데요, API변경이 필요한 부분이라 지금 시도하면 좋을 것 같아서 acceptanceRate를 response에 포함시켰습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2468&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/R0PdG/dJMcagEKMss/oQt3klG0ukAfv6cbwKLnmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/R0PdG/dJMcagEKMss/oQt3klG0ukAfv6cbwKLnmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/R0PdG/dJMcagEKMss/oQt3klG0ukAfv6cbwKLnmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FR0PdG%2FdJMcagEKMss%2FoQt3klG0ukAfv6cbwKLnmk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2468&quot; height=&quot;654&quot; data-origin-width=&quot;2468&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;404&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CJTc4/dJMcacCk9Gm/mkzN6MUGKJdhcKQTJ3UoKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CJTc4/dJMcacCk9Gm/mkzN6MUGKJdhcKQTJ3UoKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CJTc4/dJMcacCk9Gm/mkzN6MUGKJdhcKQTJ3UoKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCJTc4%2FdJMcacCk9Gm%2FmkzN6MUGKJdhcKQTJ3UoKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;415&quot; height=&quot;266&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;404&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main Branch에 Push 후 프론트로 알림하는 트리거가 성공하고, 업데이트도 성공했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNIvv2/dJMcadgVZg6/8AANW9x5JPKsTqQT9bKaSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNIvv2/dJMcadgVZg6/8AANW9x5JPKsTqQT9bKaSK/img.png&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;374&quot; data-is-animation=&quot;false&quot; style=&quot;width: 62.7083%; margin-right: 10px;&quot; data-widthpercent=&quot;63.45&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNIvv2/dJMcadgVZg6/8AANW9x5JPKsTqQT9bKaSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNIvv2%2FdJMcadgVZg6%2F8AANW9x5JPKsTqQT9bKaSK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;627&quot; height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjoci1/dJMcadnFmbn/UGAq1bJlXhW2yqiVfU5zck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjoci1/dJMcadnFmbn/UGAq1bJlXhW2yqiVfU5zck/img.png&quot; data-origin-width=&quot;906&quot; data-origin-height=&quot;938&quot; data-is-animation=&quot;false&quot; style=&quot;width: 36.1289%;&quot; data-widthpercent=&quot;36.55&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjoci1/dJMcadnFmbn/UGAq1bJlXhW2yqiVfU5zck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbjoci1%2FdJMcadnFmbn%2FUGAq1bJlXhW2yqiVfU5zck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;906&quot; height=&quot;938&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;1746&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lzqSI/dJMcacI7pPS/KJKXGkKDDK9QES2cDH3oC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lzqSI/dJMcacI7pPS/KJKXGkKDDK9QES2cDH3oC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lzqSI/dJMcacI7pPS/KJKXGkKDDK9QES2cDH3oC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlzqSI%2FdJMcacI7pPS%2FKJKXGkKDDK9QES2cDH3oC0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1886&quot; height=&quot;1746&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;1746&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적절한 File들이 수정이 된 것을 확인했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1162&quot; data-origin-height=&quot;748&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcStZL/dJMcafeM1cJ/mlBtvn0CH0MR9OnNA9WGhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcStZL/dJMcafeM1cJ/mlBtvn0CH0MR9OnNA9WGhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcStZL/dJMcafeM1cJ/mlBtvn0CH0MR9OnNA9WGhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcStZL%2FdJMcafeM1cJ%2FmlBtvn0CH0MR9OnNA9WGhK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1162&quot; height=&quot;748&quot; data-origin-width=&quot;1162&quot; data-origin-height=&quot;748&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서 브랜치 이동하여 실행했습니다. 브랜치 이름 뒤에 숫자는 Actions의 번호입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1838&quot; data-origin-height=&quot;698&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4P6vt/dJMcaiJgXMX/Co16vKTKkZm4YZZjHKCIRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4P6vt/dJMcaiJgXMX/Co16vKTKkZm4YZZjHKCIRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4P6vt/dJMcaiJgXMX/Co16vKTKkZm4YZZjHKCIRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4P6vt%2FdJMcaiJgXMX%2FCo16vKTKkZm4YZZjHKCIRK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1838&quot; height=&quot;698&quot; data-origin-width=&quot;1838&quot; data-origin-height=&quot;698&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답률이 잘 표시되는 것을 확인하고 프론트 코드를 머지했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완성된 시점에서 정리해보니, 흐름 자체는 처음 계획대로 진행되었고 몇몇 부분은 진행해보며 상황에 따라 유동적으로 진행했습니다. 앞으로 더 개선한다면 변경될 수 있을 것 같아요.&lt;/p&gt;
&lt;pre id=&quot;code_1773313553922&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[백엔드 커밋 &amp;amp; 푸쉬] &amp;rarr; [Git log로 백엔드 변경사항 프론트로 전송]
&amp;rarr; [Trigger 발동] &amp;rarr; [Claude 개발 및 PR오픈]
&amp;rarr; [변경 내용 디스코드 알림] &amp;rarr; [개발자 코드 리뷰 및 머지]&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;앞으로의 계획 및 회고&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금처럼 간단한 수정에는 깔끔하게 동작하지만 대량의 API수정이 있는 상황에서는 변경이 적절하지 않을 가능성이 있습니다. 프롬프트 고도화나 분기 처리를 통해 처리할 수 있도록 디벨롭하려 합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;기능 개발을 진행하며 알게 되었는데, OpenAPI codegen으로 타입 정의까지는 자동화가 가능하단 것을 알게 되었어요. 제 프로젝트에 둘을 결합하는 방향으로 보완하여 타입 정의까지는 OpenAPI를 이용하고, 보여지는 것에 대한 수정은 Claude에게 시키도록 하면 토큰 절약이 될 것 같다고 생각이 듭니다. 만약 비슷한 자동화를 하게 된다면 다음에는 설계부터 잡고 가는 것도 좋겠습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 기능을 사용하는 지금까지는 실패나 부자연스러운 수정이 0건이였지만 앞으로 이용하며 문제나 개선점을 찾아나가고 싶습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 제가 집중하고 싶은 부분에 더 집중할 수 있어서 좋았습니다. 프론트엔드 코드 작성에 드는 시간을유저 데이터 분석이나 서비스 고도화, 비지니스 목표 탐색에 사용할 수 있어서 만족스럽습니다.&lt;/p&gt;</description>
      <category>Claude</category>
      <category>n8n</category>
      <category>Workflow</category>
      <category>자동화</category>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/11</guid>
      <comments>https://osoohynn.tistory.com/11#entry11comment</comments>
      <pubDate>Wed, 11 Mar 2026 20:25:59 +0900</pubDate>
    </item>
    <item>
      <title>[Quarkusio/Quarkus] 첫 오픈소스 기여</title>
      <link>https://osoohynn.tistory.com/10</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;Quarkus란?&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Quarkus는 Java 기반 클라우드 네이티브 애플리케이션을 빠르고 가볍게 만들기 위한 프레임워크입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;424&quot; data-start=&quot;415&quot;&gt;마이크로서비스&lt;/li&gt;
&lt;li data-end=&quot;445&quot; data-start=&quot;425&quot;&gt;컨테이너/Kubernetes 환경&lt;/li&gt;
&lt;li data-end=&quot;462&quot; data-start=&quot;446&quot;&gt;서버리스(Lambda 등)&lt;/li&gt;
&lt;li data-end=&quot;489&quot; data-start=&quot;463&quot;&gt;빠른 스타트업과 리소스 효율이 중요한 서비스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 서비스에서 주로 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이 프레임워크를 사용해보진 않았지만, JVM 언어가 주력이기 때문에 첫 기여 리포지포리로 선정해보았습니다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 오픈소스 기여 리포지토리/이슈를 선정했을 때 기준을 공유하자면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 주로 사용하는 언어로 고르려고 했습니다. AI가 코드를 작성해주더라도, 본인이 이해할 수 있어야 메인테이너분께 해를 안 끼칩니다!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 메인테이너가 (이슈, PR이) 활발한 리포지토리를 선택하려 했습니다. 체계가 있으면 배울 점이 많을 것 같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 도전하기 쉬운 issue를 고르려고 했습니다. 너무 어렵거나 큰 기능은 오래 걸리고 신경써야할 부분이 많습니다. 저는 그래서 데이터베이스 이름이 길어졌을 때 단축하는 정도의 쉬운 이슈를 선택했습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이슈 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB2는 데이터베이스 이름을 8자로 제한합니다. Quarkus Dev Services는 datasource 이름을 그대로 DB 이름으로 사용하는데, &quot;additional&quot; 같은 8자 초과 이름을 쓰면 컨테이너 기동이 실패하는 문제였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 과정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8자를 초과하는 이름을 단순히 잘라내면 서로 다른 datasource 이름이 같은 DB 이름으로 충돌할 수 있습니다. 그래서 &lt;b&gt;prefix(4자) + SHA-224 해시(4자)&lt;/b&gt; 방식을 선택했습니다. 원래 이름의 앞 4글자로 가독성을 유지하면서, 해시 4자로 unique를 보장하는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 db-name을 직접 설정한 경우에는 truncation 없이 그대로 사용하도록 했고, 자동 truncation이 발생하면 경고 로그를 남겨 사용자가 인지할 수 있게 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PR 리뷰 과정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인테이너(yrodiere)가 3차례 리뷰를 진행했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 리뷰에서 해시 알고리즘과 로그 메시지, 테스트 방식에 대한 피드백을 받았습니다. 두 번째 리뷰에서는 테스트를 별도 파일에서 기존 integration-tests로 옮기는 제안을 받아 반영했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 까다로웠던 부분은 테스트였습니다. 처음에는 경고 로그를 검증하려 했으나, CI가 실패했습니다. 원인을 추적해보니 StartupLogCompressor가 Dev Services 기동 성공 시 로그를 의도적으로 숨기고 있었습니다. 이 내부 동작을 파악한 뒤, 로그 검증 대신 REST 엔드포인트 호출로 테스트 방식을 변경하여 CI를 통과했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;첫 기여인데, 어떠셨나요?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 제가 염려한 바와 달리 메인테이너 분께서 친절하게 알려주시고 확인도 빨리 해주시는 편이였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 제안도 해주셔서 배울 점이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;내 코드가 너무 불만족스러우면 어떡하지 하는 걱정&lt;/i&gt;도 있었는데 꽤 많은 부분이 그대로 반영되었습니다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 CI가 한 번 실패했는데, 빌드가 성공하면 로그를 숨기는 코드가 있어서 실패했었습니다.ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인테이너께서 REST 테스트로 변경을 제안해주셔서, 수정하고 CI 성공했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CI 성공 알림을 확인하고 PR에 들어가봤는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;Merged,&amp;nbsp;thank&amp;nbsp;you!&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 쿨하게 남겨주시고 머지해주셨습니다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;머지까지의 과정이 복잡할 것이라 걱정했는데, 메인테이너가 활발하게 활동하는 리포지토리임에도 불구하고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엄청 복잡한 과정없이 순조롭게 끝났습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 일을 계기로 오픈소스 기여에 장벽이 허물어진 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음에는 꼭 제가 사용하는 라이브러리나 프레임워크의 오픈소스 기여도 도전해보고 싶네요!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-01-15 18.09.53.png&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;174&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/emrIbf/dJMb99ZqVKx/p4qOq2wKBA6Waj0qbHtc0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/emrIbf/dJMb99ZqVKx/p4qOq2wKBA6Waj0qbHtc0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/emrIbf/dJMb99ZqVKx/p4qOq2wKBA6Waj0qbHtc0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FemrIbf%2FdJMb99ZqVKx%2Fp4qOq2wKBA6Waj0qbHtc0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;174&quot; data-filename=&quot;스크린샷 2026-01-15 18.09.53.png&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;174&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: &lt;a href=&quot;https://github.com/quarkusio/quarkus/pull/51913&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/quarkusio/quarkus/pull/51913&lt;/a&gt;(Merge된 PR)&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배운 점&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 작은 이슈였지만, 얻을 게 많았습니다.&lt;br /&gt;- &quot;8글자 제한 처리&quot; 단순해 보였는데&lt;br /&gt;- 해시 알고리즘 선택, 테스트 방법, 로그 메시지 문구까지 신경 쓸 게 많았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 특히 로직은 괜찮았는데, 테스트가 처음 기여해보니 생소하더라고요!&lt;br /&gt;&lt;br /&gt;2. 리뷰어 피드백을 정확히 이해하고 커밋하면 좋습니다&lt;br /&gt;- 이해가 안 갈 땐, 추측하지 말고 질문하는 편이 좋습니다.&lt;br /&gt;&lt;br /&gt;3. 프레임워크 내부 이해가 중요합니다.&lt;br /&gt;- StartupLogCompressor가 로그 숨기는 거 몰랐으면 계속 헤맸을 것입니다.&lt;br /&gt;- 테스트 실패 원인 찾으려면 내부 동작 파악이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Claude Code같은 AI 툴을 잘 활용하면 좋아요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Java</category>
      <category>Quarkus</category>
      <category>오픈소스</category>
      <category>오픈소스 기여</category>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/10</guid>
      <comments>https://osoohynn.tistory.com/10#entry10comment</comments>
      <pubDate>Thu, 15 Jan 2026 18:14:59 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 헥사고날 아키텍처에 관한 고찰</title>
      <link>https://osoohynn.tistory.com/9</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 내용은 소규모 프로젝트를 개발하며 느꼈던 아키텍처에 대한 저의 생각입니다. 여러 선택을 번복하며 느낀 점을 기록하고자 작성하였습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 학교에서 사용하는 출석 서비스를 개발하는 동아리에 가입했고, 2024년 말부터 개발에 참여할 수 있었습니다. 처음 개발에 투입돼어 온보딩할 때 프로젝트 구조는 UseCase를 중심으로 개발된 '클린 아키텍처'에 가까웠습니다. 그리고 백엔드 개발을 주도하며, 이 코드가 UseCase와 Service의 불명확한 책임 분리, 도메인 로직의 비독립성이 문제가 된다고 느꼈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바꾸는 김에 완벽하게 해보자&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 저는 2개월이라는 시간동안 팀원들과 헥사고날 아키텍처로 리팩토링하였습니다. 그 당시에는 자주 변경되는 요구사항에 빠르게 대응하기 위한 아키텍처라고 생각했고 적용해보고자 팀원들을 설득하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그럼 이제부터 문제를 서술하겠습니다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 도메인 로직과 기술의 분리는 성공적이였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 무엇이 문제였나? -&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비지니스 로직 인터페이스를 설계하고 구현하지 않는 실수가 반복되었습니다. &amp;nbsp;비슷한 로직을 두번이나 구현해야하다보니까 시간이 오래 걸렸고 비효율적이였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 진짜 부끄러운 실수이긴한데 JPA의 더티 체킹 기능을 자주 이용하던 저인데, 서비스 코드에서 @Transactional 달아놓고 save() 메서드를 호출을 하는 걸 안 해서 여러 번 문제가 생겼습니다.....ㅎ.. (DB 독립적이라 기능 작동 안 함)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 수정 한 번에도 adapter까지 두 번 바꿔야하는 1+1이벤트 같았습니다. 정말 대규모 프로젝트에선 이 구조가 어떤식으로 도움이 될진 모르겠으나, 빠르게 개발해야하는 학생들에게는 독약같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고는 레이어드 아키텍처로 다시 돌아왔습니다. 팀원 모두가 편하게 빨리 개발할 수 있는 구조로 말이죠. 실제로 이렇게 변경하는데에 하루밖에 안 걸렸습니다. 그리고 그 이후의 기능 수정은 더욱 빠르게 이루어졌고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;느낀 점&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 계기를 통해서 이론적인 사실들이 모두 맞는게 아니라는 사실을 깨달았습니다. 나의 상황과 팀의 상황 모두를 고려한 선택을 해야한다는 것을 체감했습니다. 다음부턴 이론적인 면보다 실제 상황에 합리적인 기술 선택을 할 것이라고 다짐했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/9</guid>
      <comments>https://osoohynn.tistory.com/9#entry9comment</comments>
      <pubDate>Sun, 30 Nov 2025 16:40:22 +0900</pubDate>
    </item>
    <item>
      <title>스프링 개념 정복하기 - DI/IoC</title>
      <link>https://osoohynn.tistory.com/8</link>
      <description>&lt;h2 data-end=&quot;175&quot; data-start=&quot;170&quot; data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;255&quot; data-start=&quot;176&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;187&quot; data-start=&quot;176&quot;&gt;스프링/스프링부트&lt;/li&gt;
&lt;li data-end=&quot;198&quot; data-start=&quot;188&quot;&gt;IoC / DI&lt;/li&gt;
&lt;li data-end=&quot;209&quot; data-start=&quot;199&quot;&gt;스프링 컨테이너&lt;/li&gt;
&lt;li data-end=&quot;215&quot; data-start=&quot;210&quot;&gt;AOP&lt;/li&gt;
&lt;li data-end=&quot;221&quot; data-start=&quot;216&quot;&gt;PSA&lt;/li&gt;
&lt;li data-end=&quot;232&quot; data-start=&quot;222&quot;&gt;핵심 코드 예제&lt;/li&gt;
&lt;li data-end=&quot;240&quot; data-start=&quot;233&quot;&gt;문제 풀이&lt;/li&gt;
&lt;li data-end=&quot;255&quot; data-start=&quot;241&quot;&gt;예외 해결 및 배운 점&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;260&quot; data-start=&quot;257&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;279&quot; data-start=&quot;262&quot; data-ke-size=&quot;size26&quot;&gt;1️⃣ 스프링과 스프링부트&lt;/h2&gt;
&lt;h3 data-end=&quot;298&quot; data-start=&quot;281&quot; data-ke-size=&quot;size23&quot;&gt;✔ 스프링(Spring)&lt;/h3&gt;
&lt;blockquote data-end=&quot;347&quot; data-start=&quot;299&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;347&quot; data-start=&quot;301&quot; data-ke-size=&quot;size16&quot;&gt;자바 기반 &lt;b&gt;엔터프라이즈 애플리케이션&lt;/b&gt;을 효율적으로 개발하기 위한 프레임워크&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;399&quot; data-start=&quot;348&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;366&quot; data-start=&quot;348&quot;&gt;대규모 서비스 개발에 적합&lt;/li&gt;
&lt;li data-end=&quot;399&quot; data-start=&quot;367&quot;&gt;서버 성능, 안정성, 보안 등을 높은 수준으로 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;425&quot; data-start=&quot;401&quot; data-ke-size=&quot;size23&quot;&gt;✔ 스프링부트(Spring Boot)&lt;/h3&gt;
&lt;blockquote data-end=&quot;461&quot; data-start=&quot;426&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;461&quot; data-start=&quot;428&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스프링을 더 쉽게&lt;/b&gt; 사용할 수 있도록 만들어진 도구&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;583&quot; data-start=&quot;462&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;505&quot; data-start=&quot;462&quot;&gt;프로젝트 설정 자동화 &amp;amp; 의존성 관리 간소화 (starter 제공)&lt;/li&gt;
&lt;li data-end=&quot;546&quot; data-start=&quot;506&quot;&gt;내장 WAS(Tomcat 등) &amp;rarr; 별도 서버 설치 없이 실행 가능&lt;/li&gt;
&lt;li data-end=&quot;583&quot; data-start=&quot;547&quot;&gt;비즈니스 로직에 집중할 수 있도록 &lt;b&gt;반복 설정 최소화&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;588&quot; data-start=&quot;585&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;631&quot; data-start=&quot;590&quot; data-ke-size=&quot;size26&quot;&gt;2️⃣ IoC (Inversion of Control, 제어의 역전)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;709&quot; data-start=&quot;633&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;669&quot; data-start=&quot;633&quot;&gt;기존 자바: 개발자가 직접 객체 생성 (new 키워드)&lt;/li&gt;
&lt;li data-end=&quot;709&quot; data-start=&quot;670&quot;&gt;IoC: 객체의 생성과 생명주기를 &lt;b&gt;스프링 컨테이너&lt;/b&gt;가 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224659661&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 기존 방식
public class A {
	B b = new B(); // 직접 생성
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224674759&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// IoC 적용
public class A {
	private B b; // 생성은 컨테이너가 담당
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;858&quot; data-start=&quot;855&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;900&quot; data-start=&quot;860&quot; data-ke-size=&quot;size26&quot;&gt;3️⃣ DI (Dependency Injection, 의존성 주입)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;946&quot; data-start=&quot;902&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;918&quot; data-start=&quot;902&quot;&gt;IoC를 구현하는 방법&lt;/li&gt;
&lt;li data-end=&quot;946&quot; data-start=&quot;919&quot;&gt;외부에서 객체(빈)를 &lt;b&gt;주입받아 사용&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;961&quot; data-start=&quot;948&quot; data-ke-size=&quot;size23&quot;&gt;의존성 주입 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 필드 주입&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1757224531377&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class MyService {
    @Autowired
    private MyRepository myRepository;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 생성자 주입 (권장)&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224588080&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class MyService {
    private final MyRepository myRepository;

    @Autowired
    public MyService(MyRepository myRepository) {
    	this.myRepository = myRepository;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;b&gt;3. 메서드(setter) 주입&lt;/b&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224633175&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class MyService {
    private MyRepository myRepository;
    
    @Autowired
    public void setMyRepository(MyRepository myRepository) {
    	this.myRepository = myRepository;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;1543&quot; data-start=&quot;1540&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1564&quot; data-start=&quot;1545&quot; data-ke-size=&quot;size26&quot;&gt;4️⃣ 스프링 컨테이너 &amp;amp; 빈&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1710&quot; data-start=&quot;1566&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1599&quot; data-start=&quot;1566&quot;&gt;&lt;b&gt;스프링 컨테이너&lt;/b&gt;: 빈을 생성하고 관리하는 주체&lt;/li&gt;
&lt;li data-end=&quot;1710&quot; data-start=&quot;1600&quot;&gt;&lt;b&gt;빈 등록 방법&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1710&quot; data-start=&quot;1616&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1657&quot; data-start=&quot;1616&quot;&gt;@Component (가장 간단, 클래스 직접 수정 가능할 때)&lt;/li&gt;
&lt;li data-end=&quot;1710&quot; data-start=&quot;1660&quot;&gt;@Configuration + @Bean (외부 라이브러리 객체 등록 시 사용)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1715&quot; data-start=&quot;1712&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1757&quot; data-start=&quot;1717&quot; data-ke-size=&quot;size26&quot;&gt;5️⃣ AOP (Aspect Oriented Programming)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1826&quot; data-start=&quot;1759&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1778&quot; data-start=&quot;1759&quot;&gt;&lt;b&gt;관점 지향 프로그래밍&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1826&quot; data-start=&quot;1779&quot;&gt;핵심 로직과 부가 기능(로깅, 보안, 트랜잭션 등)을 분리 &amp;rarr; 유지보수성 향상&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1831&quot; data-start=&quot;1828&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1874&quot; data-start=&quot;1833&quot; data-ke-size=&quot;size26&quot;&gt;6️⃣ PSA (Portable Service Abstraction)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1994&quot; data-start=&quot;1876&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1894&quot; data-start=&quot;1876&quot;&gt;&lt;b&gt;이식 가능한 추상화&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1994&quot; data-start=&quot;1895&quot;&gt;다양한 기술을 추상화하여 &lt;b&gt;하나의 인터페이스로 사용 가능&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1994&quot; data-start=&quot;1936&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1962&quot; data-start=&quot;1936&quot;&gt;DB: JDBC, JPA, MyBatis&lt;/li&gt;
&lt;li data-end=&quot;1994&quot; data-start=&quot;1965&quot;&gt;실행: WAS (Tomcat, Jetty 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1999&quot; data-start=&quot;1996&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2016&quot; data-start=&quot;2001&quot; data-ke-size=&quot;size26&quot;&gt;7️⃣ 핵심 코드 예제&lt;/h2&gt;
&lt;h3 data-end=&quot;2030&quot; data-start=&quot;2018&quot; data-ke-size=&quot;size23&quot;&gt;✔ 의존성 주입&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224720695&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class Chef { } 

@Component
public class Restaurant {
    private final Chef chef;
    
    public Restaurant(Chef chef) {
    	this.chef = chef;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;2234&quot; data-start=&quot;2214&quot; data-ke-size=&quot;size23&quot;&gt;✔ 편집 불가 클래스 빈 등록&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224758161&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Chef { } // 수정 불가

@Configuration
public class ChefConfig {
    @Bean
    public Chef chef() {
    	return new Chef();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;2403&quot; data-start=&quot;2391&quot; data-ke-size=&quot;size23&quot;&gt;✔ 테스트 코드&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757224794706&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@SpringBootTest
class SampleTest {
    @Autowired private Restaurant restaurant;

    @Test void test() {
    	System.out.println(restaurant);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/8</guid>
      <comments>https://osoohynn.tistory.com/8#entry8comment</comments>
      <pubDate>Sun, 7 Sep 2025 15:00:01 +0900</pubDate>
    </item>
    <item>
      <title>자바 개념 정복하기 - Stream 편</title>
      <link>https://osoohynn.tistory.com/7</link>
      <description>&lt;h2 data-end=&quot;172&quot; data-start=&quot;167&quot; data-ke-size=&quot;size26&quot;&gt;목차&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;255&quot; data-start=&quot;173&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;181&quot; data-start=&quot;173&quot;&gt;스트림 개념&lt;/li&gt;
&lt;li data-end=&quot;196&quot; data-start=&quot;182&quot;&gt;중간 연산과 최종 연산&lt;/li&gt;
&lt;li data-end=&quot;221&quot; data-start=&quot;197&quot;&gt;다양한 스트림 &amp;amp; Collector 함수&lt;/li&gt;
&lt;li data-end=&quot;232&quot; data-start=&quot;222&quot;&gt;핵심 코드 예제&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;260&quot; data-start=&quot;257&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;275&quot; data-start=&quot;262&quot; data-ke-size=&quot;size26&quot;&gt;1️⃣ 스트림 개념&lt;/h2&gt;
&lt;blockquote data-end=&quot;330&quot; data-start=&quot;277&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;330&quot; data-start=&quot;279&quot; data-ke-size=&quot;size16&quot;&gt;다양한 &lt;b&gt;데이터 소스&lt;/b&gt;(컬렉션, 배열 등)를 &lt;b&gt;표준화된 방식&lt;/b&gt;으로 다루기 위한 API&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-end=&quot;338&quot; data-start=&quot;332&quot; data-ke-size=&quot;size23&quot;&gt;특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;518&quot; data-start=&quot;339&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;379&quot; data-start=&quot;339&quot;&gt;&lt;b&gt;읽기 전용(Read-only)&lt;/b&gt; &amp;rarr; 데이터 소스를 변경하지 않음&lt;/li&gt;
&lt;li data-end=&quot;417&quot; data-start=&quot;380&quot;&gt;&lt;b&gt;일회용&lt;/b&gt; &amp;rarr; 한 번 닫으면 재사용 불가 (다시 생성 필요)&lt;/li&gt;
&lt;li data-end=&quot;462&quot; data-start=&quot;418&quot;&gt;&lt;b&gt;지연 연산(Lazy)&lt;/b&gt; &amp;rarr; 최종 연산 전까지 중간 연산은 실행되지 않음&lt;/li&gt;
&lt;li data-end=&quot;518&quot; data-start=&quot;463&quot;&gt;&lt;b&gt;순차적 or 병렬 처리&lt;/b&gt; 가능 &amp;rarr; stream() / parallelStream()&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;531&quot; data-start=&quot;520&quot; data-ke-size=&quot;size23&quot;&gt;기본형 스트림&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;674&quot; data-start=&quot;532&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;575&quot; data-start=&quot;532&quot;&gt;IntStream, LongStream, DoubleStream&lt;/li&gt;
&lt;li data-end=&quot;627&quot; data-start=&quot;576&quot;&gt;오토박싱/언박싱 비용 절감 (Stream&amp;lt;Integer&amp;gt; 대신 IntStream)&lt;/li&gt;
&lt;li data-end=&quot;674&quot; data-start=&quot;628&quot;&gt;숫자 전용 메서드(sum, average, max, min) 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;679&quot; data-start=&quot;676&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;702&quot; data-start=&quot;681&quot; data-ke-size=&quot;size26&quot;&gt;2️⃣ 중간 연산 vs 최종 연산&lt;/h2&gt;
&lt;h3 data-end=&quot;738&quot; data-start=&quot;704&quot; data-ke-size=&quot;size23&quot;&gt;중간 연산 (Intermediate Operation)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;851&quot; data-start=&quot;739&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;759&quot; data-start=&quot;739&quot;&gt;반환값이 &lt;b&gt;스트림&lt;/b&gt;인 연산&lt;/li&gt;
&lt;li data-end=&quot;779&quot; data-start=&quot;760&quot;&gt;여러 번 연결해서 사용 가능&lt;/li&gt;
&lt;li data-end=&quot;851&quot; data-start=&quot;780&quot;&gt;예: filter(), map(), distinct(), sorted(), limit(), skip()&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;883&quot; data-start=&quot;853&quot; data-ke-size=&quot;size23&quot;&gt;최종 연산 (Terminal Operation)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1001&quot; data-start=&quot;884&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;906&quot; data-start=&quot;884&quot;&gt;반환값이 &lt;b&gt;스트림이 아닌 값&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;939&quot; data-start=&quot;907&quot;&gt;실행 시점에 &lt;b&gt;스트림이 소비됨&lt;/b&gt; &amp;rarr; 재사용 불가&lt;/li&gt;
&lt;li data-end=&quot;1001&quot; data-start=&quot;940&quot;&gt;예: forEach(), collect(), count(), sum(), reduce()&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1006&quot; data-start=&quot;1003&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1037&quot; data-start=&quot;1008&quot; data-ke-size=&quot;size26&quot;&gt;3️⃣ 다양한 스트림 &amp;amp; Collector 함수&lt;/h2&gt;
&lt;h3 data-end=&quot;1048&quot; data-start=&quot;1039&quot; data-ke-size=&quot;size23&quot;&gt;중간 연산&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1272&quot; data-start=&quot;1049&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1087&quot; data-start=&quot;1049&quot;&gt;&lt;b&gt;자르기&lt;/b&gt;: skip(n), limit(maxSize)&lt;/li&gt;
&lt;li data-end=&quot;1132&quot; data-start=&quot;1088&quot;&gt;&lt;b&gt;필터링&lt;/b&gt;: filter(Predicate), distinct()&lt;/li&gt;
&lt;li data-end=&quot;1175&quot; data-start=&quot;1133&quot;&gt;&lt;b&gt;정렬&lt;/b&gt;: sorted(), sorted(Comparator)&lt;/li&gt;
&lt;li data-end=&quot;1221&quot; data-start=&quot;1176&quot;&gt;&lt;b&gt;변환&lt;/b&gt;: map(), mapToInt(), mapToObj()&lt;/li&gt;
&lt;li data-end=&quot;1244&quot; data-start=&quot;1222&quot;&gt;&lt;b&gt;평탄화&lt;/b&gt;: flatMap()&lt;/li&gt;
&lt;li data-end=&quot;1272&quot; data-start=&quot;1245&quot;&gt;&lt;b&gt;엿보기&lt;/b&gt;: peek(Consumer)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1283&quot; data-start=&quot;1274&quot; data-ke-size=&quot;size23&quot;&gt;최종 연산&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1547&quot; data-start=&quot;1284&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1325&quot; data-start=&quot;1284&quot;&gt;&lt;b&gt;소비&lt;/b&gt;: forEach(), forEachOrdered()&lt;/li&gt;
&lt;li data-end=&quot;1347&quot; data-start=&quot;1326&quot;&gt;&lt;b&gt;변환&lt;/b&gt;: toArray()&lt;/li&gt;
&lt;li data-end=&quot;1402&quot; data-start=&quot;1348&quot;&gt;&lt;b&gt;조건 검사&lt;/b&gt;: allMatch(), anyMatch(), noneMatch()&lt;/li&gt;
&lt;li data-end=&quot;1442&quot; data-start=&quot;1403&quot;&gt;&lt;b&gt;요소 찾기&lt;/b&gt;: findFirst(), findAny()&lt;/li&gt;
&lt;li data-end=&quot;1525&quot; data-start=&quot;1443&quot;&gt;&lt;b&gt;통계&lt;/b&gt;: count(), sum(), average(), max(), min(), summaryStatistics()&lt;/li&gt;
&lt;li data-end=&quot;1547&quot; data-start=&quot;1526&quot;&gt;&lt;b&gt;리듀싱&lt;/b&gt;: reduce()&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1567&quot; data-start=&quot;1549&quot; data-ke-size=&quot;size23&quot;&gt;Collector (수집)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1790&quot; data-start=&quot;1568&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1610&quot; data-start=&quot;1568&quot;&gt;&lt;b&gt;변환&lt;/b&gt;: toList(), toSet(), toMap()&lt;/li&gt;
&lt;li data-end=&quot;1687&quot; data-start=&quot;1611&quot;&gt;&lt;b&gt;통계&lt;/b&gt;: counting(), summingInt(), averagingInt(), summarizingInt()&lt;/li&gt;
&lt;li data-end=&quot;1717&quot; data-start=&quot;1688&quot;&gt;&lt;b&gt;문자열 결합&lt;/b&gt;: joining(&quot;, &quot;)&lt;/li&gt;
&lt;li data-end=&quot;1741&quot; data-start=&quot;1718&quot;&gt;&lt;b&gt;리듀싱&lt;/b&gt;: reducing()&lt;/li&gt;
&lt;li data-end=&quot;1790&quot; data-start=&quot;1742&quot;&gt;&lt;b&gt;분할/그룹화&lt;/b&gt;: partitioningBy(), groupingBy()&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1795&quot; data-start=&quot;1792&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1812&quot; data-start=&quot;1797&quot; data-ke-size=&quot;size26&quot;&gt;4️⃣ 핵심 코드 예제&lt;/h2&gt;
&lt;h3 data-end=&quot;1828&quot; data-start=&quot;1814&quot; data-ke-size=&quot;size23&quot;&gt;✔ 필터링 + 변환&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757220970190&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;String&amp;gt; names = Arrays.asList(&quot;홍길동&quot;, &quot;김철수&quot;, &quot;이영희&quot;, &quot;박민수&quot;);

List&amp;lt;String&amp;gt; filteredNames = names.stream()
    .filter(name -&amp;gt; name.startsWith(&quot;김&quot;))
    .map(String::toUpperCase)
    .collect(Collectors.toList());
    
System.out.println(filteredNames); // [김철수]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;2121&quot; data-start=&quot;2103&quot; data-ke-size=&quot;size23&quot;&gt;✔ IntStream 활용&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757220999872&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;int[] numbers = {1,2,3,4,5,6,7,8,9,10};

int sum = Arrays.stream(numbers)
    .filter(n -&amp;gt; n % 2 == 0)
    .sum();
    
System.out.println(&quot;짝수의 합: &quot; + sum); // 30&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;2306&quot; data-start=&quot;2294&quot; data-ke-size=&quot;size23&quot;&gt;✔ 문자열 처리&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757221086011&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;String&amp;gt; fruits = Arrays.asList(&quot;사과&quot;, &quot;바나나&quot;, &quot;딸기&quot;, &quot;오렌지&quot;, &quot;포도&quot;);

// 글자수 &amp;lt; 3 필터링 후 정렬
List&amp;lt;String&amp;gt; shortFruits = fruits.stream()
    .filter(f -&amp;gt; f.length() &amp;lt; 3)
    .sorted()
    .collect(Collectors.toList());

// 평균 길이
double avgLength = fruits.stream()
    .mapToInt(String::length)
    .average()
    .orElse(0);

System.out.println(shortFruits); // [딸기, 사과]
System.out.println(avgLength); // 평균 길이&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/7</guid>
      <comments>https://osoohynn.tistory.com/7#entry7comment</comments>
      <pubDate>Sun, 7 Sep 2025 13:58:43 +0900</pubDate>
    </item>
    <item>
      <title>자바 개념 정복하기 - Optional/Lambda편</title>
      <link>https://osoohynn.tistory.com/6</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;자바 개념을 확실히 다잡고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;u&gt;&lt;b&gt;진짜 제대로 알고쓰자&lt;/b&gt;&lt;/u&gt;는 의미에서 기록을 시작합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;내용은 학교 수업시간에 배운 내용을 바탕으로 저의 추가 지식을 더하고 검색을 통해 작성했습니다.&lt;/p&gt;
&lt;h3 data-end=&quot;193&quot; data-start=&quot;179&quot; data-ke-size=&quot;size23&quot;&gt;목차 (배운 내용)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;271&quot; data-start=&quot;195&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;210&quot; data-start=&quot;195&quot;&gt;Optional 정의&lt;/li&gt;
&lt;li data-end=&quot;229&quot; data-start=&quot;211&quot;&gt;Optional 사용 예제&lt;/li&gt;
&lt;li data-end=&quot;246&quot; data-start=&quot;230&quot;&gt;Lambda 사용 예제&lt;/li&gt;
&lt;li data-end=&quot;260&quot; data-start=&quot;247&quot;&gt;함수형 인터페이스&lt;/li&gt;
&lt;li data-end=&quot;271&quot; data-start=&quot;261&quot;&gt;참조 메서드&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;276&quot; data-start=&quot;273&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;304&quot; data-start=&quot;278&quot; data-ke-size=&quot;size23&quot;&gt;Optional - Null 안정성 확보&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Optional(옵셔널)&lt;/p&gt;
&lt;blockquote data-end=&quot;454&quot; data-start=&quot;345&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;454&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;자바에서 흔히 발생하는 NullPointerException을 줄이기 위해 만들어진 클래스.&lt;br /&gt;값이 있을 수도, 없을 수도 있는 객체를 감싸는 &lt;b&gt;래퍼(Wrapper)&lt;/b&gt; 역할을 함.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;562&quot; data-start=&quot;456&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;562&quot; data-start=&quot;456&quot;&gt;Optional이란?
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;562&quot; data-start=&quot;474&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;496&quot; data-start=&quot;474&quot;&gt;T 타입 객체를 감싸는 클래스&lt;/li&gt;
&lt;li data-end=&quot;529&quot; data-start=&quot;499&quot;&gt;null 안정성을 제공 (안전한 null 처리)&lt;/li&gt;
&lt;li data-end=&quot;562&quot; data-start=&quot;532&quot;&gt;마치 값을 담는 &lt;b&gt;상자(Box)&lt;/b&gt; 같은 개념&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757138336627&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 값이 있을 때
Optional&amp;lt;String&amp;gt; opt1 = Optional.of(&quot;Hello&quot;);

// 값이 없을 수도 있을 때
Optional&amp;lt;String&amp;gt; opt2 = Optional.ofNullable(null);

// 아예 빈 값
Optional&amp;lt;String&amp;gt; opt3 = Optional.empty();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757138384182&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Optional&amp;lt;String&amp;gt; opt = Optional.of(&quot;Java&quot;);

// 1) 값 직접 꺼내기
System.out.println(opt.get()); // &quot;Java&quot; (값 없으면 예외 발생)

// 2) 값이 있으면 꺼내고, 없으면 기본값
System.out.println(opt.orElse(&quot;기본값&quot;));

// 3) 값이 있으면 실행하기
opt.ifPresent(v -&amp;gt; System.out.println(&quot;길이: &quot; + v.length()));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1289&quot; data-start=&quot;1025&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1289&quot; data-start=&quot;1025&quot;&gt;주요 메서드
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1289&quot; data-start=&quot;1038&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1078&quot; data-start=&quot;1038&quot;&gt;Optional.of(value): null이 아닌 값을 감쌈&lt;/li&gt;
&lt;li data-end=&quot;1132&quot; data-start=&quot;1081&quot;&gt;Optional.ofNullable(value): null일 수도 있는 값을 감쌈&lt;/li&gt;
&lt;li data-end=&quot;1164&quot; data-start=&quot;1135&quot;&gt;isPresent(): 값 존재 여부 확인&lt;/li&gt;
&lt;li data-end=&quot;1209&quot; data-start=&quot;1167&quot;&gt;ifPresent(consumer): 값이 존재할 경우 동작 수행&lt;/li&gt;
&lt;li data-end=&quot;1254&quot; data-start=&quot;1212&quot;&gt;orElse(defaultValue): 값 없을 경우 기본값 반환&lt;/li&gt;
&lt;li data-end=&quot;1289&quot; data-start=&quot;1257&quot;&gt;get(): 값 직접 반환 (없으면 예외 발생)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1294&quot; data-start=&quot;1291&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1318&quot; data-start=&quot;1296&quot; data-ke-size=&quot;size23&quot;&gt;Lambda - 간결한 코드 작성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  Lambda Expression (람다식)&lt;/p&gt;
&lt;blockquote data-end=&quot;1486&quot; data-start=&quot;1369&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1486&quot; data-start=&quot;1371&quot; data-ke-size=&quot;size16&quot;&gt;함수를 간단한 &lt;b&gt;식(Expression)&lt;/b&gt; 으로 표현하는 방법.&lt;br /&gt;이름이 없는 함수 &amp;rarr; 익명 함수(Anonymous Function).&lt;br /&gt;간결하게 코드를 작성하고 함수형 프로그래밍을 지원.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;1491&quot; data-start=&quot;1488&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;1530&quot; data-start=&quot;1493&quot; data-ke-size=&quot;size20&quot;&gt;함수형 인터페이스 (Functional Interface)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1622&quot; data-start=&quot;1532&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1552&quot; data-start=&quot;1532&quot;&gt;람다식이 구현할 수 있는 대상&lt;/li&gt;
&lt;li data-end=&quot;1581&quot; data-start=&quot;1553&quot;&gt;&lt;b&gt;추상 메서드가 하나만 있는 인터페이스&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1622&quot; data-start=&quot;1582&quot;&gt;@FunctionalInterface 어노테이션으로 명시 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1666&quot; data-start=&quot;1624&quot; data-ke-size=&quot;size16&quot;&gt;자바 제공 주요 함수형 인터페이스 (java.util.function):&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;인터페이스설명
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;1912&quot; data-start=&quot;1668&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;1912&quot; data-start=&quot;1697&quot;&gt;
&lt;tr data-end=&quot;1727&quot; data-start=&quot;1697&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1708&quot; data-start=&quot;1697&quot;&gt;Runnable&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1727&quot; data-start=&quot;1708&quot;&gt;매개변수 없음, 반환값 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1760&quot; data-start=&quot;1728&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1742&quot; data-start=&quot;1728&quot;&gt;Supplier&amp;lt;T&amp;gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1760&quot; data-start=&quot;1742&quot;&gt;매개변수 없음, 반환값 T&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1793&quot; data-start=&quot;1761&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1775&quot; data-start=&quot;1761&quot;&gt;Consumer&amp;lt;T&amp;gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1793&quot; data-start=&quot;1775&quot;&gt;매개변수 T, 반환값 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1828&quot; data-start=&quot;1794&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1811&quot; data-start=&quot;1794&quot;&gt;Function&amp;lt;T, R&amp;gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1828&quot; data-start=&quot;1811&quot;&gt;매개변수 T, 반환값 R&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1869&quot; data-start=&quot;1829&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1848&quot; data-start=&quot;1829&quot;&gt;BiConsumer&amp;lt;T, U&amp;gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1869&quot; data-start=&quot;1848&quot;&gt;매개변수 T, U, 반환값 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1912&quot; data-start=&quot;1870&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1892&quot; data-start=&quot;1870&quot;&gt;BiFunction&amp;lt;T, U, R&amp;gt;&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1912&quot; data-start=&quot;1892&quot;&gt;매개변수 T, U, 반환값 R&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;1917&quot; data-start=&quot;1914&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;1929&quot; data-start=&quot;1919&quot; data-ke-size=&quot;size20&quot;&gt;기본 문법&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;(매개변수) -&amp;gt; { 실행문 } &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2021&quot; data-start=&quot;1962&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1985&quot; data-start=&quot;1962&quot;&gt;매개변수가 1개 &amp;rarr; 괄호 생략 가능&lt;/li&gt;
&lt;li data-end=&quot;2021&quot; data-start=&quot;1986&quot;&gt;실행문이 한 줄 &amp;rarr; {}와 return 생략 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757138431998&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 람다식
Calculator calculator1 = (int a, int b) -&amp;gt; { return a + b; };

// 더 간결하게
Calculator calculator2 = (a, b) -&amp;gt; a + b;

// 함수형 인터페이스
@FunctionalInterface interface Calculator {
	int calculate(int a, int b); 
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;2258&quot; data-start=&quot;2255&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;2289&quot; data-start=&quot;2260&quot; data-ke-size=&quot;size23&quot;&gt;메서드 참조 (Method Reference)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  메서드 참조&lt;/p&gt;
&lt;blockquote data-end=&quot;2360&quot; data-start=&quot;2323&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2360&quot; data-start=&quot;2325&quot; data-ke-size=&quot;size16&quot;&gt;하나의 메서드만 호출하는 람다식 &amp;rarr; 더 간결하게 표현 가능.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div&gt;
&lt;div&gt;종류람다식메서드 참조
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;2585&quot; data-start=&quot;2362&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;2585&quot; data-start=&quot;2404&quot;&gt;
&lt;tr data-end=&quot;2469&quot; data-start=&quot;2404&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2420&quot; data-start=&quot;2404&quot;&gt;static 메서드 참조&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2448&quot; data-start=&quot;2420&quot;&gt;(x) &amp;rarr; ClassName.method(x)&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2469&quot; data-start=&quot;2448&quot;&gt;ClassName::method&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2532&quot; data-start=&quot;2470&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2484&quot; data-start=&quot;2470&quot;&gt;인스턴스 메서드 참조&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2511&quot; data-start=&quot;2484&quot;&gt;(obj, x) &amp;rarr; obj.method(x)&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2532&quot; data-start=&quot;2511&quot;&gt;ClassName::method&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2585&quot; data-start=&quot;2533&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2548&quot; data-start=&quot;2533&quot;&gt;특정 객체 메서드 참조&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2570&quot; data-start=&quot;2548&quot;&gt;(x) &amp;rarr; obj.method(x)&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2585&quot; data-start=&quot;2570&quot;&gt;obj::method&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1757138554928&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// static 메서드 참조
result.ifPresent(System.out::println);

// 인스턴스 메서드 참조
MyFunction myFunction1 = String::equals;

// 특정 객체 메서드 참조
String s = &quot;Java&quot;;
MyFunction myFunction2 = s::equals;&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-end=&quot;2788&quot; data-start=&quot;2785&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2801&quot; data-start=&quot;2790&quot; data-ke-size=&quot;size26&quot;&gt;핵심 실습 코드&lt;/h2&gt;
&lt;h4 data-end=&quot;2823&quot; data-start=&quot;2803&quot; data-ke-size=&quot;size20&quot;&gt;함수형 인터페이스 &amp;amp; 람다식&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757138735601&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@FunctionalInterface interface Calculator {
	int calculate(int a, int b);
}

public class Main {
    public static void main(String[] args) {
        // 익명 클래스 
        Calculator calculator = new Calculator() { 
            public int calculate(int a, int b) {
                return a + b;
            } 
        }; 

        // 람다식 
        Calculator calculator1 = (int a, int b) -&amp;gt; { return a + b; };

        // 더 간결하게
        Calculator calculator2 = (a, b) -&amp;gt; a + b; 
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;3322&quot; data-start=&quot;3319&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;3347&quot; data-start=&quot;3324&quot; data-ke-size=&quot;size20&quot;&gt;자바 기본 제공 함수형 인터페이스&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757139001167&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.util.function.Supplier;

public class D {
    Calculator add = (a, b) -&amp;gt; a + b;

    public static void main(String[] args) {
    	test((a, b) -&amp;gt; a + b);
    }

    public static void test(Calculator calculator) {
        int result = calculator.calculate(1, 2);
        System.out.println(result);
        Supplier&amp;lt;Integer&amp;gt; s = () -&amp;gt; 1;
    } 

    public static Calculator calculate() {
    	return (a, b) -&amp;gt; a + b;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;3809&quot; data-start=&quot;3806&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-end=&quot;3833&quot; data-start=&quot;3811&quot; data-ke-size=&quot;size20&quot;&gt;메서드 참조 &amp;amp; Optional&lt;/h4&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1757139099553&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.util.Optional;

@FunctionalInterface
interface MyFunction {
	boolean method(String s);
}

class MyClass {
    String name;
    
    public MyClass(String name) {
    	this.name = name;
    } 

    public boolean eqName(String name) {
    	return this.name.equals(name);
    }
} 

public class E {
    public static void main(String[] args) {
        MyClass myClass = new MyClass(&quot;Java&quot;);
        
        MyFunction myFunction = myClass::eqName;
        
        Optional&amp;lt;String&amp;gt; result = Optional.ofNullable(&quot;Java&quot;);
        result.ifPresent(System.out::println);
        
        Optional&amp;lt;Integer&amp;gt; result2 = result.map(String::length);
        result2.ifPresent(System.out::println);
    } 
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;4536&quot; data-start=&quot;4533&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;4547&quot; data-start=&quot;4538&quot; data-ke-size=&quot;size26&quot;&gt;트러블 슈팅&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4767&quot; data-start=&quot;4549&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4617&quot; data-start=&quot;4549&quot;&gt;&lt;b&gt;Optional 1&lt;/b&gt;: get()을 바로 사용해 예외 발생 &amp;rarr; orElse, ifPresent 사용&lt;/li&gt;
&lt;li data-end=&quot;4710&quot; data-start=&quot;4618&quot;&gt;&lt;b&gt;Optional 2&lt;/b&gt;: orElse()는 항상 실행, orElseGet()은 필요할 때만 실행 &amp;rarr; 비용 큰 연산 시 orElseGet() 권장&lt;/li&gt;
&lt;li data-end=&quot;4767&quot; data-start=&quot;4711&quot;&gt;&lt;b&gt;Lambda 1&lt;/b&gt;: 람다식 타입 추론 오류 &amp;rarr; 함수형 인터페이스 타입을 명확히 지정 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4852&quot; data-start=&quot;4787&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;4866&quot; data-start=&quot;4859&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5081&quot; data-start=&quot;4868&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4936&quot; data-start=&quot;4868&quot;&gt;&lt;b&gt;Null 안정성의 중요성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4936&quot; data-start=&quot;4892&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4936&quot; data-start=&quot;4892&quot;&gt;Optional 활용으로 NullPointerException 방지 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;5081&quot; data-start=&quot;4937&quot;&gt;&lt;b&gt;함수형 프로그래밍의 이점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5081&quot; data-start=&quot;4961&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4991&quot; data-start=&quot;4961&quot;&gt;람다식으로 간결하고 가독성 높은 코드 작성 가능&lt;/li&gt;
&lt;li data-end=&quot;5046&quot; data-start=&quot;4994&quot;&gt;함수형 인터페이스(Function, Consumer, Supplier 등) 활용법 학습&lt;/li&gt;
&lt;li data-end=&quot;5081&quot; data-start=&quot;5049&quot;&gt;메서드 참조(::)를 통한 더 간결한 표현 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;5171&quot; data-start=&quot;5083&quot; data-ke-size=&quot;size16&quot;&gt;➡ 이를 통해 &lt;b&gt;더 모던하고 안전한 Java 코드&lt;/b&gt; 작성 가능.&lt;br /&gt;특히, Java에 함수형 프로그래밍 패러다임이 통합된 방식 이해에 큰 도움을 얻음.&lt;/p&gt;</description>
      <category>functional interface</category>
      <category>Java</category>
      <category>lambda</category>
      <category>optional</category>
      <author>osoohynn</author>
      <guid isPermaLink="true">https://osoohynn.tistory.com/6</guid>
      <comments>https://osoohynn.tistory.com/6#entry6comment</comments>
      <pubDate>Sat, 6 Sep 2025 15:11:55 +0900</pubDate>
    </item>
  </channel>
</rss>