메타태그와 SEO

메타태그와 SEO

라이믹스 레이아웃에서 SEO를 위한 메타태그와 오픈그래프를 구현하는 방법을 학습합니다.

기본 메타태그

필수 메타태그 설정

<!-- 기본 메타태그 -->
<head>
    <!-- 문자 인코딩 -->
    <meta charset="UTF-8" />

    <!-- 뷰포트 설정 -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />

    <!-- IE 호환성 -->
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />

    <!-- 페이지 설명 -->
    {@
        // 페이지별 설명 설정
        if($oDocument && $oDocument->isExists()) {
            $meta_description = $oDocument->getSummary(160);
        } elseif($module_info->module == 'board') {
            $meta_description = $module_info->description ?: $module_info->browser_title . ' - ' . $layout_info->site_title;
        } else {
            $meta_description = $layout_info->site_description;
        }

        // 특수문자 처리
        $meta_description = strip_tags($meta_description);
        $meta_description = str_replace(array('"', "\n", "\r"), array('', ' ', ' '), $meta_description);
    }
    <meta name="description" content="{$meta_description}" />

    <!-- 키워드 -->
    {@
        // 키워드 생성
        $meta_keywords = array();

        // 문서의 태그
        if($oDocument && $oDocument->isExists() && $oDocument->get('tag_list')) {
            $meta_keywords = array_merge($meta_keywords, $oDocument->get('tag_list'));
        }

        // 카테고리명
        if($category_list && $oDocument && $oDocument->get('category_srl')) {
            $meta_keywords[] = $category_list[$oDocument->get('category_srl')]->title;
        }

        // 기본 키워드
        $meta_keywords[] = $module_info->browser_title;
        $meta_keywords[] = $layout_info->site_title;

        $meta_keywords = array_unique($meta_keywords);
        $meta_keywords = implode(', ', array_slice($meta_keywords, 0, 10));
    }
    <meta name="keywords" content="{$meta_keywords}" />

    <!-- 작성자 -->
    <meta name="author" content="{$layout_info->site_author ?: $layout_info->site_title}" />

    <!-- 로봇 메타태그 -->
    <meta name="robots" content="index,follow" />

    <!-- Canonical URL -->
    {@
        // 정규 URL 생성
        if($oDocument && $oDocument->isExists()) {
            $canonical_url = getFullUrl('', 'mid', $mid, 'document_srl', $oDocument->document_srl);
        } elseif($mid) {
            $canonical_url = getFullUrl('', 'mid', $mid);
        } else {
            $canonical_url = getFullUrl('');
        }
    }
    <link rel="canonical" href="{$canonical_url}" />
</head>

오픈그래프 태그

소셜 미디어 최적화

<!-- 오픈그래프 메타태그 -->
{@
    // 오픈그래프 데이터 준비
    $og_data = new stdClass();

    // 기본 정보
    $og_data->site_name = $layout_info->site_title;
    $og_data->locale = 'ko_KR';

    // 페이지별 정보
    if($oDocument && $oDocument->isExists()) {
        // 문서 페이지
        $og_data->type = 'article';
        $og_data->title = $oDocument->getTitleText() . ' - ' . $module_info->browser_title;
        $og_data->description = $oDocument->getSummary(200);
        $og_data->url = getFullUrl('', 'mid', $mid, 'document_srl', $oDocument->document_srl);

        // 썸네일 이미지
        if($oDocument->thumbnailExists()) {
            $og_data->image = getFullUrl() . $oDocument->getThumbnail(1200, 630);
        } else {
            // 본문에서 첫 번째 이미지 추출
            preg_match('/<img[^>]+src="([^"]+)"/i', $oDocument->get('content'), $matches);
            if($matches[1]) {
                $og_data->image = $matches[1];
                if(strpos($og_data->image, '://') === false) {
                    $og_data->image = getFullUrl() . $og_data->image;
                }
            }
        }

        // 작성자 정보
        $og_data->author = $oDocument->getNickName();
        $og_data->published_time = date('c', strtotime($oDocument->get('regdate')));
        $og_data->modified_time = date('c', strtotime($oDocument->get('last_update')));

    } elseif($module_info->module == 'board') {
        // 게시판 목록
        $og_data->type = 'website';
        $og_data->title = $module_info->browser_title . ' - ' . $layout_info->site_title;
        $og_data->description = $module_info->description ?: $meta_description;
        $og_data->url = getFullUrl('', 'mid', $mid);

    } else {
        // 기타 페이지
        $og_data->type = 'website';
        $og_data->title = $layout_info->site_title;
        $og_data->description = $layout_info->site_description;
        $og_data->url = getFullUrl('');
    }

    // 기본 이미지
    if(!$og_data->image) {
        $og_data->image = getFullUrl() . $layout_info->og_default_image;
    }
}

