<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>더기</title>
    <link>https://dev-monkey-dugi.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Fri, 10 Apr 2026 18:21:05 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>monkeyDugi</managingEditor>
    <image>
      <title>더기</title>
      <url>https://tistory1.daumcdn.net/tistory/4300815/attach/00f50f5d651945b3b28292c1ead5336f</url>
      <link>https://dev-monkey-dugi.tistory.com</link>
    </image>
    <item>
      <title>Java에서 한글을 2바이트로 처리하기</title>
      <link>https://dev-monkey-dugi.tistory.com/178</link>
      <description>&lt;h2&gt;  Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;php → java로 컨버팅을 하고 php 서버의 api와 java 서버의 api의 응답 데이터를 완전히 동일하게 맞추는 방식으로&lt;/p&gt;
&lt;p&gt;테스트를 진행했다. 기능을 바꾸는 것이 아닌 코드 컨버팅만 했기 때문에 php서버의 응답과 100% 동일해야 했다.&lt;/p&gt;
&lt;h3&gt;발생 문제&lt;/h3&gt;
&lt;p&gt;문자열을 최대 150로 잘라야 했다.&lt;/p&gt;
&lt;p&gt;php에서는 기본 api를 사용해서 문자열을 자르면 바이트 기준 150개로 자른다. 이때 한글은 2바이트로 인식한다.&lt;/p&gt;
&lt;p&gt;하지만 java는 문자열 자체를 기준으로 두기 때문에 2배 적게 길이가 측정됐다.&lt;/p&gt;
&lt;p&gt;예를 들면 아래와 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;언어&lt;/th&gt;
&lt;th&gt;자를 길이&lt;/th&gt;
&lt;th&gt;타겟 문자열&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;php&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;안녕하세요&lt;/td&gt;
&lt;td&gt;안녕&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;java&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;안녕하세요&lt;/td&gt;
&lt;td&gt;안녕하세&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;java에서 &lt;code&gt;StringUtils.left(string, limit)&lt;/code&gt;를 사용했다.&lt;/p&gt;
&lt;p&gt;이는 단순이 문자열을 왼쪽부터 4자만큼 자르는 것이다.&lt;/p&gt;
&lt;h2&gt;  Trial And Error&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Charset별로 디코딩&lt;/h3&gt;
&lt;p&gt;먼저 한글을 몇 바이트로 JVM이 처리하는지 알아봤다.&lt;/p&gt;
&lt;p&gt;아래 코드로 확인한 결과 3바이트라는 것을 확일할 수 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;&amp;quot;안녕하세요&amp;quot;.getBytes().length // 15&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 3바이트라는 것을 알았기 때문에 이걸 2바이트로 바꾸면 되겠다고 생각했다.&lt;/p&gt;
&lt;p&gt;그래서 2바이트로 처리하는 Charset을 찾기 위해 모든 &lt;strong&gt;Charset&lt;/strong&gt;을 사용해서 String으로 디코딩 해봤다.&lt;/p&gt;
&lt;p&gt;하지만 모두 문자는 깨졌다. 하지만 UTF-8로 하면 깨지지 않았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// org.apache.commons.lang3.StringUtils
StringUtils.left(new String(&amp;quot;한글어쩌구&amp;quot;.getByte(), UTF-8) ,150).trim()&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Java는 OS의 시스템 Charset을 디폴트로 채택하고 있다.&lt;/h3&gt;
&lt;p&gt;JVM은 디폴트 Charset을 OS Charset으로 한다는 것을 알게 되었다.&lt;/p&gt;
&lt;p&gt;아래 코드로 테스트 결과로 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;내 컴퓨터의 Charset은 &lt;strong&gt;UTF-8&lt;/strong&gt;이었고, 그래서 위에서 Charset별로 디코딩을 시도했을 때 UTF-8을 제외하고는 모두 깨졌던 것이다.&lt;/p&gt;
&lt;p&gt;즉, 인코딩이 되면 Charset은 바꿀 수가 없다. 이미 약속된 프로토콜로 인코딩 됐기 때문에&lt;/p&gt;
&lt;p&gt;디코딩을 다른 Charset으로 하면 안되는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void 인코딩() {
  // given
  String twoByteStr = &amp;quot;안&amp;quot;;
  String encode16 = UriUtils.encode(twoByteStr, StandardCharsets.UTF_16);
  String decode8 = UriUtils.encode(twoByteStr, StandardCharsets.UTF_8);

  System.out.println(Charset.defaultCharset()); // UTF-8
  System.out.println(&amp;quot;decode8 = &amp;quot; + decode8);   // decode8 = %EC%95%88
  System.out.println(&amp;quot;encode16 = &amp;quot; + encode16); // encode16 = ╆䔥䙆╃㕈
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCLeft&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWGKla/btsqR9Q0qEB/Py4OnuZRVlU72p7pv4LpJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWGKla/btsqR9Q0qEB/Py4OnuZRVlU72p7pv4LpJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWGKla/btsqR9Q0qEB/Py4OnuZRVlU72p7pv4LpJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWGKla%2FbtsqR9Q0qEB%2FPy4OnuZRVlU72p7pv4LpJ0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;인코딩 문제가 맞았을까?&lt;/h3&gt;
&lt;p&gt;인코딩 이슈는 아니라고 볼 수 있을 것 같다.&lt;/p&gt;
&lt;p&gt;php 서버에서는 utf-16인지는 모르겠지만 2바이트로 한글을 취급하는 Charset을 사용했던 것이다.&lt;/p&gt;
&lt;p&gt;물론 그렇다고 한들 바이트 기준으로 자르는 것은 라이브러리를 못찾았기 때문에 결국 나는 직접 2바이트로 한글을 자르는 코드를&lt;/p&gt;
&lt;p&gt;작성했다. 만약 라이브러리를 찾았는데, 거기서 &lt;strong&gt;defaultCharset()&lt;/strong&gt;을 사용했다면 인코딩 문제가 됐을 수도 있다.&lt;/p&gt;
&lt;p&gt;위에서 테스트한 결과를 보면 알 수 있듯이 &lt;strong&gt;defaultCharset() OS의 Charset&lt;/strong&gt;을 읽어오기 때문이다.&lt;/p&gt;
&lt;p&gt;그러니까 애초에 인코딩 문제였다기 보다는 php에서 문자열을 그대로 자르느냐 몇 바이트 기준으로 자르느냐의 문제였던 것 같다.&lt;/p&gt;
&lt;p&gt;자바에서도 3바이트로 인식하는 것도 무관한 것 아닌가?&lt;/p&gt;
&lt;h3&gt;한글을 2바이트로 인식하는 Charset은 없을까?&lt;/h3&gt;
&lt;p&gt;UTF-8은 3바이트로 인식하고 UTF-16은 2 or 4바이트로 인식한다.&lt;/p&gt;
&lt;p&gt;그렇기 때문에 defaultCharset()을 사용한다면 os의 Charset을 바꿔야 한다.&lt;/p&gt;
&lt;p&gt;아니면 JVM 옵션을 바꾸는 방법도 있지 않을까?&lt;/p&gt;
&lt;h3&gt;⚠️ 왜 문자열 자체가 아닌 바이트 단위로 잘라서 사용해야 할까?&lt;/h3&gt;
&lt;p&gt;비지니스적 문제이다. 예를 들어 디비에 컬럼 사이즈 때문이라던지 아니면 통신하는데 있어서 규칙이 그렇다던지 등등..&lt;/p&gt;
&lt;h2&gt;  Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;바이트 기준으로 자르도록 변경&lt;/h3&gt;
&lt;p&gt;자바 or 스프링에서 제공하는 바이트 기준으로 자르는 것을 찾지 못해서 직접 구현하는 방식을 택했다.&lt;/p&gt;
&lt;p&gt;결국 2바이트 기준으로 잘라야 하는 상황이었기 때문에 택하게 되었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;code&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;  public class ByteString {

    /**
     * String에서 한글이 있을 경우 한글을 2byte or 3byte등으로 인식하게 하여
     * 바이트 기준으로 문자열을 잘라서 문자열을 반환한다.
     * @param target 자를 문자열
     * @param limit 바이트 기준으로 자를 limit
     * @param standardByteLength 한글을 몇 바이트로 취급할지 길이
     * @return 바이트 기준으로 잘린 문자열
     */
    public static String cutStringToByte(String target, int limit, int standardByteLength) {
      TargetString doggy = new TargetString(target);
      return doggy.leftStringToByteCuting(limit, standardByteLength);
    }

    private static class TargetString {

      private final String target;
      private int realLimit = 0;
      private int bufferSize = 0;

      public TargetString(String target) {
        this.target = target;
      }

      public String leftStringToByteCuting(int standardLimit, int standardByte) {
        for (int i = 0; i &amp;lt; target.length(); i++) {
          char ascii = target.charAt(i);
          boolean isFillBuffer = fillBuffer(ascii, standardLimit, standardByte);

          if (!isFillBuffer) {
            return target.substring(0, realLimit);
          }
        }

        return target.substring(0, realLimit);
      }

      private boolean fillBuffer(char c, int standardLimit, int standardByte) {
        if (isAsciiCode(c)) {
          return fillEnglishBuffer(standardLimit);
        }

        return fillKrBuffer(standardLimit, standardByte);
      }

      private static boolean isAsciiCode(int asciiCode) {
        return asciiCode &amp;lt;= 127;
      }

      private boolean fillEnglishBuffer(int standardLimit) {
        if (isFillEnglish(standardLimit)) {
          bufferSize++;
          realLimit++;

          return true;
        }
        return false;
      }

      private boolean fillKrBuffer(int standardLimit, int standardByte) {
        if (isFillKr(standardLimit, standardByte)) {
          bufferSize += standardByte;
          realLimit++;

          return true;
        }
        return false;
      }

      private boolean isFillEnglish(int standardLimit) {
        return standardLimit &amp;gt; bufferSize;
      }

      private boolean isFillKr(int standardLimit, int standardByte) {
        return standardLimit &amp;gt;= bufferSize + standardByte;
      }
    }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt; 참고 자료&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href=&quot;https://namu.wiki/w/%EC%9D%B8%EC%BD%94%EB%94%A9&quot;&gt;https://namu.wiki/w/인코딩&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://codingpractices.tistory.com/entry/%EC%9D%B8%EC%BD%94%EB%94%A9-vs-%EB%94%94%EC%BD%94%EB%94%A9-%EC%A0%95%ED%99%95%ED%95%98%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0&quot;&gt;https://codingpractices.tistory.com/entry/인코딩-vs-디코딩-정확하게-이해하기&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://it-eldorado.tistory.com/143&quot;&gt;https://it-eldorado.tistory.com/143&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://codingpractices.tistory.com/entry/%EC%9D%B8%EC%BD%94%EB%94%A9-vs-%EB%94%94%EC%BD%94%EB%94%A9-%EC%A0%95%ED%99%95%ED%95%98%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0&quot;&gt;https://codingpractices.tistory.com/entry/인코딩-vs-디코딩-정확하게-이해하기&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://luv-n-interest.tistory.com/1369&quot;&gt;https://luv-n-interest.tistory.com/1369&lt;/a&gt;&lt;/p&gt;</description>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/178</guid>
      <comments>https://dev-monkey-dugi.tistory.com/178#entry178comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:53:15 +0900</pubDate>
    </item>
    <item>
      <title>아스키 코드와 유니코드 그리고 인코딩과 디코딩</title>
      <link>https://dev-monkey-dugi.tistory.com/177</link>
      <description>&lt;h2&gt;  아스키 코드와 인코딩&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;컴퓨터의 저장 단위&lt;/h3&gt;
&lt;p&gt;컴퓨터의 기본 저장 단위는 &lt;strong&gt;Byte&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1Byte&lt;/strong&gt;는 &lt;strong&gt;8bit&lt;/strong&gt;를 의미하고, &lt;strong&gt;1byte&lt;/strong&gt;는 &lt;strong&gt;2^8&lt;/strong&gt;에 해당하는 &lt;strong&gt;256개&lt;/strong&gt;의 값을 저장할 수 있다.&lt;/p&gt;
&lt;p&gt;즉, 우리가 컴퓨터에서 사용하는 모든 문자는 이 &lt;strong&gt;Byte&lt;/strong&gt;에 담기는 것이다.&lt;/p&gt;
&lt;h3&gt;ASCII(American Standard Code for Information Interchange)&lt;/h3&gt;
&lt;p&gt;아스키는 1960년대에 미국에서 정의한 문자 부호 표준이다.&lt;/p&gt;
&lt;p&gt;컴퓨터와 통신을 하기 위해서 정의된 부호이다.&lt;/p&gt;
&lt;p&gt;컴퓨터는 &lt;strong&gt;이진수&lt;/strong&gt;만 다룰 수 있기 때문에 0, 1을 제외하고 다른 것을 읽을 수 없다.&lt;/p&gt;
&lt;p&gt;그래서 최종적으로 문자는 이진수로 바껴서 컴퓨터에 저장된다.&lt;/p&gt;
&lt;p&gt;우리가 사용하는 문자를 컴퓨터 언어로 어떤 식으로 표현할지 약속을 정하게 되는데&lt;/p&gt;
&lt;p&gt;그게 아스키 코드이다. 이 때 이 약속된 규칙으로 바꾸는 것을 &lt;strong&gt;인코딩 또는 부호화&lt;/strong&gt;라고 한다.&lt;/p&gt;
&lt;p&gt;아스키 코드는 일반적으로 사람이 사용할 때는 편하게 &lt;strong&gt;십진법&lt;/strong&gt;으로 변환해서 사용하는 것이 일반적이다.&lt;/p&gt;
&lt;p&gt;결국 마지막에 컴퓨터와 통신하기 위해서는 &lt;strong&gt;이진법&lt;/strong&gt;으로 변환되어야 한다.&lt;/p&gt;
&lt;p&gt;표를 살펴보면 아래와 같다.(모든 아스키 코드 표는 아니고 일부이다.)&lt;br&gt;&lt;figure class=&quot;imageblock alignLeft&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0vgLE/btsqTQ4u5IS/7E5d9oDLZfITauKktlVYO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0vgLE/btsqTQ4u5IS/7E5d9oDLZfITauKktlVYO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0vgLE/btsqTQ4u5IS/7E5d9oDLZfITauKktlVYO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0vgLE%2FbtsqTQ4u5IS%2F7E5d9oDLZfITauKktlVYO1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;Parity Bit&lt;/h3&gt;
&lt;p&gt;아스키 코드는 8비트를 모두 사용해서 256개를 사용하는 것이 아닌 128개만 사용한다.&lt;/p&gt;
&lt;p&gt;이유는 마지막 1비트는 &lt;strong&gt;통신 에러 검출용&lt;/strong&gt;이기 때문이다.&lt;/p&gt;
&lt;p&gt;통신 에러 검출용 비트를 &lt;strong&gt;Parity Bit&lt;/strong&gt;라고 한다.&lt;/p&gt;
&lt;h2&gt;  ANSI 코드&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;7비트로는 문자 표현에 한계가 있어서 8비트로 확장한 아스키 코드를 의미한다.&lt;/p&gt;
&lt;p&gt;256개를 표현할 수 있게 되었지만 그래도 영어권이 아닌 한국, 중국 등과 같이 많은 국가의 언어를&lt;/p&gt;
&lt;p&gt;표현하기에는 너무 부족하다.&lt;/p&gt;
&lt;h2&gt;  Unicode(Unicode Transformation Format)&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;한국어, 중국어 등 다양한 문자를 표현하기 위해 탄생한 &lt;strong&gt;문자 집합&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;문자 집합이라는건 인코딩 방식 자체를 의미하는 것이 아닌 UTF-8, UTF-16등과 같이 Unicode Format을 의미한다.&lt;/p&gt;
&lt;p&gt;즉, 전세계의 문자를 표현하기 위해 나온 인코딩 방식이다.&lt;/p&gt;
&lt;p&gt;유니코드는 1byte ~ 4byte를 사용하게 되는데 영어는 1byte 한글은 3byte을 사용한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2Byte&lt;/strong&gt;를 사용하게 되었고 &lt;strong&gt;2^16&lt;/strong&gt;으로 &lt;strong&gt;65536개&lt;/strong&gt;를 표현할 수 있게 되었다.&lt;/p&gt;
&lt;p&gt;유니코드에는 &lt;strong&gt;UTF-8&lt;/strong&gt; &lt;strong&gt;Charsest&lt;/strong&gt;이 존재한다.&lt;/p&gt;
&lt;h3&gt;UTF-8&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;8비트&lt;/strong&gt;로 문자를 표현하는 Unicode의 &lt;strong&gt;Charset(인코딩 방식)&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;즉, 실제로 인코딩을 하는 것은 Unicode가 아니라 UTF-8인 것이다.&lt;/p&gt;
&lt;p&gt;UTF-8은 유니코드가 정의한 Format(코드 표)를 따라서 인코딩 해주는 것이다.&lt;/p&gt;
&lt;h3&gt;UTF-8이 한글 표현이 가능한 이유&lt;/h3&gt;
&lt;p&gt;아스키 코드와 동일하게 8비트를 쓰는데 어떻게 한글 표현이 가능할까?&lt;/p&gt;
&lt;p&gt;8비트를 3개를 만들어서 사용하기에 가능하다.&lt;br&gt;&lt;figure class=&quot;imageblock alignLeft&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4J0S7/btsqSbBipDv/eOi3jAuX21jeFbydkLtp10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4J0S7/btsqSbBipDv/eOi3jAuX21jeFbydkLtp10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4J0S7/btsqSbBipDv/eOi3jAuX21jeFbydkLtp10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4J0S7%2FbtsqSbBipDv%2FeOi3jAuX21jeFbydkLtp10%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;UTF-16&lt;/h3&gt;
&lt;p&gt;16비트로 문자를 표현하는 &lt;strong&gt;Charset(인코딩 방식)&lt;/strong&gt;이다.&lt;/p&gt;
&lt;p&gt;한글을 2byte로 표현하기 때문에 UTF16보다는 메모리를 절약할 수 있다.&lt;/p&gt;
&lt;h2&gt;  참고 자료&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href=&quot;https://whatisthenext.tistory.com/103&quot;&gt;https://whatisthenext.tistory.com/103&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://luv-n-interest.tistory.com/1369&quot;&gt;https://luv-n-interest.tistory.com/1369&lt;/a&gt;&lt;/p&gt;</description>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/177</guid>
      <comments>https://dev-monkey-dugi.tistory.com/177#entry177comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:52:06 +0900</pubDate>
    </item>
    <item>
      <title>URL Encoding의 공백 인코딩 방식 +와 %20</title>
      <link>https://dev-monkey-dugi.tistory.com/176</link>
      <description>&lt;h2&gt;  URL 인코딩 알기&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;URL 인코딩은 웹 브라우저와 웹 서버가 URL을 안전하게 송신하기 위한 인코딩 방식이다.&lt;/p&gt;
&lt;p&gt;보통 유니코드의 &lt;strong&gt;Charset&lt;/strong&gt;인 &lt;strong&gt;UTF-8&lt;/strong&gt; 방식을 사용한다.&lt;/p&gt;
&lt;h3&gt;공백 문자 +, %20&lt;/h3&gt;
&lt;p&gt;URL 인코딩을 보면 공백을 + 또는 %20으로 표현되는 경우가 있다. +는 레거시로 많이 쓰였고,&lt;/p&gt;
&lt;p&gt;%20이 표준이기 때문에 %20이 주로 쓰인다. 무엇을 사용해야 하는가는 정답은 없고,&lt;/p&gt;
&lt;p&gt;정하기 나름이기 때문에 규칙을 정하고 따르는 것이 중요하다.&lt;/p&gt;
&lt;h2&gt;  Problem&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;URL 인코딩 공백의 문제가 있었다.&lt;/p&gt;
&lt;p&gt;URL로 넘어오는 것(&lt;strong&gt;location&lt;/strong&gt;)은 &lt;strong&gt;“&lt;em&gt;안녕하세요%20저는%20덕입니다&lt;/em&gt;”&lt;/strong&gt;라고 요청하는 상태였다.&lt;/p&gt;
&lt;p&gt;하지만 서버에서 올바른 URL인지 체크하는 과정에서 &lt;strong&gt;DB에 저장&lt;/strong&gt;되어 있는 &lt;strong&gt;ur&lt;/strong&gt;l 정보에서 &lt;strong&gt;&lt;em&gt;“안녕하세요 저는 덕입니다.”&lt;/em&gt;&lt;/strong&gt;와&lt;/p&gt;
&lt;p&gt;일치하는지 검증하는 로직에서 일치하지 않는다고 판단해서 발생한 이슈였다.&lt;/p&gt;
&lt;h3&gt;발생 이유&lt;/h3&gt;
&lt;p&gt;DB에서 가져온 url의 공백을 없애고 실제로 넘어온 url과 비교를 했기 때문에 다르다고 인식하는 경우였다.&lt;/p&gt;
&lt;p&gt;URL로 넘어온 것을 &lt;strong&gt;location&lt;/strong&gt;, DB의 정보를  이라고 칭하겠다.&lt;/p&gt;
&lt;h2&gt;  Trial And Error&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;공백 제거하지 않고 비교하기&lt;/h3&gt;
&lt;p&gt;먼저 기존 코드에서 두 값 모두 인코딩 후 정규식으로 무언가를 판단하고 있었기 때문에 이 &lt;strong&gt;인코딩과 정규식&lt;/strong&gt;이라는&lt;/p&gt;
&lt;p&gt;방식을 바꿀 수는 없었다. 그래서 &lt;strong&gt;url&lt;/strong&gt; 공백을 제거하지 않는 방법을 선택했다.&lt;/p&gt;
&lt;p&gt;문제는 아래와 같이 발생했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;location과 url을 인코딩하는 하는 과정에서 &lt;strong&gt;공백이 %20이 아닌 +&lt;/strong&gt;로 인코딩되는 현상이었다.&lt;br&gt;여기서 사용한 라이브러리는 &lt;strong&gt;java.net.URLEncoder.encode()&lt;/strong&gt;를 사용했다. 해당 api는 &lt;strong&gt;공백을 +로 인코딩&lt;/strong&gt;하는 방식이다.&lt;/li&gt;
&lt;li&gt;인코딩된 +를 Pattern.compile.matcher.find를 사용해서 정규식으로 일치 여부를 판단한다.&lt;/li&gt;
&lt;li&gt;정규식으로 +를 제대로 제대로 인식하지 못해서 완전히 동일한 문자열인데도 find하지 못한다는 결과가 나온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;del&gt;(참고로 location 같은 경우는 한글과 공백같은 경우는 인코딩이 이미 되어 들어온 상황이라서 디코딩 후 다시 인코딩하는 방식이었다.)&lt;/del&gt;&lt;/p&gt;
&lt;h3&gt;+를 왜 인식하지 못하는가&lt;/h3&gt;
&lt;p&gt;인식하지 못하는 것이 아니다. 정규 표현식에서 &lt;strong&gt;+표현식&lt;/strong&gt;은 아래와 같다.&lt;br&gt;&lt;figure class=&quot;imageblock alignLeft&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blrUu7/btsqM77M8XQ/jOKrlr6HtllKWMCySITpw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blrUu7/btsqM77M8XQ/jOKrlr6HtllKWMCySITpw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blrUu7/btsqM77M8XQ/jOKrlr6HtllKWMCySITpw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblrUu7%2FbtsqM77M8XQ%2FjOKrlr6HtllKWMCySITpw0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.w3schools.com/jsref/jsref_obj_regexp.asp&quot;&gt;https://www.w3schools.com/jsref/jsref_obj_regexp.asp&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;인코딩된 결과를 먼저 보면 location과 url 모두 &lt;strong&gt;&lt;em&gt;“안녕하세요+저는+덕이입니다”이다.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;하지만 n+ 표현식이기 때문에 find할 수 없는게 문제였다.&lt;/p&gt;
&lt;p&gt;검증은 아래 테스트로 알 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void 공백_테스트() {
  Matcher matcher1 = Pattern.compile(&amp;quot;%20&amp;quot;).matcher(&amp;quot;%20&amp;quot;);
  assertThat(matcher1.find()).isTrue();

  Matcher matcher2 = Pattern.compile(&amp;quot;abc+&amp;quot;).matcher(&amp;quot;abc&amp;quot;);
  assertThat(matcher2.find()).isTrue();

  Matcher matcher3 = Pattern.compile(&amp;quot;abc+&amp;quot;).matcher(&amp;quot;fsdafsdfabc+c&amp;quot;);
  assertThat(matcher3.find()).isTrue();

  Matcher matcher4 = Pattern.compile(&amp;quot;abc+&amp;quot;).matcher(&amp;quot;bc+c&amp;quot;);
  assertThat(matcher4.find()).isFalse();

  Matcher matcher5 = Pattern.compile(&amp;quot;abc+d&amp;quot;).matcher(&amp;quot;abc+c&amp;quot;);
  assertThat(matcher5.find()).isFalse();

    Matcher matcher6 = Pattern.compile(&amp;quot;abc+d&amp;quot;).matcher(&amp;quot;abc&amp;quot;);
  assertThat(matcher6.find()).isFalse();
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;  Solution&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;공백을 %20으로 인코딩하기&lt;/h3&gt;
&lt;p&gt;먼저 URLEncoding Api로는 불가능했기 때문에  spring이 제공하는 &lt;strong&gt;UriUtils&lt;/strong&gt;를 사용했다.&lt;/p&gt;
&lt;p&gt;이렇게 사용해서 완전히 일치하는 문자열이 되기 때문에 일치하도록 개선할 수 있다.&lt;/p&gt;
&lt;p&gt;즉, &lt;strong&gt;URLEncoder를 지양하고 UriUtils&lt;/strong&gt;를 쓰도록 하는게 좋다.&lt;/p&gt;</description>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/176</guid>
      <comments>https://dev-monkey-dugi.tistory.com/176#entry176comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:50:18 +0900</pubDate>
    </item>
    <item>
      <title>Instant, LocalDateTime, Instant 차이와 활용</title>
      <link>https://dev-monkey-dugi.tistory.com/175</link>
      <description>&lt;h2&gt;  서론&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;현재 우리 회사에서는 모든 날짜는 UTC 타임존을 기반으로 한다.&lt;/p&gt;
&lt;p&gt;이 과정에서 타임존에 대한 이해가 제대로 잡힌 분들과 잡히지 않는 분들이 있는 것 같다.&lt;/p&gt;
&lt;p&gt;코드를 보면, LocalDateTime과 Instant가 섞여서 사용되고 있다. 같은 로직임에도 불구하고.&lt;/p&gt;
&lt;p&gt;나도 제대로 잡히지 않는 사람 중 한 명이다. &lt;a href=&quot;https://www.notion.so/MySQL-TimeZone-126515ad5cfc4a26b6ade0d58d0751bf?pvs=21&quot;&gt;타임존에 대해서 한 번 정리&lt;/a&gt;를 했지만,&lt;/p&gt;
&lt;p&gt;아직도 UTC를 채택하는 이유에 CTO님께 설명을 들었음에도 공감을 떠나서 이해를 제대로 하지 못한 상황이다.&lt;/p&gt;
&lt;p&gt;이번 기회에 LocalDateTime과 Instant로 타임존에 대한 이해도를 조금 더 높일 목적이다.&lt;/p&gt;
&lt;h2&gt;  Instant, LocalDateTime, ZoneDateTime 차이점 및 특징&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;LocalDateTime&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Java 1.8&lt;/li&gt;
&lt;li&gt;로컬 타임존을 따른다.&lt;/li&gt;
&lt;li&gt;타임존을 포함하지 않는다.&lt;/li&gt;
&lt;li&gt;atZone() 메서드를 사용하여 타임존 변환이 불가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Instatnt 클래스&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Java 1.8&lt;/li&gt;
&lt;li&gt;UTC 타임존으로 사용되며, 1970년 1월 1일 00:00:00(GMT)부터 경과한 시간을 초 단위로 표현한다.&lt;/li&gt;
&lt;li&gt;타임존을 포함하지 않는다.&lt;/li&gt;
&lt;li&gt;무조건 UTC 타임존이다.&lt;/li&gt;
&lt;li&gt;atZone() 메서드를 사용하여 다른 타임존으로 변환이 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;ZoneDateTime&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Java 1.8&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;타임존을 포함한다.&lt;br&gt;공식 문서를 보면 아래와 같이 타임존을 포함한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;A date-time with a time-zone in the ISO-8601 calendar system, such as 2007-12-03T10:15:30+01:00 Europe/Paris.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;각 타입의 포맷&lt;/h3&gt;
&lt;p&gt;아래 코드는 각 타입의 포맷을 간단하게 살펴보는 코드이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;유일하게 ZoneDateTime만 타임존이 존재한다.&lt;/li&gt;
&lt;li&gt;Instant는 타임존은 없지만 항상 UTC이기 때문에 -9시간하여 계산된다.&lt;/li&gt;
&lt;li&gt;LocalDateTime도 타임존이 존재하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void 테스트1() {
  // given
  final DateTimeFormatter dateTimeFormatterISO8601 = DateTimeFormatter.ofPattern(&amp;quot;yyyy-MM-dd&amp;#39;T&amp;#39;HH:mm:ssxxx&amp;quot;);
  String dateTimeString = &amp;quot;2023-01-01T00:00:00+09:00&amp;quot;;

  ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateTimeString, dateTimeFormatterISO8601);
  Instant instant = ZonedDateTime.parse(dateTimeString, dateTimeFormatterISO8601).toInstant();
  LocalDateTime localDateTime = LocalDateTime.parse(dateTimeString, dateTimeFormatterISO8601);

  // when
  System.out.println(&amp;quot;================&amp;quot;);
  System.out.println(&amp;quot;zonedDateTime = &amp;quot; + zonedDateTime);
  System.out.println(&amp;quot;instant = &amp;quot; + instant);
  System.out.println(&amp;quot;localDateTime = &amp;quot; + localDateTime);
  System.out.println(&amp;quot;================&amp;quot;);
}

// zonedDateTime = 2023-01-01T00:00+09:00
// instant       = 2022-12-31T15:00:00Z
// localDateTime = 2023-01-01T00:00&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;LocalDateTime은 타임존 적용이 불가능하다.&lt;/h3&gt;
&lt;p&gt;LocalDateTime은 처음 생성할 때는 타임존을 부여하여 정할 수 있다.&lt;/p&gt;
&lt;p&gt;보는 바와 같이 UTC로 적용된 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;하지만 이후 타임존을 변경하고 싶어도 ZoneDateTime으로 변환해도 타임존이 적용된 시간인 +9:00이 적용된 시간이 아니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;LocalDateTime은 나라별로 타임존을 변경해야 한다면 사용하기 불편하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 현재 시간: 2023-05-28 17:59:00
@Test
void LocalDateTime_타임존_변환() {
  // given
  LocalDateTime localDateTime = LocalDateTime.now(ZoneId.of(&amp;quot;UTC&amp;quot;));
  ZonedDateTime localDateTimeToZoneDateTime = localDateTime.atZone(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;));
  LocalDateTime zoneDateTimeToLocalDateTime = localDateTimeToZoneDateTime.toLocalDateTime();

  // when
  System.out.println(&amp;quot;============&amp;quot;);
  System.out.println(&amp;quot;localDateTime = &amp;quot; + localDateTime);
  System.out.println(&amp;quot;localDateTimeToZoneDateTime = &amp;quot; + localDateTimeToZoneDateTime);
  System.out.println(&amp;quot;zoneDateTimeToLocalDateTime = &amp;quot; + zoneDateTimeToLocalDateTime);
  System.out.println(&amp;quot;============&amp;quot;);
}

// localDateTime               = 2023-05-28T08:59:00.506969
// localDateTimeToZoneDateTime = 2023-05-28T08:59:00.506969+09:00[Asia/Seoul]
// zoneDateTimeToLocalDateTime = 2023-05-28T08:59:00.506969&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;ZoneDateTime은 타임존 적용이 가능하다.&lt;/h3&gt;
&lt;p&gt;보는 바와 같이 UTC → Asia/Seoul로 적용된 것을 확인할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 현재 시간: 2023-05-28 18:10:00

@Test
void ZoneDateTime_타임존_변환() {
  // given
  ZonedDateTime zonedDateTime = ZonedDateTime.now(ZoneId.of(&amp;quot;UTC&amp;quot;));
  ZonedDateTime convertTimezone = zonedDateTime.withZoneSameInstant(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;));

  // when
  System.out.println(&amp;quot;===============&amp;quot;);
  System.out.println(&amp;quot;zonedDateTime = &amp;quot; + zonedDateTime);
  System.out.println(&amp;quot;convertTimezone = &amp;quot; + convertTimezone);
  System.out.println(&amp;quot;===============&amp;quot;);
}

// zonedDateTime   = 2023-05-28T09:10:24.292591Z[UTC]
// convertTimezone = 2023-05-28T18:10:24.292591+09:00[Asia/Seoul]&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Instant는 ZoneDateTime으로 변환하면 타임존 적용이 가능하다.&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 현재 시간: 2023-05-28 18:34:00

@Test
void Instant_타임존_변환() {
  // given
  Instant instant = Instant.now();
  ZonedDateTime instantToZoneDateTime = instant.atZone(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;));

  // when
  System.out.println(&amp;quot;===============&amp;quot;);
  System.out.println(&amp;quot;instant = &amp;quot; + instant);
  System.out.println(&amp;quot;instantToZoneDateTime = &amp;quot; + instantToZoneDateTime);
  System.out.println(&amp;quot;===============&amp;quot;);
}

