<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>change-challenge</title>
    <link>https://change-challenge.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 25 May 2026 19:22:28 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>CVnarod</managingEditor>
    <image>
      <title>change-challenge</title>
      <url>https://tistory1.daumcdn.net/tistory/4829602/attach/c80dbf553d53458c93914a3b68a59732</url>
      <link>https://change-challenge.tistory.com</link>
    </image>
    <item>
      <title>마땅히 알아야할 것들 : Python이 느리고, CPU bound 작업에서 멀티스레드가 무력화되는 이유</title>
      <link>https://change-challenge.tistory.com/13</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Gemini_Generated_Image_vt2n1svt2n1svt2n.png&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;2048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buFaso/dJMcaiW26r2/vbu3RfXSJ99VDpIysre3v0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buFaso/dJMcaiW26r2/vbu3RfXSJ99VDpIysre3v0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buFaso/dJMcaiW26r2/vbu3RfXSJ99VDpIysre3v0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuFaso%2FdJMcaiW26r2%2Fvbu3RfXSJ99VDpIysre3v0%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;561&quot; height=&quot;561&quot; data-filename=&quot;Gemini_Generated_Image_vt2n1svt2n1svt2n.png&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;2048&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 사내 PDF 마스킹 작업을 통해 새벽까지 머리 깨지면서 버그 픽스와 리팩토링을 진행하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면서 피상적으로 알았던 &lt;b&gt;어플리케이션과 쓰레드, 프로세스와의 관계,&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 힘들게 힘들게 공부했었던 &lt;b&gt;컴퓨터구조&lt;/b&gt;와 같은 CS 지식이 실제적이고, 마땅히 개발자로서 알아야하는 것이라는 생각이 들었습니다.&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;요즘 누구나 AI를 통해 딸깍해서 개발하는 시대에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역으로 가는 것 같지만 깊은 지식으로 개발자로서 차별성을 두기 위해 아래로 깊게 파는 공부를 진행해보려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(물론 저에게는 조금 깊어졌지만 누군가에게는 무릎 밖에 안오는 높이일 수도 있습니다!)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또, 42서울에서 배웠던 이 밑단까지 까보는 이러한 재미가 보통 재밌는게 아닌 것도 같습니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_Photo_2026-03-30-22-04-44.jpeg&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgIuyq/dJMcac3BWAS/0evP8rTvey9BU6JiBsfdLK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgIuyq/dJMcac3BWAS/0evP8rTvey9BU6JiBsfdLK/img.jpg&quot; data-alt=&quot;동료와 바닥까지 이해하기 위해 고군분투한 흔적&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgIuyq/dJMcac3BWAS/0evP8rTvey9BU6JiBsfdLK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgIuyq%2FdJMcac3BWAS%2F0evP8rTvey9BU6JiBsfdLK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;725&quot; height=&quot;544&quot; data-filename=&quot;KakaoTalk_Photo_2026-03-30-22-04-44.jpeg&quot; data-origin-width=&quot;1440&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동료와 바닥까지 이해하기 위해 고군분투한 흔적&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;---&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 흐름은&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery에서 CPU 바운드 작업(PDF 읽고 쓰기 등)을 할 때, 멀티스레드가 왜 무력화 되고 이와 같이 python은 왜 느릴까에 대한 흐름으로 진행했습니다.&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;1. Python의 메모리 구조: 왜 느린가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C에서&lt;/p&gt;
