본문 바로가기
React TIL

[React] Day_78 최종 프로젝트 관련 내용정리

by 림졍 2025. 1. 9.
728x90
반응형

우당탕탕 디자인컬러 정하기 🫨!!

 

디자이너 팀과 와이어뿌레임 컬러도 정하고 (놀랍게도 저렇게 진행했습니다. ye..)

모의 면접에 기능관련 추가적인 내용까지 공부했더니 벌써 시간이;;; 

오늘도 으쌰으쌰 TIL 작성을 좀 하겠습니다.

그럼 가시죠.

 

CRUD - GeoLocation API를 활용한 사용자의 실시간 위치 가져오기

지도 API는 처음이라.. 

 

저렇게 나타내는거.. 생각보다 쉽지 않다;;;

 

Intro

게시글 작성 페이지와 실시간 채팅, 이 두가지를 담당하게 된 림졍.

작성페이지에 들어가는 Supabase 테이블에 upload_place 을 어떻게 할 것인지 고민중이었다.

우선 더미데이터에는 서울, 부산 등 지역별로 나눠져 있었지만..

이것을 왠지 위치를 불러와서 업로드하게 하는것도 좋겠다는 생각이 들어

GeoLoaction API를 활용해서 주소를 가져와 ㅇㅇ시 ㅇㅇ동까지 나오게끔 구현을 해보고자 하는데..

 

오늘도 ㅈ...작 작업을 진행해볼까.. 

 

테스트 진행 과정

1. 프로젝트 생성하기

 

해당 내용 테스트를 위해 코드를 다시 파는 것보다는..

스케쥴링 테스트를 진행했던 Supabase, 코드, GitHub 레포지토리에 이어서 테스트를 진행하기로 했다.

 

- 테스트가 진행된 GitHub 레포지토리

 

GitHub - reizvoll/testtt

Contribute to reizvoll/testtt development by creating an account on GitHub.

github.com

 

2. 기존 Supabase에 posts 테이블 추가

 

SQL를 사용하여 기존 Supabase에 테이블을 추가하여 ERD를 구성하였다.

 

- 테이블 추가관련 SQL

-- users 테이블이 이미 존재한다고 가정
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY,
    name TEXT,
    email TEXT UNIQUE,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- posts 테이블 생성
CREATE TABLE IF NOT EXISTS posts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    title TEXT NOT NULL,
    content TEXT,
    upload_place TEXT,
    season_tag TEXT,
    body_size INT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    longitude TEXT,
    latitude TEXT,
    view INT DEFAULT 0,
    likes INT DEFAULT 0,
    bookmarks INT DEFAULT 0,
    comments INT DEFAULT 0,
    thumbnail TEXT,
    
    -- 외래 키 설정
    CONSTRAINT fk_user
    FOREIGN KEY (user_id)
    REFERENCES users (id)
    ON DELETE CASCADE
);

 

완성된 DB 스키마의 모습.

 

 

3. GeoLocation API 활용하기

 

GeoLocation API를 사용하면 사용자의 위치를 위/경도로 가져올 수 있다..!

이번 TIL에서는 Geolocation.getCurrentPosition() 를 활용하여 현재 위치를 가져올 예정이다.

 

[자세한 사용법 참고 링크 → 더 보기]

 

- Geolocation.getCurrentPosition()를 활용한 현재 위치를 가져오는 코드 예제  [출처 - 바로 가기]

function geoFindMe() {
  const status = document.querySelector("#status");
  const mapLink = document.querySelector("#map-link");

  mapLink.href = "";
  mapLink.textContent = "";

  function success(position) {
    const latitude = position.coords.latitude;
    const longitude = position.coords.longitude;

    status.textContent = "";
    mapLink.href = `https://www.openstreetmap.org/#map=18/${latitude}/${longitude}`;
    mapLink.textContent = `위도: ${latitude} °, 경도: ${longitude} °`;
  }

  function error() {
    status.textContent = "현재 위치를 가져올 수 없음";
  }

  if (!navigator.geolocation) {
    status.textContent = "브라우저가 위치 정보를 지원하지 않음";
  } else {
    status.textContent = "위치 파악 중…";
    navigator.geolocation.getCurrentPosition(success, error);
  }
}

document.querySelector("#find-me").addEventListener("click", geoFindMe);

 

 

4. 카카오맵 API 활용법 (Kakao-Map API)

 

Geolocation API를 활용해 사용자의 위치 정보를 받아와서 주소(예: ‘00시 00동’)를 얻으려면,

가져온 위도(latitude)와 경도(longitude)를 이용하여 지도 API를 호출해 주소로 변환시켜주는

역지오코딩(Reverse Geocoding)을 사용해야 한다. (그렇다… 결국 지도API를 활용해야 한다… )

지도 API는 가장 간결하고 사용하기 쉬운 카카오맵 API를 활용하기로 결정했다.

 

 

