nuxt + supabase 게시판 만들기 2. CRUD 구현

jellykelly·2025년 7월 17일
0

Nuxt

목록 보기
6/8
post-thumbnail

게시판을 구성하기 위해서는 다음과 같은 4가지 화면이 필요합니다.

  1. 게시글을 작성하는 화면
  2. 게시글 리스트 화면
  3. 리스트를 클릭하면 보이는 상세 화면
  4. 게시글 수정 화면

각 화면을 구현하고 Supabase를 연동하여 실제 데이터베이스와 연결되는 기본적인 CRUD기능을 연습해보겠습니다

1. 게시글 작성

페이지 생성

먼저 게시글을 새로 작성할 수 있는 화면을 만들었습니다.
title과 content를 입력받아 Supabase의 posts 테이블에 데이터를 저장합니다.

// pages/create.vue

<template>
	<div>
		<h1>새 글 작성</h1>
		<form @submit.prevent="submit">
			<div>
				<label for="title">제목</label>
				<input id="title" v-model="title" required />
			</div>
			<div>
				<label for="content">내용</label>
				<textarea id="content" v-model="content" required></textarea>
			</div>
			<button type="submit">저장</button>
		</form>
	</div>
</template>

<script setup>
	const supabase = useSupabase();
	const router = useRouter();
	const title = ref('');
	const content = ref('');

	async function submit() {
    	// posts 테이블에 제목, 내용 새 레코드 삽입
		const { error } = await supabase
			.from('posts')
			.insert([{ title: title.value, content: content.value }]);
		if (error) {
			alert('저장 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
	}
</script>

데이터 입력

제목, 내용을 입력하고 저장버튼을 누르면

supabase에 DB에 잘 들어와 있는것을 확인할 수 있습니당

메인으로 이동 라우터 추가

게시글을 저장하면 메인 리스트로 이동하도록 submit 함수에 router를 추가합니다.
나중에는 '저장되었습니다!' 같은 popup을 추가해야겠어요. 지금은 갈길이 머니 일단 생략..

// pages/create.vue

	async function submit() {
		const { error } = await supabase
			.from('posts')
			.insert([{ title: title.value, content: content.value }]);
		if (error) {
			alert('저장 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
		router.push('/'); // 저장 후 목록(index)으로 이동
	}

2. 게시글 리스트

저장된 DB를 불러와서 화면에 뿌려줍니다.
게시판 가장 기본 형태인 테이블 구조로 마크업 하겠습니다.

페이지 생성

// pages/index.vue

<template>
	<div class="index">
		<h1>게시글 목록</h1>

		<table>
			<thead>
				<tr>
					<th>No.</th>
					<th>제목</th>
					<th>작성일</th>
				</tr>
			</thead>
			<tbody>
				<tr v-for="post in posts" :key="post.id">
					<td>{{ post.id }}</td>
					<td>{{ post.title }}</td>
					<td>{{ post.created_at }}</td>
				</tr>
			</tbody>
		</table>

		<NuxtLink to="/create" class="btn">글쓰기</NuxtLink>
	</div>
</template>

<script setup>
	const supabase = useSupabase();

	const { data: posts } = await useAsyncData('posts', async () => {
    	// posts 테이블에서 모든 컬럼을 선택한 후, id 컬럼을 기준으로 내림차순 정렬
		const { data, error } = await supabase
			.from('posts')
			.select('*')
			.order('id', { ascending: false });

		if (error) {
			console.error('Supabase fetch error:', error);
			return [];
		}
		return data;
	});

</script>

작성일 텍스트가 불필요하게 긴 이유는, DB 테이블을 만들 때 작성일 데이터타입을 timestamptz으로 선택했기 때문.... date타입으로 변경해주면 yyyy-mm-dd만 딱 출력됩니다!

create.vue 코드 수정

// pages/create.vue

	const router = useRouter();

	async function submit() {
		const { error } = await supabase
			.from('posts')
			.insert([
				{
					title: title.value,
					content: content.value,
					created_at: new Date().toISOString().split('T')[0], // 명시적으로 날짜 기본값 전달
				},
			]);
		if (error) {
			alert('저장 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
		router.push('/'); 
	}

클릭 시 상세페이지로 이동

이제 리스트를 클릭했을 때, 해당 게시글의 상세페이지로 이동해야합니다. <tr>을 클릭했을 때 페이지 이동하도록 click event를 추가하겠습니다.

dynamic routes

Nuxt에서는 [id].vue와 같이 대괄호로 파일명을 감싸면 URL 파라미터를 자동으로 처리합니다. (Dynamic Routes)
예: pages/post-[id].vue/post-1, /post-14 등 경로에 자동 대응

// pages/index.vue

<tbody>
	<tr v-for="post in posts" :key="post.id" @click="linkToPost(post.id)">
		<td>{{ post.id }}</td>
		<td>{{ post.title }}</td>
		<td class="date">{{ post.created_at }}</td>
	</tr>
</tbody>

<script setup>
	function linkToPost(id) {
		router.push(`/post-${id}`);
	}
</script>

3. 게시글 상세

posts 테이블에서 조회한 데이터를 post.value에 할당하고 title, content를 화면에 뿌려줍니다

페이지 생성

// pages/post-[id]/index.vue

<template>
	<h1>{{ post.title }}</h1>
	<p class="cont">{{ post.content }}</p>

	<NuxtLink to="/" class="btn">목록으로</NuxtLink>
</template>

<script setup>
	const supabase = useSupabase();
	const post = ref(null);

	onMounted(async () => {
    	// posts 테이블에서 id가 route.params.id인 단일 레코드 조회
		const { data, error } = await supabase
			.from('posts')
			.select('*')
			.eq('id', route.params.id)
			.single();
		if (error) {
			alert('불러오는 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
        
        // route.params.id에 해당하는 게시글 객체를 post.value에 할당
		post.value = data;
	});
</script>

4. 삭제 기능 추가

상세 화면 내에 게시물 삭제 기능을 추가해보겠습니다.

// pages/post-[id]/index.vue

<template>
	<h1>{{ post.title }}</h1>
	<p class="cont">{{ post.content }}</p>
	<button @click="deletePost(post.id)">삭제</button>
	<NuxtLink to="/" class="btn">목록으로</NuxtLink>
</template>

<script setup>
	const supabase = useSupabase();
	const post = ref(null);
    const router = useRouter();

	onMounted(async () => {
		const { data, error } = await supabase
			.from('posts')
			.select('*')
			.eq('id', route.params.id)
			.single();
		if (error) {
			alert('불러오는 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
		post.value = data;
	});
    
    async function deletePost(id) {
		const isConfirmed = confirm('정말로 삭제하시겠습니까?');

		if (!isConfirmed) {
			return;
		}
        
		// posts 테이블에서 해당 id 행 삭제
		const { error } = await supabase.from('posts').delete().eq('id', id);
        
		if (error) {
			alert('삭제 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
        
        // 삭제 성공 후 목록 페이지로 이동
		router.push('/');
	}
</script>

삭제버튼을 누르면 confirm창을 띄우고, 확인버튼 클릭 시 게시물 삭제 후 메인으로 이동하도록 설정하였습니다.
(일단 디자인은 생략,,,,,,)

No. 컬럼 출력방식 수정

그런데 No. 컬럼의 값을 id로 받았더니, 중간에 삭제한 id가 그대로 출력되어 뭔가 어색합니당,,,
전체 post의 length에서 post의 index를 빼는 방식으로 내림차순이 되게 정렬하겠습니다.

// pages/index.vue

<tbody>
	<tr v-for="(post, idx) in posts" :key="post.id">
		<td>{{ posts.length - idx }}</td> <!-- no. 컬럼을 내림차순으로 정렬 -->
		<td>{{ post.title }}</td>
		<td>{{ post.created_at }}</td>
	</tr>
</tbody>
		

마음이 편안해집니다...

5. 게시글 수정

수정화면 역시 dynamic routes를 사용해서 이동합니다.
create.vue에서는 게시글을 작성하면 리스트(index.vue)로 이동했지만, edit.vue에서는 수정 완료 시 해당 상세페이지로 라우팅 해주었습니다.

// pages/post-[id]/edit.vue

<template>
	<h1>게시글 수정</h1>
	<form @submit.prevent="update">
		<label for="title">제목</label>
		<input id="title" v-model="title" required />

		<label for="content">내용</label>
		<textarea id="content" v-model="content" required></textarea>

		<button type="submit">수정</button>
	</form>
</template>

<script setup>
	const supabase = useSupabase();
	const route = useRoute();
	const router = useRouter();
	const post = ref(null);
	const title = ref('');
	const content = ref('');

	onMounted(async () => {
		// posts 테이블에서 id가 route.params.id인 단일 레코드 조회
		const { data, error } = await supabase
			.from('posts')
			.select('*')
			.eq('id', route.params.id)
			.single();
            
		if (error) {
			alert('불러오는 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}

		// 조회된 데이터를 post, title, content에 각각 할당
		post.value = data;
		title.value = data.title;
		content.value = data.content;
	});

	async function update() {
		// posts 테이블에서 id가 post.value.id인 레코드 업데이트
		const { error } = await supabase
			.from('posts')
			.update({ title: title.value, content: content.value })
			.eq('id', post.value.id);
		if (error) {
			alert('수정 중 오류가 발생했습니다.');
			console.error(error);
			return;
		}
        
        // 업데이트 성공 시 해당 id의 상세 페이지로 라우팅
		router.push(`/post-${post.value.id}`);
	}
</script>

🔑 전체 코드

create.vue

<template>
  <div>
    <h1>새 글 작성</h1>
    <form @submit.prevent="submit">
      <div>
        <label for="title">제목</label>
        <input id="title" v-model="title" required />
      </div>
      <div>
        <label for="content">내용</label>
        <textarea id="content" v-model="content" required></textarea>
      </div>
      <button type="submit">저장</button>
    </form>
  </div>
</template>

<script setup>
const supabase = useSupabase()
const router = useRouter()
const title = ref('')
const content = ref('')

async function submit() {
  const { error } = await supabase
    .from('posts')
    .insert([
      {
        title: title.value,
        content: content.value,
        created_at: new Date().toISOString().split('T')[0], 
      },
    ])
  if (error) {
    alert('저장 중 오류가 발생했습니다.')
    console.error(error)
    return
  }
  router.push('/') 
}
</script>

index.vue

<template>
  <div>
    <h1>게시글 목록</h1>
    <table>
      <thead>
        <tr>
          <th>No.</th>
          <th>제목</th>
          <th>작성일</th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="post in posts"
          :key="post.id"
          @click="linkToPost(post.id)"
          style="cursor: pointer"
        >
          <td>{{ posts.length - idx }}</td>
          <td>{{ post.title }}</td>
          <td>{{ post.created_at }}</td>
        </tr>
      </tbody>
    </table>
    <NuxtLink to="/create">글쓰기</NuxtLink>
  </div>
</template>

<script setup>
const supabase = useSupabase()
const router = useRouter()

const { data: posts } = await useAsyncData('posts', async () => {
  const { data, error } = await supabase
    .from('posts')
    .select('*')
    .order('id', { ascending: false })
  if (error) {
    console.error('Supabase fetch error:', error)
    return []
  }
  return data
})

function linkToPost(id) {
  router.push(`/post-${id}`) 
}
</script>

[id].vue

<template>
  <div>
    <h1>{{ post.title }}</h1>
    <p>{{ post.content }}</p>
    <NuxtLink :to="`/post-${post.id}/edit`">수정</NuxtLink>
    <button @click="deletePost(post.id)">삭제</button>
    <NuxtLink to="/">목록으로</NuxtLink>
  </div>
</template>

<script setup>
const supabase = useSupabase()
const route = useRoute()
const router = useRouter()
const post = ref(null)

onMounted(async () => {
  const { data, error } = await supabase
    .from('posts')
    .select('*')
    .eq('id', route.params.id)
    .single()
  if (error) {
    alert('불러오는 중 오류가 발생했습니다.')
    console.error(error)
    return
  }
  post.value = data
})

async function deletePost(id) {
  const confirmed = confirm('정말로 삭제하시겠습니까?')
  if (!confirmed) return
  const { error } = await supabase.from('posts').delete().eq('id', id)
  if (error) {
    alert('삭제 중 오류가 발생했습니다.')
    console.error(error)
    return
  }
  router.push('/')
}
</script>

edit.vue

<template>
  <div>
    <h1>글 수정</h1>
    <form @submit.prevent="update">
      <div>
        <label for="title">제목</label>
        <input id="title" v-model="title" required />
      </div>
      <div>
        <label for="content">내용</label>
        <textarea id="content" v-model="content" required></textarea>
      </div>
      <button type="submit">수정</button>
    </form>
  </div>
</template>

<script setup>
const supabase = useSupabase()
const route = useRoute()
const router = useRouter()
const title = ref('')
const content = ref('')
const post = ref(null)

onMounted(async () => {
  const { data, error } = await supabase
    .from('posts')
    .select('*')
    .eq('id', route.params.id)
    .single()
  if (error) {
    alert('불러오는 중 오류가 발생했습니다.')
    console.error(error)
    return
  }
  post.value = data
  title.value = data.title
  content.value = data.content
})

async function update() {
  const { error } = await supabase
    .from('posts')
    .update({ title: title.value, content: content.value })
    .eq('id', post.value.id)
  if (error) {
    alert('수정 중 오류가 발생했습니다.')
    console.error(error)
    return
  }
  router.push(`/post-${post.value.id}`)
}
</script>
profile
Hawaiian pizza with extra pineapples please! 🥤

0개의 댓글