&lt;pre id=&quot;code_1774876033875&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;int x = 2;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;는 컴파일 시점에 &lt;b&gt;int는 4 byte&lt;/b&gt;라는 것이 확정됩니다. 컴파일러는 이 정보를 바탕으로 &lt;b&gt;스택&lt;/b&gt;에 4바이트만 할당하고, 변수명 x는 컴파일 후 사라져 [rbp-4] 같은 메모리 오프셋으로 대체됩니다. 실행 시에는 &lt;b&gt;mov DWORD [rbp-4], 2&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;반면 Python에서&lt;/p&gt;
&lt;pre id=&quot;code_1774876092967&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;x = 2&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;는 사정이 완전히 다릅니다. Python은 &lt;b&gt;dynamic typing 언어&lt;/b&gt;이므로, x = 2 다음 줄에 x = &quot;hello&quot;가 올 수 있습니다. 어떤 변수든 실행 중에 타입이 바뀔 수 있기 때문에, &lt;i&gt;&lt;b&gt;컴파일 시점에 타입과 크기를 확정할 수 없습니다.&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 CPython은 모든 값을 &lt;b&gt;PyObject&lt;/b&gt;라는 C 구조체로 감싸서 &lt;b&gt;힙(heap)&lt;/b&gt;에 저장합니다. PyObject에는 타입 포인터(ob_type), 참조 카운트(ob_refcnt), 실제 값 등이 포함되어 약 &lt;b&gt;28바이트 이상&lt;/b&gt;을 차지합니다. C에서 4바이트면 되는 정수가 Python에서는 28바이트짜리 힙 객체가 되는 것입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python도 실행 전에 전체 코드를 바이트코드(.pyc)로 컴파일하는 단계가 있습니다. 다만 이 컴파일은 문법 검증과 바이트코드 변환만 수행하며, 타입 추론이나 메모리 크기 결정 같은 최적화는 하지 않습니다. 실제 타입과 크기는 바이트코드를 &lt;b&gt;한 줄씩 실행&lt;/b&gt;하는 런타임에 결정됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. GIL이 필요한 이유: 참조 카운트의 thread safety&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Python(CPython)은 메모리 관리를 위해 &lt;b&gt;참조 카운트(reference counting)&lt;/b&gt; 방식의 GC를 사용합니다. 모든 &lt;b&gt;PyObject&lt;/b&gt;의 &lt;b&gt;ob_refcnt&lt;/b&gt; 필드가 몇 명이 참조하는가를 추적하고, 이 값이 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;문제는 이 ob_refcnt가 &lt;b&gt;힙에 있는 공유 데이터&lt;/b&gt;라는 것입니다. 멀티스레드 환경에서 여러 스레드가 동시에 같은 객체의 ob_refcnt를 수정하면 race condition이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해 CPython은 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;GIL(Global Interpreter Lock)&lt;/b&gt;&lt;/span&gt;을 도입했습니다. GIL은 프로세스 전체에 하나만 존재하는 mutex로, &lt;b&gt;한 순간에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록&lt;/b&gt; 강제합니다. 이 방식은 참조 카운트를 안전하게 보호하지만, 대가로 멀티스레드의 병렬 실행이 진행되지 않습니다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아보니 GIL이 도입된 1992년에는 개인 PC가 싱글코어였기 때문에, 어차피 진정한 병렬 실행이 불가능했으므로 GIL은 합리적인 선택이었습니다. 하지만 멀티코어가 보편화된 현재 이 설계는 CPU bound 작업에서 병목이 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. GIL의 탈출구: I/O 대기와 C 확장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GIL이 항상 스레드를 막는 것은 아닙니다. 두 가지 상황에서 GIL이 해제됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;I/O 대기&lt;/b&gt;: 네트워크 요청, 파일 읽기, DB 쿼리 등에서 OS에서 오래 기다려야하는 요청을 하는 순간 GIL이 풀립니다. CPU가 할 일이 없는 대기 시간에는 다른 스레드가 GIL을 획득하여 실행할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;-&amp;gt; 즉, 프로세스 당 GIL은 하나씩 존재하고, I/O 바운드 작업과 같은 것들은 멀티스레드로 사용이 가능한 것 입니다!!!&amp;nbsp;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;C 확장&lt;/b&gt;: NumPy, Pillow 같은 라이브러리의 내부 C 코드는 ob_refcnt를 건드리지 않으므로, 명시적으로 GIL을 해제하고 자체 연산을 수행할 수 있다고 합니다!!&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;I/O bound 작업에서는 멀티스레드가 효과적&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Celery에서의 실전 적용&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;p data-ke-size=&quot;size16&quot;&gt;Celery로 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;PDF 렌더링(CPU bound)&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;b&gt;-pool=threads (pthread 기반)&lt;/b&gt;로 실행하면, 모든 스레드가 하나의 GIL을 공유하므로 한 순간에 하나의 스레드만 실행됩니다. CPU bound 작업에서는 사실상 직렬 실행과 다름없습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-pool=prefork (프로세스 기반)&lt;/b&gt;로 변경하면, 각 워커 프로세스가 독립된 Python 인터프리터와 독립된 GIL을 가집니다. 따라서 4개의 워커가 4개의 CPU 코어에서 병렬로 PDF를 동시 처리할 수 있습니다.&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근데 prefork는 프로세스를 복사하는 것이기 때문에 Stack만 복사하는 쓰레드와 다르게 메모리를 더 먹을 수 밖에 없습니다! 이래서 CPU 바운드 작업을 prefork로 진행하면 금방 memory가 찹니다!&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 이메일 발송, 외부 API 호출 같은 &lt;b&gt;I/O bound 작업&lt;/b&gt;에서는 --pool=threads가 더 효율적입니다. I/O 대기 중 GIL이 해제되므로 적은 메모리로 높은 동시성을 확보할 수 있기 때문입니다.&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery로 PDF 마스킹 작업을 할 때,&amp;nbsp;스레드 값들이 오염되는 것을 방지하고자 계속 집중하다보니&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 Python과 쓰레드/프로세스 간의 관계를 제대로 이해하지 못해서 병렬처리를 했다고 생각했지만 못하고 있었습니다.&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;동료의 마스킹 작업을 하는 EC2의 리팩토링까지 진행하여&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;color: #006dd7;&quot;&gt;&lt;b&gt;&amp;bull;&amp;nbsp;마스킹&amp;nbsp;속도:&amp;nbsp;120초&amp;nbsp;&amp;rarr;&amp;nbsp;60초&amp;nbsp;(2배&amp;nbsp;&amp;uarr;)&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&amp;bull; PDF 제작 속도: 60초 &amp;rarr; 15초 (4배 &amp;uarr;)&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;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 110px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;원인&lt;/td&gt;
&lt;td style=&quot;height: 17px; text-align: center;&quot;&gt;결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Python이 느린 이유&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;동적 타입 &amp;rarr; 모든 값이 힙의 PyObject &amp;rarr; 매번 해석 실행&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;C 대비 메모리 7배, 속도 수십 배 차이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;GIL이 존재하는 이유&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;힙의 ob_refcnt를 race condition 없이 보호하기 위해&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;멀티스레드의 CPU bound 병렬 실행 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;CPU bound에서의 해결&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;prefork로 프로세스를 분리하여 GIL을 각각 가짐&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;I/O bound에서의 해결&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;thread pool 사용, I/O 대기 시 GIL 해제&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;적은 리소스로 높은 동시성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description>
      <category>CS</category>
      <category>Celery</category>
      <category>devops</category>
      <category>Python</category>
      <category>개발자</category>
      <author>CVnarod</author>
      <guid isPermaLink="true">https://change-challenge.tistory.com/13</guid>
      <comments>https://change-challenge.tistory.com/13#entry13comment</comments>
      <pubDate>Mon, 30 Mar 2026 22:55:52 +0900</pubDate>
    </item>
    <item>
      <title>Celery 태스크가 50% 확률로 사라지는 미스터리 (18시간의 삽질기)</title>
      <link>https://change-challenge.tistory.com/12</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Gemini_Generated_Image_ealj39ealj39ealj (1).png&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;2048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GobaG/dJMcaiWW3su/aqk0dm77yuFYlEZ8qrfPe1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GobaG/dJMcaiWW3su/aqk0dm77yuFYlEZ8qrfPe1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GobaG/dJMcaiWW3su/aqk0dm77yuFYlEZ8qrfPe1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGobaG%2FdJMcaiWW3su%2Faqk0dm77yuFYlEZ8qrfPe1%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;530&quot; height=&quot;530&quot; data-filename=&quot;Gemini_Generated_Image_ealj39ealj39ealj (1).png&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;2048&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;어씨? 뭐야? 왜 똑같이&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;했는데 어떨 때는 되고, 어떨 때는 안되지?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 상황은 정말 개발자에게는 최악인 것 같습니다 ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확실히 실패하면 원인을 찾기라도 하는데, 50% 확률이면 모든 가설이 맞는 것 같기도 하고 틀린 것 같기도 합니다. 이번에 이러한 상황이 한번 발생하였고, 18시간이 걸리고, 8번의 시도가 있었습니다. 이에 대해 제 스스로도 공부하고, 공유하려고 작성합니다. (틀린 부분이나 발전하면 좋을 것들은 언제든지 댓글로 남겨주세요!)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시스템 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 팀에서 사내 Django 기반 백엔드를 운영 중입니다. 이 백엔드는 백그라운드 작업 처리에 Celery를 쓰고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 구조는 다음과 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;메인 Celery (Redis 기반)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774317175205&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- 월간 리포트 생성, PDF 마스킹, 작업 초과 알림 등 모든 백그라운드 작업 처리
