LogoSEO Jing
  • All Posts
  • SEO Jing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

CSSUX모바일DevLog

모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기

2026년 3월 22일·15분 읽기

문제: 모바일에서 코드가 읽기 어렵다

블로그에 코드 블록이 많다. PC에서는 문제없지만, 모바일 세로 화면에서는 가로 스크롤 없이 코드를 읽기가 어렵다. 320px - 390px 폭에 들어가는 코드 라인은 40자에서 50자 정도인데, 실제 코드는 80~120자인 경우가 많다.

해결하고 싶은 것은 단순하다. 버튼 하나로 코드 블록을 가로 방향으로 넓게 보여주기. 네이티브 앱이라면 화면 회전을 강제하면 끝이지만, 웹에서는 그게 쉽지 않다.


방법 1: Screen Orientation API

가장 먼저 떠오르는 방법이다. screen.orientation.lock('landscape')를 호출하면 화면이 가로로 고정된다.

js
// 가로 모드 강제
async function forceLandscape() {
  try {
    await screen.orientation.lock("landscape");
  } catch (e) {
    console.error("회전 잠금 실패:", e);
  }
}

문제는 제약이 너무 많다는 것이다.

  • iOS Safari: 완전히 미지원. screen.orientation.lock이 존재하지 않는다.
  • Android Chrome: 전체화면(Fullscreen API) 상태에서만 동작한다.
  • 데스크톱 브라우저: 대부분 무시한다.

즉, 전체화면 진입 → 가로 회전 잠금 → 코드 보기 → 잠금 해제 → 전체화면 해제라는 복잡한 흐름이 되고, 그마저도 iOS 사용자 절반을 버려야 한다. 현실적이지 않다.

js
// Android에서만 동작하는 전체화면 + 회전 조합
async function enterLandscapeFullscreen(element) {
  await element.requestFullscreen();
  await screen.orientation.lock("landscape");
}

// iOS에서는 requestFullscreen도 제한적
// Safari는 video 요소에서만 전체화면을 허용한다

방법 2: PWA manifest의 orientation

PWA의 manifest.json에서 화면 방향을 지정할 수 있다.
json
{
  "name": "My Blog",
  "display": "standalone",
  "orientation": "landscape"
}

이 방법은 앱으로 설치된 경우에만 작동한다. 일반 브라우저에서 접속하면 manifest의 orientation은 완전히 무시된다. 블로그를 PWA로 설치하는 사용자는 거의 없으므로, 이 방법도 탈락이다.

방법 3: 회전 유도 UI

직접 회전하지 않고, 사용자에게 "기기를 가로로 돌려주세요"라는 안내를 보여주는 방식이다. 게임이나 영상 앱에서 흔히 볼 수 있다.

css
/* 세로 모드일 때만 회전 안내 표시 */
.rotate-prompt {
  display: none;
}

@media (orientation: portrait) {
  .rotate-prompt {
    display: flex;
    /* 화면 중앙에 "기기를 돌려주세요" 아이콘 */
  }
}

구현은 간단하지만, 사용자 경험이 나쁘다. 코드 블록 하나를 보기 위해 물리적으로 폰을 돌려야 한다. 회전 잠금을 걸어둔 사용자는 설정까지 바꿔야 한다. "버튼 하나로 넓게 보기"라는 원래 목표와 거리가 멀다.

방법 4: CSS writing-mode

텍스트 방향 자체를 바꾸는 CSS 속성이다. writing-mode: vertical-rl을 쓰면 텍스트가 세로로 흐른다.

css
.rotated-text {
  writing-mode: vertical-rl;
  transform: rotate(180deg);
}

코드 블록에는 완전히 부적합하다. 코드는 가로로 읽어야 하는데, writing-mode는 글자 하나하나의 배치 방향을 바꾸기 때문에 코드의 들여쓰기와 정렬이 전부 깨진다. 표(table)나 세로 텍스트 레이아웃에서는 유용하지만, 코드 뷰어에 쓸 수 있는 방법은 아니다.

방법 5: CSS transform rotate — 가짜 가로 모드

남은 방법은 하나다. 화면은 세로 그대로 두고, UI만 90도 돌리는 것. transform: rotate(90deg)로 시각적 회전을 만들고, viewport 단위(100vh, 100vw)로 너비와 높이를 교체한다.

css
.fullscreen-rotated {
  position: fixed;
  inset: 0;

  .inner {
    position: absolute;
    width: 100vh; /* 세로 길이 > 가로 폭으로 */
    height: 100vw; /* 가로 길이 > 세로 폭으로 */
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) rotate(90deg);
  }
}

