Skip to content
 
 

Repository files navigation

🌱 EcoMoa (에코모아)

🍀 환경을 위한 우리들의 작은 실천, 에코모아 🍀

에코모아는 환경을 위한 우리의 일상 속 작은 실천들을 모아 더 나은 미래를 만들고자 탄생했어요!
여러분의 일상에서 이산화탄소를 절감할 수 있는 다양한 친환경 활동을 제안하고
지속적으로 실천할 수 있도록 '에코모아'가 도와드릴게요.
지속적인 작은 실천들은 에너지를 절약하고 나만의 친환경 데이터를 쌓을 수 있을 뿐만 아니라
나의 친환경 경험과 정보를 다른 사용자들과 공유하고 소통하여 서로 응원하고 성장해나가요!

프로젝트 배너

Graphic - Service Page01 (3) 1 2 3 4 5

GreenPeace

"기후 위기는 더 이상 먼 미래의 이야기가 아닙니다."

우리는 기후 변화의 심각성을 직접 체감하고 있습니다.

지난해 연평균 기온이 산업화 이전보다 약 1.45℃ 상승하며 역사상 가장 더운 해를 기록했고,
해수 온도 상승, 빙하 및 해빙의 녹는 속도가 증가하였습니다.
보고서에서 이러한 현상은 전지구적 온실가스 감축 노력이 부족했다는 증거이며,
적극적인 온실가스 감축 노력을 강화해야한다고 경고합니다.

https://www.greenpeace.org/korea/update/30698/blog-ce-act-now-for-climate-disaster**

👥 팀원 소개

Frontend Developers



장종호
Team Lead
📊 탄소계산기
jangjh9414@gmail.com



노용철
Deputy Lead
🌿 챌린지
🗺️ 지도
ryc0208@naver.com



유재희
Member
🔐 로그인/회원가입
🏠 홈
bsjaehee94@gmail.com



장수인
Member
👥 커뮤니티
rose970515@naver.com

Design

이지솔
🎨 UI/UX Design
홈/챌린지/지도
나영은
🎨 UI/UX Design
계산기/커뮤니티

🛠️ 기술 스택

ecomoa (1)

Next.js TypeScript Tailwind CSS React Query Chart.js Zustand AOS Supabase Vercel

기술적 의사결정

분류 기술 채택 이유
Framework Next.js - 서버 사이드 렌더링 지원
- 빠른 페이지 로드
- SEO 최적화
언어 TypeScript - 타입 안정성 확보
- 개발 생산성 향상
- 버그 사전 방지
CSS Framework Tailwind CSS - 빠르고 일관된 스타일링
- 개발 시간 단축
- 높은 커스터마이징 자유도
클라이언트 상태 Zustand - 간편한 클라이언트 상태 관리
- 작은 번들 사이즈
- 직관적인 API
서버 상태 TanStack Query - 효율적인 데이터 fetching
- 강력한 캐싱 기능
- 서버 상태 최적화
차트 Chart.js - 유연한 차트 커스터마이징
- 다양한 차트 타입 제공
- 성능 최적화
애니메이션 AOS - 손쉬운 스크롤 애니메이션
- 다양한 효과 제공
- 가벼운 라이브러리
배포 Vercel - Next.js 최적화 배포
- 간편한 CI/CD
- 안정적인 성능
지도 Kakao Maps SDK - 높은 데이터 정확도
- 풍부한 API 문서
- 안정적인 서비스
공공 데이터 스마트서울맵 API - 서울시 친환경 가게 정보
- 제로웨이스트샵 위치 데이터
- 실시간 데이터 업데이트
데이터베이스 Supabase - 서버리스 아키텍처
- 실시간 DB 기능
- Postgres 기반의 안정성
폼 관리 React Hook Form - 간편한 폼 상태 관리
- 효율적인 유효성 검사
- 최적화된 성능
유효성 검사 Zod - TypeScript 친화적
- Hook Form 연동 용이
- 강력한 스키마 검증

💫 페이지 소개 및 주요 기능


🏠 메인페이지

메인 소개

메인 소개 2

메인 소개 3

메인 소개4

  • 제공되는 서비스를 빠르게 알아 볼 수 있도록 소개하는 페이지
  • aos 라이브러리를 사용하여 애니메이션 효과 추가


