All case studies
CurrentBadabomKorea Institute of Marine Science & Technology Promotion (KIMST)Jan 2026 - Present

LEGACY UI / ARCHITECTURE

Wrapping 106 Admin Screens in a Console Shell Without Touching Them

The admin menu had to become a separate-window console, but every sidebar click re-rendered the topbar and reloaded a pile of heavy assets. Rewriting ~100 /adm screens was not an option. So I converted the SiteMesh decorator in place into a child decorator — every existing mapping followed automatically — kept the shell resident in the parent, and swapped only the iframe body.

Reused
106 mappings

0 remaps · in-place conversion

Screens rewritten
0

/adm bodies untouched

Full sweep
225 screens

every /adm view.do, live

Menus migrated
35 / 35

entire admin menu tree

SiteMesh 3JSPiframeSpring MVCAuthenticInterceptorSessionTimereGovFrame

Problem

Every menu click re-rendered the topbar along with everything else

The request from the client contact was short: open the admin menu in a separate window, and handle session timeout and window closing while you are at it. So the first version put a console — sidebar plus topbar — inside that window, wrapping the existing /adm pages in a single SiteMesh decorator. It got the console look with no screen rewrites.

Using it exposed the flaw. Because the decorator wrapped every page in full, each sidebar click re-rendered the topbar and sidebar and reloaded the couple dozen head assets. Admin screens carry grids, charts, maps, and a large-file uploader, so navigation was noticeably heavy — and since the sidebar was rebuilt each time, its accordion state did not survive either.

The fix was obvious: load the topbar and sidebar once, keep them resident, and swap only the body. The catch is that doing that normally means rewriting your screens. There were 183 content JSPs under /adm, and 166 of them were full pages with their own <html>. Rewriting was never on the table.

  • A menu click meant a full page load

    With the decorator wrapping every page, the topbar, sidebar, and 29 head assets reloaded on each navigation. On screens carrying grids, charts, and maps that lands as felt latency.

  • 183 JSPs would have needed rewriting

    Of the 183 /adm content JSPs, 166 were structured as full pages. Reworking them to emit body-only content was a project in itself.

  • Remapping 106 entries makes one missed line an incident

    Moving sitemesh mappings to a new decorator one by one means a single omission renders that page as a shell inside a shell — doubled topbar, two session timers, heavy JS loaded twice.

  • The risk sat at the boundaries, not in the screens

    Adding another iframe layer endangered a handful of frame boundaries, not the 106 screens: the vendor uploader, login/permission bounces, authorization registration for the new URL, and re-entry from the main GNB.

Approach

Convert in place instead of remapping, so the mappings follow on their own

The pivotal decision was this: do not move the 106 mappings to a new child decorator — convert the existing decorator in place. Strip out the topbar, sidebar, and session scripts, leave body rendering, and all 106 mappings now point at the child by definition. The category of "mapping we forgot" stops existing. The only addition was one line for the shell URL.

After that it became a question of what lives where. The parent shell owns the topbar, sidebar, and session watchdog; the child owns the head assets and the body. One pleasant surprise: session monitoring got more robust, because the parent never reloads on iframe navigation, so its timer is never interrupted. The flip side is that the child must never duplicate window.close() or the timers — inside an iframe window.close() is a no-op, so a copy there means logout silently fails to close the parent popup.

Before starting I defined four blocking conditions: if any of them failed in a proof of concept, the work would not proceed. Does the vendor large-file uploader still work inside a frame? Do all three login/permission bounce paths escape the iframe to the top window? Does skipping authorization registration for the new shell URL leave it fail-open? Does clicking the admin menu in the main GNB replace the entire popup? A full sweep for frame-hostile code (top/parent/window.name) found zero hits inside the /adm tree itself — all the risk really was at the edges.

BEFORE

full reload per click

Separate window (admWin)

window.open

full page navigation

adminConsole.jsp

one decorator

  • topbar + sidebar
  • 29 head assets
  • session timer
sitemesh:write body

/adm page body

183 JSPs

  • menu click = re-render everything

AFTER

shell resident · body swaps

adminShell.jsp (parent)

once per popup lifetime

  • topbar + sidebar
  • session watchdog (uninterrupted)
  • assets = jQuery only
