모바일 최적화

모바일 최적화

라이믹스 레이아웃에서 모바일 환경을 체크하고 최적화하는 방법을 학습합니다.

모바일 체크

디바이스 감지

<!-- 모바일 체크하기 -->
{@
    // 라이믹스 모바일 체크 함수
    $is_mobile = Mobile::isMobile();
    $is_tablet = Mobile::isTablet();
    $is_desktop = !$is_mobile && !$is_tablet;

    // 디바이스별 클래스
    $device_class = '';
    if($is_mobile) {
        $device_class = 'device-mobile';
    } elseif($is_tablet) {
        $device_class = 'device-tablet';
    } else {
        $device_class = 'device-desktop';
    }

    // 특정 모바일 기기 체크
    $user_agent = $_SERVER['HTTP_USER_AGENT'];
    $is_ios = preg_match('/iPhone|iPad|iPod/i', $user_agent);
    $is_android = preg_match('/Android/i', $user_agent);
    $is_samsung = preg_match('/SAMSUNG|SM-/i', $user_agent);
}

<!-- body 태그에 디바이스 클래스 추가 -->
<body class="{$device_class} os-{$is_ios ? 'ios' : ($is_android ? 'android' : 'other')}">

    <!-- 모바일 전용 콘텐츠 -->
    <div cond="$is_mobile" class="mobile-only">
        <!-- 모바일 전용 네비게이션 -->
        <nav class="mobile-nav">
            <button type="button" class="nav-toggle">
                <i class="xi-bars"></i>
            </button>
        </nav>
    </div>

    <!-- 데스크탑 전용 콘텐츠 -->
    <div cond="$is_desktop" class="desktop-only">
        <!-- 데스크탑 사이드바 -->
        <aside class="desktop-sidebar">
            <!-- 사이드바 콘텐츠 -->
        </aside>
    </div>

    <!-- 반응형 콘텐츠 -->
    <div class="responsive-content">
        <!-- 모든 디바이스에서 표시 -->
    </div>
</body>

웹뷰 체크

앱 내장 브라우저 감지

<!-- 웹뷰 여부 체크하기 (PHP) -->
{@
    // User-Agent로 웹뷰 체크
    $user_agent = $_SERVER['HTTP_USER_AGENT'];

    // 일반적인 웹뷰 패턴
    $webview_patterns = array(
        '/wv/',                    // Android WebView
        '/WebView/',              
        '/Version\/[\d\.]+.*Safari/', // iOS WebView
        '/Line/',                 // Line 앱
        '/FB/',                   // Facebook 앱
        '/Instagram/',            // Instagram 앱
        '/KAKAO/',               // 카카오톡
        '/NAVER/',               // 네이버 앱
    );

    $is_webview = false;
    foreach($webview_patterns as $pattern) {
        if(preg_match($pattern, $user_agent)) {
            $is_webview = true;
            break;
        }
    }

    // 안드로이드 웹뷰 상세 체크
    $is_android_webview = false;
    if(preg_match('/Android/', $user_agent)) {
        if(preg_match('/wv/', $user_agent) || 
           (preg_match('/Version\/[\d\.]+/', $user_agent) && !preg_match('/Chrome\/[\d\.]+/', $user_agent))) {
            $is_android_webview = true;
        }
    }
}

<!-- 웹뷰 전용 처리 -->
<div cond="$is_webview" class="webview-notice">
    <p>앱에서 보고 계십니다. 더 나은 경험을 위해 브라우저에서 열어보세요.</p>
    <button onclick="openInBrowser()">브라우저에서 열기</button>
</div>

<script>
// JavaScript로 웹뷰 체크
function isWebView() {
    var userAgent = navigator.userAgent || navigator.vendor || window.opera;

    // iOS 웹뷰
    if(/iPhone|iPod|iPad/.test(userAgent)) {
        // Safari 브라우저가 아닌 경우
        if(!/Safari/.test(userAgent) || /CriOS/.test(userAgent)) {
            return true;
        }
        // standalone 모드
        if(window.navigator.standalone) {
            return true;
        }
    }

    // Android 웹뷰
    if(/Android/.test(userAgent)) {
        if(/wv/.test(userAgent) || 
           (/Version\/[\d\.]+/.test(userAgent) && !/Chrome\/[\d\.]+/.test(userAgent))) {
            return true;
        }
    }

    // 기타 앱 내장 브라우저
    if(/FBAN|FBAV|Instagram|Line|KAKAO|NAVER/.test(userAgent)) {
        return true;
    }

    return false;
}

