Devlog · 003 · 유지보수
003: 만우절 장난 미리 세팅해두기
매년 4월 1일에만 메인 화면의 “효율화”를 “비효율화”로 바꾸는 장난을 미리 자동화했다. 비효율화라는 장난을 효율적으로 실행하려고, 정적 사이트에서 기준시각을 얻고 원래 제목까지 복원하는 흐름을 붙인 기록.
메인 화면 바로가기무엇을 만들었나: 대한민국 표준시 기준 매년 4월 1일에만 메인 제목과 브라우저 탭 제목을 바꾸는 날짜 기반 장난 기능.
왜 만들었나: 당일에 직접 문구를 바꾸고 다음 날 다시 배포하는 수동 절차가 만우절 장난치고는 지나치게 비효율적이어서.
무엇을 배웠나: 제목 한 줄을 바꾸는 기능도 기준시각, 캐시, 공통 스크립트 실행 순서, 원상 복구, 실패 시 동작까지 함께 설계해야 안정적이다.
- 수정 파일
/static/home-index.js- 변경 범위
#homeHeroTitle,document.title- 날짜 기준
- HTTP
Date헤더 →Asia/Seoul - 복구 방식
- 원래 상태 저장 후 분 단위 재확인
- 실패 시 동작
- 서버 시각 실패 시 클라이언트 시각 보조 사용
- 데이터 처리
- 개인정보·파일 업로드·DB·서버 저장 없음
1. 시작점: 비효율화라는 장난을 효율적으로 치기
요즘 자꾸 지루하고 심심해서 좀이 쑤셨다. 해야 하는 건 많은데 하기 싫고, 딴 거 재밌는 걸 하고 싶었다. 장난칠 만한 것, 대충 하다 말 것, 쓸모없는 것을 끼적거리다가 웹사이트 제목에 “교육행정 업무 비효율화 웹페이지”라고 써보면 어떨까 하는 생각이 순간 들었다.
꽤 마음에 들었다. 그런데 4월 1일 당일에 기억해서 문구를 바꾸고, 4월 2일에 다시 원상 복구하는 방식은 만우절 장난치고는 지나치게 비효율적이다. 그래서 비효율화라는 장난을 효율적으로 치는 것 자체를 구현 과제로 잡았다. ㅋㅋㅋㅋ
2. 목표: 4월 1일에 제목 두 군데만 바꾸기
목표는 작고 명확하게 잡았다. 대한민국 표준시간 기준으로 매년 4월 1일에만 문구를 바꾸고, “비효율화”만 빨간색 굵은 글씨로 강조한다. 4월 2일이 되면 저장해둔 원래 상태로 돌아간다.
- 변경함메인 화면의 큰 제목
#homeHeroTitle - 변경함브라우저 탭에 표시되는
document.title - 변경하지 않음설명 문구, 버튼, 링크, 검색 기능, 계산 로직
- 변경하지 않음HTML 구조, 공통
/static/style.css, 개인정보·파일 처리 방식
3. 구현 구조: 정적 사이트에서 기준시각 얻기
이 사이트는 별도의 백엔드 프로그램이나 데이터베이스 없이 정적 파일로 운영한다.
그래서 애플리케이션 서버에 “지금 몇 시냐”고 묻는 API를 새로 만들지 않고,
같은 출처의 정적 파일에 요청을 보내 웹서버나 CDN이 응답 헤더에 넣어준 HTTP Date 값을 읽기로 했다.
- 정적 파일 요청
/static/brand-config.js에 캐시 회피 요청 - 시각 파싱응답의
Date헤더를 타임스탬프로 변환 - 서울 날짜 판정
Asia/Seoul기준 4월 1일인지 확인 - 제목 적용메인 제목과 브라우저 탭 제목만 교체
- 재확인·복구분 단위로 확인해 4월 2일에는 원래 상태 복원
메인 HTML에는 이미 <h1 id="homeHeroTitle">…</h1>이 있다.
따라서 새 HTML 구조를 만들지 않고, 자바스크립트가 해당 요소의 내용과 강조용 스타일만 주입하도록 범위를 제한했다.
const APRIL_FOOLS_CONFIG = Object.freeze({
enabled: true,
timeZone: "Asia/Seoul",
month: 4,
day: 1,
serverTimeProbeUrl: "/static/brand-config.js",
serverTimeTimeoutMs: 3500,
brandUiWaitMs: 4000,
fallbackToClientTime: true,
headlineText: "교육행정 업무 비효율화 웹페이지",
headlineHtml:
'교육행정 업무 <span class="april-fools-highlight">비효율화</span> 웹페이지',
highlightColor: "#c62828"
});
설정값을 한 객체에 모아두면 날짜나 강조 색상을 바꾸려고 여러 함수를 뒤질 필요가 없다.
enabled는 기능 전체를 끄는 비상 스위치이고,
brandUiWaitMs는 공통 브랜드 스크립트가 초기화될 때까지 기다리는 최대 시간이다.
4. 만들면서 부딪힌 점: 시각·캐시·실행 순서
4-1. 사용자 PC 시각만 믿을 수는 없었다
가장 간단한 방법은 new Date()로 사용자 기기 시각을 바로 읽는 것이다.
하지만 사용자가 PC 날짜를 바꾸면 장난을 미리 볼 수 있고, 기기 시각이 틀린 환경에서는 엉뚱한 날에 실행될 수 있다.
그래서 서버·CDN 응답 시각을 우선 사용했다.
4-2. 캐시된 응답과 느린 요청을 방치하면 안 됐다
정적 파일은 캐시되기 쉽다. 요청 URL에 임시 쿼리값을 붙이고 cache: "no-store"를 지정해
오래된 응답 재사용 가능성을 낮췄다. 응답이 3.5초 이상 없으면 AbortController로 중단한다.
먼저 본문을 받지 않는 HEAD를 시도하고, 지원되지 않으면 GET으로 재시도한다.
function addCacheBuster(url) {
const target = new URL(url, window.location.href);
target.searchParams.set("__server_time", String(Date.now()));
return target.toString();
}
async function requestServerDate(method) {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => {
controller.abort();
}, APRIL_FOOLS_CONFIG.serverTimeTimeoutMs);
try {
const response = await fetch(
addCacheBuster(APRIL_FOOLS_CONFIG.serverTimeProbeUrl),
{
method,
cache: "no-store",
credentials: "same-origin",
signal: controller.signal
}
);
if (!response.ok) {
throw new Error("서버 시간 확인 응답 오류: HTTP " + response.status);
}
const rawDate = response.headers.get("Date");
const timestamp = rawDate ? Date.parse(rawDate) : NaN;
if (!Number.isFinite(timestamp)) {
throw new Error("서버 응답에 유효한 Date 헤더가 없습니다.");
}
if (method === "GET" && response.body) {
try {
await response.body.cancel();
} catch (_) {}
}
return timestamp;
} finally {
window.clearTimeout(timeoutId);
}
}
async function getServerTimestamp() {
try {
return await requestServerDate("HEAD");
} catch (headError) {
try {
return await requestServerDate("GET");
} catch (getError) {
const error = new Error("서버 시간을 확인하지 못했습니다.");
error.cause = getError || headError;
throw error;
}
}
}
4-3. 공통 브랜드 스크립트가 제목을 다시 덮어쓸 수 있었다
공통 브랜드 UI는 초기화 과정에서 홈 제목과 브라우저 탭 제목을 정리한다.
만우절 코드가 너무 먼저 실행되면 정상 제목이 뒤늦게 다시 들어올 수 있다.
그래서 최대 4초 동안 window.BrandUI 준비를 기다린 뒤 적용하고,
메인 제목 요소는 MutationObserver로 감시해 활성 상태에서 덮어쓰기가 발생하면 다시 적용한다.
async function waitForBrandUi() {
const startedAt = performance.now();
while (
!window.BrandUI &&
performance.now() - startedAt < APRIL_FOOLS_CONFIG.brandUiWaitMs
) {
await wait(50);
}
}
function setAprilFoolsHeadline(heroTitle) {
if (!heroTitle) return;
if (heroTitle.innerHTML !== APRIL_FOOLS_CONFIG.headlineHtml) {
heroTitle.innerHTML = APRIL_FOOLS_CONFIG.headlineHtml;
}
heroTitle.setAttribute("aria-label", APRIL_FOOLS_CONFIG.headlineText);
}
function startAprilFoolsHeadlineGuard(heroTitle) {
if (!heroTitle || aprilFoolsObserver) return;
aprilFoolsObserver = new MutationObserver(() => {
if (!aprilFoolsActive) return;
setAprilFoolsHeadline(heroTitle);
});
aprilFoolsObserver.observe(heroTitle, {
childList: true,
characterData: true,
subtree: true
});
}
5. 해결 방식과 설계 선택: 정확성과 장난 성공률 사이
5-1. 한 번 받은 시각에 단조 증가 시간을 더하기
서버 시각을 매분 다시 요청할 필요는 없다. 최초 서버 타임스탬프에 performance.now()의 경과 시간을 더해
페이지 안에서 계속 움직이는 기준시계를 만들었다. 페이지가 열린 뒤 사용자가 시스템 시계를 바꿔도
기준시각이 갑자기 튀는 일을 줄일 수 있다.
async function createTrustedClock() {
try {
const serverTimestamp = await getServerTimestamp();
const monotonicBase = performance.now();
return {
source: "server",
now() {
return new Date(
serverTimestamp + (performance.now() - monotonicBase)
);
}
};
} catch (error) {
console.warn("[만우절 제목] 서버 시간 확인 실패", error);
if (!APRIL_FOOLS_CONFIG.fallbackToClientTime) {
return null;
}
const clientTimestamp = Date.now();
const monotonicBase = performance.now();
return {
source: "client-fallback",
now() {
return new Date(
clientTimestamp + (performance.now() - monotonicBase)
);
}
};
}
}
여기서 “trusted”는 보안 인증을 뜻하지 않는다.
사용자 PC 시각만 바로 읽는 것보다 웹서버·CDN 응답 시각을 우선한다는 실용적인 의미다.
HTTP Date는 암호학적으로 검증된 표준시각이 아니다.
5-2. UTC 타임스탬프를 서울 날짜로 판정하기
서버 헤더의 타임스탬프는 절대시각이므로, 날짜 판정은 Intl.DateTimeFormat에
Asia/Seoul을 지정해 월·일을 뽑는다. 시간대 API가 실패하는 예외 환경에서는 UTC에 9시간을 더하는 보조 경로를 둔다.
function getTimeZoneDateParts(date) {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: APRIL_FOOLS_CONFIG.timeZone,
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hourCycle: "h23"
}).formatToParts(date);
return parts.reduce((result, part) => {
if (part.type !== "literal") {
result[part.type] = Number(part.value);
}
return result;
}, {});
} catch (error) {
const koreaTime = new Date(date.getTime() + 9 * 60 * 60 * 1000);
return {
year: koreaTime.getUTCFullYear(),
month: koreaTime.getUTCMonth() + 1,
day: koreaTime.getUTCDate(),
hour: koreaTime.getUTCHours(),
minute: koreaTime.getUTCMinutes(),
second: koreaTime.getUTCSeconds()
};
}
}
function isAprilFoolsDay(date) {
const parts = getTimeZoneDateParts(date);
return (
parts.month === APRIL_FOOLS_CONFIG.month &&
parts.day === APRIL_FOOLS_CONFIG.day
);
}
5-3. 원래 상태를 저장하고, 적용과 복구를 대칭으로 만들기
제목을 바꾸는 것보다 중요한 건 원상 복구다. 처음 정상 제목의 HTML, aria-label,
document.title을 저장한 다음 적용 함수와 복구 함수를 따로 뒀다.
만우절 제목에는 전체 문장을 aria-label로 넣어 강조용 span이 화면 낭독 흐름을 불필요하게 쪼개지 않도록 했다.
function applyAprilFoolsTitle() {
const heroTitle = getHeroTitle();
if (!heroTitle) return;
captureNormalAprilFoolsState();
ensureAprilFoolsStyle();
aprilFoolsActive = true;
document.documentElement.classList.add("is-april-fools");
setAprilFoolsHeadline(heroTitle);
document.title = buildAprilFoolsDocumentTitle();
startAprilFoolsHeadlineGuard(heroTitle);
}
function restoreNormalTitle() {
const wasActive =
aprilFoolsActive ||
document.documentElement.classList.contains("is-april-fools");
aprilFoolsActive = false;
document.documentElement.classList.remove("is-april-fools");
stopAprilFoolsHeadlineGuard();
if (!wasActive || !aprilFoolsNormalState) return;
const heroTitle = getHeroTitle();
if (heroTitle) {
heroTitle.innerHTML = aprilFoolsNormalState.headlineHtml;
if (aprilFoolsNormalState.headlineAriaLabel === null) {
heroTitle.removeAttribute("aria-label");
} else {
heroTitle.setAttribute(
"aria-label",
aprilFoolsNormalState.headlineAriaLabel
);
}
}
document.title = aprilFoolsNormalState.documentTitle;
}
5-4. 페이지를 계속 열어둬도 다음 날 복구하기
페이지 로드 시 한 번만 확인하면 4월 1일 밤부터 페이지를 계속 열어둔 사용자는 4월 2일에도 장난 제목을 볼 수 있다. 그래서 다음 분 경계에 맞춰 다시 확인하고, 날짜가 바뀌면 저장해둔 정상 상태로 복구한다.
function scheduleAprilFoolsCheck(clock) {
if (!clock) return;
const check = () => {
const now = clock.now();
if (isAprilFoolsDay(now)) {
applyAprilFoolsTitle();
} else {
restoreNormalTitle();
}
const remainder = now.getTime() % 60000;
const delay = Math.max(1000, 60000 - remainder + 100);
aprilFoolsTimer = window.setTimeout(check, delay);
};
if (aprilFoolsTimer) {
window.clearTimeout(aprilFoolsTimer);
}
check();
}
async function initAprilFoolsTitle() {
if (!APRIL_FOOLS_CONFIG.enabled) return;
const [clock] = await Promise.all([
createTrustedClock(),
waitForBrandUi()
]);
captureNormalAprilFoolsState();
scheduleAprilFoolsCheck(clock);
}
5-5. 서버 시각 실패 시 장난을 포기할 것인가
fallbackToClientTime: true는 정확성과 장난 성공률 사이의 타협이다.
서버 시각 확인 실패가 웹페이지 전체 초기화를 막게 두지는 않고, 사용자 기기 시각으로 기능을 계속 진행하게 했다.
이번 작업에서는 “잘못 발동할 가능성을 완전히 없애기”보다 “작은 장난 때문에 사이트가 멈추지 않기”를 우선했다.
정확히 말하면: HTTP Date를 정상적으로 읽는 동안에는 사용자 PC 날짜만 4월 1일로 바꿔도 발동하지 않는다.
다만 서버 시각 확인에 실패하고 fallback이 작동한 경우에는 사용자 기기 시각의 영향을 받는다.