<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>wo_dbs</title>
    <link>https://wo-dbs.tistory.com/</link>
    <description>멋진 개발자가 되고 싶다!</description>
    <language>ko</language>
    <pubDate>Sun, 19 Jul 2026 19:16:26 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>재윤</managingEditor>
    <image>
      <title>wo_dbs</title>
      <url>https://tistory1.daumcdn.net/tistory/5194886/attach/dafb3d86421b4c3e87ec52d551ecff49</url>
      <link>https://wo-dbs.tistory.com</link>
    </image>
    <item>
      <title>2GB 서버를 지키는 가장 싼 방법 Rate Limiter 도입기</title>
      <link>https://wo-dbs.tistory.com/422</link>
      <description>&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;h2 data-ke-size=&quot;size26&quot;&gt;1. 왜 넣게 되었는가&lt;/h2&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;(관련 글:&amp;nbsp;&lt;a href=&quot;https://wo-dbs.tistory.com/419&quot;&gt;https://wo-dbs.tistory.com/419&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합 검색에서는 다음과 같이 SLO를 세웠었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DAU 1,000 규모(피크 동시 약 50명)에서, 통합검색을 p95 700ms 이내로 SLI 95% 이상 유지&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;개선 한 후 동시 접속자를 30 &amp;rarr; 110명을 5분간 램프업하여 부하 테스트를 했을 때 요청은 전부 성공했지만 동시 사용자가 늘수록 응답이 점점 느려졌습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1968&quot; data-origin-height=&quot;710&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9qrvZ/dJMcacRcebx/LXYwMNHEc2Zm2NVN6cR1l0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9qrvZ/dJMcacRcebx/LXYwMNHEc2Zm2NVN6cR1l0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9qrvZ/dJMcacRcebx/LXYwMNHEc2Zm2NVN6cR1l0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9qrvZ%2FdJMcacRcebx%2FLXYwMNHEc2Zm2NVN6cR1l0%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;1968&quot; height=&quot;710&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1968&quot; data-origin-height=&quot;710&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; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;동시 VU&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;SLI&lt;/td&gt;
&lt;td&gt;상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;~100%&lt;/td&gt;
&lt;td&gt;여유&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;~99.5%&lt;/td&gt;
&lt;td&gt;양호&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;~97%&lt;/td&gt;
&lt;td&gt;저하 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;~86%&lt;/td&gt;
&lt;td&gt;저하&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;~57%&lt;/td&gt;
&lt;td&gt;붕괴&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SLO 목표는 SLI 95%이상이었습니다. 피크로 예상하는 동시 50명까진 99.5%로 여유롭게 만족하고&amp;nbsp;70명까지도 97%로 버팁니다.&amp;nbsp;하지만&amp;nbsp;&lt;b&gt;90명에서 86%로 SLO선을 깨고&lt;/b&gt;&amp;nbsp;110명에선 57%까지&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;병목은&amp;nbsp;&lt;b&gt;통합 검색의&amp;nbsp;LIKE&amp;nbsp;쿼리&lt;/b&gt;였습니다. 인덱스를 타지 못하는&amp;nbsp;LIKE '%keyword%'가 캐시에 올라온 행을&amp;nbsp;&lt;b&gt;CPU로 훑으며 문자열을 하나씩 비교&lt;/b&gt;하다 보니 동시 요청이 늘수록 CPU 부하가 그만큼 커졌습니다. (자세한 분석은 위 글에 있습니다.)&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;b&gt;통합 검색의&amp;nbsp;LIKE&amp;nbsp;쿼리&lt;/b&gt;였습니다.&amp;nbsp;인덱스를 타지 못하는&amp;nbsp;LIKE '%keyword%'가 캐시에&amp;nbsp;올라온 행을&amp;nbsp;&lt;b&gt;CPU로 훑으며 문자열을 하나씩 비교&lt;/b&gt;하다 보니,&amp;nbsp;동시 요청이 늘수록 CPU 부하가&amp;nbsp;그만큼 커졌습니다.&amp;nbsp;(자세한 분석은 위 글에 있습니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 이 부하 구간에서&amp;nbsp;&lt;b&gt;CPU는 100%까지 완전히 포화&lt;/b&gt;됐습니다.&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-filename=&quot;image.png&quot; data-origin-width=&quot;1510&quot; data-origin-height=&quot;644&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dGVZ0L/dJMcac4K2N9/FThSronuRjrEW4NvEXaI3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dGVZ0L/dJMcac4K2N9/FThSronuRjrEW4NvEXaI3k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dGVZ0L/dJMcac4K2N9/FThSronuRjrEW4NvEXaI3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdGVZ0L%2FdJMcac4K2N9%2FFThSronuRjrEW4NvEXaI3k%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;1510&quot; height=&quot;644&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1510&quot; data-origin-height=&quot;644&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;결론은 초과 트래픽은 하나의 API만 느려지는 것이 아니라 전체를 끌어내리게 됩니다. CPU를 나눠 쓰는 만큼 정상 범위의 사용자까지 같이 느려지고, 스파이크 한 번이 모두의 SLO를 깨게 됩니다.&lt;/b&gt;&lt;/span&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;b&gt;Rate Limiter를 도입해, 감당 가능한 범위를 넘는 트래픽을 앞단에서 걸러 SLI를 지키기로&lt;/b&gt;&amp;nbsp;했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 어디에 넣을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Rate Limiter를 넣을 수 있는 위치는 요청이 지나는 경로 마다 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;핵심 원칙은 요청은 뒤로 갈 수록 비싸다는 것입니다.&lt;/b&gt;&lt;/span&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트 &amp;rarr; NGINX &amp;rarr; Spring &amp;rarr; PostgreSQL&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 위치에서의 트레이드 오프를 생각해본다면 다음과 같았습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;NGINX에서 거절 &amp;rarr; 가장 쌉니다. NGINX에 하게 된다면 초과 요청에 429만 돌려주면 끝이라, JVM 스레드도 DB 커넥션도 건드리지 않습니다.&lt;/li&gt;
&lt;li&gt;Spring까지 도달 &amp;rarr; 이미 톰캣 스레드 하나를 점유합니다. 스레드 풀은 유한한데 요청 파싱, 필터 체인, JWT 인증을 다 거친 뒤라 거절해도 그 비용은 이미 지불한 상태입니다.&lt;/li&gt;
&lt;li&gt;PostgreSQL까지 도달 &amp;rarr; 가장 비쌉니다. DB 커넥션 풀의 한 자리를 잡게 됩니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 요청이 깊이 들어갈 수록 더 자원을 소모합니다 그래서 NINGX에서 진행하기로 하였습니다.&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;IP 기준 &amp;rarr; NGINX에서 바로 적용하기 쉽다는 장점이 있지만 하나의 공유기나 회사 네트워크처럼 여러 같은 IP를 사용하는 경우 정상 사용자들이 함께 제한될 수 있었습니다. 또한, 공격자가 여러 IP를 바꿔가며 요청하면 IP 기준 제한을 우회할 수도 있습니다.&lt;/li&gt;
&lt;li&gt;사용자 ID 기준 &amp;rarr; 정확하게 제한할 수 있다는 장점이 있습니다. 하지만 사용자 ID를 알기 위해서 Spring까지 들어와야합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 목적은 서버 자원을 최대한 앞단에서 보호하는 것이라고 생각했습니다. 그래서 우선 NGINX에서 처리할 수 있는 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;IP 기준&lt;/b&gt;&lt;/span&gt;을 적용하기로 하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 어떤 알고리즘으로 제한할 것인가?&lt;/h2&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;알고리즘&lt;/td&gt;
&lt;td&gt;특징&lt;/td&gt;
&lt;td&gt;판단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed Window&lt;/td&gt;
&lt;td&gt;구현 쉬움, 경계에서 최대 2배 통과&lt;/td&gt;
&lt;td&gt;허점 때문에 제외&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding Log&lt;/td&gt;
&lt;td&gt;가장 정확, 메모리 큼&lt;/td&gt;
&lt;td&gt;저사양엔 과함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding Counter&lt;/td&gt;
&lt;td&gt;정확&amp;middot;가벼움 절충&lt;/td&gt;
&lt;td&gt;좋지만 NGINX 기본 아님&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Leaky Bucket&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;출력 평탄화, NGINX&amp;nbsp;limit_req&amp;nbsp;기본&lt;/td&gt;
&lt;td&gt;&lt;b&gt;채택&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token Bucket&lt;/td&gt;
&lt;td&gt;버스트 허용&lt;/td&gt;
&lt;td&gt;nodelay로 유사 효과&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NGINX&amp;nbsp;limit_req는 내부적으로&amp;nbsp;Leaky Bucket입니다.&amp;nbsp;여기에&amp;nbsp;burst와&amp;nbsp;nodelay를 얹으면&amp;nbsp;정상적인 순간 버스트는 통과시키고 지속적인 남용만 막는&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;이 부분이 중요한 이유는 실제 앱이 켜지는 순간 프로필, 목록 등 API 여러 개를 동시에 호출합니다 이걸 초당 1개로 딱 잘라보리면 정상 사용자가 막히게 됩니다. nodelay로 버스트 한도 내 요청을 즉시 통과시키되 그 한도를 넘겨 계속 때리는 트래픽만 차단하도록 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;# http 블록: IP당 존 정의 (10m &amp;asymp; 16만 IP 추적)
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    # 일반 API
    location /api/ {
        limit_req zone=api burst=20 nodelay;
        limit_req_status 429;
        proxy_pass http://spring_upstream;
    }

    # 헬스체크 및 메트릭은 제한에서 제외
    location /actuator/ {
        proxy_pass http://spring_upstream;
    }
}
&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;rate=10r/s&amp;nbsp;- 장기 평균 허용 속도&lt;/li&gt;
&lt;li&gt;burst=20&amp;nbsp;- 순간 몰림 20개까지 큐 허용&lt;/li&gt;
&lt;li&gt;nodelay&amp;nbsp;- 버스트분을 지연 없이 즉시 통과 (Token Bucket처럼)&lt;/li&gt;
&lt;li&gt;limit_req_status 429&amp;nbsp;- 초과 시 기본 503 대신 429 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 초과하면 어떻게?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초과할 경우 &lt;b&gt;HTTP 429 Too Many Requests&lt;/b&gt;&amp;nbsp;반환하도록 하였습니다. 503의 반환을 생각하였지만 서버 장애가 일어났다기 보다 요청이 그저 많다고 보여주는 게 더 좋다고 생각했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, Retry-After 헤더로 언제 재시도할 수 있는지 알려주는 방면을 생각해보았고 health 및 정적 리소스는 제외시켰습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;429 카운트 메트릭으로 수집해서 얼마나 어느 엔드포인트에서 걸리는지 모니터링도 구축하였습니다. 그 후 임계값(rate/burst)을 튜닝을 할 수 있다고 생각하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 테스트 - 실제로 부하를 걸어 검증&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1장의 부하 테스트에서 CPU가 100%까지 포화됐던 것을,&amp;nbsp;이번엔&amp;nbsp;&lt;b&gt;NGINX rate limit(IP당 10r/s, burst 20)을 건 상태&lt;/b&gt;로 같은 스크립트를 그대로 돌려 비교했습니다.&amp;nbsp;k6는 한 대(=&amp;nbsp;IP 하나)에서&amp;nbsp;쏘므로,&amp;nbsp;이 테스트는 곧&amp;nbsp;&lt;b&gt;단일 클라이언트가 폭주하는 시나리오&lt;/b&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;항목&lt;/td&gt;
&lt;td&gt;제한 전&lt;/td&gt;
&lt;td&gt;제한 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;시스템 CPU (2 vCPU)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;100% 포화&lt;/td&gt;
&lt;td&gt;~10&amp;ndash;25%&amp;nbsp;(idle 50&amp;ndash;90%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;통과(200) 요청 p95&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;962 ms (SLO 초과)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;137 ms&lt;/b&gt;&amp;nbsp;(SLO 안 ✓)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;통과(200) 요청 p95&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;962 ms&lt;/b&gt;&amp;nbsp;(SLO 초과 ✗)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;137 ms&lt;/b&gt;&amp;nbsp;(SLO 안 ✓)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429 (차단)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;b&gt;99.6%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메모리&lt;/td&gt;
&lt;td&gt;574 MiB&lt;/td&gt;
&lt;td&gt;573 MiB (동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1503&quot; data-origin-height=&quot;636&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zSm4Z/dJMcaaFXvYJ/XvxPvccDsDVd5QKnLn1ak0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zSm4Z/dJMcaaFXvYJ/XvxPvccDsDVd5QKnLn1ak0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zSm4Z/dJMcaaFXvYJ/XvxPvccDsDVd5QKnLn1ak0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzSm4Z%2FdJMcaaFXvYJ%2FXvxPvccDsDVd5QKnLn1ak0%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;1503&quot; height=&quot;636&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1503&quot; data-origin-height=&quot;636&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폭주한 요청의&amp;nbsp;&lt;b&gt;99.6%를 NGINX가 429로 컷&lt;/b&gt;하자&amp;nbsp;인덱스 없는&amp;nbsp;LIKE&amp;nbsp;스캔이 애초에 DB까지&amp;nbsp;도달하지 못했습니다.&amp;nbsp;그 결과&amp;nbsp;&lt;b&gt;CPU가 포화에서 풀리고&lt;/b&gt;(idle 0%&amp;nbsp;&amp;rarr;&amp;nbsp;50~90%),&amp;nbsp;제한을 통과한&amp;nbsp;요청은&amp;nbsp;&lt;b&gt;p95 137ms&lt;/b&gt;로 SLO(700ms)&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;즉 1차의&amp;nbsp;&quot;전부 받아서 다 같이 962ms로 느려지기&quot;가,&amp;nbsp;2차에서는&amp;nbsp;&lt;b&gt;&quot;초과는 429로 버리고, 통과분은 137ms로 빠르게&quot;&lt;/b&gt;&amp;nbsp;로 바뀌었습니다.&amp;nbsp;Rate Limiter가 SLO를 지켜내었습니다.&lt;/p&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/422</guid>
      <comments>https://wo-dbs.tistory.com/422#entry422comment</comments>
      <pubDate>Sat, 4 Jul 2026 14:03:09 +0900</pubDate>
    </item>
    <item>
      <title>Rate Limiter</title>
      <link>https://wo-dbs.tistory.com/421</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. Rate Limiter란&lt;/b&gt;&lt;/h2&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;남용/DDoS 방어, 서버 자원 보호(특히 2GB 같은 저사양)&lt;/li&gt;
&lt;li&gt;공정한 사용(한 유저가 자원 독점 방지)&lt;/li&gt;
&lt;li&gt;비용 제어(외부 API 호출량 등)&lt;/li&gt;
&lt;/ul&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;rate limiter가 요청이 왔을 때 어떤 걸로 카운터를 셀 것인가.&lt;/li&gt;
&lt;li&gt;이 요청에 대해 통과를 시킬까 막을까에 대한 수학적 방법 == 알고리즘&lt;/li&gt;
&lt;li&gt;알고리즘 코드가 어느 서버/ 어느 층에서 도느냐. 즉, 요청이 지나가는 경로 중 어디에 rate limiter를 심을 것이냐&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 그래서 Rate Limiter를 도입할 때는 다음과 같은 문장이 완성되어야합니다&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[3번. NGINX 에서] [1번. IP 기준으로] [2번. Token Bucket 방식으로] 분당 10개 제한한다&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;1,2,3번에 대한 각각 부분을 좀 더 세밀하게 공부해도록 하겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;무엇을 기준으로 셀까? (Key)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분당 10개 제한이라고 하였을 때&amp;nbsp;&lt;b&gt;누구/무엇의&lt;/b&gt;&amp;nbsp;10개냐를 정하는 것입니다. 카운터를&amp;nbsp;&lt;b&gt;무슨 단위로 묶을지&lt;/b&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;기준(key)&lt;/td&gt;
&lt;td&gt;의미&lt;/td&gt;
&lt;td&gt;쓰는 곳&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;IP 주소&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;이 IP에서 온 요청을 셈&lt;/td&gt;
&lt;td&gt;NGINX 기본, 익명 트래픽&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;유저 ID&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;로그인한 이 사람 요청을 셈&lt;/td&gt;
&lt;td&gt;로그인 후 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;API 키&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;이 키(외부 개발자)로 온 요청&lt;/td&gt;
&lt;td&gt;공개 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;IP + 엔드포인트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;이 IP가&amp;nbsp;/login에 온 것만 셈&lt;/td&gt;
&lt;td&gt;로그인 brute-force 방어&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기준이 중요한 이유는 기준을 잘못 잡으면 엉뚱하게 막히게 됩니다. 예를 들어서&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;IP로만 세면 &amp;rarr; 회사/카페 같은 IP 쓰는 100명이&amp;nbsp;&lt;b&gt;한 덩어리로 묶여&lt;/b&gt;&amp;nbsp;억울하게 차단.&lt;/li&gt;
&lt;li&gt;유저 ID로 세면 &amp;rarr; 정확하지만&amp;nbsp;&lt;b&gt;로그인 안 한 요청&lt;/b&gt;(회원가입, 로그인 자체)은 유저ID가 없어서 못 셈.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 상황마다 key를 다르게 구분하는 것도 생각을 해봐야합니다. 로그인 전엔 IP, 로그인 후엔 유저 ID형태&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;어떤 알고리즘으로 셀까&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;어떻게 세느냐&quot;&lt;/b&gt;&amp;nbsp;의 방식입니다. 이 알고리즘들은 크게 5가지 정도가 있습니다. 이 알고리즘들은 같은 분당 10개라도 세는 방식에 따라 동작이 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 알고리즘들을 공부해보았습니다. 전부 분당 5개 허용 기준으로 공부해보았습니다.&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;b&gt;Fixed Window Counter (고정 윈도우)&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;시간을 고정 구간으로 쪼개고, 구간마다 카운터를 세는 방법입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[10:00:00~10:00:59] 카운터=0 &amp;rarr; 요청마다 +1, 5 넘으면 거부
[10:01:00~10:01:59] 카운터=0으로 리셋
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점은&lt;/b&gt; 구현 초간단 (Redis&amp;nbsp;INCR&amp;nbsp;+&amp;nbsp;EXPIRE&amp;nbsp;한 줄)하고, 메모리 소비량이 작습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점으로는 정말 치명적인 문제가 있습니다. 구간 경계에서 최대 2배까티 통과할 수 있는 경계 문제(burst at boundary)가 있습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;10:00:59 에 5개  +  10:01:00 에 5개
&amp;rarr; 실제로 1초 사이에 10개가 몰림 (한도 5인데!)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 알고리즘은 시간을 딱 잘린 구간으로 나누고 구간이 바뀌면 카운터를 0으로 리셋합니다. 경계문제는 실제 흐르는 시간이 아니라 시계의 정각 기준으로 돌리게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;구간 A: [10:00:00 ~ 10:00:59]   카운터 A
구간 B: [10:01:00 ~ 10:01:59]   카운터 B  &amp;larr; 10:01:00 되는 순간 0으로 리셋
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카운터 A와 B는 서로를 전혀 모릅니다. A가 5개를 다 썼든, 말든, B는 10:01:00에 0에서 시작합니다.&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 class=&quot;angelscript&quot;&gt;&lt;code&gt;시각        요청   구간   카운터        판정
─────────────────────────────────────────────
10:00:57    1개    A     A=1           통과
10:00:58    1개    A     A=2           통과
10:00:59    3개    A     A=3,4,5       통과 (A 꽉 참)
─────────── 10:01:00 정각, 카운터 리셋! ───────────
10:01:00    3개    B     B=1,2,3       통과 (B 새 구간!)
10:01:01    2개    B     B=4,5         통과 (B 꽉 참)

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 구간만 보면 A도 5개, B도 5개로 규칙을 지켰습니다. 하지만&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;ldquo;구간 경계를 걸친 실제 시간 창&amp;rdquo;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;10:00:57 ~ 10:01:01&lt;/b&gt;&amp;nbsp;이라는&amp;nbsp;&lt;b&gt;실제 연속된 5초&lt;/b&gt;를 보면&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;A에서 5개 (10:00:57~59) + B에서 5개 (10:01:00~01) = 10개
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 연속된 1분(예: 10:00:30~10:01:29)을 잘라봐도 최대 10개가 들어올 수 있습니다. 한도는 5인데 실제론 2배(10개)가 통과해버린 것입니다.&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;즉, 규칙은 분당 5개인데 구간 경계 근처에 몰아치면 짧은 시간에 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;쉽게 말하면 시간을 자르는 방식이 2가지 종류이기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;A: 알고리즘이 자르는 창 - 시계 정각 기준으로 고정&lt;/li&gt;
&lt;li&gt;B: 실제로 우리가 지키고 싶은 창 - 아무 60초&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fixed Window는 A로만 검사하는데 B는 어느 구간을 봤을 때 한도를 넘을 수 있다느 것입니다.&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;그러면 이 문제가 왜 심각하냐면 이 알고리즘은 정각~정각 사이 5개만 보장합니다. 근데 공격자가 B방식으로 (정각을 걸쳐서) 자르면 10개가 잡히게 됩니다. 즉 알고리즘은 A로 5개 지켰다고 하는데 실제 세상에서는 10개가 들어온 것입니다.&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;그래서 Fixed Window는&amp;nbsp;&lt;b&gt;자기가 정한 위치(정각)에서만&lt;/b&gt;&amp;nbsp;5개를 보장하고&amp;nbsp;&lt;b&gt;다른 위치(경계 걸침)에선 최대 10개&lt;/b&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;b&gt;Sliding Window Log (슬라이딩 로그)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 요청의 타임스탬프를 리스트로 저장하는 방식입니다. 매 요청마다 지금부터 1분 이내 개수를 셉니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;요청 올 때: 1분 지난 타임스탬프 제거 &amp;rarr; 남은 개수 &amp;lt; 5 면 허용하고 현재시각 기록
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점은 가장 정확하고 Fixed Window에서 나온 경계 문제가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 단점으로는 요청마다 타임스탬프를 다 저장해야하기에 메모리를 많이 먹습니다. 초당 수천 요청이면 폭팔할 수도 있습니다. 구현은 Redis Sorted Set으로 구현할 수 있지만 비용이 비싸게 됩니다.&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;또한, 추가적인 단점으로는 clock skew(시계 오차)가 있습니다.&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;분산처리에서는 각 서버의 시계가 완벽히 똑같지 않습니다. NTP로 맞추어도 수십~수백ms씩 차이가 날 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Spring #1 시계: 10:00:00.000
Spring #2 시계: 10:00:00.300   &amp;larr; 300ms 빠름
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sliding Window Log는 지금부터 1분 이내 타임스탬프를 세는데&amp;nbsp;&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;&lt;b&gt;만약 각 인스턴스가 자기 로컬 시계로 타임스탬프를 찍어서 Redis에 넣으면&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;#1이 찍은 10:00:59.900  과  #2가 찍은 10:01:00.100
&amp;rarr; 실제론 #2 시계가 빨라서 진짜 순서가 뒤바뀔 수도
&amp;rarr; &quot;1분 이내&quot; 판정이 인스턴스마다 미묘하게 달라짐
&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;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Redis 자신의 시간을 쓴다. 이 방법은 제일 흔한 방법입니다.&lt;/li&gt;
&lt;li&gt;NTP로 서버 시계 동기화 &amp;rarr; 오차를 줄이지만 0으로 만들진 못한다. == 보조 방법&lt;/li&gt;
&lt;li&gt;작은 오차는 그냥 감수&lt;/li&gt;
&lt;/ol&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;b&gt;Sliding Window Counter (슬라이딩 카운터)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fixed Window의 가벼움과 Sliding Log의 정확함을 합친 버전입니다. 즉, 현재 윈도우 + 이전 윈도우 카운터를 가중 평균한 것입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;현재 윈도우 진행률이 30%라면:
추정치 = 이전윈도우_카운트 &amp;times; (1 - 0.3) + 현재윈도우_카운트
       = 이전 5개 &amp;times; 0.7 + 현재 2개 = 5.5 &amp;rarr; 5 초과 &amp;rarr; 거부
&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카운터를 2개 유지하게 됩니다. &amp;rarr; 이전 구간, 현재 구간&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;이전 윈도우 [10:00:00~10:00:59]  카운트 = 5
현재 윈도우 [10:01:00~10:01:59]  카운트 = 2
지금 시각 = 10:01:18   &amp;rarr; 현재 윈도우 18초 지남 = 진행률 30%
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;이 알고리즘의 목표는 지금 기준 뒤로 60초를 보는 것입니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금이 10:01:18이니&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;진짜 보고 싶은 창 = [10:00:18 ~ 10:01:18]  (딱 60초)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 60초 창을 그림으로 보면 두 구간에 거려있습니다&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;이전 윈도우                     현재 윈도우
[10:00:00 ────────── 10:00:59][10:01:00 ──── 10:01:18 ──── 10:01:59]
          |◄── 겹침 (70%) ──►||◄─ 겹침(30%) ─►|
       10:00:18                            10:01:18
&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;이전 윈도우의&amp;nbsp;&lt;b&gt;뒤쪽 70%&lt;/b&gt;&amp;nbsp;(10:00:18~10:00:59)가 겹침&lt;/li&gt;
&lt;li&gt;현재 윈도우의&amp;nbsp;&lt;b&gt;앞쪽 30%&lt;/b&gt;&amp;nbsp;(10:01:00~10:01:18)가 겹침&lt;/li&gt;
&lt;/ul&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;문제는 이전 윈도우 요청이 언제 왔는지 모릅니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이전 윈도우에 5개가 왔다는 거는 알 수 있지만 그게 앞에 몰렸는지 뒤에 몰렸는지에 대한 기록이 없습니다.. 그래서 고르게 퍼져 있다고 가정합니다.&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;고르게 퍼졌다면 겹치는 70% 구간엔 5개 중 70%인 3.5개가 있다고 추정합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;이전 윈도우 기여분 = 5 &amp;times; 0.7 = 3.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 윈도우 2개는 지금 창 안에 확실히 있으니 그대로&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;현재 윈도우 기여분 = 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추정치 = 3.5 + 2 = 5.5 &amp;rarr; 한도 5 초과 &amp;rarr; 거부&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 class=&quot;angelscript&quot;&gt;&lt;code&gt;추정치 = 이전카운트 &amp;times; (1 - 진행률) + 현재카운트
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(1-진행률)이 왜 이전 윈도우 가중치냐면 현재 윈도우가 진행될 수록 60초 차으이 꼬리가 이전 윈도우 밖으로 빠져나가서 겹치는 부분이 점점 줄어들게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;진행률 0%  (10:01:00): 창 = [10:00:00~10:01:00] &amp;rarr; 이전 윈도우 전체 겹침 &amp;rarr; 가중치 1.0
진행률 30% (10:01:18): 이전 윈도우 70% 겹침       &amp;rarr; 가중치 0.7
진행률 50% (10:01:30): 이전 윈도우 50% 겹침       &amp;rarr; 가중치 0.5
진행률 100%(10:01:59): 이전 윈도우 0% 겹침         &amp;rarr; 가중치 0.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 시간이 갈수록 이전 윈도우의 영향력이 선형적으로 사라집니다. 그게 1 - 진행률 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점으로는 메모리가 적고 경계 문제는 대두분 해결 됩니다. 보통 NGINX, 대형 서비스가 실제로 많이 쓰는 방법입니다.&lt;/p&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Token Bucket (토큰 버킷)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 알고리즘은 버킷에 토큰이 일정 속도로 채워지고, 요청은 토큰 1개를 소모하게 됩니다. 토큰이 없으면 거부하게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;버킷 용량 = 5 (최대 저장)
채우는 속도 = 분당 5개 (12초에 1개)

- 요청 오면 토큰 1개 소모
- 안 쓰면 토큰이 쌓임(최대 5까지) &amp;rarr; 잠깐 몰리는 burst 허용
- 비어 있으면 거부
&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;12초 마다 동전(토큰) 1개가 저금통에 자동으로 떨어집니다.&lt;/li&gt;
&lt;li&gt;저금통은 최대 5개까지만 담깁니다. (넘치면 버려짐)&lt;/li&gt;
&lt;li&gt;요청 1개 = 동전 1개 꺼내 쓰기 동전 옶으면 거부&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안 쓰고 놔두면 동전이 5개까지 쌓이며 그러면 그 수간 5개를 한 번에 꺼내 쓸 수 있습니다. 이것이 brust입니다.&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 class=&quot;angelscript&quot;&gt;&lt;code&gt;시각      이벤트              토큰 잔량    판정
──────────────────────────────────────────────
10:00:00  시작 (가득)          5
          ...1분간 아무 요청 없음...  (충전돼도 5가 max라 그대로)
10:01:00  토큰 = 5 (꽉 참)     5

10:01:00  요청 1              5&amp;rarr;4        통과
10:01:00  요청 2              4&amp;rarr;3        통과
10:01:00  요청 3              3&amp;rarr;2        통과   &amp;larr; 같은 순간에
10:01:00  요청 4              2&amp;rarr;1        통과      5개가
10:01:00  요청 5              1&amp;rarr;0        통과   &amp;larr; 한꺼번에 통과! (burst)
10:01:00  요청 6              0          거부 (토큰 없음)

10:01:12  (12초 경과, +1 충전) 0&amp;rarr;1
10:01:12  요청 7              1&amp;rarr;0        통과
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10:01:00에 동시에 5개가 몰렸지만 다 통과했습니다. 평소에 안 써서 토큰 5개를 쌓여뒀기 때문이죠. 하지만 6번째는 즉시 거부 그리고 그 뒤론 12초에 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;2개의 파라미터가 각각 뭘 정하는지 본다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Token Bucket은 이 두 개로 움직입니다.&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;파라미터&lt;/td&gt;
&lt;td&gt;의미&lt;/td&gt;
&lt;td&gt;위 예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;충전 속도 (refill rate)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;장기 평균&lt;/b&gt;&amp;nbsp;허용 속도&lt;/td&gt;
&lt;td&gt;12초당 1개 = 분당 5개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;버킷 용량 (capacity)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;한 번에 몰아칠 수 있는 최대량 (burst 크기)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;5개&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;는 길게 보면 결국 이 속도로 수렴한다를 정하게 됩니다. 버스트로 5개 써버려도 그 후엔 12초당 1개 페이스로 강제됩니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점은 현실 트래픽은 뚝뚝 끊겨서 요청이 몰려옵니다 예를 들어&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 앱을 켜는 순간 API 5~6개 동시에 호출(프로필 + 알림 + 목록..)&lt;/li&gt;
&lt;li&gt;평소엔 조용하다가 이 순간만 확 몰리게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초당 1개로 딱 자르는 (leaky bucket 식 - 밑에서 하게 됩니다) 방식이면 이 정상적인 앱 켜기도 막혀서 사용자 경험이 나빠집니다. Token Bucket은 평소 안 쓴 토큰을 쌓아뒀다가 이 순간 버트스를 허용하니 정상 사용자는 매끄럽게 쓰고, 지속적인 남용만 막습니다.&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;bucket4j(자바), AWS API Gateway, Stripe&lt;/b&gt;&amp;nbsp;등. NGINX&amp;nbsp;limit_req의&amp;nbsp;burst&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;&lt;b&gt;Leaky Bucket (리키 버킷)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 알고리즘은 요청을 큐에 넣고 일정 속도로만 처리하는 방법입니다. 큐가 다 차면 거부하게 됩니다&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;큐 처리 속도 = 12초에 1개 (분당 5개)
요청 &amp;rarr; 큐에 쌓임 &amp;rarr; 일정 속도로 빠져나감 &amp;rarr; 큐 꽉 차면 거부
&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위에서 물(요청)이 아무 속도로나 쏟아져 들어오게 되빈다.&lt;/li&gt;
&lt;li&gt;바닥 구멍으로는 항상 일정한 속도로만 물이 빠져나가게 됩니다(12초 1개)&lt;/li&gt;
&lt;li&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;타임라인으로 보면 (처리속도 12초당 1개, 큐 용량 5)&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;시각      들어옴   큐 상태          나감(처리)     비고
─────────────────────────────────────────────────────────
10:00:00  5개 몰림  [■■■■■] (5/5)    -            5개가 확 들어와 큐에 쌓임
10:00:00  +1개      큐 꽉참!          -            &amp;rarr; 거부 (넘침)

10:00:12  -        [■■■■_] (4/5)    1개 처리   12초마다
10:00:24  -        [■■■__] (3/5)    1개 처리   1개씩
10:00:36  -        [■■___] (2/5)    1개 처리   일정하게
10:00:48  -        [■____] (1/5)    1개 처리   빠져나감
10:01:00  -        [_____] (0/5)    1개 처리 
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10:00:00에 5개가 동시에 몰렸지만, 실제 처리는 12초 간격으로 하나씩 나가게 됩니다. 요청들은 큐에서 기다렸다가 순서대로 처리됩니다. 6번째는 큐가 꽉 차서 거부.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 출력이 완벽하게 평탄합니다. 아무리 몰려와도 뒷단 예를 들어 Spring/DB엔 항상 12초에 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;Token Bucket과의 결정적 차이는&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 217px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;Token Bucket&lt;/td&gt;
&lt;td&gt;Leaky Bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;양동이에 든 것&lt;/td&gt;
&lt;td&gt;&lt;b&gt;토큰&lt;/b&gt;(허가증)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;요청&lt;/b&gt;(대기열)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;요청 처리&lt;/td&gt;
&lt;td&gt;토큰 있으면&amp;nbsp;&lt;b&gt;즉시 통과&lt;/b&gt;, 없으면 거부&lt;/td&gt;
&lt;td&gt;큐에 넣고&amp;nbsp;&lt;b&gt;기다렸다가&lt;/b&gt;&amp;nbsp;일정속도로 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;지연(대기)&lt;/td&gt;
&lt;td&gt;없음 (통과 아니면 거부)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;있음&lt;/b&gt;&amp;nbsp;(큐에서 대기)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;출력 모양&lt;/td&gt;
&lt;td&gt;버스트 가능 (쌓인 토큰만큼 순간 폭발)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;항상 평탄&lt;/b&gt;&amp;nbsp;(일정 속도)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성격&lt;/td&gt;
&lt;td&gt;버스트&amp;nbsp;&lt;b&gt;허용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;버스트&amp;nbsp;&lt;b&gt;평탄화(흡수)&lt;/b&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;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;입력:          ●●●●● (5개 동시에)

Token Bucket:  ●●●●● ────────    &amp;larr; 쌓인 토큰으로 5개 즉시 통과, 그 후 거부/대기
Leaky Bucket:  ● ─ ● ─ ● ─ ● ─ ● &amp;larr; 5개를 일정 간격으로 하나씩 흘려보냄
&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;Token&lt;/b&gt;: 지금 몰아쳐도 돼(토큰 있으면). 대신 순간 부하는 뒷단이 감당해야 함.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Leaky&lt;/b&gt;: 몰아쳐도 뒷단엔 일정하게만 갈게. 대신 요청들은 기다려야 함(지연).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉&amp;nbsp;&lt;b&gt;Token은 응답이 빠른 대신 뒷단에 순간 부하&lt;/b&gt;,&amp;nbsp;&lt;b&gt;Leaky는 뒷단이 편한 대신 요청에 지연&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;&lt;b&gt;NGINX&amp;nbsp;limit_req에 매핑이 되게 됩니다.&lt;/b&gt; NGINX가 딱 이 Leaky Bucket 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;limit_req_zone $binary_remote_addr zone=api:10m rate=5r/m;

location /api/ {
    limit_req zone=api burst=5;          # &amp;larr; 큐 용량 = 5
}
&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;rate=5r/m&lt;/b&gt;&amp;nbsp;= 구멍에서 빠지는 속도 (12초당 1개).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;burst=5&lt;/b&gt;&amp;nbsp;= 큐(양동이) 크기. 초과분을 5개까지&amp;nbsp;&lt;b&gt;대기&lt;/b&gt;시킴.&lt;/li&gt;
&lt;li&gt;큐 넘으면 &amp;rarr;&amp;nbsp;&lt;b&gt;503/429 거부&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&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;nodelay&amp;nbsp;옵션의 의미:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;limit_req zone=api burst=5 nodelay;
&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;nodelay&amp;nbsp;&lt;b&gt;없으면&lt;/b&gt;: burst 요청을 큐에 넣고&amp;nbsp;&lt;b&gt;천천히&lt;/b&gt;(12초 간격) 처리 &amp;rarr; 요청이 지연됨(순수 leaky).&lt;/li&gt;
&lt;li&gt;nodelay&amp;nbsp;&lt;b&gt;있으면&lt;/b&gt;: burst 한도 내 요청을&amp;nbsp;&lt;b&gt;기다리게 하지 않고 즉시 통과&lt;/b&gt;시킴 &amp;rarr; 이 순간만은&amp;nbsp;&lt;b&gt;Token Bucket처럼&lt;/b&gt;&amp;nbsp;동작. (대신 토큰 자리는 여전히 rate 속도로 회복)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 burst=N nodelay을 많이 쓴다고 합니다. 정상적인 순간 몰림은 즉시 통과시키되 지속적 남용은 막는 절충&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Leaky를 쓰는 상황은&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;뒷단(DB, 외부 API)이&amp;nbsp;&lt;b&gt;일정 속도 이상 못 버틸 때&lt;/b&gt;&amp;nbsp;- 무조건 평탄하게 흘려보내야 안전&lt;/li&gt;
&lt;li&gt;트래픽 shaping(대역폭 제어, 결제/외부 연동 호출량 조절 등).&lt;/li&gt;
&lt;li&gt;반대로 사용자 대면 API에서 응답 지연이 싫으면 Token Bucket(또는 nodelay)이 나음.&lt;/li&gt;
&lt;/ul&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;b&gt;요약 비교표&lt;/b&gt;&lt;/h3&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;알고리즘&lt;/td&gt;
&lt;td&gt;정확도&lt;/td&gt;
&lt;td&gt;메모리&lt;/td&gt;
&lt;td&gt;버스트&lt;/td&gt;
&lt;td&gt;구현 난이도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed Window&lt;/td&gt;
&lt;td&gt;낮음(경계문제)&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;경계서 2배 샘&lt;/td&gt;
&lt;td&gt;매우 쉬움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding Log&lt;/td&gt;
&lt;td&gt;최고&lt;/td&gt;
&lt;td&gt;많음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding Counter&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;거의 없음&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Token Bucket&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;&lt;b&gt;허용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;쉬움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Leaky Bucket&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;적음&lt;/td&gt;
&lt;td&gt;&lt;b&gt;평탄화&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;어디서 셀까&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카운팅 로직을&amp;nbsp;&lt;b&gt;어느 위치에 둘거냐를 말해볼 수 있습니다. 다음은 예시 입니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;클라이언트 &amp;rarr; [CDN/WAF] &amp;rarr; [NGINX/게이트웨이] &amp;rarr; [애플리케이션] &amp;rarr; [DB]
              (엣지)        (네트워크, IP)       (유저/비즈니스)
&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;엣지(CDN/WAF)&lt;/b&gt;: 대규모 DDoS 흡수. Cloudflare 등.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;게이트웨이/NGINX&lt;/b&gt;: IP 단위 거친 제한.&amp;nbsp;&lt;b&gt;요청이 앱에 닿기 전 차단 &amp;rarr; 제일 쌈.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;애플리케이션&lt;/b&gt;: 유저, API키, 등급별 정밀 제한. 비즈니스 규칙 반영.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;앞단에서 거를수록 싸다&quot;라고 말할 수 있습니다.&lt;/b&gt; 뒤로 갈수록 이미 자원을 쓴 뒤라 비싸게 됩니다. 즉, 위치에 따라&amp;nbsp;&lt;b&gt;비용과 정밀도&lt;/b&gt;가 트레이드오프가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;분산 환경의 어려움 (Redis)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스가 여러 개면 카운터를 공유해야 함 여기서 2가지 함정이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 원자성(atomicity) / race condition&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;동시에 두 요청이:
  read 카운터=4  &amp;rarr;  둘 다 &quot;4&amp;lt;5니까 OK&quot;  &amp;rarr;  둘 다 +1  &amp;rarr;  실제 6 (한도 초과!)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;read-modify-write가 쪼개지면 초과 통과하게 됩니다. &lt;b&gt;Redis Lua 스크립트&lt;/b&gt;나&amp;nbsp;INCR(원자적 연산)로 &quot;읽고-검사-증가&quot;를 한 번에 처리해야 함. bucket4j-redis 같은 라이브러리가 이걸 해주게 됩니다.&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;2. 네트워크 비용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매 요청마다 Redis 왕복 &amp;rarr; 지연 추가. 그래서 로컬 캐시 + 주기적 동기화 같은 최적화도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실무 (HTTP 규약)&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초과 시&amp;nbsp;&lt;b&gt;429 Too Many Requests&lt;/b&gt;&amp;nbsp;반환.&lt;/li&gt;
&lt;li&gt;헤더로 상태 알려주기&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30          (초 후 리셋)
Retry-After: 30              (재시도까지 대기 초)
&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;key 전략&lt;/b&gt;: IP / 유저ID / API키 / (IP+엔드포인트) 조합. 로그인 API는 IP+계정 조합으로 brute-force 방어.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Fail-open vs fail-closed&lt;/b&gt;: rate limiter(Redis)가 죽으면? 요청을 통과시킬지(open, 가용성 우선) 막을지(closed, 보안 우선) 미리 정해두기.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/421</guid>
      <comments>https://wo-dbs.tistory.com/421#entry421comment</comments>
      <pubDate>Sat, 4 Jul 2026 13:59:30 +0900</pubDate>
    </item>
    <item>
      <title>Docker 및 Docker Compose 도입 - 7/1</title>
      <link>https://wo-dbs.tistory.com/420</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 서비스의 운영 서버는 2GB인 한 대로 운영하고 있습니다. 하지만 성능을 업그레이드를 하면서 스펙이 점점 올라가기 시작했습니다. NGINX, Spring Boot, PostgreSQL, 그리고 관측성을 위한 Grafana Alloy까지 올라갔습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 검색 성능을 위한 Redis 고려와 CI/CD 파이프라인까지 붙이려는 시점이 되게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스가 이 정도로 늘어나니 서버에 그때그때 손으로 설치하고 실행하는 방식이 한계에 부딪혔습니다. 이 글은 그 한계를 어떻게 느꼈고 왜 Docker와 Docker Compose를 선택했으며 2GB라는 제약 안에서 어떤 선택을 했는지 에 대한 기록입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 왜 Docker인가&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 서비스가 늘어나면 수동 관리가 무너진다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에 현재 다음을 전부 설치한다고 생각해보았을 때&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NGINX 설치 &amp;middot; 설정&lt;/li&gt;
&lt;li&gt;JDK 설치 + Spring 실행 스크립트&lt;/li&gt;
&lt;li&gt;PostgreSQL 설치 &amp;middot; 버전 관리&lt;/li&gt;
&lt;li&gt;Grafana Alloy 바이너리&lt;/li&gt;
&lt;li&gt;(예정) Redis&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;서비스가 1~2개면 그냥 설치해도 되지만 6개쯤 되면 격리와 조합이 필요한 임계점이 오게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker는 각 서비스를 컨테이너로 격리하고 그 실행 환경을 이미지 하나에 담는다. 이 서버에 뭐가 깔려 있는가가 기억이 아니라 코드(docker-compose.yml)로 남게 됩니다.&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;1.2 CI/CD의 배포 단위가 필요했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 Docker 도입의 가장 정직한 방아쇠는 CI/CD였습니다. CI/CD를 제대로 하려면 &quot;빌드 산출물&quot;을 서버로 옮겨 실행해야 하는데 그 산출물이 JAR 파일이면 여전히 &quot;서버에 JDK 버전 맞고, 환경변수 맞고&amp;hellip;&quot;를 신경 써야 합니다. 반면 Docker 이미지는 실행 환경까지 통째로 담은 배포 단위입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CI에서 docker build &amp;rarr; 레지스트리에 push&lt;/li&gt;
&lt;li&gt;서버에서 docker pull &amp;rarr; docker compose up -d&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CI에선 됐는데 서버에선 안 됨이 구조적으로 사라집니다. 롤백도 이전 이미지 태그로 다시 up 하면 끝나게 됩니다.&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;1.3 로컬 = 운영 환경 일치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로 Redis를 구축하기 전에 이 이점이 특히 와닿았습니다. Compose 파일에 몇 줄 추가하는 것만으로 로컬에도 운영과 같은 버전의 Redis가 뜨게 됩니다. &quot;내 로컬에 Redis 깔고 버전 맞추고&amp;hellip;&quot; 하는 온보딩 비용이 사라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 왜 Docker Compose인가&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Docker(컨테이너화)를 왜? &amp;rarr; 환경을 코드로 고정하고 어디서든 똑같이 뜨게 만들 수 있습니다.&lt;/li&gt;
&lt;li&gt;Compose를 왜? &amp;rarr; 그 컨테이너가 여러 개(NGINX + Spring + PG + Redis + Alloy)니까 한 파일로 묶어 한 번에 관리할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose는 다중 서비스 스택을 하나의 선언적 파일로 정의하고 docker compose 명령 하나로 전체를 재현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 간 통신도 서비스명으로 해결되고(db:5432, redis:6379),&lt;br /&gt;기동 순서(depends_on), 전용 네트워크, 볼륨, 리소스 상한까지 전부 코드로 남는다. 특히 저희 서비스는 리소스 상한이 되게 중요하기 때문에 이것이 가장 도움이 될 것이라고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 2GB와의 싸움&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스를 컨테이너로 나누는 것과 별개로 총 메모리는 여전히 2GB입니다. 현실적인 예산을 짜보게 되었습니다.&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;컴포넌트&lt;/td&gt;
&lt;td&gt;대략 RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS + Docker 데몬&lt;/td&gt;
&lt;td&gt;200~400MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring Boot (JVM)&lt;/td&gt;
&lt;td&gt;400~700MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;200~400MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NGINX&lt;/td&gt;
&lt;td&gt;20~50MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grafana Alloy&lt;/td&gt;
&lt;td&gt;100~200MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis (캐시)&lt;/td&gt;
&lt;td&gt;50~150MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총합은 대략 1.2~1.8GB 나오게 됩니다. 2GB에서 돌아가긴 하지만 여유가 거의 없습니다. 트래픽이 튀거나 JVM GC가 돌 때 OOM으로 컨테이너가 죽을 수 있게 됩니다.&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. 스왑(swap)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2GB에선 스왑이 사실상 필수다. 순간 스파이크에 프로세스가 즉사하는 걸 막는다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# /etc/fstab 에 등록해 재부팅 후에도 유지
&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;p data-ke-size=&quot;size16&quot;&gt;2. JVM 힙 명시적 제한 &amp;amp; 서비스별 메모리 상한&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;한 프로세스가 폭주하는 걸 막을 수가 있습니다.&lt;/li&gt;
&lt;li&gt;컨테이너 안의 JVM은 호스트 전체 메모리를 보고 힙을 크게 잡으려다 터지게됩니다. 그래서 힙을 명시하였습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;spring:
  environment:
    - JAVA_OPTS=-Xms256m -Xmx512m -XX:MaxMetaspaceSize=128m
  mem_limit: 700m
postgres:
  mem_limit: 400m
redis:
  mem_limit: 150m
  command: redis-server --maxmemory 120mb --maxmemory-policy allkeys-lru
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면 모든 결정이 &quot;2GB 단일 서버&quot;라는 하나의 제약에서 나오게 되었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스가 늘어 수동 관리가 한계 &amp;rarr; Docker&lt;/li&gt;
&lt;li&gt;6개 서비스를 한 파일로, 리소스 상한까지 &amp;rarr; Compose&lt;/li&gt;
&lt;li&gt;메모리가 빠듯해서 &amp;rarr; JVM 힙 제한, mem_limit, swap&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제약이 오히려 선택을 명확하게 만들어주게 되었습니다.&lt;/p&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/420</guid>
      <comments>https://wo-dbs.tistory.com/420#entry420comment</comments>
      <pubDate>Thu, 2 Jul 2026 11:36:41 +0900</pubDate>
    </item>
    <item>
      <title>통합검색 성능 개선기</title>
      <link>https://wo-dbs.tistory.com/419</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 배경, 목표, 그리고 개선 방향&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 배경 및 목표&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 서비스에는&amp;nbsp;&lt;b&gt;통합검색&lt;/b&gt;&amp;nbsp;기능이 있습니다. 검색어 하나를 넣으면&amp;nbsp;&lt;b&gt;보관함, 일정, 링크, 텍스트, 파일&lt;/b&gt;&amp;nbsp;5종을 한 번에 찾아줍니다. 그런데 이 API는 한 번 호출될 때마다&amp;nbsp;&lt;b&gt;5개 테이블을 전부 검색하게 되었습니다.&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;1. SLO&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SLI&amp;nbsp;= 700ms 이내에 성공한 검색 요청 수 / 전체 검색 요청 수&amp;nbsp;&lt;br /&gt;SLO&amp;nbsp;= SLI &amp;ge; 95%&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색의 기준선이 필요했습니다. 사용자 응답성 연구와&amp;nbsp;SRE의 latency SLO 설계 방식을 참고해&amp;nbsp;700ms를 초기 기준으로 잡았습니다. (제일 참고 부분에 첨부하였습니다.)&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;2. 동시 접속자의 한계&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하가 걸렸을 때도 700ms를 지키는 가에 대한 한계를 숫자로 정확하게 알아야했습니다. DAU 1,000명을 가정하더라도 1,000명이 동시에 들어오는 것이 아니라 하루에 흩어져 들어와 잠깐씩 쓴다는 가정이 필요했습니다. 그래서 DAU를 동시 접속자로 환상해야했고 Little&amp;rsquo;s Law로 접근했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균 동시 인원(L) = 도착률(&amp;lambda;) &amp;times; 체류시간(W)&lt;br /&gt;&amp;asymp; (하루 사용자 수 &amp;divide; 하루 이용 시간대) &amp;times; 1인 세션 시간 = (1,000명 &amp;divide; 12시간) &amp;times; 8분 &amp;asymp;&amp;nbsp;11명&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가정은 이렇게 넣었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;1인 세션 = 8분&lt;/b&gt;&amp;nbsp;활동&lt;/li&gt;
&lt;li&gt;&lt;b&gt;하루 이용 시간대 = 12시간&lt;/b&gt;&amp;nbsp;- 한 사람의 이용 시간이 아니라&amp;nbsp;사용자들이 하루 중 들어오는 시간 폭. 새벽엔 거의 안 쓰니 깨어 있는 12시간 동안 흩어져 들어온다고 봤습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균 동시 사용자는 약&amp;nbsp;&lt;b&gt;11명&lt;/b&gt;. 다만 트래픽은 몰리는 시간대가 있으니 피크를 그&amp;nbsp;&lt;b&gt;4~5배&lt;/b&gt;로 보고&amp;nbsp;&lt;b&gt;동시 약 50명&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;3. 목표&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DAU 1,000 규모(피크 동시 약 50명)에서, 통합검색을 p95 700ms 이내로 SLI 95% 이상 유지&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 기존 구조와 문제점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 기존 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합검색은 5개 테이블을&amp;nbsp;다음과 같이 조회합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;WHERE users_id = ? AND status = 'ACTIVE'
  AND lower(컬럼) LIKE lower('%검색어%')
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 부하 테스트 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 동시 몇 명까지 버티나(용량)부터 재기로 했고 그러려면 두 가지의 준비가 필요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 더미 데이터&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;데이터&lt;/td&gt;
&lt;td&gt;일반 유저 1명&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;헤비 유저 1명&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;상한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보관함&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;유저당 100개 (앱 제한)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;일정&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;링크&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;텍스트&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;파일&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;총 5GB (평균 1MB &amp;rarr; 5,000개)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;합계&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 4,100행&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 35,100행&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 유저&amp;nbsp;29명&amp;nbsp;+ 데이터를 많이 쌓아둔 헤비 유저&amp;nbsp;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;2. 동시 사용자를 흉내 낼 방법&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6로 동시 사용자(VU)를 천천히 올리며,&amp;nbsp;SLI(700ms 이내 비율)&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;&lt;b&gt;2.3 부하 테스트로 드러난 문제&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선, 극단을 봤습니다.&amp;nbsp;똑같은 단어로 검색하면&amp;nbsp;유저의 모든 데이터가 한 응답에 담기게&amp;nbsp;했습니다. 동시 사용자를&amp;nbsp;10 &amp;rarr; 50 &amp;rarr; 100 &amp;rarr; 150 &amp;rarr; 200명으로 올렸습니다.&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;지표&lt;/td&gt;
&lt;td&gt;값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLI(700ms)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;&lt;b&gt;35초&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패율&lt;/td&gt;
&lt;td&gt;66% (500 에러)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전송량&lt;/td&gt;
&lt;td&gt;910MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 빠른 요청조차 2.9초였습니다. 500는&amp;nbsp;Broken pipe이었습니다. 거대한 JSON을 직렬화하는 도중 클라이언트가 60초 타임아웃으로 먼저 끊어버려 서버가 닫힌 소켓에 쓰다 실패한 것이었습니다.&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;그런데 이 결과로는 &quot;동시 몇 명까지 버티나&quot;를 알 수 없었습니다.&amp;nbsp;여기서 무너진 건 검색 처리 능력이 아니라&amp;nbsp;응답 크기였기 때문입니다. 그래서 변수를 하나로 좁혔습니다.&amp;nbsp;데이터 일부에만 단어를 심어&amp;nbsp;검색 결과가 유저당 50건만 나오게 맞추고 동시 접속자를 10 ~ 30명으로 줄였습니다.&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동시&lt;/td&gt;
&lt;td&gt;사용자 SLI(700ms 이내)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10명&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15명&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;20명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;~99%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25명&lt;/td&gt;
&lt;td&gt;~85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30명&lt;/td&gt;
&lt;td&gt;~60%&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;단일 요청은&amp;nbsp;44ms로 빠른데도&amp;nbsp;동시 20명까지는 SLO를 지키다 25명부터 무너지기 시작해 30명에서 60%로&amp;nbsp;떨어졌습니다. 전체 p95 984ms, 처리량 36 req/s) 즉&amp;nbsp;개선 전 용량은 동시 약 20명. 목표(동시 50명)의 절반에도 못 미쳤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SLO를 지키지 못하는지 원인을 파악하기 위해 쿼리 실행 계획과 CPU 상태를 보았습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Seq Scan on schedules
  Rows Removed by Filter: 39,005   &amp;larr; 읽고 버린 행
  Buffers: shared hit=534          &amp;larr; 전부 메모리(캐시)에서 읽음 (디스크 0)
  (actual ... rows=10)             &amp;larr; 실제로 찾은 행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블엔 30명 전원의&amp;nbsp;39,015행이 섞여 있는데&amp;nbsp;전부를 한 줄씩 읽어&amp;nbsp;10건만 건지고&amp;nbsp;39,005행을 버립니다.&amp;nbsp;그리고&amp;nbsp;Buffers: shared hit이 말해주듯 이 읽기는&amp;nbsp;디스크가 아니라 메모리(캐시)에서&amp;nbsp;일어납니다. 디스크 대기가 아니라&amp;nbsp;순수 CPU로 3.9만 행을 훑고 문자열 비교(LIKE)&amp;nbsp;하는 작업이고 이게&amp;nbsp;5개 테이블에서 매 요청마다&amp;nbsp;반복됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU는&amp;nbsp;mpstat 명령어로 확인했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;usr 97% idle 0% iowait 0%
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;idle 0%&amp;nbsp;&amp;rarr; CPU&amp;nbsp;&lt;b&gt;완전 포화&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;iowait 0%&amp;nbsp;&amp;rarr; 디스크 대기&amp;nbsp;&lt;b&gt;아님&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 병목은 디스크도 메모리도 아닌 CPU였습니다.&amp;nbsp;인덱스 없는 풀스캔이 매 요청마다 5개 테이블에서 수만 행을 CPU로 훑고 있었고, 동시 사용자가 늘자 CPU가 가장 먼저 포화됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 개선 1 - &lt;b&gt;인덱스 추가&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;풀스캔을 없애는 것&lt;/b&gt;이 첫 목표였습니다. 하지만 함정이 하나 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;LIKE '%검색어%'는 인덱스를 못 탄다&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 든 생각은 &quot;검색하는 컬럼에 인덱스를 걸면 되지 않나?&quot;였습니다. 하지만&amp;nbsp;&lt;b&gt;PostgreSQL에서&amp;nbsp;LIKE '%검색어%'(앞 와일드카드)는 일반 btree 인덱스를 타지 못합니다.&lt;/b&gt;&amp;nbsp;인덱스는 앞에서부터 정렬된 자료구조라&amp;nbsp;'검색어%'처럼&amp;nbsp;&lt;b&gt;앞이 고정&lt;/b&gt;돼야 쓸 수 있는데,&amp;nbsp;'%검색어%'는 앞이 열려 있어 결국&amp;nbsp;&lt;b&gt;전 행을 훑을 수밖에&lt;/b&gt;&amp;nbsp;없습니다. 검색어 컬럼에 인덱스를 걸어도&amp;nbsp;&lt;b&gt;LIKE 자체는 빨라지지 않습니다.&lt;/b&gt;&amp;nbsp;방향을 바꿔야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색은&amp;nbsp;항상&amp;nbsp;사용자 PK로 먼저 거르게 되었습니다.&amp;nbsp;테이블엔 30명 전원의 3.9만 행이 섞여 있지만 한 요청이 실제로 봐야 할 건&amp;nbsp;그 유저의 행뿐이라 LIKE가 훑는 대상이&amp;nbsp;전체 3.9만 행에서 그 유저의 약 1,000행으로 줄어듭니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE INDEX idx_schedules_users_status ON schedules (users_id, status);
- 보관함 링크, 텍스트, 파일 4개 테이블도 동일하게
ANALYZE ...;
&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;인덱스 전&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;인덱스 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 방식&lt;/td&gt;
&lt;td&gt;Seq Scan&amp;nbsp;(풀스캔)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Bitmap Index Scan&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;읽고 버린 행&lt;/td&gt;
&lt;td&gt;&lt;b&gt;39,005&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;990&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;읽은 페이지&lt;/td&gt;
&lt;td&gt;534&lt;/td&gt;
&lt;td&gt;&lt;b&gt;22&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쿼리 시간&lt;/td&gt;
&lt;td&gt;5.1ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0.68ms&lt;/b&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;b&gt;39,005에서 990&lt;/b&gt;으로 줄었습니다. 읽은 페이지도 534 &amp;rarr; 22로 떨어지며 단일 쿼리가 약&amp;nbsp;&lt;b&gt;7.5배&lt;/b&gt;&amp;nbsp;빨라졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 전과&amp;nbsp;&lt;b&gt;똑같은 부하 테스트(동시 10&amp;rarr;30명)&lt;/b&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;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;2984&quot; data-origin-height=&quot;734&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cutKOB/dJMcagMUGSh/Cpsu7vjz2LZgfK16CKi1a1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cutKOB/dJMcagMUGSh/Cpsu7vjz2LZgfK16CKi1a1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cutKOB/dJMcagMUGSh/Cpsu7vjz2LZgfK16CKi1a1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcutKOB%2FdJMcagMUGSh%2FCpsu7vjz2LZgfK16CKi1a1%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;2984&quot; height=&quot;734&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;2984&quot; data-origin-height=&quot;734&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;지표&lt;/td&gt;
&lt;td&gt;개선 전&lt;/td&gt;
&lt;td&gt;&amp;nbsp;개선 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 응답시간&lt;/td&gt;
&lt;td&gt;984ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;464ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리량&lt;/td&gt;
&lt;td&gt;36 req/s&lt;/td&gt;
&lt;td&gt;&lt;b&gt;85 req/s&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLI (동시 25명)&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;~99.9%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLI (동시 30명)&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;~99.3%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전엔 25명부터 무너져 30명에서 SLI 60%였던 것&amp;nbsp;인덱스 후엔 30명까지 99% 이상으로 버텼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처리량은&amp;nbsp;약 2.4배(36&amp;rarr;85 rps), p95는&amp;nbsp;절반 이하가 됐습니다. 용량으로 보면&amp;nbsp;동시 ~20명 &amp;rarr; ~30명&amp;nbsp;으로 올라간 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 30명 구간에서 SLI가 딱 100%가 아니라&amp;nbsp;99.3%까지 미세하게 내려왔습니다.&amp;nbsp;부하 중 서버 CPU를 보니&amp;nbsp;여전히 ~92%로 높았습니다. 저는 CPU가 뭐 때문에 병목인지 궁금해졌습니다.&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은 EXPLAIN상 0.68ms로 빠른데 CPU가 높다는 건&amp;nbsp;비용이 SQL이 아니라 그 바깥 어딘가에 있다는 뜻이라고 생각했습니다. 그래서&amp;nbsp;부하 중&amp;nbsp;async-profiler로 CPU 플레임그래프를 떠서 직접 확인했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;다운로드 (6).png&quot; data-origin-width=&quot;3364&quot; data-origin-height=&quot;6165&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/P4XCV/dJMcafHdE13/mdORar2MvTW09uoB3K4Mmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/P4XCV/dJMcafHdE13/mdORar2MvTW09uoB3K4Mmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/P4XCV/dJMcafHdE13/mdORar2MvTW09uoB3K4Mmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FP4XCV%2FdJMcafHdE13%2FmdORar2MvTW09uoB3K4Mmk%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;413&quot; height=&quot;757&quot; data-filename=&quot;다운로드 (6).png&quot; data-origin-width=&quot;3364&quot; data-origin-height=&quot;6165&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 프레임이&amp;nbsp;자기 코드에서 직접&amp;nbsp;쓴 시간(self-time)을 패키지별로 묶어봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;패키지(self-time)&lt;/td&gt;
&lt;td&gt;비중&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;native / syscall / JIT&lt;/td&gt;
&lt;td&gt;27.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDK util (String, HashMap &amp;hellip;)&lt;/td&gt;
&lt;td&gt;24.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GC&lt;/td&gt;
&lt;td&gt;13.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS/S3&lt;/td&gt;
&lt;td&gt;12.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hibernate/JPA&lt;/td&gt;
&lt;td&gt;6.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring/AOP proxy&lt;/td&gt;
&lt;td&gt;4.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기타&lt;/td&gt;
&lt;td&gt;나머지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 개별 프레임을 봐도&amp;nbsp;HashMap,&amp;nbsp;String같은 것들뿐이라, 원인은 보이지 않았습니다. native와 JDK util만 잔뜩 크고 정작 순수 Hibernate는 6.5%인데 그렇다고 ORM이 싸다고 결론 내리기엔 동시에&amp;nbsp;String 및 HashMap이 비정상적으로 컸습니다.&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;알고보니 self-time 패키지 별로 묶는 것은 그 순간 CPU가 물리적으로 어느 코드 줄에 있었나를 셉니다. 그런데&amp;nbsp;하나의 작업이 여러 패키지로 흩어지면&amp;nbsp;원인이 분해돼 버리는 것을 알게되었습니다. 메서드 단위인 inclusive로 다시 집계했습니다. 흩어졌던 비용이&amp;nbsp;원인 하나로 모이자&amp;nbsp;그림이 완전히 바뀌었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU 소비처&lt;/td&gt;
&lt;td&gt;비중&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 presigned URL 생성&amp;nbsp;(첨부 검색, 행마다)&lt;/td&gt;
&lt;td&gt;42.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GC&lt;/td&gt;
&lt;td&gt;15.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;엔티티 하이드레이션 + DTO (4개 검색)&lt;/td&gt;
&lt;td&gt;~18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보관함 이름 조회&lt;/td&gt;
&lt;td&gt;7.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;통합검색 search() - 71.0%
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;searchFiles(첨부) - 50.0%
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;S3 presigned URL 생성 - 42.6%&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;searchLinks / Texts / Schedules / Folders ~18%&lt;/li&gt;
&lt;li&gt;FoldersService.findById - 7.8%&lt;/li&gt;
&lt;li&gt;GC -15.6%&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;병목은 검색 결과인 파일의 S3 presigned URL을 동기로 서명 및 생성하는 것이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. &lt;b&gt;개선 2 - 검색 결과의 S3 서명 제거&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 통합검색 결과 화면은 이 URL을 즉시 쓰지 않으므로(상세 진입 시 발급해도 충분),&amp;nbsp;목록 응답에서는 서명을 생성하지 않도록&amp;nbsp;제거한 후, 부하 테스트(동시 10&amp;rarr;30명)를 다시 돌렸습니다. 결과는 다음과 같습니다.&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;지표&lt;/td&gt;
&lt;td&gt;인덱스 후&lt;/td&gt;
&lt;td&gt;서명 제거 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 응답시간&lt;/td&gt;
&lt;td&gt;464.16ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;291.84ms&lt;/b&gt;&amp;nbsp;(&amp;minus;37%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p90&lt;/td&gt;
&lt;td&gt;377.51ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;237.65ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;평균&lt;/td&gt;
&lt;td&gt;224.23ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;157.13ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최댓값&lt;/td&gt;
&lt;td&gt;1.06s&lt;/td&gt;
&lt;td&gt;&lt;b&gt;759.65ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리량&lt;/td&gt;
&lt;td&gt;84.6 req/s&lt;/td&gt;
&lt;td&gt;&lt;b&gt;120.5 req/s&lt;/b&gt;&amp;nbsp;(+42%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패율&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0.00%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;총 처리 요청&lt;/td&gt;
&lt;td&gt;27,082&lt;/td&gt;
&lt;td&gt;&lt;b&gt;38,555&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLI(동시 30명)&lt;/td&gt;
&lt;td&gt;~99.3%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;~100%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청을 1.1만 건 더&amp;nbsp;처리했고 p95는 절반 가까이, 처리량은 약 1.4배가 됐습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;2728&quot; data-origin-height=&quot;722&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5dTwX/dJMb99UttE4/BZRsBHqu9PKpGIIkPcCy5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5dTwX/dJMb99UttE4/BZRsBHqu9PKpGIIkPcCy5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5dTwX/dJMb99UttE4/BZRsBHqu9PKpGIIkPcCy5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5dTwX%2FdJMb99UttE4%2FBZRsBHqu9PKpGIIkPcCy5k%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;2728&quot; height=&quot;722&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;2728&quot; data-origin-height=&quot;722&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;b&gt;100% 라인에 붙어&lt;/b&gt;&amp;nbsp;있었지만, 정확히는&amp;nbsp;&lt;b&gt;딱 100%가 아니었습니다.&lt;/b&gt;&amp;nbsp;21:35 한 지점에서&amp;nbsp;&lt;b&gt;99.983%까지 미세하게 내려왔습니다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;약 0.017%의 요청만 700ms를 넘겼다&lt;/b&gt;는 뜻입니다.&lt;/li&gt;
&lt;li&gt;38,555건 기준&amp;nbsp;&lt;b&gt;대략 6~7건&lt;/b&gt;&amp;nbsp;수준이고, k6의&amp;nbsp;max=759.65ms가 바로 그 꼬리(tail)입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;드물게 700ms를 넘기는 꼬리가 아직 남아 있었습니다.&lt;/b&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU 소비처&lt;/td&gt;
&lt;td&gt;제거 전&lt;/td&gt;
&lt;td&gt;제거 후&lt;/td&gt;
&lt;td&gt;계층&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;통합검색&amp;nbsp;search&lt;/td&gt;
&lt;td&gt;71.0%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;58.0%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;최상위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GC&lt;/td&gt;
&lt;td&gt;15.6%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;5.1%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;최상위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기타&lt;/td&gt;
&lt;td&gt;나머지&lt;/td&gt;
&lt;td&gt;나머지&lt;/td&gt;
&lt;td&gt;최상위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPA 쿼리 실행 및 매핑&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;~34%&lt;/td&gt;
&lt;td&gt;통합 검색 search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;findById&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;13.8%&lt;/td&gt;
&lt;td&gt;통합 검색 search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기타 (AOP 프록시, DTO 생성 등)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;~10%&lt;/td&gt;
&lt;td&gt;통합 검색 search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;쿼리 해석, 플랜, SQL 변환&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;22.2%&lt;/td&gt;
&lt;td&gt;JPA 쿼리 실행 및 매핑&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JDBC 드라이버&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;7.7%&lt;/td&gt;
&lt;td&gt;JPA 쿼리 실행 및 매핑&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPQL 파싱&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;td&gt;JPA 쿼리 실행 및 매핑&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;하이드레이션(엔티티 생성)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1.2%&lt;/td&gt;
&lt;td&gt;JPA 쿼리 실행 및 매핑&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레임그래프에서 FoldersService.findById가&amp;nbsp;&lt;b&gt;13.8%&lt;/b&gt;&amp;nbsp;를 차지한다는 점이었습니다. 이 프레임은 그래프에&amp;nbsp;&lt;b&gt;세 번&lt;/b&gt;(링크, 텍스트, 파일&amp;nbsp;안) 따로 잡혀 있었고 코드를 열어보니 원인을 알게 되었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;for (AttachMents a : files) {                                  // 결과 행 N개
    String foldersName = foldersService.findById(a.getStorageId()).getName(); // 행마다 +1 쿼리
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 검색 쿼리가 조건에 맞는 파일들을&amp;nbsp;&lt;b&gt;한 번에&lt;/b&gt;&amp;nbsp;가져온 후 결과가 N개 행이라고 하면 그다음&amp;nbsp;루프가 그 행들을 돌면서&amp;nbsp;&lt;b&gt;행마다&lt;/b&gt;&amp;nbsp;foldersService.findById(...)로 보관함 이름을 다시 조회합니다. 실제로 DB에 나가는 SQL을 세어보면&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT ... FROM attachments WHERE ... ;   -- 검색: 1번
SELECT ... FROM folders WHERE folders_id=?;  -- 1번째 행
SELECT ... FROM folders WHERE folders_id=?;  -- 2번째 행
...                                          -- N번째 행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 검색&amp;nbsp;1번 + 보관함 조회 N번 = 총&amp;nbsp;1 + N번의 쿼리가 나가고 있었습니다. 검색 결과가 100건이면 화면 하나에&amp;nbsp;쿼리가 101번&amp;nbsp;날아가는 셈입니다. 이것이 바로&amp;nbsp;N+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;findById가&amp;nbsp;13.8%(links 7.2% + texts 3.3% + files 3.3%)까지 올라온 것도 이 세 곳의 반복 조회가 합쳐진 결과였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 개선 3 - &lt;b&gt;N+1 제거 : DTO 프로젝션 + JOIN&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1 해법은 보통 세 가지인데,&amp;nbsp;이 코드의 구조가 선택지를 좁혔습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fetch join은 적용이 불가능했습니다. 보관함이&amp;nbsp;@ManyToOne이 아니라&amp;nbsp;경로식을 못 사용했습니다.&lt;/li&gt;
&lt;li&gt;EntityGraph 같은 경우도 fetch join과 같은 방향이었습니다.&lt;/li&gt;
&lt;li&gt;DTO 프로젝션은 연관관계 없이 조인이 가능하였습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일, 링크, 텍스트는 보관함을&amp;nbsp;연관관계가 아니라 FK로 들고 있어서 fetch join 및 entity graph는&amp;nbsp;쓸 수가 없었습니다.&amp;nbsp;게다가 이 둘은 N+1만 없앨 뿐&amp;nbsp;엔티티를 그대로 부풀려 로딩합니다. 반면 DTO 프로젝션은&amp;nbsp;N+1과 하이드레이션을 동시에&amp;nbsp;없앱니다. 그래서&amp;nbsp;DTO 프로젝션을 선택했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;레포지토리 - DTO를 직접 select 및 Folders를 ON절로 JOIN&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;smalltalk&quot;&gt;&lt;code&gt;@Query(&quot;&quot;&quot;
    select new com.toit.items.attachments.dto.response.AttachMentsFilesGetInFoldersResponse(
        a.attachmentsId, a.storageId, a.attachmentsType, a.objectKey,
        a.attachmentsExtension, a.attachmentsSize, a.fileName, a.createdAt,
        f.name, a.textContent)
    from AttachMents a
    join Folders f on f.foldersId = a.storageId
    where a.users.usersId = :usersId
      and a.status = :status
      and a.attachmentsType = :type
      and lower(coalesce(a.fileName, '')) like lower(concat('%', :keyword, '%'))
    order by a.createdAt desc
&quot;&quot;&quot;)
List&amp;lt;AttachMentsFilesGetInFoldersResponse&amp;gt; searchFilesWithFolder(...);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;엔티티 하이드레이션도 생략. 링크 및 텍스트도 동일 패턴으로 적용했습니다.&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU 소비처 및 지표&lt;/td&gt;
&lt;td&gt;서명 제거 후&lt;/td&gt;
&lt;td&gt;&amp;nbsp;N+1 제거 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;findById&lt;/td&gt;
&lt;td&gt;13.8%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;통합검색 search() 전체&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;58.0%&lt;/td&gt;
&lt;td&gt;&lt;b&gt;35.0%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;291.84ms&lt;/td&gt;
&lt;td&gt;296.41ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리량&lt;/td&gt;
&lt;td&gt;120.5 rps&lt;/td&gt;
&lt;td&gt;112.5 rps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패율&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 전체 CPU가&amp;nbsp;58%에서 35%로 내려갔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 마무리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 병목을 걷어낸 뒤, 이 서버가&amp;nbsp;동시 몇 명까지 버티는지&amp;nbsp;한계를 다시 재보았습니다. k6로 VU(동시 사용자)를&amp;nbsp;1분 간격으로 30 &amp;rarr; 50 &amp;rarr; 70 &amp;rarr; 90 &amp;rarr; 110명까지 단계적으로 올렸고 동시에 서버의 CPU 및 메모리를 함께 기록했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;부하 단계별 SLI (700ms 내 응답 비율)&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;image.png&quot; data-origin-width=&quot;1968&quot; data-origin-height=&quot;710&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dmghVS/dJMcabLCDTB/GxJPgVkr2o8Bx5p2XjRuKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dmghVS/dJMcabLCDTB/GxJPgVkr2o8Bx5p2XjRuKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dmghVS/dJMcabLCDTB/GxJPgVkr2o8Bx5p2XjRuKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdmghVS%2FdJMcabLCDTB%2FGxJPgVkr2o8Bx5p2XjRuKK%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;1968&quot; height=&quot;710&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1968&quot; data-origin-height=&quot;710&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;동시 VU&lt;/td&gt;
&lt;td&gt;SLI&lt;/td&gt;
&lt;td&gt;상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;~100%&lt;/td&gt;
&lt;td&gt;여유&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;~99.5%&lt;/td&gt;
&lt;td&gt;양호&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;~97%&lt;/td&gt;
&lt;td&gt;저하 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;~86%&lt;/td&gt;
&lt;td&gt;저하&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;110&lt;/td&gt;
&lt;td&gt;~57%&lt;/td&gt;
&lt;td&gt;붕괴&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;p95&amp;nbsp;&lt;b&gt;1.02s&lt;/b&gt;, 처리량&amp;nbsp;&lt;b&gt;127 rps&lt;/b&gt;, 실패율&amp;nbsp;&lt;b&gt;0%&lt;/b&gt;. &lt;b&gt;동시 ~50명까지는 SLI 99%로 버티지만 70명부터 무너지기 시작&lt;/b&gt;하고 110명에선 절반 이상의 요청이 700ms를 넘겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서버 CPU를 뜯어보니&lt;/b&gt; 부하 최고 구간의&amp;nbsp;시스템 전체과&amp;nbsp;자바 프로세스을 같이 봤습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;시스템 전체 (2 vCPU): usr ~93%, iowait 0%, idle 0%&lt;/li&gt;
&lt;li&gt;자바 프로세스: %CPU ~50~63% (200% 중) &amp;rarr; 약 0.5 코어만 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 두 가지를 읽을 수 있었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;usr가 93%&amp;nbsp;: CPU가 커널 처리나 대기가 아니라&amp;nbsp;순수 유저 공간의 계산으로 포화됐다.&lt;/li&gt;
&lt;li&gt;자바는 0.5 코어뿐 : 나머지&amp;nbsp;~1.4 코어는 앱이 아닌 다른 프로세스 즉&amp;nbsp;DB(PostgreSQL)&amp;nbsp;가 쓰고 있었다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LIKE 스캔은&amp;nbsp;Buffers: shared hit(디스크 읽기 0, 전부 메모리 캐시)로 돌고 있었습니다. 즉 DB는&amp;nbsp;디스크를 기다리는 게 아니라 캐시에 올라온 행을 CPU로 훑으며 문자열을 비교(LIKE)&amp;nbsp;하고 있었던 것입니다. 인덱스를 못 타는&amp;nbsp;LIKE '%keyword%'가&amp;nbsp;DB의 CPU를 태우고 있었습니다.&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;6.1 &lt;b&gt;한 줄 요약&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통합검색을 EXPLAIN, async-profiler, k6로 측정하며 병목을 하나씩 걷어냈습니다. 인덱스로 풀스캔을, 프로파일링으로 S3 서명(CPU 42.6%)을, JOIN으로 N+1을 제거해 p95 984ms &amp;rarr; 292ms, 처리량 36 &amp;rarr; 120 rps, 용량 동시 20명 &amp;rarr; 50명으로 끌어올렸습니다. 그리고 마지막 한계 테스트에서 다음 병목(DB CPU)의 위치까지 확인했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;레퍼런스&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jakob Nielsen,&amp;nbsp;Response Time Limits&amp;nbsp;(Nielsen Norman Group)&lt;/li&gt;
&lt;li&gt;Google Search Central / web.dev, Core Web Vitals - INP (200ms good, 500ms poor)&lt;/li&gt;
&lt;li&gt;Google, SRE Workbook - Implementing SLOs (percentile 기반 latency SLI)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/419</guid>
      <comments>https://wo-dbs.tistory.com/419#entry419comment</comments>
      <pubDate>Thu, 2 Jul 2026 11:35:20 +0900</pubDate>
    </item>
    <item>
      <title>Spring의 요청 처리 순서</title>
      <link>https://wo-dbs.tistory.com/418</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서 요청이 왔을 때 어떻게 되는지 하나하나 보려고 합니다. 하기 전에 알아야할 부분이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. OSIV가 항상 인터셉터인 것은 아님.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring boot MVC에서는 보통 OpenEntityManagerInViewInterceptor 형태로 동작한다고 보면 되고 직접 OpenEntityManagerInViewFilter를 등록할 수도 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 흐름은 2가지로 나누게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OSIV Interceptor 방식: [필터들] &amp;rarr; [DispatcherServlet] &amp;rarr; [OSIV Interceptor] &amp;rarr; [Controller]&lt;/li&gt;
&lt;li&gt;OSIV Filter 방식: [OSIV Filter] &amp;rarr; [다른 필터들] &amp;rarr; [DispatcherServlet] &amp;rarr; [Controller]&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Repository 호출마다 미니 트랜잭션이 생기는지는 상황에 따라 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스 메서드에서 @Transactional이 없으면, Spring Data JPA Repository의 기본 메서드나 @Transactional(readOnly=true)가 붙은 조회 메서드는 자체적으로 짧은 트랜잭션을 열 수 있습니다.&lt;/li&gt;
&lt;li&gt;하지만 서비스에 큰 @Transactional이 있으면 Repository들은 전부 그 하나의 트랜잭션에 참여합니다.&lt;/li&gt;
&lt;li&gt;서비스에 @Transactional 없음: Repository 조회마다 짧은 트랜잭션 가능&lt;/li&gt;
&lt;li&gt;서비스에 @Transactional 있음: 서비스 시작 시 트랜잭션 1개 시작, 그 안에서 Repository 여러 개 호출, 서비스 종료 시 한 번에 commit&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;0단계: 애플리케이션이 떠 있는 상태&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 요청이 오기전에 Spring boot 애플리케이션은 이미 실행되어있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;main()&lt;/li&gt;
&lt;li&gt;SpringApplication.run()&lt;/li&gt;
&lt;li&gt;ApplicationContext 생성&lt;/li&gt;
&lt;li&gt;내장 Tomcat 기동&lt;/li&gt;
&lt;li&gt;Spring MVC / Security / JPA / DataSource 준비&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략 다음과 같은 것들이 만들어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[웹 계층]&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내장 Tomcat&lt;/li&gt;
&lt;li&gt;DispatcherServlet&lt;/li&gt;
&lt;li&gt;HandlerMapping&lt;/li&gt;
&lt;li&gt;HandlerAdapter&lt;/li&gt;
&lt;li&gt;MessageConverter, 예: Jackson&lt;/li&gt;
&lt;li&gt;Servlet Filter들&lt;/li&gt;
&lt;li&gt;Spring Security FilterChain&lt;/li&gt;
&lt;li&gt;HandlerInterceptor들&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[스프링 빈]&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller&lt;/li&gt;
&lt;li&gt;Service&lt;/li&gt;
&lt;li&gt;UseCase&lt;/li&gt;
&lt;li&gt;Repository 프록시&lt;/li&gt;
&lt;li&gt;TransactionManager&lt;/li&gt;
&lt;li&gt;DataSource&lt;/li&gt;
&lt;li&gt;EntityManagerFactory&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[JPA/DB]&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EntityManagerFactory&lt;/li&gt;
&lt;li&gt;JpaTransactionManager&lt;/li&gt;
&lt;li&gt;HikariCP 커넥션 풀&lt;/li&gt;
&lt;li&gt;Hibernate 설정&lt;/li&gt;
&lt;li&gt;엔티티 메타데이터&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[보안]&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SecurityFilterChain&lt;/li&gt;
&lt;li&gt;JWT 필터&lt;/li&gt;
&lt;li&gt;AuthenticationProvider 등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 객체가 2개가 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;EntityManagerFactory = EntityManager를 만드는 공장&lt;/li&gt;
&lt;li&gt;EntityManager = 실제 요청 중 엔티티를 관리하는 영속성 컨텍스트 역할&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1단계 : 톰캣이 요청을 받음&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라언트가 요청을 다음과 같이 보냈다고 가정해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /page/search?keyword=회의
Authorization: Bearer xxx.yyy.zzz
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 OS 소켓을 통해 톰캣으로 들어옵니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Client &amp;rarr; (TCP) &amp;rarr; OS Socket &amp;rarr; Tomcat 8080 Listen&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;요청 1개 &amp;rarr; Tomcat Worker Thread 1개 배정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스레드가 요청 처리의 중심이 되됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Worker Thread &amp;rarr; Filter &amp;rarr; DispatcherSErvlet &amp;rarr; Controller &amp;rarr; Service &amp;rarr; Repository &amp;rarr; JSON 직렬화 &amp;rarr; 응답 완료&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;대표적으로 이런 것들이 스레드에 묶입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SecurityContext = 현재 인증 사용자 정보&lt;/li&gt;
&lt;li&gt;EntityManager = 현재 요청의 영속성 컨텍스트&lt;/li&gt;
&lt;li&gt;Transaction 정보 = 현재 트랜잭션 상태&lt;/li&gt;
&lt;li&gt;DB Connection = 현재 트랜잭션에 연결된 커넥션&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2단계 : 톰캣이 HTTP 요청을 Servlet 요청 객체로 만듦&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣은 HTTP 텍스트를 파싱합니다.&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;GET /page/search?keyword=회의 HTTP/1.1
Host: example.com
Authorization: Bearer xxx.yyy.zzz
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Servlet API 객체를 만듭니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HttpServletRequest&lt;/li&gt;
&lt;li&gt;HttpServletResponse&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 자바 코드에서는 HTTP 요청을 문자열로 직접 파싱하지 않고 request 객체로 다루게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;톰캣은 이 요청을 DispatcherServlet으로 보낼 준비&lt;/b&gt;를 합니다. 하지만 그 전에!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Servlet Filter Chain을 먼저 탑니다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3단계 : Servlet Filter Chain 실행&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터는 DispatcerServlet 보다 앞에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서는 = HttpServletRequest &amp;rarr; CharacterEncdoingFilter &amp;rarr; CorsFilter &amp;rarr; Spring Security FilterChain &amp;rarr; Custom Filter &amp;rarr; DispatcherServlet&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CharacterEncodingFilter &amp;rarr; 요청/응답 인코딩을 맞춥니다 ex) UTF-8 설정&lt;/li&gt;
&lt;li&gt;CORS Filter &amp;rarr; 프론트엔드 도메인이 다른 경우 CORS 정책을 검사합니다 ex) Origin 검사, Access-Control-Allow-Origin 설정, preflight OPTIONS 처리&lt;/li&gt;
&lt;li&gt;Spring Security FilterChain &amp;rarr; 보안 필터들이 실행됩니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대략 == SecurityContextHolderFilter &amp;rarr; JwtAuthenticationFilter &amp;rarr; AUthorizationFilter&lt;/li&gt;
&lt;li&gt;JWT 인증 = Authorization 헤더 읽기 &amp;rarr; Barer 토큰 추출 &amp;rarr; JWT 서명 검증 &amp;rarr; 만료 시간 검증 &amp;rarr; userId, role 추출 &amp;rarr; Authentication 객체 생성 &amp;rarr; SecurityContextHolder에 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 컨트롤러에서는 다음과 같은 형식으로 사용자 정보를 꺼낼 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;SecurityContextHolder.getContext().getAuthentication();
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Long usersId = SecurityUtil.getCurrentUserId();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점의 상태로 봤을 때 OSIV가 인터셉터 방식이면 아직 영속성 컨텍스트는 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상태:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Worker Thread 있음&lt;/li&gt;
&lt;li&gt;HttpServletRequest 있음&lt;/li&gt;
&lt;li&gt;SecurityContext 있음&lt;/li&gt;
&lt;li&gt;EntityManager 없음&lt;/li&gt;
&lt;li&gt;트랜잭션 없음&lt;/li&gt;
&lt;li&gt;DB Connection 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 일반적인 OSIV Interceptor 기준으로 필터 단계에서 JPA Lazy Loading을 하면 위험합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Filter에서 Lazy Loading 시도 &amp;rarr; EntityManager 없음 &amp;rarr; LazyInitializationException 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단 OSIV를 Filter로 직접 등록하고 보안 필터보다 앞에 두면 필터에서도 EntityManager가 있을 수 있다. 하지만 일반적으으로는 필터 단계에는 아직 JPA 영속성 컨텍스트가 없다고 보는 게 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4단계 : DispatcherServlet 진입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터를 다 통과하면 DispatcherServlet이 요청을 받습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;DispatcherServlet.doDispatch()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 서블릿은 Spring MVC의 중앙 관제탑입니다. 하는 일은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;4.1 HandlerMapping으로 컨트롤러 찾기&lt;/li&gt;
&lt;li&gt;4.2 Interceptor preHandle 실행&lt;/li&gt;
&lt;li&gt;4.3 HandlerAdapter로 컨트롤러 호출&lt;/li&gt;
&lt;li&gt;4.4 컨트롤러 반환값 처리&lt;/li&gt;
&lt;li&gt;4.5 JSON 변환 또는 View 렌더링&lt;/li&gt;
&lt;li&gt;4.6 Interceptor afterCompletion 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HandlerMapping에서 요청 URL과 HTTP Method를 보고 어떤 컨트롤러 메서들르 호출할지 찾습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;GET /page/search
   &amp;darr;
PageSearchViewController.search()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 @GetMapping, @PostMapping, @RequestMapping 정보가 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5단계 : OSIV Interceptor preHandle&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HandlerMapping으로 컨트롤러를 찾은 뒤 인터셉터의 preHandle()이 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 켜져 있으면 여기서 EntityManager를 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;OpenEntityManagerInViewInterceptor.preHandle()
   &amp;darr;
EntityManager 생성
   &amp;darr;
영속성 컨텍스트 생성
   &amp;darr;
현재 톰캣 워커 스레드에 바인딩
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히는 Spring의 TransactionSynchronizationManager에 바인딩됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 Thread &amp;rarr; TransactionSynchronizationManager &amp;rarr; EntityManagerHolder &amp;rarr; EntityManager&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 요청 스레드에서 JPA를 사용하면 이 EntityManger를 재사용합니다.&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;열림: EntityManager, 영속성 컨텍스트&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 안 열림: DB 트랜잭션, DB 커넥션&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 연 것은 상자라고 봐야합니다.&lt;/p&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&gt;영속성 컨텍스트 == 엔티티 담는 상자&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아직 DB 작업 단위인 트랜잭션은 시작하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6단계 : 컨트롤러 호출 준비&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러 메서드를 호출하기 전에 Spring MVC가 파라미터를 준비합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 컨트롤러가 다음과 같다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@GetMapping(&quot;/page/search&quot;)
public ResponseEntity&amp;lt;PageSearchResponse&amp;gt; search(
        @RequestParam(&quot;keyword&quot;) String keyword
) {
    Long usersId = SecurityUtil.getCurrentUserId();
    return ResponseEntity.ok(pageSearchUseCase.search(usersId, keyword));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC는 이런 일을 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;6.1 @RequestParam(&quot;keyword&quot;) 읽기&lt;/li&gt;
&lt;li&gt;6.2 request parameter에서 keyword 값 꺼내기&lt;/li&gt;
&lt;li&gt;6.3 String 타입으로 변환&lt;/li&gt;
&lt;li&gt;6.4 컨트롤러 메서드 인자로 넣기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 @RequestBody가 있으면 Jackson이 먼저 JSON을 객체로 바꾸게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;HTTP Body JSON
   &amp;darr;
Jackson
   &amp;darr;
Request DTO
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7단계 : 컨트롤러 실행&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 컨트롤러가 실행됩니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;public ResponseEntity&amp;lt;PageSearchResponse&amp;gt; search(String keyword) {
    Long usersId = SecurityUtil.getCurrentUserId();
    PageSearchResponse response = pageSearchUseCase.search(usersId, keyword);
    return ResponseEntity.ok(response);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 이렇게 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;7.1 SecurityContext에서 userId 꺼냄&lt;/li&gt;
&lt;li&gt;7.2 UseCase 또는 Service 호출&lt;/li&gt;
&lt;li&gt;7.3 응답 객체를 ResponseEntity로 감쌈&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 상태는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Tomcat Worker Thread 있음
SecurityContext 있음
EntityManager 있음
트랜잭션은 아직 없을 수 있음
DB Connection도 아직 없을 수 있음
&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;h2 data-ke-size=&quot;size26&quot;&gt;8단계 : Service/ UseCase 진입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 2가지 경로로 봐야하는데&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;A. UseCase에 @Transactional이 있음&lt;/li&gt;
&lt;li&gt;B. UseCase에 @Transactional이 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 A로 진행하고 마지막에 B경로를 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 이렇게 되어 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
public PageSearchResponse search(Long usersId, String keyword) {
    Users user = usersService.findById(usersId);
    List&amp;lt;FolderDto&amp;gt; folders = foldersService.searchFolders(user, keyword);
    List&amp;lt;ScheduleDto&amp;gt; schedules = schedulesService.searchSchedules(user, keyword);
    List&amp;lt;LinkDto&amp;gt; links = linksService.searchLinks(user, keyword);
    List&amp;lt;TextDto&amp;gt; texts = textsService.searchTexts(user, keyword);
    List&amp;lt;FileDto&amp;gt; files = attachMentsService.searchFiles(user, keyword);

    return new PageSearchResponse(folders, schedules, links, texts, files);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 컨트롤러가 실제 객체를 바로 호출하지 않습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller &amp;rarr; UseCase Proxy &amp;rarr; TransactionInterceptor &amp;rarr; 실제 UseCase 객체&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 @Transactional AOP가 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9단계 : @Transactional AOP가 실제로 하는일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분을 세부 단계로 나눠서 봐야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.1 프록시가 호출을 가로챔&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러는 pageSearchUseCase.search()를 호출한다고 생각하지만, 실제로는 프록시가 먼저 받게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller &amp;rarr; PageSearchUseCase Proxy &amp;rarr; TransactionInterceptor&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시는 @Transactional이 붙이 있는지 확인한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.2 트랜잭션 설정 읽음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional에는 여러 설정이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@Transactional(readOnly=true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시는 이런 정보들을 읽습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;readOnly = true
propagation = REQUIRED // 이미 트랜잭션이 있으면 참여 없으면 새로 시작
isolation = DEFAULT
rollbackFor = RuntimeException/Error 기본
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 컨트롤러에서 서비스로 처음 들어가는 상황이면 보통 트랜잭션이 없습니다. 그래서 새로 시작합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.3 현재 스레드에서 EntityManager 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 켜져 있다면 5단계에서 이미 EntityManager가 만들어져 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 스레드 &amp;rarr; TransactionSynchronizationManager &amp;rarr; EntityManagerHolder 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 @Transactional은 새 EntityManager를 만들지 않고 OSIV가 만들어 둔 EntityManager를 사용합니다. == OSIV가 만든 EntityManager &amp;rarr; @Transactional이 트랜잭션을 붙임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 OSIV가 꺼져있다면 이 시점에 EntityManager를 새로 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV OFF &amp;rarr; @Transactional 시작 &amp;rarr; EntityManager 생성 &amp;rarr; 트랜잭션 종료 시 EntityManager close&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.4 트랜잭션 시작&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JpaTransactionManager가 트랜잭션을 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JpaTransactionManager &amp;rarr; EntityManager에 트랜잭션 시작 요청 &amp;rarr; DB Connection 준비&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제부터 이 서비스 메서드 안에서는 DB 변경을 하나의 작업 단위로 묶을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service 시작 &amp;rarr; 여러 Repository 호출 &amp;rarr; 모두 성공하면 commit, 중간에 실패하면 rollback&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.5 DB Connection 획득&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션이 시작되면서 또는 실제 SQL이 필요한 시점에 HikariCP에서 커넥션을 가져옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EntityManger/Hibernate &amp;rarr; DataSource &amp;rarr; HikariCP &amp;rarr; DB Connection 획득&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 커넥션은 트랜잭션 동안 유지됩니다. 트랜잭션 시작 &amp;rarr; Conntection 획득 &amp;rarr; SELECT 여러 번 &amp;rarr; 트랜잭션 종료 &amp;rarr; Connection 반납&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional이 UseCase 전체에 붙어 있으면, 내부 Repository 호출 5개가 같은 트랜잭션과 같은 커넥션 흐름 안에서 실행될 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;트랜잭션 1개
Connection 1개
   ├─ users 조회
   ├─ folders 조회
   ├─ schedules 조회
   ├─ links 조회
   ├─ texts 조회
   └─ files 조회
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히는 커넥션 획득 시점은 설정과 실제 DB 접근 시점에 따라 약간 달라질 수 있지만 핵심은 트랜잭션 범위 동안 커넥션이 묶인다는 것입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.6 실제 서비스 메서드 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 프록시가 실제 서비스 메서드를 호출합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;TransactionInterceptor
   &amp;darr;
실제 PageSearchUseCaseImpl.search()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 코드가 실행됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Users user = usersService.findById(usersId);
List&amp;lt;FolderDto&amp;gt; folders = foldersService.searchFolders(user, keyword);
List&amp;lt;ScheduleDto&amp;gt; schedules = schedulesService.searchSchedules(user, keyword);
List&amp;lt;LinkDto&amp;gt; links = linksService.searchLinks(user, keyword);
List&amp;lt;TextDto&amp;gt; texts = textsService.searchTexts(user, keyword);
List&amp;lt;FileDto&amp;gt; files = attachMentsService.searchFiles(user, keyword);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 서비스 내부에서 Repository 호출이 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.7 Repository/JPA 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository는 인터페이스처럼 보이지만 실제로는 스프링 데이터 JPA 프록시입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;textsRepository.searchByTextContent(...)
   &amp;darr;
Repository Proxy
   &amp;darr;
SimpleJpaRepository 또는 Query 실행 로직
   &amp;darr;
EntityManager
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository는 EntityManager를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Repository
   &amp;darr;
EntityManager
   &amp;darr;
Hibernate Session
   &amp;darr;
JDBC Connection
   &amp;darr;
DB
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL or Query Method 해석은&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;List&amp;lt;Texts&amp;gt;findByContentContaining(String keyword);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA는 메서드 이름을 보고 쿼리를 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;findByContentContaining
   &amp;darr;
content like '%keyword%'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL을 직접 썼다면 Hibernate가 JPQL을 SQL로 바꿉니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;select t
from Texts t
where t.content like concat('%', :keyword, '%')
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQL은 대략 이렇게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;select t.id, t.content, t.users_id
from texts t
where t.contentlike ?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PreparedStatement 생성은 Hibernate는 JDBC를 통해 SQL을 실행합니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;SQL 생성
   &amp;darr;
PreparedStatement 생성
   &amp;darr;
파라미터 바인딩
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;? = &quot;%회의%&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB로 SQL 전송&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;JDBC Connection
   &amp;darr;
DB에 SQL 전송
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB가 ResultSet 반환 &amp;rarr; DB가 결과 row를 반환합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;ResultSet
   ├─ row 1
   ├─ row 2
   └─ row 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hibernate가 엔티티로 변환 &amp;rarr; Hibernate는 ResultSet을 엔티티 객체로 바꾸게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;ResultSet row
   &amp;darr;
Texts 엔티티 객체 생성
   &amp;darr;
필드 값 채움
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 하이드레이션이라고 보면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;DB row
&amp;rarr; Java Entity
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영속성 컨텍스트에 등록 &amp;rarr; 엔티티는 영속성 컨텍스트에 등록됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;영속성 컨텍스트
   ├─ EntityKey(Texts, 1) &amp;rarr; Texts 객체
   ├─ EntityKey(Texts, 2) &amp;rarr; Texts 객체
   └─ EntityKey(Texts, 3) &amp;rarr; Texts 객체
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 변경 감지를 위해 스냅샷도 저장합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;현재 엔티티 값
Texts(id=1, content=&quot;회의 자료&quot;)

스냅샷
Texts(id=1, content=&quot;회의 자료&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 flush 때 이 둘을 비교합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;현재 값 != 스냅샷
   &amp;darr;
UPDATE SQL 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LAZY 연관관계는 프록시로 둠 &amp;rarr; 엔티티에 지연 로딩 관계가 있으면 실제 객체를 바로 가져오지 않고 프록시를 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@ManyToOne(fetch=FetchType.LAZY)
privateUsersusers;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 후 객체 상태는 이런 형태입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Texts
   ├─ id = 1
   ├─ content = &quot;회의 자료&quot;
   └─ users = Users 프록시
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 text.getUsers().getName()을 호출하면 프록시 초기화가 일어납니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;text.getUsers().getName()
   &amp;darr;
Users 프록시 초기화
   &amp;darr;
SELECT users ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 영속성 컨텍스트가 살아 있어야 합니다. OSIV가 켜져 있으면 서비스 밖에서도 가능합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.8 DTO 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Repository가 엔티티를 반환하면 서비스에서 DTO로 꾸게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;List&amp;lt;TextResponse&amp;gt; result = texts.stream()
    .map(TextResponse::from)
    .toList();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 조심할 점이 있습니다. DTO 변환 중 LAZY 필드를 건드리면 추가 SQL이 나가게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public static TextResponse from(Texts text) {
    return new TextResponse(
        text.getId(),
        text.getContent(),
        text.getUsers().getName()
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;text.getUsers().getName() 때문에 Users 조회가 추가로 나갈 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Texts 100개 조회
   &amp;darr;
각 Text마다 users.getName()
   &amp;darr;
Users SELECT 100번
   &amp;darr;
N+1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 성능이 중요한 조회는 fetch join이나 DTO 직접 조회를 써야합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;select new TextResponse(t.id, t.content, u.name)
from Texts t
join t.users u
where t.content like :keyword
&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;9.9 실제 서비스 메서드 종료&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 메서드가 응답 DTO를 만들고 정상 종료합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;return new PageSearchResponse(folders, schedules, links, texts, files);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 다시 TransactionInterceptor로 제어가 돌아가게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;실제 Service 메서드 종료
   &amp;darr;
TransactionInterceptor로 복귀
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 트랜잭션을 끝낼 차례입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.10 정상 종료 시 flush&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션을 commit하기 전에 flush가 일어날 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;flush = 영속성 컨텍스트 변경 내용을 SQL로 DB에 보내는 것
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;flush 때 Hibernate는 변경 감지를 합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;영속성 컨텍스트 안의 엔티티
   &amp;darr;
현재 값과 스냅샷 비교
   &amp;darr;
다르면 UPDATE SQL 생성
   &amp;darr;
INSERT/UPDATE/DELETE SQL 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 전용이면 보통 변경이 없으므로 UPDATE는 없습니다. @Transactional(readOnly = true)면 Hibernate가 flush를 줄이거나 변경 감지 부담을 줄이는 최적화를 할 수 있ㅅ브니다&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;readOnly = true
   &amp;darr;
조회 전용으로 취급
   &amp;darr;
flush 최적화 가능
&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;9.11 commit&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;flush 후 문제가 없으면 commit 진행합니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;commit = 트랜잭션 안에서 실행된 SQL 결과를 DB에 최종 확정
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;flush
   &amp;darr;
SQL 실행
   &amp;darr;
commit
   &amp;darr;
DB 반영 확정
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;flush와 commit 차이는 다시 정리&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;flush
= SQL을 DB에 보냄
= 아직 최종 확정 아님

commit
= 트랜잭션 결과를 최종 확정
= rollback 불가 상태가 됨
&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;9.12 예외 발생 시 rollback&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 실행 중 RuntimeException이 발생하면 rollback합니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Transactional
public void logic() {
    member.setName(&quot;재윤&quot;);
    throw new RuntimeException();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;예외 발생
   &amp;darr;
TransactionInterceptor가 예외 감지
   &amp;darr;
rollback 판단
   &amp;darr;
rollback
   &amp;darr;
DB 변경 취소
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 Spring은 RuntimeException, Error에 대해 rollback합니다. Checked Exception은 기본적으로 rollback하지 않는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;9.13 Connection 반납&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션이 끝나면 DB 커넥션을 HikariCP에 반납하게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;commit 또는 rollback
   &amp;darr;
Connection 정리
   &amp;darr;
HikariCP에 반납
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 켜져 있어도 커넥션은 계속 잡고 있지 않는 것이 일반적입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;트랜잭션 종료
   &amp;darr;
Connection 반납
   &amp;darr;
EntityManager는 OSIV 때문에 계속 살아 있음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 서비스 트랜잭션이 끝난 뒤 상태는 이렇게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;EntityManager 있음
영속성 컨텍스트 있음
트랜잭션 없음
DB Connection 반납됨
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10단계 : 컨트롤러로 복귀&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스가 응답 객체를 반환하면 컨트롤러로 돌아오게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;PageSearchResponse response = pageSearchUseCase.search(usersId, keyword);
return ResponseEntity.ok(response);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점 상태는 다음과 같습니다,&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Controller로 복귀
   &amp;darr;
ResponseEntity 생성
   &amp;darr;
트랜잭션은 끝남
   &amp;darr;
Connection 반납됨
   &amp;darr;
EntityManager는 아직 살아 있음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV 때문에 영속성 컨텍스트는 아직 살아 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11단계 : 컨트롤러 반환값 처리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DispatcherServlet이 컨트롤러 반환값을 받게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;ResponseEntity&amp;lt;PageSearchResponse&amp;gt;
   &amp;darr;
HandlerMethodReturnValueHandler
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ResponseEntity니까 HTTP 상태 코드와 Body를 준비합니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;status = 200 OK
body = PageSearchResponse
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 HttpMessageConverter를 찾습니다. REST API라면 보통 Jackson이 선택된다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;MappingJackson2HttpMessageConverter
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12단계 : JSON 직렬화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson이 응답 객체를 JSON으로 바꾸게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;PageSearchResponse
   &amp;darr;
Jackson
   &amp;darr;
JSON 문자열
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO만 들어 있다면 보통 추가 쿼리가 없습니다,&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;DTO
   &amp;darr;
getter 호출
   &amp;darr;
단순 값 반환
   &amp;darr;
SQL 없음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 DTO 안에 엔티티가 들어 있거나 엔티티를 직접 반환하면 위험합니다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;return order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson이 getter를 호출하면서 lazy loading이 발생할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;Jackson
   &amp;darr;
order.getMember()
   &amp;darr;
Member 프록시 초기화
   &amp;darr;
EntityManager 살아 있음
   &amp;darr;
SELECT member ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 켜져 있기 때문에 JSON 직렬화 중에도 지연 로딩이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 OSIV의 장점이자 단점입니다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;장점:
LazyInitializationException이 덜 남

단점:
JSON 직렬화 중 예상치 못한 SQL 발생
N+1 문제를 숨김
성능 추적이 어려움
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 OSIV가 꺼져 있다면 이 시점에 EntityManager가 없으므로 lazy loading은 터지게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;OSIV OFF
   &amp;darr;
Jackson에서 lazy getter 호출
   &amp;darr;
EntityManager 없음
   &amp;darr;
LazyInitializationException
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13단계 : OSIV afterCompletion&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 직렬화까지 끝나면 DispatcherServlet의 마무리 과정에서 인터셉터 afterCompletion()이 실행됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;OpenEntityManagerInViewInterceptor.afterCompletion()
   &amp;darr;
현재 스레드에서 EntityManager unbind
   &amp;darr;
EntityManager.close()
   &amp;darr;
영속성 컨텍스트 종료
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 요청 동안 살아 있던 &amp;ldquo;상자&amp;rdquo;가 닫히게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;영속성 컨텍스트 close
   &amp;darr;
엔티티 관리 종료
   &amp;darr;
엔티티들은 준영속 상태
   &amp;darr;
참조 없으면 GC 대상
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점:&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;OSIV afterCompletion에서 commit하는 게 아님.
commit은 이미 서비스 @Transactional 종료 시점에 끝났음.
OSIV는 EntityManager를 닫는 역할.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉:&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;@Transactional
= 트랜잭션 commit/rollback 담당

OSIV
= 요청 끝에 EntityManager close 담당
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14단계 : 필터 체인으로 복귀&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DispatcherServlet이 끝나면 다시 필터 체인을 역순으로 빠져나가게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;DispatcherServlet 종료
   &amp;darr;
Security Filter 복귀
   &amp;darr;
CORS Filter 복귀
   &amp;darr;
CharacterEncodingFilter 복귀
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 마지막에 SecurityContext를 정리합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;SecurityContextHolder.clearContext()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 정리하냐면 톰캣 워커 스레드는 재사용되기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;요청 A: userId=1
   &amp;darr;
스레드 반납
   &amp;darr;
요청 B가 같은 스레드 사용 가능
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 SecurityContext를 안 지우면 요청 B에서 요청 A의 사용자 정보가 남을 수 있다. 그래서 요청 끝에 반드시 정리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;15단계 : 톰캣 응답 전송&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 톰캣이 HTTP 응답을 클라이언트에게 보내게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;HttpServletResponse
   &amp;darr;
HTTP Response
   &amp;darr;
TCP Socket
   &amp;darr;
Client
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 예시는 이런 식입니다.&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;HTTP/1.1 200 OK
Content-Type: application/json

{
  &quot;folders&quot;: [],
  &quot;schedules&quot;: [],
  &quot;links&quot;: [],
  &quot;texts&quot;: [],
  &quot;files&quot;: []
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 전송이 끝나면 톰캣 워커 스레드는 풀에 반납됩니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;요청 처리 완료
   &amp;darr;
ThreadLocal 정리 완료
   &amp;darr;
워커 스레드 반납
   &amp;darr;
다음 요청 대기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;전체 순서 요약&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 도착&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Tomcat
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;소켓에서 HTTP 요청 수신&lt;/li&gt;
&lt;li&gt;HttpServletRequest/Response 생성&lt;/li&gt;
&lt;li&gt;Worker Thread 배정&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Servlet Filter Chain
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;인코딩 처리&lt;/li&gt;
&lt;li&gt;CORS 처리&lt;/li&gt;
&lt;li&gt;Spring Security
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;JWT 검증&lt;/li&gt;
&lt;li&gt;SecurityContext 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;DispatcherServlet
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HandlerMapping&lt;/li&gt;
&lt;li&gt;컨트롤러 메서드 찾기&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;OSIV preHandle
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;EntityManager 생성&lt;/li&gt;
&lt;li&gt;영속성 컨텍스트 생성&lt;/li&gt;
&lt;li&gt;현재 스레드에 바인딩&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Controller
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;request parameter 바인딩&lt;/li&gt;
&lt;li&gt;SecurityContext에서 userId 조회&lt;/li&gt;
&lt;li&gt;Service/UseCase 호출&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;@Transactional AOP&lt;/li&gt;
&lt;li&gt;6.1 프록시가 호출 가로챔&lt;/li&gt;
&lt;li&gt;6.2 트랜잭션 설정 확인&lt;/li&gt;
&lt;li&gt;6.3 EntityManager 확인&lt;/li&gt;
&lt;li&gt;6.4 트랜잭션 시작&lt;/li&gt;
&lt;li&gt;6.5 Connection 획득&lt;/li&gt;
&lt;li&gt;6.6 실제 서비스 메서드 실행&lt;/li&gt;
&lt;li&gt;6.7 Repository/JPA 실행&lt;/li&gt;
&lt;li&gt;6.8 flush&lt;/li&gt;
&lt;li&gt;6.9 commit 또는 rollback&lt;/li&gt;
&lt;li&gt;6.10 Connection 반납&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Repository / JPA
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Repository Proxy&lt;/li&gt;
&lt;li&gt;EntityManager 사용&lt;/li&gt;
&lt;li&gt;JPQL/Query Method &amp;rarr; SQL&lt;/li&gt;
&lt;li&gt;SQL 실행&lt;/li&gt;
&lt;li&gt;ResultSet 수신&lt;/li&gt;
&lt;li&gt;엔티티 생성&lt;/li&gt;
&lt;li&gt;영속성 컨텍스트 등록&lt;/li&gt;
&lt;li&gt;스냅샷 저장&lt;/li&gt;
&lt;li&gt;LAZY 프록시 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Service
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;엔티티 &amp;rarr; DTO 변환&lt;/li&gt;
&lt;li&gt;응답 객체 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Controller 반환
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;ResponseEntity 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;JSON 직렬화
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Jackson&lt;/li&gt;
&lt;li&gt;DTO &amp;rarr; JSON&lt;/li&gt;
&lt;li&gt;엔티티 lazy 접근 시 추가 SQL 가능&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;OSIV afterCompletion]
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;EntityManager unbind&lt;/li&gt;
&lt;li&gt;EntityManager close&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Filter 정리
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;SecurityContext clear&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Tomcat
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HTTP 응답 전송&lt;/li&gt;
&lt;li&gt;Worker Thread 반납&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;UseCase에 @Transactional이 없으면 어떻게 바뀌나&lt;/h2&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;public PageSearchResponse search(Long usersId, String keyword) {
    usersService.findById(usersId);
    foldersService.searchFolders(...);
    schedulesService.searchSchedules(...);
    linksService.searchLinks(...);
    textsService.searchTexts(...);
    attachMentsService.searchFiles(...);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 큰 트랜잭션 하나가 없습니다. 대신 Repository나 하위 Service 메서드에 붙은 트랜잭션이 각각 동작할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;UseCase @Transactional 없음
 │
 ├─ usersRepository.findById()
 │    ├─ 짧은 readOnly 트랜잭션 시작
 │    ├─ SELECT
 │    ├─ commit
 │    └─ Connection 반납
 │
 ├─ foldersRepository.search()
 │    ├─ 짧은 readOnly 트랜잭션 시작
 │    ├─ SELECT
 │    ├─ commit
 │    └─ Connection 반납
 │
 ├─ schedulesRepository.search()
 │    └─ 같은 패턴
 │
 ├─ linksRepository.search()
 │    └─ 같은 패턴
 │
 ├─ textsRepository.search()
 │    └─ 같은 패턴
 │
 └─ filesRepository.search()
      └─ 같은 패턴
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 OSIV가 켜져 있으면 EntityManager는 요청 전체 동안 하나일 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;EntityManager 1개
   ├─ 짧은 트랜잭션 1
   ├─ 짧은 트랜잭션 2
   ├─ 짧은 트랜잭션 3
   ├─ 짧은 트랜잭션 4
   └─ 짧은 트랜잭션 5
&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;@Transactional 있음: UseCase 전체를 하나의 트랜잭션으로 묶음

@Transactional 없음: Repository/하위 Service 단위로 트랜잭션이 여러 번 생길 수 있음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;제일 중요한 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 요청 흐름에서 헷갈리면 이 네 개를 분리해서 보면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;1. 톰캣 스레드
   = 요청을 실행하는 주체

2. SecurityContext
   = 현재 로그인 사용자 정보

3. EntityManager / 영속성 컨텍스트
   = 엔티티를 담고 관리하는 상자
   = OSIV ON이면 요청 끝까지 살아 있음

4. @Transactional / 트랜잭션
   = DB 변경을 flush, commit, rollback하는 작업 단위
   = 보통 서비스 메서드 범위

5. DB Connection
   = 실제 DB와 통신하는 통로
   = 트랜잭션 중 실제 SQL 실행 시 잡고 끝나면 반납
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 문장으로 정리하면:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 오면 톰캣 스레드가 필터와 DispatcherServlet을 거쳐 컨트롤러를 호출하고, OSIV가 EntityManager를 요청 동안 열어두며, 서비스의 @Transactional 프록시가 그 EntityManager에 트랜잭션과 DB 커넥션을 붙여 Repository/JPA 작업을 실행하고, 서비스 종료 시 flush/commit 후 커넥션을 반납하고, JSON 직렬화까지 끝난 뒤 OSIV가 EntityManager를 닫는다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/418</guid>
      <comments>https://wo-dbs.tistory.com/418#entry418comment</comments>
      <pubDate>Thu, 2 Jul 2026 11:35:06 +0900</pubDate>
    </item>
    <item>
      <title>OSIV</title>
      <link>https://wo-dbs.tistory.com/417</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 트랜잭션 범위의 영속성 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션이 시작될 때 영속성 컨텍스트가 시작되고 트랜잭션이 끝날 때 영속성 컨텍스트도 끝난다.&lt;/li&gt;
&lt;li&gt;스프링에서는 보통 서비스 메서드에 @Transactional을 붙인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Transactional
public Order findOrder(Long id) {
    return orderRepository.findOrder(id);
}
&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1902&quot; data-origin-height=&quot;1278&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nd6QS/dJMcaa6ZakA/N0ZNtjGCF3kDZRfw6EJ6Tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nd6QS/dJMcaa6ZakA/N0ZNtjGCF3kDZRfw6EJ6Tk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nd6QS/dJMcaa6ZakA/N0ZNtjGCF3kDZRfw6EJ6Tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnd6QS%2FdJMcaa6ZakA%2FN0ZNtjGCF3kDZRfw6EJ6Tk%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;686&quot; height=&quot;461&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1902&quot; data-origin-height=&quot;1278&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 안에서는 엔티티가&lt;span style=&quot;background-color: #f6e199;&quot;&gt; &lt;b&gt;영속상태&lt;/b&gt; &lt;/span&gt;였는데, 서비스가 끝나고 트랜잭션이 종료되면 컨트롤러에 반환된 엔티티는 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;준영속 상태&lt;/b&gt;&lt;/span&gt;가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 흐름에서 지연로딩 시에 문제가 생김&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Order 엔티티가 있다고 가정해보자&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Entity
public class Order {

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LAZY는 처음 Order를 조회할 때 Member를 바로 조회하지 않는 전략이다. 대신 order.getMember()를 하면 실제 Member가 아니라 프록시 객체를 넣어둔다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;Order 조회
   │
   ├─ Order 실제 데이터 조회
   └─ Member는 실제 데이터가 아니라 프록시로 둠
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시는 쉽게 말하면 이런 느낌이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;ldquo;진짜 Member는 아직 안 가져왔고, 나중에 member.getName() 같은 실제 값이 필요하면 DB에서 조회할게.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;문제는 프록시가 DB에서 실제 데이터를 가져오려면 영속성 컨텍스트가 필요하다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시 초기화 필요 &amp;rarr; 영속성 컨텍스트 필요 &amp;rarr; DB 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;그런데 컨트롤러나 뷰 계층에서는 트랜잭션이 이미 끝났고 영속성 컨텍스트도 닫혔다&amp;hellip;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 프록시가 초기화 되지 못한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller/View &amp;rarr; order.getMember().getName() &amp;rarr; 프록시 초기화 시도 &amp;rarr; 영속성 컨텍스트 없음 &amp;rarr; LazyInitializationException&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결하기 위한 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 글로벌 FetchType.EAGER&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;LAZY를 EAGER로 바꾸는 것&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;@ManyToOne(fetch = FetchType.EAGER)
private Member member;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 했을 때 Order를 조회할 때 Member도 같이 조회한다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;Order 조회
   │
   └─ Member도 즉시 조회
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 컨트롤러에서 order.getMember().getName()을 호출해도 이미 Member가 로딩되어있기 때문에 LazyInitializationException은 안난다.&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;1. 사용하지 않는 엔티티까지 로딩한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면 A는 Order만 필요하다고 해보자&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면 A : Order만 필요&lt;/li&gt;
&lt;li&gt;화면 B : Order + Member 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 Order.member를 글로벌로 EAGER로 설정하면 화면 A에서도 Member를 항상 같이 조회함. 안 쓰는 데이터를 매번 가져옴&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;2. JPQL에서 N + 1 문제가 발생할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;em.find(Order.class, 1L)처럼 단건 조회할 때는 즉시 로딩이면 조인해서 가져올 수 있다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;select o.*, m.*
from orders o
left join member m on o.member_id = m.member_id
where o.id = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 괜찮아 보이지만 그런데 JPQL을 쓰면 문제가 생긴다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;List&amp;lt;Order&amp;gt; orders =
    em.createQuery(&quot;select o from Order o&quot;, Order.class)
      .getResultList();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JQPL은 기본적으로 JPQL 자체를 먼저 SQL로 번역한다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;select * from orders
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 Order.member가 EAGER니까 JPA는 각 Order의 Member를 추가로 조회해야한다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;select * from member where id = ?
select * from member where id = ?
select * from member where id = ?
select * from member where id = ?
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Order가 10개면,&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Order 목록 조회 SQL 1번
각 Order의 Member 조회 SQL 10번
총 11번
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 N + 1 문제라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 치명적인 이유는 데이터가 많아질 수록 SQL이 폭증하기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;Order 10개  &amp;rarr; SQL 11번
Order 100개 &amp;rarr; SQL 101번
Order 1000개 &amp;rarr; SQL 1001번
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 JPQL Fetch Join&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현실적인 대안이 fetch join이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 JPQL은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;select o
from Order o
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 Order 만 조회한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 fetch join을 쓰면 다음과 같이 진행된다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;select o
from Order o
join fetch o.member
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뜻은 &amp;rarr; Order를 조회할 때 연관된 Member도 SQL Join으로 같이 가져와라&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 SQL은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;select o.*, m.*
from orders o
join member m on o.member_id = m.member_id
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 Order와 Member를 한 번에 가져온다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Order 목록 조회 &amp;rarr; SQL Join 한번 &amp;rarr; Order + Member 모두 로딩&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 컨트롤러나 뷰에서 order.getMember().getName()을 호출해도 이미 Member가 로딩 되어있다. 그리고 N + 1도 줄일 수 있다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;기존:
Order 1번 조회 + Member N번 조회

fetch join:
Order와 Member를 JOIN으로 1번 조회
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;하지만 단점도 존재한다.&lt;/b&gt;&lt;/span&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면 A: order만 필요&lt;/li&gt;
&lt;li&gt;화면 B: order + member 필요&lt;/li&gt;
&lt;li&gt;화면 C: order + orderItems 필요&lt;/li&gt;
&lt;li&gt;화면 D: order + member + orderItems 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 Repository에 이런 메서드가 늘어날 수 있다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;findOrder()
findOrderWithMember()
findOrderWithOrderItems()
findOrderWithMemberAndOrderItems()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 화면 요구사항에 맞춘 조회 메서드가 계속 증가한다. &amp;rarr; 프레젠테이션 계층이 알게 모르게 데이터 접근 계층을 침범하는 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;쉽게 말하면 화면에서 필요한 데이터 모양 때문에 Repository 메서드가 화면 별로 계속 생김&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 이게 어느 정도는 필요하다고 볼 수 있다. 왜냐하면 성능 최적화는 결국 화면 별로 필요한 데이터가 다르기 떄문.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면 A는 order만 필요&lt;/li&gt;
&lt;li&gt;화면 B는 member까지 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘을 무조건 하나로 합치면 한쪽은 불필요한 데이터를 가져오고 너무 쪼개면 메서드가 많아진다. &amp;rarr; &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;무조건 일반화하지말고 적절한 선에서 타협점을 찾아야함&lt;/b&gt;&lt;/span&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;3.3 강제로 초기화 및 Facade 계층&lt;/h3&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Transactional
public Order findOrder(Long id) {
    Order order = orderRepository.findOrder(id);

    order.getMember().getName(); // 강제로 프록시 초기화

    return order;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 order.getMember()만 호출하면 안될 수도 있다는 점이다. order.getMember()는 프록시 객체만 반환할수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 초기화는 실제 값을 사용할 때 일어남 이렇게 해야 Member 프록시가 초기화된다. 흐름은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Service 트랜잭션 안 &amp;rarr; Order 조회 &amp;rarr; order.getMember().getName() &amp;rarr; Member 프록시 초기화 &amp;rarr; 트랜잭션 종료 &amp;rarr; Controller로 반환 &amp;rarr; 이미 Member가 로딩되어 있으므로 사용 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;but 단점도 있다. 서비스 계층이 프레젠테이션 계층을 의식하는 것임 즉, 서비스가 원래는 비즈니스 로직에 집중해야하는데 &amp;ldquo;뷰에서 member.name을 쓸 테니까 미리 초기화해야지&amp;rdquo; 이런 생각이 든다.&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;그래서 나온 게 Facade 계층&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade는 컨트롤러와 서비스 사이에 하나의 계층을 더 두는 방식이다. 원래 구조는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller &amp;rarr; Service &amp;rarr; Repository&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade를 넣으면 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller &amp;rarr; Facade &amp;rarr; Service &amp;rarr; Repository&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Facade의 역할은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프레젠테이션 계층과 서비스 계층 사이의 논리적 의존성 분리&lt;/li&gt;
&lt;li&gt;프레젠테이션 계층에서 필요한 엔티티 초기화&lt;/li&gt;
&lt;li&gt;서비스 계층 호출&lt;/li&gt;
&lt;li&gt;레포지토리 호출 조합&lt;/li&gt;
&lt;/ul&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 class=&quot;axapta&quot;&gt;&lt;code&gt;class OrderFacade {

    @Autowired
    OrderService orderService;

    public Order findOrder(Long id) {
        Order order = orderService.findOrder(id);

        // 프레젠테이션 계층이 필요한 프록시 객체를 강제로 초기화
        order.getMember().getName();

        return order;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 서비스는 비즈니스 로직에 집중&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class OrderService {

    public Order findOrder(Long id) {
        return orderRepository.findOrder(id);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 강제 초기화 코드를 서비스에서 빼고 Facade로 옮긴 것&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller는 Service를 직접 알 필요가 줄어든다.&lt;/li&gt;
&lt;li&gt;Service는 화면용 초기화 로직을 몰라도 된다.&lt;/li&gt;
&lt;li&gt;화면에 필요한 데이터 준비는 Facade가 담당한다.&lt;/li&gt;
&lt;li&gt;비즈니스 로직과 프레젠테이션용 로직을 분리할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 보면 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Controller&lt;/li&gt;
&lt;li&gt;OrderFacade
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션 시작&lt;/li&gt;
&lt;li&gt;Service 호출&lt;/li&gt;
&lt;li&gt;필요한 프록시 초기화&lt;/li&gt;
&lt;li&gt;엔티티 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Controller/View&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주의할 점은 Facade에서 프록시를 초기화하려면 Facade도 트랜잭션 범위 안에 있어야 한다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Transactional
public Order findOrder(Long id) {
    Order order = orderService.findOrder(id);
    order.getMember().getName();
    return order;
}
&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;단점 &amp;rarr; 계층 하나가 더 생긴다는 것 자체가 단점임 == 그래서 코드가 더 늘어남.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 준영속 상태와 지연 로딩 문제의 본질&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;3번에서 해결책을 보긴 했지만 문제의 본질은 엔티티가 프레젠테이션 계층에서 준영속 상태이기 때문에 발생하는 것&lt;/b&gt;&lt;/span&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;rarr; 그러면 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;영속성 컨텍스트를 뷰까지 열어두면 되지 않을까? 이게 바로 OSIV이다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. OSIV란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Open Session in View이며 Hibernate 용어로는 Session 이고 JPA에서는 EntityManager니까 정확히는 Open EntityManager in View라고 함.&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;뜻은 영속성 컨텍스트를 뷰까지 열어두는 것&lt;/b&gt;&lt;/span&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;h2 data-ke-size=&quot;size26&quot;&gt;6. 과거 OSIV : 요청 당 트랜잭션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거 방식의 OSIV를 보자&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;image.png&quot; data-origin-width=&quot;1704&quot; data-origin-height=&quot;1506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcHcXy/dJMcadWSAk6/vk6CtuuxykEQvKFsBiSJxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcHcXy/dJMcadWSAk6/vk6CtuuxykEQvKFsBiSJxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcHcXy/dJMcadWSAk6/vk6CtuuxykEQvKFsBiSJxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcHcXy%2FdJMcadWSAk6%2Fvk6CtuuxykEQvKFsBiSJxk%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;644&quot; height=&quot;569&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1704&quot; data-origin-height=&quot;1506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 장점은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 전체에서 영속성 컨텍스트가 살아 있음&lt;/li&gt;
&lt;li&gt;뷰에서도 지연 로딩 가능&lt;/li&gt;
&lt;li&gt;엔티티는 요청 끝까지 영속 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음과 같은 것들이 가능하다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Member member = memberService.getMember(id);
model.addAttribute(&quot;member&quot;, member);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뷰에서 member.orders 같은 지연 로딩 컬렉션을 접근해도 가능함.&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;하지만 치명적인 문제가 있었다.&lt;/b&gt;&lt;/span&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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에서 단순히 화면 표시용으로 이름을 바꾼다고 가정&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Member member = memberService.getMember(id);
member.setName(&quot;XXX&quot;); // 화면에만 XXX로 보여주고 싶음
model.addAttribute(&quot;member&quot;, member);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자의 의도는 DB를 바꾸려는 게 아니라 화면에만 이름을 XXX로 보여주고 싶다라는 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 요청 끝에서 트랜잭션이 커밋됨 커밋 전에 Flush가 일어나고 변경 감지가 동작함.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;member.setName(&quot;XXX&quot;)&lt;/li&gt;
&lt;li&gt;영속 상태 엔티티 변경됨&lt;/li&gt;
&lt;li&gt;요청 끝&lt;/li&gt;
&lt;li&gt;flush&lt;/li&gt;
&lt;li&gt;dirty checking&lt;/li&gt;
&lt;li&gt;update member set name='XXX'&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과 적으로 DB이름까지 XXX로 바뀔 수 있음 그래서 과거 OSIV의 가장 큰 문제는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;&amp;rarr; 프레젠테이션 계층에서 엔티티를 수정했는데 그 수정이 DB에 반영될 수 있다.&lt;/b&gt;&lt;/span&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;h3 data-ke-size=&quot;size23&quot;&gt;6.1 읽기 전용 인터페이스 제공&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;interface MemberView {
    String getName();
}

@Entity
class Member implements MemberView {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에는 Member가 아니라 MemberView 타입으로 넘긴다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;public MemberView getMember(Long id) {
    return memberRepository.findById(id);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 프레젠테이션 계층에서는 getName()만 보이고 setName()은 못 쓴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Member 타입이면: getName() 가능, setName() 가능&lt;/li&gt;
&lt;li&gt;MemberView 타입이면: getName() 가능, setName() 불가능&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;단점은 인터페이스를 따로 설계해야해서 완전히 깔끔한 해결은 아니다.&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;6.2 엔티티 래핑&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티를 직접 넘기지 않고 읽기 메서드만 가진 래퍼 객체로 감싼다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;class MemberWrapper {

    private Member member;

    public MemberWrapper(Member member) {
        this.member = member;
    }

    public String getName() {
        return member.getName();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에는 MemberWrapper를 넘긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 외부에서는 getName()만 호출할 수 있고, setName()은 호출할 수 없다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MemberWrapper
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;내부에는 member를 가지고 있음 하지만 읽기 메서드만 외부에 제공&lt;/li&gt;
&lt;/ul&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;단점은 래퍼 클래스를 계속 만들어야해서 번거롭다.&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;6.3 DTO만 반환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 전통적이고 실무에서 많이 쓰는 방식&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;class MemberWrapper {

    private Member member;

    public MemberWrapper(Member member) {
        this.member = member;
    }

    public String getName() {
        return member.getName();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스나 컨트롤러에서 엔티티를 DTO로 변환해서 변환한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Member member = memberService.getMember(id);

MemberDTO dto = new MemberDTO(member.getName());

return dto;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO는 엔티티가 아니기 때문에 영속성 컨텍스트가 관리하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 DTO 값을 바꿔도 DB 변경 감지가 일어나지 않는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Entity 수정 &amp;rarr; 영속성 컨텍스트가 관리 중이면 DB 반영 가능&lt;/li&gt;
&lt;li&gt;DTO 수정 &amp;rarr; 그냥 일반 객체 수정, DB와 무관&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점은 코드량이 늘어난다는 것&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;MemberDTO
OrderDTO
OrderDetailDTO
OrderWithItemsDTO
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 API 서버에서는 보통 이 방식이 가장 안전함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 스프링 OSIV: 비즈니스 계층 트랜잭션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거 OSIV는 요청 전체가 트랜잭션이라 위험했음 그래서 스프링이 제공하는 OSIV는 조금 다르게 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;&amp;rarr; 영속성 컨텍스트는 요청 끝까지 유지하지만, 트랜잭션은 비즈니스 계층에서만 사용한다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 서비스 계층에서 @Transactional이 붙은 메서드가 실행될 때만 트랜잭션을 시작한다.&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;image.png&quot; data-origin-width=&quot;1936&quot; data-origin-height=&quot;1480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WQGBG/dJMcacqbQY4/a0iFcykGBFnIt6ozBdRZPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WQGBG/dJMcacqbQY4/a0iFcykGBFnIt6ozBdRZPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WQGBG/dJMcacqbQY4/a0iFcykGBFnIt6ozBdRZPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWQGBG%2FdJMcacqbQY4%2Fa0iFcykGBFnIt6ozBdRZPk%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;615&quot; height=&quot;470&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1936&quot; data-origin-height=&quot;1480&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;974&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSI4MD/dJMcaf1qMyr/X11pozS309KFuwJL1PHt70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSI4MD/dJMcaf1qMyr/X11pozS309KFuwJL1PHt70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSI4MD/dJMcaf1qMyr/X11pozS309KFuwJL1PHt70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSI4MD%2FdJMcaf1qMyr%2FX11pozS309KFuwJL1PHt70%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;562&quot; height=&quot;444&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1232&quot; data-origin-height=&quot;974&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 영속성 컨텍스트는 요청 시작 부터 끝까지 살아있는 거고 트랜잭션이 Service 시작 후와 Service 종료까지만 살아있는데 트랜잭션 역할을 좀 더 봐보자 영속성 컨텍스트와 트랜잭션의 역할을 나누어보자&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;영속성 컨텍스트 = JPA가 엔티티를 관리하는 메모리 공간&lt;/li&gt;
&lt;li&gt;트랜잭션 = DB에 변경 내용을 안전하게 반영하거나 취소하는 작업 단위&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영속성 컨텍스트의 역할&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JPA의 작업장이라고 말할 수 있으며
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조회한 엔티티, 1차 캐시, 변경 감지용 스냅샷, 프록시 객체, 지연 로딩에 필요한 연결 정보&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트른잭션의 역할&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB에 실제로 반영할지 말지를 결정하는 경계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 서비스를&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void changeName(Long id) {
    Member member = memberRepository.findById(id);
    member.setName(&quot;재윤&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에서 save()가 없어도 DB에 반영이 가능함 이유는 트랜잭션 커밋 시점에 다음과 같은 일이 일어남&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;@Transactional 시작&lt;/li&gt;
&lt;li&gt;DB 조회&lt;/li&gt;
&lt;li&gt;엔티티 영속 상태&lt;/li&gt;
&lt;li&gt;member.setName(&amp;rdquo;재윤&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;@Transactional 종료&lt;/li&gt;
&lt;li&gt;flush&lt;/li&gt;
&lt;li&gt;변경 감지&lt;/li&gt;
&lt;li&gt;UPDATE SQL 실행&lt;/li&gt;
&lt;li&gt;commit&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 트랜잭션의 역할은 이것임.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 이 작업 단위를 시작한다.
2. 변경 내용을 DB에 반영할 수 있는 상태로 만든다.
3. 커밋 시점에 flush를 통해 SQL을 실행한다.
4. 성공하면 commit한다.
5. 예외가 나면 rollback한다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 영속성 컨텍스트는 트랜잭션 밖에서도 엔티티를 들고 있을 수 있지만, DB에 변경을 반영하는 flush/commit은 트랜잭션 안에서만 정상적으로 동작하도록 설계되어 있다.&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;스프링 OSVI의 핵심 특징을 보자&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트 요청이 들어올 때 영속성 컨텍스트를 생성한다.&lt;/li&gt;
&lt;li&gt;요청이 끝날 때까지 같은 영속성 컨텍스트를 유지한다.&lt;/li&gt;
&lt;li&gt;한 번 조회한 엔티티는 요청이 끝날 때까지 영속 상태를 유지한다.&lt;/li&gt;
&lt;li&gt;엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다.&lt;/li&gt;
&lt;li&gt;트랜잭션이 없는 프레젠테이션 계층은 지연 로딩을 포함한 조회만 가능하다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 문장 &amp;rarr; 엔티티 수정은 트랜잭션이 있는 계층에서만 동작한다. 트랜잭션이 없는 프레젠테이션 계층은 징녀 로딩을 포함해서 조회만 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 컨트롤러에서 지연 로딩은 가능함.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;Stringname=order.getMember().getName();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 컨트롤러에서 수정해도 바로 DB에 반영되지는 않음&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;member.setName(&quot;XXX&quot;);
&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;그러면 스프링 OSIV에서 트랜잭션 없이 읽기를 보자&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;영속성 컨텍스트를 통한 모든 변경은 트랜잭션 안에서 이루어져야한다. 그런데 단순 조회는 트랜잭션 없이도 가능함. 이것을 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Nontransactional reads 즉, 트랜잭션 없이 읽기라고 함&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 있음: 조회 가능, 수정 가능, flush 가능, commit 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 없음: 조회 가능, 지연 로딩 가능, 수정은 DB 반영 불가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 OSIV에서는 컨트롤러/뷰 계층에 트랜잭션은 없지만 영속성 컨텍스트는 살아있다. 그래서 지연로딩이 가능함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 스프링 OSIV 주의 사항 : 수정 후 트랜잭션 서비스 호출&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 중요한 부분&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 OSIV는 프레젠테이션 계층에서 수정해도 DB에 반영되지 않는다.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;Member member = memberService.getMember(id);
member.setName(&quot;XXX&quot;);
return &quot;view&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것만 있으면 DB에 반영되지 않는다 왜냐하면 컨트롤러에는 트랜잭션이 없고 요청 끝에서 OSIV는 flush()를 호출하지 않고 em.close()만 하기 때문&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 문제가 되는 경우가 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Member member = memberService.getMember(id);
member.setName(&quot;XXX&quot;);

memberService.biz(); // @Transactional
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름을 보면&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;요청 시작 &amp;rarr; 영속성 컨텍스트 생성&lt;/li&gt;
&lt;li&gt;memberService.getMember(id)
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;트랜잭션 안에서 Member 조회&lt;/li&gt;
&lt;li&gt;트랜잭션 종료&lt;/li&gt;
&lt;li&gt;영속성 컨텍스트는 살아 있음&lt;/li&gt;
&lt;li&gt;Member는 계속 영속 상태&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;컨트롤러에서 member.setName(&quot;XXX&quot;) &amp;rarr; 트랜잭션은 없지만 영속 상태 엔티티 값이 바뀜&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;http://memberService.biz&quot;&gt;memberService.biz&lt;/a&gt;() 호출 &amp;rarr; @Transactional 때문에 트랜잭션 시작 &amp;rarr; 기존 영속성 컨텍스트에 트랜잭션이 붙음&lt;/li&gt;
&lt;li&gt;biz() 종료 &amp;rarr; 트랜잭션 커밋, flush 발생, 변경 감지 동작, 컨트롤러에서 바꾼 XXX도 DB에 반영됨&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;문제는 &amp;rarr; OSIV에서는 같은 요청 안에서 하나의 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 컨트롤러에서 바꿔둔 엔티틱밧이 나중에 실행된 트랜잭션에서 flush 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;이 해결 방법은 트랜잭션이 있는 비즈니스 로직을 모두 먼저 호출하고 그 다음에 엔티티를 변경해야한다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. OSIV에서 Repository 직접 호출&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 계층을 엄격하게 나누면 Controller는 Service만 호출하고 Repository는 직접 호출하지 않는 게 일반적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 OSIV를 쓰면 영속성 컨텍스트가 컨트롤러까지 살아있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 단순 조회하면 컨트롤러에서 Repository를 직접 호출해도 기술적으로 문제가 없다고 한다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;class OrderController {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    public String order(Order order, Model model) {
        Long id = orderService.order(order); // 상품 구매 비즈니스 로직

        Order orderResult = orderRepository.findOne(id); // 단순 조회
        model.addAttribute(&quot;order&quot;, orderResult);

        return &quot;view&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OSIV 사용 전 : 프레젠테이션 계층에서 사용할 지연 로딩 엔티티를 미리 초기화해야했다. 초기화는 서비스나 Facade가 담당했다.&lt;/li&gt;
&lt;li&gt;OSIV 사용 후 : 영속성 컨텍스트가 프레젠테이션 계층까지 살아 있으므로 단순 엔티티 조회는 컨트롤러에서 Repository를 직접 호출해도 동작한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 조심해야할 부분은 계층형 아키텍처 관점에서는 Controller가 Repository를 직접 아는 게 별로 좋지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 너무 엄격하게 Controller는 무조건 Service만 호출해야한다라고 하면 오히려 단순 조회 코드도 불필요하게 복잡해질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 비즈니스 로직은 Service에서 처리하고 단순 조회는 유연하게 볼 수 있다는 관점&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. Spring OSIV의 단점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크게 3가지가 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.1 같은 영속성 컨텍스트를 여러 트랜잭션이 공유할 수 있다.&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 하나
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;영속성 컨텍스트 하나&lt;/li&gt;
&lt;li&gt;트랜잭션 A&lt;/li&gt;
&lt;li&gt;컨트롤러에서 엔티티 수정&lt;/li&gt;
&lt;li&gt;트랜잭션 B&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 영속성 컨텍스트 안에서 엔티티 상태가 유지 되기 때문에 나중 트랜잭션에서 예상치 못한 변경이 flush 될 수 있음&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.2 프레젠테이션 계층에서 수정 후 비즈니스 로직 호출하면 수정될 수 있다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 예제로 본 내용&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;member.setName(&quot;XXX&quot;);
memberService.biz();// @Transactional
&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;memberService.biz();// 먼저 트랜잭션 비즈니스 로직

member.setName(&quot;XXX&quot;);// 마지막 화면용 변경
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 제일 좋은 것은&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;컨트롤러에서 엔티티를 수정하지 않는다.
DTO로 변환해서 화면/API에 넘긴다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.3 프레젠테이션 계층에서 지연 로딩 SQL이 실행된다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV가 켜져 있으면 컨트롤러나 뷰에서도 지연 로딩이 가능함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;그런데 이 말은 반대로 컨트롤러나 JSON 직렬화 중에도 SQL이 나갈 수 있다.는 뜻이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어:&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;return order;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson이 JSON으로 변환하면서 getter를 호출&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;getMember()
getOrderItems()
getDelivery()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 지연 로딩이 터질 수 있어.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;JSON 직렬화 -&amp;gt; getMember() -&amp;gt; SQL 실행
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&quot;왜 이 API가 느리지?&quot; -&amp;gt; Service 쿼리는 1개인데, JSON 직렬화 중 Lazy Loading으로 쿼리 수십 개 발생
&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;h2 data-ke-size=&quot;size26&quot;&gt;11. OSIV vs Facade vs DTO&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV를 쓰지 않는 대안으로 Facade나 DTO를 말한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.1 OSIV 사용&lt;/h3&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;장점:
뷰/컨트롤러에서 지연 로딩 가능
엔티티 그래프 탐색이 편함
코드가 줄어듦

단점:
쿼리 발생 위치가 불명확
프레젠테이션 계층에서 엔티티 수정 위험
성능 튜닝 지점이 흐려짐
원격 클라이언트에는 적용 불가
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.2 Facade 사용&lt;/h3&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;장점:
서비스와 프레젠테이션 계층 분리
필요한 엔티티를 트랜잭션 안에서 초기화 가능

단점:
계층 하나 추가
위임 코드 증가
화면별 초기화 코드 증가
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.3 DTO 사용&lt;/h3&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;장점:
API 응답 구조 명확
엔티티 노출 방지
프레젠테이션 계층에서 수정해도 DB와 무관
성능 최적화 쿼리와 응답 모델을 분리 가능

단점:
DTO 클래스와 변환 코드 증가
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 방법을 쓰든 OSIV를 사용하는 것과 비교해서 지루한 코드를 많이 작성해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, OSIV는 편하다. 하지만 편한 만큼 조심해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. OSIV를 사용하는 방법이 항상 좋은 건 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 화면에서는 OSIV가 오히려 효과적이지 않을 수 있다라는 것 예를 들어 관리자 통계 화면처럼 여러 테이블을 조인하고 집계해야 하는 경우&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;회원 수
주문 수
매출 합계
카테고리별 판매량
일자별 통계
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 엔티티 그래프 탐색으로 만들면 비효율적일 수 있다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;List&amp;lt;Order&amp;gt;orders=orderRepository.findAll();

for (Orderorder :orders) {
		order.getMember().getName();
		order.getOrderItems().size();
		order.getDelivery().getAddress();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 지연 로딩이 계속 발생하고 N+1 문제가 생길 수 있음. 이런 경우에는 처음부터 필요한 데이터를 JPQL로 조회해서 DTO로 받는 게 낫다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;select new OrderStatsDto(
    o.id,
    m.name,
    sum(oi.price)
)
from Order o
join o.member m
join o.orderItems oi
group by o.id, m.name
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 복잡한 조회 화면은 이게 낫다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;엔티티 조회 후 그래프 탐색 보다 처음부터 DTO 조회
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. Spring Boot 기준으로 이해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot + JPA에서는 보통 OSIV가 기본으로 켜져 있는 경우가 많음&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;spring.jpa.open-in-view=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태에서는 이런 일이 가능&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@GetMapping(&quot;/orders/{id}&quot;)
publicOrderResponsegetOrder(@PathVariable Long id) {
	Order order=orderService.findOrder(id);
	
	// 서비스 트랜잭션은 끝났지만 OSIV 때문에 지연 로딩 가능
	String memberName=order.getMember().getName();
	
	return new OrderResponse(order.getId(),memberName);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 API 서버에서는 보통 이렇게 하는 게 더 안전&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;spring.jpa.open-in-view=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 필요한 데이터를 서비스/리포지토리에서 명확히 가져온다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;@Transactional(readOnly=true)
public OrderResponsegetOrder(Long id) {
	Order order=orderRepository.findOrderWithMember(id);
	
	return new OrderResponse(
			order.getId(),
			order.getMember().getName()
	   );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 아예 DTO로 조회한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public OrderResponse findOrderDto(Long id) {
    return em.createQuery(
        &quot;select new com.example.OrderResponse(o.id, m.name) &quot; +
        &quot;from Order o join o.member m &quot; +
        &quot;where o.id = :id&quot;,
        OrderResponse.class
    ).setParameter(&quot;id&quot;, id)
     .getSingleResult();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;14. 결론&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OSIV는 영속성 컨텍스트를 요청 끝까지 열어두어 컨트롤러/뷰에서도 지연 로딩을 가능하게 하지만 그만큼 쿼리 발생 위치와 엔티티 수정 위험을 조심해야 하는 전략이다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/417</guid>
      <comments>https://wo-dbs.tistory.com/417#entry417comment</comments>
      <pubDate>Thu, 2 Jul 2026 11:31:30 +0900</pubDate>
    </item>
    <item>
      <title>톰캣</title>
      <link>https://wo-dbs.tistory.com/416</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣은 한 마디로 설명 &amp;rarr; 자바 웹 애플리케이션을 실행시켜주는 서블릿 컨테이너이자 WAS&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;Spring boot로 API 서버를 만들면 보통 우리가 직접 톰캣을 설치하지 않아도 된다. 내부적으로 내장 톰캣이 떠서 HTTP 요청을 받고 그 요청을 Spring MVC로 넘겨주게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 톰캣이 무엇인가?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;톰캣, 정확히는 Apache Tomcat은 자바 기반 웹 애플리케이션을 실행하기 위한 서버이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할을 나누어 보면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;클라이언트
   &amp;darr; HTTP 요청
Tomcat
   &amp;darr;
Servlet / Spring MVC
   &amp;darr;
Service
   &amp;darr;
Repository / DB
&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;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;GET /api/members/1 HTTP/1.1
Host: example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 요청을 가장 먼저 받는 게 톰캣이다. 톰캣은 이 요청을 분석해서 Spring MVC 쪽으로 넘겨준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 톰캣은 웹 서버인가? WAS인가?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;톰캣에 대해 알기 전에 우선 서블릿과 서블릿 컨테이너부터 공부해보자&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 서블릿을 우선 공부해보자&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서브릿은 HTTP 요청을 자바 코드로 처리하기 위한 표준 객체&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 서블릿이 왜 필요했냐?&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저 요청은 원래 다음과 같은 문자열입니다. 브라우저가 /hello로 요청하면 실제로는 대충 다음과 같은 HTTP 메시지가 날아옴&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;GET /hello HTTP/1.1
Host: localhost:8080
User-Agent: Chrome
Accept: text/html
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 그냥 네트워크를 타고 온 문자열 데이터라. 자바 입장에서는 원래 다음과 같은 걸 직접해야함.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 소켓 열기
2. 포트에서 요청 기다리기
3. HTTP 문자열 읽기
4. GET인지 POST인지 파싱하기
5. URL이 /hello인지 확인하기
6. 헤더 읽기
7. 바디 읽기
8. 응답 형식 맞춰서 직접 작성하기
9. 클라이언트에게 다시 보내기
&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;서블릿은 이 복잡한 HTTP 처리를 자바 객체 방식으로 바꿔주게 된다. 즉, 이 서블릿을 쓰면 개발자는 HTTP 문자열을 직접 파싱하지 않아도 된다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;protected void doGet(HttpServletRequest request,
                     HttpServletResponse response) throws IOException {
    response.getWriter().write(&quot;hello&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 것은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HttpServletRequest = HTTP 요청을 자바 객체로 표현한 것&lt;/li&gt;
&lt;li&gt;HttpServletResponse = HTTP 응답을 자바 객체로 표현한 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 서블릿은 HTTP 세계와 자바 코드 사이의 표준 연결 규칙이다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HTTP 요청 문자열&lt;/li&gt;
&lt;li&gt;Tomcat이 파싱&lt;/li&gt;
&lt;li&gt;HttpServletRequest 객체&lt;/li&gt;
&lt;li&gt;Servlet의 doGet(), doPost() 호출&lt;/li&gt;
&lt;li&gt;HttpServletResponse에 응답 작성&lt;/li&gt;
&lt;li&gt;HTTP 응답으로 변환&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 표준 객체이어야함은 서블릿이 표준이라는 것이 중요한데 만약 이 표준이 없으면 서버마다 요청 처리 방식이 다를 수 있다. 예를 들어&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat에서는 handleRequest()&lt;/li&gt;
&lt;li&gt;Jetty에서는 process()&lt;/li&gt;
&lt;li&gt;Undertow에서는 execute()&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이런 식이면 서버를 바꿀 때 마다 코드를 바꿔야함. 그런데 서블릿 표준이 있으니 개발자는 위와 같이 만들기만 하면 된다. 그래서 Tomcat이든 Jetty든 Undertow든 서블릿 규칙을 아는 서버는 위 코드를 실행할 수 있다.&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;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;즉 Servlet = Java 웹 서버와 java 웹 애플리케이션 사이의 공통 약속&lt;/b&gt;&lt;/span&gt;&lt;/p&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&gt;예전에는 다음과 같이 컨트롤러 대신 직접 만들었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public class HelloServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request,
                         HttpServletResponse response) throws IOException {
        response.getWriter().write(&quot;hello&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 /hello로 요청하면 이 doGet()이 실행되는데 문제는 이 서블릿을 누가 실행하냐 라는 것&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 요청 &amp;rarr; HelloServlet의 doGet() 실행&lt;/li&gt;
&lt;/ul&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 서블릿 컨테이너가 하는 일&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서브릿 컨테이너는 다음과 같은 일을 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HTTP 요청을 받음&lt;/li&gt;
&lt;li&gt;HttpServletRequest, HttpServletResponse 객체를 만듦&lt;/li&gt;
&lt;li&gt;어떤 서블릿이 처리할 요청인지 찾음&lt;/li&gt;
&lt;li&gt;해당 서블릿의 service(), doGet(), doPost() 등을 호출함&lt;/li&gt;
&lt;li&gt;응답을 클라이언트에게 돌려줌&lt;/li&gt;
&lt;li&gt;서블릿의 생성, 초기화, 종료까지 관리함&lt;/li&gt;
&lt;/ol&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;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /hello
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 흐름은 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저 &amp;rarr; Tomcat &amp;rarr; HelloServlet &amp;rarr; 응답&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 자세히 보면&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;브라우저가 GET /hello 요청&lt;/li&gt;
&lt;li&gt;Tomcat이 요청을 받음&lt;/li&gt;
&lt;li&gt;Tomcat이 HttpServletRequest 객체 생성&lt;/li&gt;
&lt;li&gt;Tomcat이 HttpServletResponse 객체 생성&lt;/li&gt;
&lt;li&gt;/hello를 처리할 서블릿을 찾음&lt;/li&gt;
&lt;li&gt;HelloServlet의 doGet() 호출&lt;/li&gt;
&lt;li&gt;response에 &quot;hello&quot; 작성&lt;/li&gt;
&lt;li&gt;Tomcat이 HTTP 응답으로 변환해서 브라우저에 전달&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 Spring에서는 어떻게 연결되냐?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC에서는 우리가 직접 HelloServelt 같은 걸 만들지 않는다. 대신 Controller를 만든다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
public class HelloController {

    @GetMapping(&quot;/hello&quot;)
    public String hello() {
        return &quot;hello&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이것도 내부적으로는 서블릿 컨테이너 위에서 동작한다. Spring MVC에서는 핵심 서블릿이 하나 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DispatcherServlet&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣은 모든 요청을 이 DispatcherServlet에게 넘긴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저 &amp;rarr; Tomcat, 서블릿 컨테이너 &amp;rarr; DispatcherServlet &amp;rarr; Controller &amp;rarr; Service &amp;rarr; Repository&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Spring에서 서블릿 컨테이너는 DispatcherServelet을 실행시켜주는 역할을 한다.&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;2.4 웹 서버&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;웹 서버는 주로 정적 리소스를 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 HTML, CSS, JavaScript, 이미지, 동영상&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 웹 서버는 Nginx, Apache HTTP Server가 있다.&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;2.5 WAS&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WAS는 동적인 요청을 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 로그인, 회원가입, 게시글 작성, 주문 처리, 결제 처리, DB 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 요청은 파일을 내려주는 게 아니라, 서버 내부 로직을 실행해야 한다. 대표적인 WAS는 Tomcat, Jetty, Undertow, Wildfly, WebLogic, WebSphere가 있다.&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;rarr; 웹 서버 없이도 WAS만으로도 서비스는 가능한가?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가능하다. Spring boot 내장 Tomcat만 띄워도 다음과 같이 동작할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client &amp;rarr; Spring boot 내장 Tomcat &amp;rarr; Controller &amp;rarr; Service &amp;rarr; DB&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 로컬 개발할 때 우리가 &lt;a href=&quot;http://localhost:8080&quot;&gt;localhost:8080&lt;/a&gt;을 쓰는데 이때 앞에 Nginx 같은 웹 서버 없이도 잘 동작함. 왜냐하면 Tomcat도 HTTP 요청을 받을 수 있기 때문임.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WAS만으로도 가능하지만, 운영 화경에서는 WAS가 비즈니스 로직에 집중하도록 앞에 웹 서버를 두는 경우가 많다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 파일 처리는 웹 서버가 훨씬 잘한다는 것 HTML, CSS, JS, 이미지, 동영상, 폰트 같은 것들 Tomcat도 이런 파일을 내려줄 수 있지만 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Nginx 같은 웹 서버가 정적 파일 처리에 더 특화되어있다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 이미지 파일을 요청했다고 한다면&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /images/logo.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 Tomcat이 처리하면 Tomcat의 스레드, 메모리, 네트워크 자원을 사용한다. 그런데 Tomcat은 원래 이런걸 위해 존재하는 것이 아니라 보통 다음과 같은 요청을 처리하려고 있는 것&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로그인, 회원가입, 게시글 작성, DB 조회, 결제, 검색&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 정적 파일은 Nginx가 처리하고 동적 요청만 Tomcat으로 넘기는 구조를 많이 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;리버스 프록시 역할&lt;/b&gt;&lt;/span&gt;을 한다. 운영에서는 Nginx가 앞에서 요청을 받고 WAS로 넘겨주는 구조인데 이 구조에서 Nginx는 일종의 문지기 역할을 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;외부 요청 받기, 내부 WAS 전달, 요청 경로 분기, 헤더 추가, 접근 제한&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;/api/*       &amp;rarr; Spring Boot 서버
/admin/*     &amp;rarr; 관리자 서버
/images/*    &amp;rarr; 정적 파일 서버
&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;HTTP 처리를 앞단에서 하기 편하다.&lt;/b&gt;&lt;/span&gt; WAS만 써도 HTTPS 설정 가능하다. 하지만 운영에서는 보통 Nginx나 로드밸런서에서 HTTPS를 처리하고, 내부에서는 HTTP로 WAS에 전달한다.&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;이렇게 하였을 때 인증서 관리, TLS 설정, 갱신을 한 곳어세 처리하기 편하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;로드밸런싱이 쉽다&lt;/b&gt;.&lt;/span&gt; WAS 서버가 여러 대일 때를 생각해보자.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring boot 서버 1, Spring boot 서버 2, Spring boot 서버 3&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NGINX나 로드밸런서를 앞에 두면 요청을 여러 WAS로 나눠줄 수 이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;client
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NGINX
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat1&lt;/li&gt;
&lt;li&gt;tomcat2&lt;/li&gt;
&lt;li&gt;tomcat3&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAS만 직접 노출하면 이런 분산 처리를 직접 구성하기가 더 번거롭다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, &lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;WAS를 보호한다&lt;/span&gt;.&lt;/b&gt; Tomcat은 비즈니스 로직을 실행하는 핵심 서버이다 그래서 가능하면 외부에 직접 노출하지 않고 앞에 NGINX나 로드밸런서를 두는 경우가 많다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인터넷 &amp;rarr; NGINX or Load Balancer &amp;rarr; 내부망 Tomcat&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 Tomcat은 내부에서만 접근 가능하게 만들 수 있다. 또 NGINX에서 요청 크기 제한, 특정 IP 차단, Rate Limit, 잘못된 요청 필터링, 정적 파일 캐싱, 압축&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한,&lt;span style=&quot;background-color: #f6e199;&quot;&gt; &lt;b&gt;장애 대응과 배포에도 유리하다.&lt;/b&gt;&lt;/span&gt; WAS를 재배포를 해야한다고 할 때 NGINX 앞단에서 트래픽을 조절하면 특정 WAS를 잠시 뺴고 배포할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Nginx
  ├── Tomcat 1  &amp;larr; 배포 중이라 잠시 제외
  └── Tomcat 2  &amp;larr; 계속 요청 처리
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런식으로 무중단 배포나 헬스 체크 구성도 쉬워진다. &amp;rarr; 물론 요즘은 AWS ALB, Kubernetes Service/Ingress 같은 게 이 역할을 한다.&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;2.6 웹 서버랑 WAS를 분리한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역사적 배경을 한 번 보자&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음에는 웹 서버가 정적 파일만 잘 내려주면 됐는데, 점점 로그인 및 게시판, 주문 같은 동적 기능이 필요해지면서 WAS가 등장함&lt;/li&gt;
&lt;/ul&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;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /index.html
GET /style.css
GET /logo.png
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 그냥 파일 찾아서 내려주면 됐다.&lt;/p&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;브라우저
  &amp;darr;
웹 서버
  &amp;darr;
HTML 파일 응답
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 웹 서버의 역할은 단순했다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;요청받기
파일 찾기
파일 내려주기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 Apache HTTP Server 같은 웹 서버가 이 역할을 잘했다.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로그인, 회원가입, 게시판 글쓰기, 댓글 작성 등등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 것들은 단순히 HTML 파일을 내려주는 걸로는 불가능하다. 로그인 같은 기능들은 DB를 봐야하기 때문&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;사용자가 아이디/비번 입력
  &amp;darr;
DB에서 회원 조회
  &amp;darr;
비밀번호 검증
  &amp;darr;
로그인 성공/실패 응답
&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;그래서 CGI 같은 방식이 나왔다. &amp;rarr; 초기에는 웹 서버가 직접 프로그램을 실행하는 방식이 있었다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 요청이 오면&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;Apache
  &amp;darr;
외부 프로그램 실행
  &amp;darr;
프로그램이 DB 조회
  &amp;darr;
결과 HTML 생성
  &amp;darr;
Apache가 응답
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 방식이 CGI이다. 문제는 요청이 올 때마다 프로그램을 새로 실행하는 식이라서 비효율적이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 1개 &amp;rarr; 프로세스 1개 실행&lt;/li&gt;
&lt;li&gt;요청 100개 &amp;rarr; 프로세스 100개 실행&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;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;그래서 Java 는 Servelet이라는 표준이 나왔다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청마다 프로그램을 새로 실행하지 말고 자바 객체를 메모리에 올려두고 재사용하자. &amp;rarr; Servlet 방식 == 서블릿 객체를 미리 만들어두고 요청 마다 메서드 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ex&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;protected void doGet(HttpServletRequest request,
                     HttpServletResponse response) {
    // 요청 처리
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청이 올때마다 자바 프로그램을 새로 실행시키는 게 아니라 이미 떠 있는 JVM 안에서 메서드만 호출하는 방식 &amp;rarr; 이걸 관리하는 게 서블릿 컨테이너 이고 대표가 Tomcat 이다.&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;그래서 웹 서버 옆에 WAS가 붙었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client
  &amp;darr;
Web Server
  &amp;darr;
HTML/CSS/JS/이미지 응답
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 동적 처리가 필요해지니까&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client
  &amp;darr;
Web Server
  &amp;darr;
CGI / 외부 프로그램
  &amp;darr;
DB
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 java 웹 에서는&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client
  &amp;darr;
Web Server
  &amp;darr;
Servlet Container / WAS
  &amp;darr;
DB
&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;예전 Java 웹 운영에서는 이런 구조가 흔했다&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Client
  &amp;darr;
Apache HTTP Server
  &amp;darr; mod_jk / AJP
Tomcat
  &amp;darr;
Servlet / JSP
  &amp;darr;
DB
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apache는 정적 파일과 앞단 요청 처리를 맡고, Tomcat은 Java Servlet/JSP 실행을 맡았다. 그때 관점에서는 Tomcat을 &amp;ldquo;웹 서버 뒤에 붙는 Java 실행 서버&amp;rdquo;처럼 많이 썼다&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;2.7 톰캣(Web Application Server)은 정확히 무엇인가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;톰캣은 원래 서블릿 컨테이너이다. 하지만 HTTP 요청을 직접 받을 수 있고, 동적 요청도 처리할 수 있기 떄문에 일반적으로 WAS라고도 부른다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Tomcat = Servlet Container + 간단한 Web Server 기능
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Nginx처럼 정적 파일 처리, 리버스 프록시, 로드밸런싱, TLS 종료 같은 기능에 특화된 서버는 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Spring MVC에서 모든 요청의 시작점은 사실상 DispatcherServlet이며 그 DispatcherServlet을 실행시켜주는 게 톰캣이다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tomcat, 서블릿 컨테이너 &amp;rarr; 어떤 Servlet을 호출할지까지만 안다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DispatcherServlet, Spring MVC의 Servlet &amp;rarr; 어떤 Controller를 호출할지 안다.&lt;/p&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&gt;Tomcat이 하는 일&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tomcat은 서블릿 컨테이너니까 이런 일을 한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;HTTP 요청 받기
&amp;darr;
HttpServletRequest / HttpServletResponse 생성
&amp;darr;
이 요청을 처리할 Servlet 찾기
&amp;darr;
DispatcherServlet 호출
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 요청이 이렇게 오면:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /members/1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tomcat 입장에서는 이렇게 보는 것이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;/members/1 요청이 왔네?
이 요청은 DispatcherServlet한테 보내야겠다.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tomcat은 여기까지만 안다. Tomcat은 이런 건 모른다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;MemberController가 있는지
getMember() 메서드가 있는지
@PathVariable Long id를 어떻게 넣는지
JSON으로 응답해야 하는지
&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;DispatcherServlet이 하는 일&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DispatcherServlet은 Spring MVC의 핵심 서블릿이며, Tomcat이 DispatcherServlet을 호출하면 그때부터 Spring MVC 내부 로직이 시작된다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;DispatcherServlet
&amp;darr;
HandlerMapping에게 물어봄
&quot;이 요청을 처리할 Controller가 누구야?&quot;
&amp;darr;
HandlerAdapter가 Controller 메서드 실행
&amp;darr;
ArgumentResolver가 파라미터 넣어줌
&amp;darr;
HttpMessageConverter가 JSON 변환
&amp;darr;
응답 반환
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;이 요청을 처리할 Controller가 누구지?
어떤 메서드를 실행해야 하지?
파라미터는 어떻게 넣어야 하지?
반환값은 JSON으로 바꿔야 하나?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;이건 &lt;b&gt;Tomcat 자체가 하는 게 아니라, Tomcat 안에서 실행되는 DispatcherServlet이 Spring MVC 기능을 사용해서 하는 일&lt;/b&gt;이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 요청 흐름 자세히 보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 요청이 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GET /members/1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot + Tomcat에서는 대략 이렇게 처리된다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트가 HTTP 요청을 보냄&lt;/li&gt;
&lt;li&gt;Tomcat의 Connector가 요청을 받음&lt;/li&gt;
&lt;li&gt;Tomcat이 HttpServletRequest, HttpServletResponse 객체를 생성&lt;/li&gt;
&lt;li&gt;해당 요청을 처리할 Servlet을 찾음&lt;/li&gt;
&lt;li&gt;Spring MVC에서는 DispatcherServlet이 요청을 받음&lt;/li&gt;
&lt;li&gt;DispatcherServlet이 HandlerMapping에게 물어봄&lt;br /&gt;&quot;GET /members/1 처리할 Controller가 누구야?&quot;&lt;/li&gt;
&lt;li&gt;HandlerMapping이 MemberController의 getMember()를 찾음&lt;/li&gt;
&lt;li&gt;HandlerAdapter가 Controller 메서드를 실행&lt;/li&gt;
&lt;li&gt;Service, Repository, DB 로직 수행&lt;/li&gt;
&lt;li&gt;반환값을 JSON으로 변환&lt;/li&gt;
&lt;li&gt;HttpServletResponse에 응답 작성&lt;/li&gt;
&lt;li&gt;Tomcat이 클라이언트에게 HTTP 응답 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 톰캣의 주요 구성 요소&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣 내부 구조를 보면 대표적으로 이런 요소들이 있다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Server
 └── Service
      ├── Connector
      └── Engine
           └── Host
                └── Context
                     └── Servlet
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sever &amp;rarr; 톰캣 전체를 의미한다. == 톰캣 프로세스 하나라고 생각하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Service &amp;rarr; Connector와 Engine을 묶는 단위이다. == 요청을 받는 쪽과 요청을 처리하는 쪽을 연결한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Connector &amp;rarr; Connector는 클라이언트의 요청을 받는 부분이다. 예를 들어 HTTP 요청은 보통 8080 포트로 들어온다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;&amp;lt;http://localhost:8080&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 8080 포트에서 요청을 기다리는 게 Connector이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring boot 에서 다음과 같은 설정을 하면&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;server.port=8080
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣 Connector가 8080 포트에서 요청을 받게 된다. Connector는 HTTP 요청을 파싱해서 내부 객체로 바뀐다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;HTTP 요청 문자열
   &amp;darr;
HttpServletRequest
HttpServletResponse
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Engine &amp;rarr; Connector가 받은 요청을 실제 처리할 곳으로 넘긴다. 예를 들어 여러 도메인이나 여러 애플리케이션이 있을 때 적절한 Host, Context를 찾는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Host &amp;rarr; 가상 호스트를 의미한다. 예를 들어 &lt;a href=&quot;http://api.example.com&quot;&gt;api.example.co&lt;/a&gt;m, &lt;a href=&quot;http://admin.example.com&quot;&gt;admin.example.com&lt;/a&gt; 같은 도메인 단위&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Context &amp;rarr; 하나의 웹 애플리케이션이다. 예를 들어 톰캣에 여러 애플리케이션을 올릴 수 있다. /app, /app2 각각 Context가 될 수 있다. Spring boot 애플리케이션 하나도 하나의 Context라고 볼 수 있다. 보통 Spring boot에서는 context-path를 설정할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;server.servlet.context-path=/api
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 모든 요청 앞에 /api가 붙는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Servelet &amp;rarr; 실제 요청을 처리하는 자바 객체이다. Spring MVC에서는 대표적으로 DispatcherServlet이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 톰캣의 스레드 모델&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣은 요청이 들어올 때마다 스레드를 사용해서 요청을 처리한다. 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;요청 1 &amp;rarr; Thread-1
요청 2 &amp;rarr; Thread-2
요청 3 &amp;rarr; Thread-3
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring boot의 기본 MVC 방식은 일반적으로 요청 하나당 스레드 하나가 할당되는 구조이다. 예를 들어 동시에 100명이 요청하면 톰캣의 워커 스레드들이 각각 요청을 맡는다.&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;5.1 중요한 설정 - max-threads&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;톰캣은 무한정 스레드를 만드는 것이 아니다. 설정된 최대 스레드 수가 있다.&lt;/li&gt;
&lt;li&gt;Spring boot 에서는 보통 이런식으로 설정할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;server.tomcat.threads.max=200 // 최대 200개의 요청 처리 스레드를 사용할 수 있다.
server.tomcat.threads.min-spare=10 // 최소로 대기시켜둘 스레드의 수
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.2 만약 요청이 너무 많이 들어오면?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣 최대 스레드 200개라고 가정해보았을 때&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동시 요청 300개 &amp;rarr; 200개 처리, 나머지 100개는 대기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기열까지 꽉 차면 요청이 거절 될 수 있다. 관련 설정으로는 다음과 같은 것들이 있다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;server.tomcat.threads.max=200
server.tomcat.accept-count=100
server.tomcat.max-connections=8192
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략 의미는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;max-connections = 동시에 연결 가능한 최대 커넥션 수&lt;/li&gt;
&lt;li&gt;threads.max = 실제 요청을 처리하는 최대 스레드 수&lt;/li&gt;
&lt;li&gt;accept-count = 스레드가 부족할 때 대기 가능한 요청 수&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 톰캣과 DB 커넥션 풀의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣 스레드 수와 DB 커넥션 풀 크기는 서로 영향을 준다. 예를 들어&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;server.tomcat.threads.max=200
spring.datasource.hikari.maximum-pool-size=10
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 설정되어있다고 가정해보자 그러면 톰캣은 동시에 200개의 요청을 처리하려고 할 수 있다. 그런데 DB 커넥션은 10개 밖에 없다. 그러면 DB를 사용한느 요청은 10개만 실제로 DB에 접근할 수 있고 나머지는 커넥션을 기다린다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Tomcat Thread 200개
DB Connection 10개

&amp;rarr; DB 작업이 필요한 요청 200개가 들어오면
&amp;rarr; 10개만 DB 사용
&amp;rarr; 190개는 커넥션 대기
&amp;rarr; 응답 지연 증가
&amp;rarr; 타임아웃 가능
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 성능 튜닝할 때는 톰캣 스레드만 늘리면 안 된다. 같이 봐야하는 것들은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Tomcat threads
HikariCP maximum-pool-size
DB max_connections
API 응답 시간
CPU 사용률
메모리 사용률
외부 API 호출 시간
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 톰캣과 Spring boot 내장 톰캣&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 보통 다음과 같이 진행했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;톰캣 서버 설치&lt;/li&gt;
&lt;li&gt;WAR 파일 빌드&lt;/li&gt;
&lt;li&gt;톰캣 webapps 폴더에 배포&lt;/li&gt;
&lt;li&gt;톰캣 실행&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 톰캣이 먼저 있고 그 위에 애플리케이션을 올렸다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;External Tomcat
  └── my-app.war
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Spring boot에서 이걸 업그레이드를 했다. 애플리케이션 안에 톰캣이 포함된다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;my-app.jar
  └── embedded Tomcat
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음과 같이 실행한다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;java -jar app.jar
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 Spring boot가 실행되면서 내부에서 톰캣도 같이 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Spring Boot 실행
  &amp;darr;
내장 Tomcat 실행
  &amp;darr;
DispatcherServlet 등록
  &amp;darr;
8080 포트에서 요청 대기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 톰캣이 없으면 Spring MVC가 동작할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 Spring MVC 웹 애플리케이션은 서블릿 기반이다. 그래서 요청을 받아서 DispatcherServlet을 실행시켜 줄 서블릿 컨테이너가 필요하다 그 역할을 톰캣이한다. 다만 반드시 톰캣이어야하는 것은 아니다. Spring boot에서는 다른 서버도 사용할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat, Jetty, Undertow&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 톰캣 vs NGINX&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 167px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Tomcat&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;Nginx&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;자바 웹 애플리케이션 실행&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;웹 서버, 리버스 프록시&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;가능&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;직접 비즈니스 로직 처리 X&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;가능하지만 전문은 아님&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;매우 강함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Java Servlet 실행&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;가능&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Spring Boot 실행&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;가능&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;불가능&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;가능은 하지만 보통 Nginx 사용&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;강함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;TLS/HTTPS 처리&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;가능&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;보통 Nginx에서 처리&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;h2 data-ke-size=&quot;size26&quot;&gt;10. 톰캣과 서블릿 생명주기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣은 서블릿 객체를 직접 관리한다. 서블릿 생명주기는 보통 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서블릿 클래스 로딩&lt;/li&gt;
&lt;li&gt;서블릿 인스턴스 생성&lt;/li&gt;
&lt;li&gt;init() 호출&lt;/li&gt;
&lt;li&gt;요청마다 service() 호출&lt;/li&gt;
&lt;li&gt;종료 시 destroy() 호출&lt;/li&gt;
&lt;/ol&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 class=&quot;java&quot;&gt;&lt;code&gt;public class MyServlet extends HttpServlet {

    @Override
    public void init() {
        System.out.println(&quot;서블릿 초기화&quot;);
    }

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) {
        System.out.println(&quot;요청 처리&quot;);
    }

    @Override
    public void destroy() {
        System.out.println(&quot;서블릿 종료&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 마다 서블릿 객체를 새로 만드는 것이 아니라 보통 서블릿 객체는 하나 만들어두고 여러 요청을 여러 스레드가 동시에 처리한다. 그래서 서블릿이나 Controller에서 공유 상태를 조심해야한다. 예를 들어 다음고 ㅏ같은 코드는 위험하다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestController
public class MemberController {

    private String name;

    @GetMapping(&quot;/members&quot;)
    public String getMember(@RequestParam String name) {
        this.name = name;
        return this.name;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller는 싱글톤 빈이기 때문에 여러 요청이 동시에 들어오면 this.name 값이 서로 덮어써질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 요청마다 달라지는 값은 지역 변수로 둬야 한다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@GetMapping(&quot;/members&quot;)
public String getMember(@RequestParam String name) {
    return name;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 톰캣 요청 처리와 HTTP 메서드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;톰캣은 HTTP 메서드에 따라 서블릿 메서드를 호출한다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;GET     &amp;rarr; doGet()
POST    &amp;rarr; doPost()
PUT     &amp;rarr; doPut()
DELETE  &amp;rarr; doDelete()
PATCH   &amp;rarr; service() 내부에서 처리
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC에서는 우리가 직접 doGet()을 구현하지 않고 다음과 같이 쓴다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@GetMapping
@PostMapping
@PutMapping
@DeleteMapping
@PatchMapping
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부적으로는 톰캣이 요청을 받고, DispatcherServlet을 거쳐서 알맞은 Controller 메서드가 실행된다.&lt;/p&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/416</guid>
      <comments>https://wo-dbs.tistory.com/416#entry416comment</comments>
      <pubDate>Thu, 2 Jul 2026 11:24:09 +0900</pubDate>
    </item>
    <item>
      <title>이미지 조회 시 S3 원본 직접 전송 구조를 Lambda 리사이즈 및 CloudFront 캐싱 구조로 전환</title>
      <link>https://wo-dbs.tistory.com/415</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저희 서비스는 사용자가 저장한 이미지를 본인만 조회할 수 있도록 S3 Presigned URL 방식을 사용하고 있었습니다. 인증된 사용자에게만 만료 기한이 있는 URL을 발급해 보안을 유지하는 구조였습니다. 그런데 코드를 살펴보니 이미지 조회 과정에서 비효율이 있었습니다. 사용자가 이미지를 조회할 때마다 보유한 이미지 수만큼 서버에서 Presigned URL을 생성하고 있었고, 동일한 이미지를 다시 조회해도 매번 새로운 URL을 발급하고 있었습니다.&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;이번 글에서는 이 문제를 해결하기 위해 기존 S3 기반 이미지 조회 구조를 CloudFront Signed Cookie, Lambda 리사이징, CloudFront 캐싱 구조로 개선한 과정을 정리합니다. S3로만 이미지를 조회하다가 CloudFront와 Lambda를 도입하려는 분들에게, 구조 변경의 이유와 적용 과정, 그리고 개선 결과를 함께 공유하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 기존 구조 및 문제점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.1 기존 구조 및 문제점&lt;/b&gt;&lt;/h3&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;image.png&quot; data-origin-width=&quot;2016&quot; data-origin-height=&quot;1214&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQ0sVw/dJMb99Uffjo/r1kzwA0V6Nzo0YkH64lj30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQ0sVw/dJMb99Uffjo/r1kzwA0V6Nzo0YkH64lj30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQ0sVw/dJMb99Uffjo/r1kzwA0V6Nzo0YkH64lj30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQ0sVw%2FdJMb99Uffjo%2Fr1kzwA0V6Nzo0YkH64lj30%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;752&quot; height=&quot;453&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;2016&quot; data-origin-height=&quot;1214&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버: DB 조회 + presigned URL 생성 (이미지 N장 &amp;times; N회)&lt;/li&gt;
&lt;li&gt;클라이언트: presigned URL로 S3에서 이미지 직접 다운로드&lt;/li&gt;
&lt;/ul&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;&lt;b&gt;1. 문제 1 - 조회마다 presigned URL 재생성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조에서는 이미지를 조회할 때마다 서버에서 S3 Presigned URL을 새로 발급했습니다. 이미지 접근 권한을 제한하기 위해 만료 시간이 있는 URL을 사용하는 방식이었지만 같은 이미지를 다시 조회해도 매번 서버 호출과 서명 연산이 반복되는 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 그리드 화면처럼 여러 이미지를 한 번에 조회하는 경우, 이미지 개수만큼 Presigned URL을 생성해야 하므로 서버 부담이 커질 수 있었습니다.&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;2. 문제 2 - 그리드 조회 시 원본 크기 이미지 전체 다운로드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 Presigned URL로 S3 원본 이미지를 다운로드해 화면에 표시했습니다. 즉, 수 MB 크기의 원본 이미지가 그대로 전달되고 있었습니다. 이로 인해 이미지 크기가 클수록 로딩 시간이 길어졌고, 이미지마다 표시 시점이 달라져 사용자 경험이 일정하지 않은 문제가 발생했습니다.&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;b&gt;1.2 문제 2번에 대한 측정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 크기에 따른 S3 조회 속도를 정확히 확인하기 위해 Flutter에서 presigned URL로 이미지를 로드하는 시간을 직접 측정했습니다.&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;&amp;nbsp;&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;이미지 크기&lt;/td&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small (1.0MB)&lt;/td&gt;
&lt;td&gt;317ms&lt;/td&gt;
&lt;td&gt;775ms&lt;/td&gt;
&lt;td&gt;969ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (2.3MB)&lt;/td&gt;
&lt;td&gt;745ms&lt;/td&gt;
&lt;td&gt;1,738ms&lt;/td&gt;
&lt;td&gt;1,842ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large (10.1MB)&lt;/td&gt;
&lt;td&gt;677ms&lt;/td&gt;
&lt;td&gt;2,185ms&lt;/td&gt;
&lt;td&gt;2,875ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;p50 기준으로 이미지 크기가 클수록 다운로드 시간이 늘어났습니다. 더 주목할 점은 p95입니다. Small은 p50 317ms에서 p95 775ms로 2.4배 벌어지고, Large는 p50 677ms에서 p95 2,185ms로 3.2배 벌어집니다. 원본 크기가 클수록 응답 시간의 분산도 함께 커지는 구조를 정확하게 알 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 개선 1 - &lt;b&gt;조회할 때마다 서버를 거치지 않고 최적화된 이미지 제공&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이미지 조회 과정에서 발생하는 두 가지 비효율을 줄이고 싶었습니다. 첫 번째는 이미지 개수만큼 Presigned URL을 반복 발급해야 하는 서버 부담이고 두 번째는 화면에는 썸네일만 필요함에도 원본 이미지를 그대로 다운로드하는 클라이언트 측 부담이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 문제를 단순히 &amp;ldquo;이미지를 어떻게 가져올 것인가&amp;rdquo;가 아니라, &amp;ldquo;서버의 서명 연산을 줄이면서도 화면 크기에 맞는 이미지만 전달할 수 있는 구조&amp;rdquo;로 다시 생각해보았습니다.&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;이미지 접근 권한을 유지하면서도 서버 호출을 줄이는 것&lt;/b&gt;이었습니다. 기존처럼 이미지별로 URL을 매번 발급하는 방식이 아니라, 한 번 인증된 사용자가 일정 시간 동안 허용된 이미지 경로에 접근할 수 있는 구조가 필요하다고 판단했습니다. 다만, 이미지가 외부에 공개되어서는 안 되기 때문에 URL을 알고 있더라도 인증되지 않은 사용자는 접근할 수 없는 방식이어야 했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이미지 자체의 용량을 줄이고, 화면 크기에 맞는 이미지만 전달해 로딩 속도를 개선하는 것&lt;/b&gt;이었습니다. 기존에는 썸네일 화면에서도 원본 이미지를 그대로 다운로드했기 때문에, 실제 화면에 필요한 것보다 훨씬 큰 이미지가 전달되고 있었습니다. 이미지를 화면 크기에 맞게 리사이즈하고, 전송 용량도 줄여 더 빠르게 응답할 수 있는 구조가 필요했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 기존 S3 Presigned URL 방식 안에서 해결할 수 있는 방법을 검토했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트에서 발급받은 Presigned URL을 일정 시간 캐싱해두는 방법도 생각했습니다. 하지만 Presigned URL은 만료 시간이 있고 다시 발급될 때마다 URL 값이 달라질 수 있습니다. 결국 클라이언트는 URL이 유효한지 확인하거나 만료된 경우 다시 서버에 요청해야 했습니다. 따라서 클라이언트 캐싱만으로는 이미지 조회 전에 서버를 거쳐야 하는 구조를 근본적으로 해결하기 어렵다고 판단했습니다.&lt;/li&gt;
&lt;li&gt;또한 S3에 썸네일 이미지를 미리 생성해두는 방법도 생각했습니다. 이 방식은 원본 이미지 다운로드 문제를 줄일 수 있지만, 사용되지 않을 크기의 이미지까지 미리 생성해야 하고 저장 용량도 증가한다는 단점이 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 S3 Presigned URL 방식만으로는 서버 호출을 줄이면서도 필요한 크기의 이미지만 효율적으로 전달하기 어렵다고 판단했습니다.&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;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;이미지 조회의 진입점을 S3가 아니라 CDN으로 옮기는 방향&lt;/b&gt;&lt;/span&gt;을 생각했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CDN을 사용하여 반복적으로 조회되는 이미지를 사용자와 가까운 위치에서 빠르게 전달할 수 있습니다. 이를 통해 이미지 로딩 시간뿐만 아니라 사용자가 화면에서 느끼는 대기 시간도 줄일 수 있다고 판단했습니다.&lt;/li&gt;
&lt;li&gt;또한 CDN은 단순히 이미지를 빠르게 전달하는 캐시 계층이 아니라, 사용자에게 이미지를 제공하는 새로운 진입점이 됩니다. 따라서 이미지 접근 제어도 S3가 아닌 CDN 계층에서 처리할 수 있다고 판단했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이미지 용량을 줄이고 화면 크기에 맞게 변환하는 작업은 서버에서 직접 처리하기보다, &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;필요할 때만 실행되는 서버리스 방식&lt;/b&gt;&lt;/span&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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 기준을 바탕으로 AWS에서 제공하는 CloudFront와 Lambda를 활용하는 방향을 검토했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CloudFront는 이미지 조회의 진입점 역할을 하면서 캐싱을 통해 반복 요청을 빠르게 처리할 수 있고, Lambda는 캐시 미스 시점에만 실행되어 원본 이미지를 필요한 크기로 변환할 수 있습니다.&lt;/li&gt;
&lt;li&gt;또한 CloudFront에서 제공하는 Signed Cookie를 사용하면, 이미지 URL을 알고 있더라도 인증되지 않은 사용자는 접근할 수 없도록 제한할 수 있습니다. 이를 통해 등록된 사용자와 허용된 기기만 CloudFront를 통해 이미지를 조회할 수 있는 구조를 만들 수 있다고 판단했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 이 구조를 사용함으로써 운영 서버에 비효율적인 요청을 없앨 수 있고, 이미지 크기와 사용자의 기기 화면에 맞게 줄여 속도가 개선될 것이라고 생각했습니다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;1458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tQfjN/dJMcafUvkv9/ZL8TgfeNyMTiKYbzKVF6T0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tQfjN/dJMcafUvkv9/ZL8TgfeNyMTiKYbzKVF6T0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tQfjN/dJMcafUvkv9/ZL8TgfeNyMTiKYbzKVF6T0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtQfjN%2FdJMcafUvkv9%2FZL8TgfeNyMTiKYbzKVF6T0%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;717&quot; height=&quot;617&quot; data-filename=&quot;image.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;1458&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;이미지 조회 시 서버를 거치지 않고 Secure Storage에 저장된 쿠키를 첨부해 CloudFront에 직접 요청합니다. 쿠키가 만료된 경우 서버에 재발급을 요청한 뒤 자동으로 재시도하며, 캐시 미스 시 Lambda가 S3 원본을 가져와 리사이즈한 결과를 CloudFront가 캐싱합니다.&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;2.2 AWS 보안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. S3 직접 접근 차단&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;S3 버킷은 퍼블릭 액세스를 차단하고, CloudFront OAC(Origin Access Control)를 통해서만 접근할 수 있도록 구성했습니다. 따라서 사용자가 S3 객체 URL을 직접 알고 있더라도 S3로 직접 요청하면 AccessDenied가 반환됩니다.&lt;/li&gt;
&lt;li&gt;또한 이미지 조회 요청은 CloudFront를 통해서만 처리되며, CloudFront에서는 Signed Cookie를 검증해 인증된 사용자만 이미지에 접근할 수 있도록 구성했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Lambda 직접 접근 차단&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;리사이즈를 수행하는 Lambda Function URL에는&amp;nbsp;AWS_IAM&amp;nbsp;인증을 적용했습니다. 여기에 CloudFront Lambda OAC를 연결해, CloudFront가 SigV4 서명 요청을 보낼 때만 Lambda를 호출할 수 있도록 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. CloudFront Signed Cookie 인증&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;CloudFront의&amp;nbsp;리사이즈 경로, 기본 접근 경로에 Viewer access restriction을 적용했습니다. 따라서 Signed Cookie가 없는 요청은 CloudFront 단계에서 차단됩니다. 사용되는 쿠키는 아래 3개입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;CloudFront-Policy &amp;rarr; 어떤 경로에, 언제까지 접근 가능한지 적힌 정책
CloudFront-Signature &amp;rarr; 위 Policy가 서버가 만든 진짜 정책인지 증명하는 서명값
CloudFront-Key-Pair-Id &amp;rarr; 서명 검증에 사용할 공개키 ID
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키 운영 정책은 다음과 같이 잡았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;TTL&lt;/b&gt;: 15분&lt;/li&gt;
&lt;li&gt;&lt;b&gt;저장 위치&lt;/b&gt;: Flutter Secure Storage&lt;/li&gt;
&lt;li&gt;&lt;b&gt;만료 처리&lt;/b&gt;: 이미지 요청 시 403이 발생하면 쿠키를 재발급한 뒤 한 번 재시도&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로그아웃 처리&lt;/b&gt;: 저장된 Signed Cookie 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.3 이미지 다운로드의 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 구조를 CloudFront와 리사이즈 방식으로 바꾸고 나니, 이번에는 다운로드 쪽에서 문제가 생겼습니다. 기존에는 하나의 presigned URL로 조회와 다운로드를 모두 처리하고 있었습니다. 이미지를 화면에 보여줄 때도, 외부로 공유하거나 저장할 때도 같은 URL을 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 조회용 URL을 CloudFront 리사이즈 URL로 바꾸면서 이 URL로 다운로드를 시도하면 400px로 리사이즈된 WebP 파일이 내려왔습니다. 화면에 작게 표시할 때는 충분하지만 사용자가 이미지를 외부로 공유하거나 저장할 때는 원본 화질이 필요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각해보니 조회와 다운로드는 애초에 요구사항이 달랐습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조회 &amp;rarr; 화면 크기에 맞는 작은 이미지면 충분 (리사이즈)&lt;/li&gt;
&lt;li&gt;다운로드 &amp;rarr; 원본 화질 그대로 필요 (원본)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 두 경로를 분리하기로 했습니다. 조회는 Lambda를 거치는 리사이즈 경로를 사용하고, 다운로드는 S3 원본을 그대로 가져오는 경로를 사용하도록 했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;조회 &amp;rarr; Lambda 리사이즈 &amp;rarr; WebP&lt;/li&gt;
&lt;li&gt;다운로드 &amp;rarr; S3 원본 그대로&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다운로드 경로 역시 CloudFront를 통하기 때문에 S3에 직접 접근하지 않고 조회와 동일하게 Signed Cookie로 인증하도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 개선 후 재측정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 후에도 기존 측정과 동일하게 Small, Medium, Large 3개의 이미지를 기준으로 Flutter에서 이미지 로드 시간을 측정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 이미지는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Small&lt;/b&gt;: 1.0MB 원본 이미지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Medium&lt;/b&gt;: 2.3MB 원본 이미지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Large&lt;/b&gt;: 10.1MB 원본 이미지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CloudFront는 캐시 특성상 첫 요청과 이후 요청의 동작이 다르기 때문에, 두 상태를 구분해서 측정했습니다.&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;: CloudFront에 캐시가 없어 Lambda가 원본을 읽어 리사이즈하는 첫 요청&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 히트&lt;/b&gt;: 리사이즈 결과가 CloudFront에 캐싱된 이후의 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 측정 결과 및 개선 전 비교&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.1163%;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.2326%;&quot;&gt;원본 크기&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.9302%;&quot;&gt;개선 후 전송 크기&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 10.8139%;&quot;&gt;S3 p50&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 23.4884%;&quot;&gt;CF 캐시 히트 p50&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 19.3023%;&quot;&gt;p50 기준 감소율&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.1163%;&quot;&gt;Small&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.2326%;&quot;&gt;1.0MB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.9302%;&quot;&gt;13.4KB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 10.8139%;&quot;&gt;317ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 23.4884%;&quot;&gt;45ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 19.3023%;&quot;&gt;85.8% 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.1163%;&quot;&gt;Medium&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.2326%;&quot;&gt;2.3MB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.9302%;&quot;&gt;22.3KB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 10.8139%;&quot;&gt;745ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 23.4884%;&quot;&gt;40ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 19.3023%;&quot;&gt;94.6% 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.1163%;&quot;&gt;Large&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.2326%;&quot;&gt;10.1MB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 15.9302%;&quot;&gt;32.9KB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 10.8139%;&quot;&gt;677ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 23.4884%;&quot;&gt;38ms&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 19.3023%;&quot;&gt;94.4% 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 이미지별로 웜업 5회 후 100회 반복 측정한 결과입니다.S3 전체 누적 기준 p50 538ms에서 CloudFront 캐시 히트 p50 45ms로, p50 기준 약 12배 개선됐습니다. 전송 크기도 크게 줄었습니다. 기존에는 1.0~10.1MB 원본 이미지를 그대로 전송했지만, 개선 후에는 Lambda에서 400px 리사이즈 및 WebP 변환을 거쳐 13.4~32.9KB로 전송됩니다.&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;b&gt;3.1 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 두드러진 변화는 응답 시간의 안정성입니다. S3는 이미지 크기가 클수록 p95가 폭발적으로 벌어집니다. Large 기준으로 p50 677ms인데 p95는 2,185ms로 3.2배 차이가 납니다. 반면 CloudFront 캐시 히트는 세 이미지 모두 p50 38~45ms, p95 62~64ms 수준으로 원본 크기와 무관하게 안정적입니다. S3에서는 원본 크기가 클수록 사용자가 체감하는 속도가 느려질 뿐 아니라 예측하기도 어려웠는데, CloudFront 캐시 히트 이후에는 그 분산 자체가 사라졌습니다.&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;캐시 미스 상태에서는 개선 전보다 오히려 느린 결과가 나왔습니다. CloudFront에 캐시가 없는 첫 요청에서는 Lambda가 S3 원본을 읽고 sharp로 리사이즈 및 WebP 변환을 수행해야 하기 때문입니다. 캐시 미스 측정은 각 이미지의 첫 요청 1회씩을 샘플로 기록했으며, &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Small 967ms, Medium 980ms, Large 2,320ms&lt;/b&gt;&lt;/span&gt;였습니다. Large의 캐시 미스가 가장 높은 건 원본 파일 크기(10.1MB)의 영향이고, Small과 Medium이 비슷한 수준으로 나온 건 Lambda가 이미 웜 상태였기 때문으로 보입니다. 즉, 이번 개선은 첫 요청 속도보다 반복 조회 성능을 개선하는 방향입니다. 한 번 리사이즈된 이미지가 CloudFront에 캐싱되면 이후 요청은 원본 크기와 무관하게 안정적인 속도로 제공됩니다. 실제 사용 환경에서는 같은 이미지를 반복 조회하는 경우가 많기 때문에 대부분의 요청이 캐시 히트로 처리됩니다.&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;b&gt;3.2 한계점 및 보완 계획&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 히트 상태에서는 p50 기준 85~94% 개선 효과가 있었지만, 캐시 미스 상태에서는 Lambda 리사이즈 처리 시간이 추가되어 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;S3 직접 조회보다 느려지는 한계&lt;/b&gt;&lt;/span&gt;가 있습니다. 이를 보완하기 위해 Flutter 클라이언트 캐싱을 함께 적용할 계획입니다. CloudFront 캐시가 서버 측 캐시 역할을 한다면, Flutter 로컬 캐시는 사용자 기기에서 반복 조회되는 이미지를 즉시 재사용하는 역할을 합니다. 최종적으로는 다음과 같은 다층 캐시 구조를 목표로 합니다. 이를 통해 최초 요청에서 Lambda 리사이즈가 수행되더라도, 이후 동일 이미지 재조회나 화면 재진입 시에는 Flutter 로컬 캐시 또는 CloudFront 캐시를 통해 더 빠르게 이미지를 표시할 수 있습니다.&lt;/p&gt;</description>
      <category>프로젝트/toIT</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/415</guid>
      <comments>https://wo-dbs.tistory.com/415#entry415comment</comments>
      <pubDate>Fri, 12 Jun 2026 16:47:45 +0900</pubDate>
    </item>
    <item>
      <title>[외주] 배포 이후 - 달라진 요구사항 회의, 그리고 계속 쌓는 업데이트 로그 (6/5, 6/17, 7/1)</title>
      <link>https://wo-dbs.tistory.com/414</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 업데이트: 7/1&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;다만 이 글은 한 번 쓰고 끝내는 글이 아닙니다. 유지보수 계약 기간 동안 요구사항 회의와 업데이트 내역을 날짜별로 계속 쌓아가는 로그이며, 새 회의가 있을 때마다 마지막 섹션에 내용이 추가됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 달라진 요구사항 회의 (6/5)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6월 5일, 사장님과 첫 정식 요구사항 회의를 가졌습니다. 확실히 달랐습니다. 예전처럼 &quot;이것도 되면 좋겠는데&quot;가 아니라, 하나하나 꼼꼼하게 정리해서 말씀해 주셨습니다. 계약 구조를 다시 잡은 효과를 회의 방식에서부터 체감할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 고시원 관리 프로그램 2차 업데이트 계획표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항을 정리하고 나서 가장 먼저 한 일은 우선순위를 나누는 것이었습니다. 모든 기능을 한꺼번에 처리하려고 하면 결국 아무것도 제대로 못 하는 상황이 생기기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기준은 단순하게 잡았습니다. 현재 고시원 운영에 직접 영향을 주는 기능은   높음, 불편하지만 당장 급하지 않은 기능은   중간, 있으면 좋지만 여유가 생길 때 작업해도 되는 기능은   낮음으로 분류했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 결과 미납 상태 처리, 계약 만료일 분리, 월세 납부일 자동 TodoList 표시처럼 실제 운영과 직결되는 기능들을 Phase 1로 묶어 가장 먼저 처리하기로 했습니다. 이후 Phase 2, 3에서 입실자 관리 개선과 반복 TodoList 같은 기능들을 순차적으로 진행하고, 주식 차트 스타일의 연간 캘린더는 학습 시간이 필요한 만큼 Phase 4로 따로 분리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 일정은 약 한 달 반, 7월 20일 전후를 목표로 잡았습니다. 진행하면서 기간이 달라질 수 있지만, 변동이 생길 경우 미리 공유드릴 예정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 사장님 미팅 후기 및 미팅 끝나고 난 후&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계획표를 완성한 후 사장님과 미팅을 진행하였습니다. 사장님께서는 계획표를 보시고 기한이 명확하게 정해져 있어서 좋다고 말씀해 주셨습니다. 또한 이 계획표는 총무님들께도 공유하여, 배포가 이루어질 때마다 함께 진행 상황을 체크하겠다고 하셨습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유지보수 계약 부분도 순조롭게 마무리되었습니다. 월 15만원씩, 1년간의 유지보수 계약에 사장님께서 흔쾌히 동의해 주셨고, 계약서도 함께 작성하였습니다. 저번 포스팅에서 이야기했던 계약 구조가 이렇게 실제로 자리를 잡게 되었고, 저 역시 앞으로의 작업에 더 집중할 수 있겠다는 안도감이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 날짜별 요구사항 &amp;amp; 업데이트 로그&lt;/h2&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;h2 data-ke-size=&quot;size26&quot;&gt;5. 날짜별 요구사항 업데이트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6/5&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;todoList
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 입실자의 월세 납부일마다 todoList에 해당자가 뜨게&lt;/li&gt;
&lt;li&gt;퇴실일 기준 한 달 전에 todoList에 뜨게&lt;/li&gt;
&lt;li&gt;반복 일정 등록 기능 &amp;rarr; 생성&amp;middot;수정&amp;middot;삭제 가능, 요일/날짜 지정 모두 지원
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;날짜 지정 시 말일 처리: 1월 30일 등록 건은 2월엔 28일에 표시 (28일 이후 날짜는 전부 28일로)&lt;/li&gt;
&lt;li&gt;기간은 등록일 기준 5년 뒤까지&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;대시보드
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도면 사진 위에 이름&amp;middot;방타입 표시 + 오른쪽에 층별 이름&amp;middot;방타입 목록
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;오른쪽 목록도 도면처럼 클릭 시 상세 보기&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입실자 관리
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입실자 목록이 세로로 너무 길다 &amp;rarr; 층별(섹션) 보기 / 행 높이 줄이기 2가지 방식 중 선택 가능하게&amp;nbsp;&lt;/li&gt;
&lt;li&gt;상태 컬럼 제거&lt;/li&gt;
&lt;li&gt;방 관리 컬럼
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가격에 천원 단위가 안 들어가는 문제&lt;/li&gt;
&lt;li&gt;방관리 키워드(부동산식 입력) 추가&amp;middot;수정&amp;middot;삭제&lt;/li&gt;
&lt;li&gt;방 관리 데이터 기입 시 todoList에 자동 반영&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;상세 관리 &amp;rarr; 기존 퇴실일을 계약 만료일로 변경하고, 확정 퇴실일 컬럼 별도 추가&lt;/li&gt;
&lt;li&gt;월세 납부일 컬럼
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;5일 전&amp;middot;4일 전 표시 불필요, 당일만 표시&lt;/li&gt;
&lt;li&gt;하나라도 미납이면 계속 미납 상태 유지 (예: 6월에 4&amp;middot;5월 냈어도 3월 미납이면 미납 표시)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이력에서 수정 가능하게 &amp;rarr; 단, 보증금만&lt;/li&gt;
&lt;li&gt;상세 페이지에 메모장&lt;/li&gt;
&lt;li&gt;상세 이력 및 신규 입실자 등록에 계약금 항목&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;연간 캘린더
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;막대 그래프 축소 시 잘 안 보임 &amp;rarr; 토스 주식 차트 형태로 (단위: 일&amp;middot;주&amp;middot;월&amp;middot;년 선택)&lt;/li&gt;
&lt;/ul&gt;
&lt;a href=&quot;https://www.tossinvest.com/stocks/A039490/order&quot;&gt;https://www.tossinvest.com/stocks/A039490/order&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;통계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;없음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6/17&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;todoList
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;방에 대한 유지보수 내역 추가하면 달력에 다 뜨게 하며 할일, 월세납부, 계약 만료 버튼 처럼 추가하기&lt;/li&gt;
&lt;li&gt;todoList에서 우선순위를 지정할 수 있으면 좋겠다. 그래서 할일을 중간에 넣을 수 있도록&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;입실자 관리
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현금 승계 부분에서 계좌 내역을 2개만 선택할 수 있게. == 사장님, 원장님&lt;/li&gt;
&lt;li&gt;입실일에 대한 개월 수 데이터 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;출력이라는 큰 타이틀이 필요 &amp;amp;&amp;amp; 1년 단위, 달 단위 필요
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;방 유지보수 내역 출력 &amp;rarr; PDF 필요&lt;/li&gt;
&lt;li&gt;현금 승계 프린트 한 번에 되게 &amp;rarr; 한장에 다 모아서 하루치를 다 보고 싶다. 1인당 종이 하나씩 써야해서 불편하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;데이터 백업 &amp;rarr; 추후 많이 만들어졌을 때 진행
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사장님 입장 &amp;rarr; 병원에서 일을 하셨는데 이게 데이터 보관이 정말 중요했다. 그래서 나도 관리 잘해주셨으면 한다. 나한테 맡기는 것이 분명 리스크가 있을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7/1&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;todoList
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 월세에 대한 분들 그 달의 납부가 되었는지 안 되었는지
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;표시가 어려우면 &amp;lsquo;-&amp;rsquo;, &amp;lsquo;+&amp;rsquo; 같은 방향으로&lt;/li&gt;
&lt;li&gt;체크는 할 수 있게끔&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;todoList에서 뜨는 게 월세 납우 일자가 만약 3월 31일이면 2월은 28일날 뜨게 나와야하고 7월 31일이면 6월은 30일 뜨도록 진행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;입실자 관리
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이력 부분에서 보증금 차감 이력이 안 보인다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이게 없으면 이 사람이 왜 차감 되었는지 볼 수가 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;이력 부분에서 다른 모든 데이터들이 수정이 가능할 수 있도록
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;월세 납부 이력, 기본정보, 계약 정보 모두&lt;/li&gt;
&lt;li&gt;이게 필요한 이유는 현재 이력에서 수정이 안되니 이 사람을 입실을 다시 하고 해야하는데 만약 이 사람이 원래 있던 방에 다른 사람이 이미 입실해있으면 반영이 안 된다.&lt;/li&gt;
&lt;li&gt;원래는 이력 데이터라 수정이 되면 안 좋을 것 같다고 생각했지만,, 어차피 많이 건드릴 필요가 없을 것 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;입실자 현황 인쇄 &amp;rarr; 사장님께서 브라우저를 통해 인쇄 버튼을 눌렸는데 이상하게 나옴. 확대 축소는 다른 곳에서 잘 되는데 내가 만든 프로그램에서는 안 됨.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입실자 현황을 PDF로 할 수 있는 것이 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;대시보드 &amp;amp; 입실자 관리 &amp;amp; 통계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 방 밖에 없는데 공용 공간이 하나 있으면 좋겠다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;고시원에서 주방, 세탁기 같은 것들 AS 받으면 기록할 방법이 없다.&lt;/li&gt;
&lt;li&gt;수기로 넣을 수 있게 해주면 좋겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;공용 공간도 통계에서 방 관리 유지 통계에서 보이도록&lt;/li&gt;
&lt;li&gt;공용 공간이 todoList에서 기입할 수 있도록 하면 좋겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;통계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 유지보수 금액들 월별 예상 수입 추이 처럼 통계가 나오면 좋겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&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;</description>
      <category>외주/고시원 외주 개발 일지</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/414</guid>
      <comments>https://wo-dbs.tistory.com/414#entry414comment</comments>
      <pubDate>Thu, 11 Jun 2026 20:28:29 +0900</pubDate>
    </item>
    <item>
      <title>[외주] 고시원 관리 프로그램 2차 업데이트 요구사항 정리 및 계획표 (6/5, 6/17, 7/1 통합)</title>
      <link>https://wo-dbs.tistory.com/413</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;6/5 미팅에서 말씀해 주신 요구사항에 더해, 6/17과 7/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;각 항목 뒤의 괄호는 요구사항이 나온 미팅 날짜입니다. 표시가 없는 항목은 6/5 미팅 기준입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 요구사항 전체 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 todoList&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.1 각 입실자들의 월세 납부일이 있다. todoList에 하루마다 월세 내는 날 해당자가 뜨는 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.2 각 입실자들의 퇴실일 기준으로 한 달 전에 todoList에 뜨는 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.3 반복적인 todoList에 들어갈 것을 넣을 수 있으며 수정, 생성, 삭제가 가능함. 또한, 반복적인 일에 대해서 설정할 때 요일로 물어볼 수도 있으며 날짜로도 지정 가능하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;날짜로 지정할 때 2월 같은 경우는 1월 30일 &amp;rarr; 2월 28일 같은 것에 뜨게 (28일 이후로는 전부 28일 날짜에 넣어주면 됨)&lt;/li&gt;
&lt;li&gt;기간 설정으로는 넣은 날짜에 맞추어 5년 뒤까지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.4 방에 대한 유지보수 내역을 추가하면 달력에 표시되며, 할일&amp;middot;월세납부&amp;middot;계약만료 버튼처럼 추가 가능하게(6/17)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.5 todoList 우선순위 지정 기능 - 할일을 중간에 끼워 넣을 수 있도록 (6/17)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.6 각 입실자의 월세가 그 달에 납부되었는지 표시 - 표시가 어렵다면 '-', '+' 방식으로, 체크는 가능하게 (7/1)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.1.7 월세 납부일 말일 처리 - 납부일이 3월 31일이면 2월엔 28일에, 7월 31일이면 6월엔 30일에 뜨도록 (7/1)&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;1.2 대시보드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.2.1 도면 사진 위에 이름과 방타입이 뜨는 것과 오른쪽에 각 층마다 이름 &amp;amp; 방타입이 뜨는 것&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;오른쪽에 뜨는 이름 &amp;amp; 방타입의 경우 도면과 같이 누르면 상세도 보인다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 입실자 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.1 현재 입실자 목록이 세로로 너무 길어서 세로 길이를 줄이고 싶다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해결 방법 &amp;rarr; 각 층별(섹션)로 볼 수 있는 방법, 위아래 줄이는 방법 2가지로 선택해서 볼 수 있게 진행&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.2 상태 컬럼은 필요 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.3 방 관리 컬럼&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가격 천원이 안 들어감&lt;/li&gt;
&lt;li&gt;방관리 키워드(부동산처럼 입력하자) - 수정, 삭제, 추가&lt;/li&gt;
&lt;li&gt;방 관리에 대한 데이터 기입 시에 todoList에서도 자동으로 보이게&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.4 상세 관리&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입실일에 대한 개월 수 데이터 필요&lt;/li&gt;
&lt;li&gt;계약서 상의: 계약 만료일 컬럼 추가, 추가적인 컬럼 실질적인 (퇴실일 필요) 2가지가 있음. 그래서 현재 퇴실일이 있는데 이것을 계약 만료일로 진행하고, 퇴실일을 따로 만들어서 확정 퇴실일이 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.5 월세 납부일 컬럼&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;5일전, 4일전 보이는 형태는 필요없다. 그 날만 있으면 된다.&lt;/li&gt;
&lt;li&gt;하나라도 안 내면 미납 처리 - 미납 상태이면 계속 미납이 뜨도록 (예: 6월인데 5월, 4월은 냈어도 3월이 미납이면 계속 미납 표시)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.6 이력에서 수정할 수 있도록 (퇴실 처리 갔을 때 데이터 수정, 단 보증금만)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.7 입실자 관리 상세 페이지에서 메모장&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.8 상세 이력 및 신규 입실자 등록에서 계약금 필요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.9 현금 승계에서 계좌 내역을 2개만 선택 가능하게 - 사장님, 원장님 (6/17)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.10 이력에서 보증금 차감 이력 표시 - 차감 사유를 확인할 수 있도록 (7/1)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.3.11 이력 데이터 전체 수정 가능하게 - 월세 납부 이력, 기본 정보, 계약 정보 모두 (7/1)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 1.3.6(보증금만 수정)의 확장. 현재는 이력 수정이 안 되어 재입실 처리를 해야 하는데, 원래 방에 다른 입실자가 있으면 반영이 안 되는 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 연간 캘린더&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.4.1 막대 그래프 축소 시 잘 보이지 않음 &amp;rarr; 토스 주식 차트 스타일로 구현&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.4.2 단위는 일 / 주 / 월 / 년 선택&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;1.5 출력 (6/17 신규)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.5.1 '출력' 큰 메뉴 신설 - 1년 단위, 달 단위 지원 (6/17)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.5.2 방 유지보수 내역 PDF 출력 (6/17)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.5.3 현금 승계 출력을 하루치 한 장에 모아서 - 현재 1인당 종이 한 장씩 필요해 불편 (6/17)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.5.4 입실자 현황 PDF 출력 - 브라우저 인쇄 시 화면이 깨지는 문제 해결 (7/1)&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;1.6 공용 공간 (7/1 신규)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.6.1 공용 공간 등록 - 주방, 세탁기 등 AS 내역을 수기로 기록 가능하게 (7/1)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.6.2 공용 공간도 방 유지보수 통계에 포함 (7/1)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.6.3 공용 공간을 todoList에서 기입 가능하게 (7/1)&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;1.7 통계 (7/1 신규)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.7.1 유지보수 금액을 월별 예상 수입 추이처럼 통계로 표시 (7/1)&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;1.8 데이터 백업 (6/17)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.8.1 데이터 백업 - 데이터가 충분히 쌓인 뒤 진행하기로 협의. 세부 방안은 「데이터 관리 방안 보고서(6/17)」 참고&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;h2 data-ke-size=&quot;size26&quot;&gt;2. 작업 계획표&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;우선순위 분류 기준&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  높음 - 현재 운영에 직접 영향을 주는 기능&lt;/li&gt;
&lt;li&gt;  중간 - 불편하지만 당장 급하지 않은 기능&lt;/li&gt;
&lt;li&gt;  낮음 - 개선 사항, 여유 있을 때 작업&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Phase 1 - 긴급 수정 (6/10 ~ 6/20, 약 1.5주) - 완료&lt;/h3&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;번호&lt;/td&gt;
&lt;td&gt;우선순위&lt;/td&gt;
&lt;td&gt;기능 예상&lt;/td&gt;
&lt;td&gt;소요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3.5&lt;/td&gt;
&lt;td&gt; &lt;/td&gt;
&lt;td&gt;미납 상태 누적 처리 (하나라도 미납 시 계속 표시)&lt;/td&gt;
&lt;td&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3.4&lt;/td&gt;
&lt;td&gt; &lt;/td&gt;
&lt;td&gt;계약 만료일 / 퇴실일 컬럼 분리&lt;/td&gt;
&lt;td&gt;1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3.5&lt;/td&gt;
&lt;td&gt; &lt;/td&gt;
&lt;td&gt;월세 납부일 당일만 표시로 변경&lt;/td&gt;
&lt;td&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.1&lt;/td&gt;
&lt;td&gt; &lt;/td&gt;
&lt;td&gt;월세 납부일 기준 당일 자동 TodoList 표시&lt;/td&gt;
&lt;td&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.1.2&lt;/td&gt;
&lt;td&gt; &lt;/td&gt;
&lt;td&gt;퇴실일 1개월 전 자동 TodoList 표시&lt;/td&gt;
&lt;td&gt;1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.3.8&lt;/td&gt;
&lt;td&gt; &lt;/td&gt;
&lt;td&gt;상세 이력 및 신규 입실자 등록에서 계약금 필요&lt;/td&gt;
&lt;td&gt;1 ~ 2일&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;h3 data-ke-size=&quot;size23&quot;&gt;Phase 2 - 중간 우선순위 개선 (6/21 ~ 7/1, 약 1.5주) - 완료&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 169px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;번호&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;우선순위&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;기능 예상&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;예상 소요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;1.3.1&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;입실자 목록 세로 길이 축소 (층별 섹션 / 접기 2가지 방식)&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.3.2&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;상태 컬럼 제거&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.3.3&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;방 관리 가격 천원 단위 수정&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.3.3&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;방 관리 키워드 부동산 스타일 입력 (수정 / 삭제 / 추가)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.3.6&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;퇴실 이력에서 보증금 수정 가능하도록&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.2.1&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;도면 위 이름 &amp;amp; 방타입 표시 + 오른쪽 목록 연동 클릭 시 상세 보기&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1.1.3&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;반복 TodoList 생성 / 수정 / 삭제 (요일 or 날짜 지정)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Phase 3 - 낮은 우선순위 개선 (7/2 ~ 7/11, 약 1.5주) - 완료&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 98px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;번호&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;우선순위&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;기능&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;예상 소요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1.1.3&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;반복 기간 설정 (최대 5년)&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1.3.3&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;방 관리 데이터 입력 시 TodoList 자동 반영&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1.3.7&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;입실자 상세 페이지 메모장 추가&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1.3.4&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;입실일 기준 개월 수 표시&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;0.5일&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Phase 4 - 6/17 추가 요구사항: 출력 &amp;amp; TodoList 확장 (7/12 ~ 7/24, 약 2주)&lt;/h3&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;번호&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt;우선순위&lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;기능&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;예상 소요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;1.5.1&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;'출력' 메뉴 신설 (1년 / 달 단위)&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;1.5.3&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;현금 승계 하루치 한 장 출력(어려움)&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;1.5.2&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;방 유지보수 내역 PDF 출력&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;1.3.9&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;현금 승계 계좌 2개 제한 (사장님 / 원장님)&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;0.5일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;1.1.4&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;방 유지보수 내역 달력 표시 + 버튼 추가&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;1.1.5&lt;/td&gt;
&lt;td style=&quot;width: 7.90698%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 66.1628%;&quot;&gt;TodoList 우선순위 지정 (중간 삽입)&lt;/td&gt;
&lt;td style=&quot;width: 13.7209%;&quot;&gt;1~2일&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;h3 data-ke-size=&quot;size23&quot;&gt;Phase 5 - 7/1 추가 요구사항: 이력, 공용 공간, 통계 (7/25 ~ 8/8, 약 2주)&lt;/h3&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;번호&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt;우선순위&lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;기능&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;예상 소요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.5.4&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;입실자 현황 PDF 출력 (인쇄 깨짐 해결)&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;1~2일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.3.10&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;보증금 차감 이력 표시&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;0.5~1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.3.11&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;이력 데이터 전체 수정 (월세 납부 / 기본 정보 / 계약 정보)&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;2~3일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.1.6&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;월세 납부 여부 표시 및 체크&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.1.7&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;월세 납부일 말일 처리&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;0.5~1일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.6.1~3&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;공용 공간 등록 + 통계 포함 + TodoList 연동&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;2~3일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 11.5116%;&quot;&gt;1.7.1&lt;/td&gt;
&lt;td style=&quot;width: 12.3256%;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;width: 63.1395%;&quot;&gt;유지보수 금액 월별 추이 통계&lt;/td&gt;
&lt;td style=&quot;width: 13.0233%;&quot;&gt;1~2일&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;h3 data-ke-size=&quot;size23&quot;&gt;Phase 6 - 연간 캘린더 (8/9 ~ 8/23, 약 2주)&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 58px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;번호&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;우선순위&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;기능&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;예상 소요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1.4.1&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;연간 캘린더 주식 차트 스타일 학습 및 구현&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1~1.5주&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1.4.2&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt; &lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;캘린더 단위 선택 (일 / 주 / 월 / 년)&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1일&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;별도 진행&lt;/h3&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;번호&lt;/td&gt;
&lt;td&gt;기능&lt;/td&gt;
&lt;td&gt;비고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.8.1&lt;/td&gt;
&lt;td&gt;데이터 백업&lt;/td&gt;
&lt;td&gt;데이터가 충분히 쌓인 뒤 진행 (시기 별도 협의, 6/17 보고서 참고)&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전체 예상 일정 요약&lt;/h3&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;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;내용&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;기간 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase 1&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;긴급 수정&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;6/10 ~ 6/20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase 2&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;중간 우선순위 개선&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;6/21 ~ 7/1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase 3&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;낮은 우선순위 개선&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;7/2 ~ 7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase 4&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;6/17 추가 요구사항 (출력 &amp;amp; TodoList 확장)&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;7/12 ~ 7/24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase 5&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;7/1 추가 요구사항 (이력 &amp;middot; 공용 공간 &amp;middot; 통계)&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;7/25 ~ 8/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;Phase 6&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;연간 캘린더&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;8/9 ~ 8/23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.814%;&quot;&gt;&lt;b&gt;완료 목표&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 61.1628%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 23.0233%;&quot;&gt;&lt;b&gt;8월 23일 전후&lt;/b&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;</description>
      <category>외주/고시원 외주 요구사항 명세서</category>
      <author>재윤</author>
      <guid isPermaLink="true">https://wo-dbs.tistory.com/413</guid>
      <comments>https://wo-dbs.tistory.com/413#entry413comment</comments>
      <pubDate>Thu, 11 Jun 2026 20:26:31 +0900</pubDate>
    </item>
  </channel>
</rss>