main-photo-section-all-boards.plan.md 18 KB

Plan: 메인 Photo Section — 전 게시판 미디어(이미지/동영상) 노출

Summary

메인페이지 photo section이 현재 photo 게시판(일상/풍경/사진) 글만 노출한다. 이를 모든 게시판에서 이미지 또는 임베드 동영상(YouTube)이 포함된 최신 글의 대표 이미지(thumbnail)가 노출되도록 확장한다. 썸네일 데이터는 이미 저장 시점에 전 게시판에 대해 생성되고 있으므로(tb_post.thumbnail), 조회 쿼리와 뷰만 변경하면 된다.

User Story

As a 블로그 방문자, I want 메인페이지 포토 영역에서 게시판 구분 없이 이미지·동영상이 있는 최신 글을 한눈에 보고, So that 어떤 게시판에 올라온 미디어든 놓치지 않고 탐색할 수 있다.

Problem → Solution

  • 현재: MainController::indexBoardService::latest('photo')Board::getLatestBRD.code = 'photo'로 제한 → 다른 게시판의 이미지/동영상 글은 미노출
  • 변경: 전 게시판 대상 + thumbnail이 존재하는 글만 조회하는 신규 메서드(getLatestMedia / latestMedia) 추가, 메인에서 그것을 사용

Metadata

  • Complexity: Small–Medium
  • Source PRD: N/A (free-form)
  • PRD Phase: N/A
  • Estimated Files: 5 (+1 선택)

UX Design

Before

┌─ 메인페이지 ──────────────────────────────┐
│ [일상/풍경/사진]              [전체보기→photo] │
│ ┌───┐┌───┐┌───┐┌───┐                      │
│ │img││img││img││img│  ← photo 게시판 글만    │
│ └───┘└───┘└───┘└───┘                      │
└──────────────────────────────────────────┘
다른 게시판에 이미지/영상 글 → 안 보임

After

┌─ 메인페이지 ──────────────────────────────┐
│ [포토/미디어]              [전체보기→recently] │
│ ┌───┐┌───┐┌───┐┌───┐                      │
│ │img││ 🎬││img││img│ ← 전 게시판, 썸네일 有만 │
│ └───┘└───┘└───┘└───┘                      │
│  [게시판명] 제목 …                          │
└──────────────────────────────────────────┘

Interaction Changes

Touchpoint Before After Notes
photo section 데이터 photo 게시판 최신글 (썸네일 없어도 포함) 전 게시판 썸네일 보유 글 썸네일 없는 글은 제외됨
section 제목 "일상/풍경/사진" "포토/미디어" (임의 변경 가능) 사용자 취향
전체보기 링크 route('board.list','photo') route('recently') 전 게시판이므로
캡션 라벨 [categoryName] [boardName] (있으면 category 우선) 출처 게시판 표시

Mandatory Reading

Priority File Lines Why
P0 app/Models/Board.php 558–606 getLatest — 미러링할 SQL 패턴 (raw SQL + @code 변수 + getPaginator)
P0 app/Services/BoardService.php 156–175, 205–258 latest() 캐시 패턴 + _filter() 가공 패턴
P0 app/Http/Controllers/MainController.php 38–62 photo section 데이터 주입 지점
P1 resources/views/desktop/main.blade.php 136–175 데스크톱 photo section 마크업
P1 resources/views/mobile/main.blade.php 136–170 모바일 photo section 마크업 (photo-strip)
P2 app/Http/Traits/CommonTrait.php 152–166 getThumbnail — 썸네일 생성 규칙 (img → YouTube iframe → default)
P2 app/Services/PostService.php 166–200, 258–300 저장 시 thumbnail/image_rows/video_rows 세팅 확인

External Documentation

없음 — 내부 패턴만 사용. "No external research needed — feature uses established internal patterns."


Patterns to Mirror

RAW_SQL_QUERY (Model)

