<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>minu-j.log</title>
        <link>https://velog.io/</link>
        <description>감각있는 프론트엔드 개발자 정민우입니다.</description>
        <lastBuildDate>Thu, 12 Feb 2026 21:29:13 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>minu-j.log</title>
            <url>https://velog.velcdn.com/images/minu-j/profile/7e3fd1de-1b64-43fa-a791-0aede198cfd9/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. minu-j.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/minu-j" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Expo SDK 54 환경에서 @react-native-firebase 의존성 추가 후 iOS 빌드 실패 해결]]></title>
            <link>https://velog.io/@minu-j/Expo-react-native-firebase-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%B6%94%EA%B0%80-%ED%9B%84-iOS-%EB%B9%8C%EB%93%9C-%EC%8B%A4%ED%8C%A8-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@minu-j/Expo-react-native-firebase-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%B6%94%EA%B0%80-%ED%9B%84-iOS-%EB%B9%8C%EB%93%9C-%EC%8B%A4%ED%8C%A8-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Thu, 12 Feb 2026 21:29:13 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>Expo 환경에서 <code>@react-native-firebase</code> 의존성 추가 후 알 수 없는 오류로 인한 빌드 실패 해결</p>
</blockquote>
<h2 id="환경">환경</h2>
<p><strong>Expo SDK 54</strong>
@react-native-firebase/app@^23.8.6
@react-native-firebase/messaging@^23.8.6</p>
<h2 id="문제">문제</h2>
<p>Expo React Native 프로젝트에서 Android, iOS Platform의 FCM Push Token을 발급받기 위해 <a href="https://github.com/invertase/react-native-firebase">@react-native-firebase</a> 라이브러리를 설치한 이후,</p>
<p><code>app.config.ts</code>에</p>
<pre><code class="language-ts">...
plugins: [
    ...
  &#39;@react-native-firebase/app&#39;,
  &#39;@react-native-firebase/messaging&#39;,
    ...</code></pre>
<p>추가한 이후 <code>eas build</code>로 빌드 시 오류 발생</p>
<p>로컬 <code>expo prebuild</code> 시에는 문제 없이 빌드 됨.</p>
<p><strong>Error Log</strong></p>
<pre><code class="language-bash">Build failed: The &quot;Run fastlane&quot; step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- include of non-modular header inside framework module &#39;RNFBApp.RCTConvert_FIRApp&#39;: &#39;/Users/expo/workingdir/build/apps/native/ios/Pods/Headers/Public/React-Core/React/RCTConvert.h&#39; [-Werror,-Wnon-modular-include-in-framework-module]
- include of non-modular header inside framework module &#39;RNFBApp.RCTConvert_FIROptions&#39;: &#39;/Users/expo/workingdir/build/apps/native/ios/Pods/Headers/Public/React-Core/React/RCTConvert.h&#39; [-Werror,-Wnon-modular-include-in-framework-module]
- include of non-modular header inside framework module &#39;RNFBApp.RNFBAppModule&#39;: &#39;/Users/expo/workingdir/build/apps/native/ios/Pods/Headers/Public/React-Core/React/RCTBridgeModule.h&#39; [-Werror,-Wnon-modular-include-in-framework-module]
- include of non-modular header inside framework module &#39;RNFBApp.RNFBRCTEventEmitter&#39;: &#39;/Users/expo/workingdir/build/apps/native/ios/Pods/Headers/Public/React-Core/React/RCTEventEmitter.h&#39; [-Werror,-Wnon-modular-include-in-framework-module]
- include of non-modular header inside framework module &#39;RNFBApp.RNFBSharedUtils&#39;: &#39;/Users/expo/workingdir/build/apps/native/ios/Pods/Headers/Public/React-Core/React/RCTBridgeModule.h&#39; [-Werror,-Wnon-modular-include-in-framework-module]
- include of non-modular header inside framework module &#39;RNFBApp.RNFBUtilsModule&#39;: &#39;/Users/expo/workingdir/build/apps/native/ios/Pods/Headers/Public/React-Core/React/RCTBridgeModule.h&#39; [-Werror,-Wnon-modular-include-in-framework-module]</code></pre>
<h2 id="해결">해결</h2>
<p><strong>AI는 <code>buildReactNativeFromSource: true</code> 설정이나, 커스텀 플러그인을 생성해서 <code>[&#39;CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES&#39;] = &#39;YES&#39;</code>를 설정하는 해결방법을 제시하지만 이렇게는 해결되지 않습니다.</strong></p>
<p>이 문제는 Git Issue에서 알려진 이슈입니다.</p>
<p>이슈: <a href="https://github.com/invertase/react-native-firebase/issues/8657#issuecomment-3291550747">Solved! Build error on expo 54 / react native 0.81 (working build demonstrator script linked in comments now) #8657</a></p>
<p>해결방법: <a href="https://github.com/expo/expo/issues/39607#issuecomment-3337284928">[SDK 54][iOS] Non-modular header errors when using @react-native-firebase/app + auth with useFrameworks: &quot;static&quot; #39607</a></p>
<h3 id="요약">요약</h3>
<p><code>app.config.ts</code>에 <code>expo-build-properties</code> 플러그인 설정에서 사용중인 모든<code>Firebase Pod</code>를 <code>forceStaticLinking</code> 배열에 추가합니다.</p>
<pre><code class="language-ts">plugins: [
  ...
  [
    &#39;expo-build-properties&#39;,
    {
      ios: {
        useFrameworks: &#39;static&#39;,
        forceStaticLinking: [&#39;RNFBApp&#39;, &#39;RNFBMessaging&#39;, ... ],
      },
      ...</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Turborepo의 출력이 GitHub Actions와 같은 CI 환경에서 자동으로 Task별 그룹화 됩니다. (--log-order option)]]></title>
            <link>https://velog.io/@minu-j/turborepo-log-order-option</link>
            <guid>https://velog.io/@minu-j/turborepo-log-order-option</guid>
            <pubDate>Mon, 25 Aug 2025 15:32:55 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/80113b9d-0f3d-411a-a62d-75406af456df/image.png" alt=""></p>
<h2 id="요구사항">요구사항</h2>
<blockquote>
<p>GitHub Actions에서 Monorepo의 모든 App, Package의 Lint 에러 개수를 센 다음, 모든 Lint Problems의 총 합, 그리고 각 App과 Package별 Problems 개수를 Slack Bot으로 전송한다.</p>
</blockquote>
<h2 id="구현">구현</h2>
<p><code>pnpx turbo lint</code> 명령어를 통해 Monorepo의 Lint 검사를 실행하고, 각 Task에서 출력된 결과를 합산하여 Slack Bot으로 보낼 예정이었습니다.</p>
<p>Lint는 오류 발생 시 로컬 환경에서 아래와 같은 메시지를 출력하며 작동이 중단되는데요,</p>
<pre><code class="language-shell">...

app-1:lint: ✖ 3 problems (2 errors, 1 warning)
app-1:lint:   2 errors and 0 warnings potentially fixable with the `--fix` option.

...

app-2:lint: ✖ 3 problems (2 errors, 1 warning)
app-2:lint:   2 errors and 0 warnings potentially fixable with the `--fix` option.</code></pre>
<p>에러가 발생할 때에도 모든 Task를 끝까지 실행하기 위해 다음과 같은 명령어를 추가할 수 있습니다.</p>
<pre><code class="language-shell">pnpx turbo lint --continue</code></pre>
<p>그러면 <code>grep -E &#39;✖&#39;</code> 커맨드로 모든 App과 Package의 Lint 검사 결과를 grep하여 어떤 Task의 결과로 몇 건의 Lint 문제가 발생했는지 알 수 있겠지요.</p>
<h2 id="문제점">문제점</h2>
<p>하지만 Git Action 환경에서 <code>turbo</code> 명령어를 이용해 출력되는 결과물은 자동적으로 아래와 같이 각 Task별로 그룹화 되어 출력됩니다.</p>
<pre><code class="language-shell">▶ app-1:lint

...

 ✖ 3 problems (2 errors, 1 warning)
   2 errors and 0 warnings potentially fixable with the `--fix` option.

▶ app-2:lint

...

 ✖ 3 problems (2 errors, 1 warning)
   2 errors and 0 warnings potentially fixable with the `--fix` option.</code></pre>
<p>이 내용은 Turborepo 블로그에 2024년 개재된 <a href="https://turborepo.com/blog/turbo-1-13-0#ci-logging-improvements">1.13버전 릴리즈 노트(링크)</a>를 통해 확인할 수 있는데요,
CI 로깅 개선을 통해</p>
<ul>
<li>Azure Pipelines</li>
<li>TeamCity</li>
<li>Travis CI</li>
</ul>
<p>와 함께 GitHub Actions도 로그 출력 그룹화가 지원이 된다는 내용입니다.</p>
<p>따라서 로컬 환경에서처럼 해당 린트 오류의 결과가 어떤 앱의 오류 결과인지를 <code>grep -E &#39;✖&#39;</code> 만으로는 알 수 없게 되지요.</p>
<h2 id="해결방법">해결방법</h2>
<p>강제로 그룹화를 해제해 출력하는 방법이 있습니다. <code>turbo run</code> 커맨드의 <a href="https://turborepo.com/docs/reference/run#--log-order-option"><code>--log-order</code></a> 옵션은 로그가 어떤 방식으로 정렬될 것인지를 설정할 수 있는데요,</p>
<p>stream 옵션은 모든 Task의 로그 출력을 그룹화 없이 출력하며,</p>
<pre><code class="language-shell">...
app:lint: ✖ 3 problems (2 errors, 1 warning)
app:lint:   2 errors and 0 warnings potentially fixable with the `--fix` option.</code></pre>
<p>grouped 옵션은 로그 출력을 각 Task별로 그룹화하여 출력합니다.</p>
<pre><code class="language-shell">▶ app:lint

...

 ✖ 3 problems (2 errors, 1 warning)
   2 errors and 0 warnings potentially fixable with the `--fix` option.</code></pre>
<p>기본값은 auto이며, CI 환경에서는 기본적으로 grouped 옵션으로 실행됩니다.</p>
<p>따라서 GitHub Actions에서 아래와 같은 명령어로 실행하면 로컬 환경과 동일하게 로그 출력 결과를 Task 정보와 함께 grep 할 수 있습니다.</p>
<pre><code class="language-shell">LINT_OUTPUT=$(pnpx turbo lint --continue --log-order=stream 2&gt;&amp;1)
LINT_RESULTS=$(echo &quot;$LINT_OUTPUT&quot; | grep -E &#39;✖&#39;)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Docker-compose에서만 발생하는 "Error: Cannot find module @rollup/rollup-linux-arm64-gnu" 오류 해결]]></title>
            <link>https://velog.io/@minu-j/fix-docker-compose-rollup-arm64-error</link>
            <guid>https://velog.io/@minu-j/fix-docker-compose-rollup-arm64-error</guid>
            <pubDate>Mon, 02 Dec 2024 18:33:55 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>아래 방법을 시도하기 전, 해당 오류에 대한 활발한 토론이 있었던 <a href="https://github.com/vitejs/vite/discussions/15532">Github issue</a>를 먼저 확인하고 시도해주시기 바랍니다.</p>
<p>링크: <a href="https://github.com/vitejs/vite/discussions/15532">https://github.com/vitejs/vite/discussions/15532</a></p>
</blockquote>
<p>Turborepo로 구성된 모노레포 환경에서 아래 명령어를 통해 Dockerfile을 빌드할 경우 정상적으로 동작하지만,</p>
<pre><code class="language-shell">sudo docker build -f apps/{app-name}/Dockerfile .</code></pre>
<p>잘 구성된 docker-compose를 이용한 빌드에서만 아래와 같은 오류가 발생했습니다.</p>
<pre><code class="language-shell">sudo docker compose up --build

...

/app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js:63
                throw new Error(
                      ^

Error: Cannot find module @rollup/rollup-linux-arm64-gnu. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory.
    at requireWithFriendlyError (/app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js:63:9)
    at Object.&lt;anonymous&gt; (/app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js:72:76)
    at Module._load (node:internal/modules/cjs/loader:1023:12)
    at cjsLoader (node:internal/modules/esm/translators:356:17)
    at ModuleWrap.&lt;anonymous&gt; (node:internal/modules/esm/translators:305:7)
    at ModuleJob.run (node:internal/modules/esm/module_job:218:25)
    at async ModuleLoader.import (node:internal/modules/esm/loader:329:24) {
  [cause]: Error: Cannot find module &#39;@rollup/rollup-linux-arm64-gnu&#39;
  Require stack:
  - /app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js
      at Module._resolveFilename (node:internal/modules/cjs/loader:1144:15)
      at Module._load (node:internal/modules/cjs/loader:985:27)
      at Module.require (node:internal/modules/cjs/loader:1235:19)
      at require (node:internal/modules/helpers:176:18)
      at requireWithFriendlyError (/app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js:45:10)
      at Object.&lt;anonymous&gt; (/app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js:72:76)
      at Module._compile (node:internal/modules/cjs/loader:1376:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
      at Module.load (node:internal/modules/cjs/loader:1207:32)
      at Module._load (node:internal/modules/cjs/loader:1023:12) {
    code: &#39;MODULE_NOT_FOUND&#39;,
    requireStack: [
      &#39;/app/node_modules/.pnpm/rollup@4.24.4/node_modules/rollup/dist/native.js&#39;
    ]
  }
}</code></pre>
<h2 id="해결-과정">해결 과정</h2>
<p>해결을 시도하던 중 한가지 특이한 점을 발견했는데요, 바로 모든 node_modules 폴더를 삭제한 뒤 docker-compose build를 하면 빌드가 정상적으로 되는 것이었습니다.</p>
<h2 id="원인-이해">원인 이해</h2>
<p>원인은 모노레포의 루트 디렉토리의 node_modules 폴더를 docker-compose가 그대로 복사해가면서 발생하는 문제였습니다.</p>
<p>해결방법은 되지 못했지만 상세한 오류 원인에 대한 이해는 <a href="https://sandipdulal.medium.com/fixing-vite-build-error-on-linux-and-windows-using-docker-error-cannot-find-module-e73bb2fb479d">해당 글</a>을 통해 할 수 있었는데요, 특정 rollup 플러그인 또는 패키지는 사용자 환경에 없는 플랫폼의 바이너리를 필요로 하기 때문에, 해당 플러그인을 필요로 한다는 점이었습니다.</p>
<p>즉 제가 <code>Dockerfile</code>에서 빌드용 이미지로 사용하는 <code>node:20.11.1-alpine</code>과 제 로컬 환경이 다르기 때문에 제 로컬 <code>node_modules</code>폴더를 복사해 갈 경우 문제가 발생한다는 것이었죠.</p>
<p>아마도, 패키지 관리자가 의존성을 설치하는 과정에서 환경에 따라 각각 다른 작업들을 해주는 것 같았고, 제 로컬 <code>node_modules</code>를 복사해 갈 경우 도커는 성능 최적화를 위해 특정 과정을 생략하면서 발생하는 문제 같았습니다.</p>
<h2 id="해결">해결</h2>
<p>해결방법이 어처구니 없이 간단했는데요,
모노레포의 루트 폴더에 <code>.dockerignore</code> 파일을 추가해 주는 것입니다.</p>
<pre><code class="language-shell"># .dockerignore

node_modules</code></pre>
<p>이 경우 정상적으로 빌드된 이미지가 생성됩니다.</p>
<hr>
<p><strong>참고문서</strong>
<a href="https://github.com/vitejs/vite/discussions/15532">https://github.com/vitejs/vite/discussions/15532</a>
<a href="https://sandipdulal.medium.com/fixing-vite-build-error-on-linux-and-windows-using-docker-error-cannot-find-module-e73bb2fb479d">https://sandipdulal.medium.com/fixing-vite-build-error-on-linux-and-windows-using-docker-error-cannot-find-module-e73bb2fb479d</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Turborepo + pnpm 기반 React 앱 도커 빌드하기]]></title>
            <link>https://velog.io/@minu-j/Turborepo-pnpm-%EA%B8%B0%EB%B0%98-React-%EC%95%B1-%EB%8F%84%EC%BB%A4-%EB%B9%8C%EB%93%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@minu-j/Turborepo-pnpm-%EA%B8%B0%EB%B0%98-React-%EC%95%B1-%EB%8F%84%EC%BB%A4-%EB%B9%8C%EB%93%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 02 Dec 2024 18:08:08 GMT</pubDate>
            <description><![CDATA[<p>Turborepo에서는 Docker 이미지를 빌드하기 위한 <a href="https://turbo.build/repo/docs/guides/tools/docker">가이드</a>를 제공하고 있는데요, 가이드를 잘 보면 <code>npm</code>과 <code>Next.js</code>의 예시만 제공하고 있어서 리액트 및 다른 패키지 매니저 기반의 프로젝트에서는 적용하기 어려울 수 있습니다.</p>
<h2 id="환경">환경</h2>
<p>필자의 프로젝트 환경은 Turbo와 pnpm 기반의 모노레포 개발 환경이며, React 앱을 Nginx를 이용해 제공하는 도커 컨테이너를 만들기 위해 아래와 같은 과정을 거쳤습니다.</p>
<p>예시 파일은 turbo의 Docker example을 기반으로 작성되었으며 필자의 환경과 일치하지는 않습니다. turbo의 예시와 다른 부분만 상세히 설명하겠습니다. 나머지 부분은 turbo의 <a href="https://turbo.build/repo/docs/guides/tools/docker">가이드</a>를 참고해주세요.</p>
<h2 id="초기-환경-설정">초기 환경 설정</h2>
<pre><code class="language-dockerfile">FROM node:18-alpine AS base

FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app

# pnpm으로 turbo를 global 설치하는게 생각대로 되지 않아 npm을 이용해 global 설치했습니다.
RUN npm install -g turbo@{version}

COPY . .

RUN turbo prune app-name --docker</code></pre>
<h2 id="pnpm-설치-및-turbo-build">pnpm 설치 및 turbo build</h2>
<pre><code class="language-dockerfile">FROM base AS installer
ARG NODE_ENV
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app

COPY --from=builder /app/out/json/ .
# pnpm-lock file을 같이 copy해줍니다.
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml

# pnpm install시 세부적인 설정은 환경에 따라 다를 수 있습니다. 필자는 corepack을 사용했습니다.
RUN corepack enable &amp;&amp; corepack prepare pnpm@{version} --activate
RUN pnpm install --frozen-lockfile --prod=false

COPY --from=builder /app/out/full/ .
# pnpx을 이용해 빌드가 필요한 app을 필터링해 turbo build를 실행합니다.
RUN pnpx turbo run build --filter={app-name}</code></pre>
<h2 id="nginx-설정">Nginx 설정</h2>
<pre><code class="language-dockerfile">FROM nginx:alpine

COPY ./apps/{app-name}/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=installer /app/apps/{app-name}/dist /usr/share/nginx/html

EXPOSE 80

CMD [&quot;nginx&quot;, &quot;-g&quot;, &quot;daemon off;&quot;]</code></pre>
<h2 id="결과">결과</h2>
<pre><code class="language-dockerfile">FROM node:18-alpine AS base

# 빌드 단계
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app

RUN npm install -g turbo@{version}

COPY . .

RUN turbo prune app-name --docker

# 설치 단계
FROM base AS installer
ARG NODE_ENV
RUN apk add --no-cache libc6-compat
RUN apk update
WORKDIR /app

COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml

RUN corepack enable &amp;&amp; corepack prepare pnpm@{version} --activate
RUN pnpm install --frozen-lockfile --prod=false

COPY --from=builder /app/out/full/ .

RUN pnpx turbo run build --filter={app-name}

# Nginx 설정 단계
FROM nginx:alpine
COPY ./apps/{app-name}/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=installer /app/apps/{app-name}/dist /usr/share/nginx/html

EXPOSE 80

CMD [&quot;nginx&quot;, &quot;-g&quot;, &quot;daemon off;&quot;]</code></pre>
<hr>
<p><strong>참고자료</strong>
<a href="https://turbo.build/repo/docs/guides/tools/docker">https://turbo.build/repo/docs/guides/tools/docker</a>
<a href="https://github.com/vercel/turborepo/issues/5462">https://github.com/vercel/turborepo/issues/5462</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[단상] 구린 결과물 만들지 않는 개발자 되기]]></title>
            <link>https://velog.io/@minu-j/%EA%B5%AC%EB%A6%B0-%EA%B2%B0%EA%B3%BC%EB%AC%BC%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EB%90%98%EA%B8%B0</link>
            <guid>https://velog.io/@minu-j/%EA%B5%AC%EB%A6%B0-%EA%B2%B0%EA%B3%BC%EB%AC%BC%EC%9D%84-%EB%A7%8C%EB%93%A4%EC%A7%80-%EC%95%8A%EB%8A%94-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EB%90%98%EA%B8%B0</guid>
            <pubDate>Tue, 01 Oct 2024 04:58:48 GMT</pubDate>
            <description><![CDATA[<p><a href="https://youtu.be/lwUFqA4bmNk?si=CoWsgyGrefFGv6tn&amp;t=857">영상 링크(14분 17초 부터)</a></p>
<p>이적의 진중한 대화를 담은 콘텐츠를 발견하면 영상 전체를 챙겨보는 편이다. 성능 좋은 인공지능이 적절하게 정제된 단어들을 뽑아내는 것 같다고 할까, 뱉어내는 말 한 마디 한 마디가 전부 곱씹어볼 필요가 있는 이야기들이다.</p>
<p>얼마 전, 존박의 유튜브 &#39;존이냐박이냐&#39;에 이적이 나와 이야기한 내용 중 일부를 보고 든 생각이다.</p>
<p>~</p>
<p>이적은 비비의 &lt;밤양갱&gt;을 들으면서 가사에 &#39;ㄴ&#39;, &#39;ㄹ&#39;정도만 남겨서 &#39;떠나는 길에 네가 내게 말했지&#39;라는 물결처럼 지나는 벌스를 만들었다는 점에 감탄했다고 한다.</p>
<p>흥미로운 점은 노래가 히트한 한참 뒤에 이 이야기를 장기하에게 해줬더니, 정말 듣고싶었던 이야기였는데 이적에게 처음 들었다며 고마워 했다는 것이다.</p>
<p>이런 디테일까지 청중은 전혀 눈치채지 못했지만 &lt;밤양갱&gt;이라는 노래는 크게 히트했고, 노래가 좋은 이유는 모르지만 그저 노래가 좋다고 느끼는 거라고.</p>
<p>~</p>
<p>복잡한 기술과 최적화를 이용해 사용자 경험을 향상시키는 구체적인 내용은 사용자 입장에서 알 바 아니지만,
&quot;눈치챌까?&quot; 싶어서, 혹은 이런 저런 핑계로 생략한 사소한 디테일들은 단 몇 픽셀, 몇 초의 화면 차이로 &quot;구리다&quot;고 느낀다.</p>
<p><strong>구리지 않은 결과물을 만들려면, 역설적으로 사용자가 알지 못할 정도로 사소한 디테일에 집착해야 한다는 것.</strong></p>
<p>그러면 사용자는 그 총체적인 결과물을 보며 &quot;설명은 못하지만 왜인지 모르게 완성도가 높다&quot;고 느낄 수 있지 않을까. 마치 좋은 음악을 들으면 기분이 좋아지는 것 처럼 말이다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/70e9b123-0c4d-436c-a41e-ff5d1dbb4875/image.png" alt=""></p>
<p><em>사진 출처: 가수 이적이 도파민 중독을 피하는 방법 - Youtube 존이냐 박이냐</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[IoT] 샤오미 미에어(Mi Air) 터미널로 토큰 발급하기]]></title>
            <link>https://velog.io/@minu-j/mi-air-home-assistant</link>
            <guid>https://velog.io/@minu-j/mi-air-home-assistant</guid>
            <pubDate>Tue, 16 Jul 2024 17:22:00 GMT</pubDate>
            <description><![CDATA[<p>샤오미 미에어를 Home Assistant에 추가하기 위한 방법을 구글에 검색하면 아이폰 또는 안드로이드 앱 내부 파일 접근을 통해 토큰을 알아내는 방법만 나옵니다.</p>
<p>혹시나 너무 복잡해서 시도조차 안하신 분들이 계실까봐 사실은 매우 간단하다는 것을 알려드리기 위해 방법을 소개드리겠습니다.</p>
<h1 id="home-assistant에-추가하려면">Home Assistant에 추가하려면</h1>
<p>HA에 등록하는 목적이라면 토큰 발급 안해도 됩니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/b407d913-1fec-4d84-aade-dd9786c0b2a7/image.png" alt=""></p>
<p>HA의 Xiaomi Miio에 ID와 비밀번호, 서버 위치를 입력하면 자동으로 지원되는 기기를 불러옵니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/1992466d-1d33-4bb2-972b-f74e774ae6eb/image.png" alt=""></p>
<p>끝!</p>
<p>하지만, 자동으로 불러와지지 않는 경우 또는 다른 Home Bridge 등 사용을 위해 토큰을 얻어야 한다면 아래 방법을 이용하면 편합니다.</p>
<h1 id="터미널로-토큰-얻기">터미널로 토큰 얻기</h1>
<p><a href="https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor">Github - PiotrMachowski/Xiaomi-cloud-tokens-extractor</a></p>
<p>PiotrMachowski의 Xiaomi-cloud-tokens-extractor 리포지토리에는 샤오미 기기의 토큰을 얻는 여러 방법이 소개되어 있습니다.
그 중 가장 간단한 터미널을 이용한 방법을 소개드리겠습니다.</p>
<p><strong>1. 터미널 명령어를 통해 shell script를 실행합니다.</strong></p>
<pre><code class="language-sh">bash &lt;(curl -L https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor/raw/master/run.sh)</code></pre>
<p><strong>2. 공기청정기가 등록된 샤오미 계정의 아이디, 비밀번호를 입력합니다.</strong></p>
<pre><code class="language-sh">Username (email or user ID):

Password:

Server (one of: cn, de, us, ru, tw, sg, in, i2) Leave empty to check all available:</code></pre>
<p>서버 정보를 입력하지 않으면 모든 서버를 탐색하므로 입력하지 않아도 됩니다.</p>
<p>입력한 아이디와 비밀번호를 통해 서버를 탐색하며 모든 기기의 등록 정보를 표시합니다.</p>
<pre><code class="language-sh">Devices found for server &quot;--&quot; @ home &quot;0000000000&quot;:
   ---------
   NAME:     ***
   ID:       ***
   MAC:      ***
   IP:       ***
   TOKEN:    ***
   MODEL:    ***
   ---------</code></pre>
<p>끝!</p>
<hr>
<h1 id="macos-sequoia에서-실행-오류">macOS Sequoia에서 실행 오류</h1>
<blockquote>
<pre><code class="language-bash">curl: (56) Failure writing output to destination, passed 63 returned 4294967295</code></pre>
</blockquote>
<pre><code>
맥os 세콰이어 업데이트 이후 이런 오류를 내며 실행이 안되는데요, 이 경우 수동으로 실행해줘야 합니다.

**1. 실행 파일 다운로드**

```bash
wget https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor/releases/latest/download/token_extractor.zip
unzip token_extractor.zip
cd token_extractor</code></pre><p>*<em>2. python 가상환경 구성 및 라이브러리 설치
*</em></p>
<pre><code class="language-bash">python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt</code></pre>
<p>*<em>3. 파이썬 파일 실행
*</em></p>
<pre><code class="language-bash">python3 token_extractor.py</code></pre>
<p>이후 과정은 동일합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue3 + Jest@29 컴포넌트 테스트 환경 설정]]></title>
            <link>https://velog.io/@minu-j/Vue3-Jest29-components-test</link>
            <guid>https://velog.io/@minu-j/Vue3-Jest29-components-test</guid>
            <pubDate>Mon, 08 Jul 2024 12:40:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/a06efb41-1bf4-485b-9910-85428c7459be/image.png" alt=""></p>
<blockquote>
<p>jest@29 버전 기준으로 작성되었습니다.</p>
</blockquote>
<blockquote>
<p>단위 테스트 환경이 구성되어있다는 전제로 합니다. 
<a href="https://velog.io/@minu-j/Vue3-Jest29-unit-test">Vue3 + Jest@29 단위 테스트 환경 설정 - https://velog.io/@minu-j/Vue3-Jest29-unit-test</a></p>
</blockquote>
<h1 id="환경-구성">환경 구성</h1>
<h2 id="의존성-추가">의존성 추가</h2>
<pre><code class="language-shell">$ yarn add -D @vue/test-utils @vue/vue3-jest jest-environment-jsdom ts-node</code></pre>
<ul>
<li><p><code>@vue/test-utils</code>: Vue 컴포넌트, 이벤트 트리거, DOM 조작 등 수행</p>
</li>
<li><p><code>@vue/vue3-jest</code>: Vue3 공식 jest 테스트 프리셋</p>
</li>
<li><p><code>jest-environment-jsdom</code>: Node.js환경에서 브라우저의 DOM 시뮬레이션하는 jsdom 환경을 jest에서 사용할 수 있도록 함</p>
</li>
</ul>
<pre><code>- jest@28부터 DOM 시뮬레이션 라이브러리가 기본으로 포함되지 않습니다.</code></pre><ul>
<li><code>ts-node</code>: Node.js환경에서 동작하는 TypeScript 런타임, TypeScript 파일을 실행할 수 있도록 함</li>
</ul>
<h2 id="jest-설정-파일-수정">Jest 설정 파일 수정</h2>
<h3 id="jestconfigts">jest.config.ts</h3>
<pre><code class="language-ts">import type { Config } from &#39;jest&#39;;

const config: Config = {
  ...
  testEnvironment: &#39;jest-environment-jsdom&#39;, // jsdom 환경 구성
  testEnvironmentOptions: {
    customExportConditions: [&#39;node&#39;, &#39;node-addons&#39;],
  },
  ...
  transform: {
    ...
    &#39;^.+\\.vue$&#39;: &#39;@vue/vue3-jest&#39;, // .vue 파일의 트랜스파일링 옵션을 &#39;@vue/vue3-jest&#39;로 설정
  },
};

export default config;</code></pre>
<h2 id="테스트-파일-예시">테스트 파일 예시</h2>
<h3 id="somecomponenttestts">someComponent.test.ts</h3>
<pre><code class="language-ts">import { describe, expect, it } from &#39;@jest/globals&#39;;
import { someComponent } from &#39;@/.../someComponent.vue&#39;;

describe(&#39;someComponent 컴포넌트테스트&#39;, () =&gt; {
  it(&#39;컴포넌트테스트의 상세 테스트 항목&#39;, () =&gt; {
    const wrapper = mount(TestVue, {
      props: {
        ...
      },
    });
    expect(wrapper.text()).toContain(&#39;Hello World!&#39;);
  });

  ...
});</code></pre>
<h1 id="테스트-실행">테스트 실행</h1>
<pre><code class="language-shell">yarn test
# or
yarn test --watch</code></pre>
<h1 id="참고-자료">참고 자료</h1>
<p><a href="https://ko.vuejs.org/guide/scaling-up/testing#component-testing"><strong>Vue 공식문서 컴포넌트 테스트</strong> - https://ko.vuejs.org/guide/scaling-up/testing#component-testing </a></p>
<p><a href="https://test-utils.vuejs.org/"><strong>Vue test utils 공식문서</strong> - https://test-utils.vuejs.org/</a></p>
<p><a href="https://github.com/vuejs/vue-jest"><strong>vue-jest 공식문서</strong> - https://github.com/vuejs/vue-jest</a></p>
<p><a href="https://jestjs.io/docs/configuration"><strong>jest 공식문서</strong> - https://jestjs.io/docs/configuration</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Vue3 + Jest@29 단위 테스트 환경 설정]]></title>
            <link>https://velog.io/@minu-j/Vue3-Jest29-unit-test</link>
            <guid>https://velog.io/@minu-j/Vue3-Jest29-unit-test</guid>
            <pubDate>Mon, 08 Jul 2024 12:32:13 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/fe6d6bf7-d6e1-4e17-a86d-8360528b4a68/image.png" alt=""></p>
<blockquote>
<p><a href="mailto:Vue@3.4">Vue@3.4</a>, jest@29 버전 기준으로 작성되었습니다.</p>
</blockquote>
<p><strong>Vue3에서는 vitest가 더 빠르고 다양한 테스트에 대응하기 때문에 vitest의 사용을 추천합니다. 하지만 vitest를 사용하지 못하는 특수한 경우에 Jest 29버전으로 환경 설정을 하기 위한 내용입니다.</strong></p>
<h1 id="환경-구성">환경 구성</h1>
<h2 id="의존성-추가">의존성 추가</h2>
<pre><code class="language-shell">$ yarn add -D jest @jest/global @babel/core @babel/preset-env @babel/preset-typescript babel-jest</code></pre>
<ul>
<li><p><code>jest</code>: 자바스크립트 및 TypeScript 프로젝트에서 단위 테스트를 위한 테스트 프레임워크</p>
</li>
<li><p><code>@jest/global</code>: Jest의 글로벌 API를 사용할 수 있도록 합니다.</p>
</li>
<li><p><code>@babel/core</code>: 최신 자바스크립트 문법을 구 버전 브라우저에서도 동작하게 변환해주는 Babel의 핵심 패키지</p>
</li>
<li><p><code>@babel/preset-env</code>: Babel이 특정 환경에 맞추어 자동으로 필요한 플러그인을 설정해주는 Babel 프리셋</p>
</li>
<li><p><code>@babel/preset-typescript</code>: TypeScript 코드를 Babel이 이해할 수 있는 JavaScript로 변환하기 위한 Babel 프리셋</p>
</li>
<li><p><code>babel-jest</code>: Jest와 Babel을 통합하여 Jest가 Babel을 사용해 코드를 변환할 수 있도록 합니다.</p>
</li>
</ul>
<h2 id="설정-파일-수정">설정 파일 수정</h2>
<h3 id="eslintrcjson">.eslintrc.json</h3>
<p>jest의 lint 설정을 활성화합니다.</p>
<pre><code class="language-js">{
  env: {
    ...
    &quot;jest&quot;: true
  },
  ...
}</code></pre>
<h3 id="babelconfigjs">babel.config.js</h3>
<pre><code class="language-js">module.exports = {
  ...
  presets: [
    ...
    [&#39;@babel/preset-env&#39;, { targets: { node: &#39;current&#39; } }],
    &#39;@babel/preset-typescript&#39;,
  ],
  ...
};</code></pre>
<h3 id="tsconfigjson">tsconfig.json</h3>
<p>TypeScript 컴파일러가 test파일을 처리할 수 있도록 합니다.</p>
<pre><code class="language-js">{
  &quot;include&quot;: [
    ...
    &quot;src/**/*.test.ts&quot;
  ],
  ...
}</code></pre>
<h3 id="packagejson">package.json</h3>
<p>test script를 추가합니다.</p>
<pre><code class="language-js">{
  ...
  &quot;scripts&quot;: {
    ...
    &quot;test&quot;: &quot;jest&quot;
  },
  ...
}</code></pre>
<h2 id="jest-설정-파일-생성">Jest 설정 파일 생성</h2>
<h3 id="jestconfigts">jest.config.ts</h3>
<pre><code class="language-ts">import type { Config } from &#39;jest&#39;;

const config: Config = {
  moduleFileExtensions: [&#39;js&#39;, &#39;ts&#39;, &#39;json&#39;, &#39;vue&#39;], // 확장자가 없는 파일의 import 우선순위를 설정
  moduleNameMapper: {
    &#39;^@/(.*)$&#39;: &#39;&lt;rootDir&gt;/src/$1&#39;, // &#39;@/*&#39; alias 설정
  },
  modulePathIgnorePatterns: [ // jest 파일이 포함되지 않을 경로 설정
    &#39;&lt;rootDir&gt;/node_modules&#39;,
    &#39;&lt;rootDir&gt;/build&#39;,
    &#39;&lt;rootDir&gt;/dist&#39;,
    &#39;&lt;rootDir&gt;/.yarn&#39;,
  ],
  transform: { // 트랜스파일링 옵션 설정
    &#39;^.+\\.js&#39;: &#39;babel-jest&#39;,
    &#39;^.+\\.ts&#39;: &#39;babel-jest&#39;,
  },
};

export default config;</code></pre>
<h2 id="테스트-파일-예시">테스트 파일 예시</h2>
<h3 id="dosomethingtestts">doSomething.test.ts</h3>
<pre><code class="language-ts">
import { describe, expect, it } from &#39;@jest/globals&#39;;
import { doSomething } from &#39;./doSomething.ts&#39;;

describe(&#39;doSomething 단위테스트&#39;, () =&gt; {
  it(&#39;단위테스트의 상세 테스트 항목&#39;, () =&gt; {
    expect(doSomething(&#39;say something&#39;)).toBe(&#39;Hello World!&#39;);
  });

  ...
});</code></pre>
<h1 id="실행">실행</h1>
<pre><code class="language-shell">yarn test
# or
yarn test --watch</code></pre>
<h1 id="참고-자료">참고 자료</h1>
<p><a href="https://ko.vuejs.org/guide/scaling-up/testing#unit-testing"><strong>Vue 공식문서 단위 테스트</strong> - https://ko.vuejs.org/guide/scaling-up/testing#unit-testing</a></p>
<p><a href="https://jestjs.io/docs/configuration"><strong>Jest 공식문서</strong> - https://jestjs.io/docs/configuration</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next 14에서 HTTPS 로컬 환경 구성 + Proxy middleware로 CORS 오류 해결]]></title>
            <link>https://velog.io/@minu-j/Next-14%EC%97%90%EC%84%9C-HTTPS-%EB%A1%9C%EC%BB%AC-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1-Proxy-middleware%EB%A1%9C-CORS-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@minu-j/Next-14%EC%97%90%EC%84%9C-HTTPS-%EB%A1%9C%EC%BB%AC-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1-Proxy-middleware%EB%A1%9C-CORS-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Sun, 21 Apr 2024 14:11:18 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/dfef0ac8-a5ba-4d5a-9b35-c32e7f80fdfb/image.png" alt=""></p>
<p>로컬 환경에서 프론트엔드 개발을 할 때 반드시 HTTPS와 Proxy가 동시에 필요한 시점이 옵니다. 
예를 들어 백엔드 개발 환경에 API 요청을 한다거나, 인증을 위해 백엔드에서 Cookie설정을 하는 경우 기본적인 로컬 개발환경에서는 한계가 있습니다. </p>
<p>이러한 불편을 해결하기 위해 로컬 환경에서도 개발 환경과 동일한 조건에서 테스트를 진행하기 위한 환경 구성하는 것이 필요한데요,
Webpack 또는 Vite의 DevServer를 이용하는 일반적인 환경의 React나 Vue 프로젝트의 경우 위 조건을 구성하는 것이 매우 간단합니다.</p>
<p>하지만 Next는 풀스택 프레임워크의 특성상 지금껏 했던 것과는 약간 다른 방법으로 개발 환경을 구성해야 했습니다.</p>
<p>이 글에서는 Next 14를 이용한 프론트엔드 로컬 개발환경에서 HTTPS로 서버를 실행하고, Proxy middleware를 통해 서버와 통신하기 위해 제가 구성한 방법을 기록하겠습니다.</p>
<blockquote>
<p><strong>개발 환경</strong>
Next 14.2.1
macOS Sonoma 14.2</p>
</blockquote>
<h2 id="목표">목표</h2>
<ul>
<li>로컬 프론트엔드 서버가 개발환경의 백엔드와 API 통신을 문제없이 할 수 있습니다.</li>
</ul>
<h2 id="구현">구현</h2>
<h3 id="https">HTTPS</h3>
<p>기존에 주로 사용한 Vite를 예로 들면 HTTPS서버를 로컬에서 실행하는 것은 아주 간단했는데요,</p>
<pre><code class="language-json">// ./package.json

&quot;scripts&quot;: {
      &quot;dev:https&quot;: &quot;HTTPS=true SSL_CRT_FILE=localhost.pem SSL_KEY_FILE=localhost-key.pem vite&quot;,
  ...
}</code></pre>
<p>HTTPS서버를 위한 인증 pem키를 발급받고, 위 스크립트만 실행하면 HTTPS 로컬 환경을 구성할 수 있었기 때문입니다.</p>
<p>하지만 Next 14에서는 별도의 Custom server를 이용해 프론트엔드 서버를 직접 구성해줘야 합니다.</p>
<h4 id="1-ssl-키-발급">1. SSL 키 발급</h4>
<p>우선 SSL인증을 위해 프로젝트 root 경로에서 <code>mkcert</code>를 이용해 pem키를 발급받습니다.</p>
<p><em>아래 방법은 macOS를 기준으로 작성되었습니다.</em></p>
<pre><code class="language-shell">$ brew install mkcert

$ mkcert -install

$ mkcert localhost</code></pre>
<p><strong>또는,</strong>
공동으로 작업하는 환경의 경우 shell script를 이용해 더 간편하게 키를 발급받을 수 있습니다.</p>
<pre><code class="language-shell">// ./init-ssl.sh

#!/bin/bash

MKCERT_INSTALLED=$(which mkcert)

if [ -z $MKCERT_INSTALLED ];then
    brew install mkcert
fi

mkcert -install
mkcert localhost
</code></pre>
<p>이후 package.json에 해당 shell을 실행하는 script를 생성합니다.</p>
<pre><code class="language-json">// ./package.json

&quot;script&quot;: {
  &quot;init:ssl&quot;: &quot;sh init-ssl.sh&quot;,
  ...
}
</code></pre>
<p>그럼 <code>localhost.pem</code>, <code>localhost-key.pem</code> 두 파일이 생성됩니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/61a76c79-e1f3-4d5b-b71e-80ec8ce5e936/image.png" alt=""></p>
<h4 id="2-custom-server-구성">2. Custom server 구성</h4>
<p>HTTPS 서버 환경 구성을 위해 Custom server로 프론트엔드 로컬 서버를 구축할 것입니다.</p>
<p>Root 경로에 server.js를 생성합니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/29f5bb16-5f8d-4603-aff8-bad0ff66b49c/image.png" alt=""></p>
<p>node.js로 서버를 구성하는데요, 라우팅 등 프론트엔드 서버로서의 기본적인 역할은 next가 알아서 해줄테니 걱정하지 않아도 됩니다.
저희는 특정 로컬 포트로 서버를 열어주기만 할거에요.</p>
<p>우선 필요한 패키지들을 import해줍니다.</p>
<pre><code class="language-js">// ./server.js

const http = require(&#39;http&#39;);
const https = require(&#39;https&#39;);
const fs = require(&#39;fs&#39;);
const { parse } = require(&#39;url&#39;);
const next = require(&#39;next&#39;);</code></pre>
<p>production환경에서는 실행되지 않도록 NODE_ENV를 확인하고, NextServer를 선언합니다.</p>
<pre><code class="language-js">// ./server.js

const dev = process.env.NODE_ENV !== &#39;production&#39;;
const app = next({ dev });
const handle = app.getRequestHandler();</code></pre>
<p>NextServer가 구성되면 HTTP와 HTTPS 서버를 지정된 포트로 열어줍니다.
HTTPS서버를 생성할 때에는 option에 아까 생성한 pem키를 넣어줍니다.</p>
<pre><code class="language-js">// ./server.js

app.prepare().then(() =&gt; {
  const HTTP_PORT = process.env.HTTP_PORT;
  const HTTPS_PORT = process.env.HTTPS_PORT;

  const httpServer = http.createServer((req, res) =&gt; {
    const parsedUrl = parse(req.url, true);
    handle(req, res, parsedUrl);
  });

  const httpsServer = https.createServer(
    {
      cert: fs.readFileSync(`${process.env.HTTPS_CERT_PATH}.pem`),
      key: fs.readFileSync(`${process.env.HTTPS_KEY_PATH}.pem`),
    },
    (req, res) =&gt; {
      const parsedUrl = parse(req.url, true);
      handle(req, res, parsedUrl);
    },
  );

  httpServer.listen(HTTP_PORT, err =&gt; {
    if (err) throw err;
    console.log(`&gt; Ready on http://localhost:${HTTP_PORT}`);
  });

  httpsServer.listen(HTTPS_PORT, err =&gt; {
    if (err) throw err;
    console.log(`&gt; Ready on https://localhost:${HTTPS_PORT}`);
  });
})</code></pre>
<p>package.json에 next를 실행하는 대신 방금 만든 server.js를 실행하는 script를 추가합니다.</p>
<pre><code class="language-json">// ./package.json

&quot;script&quot;: {
  &quot;dev:https&quot;: &quot;node server.js&quot;,
  ...
}</code></pre>
<p>터미널에 해당 명령어를 입력하면 정상적으로 HTTPS서버가 실행됩니다.</p>
<pre><code class="language-shell">$ npm run dev:https

&gt; app-name@0.1.0 dev:https
&gt; node server.js

&gt; Ready on http://localhost:3000
&gt; Ready on https://localhost:3001
</code></pre>
<h3 id="proxy">Proxy</h3>
<h4 id="custom-server를-사용하지-않을-때">Custom server를 사용하지 않을 때</h4>
<p>지금까지 HTTPS로 로컬 서버를 실행해보았는데요, Custom server를 사용하지 않을 때는 Next의 rewrites option을 활용하면 특정 요청의 origin이나 path를 변형하는 것이 매우 간단합니다.</p>
<pre><code>//next.config.mjs

const nextConfig = {
  async rewrites() {
    return [
      {
        source: &quot;/api/:path*&quot;,
        destination: `https://.../:path*`
      },
    ];
  },
};</code></pre><p>이런식으로 nextConfig에 rewrites를 설정해주면 특정 source에서 가는 요청의 도메인을 바꿔 브라우저에서 동일한 origin의 통신으로 인식하게 해 CORS 오류가 발생하지 않습니다.</p>
<p>또는 로컬과 개발 환경에서 api path가 상이할 경우에도 쉽게 path를 변형해 api를 요청할 수 있습니다.</p>
<h4 id="custom-server를-사용할-때">Custom server를 사용할 때</h4>
<p>하지만 custom server를 이용해 HTTPS로 로컬 서버를 구성했을 경우, nextConfig의 Proxy 설정이 적용되지 않았습니다.</p>
<p>이런 경우 next에서 제공되는 middleware를 이용해 간단하게 전역 Proxy middleware를 구성할 수 있습니다.</p>
<p>우선 app 폴더와 동일한 최상위 경로에 <code>middleware.ts</code>파일을 생성해줍니다. 저는 <code>./src</code> 하위에 app 폴더를 생성했으므로 <code>./src/middleware.ts</code>를 생성해줬습니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/07abc8e6-9443-41ed-8ed8-9f159d011bc5/image.png" alt=""></p>
<p>middleware 함수를 선언해줍니다. NextResponse의 rewrite 메서드를 이용하면 전송되는 api의 path를 요청 앞단에서 변경해줍니다.</p>
<pre><code class="language-ts">import { NextResponse } from &#39;next/server&#39;;
import type { NextRequest } from &#39;next/server&#39;;

export function middleware(request: NextRequest) {
  const rewritePath = `${process.env.NEXT_PUBLIC_API_URL}/${request.nextUrl.pathname}`;

  console.log(`[Proxy]: ${request.url} =&gt; ${rewritePath}`);

  return NextResponse.rewrite(new URL(rewritePath));
}</code></pre>
<p><strong>단,</strong>
이 방법으로 middleware를 구성할 경우 모든 api요청, next의 정적 리소스를 포함하여 모든 요청들이 이 middleware를 타면서 path가 rewrite됩니다. </p>
<p>그러므로 특정 uri path에서만 middleware가 작동하도록 설정을 해주어야 합니다.</p>
<pre><code class="language-ts">export const config = {
  matcher: [&#39;/api/:path*&#39;],
};</code></pre>
<p><strong>또는,</strong>
여러 path에 대해 조건부로 middleware의 동작이 필요할 경우 함수 내부에 조건문으로 분기해줄 수 있습니다.</p>
<pre><code class="language-ts">export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith(&#39;/api&#39;)) {
    const rewritePath = `${process.env.NEXT_PUBLIC_API_URL}${request.nextUrl.pathname}`;

    console.log(`&gt; [Proxy] ${request.url} =&gt; ${rewritePath}`);
    return NextResponse.rewrite(new URL(rewritePath, request.url));
  }

  return NextResponse.next();
}
</code></pre>
<p>이렇게 설정하면 <code>/api/...</code> 로 시작하는 모든 endpoint의 요청은 특정 origin의 요청 헤더를 가지고 요청되므로 브라우저의 CORS정책에 위배되지 않습니다.</p>
<pre><code class="language-shell">...
 ✓ Compiled /src/middleware in 217ms (71 modules)

[Proxy]: https://localhost:3000/api/v1/game/cnt =&gt; https://.../api/v1/game/cnt

 GET /favicon.ico 200 in 5ms
...
</code></pre>
<p>NODE ENV가 &#39;development&#39;인 경우에만 middleware를 동작하게 하기 위해 함수의 첫줄에 조건문을 하나 적어줍니다.</p>
<pre><code class="language-ts">if (process.env.NODE_ENV !== &#39;development&#39;) return NextResponse.next();</code></pre>
<h2 id="끝">끝</h2>
<p>정상적으로 구현이 된다면, </p>
<ul>
<li><p>Next는 로컬에서 server.js의 custom server를 통해 실행되고, 발급받은 pem키를 이용한 SSL인증으로 HTTPS로 서버를 실행합니다.</p>
</li>
<li><p>Proxy를 이용해 개발환경의 백엔드 서버와 CORS문제 없이 JSON을 주고 받습니다.</p>
</li>
<li><p>Google 등 SSO를 이용해 OAuth2로 로그인 할 때 로컬에서도 문제 없이 로그인 할 수 있습니다.</p>
</li>
<li><p>Origin이 다른 백엔드 서버와 API통신을 할 때에도 브라우저가 CORS 오류를 발생시키지 않습니다.</p>
</li>
<li><p>Refresh token 등 사용자 인증을 위해  백엔드에서 httpOnly, sameSite 등 보안 설정이 된 Set-Cookie 헤더의 데이터가 문제없이 쿠키에 저장됩니다.</p>
</li>
</ul>
<p>즉, 로컬 서버의 프론트엔드를 개발환경에 배포된 프론트엔드처럼 동일한 동작을 테스트해볼 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[State management와 TanStack Query - 2]]></title>
            <link>https://velog.io/@minu-j/State-management%EC%99%80-TanStack-Query-2</link>
            <guid>https://velog.io/@minu-j/State-management%EC%99%80-TanStack-Query-2</guid>
            <pubDate>Wed, 27 Mar 2024 16:54:26 GMT</pubDate>
            <description><![CDATA[<h1 id="tanstack-queryvue-query">TanStack Query(Vue Query)</h1>
<h3 id="vue-query란">Vue Query란?</h3>
<p>Vue Query는 Tanstack에서 개발한 <strong>server state management 라이브러리</strong>입니다. 공식적으로 React, Vue, Solid, Svelte 4가지의 프론트엔드 프레임워크를 지원하며, TanStack Query에서 각 프레임워크 별 이름을 따 Vue에서 작동하는 라이브러리의 경우 Vue Query라고 부릅니다.</p>
<h2 id="개요">개요</h2>
<blockquote>
<p>TanStack Query (FKA Vue Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.</p>
</blockquote>
<p>Vue Query는</p>
<ul>
<li><p><strong>서버로부터 데이터 가져오기</strong></p>
</li>
<li><p><strong>캐싱</strong></p>
</li>
<li><p><strong>서버 데이터를 서버와 동기화하고 업데이트하기</strong></p>
</li>
</ul>
<p>주로 세 가지 기능을 수행하기 위해 만들어졌다고 설명합니다.</p>
<h2 id="왜-사용해야-할까">왜 사용해야 할까?</h2>
<p>React, Vue 등 유명 웹 프레임워크에는 서버 데이터를 <strong>fetching, caching, synchronizing, updating</strong>하는 “일반적인” 또는 “공통화 된” 방식을 제공하지 않는 것을 문제로 제시합니다.</p>
<p>때문에 데이터를 가져오고, 관리하는 방법을 고민하고, 개발자마다 매우 다른 방식으로 개발되는 것에 대한 문제점을 이야기합니다.</p>
<p>또한 기존 상태 관리 라이브러리로 서버 데이터를 관리하는것도 적합하지 않다고 말합니다. Server state와 client state는 전혀 다르기 때문입니다.</p>
<p><strong>Server state의 차이점 (Client state와 비교하여)</strong></p>
<ul>
<li><p><strong>프론트엔드 개발자가 통제할 수 없는 위치에 데이터가 저장되어 있다.</strong></p>
</li>
<li><p><strong>가져오거나 업데이트를 하기 위해 비동기적으로 API를 이용해야 한다.</strong></p>
</li>
<li><p><strong>다른 사람과 데이터를 공유하며, 본인이 모르게 다른 사람이 데이터를 변경할 수 있다.</strong></p>
</li>
<li><p><strong>자칫 데이터가 “오래된” 데이터가 될 수 있다.</strong></p>
</li>
</ul>
<p>이 차이점을 해결하기 위해 개발하다보면 다음과 같은 문제점을 마주치게 될 것이라고 말합니다.</p>
<ul>
<li><p><strong>캐싱 → 가장 어려운 일</strong></p>
</li>
<li><p><strong>동일한 여러 요청을 단일 요청으로 보내 중복 제거하기</strong></p>
</li>
<li><p><strong>백그라운드에서 “오래된” 데이터 업데이트</strong></p>
</li>
<li><p><strong>데이터가 “오래된” 시점을 파악하기</strong></p>
</li>
<li><p><strong>최대한 빠르게 데이터 업데이트를 반영하기</strong></p>
</li>
<li><p><strong>Pagenation, Lazy loading 등 성능 최적화</strong></p>
</li>
<li><p><strong>Server state의 메모리 관리, garbage collection</strong></p>
</li>
<li><p><strong>서버 데이터 메모이제이션</strong></p>
</li>
</ul>
<p>개발자는 이 모든 문제들을 해결하기 매우 어렵고, 놓치기 쉽다고 설명하며, Vue Query는 이러한 Server state의 까다로운 문제점들을 쉽게 극복하게 해준다고 설명하고 있습니다. Vue Query를 사용하면 위 문제들을 크게 신경쓸 필요 없이 기본적으로 <strong>“알아서”</strong>, <strong>“잘”</strong> 작동합니다.</p>
<h2 id="주요-개념">주요 개념</h2>
<h3 id="queries">Queries</h3>
<p>쿼리(query)는 QueryKey라는 unique한 key에 연결된 데이터의 비동기적인 출처(asynchronous source)에 대한 선언적 종속성(declarative dependency)입니다.</p>
<p>쉽게 설명하면, 데이터를 가져오는 함수와 가져온 데이터, 그리고 unique한 key값의 한 단위를 이야기한다고 할 수 있습니다.</p>
<p>컴포넌트 또는 hook에서 쿼리를 구독(subscribe)하기 위한 방법입니다.</p>
<pre><code class="language-js">import { useQuery } from &#39;@tanstack/vue-query&#39;

const result = useQuery({ queryKey: [&#39;todos&#39;], queryFn: getTodoList })
</code></pre>
<h3 id="query-keys">Query Keys</h3>
<p>전역적으로 쿼리를 refetch하고, caching하고, 공유하는 데 내부적으로 사용되는 key값입니다.</p>
<p>Query key는 기본적으로 상수값의 배열로 선언되며, 문자열, 숫자, 객체, 배열 등 다양한 값을 포함할 수 있습니다.</p>
<pre><code class="language-ts">// 하나의 문자열도 배열로 선언해야 합니다.
useQuery({ queryKey: [&#39;todos&#39;], ... })

// 다양한 값들이 배열에 포함될 수 있으며, 모두 다른 key로 취급되어 각각 별도로 캐싱됩니다. 
useQuery({ queryKey: [&#39;todos&#39;, 0 ], ... })
useQuery({ queryKey: [&#39;todos&#39;, 1 ], ... })

// 객체 내부도 깊은 비교를 통해 다른 key로 취급합니다.
useQuery({ queryKey: [&#39;todos&#39;, { filter: &#39;done&#39; } ], ... })
useQuery({ queryKey: [&#39;todos&#39;, { filter: &#39;unfinished&#39; } ], ... })

// 배열은 순서가 다르면 다른 값으로 취급되지만,
useQuery({ queryKey: [&#39;todos&#39;, [0, 1] ], ... })
useQuery({ queryKey: [&#39;todos&#39;, [1, 0] ], ... })

// 객체는 순서가 달라도 같은 값으로 취급됩니다.
useQuery({ queryKey: [&#39;todos&#39;, { filter: &#39;done&#39;, page: 3} ], ... })
useQuery({ queryKey: [&#39;todos&#39;, { page: 3, filter: &#39;done&#39;} ], ... })</code></pre>
<p><strong>Query Function에서 사용하는 모든 변수는 Query Key에 포함되어야 합니다.</strong> 변수에 의존하는 각각 다른 속성값(페이지, 필터 등)을 가진 데이터 배열을 별도로 캐싱할 수 있습니다.</p>
<pre><code class="language-js">function useTodo(page: number, filter: &#39;done&#39; | &#39;unfinished&#39;) {
    return useQuery({ queryKey: [&#39;todos&#39;, { page, filter } ], ... })
}</code></pre>
<p>이 경우 0페이지와 1페이지는 별도로 캐싱됩니다.</p>
<p>만약 0페이지에서 1페이지에 방문했다가 빠르게 0페이지에 되돌아가더라도 굳이 0페이지를 다시 로드할 필요가 없어집니다.</p>
<ul>
<li><p>불필요한 API 호출을 줄여 성능을 최적화하고,</p>
</li>
<li><p>빠른 데이터 표시를 통해 사용자 경험을 개선합니다.</p>
</li>
</ul>
<h3 id="query-functions">Query Functions</h3>
<p>해당 쿼리의 데이터 또는 error값의 Promise를 return하는 함수입니다.</p>
<p>쿼리 함수는 Promise를 반환하는 모든 형태의 함수입니다.</p>
<ul>
<li><p>데이터를 반환하거나</p>
</li>
<li><p>오류를 발생시켜야 합니다.</p>
</li>
</ul>
<p>발생한 오류는 error 에 저장됩니다.</p>
<pre><code class="language-js">const { error } = useQuery({ ... })</code></pre>
<h3 id="query-function-variables">Query Function Variables</h3>
<p>Query Key는 쿼리의 유니크한 구분값의 역할, 쿼리 함수가 의존하는 변수의 구분을 통한 캐싱 뿐 아니라 직접 쿼리 함수의 변수로도 사용될 수 있습니다.</p>
<pre><code class="language-js">const result = useQuery({
  queryKey: [&#39;todos&#39;, { status, page }],
  queryFn: getTodoList,
})

// 쿼리 키를 매개변수로 받으면 status, page 변수에 접근할 수 있습니다.
function fetchTodoList({ queryKey }) {
  const [_key, { status, page }] = queryKey
  return new Promise()
}</code></pre>
<h3 id="mutations">Mutations</h3>
<p>단순히 조회성 쿼리가 아닌, 데이터를 생성(POST), 업데이트(PUT), 삭제(DELETE)하는 데 사용됩니다.</p>
<pre><code class="language-js">import { useMutation } from &#39;@tanstack/vue-query&#39;

const { mutate } = useMutation({
  mutationFn: (newTodo) =&gt; postTodo(newTodo),
})

function addTodo() {
  mutate({ id: new Date(), title: &#39;뷰쿼리 공부하기&#39; })
}</code></pre>
<p>데이터를 변형하는 API의 경우 <code>useQuery</code>가 아닌 <code>useMutations</code>를 사용해야 합니다.</p>
<p>Mutation Function 호출 이후 여러 상황에 대한 콜백을 지원합니다.</p>
<pre><code class="language-js">useMutation({
  mutationFn: addTodo,
  onMutate: (variables) =&gt; { },
  onError: (error, variables, context) =&gt; { },
  onSuccess: (data, variables, context) =&gt; { },
  onSettled: (data, error, variables, context) =&gt; { },
})</code></pre>
<h3 id="query-invalidation">Query Invalidation</h3>
<p>무언가 사용자의 작업으로 인해 쿼리가 변형되었다는 것이 매우 확실할 때에도 쿼리가 오래된(stale) 데이터가 되어 백그라운드에서 자동으로 refetch될 때 까지 기다리는 것은 조금 이상합니다.</p>
<p>Query Invalidation은 특정 Query Key를 가진 쿼리의 데이터가 더이상 유효하지 않음을 선언하고, 쿼리를 무효화하여 강제로 refetch되게 합니다.</p>
<p>예를 들어, todoList를 조회한 이후 특정 todo를 수정하거나, 새로운 todo를 생성해 todoList의 refetch가 필요할 경우, ‘todos’로 시작하는 Query Key를 가진 쿼리들의 데이터를 무효화합니다.</p>
<pre><code class="language-js">// `todos`로 시작하는 Query Key를 가진 캐시된 쿼리들을 무효화합니다.
queryClient.invalidateQueries({ queryKey: [&#39;todos&#39;] })</code></pre>
<p>이렇게 되면 아래 Query Key를 가진 캐시된 쿼리가 있을 경우</p>
<pre><code>[ &#39;todos&#39;, { page: 0, size: 10 } ]

[ &#39;todos&#39;, { page: 1, size: 10 } ]

[ &#39;todos&#39;, { page: 0 } ]

[ &#39;todos&#39; ]</code></pre><p>전부 refetch됩니다.</p>
<p>또는 모든 쿼리들이 유효하지 않아지는 일이 발생할 경우, 모든 쿼리를 무효화할 수도 있습니다.</p>
<pre><code class="language-js">// 모든 캐시된 쿼리를 무효화합니다.
queryClient.invalidateQueries()</code></pre>
<p>invalidateQueries()가 실행될 경우 아래와 같은 일이 발생합니다.</p>
<ul>
<li><p><strong>데이터가 stale하다고 표시</strong></p>
</li>
<li><p><strong>쿼리가 렌더링되고 있을 경우 백그라운드에서 데이터 refetch</strong></p>
</li>
</ul>
<h2 id="vue-query가-알아서-하는-일들">Vue Query가 알아서 하는 일들</h2>
<blockquote>
<p>Out of the box, TanStack Query is configured with aggressive but sane defaults.</p>
</blockquote>
<p>Vue Query는 <strong>공격적이지만 정상적인</strong> 기본값이 설정되어 있다고 설명합니다. 즉, 서버 상태의 효과적인, 효율적인 관리를 위해 적극적으로 개입하는 라이브러리 라는 것입니다.</p>
<p>때문에 Vue Query를 사용하기 위해서는 어떤 부분들을 기본적으로 수행해주는지 필수적으로 알아야 당황하지 않고 Vue Query의 기능을 최대한 활용할 수 있습니다.</p>
<blockquote>
<p>useQuery 또는 useInfiniteQuery를 이용해 생성한 쿼리 인스턴스는 캐시된 데이터를 오래된(stale) 데이터로 간주합니다.</p>
</blockquote>
<p><strong><em>오래된 데이터 → stale queries</em></strong></p>
<ul>
<li><p>staleTime을 전역적으로, 또는 쿼리별로 설정할 수 있습니다.</p>
</li>
<li><p>staleTime을 길게 설정하면 쿼리 데이터를 자주 가져오지 않습니다.</p>
</li>
</ul>
<blockquote>
<p>오래된 데이터(stale queries)는 다음과 같은 상황에 백그라운드에서 자동적으로 refetch됩니다.</p>
</blockquote>
<p>자동적으로 refetch되는 조건</p>
<ul>
<li><p><strong>새로운 query가 마운트될 때 - New instances of the query mount</strong></p>
</li>
<li><p><strong>윈도우가 다시 focus될 때 - The window is refocused</strong></p>
</li>
<li><p><strong>네트워크가 재연결 되었을 때 - The network is reconnected</strong></p>
</li>
<li><p><strong>Refetch 주기가 설정되어 있을 때 - The query is optionally configured with a refetch interval</strong></p>
</li>
</ul>
<p>위 조건들은 각각 다음과 같은 옵션으로 사용자화 할 수 있습니다.</p>
<ul>
<li><p><code>refetchOnMount</code></p>
</li>
<li><p><code>refetchOnWindowFocus</code></p>
</li>
<li><p><code>refetchOnReconnect</code></p>
</li>
<li><p><code>refetchInterval</code></p>
</li>
</ul>
<p>예상치 못한 refetch가 너무 자주 일어난다면 <strong>“윈도우가 다시 focus될 때”</strong>조건을 의심해봐야 합니다.</p>
<blockquote>
<p>쿼리의 활성 인스턴스가 없을 경우 비활성(inactive) 레이블이 지정되며, 나중 사용을 대비해 캐시에 남아있습니다.</p>
</blockquote>
<ul>
<li><p>비활성(inactive) 쿼리들은 기본적으로 5분 이후 garbage collect됩니다.</p>
</li>
<li><p>cacheTime설정을 변경하면 이 시간을 수정할 수 있습니다.</p>
</li>
</ul>
<blockquote>
<p>실패한 쿼리는 기하급수적인 백오프 지연(exponential backoff delay)을 사용해 자동으로 3번 재시도합니다.</p>
</blockquote>
<ul>
<li><p>retry 또는 retryDelay설정값을 변경할 수 있습니다.</p>
</li>
<li><p>기본 시도 횟수 3 을 수정할 수 있고, 백오프 함수도 수정 가능합니다.</p>
</li>
</ul>
<blockquote>
<p>쿼리 결과는 구조적으로 공유되어(structurally shared) 데이터가 실제로 변경되었는지 감지합니다. 변경되지 않은 데이터는 참조된 변수값도 변경하지 않아 최적화에 도움을 줍니다.</p>
</blockquote>
<ul>
<li><p>낯선 개념이지만 99.9%의 상황에서 도움이 되며 비용이 들지 않고, 앱의 성능을 향상할 수 있다고 말합니다.</p>
</li>
<li><p>JSON 형식의 데이터만 지원됩니다.</p>
</li>
<li><p>만약 매우 큰 응답값으로 인해 성능 문제가 발생한다면 structuralSharing 을 비활성화할 수 있습니다.</p>
</li>
</ul>
<h2 id="뷰쿼리를-사용해-해결된-것들">뷰쿼리를 사용해 해결된 것들</h2>
<ul>
<li><p><strong>간단한 코드, fetch 코드의 스타일 통일화</strong></p>
<ul>
<li><p>대부분의 상황에서 알아서 server state를 관리합니다.</p>
</li>
<li><p>간단하고 명확한 메서드를 제공합니다.</p>
</li>
<li><p>fetch코드가 매우 단순하고, 가독성이 높아집니다.</p>
</li>
<li><p>개발자마다 각기 다른 fetch 코드 스타일을 공통화해줍니다.</p>
</li>
</ul>
</li>
<li><p><strong>기본 제공되는 손쉬운 캐싱, 다양한 라이프사이클에 대한 유기적인 대응</strong></p>
<ul>
<li><p>자동으로 서버 데이터를 캐싱해줍니다.</p>
</li>
<li><p>다양한 메서드를 이용해 여러 상황의 라이프사이클에 대응합니다.</p>
</li>
<li><p>필요한 경우 상세한 사용자 정의를 통해 쿼리를 관리할 수 있습니다.</p>
</li>
</ul>
</li>
<li><p><strong>fetch 코드의 재사용성 증가</strong></p>
<ul>
<li><p>전역적으로 관리되기때문에 굳이 캐싱을 위해 store를 거칠 필요가 없습니다. </p>
</li>
<li><p>컴포저블로 useQuery를 사용하면 재사용성이 높은 코드를 작성할 수 있습니다.</p>
</li>
</ul>
</li>
</ul>
<p>읽어보면 좋을 글
공식문서가 매우 친절합니다. 공식문서에 쓰여진 대부분의 내용은 사용하는데 큰 도움이 됩니다.
<a href="https://tanstack.com/query/v4/docs/framework/vue/overview">Overview | TanStack Query Docs</a></p>
<p>큰 프로젝트에서 Query Key를 효과적으로 관리하기 위한 전략은 필수적입니다. Query Key가 중복될 위험이 있기 때문입니다.
<a href="https://tkdodo.eu/blog/effective-react-query-keys">Effective React Query Keys</a></p>
<p>공식 문서에서 중요한 개념과 내용들을 간단하게 정리해 놓은 글입니다.
<a href="https://velog.io/@han-byul-yang/tanstack-query-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EC%A0%95%EB%A6%AC">React Query 공식 문서 간단 정리</a></p>
<p>Tanstack Query에 대한 자세한 내용들을 한국어로 정리해 놓은 문서입니다.
<a href="https://github.com/ssi02014/react-query-tutorial">ssi02014/react-query-tutorial: 😃 TanStack Query(aka. react query) 에서 자주 사용되는 개념 정리</a></p>
<p>카카오페이에서 React Query를 도입한 이유를 작성한 글입니다.
<a href="https://tech.kakaopay.com/post/react-query-1/#-api-%EC%9A%94%EC%B2%AD-%EC%88%98%ED%96%89%EC%9D%84-%EC%9C%84%ED%95%9C-%EA%B7%9C%EA%B2%A9%ED%99%94%EB%90%9C-%EB%B0%A9%EC%8B%9D-%EB%B6%80%EC%9E%AC">카카오페이 프론트엔드 개발자들이 React Query를 선택한 이유</a></p>
<h2 id="참고-자료">참고 자료</h2>
<p><a href="https://vuejs.org/guide/scaling-up/state-management.html">https://vuejs.org/guide/scaling-up/state-management.html</a></p>
<p><a href="https://simplevue.gitbook.io/intro/03.-state-props">https://simplevue.gitbook.io/intro/03.-state-props</a> </p>
<p><a href="https://velog.io/@peterhyun1234/Frontend-Development-What-is-State-Management">https://velog.io/@peterhyun1234/Frontend-Development-What-is-State-Management</a> </p>
<p><a href="https://tanstack.com/query/v4/docs/vue/overview">https://tanstack.com/query/v4/docs/vue/overview</a> </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[State management와 TanStack Query - 1]]></title>
            <link>https://velog.io/@minu-j/State-management%EC%99%80-TanStack-Query-1</link>
            <guid>https://velog.io/@minu-j/State-management%EC%99%80-TanStack-Query-1</guid>
            <pubDate>Wed, 27 Mar 2024 16:37:08 GMT</pubDate>
            <description><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><strong>서버에서 불러온 데이터(Server state)</strong>는 <strong>사용자의 데이터(Client state)</strong>와 별도로 관리하는 것이 세계적인 프론트엔드 추세이며, <strong>TanStack Query</strong>는 Server state management의 표준으로 자리잡은 라이브러리이다.</p>
<p><strong>TanStack Query의 장점</strong></p>
<ol>
<li><p>공통화되고, 선언적인 API 요청 관련 코드를 간편하게 작성할 수 있다.</p>
</li>
<li><p>QueryKey를 이용하여 서버 데이터를 전역적으로 캐싱해 불필요한 API 호출을 최소화한다.</p>
</li>
<li><p>예측 가능한 적절한 타이밍에 서버 데이터를 업데이트(refetch)할 수 있다.</p>
</li>
</ol>
<h2 id="작성-배경">작성 배경</h2>
<p>State에 대한 기본 개념 정리부터, Client state, Server state를 구분하는 프론트엔드의 추세, Server state를 효과적으로 관리하는 라이브러리인 TanStack Query에 대해 알아봤습니다.</p>
<p>State에 대한 관리가 고도화되는 흐름에 맞춰 각 방식의 문제점과, 해결 방안, 그리고 서버 상태 관리 라이브러리의 필요성을 중점으로 정리했습니다.</p>
<p>Tanstack 공식문서의 Vue Query v4 문서를 기준으로 작성했습니다.</p>
<h2 id="state-management">State management</h2>
<h3 id="state">State</h3>
<blockquote>
<p>React: 컴포넌트 안에서 관리되며 변경되면 컴포넌트를 리렌더링하는 JavaScript 객체</p>
</blockquote>
<blockquote>
<p>Vue: 앱 구동에 필요한 기본 데이터 소스.</p>
</blockquote>
<p>특정 DOM만 사용자에게 다시 보여주는 SPA에서 화면을 업데이트 하는 조건은 <strong>“사용자에게 표시할 화면의 ‘데이터’가 변화되었는가?”</strong>입니다. 사용자에게 보여줄 화면의 데이터가 바뀌면 당연히 화면을 새로 그려야 하기 때문입니다. Single page 애플리케이션은 이 필요때문에 데이터, 즉 state가 변하면 화면을 새로 그릴 수 있도록(re-render) 설계되어 있고, 변화가 필요한 특정 DOM에 데이터를 Bind해 변화되는 데이터를 페이지 새로고침 없이 사용자에게 보여줍니다.</p>
<p>다루는 데이터가 얼마 되지 않는 애플리케이션의 state는 물론 신경쓰지 않고 편한대로 관리할 수 있겠지만, 화면에 보여줘야 하는 데이터의 종류가 매우 다양하고, 데이터의 라이프 사이클이 제각기 다르기 때문에 <strong>state의 철저한 관리</strong>는 필수적입니다.</p>
<h2 id="상태-관리">상태 관리</h2>
<p>State는 화면을 렌더링한다는 특징을 제외하면, 결국 JavaScript의 객체 변수이기 때문에 제대로된 관리가 없다면 손실되거나 사라져버릴 수 있고, 컴포넌트 간 공유도 복잡하고 어렵습니다. <strong>특히, State의 변화는 화면의 렌더링으로 이어진다는 특성상, State의 무분별한 관리는 브라우저 성능에도 큰 영향을 미칩니다.</strong> 필요한 화면의 일부만 교체하며 화면을 표시해 성능을 개선하는 가상DOM의 이점을 전혀 활용하지 못할 것입니다.</p>
<h3 id="state의-철저한-관리가-필요한-이유">State의 철저한 관리가 필요한 이유</h3>
<ul>
<li><p>사용자에게 필요한 데이터를 적절하게 유지하고 관리해야 합니다.</p>
</li>
<li><p>State는 화면을 렌더링하는 성능에 영향을 미칩니다.</p>
</li>
<li><p>민감한 데이터의 경우 보안 문제가 생길 수 있습니다.</p>
</li>
<li><p>코드의 유지보수를 위해서는 데이터의 흐름을 파악할 수 있어야 합니다.</p>
</li>
</ul>
<h3 id="vue에서의-상태-관리">Vue에서의 상태 관리</h3>
<p>Vue에서는 데이터의 변화를 감지하기 위해 상태를 ‘반응형’ 데이터로 관리합니다. 반응형 상태 선언을 위해 권장되는 형태는 ref() 함수를 사용하는 것입니다.</p>
<pre><code class="language-ts">const age = ref(&#39;27&#39;)</code></pre>
<p>2024년으로 해가 바뀌어서 나이를 먹었다면, 다음과 같이 상태를 변경하면 화면을 리렌더링 합니다.</p>
<pre><code class="language-ts">age.value++</code></pre>
<p>state는 Vue의 <code>&lt;script setup /&gt;</code> 태그 내에서 작성되어 화면을 리렌더링 하는 조건이 됩니다.</p>
<p>그런데 이 경우 state는 해당 state가 선언 된 특정 .vue파일 내부 스코프에서만 유효합니다. 다른 컴포넌트에서 데이터에 접근하려면 props를 명시적으로 선언해야 하고, 외부에서 컴포넌트에 props를 넘겨 데이터를 공유합니다.</p>
<pre><code class="language-ts">&lt;script setup&gt;
const { age } = defineProps&lt;{ age: number }&gt;()

console.log(age) // 27
&lt;/script&gt;</code></pre>
<h3 id="문제점">문제점</h3>
<p>그런데 문제가 있습니다. props는 무조건 컴포넌트 내부에 선언된 컴포넌트에만 데이터를 넘겨줄 수 있다는 것입니다.</p>
<p><img src="https://react.bootcss.com/images/docs/sketches/s_prop-drilling.png" alt="prop drilling">
<em>출처: Passing Data Deeply with Context</em> </p>
<p>만약 상하 컴포넌트 구조가 매우 복잡한 프로젝트라면 데이터를 하위 컴포넌트에 직접 넘겨주는 props로는 데이터를 전달하는 데 무리가 있습니다.</p>
<p>여러 컴포넌트를 건너 데이터가 전달되는 구조에서</p>
<ul>
<li><p>**데이터의 흐름이 어떻게 관리되고 있는지</p>
</li>
<li><p>*</p>
</li>
<li><p>**State 변경으로 인한 컴포넌트 리렌더링이 어떻게 일어나는지</p>
</li>
<li><p>*</p>
</li>
<li><p>**데이터가 유실되거나, 함부로 변경되지 않고 있는지</p>
</li>
<li><p>*</p>
</li>
</ul>
<p>개발자는 예측하기 매우 어렵습니다.</p>
<h3 id="local-state와-global-state">Local state와 Global state</h3>
<p>Global state는 이런 props의 단점을 극복하기 위해 등장한 개념입니다. 기존의 state는 이제 <strong>local state(지역 상태)</strong>와 <strong>global state(전역 상태)</strong>로 구분됩니다.</p>
<ul>
<li><p><strong>Local state</strong>: 컴포넌트 내부에서, 해당 컴포넌트를 리렌더링하는 state</p>
</li>
<li><p><strong>Global state</strong>: 여러 컴포넌트에서 전역적으로 호출되며, 부모-자식 관계와 상관 없이 컴포넌트를 리렌더링하는 state</p>
</li>
</ul>
<p>그리고 일반적으로 global state는 관리의 편의를 위해 라이브러리를 이용합니다.</p>
<h3 id="전역적인-상태-관리가-필요한-이유">전역적인 상태 관리가 필요한 이유</h3>
<ol>
<li><p><strong>데이터 지속</strong>: 새로고침되어 자바스크립트 변수가 새로 선언되지 않는 이상, 컴포넌트와 무관하게 데이터를 유지합니다.</p>
</li>
<li><p><strong>컴포넌트 간 소통</strong>: 부모-자식 관계와 무관하게 전역적으로 컴포넌트 간 데이터 소통을 할 수 있습니다. 변경된 데이터는 의존관계를 갖지 않는 컴포넌트들을 리렌더링 하게 합니다.</p>
</li>
<li><p><strong>예측 가능성</strong>: 위에서 설명한 데이터의 흐름과 컴포넌트의 리렌더링, 데이터의 손상을 예측 가능하도록 합니다.</p>
</li>
</ol>
<h3 id="문제점-1">문제점</h3>
<p>State에는 완전히 다른 라이프사이클을 가지는 두 종류의 데이터가 있습니다.</p>
<ul>
<li><p><strong>사용자가 브라우저에서 직접 만들어내거나 편집하는 데이터</strong></p>
</li>
<li><p><strong>서버에서 fetch해 가져오는 데이터</strong></p>
</li>
</ul>
<p>이 두 가지의 데이터를 지금처럼 혼합해 관리한다면 문제가 발생합니다.</p>
<p><strong>사용자의 데이터, 서버 데이터 둘 중 하나만 변경되어도 바인딩된 컴포넌트들이 전부 리렌더링 됩니다.</strong></p>
<p>계속 말씀드렸듯이 불필요한 렌더링은 성능 문제를 일으키기 때문에 항상 경계해야 합니다.</p>
<p><strong>서버 데이터는 비효율적으로 여러번 호출될 수 있습니다.</strong></p>
<p>일반적으로 데이터가 필요해 data fetch 함수가 동작되는 상황은 컴포넌트가 마운트될 때입니다. 따라서 다음과 같은 코드를 작성할 수 있습니다.</p>
<pre><code class="language-ts">onMounted(async () =&gt; {
    await axios.get(...); // 컴포넌트가 마운트될 때 서버 데이터를 요청합니다.
});</code></pre>
<p>하지만, 다음과 같은 경우를 생각해볼 수 있습니다.</p>
<ul>
<li><p><strong>데이터를 가져온지 몇 초만에, 또 다른 컴포넌트가 동일한 데이터를 요청한다면?</strong></p>
</li>
<li><p><strong>여러 자식 컴포넌트에서 동일한 데이터를 요청한다면?</strong></p>
</li>
<li><p><strong>별로 자주 변하지도 않는 데이터인데 컴포넌트 마운트가 수시로 발생한다면?</strong></p>
</li>
</ul>
<p>불필요한 API 요청이 자주 발생할 수 밖에 없습니다.</p>
<p>예를 들어 특정 Table 컴포넌트에서 페이지를 수시로 변경한다고 생각해보겠습니다.</p>
<pre><code class="language-ts">const onChangePage = async () =&gt; {
    await axios.get(...); // Table의 page가 변경될 때 서버 데이터를 요청합니다.
};</code></pre>
<p>Table의 페이지를 2페이지로 넘겼다가 1초만에 금방 다시 1페이지로 돌아왔을 때, 방금 막 불러왔던 1페이지를 또 불러와야 합니다.</p>
<p>불필요한 API 요청 자체도 문제이지만, 데이터를 자바스크립트에서 효율적으로 캐싱할 수 있다면 데이터를 매우 빠르게 화면에 표시할 수 있으므로 사용자 경험에 긍정적인 영향을 미칠 것입니다.</p>
<h3 id="client-state와-server-state">Client state와 Server state</h3>
<p>위 같은 문제를 해결하기 위해 지금까지 local state와 global state라고만 구분되었던, 사용자가 브라우저에서 만들어낸 데이터들은 client state(클라이언트 상태)로, 서버에서 불러온 데이터는 server state(서버 상태)로 구분합니다.</p>
<ul>
<li><p><strong>Client state</strong>: 사용자가 생성, 편집한 데이터</p>
</li>
<li><p><strong>Server state</strong>: 서버에 요청해 가져온 데이터</p>
</li>
</ul>
<p>그렇다면 Global state에 서버 데이터를 분리해 저장하며, 데이터를 캐싱한다면 문제가 해결될까요?</p>
<pre><code class="language-ts">export const useCacheStore = defineStore(&#39;cache&#39;, () =&gt; {
  // state: 필요한 데이터
    const data = ref()

    // getter: 캐시된 데이터를 가져오는 함수
    async function getCachedData() {
        // 데이터가 저장되어있지 않는 경우에만 서버에 데이터를 요청합니다.
        if (!data.value) {
            await fetchData()
        }
        return data.value
    }

    // action: API data fetch 함수
  async function fetchData() {
    data.value = await axios.get(...); // 일단 error는 고려하지 않겠습니다.
  }

  return { data, getData }
})</code></pre>
<p>이렇게 되면 캐싱되어있는 데이터를 잘 불러올 수 있을 것입니다.</p>
<p>하지만 <strong>state에 캐싱된 data와 현재 서버의 data가 항상 일치한다는 보장이 없으므로</strong> 위 코드는 문제가 됩니다.</p>
<p>캐싱을 한다 하더라도, 캐시가 최신의 서버 데이터와 일치하는지 구분하기 어렵습니다. <strong>캐시는 언제든 유효하지 않은(invalidate), 이전 버전의 데이터일 수 있습니다.</strong></p>
<p>그렇다면 특정 상황에 데이터를 다시 불러오는(refetch) 상황을 구분해줘야 합니다. 하지만 다양한 API와, 복잡한 컴포넌트를 가지고 있다면 아래와 같은 상황들을 구분하며 refetch하는 로직을 개발하기엔 각각의 함수 하나 하나를 개발하는데 매우 많은 노력이 들어갑니다.</p>
<p>다음과 같은 상황들을 구분하며 데이터 refetch 주기를 정하기 쉽지 않습니다.</p>
<ul>
<li><p><strong>컴포넌트가 다시 마운트되면 데이터를 다시 불러와야 할까요?</strong></p>
</li>
<li><p><strong>input이나 button 태그에 focus될 때 데이터를 다시 불러와야 할까요?</strong></p>
</li>
<li><p><strong>네트워크가 장애로 인해 끊겼다가 다시 연결되었을 때 데이터를 다시 불러와야 할까요?</strong></p>
</li>
<li><p><strong>혹은 5분마다? 10분마다? 데이터가 만료되어 다시 불러와야 하는 주기를 어느정도로 해야 할까요?</strong> </p>
</li>
</ul>
<p>아무튼, 실력 좋은 개발자들이 API 요청에 대해 위에서 제시된 내용을 모두 만족하는 코드를 짠다고 가정해보겠습니다. 개발자들은 저마다의 방식으로 복잡한 구현방식을 만족하는 store를 생성할 것입니다.</p>
<p>Loading 또는 API 요청 성공, 실패를 구분하는 status 값이 필요하다면?</p>
<p><code>isLoading()</code>? 또는 <code>isError()</code>? boolean값으로 status를 반환하는 코드를 만드는 개발자도 있을 것이고, <code>status: ‘loading’ | ‘success’ | ‘error’</code> 상태값에 대한 <code>string</code>을 반환하는 코드를 만드는 개발자도 있을 것입니다. 모든 코드를 다 만드는 개발자도 물론 있을 것입니다.</p>
<pre><code class="language-ts">interface apiStatus {
    isLoading: boolean;
}
// or
interface apiStatus {
    status: ‘loading’ | ‘success’ | ‘error’;
}</code></pre>
<p>또 error에 대한 상태값 처리를 할 때도 <code>try-catch</code>문을 이용하는 개발자도 있을 것이고, <code>then-catch</code>문을 이용하는 경우가 있을 것이고, <code>finally</code>문을 사용할지, 예외처리 바깥에 특정 코드를 실행할지…</p>
<p>구현 방법이 매우 복잡하고, 수많은 API를 위한 Pinia store들을 통일된 문법으로 관리하는 것은 너무 어렵습니다.</p>
<p>Server state의 관리를 간편하게 하기 위해 개발된 라이브러리가 바로 Tanstack에서 개발한 Vue Query입니다.</p>
<p>계속...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[서버 비용 0원, 300만 조회수 게임 만들기 : 집단지성 오목 게임 Kibitz bugs]]></title>
            <link>https://velog.io/@minu-j/Kibitz-bugs</link>
            <guid>https://velog.io/@minu-j/Kibitz-bugs</guid>
            <pubDate>Sat, 02 Mar 2024 18:37:58 GMT</pubDate>
            <description><![CDATA[<p>2024년 2월 27일부 트위치가 한국 시장을 철수한 기념으로,
Kibitz bugs 프로젝트를 돌아보며 회고 글을 써보고자 합니다.</p>
<h2 id="kibitz-bugs">Kibitz bugs</h2>
<p><a href="https://kibitz-bugs.xyz">https://kibitz-bugs.xyz</a></p>
<p><strong>Kibitz bugs</strong>는 스트리밍 플랫폼 트위치의 댓글 API를 활용하여 스트리머와 시청자 집단지성 간 오목 대결을 펼치는 게임입니다.
방송을 진행하는 스트리머는 일반적인 오목 게임을 하듯 수를 두고, 시청자 모두가 댓글로 좌표를 입력해 집단지성으로 좌표를 &#39;선택&#39;합니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/cd8d6c4c-9bdc-4524-ab08-f7f1088e6253/image.png" alt=""></p>
<p>침착맨, 우왁굳, 우주하마, 풍월량, 우정잉 등 유명 트위치 스트리머를 포함한 약 <strong>4,300명 이상</strong>의 트위치 스트리머가 약 <strong>6,300회 이상</strong>의 오목 게임을 플레이했고,</p>
<p>한 방송에서는 <strong>19,000명</strong>이 동시에 접속해도 안정적으로 게임을 플레이했습니다.</p>
<p>방송 영상은 각 스트리머의 유튜브 채널에도 업로드되어 총합 <strong>300만 명</strong>에 가까운 조회수 또한 기록했습니다.</p>
<p>🔗<a href="https://youtube.com/playlist?list=PL5gs1D9-S_9g4hkd-Z14JvIV6zTFS3nZx&amp;si=PyeUAeHny9J56Qv-"><strong>YouTube 재생목록 링크(195개의 영상)</strong></a></p>
<h2 id="기획부터-개발까지">기획부터 개발까지</h2>
<h3 id="쓸만한-프로젝트-만들기">&#39;쓸만한&#39; 프로젝트 만들기</h3>
<p>프로젝트는 백엔드 친구 한 명과 저, 이렇게 두 명이 진행했습니다. 프로젝트를 기획하며 가장 중요하게 생각한 목표는 <strong>&#39;누군가 사용할 프로젝트를 만들자&#39;</strong> 였습니다. 아무리 사이드 프로젝트의 목적이 다양하다고 해도, 사용자가 없다는 전제로 시작한다면 개발하는 과정이 매우 길고 지루할 것입니다. 반대로 기껏 만들었는데 아무도 사용하지 않는다면 그동안 들인 시간과 정성이 너무 아깝겠지요.</p>
<p>사이드 프로젝트를 이미 경험해 보신 분들께서는 잘 아시겠지만, 내가 만든 프로젝트를 누군가 한 명이라도 사용한다는 것은 매우 설레고 특별한 경험입니다. 아무 금전적 이익이 없더라도 누군가 내가 기획하고 개발한 서비스를 사용한다는 것 자체가 행복한 일이지요.</p>
<p><strong>그런 의미에서 스트리머의 컨텐츠 전용 게임개발은 처음부터 전략적이었습니다.</strong></p>
<p><img src="https://miro.medium.com/v2/resize:fit:960/0*JB6K7NDfOTspbS1t.gif" alt=""></p>
<p>수요와 공급의 기본적인 원리를 이용해 펜을 판매하는, 영화 &lt;울프 오브 월스트리트&gt;에서의 인상깊은 장면이 있는데요,
당연하게 프로젝트도 마찬가지로 필요에 의한 개발로 탄생한 제품은 누군가 사용할 수 밖에 없다고 생각했습니다. 물론 기존에 없는 서비스이거나, 기존에 있었어도 기존 제품보다 훨씬 좋아야 하는 것은 당연합니다.
(비상한 아이디어가 아니더라도 더 나은 사용자 경험이나, 디자인으로도 가능하다고 생각하긴 합니다)</p>
<p>그런 의미에서 <strong>Kibitz bugs는 스트리머, 시청자 모두에게 필요한 서비스</strong>였습니다.
스트리머는 콘텐츠가 필요합니다. 하루하루 어떤 콘텐츠로 방송할지 고민을 하는 분들이기 때문입니다. 재밌어보이는 콘텐츠가 있다면 당연히 사용하는 스트리머가 있을 것이라고 생각했습니다.</p>
<p>시청자는 스트리머와 소통을 원합니다. 기존 오목 게임으로 시청자와 스트리머가 오목 게임을 하는 것을 유튜브에서 몇 번 본적이 있는데요, 길어야 두세 시간 하는 방송 중 많아 봤자 기껏 해야 몇 명이 참여할 수 있을까요?</p>
<p>여기서 Kibitz bugs의 아이디어를 얻었는데요, <strong>채팅에 참여한 시청자 모두가 한 번에 게임에 참여한다면 어떤 일이 일어날까?</strong> 라는 생각이었습니다. 간단한 좌표를 입력해서 지루하지 않게 한 판을 끝내기에도 매우 적절했고요. 시청자 참여 콘텐츠라는 특성상 시청자에 의해 자연스럽게 홍보되고, 자연스럽게 많은 사용자를 모을 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/21b3922c-7725-41dc-b039-e86235056907/image.png" alt=""></p>
<ul>
<li><strong>스트리머, 시청자 모두의 니즈에 맞았다.</strong></li>
<li><strong>모두가 룰을 알고 있고, 잠깐 재밌게 할 게임으로 오목이 매우 적절했다.</strong></li>
<li><strong>투표로 좌표를 정하는 방식도 오목판이라는 공간이 너무 딱 맞았다.</strong></li>
</ul>
<p>또한 스트리밍된 영상은 트위치에서만 그치지 않습니다. 그 분야 전문가들에 의해 유튜브에도 재밌게 편집되어 올라가 프로젝트가 널리 널리 퍼질 수 있었습니다. 덕분의 프로젝트의 인지도 측면에서도 유리했습니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/acd4d168-5788-457f-b6fc-6607b95808e7/image.png" alt=""></p>
<h3 id="서버-비용-0원으로-게임-만들기">서버 비용 0원으로 게임 만들기</h3>
<p>사이드프로젝트에서 어쩌면 개발보다 더 중요한 부분은 현실적으로 발생하는 <strong>비용</strong>에 관한 문제입니다. 사용자가 많을수록, 또는 AI처럼 서버가 하는 일이 많아질수록 비용이 만만치 않기 때문입니다.</p>
<p>트위치의 유명 스트리머 몇 명만 동시에 방송해도 수만 명의 동시 시청자가 들어오고, 최악의 상황을 가정하면 1초에 수만 명의 투표를 집계해야 하는 문제가 있었습니다. 과연 백수 두 명의 얇은 지갑으로, 어떻게 트래픽을 감당할 수 있었을까요?</p>
<p>물론 이 문제는 아이디어가 처음 떠올랐을 때 부터 예상한 문제였는데요, Kibitz bugs의 모든 게임 로직은 전부 클라이언트 브라우저에서 처리됩니다. </p>
<p>게임에 접속한 스트리머의 브라우저에서 트위치의 채팅 API로 받아온 채팅 메시지들을 정제하고, 좌표를 종합합니다. 종합된 좌표별 득표수는 부드러운 애니메이션을 위해 0.5초에 한번 쓰로틀링되어 브라우저 화면에 표시됩니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/4b937a6b-9741-4832-96f2-e466a268dc9d/image.gif" alt=""></p>
<p>제일 많이 득표된 좌표에 마치 마우스를 올린 것 처럼 표시해 실제로 모니터 너머에 하나 지성을 가진 사람이 있는 것 같은 모습을 보여줍니다.
게임을 플레이하는 스트리머로부터 <strong>&#39;시청자들이 마치 알파고가 계산하듯이 오목을 둔다&#39;</strong> 라는 말을 정말 많이 들었는데요, 정확히 의도된 인상이었습니다.</p>
<p>게임의 주요 로직을 담당하는 코드들(특히 오목의 승패, 금수 판정 알고리즘) 또한 스트리머의 브라우저에서 전부 돌아갈 수 있도록 자바스크립트로 작성되었습니다.
서버의 비용을 줄이는 것과 동시에 스트리머 특성상 매우 좋은 PC로 게임을 플레이할 것을 가정할 때 성능면에서 부족함이 없을 것이라고 생각했기 때문입니다. 시청자 수가 많을수록 더더욱 좋은 사양의 컴퓨터로 게임을 할테니 계산상 게임 플레이에 성능상 문제가 발생할 일은 없었습니다.</p>
<p>서버 부담을 줄이는 데에는 이미지와 빌드 용량 최적화도 한몫 했습니다. <code>0.1.0-alpha</code>버전에서 유저가 처음 접속하면 약 11.4MB의 리소스를 다운로드했는데요, 최적화 이후 1/10수준인 1.3MB로 줄였습니다.</p>
<p>반년동안 수십만 명의 시청자 투표를 감당한 서버는 AWS의 Free Tier, 서버 비용은 0원입니다. 게임을 운영하며 서버 비용이 전혀 들지 않았어요. 때문에 지저분한 광고를 게임 좌우에 붙이는 일도 없었고, 사용자들은 더 쾌적한 환경에서 게임을 즐길 수 있었습니다.</p>
<h2 id="배포">배포!</h2>
<p>애초에 짧은 기간 집중해서 개발하려고 했던 프로젝트이기 때문에 기본적인 Twitch OAuth 인증과 댓글 수집, 오목 알고리즘과 주요 게임 엔진 개발이 완료되자마자 일단 빠르게 배포를 했습니다.</p>
<p>배포를 한 이후 트위치 시청자의 활동이 매우 활발한 침착맨 커뮤니티(침하하)에 <a href="https://chimhaha.net/new/295156?keyword=%EC%98%A4%EB%AA%A9&amp;page=1&amp;searchType=title">글</a>을 작성해 홍보했고, 좋은 반응으로 단숨에 베스트 게시물에 오르면서 세상에 알려지게 되었습니다. 
특히 스트리머 방송에 참여하는 형식의 게임이다보니 시청자분들의 적극적인 홍보와 추천이 큰 힘이 되었습니다.</p>
<p>전혀 예상하지 못했던 것은, 배포가 된 바로 다음날 새벽, <a href="https://youtu.be/zclW36vPtOU?si=wvfOqI2_MjdyyC7V">우왁굳</a>님이 방송에서 게임을 했다는 점입니다. 아깝게도 잠을 자느라 생방송을 시청하지 못했는데요, 아침에 일어나 보니 우왁굳 팬카페가 오목 관련 글로 뜨거웠습니다. 그 다음날 한번 더 연속으로 게임을 플레이한 덕분에 아직 정식 버전이 개발되기도 전에 게임의 접속자 수가 폭증했습니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/e6f78769-9e91-45ee-8c05-7f1fd3843376/image.png" alt=""></p>
<p>예상치 못한 새벽 방송에서 약 <strong>17,000</strong>명의 동시 시청자가 플레이하며 게임의 안정성을 검증했습니다. 게임 방송을 전문으로 하는 스트리머의 피드백도 매우 긍정적이었는데요, 딜레이가 매우 적고 댓글 반영이 매우 빠릿빠릿하다, 게임을 잘 만들었다는 긍정적인 코멘트를 받았습니다.</p>
<p><a href="https://youtu.be/zclW36vPtOU?si=v3lAWghDEj0dHfVb">👉우왁굳 1차 방송 유튜브</a>
<a href="https://youtu.be/RzqKrBfTWuY?si=EwunM2QKrpJPsDQF">👉우왁굳 2차 방송 유튜브</a></p>
<p><strong>구독자 약 180만</strong> 게임 전문 유튜버이자 트위치 스트리머인 우주하마님의 팬카페에도 한 번 홍보 게시글을 작성했습니다. 정말 재밌는 방송이었는데요,</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/f1707118-a06d-4e14-bf46-0a40713ff06d/image.png" alt=""></p>
<p>특히 선(흑돌)은 육목을 하지 못하는 점을 기회로 살려 승리하는 재미있는 상황도 연출되었는데요, 렌주룰이 완벽히 적용된 오목 룰 판정 알고리즘, 그리고 우주하마님의 높은 오목에 대한 이해도가 돋보이는 장면이었습니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/ec3b1b1d-2de0-4248-a464-35d279cdf176/image.png" alt=""></p>
<p>해당 영상은 유튜브 업로드 이후 뜨거운 반응으로 <strong>인기 급상승 동영상</strong>에도 올랐고, 오늘 기준 약 <strong>110만 조회수</strong>를 달성했습니다.</p>
<p><a href="https://youtu.be/MfN0i6TCRIc?si=JvSFATi89CGw5Vsy">👉우주하마 방송 유튜브</a></p>
<h2 id="모니터링과-피드백">모니터링과 피드백</h2>
<p>스트리머가 Twitch 계정으로 로그인할 때 Telegram bot을 이용해 방송 알림을 받을 수 있도록 모니터링 시스템을 구축했습니다. Telegram bot을 통해 방송 알림이 오면, 웬만하면 거의 모든 방송을 다 챙겨봤습니다. 
실시간 스트리밍으로 방송되는 게임이다 보니 스트리머와 시청자의 생생한 피드백도 얻을 수 있었습니다. 게임 기능에 대한 피드백부터 테마 변경, 다른 게임의 추가 개발 등 부가적인 피드백도 받았습니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/e6803e79-9c47-4e12-81f2-7af0af6ab588/image.png" alt=""></p>
<p>또한 사용자 수 수집을 위해 <strong>Google Analytics</strong>도 사용했습니다. 아쉬운 점은 첫 배포 당시에 GA 없이 배포해서 가장 사용자가 많았던 배포 이후 약 3주 동안의 데이터가 없다는 점입니다.</p>
<p><strong>프로젝트 배포 전 GA는 필수입니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/34ae83e3-7f42-42f3-8bae-d5c35c37bc47/image.png" alt=""></p>
<p>스트리머 한 명만 접속해 플레이하는 게임 특성상 실제 사용자보다 GA에 기록되는 사용자는 매우 적지만, 사용자 추세 정도는 파악할 수 있었습니다.
특히 주목한 점은 재사용자인데요, 게임 스트리머의 방송 특성상 메인 콘텐츠로 게임을 하면 보통 다시 접속하지 않을 것으로 생각했지만 오히려 자주 방문해 주시는 스트리머분들이 꽤 많았습니다. GA에서도 확인할 수 있듯이 재방문율이 예상보다 높았습니다.</p>
<h2 id="후회하며-배우기">후회하며 배우기</h2>
<h3 id="배포-전-기본은-지켰어야-했다">배포 전 기본은 지켰어야 했다.</h3>
<p>핵심적인 게임 기능만 테스트하고 배포를 했기 때문에 첫 배포에서 빠져버린 중요한 기능들이 많았습니다.
게임 플레이에 문제가 될 정도의 문제도 있었는데요, 제한시간 초읽기나 경고가 적극적이지 않아서 시간초과로 싱겁게 게임이 끝나버린다거나, OAuth 인증 과정을 프론트에서 전부 처리해 브라우저 창을 새로고침하면 토큰이 날아가 로그아웃이 되는 등의 문제가 있었습니다.
기본적인 기능이 제대로 개발이 되지 않은 상태에서 너무 많은 사용자가 방문해 곤란해져버린 흔치 않은 경험이었습니다.</p>
<p>물론 이후 업데이트를 통해서 백엔드에서의 Authorization Code Grant로 OAuth 표준 인증 절차를 진행하고, JWT로 발급된 Refresh Token과 Access Token을 이용해 보안 측면에서 안전한 로그인 유지 기능을 구현했습니다.</p>
<p>GA를 적용하지 않아 처음부터 제대로 된 이용자 수 모니터링을 하지 못했고, 트위치 게임 카테고리에 게임 등록도 되지 않아 많은 시청자가 시청할 때 트위치 메인 화면에 게임이 노출될 기회도 여러번 놓쳤습니다.</p>
<h3 id="최대한-많은-데이터를-모았어야-했다">최대한 많은 데이터를 모았어야 했다.</h3>
<p>첫 배포 버전부터 게임 플레이 데이터를 일부 백엔드에 전송하긴 했습니다. 하지만 언제 어떻게 게임 플레이 데이터가 쓰일지 모른다는 점에서 최대한 많은 정보를 DB에 저장을 해뒀어야 했다는 아쉬움이 있습니다.(개인정보와 관련된 민감한 정보를 제외하고)</p>
<p>특히 현재 시청자 수가 몇 명인지, 한 게임에서 실제 채팅으로 참여하는 시청자의 수 또는 비율은 얼마나 되는지, 투표의 의견이 어떤 상황에서 얼마나 많이 갈리는지 등...
재밌게 활용될 수 있는 통계 데이터를 하나도 저장하지 않은 것을 후회하고 있습니다.</p>
<p>처음부터 데이터 수집에 잘 신경 썼다면 &lt;참여형 오목 게임을 통해 알아본 집단 지성 심리 연구&gt; 같은 논문이라도 한 편 쓸 수 있지 않았을까요? 데이터 수집과 분석의 중요성을 느꼈습니다.</p>
<h3 id="나중에도-볼-수-있는-코드를-작성했어야-했다">나중에도 볼 수 있는 코드를 작성했어야 했다.</h3>
<p>이건 매우 부끄러운 고해성사인데요,
짧은 프로젝트 기간, 친구인 백엔드 개발자 한 명과 저 두 명이 개발을 하다보니 큰 실수를 했습니다. 프론트엔드 개발을 혼자 진행한다는 점에서 유지보수를 고려하지 않은 코드들을 작성했기 때문입니다.</p>
<p>물론 덕분에 빠른 개발이 가능했다는 장점도 있긴 했습니다. 또 어차피 프론트엔드 개발을 혼자 진행했기 때문에 저만 알아볼 수 있는 코드를 작성하면 문제가 되지 않을 것이라고 생각했고요. 하지만 매우 큰 오산이었습니다. 프로젝트가 끝나고 단 몇 주가 지났는데도 제가 제 코드를 알아보기 힘들었습니다. 덕분에 리팩토링과 추가 기능 개발에서 크게 애를 먹었습니다.</p>
<p>특히 비즈니스 로직의 모듈화(Hook과 Util Function 등)와 구조 설계의 중요성을 느꼈습니다. 로직의 분리 없이 화면과 관련된(View) 코드와 여기저기 엉켜있다 보니 하나의 정교한 톱니바퀴 같은 코드가 되어버렸습니다. 추가 개발을 하려고 한 줄만 코드를 건드려도 모래성처럼 와르르 무너져 버릴 것 같았습니다.</p>
<p>단일 책임 원칙과도 결이 닿는 이야기일텐데요, 개인적으로 모든 상황에서 하나의 함수가 한 가지 동작만을 해야한다고 생각하지는 않습니다. 하지만 나중을 고려한 개발은 반드시 필요하다는 것을 느꼈습니다. 특히 추가 개발이 수시로 발생하거나, 공동 작업이 활발하게 진행되는 프로젝트라면 더욱 말이죠.</p>
<h2 id="앞으로의-계획과-목표">앞으로의 계획과 목표</h2>
<p>지금도 사이드 프로젝트를 하나 하고 있는데, 이번엔 크기가 좀 커서 장기간 기획과 디자인을 하는 중입니다. 게임과 관련된 건 아닙니다.</p>
<p>물론 게임 관련된 아이디어도 몇 개 있습니다. 기획하다가 포기한 아이디어도 여러 개 되고요. 프로젝트를 여러 개 병행할 수 없다 보니 시간이 없어서 못한다는 핑계를 대는 중입니다.</p>
<p>장기적인 목표라고 한다면,
<strong>기획자, 디자이너와 말이 잘 통하는 개발자가 되는 것이 목표입니다.</strong></p>
<p>개인적으로 프론트엔드 개발의 가장 큰 매력은 <strong>사용자와 아주 가깝다</strong>는 점인데요, 사용자와 가깝기 위해서는 그 어떤 분야의 개발자보다 기획과 디자인을 잘 알아야 한다고 생각합니다.
조금 오바하면 지금 당장 이직을 해도 기획자 또는 디자이너 일을 할 수 있을 정도로요. 물론 직업은 개발자이다보니 이런 분야에 전문적이진 않겠지만, 그만큼 많이 알고 관심을 가져야 한다는 뜻입니다.</p>
<p>그런 의미에서 사이드 프로젝트는 매우 필요합니다!</p>
<p>회사 일은 내 맘대로 못하지만, 내가 기획하고 내가 디자인한 사이드 프로젝트는 내 맘대로 할 수 있거든요! 사소한 것 하나에 디테일한 고민을 하는 경험을 매일매일 할 수 있습니다.</p>
<hr>
<p><strong>정민우</strong>
minu.j.dev@gmail.com
<a href="https://github.com/minu-j">@minu-j</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[운영체제] 만화로 알아보는 은행원 알고리즘(Banker's algorithm) (교착상태 회피 알고리즘)]]></title>
            <link>https://velog.io/@minu-j/%EC%9A%B4%EC%98%81%EC%B2%B4%EC%A0%9C-%EB%A7%8C%ED%99%94%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%9D%80%ED%96%89%EC%9B%90-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B5%90%EC%B0%A9%EC%83%81%ED%83%9C-%ED%9A%8C%ED%94%BC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</link>
            <guid>https://velog.io/@minu-j/%EC%9A%B4%EC%98%81%EC%B2%B4%EC%A0%9C-%EB%A7%8C%ED%99%94%EB%A1%9C-%EC%95%8C%EC%95%84%EB%B3%B4%EB%8A%94-%EC%9D%80%ED%96%89%EC%9B%90-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EA%B5%90%EC%B0%A9%EC%83%81%ED%83%9C-%ED%9A%8C%ED%94%BC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98</guid>
            <pubDate>Sun, 27 Aug 2023 16:06:09 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/71f8644c-40ea-488c-9654-b3c1be2518d0/image.jpg" alt=""></p>
<p>교착상태 회피를 구현하는 방법 중 하나인 에츠허르 데이크스트라가 제시한 <strong>은행원 알고리즘(banker&#39;s algorithm)</strong>의 예시.</p>
<p>교착 상태는 프로세스가 서로 자원을 점유하려고 하는 과정에서 아래 네 가지 필요조건이 동시에 충족될 경우 발생합니다.</p>
<ol>
<li><strong>상호 배제</strong> : 한 프로세스가 사용하는 자원은 다른 프로세스와 공유할 수 없는 배타적인 자원입니다.</li>
<li><strong>비선점</strong> : 한 프로세스가 사용하는 자원은 다른 프로세스가 빼앗을 수 없습니다.</li>
<li><strong>점유와 대기</strong> : 프로세스는 어떤 자원을 할당받은 상태에서 다른 자원을 기다리는 상태입니다.</li>
<li><strong>원형 대기</strong> : 점유와 대기를 하는 프로세스 간 사이클이 발생합니다.</li>
</ol>
<p>교착상태가 발생할 경우 해결 방법은 세 가지입니다.</p>
<ol>
<li><strong>예방</strong></li>
<li><strong>회피</strong></li>
<li><strong>검출과 회복</strong></li>
</ol>
<p>이 중 은행원 알고리즘은 교착상태 회피를 위한 알고리즘 중 하나로, 다익스트라 알고리즘으로 유명한 에츠허르 데이크스트라가 제시했습니다.</p>
<p>시스템 교착을 일으키지 않고 각 프로세스가 요구한 양 만큼의 자원을 할당해줄 수 있는 순서를 <code>안전순서열</code>이라고 하며, 안전순서열이 존재하는 상태를 <strong>안전상태</strong>, 존재하지 않는 상태를 <strong>불안전상태</strong>라고 합니다.</p>
<hr>
<p>참고</p>
<ul>
<li>쉽게 배우는 운영체제(조성호 저)</li>
<li><a href="https://jhnyang.tistory.com/102">https://jhnyang.tistory.com/102</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 안드로이드 빌드시 Execution failed for task ':app:checkDebugDuplicateClasses'. 오류 해결]]></title>
            <link>https://velog.io/@minu-j/Flutter-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EB%B9%8C%EB%93%9C%EC%8B%9C-Execution-failed-for-task-appcheckDebugDuplicateClasses.-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@minu-j/Flutter-%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EB%B9%8C%EB%93%9C%EC%8B%9C-Execution-failed-for-task-appcheckDebugDuplicateClasses.-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Thu, 24 Aug 2023 02:43:56 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/5854026c-639b-46c0-bd79-f278b97b55cd/image.png" alt=""></p>
<h3 id="상황">상황</h3>
<p>iOS 앱 빌드는 문제가 없으나 안드로이드 앱 빌드시 <code>:app:checkDebugDuplicateClasses</code> 오류 발생</p>
<h3 id="에러-메시지">에러 메시지</h3>
<pre><code>Launching lib/main.dart on Android SDK built for arm64 in debug mode...
Running Gradle task &#39;assembleDebug&#39;...

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task &#39;:app:checkDebugDuplicateClasses&#39;.
&gt; A failure occurred while executing com.android.build.gradle.internal.tasks.CheckDuplicatesRunnable
   &gt; Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.internal.jdk7.JDK7PlatformImplementations found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.internal.jdk7.JDK7PlatformImplementations$ReflectSdkVersion found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.internal.jdk8.JDK8PlatformImplementations found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.internal.jdk8.JDK8PlatformImplementations$ReflectSdkVersion found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.io.path.ExperimentalPathApi found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.io.path.PathRelativizer found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.io.path.PathsKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.io.path.PathsKt__PathReadWriteKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.io.path.PathsKt__PathUtilsKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.jdk7.AutoCloseableKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk7-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10)
     Duplicate class kotlin.jvm.jdk8.JvmRepeatableKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.jvm.optionals.OptionalsKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.random.jdk8.PlatformThreadLocalRandom found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.streams.jdk8.StreamsKt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$1 found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$2 found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$3 found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.streams.jdk8.StreamsKt$asSequence$$inlined$Sequence$4 found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.text.jdk8.RegexExtensionsJDK8Kt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)
     Duplicate class kotlin.time.jdk8.DurationConversionsJDK8Kt found in modules jetified-kotlin-stdlib-1.8.10 (org.jetbrains.kotlin:kotlin-stdlib:1.8.10) and jetified-kotlin-stdlib-jdk8-1.7.10 (org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10)

     Go to the documentation to learn how to &lt;a href=&quot;d.android.com/r/tools/classpath-sync-errors&quot;&gt;Fix dependency resolution errors&lt;/a&gt;.

* Try:
&gt; Run with --stacktrace option to get the stack trace.
&gt; Run with --info or --debug option to get more log output.
&gt; Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 5s
Exception: Gradle task assembleDebug failed with exit code 1</code></pre><h3 id="해결방법">해결방법</h3>
<p>android/build.gradle의 kotlin version 수정</p>
<p>from</p>
<pre><code>buildscript {
    ext.kotlin_version = &#39;1.7.10&#39;
    ...</code></pre><p>to</p>
<pre><code>buildscript {
    ext.kotlin_version = &#39;1.8.10&#39;
    ...</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[백준] 1239 차트 2%에서 틀렸습니다 반례]]></title>
            <link>https://velog.io/@minu-j/%EB%B0%B1%EC%A4%80-1239-%EC%B0%A8%ED%8A%B8-2%EC%97%90%EC%84%9C-%ED%8B%80%EB%A0%B8%EC%8A%B5%EB%8B%88%EB%8B%A4-%EB%B0%98%EB%A1%80</link>
            <guid>https://velog.io/@minu-j/%EB%B0%B1%EC%A4%80-1239-%EC%B0%A8%ED%8A%B8-2%EC%97%90%EC%84%9C-%ED%8B%80%EB%A0%B8%EC%8A%B5%EB%8B%88%EB%8B%A4-%EB%B0%98%EB%A1%80</guid>
            <pubDate>Fri, 30 Jun 2023 05:41:08 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/291b9299-4aea-431a-b2e9-4d1d1145649d/image.png" alt=""></p>
<blockquote>
<p>백준 1239번 차트
<a href="https://www.acmicpc.net/problem/1239">https://www.acmicpc.net/problem/1239</a></p>
</blockquote>
<p>백준 1239 차트 문제에서 테스트케이스는 전부 통과되나 2%에서 오답이 발생했다.</p>
<p>문제의 코드는</p>
<pre><code class="language-python">from itertools import permutations
from collections import deque

N = int(input())
data = list(map(int, input().split()))
ans = 0

charts= list(permutations(data, N))

visited = set()
for chart in charts:
    if chart not in visited:
        chart_q = deque(chart)
        line_count = 0
        for i in range(N):
            chart_q_copy = chart_q.copy()
            left_sum = 0
            for j in range(N):
                left_sum += chart_q_copy.popleft()
                if left_sum == 50:
                    line_count += 1
                    break
                elif left_sum &gt; 50:
                    break
            left = chart_q.popleft()
            chart_q.append(left)
        if line_count &gt; ans:
            ans = line_count
    visited.add(chart)

print(ans)</code></pre>
<p>차트의 가능한 순열을 모두 돌면서,
그래프를 회전해보며 한쪽이 정확히 50이라면 절반을 차지하므로 그래프 상 직선이 생긴다고 판단하여 카운트를 하는 방식이다.</p>
<p>반례는 다음과 같다.</p>
<pre><code>5
50 10 10 10 20

정답
1
출력
2</code></pre><hr>
<p>위 코드에서의 문제점은, 이중for문에서 deque로 그래프를 회전시킬 때 한바퀴를 돌기 때문에 같은 직선을 두번씩 볼 수 있다는 문제가 있었다.</p>
<p>아래와 같이 코드를 수정하여 문제를 해결했다.</p>
<pre><code class="language-python">from itertools import permutations
from collections import deque

N = int(input())
data = list(map(int, input().split()))
ans = 0

charts= list(permutations(data, N))

visited = set()
for chart in charts:
    if chart not in visited:
        chart_q = deque(chart)
        line_count = 0
        for i in range(N):
            chart_q_copy = chart_q.copy()
            left_sum = 0
            for j in range(N - i - 1): # deque가 더하는 범위를 제한
                left_sum += chart_q_copy.popleft()
                if left_sum == 50:
                    line_count += 1
                    break
                elif left_sum &gt; 50:
                    break
            left = chart_q.popleft()
            chart_q.append(left)
        if line_count &gt; ans:
            ans = line_count
    visited.add(chart)

print(ans)</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[IoT] TuyaIR기기의 'No permissions. Your subscription to cloud development plan has expired.' 오류 해결]]></title>
            <link>https://velog.io/@minu-j/IoT-TuyaIR%EA%B8%B0%EA%B8%B0%EC%9D%98-No-permissions.-Your-subscription-to-cloud-development-plan-has-expired.-%EC%98%A4%EB%A5%98</link>
            <guid>https://velog.io/@minu-j/IoT-TuyaIR%EA%B8%B0%EA%B8%B0%EC%9D%98-No-permissions.-Your-subscription-to-cloud-development-plan-has-expired.-%EC%98%A4%EB%A5%98</guid>
            <pubDate>Sun, 11 Jun 2023 12:32:02 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/minu-j/post/7fed7b82-f16e-422f-b55c-b815cc5381e7/image.png" alt=""></p>
<p><a href="https://github.com/prasad-edlabadka/homebridge-tuya-ir#readme">Homebridge Tuya IR 플러그인</a></p>
<p>홈브릿지에 TuyaIR 플러그인을 설치해서 애플 홈 앱에서 에어컨 리모컨을 조작하던 중, 홈 앱에서는 에어컨이 조작이 안되는데 Tuya의 SmartHome 앱에서는 되는 것을 발견했다.</p>
<p>그리고 홈브릿지 로그에 아래와 같은 오류가 찍힌걸 발견했다.</p>
<pre><code>[11/06/2023, 12:48:53] [TuyaIR] Failed to get remote configuration for: ebce59c69f7bd71a0aabkp
[11/06/2023, 12:48:53] [TuyaIR] Server returned error: &#39;No permissions. Your subscription to cloud development plan has expired.&#39;
[11/06/2023, 12:48:53] [TuyaIR] Failed because of TypeError [ERR_INVALID_ARG_TYPE]: The &quot;data&quot; argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received undefined</code></pre><p>작년 여름에 에어컨을 설치한 후 한달정도 쓰다가 가을이 되어서 그 후로 한번도 홈팟으로 에어컨을 켠 적이 없어, 언제부터 문제가 발생했는지 모르겠지만
아마 계정문제인가 싶어서 Tuya Projact에 앱과 디바이스를 다시 설정해보고 이것저것 만저봤지만 해결이 되지 않았다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/6bc60918-24f0-4800-a486-5bec18572512/image.png" alt=""></p>
<p>홈브릿지의 문제인가 싶어서 같은 라즈베리파이에 돌리고있는 홈어시스턴트에도 구성을 해보았으나 제대로 연결이 안됐고,
뭔가 라이센스 문제인가 알아보던 중..</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/05829960-6bc4-4bad-b93b-2bd04833c212/image.png" alt=""></p>
<p>Tuya IoT Platform에 IoT Core라는 클라우드 서비스가 활성화되어야 해당 플러그인에서 API로 Tuya 기기를 컨트롤 할 수 있다는 것을 알았다...</p>
<p>그리고 IoT Core 서비스는 가입시 한달 무료. 그 이후부터는 유료로 서비스 되는 것 같았다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/a95416a8-15ea-47d2-b713-b40f6d2d3354/image.png" alt=""></p>
<p>다행히 View Details를 눌러보니 만료기한을 연장신청하는 버튼이 있었고,
개발자 정보와 간단한 프로젝트 설명, 연장 신청 기간을 적어서 제출할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/21db37d8-bdaa-49c7-9e90-d63304bd8278/image.png" alt=""></p>
<p>정확히 어떤식으로 신청해야 먹힐지는 모르겠으나...
일단은 신청해놨다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/737831ac-e768-4a44-927b-e7e353b42456/image.png" alt=""></p>
<p>리뷰하고 결과를 알려주는 것 같다.</p>
<p>=============</p>
<p>글 쓴지 다음날 클라우드 서비스 콘솔을 확인해보니 정상적으로 작동하는 것을 볼수 있었다. 생각보다 후한 듯 하다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/43590286-220f-4204-93d4-b18e89158356/image.png" alt=""></p>
<p>하지만 다시 등록해봐도 똑같은 오류 발생...</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/8c465415-a905-4fcd-a3a3-8177151cd6e7/image.png" alt=""></p>
<p>권한문제가 뭔지 도대체 모르겠어서 포기하려던 찰나...
지역 설정을 바꿔주니 잘 동작합니다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/8a0c3b09-7e36-41ba-8c2a-0f741e9e836c/image.png" alt=""></p>
<p>로그도 정상적으로 뜨고요.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/3b49cfb2-f7dd-4275-8e6d-2b8ab2985895/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/fe674a41-26cb-4188-b897-04fc403c3505/image.PNG" alt=""></p>
<p>홈에서도 정상 동작합니다.</p>
<p>물론 신호 전송 기록상 켜져있는거지 IR리모컨 특성상 작동이 보장되는건 아니라서
확실히 에어컨이 켜졌는지를 확인하려면 진동센서를 실외기에 부착한다거나, 도어센서를 에어컨 날개에 부착하는 등 방식으로 해결할 수 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[VSCode]React+Typescript with Styled-components 컴포넌트 생성 Snippet(VS Code 단축어)]]></title>
            <link>https://velog.io/@minu-j/ReactReact-Typescript-with-Styled-components-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%9D%EC%84%B1-SnippetVSCode-%EB%8B%A8%EC%B6%95%EC%96%B4</link>
            <guid>https://velog.io/@minu-j/ReactReact-Typescript-with-Styled-components-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-%EC%83%9D%EC%84%B1-SnippetVSCode-%EB%8B%A8%EC%B6%95%EC%96%B4</guid>
            <pubDate>Tue, 02 May 2023 15:56:45 GMT</pubDate>
            <description><![CDATA[<p>공식문서 - <a href="https://code.visualstudio.com/docs/editor/userdefinedsnippets">Snippets in Visual Studio Code</a></p>
<p>React + Typescript + styled-components를 이용한 컴포넌트를 찍어내던 중,
반복되는 작업에 들어가는 시간을 아끼고자 스니펫을 만들었다.
지극히 개인적인 형식이어서,
공식문서를 읽어보고 본인에게 맞는 스니펫을 만들어보도록 하자.</p>
<h3 id="간략한-가이드">간략한 가이드</h3>
<p><img src="https://velog.velcdn.com/images/minu-j/post/fafe21c9-9750-49aa-b9d5-6220532dd8b7/image.png" alt=""></p>
<ol>
<li>(mac 기준) Code &gt; Preferences &gt; Configure User Snippets</li>
</ol>
<p><img src="https://velog.velcdn.com/images/minu-j/post/ef524ccb-bf81-4a0f-bc05-8b4252149023/image.png" alt=""></p>
<ol start="2">
<li>기존 언어 Snippets에 추가하거나, Global 또는 프로젝트 단위 Snippets를 생성해 사용자 지정 Snippets를 입력해준다.</li>
</ol>
<h3 id="예시">예시</h3>
<pre><code class="language-json">{
  &quot;React+Typescript+Styled-components 생성&quot;: {
    &quot;prefix&quot;: [&quot;사용자 단축어&quot;],
    &quot;body&quot;: [
      &quot;import styled from \&quot;styled-components\&quot;;\n&quot;,

      // props 타입 선언(camelCase)
      &quot;type ${1:newComponent}Props = {};\n&quot;,

      // React 컴포넌트 이름(PascalCase)
      &quot;function ${1/(.*)/${1:/pascalcase}/}({}: ${1}Props) {&quot;,
      &quot;\treturn &lt;Styled${1/(.*)/${1:/pascalcase}/}&gt;&lt;/Styled${1/(.*)/${1:/pascalcase}/}&gt;;&quot;,
      &quot;}\n&quot;,
      &quot;export default ${1/(.*)/${1:/pascalcase}/}\n&quot;,

      // Styled-components 선언(PascalCase)
      &quot;const Styled${1/(.*)/${1:/pascalcase}/} = styled.$2``;&quot;
    ],
    &quot;description&quot;: &quot;React+Typescript+Styled-components 생성&quot;
  }
}</code></pre>
<hr>
<h3 id="결과물">결과물</h3>
<pre><code class="language-jsx">import styled from &quot;styled-components&quot;;

type newComponentProps = {};

function newComponent({}: newComponentProps) {
  return &lt;StylednewComponent&gt;&lt;/StylednewComponent&gt;;
}

export default newComponent

const StylednewComponent = styled.div``;</code></pre>
<hr>
<h3 id="작동영상">작동영상</h3>
<p><img src="https://velog.velcdn.com/images/minu-j/post/b04e5a3c-fdac-4c74-9129-89636268d713/image.gif" alt=""></p>
<p>아주 빠르게 나만의 형식을 만들어 낼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] EventListener 추가하고 반드시 제거하기]]></title>
            <link>https://velog.io/@minu-j/React-EventListener-%EC%B6%94%EA%B0%80%ED%95%98%EA%B3%A0-%EB%B0%98%EB%93%9C%EC%8B%9C-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@minu-j/React-EventListener-%EC%B6%94%EA%B0%80%ED%95%98%EA%B3%A0-%EB%B0%98%EB%93%9C%EC%8B%9C-%EC%A0%9C%EA%B1%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 29 Mar 2023 13:26:26 GMT</pubDate>
            <description><![CDATA[<h3 id="react-lifecycle">React LifeCycle</h3>
<p>리액트의 함수형 컴포넌트를 사용할 때 LifeCycle을 아는 것과 모르는 것의 차이는 매우 크다.</p>
<p>리액트 프레임워크를 사용해서 프로젝트까지 해봤는데도 불구하고 오늘도 엄청난 실수를 하나 했는데,
다신 실수하지 않도록 기록을 남겨놓는다.</p>
<h3 id="addeventlistener">addEventListener</h3>
<pre><code class="language-js">import { useState } from &quot;react&quot;;

...

const [scrollLocation, setScrollLocation] = useState&lt;number&gt;(0);

const windowScrollListener = (e: Event) =&gt; {
  setScrollLocation(document.documentElement.scrollTop);
  console.log(scrollLocation);
};

window.addEventListener(&quot;scroll&quot;, windowScrollListener)

...</code></pre>
<p>함수형 컴포넌트가 렌더링 되면 scroll의 위치를 state에 지속적으로 최신화하는 함수이다.
useState는 state에 선언된 값이 바뀔 때 마다 연관된 컴포넌트들을 새로 렌더링해줘서 값을 바꿔주거나, 역동적인 화면 요소를 그릴 수 있게 해준다.
window에 scroll event가 발생할 때 마다 scrollLocation state를 바꿔준다.
위 코드는 타입스크립트로 작성한거라서 타입선언이 되어있다.</p>
<h3 id="그런데">그런데...</h3>
<p>근데 이런식으로 코드를 작성하면 정말 큰일난다.
이유는 페이지를 벗어나도 event listener가 사라지지 않기 때문.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/1ad7394a-2801-4bc8-a238-b0dc2c333449/image.gif" alt=""></p>
<p>미친듯이 중복된 listener가 작동해서
스크롤 한번에 만개씩 console.log가 쌓이는 모습을 볼 수 있다....</p>
<h3 id="useeffect">useEffect</h3>
<p>아, 그렇다면 addEventListener() 메서드가 최초 1회만 실행되게 하면 되겠구나.</p>
<p>useEffect는 React의 LifeCycle을 쉽게 이용할 수 있게 해준다.
useEffect의 두번째 인자에 들어오는 리스트 안의 값이 변할 때 useEffect에 선언된 함수가 실행된다.
빈 리스트를 넣게 되면 최초 1회만 실행된다.</p>
<pre><code class="language-js">import { useEffect, useState } from &quot;react&quot;;

...

const [scrollLocation, setScrollLocation] = useState&lt;number&gt;(0);

const windowScrollListener = (e: Event) =&gt; {
  setScrollLocation(document.documentElement.scrollTop);
  console.log(scrollLocation);
};

useEffect(() =&gt; {
  window.addEventListener(&quot;scroll&quot;, windowScrollListener);
}, []);

...</code></pre>
<p>안된다.
이유는 window에 listener를 달아버린 나머지
해당 컴포넌트를 벗어나도 listener 제거가 되지 않는다.
무조건 해당 컴포넌트를 벗어날 때 listener를 제거해야 한다.
이럴때 사용하는 것이 useEffect의 return 함수</p>
<pre><code class="language-js">import { useEffect, useState } from &quot;react&quot;;

...

const [scrollLocation, setScrollLocation] = useState&lt;number&gt;(0);

const windowScrollListener = (e: Event) =&gt; {
  setScrollLocation(document.documentElement.scrollTop);
  console.log(scrollLocation);
};

useEffect(() =&gt; {
  window.addEventListener(&quot;scroll&quot;, windowScrollListener);
  return () =&gt; {
    window.removeEventListener(&quot;scroll&quot;, windowScrollListener);
  };
}, []);

...</code></pre>
<p>이게 최종 코드이다.
return 뒤에 오는 함수의 값은 해당 컴포넌트가 제거될 때 실행되는 함수이다.
컴포넌트가 제거될 때 event listener를 제거해서 자원 낭비가 일어나지 않도록 한다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/e9465ea2-90fd-4625-8433-f81ae7dbb21e/image.gif" alt=""></p>
<p>편안..</p>
<p>반드시 한 컴포넌트에서 만든 listener는 해당 컴포넌트에서 제거하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] Google Sign-in시 iOS에서 PlatformException 오류 해결]]></title>
            <link>https://velog.io/@minu-j/Flutter-Google-Sign-in%EC%8B%9C-iOS%EC%97%90%EC%84%9C-PlatformException-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@minu-j/Flutter-Google-Sign-in%EC%8B%9C-iOS%EC%97%90%EC%84%9C-PlatformException-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Fri, 17 Mar 2023 15:40:25 GMT</pubDate>
            <description><![CDATA[<p><a href="https://pub.dev/packages/google_sign_in">https://pub.dev/packages/google_sign_in</a></p>
<p>해당 패키지로 구글 로그인 구현시 지문등록 완료한 후 안드로이드에서는 잘 로그인이 되지만 iOS에서만 아래와 같은 오류가 발생했다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/ddb77e54-5d7f-4bac-99f9-8a8acfc03c5e/image.png" alt=""></p>
<p>결국엔 공식문서를 꼼꼼하게 읽어보지 않은 탓이었는데,
공식문서 내용이 어렵기도 하고, Info.plist를 직접 건드리니까 자꾸 오류가 발생해서 조금 간단하게 해결할 수 있는 방법을 정리한다.</p>
<p>일단 Firebase나 Google Auth API 페이지에서 PLIST를 다운받아야 한다.</p>
<p>다운받은 .plist파일의 이름을 GoogleService-Info.plist로 수정한 뒤
ios &gt; Runner폴더에 그대로 넣어준다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/20b85399-4d89-4a37-9824-0246a5c5a4a3/image.png" alt=""></p>
<p>그 후 ios폴더에서 Xcode에서 열기를 선택하고,</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/59a70dd5-8fab-4760-bb31-1b76a897a235/image.png" alt=""></p>
<p>Runner &gt; Info 하단 URL Types의 URL Schemes에
GoogleService-Info.plist의 REVERSED_CLIENT_ID를 입력해준다.</p>
<pre><code class="language-plist">&lt;key&gt;REVERSED_CLIENT_ID&lt;/key&gt;
&lt;string&gt;com.googleusercontent......&lt;/string&gt; // &lt;- 이거</code></pre>
<p><img src="https://velog.velcdn.com/images/minu-j/post/190fcff6-4b18-47fd-bf31-7ae7268c28f8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/2a9bc716-7acf-4484-88d2-aa5170705e4e/image.png" alt=""></p>
<p>끝.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React] Openvidu Tutorial 따라하기]]></title>
            <link>https://velog.io/@minu-j/React-Openvidu-Tutorial-%EB%94%B0%EB%9D%BC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@minu-j/React-Openvidu-Tutorial-%EB%94%B0%EB%9D%BC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 19 Jan 2023 13:39:35 GMT</pubDate>
            <description><![CDATA[<p>목적: Openvidu를 이용한 WebRTC 비디오 공유 연습
기술스택 : Python, React, Docker</p>
<h2 id="docker-컨테이너로-openvidu-환경-세팅">Docker 컨테이너로 Openvidu 환경 세팅</h2>
<pre><code class="language-bash">$ docker run -p 4443:4443 --rm -e OPENVIDU_SECRET=MY_SECRET openvidu/openvidu-dev:2.25.0</code></pre>
<p><img src="https://velog.velcdn.com/images/minu-j/post/2aa6dc27-1f01-4830-ab26-1be13ed14053/image.png" alt="Docker"></p>
<p>잘 돌아간다.</p>
<h2 id="python-server-실행">Python Server 실행</h2>
<ol>
<li>Openvidu가 제공하는 Tutorial repo를 clone한다.</li>
</ol>
<pre><code class="language-bash">$ git clone https://github.com/OpenVidu/openvidu-tutorials.git -b v2.25.0</code></pre>
<ol start="2">
<li><p>연습을 위해 익숙한 언어로 서버환경을 셋팅하기 위해 파이썬 선택했다.</p>
<pre><code class="language-bash">$ cd openvidu-tutorials/openvidu-basic-python</code></pre>
</li>
<li><p>가상환경 설정 및 활성화</p>
<pre><code class="language-bash">$ python3 -m venv venv
</code></pre>
</li>
</ol>
<p>$ . venv/bin/activate</p>
<pre><code>
4. Requirements 설치
```bash
$ pip install -r requirements.txt</code></pre><ol start="5">
<li>서버 실행<pre><code class="language-bash">$ python3 app.py</code></pre>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/minu-j/post/849c35ea-0fa7-4afc-8630-7a32936b4c61/image.png" alt=""></p>
<p>로컬 서버도 잘 실행된다.</p>
<blockquote>
<p>** Mac에서 <code>Address already in use</code> 오류 **</p>
<pre><code class="language-bash"> * Serving Flask app &#39;app&#39; (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
Address already in use
Port 5000 is in use by another program. Either identify and stop that program, or start the server with a different port.
On macOS, try disabling the &#39;AirPlay Receiver&#39; service from System Preferences -&gt; Sharing.</code></pre>
<p>서버가 바로 실행되지 않고 위와 같은 오류가 발생했는데, 오류 내용을 읽어보면 기본적으로 5000 port로 서버를 열려고 시도해보니 이미 다른 프로그램이 사용중이라는 뜻으로 보인다.
알고보니 macOS 몬터레이 이후 5000번 port로 Airplay 연결을 한다고...
프로세스를 종료하는 등 다른 해결방법이 있지만, Airplay를 매일 사용하는 입장에서 괜히 관련 건드리기는 싫어서 서버 포트를 임의로 8000번으로 지정해주었다.</p>
<pre><code class="language-python"># app.py

...

if __name__ == &quot;__main__&quot;:
    app.run(debug=True, host=&quot;0.0.0.0&quot;, port=8000)</code></pre>
<p>더 많은 해결방법 - <a href="https://algoroot.tistory.com/44">https://algoroot.tistory.com/44</a></p>
</blockquote>
<h2 id="react-server-실행">React Server 실행</h2>
<ol>
<li>React 서버 실행을 위해 아래 폴더로 이동한다.</li>
</ol>
<pre><code class="language-bash">$ cd openvidu-tutorials/openvidu-react</code></pre>
<ol start="2">
<li>패키지 설치 및 서버 실행</li>
</ol>
<pre><code class="language-bash">$ npm install

$ npm start</code></pre>
<blockquote>
<p>** <code>error:03000086:digital envelope routines::initialization error</code> 오류 **</p>
<pre><code class="language-bash">...

opensslErrorStack: [ &#39;error:03000086:digital envelope routines::initialization error&#39; ],
  library: &#39;digital envelope routines&#39;,
  reason: &#39;unsupported&#39;,
  code: &#39;ERR_OSSL_EVP_UNSUPPORTED&#39;
}</code></pre>
<p>React script가 OpenSSL 3 규격이 맞지 않아서 생기는 문제로, package.json 수정으로 간단히 해결할 수 있다.</p>
<pre><code class="language-javascript">// package.json
...

  &quot;scripts&quot;: {
    &quot;start&quot;: &quot;react-scripts --openssl-legacy-provider start&quot;,

...</code></pre>
</blockquote>
<p>아까 서버 포트를 8000번으로 바꿔줬으므로, 서버 URL도 수정해준다.</p>
<pre><code class="language-javascript">// App.js
...

const APPLICATION_SERVER_URL = &quot;http://localhost:8000/&quot;;

...</code></pre>
<p><img src="https://velog.velcdn.com/images/minu-j/post/1bd6c2c6-c42a-4f25-9193-b4392b736f12/image.png" alt=""></p>
<p>잘 켜진다.</p>
<p><img src="https://velog.velcdn.com/images/minu-j/post/f42ecd27-b9a9-4d68-a022-f7149fee1dd7/image.png" alt=""></p>
<p>영상도 잘 들어오긴 하지만,
다중접속이 잘 안되는걸 보니 조금 문제가 있는 듯 하다.
이 부분은 차차 해결하는걸로..</p>
<h3 id="출처">출처</h3>
<p><a href="https://docs.openvidu.io/en/stable/tutorials/openvidu-react/">https://docs.openvidu.io/en/stable/tutorials/openvidu-react/</a></p>
]]></description>
        </item>
    </channel>
</rss>