Skip to content

Commit 1098450

Browse files
authored
Merge pull request #177 from GulSam00/feat/174-searchLogAndUiImprove
[Feat] : 검색 로그 기능 추가 및 UI 개선 (#174)
2 parents c377757 + 3b0df8a commit 1098450

18 files changed

Lines changed: 252 additions & 50043 deletions

File tree

README.md

Lines changed: 35 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
[Singcode - 당신의 노래방 메모장](https://www.singcode.kr)
44

5-
65
노래방만 가면 뭘 부르려고 했었지 하면서 부를 곡들을 잊어버린다면. <br/>
76
매번 인터넷에서 노래방 번호를 검색해야 했었다면. <br/>
87
내가 어떤 노래를 가장 많이 불렀는지 궁금하다면. <br/>
@@ -12,14 +11,12 @@ Supabase를 활용한 자체 DB를 통해 금영, TJ 노래방의 번호를 한
1211

1312
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
1413

15-
1614
</div>
1715

18-
1916
---
2017

21-
2218
## 📦 배포
19+
2320
[Singcode - 당신의 노래방 메모장](https://www.singcode.kr)
2421

2522
## 📚 기술 스택
@@ -54,109 +51,94 @@ sing-code/
5451
├── package.json # 루트 패키지 관리 파일
5552
└── 기타 설정 파일들 # .gitignore, .prettierrc.json, LICENSE 등
5653
```
54+
5755
### Supabase DB 구조
5856

5957
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
6058

61-
62-
6359
<img width="700" height="725" alt="image" src="https://github.com/user-attachments/assets/17080dc1-1b63-4cfb-a325-429d207c52d6" />
6460

6561
</div>
6662

67-
6863
## ✨ 주요 기능
6964

7065
### 검색 페이지
7166

72-
* 제목, 가수 이름으로 곡을 검색할 수 있습니다.
67+
- 제목, 가수 이름으로 곡을 검색할 수 있습니다.
7368

7469
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
75-
70+
7671

7772
</div>
7873

79-
### 검색 페이지 - 재생목록으로 저장
80-
81-
* 기존 재생목록이나 새로운 재생목록에 곡을 저장할 수 있습니다.
74+
### 검색 페이지 - 재생목록으로 저장
8275

83-
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
76+
- 기존 재생목록이나 새로운 재생목록에 곡을 저장할 수 있습니다.
8477

78+
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
8579

8680
</div>
8781

8882
### 부를 곡 페이지
8983

90-
* 자신이 저장한 부를 곡을 조회합니다.
91-
* 부를 곡의 순서를 바꿀 수 있습니다.
92-
* 곡을 부르거나, 부르지 않고 삭제할 수 있습니다.
93-
94-
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
84+
- 자신이 저장한 부를 곡을 조회합니다.
85+
- 부를 곡의 순서를 바꿀 수 있습니다.
86+
- 곡을 부르거나, 부르지 않고 삭제할 수 있습니다.
9587

88+
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
9689

9790
</div>
9891

99-
* 좋아요 표시한 곡이나 재생목록에 저장한 곡에서 빠르게 부를곡을 추가할 수 있습니다.
100-
92+
- 즐겨찾기 표시한 곡이나 재생목록에 저장한 곡에서 빠르게 부를곡을 추가할 수 있습니다.
10193

10294
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
10395

104-
10596
</div>
10697

107-
10898
### 인기곡 페이지
10999

110-
* 곡의 추천 순위를 집계해서 보여줍니다.
100+
- 곡의 추천 순위를 집계해서 보여줍니다.
111101

112102
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
113103

114-
115104
</div>
116105

117-
118106
### 라이브러리 페이지
119107

120-
* 자신의 활동 기록을 조회 및 관리할 수 있습니다.
121-
* 좋아요 한 곡들을 조회하고 일괄 삭제할 수 있습니다.
122-
* 자신이 가장 많이 부른 곡의 순위를 확인할 수 있습니다.
108+
- 자신의 활동 기록을 조회 및 관리할 수 있습니다.
109+
- 즐겨찾기 한 곡들을 조회하고 일괄 삭제할 수 있습니다.
110+
- 자신이 가장 많이 부른 곡의 순위를 확인할 수 있습니다.
123111

124112
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
125-
113+
126114

127115
</div>
128116

129117
### 출석 체크 기능
130118

131-
* 회원일 경우 하루에 한 번 출석 체크를 통해 포인트를 획득할 수 있습니다. 매일 12시 마다 초기화됩니다.
119+
- 회원일 경우 하루에 한 번 출석 체크를 통해 포인트를 획득할 수 있습니다. 매일 12시 마다 초기화됩니다.
132120

133121
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
134122

135-
136123
</div>
137124

138125
### 곡 추천 기능
139126

140-
* 출석 체크로 획득한 포인트를 사용해서 곡을 추천할 수 있습니다. 1 포인트 당 1 추천입니다.
127+
- 출석 체크로 획득한 포인트를 사용해서 곡을 추천할 수 있습니다. 1 포인트 당 1 추천입니다.
141128

142129
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
143130

144-
145131
</div>
146132

147-
148-
149-
150133
### 로그인 & 회원가입 지원
151134

152-
* 몇몇 추가적인 기능을 사용하려면 회원가입을 진행해야 합니다.
153-
* 이메일 인증 회원가입과 카카오 회원가입을 지원합니다.
154-
135+
- 몇몇 추가적인 기능을 사용하려면 회원가입을 진행해야 합니다.
136+
- 이메일 인증 회원가입과 카카오 회원가입을 지원합니다.
137+
155138
<div style="display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
156139

157140
</div>
158141

159-
160142
## 📖 프로젝트 기록
161143

162144
- 2025.03.12 : 프로젝트 시작
@@ -181,29 +163,29 @@ sing-code/
181163
- 2026.2.8 : 버전 2.1.0 배포. 비회원 상태로 곡 부를곡 추가가 가능, Footer 애니메이션 추가.
182164
- 2026.2.20 : 버전 2.2.0 배포. 검색어 자동 완성 기능. es-hangul로 초성 검색 지원.
183165
- 2026.3.2 : 버전 2.3.0 배포. 곡 추천 페이지에서 UI 조정, 곡 추천 기능 추가. 글자 자동 스크롤 기능 추가.
184-
166+
185167
## 📝 회고
186168

187169
### 해결하고자 했던 문제
188170

189171
- 기존 유사한 서비스에서는 하나의 곡을 검색할 때 TJ, 금영 API로 따로따로 곡 데이터를 요청하기에 기존 방식으로는 데이터를 관리하기가 어려웠습니다.
190-
- Supabase로 자체 DB를 구축하고, OPEN API와 puppeteer, cheerio를 활용한 웹 크롤링을 통해 데이터를 파싱하여 DB에 넣어주었습니다.
191-
- OPEN API에 의존하지 않고, 목적에 맞게 활용할 수 있는 자체 API를 구성할 수 있었습니다.
172+
- Supabase로 자체 DB를 구축하고, OPEN API와 puppeteer, cheerio를 활용한 웹 크롤링을 통해 데이터를 파싱하여 DB에 넣어주었습니다.
173+
- OPEN API에 의존하지 않고, 목적에 맞게 활용할 수 있는 자체 API를 구성할 수 있었습니다.
192174
- 52000개가 넘는 DB 테이블에서 모든 검색 결과를 한번에 제공하고 있어서 검색 응답 속도가 굉장히 길었습니다.
193-
- 검색 기능에 Tanstack Query의 useInfiniteQuery를 활용한 무한 스크롤을 도입하여, 페이지 단위의 데이터를 점진적으로 불러오도록 구현했습니다.
194-
- 초기 로딩 속도를 크게 감소시켰고, 사용자 경험을 크게 향상시켰습니다.
175+
- 검색 기능에 Tanstack Query의 useInfiniteQuery를 활용한 무한 스크롤을 도입하여, 페이지 단위의 데이터를 점진적으로 불러오도록 구현했습니다.
176+
- 초기 로딩 속도를 크게 감소시켰고, 사용자 경험을 크게 향상시켰습니다.
195177
- 검색 결과로 렌더링된 곡 컴포넌트에서 이벤트에 따라 UI를 동적으로 변경해야 했으나, 단 하나의 속성값 변경을 위해 매번 queryKey를 무효화하고 전체 데이터를 다시 요청하는 방식은 기술적으로도, 사용자 경험 측면에서도 불필요했습니다.
196-
- `Tanstack Query`의 낙관적 업데이트를 도입하여, UI가 변경해야 하는 이벤트 발생 시 즉각적으로 UI가 변경되도록 최적화하였습니다.
197-
- 로딩 없이 즉시 변경되는 UI를 사용자에게 보여주며 사용자 경험을 크게 개선했습니다.
178+
- `Tanstack Query`의 낙관적 업데이트를 도입하여, UI가 변경해야 하는 이벤트 발생 시 즉각적으로 UI가 변경되도록 최적화하였습니다.
179+
- 로딩 없이 즉시 변경되는 UI를 사용자에게 보여주며 사용자 경험을 크게 개선했습니다.
198180
- 하나의 mutation 요청에 의존하는 side effect(노래를 불렀을 때 의존하는 여러 queryKey 무효화, log 증가 mutation 호출)가 많았기에, 빠르게 여러 mutation을 호출할수록 요청 처리가 늦어지고 timeout이 나오기도 하였습니다.
199-
- mutation 함수에 debounce를 도입하여 짧은 시간 내 많은 요청 시 queryKey를 한 번만 무효화시키게 처리하였습니다.
200-
- Supabase에서 제공하는 Database Functions과 Trigger를 적절하게 활용하여 클라이언트의 요청 응답 속도를 줄였습니다.
181+
- mutation 함수에 debounce를 도입하여 짧은 시간 내 많은 요청 시 queryKey를 한 번만 무효화시키게 처리하였습니다.
182+
- Supabase에서 제공하는 Database Functions과 Trigger를 적절하게 활용하여 클라이언트의 요청 응답 속도를 줄였습니다.
201183
- POST, DELETE, PATCH 요청 때마다 DB와 클라이언트 간의 데이터를 동기화해줘야 했습니다.
202-
- Zustand의 store action으로 제어해보려고 하였으나, 한계를 느끼고 Tanstack Query를 도입하였습니다.
203-
- DB의 변동이 생길 시 queryKey를 무효화시켜 최신 DB 데이터를 가져올 수 있게끔 하였습니다.
184+
- Zustand의 store action으로 제어해보려고 하였으나, 한계를 느끼고 Tanstack Query를 도입하였습니다.
185+
- DB의 변동이 생길 시 queryKey를 무효화시켜 최신 DB 데이터를 가져올 수 있게끔 하였습니다.
204186
- API 요청 시 Supabase DB의 정보가 노출될 위험이 있었습니다.
205-
- Next.js의 API Route를 활용하여 서버리스 API를 구축하고 외부 API의 URL을 감췄습니다.
206-
187+
- Next.js의 API Route를 활용하여 서버리스 API를 구축하고 외부 API의 URL을 감췄습니다.
188+
207189
### 무엇을 얻었는지
208190

209191
- Turborepo를 활용하면서 모노레포의 이해를 높이고 프로젝트에 어떻게 적용하고 확장해야 하는지에 대해 익혔습니다.

apps/web/public/changelog.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
"title": "버전 1.2.0",
1212
"message": [
1313
"인기곡 페이지를 추가했습니다.",
14-
"- 전체, 연도별, 월별 부른 곡의 통계와 좋아요 한 곡의 통계를 확인할 수 있습니다.",
15-
"좋아요 한 곡이 통계에 반영되지 않는 문제를 수정했습니다."
14+
"- 전체, 연도별, 월별 부른 곡의 통계와 즐겨찾기 한 곡의 통계를 확인할 수 있습니다.",
15+
"즐겨찾기 한 곡이 통계에 반영되지 않는 문제를 수정했습니다."
1616
]
1717
},
1818
"1.3.0": {

apps/web/public/sitemap-0.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
3-
<url><loc>https://www.singcode.kr</loc><lastmod>2026-03-27T14:29:45.638Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
3+
<url><loc>https://www.singcode.kr</loc><lastmod>2026-03-30T15:15:37.869Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
44
</urlset>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import createClient from '@/lib/supabase/server';
4+
import { ApiResponse } from '@/types/apiRoute';
5+
6+
interface SearchLogCount {
7+
text: string;
8+
count: number;
9+
}
10+
11+
export async function GET(): Promise<NextResponse<ApiResponse<SearchLogCount[]>>> {
12+
try {
13+
const supabase = await createClient();
14+
const { data, error } = await supabase.from('search_logs').select('text');
15+
16+
if (error) throw error;
17+
18+
const countMap = new Map<string, number>();
19+
for (const row of data) {
20+
countMap.set(row.text, (countMap.get(row.text) ?? 0) + 1);
21+
}
22+
23+
const result: SearchLogCount[] = Array.from(countMap, ([text, count]) => ({
24+
text,
25+
count,
26+
})).sort((a, b) => b.count - a.count);
27+
28+
return NextResponse.json({ success: true, data: result });
29+
} catch (error) {
30+
if (error instanceof Error && error.cause === 'auth') {
31+
return NextResponse.json(
32+
{ success: false, error: 'User not authenticated' },
33+
{ status: 401 },
34+
);
35+
}
36+
37+
console.error('Error in search log GET API:', error);
38+
return NextResponse.json(
39+
{ success: false, error: 'Failed to get search logs' },
40+
{ status: 500 },
41+
);
42+
}
43+
}
44+
45+
export async function POST(request: Request): Promise<NextResponse<ApiResponse<void>>> {
46+
try {
47+
const { text } = await request.json();
48+
49+
const supabase = await createClient();
50+
const { error } = await supabase.from('search_logs').insert({ text });
51+
52+
if (error) throw error;
53+
54+
return NextResponse.json({ success: true });
55+
} catch (error) {
56+
if (error instanceof Error && error.cause === 'auth') {
57+
return NextResponse.json(
58+
{ success: false, error: 'User not authenticated' },
59+
{ status: 401 },
60+
);
61+
}
62+
63+
console.error('Error in search log POST API:', error);
64+
return NextResponse.json(
65+
{ success: false, error: 'Failed to post search log' },
66+
{ status: 500 },
67+
);
68+
}
69+
}

apps/web/src/app/info/like/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default function LikePage() {
2727
<Button variant="ghost" size="icon" onClick={() => router.back()} className="mr-2">
2828
<ArrowLeft className="h-5 w-5" />
2929
</Button>
30-
<h1 className="text-2xl font-bold">좋아요 곡 관리</h1>
30+
<h1 className="text-2xl font-bold">즐겨찾기 곡 관리</h1>
3131
</div>
3232

3333
<div className="flex h-[48px] items-center justify-between p-2">

apps/web/src/app/info/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { CircleDollarSign, Folder, Heart } from 'lucide-react';
3+
import { CircleDollarSign, Folder, Star } from 'lucide-react';
44
import { useRouter } from 'next/navigation';
55

66
import CountUp from '@/components/reactBits/CountUp';
@@ -11,9 +11,9 @@ import { useUserQuery } from '@/queries/userQuery';
1111
const menuItems = [
1212
{
1313
id: 'like',
14-
title: '좋아요 곡 관리',
14+
title: '즐겨찾기 곡 관리',
1515
description: '좋아요를 누른 노래를 관리합니다',
16-
icon: <Heart className="h-5 w-5" />,
16+
icon: <Star className="h-5 w-5" />,
1717
},
1818

1919
{

apps/web/src/app/search/HomePage.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import { Loader2, Search, SearchX } from 'lucide-react';
44
import { useEffect, useState } from 'react';
55
import { useInView } from 'react-intersection-observer';
6-
import { toast } from 'sonner';
76

87
import { Button } from '@/components/ui/button';
98
// import { Checkbox } from '@/components/ui/checkbox';
@@ -18,6 +17,7 @@ import { SearchSong } from '@/types/song';
1817
import AddFolderModal from './AddFolderModal';
1918
// import ChatBot from './ChatBot';
2019
import JpnArtistList from './JpnArtistList';
20+
import PopularSearchHistory from './PopularSearchHistory';
2121
import SearchAutocomplete from './SearchAutocomplete';
2222
import SearchHistory from './SearchHistory';
2323
import SearchResultCard from './SearchResultCard';
@@ -96,11 +96,6 @@ export default function SearchPage() {
9696
};
9797

9898
const handleSearchClick = () => {
99-
if (!search.trim()) {
100-
toast.error('검색어를 입력해주세요.');
101-
return;
102-
}
103-
10499
handleSearch();
105100
setIsFocusAuto(false);
106101
};
@@ -206,10 +201,8 @@ export default function SearchPage() {
206201
{isPendingSearch ? <Loader2 className="h-4 w-4 animate-spin" /> : '검색'}
207202
</Button>
208203
</div>
209-
{/* 검색 기록 */}
210-
<SearchHistory onHistoryClick={handleHistoryClick} />
211204
</div>
212-
<div ref={setScrollRef} className="h-[calc(100vh-26rem)] overflow-x-hidden overflow-y-auto">
205+
<div ref={setScrollRef} className="h-[calc(100vh-22rem)] overflow-x-hidden overflow-y-auto">
213206
{searchSongs.length > 0 && (
214207
<div className="flex w-full max-w-md flex-col gap-4 p-4">
215208
{searchSongs.map((song, index) => (
@@ -247,11 +240,19 @@ export default function SearchPage() {
247240
<p className="m-2">검색 결과가 없습니다.</p>
248241
</div>
249242
)}
250-
{searchSongs.length === 0 && !query && (
243+
244+
{/* {searchSongs.length === 0 && !query && (
251245
<div className="text-muted-foreground flex h-40 flex-col items-center justify-center">
252246
<Search className="h-8 w-8 opacity-50" />
253247
<p className="m-2">노래 제목이나 가수를 검색해보세요</p>
254248
</div>
249+
)} */}
250+
251+
{searchSongs.length === 0 && !query && (
252+
<div className="flex h-full flex-col justify-center gap-2">
253+
<SearchHistory onHistoryClick={handleHistoryClick} />
254+
<PopularSearchHistory onHistoryClick={handleHistoryClick} />
255+
</div>
255256
)}
256257
</div>
257258

0 commit comments

Comments
 (0)