// SOURCE: app/Models/Board.php:558-606 (getLatest)
$sql = "
    SELECT COUNT(*) AS total
    FROM
        (SELECT @code := ?) V,
        tb_post PST
        JOIN tb_board BRD ON BRD.id = PST.board_id
    WHERE
        CASE WHEN @code IS NOT NULL THEN BRD.code = @code ELSE (PST.is_notice = 0 AND PST.is_speaker = 0) END
        AND BRD.is_display = 1 AND PST.is_delete = 0;
";
$total = DB::selectOne($sql, [$code])->total;
// ... SELECT 목록은 BRD.code, BRD.name AS boardName, BCA.name AS categoryName, PST.* 필드 나열
$list = $this->getPaginator(
    DB::select($sql, [$code, $this->getPageOffset($page), $perPage]), $total, $perPage, $page,
    ($code ? route('board.list', $code) : null)
);
return (object)['total' => $total, 'list' => $list];

SERVICE_CACHE_PATTERN

// SOURCE: app/Services/BoardService.php:159-175 (latest)
$cacheName = sprintf('latest-board-%s-%d-%d', $code, $page, $perPage);
if (!$latest = Cache::get($cacheName)) {
    $latest = $this->boardModel->getLatest($code, $page, $perPage);
    if($latest->total) {
        $num = listNum($latest->total, $page, $perPage);
        foreach ($latest->list as $i => $row) {
            $row->num = $num--;
            $latest->list[$i] = $this->_filter($row);
        }
        $latest->list->filter();
    }
    Cache::tags('latest-board')->put($cacheName, $latest, CACHE_EXPIRE_TIME);
}
return $latest;

CONTROLLER_INJECTION

// SOURCE: app/Http/Controllers/MainController.php:49-61
// 사진게시판
$photo = $this->boardService->latest('photo', 1, DEFAULT_LIST_PER_PAGE);
// ...
return view(layout('main'), [ 'popups' => ..., 'photo' => $photo, ... ]);

BLADE_THUMBNAIL_RENDER

{{-- SOURCE: resources/views/desktop/main.blade.php:146-163 --}}
@if($photo && $photo->total > 0)
    <div class="row row-cols-sm-4 row-cols-xl-6">
    @foreach($photo->list as $row)
        ...
        @if($row->thumbnail)
            <a href="{{ $row->viewURL }}" ... class="ratio ratio-4x3">
                <img src="{{ $row->thumbnail }}" ... onerror="this.src='{{ NO_IMAGE_NORMAL_SRC }}'"/>
            </a>
        @endif

NAMING_CONVENTION

  • Model 조회: getLatest, getSearch, getUserPosts → 신규는 getLatestMedia
  • Service: latest, search, userPosts → 신규는 latestMedia
  • SQL 별칭: PST(tb_post), BRD(tb_board), BCA(tb_board_category), USR(users)
  • 주석: 한국어 한 줄 doc-comment (/** 게시판 최근 게시글 */)

Files to Change

File Action Justification
app/Models/Board.php UPDATE getLatestMedia(int $page, int $perPage) 추가 (getLatest 뒤에)
app/Services/BoardService.php UPDATE latestMedia(int $page, int $perPage) 추가 (latest 뒤에)
app/Http/Controllers/MainController.php UPDATE latest('photo',...)latestMedia(...) 교체
resources/views/desktop/main.blade.php UPDATE 제목/전체보기 링크/캡션 라벨 변경
resources/views/mobile/main.blade.php UPDATE 동일 변경 (photo-strip 구조 유지)
app/Console/Commands/RebuildThumbnails.php CREATE (선택) 구(舊) 게시글 썸네일 백필 — Task 6 참조

NOT Building

  • 게시판별 노출 on/off 관리 설정 (board_meta 옵션) — 필요 시 후속
  • YouTube 외 동영상(Vimeo 등) 썸네일 추출 — getThumbnail 확장은 별도 건
  • photo section의 페이징/무한스크롤
  • clearLatest의 캐시 태그 불일치 수정 (아래 GOTCHA 참조 — 기존 동작 유지)