sidebar click

iframe#adcFrame

target="adcFrame"

  • only the body changes
in-place conversion

adminConsole.jsp (child)

existing 106 mappings point here

  • head assets + body
  • topbar/sidebar removed
  • exposes adcMenuIds

Process

How it was built

  1. 01

    Convert the decorator rather than move the mappings

    The most dangerous option was remapping 106 entries to a new decorator. Miss one line and you get a shell inside a shell — and that page looks basically fine, just with a doubled topbar, which is exactly the kind of thing review misses. Converting the existing decorator instead meant not a single mapping changed; the only addition was one exclude line for the shell URL.

    sitemesh3.xml
    xml
    <!-- sitemesh3.xml — the 106 /adm mappings are never touched -->
    <!-- adminConsole.jsp was converted *in place* into the child decorator,
    so every existing mapping now points at the child. Remaps: 0 lines. -->
    <mapping path="/adm/techTrade/**" decorator="/WEB-INF/jsp/decorators/adminConsole.jsp"/>
    <mapping path="/adm/techCert/**" decorator="/WEB-INF/jsp/decorators/adminConsole.jsp"/>
    <mapping path="/adm/stupIvst/**" decorator="/WEB-INF/jsp/decorators/adminConsole.jsp"/>
    <!-- … ~100 more lines, unchanged … -->
     
    <!-- The only addition: the shell URL. The shell must not be decorated. -->
    <mapping path="/adm/console/**" exclude="true"/>
  2. 02

    The parent shell — anchor targets and the height chain

    Two things in the shell are easy to get wrong. First, every sidebar anchor needs target="adcFrame"; miss one and that link navigates the whole popup, taking the shell with it. Second, the iframe collapses to its 150px default unless the 100vh → flex:1 + min-height:0 → height:100% chain holds end to end. Parent and child each keep exactly one scroller, which also removed the old double scrollbar.

    adminShell.jsp
    html
    <%-- adminShell.jsp — the parent shell, loaded once per popup lifetime --%>
    <div class="adc-shell">
    <header class="adc-topbar">…system name · user · logout · close window…</header>
    <div class="adc-layout">
    <nav class="adc-side">
    <%-- Every anchor needs target="adcFrame". Miss one and that link
    navigates the whole popup, and the shell disappears. --%>
    <a class="adc-home" href="<c:url value='/adm/dashboard/view.do'/>" target="adcFrame">
    Admin dashboard
    </a>
    <c:forEach var="it" items="${items}">
    <a href="${itUrl}" target="adcFrame" data-mid="${it.webMenuId}">${it.webMenuNm}</a>
    </c:forEach>
    </nav>
    <iframe id="adcFrame" name="adcFrame" title="Admin content"></iframe>
    </div>
    </div>
     
    <style>
    /* Guard against the iframe collapsing to its 150px default — break this
    chain anywhere and the content pane goes flat. */
    .adc-shell { height: 100vh; display: flex; flex-direction: column; }
    .adc-layout { flex: 1 1 auto; min-height: 0; display: flex; }
    #adcFrame { flex: 1 1 auto; min-width: 0; width: 100%; height: 100%; border: 0; }
    </style>
  3. 03

    Syncing the active menu — and why server-side EL cannot

    Previously the server computed the active menu from the current URL. Under the shell that breaks: the parent URL is pinned at /adm/console, so it cannot know what the child is showing. Instead the child decorator exposes the menu IDs it already computed as window.adcMenuIds, and the parent reads them on every iframe load. Since load fires unconditionally, module tabs, post-save redirects, and browser back all re-sync for free. The breadcrumb is assembled purely from DOM text nodes, so there is no injection surface.

    adminShell.jsp
    javascript
    // Active-menu highlight — on every iframe load, read the menu IDs the child already computed.
    // Server-side EL cannot do this: the parent URL is pinned at /adm/console no matter what the
    // child shows. Because load fires every time, navigation that bypasses the sidebar (module tabs,
    // post-save redirects, browser back) re-syncs automatically.
    var frame = document.getElementById('adcFrame');
     
    frame.addEventListener('load', function () {
    var ids = null;
    try { ids = frame.contentWindow.adcMenuIds || null; }
    catch (e) { /* cross-origin — the frame navigated away to login */ }
     
    // Record the current path so F5 can restore it (same-origin only)
    try {
    var loc = frame.contentWindow.location;
    if (loc && /^\/adm\//.test(loc.pathname)) {
    sessionStorage.setItem('adcLastPath', loc.pathname + (loc.search || ''));
    }
    // Permission-denied fallback — when the interceptor bounces a request outside /adm,
    // the whole public site used to load inside the iframe. Return to the dashboard
    // instead, with a 30s loop guard so a denied account cannot bounce forever.
    if (loc && loc.pathname && !/^\/adm\//.test(loc.pathname)
    && loc.pathname.indexOf('/login') !== 0) {
    var last = parseInt(sessionStorage.getItem('adcDenyTs') || '0', 10);
    if (new Date().getTime() - last > 30000) {
    sessionStorage.setItem('adcDenyTs', String(new Date().getTime()));
    frame.src = contextPath + '/adm/dashboard/view.do';
    alert('You do not have access to that menu.\nReturning to the admin dashboard.');
    return;
    }
    }
    } catch (e) {}
     
    highlightSidebar(ids && ids.lv2, ids && ids.lv3);
    });
  4. 04

    Frame boundaries — three login and permission bounce paths

    This was the hardest part. Authentication failure takes three different code paths, each navigating differently: unauthenticated submits a form, authenticated-but- unauthorized uses location.href, and concurrent-session eviction in production is a plain 302. Inside an iframe all three wedge a login screen into the content pane. They were handled with target="_top" on the form, (window.top !== self ? window.top : window).location, and an escalation branch respectively. What mattered most is that these files are shared and load for every user, admin or not. So every change was wrapped in a guard that is a no-op outside a frame, keeping non-framed behaviour byte-for-byte identical.

  5. 05

    The recovery path when a screen opens at top level

    After a session expires and the user logs back in — or arrives via a bookmark or a pasted URL — the console body renders full-browser with no shell. Rather than stealing that window into the console, it goes to the main site carrying a restore hint, and the main header revives the console popup.

    Two incidents came out of this. The first version bounced without checking login state, so it fired before the interceptor's auto-submitting login form could run — deleting the login flow entirely. The second bounced the access-denied screen too, producing a "re-enter console → denied again → bounce" loop. Both are now explicit exceptions.

    adminConsole.jsp
    javascript
    // Child decorator — the recovery path for when a console screen opens as the TOP window.
    // After a session-expiry re-login, or from a bookmark or a pasted URL, the bare content
    // renders full-browser with no shell. Do not steal that window into the console (the tab
    // the user logged in from stays on the main site); send it to main with a restore hint.
    if (window.self === window.top && location.pathname.indexOf('/adm/accessDenied') === -1) {
    fetch(contextPath + '/auth/check.do', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
    .then(function (r) { return r.text(); })
    .then(function (t) {
    // Not logged in? Do nothing. Bouncing *before* the interceptor's auto-submitting
    // login form fires would delete the login flow entirely (observed in testing).
    if (t.indexOf('true') === -1) return;
    var adcPath = location.pathname;
    if (contextPath && adcPath.indexOf(contextPath) === 0) {
    adcPath = adcPath.slice(contextPath.length);
    }
    location.replace(contextPath + '/main/view.do?adcReturn='
    + encodeURIComponent(adcPath + location.search));
    })
    .catch(function () {});
    }
    // Note: the access-denied screen renders in place even at top level — bouncing it to main
    // made adcReturn restore a "re-enter console → denied again" loop (caught in a pre-deploy audit).
  6. 06

    Sweeping all 225 screens

    A structural change like this cannot end at "seems to work." If one screen escapes the console, the user just sees that one menu as broken. So I walked all 225 view.do screens under /adm in a real browser and measured whether each stayed inside the console. That turned up a missing sitemesh mapping line, a statistics screen whose scroll tracking never fired, unstyled action buttons, and more — none of which review would have caught. The sweep was split into ranges and run as parallel Claude Code agents.

Outcome

Results and takeaways

  • The architecture changed with zero screens rewritten

    Not one of the 183 JSP bodies was edited. Converting the decorator in place carried all 106 mappings along, and the genuinely new code was one shell plus one mapping line. On legacy systems, most of the design work turned out to be deciding what you can avoid touching, not what you get to build.

  • Session monitoring got more robust, not less

    The parent shell never reloads on iframe navigation, so the inactivity timer runs uninterrupted — previously it restarted on every page change. The absolute condition was never duplicating window.close() or the timers into the child.

  • The risk lived at the boundaries, not in the screen count

    The pre-work judgement was that the danger sat in four boundary seams rather than the 106 reused pages, and every incident did in fact happen there. Sweeping the codebase for frame-hostile code first, and confirming the /adm tree was clean, is what justified proceeding at all.

  • A full sweep caught defects review structurally cannot

    Walking 225 screens in a real browser surfaced a single missing mapping line, scroll tracking that silently never fired on one screen, and lost button styling — none visible from reading code. For structural changes, automating the exhaustive walk was simply the cheapest option.

  • A separate window is not a security control — and I said so

    The request specified a separate window, so that is what shipped. But research showed the government standard is an integrated left-LNB console, and a popup window buys exactly zero security. If security is the actual goal, host separation is the answer. I honoured the request and documented that fact alongside it.

MORE

Explore other cases

Badabom

AUTH / SSO

Building an SSO Provider for Partner Sites

Implemented an SSO Provider so external partner sites (e.g., OTT) could sign in with Badabom accounts. Single-use UUID tokens stored in the database support multiple WAS nodes, and CI (Connecting Information) auto-maps accounts across both sides.

View detail

Badabom

DEVOPS / OBSERVABILITY

SSE + Cross-WAS Real-Time Log Viewer

The WAS lived in the Daejeon IDC, but network-segregation policy meant only Busan-office PCs could reach it — so pulling a log effectively meant flying to Busan. I built an SSE-based viewer inside the admin web and added a cross-WAS relay so logs from both WAS nodes stream into a single screen.

View detail

Badabom

LEGACY MIGRATION

Migrating the OTT Technology-Trade System into Badabom

Moved an Oracle + MyBatis technology-trade platform (OTT) onto PostgreSQL + iBATIS. Rewrote 87 URLs, 34 JSPs, 80+ SQL queries, and 14 tables.

View detail

Badabom

FRONTEND / COMPONENT

Replacing a Commercial Data Grid With an In-House Component

Every admin list screen hand-rolled its own render loop and paging math, and the rest were locked to a commercial grid licensed per server. I built an in-house grid that absorbs the existing response contracts as-is, then moved ~80 screens onto it without changing a single line of server code.

View detail

GAIS — Government Advertising Integrated Support System

CI/CD

Automating the Build and Deploy Pipeline

Replaced a fully manual build-and-deploy workflow with a Jenkins + GitLab Webhook pipeline, cutting deploy time from 15–20 min down to around 4 min.

View detail

GAIS — Government Advertising Integrated Support System

INFRA / SESSION

Redis-Backed Session Clustering

JEUS Standard doesn't support native session clustering, so I put Redis in front as an external session store. That unlocked rolling restarts across WAS nodes.

View detail

GAIS — Government Advertising Integrated Support System

SECURITY / NETWORK

Applying TLS 1.3 via an Nginx Reverse Proxy

Touching the shared WebtoB SSL felt risky, so I put Nginx in front and terminated TLS there instead. Existing services kept running untouched while TLS 1.3 was rolled out.

View detail

Freelance · Side Projects

CLIENT WORK / WEB

Pitched and Built a Postpartum Care Center Site Renewal

My wife had stayed at a postpartum care center whose website felt dated, so I mocked up a UI sample and pitched it myself. I built an Astro static site with a 192-frame scroll animation, Kakao Map, and SEO — then shipped it to their production domain.

View detail

Freelance · Side Projects

SIDE PROJECT / AI

Family-Driven Baby Naming with AI + Tournament-Style Voting

Existing naming services are designed for solo use, so I built a way for the whole family to join in. GPT-4o suggests names aligned with Saju (birth-chart) and Ohaeng (Five-Element) rules, and the family votes tournament-style to pick the final name.

View detail
Admin Console Shell Migration | Case Study