이 방법은 모든 브라우저에서 동작한다. iOS Safari, Android Chrome, 데스크톱 브라우저 어디서든 CSS transform은 지원된다. Screen Orientation API와 달리 브라우저 제약이 없다.

원리는 단순하다.
  • width: 100vh — 뷰포트의 세로 길이를 가로 폭으로 사용
  • height: 100vw — 뷰포트의 가로 길이를 세로 폭으로 사용
  • translate(-50%, -50%) — 요소를 정확히 화면 중앙에 배치
  • rotate(90deg) — 시계 방향 90도 회전

결과적으로 사용자가 보는 화면은 가로 모드와 동일하다. 폰을 돌리지 않아도 된다.


구현하면서 부딪힌 UX 문제들

1. 코드가 중앙에서 시작되는 문제

처음에는 flex items-center justify-center로 회전 컨테이너를 화면 중앙에 놓았다. 그런데 이렇게 하면 코드가 화면 한가운데서 시작된다. 가로로 돌려서 넓이를 확보한 의미가 없어진다.

두 접근의 차이를 이해하려면, flex의 정렬 속성과 translate가 작동하는 레이어가 다르다는 것을 알아야 한다.

items-center justify-center는 flex 컨테이너가 자식의 위치를 결정한다. 컨테이너 안의 모든 자식이 수직·수평 모두 중앙으로 밀린다. 그래서 코드 블록도 화면 중앙에 놓이고, 스크롤 시작점도 가운데가 된다. flex-1을 줘도 자식이 남는 공간을 채우긴 하지만, 남는 공간 자체가 양쪽으로 균등하게 분배되기 때문에 결과는 같다.

css
/* 자식의 콘텐츠까지 중앙 정렬됨 */
.container {
  display: flex;
  align-items: center;
  justify-content: center;
}

.container .code {
  flex: 1; /* 의미 없음 — 이미 중앙 정렬 */
}

반면 translate(-50%, -50%)는 요소 자체의 렌더링 위치만 이동 한다. top: 50%; left: 50%로 요소의 좌측 상단 꼭짓점을 화면 중앙에 놓고, translate(-50%, -50%)로 자기 크기의 절반만큼 되돌려서 시각적 중앙에 배치한다. 중요한 것은, 이 과정에서 요소 내부의 레이아웃에는 개입하지 않는다는 점이다. 내부에서 flex-col과 flex-1을 쓰면 코드 영역이 자연스럽게 상단부터 채워진다.

css
/* 컨테이너만 중앙 배치, 내부는 상단부터 시작 */
.container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) rotate(90deg);
  display: flex;
  flex-direction: column;
}

.container .code {
  flex: 1; /* 남는 높이를 모두 차지, 코드가 위에서 시작 */
}
정리하면 이렇다.
방식중앙 정렬 대상내부 콘텐츠 시작점flex-1 효과
items-center justify-center자식 요소의 콘텐츠화면 중앙공간 분배가 중앙 기준
translate(-50%, -50%)요소의 렌더링 위치요소 내부 상단남는 공간을 아래로 채움

서브픽셀 렌더링 주의점

translate(-50%, -50%)에는 한 가지 함정이 있다. 요소의 너비나 높이가 홀수 픽셀일 때 -50%가 0.5px 단위로 계산되면서, 텍스트나 border가 흐릿하게 보이는 서브픽셀 렌더링 문제가 발생할 수 있다. 브라우저가 0.5px 오프셋에 있는 요소를 두 물리 픽셀에 걸쳐 안티앨리어싱하기 때문이다.

이 프로젝트의 캐러셀 컴포넌트에서도 translate를 사용하다가 비슷한 문제를 겪은 적이 있다. 해결 방법은 몇 가지가 있다.

  • will-change: transform 또는 transform: translateZ(0) — GPU 레이어를 강제하면 서브픽셀 계산이 더 정확해진다.
  • 짝수 크기 보장 — 컨테이너의 width와 height를 뷰포트 단위로 지정하면 보통 정수가 아니므로, round() 같은 CSS 함수로 보정하거나 받아들이는 선택을 한다.
  • flex 정렬로 대체 — 서브픽셀 문제가 심한 경우, 부모를 flex로 만들고 margin: auto로 자식을 중앙에 놓는 방식이 더 안전하다.

이번 코드 뷰어에서는 width: 100vh, height: 100vw로 뷰포트 크기를 그대로 사용하기 때문에 대부분의 기기에서 서브픽셀 이슈가 눈에 띄지 않는다. 하지만 만약 텍스트가 흐릿하게 보인다면, translateZ(0)을 추가하는 것만으로 해결될 가능성이 높다.