// 브라우저에서 열기
function openInBrowser() {
    var currentUrl = window.location.href;

    // iOS
    if(/iPhone|iPod|iPad/.test(navigator.userAgent)) {
        window.location = 'x-web-search://?url=' + encodeURIComponent(currentUrl);
    } 
    // Android
    else if(/Android/.test(navigator.userAgent)) {
        window.location = 'intent:' + currentUrl + '#Intent;action=android.intent.action.VIEW;category=android.intent.category.BROWSABLE;end';
    }
    // 기타
    else {
        alert('브라우저에서 직접 열어주세요: ' + currentUrl);
    }
}
</script>

안드로이드 푸시앱 모듈 변수 활용

<!-- 웹뷰 여부 체크하기(안드로이드푸시앱모듈 변수 활용) -->
{@
    // 안드로이드 푸시앱 모듈이 설치된 경우
    if(getModel('module')->getModuleConfig('androidpushapp')) {
        $androidpushapp_config = getModel('module')->getModuleConfig('androidpushapp');
        $is_pushapp = Context::get('is_pushapp') == 'Y';

        // 푸시앱 버전 정보
        $pushapp_version = Context::get('pushapp_version');
        $pushapp_os = Context::get('pushapp_os'); // android, ios
    }
}

<!-- 푸시앱 전용 기능 -->
<div cond="$is_pushapp" class="pushapp-features">
    <!-- 푸시앱 전용 헤더 -->
    <header class="pushapp-header">
        <button onclick="pushappBack()">
            <i class="xi-arrow-left"></i>
        </button>
        <h1>{$layout_info->site_title}</h1>
        <button onclick="pushappShare()">
            <i class="xi-share-alt"></i>
        </button>
    </header>

    <!-- 푸시앱 전용 하단 메뉴 -->
    <nav class="pushapp-bottom-nav">
        <a href="#" onclick="pushappGoHome()">
            <i class="xi-home"></i>
            <span>홈</span>
        </a>
        <a href="#" onclick="pushappOpenMenu()">
            <i class="xi-apps"></i>
            <span>메뉴</span>
        </a>
        <a href="#" onclick="pushappOpenNotification()">
            <i class="xi-bell"></i>
            <span>알림</span>
        </a>
        <a href="#" onclick="pushappOpenMypage()">
            <i class="xi-user"></i>
            <span>MY</span>
        </a>
    </nav>
</div>

<script>
// 푸시앱 전용 함수들
function pushappBack() {
    if(window.AndroidPushApp) {
        window.AndroidPushApp.goBack();
    } else if(window.webkit && window.webkit.messageHandlers.goBack) {
        window.webkit.messageHandlers.goBack.postMessage('');
    } else {
        history.back();
    }
}

function pushappShare() {
    var shareData = {
        title: document.title,
        text: document.querySelector('meta[name="description"]').content,
        url: window.location.href
    };

    if(window.AndroidPushApp) {
        window.AndroidPushApp.share(JSON.stringify(shareData));
    } else if(navigator.share) {
        navigator.share(shareData);
    }
}

function pushappGoHome() {
    if(window.AndroidPushApp) {
        window.AndroidPushApp.goHome();
    } else {
        location.href = '/';
    }
}
</script>

반응형 레이아웃

모바일 우선 접근

<!-- 반응형 네비게이션 -->
<header class="site-header">
    <!-- 모바일 헤더 -->
    <div class="mobile-header">
        <button type="button" class="menu-toggle" aria-label="메뉴 열기">
            <span class="hamburger">
                <span></span>
                <span></span>
                <span></span>
            </span>
        </button>

        <h1 class="mobile-logo">
            <a href="{getUrl('')}">{$layout_info->site_title}</a>
        </h1>

        <button type="button" class="search-toggle" aria-label="검색">
            <i class="xi-search"></i>
        </button>
    </div>

    <!-- 데스크탑/모바일 공통 네비게이션 -->
    <nav class="main-navigation" id="navigation">
        <ul class="nav-menu">
            <li loop="$main_menu->list=>$key,$val" class="menu-item">
                <a href="{$val['url']}" target="_blank"|cond="$val['open_window']=='Y'">
                    {$val['text']}
                </a>

                <!-- 서브메뉴 -->
                <ul cond="$val['list']" class="sub-menu">
                    <li loop="$val['list']=>$key2,$val2">
                        <a href="{$val2['url']}">{$val2['text']}</a>
                    </li>
                </ul>
            </li>
        </ul>
    </nav>

    <!-- 모바일 오버레이 -->
    <div class="mobile-overlay"></div>