<!-- Open Graph 태그 출력 -->
<meta property="og:site_name" content="{$og_data->site_name}" />
<meta property="og:type" content="{$og_data->type}" />
<meta property="og:title" content="{$og_data->title}" />
<meta property="og:description" content="{$og_data->description}" />
<meta property="og:url" content="{$og_data->url}" />
<meta property="og:locale" content="{$og_data->locale}" />

<meta property="og:image" content="{$og_data->image}" cond="$og_data->image" />
<meta property="og:image:width" content="1200" cond="$og_data->image" />
<meta property="og:image:height" content="630" cond="$og_data->image" />

<!-- 문서 타입인 경우 추가 정보 -->
<block cond="$og_data->type == 'article'">
    <meta property="article:author" content="{$og_data->author}" />
    <meta property="article:published_time" content="{$og_data->published_time}" />
    <meta property="article:modified_time" content="{$og_data->modified_time}" />

    <!-- 태그 -->
    <meta loop="$oDocument->get('tag_list')=>$tag" property="article:tag" content="{$tag}" />
</block>

<!-- Twitter 카드 -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@{$layout_info->twitter_username}" cond="$layout_info->twitter_username" />
<meta name="twitter:title" content="{$og_data->title}" />
<meta name="twitter:description" content="{$og_data->description}" />
<meta name="twitter:image" content="{$og_data->image}" cond="$og_data->image" />

구조화된 데이터

JSON-LD 스키마