🔑 로그인/회원가입

로그인

  • 간편 소셜 로그인 제공
  • 이메일 로그인, 이메일 회원가입 제공
  • 아이디 저장 기능 제공
  • 가입 후 닉네임 설정 및 에코 캐릭터 부여

소셜 로그인

  • 이메일 로그인, 회원가입이 가능
  • 네이버, 구글, 카카오 소셜 로그인이 가능
  • 최초 로그인 시 닉네임 설정 모달이 제공

소셜 로그인을 구현할 때, 구글과 카카오는 supabase의 프로바이더이기때문에 signInWithOAuth 메서드를 사용해서 쉽게 구현할 수 있었다.
그런데 네이버 로그인은 supabase에서 공식적으로 지원이되지 않아 별도로 구현이 필요했다.

  1. 네이버 개발자 웹에서 간편 로그인 후 콜백 URL 설정

  2. 콜백주소에서 code를 가져와 네이버 로그인 api를 사용해서 토큰을 발급 받고, 토큰을 이용해 naver에서 사용자 데이터를 가져왔음.

const code = searchParams.get("code");
const accessToken = await getNaverAccessToken(code);

if (!accessToken) {
  return NextResponse.redirect(
    new URL("/login?error=failed_to_authenticate", req.url)
  );
}

const userData = await fetchUserDataFromNaver(accessToken);
if (!userData) {
  return NextResponse.redirect(
    new URL("/login?error=failed_to_fetch_user_data", req.url)
  );
}
  1. 가져온 정보를 이용해 이미 가입되어있는지 확인하고, 가입되어있지않으면 supabase의 signUp 메서드를 이용해 가입 후, signInWithPassword로 로그인되도록 구현
const { error: signUpError } = await supabase.auth.signUp({
  email: userData.email,
  password: "temporary-password" // 임시 비밀번호
});

const { error: loginError } = await supabase.auth.signInWithPassword({
  email: userData.email,
  password: "temporary-password"
});

이러한 과정으로 네이버 소셜 로그인까지 기능 구현을 마칠 수 있었습니다



👤 마이페이지

마이페이지

  • 개인 프로필의 닉네임 변경 가능
  • 챌린지 포인트와 에코 레벨 확인 가능
  • 내가 작성한 게시글 및 좋아요한 게시물을 확인하고, 상세 페이지로 이동 가능
  • 회원 탈퇴 기능 제공


🌿 데일리 챌린지

데일리 챌린지

  • 레벨에 따른 캐릭터를 확인하고 경험치를 통해 현재 자신의 레벨 진행 상황을 확인
  • 탄소 절감 챌린지에 참여하여 챌린지 포인트를 얻고 에코 레벨을 업그레이드 할 수 있음
  • 각 챌린지에 참여한 후, 인증 사진을 첨부하여 인증을 완료할 수 있음
  • 캘린더를 통해 자신의 탄소 절감 활동을 확인할 수 있음

레벨링 시스템

데일리 챌린지 참여를 통해 획득하는 포인트로 성장하는 레벨링 시스템입니다.
챌린지 달성, 인증 등 다양한 활동을 통해 포인트를 모아 레벨을 올릴 수 있습니다.

// 레벨별 설정
export const LEVEL_CONFIG = {
  1: { /* 레벨 1 설정 */ },
  2: { /* 레벨 2 설정 */ },
  3: { /* 레벨 3 설정 */ },
  4: { /* 레벨 4 설정 */ },
} as const;

// 레벨 계산 함수
export const calculateLevelInfo = (totalPoints: number): LevelInfo => {
  for (const [level, config] of Object.entries(LEVEL_CONFIG)) {
    if (totalPoints >= config.min && totalPoints < config.max) {
      const currentPoints = totalPoints - config.min;
      const levelRange = config.max - config.min;
      const pointsToNextLevel = config.max - totalPoints;

      return { level: Number(level), name: config.name, ... };
    }
  }
  return { /* 최대 레벨 정보 */ };
};