- broker: Redis
- app = Celery('safety')로 선언
- 각 태스크는 @shared_task 데코레이터 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SMS 전용 Celery (RabbitMQ 기반)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774317212878&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- 문자 발송만 담당
- broker: 사내 온프레미스 RabbitMQ 서버
- 모듈 레벨에서 별도의 Celery 인스턴스 생성&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;문제는 SMS용 Celery 인스턴스가 &lt;b&gt;3곳&lt;/b&gt;에 흩어져 있었다는 점입니다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;# send_sms_1: SMS 발송 유틸
app = Celery('send_sms', broker=f'amqp://admin:***@{SKO_SMS_SENDER}:5672',
             backend='rpc://')

# send_sms_2: 인증 SMS 발송
app = Celery('send_sms', broker=f'amqp://admin:***@{SKO_SMS_SENDER}:5672',
             backend='rpc://')

# send_sms_3: 외부 시스템 데이터 전송
app = Celery('send_sms', broker=f'amqp://admin:***@{SKO_SMS_SENDER}:5672',
             backend='rpc://')
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전부 동일한 RabbitMQ 브로커를 가리키는, 사실상 같은 역할의 인스턴스가 3개 중복 선언된 상태였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로덕션 환경은 gunicorn gthread 모델로 돌아가고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;web: gunicorn backend.wsgi --workers 5 --threads 2 --worker-class gthread
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;worker 5개, thread 2개. 총 10개의 스레드가 요청을 처리합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그날 무슨 일이 있었나?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PDF 마스킹 기능을 새로 개발해서 프로덕션에 배포했습니다. 마스킹 태스크는&lt;b&gt; @shared_task&lt;/b&gt;로 선언했고, 별도의 masking 큐에서 처리되도록 라우팅했습니다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# PDF 마스킹
@shared_task(bind=True, max_retries=3)
def process_masking_seq(self, seq_id):
    &quot;&quot;&quot;각 seq를 독립적으로 처리&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 직후 테스트를 돌려봤는데, 어떨 때는 잘 되고 어떨 때는 태스크가 아예 실행되지 않았습니다. worker 로그에 태스크를 받았다는 기록 자체가 없었고, .delay()를 호출했는데 아무 일도 일어나지 않는 상태였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;배포 과정에서 뭔가 꼬였나 보다&quot;하고 재배포를 했습니다. 하지만 증상은 같았습니다. 10번 중 랜덤으로 성공하고, 실종되었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8번의 실패, 그리고 9번째 시도&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시도 1. 태스크 자동 발견이 안 되나?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 가설은 &quot;worker가 마스킹 태스크를 못 찾는 것 아닌가?&quot;였습니다. apps.py의 ready() 메서드에서 태스크 모듈을 명시적으로 import 하도록 수정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 50% 실종 현상은 그대로였습니다.&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. worker가 너무 빨리 재시작하나?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MAX_TASKS_PER_CHILD가 1로 설정되어 있었습니다. 태스크 하나 처리하고 worker 프로세스가 죽었다가 다시 살아나는 구조인데, 이 과정에서 태스크가 유실되는 건 아닌지 의심했습니다. 10으로 올리고 broker retry 설정도 추가했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 아무 효과 없었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시도 3. 별도 masking_worker 프로세스 분리&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;기본 celery worker와 masking worker가 같은 큐를 두고 경쟁하나?&quot;라는 가설이었습니다. Procfile에 masking 전용 worker를 분리했습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;masking_worker: celery -A backend worker -l INFO -Q masking -c 2
               -n masking@%h --pool=threads
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 구조적으로 올바른 변경이었지만, 근본 원인은 여전히 남아있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시도 4. .delay() &amp;rarr; .apply_async(queue='masking')&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery는 태스크를 어떤 큐에 넣을지 celery_config.py의 라우팅 설정으로 결정합니다. 이때, 제가 설정한 celery_config.py를 보고 결정하는데 라우팅이 제 때 로딩되지 않아서 그런가 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 .delay()를 .apply_async(queue='masking')으로 바꿨습니다. 명시적으로 큐를 지정하면 라우팅 문제를 우회할 수 있을 거라 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 변화 없었습니다. 이때부터 &quot;큐 라우팅 문제가 아닐 수도 있겠다&quot;는 생각이 들기 시작했습니다.&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;시도 5. prefork &amp;rarr; threads 풀&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery Worker는 대스크를 실행할 때 프로세스 풀 방식을 선택할 수 있기에 prefork에서 threads 풀로 변경했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774317270894&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- prefork: 자식 프로세스를 fork해서 태스크 실행 (기본값)
- threads: 스레드로 태스크 실행&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;prefork 모드에서 Redis를 브로커로 쓸 때, BRPOP이 멈추는 알려진 이슈가 있었습니다. 특히 &lt;b&gt;acks_late=True&lt;/b&gt; (태스크 완료 후에 ACK 보내기) 와 조합하면 더 불안정했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774317300507&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Before]
- worker: celery -A backend worker -Q masking