<!-- 구조화된 데이터 (JSON-LD) -->
<script type="application/ld+json">
{@
    // 기본 웹사이트 스키마
    $schema = array(
        '@context' => 'https://schema.org',
        '@type' => 'WebSite',
        'name' => $layout_info->site_title,
        'description' => $layout_info->site_description,
        'url' => getFullUrl(''),
        'potentialAction' => array(
            '@type' => 'SearchAction',
            'target' => array(
                '@type' => 'EntryPoint',
                'urlTemplate' => getFullUrl('', 'act', 'IS') . '&search_keyword={search_term_string}'
            ),
            'query-input' => 'required name=search_term_string'
        )
    );

    // 문서 페이지인 경우
    if($oDocument && $oDocument->isExists()) {
        $article_schema = array(
            '@context' => 'https://schema.org',
            '@type' => 'Article',
            'headline' => $oDocument->getTitleText(),
            'description' => $oDocument->getSummary(160),
            'image' => $og_data->image ?: null,
            'datePublished' => date('c', strtotime($oDocument->get('regdate'))),
            'dateModified' => date('c', strtotime($oDocument->get('last_update'))),
            'author' => array(
                '@type' => 'Person',
                'name' => $oDocument->getNickName()
            ),
            'publisher' => array(
                '@type' => 'Organization',
                'name' => $layout_info->site_title,
                'logo' => array(
                    '@type' => 'ImageObject',
                    'url' => getFullUrl() . $layout_info->logo_image
                )
            ),
            'mainEntityOfPage' => array(
                '@type' => 'WebPage',
                '@id' => $canonical_url
            )
        );

        // 댓글이 있는 경우
        if($oDocument->getCommentCount() > 0) {
            $comments = $oDocument->getComments();
            $article_schema['commentCount'] = $oDocument->getCommentCount();
            $article_schema['comment'] = array();

            foreach($comments as $comment) {
                $article_schema['comment'][] = array(
                    '@type' => 'Comment',
                    'author' => array(
                        '@type' => 'Person',
                        'name' => $comment->getNickName()
                    ),
                    'text' => $comment->getSummary(100),
                    'dateCreated' => date('c', strtotime($comment->get('regdate')))
                );
            }
        }

        echo json_encode($article_schema, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    } else {
        echo json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }
}
</script>

<!-- 빵부스러기 구조화 데이터 -->
<script type="application/ld+json" cond="count($breadcrumb) > 1">
{@
    $breadcrumb_schema = array(
        '@context' => 'https://schema.org',
        '@type' => 'BreadcrumbList',
        'itemListElement' => array()
    );

    foreach($breadcrumb as $idx => $item) {
        $breadcrumb_schema['itemListElement'][] = array(
            '@type' => 'ListItem',
            'position' => $idx + 1,
            'name' => $item['title'],
            'item' => getFullUrl() . $item['url']
        );
    }

    echo json_encode($breadcrumb_schema, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
</script>

SEO 최적화 기법

동적 타이틀 생성

<!-- 페이지별 최적화된 타이틀 -->
{@
    // 타이틀 구성 요소
    $title_parts = array();

    // 문서 제목
    if($oDocument && $oDocument->isExists()) {
        $title_parts[] = $oDocument->getTitleText();

        // 카테고리
        if($oDocument->get('category_srl') && $category_list[$oDocument->get('category_srl')]) {
            $title_parts[] = $category_list[$oDocument->get('category_srl')]->title;
        }
    }

    // 페이지 번호
    if($page > 1) {
        $title_parts[] = sprintf('%d페이지', $page);
    }

    // 검색어
    if($search_keyword) {
        $title_parts[] = '"' . $search_keyword . '" 검색 결과';
    }

    // 모듈명
    if($module_info->browser_title) {
        $title_parts[] = $module_info->browser_title;
    }

    // 사이트명
    $title_parts[] = $layout_info->site_title;

    // 조합 (60자 이내)
    $page_title = implode(' - ', array_slice($title_parts, 0, 3));
    if(mb_strlen($page_title) > 60) {
        $page_title = mb_substr($page_title, 0, 57) . '...';
    }
}
<title>{$page_title}</title>

이미지 최적화

<!-- 이미지 SEO -->
{@
    // 이미지 alt 텍스트 자동 생성
    function generateAltText($document, $image_index = 0) {
        $alt_parts = array();

        // 문서 제목
        $alt_parts[] = $document->getTitleText();

        // 이미지 순서
        if($image_index > 0) {
            $alt_parts[] = sprintf('이미지 %d', $image_index + 1);
        }

        return implode(' - ', $alt_parts);
    }
}

<!-- 본문 이미지 처리 -->
{@
    $content = $oDocument->get('content');

    // img 태그에 alt 속성 추가
    $image_index = 0;
    $content = preg_replace_callback('/<img([^>]+)>/i', function($matches) use ($oDocument, &$image_index) {
        $img_tag = $matches[0];

        // alt 속성이 없으면 추가
        if(strpos($img_tag, 'alt=') === false) {
            $alt_text = generateAltText($oDocument, $image_index);
            $img_tag = str_replace('<img', '<img alt="' . htmlspecialchars($alt_text) . '"', $img_tag);
        }

        // loading="lazy" 추가
        if(strpos($img_tag, 'loading=') === false) {
            $img_tag = str_replace('<img', '<img loading="lazy"', $img_tag);
        }

        $image_index++;
        return $img_tag;
    }, $content);
}

사이트맵 생성

동적 사이트맵

<!-- sitemap.xml 생성 -->
{@
    if($act == 'getSitemap') {
        header('Content-Type: application/xml; charset=UTF-8');

        $sitemap = '<?xml version="1.0" encoding="UTF-8"?>';
        $sitemap .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';

        // 홈페이지
        $sitemap .= '<url>';
        $sitemap .= '<loc>' . getFullUrl('') . '</loc>';
        $sitemap .= '<changefreq>daily</changefreq>';
        $sitemap .= '<priority>1.0</priority>';
        $sitemap .= '</url>';

        // 게시판 목록
        $oModuleModel = getModel('module');
        $module_list = $oModuleModel->getModuleList();

        foreach($module_list as $module) {
            if($module->module != 'board') continue;

            $sitemap .= '<url>';
            $sitemap .= '<loc>' . getFullUrl('', 'mid', $module->mid) . '</loc>';
            $sitemap .= '<changefreq>daily</changefreq>';
            $sitemap .= '<priority>0.8</priority>';
            $sitemap .= '</url>';

            // 최근 문서들
            $args = new stdClass();
            $args->module_srl = $module->module_srl;
            $args->list_count = 100;
            $args->sort_index = 'list_order';
            $args->order_type = 'asc';

            $output = executeQuery('document.getDocumentList', $args);
            if($output->data) {
                foreach($output->data as $document) {
                    $sitemap .= '<url>';
                    $sitemap .= '<loc>' . getFullUrl('', 'mid', $module->mid, 'document_srl', $document->document_srl) . '</loc>';
                    $sitemap .= '<lastmod>' . date('c', strtotime($document->last_update)) . '</lastmod>';
                    $sitemap .= '<changefreq>weekly</changefreq>';
                    $sitemap .= '<priority>0.6</priority>';
                    $sitemap .= '</url>';
                }
            }
        }

        $sitemap .= '</urlset>';

        echo $sitemap;
        exit;
    }
}

<!-- robots.txt 참조 -->
<link rel="sitemap" type="application/xml" title="Sitemap" href="{getFullUrl('', 'act', 'getSitemap')}" />

성능 최적화

메타태그 캐싱

<!-- 메타태그 캐싱 -->
{@
    // 캐시 키 생성
    $cache_key = 'meta_tags_' . md5($request_uri);
    $cached_meta = Rhymix\Framework\Cache::get($cache_key);

    if(!$cached_meta) {
        // 메타태그 생성 로직
        $cached_meta = array(
            'title' => $page_title,
            'description' => $meta_description,
            'keywords' => $meta_keywords,
            'og_data' => $og_data
        );

        // 1시간 캐싱
        Rhymix\Framework\Cache::set($cache_key, $cached_meta, 3600);
    }

    // 캐시된 데이터 사용
    extract($cached_meta);
}

Lazy Loading 최적화

// 이미지 레이지 로딩 최적화
document.addEventListener('DOMContentLoaded', function() {
    // Native lazy loading 지원 확인
    if ('loading' in HTMLImageElement.prototype) {
        // 브라우저가 지원하면 그대로 사용
        const images = document.querySelectorAll('img[loading="lazy"]');
        images.forEach(img => {
            // data-src가 있으면 src로 변경
            if(img.dataset.src) {
                img.src = img.dataset.src;
            }
        });
    } else {
        // 지원하지 않으면 Intersection Observer 사용
        const script = document.createElement('script');
        script.src = '/layouts/my_layout/js/lazysizes.min.js';
        document.body.appendChild(script);
    }
});

모범 사례

  1. 적절한 길이: 타이틀 60자, 설명 160자 이내
  2. 고유성: 각 페이지마다 고유한 메타 정보
  3. 키워드: 자연스럽게 포함, 과도한 반복 피하기
  4. 이미지: 적절한 크기와 alt 텍스트 제공
  5. 구조화: 검색엔진이 이해하기 쉬운 마크업

다음 단계

메타태그와 SEO를 구현했다면, 모바일 최적화를 학습하세요.