// instant               = 2023-05-28T09:34:40.621189Z
// instantToZoneDateTime = 2023-05-28T18:34:40.621189+09:00[Asia/Seoul]&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;  실무 활용&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;Instant와 ZoneDateTime&lt;/h3&gt;
&lt;p&gt;예로 모든 데이터를 UTC로 관리하고 있다고 하자.&lt;/p&gt;
&lt;p&gt;정산을 하는데 2023-01-01 04:00에 배치가 돌아서 2022년 12월31일의 정산을 한다고 가정하면,&lt;/p&gt;
&lt;p&gt;2022-12-30 15:00 ~ 2022-12-31 14:59:59의 데이터를 조회해서 정산해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;주의할 점&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;instant1이 아닌 instant 방식으로 활용해야 하는 것이다.&lt;br&gt;instant1 방식으로 하 경우 배치 실행 시간에 따라서 조회 기간이 15시가 아닌 19시로 변경되기 때문이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Asia/Seoul을 명시한 이유&lt;/p&gt;
&lt;p&gt;  defaultTimeString이 +09:00가 아닌 +10:00으로 잘 못 넘어오게 될 경우에도 대비하기 위함이다.&lt;/p&gt;
&lt;p&gt;  Asia/Seoul로 하면 +10:00이어도 +9:00로 변환하여, 15:00로 잡히기 때문이다. 반면 지정하지 않을 경우 14:00로 변환되기 때문에&lt;/p&gt;
&lt;p&gt;  조회 기간이 문제가 발생한다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void d123123dd() {
  // given
  final DateTimeFormatter dateTimeFormatterISO8601 = DateTimeFormatter.ofPattern(&amp;quot;yyyy-MM-dd&amp;#39;T&amp;#39;HH:mm:ssxxx&amp;quot;);
  String defaultTimeString = &amp;quot;2023-01-01T04:00:00+09:00&amp;quot;; // 새벽 4시 정산

  Instant instant = ZonedDateTime.parse(defaultTimeString, dateTimeFormatterISO8601)
      .withZoneSameInstant(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;))
      .truncatedTo(ChronoUnit.DAYS)
      .toInstant();
  long epochMilli = instant.toEpochMilli();

  Instant instant1 = ZonedDateTime.parse(defaultTimeString, dateTimeFormatterISO8601)
      .withZoneSameInstant(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;))
      .toInstant();
  long epochMilli1 = instant1.toEpochMilli();

  // when
  System.out.println(&amp;quot;==============&amp;quot;);
  System.out.println(&amp;quot;instant = &amp;quot; + instant);
  System.out.println(&amp;quot;instant1 = &amp;quot; + instant1);
  System.out.println(&amp;quot;epochMilli = &amp;quot; + epochMilli);
  System.out.println(&amp;quot;epochMilli1 = &amp;quot; + epochMilli1);
  System.out.println(&amp;quot;==============&amp;quot;);
}

