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);
테스트 전략
- 컨텍스트 스텁: 테스트용
DefaultRenderContext에roles/flags/tenant를 주입하여 스냅샷 비교 - 권한 케이스: ADMIN/USER/익명별 UI 차이 검증
- 다국어:
ko_KR/en_US로캘 변경 시 날짜/라벨이 적절히 표시되는지 확인 - 테넌트 분기: 브랜드 컬러/리소스 경로가 테넌트마다 분기되는지 확인
성능 팁
- 불변 데이터: 롤/플래그는 세션/캐시에 저장하고 요청마다 재계산을 최소화
- 지연 로딩: 무거운 모델은 필요할 때만
ctx.model()에 세팅 - 로캘 포맷: 포맷터를 재사용(캐시)하여 반복 생성 비용 절감
주의사항
역할/권한 동기화: SecurityContext와 RenderContext의 역할/권한이 어긋나면 보안/UX 이슈가 발생합니다.
컨텍스트 남용: 대용량 컬렉션을 통째로
ctx.model에 저장하지 마세요. 페이지에 필요한 최소 데이터만 전달하세요.플래그 영속: 사용자/테넌트/환경에 따라 플래그 우선순위가 달라질 수 있습니다. 충돌 시 규칙을 명시하세요.
다음으로
- Conditional Rendering — 역할/플래그 기반 분기 패턴
- GenericCrudController — CRUD와 컨텍스트 결합
- Alerts — 권한/검증 결과 피드백
- Validation — 서버 검증과 UI 연동
- Kosmos API — RenderContext 관련 API 요약