</header>

<style>
/* 모바일 우선 CSS */
.site-header {
    position: relative;
}

.mobile-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 15px;
    background: #fff;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

.main-navigation {
    position: fixed;
    top: 0;
    left: -280px;
    width: 280px;
    height: 100vh;
    background: #fff;
    transition: left 0.3s;
    overflow-y: auto;
    z-index: 1000;
}

.main-navigation.active {
    left: 0;
}

.mobile-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0,0,0,0.5);
    display: none;
    z-index: 999;
}

.mobile-overlay.active {
    display: block;
}

/* 태블릿 이상 */
@media (min-width: 768px) {
    .mobile-header {
        display: none;
    }

    .main-navigation {
        position: static;
        width: auto;
        height: auto;
        background: none;
    }

    .nav-menu {
        display: flex;
        list-style: none;
        margin: 0;
        padding: 0;
    }

    .menu-item {
        position: relative;
    }

    .sub-menu {
        position: absolute;
        top: 100%;
        left: 0;
        background: #fff;
        box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        display: none;
    }

    .menu-item:hover .sub-menu {
        display: block;
    }
}

/* 데스크탑 */
@media (min-width: 1200px) {
    .site-header {
        max-width: 1200px;
        margin: 0 auto;
    }
}
</style>

<script>
// 모바일 메뉴 토글
document.addEventListener('DOMContentLoaded', function() {
    var menuToggle = document.querySelector('.menu-toggle');
    var navigation = document.querySelector('.main-navigation');
    var overlay = document.querySelector('.mobile-overlay');

    menuToggle.addEventListener('click', function() {
        navigation.classList.toggle('active');
        overlay.classList.toggle('active');
        this.classList.toggle('active');
    });

    overlay.addEventListener('click', function() {
        navigation.classList.remove('active');
        overlay.classList.remove('active');
        menuToggle.classList.remove('active');
    });

    // 화면 크기 변경시 메뉴 닫기
    var resizeTimer;
    window.addEventListener('resize', function() {
        clearTimeout(resizeTimer);
        resizeTimer = setTimeout(function() {
            if(window.innerWidth >= 768) {
                navigation.classList.remove('active');
                overlay.classList.remove('active');
                menuToggle.classList.remove('active');
            }
        }, 250);
    });
});
</script>

터치 최적화

터치 이벤트 처리