준비하기

 

1. 카카오 개발자사이트로 접속하여 개발자 등록 및 앱을 생성한다.

(개발자 등록 관련 내용에 대한 설명은.. 이미 다 한 상태여서 생략하도록 하겠다.)

애플리케이션 추가하기 버튼을 누르면 아래와 같은 모달이 뜨게 되는데,

필요한 내용을 입력하고 저장을 누르면 애플리케이션이 생성된다!

생성이 완료된 모습

 

 

2. 내 애플리케이션 > 제품 설정 > 카카오맵 에 들어가 카카오맵을 활성화시킨다.

카카오맵이 정상적으로 활성화된 모습

 

3. 내 애플리케이션 > 앱 설정 > 플랫폼 의 Web 탭에서 사이트 도메인을 등록한다.

우선 개발 환경에서 진행되므로, 해당 로컬호스트의 채널넘버를 등록하자.

( 대부분의 로컬호스트 채널 넘버는 3000번이다.)

성공적으로 등록된 모습

 

4. 내 애플리케이션 > 앱 설정 > 앱 키 로 접속하여 필요한 API키를 복사한다.

(우리는 REST API키를 사용할 예정이므로, 해당 키를 복사하여 .env.local 에 넣어주도록 하자.)

 

 

추가) 작성 페이지는 CSR 방식으로 렌더링 될 예정이라,

환경 변수의 경우 클라이언트에서 직접 사용할 수 있도록 NEXT_PUBLIC_ 접두사를 붙여주도록 하자.

(반드시 접두사를 붙여야 빌드 시 클라이언트 코드에 노출되어 클라이언트 측에서 사용이 가능함!)

NEXT_PUBLIC_KAKAO_MAP_API_KEY=(여기에 발급받은 REST API 키를 넣어주자.)

 

 

5. 코드 구현

 

GeoLocation, 카카오맵 API를 활용한 역지오코딩으로 사용자의 실시간 위치를 가져오는 테스트 페이지를 제작해보았다.

 

초기 화면 (좌) / 주소 확인 버튼을 누르고 확인중인 모습 (우)
와! 잘 나온다!!

 

게시글이 잘 업로드 되는 것도 아래의 이미지로 확인할 수 있었다.

업로드 된 게시글 모습 (간단하게 확인하기 위해, 상세 페이지로 따로 만들지는 않았다.)

 

또한 Supabase의 posts 테이블에도 주소가 잘 들어가는 것을 확인할 수 있엇다 ^-^)b

 

 

- 완성된 코드 구조

  • 게시글 작성 페이지 (src/app/write/page.tsx)
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';

// Supabase 클라이언트 생성
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

const WritePage = () => {
  const router = useRouter();
  const userId = process.env.NEXT_PUBLIC_AUTH_2;

  const [address, setAddress] = useState('');
  const [position, setPosition] = useState({ lat: '', lng: '' });
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  const [isModalOpen, setIsModalOpen] = useState(false);

  // geolocation api를 활용한 사용자의 위치 가져오는 로직 (위도/경도)
  const handleLocationCheck = () => {
    if (navigator.geolocation) {
      setLoading(true);
      navigator.geolocation.getCurrentPosition(
        (pos) => {
          const { latitude, longitude } = pos.coords;
          setPosition({ lat: latitude.toString(), lng: longitude.toString() });
          getAddress(latitude, longitude);
        },
        (err) => {
          console.error('위치 권한이 거부되었습니다.', err);
          setLoading(false);
        }
      );
    } else {
      console.error('Geolocation을 지원하지 않는 브라우저입니다.');
    }
  };

  // 가져온 위도 경도를 맵 api를 활용해서 지역으로 변환시키는 로직
  const getAddress = async (lat: number, lng: number) => {
    const url = `https://dapi.kakao.com/v2/local/geo/coord2address.json?x=${lng}&y=${lat}`;
    const apiKey = process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY;

    try {
      const response = await fetch(url, {
        headers: {
          Authorization: `KakaoAK ${apiKey}`,
        },
      });
      const data = await response.json();

      if (data.documents.length > 0) {
        const { region_1depth_name, region_2depth_name, region_3depth_name } =
          data.documents[0].address;
        setAddress(`${region_1depth_name} ${region_2depth_name} ${region_3depth_name}`);
      } else {
        setAddress('주소 정보 없음');
      }
    } catch (error) {
      console.error('주소 조회 실패:', error);
    } finally {
      setLoading(false);
    }
  };

  // 해당 주소를 supabase posts 테이블에 업로드시키는 로직
  const handleSubmit = async () => {
    if (!title || !content || !address) {
      alert('모든 항목을 입력해주세요.');
      return;
    }

    if (!userId) {
      alert('로그인이 필요합니다.');
      console.error('USER_ID is missing in environment variables.');
      return;
    }

    const { error } = await supabase.from('posts').insert([
      {
        title,
        content,
        upload_place: address,
        latitude: position.lat,
        longitude: position.lng,
        created_at: new Date().toISOString(),
        user_id: userId,
      },
    ]);

    if (error) {
      console.error('Supabase 저장 실패:', error);
      alert('저장 실패');
    } else {
      alert('저장 성공!');
      router.push('/posts');
    }
  };

    return (
    <div className="min-h-screen flex items-center justify-center bg-white text-black">
      <div className="w-full max-w-2xl p-8 border border-gray-300 shadow-lg rounded-md">
        <h1 className="text-3xl font-bold mb-6">게시글 작성</h1>
        <label className="block mb-4">
          제목
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="제목을 입력하세요"
            className="mt-2 p-3 w-full border rounded-md focus:ring-2 focus:ring-blue-400"
          />
        </label>
        <label className="block mb-4">
          내용
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            placeholder="내용을 입력하세요"
            className="mt-2 p-3 w-full border rounded-md focus:ring-2 focus:ring-blue-400"
            rows={5}
          />
        </label>
        <button
          onClick={handleLocationCheck}
          disabled={loading}
          className={`w-full p-3 mt-4 text-white rounded-md ${
            loading ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600'
          }`}
        >
          {loading ? '주소 확인 중...' : '현재 위치 주소 확인'}
        </button>
        <p className="mt-4 text-center text-lg">
          현재 위치: {address || '주소 정보 없음'}
        </p>
        <button
          onClick={handleSubmit}
          className="w-full p-3 mt-6 bg-green-500 hover:bg-green-600 text-white rounded-md"
        >
          게시글 업로드
        </button>
      </div>
    </div>
  );
};

