Astro 프레임워크 체험기

 

챗 지피티에게 물어본 

Astro의 주요 특징
1. 멀티 프레임워크 지원: Astro는 React, Vue, Svelte, Solid.js 등 다양한 프론트엔드 프레임워크의 컴포넌트를 한 프로젝트에서 함께 사용할 수 있습니다.
2. Partial Hydration (부분 하이드레이션): Astro는 기본적으로 정적 HTML을 생성하며, 필요한 부분만 JavaScript로 하이드레이션(동적으로 활성화)하여 클라이언트에서 실행합니다.
이를 통해 JavaScript 로딩을 최소화하고 페이지 로드 속도를 최적화합니다.
3. 파일 기반 라우팅: 파일 구조를 기반으로 라우팅을 자동으로 처리합니다.
4. 빠른 빌드와 최적화: Vite를 기반으로 하여 빌드 속도가 매우 빠릅니다. 정적 HTML을 우선 생성하고, 필요한 JavaScript만 로드하므로 성능이 뛰어납니다.
5. SEO와 접근성 최적화: 정적 콘텐츠 중심이라 검색 엔진 최적화(SEO)와 초기 로드 속도가 뛰어납니다.
6. Markdown과 통합: Markdown 파일을 쉽게 작성하고 렌더링할 수 있어 블로그나 문서 사이트에 적합합니다.

 

사용 사례
- 블로그나 개인 포트폴리오
- 문서 사이트 (예: 개발 문서, 튜토리얼)
- 마케팅 랜딩 페이지
- SEO가 중요한 정적 웹사이트

 

장점
- 빠른 성능과 작은 JavaScript 번들 크기
- 멀티 프레임워크 호환성
- 쉬운 정적 사이트 생성
- 개발자 경험이 우수함 (간단한 API와 설정)

 

 


 

React, Vue, Svelte, Solidjs 등의 웹 프레임워크의 컴포넌트 지원? 궁금해서 간단히 써봤다. 

Astro 사이트 들어가보면 https://astro.build/ Get Started를 통해 튜토리얼 진행하면 되는 정도의 체험기.


튜토리얼을 진행해보자 

프로젝트 설치

// create a new project with npm
npm create astro@latest

Project initializing

 

dir 프로젝트 위치와 프로젝트명

tmpl 어떤 템플릿 쓸건지 (A basic, minimal starter가 추천이라 그냥했다)

How would you like to start your new project?

deps 종속성 관련 설치하고

git 레포지토리는 연결하지않았다

 

그러면 astro.config.mjs 가 포함된 astro 프로젝트가 생성된다. 

new project

 

타입스크립트 https://docs.astro.build/en/guides/typescript/

 

TypeScript

Learn how to use Astro's built-in TypeScript support.

docs.astro.build

위 문서대로 따라해보자.

 

tsconfig.json 

extends 는 base, strict, strictest 중에 설정할수 있다.

npm install @astrojs/ts-plugin

 

@astrojs/ts-plugin 설치한다음 tsconfig.json에 추가해준다

{
  "extends": "astro/tsconfigs/strict",
  "include": [".astro/types.d.ts", "**/*"],
  "exclude": ["dist"],
  "compilerOptions": {
    "plugins": [
      {
        "name": "@astrojs/ts-plugin"
      },
    ],
  }
}

 

 

 

자 이제 체험이니까 다른 설정관련해서는 넘어가고 

React 관련 설정을 좀 해보겠다

https://docs.astro.build/en/guides/integrations-guide/react/

 

@astrojs/react

Learn how to use the @astrojs/react framework integration to extend component support in your Astro project.

docs.astro.build

 

Astro에는 astro add를 통해 공식적으로 통합 설정을 자동화하는 명령이 포함되어 있다고한다.

아래처럼 입력하면 react 관련해서 통합 설정해주고, vue로 바꾸면 vue에 대한 통합 설정을 해준다. (오~)

npx astro add react

 

즉, 수동으로 종속 패키지를 install 안하고 통합된 종속 패키지들을 한방에 다운받을수 있다. (오!)

continue? 라고 물어보면 설치 끝날때까지 계속 엔터로 넘기기 

완료되면 어디 뭐 추가됐는지 보여준다. 친절하다.

설치 완료 후 노출창

 

 

설치를 마친후 astro.config.mjs들어와보면, 아래와 같이 react가 추가된 걸 볼수 있다.

// @ts-check
import { defineConfig } from 'astro/config';

import react from '@astrojs/react';

// https://astro.build/config
export default defineConfig({
  integrations: [react()]
});

 

 

vue도 동일하게 진행

https://docs.astro.build/en/guides/integrations-guide/vue/

npx astro add vue

 

 

그리고 스타일에 사용할 tailwind로 똑같이 진행.

https://docs.astro.build/en/guides/integrations-guide/tailwind/

 

똑같은 과정을 거치고 나면 astro.config.mjs 파일에 vue, tailwind가 추가되어있을것이다.

 

최초 화면은 어떻게 생겼을지,

dev를 명령어를 입력하고 띄어보자. 

npm run dev

 

요렇게 기본 화면 뜨는것 까지 확인!

초기화면

 

 

