1. 개요

멀티테넌시는 소프트웨어 애플리케이션의 단일 인스턴스가 여러 테넌트 또는 고객에게 서비스를 제공 하는 아키텍처를 나타냅니다 . 

테넌트가 사용하는 데이터와 리소스가 다른 것과 분리되도록 테넌트 간에 필요한 수준의 격리가 가능합니다.

이 사용방법(예제)에서는 Spring Data JPA를 사용하여 Spring Boot 애플리케이션에서 다중 테넌시를 구성하는 방법을 살펴봅니다. 또한 JWT 를 사용하여 테넌트에 Security을 추가합니다 .

2. 멀티 테넌시 모델

다중 테넌트 시스템에는 세 가지 주요 접근 방식이 있습니다.

  • 별도의 데이터베이스
  • 공유 데이터베이스 및 별도의 스키마
  • 공유 데이터베이스 및 공유 스키마

2.1. 별도의 데이터베이스

이 접근 방식에서 각 테넌트의 데이터는 별도의 데이터베이스 인스턴스에 보관되며 다른 테넌트와 격리됩니다. 이는 테넌트당 데이터베이스 라고도 합니다 .

별도의 데이터베이스

2.2. 공유 데이터베이스 및 별도의 스키마

이 접근 방식에서 각 테넌트의 데이터는 공유 데이터베이스의 개별 스키마에 보관됩니다. 이를 테넌트당 스키마 라고도 합니다 .

별도의 스키마

2.3. 공유 데이터베이스 및 공유 스키마

이 접근 방식에서 모든 테넌트는 데이터베이스를 공유하고 모든 테이블에는 테넌트 식별자가 포함된 열이 있습니다.

공유 데이터베이스

3. 메이븐 의존성

pom.xml 의 Spring Boot 애플리케이션에서 spring-boot-starter-data-jpa 의존성 을 선언하여 시작하겠습니다 .

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

또한 PostgreSQL 데이터베이스를 사용할 것이므로 pom.xml 파일 에 postgresql 의존성 도 추가해 보겠습니다.

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

별도의 데이터베이스 및 공유 데이터베이스와 별도의 스키마 접근 방식은 Spring Boot 애플리케이션의 구성과 유사합니다. 이 사용방법(예제)에서는 데이터베이스 분리 방식에 중점을 둡니다 .

4. 동적 데이터 소스 라우팅

이 섹션에서는 테넌트당 데이터베이스 모델 의 일반적인 개념을 설명합니다 .

4.1. AbstractRoutingDataSource

Spring Data JPA로 다중 테넌시를 구현하는 일반적인 아이디어 는 현재 테넌트 식별자를 기반으로 런타임에 데이터 소스를 라우팅하는 것입니다.

이를 위해 AbstractRoutingDatasource 를 현재 테넌트를 기반으로 실제 DataSource 를 동적으로 결정하는 방법으로 사용할 수 있습니다.

AbstractRoutingDataSource 클래스 를 확장 하는 MultitenantDataSource  클래스를 만들어 보겠습니다 .

public class MultitenantDataSource extends AbstractRoutingDataSource {

    @Override
    protected String determineCurrentLookupKey() {
        return TenantContext.getCurrentTenant();
    }
}

AbstractRoutingDataSource검색 키를 기반으로 getConnection 호출을 다양한 대상 DataSource 중 하나로 라우팅 합니다.

조회 키는 일반적으로 일부 스레드 바인딩된 트랜잭션 컨텍스트를 통해 결정됩니다. 따라서 각 요청에 현재 테넌트를 저장하기 위한 TenantContext  클래스를 만듭니다 .

public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static String getCurrentTenant() {
        return CURRENT_TENANT.get();
    }

    public static void setCurrentTenant(String tenant) {
        CURRENT_TENANT.set(tenant);
    }
}

현재 요청에 대한 테넌트 ID를 유지하기 위해 ThreadLocal 개체를 사용합니다 . 또한 set 메서드를 사용하여 테넌트 ID를 저장하고 get() 메서드를 사용하여 검색합니다. 

4.2. 요청별 테넌트 ID 설정

이 구성 설정 후 테넌트 작업을 수행할 때 트랜잭션을 생성하기 전에 테넌트 ID를 알아야 합니다 . 따라서 컨트롤러 Endpoints에 도달하기 전에 필터 또는 인터셉터 에서 테넌트 ID를 설정해야 합니다.

TenantContext 에서 현재 테넌트를 설정하기 위한 TenantFilter  를 추가해 보겠습니다 .