2. 닫기 버튼을 어디에 놓든 누군가는 불편하다

처음에는 일반적인 모달처럼 닫기 버튼(X)을 우상단에 배치했다. 회전된 상태에서 이 위치는 실제 폰의 우측 상단이 된다. 한 손으로 폰을 잡고 엄지로 닿기 어려운 곳이다.

그래서 바를 하단으로 옮겼다. 회전된 화면의 하단은 실제 폰의 좌측 끝이다. 하지만 이것도 오른손잡이에게만 편한 위치였다. 결국 닫기 버튼을 어디에 놓든, 사람마다 폰을 쥐는 손이 다르기 때문에 누군가는 불편하다.

해결책은 닫기 버튼 자체를 없애는 것이었다. 대신 화면 아무 곳이나 1.5초간 길게 누르면 종료되도록 했다. 왼손이든 오른손이든, 엄지가 화면의 어디에 있든 동작한다. 하단 바에는 "화면을 1.5초간 누르면 종료"라는 안내 문구만 넣었다.

tsx
const LONG_PRESS_MS = 1500;

function FullscreenView({ onClose, children }) {
  const timerRef = useRef(null);
  const [pressing, setPressing] = useState(false);

  const handlePressStart = () => {
    setPressing(true);
    timerRef.current = setTimeout(() => {
      onClose();
    }, LONG_PRESS_MS

롱프레스의 장점은 실수로 닫히지 않는다는 것이다. 코드를 스크롤하다가 손가락이 미끄러져도 탭이지 길게 누르기가 아니므로 전체보기가 유지된다. 의도적으로 "이제 나가야지"라고 생각하고 꾹 누를 때만 종료된다.


방법별 비교 정리

방법iOS SafariAndroid Chrome전체화면 필요사용자 행동 필요
Screen Orientation API미지원전체화면에서만필요불필요
PWA manifest앱 설치 시만앱 설치 시만-

CSS transform rotate만이 모든 브라우저에서 동작하면서 사용자에게 추가 행동을 요구하지 않는 유일한 방법이다. 네이티브 회전이 아니라 시각적 트릭이라는 한계가 있지만, 코드 블록 전체보기라는 용도에는 충분하다.

핵심 교훈

웹에서 "당연히 될 것 같은" 기능이 플랫폼 제약에 막히면, API 수준에서 해결하려고 하기보다 CSS로 시각적으로 우회하는 게 더 현실적인 경우가 있다. screen.orientation.lock()은 스펙이 명확하지만 브라우저 지원이 엉망이고, CSS transform은 해킹에 가깝지만 어디서든 동작한다.

그리고 기능 구현이 끝난 뒤에도 물리적 맥락을 생각해야 한다. 화면을 돌리면 닫기 버튼의 위치도 함께 돌아간다. "엄지가 닿는가?"라는 질문은 코드를 작성할 때는 떠오르지 않고, 실제로 폰을 들고 써봐야 보이는 문제다. 결국 닫기 버튼의 위치를 고민하다가 버튼 자체를 없애고 롱프레스로 바꿨는데, 때로는 UI 요소를 더 잘 배치하는 것보다 아예 없애는 게 더 나은 답이다.

포스트 목록

/SEOJing
파일 11개, 폴더 1개
Cloudflare Workers에서 fs 모듈이 안 되는 이유와 해결법모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리Context로 퀴즈 컴포넌트를 만들다 막혀서 React.Children을 공부하게 된 이야기localStorage 읽기에서 하이드레이션 에러가 터지는 이유 useSyncExternalStore로 해결useEffect cleanup과 의존성 배열 — 실전 버그 사례로 이해하는 생애주기vinext + GitHub Actions로 Cloudflare Workers 배포하기vinext 오픈소스 기여기: 한국어 slug가 RSC에서 이슈를 일으킨 이유RSC 환경에서 WebAssembly가 차단되는 이유 — Shiki에서 rehype-prism-plus로vinext는 왜 빠를까? — SSR, Vite, Edge, 그리고 Web Vitals까지
)
;
};
const handlePressEnd = () => {
setPressing(false);
clearTimeout(timerRef.current);
};
return (
<div
onTouchStart={handlePressStart}
onTouchEnd={handlePressEnd}
onTouchCancel={handlePressEnd}
>
<pre>{children}</pre>
<div>
<span>{language}</span>
<span>
{pressing ? "놓지 마세요..." : "화면을 1.5초간 누르면 종료"}
</span>
</div>
</div>
);
}
앱 설치
회전 유도 UI지원지원불필요폰 물리 회전
CSS writing-mode지원지원불필요불필요
CSS transform rotate지원지원불필요불필요