작동 방식

  1. 레벨 범위 확인

    • Object.entries()로 레벨 설정을 순회
    • totalPoints가 해당 레벨의 범위(min ≤ totalPoints <details max)에 있는지 확인합니다.
  2. 레벨 정보 계산

    • 현재 레벨에 속한 점수(currentPoints)는 totalPoints - config.min으로 계산합니다
    • 레벨 범위(maxPoints)는 config.max - config.min입니다
    • 다음 레벨까지 필요한 점수(pointsToNextLevel)는 config.max - totalPoints로 계산됩니다.
  3. 레벨 정보 반환

    • 해당 레벨의 설정과 계산된 정보를 LevelInfo 타입으로 반환합니다.
  4. 최대 레벨 처리

    • totalPoints가 최대 레벨(4)의 범위를 초과하면 레벨 4의 정보를 반환하며, 추가 경험치는 고정됩니다.

캘린더

캘린더 시스템은 사용자가 날짜를 선택하거나 월별로 일정을 확인할 수 있도록 돕는 기능입니다.
해당 기능 설명은 길어져서 노션으로 공유했습니다.

https://empty-bottle-ec4.notion.site/fda2239a05b04306ac5552815ae0219c



📊 탄소 계산기

탄소계사니

  • 전기, 수도, 가스, 교통, 폐기물에 대한 사용량을 입력하여, 각 항목에서 발생하는 탄소 배출량을 계산할 수 있음
  • 계산 결과로 이번 달 총 탄소 배출량을 수치로 확인 할 수 있음
  • 일상 속 에너지 절약 팁을 제공하여, 사용자가 탄소 배출량을 줄이는 방법을 확인하고 실천할 수 있음
  • 탄소 계산 결과는 이미지로 저장할 수 있음
  • 탄소 배출량 히스토리에서 이전에 계산한 배출량을 종합적으로 확인할 수 있음
  • 히스토리에서는 다른 사람들의 평균값과 비교하여, 자신의 배출량 수준을 파악할 수 있음
  • 적게 배출한 탄소량을 나무로 환산하여 얼마나 많은 나무를 심었을 때 상쇄될 수 있는지 확인할 수 있음

탄소계산기 기능 설명

1. 작동방식

form 내부에 있는 Input 컴포넌트로 받아온 값들을 onSubmit을 통해 supabase로 값을 전달합니다. 전달을 할때는 각각의 계산법을 적용시켜 보냅니다. 추후 해당 값들은 따로 가공하여 데이터 비교 시 사용됩니다.

2. Input 컴포넌트

2-1. Input 컴포넌트화

input창을 컴포넌트화히여 관리하였습니다. id를 지정을 하여 car라는 id를 가진 Input 컴포넌트에만 추가적으로 라디오 버튼을 제공되게 로직을 구현했습니다.

{
  /* id가 "car"일 때만 라디오 버튼을 표시 */
}
<div>
  {id === "car" && (
    <div className="mb-[12px]">
      <FuelTypeSelector
        selectedFuelType={fuelType}
        onChange={handleFuelTypeChange}
      />
    </div>
  )}
</div>;

2-2. 비활성화 시, 자동으로 값 전달

라디오 버튼 중 없음을 선택하면 input이 비활성화 되며 값이 0으로 들어가야하는 기능이 있습니다. value와 disabled에 id===car && frelType === "없음" 이라는 조건을 달아 해당 로직을 완성했습니다.

<input
  id={id}
  type="number"
  placeholder={placeholder}
  {...register(id, {
    required: requiredMessage
  })}
  // id가 "car" 일 때 fuelType이 "없음"이면 값은 0, 다른 id는 연관 없음
  value={id === "car" && fuelType === "없음" ? 0 : undefined}
  // id가 "car" 일 때 fuelType이 "없음"이면 비활성화
  disabled={id === "car" && fuelType === "없음"}
/>

3. 데이터 insert, update 분기처리

매달 첫번째로 계산을 진행하면 Insert로 데이터가 추가가됩니다. 그 이후 부터는 id 기준으로 해당 년도와 달을 체크해 값이 있다면 update를 통해 데이터를 관리합니다.

