diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml
index ee74551..dd0e594 100644
--- a/apps/web/public/sitemap-0.xml
+++ b/apps/web/public/sitemap-0.xml
@@ -1,6 +1,6 @@
-https://www.singcode.kr/manifest.webmanifest2026-08-31T07:03:47.228Zweekly0.7
-https://www.singcode.kr/patch-notes2026-08-31T07:03:47.229Zweekly0.7
-https://www.singcode.kr2026-08-31T07:03:47.229Zweekly0.7
+https://www.singcode.kr/manifest.webmanifest2026-09-08T09:58:23.352Zweekly0.7
+https://www.singcode.kr/patch-notes2026-09-08T09:58:23.354Zweekly0.7
+https://www.singcode.kr2026-09-08T09:58:23.354Zweekly0.7
\ No newline at end of file
diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx
index c8fb99b..efc66fc 100644
--- a/apps/web/src/app/popular/ArtistVotePanel.tsx
+++ b/apps/web/src/app/popular/ArtistVotePanel.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import SearchAutocomplete from '@/app/search/SearchAutocomplete';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
+import useAutocompleteNavigation from '@/hooks/useAutocompleteNavigation';
import { useMyArtistVotesQuery, useSaveArtistVotesMutation } from '@/queries/artistVoteQuery';
import { useArtistSearchQuery } from '@/queries/artistsQuery';
import { useUserQuery } from '@/queries/userQuery';
@@ -25,6 +26,8 @@ import ArtistVoteRow from './ArtistVoteRow';
const STEP = 10;
+const ARTIST_VOTE_LISTBOX_ID = 'artist-vote-autocomplete-listbox';
+
/**
* 이번 달(아직 확정되지 않은 달) 화면. 랭킹 대신 내가 이번 달에 투표한 아티스트를 편집한다.
* 검색으로 고른 아티스트는 0P로 목록에 담기기만 하고, 값을 고친 뒤 엔터(또는 저장 버튼)로
@@ -50,14 +53,20 @@ export default function ArtistVotePanel() {
const { mutate: saveVotes, isPending } = useSaveArtistVotesMutation();
const { data: searchResults = [], isFetching: isSearching } = useArtistSearchQuery(query);
- const autoCompleteList = searchResults.map(artist => ({
- // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다.
- label:
- artist.name_ko && artist.name_ko !== artist.name
- ? `${artist.name} (${artist.name_ko})`
- : artist.name,
- value: artist.name,
- }));
+ // useMemo 로 참조를 고정한다. 매 렌더 새 배열을 만들면
+ // useAutocompleteNavigation 이 목록이 바뀐 것으로 보고 활성 후보를 계속 지운다.
+ const autoCompleteList = useMemo(
+ () =>
+ searchResults.map(artist => ({
+ // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다.
+ label:
+ artist.name_ko && artist.name_ko !== artist.name
+ ? `${artist.name} (${artist.name_ko})`
+ : artist.name,
+ value: artist.name,
+ })),
+ [searchResults],
+ );
// 자동완성은 결과가 없으면 아무것도 그리지 않아 검색이 동작하는지조차 알기 어렵다.
// 검색어를 넣었는데 후보가 없으면 그 사실을 그대로 알려준다.
const isSearchEmpty = query.trim().length > 0 && !isSearching && autoCompleteList.length === 0;
@@ -88,6 +97,16 @@ export default function ArtistVotePanel() {
setIsFocusAuto(true);
};
+ const isAutocompleteOpen = isFocusAuto && autoCompleteList.length > 0;
+
+ const { activeCandidate, handleKeyDown, inputAriaProps, listboxProps } =
+ useAutocompleteNavigation({
+ listboxId: ARTIST_VOTE_LISTBOX_ID,
+ candidates: autoCompleteList,
+ isOpen: isAutocompleteOpen,
+ onClose: () => setIsFocusAuto(false),
+ });
+
const handleSelectArtist = (name: string) => {
setQuery('');
setIsFocusAuto(false);
@@ -98,6 +117,12 @@ export default function ArtistVotePanel() {
setAddedArtists(prev => [...prev, selected ?? { name, name_ko: null }]);
};
+ // 엔터는 키보드로 고른 후보를 목록에 담는다. 고른 후보가 없으면 아무 일도 하지 않는다.
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key !== 'Enter' || !activeCandidate) return;
+ handleSelectArtist(activeCandidate.value);
+ };
+
const handleChange = (artist: string, amount: number) => {
setDraft(prev => ({ ...prev, [artist]: Math.max(0, amount) }));
};
@@ -157,11 +182,18 @@ export default function ArtistVotePanel() {
placeholder="아티스트 검색"
value={query}
onChange={event => handleChangeQuery(event.target.value)}
+ onKeyDown={handleKeyDown}
+ onKeyUp={handleKeyUp}
onFocus={() => setIsFocusAuto(true)}
onBlur={() => setIsFocusAuto(false)}
+ {...inputAriaProps}
/>
{isFocusAuto && (
-
+
)}
diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx
index a6abae7..14d5984 100644
--- a/apps/web/src/app/search/HomePage.tsx
+++ b/apps/web/src/app/search/HomePage.tsx
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TOUR_DEMO_SEARCH_TERM, TOUR_DEMO_SONG } from '@/constants/tourDemoSong';
+import useAutocompleteNavigation from '@/hooks/useAutocompleteNavigation';
import useSaveSongModal from '@/hooks/useSaveSongModal';
import useSearchSong from '@/hooks/useSearchSong';
import useSearchTourController from '@/hooks/useSearchTourController';
@@ -26,6 +27,8 @@ import SearchResultCard from './SearchResultCard';
import SearchStatus from './SearchStatus';
import SearchTour from './SearchTour';
+const AUTOCOMPLETE_LISTBOX_ID = 'search-autocomplete-listbox';
+
export default function SearchPage() {
const {
search,
@@ -114,12 +117,27 @@ export default function SearchPage() {
const isNumberKeypadVisible =
searchType === 'number' && (isNumberKeypadOpen || searchSongs.length === 0);
- // 엔터 키 처리
+ const isAutocompleteOpen = isFocusAuto && autoCompleteList.length > 0;
+
+ const { activeCandidate, handleKeyDown, inputAriaProps, listboxProps } =
+ useAutocompleteNavigation({
+ listboxId: AUTOCOMPLETE_LISTBOX_ID,
+ candidates: autoCompleteList,
+ isOpen: isAutocompleteOpen,
+ onClose: () => setIsFocusAuto(false),
+ });
+
+ // 엔터 키 처리 — 활성 후보가 있으면 그 후보로, 없으면 입력값 그대로 검색한다.
const handleKeyUp = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
+ if (e.key !== 'Enter') return;
+
+ if (activeCandidate) {
+ setSearch(activeCandidate.value);
+ handleSearch(activeCandidate.value);
+ } else {
handleSearch();
- setIsFocusAuto(false);
}
+ setIsFocusAuto(false);
};
const handleSearchClick = () => {
@@ -304,14 +322,18 @@ export default function SearchPage() {
className="pl-8"
value={search}
onChange={handleChangeSearch}
+ onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onFocus={() => setIsFocusAuto(true)}
onBlur={() => setIsFocusAuto(false)}
+ {...inputAriaProps}
/>
- {isFocusAuto && searchType !== 'number' && (
+ {/* 검색 타입별 노출 여부는 useSearchSong이 autoCompleteList를 비우는 것으로 정한다 */}
+ {isFocusAuto && (
)}
diff --git a/apps/web/src/app/search/SearchAutocomplete.tsx b/apps/web/src/app/search/SearchAutocomplete.tsx
index 7d3bfba..5a84375 100644
--- a/apps/web/src/app/search/SearchAutocomplete.tsx
+++ b/apps/web/src/app/search/SearchAutocomplete.tsx
@@ -1,17 +1,26 @@
'use client';
+import { getAutocompleteOptionId } from '@/hooks/useAutocompleteNavigation';
import { cn } from '@/utils/cn';
import { type SearchCandidate } from '@/utils/getArtistAlias';
interface SearchAutocompleteProps {
autoCompleteList: SearchCandidate[];
onSelect: (value: string) => void;
+ /** 키보드로 이동 중인 후보. 아무것도 고르지 않은 상태는 -1 */
+ activeIndex: number;
+ onActiveIndexChange: (index: number) => void;
+ /** 입력창의 aria-controls / aria-activedescendant 가 가리키는 id */
+ listboxId: string;
className?: string;
}
export default function SearchAutocomplete({
autoCompleteList,
onSelect,
+ activeIndex,
+ onActiveIndexChange,
+ listboxId,
className,
}: SearchAutocompleteProps) {
if (autoCompleteList.length === 0) return null;
@@ -23,17 +32,25 @@ export default function SearchAutocomplete({
className,
)}
>
-
+ {/* listbox 안의 항목은 option 이어야 한다. button 을 두면 포커스가 입력창을 떠나
+ aria-activedescendant 방식(포커스는 입력창에 두고 활성 후보만 가리키는 것)이 깨진다. */}
+
{autoCompleteList.map((item, index) => (
- -
-
+
- e.preventDefault()}
+ onMouseEnter={() => onActiveIndexChange(index)}
+ onClick={() => onSelect(item.value)}
+ >
+ {item.label}
))}
diff --git a/apps/web/src/hooks/useAutocompleteNavigation.ts b/apps/web/src/hooks/useAutocompleteNavigation.ts
new file mode 100644
index 0000000..cf07fce
--- /dev/null
+++ b/apps/web/src/hooks/useAutocompleteNavigation.ts
@@ -0,0 +1,87 @@
+import { useEffect, useState } from 'react';
+
+import { type SearchCandidate } from '@/utils/getArtistAlias';
+
+// 입력창(aria-activedescendant)과 옵션(id)이 같은 규칙으로 id를 만들어야
+// 스크린리더가 "지금 읽어야 할 후보"를 찾을 수 있다.
+export const getAutocompleteOptionId = (listboxId: string, index: number) =>
+ `${listboxId}-option-${index}`;
+
+interface Params {
+ /** 드롭다운 ul 의 id. 화면마다 달라야 한다 */
+ listboxId: string;
+ /**
+ * 후보 목록. 참조가 매 렌더 바뀌면 활성 후보가 계속 초기화되므로
+ * 호출부에서 useMemo 로 감싸 넘긴다.
+ */
+ candidates: SearchCandidate[];
+ isOpen: boolean;
+ /** Esc 로 닫을 때 */
+ onClose: () => void;
+}
+
+/**
+ * 자동완성 드롭다운의 키보드 내비게이션(↑/↓/Esc)과 ARIA 속성을 담당한다.
+ *
+ * 엔터 처리는 화면마다 의미가 달라(검색 실행 / 목록에 담기) 여기서 하지 않는다.
+ * 호출부가 activeCandidate 를 보고 직접 정한다.
+ */
+export default function useAutocompleteNavigation({
+ listboxId,
+ candidates,
+ isOpen,
+ onClose,
+}: Params) {
+ // 키보드로 이동 중인 후보. 아무것도 고르지 않은 상태는 -1
+ const [activeIndex, setActiveIndex] = useState(-1);
+
+ // 후보 목록이 바뀌거나 드롭다운이 닫히면 활성 후보를 되돌린다.
+ // 남겨두면 목록이 줄어든 뒤 엉뚱한 후보가 엔터로 선택된다.
+ useEffect(() => {
+ setActiveIndex(-1);
+ }, [candidates, isOpen]);
+
+ const activeCandidate = isOpen && activeIndex >= 0 ? (candidates[activeIndex] ?? null) : null;
+
+ // ↑/↓/Esc 는 keydown 에서 처리한다. keyup 은 이미 커서가 움직인 뒤라 늦다.
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ // 한글 조합 중의 방향키는 IME 의 몫이다.
+ if (e.nativeEvent.isComposing || !isOpen || candidates.length === 0) return;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault(); // 커서가 입력 끝으로 튀는 기본 동작을 막는다
+ setActiveIndex(prev => (prev + 1) % candidates.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setActiveIndex(prev => (prev <= 0 ? candidates.length - 1 : prev - 1));
+ } else if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ // 입력창에 그대로 펼쳐 넣는다 (WAI-ARIA combobox 패턴)
+ const inputAriaProps = {
+ role: 'combobox' as const,
+ 'aria-expanded': isOpen,
+ 'aria-controls': isOpen ? listboxId : undefined,
+ 'aria-activedescendant':
+ activeIndex >= 0 && isOpen ? getAutocompleteOptionId(listboxId, activeIndex) : undefined,
+ 'aria-autocomplete': 'list' as const,
+ };
+
+ // SearchAutocomplete 에 그대로 펼쳐 넣는다
+ const listboxProps = {
+ listboxId,
+ activeIndex,
+ onActiveIndexChange: setActiveIndex,
+ };
+
+ return {
+ activeIndex,
+ setActiveIndex,
+ activeCandidate,
+ handleKeyDown,
+ inputAriaProps,
+ listboxProps,
+ };
+}
diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts
index 898210b..f290adb 100644
--- a/apps/web/src/hooks/useSearchSong.ts
+++ b/apps/web/src/hooks/useSearchSong.ts
@@ -1,4 +1,4 @@
-import { useCallback, useDeferredValue, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { usePostSearchLogMutation } from '@/queries/searchLogQuery';
@@ -43,46 +43,80 @@ export default function useSearchSong() {
const { mutate: postSearchLog } = usePostSearchLogMutation();
+ // 검색 결과 도착을 기다리는 인기 검색어 로그 후보
+ const [pendingLog, setPendingLog] = useState<{ text: string; seq: number } | null>(null);
+ const seqRef = useRef(0);
+
const { setFooterAnimateKey } = useFooterAnimateStore();
const { addToHistory } = useSearchHistoryStore();
const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore();
- const deferredSearch = useDeferredValue(search);
+ // 아티스트 별칭 사전이라 제목·번호 탭에서는 후보를 만들지 않는다.
+ // 드롭다운만 숨기면 절반만 막힌다 — handleSearch의 별칭 치환은 이 목록을 직접 보므로,
+ // 제목 탭에서 "원오크"를 직접 타이핑해 엔터를 눌러도 ONE OK ROCK으로 바뀌어 0건이 됐다.
+ // 목록 자체를 비워 드롭다운과 치환을 한 곳에서 함께 끈다.
+ const canUseArtistAlias = searchType === 'all' || searchType === 'artist';
+ // handleSearch가 이 목록에서 별칭 치환을 하므로 search를 그대로 따라가야 한다.
+ // useDeferredValue를 끼우면 목록이 한 박자 늦어, 붙여넣기 직후 엔터처럼 지연이 큰
+ // 순간에 "검색할 문자열"과 "그 문자열의 후보"가 어긋나 치환이 조용히 빠진다.
+ // 사전 규모가 수백 개라 미룰 만큼 무겁지도 않다.
const autoCompleteList = useMemo(
- () => getAutoCompleteSuggestions(deferredSearch),
- [deferredSearch],
+ () => (canUseArtistAlias ? getAutoCompleteSuggestions(search) : []),
+ [search, canUseArtistAlias],
);
- const handleSearch = (termOverride?: string, typeOverride?: SearchType) => {
+ // overrideText: 자동완성 후보를 키보드/마우스로 고른 경우 그 값으로 바로 검색한다.
+ // setSearch 는 다음 렌더에야 반영되므로, 인자로 받지 않으면 직전 입력값으로 검색이 나간다.
+ const handleSearch = (overrideText?: string) => {
// trim 제거
- const trimSearch = (termOverride ?? search).trim();
+ const trimSearch = (overrideText ?? search).trim();
if (!trimSearch) {
setQuery('');
return;
}
- let parsedSearch = trimSearch;
-
- if (autoCompleteList.length === 1) {
- if (autoCompleteList[0].label === trimSearch) {
- // 자동완성 리스트가 하나(정확히 일치하면)고 label도 일치하면 해당 alias의 value로 자동 치환
- parsedSearch = autoCompleteList[0].value;
- }
- }
+ // 입력한 말이 별칭과 정확히 겹치면 그 별칭의 공식 명칭으로 바꿔 검색한다.
+ // 후보 개수로 판단하면("리스트가 하나일 때만") 같은 아티스트의 별칭끼리 접두가
+ // 겹치는 순간 치환이 통째로 건너뛰어졌다 — "원오크"는 "원오크락"과 함께 걸려
+ // 후보가 2개가 되는데, ONE OK ROCK은 artist_ko가 비어 있어 치환 없이는 0건이었다.
+ const exactMatch = autoCompleteList.find(
+ candidate => candidate.label.toLowerCase() === trimSearch.toLowerCase(),
+ );
+ const parsedSearch = exactMatch ? exactMatch.value : trimSearch;
// 중간 띄어쓰기는 제거하지 않고 그대로 전달한다.
// 검색어의 공백 처리(토큰 분리 → %로 치환)는 검색 API(/api/search)가 담당한다.
if (parsedSearch) {
setQuery(parsedSearch);
setSearch(parsedSearch);
- setQueryType(typeOverride ?? searchType);
+ setQueryType(searchType);
addToHistory(parsedSearch);
- postSearchLog(parsedSearch);
+ // 인기 검색어 로그는 여기서 바로 남기지 않는다.
+ // 결과가 0건인 오타·존재하지 않는 곡까지 집계되면 인기 검색어가 오염된다.
+ // 검색 결과가 도착한 뒤 1건이라도 있을 때만 아래 effect가 기록한다.
+ // seq는 같은 검색어를 연달아 검색해도 effect가 다시 돌게 하는 용도다.
+ seqRef.current += 1;
+ setPendingLog({ text: parsedSearch, seq: seqRef.current });
}
};
+ // 검색 결과가 1건 이상일 때만 인기 검색어 로그를 남긴다.
+ useEffect(() => {
+ if (!pendingLog) return;
+ // 이번 검색어의 결과가 아직 도착하지 않았으면 대기한다.
+ if (query !== pendingLog.text || isPendingSearch) return;
+
+ setPendingLog(null);
+ if (isError) return;
+
+ const hasResult = searchResults?.pages.some(page => page.data.length > 0) ?? false;
+ if (hasResult) {
+ postSearchLog(pendingLog.text);
+ }
+ }, [pendingLog, query, isPendingSearch, isError, searchResults, postSearchLog]);
+
const handleSearchTypeChange = (value: SearchType) => {
setSearchType(value);
};
diff --git a/apps/web/src/utils/getArtistAlias.ts b/apps/web/src/utils/getArtistAlias.ts
index 21eeb56..5c1bcf6 100644
--- a/apps/web/src/utils/getArtistAlias.ts
+++ b/apps/web/src/utils/getArtistAlias.ts
@@ -4,17 +4,37 @@ import { artistAlias } from '@repo/constants';
export type SearchCandidate = { label: string; value: string };
-const createCandidateList = (): SearchCandidate[] => {
- const list: SearchCandidate[] = [];
+// getChoseong은 공백을 그대로 남긴다 — getChoseong('요네즈 켄시') === 'ㅇㄴㅈ ㅋㅅ'.
+// 사람은 초성을 붙여서 치므로("ㅇㄴㅈㅋㅅ") 양쪽에서 공백을 지우고 비교해야
+// 이름 중간의 공백을 넘어가도 초성 검색이 이어진다. 전사된 일본 아티스트명은
+// 대부분 "성 이름" 꼴이라 이걸 안 하면 초성 검색이 첫 단어에서 끊긴다.
+const removeSpaces = (value: string) => value.replace(/\s+/g, '');
+
+type IndexedCandidate = SearchCandidate & {
+ /** 소문자로 맞춘 라벨 — 입력할 때마다 다시 만들지 않도록 미리 계산한다 */
+ searchLabel: string;
+ /** 공백을 지운 초성. 한글이 없는 라벨(YOASOBI, 米津玄師)은 빈 문자열이 된다 */
+ choseong: string;
+};
+
+const createCandidateList = (): IndexedCandidate[] => {
+ const list: IndexedCandidate[] = [];
+
+ const pushCandidate = (label: string, value: string) => {
+ list.push({
+ label,
+ value,
+ searchLabel: label.toLowerCase(),
+ choseong: removeSpaces(getChoseong(label)),
+ });
+ };
Object.entries(artistAlias).forEach(([officialName, aliases]) => {
// 공식 명칭 검색 후보에 추가
- list.push({ label: officialName, value: officialName });
+ pushCandidate(officialName, officialName);
// 별명들 검색 후보에 추가
- aliases.forEach(alias => {
- list.push({ label: alias, value: officialName });
- });
+ aliases.forEach(alias => pushCandidate(alias, officialName));
});
return list;
@@ -27,13 +47,18 @@ export const getAutoCompleteSuggestions = (query: string): SearchCandidate[] =>
if (!query) return [];
const normalizedQuery = query.toLowerCase().trim(); // 대소문자 무시
+ // 공백만 입력한 경우 — 빈 문자열은 모든 후보의 접두사라 후보 10개가 그대로 뜬다
+ if (!normalizedQuery) return [];
+
+ const choseongQuery = removeSpaces(normalizedQuery);
// 배열 필터링 (여기가 핵심)
// includes: 중간에 포함된 것도 찾음 ("라시" -> "아라시")
// startsWith: 앞에서부터 일치하는 것만 찾음 ("아" -> "아라시") -> 보통 자동완성은 이걸 씀
return SEARCH_CANDIDATES.filter(
candidate =>
- candidate.label.toLowerCase().startsWith(normalizedQuery) ||
- getChoseong(candidate.label).startsWith(normalizedQuery),
+ candidate.searchLabel.startsWith(normalizedQuery) ||
+ // 한글이 없는 라벨은 초성이 빈 문자열이라 아무 입력에나 걸리지 않도록 걸러낸다
+ (candidate.choseong !== '' && candidate.choseong.startsWith(choseongQuery)),
).slice(0, 10); // 성능을 위해 상위 10개만 자름
};
diff --git a/packages/constants/src/artistAlias.ts b/packages/constants/src/artistAlias.ts
index 00c4838..59aaa96 100644
--- a/packages/constants/src/artistAlias.ts
+++ b/packages/constants/src/artistAlias.ts
@@ -16,11 +16,11 @@ export const artistAlias = {
"Creepy Nuts": ["크리피 너츠"],
// [Gemini 추천 아티스트 추가] 한국 인기 최상위
- Ado: ["아도", "우세와", "신시대"],
- imase: ["이마세", "나이트댄서"],
+ Ado: ["아도"],
+ imase: ["이마세"],
"ONE OK ROCK": ["원오크락", "원오크", "원옥"],
"X JAPAN": ["엑스재팬", "엑스제팬"],
- "tuki.": ["츠키", "투키", "만찬가"],
+ "tuki.": ["츠키", "투키"],
HoneyWorks: ["허니웍스"],
"L'Arc~en~Ciel": ["라르크 앙 시엘", "라르크"],
松田聖子: ["마츠다 세이코"],
@@ -36,7 +36,7 @@ export const artistAlias = {
浜崎あゆみ: ["하마사키 아유미"],
水樹奈々: ["미즈키 나나"],
"モーニング娘。": ["모닝구 무스메"],
- Official髭男dism: ["오피셜히게단디즘", "히게단", "프리텐더"],
+ Official髭男dism: ["오피셜히게단디즘", "히게단"],
ヨルシカ: ["요루시카"],
中島美嘉: ["나카시마 미카"],
宇多田ヒカル: ["우타다 히카루"],