diff --git a/components/admin/AdminAdsSettingsCard.vue b/components/admin/AdminAdsSettingsCard.vue
index bd979c4..965f87b 100644
--- a/components/admin/AdminAdsSettingsCard.vue
+++ b/components/admin/AdminAdsSettingsCard.vue
@@ -6,6 +6,12 @@ const adSlots = [
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
placeholder: ''
},
+ {
+ key: 'adHomeInfeedCode',
+ label: '메인 인피드',
+ description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.',
+ placeholder: ''
+ },
{
key: 'adSidebarCode',
label: '오른쪽 사이드',
diff --git a/db/migrations/051_site_settings_home_infeed_ad.sql b/db/migrations/051_site_settings_home_infeed_ad.sql
new file mode 100644
index 0000000..2d04896
--- /dev/null
+++ b/db/migrations/051_site_settings_home_infeed_ad.sql
@@ -0,0 +1,2 @@
+ALTER TABLE site_settings
+ ADD COLUMN IF NOT EXISTS ad_home_infeed_code TEXT NOT NULL DEFAULT '';
diff --git a/docs/history.md b/docs/history.md
index 28ba95a..c3c2988 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,9 @@
# 의사결정 이력
+## 2026-06-05 v1.5.72 — 메인 인피드 광고는 클라이언트에서 한 번 무작위 배치
+
+메인 Latest 목록 사이 광고는 매번 같은 위치보다 자연스럽게 보이도록 무작위 삽입을 허용한다. 다만 SSR 단계에서 무작위 값을 만들면 서버 HTML과 클라이언트 hydration 결과가 달라질 수 있으므로, 브라우저 마운트 이후 게시물 사이 한 지점을 한 번 산출해 삽입한다. 광고는 첫 항목 앞이나 마지막 항목 뒤가 아니라 게시물 사이에만 들어가도록 제한한다.
+
## 2026-06-05 v1.5.71 — 화면 위치 광고 슬롯은 사이트 코드와 분리 관리
사이트 검증·공통 스크립트용 사이트 코드 카드와, 실제 화면 위치에 삽입되는 광고 단위를 분리했다. 이유는 ads.txt/head/footer는 전역 설정이고, 메인 피드·사이드바·게시물 본문 상단·하단 광고는 위치별 표시 여부와 레이아웃 책임이 다르기 때문이다. 광고 HTML은 관리자 신뢰 콘텐츠로 저장하되, 빈 값은 DOM을 만들지 않고 클라이언트에서만 삽입해 AdSense 스크립트 중복 실행 가능성을 줄인다.
diff --git a/docs/map.md b/docs/map.md
index b97baa3..82c842e 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -77,7 +77,7 @@
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
-| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·오른쪽 사이드·게시물 본문 상단·하단 광고 슬롯 |
+| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·하단 광고 슬롯 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
@@ -91,7 +91,7 @@
|------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
-| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·오른쪽 사이드·게시물 본문 상단·하단) |
+| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 본문 상단·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
diff --git a/docs/spec.md b/docs/spec.md
index 544ac40..cf5aeba 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -64,7 +64,7 @@
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
-- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
+- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
### Post 페이지
@@ -119,7 +119,7 @@
### 사이트 광고 슬롯
-- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 네 위치의 HTML 코드를 저장한다.
+- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostTopCode`, `adPostBottomCode` 다섯 위치의 HTML 코드를 저장한다.
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
diff --git a/docs/update.md b/docs/update.md
index 5674290..c27bf06 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,11 @@
# 업데이트 이력
+## v1.5.72
+
+- 사이트 설정 Ads: 메인 인피드 광고 슬롯 추가.
+- 메인 화면: Latest 게시물 목록 사이 한 곳에 인피드 광고를 무작위 삽입하도록 추가.
+- 사이트 설정 저장소: 메인 인피드 광고 코드 컬럼과 관리자 입력 검증 추가.
+
## v1.5.71
- 사이트 설정: 위치별 광고 코드를 관리하는 Ads 카드 추가.
diff --git a/package-lock.json b/package-lock.json
index 6c0cea7..a139008 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.5.71",
+ "version": "1.5.72",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.5.71",
+ "version": "1.5.72",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index 2ea22cd..193533c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.5.71",
+ "version": "1.5.72",
"private": true,
"type": "module",
"imports": {
diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue
index 8a8297b..2311adc 100644
--- a/pages/admin/settings/index.vue
+++ b/pages/admin/settings/index.vue
@@ -137,6 +137,7 @@ const siteCodeSnapshot = reactive({
/** 편집 시작 시점의 광고 슬롯(취소 시 복원용) */
const adsSnapshot = reactive({
adHomeFeedCode: '',
+ adHomeInfeedCode: '',
adSidebarCode: '',
adPostTopCode: '',
adPostBottomCode: ''
@@ -178,6 +179,7 @@ const form = reactive({
customHeadCode: settings.value?.customHeadCode || '',
customFooterCode: settings.value?.customFooterCode || '',
adHomeFeedCode: settings.value?.adHomeFeedCode || '',
+ adHomeInfeedCode: settings.value?.adHomeInfeedCode || '',
adSidebarCode: settings.value?.adSidebarCode || '',
adPostTopCode: settings.value?.adPostTopCode || '',
adPostBottomCode: settings.value?.adPostBottomCode || ''
@@ -271,6 +273,7 @@ const hasSiteCodeChanges = computed(() => editSiteCode.value && (
*/
const hasAdsChanges = computed(() => editAds.value && (
form.adHomeFeedCode !== adsSnapshot.adHomeFeedCode
+ || form.adHomeInfeedCode !== adsSnapshot.adHomeInfeedCode
|| form.adSidebarCode !== adsSnapshot.adSidebarCode
|| form.adPostTopCode !== adsSnapshot.adPostTopCode
|| form.adPostBottomCode !== adsSnapshot.adPostBottomCode
@@ -1128,6 +1131,7 @@ const buildSiteSettingsPayload = () => ({
customHeadCode: form.customHeadCode || '',
customFooterCode: form.customFooterCode || '',
adHomeFeedCode: form.adHomeFeedCode || '',
+ adHomeInfeedCode: form.adHomeInfeedCode || '',
adSidebarCode: form.adSidebarCode || '',
adPostTopCode: form.adPostTopCode || '',
adPostBottomCode: form.adPostBottomCode || ''
@@ -1720,6 +1724,7 @@ const saveSiteCodeSection = async () => {
*/
const beginEditAds = () => {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
+ adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode
@@ -1732,6 +1737,7 @@ const beginEditAds = () => {
*/
const cancelEditAds = () => {
form.adHomeFeedCode = adsSnapshot.adHomeFeedCode
+ form.adHomeInfeedCode = adsSnapshot.adHomeInfeedCode
form.adSidebarCode = adsSnapshot.adSidebarCode
form.adPostTopCode = adsSnapshot.adPostTopCode
form.adPostBottomCode = adsSnapshot.adPostBottomCode
@@ -1754,6 +1760,7 @@ const saveAdsSection = async () => {
if (ok) {
adsSnapshot.adHomeFeedCode = form.adHomeFeedCode
+ adsSnapshot.adHomeInfeedCode = form.adHomeInfeedCode
adsSnapshot.adSidebarCode = form.adSidebarCode
adsSnapshot.adPostTopCode = form.adPostTopCode
adsSnapshot.adPostBottomCode = form.adPostBottomCode
diff --git a/pages/index.vue b/pages/index.vue
index 5717861..f06e457 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -15,6 +15,7 @@ const postFeedStyleStorageKey = 'POST_FEED_STYLE'
const postFeedStyleOpen = ref(false)
const postFeedStyle = ref('compact')
+const homeInfeedAdInsertIndex = ref(-1)
/** @typedef {'list' | 'compact' | 'cards'} PostFeedStyle */
@@ -165,6 +166,7 @@ const mapLatestPost = (post) => {
const featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
const latestPosts = computed(() => posts.value.map(mapLatestPost))
+const hasHomeInfeedAd = computed(() => Boolean(String(siteSettings.value?.adHomeInfeedCode || '').trim()))
const featuredTrackRef = ref(null)
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
@@ -236,6 +238,7 @@ onMounted(() => {
const storedStyle = localStorage.getItem(postFeedStyleStorageKey)
postFeedStyle.value = normalizePostFeedStyle(storedStyle)
+ randomizeHomeInfeedAdInsertIndex()
document.addEventListener('pointerdown', onDocumentPointerDown)
})
@@ -277,6 +280,29 @@ const scrollFeatured = (direction) => {
behavior: 'smooth'
})
}
+
+/**
+ * 메인 인피드 광고 삽입 위치를 현재 브라우저 렌더에서 한 번 정한다.
+ * @returns {void}
+ */
+const randomizeHomeInfeedAdInsertIndex = () => {
+ if (!hasHomeInfeedAd.value || latestPosts.value.length < 2) {
+ homeInfeedAdInsertIndex.value = -1
+ return
+ }
+
+ const minIndex = 0
+ const maxIndex = latestPosts.value.length - 2
+ homeInfeedAdInsertIndex.value = minIndex + Math.floor(Math.random() * (maxIndex - minIndex + 1))
+}
+
+watch([latestPosts, hasHomeInfeedAd], () => {
+ if (!import.meta.client) {
+ return
+ }
+
+ randomizeHomeInfeedAdInsertIndex()
+})
@@ -465,89 +491,100 @@ const scrollFeatured = (direction) => {
data-post-feed="latest"
:class="getPostFeedContainerClass(postFeedStyle)"
>
-
-
-
-