Spring Boot와 Redis를 활용한 권한 시스템 구축: Ruoyi-React 기반 개발 가이드

Spring Boot와 Redis를 활용한 권한 시스템 구축: Ruoyi-React 기반 개발 가이드

최근 기존 프로젝트 리팩토링 작업을 진행하면서, 안전하고 유연하며 유지보수가 용이한 권한 관리 시스템 구축이 핵심 요구사항이었습니다. 오픈소스 스타터프레임워크는 많지만, 일부는 과도하게 무겁거나 기능이 부족하거나 문서가 부족한 문제가 있었습니다. 여러 솔루션을 비교한 결과, Ruoyi-React 프레임워크를 기반으로 한 2차 개발을 선택하게 되었습니다. 이 솔루션은 Spring Boot의 안정적인 백엔드와 React의 현대적인 프론트엔드를 완벽하게 결합하고, Redis를 사용하여 고빈도의 인증 및 권한 데이터를 처리함으로써 기업급 백오피스 시스템 구축에 있어 "황금 조합"이라고 할 수 있습니다. 이 글에서는 실무자 관점에서 전체 구축 과정을 단계별로 설명하고, 공식 문서에는 없지만 실제 개발 과정에서 반드시 마주치게 될 "함정"들에 대한 경험을 공유하겠습니다. 새로운 시스템을 빠르게 구축하거나 기존 아키텍처를 최적화하고자 하는 분들에게 이 가이드가 실질적인 도움이 될 것이라 확신합니다.

1. 개발 환경 설정 및 핵심 의존성 분석

코드 작성을 시작하기 전에 명확하고 안정적인 개발 환경은 성공의 기초입니다. 많은 초보 개발자들이 환경 설정의 중요성을 간과하여 이후 개발 과정에서 다양한 호환성 문제에 직면하기도 합니다. 이번에 구축하는 것은 프론트엔드와 백엔드가 분리된 아키텍처이므로, 각각 백엔드(Spring Boot)와 프론트엔드(React) 환경을 별도로 준비해야 합니다.

백엔드 환경의 핵심은 JDK, Maven 그리고 Redis입니다. JDK 17 이상 버전 사용을 강력히 권장합니다. Spring Boot 3.x는 Java 17의 새로운 기능인 레코드 클래스(Record)와 새로운 가비지 컬렉터를 완전히 지원하며, 이는 성능 향상으로 이어집니다. Maven 버전은 3.8 이상을 사용하는 것이 좋으며, 의존성 충돌을 더 잘 처리할 수 있습니다. Redis의 경우 Docker를 사용하여 배포하는 것을 추천합니다. 이는 환경 일관성을 보장하고 운영체제 차이로 인한 설정 문제를 방지할 수 있습니다.

프론트엔드 환경은 Node.js와 패키지 관리기 주변으로 구성됩니다. Node.js 버전은 18.x LTS 장기 지원 버전을 선택하는 것이 좋습니다. 패키지 관리기로는 pnpm 사용을 더 선호합니다. 특히 대형 프로젝트에서 npm이나 yarn보다 설치 속도와 디스크 공간 사용량에서 현저한 이점을 제공합니다.

1.1 백엔드 핵심 의존성 및 설정 포인트

표준 Spring Boot 프로젝트를 생성한 후, pom.xml 파일에 Ruoyi 프레임워크의 핵심 의존성을 추가해야 합니다. 여기서 중요한 것은 단순히 복사-붙여넣기가 아니라 각 의존성의 역할을 이해하는 것입니다.

<!-- Spring Security 보안 프레임워크 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT 토큰 유틸리티 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<!-- Redis 통합 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Ruoyi 시스템 핵심 모듈 -->
<dependency>
    <groupId>com.ifast</groupId>
    <artifactId>ifast-common</artifactId>
    <version>${ifast.version}</version>
</dependency>

참고: JWT 의존성(api, impl, jackson) 세 부분을 모두 도입해야 하며, 버전 번호는 일치해야 합니다. 많은 개발자들이 jjwt-api만 도입하여 실행 시 NoClassDefFoundError 오류가 발생하는 경우가 많습니다.

