| 🍀 환경을 위한 우리들의 작은 실천, 에코모아 🍀 |
|---|
에코모아는 환경을 위한 우리의 일상 속 작은 실천들을 모아 더 나은 미래를 만들고자 탄생했어요!
여러분의 일상에서 이산화탄소를 절감할 수 있는 다양한 친환경 활동을 제안하고
지속적으로 실천할 수 있도록 '에코모아'가 도와드릴게요.
지속적인 작은 실천들은 에너지를 절약하고 나만의 친환경 데이터를 쌓을 수 있을 뿐만 아니라
나의 친환경 경험과 정보를 다른 사용자들과 공유하고 소통하여 서로 응원하고 성장해나가요!
"기후 위기는 더 이상 먼 미래의 이야기가 아닙니다."
우리는 기후 변화의 심각성을 직접 체감하고 있습니다.
지난해 연평균 기온이 산업화 이전보다 약 1.45℃ 상승하며 역사상 가장 더운 해를 기록했고,
해수 온도 상승, 빙하 및 해빙의 녹는 속도가 증가하였습니다.
보고서에서 이러한 현상은 전지구적 온실가스 감축 노력이 부족했다는 증거이며,
적극적인 온실가스 감축 노력을 강화해야한다고 경고합니다.
https://www.greenpeace.org/korea/update/30698/blog-ce-act-now-for-climate-disaster**
장종호 Team Lead 📊 탄소계산기 jangjh9414@gmail.com |
노용철 Deputy Lead 🌿 챌린지 🗺️ 지도 ryc0208@naver.com |
유재희 Member 🔐 로그인/회원가입 🏠 홈 bsjaehee94@gmail.com |
장수인 Member 👥 커뮤니티 rose970515@naver.com |
|
이지솔 🎨 UI/UX Design 홈/챌린지/지도 |
나영은 🎨 UI/UX Design 계산기/커뮤니티 |
| 분류 | 기술 | 채택 이유 |
|---|---|---|
| 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 연동 용이 - 강력한 스키마 검증 |
|
|
- 이메일 로그인, 회원가입이 가능
- 네이버, 구글, 카카오 소셜 로그인이 가능
- 최초 로그인 시 닉네임 설정 모달이 제공
소셜 로그인을 구현할 때, 구글과 카카오는 supabase의 프로바이더이기때문에 signInWithOAuth 메서드를 사용해서 쉽게 구현할 수 있었다.
그런데 네이버 로그인은 supabase에서 공식적으로 지원이되지 않아 별도로 구현이 필요했다.
-
네이버 개발자 웹에서 간편 로그인 후 콜백 URL 설정
-
콜백주소에서 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)
);
}- 가져온 정보를 이용해 이미 가입되어있는지 확인하고, 가입되어있지않으면 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 { /* 최대 레벨 정보 */ };
};-
레벨 범위 확인
Object.entries()로 레벨 설정을 순회- totalPoints가 해당 레벨의 범위(min ≤ totalPoints <details max)에 있는지 확인합니다.
-
레벨 정보 계산
- 현재 레벨에 속한 점수(currentPoints)는 totalPoints - config.min으로 계산합니다
- 레벨 범위(maxPoints)는 config.max - config.min입니다
- 다음 레벨까지 필요한 점수(pointsToNextLevel)는 config.max - totalPoints로 계산됩니다.
-
레벨 정보 반환
- 해당 레벨의 설정과 계산된 정보를 LevelInfo 타입으로 반환합니다.
-
최대 레벨 처리
- totalPoints가 최대 레벨(4)의 범위를 초과하면 레벨 4의 정보를 반환하며, 추가 경험치는 고정됩니다.
캘린더 시스템은 사용자가 날짜를 선택하거나 월별로 일정을 확인할 수 있도록 돕는 기능입니다.
해당 기능 설명은 길어져서 노션으로 공유했습니다.
https://empty-bottle-ec4.notion.site/fda2239a05b04306ac5552815ae0219c
|
form 내부에 있는 Input 컴포넌트로 받아온 값들을 onSubmit을 통해 supabase로 값을 전달합니다. 전달을 할때는 각각의 계산법을 적용시켜 보냅니다. 추후 해당 값들은 따로 가공하여 데이터 비교 시 사용됩니다.
input창을 컴포넌트화히여 관리하였습니다. id를 지정을 하여 car라는 id를 가진 Input 컴포넌트에만 추가적으로 라디오 버튼을 제공되게 로직을 구현했습니다.
{
/* id가 "car"일 때만 라디오 버튼을 표시 */
}
<div>
{id === "car" && (
<div className="mb-[12px]">
<FuelTypeSelector
selectedFuelType={fuelType}
onChange={handleFuelTypeChange}
/>
</div>
)}
</div>;라디오 버튼 중 없음을 선택하면 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 === "없음"}
/>매달 첫번째로 계산을 진행하면 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),
// ...
})
}지금까지 계산한 탄소 배출량을 종합적으로 확인할 수 있는 기능입니다.
여러 데이터를 한 번에 패칭해야 하는 상황에서, 각 요청을 병렬적으로 처리하기 위해 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())
);- 사용자가 검색어 입력 시
searchTerm상태 업데이트 - 300ms 동안 추가 입력이 없으면
debouncedSearchTerm업데이트 - 최종 검색어로 가게 목록 필터링
이를 통해 불필요한 렌더링을 방지하고 검색 성능을 최적화했습니다.
애플리케이션의 초기 로딩 성능 최적화를 위해 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);
}, []);-
createElement("script")로 새로운 script 태그를 생성합니다. -
script.src에서autoload=false옵션으로 SDK를 불러옵니다. 이는 스크립트 로드 시 자동으로 카카오맵을 초기화하지 않도록 하는 설정입니다. -
script.async = true를 통해 스크립트를 비동기적으로 로드하여 페이지 로딩을 차단하지 않습니다. -
script.onload이벤트 핸들러에서window.kakao.maps.load()메서드를 호출하여 수동으로 카카오맵을 초기화합니다. -
setIsMapLoaded(true)로 지도 로드 상태를 관리하여, 지도가 완전히 로드된 후에만 마커나 인포윈도우 등의 기능을 사용할 수 있도록 합니다.
이러한 방식으로 구현하면 페이지 초기 로딩 성능을 최적화하고,
지도 로드 상태에 따른 UI 처리를 효과적으로 할 수 있었습니다.
지도 로드가 완료된 후에는 마커, 인포윈도우, 줌 컨트롤 등 다양한 기능을 구현하였습니다.
|
문제
- 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) => {
// 회원 탈퇴 로직
};문제
- 사용자가 하루에 한 번만 챌린지를 제출할 수 있어야 하는데 중복 제출이 가능한 문제가 발생했습니다.
구체적인 문제점 및 해결 과정에서의 실수
- 구체적인 문제점
- 같은 날짜에 여러 번 챌린지 제출이 가능
- 시간대(Timezone) 처리로 인한 날짜 계산 오류
- DB의 timestamptz 타입과 프론트엔드의 날짜 처리 불일치
- 캘린더 데이터 표시 불일치
- 해결 과정에서의 실수
- 복잡한 시간 범위 쿼리 접근
초기 해결 시도에서는 다음과 같은 복잡한 쿼리를 사용해보기도 했는데 오히려 에러를 해결하기 더 까다로웠습니다.
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 요청 비용을 절감하고
예외 상황에 대한 명확한 처리를 해야겠다 느꼈습니다
문제
- 공용으로 사용하는 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 컴파일러가 해당 클래스들을 인식하고 생성할 수 있도록 개선했습니다.
- 분리된 컴포넌트 파일에 있는 Tailwind 클래스들은 빌드 시점에 고정적으로 존재함
- JIT 컴파일러가 빌드 시 해당 클래스들을 발견하고 필요한 CSS를 생성
- 결과적으로 런타임에서 모달이 동적으로 마운트되어도 이미 필요한 스타일이 CSS에 포함되어 있음
느낀점
학습했던 점
- Tailwind CSS의 JIT 컴파일러 동작 방식에 대해 이해함
- 런타임과 컴파일타임의 차이가 스타일 적용에 미치는 영향을 파악함
- 동적 컴포넌트의 스타일링 시 고려해야 할 사항들을 배움
이번 이슈를 통해 각 스타일링 방식마다 동작 방식이 다르고 실제 DOM에 스타일이 적용되는 시점이 다르다는 것을 명확히 알게 되었습니다. 이러한 이해를 바탕으로 앞으로는 더 효율적인 컴포넌트 설계와 스타일링 전략을 수립할 수 있을 것 같습니다.

