@Component
@Order(1)
class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
      FilterChain chain) throws IOException, ServletException {

        HttpServletRequest req = (HttpServletRequest) request;
        String tenantName = req.getHeader("X-TenantID");
        TenantContext.setCurrentTenant(tenantName);

        try {
            chain.doFilter(request, response);
        } finally {
            TenantContext.setCurrentTenant("");
        }

    }
}

이 필터에서는 요청 헤더 X-TenantID  에서 테넌트 ID를 가져와 TenantContext 에 설정합니다 . 필터 체인 아래로 제어를 전달합니다. finally 블록은 현재  테넌트가 다음 요청 전에 재설정되도록 합니다. 이렇게 하면 교차 테넌트 요청 오염의 위험이 방지됩니다.

다음 섹션에서는  Database per Tenant 모델에서 테넌트 및 데이터 소스 선언을 구현합니다.

5. 데이터베이스 접근

이 섹션에서는 테넌트당 데이터베이스 모델 을 기반으로 다중 테넌시를 구현 합니다.

5.1. 임차인 선언

이 접근 방식에서는 여러 데이터베이스가 있으므로 Spring Boot 애플리케이션에서 여러 데이터 소스를 선언해야 합니다.

별도의 테넌트 파일에서 DataSource 를 구성할 수 있습니다 . 따라서 allTenants 디렉터리에 tenant_1.properties  파일을 생성 하고 테넌트의 데이터 소스를 선언합니다.

name=tenant_1
datasource.url=jdbc:postgresql://localhost:5432/tenant1
datasource.username=postgres
datasource.password=123456
datasource.driver-class-name=org.postgresql.Driver

또한 다른 테넌트에 대한 tenant_2.properties 파일을 생성합니다.

name=tenant_2
datasource.url=jdbc:postgresql://localhost:5432/tenant2
datasource.username=postgres
datasource.password=123456
datasource.driver-class-name=org.postgresql.Driver

각 테넌트에 대한 파일로 끝납니다.

모든 임차인

5.2. 데이터 소스 선언

이제 테넌트의 데이터를 읽고 DataSourceBuilder 클래스 를 사용하여 DataSource 를 생성해야 합니다 . 또한 AbstractRoutingDataSource 클래스 에서 DataSources 를 설정합니다.

이에 대한 MultitenantConfiguration 클래스를 추가해 보겠습니다 .

@Configuration
public class MultitenantConfiguration {

    @Value("${defaultTenant}")
    private String defaultTenant;

    @Bean
    @ConfigurationProperties(prefix = "tenants")
    public DataSource dataSource() {
        File[] files = Paths.get("allTenants").toFile().listFiles();
        Map<Object, Object> resolvedDataSources = new HashMap<>();

        for (File propertyFile : files) {
            Properties tenantProperties = new Properties();
            DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();

            try {
                tenantProperties.load(new FileInputStream(propertyFile));
                String tenantId = tenantProperties.getProperty("name");

                dataSourceBuilder.driverClassName(tenantProperties.getProperty("datasource.driver-class-name"));
                dataSourceBuilder.username(tenantProperties.getProperty("datasource.username"));
                dataSourceBuilder.password(tenantProperties.getProperty("datasource.password"));
                dataSourceBuilder.url(tenantProperties.getProperty("datasource.url"));
                resolvedDataSources.put(tenantId, dataSourceBuilder.build());
            } catch (IOException exp) {
                throw new RuntimeException("Problem in tenant datasource:" + exp);
            }
        }

        AbstractRoutingDataSource dataSource = new MultitenantDataSource();
        dataSource.setDefaultTargetDataSource(resolvedDataSources.get(defaultTenant));
        dataSource.setTargetDataSources(resolvedDataSources);

        dataSource.afterPropertiesSet();
        return dataSource;
    }

}

먼저 allTenants 디렉터리 에서 테넌트의 정의를 읽고 DataSourceBuilder  클래스 를 사용하여 DataSource  빈을 생성합니다. 그런 다음 각각 setDefaultTargetDataSourcesetTargetDataSources 를 사용하여 연결할 MultitenantDataSource 클래스 의 기본 데이터 소스 및 대상 소스를 설정해야 합니다 . defaultTenant 속성 을 사용하여 application.properties 파일 에서 테넌트 이름 중 하나를 기본 데이터 소스로 설정 합니다. 데이터 소스의 초기화를 완료하기 위해 afterPropertiesSet() 메서드를 호출합니다.

이제 설정이 준비되었습니다. 