// instant     = 2022-12-31T15:00:00Z
// instant1    = 2022-12-31T19:00:00Z
// epochMilli  = 1672498800000
// epochMilli1 = 1672513200000&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;localDateTime도 가능하다.&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;근데 결국 ZoneDateTime과 Instant를 쓴다.&lt;/p&gt;
&lt;p&gt;실제로 타임존이 적용된 것은 아니지만 마지막에 Instant로 변환하여 활용하기 때문에 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;Instant instant3 = LocalDateTime.parse(defaultTimeString, dateTimeFormatterISO8601)
        .atZone(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;))
        .truncatedTo(ChronoUnit.DAYS)
        .toInstant();&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Epoch 활용&lt;/h3&gt;
&lt;p&gt;위와 같이 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;UTC로 데이터가 관리될 경우 타임존 셋팅에 번거로움을 줄이기 위해 Epoch을 활용하면&lt;/p&gt;
&lt;p&gt;항상 UTC second이기 때문에 훨씬 편리하게 사용할 수 있다.&lt;/p&gt;
&lt;h2&gt;  Epoch이란?&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href=&quot;https://ko.wikipedia.org/wiki/%EC%9C%A0%EB%8B%89%EC%8A%A4_%EC%8B%9C%EA%B0%84&quot;&gt;https://ko.wikipedia.org/wiki/유닉스_시간&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;UTC 기준인 1970년 1월 1일 00:00:00부터의 경과한 시간을 초로 환산하여 정수로 표현한 것을 의민한다.&lt;/p&gt;
&lt;p&gt;즉 타임존이 의미가 없이 무조건 UTC 경과 시간이다.&lt;/p&gt;
&lt;p&gt;주의할 것은 만약 DB에서 epoch을 FROM_UNIXTIME()을 사용해서 변환하면,&lt;/p&gt;
&lt;p&gt;커넥션된 타임존에 따라서 결과가 다르게 나오니 혼동하지 말자.&lt;/p&gt;
&lt;p&gt;이는 FROM_UNIXTIME()가 DB의 세션 타임존을 따라가기 때문이다. 그래서 알아서 변환하는 것이다.&lt;/p&gt;
&lt;p&gt;코드로 테스트 해보면 포맷팅 된 날짜는 다르지만 epoch은 동일한 것을 확인할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void epoch() {
  // given
  ZonedDateTime zonedDateTime = ZonedDateTime.now(ZoneId.of(&amp;quot;Asia/Seoul&amp;quot;));
  long zoneDateTimeEpoch = zonedDateTime.toEpochSecond();

  Instant instant = Instant.now();
  long instantEpoch = instant.toEpochMilli() / 1000;

  // when
  System.out.println(&amp;quot;=============&amp;quot;);
  System.out.println(&amp;quot;zonedDateTime = &amp;quot; + zonedDateTime);
  System.out.println(&amp;quot;instant = &amp;quot; + instant);
  System.out.println(&amp;quot;zoneDateTimeEpoch = &amp;quot; + zoneDateTimeEpoch);
  System.out.println(&amp;quot;instantEpoch = &amp;quot; + instantEpoch);
  System.out.println(&amp;quot;=============&amp;quot;);
}