[After]
- worker: celery -A backend worker -Q masking --pool=threads&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;rarr; 사실 이것이 문제를 인식하기에 좋은 싸인이었습니다. 나중에 문제 처리하다보니 current_app 오염을 더 극명하게 안 시도였습니다.&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;시도 6. acks_late 제거&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;acks_late는 &quot;태스크를 받자마자 ACK&quot; vs &quot;완료 후 ACK&quot; 설정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery의 acks_late=True 설정이 Redis consumer의 BRPOP과 충돌하는 건 아닌지 의심했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774317337166&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- acks_late=True (원래 설정)
1. worker가 태스크를 가져옴
2. 태스크 실행
3. 완료 후 &quot;잘 받았어&quot; ACK 전송
&amp;rarr; 만약 실행 중 worker가 죽으면? ACK 안 했으니 Redis가 다른 worker에게 재전달

- acks_late=False (변경)
1. worker가 태스크를 가져옴
2. 즉시 &quot;잘 받았어&quot; ACK 전송
3. 태스크 실행
&amp;rarr; worker가 죽으면 태스크 유실되지만, BRPOP 충돌 가능성은 줄어듦&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 여전히 간헐적으로 태스크가 사라졌습니다. worker가 태스크를 받는/처리하는 방식을 아무리 바꿔봐야, 애초에 태스크가 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;&lt;b&gt;시도 7. Celery 5.3.4 known issue 우회&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery GitHub에 올라온 이슈 #7276을 발견했습니다. --without-heartbeat --without-gossip --without-mingle 옵션을 추가했습니다. (&lt;a href=&quot;https://github.com/celery/celery/discussions/7276&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/celery/celery/discussions/7276&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 좀 더 안정적이 된 것 같기도 했지만 태스크는 여전히 가끔 사라졌습니다. &quot;worker 쪽은 점점 최적화가 되는데, 왜 아직도 안 되지?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시점에서 시야를 바꿔야 한다는 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;시도 8. Redis 큐 직접 조회&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7번의 시도를 전부 worker쪽으로 진행하였는데 태스크가 Redis에 들어가는 지 확인을 해봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 프로세스에서 .apply_async() 직후에 Redis LLEN 명령으로 큐 길이를 직접 확인하는 코드를 넣었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;import redis as _redis
_r = _redis.Redis.from_url(settings.CELERY_BROKER_URL)
_qlen = _r.llen('masking')
logger.info(f&quot;[마스킹] Redis masking 큐 길이: {_qlen}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과가 당연히도 큐 길이가 0이었습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[마스킹] process_masking_seq.apply_async() 호출 완료
[마스킹] Redis masking 큐 길이: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.apply_async()를 호출했는데 Redis 큐 길이가 0. 태스크가 Redis에 도달하지 않은 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &lt;b&gt;worker가 아니라 Django 단에 있었습니다.&lt;/b&gt; 7번의 시도가 전부 worker 설정을 만지작거린 건 처음부터 잘못된 곳을 보고 있었던 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;범인을 찾다: current_app&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Redis에 안 들어간다&quot;는 걸 알고 나니 질문이 바뀌었습니다. &lt;b&gt;&amp;ldquo;.apply_async()가 도대체 어디로 보내고 있는 거지?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery의 &lt;b&gt;@shared_task&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;@shared_task&lt;/b&gt;는 특정 Celery 앱에 종속되지 않는 태스크를 만드는 데코레이터입니다. Celery 공식 문서에서도 권장하는 패턴입니다. 그런데 이 &quot;특정 앱에 종속되지 않는다&quot;는 말에 함정이 있었습니다.&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;@shared_task로 선언된 태스크의 .delay()나 .apply_async()가 실행될 때, Celery는 그 시점의 current_app을 사용합니다. current_app은 스레드 로컬 변수(_tls.current_app)에 저장됩니다.&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;계속 문제가 생겼던 이유는 Celery() 생성자는 기본적으로 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;i&gt;set_as_current=True&lt;/i&gt;&lt;/span&gt;이기 때문입니다!&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;class Celery:
    def __init__(self, main=None, ..., set_as_current=True, ...):
        ...
        if set_as_current:
            self.set_current()  # _tls.current_app = self
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SMS 모듈의 코드를 다시 보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;# send_sms_1 (SMS 발송 유틸 - 모듈 레벨)
app = Celery('send_sms', broker='amqp://...RabbitMQ서버...:5672',
             backend='rpc://')
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일이 import되는 순간, 해당 스레드의 current_app이 RabbitMQ를 가리키는 SMS 앱으로 바뀝니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 파일은 언제 import 될까요? 다른 모듈이 sending_sms를 import할 때, 그리고 그 모듈 자체도 모듈 레벨에서 또 다른 SMS Celery 인스턴스(send_sms_2)를 만듭니다. 사용자가 로그인하거나 SMS 인증을 요청하면 이 모듈들이 import되면서 &lt;b&gt;그 스레드의 current_app이 오염&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;gunicorn gthread 모델에서 worker 5개, thread 2개면 총 10개의 스레드가 요청을 처리합니다.&lt;/p&gt;
&lt;pre class=&quot;elm&quot;&gt;&lt;code&gt;Thread A: send_sms_2 import됨 &amp;rarr; current_app = RabbitMQ 앱 (SMS)
Thread B: (아직 import 안 됨) &amp;rarr; current_app = Redis 앱 (메인, 정상)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 요청이 Thread A에 걸리면 마스킹 태스크가 RabbitMQ로 날아가고, Thread B에 걸리면 Redis로 정상 발행됩니다. 사용자 입장에서는 같은 동작을 했는데 되었다 안 되었다 하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;랜덤으로 실행되는 이유가 여기에 있었습니다.&lt;/b&gt; Thread가 한번 오염되면 프로세스가 재시작될 때까지 계속 오염 상태입니다. 시간이 지나면서 점점 더 많은 스레드가 오염되니까, 배포 직후에는 잘 되다가 나중에 점점 안 되는 패턴이 나타나기도 했습니다.&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;[정상 흐름]
웹 요청 &amp;rarr; @shared_task.delay()
        &amp;rarr; current_app = Celery('safety', broker=Redis)
        &amp;rarr; Redis 큐에 발행
        &amp;rarr; masking_worker가 수신, 처리

[오염된 흐름]
사용자 로그인 &amp;rarr; send_sms_2 모듈 import
             &amp;rarr; Celery('send_sms', broker=RabbitMQ) 생성
             &amp;rarr; 이 스레드의 current_app = RabbitMQ 앱으로 변경

이후 같은 스레드에서:
웹 요청 &amp;rarr; @shared_task.delay()
        &amp;rarr; current_app = Celery('send_sms', broker=RabbitMQ)
        &amp;rarr; RabbitMQ로 발행 (!!!)
        &amp;rarr; masking_worker는 Redis만 보고 있음 &amp;rarr; 태스크 실종
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;수정: set_as_current=False&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Before
app = Celery('send_sms', broker=f'amqp://...', backend='rpc://')

# After
app = Celery('send_sms', broker=f'amqp://...', backend='rpc://',
             set_as_current=False)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3개 파일에 set_as_current=False를 추가했습니다. 이렇게 하면 SMS용 Celery 인스턴스가 생성되더라도 current_app을 덮어쓰지 않습니다. @shared_task는 항상 backend/celery.py에서 선언한 메인 Redis 앱을 바라보게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 후 태스크 실종 현상이 완전히 사라졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 경험에서 얻은 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;어디서&quot; 문제가 생기는지를 먼저 특정해야 합니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 큐 시스템에서 장애가 나면 본능적으로 consumer 쪽을 먼저 보게 됩니다. &quot;왜 안 받지?&quot;라는 질문이 자연스럽기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 먼저 해야 할 질문은 &quot;메시지가 큐에 진짜 들어갔나?&quot;입니다. 역시 문제는 근본적인 부분을 파악해야 합니다&amp;hellip;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis라면 LLEN으로 큐 길이를 확인하고, RabbitMQ라면 management UI에서 큐 상태를 보면 됩니다. 발행 측과 소비 측 중 어디가 문제인지 5분 만에 특정할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이걸 7번째 시도 이후에야 깨달았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실패한 시도가 헛수고는 아니었습니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8번의 실패 중 시도 3(worker 분리), 시도 5(threads 풀), 시도 7(heartbeat/gossip 제거)은 결국 최종 프로덕션 설정에 반영됐습니다. Procfile의 masking_worker 설정을 보면 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;brainfuck&quot;&gt;&lt;code&gt;masking_worker: celery -A backend worker -l INFO -Q masking -c 2
               -n masking@%h --pool=threads
               --without-heartbeat --without-gossip --without-mingle
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 찾는 과정에서 worker 설정이 점점 최적화되었고, 마지막에 &quot;worker는 충분히 최적화됐는데 왜 안 되지?&quot;라는 확신이 관점의 전환을 이끌어냈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@shared_task는 다중 앱 환경에서 위험합니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Celery 공식 문서에서 @shared_task를 권장하는 이유는 앱 인스턴스에 종속되지 않아서 재사용성이 좋기 때문입니다. 하지만 이건 &lt;b&gt;Celery 앱이 하나&lt;/b&gt;일 때의 이야기입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에 Celery 인스턴스가 여러 개 존재하면 @shared_task의 &quot;공유&quot; 특성이 독이 됩니다. 어떤 앱이 current가 되느냐에 따라 태스크가 전혀 다른 broker로 날아갈 수 있기 때문입니다.&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;추가 Celery 인스턴스를 만들 때 반드시 set_as_current=False 지정&lt;/li&gt;
&lt;li&gt;또는 @shared_task 대신 @app.task를 사용하여 명시적으로 앱을 지정&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;간헐적인데 확률이 대략 반반이라면, 멀티스레드 환경에서 스레드별 상태가 다를 가능성이 높습니다. 특히 thread-local 변수가 관여하는 경우가 많습니다. 이 케이스가 정확히 그랬습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;gunicorn --threads 2로 인해 각 worker에 2개의 스레드가 있었고, import 타이밍에 따라 오염된 스레드와 깨끗한 스레드가 공존했던 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 분산 시스템에서도 가장 강력한 디버깅 도구는 &quot;중간 지점을 직접 확인하는 것&quot;입니다. 네트워크 문제라면 tcpdump, DB 문제라면 쿼리 로그, 메시지 큐 문제라면 큐 상태 직접 조회.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추상화 계층을 믿지 말고 한 단계 아래를 직접 들여다봐야 합니다. 저는 Celery의 .apply_async()가 당연히 Redis로 보낼 거라고 생각했는데, 실제로는 RabbitMQ로 보내고 있었습니다. 그 사실을 Redis LLEN 한 줄로 밝혀낸 것입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 이 세 줄이 18시간을 끝냈습니다
import redis as _redis
_r = _redis.Redis.from_url(settings.CELERY_BROKER_URL)
print(_r.llen('masking'))  # 0 &amp;larr; 여기 안 들어가고 있었음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;부디 저와 같은 실수를 다들 하지 않으시길 &amp;hellip;&lt;/p&gt;</description>
      <category>CS</category>
      <category>Celery</category>
      <category>redis</category>
      <category>worker</category>
      <author>CVnarod</author>
      <guid isPermaLink="true">https://change-challenge.tistory.com/12</guid>
      <comments>https://change-challenge.tistory.com/12#entry12comment</comments>
      <pubDate>Tue, 24 Mar 2026 11:05:22 +0900</pubDate>
    </item>
  </channel>
</rss>