// 기존 데이터가 있으면 업데이트, 없으면 삽입
if (selectedData) {
  const { error } = await browserClient
    .from("carbon_records")
    .update({
      electricity_usage: electricity,
      electricity_co2: (electricity * 0.4781).toFixed(2),
      water_usage: water,
  // ...
} else {
  const { error } = await browserClient.from("carbon_records").insert({
    user_id: user.id,
    electricity_usage: electricity,
    electricity_co2: (electricity * 0.4781).toFixed(2),
    // ...
  })
}

탄소 히스토리

지금까지 계산한 탄소 배출량을 종합적으로 확인할 수 있는 기능입니다.

1. 데이터 관리

여러 데이터를 한 번에 패칭해야 하는 상황에서, 각 요청을 병렬적으로 처리하기 위해 Promise.all을 활용했습니다. 이를 통해 데이터를 효율적으로 불러오는 동시에 네트워크 요청 시간을 크게 단축할 수 있었습니다.

await Promise.all([
  loadUsersAvgData(setUserAvgData), // 유저 토탈 데이터
  loadMyAllData(setMyAllData, null), // 내 전체 데이터
  loadMyAvgData(setMyAllAvgData), // 내 평균 데이터
  loadTopUsersData(setUserTopData), // 유저 최고 데이터
  loadRecentFiveMonthsEmissions(currentYear, currentMonth, 2).then((data) => {
    setUserAllData(data);
  }),
  getUserFetch()
]).then(() => {
  setIsLoading(false);
});



🗺️ 친환경 지도

지도

  • 서울시 내 친환경 가게에 대한 정보를 지도에서 직접 확인할 수 있음
  • 사용자가 현재 위치에서 가까운 가게를 손쉽게 찾을 수 있음
  • 검색 기능을 통해 사용자들은 원하는 친환경 가게를 이름, 카테고리, 키워드로 검색할 수 있음
  • 내 주변 가까운 순과 인기순으로 가게를 필터링하고 정렬할 수 있어, 사용자가 가장 적합한 가게를 빠르게 찾을 수 있음
  • 다녀온 가게를 저장하여 방문 기록으로 남길 수 있음
  • 방문하고자 하는 가게를 저장하여 나중에 쉽게 찾아볼 수 있음

지도 검색어 디바운싱

사용자 경험 개선을 위해 검색어 입력 시 즉시 검색하지 않고, 입력이 끝난 후 검색을 수행하는 디바운싱을 구현했습니다.
// 검색어 상태 관리
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");

// 디바운싱 구현
useEffect(() => {
  const timer = setTimeout(() => {
    setDebouncedSearchTerm(searchTerm);
  }, 300);

  return () => {
    clearTimeout(timer);
  };
}, [searchTerm]);

// 검색 필터링 로직
const filteredStores = stores.filter(
  (store) =>
    store.store_name
      .toLowerCase()
      .includes(debouncedSearchTerm.toLowerCase()) ||
    store.road_address.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
);

작동 방식

  1. 사용자가 검색어 입력 시 searchTerm 상태 업데이트
  2. 300ms 동안 추가 입력이 없으면 debouncedSearchTerm 업데이트
  3. 최종 검색어로 가게 목록 필터링

이를 통해 불필요한 렌더링을 방지하고 검색 성능을 최적화했습니다.

카카오맵 동적 로드 구현

애플리케이션의 초기 로딩 성능 최적화를 위해 Kakao Maps SDK를 동적으로 로드하는 방식을 채택했습니다.
useEffect(() => {
  const script = document.createElement("script");
  script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_APP_JS_KEY}&autoload=false`;
  script.async = true;

  script.onload = () => {
    window.kakao.maps.load(() => setIsMapLoaded(true));
  };

  document.head.appendChild(script);
}, []);
  1. createElement("script")로 새로운 script 태그를 생성합니다.

  2. script.src에서 autoload=false 옵션으로 SDK를 불러옵니다. 이는 스크립트 로드 시 자동으로 카카오맵을 초기화하지 않도록 하는 설정입니다.

  3. script.async = true를 통해 스크립트를 비동기적으로 로드하여 페이지 로딩을 차단하지 않습니다.

  4. script.onload 이벤트 핸들러에서 window.kakao.maps.load() 메서드를 호출하여 수동으로 카카오맵을 초기화합니다.

  5. setIsMapLoaded(true)로 지도 로드 상태를 관리하여, 지도가 완전히 로드된 후에만 마커나 인포윈도우 등의 기능을 사용할 수 있도록 합니다.

이러한 방식으로 구현하면 페이지 초기 로딩 성능을 최적화하고,
지도 로드 상태에 따른 UI 처리를 효과적으로 할 수 있었습니다.
지도 로드가 완료된 후에는 마커, 인포윈도우, 줌 컨트롤 등 다양한 기능을 구현하였습니다.



👥 커뮤니티

커뮤니티

  • 챌린지 인증 / 자유게시판 / 아나바다 시장 으로 세분화
  • 챌린지 내용을 사진과 글을 첨부하여 공유할 수 있음
  • 자유게시판의 게시글 작성 버튼을 눌러 자신의 친환경 활동을 자유롭게 공유할 수 있음
  • 아나바다 시장을 통해 친환경 상품을 등록하고, 다른 사용자들과 물품을 교환하거나 판매할 수 있음
  • 작성자는 자신의 게시글을 상세 페이지에서 수정하거나 삭제할 수 있음
  • 상세 페이지에서 게시글에 대한 댓글 작성이 가능하여, 다른 사용자들과 소통 할 수 있음
  • 키워드 검색을 통해 커뮤니티 게시글을 찾아 볼 수 있음
  • 첼린지 인증 게시판을 통해 어떤 인증들이 있는지 확인 가능, 글작성,수정,삭제 가능
  • 사소한 일상의 일들을 자유게시판에서 확인 & 글 작성 수정,삭제 가능
  • 중고 거래 처럼 아나바다 게시판에서 물건을 판매할 수 있으며 글 작성,수정,삭제 가능


🚧 트러블 슈팅

1. 회원 탈퇴시 권한 문제

문제

  • supabase에서 제공하는 회원 탈퇴 로직을 사용하면 인증 권한이 없다는 에러 메시지가 출력
구체적인 문제점
  • 처음엔 Policy 관련 문제인가 해서 정책을 수정해봤지만 해결되진 않았습니다.
  • Supabase의 deleteUser메서드를 호출 후 에러 코드를 자세히 살펴봤는데요.
AuthApiError: User not allowed,
{
  __isAuthError: true,
  status: 403,
  code: 'not_admin'
}
  • 구글링을 통해 createUser나 deleteUser같은 메서드를 사용할 때는 ANON_KEY 대신 SERVICE_ROLE_KEY를 사용해서 클라이언트를 초기화해야 권한 문제가 해결된다는 사실을 알게되었습니다.
해결 과정 및 해결 내용
  • 서버 클라이언트를 초기화할 때 기존에 사용한 ANON_KEY를 SERVICE_ROLE_KEY로 변경 하며 오류를 해결할 수 있었습니다.
"use server";

import { createClient } from "@supabase/supabase-js";

// Service Role Key를 사용하는 서버 측 Supabase 클라이언트 생성
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // Service Role Key로 변경

  // process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! <- 기존 키
);

// 회원탈퇴
export const deleteUser = async (userId: string) => {
  // 회원 탈퇴 로직
};

2. 데일리 미션 유효성 검사 오류

문제

  • 사용자가 하루에 한 번만 챌린지를 제출할 수 있어야 하는데 중복 제출이 가능한 문제가 발생했습니다.
구체적인 문제점 및 해결 과정에서의 실수
  • 구체적인 문제점
  1. 같은 날짜에 여러 번 챌린지 제출이 가능
  2. 시간대(Timezone) 처리로 인한 날짜 계산 오류
  3. DB의 timestamptz 타입과 프론트엔드의 날짜 처리 불일치
  4. 캘린더 데이터 표시 불일치

  • 해결 과정에서의 실수
    • 복잡한 시간 범위 쿼리 접근

초기 해결 시도에서는 다음과 같은 복잡한 쿼리를 사용해보기도 했는데 오히려 에러를 해결하기 더 까다로웠습니다.

const { data: todayChallenge } = await supabase
  .from("challenges")
  .select("created_at")
  .eq("user_id", params.userId)
  .gte("created_at", today.utc().format())
  .lt("created_at", tomorrow.utc().format());
해결 과정
  • 복잡한 시간 범위 쿼리 대신 가장 최근 챌린지의 날짜만 비교하는 방식으로 변경했습니다
const { data: latestChallenge } = await supabase
  .from("challenges")
  .select("created_at")
  .eq("user_id", params.userId)
  .order("created_at", { ascending: false })
  .limit(1)
  .single();
  • 명확한 시간대 처리 및 날짜 비교 로직 단순화
const lastChallengeDate = dayjs(latestChallenge.created_at)
  .tz("Asia/Seoul")
  .format("YYYY-MM-DD");

const todayDate = dayjs().tz("Asia/Seoul").format("YYYY-MM-DD");

if (lastChallengeDate === todayDate) {
  throw new Error("이미 오늘의 챌린지를 제출하셨습니다.");
}
해결 내용
  • 복잡한 시간 범위 쿼리 제거하고
  • 명확한 날짜 비교 로직을 작성했으며
  • 유지보수가 용이한 코드 구조로 변경했습니다.
느낀점
이 해결 과정을 통해서 항상 명시적으로 시간대를 지정하고
복잡한 시간 관련 쿼리는 가능한 피해야겠다 생각했으며
그리고 최소한의 데이터만 조회하여 api 요청 비용을 절감하고
예외 상황에 대한 명확한 처리를 해야겠다 느꼈습니다

3. 모달에 스타일링 적용 이슈

문제

  • 공용으로 사용하는 Modal 컴포넌트에 Tailwind CSS로 스타일을 적용했을 때 의도한 대로 스타일이 적용되지 않는 현상이 발생했습니다.
문제 해결 과정
// 적용이 되지 않았던 문제의 코드
openModal({
  type: "custom",
  content: (
    <div className="flex flex-col w-[585px] h-[300px] p-6">
      {/* 모달 내용 */}
    </div>
  )
});
  • 하지만 같은 스타일을 인라인으로 적용했을 때는 정상적으로 작동했다.
// 정상 작동하는 인라인 스타일의 코드
openModal({
  type: "custom",
  content: (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        width: "585px",
        height: "300px",
        padding: "24px"
      }}
    >
      {/* 모달 내용 */}
    </div>
  )
});

🔍원인 분석

  • JIT 컴파일러 이슈

    • Tailwind CSS의 JIT 컴파일러는 빌드 시점에 사용된 클래스만 생성하며
    • 동적으로 생성되는 모달의 경우 컴파일 시점에 해당 스타일이 포함되지 않을 수 있음.
  • 스타일 적용 시점의 차이

    • 인라인 스타일: 런타임에 직접 DOM에 적용됨
    • Tailwind: 빌드 타임에 생성되어야 함

따라서 인라인 스타일이 적용되었던 결정적인 이유를 추측 했을 때
런타임과 컴파일 타임의 적용 시점의 차이 때문이었다고 생각이 들었습니다.

해결 과정
openModal({
  type: "custom",
  content: (
    <ModalContent>
    />
  )
});

// ModalContent.tsx

const ModalContent = () => {
  <div className="flex flex-col w-[585px] h-[300px] p-6">
    {"모달 내용"}
  </div>
}

컴포넌트 분리 방식을 통해 빌드 시점에
Tailwind JIT 컴파일러가 해당 클래스들을 인식하고 생성할 수 있도록 개선했습니다.

작동 원리

  1. 분리된 컴포넌트 파일에 있는 Tailwind 클래스들은 빌드 시점에 고정적으로 존재함
  2. JIT 컴파일러가 빌드 시 해당 클래스들을 발견하고 필요한 CSS를 생성
  3. 결과적으로 런타임에서 모달이 동적으로 마운트되어도 이미 필요한 스타일이 CSS에 포함되어 있음
느낀점

학습했던 점

  • Tailwind CSS의 JIT 컴파일러 동작 방식에 대해 이해함
  • 런타임과 컴파일타임의 차이가 스타일 적용에 미치는 영향을 파악함
  • 동적 컴포넌트의 스타일링 시 고려해야 할 사항들을 배움

이번 이슈를 통해 각 스타일링 방식마다 동작 방식이 다르고 실제 DOM에 스타일이 적용되는 시점이 다르다는 것을 명확히 알게 되었습니다. 이러한 이해를 바탕으로 앞으로는 더 효율적인 컴포넌트 설계와 스타일링 전략을 수립할 수 있을 것 같습니다.


About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages