<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>perseverance</title>
    <link>https://dlwogns3413.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 13 Apr 2026 20:06:12 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>perseverance</managingEditor>
    <image>
      <title>perseverance</title>
      <url>https://tistory1.daumcdn.net/tistory/5899298/attach/22ea67fbadc942bfa1f87dca2143e6c8</url>
      <link>https://dlwogns3413.tistory.com</link>
    </image>
    <item>
      <title>TCP 연결부터 데이터 송수신까지, OS 내부에서 무슨 일이 일어나는가</title>
      <link>https://dlwogns3413.tistory.com/41</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;들어가며&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 서버에 요청이 오면 스레드를 할당받아 정해진 로직을 수행하고 응답을 보낸다. 하지만 이 단순해 보이는 과정 뒤에는 OS 내부에서 NIC, 커널, 유저 영역을 넘나드는 복잡한 여정이 숨어있다. 하나의 요청이 어떻게 네트워크를 타고 들어와 애플리케이션까지 전달되고, 다시 응답이 클라이언트에게 돌아가는지, 이 글에서는 TCP 연결이 맺어지는 순간부터 데이터가 오가는 과정까지를 커널 수준에서 파헤쳐본다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TCP 연결, 3 way handshake 내부&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알다시피 TCP는 3-way handshake 과정을 통해 연결을 맺는다. 그렇다면 이 과정에서 OS 내부에서는 어떤 일이 일어날까?&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.44.01.png&quot; data-origin-width=&quot;844&quot; data-origin-height=&quot;822&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dSxnFZ/dJMcadnMLZs/xKJWThbuV1Is7KmJLEoJQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dSxnFZ/dJMcadnMLZs/xKJWThbuV1Is7KmJLEoJQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dSxnFZ/dJMcadnMLZs/xKJWThbuV1Is7KmJLEoJQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdSxnFZ%2FdJMcadnMLZs%2FxKJWThbuV1Is7KmJLEoJQk%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;564&quot; height=&quot;549&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.44.01.png&quot; data-origin-width=&quot;844&quot; data-origin-height=&quot;822&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;첫 번째로 클라이언트는 TCP 연결을 위해 SYN 패킷을 서버로 보낸다. 해당 패킷이 서버에 도착하면 가장 먼저 &lt;b&gt;네트워크 인터페이스 카드(NIC)&lt;/b&gt;에 닿는다. NIC는 이를 &lt;b&gt;DMA&lt;/b&gt;를 통해 &lt;b&gt;RX Ring Buffer&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;여기서 DMA와 RX Ring Buffer라는 개념이 생소할 수 있다. 이에 대해 먼저 알아보자.&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;DMA란&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DMA는 입출력 장치나 주변 기기가 CPU를 거치지 않고 시스템 메모리(RAM)에 직접 데이터를 전송하는 기술이다. NIC 내부에는 전용 칩이 있어서 패킷 수신/송신을 CPU없이 처리할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 CPU가 직접 패킨 수신/송신을 처리한다면 아무래도 프로세스 실행하는데 바쁜 CPU가 더 많은 부담을 들여야 하므로 성능에 좋지않다. 그래서 NIC이 직접 메모리에 접근하여 패킷이 들어온다면 RAM에 접근해 데이터를 처리할 수 있도록 한것이다.&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;RX Ring Buffer란&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RX Ring Buffer는 NIC이 패킷 수신을 하면 보관해두는 커널 메모리에 있는 영역이다. OS에 포함된 NIC 드라이버 코드가 부팅 시 NIC를 감지하고 내부적으로 메서드를 실행하여 커널 메모리에 RX Ring Buffer를 할당한다. 이후 그 물리 메모리 주소를 NIC 레지스터에 써줌으로써 NIC가 해당 주소를 알 수 있게 된다. 이후 패킷이 도착하면 NIC는 CPU 개입 없이 DMA를 통해 해당 주소에 직접 패킷을 기록한다.&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;이렇게 NIC이 수신한 패킷을 RX Ring Buffer에 기록을한다면 NIC는 하드웨어 인터럽트를 보내 CPU에게 패킷이 도착했다고 알린다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU는 하던 일을 멈추고 인터럽트 핸들러를 실행하는데, 여기서 실제 패킷 처리를 하지않고 나중에 softirq를 실행하라고 표시만 하고 즉시 종료한다. 이후 softirq가 실행되면서 RX Ring Buffer에서 패킷을 꺼낸다.&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;softirq는 이 글에서는 중요한 사항이 아니라 더 찾아보고 싶은 분들은 &lt;a title=&quot;해당 글&quot; href=&quot;https://yohda.tistory.com/entry/%EB%A6%AC%EB%88%85%EC%8A%A4-%EC%BB%A4%EB%84%90-Interrupt-softirq&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;해당 글&lt;/a&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;스크린샷 2026-03-21 오후 4.22.26.png&quot; data-origin-width=&quot;2222&quot; data-origin-height=&quot;976&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqkd79/dJMcaaSbTYZ/IlPoWmJrjqFjSKBSsKBa8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqkd79/dJMcaaSbTYZ/IlPoWmJrjqFjSKBSsKBa8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqkd79/dJMcaaSbTYZ/IlPoWmJrjqFjSKBSsKBa8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbqkd79%2FdJMcaaSbTYZ%2FIlPoWmJrjqFjSKBSsKBa8k%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;2222&quot; height=&quot;976&quot; data-filename=&quot;스크린샷 2026-03-21 오후 4.22.26.png&quot; data-origin-width=&quot;2222&quot; data-origin-height=&quot;976&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;RX Ring Buffer에서 꺼낸 패킷을 커널 네트워크 스택이 확인한다. IP 레이어를 거쳐 TCP 레이어까지 올라오면 SYN 패킷임을 확인하고, 클라이언트 IP, Port, MSS 등 handshake에 필요한 정보를 &lt;b&gt;request_sock&lt;/b&gt; 구조체에 담아 &lt;b&gt;SYN Queue&lt;/b&gt;에 보관한다. 이후 커널이 자동으로 SYN+ACK 패킷을 클라이언트에게 전송한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-21 오후 4.36.57.png&quot; data-origin-width=&quot;2160&quot; data-origin-height=&quot;946&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIgwTZ/dJMcajnXPQx/Bx2uIwzwNR4VLEMOFHqh6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIgwTZ/dJMcajnXPQx/Bx2uIwzwNR4VLEMOFHqh6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIgwTZ/dJMcajnXPQx/Bx2uIwzwNR4VLEMOFHqh6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIgwTZ%2FdJMcajnXPQx%2FBx2uIwzwNR4VLEMOFHqh6K%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;2160&quot; height=&quot;946&quot; data-filename=&quot;스크린샷 2026-03-21 오후 4.36.57.png&quot; data-origin-width=&quot;2160&quot; data-origin-height=&quot;946&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;클라이언트가 SYN+ACK 패킷을 수신하면 ACK를 서버로 보낸다. 서버에 도착하면 앞서 설명한 것과 동일하게 NIC가 RX Ring Buffer에 패킷을 저장하고 하드웨어 인터럽트를 발생시키며, softirq가 실행되어 패킷을 꺼내 ACK임을 확인한다. 이때 SYN Queue에 보관 중이던 request_sock을 꺼내 완성된 sock 구조체를 생성하고 &lt;b&gt;ACCEPT Queue&lt;/b&gt;에 보관한다. 이로써 TCP 3-way handshake가 완료되고 연결이 수립된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-21 오후 4.42.34.png&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;856&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mc3TT/dJMcajuMiWG/sG8B9ybODKobkJv3YRKhZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mc3TT/dJMcajuMiWG/sG8B9ybODKobkJv3YRKhZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mc3TT/dJMcajuMiWG/sG8B9ybODKobkJv3YRKhZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmc3TT%2FdJMcajuMiWG%2FsG8B9ybODKobkJv3YRKhZk%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;1894&quot; height=&quot;856&quot; data-filename=&quot;스크린샷 2026-03-21 오후 4.42.34.png&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;856&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;ACCEPT Queue에 있는 연결은 서버 애플리케이션이 &lt;b&gt;accept()&lt;/b&gt;를 호출할 때 꺼내간다. accept()는 시스템 콜로, 유저 영역에서 실행되던 코드가 커널 영역으로 전환되어 커널 메모리에 접근이 가능해진다. 이때 ACCEPT Queue에 있던 struct sock을 꺼내 &lt;b&gt;struct socket&lt;/b&gt;을 커널 메모리에 생성한다. struct socket은 struct sock을 감싸는 래퍼 객체로, 유저 공간과의 인터페이스 역할을 한다. 실제 수신 버퍼와 송신 버퍼는 struct sock 내부에 존재하며 이를 통해 데이터를 송수신한다.&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;다만 struct socket은 커널 메모리에 존재하기 때문에 애플리케이션이 직접 접근할 수 없어, accept()의 반환값으로 &lt;b&gt;파일 디스크립터 번호&lt;/b&gt;를 돌려준다. 애플리케이션은 이 번호를 이용해 read(), write() 등의 시스템 콜을 호출할 수 있고, 커널은 파일 디스크립터 번호를 보고 대응하는 struct socket을 찾아 struct sock의 버퍼에서 데이터를 읽거나 쓰는 작업을 수행한다. 즉 애플리케이션은 파일 디스크립터라는 티켓 번호만 들고 있으면 되고, 커널이 그 번호를 통해 실제 소켓 구조체를 찾아 데이터를 처리해주는 것이다.&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;그렇다면 tomcat을 사용한다고 했을때 accept queue에 있던 sock을 언제 꺼내고, 해당 소켓으로 데이터가 왔을때 어떻게 스레드를 할당 받을까 ?&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;Tomcat에는 &lt;b&gt;Acceptor 스레드&lt;/b&gt;가 있어 accept() 시스템 콜을 호출하며 ACCEPT Queue에 연결이 올 때까지 블로킹 대기한다. 연결이 오면 accept()가 반환되고 파일 디스크립터를 획득하여 해당 소켓을 &lt;b&gt;Poller에 이벤트로 등록&lt;/b&gt;한다. 이벤트로 등록하는 이유는 이 시점이 TCP 3-way handshake만 완료된 상태로, 클라이언트가 아직 데이터를 보내기 전이기 때문이다. Poller 스레드는 등록된 소켓들을 epoll로 감시하다가 데이터가 도착한 소켓이 있으면 &lt;b&gt;Selector에게 넘긴다&lt;/b&gt;. Selector는 해당 소켓을 &lt;b&gt;Worker Queue&lt;/b&gt;에 넣고, Worker Thread Pool에서 유휴 스레드가 있으면 Worker Queue에서 소켓을 꺼내 실제 HTTP 요청을 처리하게 된다. 만약 유휴 스레드가 없다면 스레드가 반납될 때까지 Worker Queue에서 대기하게 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.01.47.png&quot; data-origin-width=&quot;1642&quot; data-origin-height=&quot;980&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/diyF4M/dJMcagri0Nv/upbr9KEszP74Du0R430xsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/diyF4M/dJMcagri0Nv/upbr9KEszP74Du0R430xsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/diyF4M/dJMcagri0Nv/upbr9KEszP74Du0R430xsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdiyF4M%2FdJMcagri0Nv%2Fupbr9KEszP74Du0R430xsK%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;1642&quot; height=&quot;980&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.01.47.png&quot; data-origin-width=&quot;1642&quot; data-origin-height=&quot;980&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터 송신, 수신시 OS 내부적으로 일어나는 과정&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 송신 즉 애플리케이션에서 write 시스템 콜을 호출하면 OS 내부적으로 어떤일이 발생할까?&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;애플리케이션이 write() 시스템 콜을 호출하면 파일 디스크립터 번호에 해당하는 struct socket을 찾고, 전달한 데이터를 struct sock의 송신 버퍼에 복사한다. 커널은 이 데이터를 MSS 크기에 맞게 잘라 TCP 세그먼트를 만들고, IP 헤더를 붙여 IP 데이터그램을 만든 뒤 최종적으로 패킷을 완성한다. 완성된 패킷은 qdisc에 들어가 우선순위와 순서가 결정된다. 이후 드라이버가 qdisc에서 패킷을 꺼내 TX Ring Buffer에 써주면, NIC가 DMA로 TX Ring Buffer에서 패킷을 읽어 물리 신호로 변환해 전송한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.09.48.png&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;850&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l9eQ9/dJMcafTt3Mz/YuVetAcTzWkTs3mDcZkJS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l9eQ9/dJMcafTt3Mz/YuVetAcTzWkTs3mDcZkJS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l9eQ9/dJMcafTt3Mz/YuVetAcTzWkTs3mDcZkJS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl9eQ9%2FdJMcafTt3Mz%2FYuVetAcTzWkTs3mDcZkJS0%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;515&quot; height=&quot;608&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.09.48.png&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TX Ring Buffer는 RX Ring Buffer와 비슷하게 NIC이 패킷 송신을 위해 보관해두는 커널 메모리에 있는 영역이다.&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;에는 NIC가 패킷을 수신하고 DMA를 통해 RX Ring Buffer에 저장한다. 이후 하드웨어 인터럽트를 발생시키고 softirq가 실행되어 RX Ring Buffer에서 패킷을 꺼낸다. 커널 네트워크 스택을 거치며 IP 헤더를 제거하고 TCP 레이어까지 올라오면, 해당 패킷이 어느 연결의 것인지 파악해야 한다. 이때 커널은 내부적으로 &lt;b&gt;해시 테이블&lt;/b&gt;을 이용하는데, 패킷의 출발지 IP, 목적지 IP, 출발지 Port, 목적지 Port 4가지를 키로 하여 대응하는 struct sock을 찾는다. 찾은 struct sock의 수신 버퍼에 데이터를 쌓는다.&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;애플리케이션이 read() 시스템 콜을 호출하면 파일 디스크립터 번호에 해당하는 struct socket을 찾고, struct sock의 수신 버퍼에서 데이터를 꺼내 애플리케이션 메모리로 복사해준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.15.17.png&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;828&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boK6WK/dJMcaio8NrQ/H7LycP37OcDDvKGHQzfQgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boK6WK/dJMcaio8NrQ/H7LycP37OcDDvKGHQzfQgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boK6WK/dJMcaio8NrQ/H7LycP37OcDDvKGHQzfQgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboK6WK%2FdJMcaio8NrQ%2FH7LycP37OcDDvKGHQzfQgk%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;514&quot; height=&quot;591&quot; data-filename=&quot;스크린샷 2026-03-21 오후 5.15.17.png&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;828&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;h4 data-ke-size=&quot;size20&quot;&gt;끝으로&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OS 내부적으로 이런 것들을 알아서 뭐에 쓸까 생각할 수도 있지만, 대규모 트래픽이 몰려올 때 이런 내부 구조를 깊이 이해하고 있어야 장애에 제대로 대응할 수 있다.&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;예를 들어 갑작스러운 트래픽 폭증 시 SYN Queue 크기를 적절히 튜닝해놓지 않았다면 TCP 3-way handshake조차 완료하지 못하는 클라이언트가 생길 수 있다. 마찬가지로 ACCEPT Queue가 너무 작다면 handshake는 완료됐지만 애플리케이션이 연결을 꺼내가기 전에 드롭되는 상황이 발생한다. RX Ring Buffer가 부족하다면 그보다 더 앞단에서 패킷 자체가 드롭된다. 결국 트래픽이 몰릴 때 어느 지점에서 드롭이 발생하는지 파악하지 못하면 원인도 모른 채 장애를 맞이하게 된다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고자료&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://brunch.co.kr/@growthminder/23&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://brunch.co.kr/@growthminder/23&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1774081671927&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;네트워크 커널 튜닝&quot; data-og-description=&quot;할 수 있을 듯 하기 어려운 리눅스 커널 튜닝 | 대량의 트래픽이 발생할 때를 대비해야 한다면..? 신상품 공개, 콘서트 티켓 세일 등과 같은 이벤트가 있는 경우, 웹사이트에 순간적으로 엄청난 &quot; data-og-host=&quot;brunch.co.kr&quot; data-og-source-url=&quot;https://brunch.co.kr/@growthminder/23&quot; data-og-url=&quot;https://brunch.co.kr/@growthminder/23&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/buru3s/dJMb85vOcpC/RB0FJqkuKSJ6IC1cUsAiH1/img.jpg?width=1280&amp;amp;height=1725&amp;amp;face=0_0_1280_1725,https://scrap.kakaocdn.net/dn/bvaH2P/dJMb8952RbF/b0PgvGXUrxaImzZZkjbMPK/img.jpg?width=1280&amp;amp;height=1725&amp;amp;face=0_0_1280_1725&quot;&gt;&lt;a href=&quot;https://brunch.co.kr/@growthminder/23&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://brunch.co.kr/@growthminder/23&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/buru3s/dJMb85vOcpC/RB0FJqkuKSJ6IC1cUsAiH1/img.jpg?width=1280&amp;amp;height=1725&amp;amp;face=0_0_1280_1725,https://scrap.kakaocdn.net/dn/bvaH2P/dJMb8952RbF/b0PgvGXUrxaImzZZkjbMPK/img.jpg?width=1280&amp;amp;height=1725&amp;amp;face=0_0_1280_1725');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;네트워크 커널 튜닝&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;할 수 있을 듯 하기 어려운 리눅스 커널 튜닝 | 대량의 트래픽이 발생할 때를 대비해야 한다면..? 신상품 공개, 콘서트 티켓 세일 등과 같은 이벤트가 있는 경우, 웹사이트에 순간적으로 엄청난&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;brunch.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://d2.naver.com/helloworld/47667&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://d2.naver.com/helloworld/47667&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>OS</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/41</guid>
      <comments>https://dlwogns3413.tistory.com/41#entry41comment</comments>
      <pubDate>Sat, 21 Mar 2026 17:29:55 +0900</pubDate>
    </item>
    <item>
      <title>카카오 2026 신입 공채 2차 인터뷰 불합격</title>
      <link>https://dlwogns3413.tistory.com/40</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-18 오후 5.56.25.png&quot; data-origin-width=&quot;994&quot; data-origin-height=&quot;515&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/co8JXk/dJMb99ZfscY/i0nlN3bCHPaAHxpnZia7c1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/co8JXk/dJMb99ZfscY/i0nlN3bCHPaAHxpnZia7c1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/co8JXk/dJMb99ZfscY/i0nlN3bCHPaAHxpnZia7c1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fco8JXk%2FdJMb99ZfscY%2Fi0nlN3bCHPaAHxpnZia7c1%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;994&quot; height=&quot;515&quot; data-filename=&quot;스크린샷 2025-12-18 오후 5.56.25.png&quot; data-origin-width=&quot;994&quot; data-origin-height=&quot;515&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭐 어찌됐건 마지막 단계를 넘지 못했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 지금 당장은 뭘 하지를 못하겠다. 기대는 안 하고 있었지만 막상 실제로 불합격을 받으니 아무것도 손에 안 잡힌다.&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차 면접에서는 기술, 인성이 3:7 비율로 나왔는데, 기술 질문에 대해서는 모두 답변하였지만, 꼬리질문에서 내가 너무 빠른 답변을 한 게 아쉽긴 하다. 좀 더 생각하고 구체적으로 말해야 하는 걸 너무 두리뭉실 말해 면접관 입장에서도 더 이상 꼬리질문할게 생각이 안 나 질문을 그만두는 느낌을 받았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인성에서는 너무 열심히 준비한게 패착 요인이 아니었나 싶다. 면접관과 이야기하는 느낌이 아닌 어떻게 해서든 내가 대비한 질문과 비슷한 질문을 받으면 암기한 답변을 얘기하느라 바빴다. 좀 더 자연스럽게 얘기하는 분위기였다면 어땠을까 하는 생각도 든다.&lt;/p&gt;
&lt;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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쨌든 이만큼 온 것만으로도 잘했다고 생각하고, 1차 면접에서 합격한 것만으로도 지금까지 내가 잘못된 방향으로 가고 있지는 않구나라고 생각이 들게 해 준 곳이라 감사하기도 하다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>회고</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/40</guid>
      <comments>https://dlwogns3413.tistory.com/40#entry40comment</comments>
      <pubDate>Thu, 18 Dec 2025 18:14:55 +0900</pubDate>
    </item>
    <item>
      <title>이메일 확실히 보내기 4편 - 토큰 버킷 알고리즘 기반 유량 제어</title>
      <link>https://dlwogns3413.tistory.com/39</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;들어가며&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://dlwogns3413.tistory.com/36&quot;&gt;1편&lt;/a&gt;에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Gmail SMTP의 하루 발송량 제한인 500건을 넘지 않기 위해 BCC를 도입하여 발송량 자체를 줄였다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://dlwogns3413.tistory.com/37&quot;&gt;2편&lt;/a&gt;에서는 서킷브레이커 패턴을 활용해&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Gmail SMTP의 하루 발송량을 초과하더라도 정상적으로 이메일을 발송할 수 있는 시스템을 구현하였다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;a title=&quot;3편&quot; href=&quot;https://dlwogns3413.tistory.com/38&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;3편&lt;/a&gt;에서는 Transactional Outbox 패턴을 적용하여 &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;네트워크 장애, 비동기 스레드 풀 포화, 애플리케이션 종료 상황에서도 최소 1회 이메일 전송을 보장하였다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이번 편에서는 어떻게&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;AWS SES의 초당 14건 전송 제한에 맞춰 초당 전송량 초과 예외 없이 이메일을 전송했는지에 관해 작성할 예정이다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;배경&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 운영 중 다음과 같은 에러가 발생했다.&lt;/p&gt;
&lt;pre id=&quot;code_1765692419952&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.springframework.mail.MailSendException: Failed messages: org.eclipse.angus.mail.smtp.SMTPSendFailedException: 454 Throttling failure: Maximum sending rate exceeded&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;이 예외는 AWS SES의 초당 전송률을 초과해서 발생한 것이다. 현재 우리 서비스의 AWS SES 초당 전송 제한은 14건이다.&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;스크린샷 2025-12-14 오후 3.10.47.png&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;216&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vZ4nF/dJMcajnbEoL/O4TU8VGAn53p6KDxkyv3S0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vZ4nF/dJMcajnbEoL/O4TU8VGAn53p6KDxkyv3S0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vZ4nF/dJMcajnbEoL/O4TU8VGAn53p6KDxkyv3S0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvZ4nF%2FdJMcajnbEoL%2FO4TU8VGAn53p6KDxkyv3S0%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;823&quot; height=&quot;216&quot; data-filename=&quot;스크린샷 2025-12-14 오후 3.10.47.png&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;216&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 이메일 전송이 일어나는 지점은 총 5곳이다.&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;이벤트 참여 마감 30분 전 리마인드 이메일&lt;/li&gt;
&lt;li&gt;이벤트 시작 1시간 전 리마인드 이메일&lt;/li&gt;
&lt;li&gt;전송 실패한 이메일 재전송&lt;/li&gt;
&lt;li&gt;이벤트 주최자가 선택한 참여자에게 이메일 전송&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 여러 지점에서 특정 시간대에 이메일 전송이 몰리면서 순간적으로 초당 14건 제한을 넘어서 발생한 것으로 보인다.&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;예외가 발생해 이메일 전송에 실패하더라도, 이전에 적용한 Transaction Outbox 패턴 덕분에 실패한 이메일은 재전송되어 언젠가는 사용자에게 전달된다.&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;하지만 초당 14건 제한을 초과하는 예외가 계속 발생한다면, 사용자에게 이메일을 즉시 전달하지 못할 뿐만 아니라, 재전송 스케줄러가 처리해야 할 이메일이 계속 쌓이면서 스케줄러에 부담이 가중되고 최악의 경우 이는 다시 초당 전송률 초과를 유발하는 악순환이 발생할 수 있다.&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;가능한 방법들&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #3d4144; text-align: start;&quot;&gt;해당 에러를 방지하기 위한 가장 쉬운 방법으로는 AWS SES 발신 한도 증가 요청을 하는 방법이다. 하지만 이 방법을 택하기에는 주어진 전송 할당량을 다 사용하지도 않았을 뿐더러, 요청이 무조건 받아들여진다는 보장도 없기 때문에 최후의 방법으로 남겨두고자 했다.&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;해결해야 할 문제는 초당 14건의 전송 속도에 맞춰 이메일을 전송하는 것이다. 만약 초당 14건을 넘는 이메일이 발생하면, 1초에 최대 14건만 전송되도록 제어하고 나머지는 대기시켜야 한다.&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;예외를 터뜨려서 실패 처리하는 방법도 있지만, 그럼 불필요한 실패/재시도 사이클이 발생한다. 예외가 발생하면 Outbox 패턴에 의해 재전송 큐에 쌓이고, 스케줄러가 다시 재시도하게 된다. 이는 시스템에 불필요한 부하를 발생시킨다. 차라리 애초에 예외를 발생시키지 않고 적절히 대기했다가 전송하면, 실패 없이 바로 전송 완료할 수 있어 더 효율적이다.&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;처음에는 세마포어로 14개의 이메일 요청만 동시에 실행되게 하고 나머지는 대기시키면 되지 않을까 생각했지만, 이는 전혀 해결법이 아니었다. 세마포어는 동시에 실행 중인 작업을 제한하는 것이지 초당 전송 횟수를 제한하는 것이 아니다. 만약 이메일 전송이 0.1초만에 끝난다면, 세마포어가 14개여도 1초에 140건이 전송될 수 있다.&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;그래서 초당 14건의 이메일 전송만 허용하도록 해야 했는데, 이를 위한 알고리즘은 크게 3가지가 있었다.&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;/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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;슬라이딩 윈도우 로그 알고리즘&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬라이딩 윈도우 로그 알고리즘이 어떻게 동작하는지 단계별로 살펴보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.39.14.png&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;324&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUos89/dJMcaihzkQX/fP68MLkznkqWhJGR67oVT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUos89/dJMcaihzkQX/fP68MLkznkqWhJGR67oVT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUos89/dJMcaihzkQX/fP68MLkznkqWhJGR67oVT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUos89%2FdJMcaihzkQX%2FfP68MLkznkqWhJGR67oVT0%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;1142&quot; height=&quot;324&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.39.14.png&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;324&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12/12 15:00:01.100에 첫 번째 이메일 전송 요청이 들어왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 윈도우(1초) 내에 발송한 이메일이 없으므로 바로 AWS SES로 전송된다. 동시에 이 요청의 타임스탬프를 로그에 기록한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.39.44.png&quot; data-origin-width=&quot;1132&quot; data-origin-height=&quot;330&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cPRbdn/dJMcaaw4i8m/O9EWi9DAxV54FXEV7sqD4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cPRbdn/dJMcaaw4i8m/O9EWi9DAxV54FXEV7sqD4k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cPRbdn/dJMcaaw4i8m/O9EWi9DAxV54FXEV7sqD4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcPRbdn%2FdJMcaaw4i8m%2FO9EWi9DAxV54FXEV7sqD4k%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;1132&quot; height=&quot;330&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.39.44.png&quot; data-origin-width=&quot;1132&quot; data-origin-height=&quot;330&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12/12 15:00:01.200에 두 번째 요청이 들어왔다.&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초 이내(15:00:00.200 ~ 15:00:01.200)에 발송한 이메일은 1건이다. 14건 미만이므로 바로 전송되고, 이 요청도 로그에 추가된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.40.15.png&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;422&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/P5rQI/dJMcafd4L3O/IFkoDG8IzyU3PRI0NoeVzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/P5rQI/dJMcafd4L3O/IFkoDG8IzyU3PRI0NoeVzk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/P5rQI/dJMcafd4L3O/IFkoDG8IzyU3PRI0NoeVzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FP5rQI%2FdJMcafd4L3O%2FIFkoDG8IzyU3PRI0NoeVzk%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;1148&quot; height=&quot;422&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.40.15.png&quot; data-origin-width=&quot;1148&quot; data-origin-height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12/12 15:00:01.400에 새로운 요청이 들어왔다.&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초 이내(15:00:00.400 ~ 15:00:01.400)에 이미 14건이 전송되어 있다. 로그를 확인하면 15:00:01.100부터 15:00:01.812까지 14개의 타임스탬프가 기록되어 있다.&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;초당 14건 제한에 도달했으므로 이 요청은 대기해야 한다.&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;가장 오래된 로그(15:00:01.100)가 윈도우 밖으로 나가는 시점 = 15:00:01.100 + 1초 = 15:00:02.100&lt;/li&gt;
&lt;li&gt;대기 시간 = 15:00:02.100 - 15:00:01.400 = 0.7초&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 0.7초 후에 가장 오래된 로그가 윈도우 밖으로 나가면서 슬롯이 하나 비게 되고, 그때 대기 중인 요청이 실행될 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.42.23.png&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJ1oCC/dJMcai9EP3H/MLRYKjDIGd1qyYIEDpLtJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJ1oCC/dJMcai9EP3H/MLRYKjDIGd1qyYIEDpLtJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJ1oCC/dJMcai9EP3H/MLRYKjDIGd1qyYIEDpLtJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJ1oCC%2FdJMcai9EP3H%2FMLRYKjDIGd1qyYIEDpLtJk%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;1142&quot; height=&quot;400&quot; data-filename=&quot;스크린샷 2025-12-14 오후 4.42.23.png&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12/12 15:00:02.101이 되었다.&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;가장 오래된 로그인 15:00:01.100이 현재 시간 기준 1초를 넘어갔다(15:00:01.101 ~ 15:00:02.101 윈도우에서 벗어남). 이 로그를 제거하면 윈도우 내 발송 건수가 13건이 되고, 대기하던 요청이 실행될 수 있다.&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;대기 중이던 15:00:01.400 요청이 AWS SES로 전송되고, 15:00:01.200부터 15:00:01.812, 그리고 새로 추가된 타임스탬프가 로그에 남는다.&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초 이내의 로그를 확인하고, 오래된 로그를 제거하면서 정확히 초당 14건을 유지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬라이딩 윈도우 로그 알고리즘을 통해 구현한다면 장단점은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;정확히 초당 14건을 보장할 수 있다&lt;/b&gt;. 어느 순간의 윈도를 보더라도, 허용되는 요청의 개수는 14건을 초과하지 않는다.&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;&lt;b&gt;메모리 사용량 증가&lt;br /&gt;&lt;/b&gt;모든 요청의 타임스탬프를 저장해야 하므로 메모리 사용량이 많다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구현 복잡도&lt;br /&gt;&lt;/b&gt;슬라이딩 윈도우 방식으로 1초 내 로그를 유지하려면 다음과 같은 추가 구현이 필요하다.
&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;li&gt;매 요청마다 현재 1초 내에 몇 건이 발송되었는지 계산&lt;/li&gt;
&lt;li&gt;14건이 꽉 찬 경우 대기 시간을 계산하고 해당 시간만큼 대기하는 로직&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;라이브러리 부재&lt;br /&gt;&lt;/b&gt;슬라이딩 윈도우 로그 알고리즘을 구현한 Java 라이브러리로 ratelimitj가 존재하지만, 현재는 더 이상 지원하지 않는 라이브러리다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;누출 버킷 알고리즘&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누출 버킷 알고리즘은 물이 일정한 속도로 새는 양동이를 떠올리면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;요청이 들어오면 버킷(큐)에 담는다&lt;/li&gt;
&lt;li&gt;버킷에서 &lt;b&gt;고정된 속도로&lt;/b&gt; 요청을 꺼내서 처리한다 (초당 14건)&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-14 오후 7.51.09.png&quot; data-origin-width=&quot;2340&quot; data-origin-height=&quot;452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qqXZv/dJMcac9rSH2/K0kKmkuIqDOSx7pINXe9l0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qqXZv/dJMcac9rSH2/K0kKmkuIqDOSx7pINXe9l0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qqXZv/dJMcac9rSH2/K0kKmkuIqDOSx7pINXe9l0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqqXZv%2FdJMcac9rSH2%2FK0kKmkuIqDOSx7pINXe9l0%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;2340&quot; height=&quot;452&quot; data-filename=&quot;스크린샷 2025-12-14 오후 7.51.09.png&quot; data-origin-width=&quot;2340&quot; data-origin-height=&quot;452&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;이메일 전송 요청이 들어오면 큐에 담고, 큐에 담긴 요청은 71ms마다 1건씩 AWS SES로 전송된다. 초당 14건을 보낼 수 있으니 1000ms / 14 = 약 71ms 간격으로 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;누출 버킷 알고리즘의 장단점은 다음과 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;고정된 처리율(71ms마다 1건)&lt;/b&gt;을 갖고 있기 때문에 안정적 출력이 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;메모리 사용량&lt;br /&gt;&lt;/b&gt;일반적으로 누출 버킷 알고리즘이 메모리 효율적이라고 알려져 있지만, 우리 상황에서는 오히려 비효율적이다.&lt;b&gt;&lt;br /&gt;&lt;/b&gt;누출 버킷을 구현하려면 큐에 이메일 전송 요청을 저장해야 하는데, 여기에는 수신자 정보, 이메일 본문 등 상당히 큰 데이터가 포함된다. 큐에 이런 데이터들이 쌓이면 메모리 사용량이 크게 증가한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구현 복잡도&lt;br /&gt;&lt;/b&gt;구현이 복잡하다는 점도 문제다. 별도로 큐를 두고 71ms마다 1건씩 AWS SES로 전송하는 워커를 구현해야 하며, 큐가 임계영역이므로 동시성 제어도 필요하다. 또한 큐가 가득 차면 요청을 대기시키고, 큐가 비워지면 대기 중인 요청을 깨우는 로직도 구현해야 한다. 이 모든 것을 직접 구현해야 한다는 점에서 부담이 크다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 환경에서 구현이 더욱 복잡함&lt;br /&gt;&lt;/b&gt;서버가 여러 대로 확장될 경우 더욱 복잡해진다. 큐를 Redis 같은 공유 저장소에 두어야 하고, 71ms마다 큐에서 꺼내는 워커는 딱 한 서버에서만 실행되어야 한다. 여러 서버에서 동시에 워커가 돌면 초당 14건 제한을 초과해서 전송하게 되기 때문이다. 따라서 특정 서버 하나만 워커를 담당하도록 해야 하는데, 그 서버가 죽으면 다른 서버가 워커를 이어받는 로직을 구현해야 한다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;토큰 버킷 알고리즘&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 버킷 알고리즘은 버킷에 토큰을 채워두고, 요청이 올 때마다 토큰을 소비하는 방식이다. 그리고 주기적으로 토큰을 채운다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초당 14건을 전송할 수 있다면, 버킷에 최대 14개의 토큰을 보관할 수 있다. 매초마다 버킷에 14개의 토큰이 채워지고, 이메일을 전송할 때마다 토큰 1개를 소비한다. 토큰이 없으면 토큰이 채워질 때까지 대기한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동작 과정&lt;/b&gt;&lt;/p&gt;
&lt;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;토큰이 있다면 토큰을 소비하고 AWS SES로 요청을 보낸다&lt;/li&gt;
&lt;li&gt;토큰이 없다면 토큰이 생길 때까지 대기한다&lt;/li&gt;
&lt;li&gt;매초마다 14개의 토큰이 채워진다&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단계별 동작 과정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: 초기 상태 (01:00:00.000)&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;스크린샷 2025-12-15 오전 9.04.27.png&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;524&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2U6aB/dJMcad1zUhr/hcr8JHtXyielo0qMhdqCnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2U6aB/dJMcad1zUhr/hcr8JHtXyielo0qMhdqCnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2U6aB/dJMcad1zUhr/hcr8JHtXyielo0qMhdqCnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2U6aB%2FdJMcad1zUhr%2Fhcr8JHtXyielo0qMhdqCnk%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;1182&quot; height=&quot;524&quot; data-filename=&quot;스크린샷 2025-12-15 오전 9.04.27.png&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;524&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버킷에는 14개의 토큰이 있다. 요청 1건이 들어왔다. 토큰 1개를 소비하고 AWS SES로 이메일을 전송한다. 남은 토큰은 13개이다.&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단계: 버스트 트래픽 (01:00:00.100)&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;스크린샷 2025-12-15 오전 9.05.31.png&quot; data-origin-width=&quot;1152&quot; data-origin-height=&quot;534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/phT5n/dJMcad1zUhz/fcecxkuI7GHMvOphFoK6Tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/phT5n/dJMcad1zUhz/fcecxkuI7GHMvOphFoK6Tk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/phT5n/dJMcad1zUhz/fcecxkuI7GHMvOphFoK6Tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FphT5n%2FdJMcad1zUhz%2FfcecxkuI7GHMvOphFoK6Tk%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;1152&quot; height=&quot;534&quot; data-filename=&quot;스크린샷 2025-12-15 오전 9.05.31.png&quot; data-origin-width=&quot;1152&quot; data-origin-height=&quot;534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;갑자기 13건의 요청이 동시에 들어왔다. 아직 토큰이 13개 남아있기 때문에, 모든 요청이 각각 토큰을 소비하고 AWS SES로 전송된다. 남은 토큰은 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;b&gt;3단계: 토큰 부족 (01:00:00.200)&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;스크린샷 2025-12-15 오전 9.07.08.png&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pyuZC/dJMcajnbUek/ixAqKw6su4HTs1VLl6g8ek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pyuZC/dJMcajnbUek/ixAqKw6su4HTs1VLl6g8ek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pyuZC/dJMcajnbUek/ixAqKw6su4HTs1VLl6g8ek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpyuZC%2FdJMcajnbUek%2FixAqKw6su4HTs1VLl6g8ek%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;1140&quot; height=&quot;534&quot; data-filename=&quot;스크린샷 2025-12-15 오전 9.07.08.png&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 요청이 들어왔지만 토큰이 모두 소진되었다. 이 요청은 토큰이 채워질 때까지 대기한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4단계: 토큰 충전 (01:00:01.000)&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;스크린샷 2025-12-15 오전 9.08.02.png&quot; data-origin-width=&quot;1136&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AaDu7/dJMcacuTi6Z/r70lDX3xT1X1KuI3u89Ou0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AaDu7/dJMcacuTi6Z/r70lDX3xT1X1KuI3u89Ou0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AaDu7/dJMcacuTi6Z/r70lDX3xT1X1KuI3u89Ou0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAaDu7%2FdJMcacuTi6Z%2Fr70lDX3xT1X1KuI3u89Ou0%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;1136&quot; height=&quot;538&quot; data-filename=&quot;스크린샷 2025-12-15 오전 9.08.02.png&quot; data-origin-width=&quot;1136&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1초가 지나 14개의 토큰이 다시 채워졌다. 대기 중이던 요청이 깨어나 토큰을 소비하고 AWS SES로 이메일을 전송한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 버킷 알고리즘의 장단점은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;메모리 사용량이 효율적이다.&lt;br /&gt;&lt;/b&gt;토큰 개수만 저장하면 된다. 슬라이딩 윈도우처럼 모든 타임스탬프를 저장하거나, 누출 버킷처럼 큐에 이메일 데이터를 저장할 필요가 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;구현이 다른 알고리즘에 비해 단순하다.&lt;br /&gt;&lt;/b&gt;별도의 워커나 스케줄러 없이 요청이 올 때마다 토큰만 확인하고 소비하면 된다. 누출 버킷처럼 71ms마다 돌아가는 워커를 구현할 필요가 없다.&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;검증된 라이브러리가 존재한다.&lt;br /&gt;&lt;/b&gt;Guava의 RateLimiter, Bucket4j, Resilience4j 등 이미 검증된 라이브러리들이 많아 직접 구현할 필요가 없다.&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 환경에서도 구현이 간단하다.&lt;br /&gt;&lt;/b&gt;서버가 여러 대로 확장되어도 구현이 간단하다. Redis에 토큰 개수만 저장하고, 각 서버에서 독립적으로 토큰을 차감하면 된다. 누출 버킷처럼 특정 서버 하나가 워커를 담당할 필요도 없고, 서버가 죽었을 때 워커를 이어받는 복잡한 로직도 필요 없다.&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;짧은 시간에 집중되는 트래픽도 처리 가능하다.&lt;br /&gt;&lt;/b&gt;토큰만 존재한다면 순간적으로 토큰 개수만큼 요청을 한 번에 처리할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;버킷 크기와 토큰 공급률이라는 두 개의 인자를 가지고 있는데, 이 값을 적절하게 튜닝하기가 어렵다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;누출 버킷 알고리즘은 별도 워커 구현, 동시성 제어, 분산 환경 처리 등 구현이 복잡해 제외했다. 슬라이딩 윈도우 로그 알고리즘과 토큰 버킷 알고리즘을 고민했는데, 토큰 버킷이 메모리 사용량 면에서 더 효율적이었다. 슬라이딩 윈도우는 모든 타임스탬프를 저장해야 하지만, 토큰 버킷은 토큰 개수와 마지막 갱신 시간만 저장하면 된다. 또한 Guava, Bucket4j 같은 검증된 라이브러리도 존재했다.&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;무엇보다 AWS SES 공식 문서를 보니 다음과 같이 명시되어 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-15 오전 10.50.47.png&quot; data-origin-width=&quot;594&quot; data-origin-height=&quot;101&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKfLte/dJMb99LG8es/5ERcmYfB2kLjEWfdXxrsf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKfLte/dJMb99LG8es/5ERcmYfB2kLjEWfdXxrsf1/img.png&quot; data-alt=&quot;AWS SES 공식문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKfLte/dJMb99LG8es/5ERcmYfB2kLjEWfdXxrsf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKfLte%2FdJMb99LG8es%2F5ERcmYfB2kLjEWfdXxrsf1%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;594&quot; height=&quot;101&quot; data-filename=&quot;스크린샷 2025-12-15 오전 10.50.47.png&quot; data-origin-width=&quot;594&quot; data-origin-height=&quot;101&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;AWS SES 공식문서&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;AWS SES 공식 문서를 보니 짧은 순간 동안은 초당 전송률을 초과해 보낼 수 있다고 명시되어 있었다. 즉, 초당 14건을 엄격하게 지킬 필요는 없기도 하고 위의 이유들로 인해 토큰 버킷 알고리즘을 선택했다.&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;라이브러리 선택&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현하기에 앞서 어떤 라이브러리를 선택해야할지 정해야한다. 토큰 버킷 알고리즘을 구현하고 있는 라이브러리는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Guava, Bucket4j, Resilience4j 이렇게 있다.&amp;nbsp;&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;이중 우리는 Bucket4j를 사용하기로 했는데, Bucket4j를 선택한 가장 큰 이유는 &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;span style=&quot;color: #333333; text-align: start;&quot;&gt;Guava RateLimiter와 Resilience4j는 기본적으로 로컬 in-memory만 지원하기 때문에 서버가 여러 대로 확장되면 각 서버가 독립적으로 초당 14건씩 전송하게 된다. 예를 들어 서버가 3대라면 전체적으로는 초당 42건이 전송되어 AWS SES 제한을 초과하게 된다. 반면 Bucket4j는 Redis 같은 분산 환경도 지원해서 여러 서버가 하나의 버킷을 공유할 수 있다. 따라서 서버가 몇 대든 전체 합쳐서 정확히 초당 14건만 전송된다.&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;구현&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 버킷 크기와 리필 주기를 설정해야 한다. 버킷 크기는 14개로 설정했고, 1초마다 14개의 토큰이 리필되도록 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1765765286060&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Bandwidth limit = Bandwidth.builder()
	.capacity(14)
	.refillGreedy(14, Duration.ofSeconds(1))
	.build();&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;리필 전략은 크게 Greedy 전략과 Intervally 전략이 있다. &lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Greedy 전략&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간에 비례해서 토큰을 지속적으로 채운다. 예를 들어 0.5초가 지나면 7개의 토큰이 채워지고, 1초가 지나면 14개가 채워진다. 따라서 토큰이 모두 소진되어도 0.5초만 기다리면 7개를 사용할 수 있어 대기 시간을 최소화할 수 있다.&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;Intervally 전략&lt;br /&gt;&lt;/b&gt;정해진 간격마다 한 번에 모든 토큰을 채운다. 1초마다 14개를 한 번에 채우는 방식이다. 토큰이 소진되면 무조건 1초를 기다려야 하므로, 0.5초나 0.9초를 기다려도 토큰이 생기지 않는다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 Greedy 전략을 선택했는데, 그 이유는 토큰이 부족할 때 대기 시간을 최소화하고 즉시 처리할 수 있기 때문이다.&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;하지만 Greedy 방식을 사용하면 특정 1초 구간에서 14개 이상의 이메일이 전송될 수 있다. 예를 들어 00:00:00에 14건을 전송하고, 00:00:00.5에 7개의 토큰이 리필되어 추가로 7건을 전송하면, 해당 1초 구간에 총 21건이 전송된다. 이는 초당 14건 제한을 초과하는 것이다.&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;다만 AWS SES는 짧은 순간의 버스트를 허용하고, Greedy 방식은 장기적으로 평균 초당 14건을 유지한다. 또한 요청이 들어왔을 때 즉시 처리할 수 있어 사용자 경험도 더 좋다. 만약 실제 운영 중에 throttling 에러가 계속 발생한다면, Intervally 전략으로 변경하거나 버킷 크기를 줄이는 방법을 고려할 예정이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;렇게 버킷을 만들어 다음과 같이 이메일을 전송하기 전에 토큰을 소비하고 전송한다.&lt;/p&gt;
&lt;pre id=&quot;code_1765765921339&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;try {
    boolean success = bucket.asBlocking()
            .tryConsume(1, Duration.ofSeconds(5)); // 최대 5초 대기
    
    if (!success) {
        throw new RateLimitException(&quot;대기 시간 초과: 5초 내에 토큰을 얻지 못했습니다&quot;);
    }
    
    sendEmail();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException(&quot;Rate limiting interrupted&quot;, e);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰이 있다면 즉시 이메일을 전송하고, 토큰이 없다면 다음 토큰이 생길 때까지 대기한다. 이때 5초 동안 토큰을 얻지 못하면 예외를 발생시켰다. 그 이유는 무한정 대기할 경우 스레드가 계속 블로킹되어 스레드 풀이 고갈될 수 있기 때문이다. 어차피 예외가 발생하면 Transaction Outbox 패턴에 의해 재전송 큐에 들어가고, 스케줄러가 나중에 재시도하므로 이메일 전송 자체는 보장된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h3&gt;
&lt;div data-test-render-count=&quot;1&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div data-state=&quot;closed&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS SES의 초당 14건 전송 제한을 초과하여 발생하는 throttling 에러를 해결하기 위해 Rate Limiting을 구현했다.&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;라이브러리는 Bucket4j를 선택했다. Guava와 Resilience4j는 로컬 in-memory만 지원하지만, 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;구현 시 Greedy 리필 전략을 선택해 대기 시간을 최소화했고, 5초 타임아웃을 설정해 스레드 풀 고갈을 방지했다. Greedy 방식은 특정 1초 구간에서 14건을 초과할 수 있지만, AWS SES가 짧은 버스트를 허용하고 장기적으로는 평균을 유지하므로 문제없다고 판단하였다.&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;이제 여러 지점에서 이메일 전송이 동시에 발생해도 throttling 에러 없이 안정적으로 이메일을 전송할 수 있게 되었다. 만약 실제 운영 중에 문제가 발생한다면 Intervally 전략으로 변경하거나 버킷 크기를 조정할 예정이다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div data-state=&quot;closed&quot;&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div data-state=&quot;closed&quot;&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/39</guid>
      <comments>https://dlwogns3413.tistory.com/39#entry39comment</comments>
      <pubDate>Mon, 15 Dec 2025 11:38:22 +0900</pubDate>
    </item>
    <item>
      <title>이메일 확실히 보내기 3편 - 최소 한번 보내기 (Transactional Outbox Pattern)</title>
      <link>https://dlwogns3413.tistory.com/38</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;배경&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;1편&quot; href=&quot;https://dlwogns3413.tistory.com/36&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;1편&lt;/a&gt;에서는 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Gmail SMTP의 하루 발송량 제한인 500건을 넘지 않기 위해 BCC를 도입하여 발송량 자체를 줄였다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;a title=&quot;2편&quot; href=&quot;https://dlwogns3413.tistory.com/37&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2편&lt;/a&gt;에서는 서킷브레이커 패턴을 활용해&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;Gmail SMTP의 하루 발송량을 초과하더라도 정상적으로 이메일을 발송할 수 있는 시스템을 구현하였다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이번 편에서는 이메일을 최소 한번 보내도록 보장하기 위해 어떻게 구현했는지에 관해 작성할 예정이다.&amp;nbsp;&lt;/span&gt;&lt;/span&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;&lt;b&gt;현재 시스템의 문제점&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 이메일을 보낼때 JavaMailSender를 통해 비동기로 보내고 있다. 이 구조에서는 이메일이 보내지지 않을 수 있는데 이는 다음 이유때문이다.&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이메일을 보낼때 SMTP 서버에게 데이터를 보내야 한다. 이때 네트워크를 타기 때문에 패킷이 유실되거나, 네트워크 지연, 네트워크 다운등의 이유로 데이터를 보내지 못할 수 있다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;비동기 스레드가 가득 찬다면, 이메일을 보낼 스레드가 부족해져 이메일 전송이 안될 수 있다.&lt;/li&gt;
&lt;li&gt;비동기 스레드로 이메일을 보내고 있지만, 이때 애플리케이션이 종료된다면 최종적으로 이메일이 보내지지 않을 수 있다.&amp;nbsp;&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;(1) 네트워크는 신뢰할 수 없는 매체이다.&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 네트워크로 전송할 때, TCP를 사용하더라도 항상 목적지에 도착한다고 보장할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 &quot;TCP는 신뢰성 있는 프로토콜&quot;이라고 배웠다. 그렇다면 왜 TCP를 사용해도 데이터 전송이 실패할 수 있을까?&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;TCP는 ACK를 받지 못하면 데이터를 재전송한다. 이를 통해 최소 한 번의 전송을 보장하려 하지만, 재전송 역시 무한정 할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상대 서버가 다운되었거나, ACK가 지속적으로 유실되거나, 전송 데이터가 손실되는 상황에서 TCP는 정해진 최대 횟수만큼만 재전송을 시도한다. 최대 재시도 후에도 ACK를 받지 못하면 연결을 종료하고 애플리케이션에 에러를 반환한다.&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;결국 TCP를 사용하더라도 데이터가 목적지에 도달하지 못하는 상황은 발생할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;(2) 비동기 스레드가 가득찬다면&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 우리는 Spring의 @Async 어노테이션을 이용해 이메일을 전송하고 있다. @Async는 다음과 같이 동작한다.&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;작업이 들어올 때마다 corePoolSize까지 스레드를 할당한다. 모든 코어 스레드가 작업 중일 때 새로운 이메일 전송 요청이 들어오면, 해당 작업은 큐에서 대기한다. 큐도 설정한 크기만큼 가득 차면, maxPoolSize까지 스레드를 추가로 생성하여 작업을 처리한다.&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;그렇다면 maxPoolSize까지 모든 스레드가 사용 중이고 큐도 가득 찬 상태에서 이메일을 보내려 하면 어떻게 될까? 이를 위해 Spring에서는 다음과 같은 거절 정책을 제공한다.&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;AbortPolicy&lt;/b&gt;: 새로운 작업 제출 시 RejectedExecutionException을 발생시킴 (기본 정책)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DiscardPolicy&lt;/b&gt;: 새로운 작업을 조용히 버림&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CallerRunsPolicy&lt;/b&gt;: 작업을 제출한 스레드가 직접 작업을 실행함&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;현재 이벤트 생성 시 이메일을 전송하는데, 기본 정책인 AbortPolicy를 사용하면 스레드 풀 포화 시 RejectedExecutionException이 발생하여 이벤트 생성 자체가 실패할 수 있다.&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;이를 해결하기 위해 커스텀 정책을 만들어 트랜잭션 커밋 후 해당 스레드에서 직접 이메일을 전송할 수도 있다. 하지만 이메일 전송은 네트워크 I/O를 수반하기 때문에 시간이 오래 걸리고, 그만큼 사용자의 응답 시간이 느려질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;(3) 이메일을 보내지 않았는데 애플리케이션이 종료된다면&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션은 다양한 이유로 종료될 수 있다. OOM으로 인한 강제 종료, 새로운 버전 배포를 위한 정상 종료 등이 그 예다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 이메일 전송 작업이 아직 완료되지 않았다면, 해당 작업은 버려져 이메일이 전송되지 않는다. 물론 Spring에서는 @Async 작업이 모두 완료될 때까지 우아하게 종료(graceful shutdown)할 수 있도록 설정을 제공하지만, 이 역시 무한정 기다릴 수는 없다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이메일 최소 한번 보내기&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위한 방법은, 이메일 전송 전에 이메일 발송 내역을 먼저 저장을 하고 커밋된 이후에 이메일을 발송시키면된다. 그리고 별도의 워커를 두어 이메일 발송이 실패한 내역에 대해서 이메일을 재전송하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식을 Transactional Outbox 패턴이라고 부른다.&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-13 오후 2.25.13.png&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;988&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4tZLo/dJMcaajwurw/V2DNyrcB4iuUPEeKQgmKLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4tZLo/dJMcaajwurw/V2DNyrcB4iuUPEeKQgmKLk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4tZLo/dJMcaajwurw/V2DNyrcB4iuUPEeKQgmKLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4tZLo%2FdJMcaajwurw%2FV2DNyrcB4iuUPEeKQgmKLk%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;2372&quot; height=&quot;988&quot; data-filename=&quot;스크린샷 2025-12-13 오후 2.25.13.png&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;988&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대략적인 아이디어는 다음과 같다.&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이벤트 생성 API를 클라이언트가 호출한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;서버는 요청한 이벤트를 데이터베이스에 저장한다. 동시에 이메일 발송 내역을 저장한다. &lt;b&gt;이때 두 작업은 같은 트랜잭션에 묶는다.&amp;nbsp;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;트랜잭션이 커밋되면 이메일 발송을 비동기 스레드로 시작한다. 만약 이메일 발송에 성공했다면 이메일 발송 내역을 삭제한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;스케줄링이 돌면서 실패한 이메일 발송 내역을 조회하며 이메일을 재전송한다.&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2번에서 이벤트 저장과 이메일 발송 내역을 한 트랜잭션으로 묶는 이유는 도메인 로직과 이메일 발송 내역에 대한 정합성을 유지하기 위해서이다. 둘중 하나라도 실패를 한다면 롤백을 함으로써 추후 이메일 전송에 실패했을 경우 워커가 이메일 발송 내역을 조회해 재시도를 하게 하기 위해서이다.&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;그러면 이벤트 저장과 이메일 발송 내역 저장을 하나의 구간으로 묶고, 이메일 전송을 하나의 구간으로 바라왔을때 발생 가능한 4가지 CASE가 존재한다.&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번 구간, 이메일 전송을 2번 구간이라고 했을때&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;1번 성공, 2번 성공&lt;br /&gt;이벤트 저장과 이메일 발송 내역 저장이 성공적으로 실행되어 트랜잭션이 commit되고, 이후에 이메일이 전송되어 정상적으로 이메일이 전송된다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;1번 실패, 2번 성공&amp;nbsp;&lt;br /&gt;이벤트 저장과 이메일 발송 내역 저장이 실패한 경우이다. 이 경우에는 이메일 전송이 되지 않는데 그 이유는 이메일 전송은 트랜잭션이 커밋된 이후에만 실행되도록 구현했기 때문이다. 즉, 해당 케이스는 발생 가능성이 없다고 봐야한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;1번 성공, 2번 실패&lt;br /&gt;이벤트 저장과 이메일 발송 내역 저장이 성공적으로 실행되어 트랜잭션이 commit되고, 이후에 이메일이 전송되지만, 이메일 전송에 실패한 경우이다. 해당 경우에는 이메일 발송 내역이 DB에 저장되어 있기 때문에 추후 워커에 의해 이메일 재전송이 일어나 최종적으로는 이메일 전송에 성공하게 된다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;1번 실패, 2번 실패&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이벤트 저장과 이메일 발송 내역 저장이 실패하고, 이메일 전송 또한 실패한 경우이다. 로직은 실패하였지만, 데이터 정합성은 깨지지 않았기 때문에 의도한 상황으로 봐도 무방하다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;실제 구현&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 이벤트를 생성하는 API를 클라이언트가 호출한다면 이벤트를 생성한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1765611469940&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public Event createEvent(
            final Long organizationId,
            final LoginMember loginMember,
            final EventCreateRequest eventCreateRequest,
            final LocalDateTime currentDateTime
) {
	Event event = Event.create(
                eventCreateRequest.title(),
                eventCreateRequest.description(),
                eventCreateRequest.place(),
                organization,
                eventOperationPeriod,
                eventCreateRequest.maxCapacity(),
                getOrganizationMemberByIds(loginMemberIncludedIds),
                createQuestions(eventCreateRequest.questions()),
                eventCreateRequest.isApprovalRequired()
	);

	Event savedEvent = eventRepository.save(event);
		
	emailNotifier.remind(reminderEmail);

	return savedEvent;
}&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;이벤트를 생성하고 emailNotifier를 통해 이메일 전송을 하게된다. 여기서 왜 이메일 발송 내역을 저장하지 않냐고 할 수 있는데 해당 부분은 다음과 같이 emailNotifier 내부에서 진행하고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1765611677583&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class OutboxEmailSender implements EmailSender {

    private final EmailOutboxRepository emailOutboxRepository;
    private final EmailOutboxRecipientRepository emailOutboxRecipientRepository;
    private final EmailSender delegate;

    @Override
    @Transactional(propagation = Propagation.MANDATORY)
    public void sendEmails(final List&amp;lt;String&amp;gt; recipientEmails, final String subject, final String body) {
        EmailOutbox outbox = EmailOutbox.createNow(subject, body);
        List&amp;lt;EmailOutboxRecipient&amp;gt; recipients = recipientEmails.stream()
                .map(email -&amp;gt; EmailOutboxRecipient.create(outbox, email))
                .toList();
        emailOutboxRepository.save(outbox);
        emailOutboxRecipientRepository.saveAll(recipients);

        registerAfterCommitSend(recipientEmails, subject, body);
    }

    private void registerAfterCommitSend(final List&amp;lt;String&amp;gt; recipientEmails, final String subject, final String body) {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                delegate.sendEmails(recipientEmails, subject, body);
            }
        });
    }
}&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;위 코드를 보면 이메일 발송 내역을 Outbox 테이블에 저장하는 것을 확인할 수 있다. 이 부분을 별도로 분리한 이유는 도메인 로직이 아닌 기술적 관심사라고 판단했기 때문이다. Outbox 패턴은 이메일 전송 실패 시 최종적으로 성공을 보장하기 위한 기술이므로 인프라 레이어로 분리하였다.&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;트랜잭션 전파 옵션을 MANDATORY로 설정한 이유는 이메일 발송 내역 저장이 항상 특정 트랜잭션 내에서 수행되어야 하기 때문이다. 만약 트랜잭션 없이 호출되면 의도적으로 예외가 발생하도록 하여 잘못된 사용을 방지한다.&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;Outbox 테이블에 저장이 성공하고 커밋되면, 그제서야 실제 이메일 전송을 비동기로 수행한다. 이메일 전송에 성공하면 Outbox 튜플을 삭제하고, 실패하면 스케줄러가 1분마다 실패한 Outbox를 조회하여 재전송한다.&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;또 다른 문제&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이런식으로 구현하면 또 다시 문제에 봉착하게 된다. 스케줄러가 &quot;전송 실패한 Outbox&quot;를 구분할 수 없기 때문이다. 현재 구조에서는 이메일 전송 성공 시에만 Outbox 튜플을 삭제하므로, 아직 전송 중인 경우에도 튜플이 남아있다. 따라서 스케줄러 입장에서는 해당 Outbox가 비동기 스레드를 통해 전송 중인지, 아니면 실패한 것인지 알 수 없다.&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;이로 인해 전송 중이지만 아직 삭제되지 않은 Outbox를 스케줄러가 조회하여 재전송하면 중복 전송이 발생한다.&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;물론 이메일 전송에 성공했으나 응답이 유실되어 Outbox 삭제에 실패하는 경우에도 중복 전송이 발생할 수 있다. 하지만 이는 네트워크 문제로 발생하는 상황으로 확률이 낮은 반면, 전송 중인 Outbox를 재전송하는 경우는 스케줄링 주기마다 빈번하게 발생한다. 따라서 후자의 중복 전송을 방지하는 것이 우선 과제였다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결 시도 1: 상태 컬럼 추가&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 Outbox 테이블에 상태 컬럼을 추가하면 된다. 이메일 전송 중일 때는 SENDING, 전송 실패 시에는 FAIL, 전송 성공 시에는 SUCCESS로 상태를 업데이트하는 방식이다. 스케줄러는 FAIL 상태인 Outbox만 조회하여 재전송을 수행한다.&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;하지만 이 방식에도 문제가 있다. SENDING 상태에서 이메일 전송 중 애플리케이션이 종료되면, 상태가 FAIL로 업데이트되지 않아 실제로는 전송 중이 아닌데도 SENDING 상태로 남게 된다. 이를 해결하려면 SENDING 상태의 Outbox가 실제로 전송 중인지, 아니면 전송 실패로 인해 멈춰있는 것인지 구별해야 한다. 결국 이는 처음에 직면했던 &quot;상태 구분 불가&quot; 문제로 다시 돌아가게 된다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결 시도 2: Worker 전용 처리 방식&lt;/b&gt;&lt;/h4&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;스크린샷 2025-12-13 오후 6.02.58.png&quot; data-origin-width=&quot;2210&quot; data-origin-height=&quot;762&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzdEmc/dJMcaaKAM5A/AF3d7KNKvr329rCr40INBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzdEmc/dJMcaaKAM5A/AF3d7KNKvr329rCr40INBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzdEmc/dJMcaaKAM5A/AF3d7KNKvr329rCr40INBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzdEmc%2FdJMcaaKAM5A%2FAF3d7KNKvr329rCr40INBk%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;2210&quot; height=&quot;762&quot; data-filename=&quot;스크린샷 2025-12-13 오후 6.02.58.png&quot; data-origin-width=&quot;2210&quot; data-origin-height=&quot;762&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;서버는 이메일을 직접 전송하지 않고 Outbox 테이블에 이메일 발송 내역만 저장한다. 실제 이메일 전송은 오직 Worker만 담당하며, 전송 성공 시 상태를 SUCCESS로 업데이트한다.&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;Worker는 SUCCESS가 아닌 Outbox만 조회하여 이메일을 전송한다. 이메일 전송을 Worker만 담당하므로, 서버에서 비동기 전송 중 발생하던 &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;하지만 이 방식도 Worker가 여러 대로 확장되면 문제가 발생한다. 여러 Worker가 동시에 SUCCESS가 아닌 Outbox를 조회하면, 아직 전송되지 않은 동일한 레코드를 여러 Worker가 중복으로 가져가 이메일을 중복 전송하게 된다. 결국 이경우에도 해당 Outbox가 현재 전송 중인지 아닌지를 판단할 수 없어 발생하는 문제다. 여러 Worker가 동일한 레코드를 &quot;아직 전송되지 않음&quot;으로 판단하여 동시에 처리하기 때문이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;근본 문제&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 모든 접근 방식이 동일한 근본 문제로 귀결된다. 바로 &lt;b&gt;해당 Outbox가 현재 전송 중인지 아닌지를 판단할 수 없다&lt;/b&gt;는 점이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비동기 스레드 + 스케줄러 방식&lt;/b&gt;: 스케줄러가 해당 Outbox를 비동기 스레드에서 전송 중인지, 아니면 전송에 실패한 것인지 구분 불가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 컬럼 추가 방식&lt;/b&gt;: SENDING 상태가 실제로 전송 중인지, 아니면 애플리케이션 종료로 인해 멈춰있는 것인지 구분 불가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Worker 전용 처리 방식&lt;/b&gt;: 여러 Worker가 해당 Outbox를 다른 Worker에서 전송 중인지, 아니면 아직 아무도 처리하지 않은 것인지 구분 불가&lt;/li&gt;
&lt;/ul&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위한 두 가지 방법이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 비관적 락(Pessimistic Lock) 사용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일 전송 중에는 다른 Worker가 해당 Outbox를 조회하지 못하도록 데이터베이스 락을 거는 방식이다. MySQL의 FOR UPDATE SKIP LOCKED 구문을 사용하면 락이 걸린 레코드는 건너뛰고 다른 레코드를 조회할 수 있다.&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;Outbox 테이블에 locked_at 컬럼을 추가하여 레코드 처리 시작 시각을 기록한다. Worker는 locked_at과 현재 시간을 비교하여 일정 시간(예: 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;비관적 락 방식은 락이 걸린 상태에서 이메일 전송이 일어나기 때문에 트랜잭션을 과도하게 오래 유지하게 된다. 이메일 전송은 네트워크 I/O를 수반하여 수 초에서 수십 초가 걸릴 수 있으며, 이 시간 동안 데이터베이스 연결을 점유하게 되어 커넥션 풀 고갈 및 전체적인 DB 성능 저하를 초래할 수 있다. 따라서 타임스탬프 기반 방식을 선택했다.&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;구체적인 구현&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outbox 테이블에 locked_at 컬럼을 추가하고, Worker는 다음과 같은 조건으로 처리 대상을 조회한다.&lt;/p&gt;
&lt;pre id=&quot;code_1765628641627&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT *
FROM email_outbox
WHERE locked_at IS NULL
   OR locked_at &amp;lt; NOW() - INTERVAL 5 MINUTE
FOR UPDATE SKIP LOCKED
LIMIT 10;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;locked_at&lt;/span&gt;이 &lt;span&gt;NULL&lt;/span&gt;인 경우는 아직 어떤 Worker도 처리하지 않은 Outbox이고, &lt;span&gt;locked_at&lt;/span&gt;이 5분 이상 지난 경우는 이메일 전송 도중 Worker가 비정상 종료되는 등으로 인해 처리가 중단되었다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;span&gt;&lt;b&gt;5분을 처리 임계 시간(lease timeout)&lt;/b&gt;&lt;/span&gt; 으로 두고, 이를 초과한 Outbox는 재처리 대상으로 간주했다.&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&gt;여기서 &lt;/span&gt;FOR UPDATE SKIP LOCKED&lt;span&gt;를 사용하는 이유는 &lt;/span&gt;Worker가 여러 대로 확장되었을 때 동일한 Outbox를 동시에 처리하는 상황을 방지하기 위함&lt;span&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;Worker는 위 쿼리로 조회한 레코드에 대해, &lt;span&gt;같은 트랜잭션 안에서&lt;/span&gt; &lt;span&gt;locked_at&lt;/span&gt;을 현재 시각으로 업데이트한다.&lt;/p&gt;
&lt;pre id=&quot;code_1765629424335&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UPDATE email_outbox
SET locked_at = NOW()
WHERE id IN (...);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 트랜잭션이 커밋되기 전까지 해당 레코드는 Row Lock이 유지되며, 커밋 이후에는 &lt;span&gt;locked_at&lt;/span&gt; 값이 갱신되어 다른 Worker가 동일한 Outbox를 다시 가져가지 못하게 된다.&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;트랜잭션이 커밋된 후 Worker는 이메일 전송을 시도한다. 이메일 전송이 성공하면 해당 Outbox 레코드를 삭제하고, 실패할 경우에는 레코드를 그대로 유지하여 다음 스케줄에서 재처리되도록 하였다.&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;해당 방식을 이용하면 트랜잭션을 짧게 유지하면서도(조회 및 locked_at 업데이트만), 이메일 전송이라는 긴 작업은 트랜잭션 외부에서 수행할 수 있어 데이터베이스 성능에 미치는 영향을 최소화한다.&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;물론 이 방식에도 단점이 있다. 전송 실패 시 재시도까지 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;그럼 문제를 해결한 전체적인 아키텍처는 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-13 오후 9.57.11.png&quot; data-origin-width=&quot;2204&quot; data-origin-height=&quot;874&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HJcht/dJMcaaqimiD/hHZ88cONTpOdtS4y05k7zK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HJcht/dJMcaaqimiD/hHZ88cONTpOdtS4y05k7zK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HJcht/dJMcaaqimiD/hHZ88cONTpOdtS4y05k7zK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHJcht%2FdJMcaaqimiD%2FhHZ88cONTpOdtS4y05k7zK%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;2204&quot; height=&quot;874&quot; data-filename=&quot;스크린샷 2025-12-13 오후 9.57.11.png&quot; data-origin-width=&quot;2204&quot; data-origin-height=&quot;874&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서는 이메일을 최소 한 번 보내도록 보장하기 위해 Transactional Outbox 패턴을 도입했다.&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;기존 시스템은 네트워크 장애, 비동기 스레드 풀 포화, 애플리케이션 종료 등의 상황에서 이메일 전송이 실패할 수 있었지만 Outbox 패턴을 통해 이메일 발송 내역을 데이터베이스에 먼저 저장하고, 별도의 Worker가 주기적으로 실패한 발송 내역을 재처리하도록 구현했다. 이 과정에서 &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;최종적으로 locked_at 컬럼과 MySQL의 FOR UPDATE SKIP LOCKED 구문을 조합하여 이 문제를 해결했다. 해당 방식은 트랜잭션을 짧게 유지하면서도(조회 및 업데이트만) 이메일 전송이라는 긴 작업은 트랜잭션 외부에서 수행할 수 있어 오랜 시간동안 트랜잭션을 붙잡고 있지 않을 수 있었다.&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;이제 우리 시스템은 네트워크 장애, 스레드 풀 포화, 애플리케이션 재시작 등의 상황에서도 이메일 전송을 보장할 수 있게 되었다. Worker가 주기적으로 실패한 발송 내역을 재처리하므로, 일시적인 장애 상황에서도 최종적으로는 모든 이메일이 전송된다. 즉, 최소 한번은 이메일 전송에 성공한다는 의미이다.&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;다음 편에서는 AWS SES의 초당 14건 전송 제한을 토큰 버킷 알고리즘으로 해결한 과정을 다룰 예정이다.&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/38</guid>
      <comments>https://dlwogns3413.tistory.com/38#entry38comment</comments>
      <pubDate>Sat, 13 Dec 2025 21:48:15 +0900</pubDate>
    </item>
    <item>
      <title>이메일 확실히 보내기 2편 - 서킷브레이커</title>
      <link>https://dlwogns3413.tistory.com/37</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;저번 글&quot; href=&quot;https://dlwogns3413.tistory.com/36&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;저번 글&lt;/a&gt;에서는 Gmail SMTP의 하루 발송량 제한인 500건을 넘지 않기 위해 BCC를 도입하여 발송량 자체를 줄였다. 하지만 BCC 방식은 어디까지나 임시방편일 뿐이다. BCC로 발송량을 조금 줄일 수는 있지만, 500건 제한을 근본적으로 해결하는 방법은 아니기 때문이다. 그래서 실제로 500건을 넘어가는 상황이 발생했을 때 어떻게 처리할지도 함께 고민해야 했다.&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;이번 글에서는 하루 발송량이 500건을 넘었을 때 어떻게 대처했는지 공유한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;500건이 넘는다면?&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하루 발송량이 500건을 초과해 Gmail SMTP 서버로부터 실패 응답을 받게 되면 어떻게 처리해야 할까? 다른 SMTP 서버로 전환해야 할까? 아니면 애초에 하루 발송량 제한이 높은 다른 서비스로 완전히 옮겨야 할까?&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;서킷브레이커 패턴을 활용해 Gmail SMTP에서 예외가 발생하면 호출을 차단하고, AWS SES로 자동 전환하여 메일을 전송하는 방식&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;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;왜 Gmail SMTP와 AWS SES를 둘 다 사용하는가?&lt;/li&gt;
&lt;li&gt;왜 서킷브레이커를 사용하는가?&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Gmail SMTP와 AWS SES를 병행한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 장기적으로는 AWS SES만 사용하는 것이 맞다. Gmail SMTP는 애초에 대용량 이메일 전송을 위한 서비스가 아니기 때문이다.&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;하지만 AWS SES를 처음 사용할 때는 &lt;b&gt;샌드박스 환경&lt;/b&gt;에서 시작하게 된다. 샌드박스 상태에서는 하루 최대 200건까지만 발송할 수 있고, 인증된 이메일 주소로만 전송이 가능하다. 샌드박스 해제 신청 후 승인을 받아야 비로소 더 많은 이메일을 전송할 수 있다.&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;샌드박스 해제 전까지는 Gmail SMTP(500건)를 메인으로 사용하고, 승인 후에는 AWS SES로 완전히 전환하는 전략&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;그래서 현재는 두 서비스를 함께 사용하되, Gmail의 제한에 도달하면 서킷브레이커가 작동하여 AWS SES로 자동 전환되는 구조를 만들기로 결정했다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서킷브레이커 패턴을 사용한 이유&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 우리 계획은 이렇다. Gmail SMTP를 사용해 초기에 500건을 발송하고 하루 발송량이 초과된다면 AWS SES를 이용해 이메일을 발송하는 것이다. 이를 구현하기 위해서는 다음과 같이 2가지 방법이 있을 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재시점부터 24시간 이내에 Gmail SMTP로의 발송건수가 500건이라면 AWS SES로 발송하기&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Gmail SMTP로부터 하루 발송량을 다 썼다는 예외가 발생된다면 그 뒤로부터 Gmail SMTP 호출을 차단하고 AWS SES를 사용하기&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;(1) 현재시점부터 24시간 이내에 Gmail SMTP로의 발송건수가 500건이라면 AWS SES로 발송하기&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 구현하기 위해서는 Gmail SMTP로 발송할 때마다 발송한 시각을 자료구조에 저장해 둬야 한다. 그리고 해당 자료구조에서 최근 24시간 이내에 발송한 이메일 건수가 500건 이상이라면 AWS SES로 발송하는 로직을 구현해야 한다.&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-12 오전 10.01.58.png&quot; data-origin-width=&quot;1360&quot; data-origin-height=&quot;456&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2R6tD/dJMcagxgvwQ/5UhHVJ43ubeFV0lk9RNLqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2R6tD/dJMcagxgvwQ/5UhHVJ43ubeFV0lk9RNLqk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2R6tD/dJMcagxgvwQ/5UhHVJ43ubeFV0lk9RNLqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2R6tD%2FdJMcagxgvwQ%2F5UhHVJ43ubeFV0lk9RNLqk%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;1360&quot; height=&quot;456&quot; data-filename=&quot;스크린샷 2025-12-12 오전 10.01.58.png&quot; data-origin-width=&quot;1360&quot; data-origin-height=&quot;456&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 그림과 같이 12월 12일 4시 1분에 이메일을 발송하기 위해 현재 24시간 이내의 발송건수를 확인한다. 24시간 이내에 2건밖에 없으니 Gmail SMTP로 발송을 하고 12월 12일 4시 1분에 대한 로그를 자료구조에 저장한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-12 오전 10.03.51.png&quot; data-origin-width=&quot;1344&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6UCfm/dJMcac9qX40/oC6vCAklxVfS1uk1R1bnZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6UCfm/dJMcac9qX40/oC6vCAklxVfS1uk1R1bnZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6UCfm/dJMcac9qX40/oC6vCAklxVfS1uk1R1bnZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6UCfm%2FdJMcac9qX40%2FoC6vCAklxVfS1uk1R1bnZk%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;1344&quot; height=&quot;480&quot; data-filename=&quot;스크린샷 2025-12-12 오전 10.03.51.png&quot; data-origin-width=&quot;1344&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 12월 12일 5시 1분에 이메일을 발송하기 위해 24시간 이내의 발송건수를 확인한다. 24시간 이내의 이메일 발송건수가 3건밖에 없으니 Gmail SMTP로 발송을 하고 12월 12일 5시 1분에 대한 로그를 자료구조에 저장한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-12 오전 10.07.42.png&quot; data-origin-width=&quot;1352&quot; data-origin-height=&quot;694&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bj3yIR/dJMcachkzJA/AlqKC5cNFYkmk8jXtS2Jb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bj3yIR/dJMcachkzJA/AlqKC5cNFYkmk8jXtS2Jb0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bj3yIR/dJMcachkzJA/AlqKC5cNFYkmk8jXtS2Jb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbj3yIR%2FdJMcachkzJA%2FAlqKC5cNFYkmk8jXtS2Jb0%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;1352&quot; height=&quot;694&quot; data-filename=&quot;스크린샷 2025-12-12 오전 10.07.42.png&quot; data-origin-width=&quot;1352&quot; data-origin-height=&quot;694&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12월 12일 20시 1분에 이메일을 발송하려고 할 때, 24시간 이내(12월 11일 20시 1분 ~ 12월 12일 20시 1분)의 발송 건수를 확인한다. 현재 이 기간 동안 이미 500건을 발송했으니 Gmail SMTP를 사용할 수 없으므로 AWS SES로 전환하여 이메일을 전송한다. 이때는 Gmail SMTP를 사용하지 않았으니 해당 발송 기록을 자료구조에 저장하지 않는다.&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;현재 시점을 기준으로 24시간 동안의 발송 기록을 슬라이딩하며 관리하는 방식&lt;/b&gt;을 슬라이딩 윈도우 로깅 알고리즘이라고 부른다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;장점: Gmail 제한 방식과 정확히 일치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 가장 큰 장점은 &lt;b&gt;Gmail의 실제 제한 방식과 정확히 일치&lt;/b&gt;한다는 점이다. Gmail은 자정 기준이 아니라 '현재 시점 기준 24시간 동안의 발송 건수'로 제한을 계산한다. 동일한 기준을 사용하면 예외가 발생하기 전에 안전하게 AWS SES로 전환할 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단점 1:&amp;nbsp; 높은 구현 복잡도&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 카운터 하나를 올리는 구조가 아니고 슬라이딩 윈도 방식으로 24시간 내 로그를 유지해야 하기 때문에 다음과 같은 추가 구현이 필요하다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;24시간 이내의 로그가 아니라면 삭제 로직&lt;/li&gt;
&lt;li&gt;매 발송마다 &quot;24시간 내 몇 건인지&quot; 계산&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;h4 data-ke-size=&quot;size20&quot;&gt;단점 2: 복잡한 상태 관리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나의 문제는 상태 관리다. 서버가 여러 대라면 인스턴스마다 다른 메모리를 들고 있을 수 있으니 Redis 같은 외부 스토리지가 필요해진다. 그리고 메모리에 저장한다면 서버 재시작 시 메모리가 초기화된다는 단점도 함께 존재한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단점 3: 보수적인 동작&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 아주 보수적으로 동작할 수 있다는 단점도 있다. Gmail이 실제로는 아직 500건 제한을 넘기지 않았는데, 우리 로직이 먼저 SES로 전환시키는 상황이 생길 수 있다. 정확성을 얻는 대신 어느 정도 여유는 포기하는 셈이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) Gmail SMTP로부터 하루 발송량을 다 썼다는 예외가 발생된다면 그 뒤로부터 Gmail SMTP 호출을 차단하고 AWS SES를 사용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 구현하려면 어떻게 해야 할까? Gmail SMTP로부터 하루 발송량 초과 예외가 발생하면, 그 이후부터는 Gmail SMTP 호출을 차단하고 AWS SES를 사용하도록 구현해야 한다.&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;구현 방법은 간단하다. Gmail SMTP로부터 발송량 초과 예외 메시지를 받으면, 그때부터는 Gmail SMTP를 호출하지 않고 AWS SES로만 요청을 보내면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1765506155003&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;javax.mail.MessagingException: 550 5.4.5 Daily sending quota exceeded&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;언제 다시 Gmail SMTP를 사용할 수 있을까?&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;24시간이 지나면 발송량이 리셋되지만, 정확히 언제 복구되는지 알 수 없다. 그래서 &lt;b&gt;3시간마다 Gmail SMTP를 사용할 수 있는지 체크&lt;/b&gt;하고, 발송 가능하다면 다시 Gmail SMTP를 사용하도록 구현하면 된다.&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;서킷브레이커 패턴&lt;/b&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;장애가 발생한 서비스를 일시적으로 차단해 연쇄 장애를 방지&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;/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. Closed&amp;nbsp;&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;/li&gt;
&lt;li&gt;실패 횟수를 카운팅 한다&lt;/li&gt;
&lt;li&gt;Gmail SMTP가 정상 작동하는 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Open&amp;nbsp;&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;/li&gt;
&lt;li&gt;모든 요청이 즉시 차단되고 Fallback 로직이 실행된다&lt;/li&gt;
&lt;li&gt;Gmail SMTP에서 500건 초과 예외가 발생 &amp;rarr; AWS SES로 전환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Half-Open&amp;nbsp;&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;일정 시간이 지나면 자동으로 Half-Open 상태로 전환된다&lt;/li&gt;
&lt;li&gt;소수의 테스트 요청을 보내서 서비스가 복구되었는지 확인한다&lt;/li&gt;
&lt;li&gt;성공하면 Closed로, 실패하면 다시 Open으로 전환된다&lt;/li&gt;
&lt;li&gt;3시간마다 Gmail SMTP 상태를 체크하는 단계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 진영의 서킷 브레이커 라이브러리로는 크게 Hystrix와 Reslience4J가 존재한다. Hystrix는 넷플릭스에서 만든 오픈소스인데, deprecated 되었으므로 reslience4j를 사용하면 된다. Hystrix에서도 오픈소스인 resilience4j 사용을 권장하고 있다. 그래서 우리도 resilience4j를 이용해 서킷브레이커를 적용하였다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;resilience4j를 이용해 서킷브레이커 패턴 구현하기&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서킷브레이커 패턴을 적용하면서 제일 중요한 건 언제 OPEN 상태로 만들거냐 라고 생각한다. 그럼 언제 OPEN 상태로 만들어야 할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;가장 간단한 방법은 Gmail SMTP에서 하루 발송량 초과 예외가 발생하면 즉시 OPEN 상태로 만드는 것이다. 하지만 이 방식에는 문제가 하나 있다.&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;Gmail SMTP 서버가 일시적으로 다운된 경우를 생각해 보자. 네트워크 장애나 Gmail 서버 이슈로 타임아웃이 발생했지만, 하루 발송량 초과 예외는 발생하지 않는다. 그렇다면 서킷브레이커가 OPEN 상태로 전환되지 않고, 계속해서 실패하는 Gmail 호출을 반복하게 된다. 이렇게 되면 서킷브레이커를 사용하는 의미가 없다. 서킷브레이커의 핵심 목적은 장애가 발생한 서비스로의 불필요한 호출을 차단하는 것인데, 정작 필요한 상황에서 작동하지 않기 때문이다.&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;첫 번째는 Gmail SMTP 서버가 일시적으로 다운된 경우다&lt;/b&gt;. 타임아웃이나 연결 실패 등이 발생하지만 시간이 지나면 복구될 가능성이 있기 때문에 자동으로 복구를 시도해야 한다. &lt;b&gt;두 번째는 하루 발송량 초과로 더 이상 보낼 수 없는 경우다.&lt;/b&gt; 이때는 Gmail SMTP 서버는 정상이지만 quota가 소진된 상황이므로 특정 시간이 지나야 복구된다. 따라서 자동 복구 시도가 의미 없고, 수동으로 복구해야 한다.&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;Resilience4j에는 이런 상황을 위한 &lt;b&gt;FORCED_OPEN&lt;/b&gt; 상태가 존재한다. 일반적인 OPEN 상태는 실패율이나 실패 횟수가 임계치를 초과했을 때 자동으로 전환되며, 일정 시간이 지나면 자동으로 HALF_OPEN 상태로 전환되어 테스트를 시도한 후 CLOSED로 돌아간다. 반면 FORCED_OPEN 상태는 개발자가 강제로 전환하는 것으로, 자동으로 HALF_OPEN으로 전환되지 않고 개발자가 직접 CLOSED 상태로 만들어야 한다.&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;스크린샷 2025-12-12 오후 12.55.05.png&quot; data-origin-width=&quot;1820&quot; data-origin-height=&quot;1422&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/czyp98/dJMcad1yWCU/ELFvesPKolIWIgwkfzKjI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/czyp98/dJMcad1yWCU/ELFvesPKolIWIgwkfzKjI0/img.png&quot; data-alt=&quot;일반적인 장애 상황 (서버 다운, 타임아웃)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/czyp98/dJMcad1yWCU/ELFvesPKolIWIgwkfzKjI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fczyp98%2FdJMcad1yWCU%2FELFvesPKolIWIgwkfzKjI0%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;1820&quot; height=&quot;1422&quot; data-filename=&quot;스크린샷 2025-12-12 오후 12.55.05.png&quot; data-origin-width=&quot;1820&quot; data-origin-height=&quot;1422&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gmail SMTP를 사용할 때 타임아웃이나 연결 실패등의 오류가 발생한다면 Resilience4j에서 이를 수집한다. 그리고 최근 5번의 Gmail 호출 중 50% 이상이 실패한다면 OPEN 상태가 되어 AWS SES를 호출하게 된다. 그리고 3분마다 HALF_OPEN 상태가 되어 Gmail SMTP가 정상작동하는지 확인하게 된다.&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;최근 5번의 호출중 50%이상이 실패했을 때 OPEN 상태로 변하게 한 이유는 우리 서비스는 그렇게 많은 사용자가 없기 때문에 TPS가 낮은 편이어서 큰 window 크기를 설정한다면 해당 윈도 크기만큼 실패할 때까지 서킷이 안 열려 불필요한 부하가 계속 발생할 거라 판단했다. 그래서 5건으로 줄여 &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;또한 실패율을 50% 이상으로 설정한 이유는 너무 낮게 설정하면(30%) 일시적 실패에도 민감하게 반응하고, 너무 높게 설정하면(70%) 장애 감지가 늦어지니까, &lt;b&gt;50%가 가장 합리적인 기준&lt;/b&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;다음은 Gmail SMTP의 하루 전송량 초과 시의 상태이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-12 오후 12.59.43.png&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wUHAG/dJMcafymqo2/tdHe9LXrtNRqgTbQ4hmQG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wUHAG/dJMcafymqo2/tdHe9LXrtNRqgTbQ4hmQG0/img.png&quot; data-alt=&quot;Gmail SMTP 하루 전송량 초과시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wUHAG/dJMcafymqo2/tdHe9LXrtNRqgTbQ4hmQG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwUHAG%2FdJMcafymqo2%2FtdHe9LXrtNRqgTbQ4hmQG0%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;1854&quot; height=&quot;1104&quot; data-filename=&quot;스크린샷 2025-12-12 오후 12.59.43.png&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1104&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Gmail SMTP 하루 전송량 초과시&lt;/figcaption&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;Gmail SMTP 이용 시 하루 전송량 초과 예외가 발생하게 된다면 FORCED_OPEN 상태로 전환하여 AWS SES를 이용하도록 구현해 주었다. 그리고 3시간마다 스케줄링을 돌려 Gmail SMTP를 사용할 수 있게 된다면 다시 CLOSED 상태로 돌아가도록 구현하였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1765512692038&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PostConstruct
public void registerQuotaExceededHandler() {
        circuitBreaker.getEventPublisher()
            .onError(event -&amp;gt; {
                    Throwable cause = event.getThrowable();
                    if (cause.getMessage() != null &amp;amp;&amp;amp; cause.getMessage()
                            .contains(DAILY_LIMIT_EXCEEDED_CODE)) {
                        circuitBreaker.transitionToForcedOpenState();
		}
	});
}&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;CLOSED로 돌아가는 스케줄링을 24시간이 아닌 3시간으로 한 이유는 Gmail의 하루 발송량 제한이 &quot;특정시간부터 자정까지&quot;가 아니라 &quot;지난 24시간 동안&quot;을 기준으로 계산되기 때문에 언제 Gmail의 발송량 제한이 풀릴지 모르게 때문에 24시간이 아닌, 안정적으로 3시간 주기로 했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1765512735922&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Scheduled(fixedRate = 3 * 60 * 60 * 1000)
public void recoverGmailCircuitBreaker() {
	if (circuitBreaker.getState() == CircuitBreaker.State.FORCED_OPEN) {
		if (gmailHealthChecker.isAvailable()) {
			circuitBreaker.transitionToClosedState();
		}
	}
}&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;정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서킷브레이커는 본질적으로 &quot;장애 감지 후 차단&quot;하는 패턴이다. 즉, 최소한 한 번은 500건을 초과해 봐야 FORCED_OPEN 상태로 전환된다. 500건을 정확히 지키려면 슬라이딩 윈도 방식처럼 사전에 카운팅 하는 방법이 필요한데, 이는 구현 복잡도가 높아진다는 트레이드오프가 있다.&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;500건을 약간 초과할 수 있지만, 이후 빠르게 AWS SES로 전환하면 된다&quot;는 실용적인 접근을 택했다. 완벽한 정확성보다는 구현 복잡도와 유지보수성을 우선시해 해당 방법을 선택하였다. 그래서 1번 방법인 이동 윈도 알고리즘을 사용하여 구현하는 대신 서킷브레이커를 이용해 구현해주었다.&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;이로써 Gmail SMTP의 하루 발송량을 초과하더라도 정상적으로 이메일을 발송할 수 있는 시스템을 구현하였다. 다음 글에서는 애플리케이션이 갑자기 중단되거나 비동기 스레드 풀이 가득 차서 이메일 발송에 실패하는 상황에서 어떻게 안정적으로 이메일을 전송할 수 있는지에 대해 다룰 예정이다.&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/37</guid>
      <comments>https://dlwogns3413.tistory.com/37#entry37comment</comments>
      <pubDate>Fri, 12 Dec 2025 13:27:36 +0900</pubDate>
    </item>
    <item>
      <title>이메일 확실히 보내기 1편 - BCC</title>
      <link>https://dlwogns3413.tistory.com/36</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;우아한테크코스에서 진행 중인 팀 프로젝트에서는 사용자가 속한 조직에서 이벤트가 주최되면, 알림을 설정한 사용자들에게 이메일을 보내고 있다. 추가로 이벤트 신청 마감 30분 전, 이벤트 시작 하루 전에도 리마인더 이메일을 보내고 있다.&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;span&gt;여기서 중요한 점은 이메일을 &amp;lsquo;확실하게&amp;rsquo; 보내야 한다는 것이다. 이벤트 리마인더 성격의 서비스다 보니, 이벤트가 열렸다는 이메일을 제때 전달하지 못하면 특히 선착순 이벤트에서는 사용자가 참여하고 싶어도 참여하지 못할 수 있다.&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;span&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;Gmail SMTP 하루 발송량 초과&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;우리는 처음에 Gmail SMTP만 사용해서 이메일을 보내고 있었다. 사이드 프로젝트에서도 여러 번 써봐서 충분할 줄 알았지만, 서비스가 커지면서 문제가 발생했다.&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;span&gt;사용자가 늘어나고 하루에 보내야 할 이메일 수가 500건을 넘어서기 시작했다. 그렇게 하루 500건을 넘기자 다음과 같은 에러가 발생했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1765422048003&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;MailSendException: Failed messages: SMTPSendFailedException: 550-5.4.5 Daily user sending limit exceeded&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;ldquo;하루 발송량을 초과했다&amp;rdquo;는 의미다. 실제로 Gmail SMTP 공식 문서에도 무료 계정 기준 하루 발송량은 최대 500건이라고 적혀 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-11 오후 12.02.14.png&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;157&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlMzQb/dJMcaaKz0Kj/y1408m0MbKdtZIkMhKQtE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlMzQb/dJMcaaKz0Kj/y1408m0MbKdtZIkMhKQtE0/img.png&quot; data-alt=&quot;Gmail SMTP 공식문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlMzQb/dJMcaaKz0Kj/y1408m0MbKdtZIkMhKQtE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdlMzQb%2FdJMcaaKz0Kj%2Fy1408m0MbKdtZIkMhKQtE0%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;712&quot; height=&quot;157&quot; data-filename=&quot;스크린샷 2025-12-11 오후 12.02.14.png&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;157&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Gmail SMTP 공식문서&lt;/figcaption&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;span&gt;그럼 여기서 어떻게 해야 할까? 선택지는 두 가지다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;Gmail 유료 계정으로 발송량을 늘리거나&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;아예 발송량 제한이 더 넉넉한 다른 이메일 서비스를 쓰거나&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;하지만 가장 빨리 할 수 있는 방법을 먼저 고려했다. 발송량 자체를 줄일 수 있다면, 하루 500건을 넘길 가능성도 낮아지지 않을까?&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;span&gt;잘 생각해보면, 우리는 여러 사용자에게 &amp;lsquo;완전히 동일한 이메일&amp;rsquo;을 보내고 있었다. 예를 들어 A라는 이벤트가 주최되면, 그 이벤트가 속한 조직의 모든 사용자에게 같은 형태의 템플릿 메일을 보내는 식이다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-11 오후 12.12.09.png&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;715&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHkmnX/dJMcaaKz0Xa/eTdfkIol68YX4KnvkRWni0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHkmnX/dJMcaaKz0Xa/eTdfkIol68YX4KnvkRWni0/img.png&quot; data-alt=&quot;이메일 내용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHkmnX/dJMcaaKz0Xa/eTdfkIol68YX4KnvkRWni0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHkmnX%2FdJMcaaKz0Xa%2FeTdfkIol68YX4KnvkRWni0%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;427&quot; height=&quot;487&quot; data-filename=&quot;스크린샷 2025-12-11 오후 12.12.09.png&quot; data-origin-width=&quot;627&quot; data-origin-height=&quot;715&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이렇게 동일한 내용의 이메일을 여러 명에게 보낼 때, 사실 한 번의 이메일로 여러 명에게 보내는 방법이 두 가지 있다. &lt;/span&gt;&lt;b&gt;&lt;span&gt;CC와 BCC.&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 우리는 그중 &lt;/span&gt;&lt;b&gt;&lt;span&gt;BCC&lt;/span&gt;&lt;/b&gt;&lt;span&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;BCC&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;BCC는 수신자를 숨긴 채 여러 명에게 한 번에 메일을 보내는 방식이다. 수신자는 누가 함께 이 메일을 받았는지 알 수 없다. 반대로 CC는 수신자 목록이 모두 보이기 때문에, 우리는 보안상 BCC를 택했다.&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;span&gt;그렇다면 BCC를 쓰면 어떻게 &amp;lsquo;1건의 이메일로 여러 명에게&amp;rsquo; 동일한 메일을 보낼 수 있을까? 실제 흐름은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1765427359279&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class EmailService {
    
    @Autowired
    private JavaMailSender mailSender;
    
    public void sendBccEmail() {
        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message);
        
        helper.setFrom(&quot;jaehun@gmail.com&quot;);
        helper.setBcc(new String[]{
            &quot;user1@naver.com&quot;,
            &quot;user2@kakao.com&quot;
        });
        helper.setSubject(&quot;우테코 8기 모집 안내&quot;);
        helper.setText(&quot;본문 내용...&quot;);
        
        mailSender.send(message);  
    }
}&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;이처럼 BCC에 여러 명을 넣어두면 내부적으로는 Gmail SMTP로 다음과 같은 SMTP 세션이 전송된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1765427779906&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;C: MAIL FROM:&amp;lt;jaehun@gmail.com&amp;gt;
S: 250 OK