export default WritePage;

 

  • 게시글 목록 페이지 (src/app/posts/page.tsx)
'use client';
import { useState, useEffect } from 'react';
import { createClient } from '@supabase/supabase-js';

// Supabase 클라이언트 생성
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Post 타입 정의
interface Post {
  id: string;
  title: string;
  content: string;
  upload_place: string;
  created_at: string;
}

// 게시글 목록 및 삭제 페이지
const PostsPage = () => {
  // posts 상태에 Post[] 타입 명시
  const [posts, setPosts] = useState<Post[]>([]);

  // 페이지 로딩 시 게시글 불러오기
  useEffect(() => {
    fetchPosts();
  }, []);

  // Supabase에서 게시글 불러오기
  const fetchPosts = async () => {
    const { data, error } = await supabase
      .from('posts')
      .select('*')
      .order('created_at', { ascending: false });

    if (error) {
      console.error('게시글 불러오기 실패:', error);
    } else {
      // 데이터가 있을 경우 상태 업데이트
      setPosts(data || []);
    }
  };

  // 게시글 삭제 핸들러
  const deletePost = async (id: string) => {
    const { error } = await supabase
      .from('posts')
      .delete()
      .match({ id });

    if (error) {
      console.error('게시글 삭제 실패:', error);
    } else {
      alert('게시글이 삭제되었습니다.');
      fetchPosts(); // 삭제 후 목록 갱신
    }
  };

  return (
    <div className="p-8 bg-white min-h-screen text-black">
      <h1 className="text-4xl font-bold mb-6">게시글 목록</h1>

      {posts.length > 0 ? (
        posts.map((post) => (
          <div
            key={post.id}
            className="p-6 mb-4 bg-gray-100 rounded-md shadow"
          >
            <h2 className="text-2xl font-bold">{post.title}</h2>
            <p className="text-lg">{post.content}</p>
            <p className="text-sm text-gray-500">
              위치: {post.upload_place}
            </p>
            <p className="text-sm text-gray-400">
              작성일: {new Date(post.created_at).toLocaleDateString()}
            </p>

            <button
              onClick={() => deletePost(post.id)}
              className="mt-4 bg-red-500 p-2 text-white rounded"
            >
              삭제
            </button>
          </div>
        ))
      ) : (
        <p className="text-center text-lg">게시글이 없습니다.</p>
      )}
    </div>
  );
};

export default PostsPage;

 

 

마무리 -  컨디션 조절 잘하기.

 

쿨럭쿨럭

 

정리한다고 늦게늦게 자버렸더니... 또 건강이슈가 발생해버렸다.. (쓰읍)

가습기로도 채워지지 않는 건조한 환경이라 그런건지.. -12도에 얼어붙은 집 때문인지..

마른기침이 쿨럭대네요.. 건강조심하십쇼 열분.

암튼 오늘도 여기서 마무리짓겄슈. 굳바3

 

 

 

 

오늘의 KPT 회고

 

Keep: TIL 알차게 써서 행복한 림졍

Problem: 건강이 상했다.

Try: 제때 쓰고 제때 자기

728x90
반응형