application.yml 파일을 구성할 때 몇 가지 주의할 점이 있습니다:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/auth_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    username: admin
    password: secure_password
  redis:
    host: localhost
    port: 6379
    password: # Redis에 비밀번호가 설정된 경우
    database: 1 # 권한 시스템을 위해 별도 데이터베이스 할당 권장
    timeout: 10s
    lettuce:
      pool:
        max-active: 20 # 최대 연결 수, 동시성량에 따라 조정
        max-idle: 10
        min-idle: 5

# JWT 설정
jwt:
  header: Authorization
  secret: my-super-secret-jwt-key-123456789 # 충분히 복잡해야 하며 환경 변수 주입 권장
  expiration: 3600 # 토큰 유효기간(초), 1시간
  token-start-with: Bearer

첫 번째 함정: Redis 연결 풀 설정. 프로덕션 환경에서 lettuce.pool을 구성하지 않으면 Spring Boot는 기본적으로 무제한 연결 풀을 사용하며, 이는 고갯수 시나리오에서 Redis 서버 자원을 고갈시킬 수 있습니다. 위의 구성은 비교적 보수적인 시작 값이며, 실제 부하 테스트 결과에 따라 조정해야 합니다.

1.2 프론트엔드 프로젝트 초기화 및 프록시 설정

프론트엔드로는 Ant Design Pro 기반의 Ruoyi-React 템플릿을 사용합니다. 프로젝트를 복제한 후 첫 번째 작업은 npm install이 아니라 .npmrc 또는 .yarnrc 파일을 확인하여 미러 소스가 올바르게 구성되어 있는지 확인하는 것입니다. 이는 의존성 설치 속도를 크게 향상시킬 수 있습니다.

# pnpm으로 의존성 설치 (권장)
pnpm install

# 또는 npm 사용
npm install --registry=https://registry.npmmirror.com

설치가 완료되면 중요한 것은 프록시 설정입니다. 개발 중에는 프론트엔드와 백엔드가 다른 포트에서 실행되므로 API 요청이 올바르게 라우팅되도록 프록시를 구성해야 합니다.

# vue.config.js 또는 config/config.js
module.exports = {
  proxy: {
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true,
      pathRewrite: {
        '^/api': ''
      }
    }
  }
};

두 번째 함정: CORS 문제. 프록시 설정을 하지 않으면 브라우저의 CORS 정책으로 인해 API 요청이 차단될 수 있습니다. 이는 개발 중에 자주 발생하는 문제이며, 프록시 설정은 이 문제를 해결하는 가장 간단한 방법 중 하나입니다.

2. 인증 및 권한 모델 설계

권한 시스템의 핵심은 사용자, 역할, 권한 간의 관계를 명확히 정의하는 것입니다. 전통적인 RBAC(Role-Based Access Control) 모델을 기반으로 하되, JWT와 Redis를 결합하여 성능을 최적화합니다.

사용자(User)는 여러 역할(Role)을 가질 수 있으며, 각 역할은 다양한 권한(Permission)을 보유합니다. 이 관계는 데이터베이스에 명시적으로 저장되어야 합니다. 동시에, 사용자의 세션 정보는 Redis에 캐시되어 빠른 접근이 가능하도록 합니다.

2.1 데이터베이스 스키마 설계

MySQL 데이터베이스에는 다음과 같은 주요 테이블이 필요합니다:

-- 사용자 테이블
CREATE TABLE sys_user (
  user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(30) NOT NULL UNIQUE,
  password VARCHAR(100) NOT NULL,
  email VARCHAR(100),
  status CHAR(1) DEFAULT '0',
  create_time DATETIME,
  update_time DATETIME
);

-- 역할 테이블
CREATE TABLE sys_role (
  role_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  role_name VARCHAR(30) NOT NULL,
  role_key VARCHAR(100) NOT NULL,
  status CHAR(1) DEFAULT '0'
);

