Series Navigation
- Part 1: One Account, Multiple Schools, Multiple Roles
- Part 2: 4-Tier Security to Prevent Data Leaks in Multi-Tenancy
- Part 3: Multi-Campus, Multi-Role JWT Design and ThreadLocal Safety
- Part 4: Comparing 5 Row-Level Security Implementations and Selection Guide ← Current
- Part 5: Legacy System Multi-Tenancy Migration (Coming Soon)
Previously
Part 3 covered JWT token design, ThreadLocal safety, integration testing strategies, and real edge cases.
This article objectively compares CheckUS’s 4-Tier AOP approach with other industry implementation methods. Not claiming “CheckUS’s way is the best!”, but analyzing the pros, cons, and suitable situations for each approach.
5 Row-Level Security Implementation Methods
1. PostgreSQL Native RLS (Database Level)
PostgreSQL supports Row-Level Security natively in the database engine.
-- Create RLS policy
CREATE POLICY tenant_isolation_policy ON students
USING (campus_id = current_setting('app.current_campus_id')::bigint);
ALTER TABLE students ENABLE ROW LEVEL SECURITY;
-- Set session variable from application
SET app.current_campus_id = 1;
-- All subsequent queries automatically filtered
SELECT * FROM students; -- WHERE campus_id = 1 automatically added!
Pros
- ✅ Perfect automation: No application code changes needed
- ✅ DB-level security: SQL Injection attacks also filtered
- ✅ Consistency: Works identically for Native Query and JPA
Cons
- ❌ PostgreSQL only: Cannot use with MySQL, MariaDB, etc.
- ❌ Difficult debugging: Automatic filtering makes understanding query logs hard
- ❌ Performance overhead: Every query requires session variable reference
Suitable When
- Using PostgreSQL and cannot change DB
- Complete automation is top priority
- SQL Injection defense is critical (financial sector, etc.)
- Team has SQL experts who can manage RLS policies
2. Hibernate Global Filter (ORM Level)
Hibernate allows defining filters at the Entity level.
// Define filter on Entity
@Entity
@FilterDef(name = "campusFilter", parameters = @ParamDef(name = "campusId", type = Long.class))
@Filter(name = "campusFilter", condition = "campus_id = :campusId")
public class Student {
@Id private Long id;
@Column(name = "campus_id") private Long campusId;
private String name;
}
// Enable filter in Repository
@Repository
public class StudentRepository {
@PersistenceContext
private EntityManager entityManager;
public List<Student> findAll(Long campusId) {
Session session = entityManager.unwrap(Session.class);
// Enable filter
session.enableFilter("campusFilter").setParameter("campusId", campusId);
return session.createQuery("FROM Student", Student.class).list();
// SELECT * FROM students WHERE campus_id = :campusId automatically generated
}
}
Pros
- ✅ ORM-level automation: Automatically applies to JPQL/Criteria API
- ✅ DB independent: Works with both MySQL and PostgreSQL
- ✅ Explicit control: Control filter activation in code
Cons
- ❌ No Native Query support: Native SQL requires manual filtering
- ❌ Complex session management: Must call
enableFilter()every time - ❌ Incomplete Spring Data JPA integration: Doesn’t auto-apply to
findAll()and other default methods
Suitable When
- Most queries use JPQL/Criteria API
- Low Native Query usage
- Team familiar with Hibernate
3. API Gateway (Infrastructure Level)
Approach where API Gateway intercepts requests to add query parameters or headers.
# Kong API Gateway configuration
services:
- name: checkus-api
routes:
- name: students
paths:
- /students
plugins:
- name: request-transformer
config:
add:
querystring:
- "campusId:$(headers.X-Campus-Id)"
// Backend simply uses query parameter
@GetMapping("/students")
public List<Student> getStudents(@RequestParam Long campusId) {
return studentRepository.findByCampusId(campusId);
}
Pros
- ✅ Supports various backends: Works identically for Java, Python, Node.js
- ✅ Centralized security: All requests go through Gateway
- ✅ Clean backend code: Focus only on business logic
Cons
- ❌ Infrastructure dependency: Entire system stops if Gateway fails
- ❌ Increased deployment complexity: Separate deployment needed for Gateway config changes
- ❌ Difficult internal API handling: What about inter-service communication?
Suitable When
- Microservices architecture
- Already using API Gateway
- Mixed languages/frameworks
4. Database View (DB-Level Abstraction)
Approach creating Views for each campus.
-- View for Gangnam Study Center
CREATE VIEW students_campus_1 AS
SELECT * FROM students WHERE campus_id = 1;
-- View for Bundang Math Academy
CREATE VIEW students_campus_2 AS
SELECT * FROM students WHERE campus_id = 2;
// Application accesses Views
@Query("SELECT * FROM students_campus_1", nativeQuery = true)
List<Student> findStudentsForCampus1();
Pros
- ✅ DB-level security: Cannot access other campus data even with application bugs
- ✅ Simple permission management: Grant View access per DB user
Cons
- ❌ Scalability issues: Need to create Views for each campus (unmanageable with hundreds)
- ❌ Cannot handle dynamically: Difficult to handle users accessing multiple campuses
- ❌ DML constraints: Complex INSERT/UPDATE through Views
Suitable When
- Small, fixed number of campuses (≤10)
- Read-only requirements
- DB permission management is critical (financial sector, etc.)
5. Spring AOP + ThreadLocal (CheckUS Approach)
CheckUS’s chosen method. Briefly summarized as covered in detail in Part 2.
// HTTP Interceptor sets ThreadLocal
CampusContextHolder.setCampusIds(Set.of(campusId));
// AOP validates
@Before("@annotation(CampusFiltered)")
public void checkCampusContext() { ... }
// Use in Service
@CampusFiltered
public List<Student> getStudents() {
Long campusId = CampusContextHolder.getSingleCampusId();
return studentRepository.findByCampusId(campusId);
}
Pros
- ✅ Explicit control: Intent clear with
@CampusFiltered - ✅ Native Query support: Applicable to all query types
- ✅ Multiple campus concurrent query: Can use
Set<Long> campusIds - ✅ Frontend integration: Axios Interceptor + ESLint rules
Cons
- ❌ Manual filtering required: Developers must write ThreadLocal usage code directly
- ❌ Mistake possibility: AOP not applied if
@CampusFilteredomitted - ❌ Complex async handling: TaskDecorator needed
Suitable When
- Cross-campus requirements (one user accesses multiple campuses)
- High Native Query usage
- Spring Boot environment
Comparing Real AOP Implementation Cases
Analyzing 4 actual industry cases using Spring AOP for multi-tenancy implementation.
Case 1: AOP + Hibernate Filter (2024)
Source: Medium - “Multi-Tenancy with Spring Boot and Hibernate” (2024)
@Aspect
@Component
public class TenantAspect {
@Before("execution(* com.example.service.*.*(..))")
public void setTenantContext(JoinPoint joinPoint) {
// Extract tenantId from HTTP header
String tenantId = RequestContextHolder.currentRequestAttributes()
.getHeader("X-Tenant-Id");
TenantContext.setCurrentTenant(tenantId);
// Automatically enable Hibernate Filter
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
}
}
Characteristics
- ✅ Complete automation: No separate code needed in Service methods
- ✅ Perfect JPQL support: Automatic filtering with Hibernate Filter
- ❌ No Native Query support: Native SQL requires manual filtering
Comparison with CheckUS
- Automation level: ⭐⭐⭐⭐⭐ (CheckUS: ⭐⭐⭐)
- Native Query support: ❌ (CheckUS: ✅)
- Explicitness: ⭐⭐ (CheckUS: ⭐⭐⭐⭐)
Case 2: AOP + Redis (2025)
Source: Baeldung - “High-Performance Multi-Tenancy” (2025)
@Aspect
@Component
public class TenantValidationAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Before("@annotation(TenantRequired)")
public void validateTenant(JoinPoint joinPoint) {
String tenantId = TenantContext.getCurrentTenant();
// Validate tenantId from Redis (caching)
Boolean exists = redisTemplate.hasKey("tenant:" + tenantId);
if (!exists) {
throw new InvalidTenantException();
}
}
}
Characteristics
- ✅ High performance: Reduced DB load with Redis caching
- ✅ Separated validation layer: Separately validates Tenant existence
- ❌ Redis dependency: System stops on Redis failure
Comparison with CheckUS
- Performance: ⭐⭐⭐⭐⭐ (CheckUS: ⭐⭐⭐⭐)
- Complexity: ⭐⭐ (CheckUS: ⭐⭐⭐⭐)
- Infrastructure requirements: Redis required (CheckUS: none)
Case 3: AspectJ Load-Time Weaving (2024)
Source: DZone - “Deep Multi-Tenancy with AspectJ” (2024)
@Aspect
public class HibernateSessionAspect {
@Around("execution(* org.hibernate.SessionFactory.openSession(..))")
public Object injectTenantFilter(ProceedingJoinPoint pjp) throws Throwable {
Session session = (Session) pjp.proceed();
String tenantId = TenantContext.getCurrentTenant();
// Automatically apply filter to all Sessions
session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
return session;
}
}
Characteristics
- ✅ Bytecode-level application: Intercepts even Hibernate internal methods
- ✅ Complete automation: No developer code changes needed
- ❌ Complex configuration: AspectJ Load-Time Weaver setup required
- ❌ Performance overhead: AOP executes on every Session creation
Comparison with CheckUS
- Automation level: ⭐⭐⭐⭐⭐ (CheckUS: ⭐⭐⭐)
- Configuration complexity: ⭐ (CheckUS: ⭐⭐⭐⭐)
- Performance: ⭐⭐⭐ (CheckUS: ⭐⭐⭐⭐)
Case 4: CheckUS 4-Tier (2025)
Characteristics (already known, briefly)
- ✅ Explicit control:
@CampusFilteredannotation - ✅ Frontend integration: Axios + ESLint
- ✅ Multiple Tenant concurrent query:
Set<Long> campusIds - ❌ Manual filtering: Write ThreadLocal usage code directly
Comprehensive Comparison Table
| Implementation | Automation | Native Query | Multiple Tenants | Config Complexity | Performance | DB Independence |
|---|---|---|---|---|---|---|
| PostgreSQL RLS | ⭐⭐⭐⭐⭐ | ✅ | ✅ | ⭐⭐⭐ | ⭐⭐⭐ | ❌ (PostgreSQL only) |
| Hibernate Filter | ⭐⭐⭐⭐ | ❌ | ✅ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ |
| API Gateway | ⭐⭐⭐ | ✅ | ✅ | ⭐⭐ | ⭐⭐⭐ | ✅ |
| Database View | ⭐⭐⭐⭐ | ✅ | ❌ | ⭐ | ⭐⭐⭐⭐ | ✅ |
| CheckUS 4-Tier | ⭐⭐⭐ | ✅ | ✅ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ |
| AOP + Hibernate Filter | ⭐⭐⭐⭐⭐ | ❌ | ✅ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ |
| AOP + Redis | ⭐⭐⭐⭐ | ✅ | ✅ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ |
| AspectJ LTW | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ✅ | ⭐ | ⭐⭐⭐ | ✅ |
CheckUS Approach Trade-offs
What We Chose
- Explicit control (
@CampusFilteredannotation)- Code clearly shows campus filtering requirement
- Easy onboarding for new developers
- Full Native Query support
- No constraints on complex statistical queries, performance optimization queries
- Hibernate Filter doesn’t support Native Query
- Multiple campus concurrent query (
Set<Long> campusIds)- Perfect support for cross-campus requirements
- Database View cannot handle dynamically
- Frontend integration (Axios Interceptor + ESLint)
- Entire 4-Tier follows consistent rules
- API Gateway approach only protects backend
- Simple configuration (no Redis, AspectJ LTW needed)
- Implements with only Spring Boot + MySQL
- No additional infrastructure
What We Gave Up
- Complete automation (Hibernate Filter level)
- Developers must write ThreadLocal usage code directly
- AOP only validates, doesn’t auto-filter
- Maximum performance (no Redis caching)
- ThreadLocal is fast, but slower than Redis caching
- But no worries about Redis failures
- Bytecode-level application (AspectJ LTW)
- Doesn’t intercept Hibernate internals
- But simple configuration and easy debugging
Why These Choices?
At CheckUS’s current scale, simplicity and flexibility were more important.
Current situation:
- Campus count: 2-3 (at most 10)
- Users: hundreds
- Traffic: tens of requests per second
Future plans:
- Campus count: hundreds (franchise expansion)
- Users: tens of thousands
- Traffic: thousands of requests per second
At current stage:
- ✅ Developer understanding and easy maintenance most important
- ✅ Rapid development without additional infrastructure like Redis
- ✅ Free Native Query usage (many statistical queries)
For future expansion:
- 🔄 Can add Redis caching (while maintaining ThreadLocal)
- 🔄 Can strengthen automation with AspectJ LTW
- 🔄 Compatible even when introducing API Gateway
Suitable Situations for Each Method
Recommend PostgreSQL Native RLS
✅ Recommended for:
- Using PostgreSQL and cannot change
- Complete automation is top priority
- SQL Injection defense critical (financial sector, etc.)
- Team has DB experts
❌ Avoid when:
- Using MySQL
- Frequent query debugging needed
- Considering DB change
Recommend Hibernate Filter
✅ Recommended for:
- Most queries use JPQL/Criteria API
- Low Native Query usage
- Team familiar with Hibernate
- Prefer ORM-level automation
❌ Avoid when:
- High Native Query usage (statistics, complex joins)
- Heavy use of Spring Data JPA default methods
- High Raw SQL necessity
Recommend API Gateway
✅ Recommended for:
- Microservices architecture
- Already using API Gateway
- Mixed languages/frameworks (Java, Python, Node.js)
- Need centralized security policy
❌ Avoid when:
- Monolithic architecture
- Many inter-service internal calls
- Gateway failure is critical
Recommend CheckUS 4-Tier
✅ Recommended for:
- Cross-Tenant requirements (one user accesses multiple Tenants)
- High Native Query usage
- Spring Boot + MySQL/PostgreSQL environment
- Prefer explicit control (understandability over automation)
- Want frontend-integrated architecture
❌ Avoid when:
- Complete automation required (zero developer mistakes goal)
- Bytecode-level control needed
- Ultra-high performance requirements (Redis caching essential)
Hybrid Approach: Combining Multiple Methods
In practice, combining multiple methods is also possible.
Example 1: CheckUS + Redis Caching
@CampusFiltered
@Cacheable(value = "students", key = "#root.method.name + '_' + @campusContextHolder.getSingleCampusId()")
public List<Student> getStudents() {
Long campusId = CampusContextHolder.getSingleCampusId();
return studentRepository.findByCampusId(campusId);
}
- Maintains CheckUS’s explicit control
- Improves performance with Redis caching
- Safety maintained by including campusId in cache key
Example 2: API Gateway + Hibernate Filter
# API Gateway: Header validation
- name: tenant-validator
config:
validate_header: X-Tenant-Id
# Backend: Automatic filtering with Hibernate Filter
- 1st validation at Gateway
- 2nd defense with Hibernate Filter in backend
Conclusion
There is no definitive answer to “Which method is best?”
What matters is:
- Team’s tech stack (PostgreSQL? MySQL? Hibernate proficiency?)
- Requirements (Cross-Tenant? Native Query usage? Complete automation?)
- Current scale and future plans (Traffic? Expansion plans?)
- Team culture (Explicit control vs automation?)
CheckUS chose 4-Tier prioritizing cross-campus + Native Query + simplicity. But for other teams, Hibernate Filter or PostgreSQL RLS might be better choices.
Next Episode Preview
Part 4 objectively compared various Row-Level Security implementation methods. We examined the pros, cons, and suitable situations for PostgreSQL RLS, Hibernate Filter, API Gateway, and CheckUS 4-Tier.
Part 5: Legacy Migration Strategy will cover:
- 🔧 Applying multi-tenancy to existing systems
- 📊 Data migration strategy (adding campusId)
- 🧪 Zero-downtime deployment plan
- ⚠️ Problems and solutions during migration
- ✅ Step-by-step migration checklist
How to apply multi-tenancy to already running systems? We’ll reveal practical migration guides.
👉 Continue to Part 5: Legacy System Multi-Tenancy Migration (Coming Soon)
References
Real Implementation Cases
- Medium - Multi-Tenancy with Spring Boot and Hibernate (2024)
- Baeldung - High-Performance Multi-Tenancy (2025)
- DZone - Deep Multi-Tenancy with AspectJ (2024)
Note: These cases can be found by searching “Spring Boot Multi-tenancy AOP”
Official Documentation
Note: Refer to “Row Level Security” and “Filters” sections in PostgreSQL and Hibernate official documentation.
CheckUS Architecture Series
- Part 1: One Account, Multiple Schools, Multiple Roles
- Part 2: 4-Tier Security to Prevent Data Leaks in Multi-Tenancy
- Part 3: Multi-Campus, Multi-Role JWT Design and ThreadLocal Safety
- Part 4: Comparing 5 Row-Level Security Implementations and Selection Guide ← Current
- Part 5: Legacy System Multi-Tenancy Migration (Coming Soon)
Comments