Step-by-Step Tasks

Task 1: Board::getLatestMedia 추가

  • ACTION: app/Models/Board.phpgetLatest(606행) 바로 뒤에 신규 메서드 추가
  • IMPLEMENT:

    /**
    * 전체 게시판 최근 미디어(썸네일 보유) 게시글 조회
    */
    public function getLatestMedia(int $page, int $perPage): object
    {
    $sql = "
        SELECT
            COUNT(*) AS total
        FROM
            tb_post PST
            JOIN tb_board BRD ON BRD.id = PST.board_id
        WHERE
            PST.thumbnail IS NOT NULL AND PST.thumbnail != ''
            AND PST.is_notice = 0 AND PST.is_speaker = 0
            AND PST.is_secret = 0 AND PST.is_personal = 0
            AND BRD.is_display = 1 AND PST.is_delete = 0;
    ";
    
    $total = DB::selectOne($sql)->total;
    
    $sql = "
        SELECT
            BRD.code, BRD.name AS boardName,
            BCA.name AS categoryName,
            PST.id, PST.board_id, PST.board_category_id, PST.user_id, PST.thumbnail, PST.subject, PST.content,
            PST.sid, PST.username, PST.email, PST.comment_rows, PST.is_reply, PST.is_personal, PST.is_secret,
            PST.is_notice, PST.is_speaker, PST.is_html, PST.use_comment, PST.receive_email, PST.hit, PST.like, PST.dislike,
            PST.blame, PST.ip_address, PST.user_agent, PST.device_type, PST.file_rows, PST.image_rows,
            PST.link_rows, PST.tag_rows, PST.is_delete, PST.created_at
        FROM
            tb_post PST
            INNER JOIN tb_board BRD ON BRD.id = PST.board_id
            LEFT JOIN tb_board_category BCA ON BCA.id = PST.board_category_id
            LEFT JOIN users USR ON USR.id = PST.user_id
        WHERE
            PST.thumbnail IS NOT NULL AND PST.thumbnail != ''
            AND PST.is_notice = 0 AND PST.is_speaker = 0
            AND PST.is_secret = 0 AND PST.is_personal = 0
            AND BRD.is_display = 1 AND PST.is_delete = 0
        ORDER BY
            PST.created_at DESC, PST.id DESC
        LIMIT ?, ?;
    ";
    
    $list = $this->getPaginator(
        DB::select($sql, [$this->getPageOffset($page), $perPage]), $total, $perPage, $page, null
    );
    
    return (object)[
        'total' => $total,
        'list' => $list
    ];
    }
    
  • MIRROR: RAW_SQL_QUERY 패턴 (SELECT 필드 목록·별칭·getPaginator 호출을 getLatest와 동일하게)

  • IMPORTS: 추가 불필요 (기존 DB 파사드 사용 중)

  • GOTCHA: ① 크로스보드 공개 영역이므로 is_secret = 0 AND is_personal = 0 필터 필수 — 기존 getLatest(code=NULL)에는 없어 비밀글이 노출될 수 있는 구조지만, 미디어 섹션은 썸네일이 그대로 보이므로 반드시 막을 것. ② @code 세션변수 패턴은 단일 코드 필터용이므로 여기선 불필요 — 제거.

  • VALIDATE: php -l app/Models/Board.php → No syntax errors