C: RCPT TO:&amp;lt;user1@naver.com&amp;gt;
S: 250 Accepted

C: RCPT TO:&amp;lt;user2@kakao.com&amp;gt;
S: 250 Accepted

# 메시지 본문 전송
C: DATA
S: 354 End data with &amp;lt;CR&amp;gt;&amp;lt;LF&amp;gt;.&amp;lt;CR&amp;gt;&amp;lt;LF&amp;gt;

C: From: jaehun@gmail.com
C: Subject: 우테코 8기 모집 안내
C: Date: Wed, 11 Dec 2024 15:30:00 +0900
C: Message-ID: &amp;lt;abc123@ahmadda.com&amp;gt;
C: 
C: 본문 내용...
C: .

S: 250 OK Message accepted for delivery&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&gt;여기서 보면 BCC로 넣은 수신자들이 각각 &lt;/span&gt;&lt;span&gt;RCPT TO&lt;/span&gt;&lt;span&gt; 명령으로 전송되는 것을 알 수 있다. Gmail SMTP 서버는 이를 받으면 수신자의 도메인별로 메일을 나눠 전달한다. 예를 들어 위 예시처럼 naver와 kakao가 있다면 두 도메인에 각각 따로 메일을 보낸다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1765428078780&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# naver SMTP로 
C: RCPT TO:&amp;lt;user1@naver.com&amp;gt;
S: 250 Accepted