// zonedDateTime     = 2023-05-28T18:54:34.260566+09:00[Asia/Seoul]
// instant           = 2023-05-28T09:54:34.260676Z
// zoneDateTimeEpoch = 1685267674
// instantEpoch      = 1685267674&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;  LocalDateTime, ZonedDateTime, Instant 목적&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;LocalDateTime&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;타임존이 필요없는 경우 간단하게 사용하기 좋다.&lt;/li&gt;
&lt;li&gt;즉, 특정 타임존에 의존하지 않는 경우만 활용하기 좋다.&lt;/li&gt;
&lt;li&gt;타임존에 의존한다면 사용하지 않길. 위에서 봤던 것 처럼 안되는 것이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;ZonedDateTime&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;타임존을 포함하기 때문에 타임존에 따른 시간 표현이 가능하다.&lt;/li&gt;
&lt;li&gt;타임존이 필요한 글로벌 서비스 같은 경우 활용하기 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Instant&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;타임존을 포함하지 않고, UTC 기준으로 활용할 때 편하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;흠.. 아직 정확하게 와닿지가 않는다.&lt;/p&gt;</description>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/175</guid>
      <comments>https://dev-monkey-dugi.tistory.com/175#entry175comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:48:35 +0900</pubDate>
    </item>
    <item>
      <title>Query DSL에서 MySQL FIELD Function 사용하기</title>
      <link>https://dev-monkey-dugi.tistory.com/174</link>
      <description>&lt;h2&gt;  서론&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;Query DSL을 사용하다 보면, DB Function을 지원하지 않는 것들이 많이 있다.&lt;/p&gt;
