diff --git a/docs/history.md b/docs/history.md
index 73e3dc9..c9e9249 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,9 @@
# 의사결정 이력
+## 2026-04-02 v1.3.53
+- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬`과 `커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
+- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.
+
## 2026-04-02 v1.3.52
- 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다.
- `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다.
diff --git a/docs/todo.md b/docs/todo.md
index 57c07e0..3b49186 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -2,7 +2,7 @@
## 중기 개선
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
-- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리` composable 분리는 시작했으므로, 다음 단계에서는 `아이템 관리`와 `목록 관리`도 같은 기준으로 옮기고 공통 모달 상태를 어느 계층에서 소유할지 정리한다.
+- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
- 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다.
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
diff --git a/docs/update.md b/docs/update.md
index f7b08aa..df8ef4d 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,9 @@
# 업데이트 로그
+## 2026-04-02 v1.3.53
+- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임.
+- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘.
+
## 2026-04-02 v1.3.52
- 관리자 본문 섹션을 컴포넌트로 나눈 뒤 `AdminView.vue` 스타일이 `scoped`에 묶여 자식 컴포넌트까지 제대로 닿지 않던 문제를 정리하고, 관리자 전용 공통 스타일을 `adminUiScope` 범위로 다시 묶어 각 페이지 CSS가 함께 살아나도록 보강함.
- 템플릿 요청 카드의 신규 게임 입력 영역에는 `게임 이름 / 게임 ID` 필드 스타일을 다시 붙여, 요청 카드만 따로 풀린 것처럼 보이던 레이아웃을 복구함.
diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js
new file mode 100644
index 0000000..d5470ef
--- /dev/null
+++ b/frontend/src/composables/useAdminCustomItems.js
@@ -0,0 +1,203 @@
+import { nextTick } from 'vue'
+
+export function useAdminCustomItems({
+ api,
+ toast,
+ customItems,
+ customItemPage,
+ customItemLimit,
+ customItemPageCount,
+ customItemQuery,
+ customItemOrphanOnly,
+ customItemModalOpen,
+ customItemDeleteModalOpen,
+ customItemModalHistoryActive,
+ modalTargetCustomItem,
+ customItemModalDraftLabel,
+ customItemModalLabelSaving,
+ customItemModalTargetGameId,
+ customItemModalGameQuery,
+ customItemModalGameSort,
+ games,
+ selectedGameId,
+ refreshCustomItems,
+ loadGame,
+ setTab,
+ selectAdminGame,
+ resetMessages,
+ success,
+ error,
+}) {
+ function submitCustomItemSearch() {
+ customItemPage.value = 1
+ refreshCustomItems()
+ }
+
+ function toggleCustomItemOrphanOnly() {
+ customItemPage.value = 1
+ refreshCustomItems()
+ }
+
+ function changeCustomItemLimit(limit) {
+ customItemLimit.value = limit
+ customItemPage.value = 1
+ refreshCustomItems()
+ }
+
+ function moveCustomItemPage(direction) {
+ const nextPage = customItemPage.value + direction
+ if (nextPage < 1 || nextPage > customItemPageCount.value) return
+ customItemPage.value = nextPage
+ refreshCustomItems()
+ }
+
+ function pushCustomItemModalHistoryState() {
+ if (typeof window === 'undefined') return
+ window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href)
+ customItemModalHistoryActive.value = true
+ }
+
+ function openCustomItemModal(item) {
+ modalTargetCustomItem.value = item || null
+ customItemModalDraftLabel.value = item?.label || ''
+ customItemModalTargetGameId.value = ''
+ customItemModalGameQuery.value = ''
+ customItemModalGameSort.value = 'recent'
+ customItemModalOpen.value = true
+ pushCustomItemModalHistoryState()
+ }
+
+ function closeCustomItemModal({ fromPopState = false } = {}) {
+ customItemModalOpen.value = false
+ customItemDeleteModalOpen.value = false
+ modalTargetCustomItem.value = null
+ customItemModalDraftLabel.value = ''
+ customItemModalLabelSaving.value = false
+ customItemModalTargetGameId.value = ''
+ customItemModalGameQuery.value = ''
+ customItemModalGameSort.value = 'recent'
+
+ if (fromPopState) {
+ customItemModalHistoryActive.value = false
+ return
+ }
+
+ if (customItemModalHistoryActive.value && typeof window !== 'undefined') {
+ customItemModalHistoryActive.value = false
+ window.history.back()
+ }
+ }
+
+ function openCustomItemDeleteModal(item) {
+ if (!item) return
+ if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
+ error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
+ return
+ }
+ modalTargetCustomItem.value = item
+ customItemDeleteModalOpen.value = true
+ }
+
+ function closeCustomItemDeleteModal() {
+ customItemDeleteModalOpen.value = false
+ }
+
+ function jumpToGameAdmin(gameId) {
+ if (!gameId) return
+ closeCustomItemModal()
+ setTab('game-admin')
+ nextTick(() => {
+ selectAdminGame(gameId)
+ })
+ }
+
+ async function removeCustomItem(item = modalTargetCustomItem.value) {
+ resetMessages()
+ if (!item) return
+ if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
+ error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
+ return
+ }
+
+ try {
+ await api.deleteAdminCustomItem(item.id)
+ closeCustomItemDeleteModal()
+ closeCustomItemModal()
+ await refreshCustomItems()
+ success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
+ } catch (e) {
+ error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
+ }
+ }
+
+ async function removeUnusedCustomItems() {
+ resetMessages()
+ const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
+ if (!ok) return
+
+ try {
+ const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
+ await refreshCustomItems()
+ success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
+ } catch (e) {
+ error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
+ }
+ }
+
+ async function saveCustomItemModalLabel() {
+ const item = modalTargetCustomItem.value
+ const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
+ if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
+
+ try {
+ customItemModalLabelSaving.value = true
+ const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
+ item.label = data.item?.label || nextLabel
+ customItemModalDraftLabel.value = item.label
+ customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
+ toast.success('아이템 이름을 변경했어요.')
+ } catch (e) {
+ error.value = '아이템 이름 변경에 실패했어요.'
+ } finally {
+ customItemModalLabelSaving.value = false
+ }
+ }
+
+ async function promoteCustomItem(item) {
+ resetMessages()
+ if (!customItemModalTargetGameId.value) {
+ error.value = '추가할 게임을 먼저 선택해주세요.'
+ return
+ }
+
+ try {
+ item.isPromoting = true
+ await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
+ const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
+ if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
+ closeCustomItemModal()
+ success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
+ } catch (e) {
+ error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
+ } finally {
+ item.isPromoting = false
+ }
+ }
+
+ return {
+ submitCustomItemSearch,
+ toggleCustomItemOrphanOnly,
+ changeCustomItemLimit,
+ moveCustomItemPage,
+ pushCustomItemModalHistoryState,
+ openCustomItemModal,
+ closeCustomItemModal,
+ openCustomItemDeleteModal,
+ closeCustomItemDeleteModal,
+ jumpToGameAdmin,
+ removeCustomItem,
+ removeUnusedCustomItems,
+ saveCustomItemModalLabel,
+ promoteCustomItem,
+ }
+}
diff --git a/frontend/src/composables/useAdminFeaturedGames.js b/frontend/src/composables/useAdminFeaturedGames.js
new file mode 100644
index 0000000..905819e
--- /dev/null
+++ b/frontend/src/composables/useAdminFeaturedGames.js
@@ -0,0 +1,93 @@
+import { nextTick } from 'vue'
+import Sortable from 'sortablejs'
+
+export function useAdminFeaturedGames({
+ api,
+ featuredListEl,
+ featuredSortable,
+ featuredGameIds,
+ games,
+ resetMessages,
+ success,
+ error,
+}) {
+ function destroyFeaturedSortable() {
+ if (featuredSortable.value) {
+ featuredSortable.value.destroy()
+ featuredSortable.value = null
+ }
+ }
+
+ async function syncFeaturedSortable() {
+ await nextTick()
+ destroyFeaturedSortable()
+ if (!featuredListEl.value) return
+
+ featuredSortable.value = Sortable.create(featuredListEl.value, {
+ animation: 160,
+ draggable: '[data-featured-id]',
+ handle: '[data-featured-handle]',
+ ghostClass: 'ghost',
+ chosenClass: 'chosen',
+ onEnd: (evt) => {
+ if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
+ const nextIds = [...featuredGameIds.value]
+ const [moved] = nextIds.splice(evt.oldIndex, 1)
+ nextIds.splice(evt.newIndex, 0, moved)
+ featuredGameIds.value = nextIds
+ },
+ })
+ }
+
+ function addFeaturedGame(gameId) {
+ resetMessages()
+ if (!gameId || featuredGameIds.value.includes(gameId)) return
+ if (featuredGameIds.value.length >= 50) {
+ error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
+ return
+ }
+ featuredGameIds.value = [...featuredGameIds.value, gameId]
+ syncFeaturedSortable()
+ }
+
+ function removeFeaturedGame(gameId) {
+ resetMessages()
+ featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
+ syncFeaturedSortable()
+ }
+
+ function moveFeaturedGame(gameId, direction) {
+ const currentIndex = featuredGameIds.value.indexOf(gameId)
+ const nextIndex = currentIndex + direction
+ if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
+ const nextIds = [...featuredGameIds.value]
+ const [moved] = nextIds.splice(currentIndex, 1)
+ nextIds.splice(nextIndex, 0, moved)
+ featuredGameIds.value = nextIds
+ syncFeaturedSortable()
+ }
+
+ async function saveFeaturedOrder() {
+ resetMessages()
+ try {
+ const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
+ games.value = data.games || []
+ featuredGameIds.value = games.value
+ .filter((game) => game.displayRank != null)
+ .sort((a, b) => a.displayRank - b.displayRank)
+ .map((game) => game.id)
+ success.value = '홈 화면 게임 순서를 저장했어요.'
+ } catch (e) {
+ error.value = '게임 순서 저장에 실패했어요.'
+ }
+ }
+
+ return {
+ destroyFeaturedSortable,
+ syncFeaturedSortable,
+ addFeaturedGame,
+ removeFeaturedGame,
+ moveFeaturedGame,
+ saveFeaturedOrder,
+ }
+}
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue
index 1146d6c..d1f6600 100644
--- a/frontend/src/views/AdminView.vue
+++ b/frontend/src/views/AdminView.vue
@@ -1,7 +1,6 @@