Task 2: BoardService::latestMedia 추가

  • ACTION: app/Services/BoardService.phplatest()(175행) 바로 뒤에 추가
  • IMPLEMENT:

    /**
    * 전체 게시판 최근 미디어 게시글
    */
    public function latestMedia(int $page = 1, int $perPage = DEFAULT_LIST_PER_PAGE)
    {
    $cacheName = sprintf('latest-media-%d-%d', $page, $perPage);
    if (!$latest = Cache::get($cacheName)) {
        $latest = $this->boardModel->getLatestMedia($page, $perPage);
        if($latest->total) {
            $num = listNum($latest->total, $page, $perPage);
            foreach ($latest->list as $i => $row) {
                $row->num = $num--;
                $latest->list[$i] = $this->_filter($row);
            }
            $latest->list->filter();
        }
        Cache::tags('latest-board')->put($cacheName, $latest, CACHE_EXPIRE_TIME);
    }
    return $latest;
    }
    
  • MIRROR: SERVICE_CACHE_PATTERN (latest()와 동일 구조, 캐시명만 latest-media-…)

  • IMPORTS: 추가 불필요

  • GOTCHA: ① _filter()가 null 반환 가능(공지 제외 설정 등) → list->filter() 호출 유지. ② 기존 clearLatest()는 태그 latest-board-{code}를 flush하지만 latest()는 태그 latest-board로 저장하는 기존 불일치가 있음 — 신규도 동일 태그(latest-board)를 사용해 기존 동작(CACHE_EXPIRE_TIME 만료 의존)과 일관 유지. 글 등록 직후 즉시 반영 안 되는 것은 기존 photo section과 동일한 특성.

  • VALIDATE: php -l app/Services/BoardService.php

Task 3: MainController::index 교체

  • ACTION: app/Http/Controllers/MainController.php:49-50 수정
  • IMPLEMENT:

    // 전체 게시판 미디어(사진/동영상)
    $photo = $this->boardService->latestMedia(1, DEFAULT_LIST_PER_PAGE);
    
  • MIRROR: CONTROLLER_INJECTION — 뷰 변수명 photo 유지(뷰 diff 최소화)

  • GOTCHA: 변수명을 바꾸면 desktop/mobile 두 뷰를 모두 수정해야 하므로 유지한다.

  • VALIDATE: php -l app/Http/Controllers/MainController.php

Task 4: 데스크톱 뷰 수정

  • ACTION: resources/views/desktop/main.blade.php:136-163
  • IMPLEMENT: ① 제목 일상/풍경/사진포토/미디어, ② 전체보기 route('board.list', 'photo')route('recently'), ③ 캡션 라벨을 게시판 출처로:

    @if($row->categoryName)
    <label>
        <a href="{{ $row->listURL }}?category={{ $row->board_category_id }}" rel="search">[{{ $row->categoryName }}]</a>
    </label>
    @else
    <label>
        <a href="{{ $row->listURL }}" rel="search">[{{ $row->boardName }}]</a>
    </label>
    @endif
    
  • MIRROR: BLADE_THUMBNAIL_RENDER — @if($row->thumbnail) 가드·onerror 폴백 유지

  • GOTCHA: getLatestMedia가 썸네일 보유 글만 반환하므로 @if($row->thumbnail) 가드는 사실상 항상 참이지만, _filter 이후 안전장치로 유지.

  • VALIDATE: php artisan view:clear && php artisan view:cache 오류 없음

Task 5: 모바일 뷰 수정

  • ACTION: resources/views/mobile/main.blade.php:136-160 — Task 4와 동일 변경 (photo-strip 가로 스크롤 구조는 유지)
  • MIRROR: Task 4
  • VALIDATE: php artisan view:clear && php artisan view:cache

Task 6 (선택): 구 게시글 썸네일 백필 커맨드

  • ACTION: 썸네일 저장 기능 도입 이전의 옛 글은 thumbnail이 NULL이라 미디어 섹션에 안 나온다. 필요 시 php artisan make:command RebuildThumbnails로 백필.
  • IMPLEMENT: tb_post에서 thumbnail IS NULL AND (image_rows > 0 OR content LIKE '%<img%' OR content LIKE '%iframe%') 대상 조회 → 각 행에 CommonTrait::getThumbnail($row->content) 적용 → UPDATE. 청크(1000행) 단위 처리.
  • GOTCHA: getThumbnail은 YouTube만 동영상 썸네일 지원. 실행 전 SELECT COUNT(*)로 대상 규모 확인.
  • VALIDATE: dry-run 옵션(--dry)으로 대상 수만 출력 → 실제 실행 → 메인 확인