&lt;p&gt;나는 파라미터로 온 Id 목록을 기준으로 sorting할 필요가 있어서 field function이 필요했다.&lt;/p&gt;
&lt;p&gt;그 과정에서 겪은 issue를 살펴보자.&lt;/p&gt;
&lt;h2&gt;  트러블 슈팅&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;기존 방법&lt;/h3&gt;
&lt;p&gt;Expressions를 사용했는데 동적 쿼리를 활용하기 위함일 뿐이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private static &amp;lt;T&amp;gt; SimpleTemplate&amp;lt;Integer&amp;gt; template(Path&amp;lt;T&amp;gt; column, List&amp;lt;String&amp;gt; ids) {
  return Expressions.simpleTemplate(Integer.class,
      &amp;quot;FIELD({0}, {1})&amp;quot;, column, String.join(&amp;quot;, &amp;quot;, String.join(&amp;quot;, &amp;quot;, ids))
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음에는 이 코드에는 문제가 있다.&lt;/p&gt;
&lt;p&gt;쿼리 로그로는 이상없이 빌드가 된 것을 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;해당 쿼리를 복사하여 돌려봐도 잘 나왔다.&lt;/p&gt;
&lt;p&gt;하지만 애플리케이션을 통하면 정렬이 되지 않았다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;바로 String.join()의 문제이다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;String.join()의 결과는 FIELD(id, 1, 2, 3)으로 생성된 것이 아니고, FIELD(id, ‘1, 2, 3’)으로 생성된 것이다.&lt;/p&gt;
&lt;p&gt;String 타입이기 때문에 ‘’가 붙게 되었다.&lt;/p&gt;
&lt;h3&gt;개선 방법&lt;/h3&gt;
&lt;p&gt;Collection 자체를 마지막 파라미터로 넘기도록 변경했다.&lt;/p&gt;
&lt;p&gt;결과는 FIELD(id, 1, 2, 3)와 같이 생성된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private static &amp;lt;T&amp;gt; SimpleTemplate&amp;lt;Integer&amp;gt; template(Path&amp;lt;T&amp;gt; column, List&amp;lt;String&amp;gt; ids) {
  return Expressions.template(Integer.class, &amp;quot;FIELD({0}, {1})&amp;quot;, column, ids);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음에는 biding log가 [1, 2, 3] 이렇게 나와서 String임에도 &lt;code&gt;‘’&lt;/code&gt;로 감싸진다는 것을 생각하지 못했다.&lt;/p&gt;</description>
      <category>Field</category>
      <category>QueryDSL</category>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/174</guid>
      <comments>https://dev-monkey-dugi.tistory.com/174#entry174comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:48:00 +0900</pubDate>
    </item>
    <item>
      <title>Java Cloneable</title>
      <link>https://dev-monkey-dugi.tistory.com/173</link>
      <description>&lt;h3&gt;  Cloneable 정의&lt;/h3&gt;
&lt;hr&gt;
&lt;ul&gt;
&lt;li&gt;단순히 복사를 위한 &lt;code&gt;marker interface&lt;/code&gt;이다.&lt;br&gt;marker interface는 아무것도 존재하지 않는 interface를 의미한다.&lt;/li&gt;
&lt;li&gt;interface를 구현하고, &lt;code&gt;super.clone()&lt;/code&gt;을 호출하면 사용할 수 있다.&lt;br&gt;만약 interface를 구현하지 않는 클래스가 호출하게 되면 &lt;code&gt;CloneNotSupportedException&lt;/code&gt;가 발생한다,.&lt;/li&gt;
&lt;li&gt;피상 복사(Shallow Copy)이다.&lt;/li&gt;
&lt;li&gt;clone() 메서드의 기본 제공은 Object 클래스가 제공한다.&lt;/li&gt;
&lt;li&gt;java.lang 패키지에 위치하므로, Import가 필요없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;  주의 사항&lt;/h3&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;피상 복사(Shallow Copy)이다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;aside&gt;
  처음에 Cloneable 구현체의 복사가 Deep Copy로 이루어져서 필드도 모두 Deep Copy를 해주는 줄 알았다.

&lt;/aside&gt;

&lt;p&gt;피상 복사를 필드 대 필드 복사라고 하는데 이는 필드를 주소만 참조하는 것을 의미하고, Deep Copy하지 않는 것을 의미한다.&lt;/p&gt;
&lt;p&gt;즉, 필드는 피상 복사가 되고, Cloneable 구현체는 새로운 메모리에 복사를 하게 된다.&lt;/p&gt;
&lt;p&gt;만약 Deep Copy를 하기 위해서는 필드까지 직접 Copy 코드를 구현해줘야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void cloneable_shallow_copy() throws CloneNotSupportedException {
  // given
  List&amp;lt;String&amp;gt; strings = Arrays.asList(&amp;quot;1&amp;quot;, &amp;quot;2&amp;quot;);
  CloneDugi sut = new CloneDugi(strings);

  // when
  CloneDugi clone = sut.clone();

  // then
  assertThat(clone).isNotSameAs(sut);
  assertThat(clone.getStrs()).isSameAs(sut.getStrs());

  strings.set(0, &amp;quot;3&amp;quot;);
  assertThat(sut.getStrs().get(0)).isEqualTo(&amp;quot;3&amp;quot;);
  assertThat(clone.getStrs().get(0)).isEqualTo(&amp;quot;3&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 코드를 보면 구현체는 CloneDugi 클래스는 메모리 주소가 다른 것을 알 수 있고, 필드 주소를 동일한 것을 확인할 수 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cloneable이 아닌 Object에 존재하고, protected이기 때문에 외부에서 호출이 불가능하다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void protect라니() throws CloneNotSupportedException {
  Object clone = new Dugi(&amp;quot;dd&amp;quot;).clone();    // protectd라서 에러
    Object clone = new Dugi(&amp;quot;dd&amp;quot;).toString(); // public이라서 가능
}

class Dugi implements Cloneable {

  private final String name;

  Dugi(String name) {
    this.name = name;
  }

    // 해결하기 위해 override 필요
    @Override
    protected Object clone() throws CloneNotSupportedException {
      return super.clone();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCLeft&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caAoxi/btsqO8LR8rS/4QD1utOBFu1d07Ia3QTzkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caAoxi/btsqO8LR8rS/4QD1utOBFu1d07Ia3QTzkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caAoxi/btsqO8LR8rS/4QD1utOBFu1d07Ia3QTzkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaAoxi%2FbtsqO8LR8rS%2F4QD1utOBFu1d07Ia3QTzkk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;근데 사실상 이게 그렇게 단점이라고 하기는 어려운 것 같다.&lt;/p&gt;
&lt;p&gt;그냥 override하면 끝이기 때문이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;구현하기 않은 클래스가 Object.clone()을 하면 &lt;code&gt;CloneNotSupportedException&lt;/code&gt;이 발상한다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/173</guid>
      <comments>https://dev-monkey-dugi.tistory.com/173#entry173comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:46:58 +0900</pubDate>
    </item>
    <item>
      <title>테스트로 도메인 지식을 유출하면 안된다.</title>
      <link>https://dev-monkey-dugi.tistory.com/172</link>
      <description>&lt;h2&gt;서론&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;회사에서 &lt;a href=&quot;https://www.yes24.com/Product/Goods/104084175&quot;&gt;단위 테스트&lt;/a&gt; 스터디를 하면서 챕터 11에서 테스트로 도메인 지식을 유출하면 안된다는 내용을 봤다.&lt;/p&gt;
&lt;p&gt;좋은 내용 같아서 정리한다.&lt;/p&gt;
&lt;h2&gt;본론&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;무의미한 검증&lt;/li&gt;
&lt;li&gt;거짓 음성(거짓 성공)&lt;/li&gt;
&lt;li&gt;도메인 로직과 강결합&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;calculateTotalPrice()를 테스트할 경우 테스트는 통과하게 된다.&lt;/p&gt;
&lt;p&gt;하지만 무의미한 검증이다. 문제점을 알아보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;class Payment {

  private double price;

  public Payment(double price) {
    this.price = price;
  }

  public double calculateTotalPrice() {
    return price * 1.1;
  }

  public double getPrice() {
    return price;
  }
}

@Test
void 무의미한_테스트() {
  // given
  Payment sut = new Payment(10_000);

  // when
  double actual = sut.calculateTotalPrice();

  // then
  double expected = sut.getPrice() * 1.1; // 로직 노출
  assertThat(actual).isEqualTo(expected);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;도메인 로직 복사로 전혀 검증이 되지 않는다.&lt;/strong&gt;&lt;/p&gt;
 &lt;aside&gt;
   우리가 테스트를 작성하는 이유는 로직이 제대로 된 결과를 도출하는지이다.
 하지만 이는 로직이 잘못 되었어도 테스트는 통과하기 때문에 거짓 음성(거짓 성공) 테스트가 된다.
 결국 항상 로직과 같은 결과를 뽑아내기 때문에 실패할 수 없는 테스트이다.

 &lt;/aside&gt;

&lt;p&gt; 결과는 11,000이 나와야 하는데 롤직이 price * 10으로 잘못 로직을 작성해도 테스트는 통과하게 된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;로직은 바뀌고 결과는 같을 경우 혼란을 준다.&lt;/p&gt;
 &lt;aside&gt;
   우연하게 이렇게 될 수 있다. 이럴 경우 테스트에 있는 도메인 로직을 수정할 것인가? 유지할 것인가?
 당연히 수정하는게 맞고, 실수로 수정하지 않을수도 있다. 이는 혼란을 불러온다.

 &lt;/aside&gt;


&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;방안&lt;/h3&gt;
&lt;p&gt;하드 코딩으로 검증한다.&lt;/p&gt;
&lt;p&gt;이 코드는 모든 문제를 해결한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Test
void 하드코딩_검증() {
  // given
  Payment sut = new Payment(10_000);

  // when
  double actual = sut.calculateTotalPrice();

  // then
  double expected = 11_000
  assertThat(actual).isEqualTo(expected);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href=&quot;https://www.yes24.com/Product/Goods/104084175&quot;&gt;단위 테스트 11장 11.3&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://jojoldu.tistory.com/615&quot;&gt;https://jojoldu.tistory.com/615&lt;/a&gt;&lt;/p&gt;</description>
      <category>단위 테스트</category>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/172</guid>
      <comments>https://dev-monkey-dugi.tistory.com/172#entry172comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:45:54 +0900</pubDate>
    </item>
    <item>
      <title>MySQL rand() SubQuery 실행 계획</title>
      <link>https://dev-monkey-dugi.tistory.com/171</link>
      <description>&lt;h2&gt;문제&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;만약 아래 쿼리를 돌리면 결과는 어떻게 나올까?&lt;/p&gt;
&lt;p&gt;나는 랜덤으로 사용자를 1명 뽑아서 그 사용자 1명을 조회할 줄 알았다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;user table&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;name&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;name1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;name2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;name3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;select *
from users
where id = (
    select id
    from users
    order by rand()
    limit 1
);

-- 애초에 쿼리를 아래와 같이 쓰면 되지 않나라고 생각할 수 있지만 이건 실행 계획을 이해하기 위한 예제일 뿐이고,
-- 내가 실제로 겪은 문제는 다른 테이블 간 조인이 걸려 있어서 이렇게 할 수 없는 상황이었고,
-- 이로 인해 rand() 서브 쿼리에 대한 실행 계획을 알기 위함에 이외 같은 예제를 선택했다.
select *
from users
order by rand()
limit 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 결과는 랜덤하게 0 ~ 3개까지 나올 수 있다.&lt;/p&gt;
&lt;p&gt;그 이유를 지금부터 실행 계획을 통해 하나씩 알아보자.&lt;/p&gt;
&lt;h2&gt;본론&lt;/h2&gt;
&lt;hr&gt;
&lt;aside&gt;
  모든 것을 알아보지 않고, 문제와 관련된 부분만 알아보도록 한다.

&lt;/aside&gt;

&lt;p&gt;&lt;strong&gt;실행 계획 결과(표)&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;select_type&lt;/th&gt;
&lt;th&gt;table&lt;/th&gt;
&lt;th&gt;partitions&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;possible_keys&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;key_len&lt;/th&gt;
&lt;th&gt;ref&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;filtered&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;PRIMARY&lt;/td&gt;
&lt;td&gt;users&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Using where&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;UNCACHEABLE SUBQUERY&lt;/td&gt;
&lt;td&gt;users&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;index&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;PRIMARY&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Using where;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using temporary;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;실행 계획 컬럼&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;id&lt;/strong&gt;: 단지 select 쿼리별로 부여되는 식별자이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;테이블 접근 순서와 실행 계획 id의 순서는 아니다.&lt;br&gt;테이블 접근 순서를 확인하려면 explain analyze를 사용하면 아래와 같이 볼 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;  -&amp;gt; Filter: (users.id = (select #2))  (cost=0.55 rows=3) (actual time=0.0841..0.0841 rows=0 loops=1)
      -&amp;gt; Table scan on users  (cost=0.55 rows=3) (actual time=0.0317..0.033 rows=3 loops=1)
      -&amp;gt; Select #2 (subquery in condition; uncacheable)
          -&amp;gt; Limit: 1 row(s)  (actual time=0.013..0.0131 rows=1 loops=3)
              -&amp;gt; Sort: rand(), limit input to 1 row(s) per chunk  (actual time=0.0126..0.0126 rows=1 loops=3)
                  -&amp;gt; Stream results  (cost=0.55 rows=3) (actual time=0.00401..0.00674 rows=3 loops=3)
                      -&amp;gt; Covering index scan on users using PRIMARY  (cost=0.55 rows=3) (actual time=0.00268..0.00496 rows=3 loops=3)&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;select_type&lt;/strong&gt;: selcet 쿼리가 어떤 타입인지 보여준다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SIMPLE&lt;/strong&gt;: UNION, 서브 쿼리를 사용하지 않는 단순한 select 쿼리. 단 하나만 존재한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PRIMARY&lt;/strong&gt;: UNION이나 서브 쿼리를 가지는 SELECT 쿼리의 실행 계획에서 가장 바깥쪽 쿼리를 의미한다. 단 하나만 존재한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DERIVED&lt;/strong&gt;: from절에 사용된 서브 쿼리&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SUBQUERY&lt;/strong&gt;: from절 이외에서 사용되는 서브쿼리만을 의미한다. 값을 캐싱하여 사용한다.&lt;ul&gt;
&lt;li&gt;바깥 쿼리에 영향을 받지 않으므로 캐싱하여 사용할 수 있는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DEPENDENT SUBQUERY&lt;/strong&gt;: 서브쿼리가 바깥쪽 select 쿼리에서 정의된 컬럼을 사용하는 경우&lt;ul&gt;
&lt;li&gt;캐시는 되지만 SUBQUERY처럼 딱 한 번만 캐시되지는 않고, 외부 쿼리의 값 단위로 캐시가 만들어지는 방식이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UNCACHEABLE SUBQUERY&lt;/strong&gt;: 서브 쿼리가 캐싱되지 않은 경우이며, 아래 세 가지 경우에 발생한다.&lt;ul&gt;
&lt;li&gt;사용자 변수가 서브쿼리에 사용된 경우&lt;/li&gt;
&lt;li&gt;NOT_DETERMINSTIC 속성의 스토어드 루틴이 서브쿼리 내에 사용된 경우&lt;/li&gt;
&lt;li&gt;UUID()나 RNAD()와 같이 결과값이 호출할 때마다 달라지는 함수가 서브쿼리에 사용된 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;type&lt;/strong&gt;: 어떤 방식으로 테이블을 읽었는지.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;const&lt;/strong&gt;: pk나 유니크 키로 where 조건절을 가지고, 반드시 1건을 반환하는 쿼리 처리 방식&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;eq_ref&lt;/strong&gt;: 조인에서 첫 번째 읽은 테이블의 컬럼값을 이용해 두 번째 테이블을 pk나 유니크 키로 동등 조건 검색(반드시 1건만 반환)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;  select *
  from dept_emp de,
       employees e
  where e.emp_no=de.emp_no
  and de.dept_no=&amp;#39;d005&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;ref&lt;/strong&gt;: 조인 순서와 인덱스 종류에 관계없이 동등 조건으로 검색할 때 사용된다. 반드시 결과가 1건이라는 보장이 없을 경우&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;  select *
  from dept_emp
  where dept_no=&amp;#39;d005&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;range&lt;/strong&gt;: 인덱스를 하나의 값이 아닌 범위 검색을 의미한다. 주로 &amp;gt;, &amp;lt;, IS NULL 등과 같다. 모든 쿼리가 이 방법을 채택해도 최적의 성능이 보장된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;index&lt;/strong&gt;: 인덱스 풀 스캔을 의미한다. 즉, 인덱스를 효율적으로 사용한 것이 아니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;테이블 풀 스캔 방식과 레코드 건수는 동일하다.&lt;/li&gt;
&lt;li&gt;하지만 인덱스는 보통 데이터 파일 전체보다는 크기가 작으므로 테이블 풀스캔 보다는 빠르고,&lt;br&gt;정렬된 인덱스의 장점을 이용할 수 있어서 테이블 풀 스캔 보다는 훨씬 효율적이다. 그러나 결국 풀 스캔.&lt;br&gt;만약 limit 10과 같이 사용한다면 효율적이다. 인덱스를 거꾸로 읽어서 10개만 가져오면 되기 때문에.&lt;/li&gt;
&lt;li&gt;세 가지 중 1번 + 2번 or 1번 + 3번 조건이 충족할 때 채택된다.&lt;ul&gt;
&lt;li&gt;range나 const, ref 같은 방식 불가인 경우&lt;/li&gt;
&lt;li&gt;인덱스에 포함된 컬럼만으로 처리할 수 있는 쿼리인 경우(즉, 데이터 파일을 읽지 않아도 되는 경우) ????&lt;/li&gt;
&lt;li&gt;인덱스를 이용해 정렬이나 그루핑 작업이 가능한 경우(즉, 별도의 정렬 작업을 피할 수 있는 경우)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ALL: 테이블 풀 스캔&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;possible_keys&lt;/strong&gt;: 옵티마이저가 인덱스를 사용할법 했던 목록일 뿐이기 때문에 무시해라.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;key&lt;/strong&gt;: 실제 사용된 인덱스&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;rows&lt;/strong&gt;: 옵티마이저가 쿼리를 처리하기 위해 얼마나 많은 레코드를 읽고 체크해야 하는지의 row수로써 반환되는 row수가 아니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;filtered&lt;/strong&gt;: 테이블에서 인덱스 조건에만 일치하는 레코드의 퍼센테이지이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Extra&lt;/strong&gt;: 성능에 대한 내용이 주로 표시된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Using filesort&lt;/strong&gt;: 인덱스 정렬을 하지 못해서 레코드를 읽어 소트 버퍼에 복사하고, 다시 정렬하게 된다. &lt;strong&gt;성능 저하의 원인&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Using index&lt;/strong&gt;: 데이터 파일을 읽지 않고 인덱스만 읽어서 쿼리를 모두 처리할 수 있을 때&lt;ul&gt;
&lt;li&gt;인덱스에 해당하는 값만 조회한다면 인덱스 페이지만 읽으면 되기 때문에 빠르다.&lt;/li&gt;
&lt;li&gt;인덱스 이외의 값을 추가로 조회한다면, 다시 데이터 파일에 접근해서 건수만큼 접근해서 조회해야 하는 단점이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Using temporary&lt;/strong&gt;: 중간 결과를 담아 두기 위한 임시 테이블 사용.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Using where&lt;/strong&gt;: MySQL 엔진(조인, 필터링, 그룹핑 …)에서 별도의 가공을 해서 필터링 작업을 처리한 경우.&lt;ul&gt;
&lt;li&gt;스토리지 엔진: 레코드 읽기 및 저장을 하며, 작업 범위 결정( emp_no BETWEEN 10000 and 2000 )을 한다.&lt;/li&gt;
&lt;li&gt;MySQL 엔진: 체크 조건 결정(gender = ‘F’)과 조인 등을 처리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;문제 원인&lt;/h3&gt;
&lt;p&gt;위의 실행 계획을 기반으로 원인을 알 수 있다.&lt;/p&gt;
&lt;p&gt;먼저 explain analyze와 explain을 확인 해보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-&amp;gt; Filter: (users.id = (select #2))  (cost=0.55 rows=3) (actual time=0.0841..0.0841 rows=0 loops=1)
    -&amp;gt; Table scan on users  (cost=0.55 rows=3) (actual time=0.0317..0.033 rows=3 loops=1)
    -&amp;gt; Select #2 (subquery in condition; uncacheable)
        -&amp;gt; Limit: 1 row(s)  (actual time=0.013..0.0131 rows=1 loops=3)
            -&amp;gt; Sort: rand(), limit input to 1 row(s) per chunk  (actual time=0.0126..0.0126 rows=1 loops=3)
                -&amp;gt; Stream results  (cost=0.55 rows=3) (actual time=0.00401..0.00674 rows=3 loops=3)
                    -&amp;gt; Covering index scan on users using PRIMARY  (cost=0.55 rows=3) (actual time=0.00268..0.00496 rows=3 loops=3)&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;select_type&lt;/th&gt;
&lt;th&gt;table&lt;/th&gt;
&lt;th&gt;partitions&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;possible_keys&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;key_len&lt;/th&gt;
&lt;th&gt;ref&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;filtered&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;PRIMARY&lt;/td&gt;
&lt;td&gt;users&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Using where&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;UNCACHEABLE SUBQUERY&lt;/td&gt;
&lt;td&gt;users&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;index&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;PRIMARY&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;null&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Using where;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using temporary;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;들여쓰기가 깊을순으로 실행되는 순서이다. 실행 계획을 살펴보자.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sort: rand(), limit input to 1 row(s) per chunk  (actual time=0.0126..0.0126 rows=1 loops=3)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  order by rand() 쿼리의 실행 계획이다. 이 부분을 보면 rows=1, loops=3이다.&lt;/p&gt;
&lt;p&gt;  바깥 쿼리가 type을 보면 테이블 풀 스캔을 타는 것을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;  즉, 바깥 쿼리의 결과가 3row이기 때문에 loop를 3번 돌게 되고, limit1에 의해 1개의 결과를 반환하게 된다.&lt;/p&gt;
&lt;p&gt;  즉, 바깥 쿼리의 풀 스캔 결과에 따라서 loop가 결정된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Select #2 (subquery in condition; uncacheable)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  MySQL은 UUID()나 RNAD()와 같이 결과값이 호출할 때마다 달라지는 함수가 서브쿼리에 사용된 경우에 캐싱하지 않고 매번 다시 실행하게 된다.&lt;/p&gt;
&lt;p&gt;  만약 캐싱되었다면, 항상 결과는 1개였을 것이다. 하지만 매번 새로 호출하기 때문에 확률적으로 0 ~ 3개의 rowr가 반환될 수 있는 것이다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Table scan on users  (cost=0.55 rows=3) (actual time=0.0317..0.033 rows=3 loops=1)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  바깥 쿼리는 테이블 풀 스캔을 했다. type을 확인해도 ALL인걸 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;  당연히 1번만 실행되며 전체 row인 3개가 반환된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Filter: (users.id = (select #2))  (cost=0.55 rows=3) (actual time=0.0841..0.0841 rows=0 loops=1)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;  여기는 id = (sub query)로 조인하는 부분이다.&lt;/p&gt;
&lt;p&gt;  항상 loop는 1번 돌고, 매칭되는 rand() 결과에 따라 rows=0 ~ 3으로 변경된다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 내용을 그림으로 풀면 아래와 같다.&lt;/p&gt;
&lt;p&gt;바깥 쿼리의 결과 수 만큼 랜덤 쿼리를 loop하는 것이다.&lt;br&gt;&lt;figure class=&quot;imageblock alignLeft&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mgjRP/btsqTRvy62L/2eCsiuloDo3ayv6m9Vugc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mgjRP/btsqTRvy62L/2eCsiuloDo3ayv6m9Vugc1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mgjRP/btsqTRvy62L/2eCsiuloDo3ayv6m9Vugc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmgjRP%2FbtsqTRvy62L%2F2eCsiuloDo3ayv6m9Vugc1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;원인 정리&lt;/h3&gt;
&lt;p&gt;MySQL에서는 rand(), uuid()와 같이 호출할 때 마다 결과가 달라지는 함수는 캐싱처리하지 않고 매번 재실행한다.&lt;/p&gt;
&lt;p&gt;그리고 바깥 쿼리의 수 만큼 루프를 돌게되기 0 ~ 3개가 랜덤하게 결과로 반환된느 것이다.&lt;/p&gt;
&lt;h3&gt;해결 방법&lt;/h3&gt;
&lt;p&gt;바깥 쿼리에 0을 붙여서 해결 해도 되지만 실무에서 겪은 이슈에서는 그럴 수 없는 상황이어서&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;테이블 서브 쿼리로 변경&lt;/p&gt;
&lt;p&gt;  테이블 서브 쿼리로 할 경우는 문제가 발생하지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;  explain analyze
  select u_main.*
  from users u_main
  inner join (
              select id
              from users
              order by rand()
              limit 1
  ) u_sub on u_sub.id = u_main.id;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;다른 테이블과 조인이 엮여있는 상황이라면(실제로 내가 겪은 문제였음)&lt;/p&gt;
&lt;p&gt;  스칼라 서브 쿼리(select 필드 서브 쿼리)로 해결할 수 있다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;서브 쿼리 제거&lt;/p&gt;
&lt;p&gt;  애초에 이렇게 사용하면 되긴 하지만 내가 겪은 문제는 쿼리가 조인이 좀 걸려있는 상태라서 이런 쿼리는 사용할 수 없었음.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;  select *
  from users
  order by rand()
  limit 1;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;핵심&lt;/h3&gt;
&lt;p&gt;rand() 함수와 같이 결과값이 호출 시 마다 달라지는 함수는 조인 서브 쿼리로 사용할 경우&lt;/p&gt;
&lt;p&gt;바깥 쿼리의 결과 수 만큼 재실행하며, 캐싱 처리하지 않는다.&lt;/p&gt;
&lt;p&gt;그래서 limit을 이와 같이 걸어도 원하는 결과를 얻을 수 없다.&lt;/p&gt;
&lt;p&gt;이걸 알고만 있다면, 해결 방안은 상황에 맞게 잘 만들어 낼 수 있다.&lt;/p&gt;
&lt;h2&gt;잡지식&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;백틱(Backtick)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;table&lt;/code&gt;  이렇게 테이블이나 컬럼을 명시하기 위한 문자를 칭하는 용어&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>explain</category>
      <category>MySQL Rand()</category>
      <category>실행 계획</category>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/171</guid>
      <comments>https://dev-monkey-dugi.tistory.com/171#entry171comment</comments>
      <pubDate>Thu, 10 Aug 2023 21:44:09 +0900</pubDate>
    </item>
    <item>
      <title>Spring Cloud Stream으로 RabbitMQ 샘플 구축하기</title>
      <link>https://dev-monkey-dugi.tistory.com/170</link>
      <description>&lt;h3&gt;  &lt;a href=&quot;https://blog.dudaji.com/general/2020/05/25/rabbitmq.html&quot;&gt;RabbitMQ&lt;/a&gt;란&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;AMQP를 따르는 &lt;strong&gt;오픈 소스 메시지 브로커이다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;요청에 대한 처리 시간이 길 때, 해당 요청을 Queue에 위임하고 빠르게 응답할 때 사용한다.&lt;/p&gt;
&lt;p&gt;또한, MQ를 사용하여 애플리케이션 간 결합도를 낮출 수 있다.&lt;/p&gt;
&lt;h3&gt;  &lt;a href=&quot;http://egloos.zum.com/killins/v/3025514&quot;&gt;AMQP&lt;/a&gt;란&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;Advaced Message Queing Protocol의 약자로써, MQ의 표준 프로토콜이다.&lt;/p&gt;
&lt;p&gt;이를 기반으로 나온 제품 중 하나가 RabbitMQ이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;등장 배경&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이전에는 각 플랫폼에 종속적인 제품들이었기 때문에 이기종 간 메시지 교환을 위해서&lt;/p&gt;
&lt;p&gt;포맷 컨버전을 해야 했기 때문에 &lt;strong&gt;메시지 브릿지&lt;/strong&gt;를 이용하거나(&lt;strong&gt;속도 저하 발생&lt;/strong&gt;) 시스템 자체를 통일 시켜야 하는&lt;/p&gt;
&lt;p&gt;불편함과 비효율성이 있었다.이러한 단점을 보완하기 위해 나온 표준 프로토컬이 AMQP이다.&lt;/p&gt;
&lt;p&gt;즉, AMQP가 나온 목적은 이기종 간 시스템간에 최대한 효율적인 방법으로 메시지를 교환하기 위해서다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AMQP 충족 조건&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;특정 벤더에 종속되는 것을 방지하기 위해 아래 조건을 충족한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;모든 브로커들은 똑같은 방식으로 동작할 것&lt;/li&gt;
&lt;li&gt;모든 클라이언트들은 똑같은 방식으로 동작할 것&lt;/li&gt;
&lt;li&gt;네트워크 상으로 전송되는 명령어들의 표준화&lt;/li&gt;
&lt;li&gt;프로그래밍 언어 중립&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;docker run&lt;/h3&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code&gt;docker run -d --hostname cloud-stream-rabbit --name cloud-stream-rabbit -p 15672:15672 -p 5672:5672 rabbitmq:3-management&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;  @Async가 아니고 왜 MQ인가&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;@Async로 할 경우는 문제점&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;서버가 다운될 경우 모든 쓰레드는 사라지기 때문에 데이터 유실&lt;/li&gt;
&lt;li&gt;실패한 메시지를 DLQ와 같은 곳에 보존할 수 없다.&lt;/li&gt;
&lt;li&gt;재시도가 번거롭다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;DB Queue가 아닌 이유&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;기존 php에서는 DB Queue를 사용하고 있었다.&lt;/p&gt;
&lt;p&gt;하지만 스프링에서는 이를 지원하지 않기 때문에 굳이 구현할 수고를 덜기 위해&lt;/p&gt;
&lt;p&gt;spring cloud stream에서 제공하는 RabbitMQ를 사용한다.&lt;/p&gt;
&lt;p&gt;kafka는 비용이 발생하기 때문에 사용하지 않는다.&lt;/p&gt;
&lt;h3&gt;  재시도 전략&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;재시도 전략이란?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;재시도 전략은 consume이 실패했을 경우 몇변을 재시도 하고, 재시도 간격은 어떻게 할지 등의 전략이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;우리 서비스에서 재시도 전략&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;크게 복잡하게 가져갈 상황은 아니라서 아주 간단하게 가져간다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;재시도는 일단 1번으로 한다.&lt;/li&gt;
&lt;li&gt;DLX로 전송한다.&lt;/li&gt;
&lt;li&gt;DLQ에 담긴 메시지를 수동으로 처리하거나 스크립트를 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;  에러 핸들링 전략&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;에러 핸들링 전략이란&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;메시지 consume이 실패할 경우 메시지를 버릴지, 보존할지 보존한다면 어떻게 보존할지 등의 전략이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;우리의 전략&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;우리는 DLX를 활용한다.&lt;/p&gt;
&lt;p&gt;DLX에 전송된 메시지는 수동으로 처리할 수도 있고, 자동화 스크립트를 개발할 수도 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;전략 종류&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;전략 종류에는 대략 크게 3가지가 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Message Drop: 실패한 메시지는 폐기한다.&lt;/li&gt;
&lt;li&gt;Re Queue Message: 실패한 메시지를 다시 queue에 넣는다.&lt;/li&gt;
&lt;li&gt;Dead Letter Queue: 실패한 메시지는 DLQ로 보낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Message Drop&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;특별할 것 없이 메시지를 폐기하고 끝이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Re Queue Message&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;메시지를 다시 queue에 넣는데 무작정 계속 반복하면 해당 메시지는 평생 queue에 남아있게 된다.&lt;/p&gt;
&lt;p&gt;그렇기 때문에 메시지 기간이나 시도 횟수에 따라서 핸들링하는 등의 행위가 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dead Letter Queue(사용할 전략)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;실패한 메시지를 보관하는 별도의 queue이다.&lt;/p&gt;
&lt;p&gt;DLQ로 메시지가 들어올 경우 개발자에게 &lt;code&gt;알림&lt;/code&gt;을 가게 하는 등의 처리로 수동으로 재시도하는 등의&lt;/p&gt;
&lt;p&gt;방식을 쓸 수 있다. 아니면 이를 자동화하는 코드를 작성해도 좋을 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Error Handling&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;DLQ를 사용하지 않거나 Re Queue를 하지 않을 경우 별도의 핸들링이 필요할 것이다.&lt;/p&gt;
&lt;p&gt;그럴 경우 &lt;strong&gt;ErrorMessage&lt;/strong&gt;용 &lt;strong&gt;consumer&lt;/strong&gt;를 만들어서 핸들링할 수 있다.&lt;/p&gt;
&lt;p&gt;대략 살펴보면 아래와 같이 핸들링할 수 있고, 더 깊은 내용은 다루지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;java code&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Bean
public Consumer&amp;lt;Employee&amp;gt; sample() {
  return (employee) -&amp;gt; {
    System.out.println(employee.toString());
    if (employee.getAge() &amp;gt; 100) {
      throw new RuntimeException(&amp;quot;occured consumer&amp;quot;);
    }
  };
}

@Bean
public Consumer&amp;lt;ErrorMessage&amp;gt; errorHandler() {
  return e -&amp;gt; {
    errorOccur++;
    System.out.println(&amp;quot;에러 발생: &amp;quot; + e);
  };
}&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;errorHandler()&lt;ul&gt;
&lt;li&gt;이 역시 똑같은 Consumer이기 생성되는 exchange는 &lt;strong&gt;errorHandler-in-0&lt;/strong&gt;이다.&lt;/li&gt;
&lt;li&gt;다만 T 타입만 ErrorMessage일 뿐이다. 이렇게 타입만 바꿔주면 알아서&lt;br&gt;에러 전용 excahnge와 consumer가 반인딩 된다.&lt;/li&gt;
&lt;li&gt;재시도까지 모두 실패했을 경우 에러로 판단해서 그 때 queue에 담겨서 consume하게 된다.&lt;/li&gt;
&lt;li&gt;물론 이렇게 해도 아무 처리도 하지 않기 때문에 메시지가 버려지는 것은 똑같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;  용어 정리&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rabbitmq.com/connections.html&quot;&gt;&lt;strong&gt;Connection&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;단일 TCP 연결&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TCP 기반&lt;/li&gt;
&lt;li&gt;효율성을 위해 수명이 긴 연결을 한다. 즉 프로토콜 작업당 새 연결이 열리지 않는다.&lt;/li&gt;
&lt;li&gt;하나의 클라이언트는 단일 TCP 연결을 사용한다.&lt;/li&gt;
&lt;li&gt;즉, 커넥션 풀처럼 커넥션을 유지한다.&lt;/li&gt;
&lt;li&gt;예를 들어 a 서버에서 10개의 커넥션을 한 번 만들었다면 10개가 계속 유지된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;AMQP 0-9-1&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;하나의 커넥션에 &lt;strong&gt;&lt;em&gt;Channel&lt;/em&gt;&lt;/strong&gt;이라고 하는 여러 &lt;strong&gt;&lt;em&gt;경량 연결&lt;/em&gt;&lt;/strong&gt;을 열 수 있다.&lt;br&gt;이 프로토콜을 &lt;strong&gt;&lt;em&gt;AMQP 0-9-1&lt;/em&gt;&lt;/strong&gt;이라고 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;em&gt;AMQP 0-9-1&lt;/em&gt;&lt;/strong&gt;는 하나 이상의 채널을 열고 채널에서 프로토콜 작업(구독, 소비)를 수행한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Channel&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;channel은 connection없이는 존재할 수 없다.&lt;/li&gt;
&lt;li&gt;단일 TCP 연결에 &lt;strong&gt;&lt;em&gt;경량 연결&lt;/em&gt;&lt;/strong&gt;을 &lt;strong&gt;&lt;em&gt;Channel&lt;/em&gt;&lt;/strong&gt;이라고 한다.&lt;/li&gt;
&lt;li&gt;모든 작업은 채널에서 발생한다.&lt;/li&gt;
&lt;li&gt;최대 채널 수를 자바에서 제어할 수 있다.&lt;/li&gt;
&lt;li&gt;송신을 하면 아래와 같이 pulisher 전용 채널이 생긴다. publish channel은 consume channel과는&lt;br&gt;다르다. 별개이다.&lt;/li&gt;
&lt;li&gt;프로세스와 쓰레드와 비교하자면, connection은 프로세스이고, channel이 쓰레드와 비슷하게 생각할 수 있을 것 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;  python으로 connection, channel 알아보기&lt;/h3&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;consum 코드 작성&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; #!/usr/bin/env python
 import pika, sys, os

 def main():
     def callback(ch, method, properties, body):
         print(&amp;quot; [x] Received %r&amp;quot; % body)

         # 커넥션 및 채널 생성
     connection = pika.BlockingConnection(pika.ConnectionParameters(host=&amp;#39;localhost&amp;#39;))
     channel = connection.channel()

         # 큐 선언, 익스체인지 선언, 큐 바인딩
     channel.queue_declare(queue=&amp;#39;hello&amp;#39;)
     channel.exchange_declare(exchange=&amp;#39;defaultExchange&amp;#39;,exchange_type=&amp;#39;direct&amp;#39;)
     channel.queue_bind(queue=&amp;#39;hello&amp;#39;, exchange=&amp;#39;defaultExchange&amp;#39;, routing_key=&amp;#39;defaultRoutingKey&amp;#39;)

         # 컨수머 센팅
     channel.basic_consume(queue=&amp;#39;hello&amp;#39;, on_message_callback=callback, auto_ack=True)

         # 컨숨 시작
     print(&amp;#39; [*] Waiting for messages. To exit press CTRL+C&amp;#39;)
     channel.start_consuming()

 if __name__ == &amp;#39;__main__&amp;#39;:
     try:
         main()
     except KeyboardInterrupt:
         print(&amp;#39;Interrupted&amp;#39;)
         try:
             sys.exit(0)
         except SystemExit:
             os._exit(0)&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;receiver.py&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;consume 실행&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;실행하게 되면 컨숨을 하기 위해 대기하게 된다.&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ntU1o/btr7e07VshI/P4nz0Ax1VHLiGGUfZSTKo1/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;브로커에 defaultExchange, queue, binding이 모두 생성된다.(매니지먼트 툴에서 확인 해보면 된다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;python receiver.py&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;publisher 코드 작성&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; #!/usr/bin/env python
 import pika

 if __name__ == &amp;quot;__main__&amp;quot;:

     connection = pika.BlockingConnection(pika.ConnectionParameters(host=&amp;#39;localhost&amp;#39;))
     channel = connection.channel()

     channel.queue_declare(queue=&amp;#39;hello&amp;#39;)
     channel.exchange_declare(exchange=&amp;#39;defaultExchange&amp;#39;, exchange_type=&amp;#39;direct&amp;#39;)
     channel.queue_bind(queue=&amp;#39;hello&amp;#39;, exchange=&amp;#39;defaultExchange&amp;#39;, routing_key=&amp;#39;defaultRoutingKey&amp;#39;)

     channel.basic_publish(exchange=&amp;#39;defaultExchange&amp;#39;, routing_key=&amp;#39;defaultRoutingKey&amp;#39;, body=&amp;#39;Hello World!&amp;#39;)
     print(&amp;quot; [x] Sent &amp;#39;Hello World!&amp;#39;&amp;quot;)
     connection.close()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;receiver코드와 거의 동일하고 큐, 익스체인, 바인딩 코드가 중복된다.그런데 send.py가 먼저 실행될지 receiver.py가 먼저 실행될지는 알 수 없기 때문에 동일한 셋팅이 들어가게 된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;해당 코드는 브로커에 큐, 익스체인지, 바인딩이 있는지 없는지 판단하고 있으면 그대로 사용하고 없으면 새로 만든다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;send.py&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;publisher 실행&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;publishing을 했으므로 대기 중이던 consumer는 consume을 하게 된다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1820&quot; data-origin-height=&quot;186&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cElnqH/btr7mufpsTX/kQfb8IzRvzplBpoNZtNwDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cElnqH/btr7mufpsTX/kQfb8IzRvzplBpoNZtNwDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cElnqH/btr7mufpsTX/kQfb8IzRvzplBpoNZtNwDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcElnqH%2Fbtr7mufpsTX%2FkQfb8IzRvzplBpoNZtNwDK%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;186&quot; data-origin-width=&quot;1820&quot; data-origin-height=&quot;186&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;python send.py&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;connections 및 channels 확인하기&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;위 사진을 보면 connection이 2개이다.

60370이 consume connection이고, 58306이 publish connection이다.

send.py, [receiver.py](http://receiver.py) 코드에서 각각 connection을 생성했기 때문에 그렇다.

publishing, consume 서버가 분리 되어 있다면 당연히 이런 구조가 나오는 것이다.&lt;/code&gt;&lt;/pre&gt;&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;&lt;p&gt;publishing, consume이 같은 connection의 같은 channel 사용하기&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsw8Hw/btr7fmQNuLM/xOLRDOZIeWHlC4nbUFpZeK/img.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;위 사진과 같이 하나의 channel에서 publising, consume을 모두 하게 된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;#!/usr/bin/env python import pika if __name__ == &amp;quot;__main__&amp;quot;: connection = pika.BlockingConnection( pika.ConnectionParameters(host=&amp;#39;localhost&amp;#39;)) channel = connection.channel() channel.queue_declare(queue=&amp;#39;hello&amp;#39;) channel.exchange_declare(exchange=&amp;#39;defaultExchange&amp;#39;, exchange_type=&amp;#39;direct&amp;#39;) channel.queue_bind(queue=&amp;#39;hello&amp;#39;, exchange=&amp;#39;defaultExchange&amp;#39;, routing_key=&amp;#39;defaultRoutingKey&amp;#39;) # publisher channel.basic_publish(exchange=&amp;#39;defaultExchange&amp;#39;, routing_key=&amp;#39;defaultRoutingKey&amp;#39;, body=&amp;#39;Hello World!&amp;#39;) print(&amp;quot; [x] Sent &amp;#39;Hello World!&amp;#39;&amp;quot;) # consumer def callback(ch, method, properties, body): print(&amp;quot; [x] Received %r&amp;quot; % body) channel.basic_consume(queue=&amp;#39;hello&amp;#39;, on_message_callback=callback, auto_ack=True) channel.start_consuming() print(&amp;#39;a&amp;#39;) print(&amp;#39;b&amp;#39;) print(&amp;#39;c&amp;#39;) connection.close()&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;rabbitMQ가 추구하는 경량 connection 만들기&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; #!/usr/bin/env python
 import pika

 if __name__ == &amp;quot;__main__&amp;quot;:

     connection = pika.BlockingConnection(pika.ConnectionParameters(host=&amp;#39;localhost&amp;#39;))
     channel1 = connection.channel()
     channel2 = connection.channel()

     channel1.queue_declare(queue=&amp;#39;hello1&amp;#39;)
     channel1.exchange_declare(exchange=&amp;#39;defaultExchange1&amp;#39;, exchange_type=&amp;#39;direct&amp;#39;)
     channel1.queue_bind(queue=&amp;#39;hello1&amp;#39;, exchange=&amp;#39;defaultExchange1&amp;#39;, routing_key=&amp;#39;defaultRoutingKey1&amp;#39;)

     channel2.queue_declare(queue=&amp;#39;hello2&amp;#39;)
     channel2.exchange_declare(exchange=&amp;#39;defaultExchange2&amp;#39;, exchange_type=&amp;#39;direct&amp;#39;)
     channel2.queue_bind(queue=&amp;#39;hello2&amp;#39;, exchange=&amp;#39;defaultExchange2&amp;#39;, routing_key=&amp;#39;defaultRoutingKey2&amp;#39;)

     # publisher
     channel1.basic_publish(exchange=&amp;#39;defaultExchange1&amp;#39;, routing_key=&amp;#39;defaultRoutingKey1&amp;#39;, body=&amp;#39;Hello World1!&amp;#39;)
     channel2.basic_publish(exchange=&amp;#39;defaultExchange2&amp;#39;, routing_key=&amp;#39;defaultRoutingKey2&amp;#39;, body=&amp;#39;Hello World2!&amp;#39;)
     print(&amp;quot; [x] Sent &amp;#39;Hello World!&amp;#39;&amp;quot;)
     connection.close()&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;위 코드와 같이 connection에서 channel만 새로 만드는 것이다. 그러면 connection 1개에 2개의 publishing channel이 만들어진다.&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wWG9E/btr7gTtogTw/jW45AHc9bIO5wKwbjHKWK0/img.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;rabbitMQ는 경량 connection은 connection 하나에 여러개의 channel을 운영하는 것이다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;channel 상세하게 알아보기&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;구조&lt;ul&gt;
&lt;li&gt;spring boot server 하나에서 pruducer, rabbitMQ, consume 모두 함.&lt;/li&gt;
&lt;li&gt;exchage: &lt;code&gt;defaultExchange&lt;/code&gt;, &lt;code&gt;customExchange&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;queue: &lt;strong&gt;&lt;code&gt;defaultExchange.defaultQueue&lt;/code&gt;, &lt;code&gt;customExchange.customQueue&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;spring boot server 실행&lt;ul&gt;
&lt;li&gt;connection 정보&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bADULG/btr7gSVy8j8/7GFfrerwX5WBk37D7wkp5k/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;channels 정보&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bh9cjx/btr7eZ82cVN/jCWtLKiycj58R1Q1WMnk2K/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;queue, consumer 정보&lt;ul&gt;
&lt;li&gt;channel: 62740(1)&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pcRYd/btr7iLuPWcN/Kw6W6gT8XhsTJXvggNoCKK/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;channel: 62740(2)&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nnhkw/btr7pTFYQz0/AzuZxglGq923SEJgjKynpk/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;connection: 62740&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;defaultExchange&lt;/code&gt;, &lt;code&gt;customExchange&lt;/code&gt;에 모두 전송&lt;ul&gt;
&lt;li&gt;connection 정보&lt;/li&gt;
&lt;li&gt;server 실행 시 생겼던 connection(&lt;code&gt;62740&lt;/code&gt;)에 channel(consume)이 1개 추가되고,&lt;br&gt;&lt;code&gt;62868&lt;/code&gt; connection이 새로 생기면서 channel(publisher)이 1개 추가되어 총 4개의 channels가 된다.&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bepYcw/btr7hTz5DsK/NXVS33wyozKYSm5Ekuyfy0/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;channel 정보&lt;ul&gt;
&lt;li&gt;62868(1): publisher 라고 나와있음.&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdMv8A/btr7gz29Ny5/ZlRkk8FbKFnWEoTBkBoNnK/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;62740(1), 62740(2), 62740(3)(1), (2)에 기존에 exchange가 연결되어 있었기 때문에 이 채널로 consume한다.&lt;br&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SMvef/btr7fnhRFJd/kyksrXxMa3PmNiRxIkw7K1/img.png&quot; alt=&quot;&quot;&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(1), (2)는 기존에 있던 것이고 (3)이 추가된 것인데 어떤 exchange도 연결되지 않았다.&lt;/code&gt; ???!@&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;channel 정리&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;62868(1) 채널은 publisher 라고 나와 있는데 왜 channel이 1개일까? exchange가 2개인데 2개 생겨야 하는거 아닌가?&lt;/li&gt;
&lt;li&gt;62740(3)는 뭘까? 아무런 역할이 없어 보이는데?&lt;/li&gt;
&lt;li&gt;consumer는 각각의 채널을 이용한다라는게 맞을까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;exchange와 prefetch count&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.dudaji.com/general/2020/05/25/rabbitmq.html&quot;&gt;https://blog.dudaji.com/general/2020/05/25/rabbitmq.html&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;  구조&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1736&quot; data-origin-height=&quot;266&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k4zUk/btr7iLaFkVy/zwxF2UFRofMENPZJRQP9yk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k4zUk/btr7iLaFkVy/zwxF2UFRofMENPZJRQP9yk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k4zUk/btr7iLaFkVy/zwxF2UFRofMENPZJRQP9yk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk4zUk%2Fbtr7iLaFkVy%2FzwxF2UFRofMENPZJRQP9yk%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;1736&quot; height=&quot;266&quot; data-origin-width=&quot;1736&quot; data-origin-height=&quot;266&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;모든 잡을 하나의 queue, exchange로 관리한다. 여거 큐를 관리하는 것 보다는 하나의 큐에서 관리하는 것이 편할 것으로 판단했고&lt;/p&gt;
&lt;p&gt;분리가 필요하다면 그 때 가서 분리하는 방향을 생각해 보자.&lt;/p&gt;
&lt;h3&gt;  &lt;strong&gt;구현 레벨&lt;/strong&gt;&lt;/h3&gt;
&lt;hr&gt;
&lt;p&gt;위의 도식과 같이 모든 작업을 하나의 exchange, queue에서 관리하기 위해서 리플렉션을 사용하는 것이 핵심히다.&lt;/p&gt;
&lt;p&gt;원래는 어떤 잡을 실행할지 케이스 별로 if 문을 consumer에서 사용해서 코드가 굉장히 지저분 했는데&lt;/p&gt;
&lt;p&gt;if 문이 필요한 부분을 producer가 push할 때 리플렉션을 payload로 넘겨 consumer를 라이브러리화 하여&lt;/p&gt;
&lt;p&gt;심플하게 만들었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;추가로 exchange, queue를 만들지 않을 경우 사용 방법&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;클라이언트는 DefaultProducer를 주입받아 push를 한다.&lt;ul&gt;
&lt;li&gt;push메서드 파라미터로는 JobSpringBean 클래스의 Method 타입과 method의 파라미터가 필요하다.&lt;/li&gt;
&lt;li&gt;파라미터는 기본 데이터 타입만 가능하다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;private final DefaultProducer defaultProducer; // bean public void client() throws NoSuchMethodException { defaultProducer.push( JobSpringBean.class.getMethod(&amp;quot;jobMethod&amp;quot;, Long.class), List.of(1L) ); }&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Job을 실행할 JobSpringBean 클래스를 생성해서 로직을 작성한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Component public class JobSpringBean { public void jobMethod(Long id) { Member member = findById(id); ... } }&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;리플렉션을 사용해서 개선 된 점을 정리 해보면 아래와 같다.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;consumer에서의 분기 처리 복잡도 단순화 되었다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;애초에 관리의 용이성을 위해서 하나의 exchange, queue로 모두 관리하는 것을 원했는데&lt;br&gt;이를 리플렉션 없이 구현할 경우 consumer에 분기 처리가 너무 많아지게 된다.&lt;br&gt;이를 개선하기 위해 프록시 패턴도 사용하고 했지만 그래도 결국 복잡한 것은 비슷하다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;개선 후에는 클라이언트에서 Job 클래스의 Method만 넘겨주면 되기 때문에 Consumer가 consume하는 역할만 하도록 개선 되었다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;전체 코드&lt;/p&gt;
&lt;p&gt;  &lt;strong&gt;DefaultConsumer.java&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;  @Slf4j
  @RequiredArgsConstructor
  @Service
  public class DefaultConsumer {

    private final ApplicationContext applicationContext;

    @Bean
    public Consumer&amp;lt;DefaultPayload&amp;gt; defaultConsume() {
      return (message) -&amp;gt; {
        Object bean = applicationContext.getBean(message.getJobBeanClass());
        String methodName = message.getMethodName();
        List&amp;lt;Serializable&amp;gt; args = message.getNormalizedArgs();

        try {
          message.getJobBeanClass()
              .getMethod(methodName, message.getParameterTypes())
              .invoke(bean, args.toArray());
        } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
          throw new RuntimeException(e);
        }
      };
    }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;  &lt;strong&gt;DefaultProducer.java&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;  @RequiredArgsConstructor
  @Component
  public class DefaultProducer {

    private final StreamBridge streamBridge;

    public void push(Method method, List&amp;lt;Serializable&amp;gt; args) {
      Class&amp;lt;?&amp;gt; clazz = method.getDeclaringClass();
      String methodName = method.getName();

      DefaultPayload payload = new DefaultPayload(clazz, methodName, args);
          streamBridge.send(&amp;quot;defaultExchange&amp;quot;, MessageBuilder.withPayload(payload).build());
    }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;DefaultPayload.java&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@NoArgsConstructor(access = AccessLevel.PUBLIC) // StreamBridge가 직렬화를 하기 위해서 기본 생성자가 필요함.
@Getter
public class DefaultPayload {

  private Class&amp;lt;?&amp;gt; jobBeanClass;
  private String methodName;
  private List&amp;lt;Serializable&amp;gt; args;
  private final List&amp;lt;Class&amp;lt;?&amp;gt;&amp;gt; argTypes = new ArrayList&amp;lt;&amp;gt;();

  public DefaultPayload(Class&amp;lt;?&amp;gt; jobBeanClass, String methodName, List&amp;lt;Serializable&amp;gt; args) {
    this.jobBeanClass = jobBeanClass;
    this.methodName = methodName;
    this.args = args;
    args.forEach(argValue -&amp;gt; this.argTypes.add(argValue.getClass()));
  }

  /**
   * consumer에서 역직렬화 시 Long type을 Integer로 받기 때문에 Integer를 Long으로 변환
   * args 필드를 깊은 복사해서 반환한다.
   */
  public List&amp;lt;Serializable&amp;gt; getNormalizedArgs() {
    int size = this.argTypes.size();
    return IntStream.range(0, size)
        .mapToObj(i -&amp;gt; convertArgIntegerToLong(this.argTypes.get(i), this.args.get(i)))
        .toList();
  }

  /**
   * @return Class&amp;lt;?&amp;gt;[]
   * reflection invoke를 위해서 배열이 필요하기 때문에 제공하는 메서드. consumer에서 사용
   */
  public Class&amp;lt;?&amp;gt;[] getParameterTypes() {
    return argTypes.toArray(Class[]::new);
  }

  private Serializable convertArgIntegerToLong(Class&amp;lt;?&amp;gt; argType, Serializable arg) {
    if (argType == Long.class &amp;amp;&amp;amp; arg.getClass() == Integer.class) {
      return Long.valueOf(String.valueOf(arg));
    }

    return arg;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;application.yml 예제 코드&lt;/h3&gt;
&lt;hr&gt;
&lt;pre&gt;&lt;code&gt;spring:
  cloud:
    function:
      definition: defaultConsume;customConsume
    stream:
      bindings:
        defaultConsume-in-0:
          destination: defaultExchange
          group: defaultQueue
          consumer:
            max-attempts: 1
        customConsume-in-0:
          destination: customExchange
          group: customQueue
          consumer:
            max-attempts: 1
      rabbit:
        bindings:
          consumeDefault-in-0:
            consumer:
              dead-letter-queue-name: defaultDlxQueue
              auto-bind-dlq: true
          customExchange-in-0:
            consumer:
              dead-letter-queue-name: customtDlxQueue
              auto-bind-dlq: true
  rabbitmq:
    addresses: localhost
    username: guest
    password: guest
    port: 5672&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Spring</category>
      <category>RabbitMQ</category>
      <category>spring cloud stream</category>
      <category>StreamBridge</category>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/170</guid>
      <comments>https://dev-monkey-dugi.tistory.com/170#entry170comment</comments>
      <pubDate>Sat, 1 Apr 2023 19:57:12 +0900</pubDate>
    </item>
    <item>
      <title>플라이 웨이트 패턴</title>
      <link>https://dev-monkey-dugi.tistory.com/169</link>
      <description>&lt;p&gt;객체를 가볍게 만들어서 메모리 사용을 줄이는 패턴이다. 플라이급의 플라이인 가벼운 것을 의미한다.&lt;/p&gt;
&lt;p&gt;자주 변하는 &lt;strong&gt;속성(exstrinsit)과 변하지 않는 속성(instrinsit)&lt;/strong&gt;을 분리하고 재사용하여 메모리&lt;br&gt;사용을 줄일 수 있다.&lt;/p&gt;
&lt;p&gt;잘 변하지 않는 것들을 모아 놓은 것을 플라이 웨이트 패턴이라고 하는데 이 때&lt;/p&gt;
&lt;p&gt;잘 변하지 않는 것들을 팩토리에 모아 놓고 캐싱하여 재사용하는 것이다.&lt;/p&gt;
&lt;h2&gt;  편집기 예제로 패턴 적용 전 알아보기&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;해당 프로그램은 hello라는 글을 쓸 수 있는 편집기이다.&lt;/p&gt;
&lt;p&gt;hello라는 글자를 white 색상, 폰트는 Nanum, 폰트 크기는 12로 정해진 글자이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;public class Character {

  private char value;
  private String color;
  private String fontFamily;
  private int fontSize;

  public Character(char value, String color, String fontFamily, int fontSize) {
    this.value = value;
    this.color = color;
    this.fontFamily = fontFamily;
    this.fontSize = fontSize;
  }
}

public class Client {

  public static void main(String[] args) {
    Character c1 = new Character(&amp;#39;h&amp;#39;, &amp;quot;white&amp;quot;, &amp;quot;Nanum&amp;quot;, 12);
    Character c2 = new Character(&amp;#39;e&amp;#39;, &amp;quot;white&amp;quot;, &amp;quot;Nanum&amp;quot;, 12);
    Character c3 = new Character(&amp;#39;l&amp;#39;, &amp;quot;white&amp;quot;, &amp;quot;Nanum&amp;quot;, 12);
    Character c4 = new Character(&amp;#39;l&amp;#39;, &amp;quot;white&amp;quot;, &amp;quot;Nanum&amp;quot;, 12);
    Character c5 = new Character(&amp;#39;o&amp;#39;, &amp;quot;white&amp;quot;, &amp;quot;Nanum&amp;quot;, 12);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 프로그램은 한글자 한글자마다 객체를 생성하게 되므로 메모리를 아주 많이 사용하게 된다.&lt;/p&gt;
&lt;p&gt;그래서 이를 개선하기 위해 &lt;strong&gt;intrinsit&lt;/strong&gt;한 내용은 캐싱해놓고 사용해서 메모리 사용을 줄일 수 있도록 개선할 수&lt;/p&gt;
&lt;p&gt;있는데 이를 플라이 웨이트 패턴으로 할 수 있다.&lt;/p&gt;
&lt;h2&gt;  플라이 웨이트 패턴으로 메모리 사용량 줄이기&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;instrinsit 데이터 고르기&lt;/h3&gt;
&lt;p&gt;이는 도메인적으로 다 다르기 때문에 현재 예제에서는 fontFamily, fontSize라고 판단한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;그럼 이 데이터는 변하지 않는 데이터로써 모두가 공유하는 객체가 될 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;적용하기&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;instrinsit 데이터 관리 클래스&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;데이터를 관리하는 객체로 변하지 않는 값이므로 immutable로 만들어야 한다.&lt;/p&gt;
&lt;p&gt;class final까지 있는 이유는 상속으로 생성되는 것까지 막기 위함인데&lt;/p&gt;
&lt;p&gt;해당 패턴의 용도 자체가 메모리 사용량을 줄이기 위함이기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;public final class Font {

  final String family;
  final int size;

  public Font(String family, int size) {
    this.family = family;
    this.size = size;
  }

  public String getFamily() {
    return family;
  }

  public int getSize() {
    return size;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;exstrinsit 데이터 관리 클래스&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;변하는 데이터를 관리하는 객체로써 &lt;strong&gt;instrinsit&lt;/strong&gt;인 Font를 갖는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;public class Character {

  char value;
  String color;
  Font font;

  public Character(char value, String color, Font font) {
    this.value = value;
    this.color = color;
    this.font = font;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fly weight factory&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;intrinsit&lt;/strong&gt;을 생성하고 조회하는 팩토리 클래스로써 이미 존재하는 Font이면 그대로 반환하고&lt;/p&gt;
&lt;p&gt;그렇지 않으면 새로 생성해서 반환한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;public final class FontFactory {

  private static final Map&amp;lt;String, Font&amp;gt; cache = new HashMap&amp;lt;&amp;gt;();

  private FontFactory() {}

  public Font getFont(String font) {
    if (cache.containsKey(font)) {
      return cache.get(font);
    }

    String[] split = font.split(&amp;quot;:&amp;quot;);
    String fontFamily = split[0];
    String fontSize = split[1];

    Font newFont = new Font(fontFamily, Integer.parseInt(fontSize));
    cache.put(font, newFont);

    return newFont;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Client 코드&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;public class Client {

  public static void main(String[] args) {
    Character c1 = new Character(&amp;#39;h&amp;#39;, &amp;quot;white&amp;quot;, FontFactory.getFont(&amp;quot;nanum:12&amp;quot;));
    Character c2 = new Character(&amp;#39;e&amp;#39;, &amp;quot;white&amp;quot;, FontFactory.getFont(&amp;quot;nanum:12&amp;quot;));
    Character c3 = new Character(&amp;#39;l&amp;#39;, &amp;quot;white&amp;quot;, FontFactory.getFont(&amp;quot;nanum:12&amp;quot;));
    Character c4 = new Character(&amp;#39;l&amp;#39;, &amp;quot;white&amp;quot;, FontFactory.getFont(&amp;quot;nanum:12&amp;quot;));
    Character c5 = new Character(&amp;#39;o&amp;#39;, &amp;quot;white&amp;quot;, FontFactory.getFont(&amp;quot;nanum:12&amp;quot;));
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;  장점과 단점&lt;/h2&gt;
&lt;hr&gt;
&lt;h3&gt;장점&lt;/h3&gt;
&lt;p&gt;메모리 사용량을 줄일 수 있다.&lt;/p&gt;
&lt;h3&gt;단점&lt;/h3&gt;
&lt;p&gt;구조가 복잡해진다.&lt;/p&gt;</description>
      <category>플라이 웨이트 패턴</category>
      <author>monkeyDugi</author>
      <guid isPermaLink="true">https://dev-monkey-dugi.tistory.com/169</guid>
      <comments>https://dev-monkey-dugi.tistory.com/169#entry169comment</comments>
      <pubDate>Sun, 5 Feb 2023 15:27:56 +0900</pubDate>
    </item>
  </channel>
</rss>