RenderContext & Auth

RenderContext는 SSR 렌더링 중 필요한 요청/세션/사용자/피처 플래그/로캘/테넌트 정보를 안전하게 전달하는 컨텍스트입니다. 컨트롤러/인터셉터에서 값을 세팅하고, DSL 컴포넌트는 오직 RenderContext만 의존해 UI를 렌더링하므로 테스트와 보안이 단순해집니다.

핵심 구성

내용 예시 비고
auth.userId 현재 사용자 ID "u-1234" 익명은 null
auth.roles 롤 목록 ["USER","ADMIN"] RBAC
auth.permissions 세분 권한 ["item:read","item:write"] ABAC/스코프
flags 피처 플래그 ["newForm","betaTable"] 실험/롤아웃
tenant 테넌트 식별자 "acme" 멀티테넌시
locale 로캘 "ko_KR" i18n
model.* 렌더링 데이터 doc.title / items 뷰 모델

RenderContext 인터페이스

public interface RenderContext {
  Object model(String key);
  RenderContext model(String key, Object value);
  <T> T get(String key, Class<T> type);
  RenderContext set(String key, Object value);

  // 헬퍼
  default String userId() { return get("auth.userId", String.class); }
  default Set<String> roles() { return get("auth.roles", Set.class); }
  default Set<String> permissions() { return get("auth.permissions", Set.class); }
  default Set<String> flags() { return get("flags", Set.class); }
  default String tenant() { return get("tenant", String.class); }
  default Locale locale() { return get("locale", Locale.class); }

  default boolean hasRole(String role) { return roles() != null && roles().contains(role); }
  default boolean hasPerm(String perm) { return permissions() != null && permissions().contains(perm); }
  default boolean feature(String flag) { return flags() != null && flags().contains(flag); }
}

스프링 인터셉터로 컨텍스트 적재

@Component
public class RenderContextInterceptor implements HandlerInterceptor {

  @Autowired private AuthFacade auth; // SecurityContext 래퍼
  @Autowired private TenantResolver tenantResolver;
  @Autowired private FeatureFlagService flags;

  @Override
  public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
    RenderContext ctx = (RenderContext) req.getAttribute("RC");
    if (ctx == null) {
      ctx = new DefaultRenderContext(); // 스레드/요청 범위
      req.setAttribute("RC", ctx);
    }

    // 인증/권한
    ctx.set("auth.userId", auth.currentUserId().orElse(null));
    ctx.set("auth.roles", auth.currentRoles());
    ctx.set("auth.permissions", auth.currentPermissions());

    // 테넌트/로캘/플래그
    ctx.set("tenant", tenantResolver.resolve(req));
    ctx.set("locale", req.getLocale());
    ctx.set("flags", flags.forUser(auth.currentUserId().orElse("anonymous")));

    return true;
  }
}

컨트롤러에서 모델 세팅

@GetMapping("/docs/kosmos/spring/render-context")
public ResponseEntity<String> page(HttpServletRequest req) {
  RenderContext ctx = (RenderContext) req.getAttribute("RC");
  ctx.model("page.title", "RenderContext & Auth");
  ctx.model("doc.subtitle", "컨텍스트 기반 렌더링");
  HtmlComponent body = viewBody(); // 아래 예시 구성
  return ResponseEntity.ok(new PageTemplate()
    .header(new GlobalHeader())
    .breadcrumbs(new Breadcrumbs(List.of(
      new String[]{"Docs","/docs/kosmos/overview"},
      new String[]{"RenderContext & Auth","/docs/kosmos/spring/render-context"}
    )))
    .sidebar(new DocsSidebar(loadSideTree()))
    .content(body)
    .render(ctx));
}

코드 심화: Auth/Flag로 조건 렌더