6. 테스트

6.1. 테넌트용 데이터베이스 생성

먼저 PostgreSQL 에서 두 개의 데이터베이스를 정의해야 합니다 .

테넌트-db

그런 다음 아래 스크립트를 사용하여 각 데이터베이스에 직원 테이블을 만듭니다.

create table employee (id int8 generated by default as identity, name varchar(255), primary key (id));

6.2. 샘플 컨트롤러

요청 헤더의 지정된 테넌트에 Employee 엔터티를 만들고 저장하기 위한 EmployeeController  클래스를 만들어 보겠습니다 .

@RestController
@Transactional
public class EmployeeController {

    @Autowired
    private EmployeeRepository employeeRepository;

    @PostMapping(path = "/employee")
    public ResponseEntity<?> createEmployee() {
        Employee newEmployee = new Employee();
        newEmployee.setName("Baeldung");
        employeeRepository.save(newEmployee);
        return ResponseEntity.ok(newEmployee);
    }
}

6.3. 샘플 요청

Postman 을 사용하여 테넌트 ID tenant_1 에 직원 엔터티 를 삽입하기 위한 게시 요청을 생성해 보겠습니다 .

테넌트 1

또한 tenant_2 에 요청을 보냅니다 .

임차인2

그 후 데이터베이스를 확인하면 각 요청이 관련 테넌트의 데이터베이스에 저장되었음을 알 수 있습니다.

7. Security

멀티테넌시는 공유 환경 내에서 고객의 데이터를 보호해야 합니다. 이는 각 테넌트가 자신의 데이터에만 액세스할 수 있음을 의미합니다. 따라서 테넌트에 Security을 추가해야 합니다. 사용자가 애플리케이션에 로그인 하고 JWT를 가져와서 테넌시 액세스 권한을 증명하는 데 사용되는 시스템을 구축해 보겠습니다.

7.1. 메이븐 의존성

pom.xml 에 spring-boot-starter-security 의존성 을 추가하여 시작하겠습니다 .

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

또한 JWT를 생성하고 확인해야 합니다. 이를 위해 pom.xml 에 jjwt추가합니다 .

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

7.2. Security 구성

먼저 테넌트 사용자에 대한 인증 기능을 제공해야 합니다. 간단히 하기 위해 SecurityConfiguration 클래스에서 메모리 내 사용자 선언을 사용하겠습니다.

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .passwordEncoder(passwordEncoder())
      .withUser("user")
      .password(passwordEncoder().encode("baeldung"))
      .roles("tenant_1");

    auth.inMemoryAuthentication()
      .passwordEncoder(passwordEncoder())
      .withUser("admin")
      .password(passwordEncoder().encode("baeldung"))
      .roles("tenant_2");
}

두 명의 테넌트에 대해 두 명의 사용자를 추가합니다. 또한 테넌트를 역할로 생각합니다. 위의 코드에 따르면 사용자 이름 useradmin 은 각각 tenant_1tenant_2 에 액세스할 수 있습니다.

이제 사용자 인증을 위한 필터를 만듭니다. LoginFilter 클래스  를 추가해 보겠습니다 .

public class LoginFilter extends AbstractAuthenticationProcessingFilter {