<!-- 터치 친화적인 UI -->
<style>
/* 터치 타겟 크기 최적화 */
.touch-target {
    min-width: 44px;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* 터치 피드백 */
.touchable {
    -webkit-tap-highlight-color: rgba(0,0,0,0.1);
    touch-action: manipulation; /* 더블탭 줌 방지 */
}

.touchable:active {
    transform: scale(0.95);
    opacity: 0.8;
}

/* 스와이프 가능한 영역 */
.swipeable {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scroll-snap-type: x mandatory;
}

.swipeable::-webkit-scrollbar {
    display: none;
}

.swipe-item {
    scroll-snap-align: start;
}
</style>

<script>
// 터치 스와이프 감지
class TouchSwipe {
    constructor(element, options = {}) {
        this.element = element;
        this.options = Object.assign({
            threshold: 50,
            onSwipeLeft: null,
            onSwipeRight: null,
            onSwipeUp: null,
            onSwipeDown: null
        }, options);

        this.touchStartX = 0;
        this.touchStartY = 0;
        this.touchEndX = 0;
        this.touchEndY = 0;

        this.init();
    }

    init() {
        this.element.addEventListener('touchstart', this.handleTouchStart.bind(this), {passive: true});
        this.element.addEventListener('touchmove', this.handleTouchMove.bind(this), {passive: true});
        this.element.addEventListener('touchend', this.handleTouchEnd.bind(this), {passive: true});
    }

    handleTouchStart(e) {
        this.touchStartX = e.changedTouches[0].screenX;
        this.touchStartY = e.changedTouches[0].screenY;
    }

    handleTouchMove(e) {
        this.touchEndX = e.changedTouches[0].screenX;
        this.touchEndY = e.changedTouches[0].screenY;
    }

    handleTouchEnd() {
        this.handleSwipe();
    }

    handleSwipe() {
        const deltaX = this.touchEndX - this.touchStartX;
        const deltaY = this.touchEndY - this.touchStartY;

        if(Math.abs(deltaX) > Math.abs(deltaY)) {
            // 좌우 스와이프
            if(deltaX > this.options.threshold && this.options.onSwipeRight) {
                this.options.onSwipeRight();
            } else if(deltaX < -this.options.threshold && this.options.onSwipeLeft) {
                this.options.onSwipeLeft();
            }
        } else {
            // 상하 스와이프
            if(deltaY > this.options.threshold && this.options.onSwipeDown) {
                this.options.onSwipeDown();
            } else if(deltaY < -this.options.threshold && this.options.onSwipeUp) {
                this.options.onSwipeUp();
            }
        }
    }
}

// 사용 예시
document.addEventListener('DOMContentLoaded', function() {
    // 이미지 갤러리 스와이프
    var gallery = document.querySelector('.image-gallery');
    if(gallery) {
        new TouchSwipe(gallery, {
            onSwipeLeft: function() {
                // 다음 이미지
                nextImage();
            },
            onSwipeRight: function() {
                // 이전 이미지
                prevImage();
            }
        });
    }

    // 모바일 메뉴 스와이프
    var menuSwipe = new TouchSwipe(document.body, {
        threshold: 100,
        onSwipeRight: function() {
            // 메뉴 열기
            if(window.innerWidth < 768) {
                document.querySelector('.main-navigation').classList.add('active');
            }
        },
        onSwipeLeft: function() {
            // 메뉴 닫기
            if(window.innerWidth < 768) {
                document.querySelector('.main-navigation').classList.remove('active');
            }
        }
    });
});
</script>

성능 최적화

모바일 리소스 최적화

<!-- 조건부 리소스 로딩 -->
{@
    // 디바이스별 리소스 설정
    if($is_mobile) {
        $css_files = array('/layouts/my_layout/css/mobile.min.css');
        $js_files = array('/layouts/my_layout/js/mobile.min.js');
        $image_quality = 70;
    } else {
        $css_files = array('/layouts/my_layout/css/desktop.min.css');
        $js_files = array('/layouts/my_layout/js/desktop.min.js');
        $image_quality = 90;
    }
}

<!-- CSS 로딩 -->
<link loop="$css_files=>$css_file" rel="stylesheet" href="{$css_file}" />

<!-- 크리티컬 CSS 인라인 -->
<style>
/* 초기 렌더링에 필요한 최소 CSS */
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.site-header { background: #fff; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
</style>

<!-- 나머지 CSS 비동기 로딩 -->
<link rel="preload" href="{$css_files[0]}" as="style" onload="this.onload=null;this.rel='stylesheet'">

<!-- JavaScript 지연 로딩 -->
<script>
// 필수 스크립트만 먼저 로드
document.addEventListener('DOMContentLoaded', function() {
    // 비필수 스크립트 지연 로딩
    setTimeout(function() {
        var scripts = {$js_files|@json_encode};
        scripts.forEach(function(src) {
            var script = document.createElement('script');
            script.src = src;
            script.async = true;
            document.body.appendChild(script);
        });
    }, 1000);
});
</script>

<!-- 이미지 최적화 -->
{@
    // 반응형 이미지 생성
    function getResponsiveImage($src, $alt = '') {
        $srcset = array();
        $sizes = array(320, 640, 768, 1024, 1200);

        foreach($sizes as $size) {
            $srcset[] = getThumbnail($src, $size) . ' ' . $size . 'w';
        }

        return sprintf(
            '<img src="%s" srcset="%s" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" alt="%s" loading="lazy" />',
            getThumbnail($src, 768),
            implode(', ', $srcset),
            $alt
        );
    }
}

모범 사례

  1. 터치 타겟: 최소 44x44px 크기 유지
  2. 폰트 크기: 모바일에서 최소 16px
  3. 뷰포트: 확대/축소 가능하게 설정
  4. 성능: 불필요한 리소스 제거
  5. 테스트: 실제 기기에서 테스트

다음 단계

모바일 최적화를 완료했다면, 고급 레이아웃 기법을 학습하세요.