C: DATA
S: 354 Start mail input

C: From: jaehun@gmail.com
C: Subject: 우테코 8기 모집 안내
C: Received: from smtp.gmail.com by mx1.naver.com; ...
C: 
C: 본문 내용...
C: .

S: 250 OK Message queued
C: QUIT


# kakao SMTP로 
C: RCPT TO:&amp;lt;user1@naver.com&amp;gt;
S: 250 Accepted

C: DATA
S: 354 Start mail input

C: From: jaehun@gmail.com
C: Subject: 우테코 8기 모집 안내
C: Received: from smtp.gmail.com by mx1.naver.com; ...
C: 
C: 본문 내용...
C: .

S: 250 OK Message queued
C: QUIT&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&gt;수신 SMTP 서버는 도착한 메일을 각 수신자의 메일박스에 저장한다. 이후 각 사용자는 자신의 메일 클라이언트에서 이 메일을 확인할 수 있게 된다.&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;span&gt;여기서 중요한 부분은 BCC 처리 방식이다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;송신 SMTP는 BCC 수신자를 &lt;/span&gt;&lt;b&gt;&lt;span&gt;RCPT TO 단계에서는 정상적으로 전달&lt;/span&gt;&lt;/b&gt;&lt;span&gt;하지만, &lt;/span&gt;&lt;b&gt;&lt;span&gt;DATA 단계에서 보내는 헤더에는 BCC 주소를 포함하지 않는다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span&gt;그래서 &lt;/span&gt;&lt;span&gt;메일을 받은 수신자 입장에서는 BCC 대상자가 누군지 확인할 수 없다.&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;span&gt;전체적인 과정을 그림으로 표현하면 다음과 같다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled-2025-12-11-1437.png&quot; data-origin-width=&quot;1803&quot; data-origin-height=&quot;460&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QZBJR/dJMcacBDJAy/Re905kWixZwRS3jfbhovm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QZBJR/dJMcacBDJAy/Re905kWixZwRS3jfbhovm0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QZBJR/dJMcacBDJAy/Re905kWixZwRS3jfbhovm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQZBJR%2FdJMcacBDJAy%2FRe905kWixZwRS3jfbhovm0%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;1803&quot; height=&quot;460&quot; data-filename=&quot;Untitled-2025-12-11-1437.png&quot; data-origin-width=&quot;1803&quot; data-origin-height=&quot;460&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;span&gt;여기서 더 고려해야 할 사항이 있는데 Gmail SMTP 공식문서를 보면 총 수신자 수는 최대 100명이어야 한다는 점이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-12-11 오후 2.04.49.png&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;76&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/P9CMS/dJMcabvXqyC/DZ1KtayXfK5NlNc0tFpVUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/P9CMS/dJMcabvXqyC/DZ1KtayXfK5NlNc0tFpVUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/P9CMS/dJMcabvXqyC/DZ1KtayXfK5NlNc0tFpVUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FP9CMS%2FdJMcabvXqyC%2FDZ1KtayXfK5NlNc0tFpVUk%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;702&quot; height=&quot;76&quot; data-filename=&quot;스크린샷 2025-12-11 오후 2.04.49.png&quot; data-origin-width=&quot;702&quot; data-origin-height=&quot;76&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;그래서 구현할 때도 한 번에 보내는 최대 수신자 수를 100명으로 제한해 두었다. 만약 수신자가 100명을 넘어가면, 수신자를 나눠 여러번 보내도록 처리했다.&lt;/p&gt;
&lt;pre id=&quot;code_1765429636461&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class BccChunkingEmailSender implements EmailSender {

    private final EmailSender delegate;
    private final int maxBcc;

    @Override
    public void sendEmails(final List&amp;lt;String&amp;gt; recipientEmails, final String subject, final String body) {
        for (int i = 0; i &amp;lt; recipientEmails.size(); i += maxBcc) {
            int end = Math.min(i + maxBcc, recipientEmails.size());
            List&amp;lt;String&amp;gt; chunk = recipientEmails.subList(i, end);

            delegate.sendEmails(chunk, subject, body);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;BCC 단점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 동일한 내용을 가지는 이메일을 여러 수신자에게 보내야 하는 경우에는 무조건 BCC방식으로 보내는 것이 좋을까? 꼭 그렇지만은 않다. 상황에 따라 오히려 독이 될 수도 있다.&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;BCC의 단점으로는 개인화가 불가능하다는 것과, 개인적으로 추적이 불가능하다는점, 그리고 전송 실패 시 전체가 모두 실패할 수 있다는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 개인화 불가능&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;BCC는 기본적으로 &amp;ldquo;완전히 동일한 이메일&amp;rdquo;을 여러 명에게 한 번에 보내는 방식이다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;그래서 다음처럼 각 사용자 이름을 넣어 개인화된 메시지를 만들 수 없다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1765435725217&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개별 전송 방식 - 개인화 가능
for (User user : users) {
    helper.setTo(user.getEmail());
    helper.setText(
        String.format(&quot;안녕하세요 %s님!&quot;, user.getName())
    );
    mailSender.send(message);
}

// 결과: 각 수신자가 받는 메시지
To: user1@gmail.com 
Subject: 우테코 8기 모집 안내

안녕하세요 홍길동님!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 추적 불가능&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;특정 사용자가 메일을 실제로 열어봤는지 추적해야 하는 상황도 종종 있는데, &lt;/span&gt;&lt;span&gt;BCC는 개인화가 불가능하기 때문에 &lt;/span&gt;&lt;b&gt;&lt;span&gt;수신자별 행동을 따로 추적하는 것이 사실상 불가능하다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 전송 실패 시 전체 실패 가능성&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BCC는 여러 수신자를 한 번에 묶어서 보내기 때문에, 그중 하나라도 문제가 있는 주소가 있으면 전체가 실패할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1765435939866&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// BCC 방식
C: RCPT TO:&amp;lt;user1@gmail.com&amp;gt;
S: 250 Accepted
C: RCPT TO:&amp;lt;invalid@wrong.com&amp;gt;
S: 550 No such user  // &amp;larr; 여기서 에러!&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이런 단점들 때문에, BCC는 &amp;ldquo;여러 명에게 동일한 메일을 빠르게 보낼 때&amp;rdquo;는 괜찮은 방식이지만 &lt;/span&gt;&lt;span&gt;MAU가 커지는 서비스나 개인화/트래킹이 중요한 서비스에서는 한계가 명확하다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;정리하자면, BCC는 여러 명에게 한 번에 메일을 보내면서도 수신자끼리는 서로를 모르게 하는 방식이다. &lt;br /&gt;&lt;/span&gt;&lt;span&gt;SMTP 서버끼리는 RCPT TO 단계에서 BCC 수신자를 모두 알고 있지만, 실제 메일을 받은 사용자 입장에서는 헤더에 BCC가 없기 때문에 누가 함께 받았는지 알 수 없다.&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;결국 우리가 BCC를 사용한 이유는 동일한 메일을 여러 명에게 안정적으로 보내면서도, 수신자 정보는 보호하고 SMTP 발송량까지 줄일 수 있기 때문이다.&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;하지만 BCC 방식은 어디까지나 임시방편일 뿐이다. BCC를 쓰면 발송량이 조금 줄어드는 건 맞지만, 하루 500건 제한을 근본적으로 해결하는 방법은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 우리는 500건을 넘어가는 상황이 실제로 발생했을 때, 이를 어떻게 처리했는지도 따로 고민해야 했다.&lt;br /&gt;다음 글에서는 그 문제를 어떻게 해결했는지에 대해 살펴볼 예정이다.&lt;/p&gt;</description>
      <category>프로젝트</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/36</guid>
      <comments>https://dlwogns3413.tistory.com/36#entry36comment</comments>
      <pubDate>Thu, 11 Dec 2025 15:59:05 +0900</pubDate>
    </item>
    <item>
      <title>우테코 레벨2 미션1(방탈출 예약 관리) 회고</title>
      <link>https://dlwogns3413.tistory.com/35</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;약 2달 만에 쓰는 미션 1 회고.. 기억이 가물가물 하지만 최대한 써보겠다! 미리 쓰자..&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 레벨 2 첫 미션 시작 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 2 첫 미션의 페어는 모코였다. 모코와 나 둘다 스프링을 해본 경험이 있어, 이번 미션은 레벨 1 미션들에 비해서는 꽤나 쉬운 편에 속했다. 그래서 미션보다는 스프링의 내부 동작에 대해 더 깊이 파보는 시간을 가졌다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 2를 시작하면서 다짐한것은 내가 고민한 부분에 대해 기록하는 것이었다. 레벨 1에서는 이러한 기록을 하지 않아 레벨 1이 다 끝난 이후에 내가 무엇을 배웠는지, 어떤 것들을 했는지 생각이 나질 않았다.&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;스크린샷 2025-06-04 오후 4.24.51.png&quot; data-origin-width=&quot;1744&quot; data-origin-height=&quot;1050&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKKbaK/btsOpW17b8c/B2dfzkwruqkQJSjlJRF81k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKKbaK/btsOpW17b8c/B2dfzkwruqkQJSjlJRF81k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKKbaK/btsOpW17b8c/B2dfzkwruqkQJSjlJRF81k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKKbaK%2FbtsOpW17b8c%2FB2dfzkwruqkQJSjlJRF81k%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;1744&quot; height=&quot;1050&quot; data-filename=&quot;스크린샷 2025-06-04 오후 4.24.51.png&quot; data-origin-width=&quot;1744&quot; data-origin-height=&quot;1050&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1️⃣ [1 ~ 3 단계] 방탈출 예약 추가, 삭제, 조회&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Gradle&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 2가 되면서, 스프링 부트를 이용해 웹 개발을 시작하게 됐는데, 이때 Gradle에 의존성을 부여하기 시작했다. 예전에는 그냥 넘어갔던 설정을 하나하나 곱씹어 보며 어떤 동작을 하는지 살펴보는 시간을 가졌다.&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;이 과정에서 gradlew가 무엇인지, &lt;span data-token-index=&quot;0&quot;&gt;gradlew.bat라는 파일은 어떤 파일인지, plugins블럭에는 어떤 것들이 들어가 있는지, repositories블록은 어떤 것을 정의하는지를 알게 되었다. 이전에 무심코 쓰던 것에 대해 이제 이해하고 Gradle 파일을 작성하니 불편했던 마음 한편이 편안해지는 기분이었다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;@RequestBody vs ResponseEntity&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에서 객체를 JSON 형태로 넘겨주기 위해서 @RequestBody를 사용하거나, ResponseEntity를 이용해 응답 객체를 만들어줄 수 있다. 이 두 방법중 어느 방법을 사용할지가 고민이 됐는데, 결론부터 말하자면 나는 ResponseEntity를 더 선호한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@RequestBody를 사용하면 객체를 리턴하기만 하면 되니, ResponseEntity보다 손쉽게 JSON으로 직렬화 할 수 있다. ResponseEntity는 ResponseEntity를 직접 만들어서 반환해야 하는 번거로움과 메서드 반환 타입을 적어줄 때 길어질 수 있는 문제가 있다.&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;그럼에도 불구하고 ResponseEntity를 선호하는 이유는 @RequestBody는 응답 상태코드, 응답 헤더를 설정하기 번거롭기 때문이다. 물론 응답 상태코드는 @ResponseStatus 를 이용해 설정해 주고, 응답 헤더 또한 파라미터로 HttpServletResponse 객체를 받아와 설정하면 되긴 하지만, 응답을 만드는 과정이 메서드 여러 줄에 섞여 있어 리턴문만 보고 응답이 어떻게 형성되어 나가는지 한 번에 알아볼 수 없다고 생각했다.&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;그렇다면 상태코드, 응답 헤더를 커스텀 하는 경우에만 ResponseEntity를 쓰고 그렇지 않은 경우에는 @ResponseBody를 쓰면 안 되냐라고 할 수 있지만, 응답을 만들 때 하나의 방식으로 통일하는 것이 개발할 때 알아야 하는 양을 줄일 수 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 우리 팀에 새로운 신입이 왔고, 우리는 이럴때 @ResponseBody를 쓰고 이럴 때는 ResponseEntity를 써야 한다라고 알려주는 것보다는 무조건 ResponseEntity만 써야 한다라고 명시를 해주면 알아야 할 양이 줄어들기 때문에 더 이득이라고 생각했다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;소프트스킬&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모코와의 페어 프로그래밍을 마친 뒤, 회고 시간을 가졌다. 모코는 내가 의견을 낼 때 고집을 부리지 않고 상대의 말에 설득당하는 태도가 좋았다고 말했다. 하지만 동시에, 이것이 장점이자 단점일 수 있다고 지적했다. 어쩌면 내가 주관이 뚜렷하지 않아서 쉽게 설득당하는 것일 수도 있다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;lsquo;그만하자&amp;rsquo;는 식으로 피하게 되는데, 이게 오히려 독이 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 그 이후로는 토론 시간이 오래 걸리더라도 내 생각을 확실하게 말하려고 하고 있다. 그러다 내 의견의 약점이 명확해지면, 그때는 기꺼이 상대에게 설득당하는 방향으로 나아간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해보니, 내 생각을 표현하는 능력도 확실히 늘었다. 마음속으로만 생각하는 것과 실제로 말로 꺼내는 건 정말 큰 차이가 있다는 걸 느꼈다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2️⃣ [4 ~ 9 단계] 레이어드 아키텍처 도입 및 시간 관리 기능 추가&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Service에서 Controller DTO를 써도 될까 &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어드 아키텍처를 도입하면서, 요청과 응답을 처리하는 &lt;span&gt;&lt;b&gt;Controller&lt;/b&gt;&lt;/span&gt;와 비즈니스 로직을 담당하는 &lt;span&gt;&lt;b&gt;Service&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;이 과정에서 Controller가 사용하던 요청 DTO를 Service의 메서드 파라미터로 그대로 전달하게 되었다. 그런데 이 방식은 &lt;span&gt;&lt;b&gt;요청 처리의 책임이 Controller를 넘어 Service까지 전파된 것 아닌가?&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;그렇다면 controller의 요청 DTO와 서비스의 메서드 파라미터 DTO를 나누면 어떨까라는 생각이 들었다. 정리하자면 다음 두 가지 방식 이있는 것이다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Controller에서 사용하는 요청 DTO를 그대로 Service에서도 요청 인자로 사용하는 경우&lt;/li&gt;
&lt;li&gt;Controller에서 사용하는 요청 DTO와 Service에서 사용하는 요청 DTO를 분리하는 경우&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1번에 대한 나의 생각은 Controller의 요청 DTO를 그대로 Service에서 사용한다면 물론 둘의 값이 같을때가 많기 때문에 재활용을 통해 더욱 빠른 생산성을 보여줄 수 있다는 장점을 가지고 있다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 요청 API의 명세가 바뀌면 해당 DTO를 사용하고 있는 Service까지 변경해야 하는 상황이 올 수 있기 때문에, Service가 순수하게 관리되지 못한다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, API 명세가 변경될때마다 Service 레이어에 변경이 일어나는 단점이 있지만 DTO를 하나만 만들기 때문에 더욱 빠른 개발이 가능하다는 장점이 있다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2번 방식의 장단점을 따져 본다면, 컨트롤러와 서비스가 자신의 DTO만 관리를 하니 컨트롤러의 요청 DTO가 변경되어도 서비스 레이어에 영향을 주지 않는다. 즉, 유지보수가 원활해진다. 다만 DTO를 두 번 만들어줘야 하니 관리해야 할 class도 많아지고, 개발 속도도 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;지금 상황에서 이걸 깊이 고민하는 이유가 controller가 요청으로 받은 DTO와 service에서 사용하는 DTO가 원하는 포맷이 다른 경우가 지금 상황에서는 없기 때문이다. 앞으로도 있을지는 모르겠다.&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;만약 두 포맷이 다른 상황이 온다면, 두개로 DTO를 분리해야 하는 게 맞다. 그렇다면 두 포맷이 다른 상황은 언제일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직은 그런 상황을 겪어보지 않아 정확히는 모르지만, 추측해본다면 service를 호출하는 controller가 두 개 이상일 때 이런 경우가 생길 수 있을 것 같다.&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;A controller는 HTTP 로 통신을 해 DTO를 요청으로 받아오지만, B Controller는 HTTP로 통신을 하는 것이 아니라 다른 방법(GRPC)을 통해 요청을 받고 두 컨트롤러 모두 같은 서비스를 호출한다고 하자, 이때 A, B 컨트롤러가 받는 DTO의 포맷은 다를 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이렇게 포맷이 달라지는 상황이 생길 때만 DTO를 두 개로 분리하면 되지 않을까 생각할 수 있지만 나는 미리 DTO를 두개로 나누는 게 더 좋다고 생각한다.&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;그 상황이 왔을 때 DTO를 두 개로 분리한다면, 그 자체로 변경에 유연하지 못한 코드가 아닐까? 변경에 유연하도록 미리 두개로 분리하는 게 더 좋지 않을까? 물론 매번 DTO를 두 번 만들어줘야 한다는 단점이 있지만, 이를 통해 얻을 수 있는 장점이 더욱 크다고 생각한다. 복잡한 코드라도 확장에 유연하다면 좋은 코드라고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;db에 저장되기 전인 객체와 후인 객체를 나누는 것에 대하여 &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스에 저장되기 전이라면 id 값이 null이고 후라면 id값이 존재한다. id값이 null로 들어갈 수 있는 객체라는 것이 객체를 나누는 신호가 아닐까?라는 생각에 데이터베이스에 저장되기 전인 객체와 데이터베이스에 저장된 후인 객체로 나누는 것이 어떨까 생각되어 나누어 보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public abstract class Reservation {

    private String name;
    private LocalDate date;
    private ReservationTime time;

    public Reservation(String name, LocalDate date, ReservationTime time) {
        this.name = name;
        this.date = date;
        this.time = time;
    }

    public String getName() {
        return name;
    }

    public LocalDate getDate() {
        return date;
    }

    public ReservationTime getTime() {
        return time;
    }

    abstract public Long getId();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public class NotInsertedReservation extends Reservation {
    
    public NotInsertedReservation(String name, LocalDate date, ReservationTime time) {
        super(name, date, time);
    }

    @Override
    public Long getId() {
        throw new IllegalStateException(&quot;id값이 존재하지 않습니다.&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public class InsertedReservation extends Reservation {

    private Long id;

    public InsertedReservation(Long id, String name, LocalDate date, ReservationTime time) {
        super(name, date, time);
        this.id = id;
    }

    @Override
    public Long getId() {
        return id;
    }
}
&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;이렇게 나누니 데이베이스에 너무 의존적인 설계가 된 것 같다. 저렇게 할 경우 단점이 id값이 auto increment가 아닌 경우에는 쓸데없는 짓을 한 것이고, 굳이 id가 null이라는 상태가 싫어 두 개로 나눠서 얻는 이점이 없다는 것이다. 보통 상태 패턴은 각각의 상태가 같은 행위에 대해 다른 행동을 할 때 쓰는데 위와 같이 나눔으로써 다른 행위를 가진 것이 getId 밖에 없다.&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;그렇다면 id가 null인 상태를 난 왜 싫다고 여겼을까? 그 이유부터 찾아가 보자.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일단 null값이 들어갈 수 있다면 비즈니스 로직을 처리하던 중에 NPE가 터질 수 있는 위험이 존재한다.&lt;/li&gt;
&lt;li&gt;그래서 곳곳에 null 체크 로직(if (id!= null))을 작성해야 해 비즈니스 로직이 복잡해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 id값을 이용해 비즈니스 로직을 수행하는 곳은 어디일까? 식별자 값으로 우리 비즈니스 로직을 처리하는 건 주로 데이터베이스에서 조회할 때 getId로 꺼내거나, DTO를 만들 때 getId로 값을 가져올 때 사용한다. 즉, 값을 꺼내는 용도로만 사용하고, 이 id값을 이용해 비즈니스 규칙대로 로직을 태우는 일은 없다. 말 그대로 식별자이기 때문이다.&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;그렇다면 null을 허용하더라도 getter로만 쓰니 괜찮지 않을까? 하지만 id값을 equals, hashcode를 재정의하는데 쓰면 문제가 생길 수 있다. 왜냐면 같은 객체가 둘 다 id가 null일 때 id값으로 equals, hashcode를 재정의 할 경우 두 객체가 동일하다고 판단될 수 있기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Entity)) return false;
    Entity that = (Entity) o;
    return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
    return Objects.hash(id);
}
&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;더불어 id == null 상태일 때 모든 신규 인스턴스의 hashCode()가 동일(보통 0)하게 된다. 이문제를 해결하려면 equals, hashcode를 재정의할 때 id가 null인 경우를 생각해줘야 한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        Order other = (Order) o;

        // 영속(영구) 상태: 둘 다 id가 있으면 id로 비교
        if (this.id != null &amp;amp;&amp;amp; other.id != null) {
            return this.id.equals(other.id);
        }
        // Transient 상태: 참조 동일성(==)으로만 true 허용
        return this == other;
    }

    @Override
    public int hashCode() {
        // 영속 상태: id 해시코드
        if (id != null) {
            return id.hashCode();
        }
        // Transient 상태: JVM 기본(identity) 해시코드
        return System.identityHashCode(this);
    }
&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;회고&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미션을 진행하면서 스쳐 지나가는 고민들을 바로바로 노션에 기록하고, 그 고민들을 해결해 나가는 방향으로 공부하니 미션이 끝난 후에도 막막하지 않았다. 무엇을 해야 할지 헤매기보다는, 이미 쌓아둔 고민들을 하나씩 해결하는 데 집중할 수 있어 좋았다. 앞으로도 이런 방식으로 꾸준히 공부하며 더 깊이 파고들어 가야겠다.&lt;/p&gt;</description>
      <category>회고</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/35</guid>
      <comments>https://dlwogns3413.tistory.com/35#entry35comment</comments>
      <pubDate>Wed, 4 Jun 2025 20:03:49 +0900</pubDate>
    </item>
    <item>
      <title>spring boot 에서 schema.sql 파일을 어떻게 실행시키는 걸까?</title>
      <link>https://dlwogns3413.tistory.com/34</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우테코에서 미션을 진행하며 테이블 초기화를 위해 schema.sql 파일을 이용하게 되었고, 그 과정에서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;스프링을 시작하면 어떻게 schema.sql 파일을 실행시키는지 궁금해져 디버깅을 통해 알아보고자&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #16171a; text-align: start;&quot;&gt;이 글을 작성하게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;학습 내용&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnMissingBean({SqlDataSourceScriptDatabaseInitializer.class, SqlR2dbcScriptDatabaseInitializer.class})
@ConditionalOnSingleCandidate(DataSource.class)
@ConditionalOnClass({DatabasePopulator.class})
class DataSourceInitializationConfiguration {
    DataSourceInitializationConfiguration() {
    }

    @Bean
    SqlDataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer(DataSource dataSource, SqlInitializationProperties properties) {
        return new SqlDataSourceScriptDatabaseInitializer(determineDataSource(dataSource, properties.getUsername(), properties.getPassword()), properties);
    }

    private static DataSource determineDataSource(DataSource dataSource, String username, String password) {
        return StringUtils.hasText(username) &amp;amp;&amp;amp; StringUtils.hasText(password) ? DataSourceBuilder.derivedFrom(dataSource).username(username).password(password).type(SimpleDriverDataSource.class).build() : dataSource;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Spring Boot는 &lt;b&gt;SqlDataSourceScriptDatabaseInitializer&lt;/b&gt; class를 Bean으로 등록을 한다. 이때 SqlDataSourceScriptDatabaseInitializer class를 생성하게 된다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@ImportRuntimeHints({SqlInitializationScriptsRuntimeHints.class})
public class SqlDataSourceScriptDatabaseInitializer extends DataSourceScriptDatabaseInitializer {
    public SqlDataSourceScriptDatabaseInitializer(DataSource dataSource, SqlInitializationProperties properties) {
        this(dataSource, getSettings(properties));
    }

    public SqlDataSourceScriptDatabaseInitializer(DataSource dataSource, DatabaseInitializationSettings settings) {
        super(dataSource, settings);
    }

    public static DatabaseInitializationSettings getSettings(SqlInitializationProperties properties) {
        return SettingsCreator.createFrom(properties);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SqlDataSourceScriptDatabaseInitializer class가 생성될 때 &lt;b&gt;SettingsCreator&lt;/b&gt;의 createFrom 메서드가 실행된다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;static DatabaseInitializationSettings createFrom(SqlInitializationProperties properties) {
        DatabaseInitializationSettings settings = new DatabaseInitializationSettings();
        settings.setSchemaLocations(scriptLocations(properties.getSchemaLocations(), &quot;schema&quot;, properties.getPlatform()));
        ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;createFrom 메서드를 보면 setSchemaLocations settings.setSchemaLocations 메서드를 호출하여 스키마 파일의 위치를 설정해주고 있는 걸 볼 수 있다. 스키마 파일의 위치는 scriptLocations 메서드를 통해 결정한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private static List&amp;lt;String&amp;gt; scriptLocations(List&amp;lt;String&amp;gt; locations, String fallback, String platform) {
        if (locations != null) {
            return locations;
        } else {
            List&amp;lt;String&amp;gt; fallbackLocations = new ArrayList();
            fallbackLocations.add(&quot;optional:classpath*:&quot; + fallback + &quot;-&quot; + platform + &quot;.sql&quot;);
            fallbackLocations.add(&quot;optional:classpath*:&quot; + fallback + &quot;.sql&quot;);
            return fallbackLocations;
        }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scriptLocations의 인자로 오는 locations에는 createFrom 메서드에서 알 수 있듯이 properties.getSchemaLocations()의 결과가 locations로 들어오게 되고, fallback으로는 &quot;schema&quot; 문자열이 들어온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;properties.getSchemaLocations()은 application.yml 파일에 스키마 파일의 위치를 따로 설정할 수 있는데 없으면 null값이 들어오게 된다. 따라서 locations 값은 null이 되고 스키마 파일의 기본 경로는 optional:classpath*:schema.sql 파일이 되게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해서 스키마 파일의 위치가 설정된 채로 SqlDataSourceScriptDatabaseInitializer class가 빈으로 등록되고 SqlDataSourceScriptDatabaseInitializer의 &lt;code&gt;runScripts&lt;/code&gt; 메서드를 실행하여 schema.sql 파일이 실행되게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 메서드는 SqlDataSourceScriptDatabaseInitializer의 부모 class인 &lt;code&gt;AbstractScriptDatabaseInitializer에&lt;/code&gt; 위치해 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;protected void runScripts(AbstractScriptDatabaseInitializer.Scripts scripts) {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        populator.setContinueOnError(scripts.isContinueOnError());
        populator.setSeparator(scripts.getSeparator());
        if (scripts.getEncoding() != null) {
            populator.setSqlScriptEncoding(scripts.getEncoding().name());
        }

        for(Resource resource : scripts) {
            populator.addScript(resource);
        }

        this.customize(populator);
        DatabasePopulatorUtils.execute(populator, this.dataSource);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 저 메서드는 누가 실행시킬까? AbstractScriptDatabaseInitializer를 보면 &lt;code&gt;InitializingBean&lt;/code&gt; 인터페이스를 구현하고 있어 스프링 빈 생성 후 의존 관계 주입이 완료되면 &lt;code&gt;afterPropertiesSet&lt;/code&gt; 메서드를 호출하게 되는데 이때 initializeDatabase 메서드를 실행시킴으로써 applySchemaScripts 메서드를 실행하게 된다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public abstract class AbstractScriptDatabaseInitializer implements ResourceLoaderAware, InitializingBean {

    ...

    public void afterPropertiesSet() throws Exception {
        this.initializeDatabase();
    }

    public boolean initializeDatabase() {
        ScriptLocationResolver locationResolver = new ScriptLocationResolver(this.resourceLoader);
        boolean initialized = this.applySchemaScripts(locationResolver);
        return this.applyDataScripts(locationResolver) || initialized;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;applySchemaScripts 메서드가 다시 runScripts를 호출함으로써 스크립트가 실행되게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1745393854789&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private boolean applyScripts(List&amp;lt;String&amp;gt; locations, String type, ScriptLocationResolver locationResolver) {
        List&amp;lt;Resource&amp;gt; scripts = this.getScripts(locations, type, locationResolver);
        if (!scripts.isEmpty() &amp;amp;&amp;amp; this.isEnabled()) {
            this.runScripts(scripts);
            return true;
        } else {
            return false;
        }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 applyScripts 메서드가 실행할 때&amp;nbsp; isEnabled 메서드를 호출하여 결괏값이 true일 때 스크립트 파일이 실행되는데 모드 설정이 NEVER라면 절대 실행 안되고 ALWAYS이거나 모드설정이 없어도 임베디드 데이터베이스라면 실행되는 것을 확인할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1745394013488&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private boolean isEnabled() {
        if (this.settings.getMode() == DatabaseInitializationMode.NEVER) {
            return false;
        } else {
            return this.settings.getMode() == DatabaseInitializationMode.ALWAYS || this.isEmbeddedDatabase();
        }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DataSourceInitializationConfiguration으로 &lt;b&gt;SqlDataSourceScriptDatabaseInitializer &lt;/b&gt;빈이 등록되고 해당 class는 InitializingBean을 구현하고 있어 스프링 빈이 다 만들어지고 의존관계 주입이 끝날 때 afterPropertiesSet 메서드가 실행되는데 이때 스크립트 파일을 실행한다. 만약 스크립트 위치를 설정하지 않았다면 기본적으로 schema.sql 파일로 실행되게 된다.&lt;/p&gt;</description>
      <category>Spring</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/34</guid>
      <comments>https://dlwogns3413.tistory.com/34#entry34comment</comments>
      <pubDate>Wed, 23 Apr 2025 16:45:04 +0900</pubDate>
    </item>
    <item>
      <title>상속은 언제 사용해야 할까?</title>
      <link>https://dlwogns3413.tistory.com/33</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;들어가며&amp;nbsp;&lt;/b&gt;&lt;/h3&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;상속을 사용하는 이유&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속을 왜 사용할까? 상속은 두가지 용도로 사용된다. 첫번째는 타입 계층을 구현하는 것이고 두번째는 코드 재사용이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;객체지향 프로그래밍에서 타입 계층을 구현한다는 의미는 무엇일까? 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 동일하다. 즉, 객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 사실은 객체의 타입은 객체 내부의 멤버변수로 정해지는 것이 아닌 행동으로 정해진다는 것이다. 속성이 같아도 행동이 다르다면 다른 타입계층에 속한다.&lt;/p&gt;
&lt;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;상속을 사용하는 기준&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속이 타입 계층을 구현하는 용도로 사용된다는 것을 알았다. 그렇다면 언제 상속을 사용해야 할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 자바를 알려주는 책을 보면 is-a관계에서 상속을 사용하라고 한다. 여기서 의문이 두가지가 드는데 첫번째는 is-a관계는 무엇인지, 두번째는 is-a관계에서는 무조건 상속을 사용하는것이 좋은건지이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;is-a관계란&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;is-a관계는 어떤 타입 S가 다른 타입 T의 일종이라면 당연히 타입 S는 타입 T다 (S is-a T) 라고 말할 수 있어야 한다. 백엔드는 직업이다 라고 표현할 수 있고 개발자는 직업이다 라고 표현할 수 있다. 즉, 백엔드, 개발자, 직업은 is-a 관계를 만족시킨다.&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;하지만 is-a관계가 생각처럼 직관적이고 명쾌한 것이 아니다. is-a관계인줄 알았지만 특정 상황에서는 is-a관계가 아닐 수 있기 때문이다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블랙잭 게임에서는 딜러와 플레이어가 존재한다. 딜러와 플레이어는 서로 게임에 참여하여 패가 21과 가까운 수를 가진 사람이 이긴다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게임 참여자는 게임이 시작되면 베팅을 해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 알 수 있는 사실은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 딜러는 게임 참여자이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 플레이어는 게임 참여자이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 게임 참여자는 게임 시작시 베팅을한다.&lt;/p&gt;
&lt;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;pre id=&quot;code_1742975082320&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Participant {
	public void preparebetting() {
		// 베팅을한다. 
	}
}

public Dealer extends Participant {
	...
}
public Player extends Participant {
	...
}&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;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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것을 통해 알 수 있는 결론은 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다는 것이다. 여기서 중요한 것은 행동의 호환 여부를 판단하는 기준은 클라이언트의 관점이라는 것이다. 클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있다. 클라이언트가 두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 클라이언트가 다음과 같이 코드를 작성한다고 하자&lt;/p&gt;
&lt;pre id=&quot;code_1742978168918&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void betParticipant(Participant participant) {
    //인자로 전달된 모든 participant는 베팅할 수 있어야한다. 
    participant.prepareBetting();
}&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;위 코드에서 클라이언트는 인자로 전달된 Participant가 베팅을 할 수 있다고 기대하고 있다. 하지만 Dealer가 인자로 들어오게 된다면 클라이언트의 기대를 저버리게 된다.&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;/p&gt;
&lt;pre id=&quot;code_1742979124150&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Dealar extends Participant {

    @Override
    public void prepareBetting() {
    	throw new UnsupportedOperationException();
    }
}&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; 이 경우에도 클라이언트의 관점에서 기대를 저버리는것은 동일하다. 클라이언트는 모든 참여자가 베팅을 할 수 있을거라 기대하지만 예외가 발생하기 때문이다. 따라서 이 방법 역시 클라이언트의 관점에서 Participant와 Dealer의 행동이 호환되지 않는다.&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;또 다른 방법으로 instanceof를 활용해 인자로 전달된 Participant의 타입이 Dealer가 아닐 경우에만 prepareBetting 메서드를 호출하게 하는것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1742984575206&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void betParticipant(Participant participant) {
    
    if (!(participant instanceof Dealer) {
    	participant.prepareBetting();
    }
}&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;하지만 이 방법 역시 문제가 있다. 만약 Dealer 뿐만 아니라 베팅을 할 수 없는 참여자가 생긴다면 어떻게 될까? 그렇다면 위 코드를 계속해서 유지보수해야할 것이다. 일반적으로 instanceof 처럼 객체의 타입을 확인하는 코드는 새로운 타입이 추가할 때마다 코드 수정을 요구하기 때문에 OCP 원칙을 위반한다.&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;/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;betParticipant 메서드는 파라미터로 전달되는 모든 참여자가 베팅을 할 수 있다고 가정하기 때문에 betParticipant 메서드와 협력하는 모든 객체는 prepareBetting 메시지에 대해 올바르게 응답할 수 있어야 한다. 그러기 위해서 베팅을 할 수 있는 참여자와 베팅을 할 수 없는 참여자를 구분하여 상속 계층을 만들면 어떨까?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742985747634&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Participant {
	...
}

public class BettingParticipant extends Participant {
	public void prepareBetting() {...}
}

public class Dealer extends Participant {
	...
}&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;위와 같이 베팅을 할 수 있는 참여자와 하지 못하는 참여자를 구분한다면 BetttingParticipant 타입을 이용해 베팅을 할 수 있는 참여자만 인자로 전달돼야 한다는 사실을 코드에 명시할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1742985864303&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void betParticipant(BettingParticipant participant) {
    participant.prepareBetting();
}&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;요구사항 속에서 클라이언트가 기대하는 행동에 집중하자. 클래스의 이름 사이에 어떤 연관성이 있다는 사실은 아무런 의미도 없다. 두 클래스 사이에 행동이 호환되지 않는다면 타입 계층이 아니기 때문에 상속을 사용해서는 안 된다.&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;결론&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속은 단지 코드 재사용을 위해서 사용하면 안된다. 상속은 타입 계층을 구성하기 위해 사용해야 한다. 단순히 코드 재사용을 위해서 상속을 사용한다면 부모 클래스와 자식 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;출처&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조영호,&amp;nbsp;&lt;b&gt;&lt;span&gt;『&lt;/span&gt;&lt;/b&gt;오브젝트&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&lt;span&gt;&lt;b&gt;&lt;span&gt;』&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;span&gt;434 ~ 453 pg&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>JAVA</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/33</guid>
      <comments>https://dlwogns3413.tistory.com/33#entry33comment</comments>
      <pubDate>Wed, 26 Mar 2025 20:37:45 +0900</pubDate>
    </item>
    <item>
      <title>개발 시작부터 지금까지의 여정</title>
      <link>https://dlwogns3413.tistory.com/32</link>
      <description>&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개발의 시작&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_9574.JPG&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;1434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biKDKz/btsLE4O5nUX/mdZMYwrCqDSKhf8o0pv1J1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biKDKz/btsLE4O5nUX/mdZMYwrCqDSKhf8o0pv1J1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biKDKz/btsLE4O5nUX/mdZMYwrCqDSKhf8o0pv1J1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiKDKz%2FbtsLE4O5nUX%2FmdZMYwrCqDSKhf8o0pv1J1%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;637&quot; height=&quot;781&quot; data-filename=&quot;IMG_9574.JPG&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;1434&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개발의 본격적인 시작은 군대 입대 3개월전이었다. 입대전 할것도 마땅치 않아 무언가라도 해보고 싶었던 나는 우연히 프론트 개발에 대해 알게되었다. 예전에 형이 학교에서 만들어온 사이트를 내게 보여준 적이 있었는데, 정말 신기하기도 했고 어떻게 저걸 만들 수 있을지 궁금했다. 컴공 1학년을 마쳤지만, 전공수업때 배운건 C언어, 파이썬 뿐이라 그런건 도대체 어떻게 만드는지 궁금했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 처음에 웹사이트를 한번 만들어 보자고 다짐했다. 처음에는 생활코딩의 web강의를 들으며 html, css, js가 무엇인지 알게되었고 조금이나마 무언가를 내 손으로 직접 만들 수 있었다. 단지 컴퓨터밖에 없는데 무언가가 내뜻대로 만들어지고 바로 눈에 보이니 그때부터 개발에 대한 매력에 빠진 것 같다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 시작한건 유튜브 클론코딩 강의를 보는 것이었다. 그때당시 30만원이던 강의를 사서 보기시작했다. 처음에는 당연히 무슨 말인지 하나도 몰랐다. 근데 따라치는것 만으로도 내가 유튜브를 만들고 있다는 생각에 신이나 정말 재밌었다. 처음으로 강의를 다보고 아직 이해가 되지 않는 부분이 많아, 군대가기 전까지 20시간 강의를 총 3번을 돌려볼 정도로 열정적으로 강의를 들었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 javascript의 매력에 빠져, 제로초님의 강의, 노마드코더의 강의, javascript관련 책, node js 책등을 계속 보며 하루하루를 지내다 군대에 입대하게 됐다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;군 입대&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;군대에 입대하고 나서는 개발을 계속할 수는 없었다. 몸도 마음도 힘들어 도저히 개발까지 시간을 써서 하기가 힘들었고, 우리 부대에서는 파견을 자주갔는데 파견지에는 매일 12시간 야간 근무를 해야했고, 인강을 듣고 싶어도 싸지방에는 스피커도 안돼 공부할 환경이 아니었다. 그땐 공부 대신 운동을 즐겨하기 시작했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공부를 다시 시작하게 된건 전역하기 1달전이다. 병장이기도 했고, 그때는 이제 몸도 마음도 예전처럼 힘들지 않았다. 그래서 매일밤 연등을 신청해 2시간정도 인강을 보며 하루를 마무리했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;전역 후&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;전역을 하고 나서도 놀러간게 아닌 공부를 했다. 왜 그렇게 까지 했나 생각해보면 그냥 개발이 너무 재밌었다. 군대때 근무를 서며 전역을 하면 무슨 애플리케이션을 만들지 혼자 노트에 적어가며 시간을 때웠는데, 그때 생각해놓은 아이디어를 빨리 인강을 보며 실력을 쌓아 개발하고 싶었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 처음 혼자 힘으로 만들어본건 trello와 같은 드래그 앤 드롭이 가능한 투두 리스트 웹앱이었다. heroku로 인생처음 배포도 해보고, api 설계도 해보고, 하루종일 버그와도 싸워보고 많은 시간을 투자한끝에 완성하게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 한번 만들고 나니 무엇이든지 개발할 수 있을것 같았다. 개발이 너무 쉬워보였다. 그뒤로는 DND IT 동아리에 지원해 합격도하고, 다른 IT 동아리도 지원해 합격하고, 모든게 내 생각대로 잘 풀렸던것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;스크린샷 2025-01-04 오후 2.14.05.png&quot; data-origin-width=&quot;825&quot; data-origin-height=&quot;434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uP7wO/btsLEgCsIGi/b5WteqGGICTI6zzOAosUDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uP7wO/btsLEgCsIGi/b5WteqGGICTI6zzOAosUDk/img.png&quot; data-alt=&quot;첫 프로젝트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uP7wO/btsLEgCsIGi/b5WteqGGICTI6zzOAosUDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuP7wO%2FbtsLEgCsIGi%2Fb5WteqGGICTI6zzOAosUDk%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;825&quot; height=&quot;434&quot; data-filename=&quot;스크린샷 2025-01-04 오후 2.14.05.png&quot; data-origin-width=&quot;825&quot; data-origin-height=&quot;434&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;첫 프로젝트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUzuNo/btsLEXoUGAu/vc1W3JWXPZ5b7dK6uxWx61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUzuNo/btsLEXoUGAu/vc1W3JWXPZ5b7dK6uxWx61/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;366&quot; data-origin-height=&quot;326&quot; data-filename=&quot;스크린샷 2025-01-04 오후 2.14.59.png&quot; width=&quot;205&quot; height=&quot;183&quot; style=&quot;width: 46.5137%; margin-right: 10px;&quot; data-widthpercent=&quot;47.06&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUzuNo/btsLEXoUGAu/vc1W3JWXPZ5b7dK6uxWx61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUzuNo%2FbtsLEXoUGAu%2Fvc1W3JWXPZ5b7dK6uxWx61%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;366&quot; height=&quot;326&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bywGwz/btsLErxda4r/5S1h8SwCUwzMKmNi8jB4A1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bywGwz/btsLErxda4r/5S1h8SwCUwzMKmNi8jB4A1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;464&quot; data-filename=&quot;스크린샷 2025-01-04 오후 2.15.14.png&quot; width=&quot;154&quot; height=&quot;122&quot; style=&quot;width: 52.3235%;&quot; data-widthpercent=&quot;52.94&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bywGwz/btsLErxda4r/5S1h8SwCUwzMKmNi8jB4A1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbywGwz%2FbtsLErxda4r%2F5S1h8SwCUwzMKmNi8jB4A1%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;586&quot; height=&quot;464&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;IT 동아리 합격 메일&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;대학교에 복학을 하고 나서는 알고리즘, CS에 대해 집중적으로 공부하기 시작했다. 그때당시 내 목표는 오로지 네카라쿠배였다. 당연히 지금부터 하면 갈 수 있을 줄 알았다. 그렇게 매일 알고리즘 2문제, 학교에서 배운 CS, 여러 프로젝트를 진행하면서 살았다. 1학기때는 프론트엔드로 총 5개의 프로젝트를 진행할 정도로 개발을 많이 했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 프론트를 하면 할수록 CSS가 너무 싫었다. 나는 비즈니스 로직을 구현하는게 좋지, UI를 이쁘게 꾸미는건 내 스타일이 아니었다. 계속 프론트만 공부를 해오고 있어 이때까지 공부한게 아쉬웠지만 백엔드로 목표를 변경해 백엔드 공부를 중점적으로 시작했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;백엔드의 시작&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Java도 별로 모르고, Spring도 들어만 본 나는 먼저 자바의 정석을 보며 java에 대해 알아갔고, 김영한님 강의를 보며 Spring에 대해 알아갔다. 그때는 프로젝트를 하지않고 오로지 강의에만 집중했다. 하지만 너무 많은 강의가 있어 다 보는데 꽤 시간이 걸렸다. 이해가 안가는 부분은 다시 돌려보기도 하고, 질문을 하기도 하고, 그러면서 강의를 보니 너무 진도가 느렸던것 같다. 그렇게 2023년 6월까지는 강의만 보았고 그렇게 김영한님 강의를 모두 완강을 하게 되었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 강의만 보고 무언가를 만들어보지 못해 내 실력이 가늠이 안됐다. 물론 무언가를 만들면 알 수 있다는 것을 누구보다 잘 알았지만, 예전처럼 무언가를 만드는 것이 마냥 즐겁지는 않았던 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그러다 여름 방학때 학교에서 좋은 기회가 있어 소프트웨어학과와 함께 백엔드로 프로젝트를 진행하게 되었다. 2달가량 프로젝트를 하며 재밌었지만, 그때뿐이었던것 같다. 해당 프로젝트가 끝난 뒤에는 또다시 강의만 보고 있는 나를 발견했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;번아웃&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 2023년이 끝나고 2024년이 될때 사이드 프로젝트를 무조건 하자고 다짐했다. 그렇게 Round Table이라는 사이드프로젝트를 1월부터 진행하기 시작했다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프론트, 백엔드가 나눠져 있는것이 아닌 혼자힘으로 개발하기 시작했다. 근데 뭐랄까 예전에 프로젝트를 할때면 하루하루가 즐거웠는데, 프로젝트를 하려고 intellij를 키는 순간부터 너무 하기 싫어졌다. 왜인지는 모르겠는데 그냥 만드는게 예전처럼 재미도 없고 지루하고, 그런것 같다. 비즈니스 로직에 어려운 것이 조금 있었는데 내가 과연 그걸 다 개발할 수 있을까 라는 의구심도 있었던것 같다. 그리고 무엇보다도 의욕이 없었다. 그게 지금까지 계속되고 있고 Round Table은 아직까지도 개발중에 있다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;IT 동아리에 들어가 백엔드로 개발을할때도 그렇게 재미있진 않았다. 근데도 중간중간 재미있던 요소가 있었는데, 새로운 것을 적용한다던가 내가 몰랐던 부분을 알게돼 얼른 이걸 적용하고 싶다던가, 그럴때는 재미있었다. 하지만 평소하던 API개발, Test 코드 작성 등등 너무 지루하고 재미가 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예전에는 몰랐던 것이 많아서, 무엇이든 새롭고 흥미롭게 느껴졌던 걸까? 그렇다면, 나는 정말 개발을 좋아했던 걸까? 이런 생각들이 요즘 자꾸 머릿속을 맴돈다. 개발을 3년 동안 했는데도, 내세울 만한 결과가 없는 것 같아 스스로에 대한 회의감도 든다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 요즘은 잠시 휴식을 취하며 지내고 있다. 흔히 말하는 번아웃이 온 건지, 아니면 단순히 개발이 싫어진 건지는 확신할 수 없지만, 충분히 쉬고 나면 예전의 열정이 다시 돌아오지 않을까 기대하고 있다. 그래서 요즘은 최소한의 공부만 하며, 나머지 시간에는 내가 하고 싶은 일들을 하면서 시간을 보내고 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2025&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이제 4학년 2학기가 끝나고 졸업을 앞두고 있다. 다행히도 우테코라는 좋은 기회를 얻게 되었지만, 솔직히 말하면 그곳에서 예전처럼 열심히 할 수 있을지는 자신이 없다. 그래서 요즘 머릿속을 떠나지 않는 질문이 있다. &lt;b&gt;&amp;lsquo;나는 어떤 개발자가 되고 싶은가?&amp;rsquo;&lt;/b&gt; 이 고민이 계속해서 나를 사로잡고 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새해를 맞아 이번 한 해 동안 어떤 개발자로 성장하고 싶은지에 대해 깊이 고민하고, 이를 구체화하는 것을 목표로 삼았다. 우테코에서 다양한 사람들과 교류하고 함께 개발하며, 과거의 열정을 다시 찾고 싶다. 이번 한 해가 크게 성장하고 의미 있는 발자취를 남기는 시간이 되기를 바란다.&lt;/p&gt;</description>
      <category>회고</category>
      <author>perseverance</author>
      <guid isPermaLink="true">https://dlwogns3413.tistory.com/32</guid>
      <comments>https://dlwogns3413.tistory.com/32#entry32comment</comments>
      <pubDate>Sat, 4 Jan 2025 15:02:36 +0900</pubDate>
    </item>
  </channel>
</rss>