-- 권한 테이블
CREATE TABLE sys_permission (
  permission_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  permission_name VARCHAR(100) NOT NULL,
  permission_key VARCHAR(100) NOT NULL,
  parent_id BIGINT DEFAULT 0,
  menu_type CHAR(1) DEFAULT 'M'
);

-- 사용자-역할 관계 테이블
CREATE TABLE sys_user_role (
  user_id BIGINT,
  role_id BIGINT,
  PRIMARY KEY (user_id, role_id)
);

-- 역할-권한 관계 테이블
CREATE TABLE sys_role_permission (
  role_id BIGINT,
  permission_id BIGINT,
  PRIMARY KEY (role_id, permission_id)
);

세 번째 함정: 외래 키 제약. 처음에는 모든 테이블에 외래 키 제약을 추가하여 데이터 무결성을 유지하려 했지만, 실제 운영 환경에서는 성능 저하를 유발할 수 있었습니다. 결국 애플리케이션 레벨에서 데이터 무결성을 보장하는 방식으로 변경했습니다.

2.2 JWT 토큰 생성 및 검증 로직

JWT 토큰은 사용자 인증 정보를 안전하게 전달하는 데 사용됩니다. Spring Security와 JWT를 통합하여 인증 필터를 구현해야 합니다.

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   FilterChain filterChain) throws ServletException, IOException {
        
        String token = jwtTokenProvider.resolveToken(request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);
            
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication = 
                new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities());
            
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }
}

JWT 토큰 생성 유틸리티 클래스:

@Component
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    @Value("${jwt.expiration}")
    private int jwtExpirationInMs;
    
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationInMs))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }
    
    public String getUsernameFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (SignatureException ex) {
            logger.error("Invalid JWT signature");
        } catch (MalformedJwtException ex) {
            logger.error("Invalid JWT token");
        } catch (ExpiredJwtException ex) {
            logger.error("Expired JWT token");
        } catch (UnsupportedJwtException ex) {
            logger.error("Unsupported JWT token");
        } catch (IllegalArgumentException ex) {
            logger.error("JWT claims string is empty");
        }
        return false;
    }
    
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

네 번째 함정: JWT 서명 알고리즘. HS256 알고리즘을 사용하려 했지만, 보안 취약점이 발견되어 HS512로 변경해야 했습니다. 또한, 토큰 만료 시간을 너무 길게 설정하면 보안 위험이 커지므로 적절한 만료 시간을 설정하는 것이 중요합니다.

3. Redis를 활용한 권한 캐싱 전략

데이터베이스에 직접 접근하는 것보다 Redis를 사용하여 권한 정보를 캐싱하면 응답 속도를 크게 향상시킬 수 있습니다. 특히 사용자의 세션 정보와 역할-권한 관계는 자주 변경되지 않으므로 캐싱에 적합합니다.

3.1 Redis 템플릿 설정

Spring Data Redis를 사용하여 Redis 작업을 간소화할 수 있습니다.

@Configuration
public class RedisConfig {
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        // 직렬화 설정
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        
        template.afterPropertiesSet();
        return template;
    }
    
    @Bean
    public HashOperations<String, String, Object> hashOperations(RedisTemplate<String, Object> redisTemplate) {
        return redisTemplate.opsForHash();
    }
}

3.2 권한 캐싱 서비스 구현

사용자의 권한 정보를 Redis에 캐싱하고 관리하는 서비스 클래스:

@Service
public class RedisCacheService {
    
    @Autowired
    private HashOperations<String, String, Object> hashOperations;
    
    private static final String AUTH_CACHE = "auth_cache";
    
    // 사용자 권한 정보 캐싱
    public void cacheUserPermissions(String username, List<String> permissions) {
        hashOperations.put(AUTH_CACHE, username + "_permissions", permissions);
        // 캐시 만료 시간 설정 (예: 1시간)
        redisTemplate.expire(AUTH_CACHE, 1, TimeUnit.HOURS);
    }
    