// 버튼 영역: ADMIN만 편집, 일반은 읽기 전용
HtmlComponent actions = ctx.hasRole("ADMIN")
  ? El.div().children(
      El.a().css("btn btn-primary").href("/admin/tools").text("관리도구"),
      El.a().css("btn btn-outline-secondary").href("/docs/kosmos/reference").text("Reference")
    )
  : El.div().css("text-muted").text("읽기 전용");

// 신규 폼 플래그
HtmlComponent formArea = ctx.feature("newForm")
  ? El.div().css("card").child(El.div().css("card-body").children(
      El.h2().css("h5").text("신규 폼(v2)"),
      El.p().css("mb-0").text("실험 채널에서만 노출됩니다.")
    ))
  : El.div().css("card").child(El.div().css("card-body").children(
      El.h2().css("h5").text("기존 폼(v1)"),
      El.p().css("mb-0").text("안정 채널")
    ));

테넌트/로캘 응용

// 테넌트별 브랜드 컬러/로고 적용
String tenant = ctx.tenant(); // "acme" / "globex"
HtmlComponent banner = El.div().css("alert " + ("acme".equals(tenant) ? "alert-primary" : "alert-success"))
  .text("Welcome, " + tenant.toUpperCase(Locale.ROOT));

// 로캘 맞춤 날짜/숫자
Locale locale = ctx.locale();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy.MM.dd", locale);
HtmlComponent stamp = El.small().css("text-muted").text("생성일: " + LocalDate.now().format(fmt));

보안/권한 가이드

주제 권장 지양
권한 검사 컨트롤러/서비스에서 @PreAuthorize 또는 명시 가드 UI 숨김만으로 접근 제어
권한 전달 RenderContext표시 판단에만 사용 데이터 접근 허용 판단에 사용
플래그 읽기 전용 토글: UI/라벨링/로그 남김 보안 기능을 플래그로 토글
테넌트 쿼리/리소스 레벨에서 tenant_id로 격리 프론트 라벨만 다르게 표시

통합 예: 템플릿 + 조건 렌더

HtmlComponent content = El.div().children(
  banner,                      // 테넌트 배너
  El.div().css("mb-3").child(stamp),
  El.hr(),
  formArea                     // 플래그 분기
);

var page = new PageTemplate()
  .header(new GlobalHeader())
  .breadcrumbs(new Breadcrumbs(List.of(
    new String[]{"Docs","/docs/kosmos/overview"},
    new String[]{"RenderContext & Auth","/docs/kosmos/spring/render-context"}
  )))
  // 데이터가 있으면 사이드바 표시 같은 조건도 가능
  .sidebar(new DocsSidebar(loadSideTree()))
  .content(content)
  .actions(actions);

테스트 전략

  • 컨텍스트 스텁: 테스트용 DefaultRenderContextroles/flags/tenant를 주입하여 스냅샷 비교
  • 권한 케이스: ADMIN/USER/익명별 UI 차이 검증
  • 다국어: ko_KR/en_US 로캘 변경 시 날짜/라벨이 적절히 표시되는지 확인
  • 테넌트 분기: 브랜드 컬러/리소스 경로가 테넌트마다 분기되는지 확인

성능 팁

  • 불변 데이터: 롤/플래그는 세션/캐시에 저장하고 요청마다 재계산을 최소화
  • 지연 로딩: 무거운 모델은 필요할 때만 ctx.model()에 세팅
  • 로캘 포맷: 포맷터를 재사용(캐시)하여 반복 생성 비용 절감

주의사항

역할/권한 동기화: SecurityContext와 RenderContext의 역할/권한이 어긋나면 보안/UX 이슈가 발생합니다.
컨텍스트 남용: 대용량 컬렉션을 통째로 ctx.model에 저장하지 마세요. 페이지에 필요한 최소 데이터만 전달하세요.
플래그 영속: 사용자/테넌트/환경에 따라 플래그 우선순위가 달라질 수 있습니다. 충돌 시 규칙을 명시하세요.

다음으로