
본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기.
SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
블로그에 코드 블록이 많다. PC에서는 문제없지만, 모바일 세로 화면에서는 가로 스크롤 없이 코드를 읽기가 어렵다. 320px - 390px 폭에 들어가는 코드 라인은 40자에서 50자 정도인데, 실제 코드는 80~120자인 경우가 많다.
해결하고 싶은 것은 단순하다. 버튼 하나로 코드 블록을 가로 방향으로 넓게 보여주기. 네이티브 앱이라면 화면 회전을 강제하면 끝이지만, 웹에서는 그게 쉽지 않다.
가장 먼저 떠오르는 방법이다. screen.orientation.lock('landscape')를 호출하면
화면이 가로로 고정된다.
// 가로 모드 강제
async function forceLandscape() {
try {
await screen.orientation.lock("landscape");
} catch (e) {
console.error("회전 잠금 실패:", e);
}
}
문제는 제약이 너무 많다는 것이다.
screen.orientation.lock이
존재하지 않는다.Fullscreen API) 상태에서만
동작한다.즉, 전체화면 진입 → 가로 회전 잠금 → 코드 보기 → 잠금 해제 → 전체화면 해제라는 복잡한 흐름이 되고, 그마저도 iOS 사용자 절반을 버려야 한다. 현실적이지 않다.
// Android에서만 동작하는 전체화면 + 회전 조합
async function enterLandscapeFullscreen(element) {
await element.requestFullscreen();
await screen.orientation.lock("landscape");
}
// iOS에서는 requestFullscreen도 제한적
// Safari는 video 요소에서만 전체화면을 허용한다
manifest.json에서 화면 방향을 지정할 수 있다.{
"name": "My Blog",
"display": "standalone",
"orientation": "landscape"
}
이 방법은 앱으로 설치된 경우에만 작동한다. 일반 브라우저에서
접속하면 manifest의 orientation은 완전히 무시된다. 블로그를 PWA로 설치하는
사용자는 거의 없으므로, 이 방법도 탈락이다.
직접 회전하지 않고, 사용자에게 "기기를 가로로 돌려주세요"라는 안내를 보여주는 방식이다. 게임이나 영상 앱에서 흔히 볼 수 있다.
/* 세로 모드일 때만 회전 안내 표시 */
.rotate-prompt {
display: none;
}
@media (orientation: portrait) {
.rotate-prompt {
display: flex;
/* 화면 중앙에 "기기를 돌려주세요" 아이콘 */
}
}
구현은 간단하지만, 사용자 경험이 나쁘다. 코드 블록 하나를 보기 위해 물리적으로 폰을 돌려야 한다. 회전 잠금을 걸어둔 사용자는 설정까지 바꿔야 한다. "버튼 하나로 넓게 보기"라는 원래 목표와 거리가 멀다.
텍스트 방향 자체를 바꾸는 CSS 속성이다. writing-mode: vertical-rl을 쓰면
텍스트가 세로로 흐른다.
.rotated-text {
writing-mode: vertical-rl;
transform: rotate(180deg);
}
코드 블록에는 완전히 부적합하다. 코드는 가로로 읽어야 하는데,
writing-mode는 글자 하나하나의 배치 방향을 바꾸기 때문에 코드의 들여쓰기와
정렬이 전부 깨진다. 표(table)나 세로 텍스트 레이아웃에서는 유용하지만, 코드
뷰어에 쓸 수 있는 방법은 아니다.
남은 방법은 하나다.
화면은 세로 그대로 두고, UI만 90도 돌리는 것.
transform: rotate(90deg)로 시각적 회전을 만들고, viewport 단위(100vh,
100vw)로 너비와 높이를 교체한다.
.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도 회전결과적으로 사용자가 보는 화면은 가로 모드와 동일하다. 폰을 돌리지 않아도 된다.
처음에는 flex items-center justify-center로 회전 컨테이너를 화면 중앙에
놓았다. 그런데 이렇게 하면 코드가 화면 한가운데서 시작된다.
가로로 돌려서 넓이를 확보한 의미가 없어진다.
두 접근의 차이를 이해하려면, flex의 정렬 속성과 translate가 작동하는
레이어가 다르다는 것을 알아야 한다.
items-center justify-center는
flex 컨테이너가 자식의 위치를 결정한다. 컨테이너 안의 모든
자식이 수직·수평 모두 중앙으로 밀린다. 그래서 코드 블록도 화면 중앙에 놓이고,
스크롤 시작점도 가운데가 된다. flex-1을 줘도 자식이 남는 공간을 채우긴
하지만, 남는 공간 자체가 양쪽으로 균등하게 분배되기 때문에 결과는 같다.
/* 자식의 콘텐츠까지 중앙 정렬됨 */
.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을 쓰면 코드 영역이 자연스럽게 상단부터 채워진다.
/* 컨테이너만 중앙 배치, 내부는 상단부터 시작 */
.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)을 추가하는 것만으로 해결될
가능성이 높다.
처음에는 일반적인 모달처럼 닫기 버튼(X)을 우상단에 배치했다. 회전된 상태에서 이 위치는 실제 폰의 우측 상단이 된다. 한 손으로 폰을 잡고 엄지로 닿기 어려운 곳이다.
그래서 바를 하단으로 옮겼다. 회전된 화면의 하단은 실제 폰의 좌측 끝이다. 하지만 이것도 오른손잡이에게만 편한 위치였다. 결국 닫기 버튼을 어디에 놓든, 사람마다 폰을 쥐는 손이 다르기 때문에 누군가는 불편하다.
해결책은 닫기 버튼 자체를 없애는 것이었다. 대신 화면 아무 곳이나 1.5초간 길게 누르면 종료되도록 했다. 왼손이든 오른손이든, 엄지가 화면의 어디에 있든 동작한다. 하단 바에는 "화면을 1.5초간 누르면 종료"라는 안내 문구만 넣었다.
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);
};
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>
);
}
롱프레스의 장점은 실수로 닫히지 않는다는 것이다. 코드를 스크롤하다가 손가락이 미끄러져도 탭이지 길게 누르기가 아니므로 전체보기가 유지된다. 의도적으로 "이제 나가야지"라고 생각하고 꾹 누를 때만 종료된다.
| 방법 | iOS Safari | Android Chrome | 전체화면 필요 | 사용자 행동 필요 |
|---|---|---|---|---|
| Screen Orientation API | 미지원 | 전체화면에서만 | 필요 | 불필요 |
| PWA manifest | 앱 설치 시만 | 앱 설치 시만 | - | 앱 설치 |
| 회전 유도 UI | 지원 | 지원 | 불필요 | 폰 물리 회전 |
| CSS writing-mode | 지원 | 지원 | 불필요 | 불필요 |
| CSS transform rotate | 지원 | 지원 | 불필요 | 불필요 |
CSS transform rotate만이 모든 브라우저에서 동작하면서
사용자에게 추가 행동을 요구하지 않는 유일한 방법이다.
네이티브 회전이 아니라 시각적 트릭이라는 한계가 있지만, 코드 블록 전체보기라는
용도에는 충분하다.
웹에서 "당연히 될 것 같은" 기능이 플랫폼 제약에 막히면, API 수준에서
해결하려고 하기보다 CSS로 시각적으로 우회하는 게 더 현실적인
경우가 있다. screen.orientation.lock()은 스펙이 명확하지만 브라우저 지원이
엉망이고, CSS transform은 해킹에 가깝지만 어디서든 동작한다.
그리고 기능 구현이 끝난 뒤에도 물리적 맥락을 생각해야 한다. 화면을 돌리면 닫기 버튼의 위치도 함께 돌아간다. "엄지가 닿는가?"라는 질문은 코드를 작성할 때는 떠오르지 않고, 실제로 폰을 들고 써봐야 보이는 문제다. 결국 닫기 버튼의 위치를 고민하다가 버튼 자체를 없애고 롱프레스로 바꿨는데, 때로는 UI 요소를 더 잘 배치하는 것보다 아예 없애는 게 더 나은 답이다.
Post Q&A
모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.
SEOJing 글을 소셜용 영상으로 따로 소비시키는 게 아니라, 포스트 상단 요약과 블로그 유입 장치로 연결하기 위해 summaryVideo frontmatter와 Supertonic3 기반 요약 쇼츠 파이프라인을 붙인 과정을 정리했다.

