<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Alvin You :)</title>
        <link>https://velog.io/</link>
        <description>ENFP 풀스택 개발자</description>
        <lastBuildDate>Thu, 07 May 2026 05:14:34 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Alvin You :)</title>
            <url>https://images.velog.io/images/alvin_you/profile/6ac0e5f3-3f7e-41bd-b17b-761f26398468/my.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Alvin You :). All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/alvin_you" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[슬랙 데일리 허들, 누가 빠졌는지 봇이 알려줍니다]]></title>
            <link>https://velog.io/@alvin_you/slack-huddle-daily-bot</link>
            <guid>https://velog.io/@alvin_you/slack-huddle-daily-bot</guid>
            <pubDate>Thu, 07 May 2026 05:14:34 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>우리 팀은 매일 오전 9시 15분에 Slack 허들로 데일리 스탠드업을 진행한다.</p>
<p>허들이 시작될 때마다 어김없이 누군가는 &quot;아직 안 들어오셨어요&quot;라는 말을 하고, 진행자가 멤버 목록을 하나하나 훑어봐야 했다. 팀이 7개에 총 인원이 수십 명이다 보니 한눈에 파악이 쉽지 않았다.</p>
<p>자동화하면 되겠다 싶어서 만들었다. <strong>허들이 시작되면 자동으로 스레드에 출석 현황을 올리고, 참여자가 바뀔 때마다 메시지를 업데이트하는 봇.</strong></p>
<p>결과물은 이렇게 생겼다:</p>
<pre><code>📋 오늘 데일리 허들 출석 현황

✅ 이사진 3/3
✅ PMO팀 2/2
⏳ UX/UI팀 1/2  (미참여: 홍길동)
✅ 인프라서비스개발팀 3/3
⏳ 앱서비스개발팀 2/3  (미참여: 김철수)
✅ 웹서비스개발팀 3/3
✅ AI CORE팀 2/2

(전원 참여 시)
🟢 전 팀 준비 완료! 시작하세요!</code></pre><hr>
<h2 id="전체-구조">전체 구조</h2>
<pre><code>EventBridge (평일 09:15 KST)
    └─▶ Lambda (최대 10분 실행)
            ├─ Slack User Group에서 팀/멤버 조회
            ├─ 허들 감지 (conversations.history)
            └─ 10초마다 참여자 폴링 → 메시지 업데이트</code></pre><p>기술 스택:</p>
<ul>
<li><strong>런타임</strong>: Node.js 20 (TypeScript → esbuild 번들)</li>
<li><strong>인프라</strong>: AWS SAM (Lambda + EventBridge)</li>
<li><strong>Slack SDK</strong>: <code>@slack/web-api</code></li>
</ul>
<hr>
<h2 id="1-slack-앱-생성--토큰-발급">1. Slack 앱 생성 &amp; 토큰 발급</h2>
<p><a href="https://api.slack.com/apps">api.slack.com/apps</a>에서 앱을 생성한다. Manifest 방식을 쓰면 권한 설정이 편하다.</p>
<pre><code class="language-yaml">display_information:
  name: Huddle Bot
  background_color: &quot;#2c2d30&quot;

features:
  bot_user:
    display_name: Huddle Bot
    always_online: true

oauth_config:
  scopes:
    bot:
      - channels:read
      - channels:history   # 허들 메시지 감지
      - chat:write         # 메시지 전송/수정
      - chat:write.public
      - users:read         # 멤버 이름 조회
      - usergroups:read    # User Group 멤버 조회 ← 유료 플랜 전용
settings:
  socket_mode_enabled: false</code></pre>
<p>앱 설치 후 <strong>Bot Token</strong> (<code>xoxb-...</code>)을 발급받는다: OAuth &amp; Permissions → Bot User OAuth Token</p>
<p>채널 ID는 Slack에서 해당 채널 우클릭 → 채널 세부 정보 → 맨 아래에서 확인할 수 있다. <code>C</code>로 시작하는 11자리 문자열이다.</p>
<blockquote>
<p><strong>주의</strong>: <code>usergroups:read</code> scope는 <strong>유료 워크스페이스(Pro 이상)</strong> 에서만 동작한다. 무료 플랜이라면 User Group 대신 멤버 ID를 코드에 직접 관리해야 한다.</p>
</blockquote>
<hr>
<h2 id="2-user-group으로-팀-멤버-관리">2. User Group으로 팀 멤버 관리</h2>
<p>멤버 목록을 코드에 하드코딩하면 사람이 바뀔 때마다 재배포해야 한다. 대신 <strong>Slack User Group</strong>을 쓰면 Slack에서 멤버만 수정하면 자동 반영된다.</p>
<p>그룹 핸들 배열을 정의해두고, 런타임에 동적으로 멤버를 가져온다:</p>
<pre><code class="language-typescript">const USER_GROUP_HANDLES = [
  &quot;executives&quot;,    // 이사진
  &quot;pmo&quot;,           // PMO팀
  &quot;ux-ui&quot;,         // UX/UI팀
  &quot;infra-service&quot;, // 인프라서비스개발팀
  &quot;app-service&quot;,   // 앱서비스개발팀
  &quot;web-service&quot;,   // 웹서비스개발팀
  &quot;ai-core&quot;,       // AI CORE팀
];

async function fetchTeams(): Promise&lt;Team[]&gt; {
  // 1. 워크스페이스의 전체 User Group 목록 조회
  const groupsRes = await slack.usergroups.list({ include_users: false });
  const allGroups = groupsRes.usergroups ?? [];

  // 2. 핸들 배열 순서대로 매칭 (메시지 표시 순서 보장)
  const matched = USER_GROUP_HANDLES.map((handle) =&gt; {
    const group = allGroups.find((g) =&gt; g.handle === handle);
    if (!group) throw new Error(`User Group @${handle} 를 찾을 수 없습니다.`);
    return { name: group.name!, groupId: group.id! };
  });

  // 3. 각 그룹의 멤버 ID → 이름까지 한 번에 조회
  return Promise.all(
    matched.map(async ({ name, groupId }) =&gt; {
      const usersRes = await slack.usergroups.users.list({ usergroup: groupId });
      const members = await Promise.all(
        (usersRes.users ?? []).map(async (id) =&gt; {
          const info = await slack.users.info({ user: id });
          const profile = info.user?.profile;
          return { id, name: profile?.display_name || profile?.real_name || id };
        })
      );
      return { name, groupId, members };
    })
  );
}</code></pre>
<p>팀/멤버가 바뀌어도 <strong>코드 수정 없이 Slack User Group만 업데이트</strong>하면 된다. User Group은 <a href="https://slack.com/admin">slack.com/admin</a> → User Groups에서 관리할 수 있다.</p>
<hr>
<h2 id="3-허들-감지--undocumented-api의-세계">3. 허들 감지 — undocumented API의 세계</h2>
<p>가장 까다로운 부분이다. Slack은 허들(Huddle)을 위한 전용 API를 공개하지 않는다. 대신 <code>conversations.history</code>로 채널 메시지를 읽으면 허들 메시지가 숨어 있다.</p>
<p>허들 메시지의 특징:</p>
<ul>
<li><code>subtype === &quot;huddle_thread&quot;</code></li>
<li><code>room</code> 객체 안에 참여자 정보가 담겨 있음</li>
<li><code>room.has_ended === false</code> 이면 현재 진행 중</li>
</ul>
<pre><code class="language-typescript">async function findHuddle(): Promise&lt;HuddleInfo | null&gt; {
  const res = await slack.conversations.history({
    channel: CHANNEL_ID,
    limit: 20,
  });

  interface HuddleMessage {
    subtype?: string;
    ts: string;
    room?: { has_ended: boolean; participants?: string[] };
  }

  const huddleMsg = (res.messages as HuddleMessage[]).find(
    (m) =&gt; m.subtype === &quot;huddle_thread&quot; &amp;&amp; m.room != null &amp;&amp; !m.room.has_ended
  );

  if (!huddleMsg) return null;

  return {
    ts: huddleMsg.ts,
    participants: new Set&lt;string&gt;(huddleMsg.room!.participants ?? []),
  };
}</code></pre>
<p><code>room.participants</code>는 Slack 공식 문서에 명시되지 않은 필드다. 실제 메시지 응답을 직접 까보면서 발견한 것. 향후 Slack이 스펙을 바꾸면 깨질 수 있는 부분이라 주의가 필요하다.</p>
<hr>
<h2 id="4-메시지-생성--업데이트">4. 메시지 생성 &amp; 업데이트</h2>
<p>출석 현황 메시지를 만들고, 허들 스레드에 붙인다. Lambda는 <strong>stateless</strong>라서 이전 메시지의 <code>ts</code>(타임스탬프)를 메모리에 저장할 수 없다. 매번 스레드를 조회해서 봇이 보낸 메시지를 찾아야 한다.</p>
<pre><code class="language-typescript">async function findOrCreateReply(text: string, huddleTs: string): Promise&lt;void&gt; {
  // 허들 스레드의 전체 메시지 조회
  const res = await slack.conversations.replies({
    channel: CHANNEL_ID,
    ts: huddleTs,
  });

  // 봇 자신의 User ID (최초 1회만 API 호출)
  if (!BOT_USER_ID) BOT_USER_ID = (await slack.auth.test()).user_id!;

  // 스레드에서 봇이 보낸 기존 메시지 찾기
  const existing = (res.messages ?? []).find(
    (m) =&gt; m.user === BOT_USER_ID &amp;&amp; m.ts !== huddleTs
  );

  if (existing) {
    // 있으면 update
    await slack.chat.update({ channel: CHANNEL_ID, ts: existing.ts!, text });
  } else {
    // 없으면 새로 post
    await slack.chat.postMessage({
      channel: CHANNEL_ID,
      thread_ts: huddleTs,
      text,
    });
  }
}</code></pre>
<p>메시지 내용은 팀별로 참여/미참여 인원을 계산해서 만든다:</p>
<pre><code class="language-typescript">function buildMessage(teams: Team[], participants: Set&lt;string&gt;): string {
  const lines: string[] = [&quot;📋 *오늘 데일리 허들 출석 현황*\n&quot;];
  let allReady = true;

  for (const team of teams) {
    const present = team.members.filter((m) =&gt; participants.has(m.id));
    const absent  = team.members.filter((m) =&gt; !participants.has(m.id));
    const status  = absent.length === 0 ? &quot;✅&quot; : &quot;⏳&quot;;
    if (absent.length &gt; 0) allReady = false;

    const absentStr = absent.length &gt; 0
      ? `  _(미참여: ${absent.map((m) =&gt; m.name).join(&quot;, &quot;)})_`
      : &quot;&quot;;

    lines.push(`${status} *${team.name}* ${present.length}/${team.members.length}${absentStr}`);
  }

  if (allReady) lines.push(&quot;\n🟢 *전 팀 준비 완료! 시작하세요!*&quot;);
  return lines.join(&quot;\n&quot;);
}</code></pre>
<hr>
<h2 id="5-lambda-핸들러--왜-5분간-루프를-도는가">5. Lambda 핸들러 — 왜 5분간 루프를 도는가</h2>
<p>Lambda 핸들러의 실행 흐름이다:</p>
<pre><code class="language-typescript">export const handler = async () =&gt; {
  const teams = await fetchTeams();
  const start = Date.now();

  // 허들이 아직 안 시작됐을 수 있으니 최대 2분 대기
  let huddle = await findHuddle();
  while (!huddle &amp;&amp; Date.now() - start &lt; 2 * 60 * 1000) {
    await new Promise((r) =&gt; setTimeout(r, POLL_INTERVAL_MS)); // 10초 대기
    huddle = await findHuddle();
  }

  if (!huddle) {
    console.log(&quot;허들을 찾지 못했습니다. 종료합니다.&quot;);
    return;
  }

  // 5분간 10초마다 폴링
  while (Date.now() - start &lt; POLL_DURATION_MS) {
    const latest = await findHuddle();
    const participants = latest?.participants ?? new Set&lt;string&gt;();
    const message = buildMessage(teams, participants);
    await findOrCreateReply(message, huddle.ts);

    // 전원 참여 시 조기 종료
    const allPresent = teams.every((team) =&gt;
      team.members.every((m) =&gt; participants.has(m.id))
    );
    if (allPresent) break;

    await new Promise((r) =&gt; setTimeout(r, POLL_INTERVAL_MS));
  }
};</code></pre>
<h3 id="왜-eventbridge를-10초마다-트리거하지-않는가">&quot;왜 EventBridge를 10초마다 트리거하지 않는가?&quot;</h3>
<p>당연히 떠오르는 질문이다. EventBridge cron의 최소 단위는 <strong>1분</strong>이다. 10초 간격 트리거는 불가능하다.</p>
<p>그러면 &quot;1분마다 5번 트리거하면 되지 않나?&quot; 싶은데, 그렇게 하면 <strong>상태 공유 문제</strong>가 생긴다. 봇이 이미 스레드에 메시지를 올렸는지 Lambda 인스턴스끼리 공유할 방법이 없다 (DynamoDB 같은 외부 저장소를 쓰지 않는 이상). 결국 중복 메시지가 쌓인다.</p>
<p>Lambda 1개를 최대 10분(Timeout: 600초)짜리로 띄우고 내부에서 루프를 도는 방식이 훨씬 단순하다:</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>EventBridge 반복 트리거</td>
<td>—</td>
<td>1분 미만 불가, 상태 공유 필요</td>
</tr>
<tr>
<td>Lambda 내부 루프</td>
<td>단순, 상태 공유 불필요</td>
<td>Lambda 실행 시간 길어짐</td>
</tr>
</tbody></table>
<p>우리 케이스에서는 후자가 맞다. 5분간 실행되지만 대부분의 시간은 <code>setTimeout</code> 대기라 CPU 비용은 거의 없다.</p>
<hr>
<h2 id="6-인프라-설정-aws-sam">6. 인프라 설정 (AWS SAM)</h2>
<p><code>template.yaml</code> 한 파일로 Lambda + EventBridge 스케줄을 한 번에 정의한다.</p>
<pre><code class="language-yaml">AWSTemplateFormatVersion: &quot;2010-09-09&quot;
Transform: AWS::Serverless-2016-10-31