    // 캐시에서 사용자 권한 정보 조회
    @SuppressWarnings("unchecked")
    public List<String> getCachedUserPermissions(String username) {
        return (List<String>) hashOperations.get(AUTH_CACHE, username + "_permissions");
    }
    
    // 사용자 권한 정보 캐시 삭제
    public void evictUserPermissions(String username) {
        hashOperations.delete(AUTH_CACHE, username + "_permissions");
    }
    
    // 사용자 세션 정보 캐싱
    public void cacheUserSession(String username, String token) {
        hashOperations.put(AUTH_CACHE, username + "_session", token);
        redisTemplate.expire(AUTH_CACHE, 1, TimeUnit.HOURS);
    }
    
    // 세션 토큰 유효성 확인
    public boolean validateSession(String username, String token) {
        String cachedToken = (String) hashOperations.get(AUTH_CACHE, username + "_session");
        return token.equals(cachedToken);
    }
}

다섯 번째 함정: Redis 직렬화. 초기에는 기본 직렬화 방식을 사용했지만, 한글 문자 깨짐 문제가 발생했습니다. GenericJackson2JsonRedisSerializer를 사용하여 문제를 해결했습니다. 또한, 캐시 만료 시간을 적절히 설정하지 않으면 오래된 데이터가 계속 유지될 수 있으므로 주의가 필요합니다.

4. 프론트엔드 라우트 보안 구현

React 애플리케이션에서 특정 라우트에 대한 접근 권한을 제어하는 것은 중요합니다. React Router와 커스텀 컴포넌트를 사용하여 라우트 보안을 구현할 수 있습니다.

4.1 인증 헬퍼 컴포넌트

사용자 인증 상태를 확인하고, 인증되지 않은 사용자를 로그인 페이지로 리디렉션하는 컴포넌트:

import React, { useEffect, useState } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { authService } from '../services/authService';

const AuthGuard = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(null);
    const location = useLocation();
    
    useEffect(() => {
        const checkAuth = async () => {
            const token = localStorage.getItem('token');
            if (token) {
                try {
                    const isValid = await authService.validateToken(token);
                    setIsAuthenticated(isValid);
                } catch (error) {
                    setIsAuthenticated(false);
                }
            } else {
                setIsAuthenticated(false);
            }
        };
        
        checkAuth();
    }, [location]);
    
    if (isAuthenticated === null) {
        return <div>로딩 중...</div>;
    }
    
    if (!isAuthenticated) {
        return <Navigate to="/login" state={{ from: location }} replace />;
    }
    
    return children;
};

export default AuthGuard;

4.2 권한 체크 컴포넌트

사용자가 특정 권한을 가지고 있는지 확인하는 컴포넌트:

import React, { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';

const PermissionGuard = ({ permissions, children }) => {
    const [hasPermission, setHasPermission] = useState(null);
    
    useEffect(() => {
        const checkPermission = async () => {
            const userPermissions = await authService.getUserPermissions();
            const hasRequiredPermission = permissions.some(permission => 
                userPermissions.includes(permission)
            );
            setHasPermission(hasRequiredPermission);
        };
        
        checkPermission();
    }, [permissions]);
    
    if (hasPermission === null) {
        return <div>권한 확인 중...</div>;
    }
    
    if (!hasPermission) {
        return <Navigate to="/unauthorized" replace />;
    }
    
    return children;
};

export default PermissionGuard;

여섯 번째 함정: 라우트 중복 렌더링. 초기에는 여러 인증 컴포넌트를 중첩하여 사용했지만, 이로 인해 불필요한 렌더링이 발생했습니다. 각 라우트에 필요한 인증/권한 확인 로직을 최소화하여 성능을 개선했습니다.

5. 성능 최적화 및 모니터링

권한 시스템의 안정적인 운영을 위해서는 성능 모니터링과 최적화가 필수적입니다.

5.1 캐시 적중률 모니터링

Redis 캐시의 적중률을 모니터링하여 캐시 전략의 효율성을 평가할 수 있습니다.

@RestController
@RequestMapping("/api/cache")
public class CacheMonitorController {
    
    @Autowired
    private RedisCacheService redisCacheService;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @GetMapping("/stats")
    public Map<String, Object> getCacheStats() {
        Map<String, Object> stats = new HashMap<>();
        
        // Redis 메모리 사용량
        MemoryUsage usage = redisTemplate.getConnectionFactory().getConnection().info("memory")
            .lineToMap().entrySet().stream()
            .filter(entry -> entry.getKey().equals("used_memory"))
            .findFirst()
            .map(entry -> entry.getValue())
            .map(Long::parseLong)
            .orElse(0L);
        
        stats.put("memoryUsage", usage / (1024 * 1024) + " MB");
        
        // 캐시 적중률 (간단한 예시)
        stats.put("hitRate", "85.3%");
        
        return stats;
    }
}

5.2 쿼리 성능 최적화

데이터베이스 쿼리 성능은 전체 시스템 성능에 큰 영향을 미칩니다. 특히 권한 관련 쿼리는 최적화가 필요합니다.

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    
    // 인덱스를 사용한 사용자 조회
    @Query("SELECT u FROM User u WHERE u.username = :username")
    Optional<User> findByUsername(@Param("username") String username);
    
    // 패치 조인을 사용한 N+1 문제 해결
    @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.userId = :userId")
    Optional<User> findByIdWithRoles(@Param("userId") Long userId);
}

일곱 번째 함정: N+1 쿼리 문제. 초기에는 사용자와 역할 관계를 조회할 때 N+1 쿼리 문제가 발생했습니다. @EntityGraph 또는 LEFT JOIN FETCH를 사용하여 문제를 해결했습니다.

6. 배포 및 운영 고려사항

개발이 완료된 후, 프로덕션 환경에 배포하고 안정적으로 운영하기 위한 몇 가지 중요한 고려사항이 있습니다.

6.1 환경별 설정 관리

개발, 테스트, 프로덕션 환경에 따라 다른 설정을 관리해야 합니다. Spring Profile을 사용하여 환경별 설정을 분리할 수 있습니다.

# application-prod.yml (프로덕션 환경)
spring:
  datasource:
    url: jdbc:mysql://prod-db.example.com:3306/auth_system?useSSL=true
    username: prod_user
    password: ${DB_PASSWORD}
  redis:
    host: prod-redis.example.com
    port: 6379
    password: ${REDIS_PASSWORD}

jwt:
  secret: ${JWT_SECRET}
  expiration: 1800 # 30분

6.2 로깅 및 모니터링

권한 관련 이벤트를 로깅하고 모니터링하여 보안 이슈를 조기에 감지할 수 있습니다.

@Component
public class SecurityEventListener {
    
    private static final Logger logger = LoggerFactory.getLogger(SecurityEventListener.class);
    
    @EventListener
    public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
        Authentication auth = event.getAuthentication();
        logger.info("사용자 '{}'가 성공적으로 로그인했습니다", auth.getName());
    }
    
    @EventListener
    public void handleAuthenticationFailure(AuthenticationFailureEvent event) {
        AuthenticationException ex = event.getException();
        logger.warn("로그인 실패: {}", ex.getMessage());
    }
}

여덟 번째 함정: 민감 정보 로깅. 초기에는 로그에 사용자 비밀번호와 같은 민감 정보가 포함되는 문제가 있었습니다. 로깅 전에 모든 민감 정보를 필터링하는 로직을 추가하여 보안을 강화했습니다.

이 가이드를 통해 Spring Boot와 Redis를 활용한 권한 시스템 구축의 핵심 요소와 실제 개발 과정에서 마주칠 수 있는 함정들에 대한 깊이 있는 이해를 얻으셨기를 바랍니다. 성공적인 시스템 구축을 기원합니다!

태그: Spring Boot Redis 권한 시스템 Ruoyi-React jwt

6월 26일 17:51에 게시됨