SEOJing 블로그에 대표 이미지를 자동으로 붙이는 실험을 실제로 돌려봤다. 검색 기반 cover 삽입과 Codex CLI 기반 정적 SVG 생성이 같은 frontmatter 경로로 연결됐다.

SEOJing 포스트 목록을 파일 탐색기처럼만 두지 않고, 최신 글부터 실제 사진 기반 리소그래프 배경을 붙이는 실험을 정리합니다. 이미지는 배경만 만들고, 제목과 아이콘은 블로그 UI가 맡는 쪽으로 방향을 바꿨습니다.
SEO Jing 개발 열두째 날. code-block 테스트 14개 추가로 커버리지 대폭 개선, 모바일 프레젠테이션에서 FullscreenView 방향 전환 문제와 스크롤 멈춤 버그 수정.
useEffect 의존성 배열에 불필요한 값이 포함되면 cleanup과 재실행이 뒤엉켜 DOM 상태가 꼬일 수 있다. 프레젠테이션 모드에서 발생한 모바일 스크롤 고착 버그를 통해 원인과 해결 패턴을 정리한다.
SEO Jing 개발 열한째 날. PC 프레젠테이션 확대/축소 컨트롤 추가, 모바일 orientation 판단 로직 개선, FullscreenView를 독립 컴포넌트로 분리 및 PC 대응.
한국어 MDX 블로그를 만들다 vinext 프레임워크의 ByteString 버그를 발견하고, 이슈를 작성하고, PR을 올리기까지의 과정
vinext가 빠른 이유를 이해하기 위해, SSR부터 Hydration, 빌드 도구, Edge Runtime, Web Vitals, RSC, CDN 캐싱, ISR, PPR까지 웹 렌더링 성능의 전체 그림을 정리한다
모바일 Safari에서 100vh가 화면을 넘치는 이유, vh/svh/lvh/dvh의 차이, JavaScript에서 실제 뷰포트를 구하는 방법, 그리고 전체화면 UI를 만들 때 알아야 할 CSS zoom과 모바일 판정 패턴까지 정리한다.
SEO Jing 개발 열째 날. 프레젠테이션 모드의 모바일 UX 문제들을 전면 수정. 퀴즈·코드블록·이미지·포스트목록 처리 개선, 롱프레스 UX 및 하단 바 레이아웃 안정화. 모바일 뷰포트·리스트 분할 문제 수정, 채움 비율 보수적으로 조정, 포스트 탐색기 자연 정렬 적용.
SEO Jing 개발 아홉째 날. 프레젠테이션 기능 추가, 퀴즈 구조 변경, 모바일 반응형, 코드블럭 사용성, 테스팅 도입.
모바일 웹에서 코드 블록을 가로로 넓게 보여주고 싶었다. screen.orientation.lock()은 iOS에서 안 되고, PWA manifest는 브라우저에서 무시된다. 결국 CSS transform으로 가짜 회전을 만들었고, 그 과정에서 엄지 접근성까지 고민하게 됐다.
MDX 파일을 수정하지 않고, 렌더된 DOM을 h2 기준으로 자르고 화면 높이에 맞춰 자동 페이지네이션하는 프레젠테이션 모드를 만들었다. 리스트 높이 측정이 왜 틀리는지 디버깅한 과정과, ul/ol을 li 단위로 분할하는 해결책을 정리한다.
SEO Jing 개발 여덟째 날. 아티클 퀴즈 디자인시스템 구현과 스터디 대면 자료 작성.
MDX 블로그에 퀴즈 컴포넌트를 만들면서, Context 기반 Compound Component로 시작했다가 index 문제에 막혀 React.Children API를 채택하게 된 과정을 정리한다.
SEO Jing 개발 일곱째 날. Front Matter CMS 설치와 관련 게시물 이동 탐색기 구현.
SEO Jing 개발 여섯째 날. 데스크탑 비율 수정과 씨랩 스터디 사전 진단 자료 작성.
SEO Jing 개발 다섯째 날. shiki를 rehype-prism-plus로 교체하고, gray-matter 직접 구현, MDX 모듈화, 페이지 내 검색, 테이블 디자인시스템까지.
SEO Jing 개발 넷째 날. lint, codecov, Cloudflare 배포, fs 런타임 이슈.
배포 후 블로그 포스트가 404를 반환하던 문제부터, gray-matter eval 차단, next-mdx-remote eval 차단까지 — 세 겹으로 터진 이슈를 하나씩 해결한 기록
localStorage를 읽는 컴포넌트에서 하이드레이션 불일치가 발생하는 원인과, useState+useEffect가 아닌 useSyncExternalStore가 정답인 이유를 정리한다.
vinext 프로젝트를 GitHub Actions로 Cloudflare Workers에 자동 배포하는 방법과 실제 겪은 트러블슈팅 기록
코드 하이라이팅에 Shiki를 쓰면 왜 RSC에서 WebAssembly.instantiate() 에러가 터지는지, 그리고 빌드 타임 하이라이팅으로 어떻게 해결했는지 정리한다.
CLI 코드 리뷰에서 받은 피드백과 전체 코드 수정 계획을 정리했다.
localStorage만으로 글 읽기 추적, 스크롤 진행률, 댓글 감지를 구현한 과정을 정리했다.
블로그 디테일 페이지에서 MDX를 렌더링하기 위해 검토한 라이브러리들과 최종 선택 과정.
SEO Jing 개발 셋째 날. MDX 라이브러리 이슈, 반응형, 코드블럭, 댓글, 다크모드, 코드 리뷰.
디자인 시스템 구현 시 파일 구조, 디자인 토큰, 유의 사항을 정리했다.
Tailwind v4 환경에서 폰트가 메인 페이지에서만 적용되지 않던 원인과 Hydration Mismatch 이슈를 정리했다.
MDX 파일의 경로 탐색 로직과 콘텐츠 트리 생성 과정을 정리했다.
MDX 파일 구조를 JSON으로 변환하기 위해 Node.js의 fs 모듈을 배워봤다.
SEO Jing 개발 둘째 날. 디자인 시스템 확장, 블로그 스켈레톤, 폰트 이슈 해결.
SEO Jing 프로젝트의 기술 스택 선정과 전체적인 개발 플로우 정리.
Storybook의 사용법과 디자인 시스템 개발에서의 장점을 정리했다.
MDX의 개념과 블로그에서 활용하는 이유를 정리했다.
SEO Jing 개발 첫째 날. 디자인 컨셉 설정과 디자인 시스템 구축을 시작했다.
SEO Jing을 개발하게 된 이유입니다.
프로젝트가 중단되는 이유에 대한 자기 회고입니다.