메타태그와 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);
}
});
모범 사례¶
- 적절한 길이: 타이틀 60자, 설명 160자 이내
- 고유성: 각 페이지마다 고유한 메타 정보
- 키워드: 자연스럽게 포함, 과도한 반복 피하기
- 이미지: 적절한 크기와 alt 텍스트 제공
- 구조화: 검색엔진이 이해하기 쉬운 마크업
다음 단계¶
메타태그와 SEO를 구현했다면, 모바일 최적화를 학습하세요.