디렉토리 및 파일 (https://docs.astro.build/en/basics/project-structure/)

  • src/*- 프로젝트 소스 코드(구성요소, 페이지, 스타일, 이미지 등)
  • public/*- 코드가 아니고 처리되지 않은 자산(글꼴, 아이콘 등)
  • package.json- 프로젝트 매니페스트.
  • astro.config.mjs- Astro 구성 파일. (권장)
  • tsconfig.json- TypeScript 구성 파일. (권장)

 

react, vue로 컴포넌트

자 이제 react, vue로 컴포넌트를 만들어보자.

 

근데 src > components > Welcome.astro 파일을 보면,,

구조가 신기하다.

 — 주석과 — 주석 사이에 스크립트 넣고 하위는 html, style을 넣어준다. (스타일을 제외한 코드)

 

Welcome.astro

---
import astroLogo from '../assets/astro.svg';
import background from '../assets/background.svg';
---

<div id="container">
	<img id="background" src={background.src} alt="" fetchpriority="high" />
	<main>
		<section id="hero">
			<a href="https://astro.build"
				><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
			>
			<h1>
				To get started, open the <code><pre>src/pages</pre></code> directory in your project.
			</h1>
			<section id="links">
				<a class="button" href="https://docs.astro.build">Read our docs</a>
				<a href="https://astro.build/chat"
					>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
						><path
							fill="currentColor"
							d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
						></path></svg
					>
				</a>
			</section>
		</section>
	</main>

	<a href="https://astro.build/blog/astro-5/" id="news" class="box">
		<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
			><path
				d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
				fill="#111827"></path></svg
		>
		<h2>What's New in Astro 5.0?</h2>
		<p>
			From content layers to server islands, click to learn more about the new features and
			improvements in Astro 5.0
		</p>
	</a>
</div>

<style>
// ...
</style>

 

 

기본적으로 html 렌더링된거 보면 type="module" 로 컴포넌트 코드가 들어가있더라.

 

 

여튼 React와 Vue 컴포넌트를 생성해보자. (각 구조에 맞춰 만들어야함)

components 파일 하위에 컴포넌트를 하나 생성해준다. 

 

 

ReactHeader.tsx

export default function ReactHeader() {
  return <div className="bg-pink-200">ReactHeader</div>;
}

 

 

VueBody.vue

<script>
export default {};
</script>

<template>
  <div className="bg-blue-50">Vue Body</div>
</template>

 

 

astro는 파일기반 라우팅이라 /src/pages 파일 경로에 따라 사이트 엔드포인트가 된다. (에러페이지 같은 경우는 src/pages/404.astro 이런형태로 만들면 된다.

(src/pages에서 파일 유형을 지원: .astro .md .mdx .html .js)

 

pages/index.astro에서 import 해보면 되겠지?

 

index.astro

---
import ReactHeader from '../components/ReactHeader';
import VueBody from '../components/VueBody.vue';
import Layout from '../layouts/Layout.astro';
---

<Layout>
	 <ReactHeader />
	 <VueBody />
</Layout>

 

 

오 문제없이 (vscode가 경고를 안하고있다) import가 되었다.

props는 각각 아래와 같이(문법대로) 넘기면 된다. (느낌아니까)

 

index.astro

---
import ReactHeader from '../components/ReactHeader';
import VueBody from '../components/VueBody.vue';
import Layout from '../layouts/Layout.astro';
---

<Layout>
	 <ReactHeader title="React Header"/>
	 <VueBody title="Vue Body"/>
</Layout>

 

 

 

ReactHeader.tsx

interface Props {
  title: string;
}

export default function ReactHeader(props: Props) {
  return <div className="bg-pink-200">{props.title}</div>;
}

 

 

VueBody.vue

<script>
export default {
  props: {
    title: String,
  },
};
</script>

<template>
  <div className="bg-blue-50">{{ title }}</div>
</template>

 

 

props도 각 구조에 맞게 사용하면된다.

자 이제 클릭을 받는 버튼과, 클릭 시 클릭 횟수를 노출시켜보겠다.

 

ReactHeader.tsx

import { useState } from "react";

interface Props {
  title: string;
}

export default function ReactHeader(props: Props) {
  const [count, setCount] = useState(0);

  return (
    <div className="bg-pink-200">
      <div className="bold">{props.title}</div>
      <div>{`클릭횟수: ${count}`}</div>
      <button
        className="border-[1px] border-blue-500 bg-white px-4 py-1 rounded-full"
        onClick={() => {
          setCount((prev) => prev + 1);
        }}
      >
        클릭
      </button>
    </div>
  );
}

 

 

이때 클릭 눌러도 클릭횟수는 변경되지 않는다. 

 

왜냐면?

기본적으로 Astro는 모든 UI 구성 요소를 HTML 및 CSS로만 자동 렌더링하고 클라이언트 측 JavaScript를 모두 자동으로 제거하기 때문이라고한다. (참고: https://docs.astro.build/en/concepts/islands/)

 

따라서 UI 구성요소에 JavaScript를 사용하고 싶다면,

client:* 지시문을 추가해야한다.

추가할 경우 JavaScript를 자동으로 빌드하고 번들링한다.

 

그리고 상호작용은 구성 요소 수준에서 구성되므로 사용에 따라 각 구성 요소에 대해 다른 로딩 우선순위를 처리할 수 있다고한다.

예를 들어, client:idle 브라우저가 유휴 상태가 되면 구성 요소에 로드할 수 있고 client:visible 뷰포트에 들어가면 한 번만 구성 요소에 로드할 수 있다. 일단 astro 쓰기로했으면 무조건 필수로 알아둬야할 것 같다. 

 

 

index.astro

---
import ReactHeader from '../components/ReactHeader';
import VueBody from '../components/VueBody.vue';
import Layout from '../layouts/Layout.astro';
---

<Layout>
	 <ReactHeader title="React Header" client:load/>
	 <VueBody title="Vue Body"/>
</Layout>

 

아까 그 코드에 client:load를 넣어주기만하면,, 잘 동작한다.

뭐 만들어보고 싶은건 없어서, 진짜 간단하게 astro란 뭔가 를 찾아보면서 체험?만 해봤다.

 

오늘의 일기(?) 끗~~ 

 

구글캘린더 - 팀원 일정 긁어와서 휴가 모아보기 캘린더 만들기

모아보기 캘린더

 

사전작업

모아보기 캘린더 작업할 소유자(나)에게 각 팀원의 캘린더 공유 (모든일정보기)가 되어있어야한다.

(내가 공유받은 팀원의 캘린더로 부터 일정을 긁어오기 때문에)

 

캘린더 공유 방법

구글 캘린더에서 캘린더 우측 (1) 옵션 버튼 > (2) 설정 및 공유 > 설정 페이지에서 (3) 일정의 액세스 권한' 항목 [공개 사용 설정] 에서 소유자에게 공개 > (4) 모아보기(공유) 캘린더 만들기

이때 모두 공개는 보안적으로 좋지 않으므로 소유자에게만 따로 공개하기, 공유 가능한 링크 받기 기능이나 '특정 사용자 또는 그룹과 공유'를 통해 소유자에게 공개해야함

(1) 옵션 버튼
(2) 설정 및 공유

 

3) 일정의 액세스 권한' 항목 [공개 사용 설정] 에서 소유자에게 공개

 

모아보기 캘린더 추가 절차는, 구글 캘린더 화면에서 (1) 다른 캘린더 추가 > (2) 새 캘린더 만들기 > 생성 페이지에서 캘린더 만들기 진행 

(4.1) 다른 캘린더 추가

 

(4.2) 새 캘린더 만들기

 

 

구글의 Apps Script 사용하여 구성원의 일정을 모아보기할것이므로 구글 Apps Script 방문 (https://script.google.com/home) 하여 새 프로젝트 만들기 클릭

새 프로젝트 만들기 진입 시

 

이때 어떠한 정보를 긁어 올 것인지도 단어나 문장을 맞춰야한다.

예를 들면 '휴가' 일 경우, 공유자들의 캘린더 등록 시 타이틀에 '휴가'라는 단어가 필수로 들어가야한다.

공유자의 캘린더에 [휴가] 일정 추가

 

 

Apps Script의 Code.gs에 스크립트를 추가해줘야한다.

일단 돌아가는 상황을 간단히 설명하기 위해, 아래와 같이 콘솔로그를 등록해서 저장을 하면.

아무거나 입력 후 저장

 

저장이 되면서 이렇게 myFunction이 추가되는걸 볼수있다. 함수를 여러개 만들면 눌렀을 경우 리스트로 확인할 수 있다.

myFunction이 노출

 

// 기존 트리거를 삭제하고 새로운 트리거를 설정하는 함수
function createHourlyTrigger() {
  // 현재 프로젝트의 모든 트리거를 가져옴
  const triggers = ScriptApp.getProjectTriggers();

  // 동일한 트리거가 있는지 확인하고, 있으면 삭제
  triggers.forEach(trigger => {
    if (trigger.getHandlerFunction() === 'myHourlyTask' &&
        trigger.getEventType() === ScriptApp.EventType.CLOCK) {
      ScriptApp.deleteTrigger(trigger);  // 기존 트리거 삭제
      Logger.log('기존 정시 트리거가 삭제되었습니다.');
    }
  });

  // 새로운 트리거 생성
  ScriptApp.newTrigger('myHourlyTask')  // 실행할 함수 이름
    .timeBased()  // 시간 기반 트리거 사용
    .everyHours(1)  // 매시간마다 실행 (정각에 실행)
    .create();     // 트리거 생성
  Logger.log('새로운 정시 트리거가 생성되었습니다.');
}

 

여기에 챗지피티에 질문해서 얻은 스크립트를 넣어보겠다.

간단히 설명하면, ScriptApp.getProjectTriggers를 통해 Code.gs에 작성한 함수들을 가져올 것이고,

가져온 리스트에서 특정 함수 (myHourlyTask)를 1시간 마다 실행하도록 적용할 것이다.

(= 1시간마다 공유자들의 캘린더 정보를 가져와서 원하는 것을 뽑아내서 모아보기 캘린더에 노출)

 

이후 이어서 하단에 아래 스크립트도 추가해준다.

// 매 정시에 실행될 함수
function myHourlyTask() {
  Logger.log('정시에 실행되었습니다.');

  // 원본 캘린더 ID와 소유자 이름 매핑
  const sourceCalendars = { // 구성원 캘린더 ID : 이름
    '삐약@gmail.com': '삐약', 
    // ... 해당 포맷에 맞춰 쭉 작성하기
  };

  // 대상 캘린더 ID
  const destinationCalendarId = ''; 

  // 원하는 날짜 범위 설정
  const today = new Date(); // 현재 날짜
  const startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
  const endDate = new Date(today.getFullYear(), today.getMonth() + 6 + 1, 0); // 종료 날짜를 현재 날짜 기준 6개월 이후의 마지막 날로 설정

  const keyword = '휴가'; // 필터링할 일정 제목 키워드
  const copiedTag = '#copied'; // 복사된 일정에 추가할 태그

  // 대상 캘린더에서 기존 이벤트들을 삭제
  const destinationCalendar = CalendarApp.getCalendarById(destinationCalendarId);
  const existingEvents = destinationCalendar.getEvents(startDate, endDate);

  // 기존의 모든 이벤트 삭제
  existingEvents.forEach(event => {
    event.deleteEvent();
  });

  // 모든 원본 캘린더를 순회하며 일정 가져오기
  Object.keys(sourceCalendars).forEach(calendarId => {
    const events = CalendarApp.getCalendarById(calendarId).getEvents(startDate, endDate);
    
    events.forEach(event => {
      if (event.getTitle().includes(keyword)) {
        // 일정 소유자 정보 추가
        const ownerName = sourceCalendars[calendarId];

        // 일정의 시작 시간과 종료 시간을 24시간제로 변환
        const formattedStartTime = Utilities.formatDate(event.getStartTime(), Session.getScriptTimeZone(), 'HH:mm');
        const formattedEndTime = Utilities.formatDate(event.getEndTime(), Session.getScriptTimeZone(), 'HH:mm');

        // 일정이 전날 자정부터 다음날 자정까지 이어지는지 확인
        const startTime = event.getStartTime();
        const endTime = event.getEndTime();
        const isOverMidnight = startTime.getDate() !== endTime.getDate() && startTime.getHours() === 0 && endTime.getHours() === 0;

        if (isOverMidnight) {
          // 전날 자정부터 다음날 자정일 경우, 시작 날짜에 하루종일 일정으로 추가
          const previousDay = new Date(endTime); // 종료 시간에서 하루 전으로 설정
          previousDay.setDate(previousDay.getDate() - 1); // 전날로 설정

          // 전날로 하루 종일 일정 추가 (종료 시간을 제외)
          destinationCalendar.createAllDayEvent(
            `[휴가] - ${ownerName}`, // 시간 값 없이 제목만 표시
            previousDay // 전날로 시작 시간 설정
          );
        } else {
          // 다른 일정의 경우 정상적으로 시작 시간과 종료 시간을 표시
          const newTitle = `[휴가] - ${ownerName} (${formattedStartTime} ~ ${formattedEndTime})`;

          // 대상 캘린더에 이벤트 복사
          destinationCalendar.createEvent(
            newTitle, // 일정 제목에 소유자 이름과 시간 추가
            event.getStartTime(),
            event.getEndTime(),
            {
              location: event.getLocation(),
              description: (event.getDescription() || '') + ' ' + copiedTag, // 복사된 일정에 태그 추가
              guests: event.getGuestList().map(guest => guest.getEmail()).join(','),
              sendInvites: false
            }
          );
        }
      }
    });

 

1시간 마다 실행되는 myHourlyTask의 코드를 간단히 설명해보자면,

- sourceCalendars에 공유자들의 이메일과 모아보기 캘린더에 노출될 이름을 입력한다.

- destinationCalendarId에는 모아보기 캘린더의 id를 입력면된다. 모아보기 캘린더 id는 캘린더 설정 및 공유 페이지에서 (캘린더 공유방법 참고) > '캘린더 통합' 영역의 캘린더 ID를 넣어주면 된다. (example@@group.calendar.google.com) example은 보안적인 특정 유니크 값으로 이루어져있다. 

- keyword에 공유자들 캘린더에서 가져올 키워드를 입력한다. (스크립트에 공백체크가 없으므로 공백까지 맞춰야한다) 

- startDate, endDate에 필요날 날짜를 입력하고, (나는 이번달 1일 ~ 6개월 이후까지 지정) new Date('2025-01-01') 이런식으로 직접 지정해줘도 되고, 필요한 날짜를 계산해서 넣어도된다.

 

아래 노출할 문자열들을 입력한다.

if문 하루종일 인 경우
else 문 시간 휴가인 경우

 

 

완성된 코드

// 기존 트리거를 삭제하고 새로운 트리거를 설정하는 함수
function createHourlyTrigger() {
  // 현재 프로젝트의 모든 트리거를 가져옴
  const triggers = ScriptApp.getProjectTriggers();

  // 동일한 트리거가 있는지 확인하고, 있으면 삭제
  triggers.forEach(trigger => {
    if (trigger.getHandlerFunction() === 'myHourlyTask' &&
        trigger.getEventType() === ScriptApp.EventType.CLOCK) {
      ScriptApp.deleteTrigger(trigger);  // 기존 트리거 삭제
      Logger.log('기존 정시 트리거가 삭제되었습니다.');
    }
  });

  // 새로운 트리거 생성
  ScriptApp.newTrigger('myHourlyTask')  // 실행할 함수 이름
    .timeBased()  // 시간 기반 트리거 사용
    .everyHours(1)  // 매시간마다 실행 (정각에 실행)
    .create();     // 트리거 생성
  Logger.log('새로운 정시 트리거가 생성되었습니다.');
}

// 매 정시에 실행될 함수
function myHourlyTask() {
  Logger.log('정시에 실행되었습니다.');

  // 원본 캘린더 ID와 소유자 이름 매핑
  const sourceCalendars = { // 구성원 캘린더 ID : 이름
    '삐약@gmail.com': '삐약', 
    // ... 해당 포맷에 맞춰 쭉 작성하기
  };

  // 대상 캘린더 ID
  const destinationCalendarId = ''; 

  // 원하는 날짜 범위 설정
  const today = new Date(); // 현재 날짜
  const startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
  const endDate = new Date(today.getFullYear(), today.getMonth() + 6 + 1, 0); // 종료 날짜를 현재 날짜 기준 6개월 이후의 마지막 날로 설정

  const keyword = '휴가'; // 필터링할 일정 제목 키워드
  const copiedTag = '#copied'; // 복사된 일정에 추가할 태그

  // 대상 캘린더에서 기존 이벤트들을 삭제
  const destinationCalendar = CalendarApp.getCalendarById(destinationCalendarId);
  const existingEvents = destinationCalendar.getEvents(startDate, endDate);

  // 기존의 모든 이벤트 삭제
  existingEvents.forEach(event => {
    event.deleteEvent();
  });

  // 모든 원본 캘린더를 순회하며 일정 가져오기
  Object.keys(sourceCalendars).forEach(calendarId => {
    const events = CalendarApp.getCalendarById(calendarId).getEvents(startDate, endDate);
    
    events.forEach(event => {
      if (event.getTitle().includes(keyword)) {
        // 일정 소유자 정보 추가
        const ownerName = sourceCalendars[calendarId];

        // 일정의 시작 시간과 종료 시간을 24시간제로 변환
        const formattedStartTime = Utilities.formatDate(event.getStartTime(), Session.getScriptTimeZone(), 'HH:mm');
        const formattedEndTime = Utilities.formatDate(event.getEndTime(), Session.getScriptTimeZone(), 'HH:mm');

        // 일정이 전날 자정부터 다음날 자정까지 이어지는지 확인
        const startTime = event.getStartTime();
        const endTime = event.getEndTime();
        const isOverMidnight = startTime.getDate() !== endTime.getDate() && startTime.getHours() === 0 && endTime.getHours() === 0;

        if (isOverMidnight) {
          // 전날 자정부터 다음날 자정일 경우, 시작 날짜에 하루종일 일정으로 추가
          const previousDay = new Date(endTime); // 종료 시간에서 하루 전으로 설정
          previousDay.setDate(previousDay.getDate() - 1); // 전날로 설정

          // 전날로 하루 종일 일정 추가 (종료 시간을 제외)
          destinationCalendar.createAllDayEvent(
            `[휴가] - ${ownerName}`, // 시간 값 없이 제목만 표시
            previousDay // 전날로 시작 시간 설정
          );
        } else {
          // 다른 일정의 경우 정상적으로 시작 시간과 종료 시간을 표시
          const newTitle = `[휴가] - ${ownerName} (${formattedStartTime} ~ ${formattedEndTime})`;

          // 대상 캘린더에 이벤트 복사
          destinationCalendar.createEvent(
            newTitle, // 일정 제목에 소유자 이름과 시간 추가
            event.getStartTime(),
            event.getEndTime(),
            {
              location: event.getLocation(),
              description: (event.getDescription() || '') + ' ' + copiedTag, // 복사된 일정에 태그 추가
              guests: event.getGuestList().map(guest => guest.getEmail()).join(','),
              sendInvites: false
            }
          );
        }
      }
    });

 

 

 

작성이 완료되면 다시 저장을 누르고,

createHourlyTrigger (1시간마다 자동실행하는 스크립트) 를 눌러준 다음 왼쪽에 실행 버튼을 눌러준다. 

 

그러면 실행 로그 창이 아래에 열리고 실행되는 절차에 대한 피드백을 주고 (작성했던 스크립트 내의 Logger.log가 노출)

작성했던 스크립트 내의  Logger.log가 노출

 

모아보기 캘린더에서 확인할 수 있다.

결과 

모아보기 캘린더

 

 

이때 참고할 점은 스크립트 도는 도중에는 삭제와 추가하는 과정이 진행 중이기때문에 휴가 확인이 어려울 수 있다.

수동으로 Apps Script에서 [실행]을 누를 경우 매 1시간마다 실행되므로, 실행 시간이 달라지게 된다.

 

코드는 챗지피티가 만들었고 이후 최적화는 안했다.

중요한게 아니어서 최적화할 정도의 필요성을 못느껴서이고, 확실히 코드가 별로다 (삭제-생성이 반복되서 날짜 값이 길면 오래걸리거나 실패할수 있다.) 

 

react(nextjs), tailwind를 통해 별점 컴포넌트를 만들어보자. 

 

준비물로는 5점짜리 별 5개가 이어진 이미지 2장이 필요한데, 별이 채워진 이미지와 별이 채워지지않은 이미지가 필요하다. 

(하나의 별만 있다면 크기를 지정하고 반복해서 배경이미지를 repeat 시키면 될 듯하다.)

베이스
채워진 이미지

 

그 이유는 마우스가 호버될때, 

아래 영상과 같이 마우스 커서에 따라 채워진 이미지를 보여주기 위함이다.

 

컴포넌트 코드

'use client';

import clsx from 'clsx';
import React, { useState } from 'react';

interface Props {
    value?: number;
    onChange?: (radio: number) => void;
    width?: number;
    height?: number;
}

export default function RatingStar(props: Props) {
    const { value = 0, onChange, width = 160, height = 32 } = props;
    const stepCount = 5;
    const [tempPercentage, setTempPercentage] = useState(0);
    const [percentage, setPercentage] = useState(() => value * (100 / stepCount));
    const isReadOnly = !onChange;

    const getSteppedPercentage = (value: number) => {
        // 8% (매직넘버) 이하의 경우 0%
        if (value < 8) return 0;

        const steps = Array.from({ length: stepCount }, (_, i) => (100 / stepCount) * (i + 1));
        return steps.find((step) => value <= step) || 100;
    };

    const handleMove = (clientX: number, rect: DOMRect) => {
        const x = clientX - rect.left;
        const width = rect.width;
        const newPercentage = Math.min(Math.max((x / width) * 100, 0), 100);
        const steppedPercentage = getSteppedPercentage(newPercentage);

        setTempPercentage(steppedPercentage);
    };

    const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
        if (!isReadOnly) {
            const rect = e.currentTarget.getBoundingClientRect();
            handleMove(e.clientX, rect);
        }
    };

    const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
        if (!isReadOnly) {
            const rect = e.currentTarget.getBoundingClientRect();
            const clientX = e.touches[0]?.clientX;

            if (clientX) {
                handleMove(clientX, rect);
            }
        }
    };

    const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
        e.stopPropagation();
        if (!isReadOnly) {
            setPercentage(tempPercentage);
            onChange(tempPercentage / (100 / stepCount));
        }
    };

    return (
        <div className="flex grow">
            <div
                className="relative cursor-pointer"
                style={{ width: `${width}px`, height: `${height}px` }}
                onClick={handleClick}
                onMouseMove={handleMouseMove}
                onMouseLeave={() => {
                    if (!isReadOnly) {
                        setTempPercentage(percentage);
                    }
                }}
                onTouchMove={handleTouchMove}
            >
                {['rating-star__base', 'rating-star__fill'].map((className) => {
                    const isBase = className.includes('base');
                    const isFill = className.includes('fill');

                    return (
                        <div
                            key={className}
                            className={clsx(
                                `bg-no-repeat bg-[left_center] absolute top-0 left-0`,
                                {
                                    'w-full bg-[url("/static/images/img-rating-star-base.png")]': isBase,
                                    'w-0 bg-[url("/static/images/img-rating-star-fill.png")]': isFill,
                                },
                                className,
                            )}
                            style={
                                isFill
                                    ? {
                                          width: `${tempPercentage}%`,
                                          height: `${height}px`,
                                          backgroundSize: `${width}px auto`,
                                      }
                                    : {
                                          width: `${width}px`,
                                          height: `${height}px`,
                                          backgroundSize: `${width}px auto`,
                                      }
                            }
                        />
                    );
                })}
            </div>
        </div>
    );
}

 

간단히 흐름을 말하면,

유저의 마우스 움직임에 따라 채워진 별의 엘리먼트의 width를 조정하여 (stepCount에 따른 값에 따라 width가 지정) 별이 채워지는 형식이다.

마우스 클릭이 발생하면 특정 값(percentage)에 저장되고, 이때도 마우스를 움직일 경우에 위와 동일하게 동작하고(tempPercentage) 마우스가 빠져나갈 경우 클릭시 저장된(percentage) 별의 갯수로 돌아온다.

 

getSteppedPercentage 함수의 8이라는 숫자는 매직넘버인데, 마우스 오버시 마우스 커서의 위치가 8이상 올라갔을때부터 별 1개가 채워지기를 희망하기 때문이다. (10으로 했을 경우 뭔가 별이 차야할것 같은데 라는 생각이 들었다.)

이때, width 값을 저장하기 위해 percentage를 state로 저장하고, steps를 따로 계산하고 있는데 반대로 ratio 값을 state로 저장하고 역으로 percentage를 계산해서 써도 될 것 같다.

 

그 외의 핸들러들은 마우스 움직일때 좌표를 구해서 percentage를 움직여서 tempPercentage에만 저장하여 유저가 별의 움직임을 볼수 있도록 하고, 마우스가 빠질때 tempPercentage를 리셋해서 선택된 평점이 없음을 보여준다.

만약 클릭 핸들러가 발생할 경우 tempPercentage를 저장하여 마우스가 빠졌을때 선택된 평점을 보여준다. (클릭시 percentage에 set해주고 onChange 콜백에 계산하여 ratio를 인자로 전달

 

 

사용할 때

// 선택된 값을 통해 이동시키기 때문에 따로 value 정의가 필요없을때
<RatingStar
    onChange={(ratio) => {
        moveToReview(ratio);
	}
/>

// 선택된 값을 보여주기만 할때
<RatingStar value={4} width={115} height={18} />

// 값 제어가 필요할때
<RatingStar width={210} height={30} value={ratio} onChange={(ratio) => setRatio(ratio)} />

 

 

요런식으로 노출

 

 

 

물론 이 방식이 정답은 아니고 다른 방식도 있다.

코드펜에서 구경 중에 css만으로도 제작한 방식도 봤다. (https://codepen.io/cycosta/pen/QWNaXNV

다음엔 또 다른 방식으로도 만들어봐야겠다 ㅎㅎ

 

React의 가상 DOM, Vue의 가상 DOM과 비교

 

가상 DOM이란?

DOM (Document Object Model)

브라우저가 제공하는 문서 객체 모델

HTML 요소를 트리 구조로 표현하며, 브라우저에서 렌더링되는 실제 DOM을 말한다.

실제 DOM은 업데이트가 발생할때마다 전체 DOM 구조를 다시 계산하고 렌더링한다.

 

가상 DOM (Virtual DOM)

메모리 상에서 실제 DOM의 경량화된 사본을 만드는 개념으로 DOM 상태를 Javascript 객체로 나타내어 빠르게 연산할수 있다. 실제 DOM 조작 전에 변경 사항을 비교하고 필요한 부분만 실제 DOM에 업데이트 하는 방식으로 성능 최적화를 하기 위하여 생성된다.

리액트는 가상 DOM을 업데이트 할때 최소한의 DOM 업데이트를 보장하기 때문에 복잡한 업데이트가 빈번하게 발생해도 실제로 더 빠르게 업데이트할 수 있고, 유지보수에도 용이하다.

 

 

 

React의 가상 DOM

React의 가상 DOM과 비교 알고리즘 

가상 DOM은 메모리상에 존재하는 실제 DOM의 경량 사본으로 리액트는 컴포넌트의 상태나 Props가 변경될 때마다 새로운 가상 DOM을 생성한다.

리액트는 이전의 가상 DOM과 새로 생성된 가상 DOM을 비교(diff)하여 변경된 부분만 계산하는데, 이때 변경이 없으면 건드리지 않고 변경이 있는 부분만 찾아내어 실제 DOM에 반영한다.

<div>
<p>텍스트만 변경될 경우</p>
</div>

텍스트만 변경될 경우 <p> 태그 노드는 유지되고 노드 내부의 텍스트 노드만 변경한다.

 

재조정(Reconciliation)

상태나 Props가 변경되면, UI를 효율적으로 다시 그리는 과정을 말한다.

DOM 트리구조를 위에서 아래로 순차적으로 비교하여(전체 비교는 아니다) 변경된 노드만 업데이트한다.

배열이나 리스트일 경우 key를 사용하여 재사용 여부를 판단해 불필요한 업데이트를 방지한다. 이때 key가 같으면 노드를 재사용하며 다를 경우 새로 생성한다.

 

React의 가상 DOM 업데이트 과정

1. 상태 또는 props 업데이트를 감지하게되면 해당 컴포넌트의 렌더링 함수가 호출된다. (리렌더링)

2. 변경된 상태를 기반으로 새로운 가상 DOM 생성한다.

3. 이전 가상 DOM과 2번에서 만들어진 가상 DOM을 비교하게되는데, 이때 재귀적 비교를 통해 변경된 노드를 찾는다. 이때 전체 가상 DOM을 비교하는게 아니라 최적화 방식으로 특정된 영역의 가상 DOM만을 비교한다.

    ㄴ 동일한 레벨에서 노드 유형이 동일한 경우에만 비교하며, 노드 유형이 다를 경우는 해당 노드를 완전히 교체한다.

    ㄴ key를 활용하여 고유성을 식별하며, key가 없거나 중복되면 리스트를 다시 비교하여 교체한다. (비효율적임)

    ㄴ 동일한 노드와 동일한 하위트리에서 변경사항이 없다고 판단되면, 해당 하위 트리를 비교하지 않는다.

 4. 실제 DOM에 필요한 변경사항을 최소화하여 패치한다.

 

리액트는 컴포넌트를 다시 렌더링 한 뒤 가상 DOM 비교를 통해 효율성을 유지한다.

불필요한 리렌더링을 방지하려면 개발자가 추가적인 최적화 작업(메모이제이션)을 할수있다.

 

 

 

Vue의 가상DOM 업데이트와 비교해볼까?

vue는 종속성 추적과 반응형 데이터 시스템을 활용해 React와는 다른 방식으로 실제 DOM 업데이트를 최적화한다.

ㄴ 종속성 추적 ? 데이터와 이를 사용하는 템플릿 또는 컴포넌트간의 종속성 관계를 추적한다. 이로인해 변경된 데이터와 그에 의존하는 부분만 업데이트가 가능하다. 

ㄴ 반응형 데이터 모델 ? reactive 또는 ref 같은 API로 반응형 데이터를 관리한다. 이 데이터는 기본적으로 proxy를 활요하여 데이터의 변화가 생기면 이를 즉시 감지하고 필요한 업데이트를 수행한다.

vue는 데이터 중심, react는 컴포넌트 중심으로 가상 DOM을 다룬다고 이해하면된다.

 

vue의 가상 DOM 업데이트 과정

1. reactiva, ref등을 통해 데이터 변경을 감지한다. 어떤 데이터가 어떤 DOM 노드와 연결되어있는지 추적한다. (종속성 추적)

2. 종속된 데이터가 변경된 경우에만 가상 DOM을 생성한다

3. 이전 가상 DOM과 2번에서 생성한 가상 DOM을 비교한다. (종속된 데이터가 변경된 부분만 비교)

4. 실제 DOM에 필요한 변경사항을 최소화하여 패치한다.

 

vue의 가상 DOM이 효율적인 경우?

부분 업데이트가 많은 경우에 종속성 추적으로 필요한 부분만 업데이트가 가능하고,

반응형 데이터 시스템을 활용하면 (reactive. ref) 객체 내부 속성 변경도 쉽게 감지하기에 같은 데이터 구조를 사용할 경우  vue가 더 효율적이라고 할 수 있다.

 

 

 

정말 가상 DOM이 빠를까?

가상 DOM을 사용하는 프레임워크가 성능상의 한계를 보일수 있다는 점에서 '가상 DOM이 무겁다'라는 주제가 제기되었다고한다. 이로인해 가상 DOM을 사용하지 않거나 더 가벼운 방식을 채택한 프레임워크, 라이브러리들이 등장했다. (ex: svelte, solid.js...)

가상 DOM은 실제 DOM 조작 전 변경 사항을 계산하는 단계가 존재하고, 메모리를 사용하여 저장하고 관리해야한다.

 

항상 가상 DOM이 좋은 것은 아니다.

프로젝트 규모나, 업데이트되는 빈도 혹은 구조에 따라 실제 성능이 다를 수 있다.

예를 들어 DOM의 변경사항이 거의 없거나 간단할때에 실제 DOM이 더 빠를 수 있다.

소규모 애플리케이션에서 DOM의 변화가 거의 없을 경우, 가상 DOM의 비교(diff) 연산이나 메모리 사용등이 불필요할 수 있다. 따라서 가상 DOM이 유리한 경우는 복잡하거나 빈도 높은 업데이트가 발생할때 유리할 것이다. (대규모 어플리케이션 일수록 유리)

 

 

 

타이머, 숫자 가변폭 폰트를 고정폭 폰트로 

font-variant-numeric CSS

 

타이머 UI에서 숫자 값을 감싼 wrapper이 움직이는 문제가 있었다.

이유는 날짜와 시간이 동적으로 들어가기때문에 고정폭을 적용하지 않은 UI였고, 이 때문에 발생했다.

동영상을 잘보면 미세하게 끝이 움직인다.

 

적용 전

 

왜 이런 이슈가 생기는 걸까?

고정폭 폰트, 가변폭 폰트가 각각 존재하기 때문이다.

말 그대로 고정폭 폰트는 각 글자가 동일한 폭을 차지하며 가변폭은 글자의 폭이 동일하지 않는 폰트를 말한다.

 

이때는 어떻게 고정폭으로 적용할까?

아래 CSS만 적용하면 바로 해결된다. *물론 사용하는 폰트가 OpenType 포맷이어야함

font-variant-numeric: tabular-nums;

 

적용하고나니 숫자모두 동일한 고정폭을 가지게 되었기 때문에 시간초가 줄어도 끝이 움직이지 않는다.

 

적용 후 

 

숫자의 폭이 동일해졌으므로, 숫자 정렬이 깔끔하다.

 

font-variant-numeric의 속성에는

proportional-nums(가변폭),

tabular-nums(고정폭) 외에도

lining-nums, oldstyle-nums,, diagonal-fractions, stacked-fractions 같은 속성들이 존재한다.

 

아 자고로, 해당 속성은 OpenType 기능을 지원하는 폰트만 사용 가능하다는데....

OpenType은 폰트 포맷의 하나로 Microsoft와 Adobe가 공동으로 개발한 디지털 서체 기술이라고 한다.

단일 파일로 여러 언어와 스크립트를 지원하도록 설계되었고, 글꼴의 고급 렌더링을 지원한다. (=> font-variant-numeric도 때문에 사용 가능.)

 

css도 끝없이 공부해야 몸이 고생안하는듯 하다!

 

 

원링크 (One Link), 딥 링크(Deep Link), 디퍼드 딥 링크 (Deferred Deep Link) - 웹뷰와의 통신

초반에 잘못 이해해서 꽤나 개념을 이해하는데 쉽지 않았다.

회사, 사람마다 같은 개념을 다른형식으로 말하기도 해서 헷갈리기도 했고, 마케팅하시는 분들의 블로그에서 잘못된 정보들이 있어 이해하는데 시간이 걸렸다.

다시 정리해보자.

 

원링크 (One Link)

하나의 링크를 통해 여러 플랫폼 또는 목적지로 사용자들을 유도하는 기술이나 개념.

단일 URL로 여러 목적지를 처리하는 마케팅 기술 플랫폼.

(AppsFlyer의 통합 딥링킹 플랫폼으로 하나의 단일 링크만으로도 딥링킹 제공)

즉, 사용자가 원링크를 클릭하면, 링크는 사용자의 디바이스나 운영체제(OS)등의 정보를 분석하여 가장 적합한 목적지로 라우팅한다.

웹은 웹 사이트, iOS는 앱스토어, Android는 구글 스토어..

https://{고유한값}.onelink.me와 같은 형태의 링크다. (Branded Domains 기능이 있어서 appLink.{고유한값}.com 형태로 변경해서 사용가능하다고 하다.)

 

 

딥 링크(Deep Link)

딥링크의 주요 3가지 방식은 

  • URI 스킴 방식 (URI Scheme) 
  • 앱 링크 (App Link)
  • 유니버셜 링크 (Universal Link)

 

URI 스킴 방식

여기서 초기에 딥링크는 URI 스킴 방식만 존재했는데, URI 스킴방식은 아래와 같이 사용한다.

{scheme}://{path}?{queryString=123}

myapp://product?productId=123

이 방식은 한계를 가지고 있었는데, 앱이 설치되어 있어야만 실행가능하며, scheme은 유일하지않은 값이기때문에 중복될 수 있었다.

따라서 myapp 스킴을 여러 앱에서 사용할 수 있고 이렇게 되었을때는 충돌나며, 악의적으로 이용될수도 있다고 한다. (아무 연관이 없는 회사에서 kakao:// 이렇게 만들어버린다던가..)

 

그래서 이 URI 스킴 방식의 한계를 극복하고자, 등장하게된게 앱링크(App Link)와 유니버셜링크 (Universal Link)라고 한다 (두개는 같은 개념으로 해석하면 되고, 제공하는 곳이 다르고 앱링크는 Android, 유니버셜링크는 iOS)

-> 이 부분이 내가 헷갈린 부분 중 하나이기도 하다, 회사에서 주로 유니버셜링크로 퉁 쳐서 말하는 경우들이 발생하는 것 같았고, 앱링크 !== 유니버셜링크 로 아예 다른 개념인것으로 이해해버렸다. 

앱링크 = 유니버셜링크 => 요즘 추세로는 그냥 딥링크로 통용되는듯 하다.

 

 

앱링크 (App Link) 와 유니버셜 링크 (Universal Link)

App Link는 Android

Universal Link는 iOS 

웹 사이트의 URL 형태로 만들어진다. (https://www.{고유한주소}.com/product)

고유한 웹 사이트 주소로 만들어지기때문에 항상 유일하게 되어 URI 스킴방식의 한계였던 어뷰징이 불가능하다.

둘다 도메인이 소유자라는 것을 인증해주는 절차가 필요하다고 하는데, 웹 사이트의 특정 파일에 등록해서 인증처리하면 되는 듯 하다. 

앱이 설치되어있는 경우 해당 딥링크에 맞는 화면이 실행되고, 앱이 없으면 웹 URL경로이기때문에 웹에서 처리해주면된다 (앱마켓으로 이동, 혹은 웹에서 대응)

 

문제점은 주소 입력창에 직접 복사/붙여넣기시 동작하지 않는것과 크롬, 사파리 등..을 제외하고 각 플랫폼에서 본인들이 개발한 브라우저로 할 경우 동작하지 않는 경우들이 있다고 한다. (동작이 다른 경우도 있다고 함)

 

 

디퍼드 딥링크 (Deferred Deep Link)

앱이 설치되지 않은 상태에서도 링크 데이터를 유지하여 앱 설치 후에 데이터를 복원해 사용자를 특정 화면으로 안내하는 기능 제공

지연된 딥링크로, 딥링크를 눌렀을때 앱이 설치되어있으면 앱이 열리면서 해당 컨텐츠 실행, 앱 미설치시 앱마켓 이동 후 설치 이후 해당 컨텐츠 실행

 

 

정리

원링크 플랫폼을 통해 원링크를 생성하고, 사용자가 원링크를 클릭하는 경우에 원링크 플랫폼이 사용자 디바이스의 플랫폼을 판단하고, 앱이 있는 경우 딥링크(앱링크=유니버셜링크)를 통해 앱의 특정 화면으로 이동시킨다.

이때 앱이 없는 경우 앱마켓으로 이동시키고, 이때 디퍼드 딥링크 설정이 되어있다면 디퍼드 딥링크 기능을 통해 앱 설치 후 원하는 화면으로 이동시킨다.

-> 이 경우는 원링크 제공 업체의 기술로써 여러 정보를 토대로 유저를 식별하여, 원링크 클릭한 사용자가 디퍼드 딥링크를 사용할 경우 이 사용자의 디퍼드 딥링크 정보를 DB에 저장하게 되고, 앱 설치 이후에 정보를 제공받아 해당하는 화면으로 이동시킨다.

 

나는 하나하나 다 다른 개념이고 같은 단계라고 생각했는데, 조합이라는 의미가 이런 의미라는 것을 알게되었다. 

유익한 시간이었다.

+ Recent posts