Resources:
  HuddleBotFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: huddle-bot
      Handler: dist/index.handler
      Runtime: nodejs20.x
      Timeout: 600       # 최대 10분 — 내부 루프 때문에 넉넉하게
      MemorySize: 256
      Events:
        DailyTrigger:
          Type: ScheduleV2
          Properties:
            # 한국시간 09:15 = UTC 00:15, 평일(MON-FRI)만 실행
            ScheduleExpression: &quot;cron(15 0 ? * MON-FRI *)&quot;
            Name: huddle-bot-daily</code></pre>
<p><strong>Timeout을 600초로 설정한 이유</strong>: Lambda 기본 Timeout은 3초다. 내부에서 5분 루프를 돌아야 하므로 최소 300초 이상이어야 하고, 허들 대기 시간(최대 2분)까지 포함해서 넉넉하게 600초로 잡았다.</p>
<hr>
<h2 id="7-토큰-관리">7. 토큰 관리</h2>
<p>봇 토큰을 코드에 하드코딩하면 안 된다 (Git에 올라가는 순간 끝). AWS SSM Parameter Store에 저장하고, <code>template.yaml</code>에서 Lambda 환경변수로 주입하는 방식이 깔끔하다.</p>
<pre><code class="language-bash"># SSM에 토큰 저장 (Lambda 환경변수 resolve는 String 타입만 지원)
aws ssm put-parameter \
  --name /huddle-bot/slack-bot-token \
  --value &quot;xoxb-...&quot; \
  --type String

aws ssm put-parameter \
  --name /huddle-bot/channel-id \
  --value &quot;CXXXXXXXXXX&quot; \
  --type String</code></pre>
<p><code>template.yaml</code>에서 SSM 값을 환경변수로 연결:</p>
<pre><code class="language-yaml">Environment:
  Variables:
    SLACK_BOT_TOKEN: &quot;{{resolve:ssm:/huddle-bot/slack-bot-token}}&quot;
    SLACK_CHANNEL_ID: &quot;{{resolve:ssm:/huddle-bot/channel-id}}&quot;</code></pre>