    public LoginFilter(String url, AuthenticationManager authManager) {
        super(new AntPathRequestMatcher(url));
        setAuthenticationManager(authManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
      throws AuthenticationException, IOException, ServletException {

        AccountCredentials creds = new ObjectMapper().
          readValue(req.getInputStream(), AccountCredentials.class);

        return getAuthenticationManager().authenticate(
          new UsernamePasswordAuthenticationToken(creds.getUsername(),
            creds.getPassword(), Collections.emptyList())
        );
    }

LoginFilter 클래스 AbstractAuthenticationProcessingFilter 를 확장 합니다. AbstractAuthenticationProcessingFilter 는 요청을 가로채고 tryingAuthentication() 메서드 를 사용하여 인증을 시도합니다 . 이 방법에서는 사용자 자격 증명을 AccountCredentials DTO 클래스에 매핑하고 메모리 내 인증 관리자에 대해 사용자를 인증합니다.

public class AccountCredentials {

    private String username;
    private String password;

   // getter and setter methods
}

7.3. JWT

이제 JWT를 생성하고 테넌트 ID를 추가해야 합니다. 이를 위해 successfulAuthentication() 메서드를 재정의합니다. 이 메서드는 인증 성공 후 실행됩니다.

@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res,
  FilterChain chain, Authentication auth) throws IOException, ServletException {

    Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
    String tenant = "";
    for (GrantedAuthority gauth : authorities) {
        tenant = gauth.getAuthority();
    }

    AuthenticationService.addToken(res, auth.getName(), tenant.substring(5));
}

위의 코드에 따라 사용자의 역할을 가져와 JWT에 추가합니다. 이를 위해 AuthenticationService 클래스와 addToken()  메서드를 만듭니다. 

public class AuthenticationService {

    private static final long EXPIRATIONTIME = 864_000_00; // 1 day in milliseconds
    private static final String SIGNINGKEY = "SecretKey";
    private static final String PREFIX = "Bearer";

    public static void addToken(HttpServletResponse res, String username, String tenant) {
        String JwtToken = Jwts.builder().setSubject(username)
          .setAudience(tenant)
          .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
          .signWith(SignatureAlgorithm.HS512, SIGNINGKEY)
          .compact();
        res.addHeader("Authorization", PREFIX + " " + JwtToken);
    }

addToken 메서드 는 대상 클레임 으로 테넌트 ID를 포함하는 JWT를 생성하고 응답의 Authorization 헤더에 추가했습니다.

마지막으로 SecurityConfiguration 클래스 에 LoginFilter 를 추가합니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests()
      .antMatchers("/login").permitAll()
      .anyRequest().authenticated()
      .and()
      .sessionManagement()
      .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      .and()
      .addFilterBefore(new LoginFilter("/login", authenticationManager()),
        UsernamePasswordAuthenticationFilter.class)
      .addFilterBefore(new AuthenticationFilter(),
        UsernamePasswordAuthenticationFilter.class)
      .csrf().disable()
      .headers().frameOptions().disable()
      .and()
      .httpBasic();
}

또한 SecurityContextHolder 클래스 에서 인증 을 설정하기 위해 AuthenticationFilter  클래스를 추가합니다.

public class AuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
      throws IOException, ServletException {

        Authentication authentication = AuthenticationService.getAuthentication((HttpServletRequest) req);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        chain.doFilter(req, res);
    }
}

7.4. JWT에서 테넌트 ID 가져오기

TenantContext 에서 현재 테넌트를 설정하기 위해 TenantFilter 를 수정해 보겠습니다 .

String tenant = AuthenticationService.getTenant((HttpServletRequest) req);
TenantContext.setCurrentTenant(tenant);

이 상황에서는 AuthenticationService 클래스 의 getTenant() 메서드를 사용하여 JWT에서 테넌트 ID를 가져옵니다 .

public static String getTenant(HttpServletRequest req) {
    String token = req.getHeader("Authorization");
    if (token == null) {
        return null;
    }
    String tenant = Jwts.parser()
      .setSigningKey(SIGNINGKEY)
      .parseClaimsJws(token.replace(PREFIX, ""))
      .getBody()
      .getAudience();
    return tenant;
}

8. Security 테스트

8.1. JWT 생성 

사용자 이름 user 에 대한 JWT를 생성해 보겠습니다 . 이를 위해 자격 증명을 /login 엔드포인트에 게시합니다.

jwt

토큰을 확인해 봅시다:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoidGVuYW50XzEiLCJleHAiOjE2NTk2MDk1Njd9.

토큰 을 디코딩할 때 대상 클레임 으로 설정된 테넌트 ID를 찾습니다 .

{
    alg: "HS512"
}.
{
    sub: "user",
    aud: "tenant_1",
    exp: 1659609567
}.

8.2. 샘플 요청

생성된 토큰을 사용하여 직원 엔터티 를 삽입하기 위한 게시 요청을 생성해 보겠습니다 .

샘플 토큰

Authorization 헤더 에 생성된 토큰을 설정합니다 . 테넌트 ID는 토큰에서 추출되어 TenantContext 에 설정되었습니다 .

9. 결론

이 기사에서는 다양한 다중 테넌시 모델을 살펴보았습니다.

별도의 데이터베이스 및 공유 데이터베이스와 별도의 스키마 모델에 대해 Spring Data JPA를 사용하여 Spring Boot 애플리케이션에서 다중 테넌시를 추가하는 데 필요한 클래스를 설명했습니다.

그런 다음 PostgreSQL 데이터베이스에서 다중 테넌시를 테스트하는 데 필요한 환경을 설정합니다.

마지막으로 JWT를 사용하여 테넌트에 Security을 추가했습니다.

항상 그렇듯이 이 예제의 전체 소스 코드는 GitHub에서 사용할 수 있습니다 .

Persistence footer banner