Testing Strategy

Unit Tests

프로젝트에 기존 테스트 스위트가 확인되지 않음 — 수동 검증 중심. (테스트 도입 시 Board::getLatestMedia 쿼리 결과의 비밀글 미포함을 최우선 검증)

Test Input Expected Output Edge Case?
미디어 섹션 조회 이미지 글 3개(각기 다른 게시판) 3개 모두 노출
YouTube 임베드 글 iframe만 있는 글 img.youtube.com/vi/{key}/mqdefault.jpg 썸네일 노출
비밀글 is_secret=1 + 이미지 미노출
1:1 게시판 글 is_personal=1 미노출
텍스트만 있는 글 이미지/iframe 없음 미노출 (thumbnail NULL)
미노출 게시판 is_display=0 미노출

Edge Cases Checklist

  • 썸네일 보유 글이 0건일 때 빈 섹션 처리
  • 삭제된 글(is_delete=1) 제외
  • 캐시 적중 시 stale 데이터 (CACHE_EXPIRE_TIME 내) — 기존과 동일 특성 확인
  • 외부 이미지 URL 깨짐 → onerror NO_IMAGE 폴백

Validation Commands

Static Analysis

php -l app/Models/Board.php && php -l app/Services/BoardService.php && php -l app/Http/Controllers/MainController.php

EXPECT: No syntax errors

View Compile

php artisan view:clear && php artisan view:cache

EXPECT: 컴파일 오류 없음

Cache Reset (변경 반영 확인용)

php artisan cache:clear

EXPECT: 캐시 초기화 후 메인 접속 시 신규 쿼리 실행

Manual Validation

  • 메인 접속 → photo section에 photo 외 게시판 글(이미지 포함) 노출 확인
  • YouTube 임베드만 있는 글 작성 → 메인에 YouTube 썸네일 노출 확인
  • 비밀글로 이미지 글 작성 → 메인 미노출 확인
  • 모바일 레이아웃(가로 스크롤 strip) 정상 확인
  • 전체보기 → /recently 이동 확인

Acceptance Criteria

  • 모든 게시판의 이미지/YouTube 글이 메인 photo section에 노출
  • 비밀글·1:1글·비노출 게시판·삭제글 미노출
  • 데스크톱/모바일 모두 정상 렌더
  • 문법 오류·뷰 컴파일 오류 없음

Completion Checklist

  • 신규 코드가 getLatest/latest 패턴과 구분 불가능한 스타일
  • SQL 별칭·SELECT 필드 목록 기존과 동일
  • 캐시 패턴·만료 정책 기존과 동일
  • 하드코딩 없음 (DEFAULT_LIST_PER_PAGE, CACHE_EXPIRE_TIME 상수 사용)
  • 스코프 외 작업 없음

Risks

Risk Likelihood Impact Mitigation
옛 글 thumbnail NULL로 섹션이 허전함 Task 6 백필 커맨드
비밀글 썸네일 노출 낮 (필터 포함) is_secret/is_personal 필터 + 수동 검증 항목
thumbnail 컬럼 무인덱스로 풀스캔 데이터 증가 시 인덱스 검토; 현재 규모에선 무시 가능 + 결과 캐시됨
캐시로 인한 지연 반영 확실 기존 photo section과 동일 특성 — 허용

Notes

  • getThumbnail(CommonTrait:152)은 이미지가 하나도 없을 때만 YouTube 썸네일을 시도한다. 이미지+영상 혼재 글은 첫 이미지가 대표가 된다 — 의도된 동작으로 간주.
  • photo 게시판 전용 섹션을 유지하고 싶다면 MainController에서 latest('photo')를 병행 주입하는 것도 가능하나, 본 플랜은 교체를 기준으로 한다.