<p>코드에서는 그냥 <code>process.env</code>로 읽으면 된다. 로컬에서는 <code>.env</code> 파일로, Lambda에서는 SSM에서 주입된 값으로 자동으로 동작한다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>만들고 나서 약 두 달째 매일 돌아가고 있다. 코드 한 줄 건드리지 않고도 Slack User Group 멤버만 바꾸면 자동으로 반영되고, 배포는 <code>npm run deploy</code> 한 줄로 끝난다. 작은 자동화인데 매일 아침 허들이 조금 더 매끄럽게 시작되는 게 체감이 된다.</p>
<p>전체 코드는 GitHub에 올려두었다. 팀 구성에 맞게 <code>USER_GROUP_HANDLES</code>만 수정하면 바로 쓸 수 있다.</p>
<p><a href="https://github.com/AlvinYou/huddle-bot">github.com/AlvinYou/huddle-bot</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 정적 사이트를 S3 + CloudFront로 배포하기 (GitHub Actions)]]></title>
            <link>https://velog.io/@alvin_you/Next.js-%EC%A0%95%EC%A0%81-%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-S3-CloudFront%EB%A1%9C-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0-GitHub-Actions</link>
            <guid>https://velog.io/@alvin_you/Next.js-%EC%A0%95%EC%A0%81-%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-S3-CloudFront%EB%A1%9C-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0-GitHub-Actions</guid>
            <pubDate>Tue, 10 Mar 2026 07:57:57 GMT</pubDate>
            <description><![CDATA[<h2 id="아키텍처">아키텍처</h2>
<pre><code>[GitHub] → push → [GitHub Actions] → build → [S3 Bucket] → [CloudFront CDN] → 사용자</code></pre><ul>
<li><strong>Next.js</strong> (<code>output: &#39;export&#39;</code>로 정적 HTML 생성)</li>
<li><strong>S3</strong>: 정적 파일 호스팅</li>
<li><strong>CloudFront</strong>: CDN + HTTPS</li>
<li><strong>GitHub Actions</strong>: CI/CD 파이프라인</li>
</ul>
<hr>
<h2 id="nextjs-설정">Next.js 설정</h2>
<pre><code class="language-js">// next.config.mjs
const nextConfig = {
  output: &#39;export&#39;,        // 정적 HTML 생성 → ./out 디렉토리
  images: {
    unoptimized: true,     // 정적 배포에서는 이미지 최적화 비활성화
  },
}</code></pre>
<p><code>yarn build</code> 실행 시 <code>./out</code> 디렉토리에 정적 파일이 생성된다:</p>
<pre><code>out/
├── _next/
│   └── static/
│       └── chunks/          ← JS chunk 파일들 (해시 포함)
│           ├── app/
│           │   └── page-12e1a2d8251d49e1.js
│           ├── webpack-739516a8b9f8eeb4.js
│           └── ...
├── index.html
├── about.html
└── ...</code></pre><p><code>_next/static/chunks/</code> 안의 파일들은 <strong>빌드마다 해시가 바뀐다</strong>. 이게 캐시 전략의 핵심이다.</p>
<hr>
<h2 id="github-actions-워크플로우">GitHub Actions 워크플로우</h2>
<pre><code class="language-yaml">name: Deploy to Production

on:
  push:
    branches: [main]
    paths-ignore:
      - &#39;**.md&#39;

env:
  AWS_REGION: ap-northeast-2
  S3_BUCKET: s3://my-app-prod
  CLOUDFRONT_DISTRIBUTION_ID: EXXXXXXXXXXXXX

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &#39;22&#39;
          cache: &#39;yarn&#39;

      - name: Install dependencies
        run: yarn install

      - name: Build
        run: yarn build

      # AWS credentials는 GitHub 리포지토리 Settings &gt; Secrets and variables &gt; Actions에 등록
      # - AWS_ACCESS_KEY_ID: IAM 사용자의 Access Key ID
      # - AWS_SECRET_ACCESS_KEY: IAM 사용자의 Secret Access Key
      # IAM 사용자에게 S3, CloudFront 권한이 필요하다
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Deploy to S3
        run: |
          # Step 1~4 (아래에서 상세 설명)

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
            --paths &quot;/*&quot;</code></pre>
<hr>
<h2 id="s3-배포-4단계-전략">S3 배포: 4단계 전략</h2>
<p>정적 파일을 하나의 <code>s3 sync</code>로 올리면 안 된다. <strong>파일 종류별로 캐시 정책이 다르기 때문이다.</strong></p>
<h3 id="step-1-해시된-정적-에셋-장기-캐시">Step 1: 해시된 정적 에셋 (장기 캐시)</h3>
<pre><code class="language-bash">aws s3 sync ./out/_next $S3_BUCKET/_next \
  --cache-control &quot;public, max-age=31536000, immutable&quot;</code></pre>
<ul>
<li><code>_next/static/chunks/page-12e1a2d8251d49e1.js</code> 같은 파일들</li>
<li>파일명에 해시가 포함되어 있으므로 <strong>1년 캐시 + immutable</strong> 설정</li>
<li><strong><code>--delete</code> 없음</strong> → 이전 빌드의 chunk를 보존</li>
</ul>
<blockquote>
<p><strong>왜 이전 chunk를 보존하는가?</strong></p>
<p>배포 시점에 사이트를 이용 중인 사용자의 브라우저는 이전 빌드의 HTML을 들고 있다.
이 HTML은 이전 해시의 chunk를 참조하고 있으므로, 이전 chunk가 삭제되면
클라이언트 사이드 네비게이션(링크 클릭) 시 chunk 로드에 실패한다.</p>
</blockquote>
<h3 id="step-2-html-파일-캐시-없음">Step 2: HTML 파일 (캐시 없음)</h3>
<pre><code class="language-bash">aws s3 sync ./out $S3_BUCKET \
  --content-type &quot;text/html&quot; \
  --cache-control &quot;no-cache, no-store, must-revalidate&quot; \
  --metadata-directive REPLACE \
  --exclude &quot;_next/*&quot; \
  --exclude &quot;*.jpg&quot; --exclude &quot;*.png&quot; --exclude &quot;*.jpeg&quot; \
  --exclude &quot;*.svg&quot; --exclude &quot;*.json&quot; --exclude &quot;*.ico&quot; \
  --exclude &quot;*.txt&quot; --exclude &quot;*.xml&quot; \
  --exclude &quot;*.js&quot; --exclude &quot;*.css&quot; \
  --delete</code></pre>
<ul>
<li>HTML은 항상 최신 버전을 받아야 하므로 <strong>캐시 없음</strong></li>
<li>HTML 안에 <code>&lt;script src=&quot;/_next/static/chunks/page-{hash}.js&quot;&gt;</code> 참조가 있다</li>
<li>새로고침하면 항상 최신 HTML → 최신 chunk 해시를 참조</li>
</ul>
<h3 id="step-3-clean-url-처리">Step 3: Clean URL 처리</h3>
<pre><code class="language-bash"># .html 확장자 제거 (about.html → about)
for file in $(find ./out -name &quot;*.html&quot;); do
  mv &quot;$file&quot; &quot;${file%%.html}&quot;
done

aws s3 sync ./out $S3_BUCKET \
  --content-type &quot;text/html&quot; \
  --cache-control &quot;no-cache, no-store, must-revalidate&quot; \
  --metadata-directive REPLACE \
  --exclude &quot;_next/*&quot; \
  --exclude &quot;*.jpg&quot; --exclude &quot;*.png&quot; --exclude &quot;*.jpeg&quot; \
  --exclude &quot;*.svg&quot; --exclude &quot;*.json&quot; --exclude &quot;*.ico&quot; \
  --exclude &quot;*.txt&quot; --exclude &quot;*.xml&quot; \
  --exclude &quot;*.js&quot; --exclude &quot;*.css&quot;</code></pre>
<ul>
<li><code>/about.html</code> 대신 <code>/about</code>으로 접근할 수 있도록 확장자 없는 파일도 업로드</li>
<li>CloudFront Function에서 URL 리라이트와 함께 사용</li>
</ul>
<h3 id="step-4-기타-정적-에셋-중기-캐시">Step 4: 기타 정적 에셋 (중기 캐시)</h3>
<pre><code class="language-bash">aws s3 sync ./out $S3_BUCKET \
  --exclude &quot;*&quot; \
  --include &quot;*.jpg&quot; --include &quot;*.png&quot; --include &quot;*.jpeg&quot; \
  --include &quot;*.svg&quot; --include &quot;*.json&quot; --include &quot;*.ico&quot; \
  --include &quot;*.txt&quot; --include &quot;*.xml&quot; \
  --include &quot;*.js&quot; --include &quot;*.css&quot; \
  --exclude &quot;_next/*&quot; \
  --cache-control &quot;public, max-age=86400&quot; \
  --delete</code></pre>
<ul>
<li>이미지, 폰트, 루트의 JS/CSS 등</li>
<li>1일 캐시</li>
<li><code>--exclude &quot;_next/*&quot;</code> → Step 1에서 보존한 이전 chunk를 삭제하지 않도록 보호</li>
</ul>
<h3 id="cloudfront-캐시-무효화">CloudFront 캐시 무효화</h3>
<pre><code class="language-bash">aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
  --paths &quot;/*&quot;</code></pre>
<ul>
<li>모든 경로의 edge 캐시를 무효화</li>
<li>비동기로 실행되며, 전파에 1~15분 소요</li>
<li><code>_next</code> 파일은 <code>immutable</code> 캐시이므로 무효화와 무관하게 해시로 구분</li>
</ul>
<hr>
<h2 id="캐시-전략-요약">캐시 전략 요약</h2>
<table>
<thead>
<tr>
<th>파일 종류</th>
<th>경로 예시</th>
<th>Cache-Control</th>
<th>--delete</th>
</tr>
</thead>
<tbody><tr>
<td>JS/CSS chunk (해시)</td>
<td><code>_next/static/chunks/*.js</code></td>
<td>1년, immutable</td>
<td>없음 (이전 빌드 보존)</td>
</tr>
<tr>
<td>HTML</td>
<td><code>index.html</code>, <code>about</code></td>
<td>no-cache, must-revalidate</td>
<td>있음</td>
</tr>
<tr>
<td>이미지/폰트/기타</td>
<td><code>*.png</code>, <code>*.ico</code>, <code>robots.txt</code></td>
<td>1일</td>
<td>있음</td>
</tr>
</tbody></table>
<p>핵심 원칙:</p>
<ul>
<li><strong>해시가 포함된 파일</strong>: 장기 캐시 + 이전 버전 보존</li>
<li><strong>해시가 없는 파일</strong>: 짧은 캐시 또는 캐시 없음 + 오래된 파일 삭제</li>
</ul>
<hr>
<h2 id="--exclude--include-평가-순서">--exclude/--include 평가 순서</h2>
<p>AWS CLI의 필터는 <strong>순서대로 평가</strong>된다. 이걸 모르면 의도치 않은 결과가 나온다.</p>
<pre><code class="language-bash">--exclude &quot;*&quot;         # 1) 전부 제외
--include &quot;*.js&quot;      # 2) JS 파일만 포함
--exclude &quot;_next/*&quot;   # 3) 그 중 _next/ 안의 JS는 다시 제외</code></pre>
<p>마지막에 매칭되는 규칙이 적용된다. <code>_next/static/chunks/page.js</code>는:</p>
<ol>
<li><code>--exclude &quot;*&quot;</code> → 제외됨</li>
<li><code>--include &quot;*.js&quot;</code> → 포함됨</li>
<li><code>--exclude &quot;_next/*&quot;</code> → 다시 제외됨 ← <strong>최종 결과</strong></li>
</ol>
<hr>
<h2 id="이전-chunk-누적-관리">이전 chunk 누적 관리</h2>
<p><code>_next/</code> 폴더에 <code>--delete</code>를 안 쓰면 이전 빌드의 chunk가 계속 쌓인다. 하지만:</p>
<ul>
<li>각 빌드당 chunk 총 용량은 수 MB 수준</li>
<li>S3 스토리지 비용은 무시할 수 있는 수준 ($0.025/GB/월)</li>
<li>필요하다면 S3 Lifecycle Policy로 일정 기간이 지난 파일을 자동 삭제하는 것도 가능하다</li>
</ul>
<hr>
<h2 id="주의사항">주의사항</h2>
<ol>
<li><p><strong>Step 4의 <code>--exclude &quot;_next/*&quot;</code>는 반드시 필요하다.</strong> Step 1에서 <code>--delete</code> 없이 이전 chunk를 보존하더라도, Step 4에서 <code>--include &quot;*.js&quot; --delete</code>를 사용하면 <code>_next/</code> 안의 이전 JS 파일까지 삭제 대상이 된다. 이전 단계의 의도를 후속 단계가 무효화하지 않도록 주의하자.</p>
</li>
<li><p><strong><code>s3 sync --delete</code>를 여러 단계로 나눠 쓸 때</strong>, 각 단계의 include/exclude 범위가 겹치지 않는지 확인해야 한다. 특히 <code>_next/</code> 같은 해시된 에셋 디렉토리는 모든 단계에서 명시적으로 제외하는 것이 안전하다.</p>
</li>
<li><p><strong>해시된 에셋은 절대 삭제하면 안 된다.</strong> 활성 사용자의 브라우저가 참조하고 있을 수 있다. 삭제는 Lifecycle Policy 등 별도의 정리 작업으로 분리하자.</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] Swagger Security와 Interceptor 예외 경로를 리스트로 통일하여 관리하기]]></title>
            <link>https://velog.io/@alvin_you/Java-Swagger-Security%EC%99%80-Interceptor-%EC%98%88%EC%99%B8-%EA%B2%BD%EB%A1%9C%EB%A5%BC-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EB%A1%9C-%ED%86%B5%EC%9D%BC%ED%95%98%EC%97%AC-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@alvin_you/Java-Swagger-Security%EC%99%80-Interceptor-%EC%98%88%EC%99%B8-%EA%B2%BD%EB%A1%9C%EB%A5%BC-%EB%A6%AC%EC%8A%A4%ED%8A%B8%EB%A1%9C-%ED%86%B5%EC%9D%BC%ED%95%98%EC%97%AC-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 24 Dec 2024 05:06:47 GMT</pubDate>
            <description><![CDATA[<p>회원가입, 로그인 등의 API에서 인증(Authorization)이 필요하지 않은 경우, Swagger 자물쇠 표시 제거 설정과 Interceptor 각각에서 처리해야하는 불편함이 있습니다. 이 문제를 해결하기 위해, 예외 처리할 경로를 리스트로 관리하여 직관적이고 편리하게 수정할 수 있는 방법을 소개합니다.</p>
<ol>
<li>예외 처리할 경로 정의
먼저, 인증이 필요하지 않은 경로를 관리할 클래스를 정의합니다. 이 클래스는 예외처리할 경로를 배열로 저장하고, 이를 다른 설정에서 쉽게 사용할 수 있게 해줍니다.</li>
</ol>
<pre><code class="language-java">// constant/ExcludedPaths.java

public class ExcludedPaths {
    public static final String[] PATHS = {
            &quot;/auth/sign-up&quot;, &quot;/auth/sign-in&quot;, // 회원 가입 및 로그인
            &quot;/nice/**&quot;, // 회원가입에서 사용될 nice 본인인증
            &quot;/swagger-ui/**&quot;, &quot;/v3/api-docs/**&quot; // Swagger 문서
    };
}</code></pre>
<ol start="2">
<li>WebConfig 파일에서 예외 경로 처리
이제 WebConfig 파일에서, 예외 처리할 경로를 Interceptor에 추가하여 인증이 필요하지 않은 경로를 처리합니다. 이를 통해 모든 경로를 Interceptor가 처리하면서, 인증이 필요하지 않은 경로는 제외할 수 있습니다.<pre><code class="language-java">// config/WebConfig.java
</code></pre>
</li>
</ol>
<p>@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final AuthInterceptor authInterceptor;</p>
<pre><code>...

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authInterceptor)
            .addPathPatterns(&quot;/**&quot;)
            // 인증이 필요하지 않은 경우 Interceptor에 추가해 예외처리 해야한다.
            .excludePathPatterns(ExcludedPaths.PATHS);
}

...</code></pre><p>}</p>
<pre><code>
3. Swagger 설정 및 예외 경로에서 보안 해제
Swagger에서 경로 패턴 매칭은 자동으로 처리되지 않기 때문에, 경로를 직접 비교하여 보안 자물쇠를 해제해야 합니다. 이를 위해 OpenApiCustomizer를 사용하여 예외 경로에서 보안 항목을 제거합니다.

``` java
// config/SpringConfig.java

@Configuration
public class SpringConfig {
    @Bean
    public OpenAPI openAPI() {
        String jwt = &quot;JWT&quot;;
        SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt);
        Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme()
                .name(&quot;Authorization&quot;)
                .type(SecurityScheme.Type.HTTP)
                .scheme(&quot;bearer&quot;)
                .in(SecurityScheme.In.HEADER)
                .bearerFormat(jwt)
        );

        return new OpenAPI()
                .components(components)
                .info(new Info().title(&quot;Swagger&quot;).description(&quot;Backend API Swagger UI&quot;).version(&quot;0.0.1&quot;))
                .addSecurityItem(securityRequirement);
    }

    @Bean
    public OpenApiCustomizer openApiCustomizer() {
        return openApi -&gt; {
            AntPathMatcher antPathMatcher = new AntPathMatcher();
            // ExcludedPaths 경로들은 자물쇠를 해제
            for (String pattern : ExcludedPaths.PATHS) {
                // 모든 경로를 확인하여 패턴 매칭
                for (Map.Entry&lt;String, PathItem&gt; entry : openApi.getPaths().entrySet()) {
                    String path = entry.getKey();
                    PathItem pathItem = entry.getValue();

                    // 경로 패턴이 일치하면 자물쇠 해제
                    if (antPathMatcher.match(pattern, path)) {
                        pathItem.readOperations().forEach(operation -&gt; {
                            // 보안 항목을 없애는 방식으로 자물쇠 해제
                            operation.setSecurity(new ArrayList&lt;&gt;());
                        });
                    }
                }
            }
        };
    }
}</code></pre><ol start="4">
<li>결과
이 설정을 통해 새로운 API에서 인증이 필요하지 않은 경로가 생기면, ExcludedPaths에 경로만 추가하면 됩니다. Swagger와 Interceptor에서 각각 두 번 작업하는 불편함을 해소할 수 있습니다. 간편하게 예외 경로를 관리하고, 보안 설정을 자동으로 처리할 수 있어 개발이 더욱 편리해집니다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Daily Schedule Slack Bot V2 (with flex)]]></title>
            <link>https://velog.io/@alvin_you/Daily-Schedule-Slack-Bot-V2-with-flex</link>
            <guid>https://velog.io/@alvin_you/Daily-Schedule-Slack-Bot-V2-with-flex</guid>
            <pubDate>Thu, 27 Apr 2023 08:29:27 GMT</pubDate>
            <description><![CDATA[<p>이번 회사에서도 <a href="https://flex.team/">Flex(HR 근태관리솔루션)</a>를 도입하게 되었다. 스타트업답게 슬랙은 당연히 사용하고 있었다.</p>
<p>지난번에 만든 것을 그대로 가져와서 슬랙을 이롭게 해볼까! 싶었지만 Flex의 로그인 시스템이 대대적인 개선이 이루어졌는지 기존 로그인 로직과 응답구조가 전체적으로 개편되어 있었다. <del>분석하는데 굉장히 애를 먹었지만</del></p>
<p>또한 현재 회사는 특별한 근무 구조를 가지고 있어, 13-17시만 고정근무하고 주간 40시간을 자유롭게 채우는 형태로 되어있어서 기존처럼 특정시간에만 알람을 줄 수 없었다. 그래서 알람을 한번만 보내는 것이 아닌 특정 시간마다 실행하여 매일 여러번 업데이트를 하게 만들었다. 규칙은 간단하게 아침 9시에 첫번째 슬랙을 보내고 30분 간격으로 업데이트를 하게 했다.</p>
<p><img src="https://velog.velcdn.com/images/alvin_you/post/177a157f-a5f0-4e12-ae34-491a8a40246c/image.png" alt="슬랙 메시지"></p>
<h4 id="결과물을-만들기-위해-사용한-기술은-다음과-같다">결과물을 만들기 위해 사용한 기술은 다음과 같다.</h4>
<ul>
<li>Node.js</li>
<li>AWS (Lambda, EventBridge, S3)</li>
<li>Slack API</li>
<li>Flex API <del>(API 문서를 볼 수 없어 플랙스 홈페이지를 직접 디버깅 했다)</del></li>
</ul>
<p>따로 Cron Job을 돌릴만한 컴퓨터가 없어서 Amazon EventBridge와 Lambda를 이용해 특정 시간에 기능을 실행할 수 있게 했다.</p>
<blockquote>
<p> Amazon EventBridge는 Cron job schedule로 매일 오전 9시부터 30분 간격으로 Lambda를 실행한다.
<code>0,30 0-14 ? * 2-6 *</code></p>
</blockquote>
<h4 id="lambda에서-돌아가는-코드는-다음과-같은-실행-순서를-갖는다">Lambda에서 돌아가는 코드는 다음과 같은 실행 순서를 갖는다.</h4>
<ol>
<li>Flex API를 이용하여 로그인하고 토큰을 획득한다.<blockquote>
<p>Flex 로그인은 <code>challenge</code> &gt; <code>identifier</code> &gt; <code>authentication</code> &gt; <code>password</code> &gt; <code>authorization</code> &gt; <code>customerUser</code> &gt; <code>exchange</code>를 거쳐 <code>Access Token</code>을 획득할 수 있게 되어있다. 기본적으로 Cookie에 내용을 담아 통신하는 형태로 보인다. 
투자 많이 받고 보안이 까다로워진것 같다. <del>부럽다... 우리 회사도 투자좀...</del></p>
</blockquote>
</li>
<li>Flex API를 이용하여 User List(회사 직원 정보)를 가져온다.</li>
<li>Flex API를 이용하여 User별 스케쥴을 가져온다.</li>
<li>스케쥴을 근무형태(근무, 원격근무, 외근, 출장, 오전반차, 오후반차)에 맞춰 변형한다.<blockquote>
<p>근무 형태에 따라 <code>workStartRecordType</code>이라는 값이 달라지는데 API 문서가 없이 화면만으로 각각의 의미를 찾는데 좀 힘들었다.
또한 Flex API 응답이 단순하게 근무를 시작한 경우와 시작/끝을 등록한 경우를 다르게 처리해 까다롭게 처리해야 했다.
스케쥴은 시간에 따라 출근전/근무중/퇴근후 로 나뉘기 때문에 각각의 상태에 따라 시간을 잘 표시해줄 수 있게 처리해야 했다.</p>
</blockquote>
</li>
<li>2.와 4.를 합쳐 User 별로 스케쥴을 구성한다.</li>
<li>User의 팀 정보를 이용해 팀별로 User를 묶는다.</li>
<li>슬랙 메시지를 Post(혹은 Update)한다.</li>
<li>슬랙 메시지 Update를 위해 ts, channel을 s3에 저장한다.</li>
</ol>
<h4 id="전체코드는-다음과-같다">전체코드는 다음과 같다.</h4>
<pre><code class="language-javascript">const axios = require(&quot;axios&quot;);
const AWS = require(&quot;aws-sdk&quot;);

const ACCESS_KEY_ID = &quot;&quot;;
const SECRET_ACCESS_KEY = &quot;&quot;;

const challengeURL = &quot;https://flex.team/api-public/v2/auth/challenge&quot;;
const identifierURL = &quot;https://flex.team/api-public/v2/auth/verification/identifier&quot;;
const authenticationURL = &quot;https://flex.team/api-public/v2/auth/authentication&quot;;
const passwordURL = &quot;https://flex.team/api-public/v2/auth/authentication/password&quot;;
const authorizationURL = &quot;https://flex.team/api-public/v2/auth/authorization&quot;;
const customerUserURL = &quot;https://flex.team/api-public/v2/auth/tokens/customer-user&quot;;
const exchangeURL = &quot;https://flex.team/api-public/v2/auth/tokens/customer-user/exchange&quot;;

const workSchedulesURL = &quot;https://flex.team/api/v2/time-tracking/users/work-schedules&quot;;

const customerIdHash = &quot;&quot;; // 회사 customerIdHash, Flex API를 디버깅해보면 본인 회사코드를 알수 있을 것이다. 10자리의 영숫자로 되어있다.
const searchUsersURL = `https://flex.team/action/v2/search/customers/${customerIdHash}/search-users`;

const slackPostMessageURL = &quot;https://slack.com/api/chat.postMessage&quot;;
const slackUpdateMessageURL = &quot;https://slack.com/api/chat.update&quot;;

AWS.config.update({
  region: &quot;ap-northeast-2&quot;,
  credentials: {
    accessKeyId: ACCESS_KEY_ID,
    secretAccessKey: SECRET_ACCESS_KEY,
  },
});

const Bucket = &quot;Bucket-Name&quot;;
const Key = &quot;daily-check-in.json&quot;;
const s3 = new AWS.S3({ params: { Bucket } });

const timetable = async () =&gt; {
  const now = new Date();

  const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6;
  if (isWeekend) return; // 주말 예외 처리

  const challenge = await axios
    .post(challengeURL, {
      deviceInfo: { os: &quot;web&quot;, osVersion: &quot;&quot;, appVersion: &quot;&quot; },
      locationInfo: {},
    })
    .then(({ data }) =&gt; data);

  // console.log(challenge.sessionId);

  await axios
    .post(
      identifierURL,
      { identifier: &quot;&quot; }, // Login Email
      {
        headers: {
          cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
          &quot;flexteam-v2-login-session-id&quot;: challenge.sessionId,
        },
      }
    )
    .then(({ data }) =&gt; data);

  // console.log(identifier);

  const authentication = await axios
    .get(authenticationURL, {
      headers: {
        cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
        &quot;flexteam-v2-login-session-id&quot;: challenge.sessionId,
      },
    })
    .then(({ data }) =&gt; data);

  // console.log(authentication);

  const password = await axios
    .post(
      passwordURL,
      { password: &quot;&quot; }, // Login Password
      {
        headers: {
          cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
          &quot;flexteam-v2-login-session-id&quot;: challenge.sessionId,
        },
      }
    )
    .then(({ data }) =&gt; data);

  // console.log(password);

  const authorization = await axios
    .post(
      authorizationURL,
      {},
      {
        headers: {
          cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
          &quot;flexteam-v2-login-session-id&quot;: challenge.sessionId,
        },
      }
    )
    .then(({ data }) =&gt; data);

  // console.log(authorization.v2Response.workspaceToken);

  const customerUser = await axios
    .get(customerUserURL, {
      headers: {
        cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
        &quot;flexteam-v2-workspace-access&quot;: authorization.v2Response.workspaceToken.accessToken.token,
      },
    })
    .then(({ data }) =&gt; data[0]);

  // console.log(customerUser);

  const exchange = await axios
    .post(exchangeURL, customerUser, {
      headers: {
        cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
        &quot;flexteam-v2-workspace-access&quot;: authorization.v2Response.workspaceToken.accessToken.token,
      },
    })
    .then(({ data }) =&gt; data);

  const AID = exchange.token;

  const today = new Date();
  today.setHours(0);
  today.setMinutes(0);
  today.setSeconds(0);
  today.setMilliseconds(0);
  const tomorrow = new Date(today);
  tomorrow.setDate(today.getDate() + 1);

  // 전문연 등으로 인한 실제팀과 플랙스 팀이름이 약간 다른 경우를 위해
  const getTeamName = (teamName) =&gt; {
    switch (teamName) {
      case &quot;&quot;:
        return &quot;&quot;;
      default:
        return teamName;
    }
  };

  const users = await axios
    .post(
      searchUsersURL + &quot;?size=50&quot;,
      {
        filter: {
          departmentIdHashes: [],
          userStatuses: [
            &quot;LEAVE_OF_ABSENCE&quot;,
            &quot;LEAVE_OF_ABSENCE_SCHEDULED&quot;,
            &quot;RESIGNATION_SCHEDULED&quot;,
            &quot;IN_EMPLOY&quot;,
            &quot;IN_APPRENTICESHIP&quot;,
          ],
        },
      },
      { headers: { cookie: `AID=${AID};` } }
    )
    .then(({ data }) =&gt;
      data.list.map(({ user }) =&gt; ({
        userIdHash: user.userIdHash,
        name: user.name,
        departmentName: getTeamName(user.positions[0].department.name),
      }))
    );

  // console.log(users);

  const userIdHashParam = users.map((user) =&gt; `userIdHashes=${user.userIdHash}`).join(&quot;&amp;&quot;);
  const workSchedules = await axios
    .get(
      workSchedulesURL +
        `?${userIdHashParam}&amp;timeStampFrom=${today.valueOf()}&amp;timeStampTo=${tomorrow.valueOf()}`,
      { headers: { cookie: `AID=${AID};` } }
    )
    .then(({ data }) =&gt; {
      const day = today.getDay() - 1; // 일월화수목금토 -&gt; 월화수목금토일로 인덱스 수정, 토일은 위에서 예외처리 되었기때문에 -1만해도 괜찮다.

      // console.log(data.workScheduleResults);

      const workScheduleResults = data.workScheduleResults.map((workSchedule) =&gt; ({
        userIdHash: workSchedule.userIdHash,

        workType:
          workSchedule.days[day].workRecords[workSchedule.days[day].workRecords?.length - 1 || 0]
            ?.name,
        blockTimeFrom: workSchedule.days[day].workRecords[0]?.blockTimeFrom.timeStamp,
        blockTimeTo:
          workSchedule.days[day].workRecords[workSchedule.days[day].workRecords.length - 1]
            ?.blockTimeTo.timeStamp,
        workStartRecordType:
          workSchedule.days[day].workStartRecords[
            workSchedule.days[day].workStartRecords?.length - 1 || 0
          ]?.customerWorkFormId,
        workStartRecordFrom: workSchedule.days[day].workStartRecords[0]?.blockTimeFrom?.timeStamp,
        timeOffType: workSchedule.days[day].timeOffs[0]?.timeOffRegisterUnit,
        timeOffBlockTimeFrom: workSchedule.days[day].timeOffs[0]?.blockTimeFrom?.timeStamp,
        timeOffBlockTimeTo: workSchedule.days[day].timeOffs[0]?.blockTimeTo?.timeStamp,
      }));

      return workScheduleResults.map((obj) =&gt; {
        // workType 재정의
        if (obj.timeOffType === &quot;DAY&quot;) obj.workType = &quot;휴가&quot;;
        else if (
          obj.timeOffType === &quot;HALF_DAY_PM&quot; &amp;&amp; // 오후반차이고
          obj.timeOffBlockTimeFrom &lt;= now // 오후반차 시작시간보다 크면
        )
          obj.workType = &quot;휴가&quot;;
        else if (
          obj.timeOffType === &quot;HALF_DAY_AM&quot; &amp;&amp; // 오전반차이고
          obj.timeOffBlockTimeFrom &lt;= now &amp;&amp; // 현재가 해당 시간이라면
          now &lt;= obj.timeOffBlockTimeTo
        )
          obj.workType = &quot;휴가&quot;;
        else if (obj.workStartRecordType === &quot;85611&quot;) obj.workType = &quot;근무&quot;;
        else if (obj.workStartRecordType === &quot;85613&quot;) obj.workType = &quot;외근&quot;;
        else if (obj.workStartRecordType === &quot;85614&quot;) obj.workType = &quot;원격 근무&quot;;
        else if (obj.workStartRecordType === &quot;85615&quot;) obj.workType = &quot;출장&quot;;

        // blockTimeFrom 재정의.
        obj.blockTimeFrom = obj.blockTimeFrom || obj.workStartRecordFrom;

        return obj;
      });
    });

  // console.log(workSchedules);

  const mergeUserInformation = users.reduce((acc, obj) =&gt; {
    acc[obj.userIdHash] = obj;

    return acc;
  }, {});

  workSchedules.forEach((workSchedule) =&gt; {
    mergeUserInformation[workSchedule.userIdHash] = {
      ...mergeUserInformation[workSchedule.userIdHash],
      ...workSchedule,
    };
  });

  // console.log(mergeUserInformation);

  const groupByDepartment = Object.entries(mergeUserInformation).reduce((acc, [_, user]) =&gt; {
    const key = user.departmentName;
    if (!acc[key]) acc[key] = [];
    acc[key].push(user);

    return acc;
  }, {});

  // console.log(groupByDepartment);

  const getEmoji = (workType) =&gt; {
    switch (workType) {
      case &quot;근무&quot;:
        return &quot;office&quot;;
      case &quot;원격 근무&quot;:
        return &quot;heads-down&quot;;
      case &quot;외근&quot;:
        return &quot;taxi&quot;;
      case &quot;휴가&quot;:
        return &quot;beach_with_umbrella&quot;;
      case &quot;출장&quot;:
        return &quot;airplane&quot;;
    }
  };

  const getTimeString = (type, from, to) =&gt; {
    if (type === &quot;휴가&quot;) return &quot;&quot;;
    else if (to === undefined)
      return `\`${new Date(from).toLocaleTimeString(&quot;ko-KR&quot;, { timeZone: &quot;asia/seoul&quot; })} ~ \``;
    else
      return `\`${new Date(from).toLocaleTimeString(&quot;ko-KR&quot;, {
        timeZone: &quot;asia/seoul&quot;,
      })} ~ ${new Date(to).toLocaleTimeString(&quot;ko-KR&quot;, { timeZone: &quot;asia/seoul&quot; })}\``;
  };

  const diffHour = (date1, date2) =&gt; {
    const diff = new Date(date2).valueOf() - new Date(date1).valueOf();
    const diffInHours = diff / 1000 / 60 / 60;

    return diffInHours.toFixed(2);
  };

  let message = `&gt;*${formatDate()} 데일리 체크인*\n:office: 사무실 :heads-down: 원격 근무 :taxi: 외근 :airplane: 출장 :beach_with_umbrella: 휴가\n`;

  // 슬랙 메시지에 표시될 팀명 정렬을 위한 팀 이름 리스트
  const departmentNames = [
    &quot;개발팀&quot;,
    &quot;디자인팀&quot;,
    &quot;아무개팀&quot;,
  ];

  departmentNames.forEach((departmentName) =&gt; {
    const users = groupByDepartment[departmentName];

    if (!users) return;

    if (users.filter((user) =&gt; user.workType !== undefined).length === 0) return;

    message += `&gt;${departmentName}\n`;
    users
      .filter((user) =&gt; user.workType !== undefined)

      .forEach((user) =&gt; {
        message += `:${getEmoji(user.workType)}: ${user.name}님 ${getTimeString(
          user.workType,
          user.blockTimeFrom,
          user.blockTimeTo
        )} ${
          user.workType !== &quot;휴가&quot; &amp;&amp; user.blockTimeTo
            ? `(Day: ${diffHour(user.blockTimeFrom, user.blockTimeTo)}h)`
            : &quot;&quot;
        } \n`;
      });

    message += &quot;\n&quot;;
  });

  const response = await s3.getObject({ Bucket, Key }).promise();
  const { ts, channel } = JSON.parse(response.Body?.toString(&quot;utf-8&quot;));

  const tsDate = new Date(ts * 1000);
  const needPost = tsDate.getDate() !== now.getDate(); // 당일인지 체크 후 메세지를 보내거나 수정한다.

  if (needPost) {
    const slackMessage = await axios
      .post(
        slackPostMessageURL,
        { channel: &quot;daily_check-in&quot;, text: message, invalid_charset: &quot;UTF-8&quot; },
        {
          headers: {
            &quot;Content-type&quot;: &quot;application/json; charset=utf-8&quot;,
            Authorization: &quot;슬랙토큰&quot;,
          },
        }
      )
      .then(({ data }) =&gt; data);

    await s3
      .putObject({
        Bucket,
        Key,
        Body: JSON.stringify({ ts: slackMessage.ts, channel: slackMessage.channel }),
        CacheControl: &quot;no-store&quot;,
      })
      .promise();
  } else {
    const slackMessage = await axios
      .post(
        slackUpdateMessageURL,
        { channel, text: message, invalid_charset: &quot;UTF-8&quot;, ts },
        {
          headers: {
            &quot;Content-type&quot;: &quot;application/json; charset=utf-8&quot;,
            Authorization: &quot;슬랙토큰&quot;,
          },
        }
      )
      .then(({ data }) =&gt; data);
  }
};

function formatDate(date = new Date()) {
  const d = date instanceof Date ? date : new Date();
  let month = &quot;&quot; + (d.getMonth() + 1);
  let day = &quot;&quot; + d.getDate();
  const year = d.getFullYear();

  if (month.length &lt; 2) month = &quot;0&quot; + month;
  if (day.length &lt; 2) day = &quot;0&quot; + day;

  return [year, month, day].join(&quot;.&quot;);
}

exports.handler = async (object) =&gt; {
  await timetable();

  let response = { statusCode: 200 };
  return response;
};
</code></pre>
<p><del>코드가 너무 너무 길다...</del></p>
<h4 id="제작하면서-신경쓴-점">제작하면서 신경쓴 점</h4>
<ul>
<li>플랙스 로그인 너무너무 어려웠습니다. 분석만 몇시간 걸린듯ㅜㅜ </li>
<li>회사의 근무 형태가 자유로워 그에 맞춰 처리하기 위한 로직</li>
</ul>
<h4 id="느낀점">느낀점</h4>
<p>플랙스 홈페이지 들어가면 로딩이 너무 느려서 사람들 데이터 보기가 너무 힘들었고 근무정보를 제대로 볼수가 없어서 불편했는데, 슬랙으로 편하게 볼 수 있어서 맘에 든다 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Github Action와 Firebase Hosting를 이용한 배포 자동화]]></title>
            <link>https://velog.io/@alvin_you/Setting-up-Github-Action-and-Firebase-Hosting</link>
            <guid>https://velog.io/@alvin_you/Setting-up-Github-Action-and-Firebase-Hosting</guid>
            <pubDate>Wed, 16 Mar 2022 10:17:33 GMT</pubDate>
            <description><![CDATA[<p>프론트앤드 서비스를 쉽게 배포하는 방법은 무엇일까? 회사에서는 어차피 firebase를 사용하고 있고 코드는 Github에 있으니, 두개를 조합해서 쉽게 배포라인을 구축할 수는 없을까? </p>
<p>Github Action과 firebase hosting을 이용해 배포를 자동화해보자!</p>
<p>각 서비스에 배포를 위해 github에서 특정 브랜치에 <code>master</code>, <code>develop</code> Push될때마다 Github Action이 자동으로 실행되도록 만들어보았습니다.</p>
<h3 id="firebase-ci-login">Firebase ci login</h3>
<ol>
<li>github내에서 firebase를 이용할 수 있도록 firebase-tools을 이용하여 firebase token을 얻는다.</li>
</ol>
<pre><code class="language-bash">yarn global add firebase-tools
firebase login:ci # Get Token</code></pre>
<p><img src="https://images.velog.io/images/alvin_you/post/08c29035-3b8a-4148-b847-09518631a337/image.png" alt="get token"></p>
<ol start="2">
<li>생성된 토큰을 Github &gt; settings &gt; secrets 에 세팅한다. (FIREBASE_TOKEN)</li>
</ol>
<p><img src="https://images.velog.io/images/alvin_you/post/76a3eae1-456d-45ea-b205-30f5a3513fdf/image.png" alt="set token"></p>
<h3 id="firebase-init">Firebase init</h3>
<p>자신의 환경에 맞게 public 등의 설정을 해야한다.</p>
<pre><code class="language-bash">firebase login
firebase init hosting # firebase.json, .firebaserc 파일 생성</code></pre>
<p><img src="https://images.velog.io/images/alvin_you/post/a584f44f-811d-49f3-9f83-0a3a63d550c4/image.png" alt="firebase init hosting"></p>
<h3 id="firebase-project-생성">Firebase Project 생성</h3>
<ol>
<li><p><a href="https://console.firebase.google.com/">https://console.firebase.google.com/</a> 에서 firebase project 를 생성한다.</p>
</li>
<li><p>생성시 프로젝트 명은 상관없으나, 프로젝트 ID 는 [Github workflows setting] 에서 사용되니 세팅에 <strong>주의</strong>해야 한다.</p>
</li>
<li><p>프로젝트 ID를 <code>service-name-master</code>, <code>service-name-develop</code>로 정해 github action 내에서 실행시 브랜치의 이름에 따라 자동으로 배포가 되도록 해두었다. (yml 파일 참고)</p>
</li>
</ol>
<h3 id="firebase-hosting-도메인-설정">Firebase Hosting 도메인 설정</h3>
<ol>
<li><p>firebase &gt; hosting &gt; 커스텀 도메인 버튼을 이용하여 메뉴에서 알맞은 domain을 생성한다.
<img src="https://images.velog.io/images/alvin_you/post/481476e9-c06b-4baa-97ca-f675d638003f/image.png" alt=""></p>
</li>
<li><p>도메인 생성시 소유권확인을 위해 Cloud DNS TXT에 추가해야 한다. </p>
</li>
<li><p>추가 후 서비스를 위해 Cloud DNS에 Record를 추가한다.</p>
</li>
</ol>
<h3 id="github-workflows-설정">Github workflows 설정</h3>
<ol>
<li>Github 내에 .github/workflows/main.yml 을 생성한다. (yml 파일의 파일명은 상관없다.)</li>
</ol>
<pre><code class="language-yml">name: CI

# 여기 설정되는 값으로 Github가 트리거된다.
on:
  push:
    branches:
      - develop
      - master

jobs:
  build:
    name: Deployment for Develop env
    runs-on: ubuntu-latest

    steps:
      - name: Checkout branch
        uses: actions/checkout@v2

      - name: Install dependencies
        uses: borales/actions-yarn@v2.3.0
        with:
          cmd: install --production # will run `yarn install` command

      # 위에서 맞춰두었던 프로젝트 아이디가 여기서 사용됩니다.
      # 빌드용 스크립트를 나누어 build:master, build:develop 등을 구분해서 실행할 수 있게 합니다.
      - name: Create variables
        id: vars
        run: |
          branch=${GITHUB_REF##*/}
          echo &quot;::set-output name=PROJECT_ID::service-name-${branch}&quot; 
          echo &quot;::set-output name=BUILD_SCRIPT::build:${branch}&quot;

      - name: Build
        uses: borales/actions-yarn@v2.3.0
        with:
          cmd: ${{ steps.vars.outputs.BUILD_SCRIPT }}

      - name: Deploy
        uses: w9jds/firebase-action@master
        with:
          args: deploy --only hosting
        env:
          CI: true
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          PROJECT_ID: ${{ steps.vars.outputs.PROJECT_ID }}</code></pre>
<p>firebase의 project ID 와 git의 branch 를 조합하여 배포환경을 설정했다.</p>
<ol start="2">
<li><p>위의 <strong>BUILD_SCRIPT</strong>가 정상적으로 실행되려면 package.json 파일에 다음과 같이 추가해야한다. 빌드시 필요한 내용은 프로젝트 상황에 맞게 변경해야 한다.</p>
<pre><code class="language-javascript">&quot;scripts&quot;: {
 ...
 &quot;build:develop&quot;: &quot;webpack&quot;,
 &quot;build:master&quot;: &quot;NODE_ENV=production webpack&quot;,
 ...
},</code></pre>
</li>
<li><p>짜잔 성공적!
<img src="https://images.velog.io/images/alvin_you/post/a7797f1c-9d66-4bb5-b5b8-e7811d007e8d/image.png" alt="Success!"></p>
</li>
</ol>
<h3 id="참고">참고</h3>
<p><a href="https://jec.fyi/blog/setting-up-github-actions-and-firebase-hosting">https://jec.fyi/blog/setting-up-github-actions-and-firebase-hosting</a></p>
<p><a href="https://medium.com/jahia-techblog/decorate-your-prs-with-sonarqube-and-github-actions-40e6990de242">https://medium.com/jahia-techblog/decorate-your-prs-with-sonarqube-and-github-actions-40e6990de242</a></p>
<p><a href="https://firebase.google.com/docs/hosting/custom-domain?hl=ko">https://firebase.google.com/docs/hosting/custom-domain?hl=ko</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Image Polygonal Lasso Tool 구현하기]]></title>
            <link>https://velog.io/@alvin_you/Image-Polygonal-Lasso-Tool-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@alvin_you/Image-Polygonal-Lasso-Tool-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 02 Mar 2022 13:34:19 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 진행하면서 실시간으로 이미지를 정밀하게 깍아내는 기능이 필요했다.
이미지를 처리할때 매번 네트워크를 통해 백엔드에서 처리하다보니 반응속도가 느려졌고, 이는 곧 UX의 저하로 이어졌다. 이미지 처리를 백엔드에서 하려면 이러한 과정을 거치게 된다.</p>
<blockquote>
<p>네트워크 요청 &gt; 이미지 로딩 &gt; 이미지 프로세싱 &gt; 이미지 생성 &gt; 이미지 저장 &gt; 네트워크 응답</p>
</blockquote>
<p>이미지를 프론트엔드에서 처리하면 이미지는 이미 로딩되어있기 때문에 많은 단계를 줄일 수 있다. 이는 곧 UX향상과 코스트 절감으로 연결된다. </p>
<blockquote>
<p><del>네트워크 요청</del> &gt; <del>이미지 로딩</del> &gt; 이미지 프로세싱 &gt; 이미지 생성 &gt; 이미지 저장 &gt; <del>네트워크 응답</del></p>
</blockquote>
<p>또한 이미지는 클라우드 스토리지(S3 등)로 직접 업로드하여 백엔드에서 처리할 일을 굉장히 줄여줄 수 있다.
따라서 프론트엔드에서 빠르고 정밀하게 이미지를 처리할 수 있는 기능을 만들어야했는데, HTML5 canvas library인 <a href="http://fabricjs.com/">fabric.js</a>를 이용해 구현한 경험을 공유하고자 한다.</p>
<p>결과물부터 보면 다음과 같다. <a href="https://alvinyou.github.io/Lasso-Demo/">Demo link</a>
<img src="https://images.velog.io/images/alvin_you/post/438e9d0d-a16c-4f9e-8a5b-aa5b31adda2a/image.png" alt="Demo Preview"></p>
<p>캔버스에 클릭 이벤트를 통해 생성되는 선(path)의 시작점과 끝점을 이어 다각형(polygon)으로 만들어 해당 부분을 이미지에서 제거한다.
이미지를 깍아내기 위한 로직은 다음과 같다.</p>
<ol>
<li>이미지에서 클릭 이벤트를 통해 선을 생성한다.<ul>
<li>Mouse click event(down &gt; move &gt; up)를 이용하여 선(path)을 그린다.</li>
</ul>
</li>
<li>생성된 선의 시작점과 끝점을 이어 다각형으로 변환한다. 생성되었던 선은 삭제한다.</li>
<li>만들어진 다각형을 하얀색으로 채운다. (fill)</li>
<li>기존 이미지에 다각형을 겹쳐 새로운 이미지를 생성한다.</li>
<li>캔버스에 있는 오브젝트(원본 이미지, 다각형)를 제거하고 생성된 이미지를 캔버스에 추가한다.</li>
</ol>
<h4 id="핵심-로직-demo-code">핵심 로직 <a href="https://github.com/AlvinYou/Lasso-Demo">Demo code</a></h4>
<pre><code class="language-typescript">const canvas = new fabric.Canvas(canvasRef.current);

const handleCreatedPath = (e) =&gt; {
  const path: fabric.Path = e.path;

  // 생성된 path를 이용하여 polygon을 만들어 해당 영역에 덮어쓸 준비를 한다.
  const polygon = new fabric.Polygon(
    path.path!
      .flat()
      .filter((v) =&gt; typeof v === &#39;number&#39;)
      .reduce((pre, curr, index) =&gt; {
        if (index % 2 === 0) pre.push({ x: curr });
        else pre[Math.floor(index / 2)].y = curr;

        return pre;
      }, []),
    { fill: &#39;white&#39; }
  );

  // canvas에서 파란선을 제거하고 하얀 polygon만 남긴다.
  canvas.remove(path);
  canvas.add(polygon);

  // 기존 이미지는 제거하고 새로운 이미지를 추가한다.
  const newImage = canvas.toDataURL({});
  fabric.Image.fromURL(newImage, (image) =&gt; {
    canvas.remove(...canvas.getObjects());
    canvas.add(image);
  });
};

canvas.on(&#39;path:created&#39;, handleCreatedPath);</code></pre>
<p><del>원래는 Rotate, vertical flip, horizontal flip까지 처리해야해서 머리 아팠는데 다 빼고보니 너무 간단해보인다...ㅠㅠ</del></p>
<p>위의 편집가능한 캔버스를 통하여 프론트엔드에서 이미지를 직접처리할 수 있는 기능을 만들어보았다. 위에서는 간단하게 그려진 부분의 이미지를 제거하는 기능만 만들었지만, 해당 부분만 추출하는 등으로 응용도 가능하다. 
사용자에게 더 좋은 경험을 제공해주기 위해 위와 같은 꽤 복잡한 작업이라도 프론트엔드에서 하는 것이 옳다고 생각한다. 또한 이미지의 처리 비용까지 생각한다면 일석이조가 아닐까? 가능하다면 앞으로는 프론트엔드에서 더 많은 기능들을 처리해 보자 :)
이외에도 이미지의 선명도를 높이기 위해 opencv를 붙여 이미지 sharpening 기능도 제작했었는데, 관련된 포스팅은 다음 기회에 해보겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[import 없이 함수 호출하기]]></title>
            <link>https://velog.io/@alvin_you/import-%EC%97%86%EC%9D%B4-%ED%95%A8%EC%88%98-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@alvin_you/import-%EC%97%86%EC%9D%B4-%ED%95%A8%EC%88%98-%ED%98%B8%EC%B6%9C%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 28 Feb 2022 08:36:17 GMT</pubDate>
            <description><![CDATA[<p>자주 사용하는 함수를 더 편리하게 사용할 방법은 없을까?
프론트앤드 개발을 하다보면, 여러가지 데이터에 대하여 서로 다른 형태로 표기해야할 일이 많이 있다.
예를들어 Date는 너무나 다양한 방법으로 표기가 가능하다.</p>
<ul>
<li>YYYY.MM.DD</li>
<li>MM.DD hh.mm</li>
<li>x days ago</li>
<li>...</li>
</ul>
<p>이렇게 백앤드로부터 받는 데이터는 string(혹은 number) type이다.</p>
<pre><code class="language-javascript">{
  createdAt: &quot;2021-04-21T01:00:59&quot;, // ISOString
  sentTime: 1645933201, // second
}</code></pre>
<p>편리하게 우리가 원하는 데이터로 가공할 방법은 없을까?
보통 이러한 형태의 데이터를 Backend에서 받아오고 가공하는 함수를 만들어 처리할 것이다.</p>
<pre><code class="language-typescript">// util.ts
import moment from &#39;moment&#39;;

// string(ISOString) to YYYY.MM.DD
export function toDateDisplay(dateString: string){
  const date = new Date(dateString);

  if (date instanceof Date &amp;&amp; !isNaN(date.getTime()))
    return moment(date).format(&#39;YYYY.MM.DD&#39;);
  else {
    if (this !== &#39;&#39;) console.warn(`[${this}] Invalid Date.`);
    return &#39;&#39;;
  }
}

// other.ts
import { toDateDisplay } from &#39;~/util&#39;;

class Something {
  ...
  createdAt: string;
  updatedAt: string;
  ...
}

const data = await fetch(...);
const something = new Something(data);

toDateDisplay(something.createdAt);

// another.ts
import { toDateDisplay } from &#39;~/util&#39;;

...
toDateDisplay(something.createdAt);
...</code></pre>
<p>매번 import 해서 불러오는게 가장 좋은 방법일까?
어떻게해야 더 효율적으로 변경할 수 있을까? 
<strong>여기서 나는 string에 prototype을 확장하여 해결하고자 한다.</strong></p>
<blockquote>
<p>이 방법은 관점에 따라 유연하며 실용적일 수 있지만, 원시데이터 타입에 기능을 추가하는 것이기 때문에 불편하다고 생각하실 수 있습니다.</p>
</blockquote>
<pre><code class="language-typescript">// index.d.ts
declare global {
  interface String {
    toDateDisplay: () =&gt; string; // YYYY.MM.DD
  }
}

// index.ts
import moment from &#39;moment&#39;;

String.prototype.toDateDisplay = function (this: string) {
  const date = new Date(this);

  if (date instanceof Date &amp;&amp; !isNaN(date.getTime()))
    return moment(date).format(&#39;YYYY.MM.DD&#39;);
  else {
    if (this !== &#39;&#39;) console.warn(`[${this}] Invalid Date.`);
    return &#39;&#39;;
  }
};</code></pre>
<pre><code class="language-typescript">// 기존 함수 호출
import { toDateDisplay } from &#39;~/util&#39;;

toDateDisplay(something.createdAt); // YYYY.MM.DD</code></pre>
<pre><code class="language-typescript">// prototype 추가
something.createdAt.toDateDisplay(); // YYYY.MM.DD</code></pre>
<p>위와 같이 String의 Prototype에 함수를 추가하여 Date 표기를 위한 함수를 추가했다. 원시 데이터 타입에 함수를 추가하여 불편할 수는 있지만, 편의성을 제공한다는 관점에서는 사용해볼만하다고 생각한다. 하지만 원시타입에 함수를 추가하는 것이므로 남발하지 않도록 자제해야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Axios를 이용한  JWT Refresh 자동화(with. debounce)]]></title>
            <link>https://velog.io/@alvin_you/Axios-Interceptor%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-JWT-%EA%B0%B1%EC%8B%A0-%EC%9E%90%EB%8F%99%ED%99%94with.-debounce</link>
            <guid>https://velog.io/@alvin_you/Axios-Interceptor%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-JWT-%EA%B0%B1%EC%8B%A0-%EC%9E%90%EB%8F%99%ED%99%94with.-debounce</guid>
            <pubDate>Fri, 25 Feb 2022 06:30:46 GMT</pubDate>
            <description><![CDATA[<p>화면에서 페이지가 전환될 때 각각의 컴포넌트들은 생각보다 많은 네트워크 요청을 보낸다. 
만약 Token이 만료되어있다면, 각각의 컴포넌트 요청에서 토큰을 갱신하고 기존 네트워크 요청에 연결해야하는데... 
적어도 Token Refresh 요청만이라도 통합할수 없을까?</p>
<p>Axios를 이용해서 이 문제를 해결해보고자 한다. 내가 생각한 아이디어는 간단하다. 일반적인 요청은 아래와 같이 동작한다. 각각의 Reqeust마다 적절한 Response를 받는다.
<img src="https://images.velog.io/images/alvin_you/post/d789e453-3a1f-4fcb-9a3a-c95c7e0d0611/image.png" alt="Axios 객체 요청 정상 작동"></p>
<p>이때 Axios instance는 모두 같은 토큰을 가지고 있기 때문에 만료된 토큰으로 요청한다면 이렇게 동작할 것이다. 근데 이때 동작하는 Refresh Token함수는 1) 서버로부터 새로운 토큰을 받아오고 2) 새로 받아온 토큰을 local storage에 저장하는 등 생각보다 많은 역할을 수행해야한다. 때문에 여기서 Debounce를 이용해 같은 동작을 한번만 수행하도록 개선했다.
<img src="https://images.velog.io/images/alvin_you/post/57a9b98f-5438-4f38-8dac-5b88962c9e0a/image.png" alt="Axios 요청에서 토큰이 만료된 경우">
<img src="https://images.velog.io/images/alvin_you/post/89eb6ba1-e73d-4adf-9eeb-d49e25001b07/image.png" alt="Axios 요청에서 토큰 요청을 Debounce로 묶은 경우"></p>
<p>Axios 요청에서 Error를 묶으려면 Axios Interceptor가 필요하다.  </p>
<blockquote>
<p><a href="https://yamoo9.github.io/axios/guide/interceptors.html">Axios Interceptor</a>
then이나 catch로 처리되기 전에 요청이나 응답을 가로챌 수 있습니다.</p>
</blockquote>
<p>따라서 Axios Interceptor와 debounce(lodash)를 통해서 기능을 구현했다.</p>
<h4 id="전체-코드는-다음과-같다">전체 코드는 다음과 같다</h4>
<pre><code class="language-typescript">import debounce from &#39;lodash/debounce&#39;;
import axios, { AxiosInstance, AxiosError } from &#39;axios&#39;;

const AUTHORIZATION = &#39;Authorization&#39;;

class OAuth {
  ... 
  private initInterceptor(axiosInstance: AxiosInstance) {
    // refresh token 요청을 위한 API 정의
    const getRefreshToken = async (refreshToken: string) =&gt; {
      try {
        const credential: Credential = {
          client_id: OAuthConfig.clientId,
          client_secret: OAuthConfig.clientSecret,
          grant_type: GrantType.REFRESH_TOKEN,
          refresh_token: refreshToken,
        };

        const formData = new FormData();
        for (const [key, value] of Object.entries(credential))
          formData.append(key, value);

        const { data: newToken } = await axios.post&lt;Token&gt;(
          &#39;/user/oauth/token&#39;,
          formData,
          {
            baseURL: this.apiBaseUrl,
            headers: { &#39;content-type&#39;: &#39;application/x-www-form-urlencoded&#39; },
          }
        );

        this.setAuthorization(newToken);

        return newToken;
      } catch (error) {
        // refresh token 이 만료된 경우
        this.logout();
      }
    };

    const getRefreshTokenWithSchedulerResponse = debounce(
      getRefreshToken,
      200, // 200ms 화면전환에 의한 Component요청이 생길 수있는 최대시간을 적어주면 좋을 것 같다.
      { leading: true, trailing: false } // Option 설정 중요
    );

    axiosInstance.interceptors.response.use(null, (error: AxiosError) =&gt; {
      if (!error.response) return error;

      // need refresh token: 리프래쉬가 필요한 경우 417 에러를 사용하기로 back-end 논의하여 결정함
      if (error.response.status === 417) {
        const refresh = async (resolve: typeof Promise.resolve) =&gt; {
          try {
            // 이 함수를 통하여 같은 토큰 요청이 여러번 가는것을 방지한다.
            const newToken: Token = await getRefreshTokenWithSchedulerResponse(
              this.token.refresh_token
            );

            error.config.headers[AUTHORIZATION] 
              = `${newToken.token_type} ${newToken.access_token}`;

            // 토큰을 갱신하여 새로운 config와 함께 기존 request를 다시 실행한다.
            const data = await axios.request(error.config);

            return resolve(Promise.resolve(data));
          } catch {
            return resolve(Promise.reject(error));
          }
        };

        return new Promise(refresh);
      }

      return Promise.reject(error);
    });
  }
  ...
}</code></pre>
<h4 id="fe에서-ddos공격-하지-않기">FE에서 DDoS공격 하지 않기!?</h4>
<p>debounce통해 적어도 Token expired 의한 요청이라도 O(n + n)에서 O(n + 1)로 줄일 수 있을 것이라 생각한다. 2n이나 n+1이나 그렇게 큰 차이가 안난다고 말할지도 모르겠지만, 작은 곳에서부터 디테일을 살려가야한다고 생각한다. 작은 것부터 잡아가면서 프로젝트의 디테일을 살려보자 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Daily Schedule Slack Bot (with flex)]]></title>
            <link>https://velog.io/@alvin_you/Daily-Schedule-Slack-Bot-with-flex</link>
            <guid>https://velog.io/@alvin_you/Daily-Schedule-Slack-Bot-with-flex</guid>
            <pubDate>Wed, 23 Feb 2022 06:49:04 GMT</pubDate>
            <description><![CDATA[<p>작은 스타트업에 근무하면서 회사에서는 자율적으로 재택근무를 선택해서 할 수 있는 혜택을 주었다.
하지만 막상 출근했을때 누가 출근했고 누가 재택근무를 하는지 확인하는지를 체크하기가 불편해서 항상 <a href="https://flex.team/">Flex(HR 근태관리솔루션)</a>에 들어가서 확인해야했다.
매번 들어가서 확인하는것이 생각보다 불편해서 슬랙에 데일리 스케쥴러를 만들어보았다 :)</p>
<p>결과물은 다음과 같다.
휴무일을 제외한 매일 10시에 특정 슬랙 채널에 이러한 형태로 알람이 간다.
<img src="https://images.velog.io/images/alvin_you/post/c4a8c630-cbce-42f7-bc08-8e56f0aee05b/image.png" alt="슬랙 알림 예제 화면"></p>
<p>결과물은 간단하지만 생각보다 다양한 기술들을 사용했다.
다행히 크롤링은 따로 사용하지 않았다.</p>
<ul>
<li>Node.js</li>
<li>GCP(Function, Cloud Scheduler)</li>
<li>Slack (Webhook)</li>
<li>Flex API</li>
</ul>
<p>전체적인 로직은 다음과 같다.</p>
<p>1) GCP Cloud Scheduler 에서 설정한 시간(매일 10시)에 GCP Function을 호출한다.</p>
<blockquote>
<p>Cloud Scheduler는 cron job schedule로, 매일 10시에 실행시키기 위해 빈도를 다음과 같이 설정했다.
<code>0 10 * * * (Asia/Seoul)</code></p>
</blockquote>
<p>2) GCP Function 내에 Node.js 코드를 실행한다.</p>
<blockquote>
</blockquote>
<ol>
<li>Flex API를 이용하여 로그인(JWT) 및 근태정보를 획득한다.</li>
<li>근태정보와 회사 구성원 정보를 조합하여 팀별로 분류한다.</li>
<li>2에서 얻은 데이터를 이용하여 Slack를 제작한다.</li>
<li>Slack Webhook을 이용하여 메세지를 보내고 종료한다.</li>
</ol>
<p>전체 코드</p>
<pre><code class="language-javascript">const axios = require(&quot;axios&quot;);

const departmentsID = &quot;departmentsID&quot;;
const loginURL = &quot;https://flex.team/actions/login&quot;;
const upcomingURL = `https://flex.team/actions/api/v1/departments/${departmentsID}/upcoming-events?countLimitType=DAY&amp;limitCount=1`;
const userListURL = &quot;https://flex.team/actions/people/list?size=50&amp;page=0&quot;;
const webhookURL = &quot;https://hooks.slack.com/services/...&quot;;

exports.helloWorld = async (req, res) =&gt; {
  timetable().then();
  res.status(200).send(&quot;Hello World!&quot;);
};

const timetable = async () =&gt; {
  try {
    const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6;
    if (isWeekend) return; // 주말 예외 처리

    const { accessToken, refreshToken } = await axios
      .post(loginURL, {
        email: &quot;Flex-Email&quot;,
        password: &quot;Flex-Password&quot;,
      })
      .then((res) =&gt; res.data.credentials);

    const events = await axios
      .get(upcomingURL, {
        headers: { cookie: `AID=${accessToken}; RID=${refreshToken}` },
      })
      .then((res) =&gt; res.data.data.events[0].events);

    // 휴일인 경우 종료
    if (events.findIndex((event) =&gt; event.eventType === &quot;CUSTOMER_HOLIDAY&quot;) &gt; 0)
      return;

    const users = await axios
      .get(userListURL, {
        headers: { cookie: `AID=${accessToken}; RID=${refreshToken}` },
      })
      .then((res) =&gt; res.data.users);

    events.forEach((event) =&gt; {
      const uid = event.eventCta.link.replace(&quot;/feed?uid=&quot;, &quot;&quot;);
      const user = users.find((user) =&gt; user.uuid === uid);
      if (user) event.departmentName = user.departments[0].name;
    });

    const groupByDepartment = events.reduce((acc, obj) =&gt; {
      const key = obj.departmentName;
      if (!acc[key]) acc[key] = [];
      acc[key].push(obj);
      return acc;
    }, {});

    const today = formatDate();

    let message = `*${today}*\n`;
    Object.entries(groupByDepartment).forEach(([departmentName, events]) =&gt; {
      message += `&gt;${departmentName}\n`;
      events.forEach((event) =&gt; {
        message += `:${event.eventEmoji}: ${event.eventName} \`${event.eventDescription}\`\n`;
      });
    });

    await axios.post(webhookURL, { text: message });
  } catch (error) {
    throw error;
  }
};

function formatDate(date = new Date()) {
  const d = date instanceof Date ? date : new Date();
  let month = &quot;&quot; + (d.getMonth() + 1);
  let day = &quot;&quot; + d.getDate();
  const year = d.getFullYear();

  if (month.length &lt; 2) month = &quot;0&quot; + month;
  if (day.length &lt; 2) day = &quot;0&quot; + day;

  return [year, month, day].join(&quot;.&quot;);
}
</code></pre>
<p>코드 제작하면서 신경쓴 점</p>
<ul>
<li>휴일에 알람이 가지않도록 예외처리를 한다.</li>
<li>이벤트 리스트에서 CUSTOMER_HOLIDAY 인 경우에도 알람이 가지않도록 예외처리를 한다.</li>
<li>Flex API를 직접 호출하기 위해 Flex 내에서 Token 사용위치를 분석했다. 
(Flex API에서는 cookie에 accessToken, refreshToken을 넣는다.)</li>
<li>개인별 조직명과 이벤트를 잘 merge하여 데이터를 만든다.</li>
<li>Flex에서 사용하는 Emoji 이름이 Slack에서 바로 사용하는지 미리 체크한다.
(다행히도 같은 이름을 써서 바로 따로 매칭용 Object를 만들지 않았다.)</li>
<li>날짜 표시를 이쁘게 하기위해 앞에 padding 함수를 넣어주었다.</li>
<li>회사는 탄력근무제를 시행하고 있어서 근무시간과 휴가시간을 함께 표시했다.</li>
</ul>
<p>느낀점
여러 사람들에게 편리한 기능을 만들어 제공해서 뿌듯했다. 약 1년 정도 사용하면서 나름 편리했던 나만의 슬랙 봇이라고 생각한다 :-) 다음에 또 이런 것을 만들 기회가 있다면 즐겁게 만들어야지~</p>
]]></description>
        </item>
    </channel>
</rss>