🚀 Spring Boot 대용량 데이터 처리와 보안 학습하기 🛡️

대규모 서비스를 운영하는 개발자에게 대용량 데이터 처리민감 정보 보호는 필수적인 역량이라 생각합니다.
Spring Boot 환경에서 메모리 효율성을 극대화하고, 법규 준수(GDPR, 국내 개인정보보호법 등)를 위한 강력한 보안 전략을 통합적으로 구현하는 실전 가이드를 제시합니다.

🔥 대용량 데이터 처리와 메모리 관리의 핵심

수백만 건 이상의 데이터를 처리할 때는 단순히 데이터를 메모리에 한 번에 로드하는 방식은 OutOfMemoryError를 유발할 수 있습니다.
메모리 오버헤드를 줄이고 시스템 부하를 분산하기 위해 배치, 스트리밍, 페이징 기법을 조합하여 사용해야 합니다.

1. 배치 처리

설명: 전체 데이터를 미리 정의된 작은 단위로 나누어 순차적으로 처리하는 방식입니다.
각 배치가 완료될 때마다 사용한 메모리 영역을 명시적으로 해제하여 메모리 사용량을 일정 수준 이하로 유지하는 것이 중요합니다.

💡 배치처리 프레임워크를 사용하면 트랜잭션 관리, 재시작, 모니터링 기능이 자동으로 제공되므로 더욱 견고한 배치 처리가 가능합니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class LargeDataProcessor {
    // 생략

    /**
     * 대용량 데이터를 배치 단위로 처리
     * List를 메모리상에서 분할하여 OOM을 방지하고 트랜잭션 범위를 관리합니다.
     */
    @Transactional
    public ProcessingResult processLargeDataset(List<DataDto> dataList) {
        // 생략

        for (int i = 0; i < batches.size(); i++) {
            List<DataDto> batch = batches.get(i);
            
            try {
                // 배치 처리
                processBatch(batch, result);
                
                // 명시적 GC 호출은 일반적으로 사용하지 않으나,
                // 대용량 배치 처리 후 메모리 해제가 즉각 필요한 경우 한정적으로 사용될 수 있습니다.
                // 잦은 호출은 오히려 성능 저하를 발생하기때문에, 적절한 빈도를 정하는 것이 중요합니다.
                if (i % 10 == 0) {
                    System.gc(); // 메모리 정리 요청
                    // logMemoryUsage("After batch " + i); // 외부 메모리 모니터링 연동
                }
                
                // 배치 간 지연 
                // ... 생략
                
            } catch (Exception e) {
                // 에러처리 생략
            }
        }
        return result;
    }
    // 생략
}

2. 스트리밍 처리

설명: 파일을 읽거나 데이터베이스에서 조회할 때, 전체 데이터를 메모리에 로드하지 않고 데이터 흐름 단위로 처리하는 방식입니다.
특히 파일 입출력 시 BufferedReader나 JDBC의 ResultSet.setFetchSize()를 활용하여 데이터 청크를 메모리에 조금씩만 로드해야 합니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class StreamingDataProcessor {
    // 생략

    /**
     * CSV 파일을 스트리밍으로 처리
     * 파일을 한 줄씩 읽어 메모리 사용을 최소화하고, 청크 단위로 비즈니스 로직을 실행합니다.
     */
    public void processLargeCsvFile(String fileName) {
        // 로그 생략
        
        // try-with-resources 구문으로 안전하게 리소스 해제 보장
        try (BufferedReader reader = createBufferedReader(fileName)) { 
            // 생략
            
            // 청크 처리 후 메모리 해제 및 GC 호출은 배치 처리와 동일한 목적으로 수행됩니다.
            if (lineCount % 10000 == 0) {
                System.gc(); // 메모리 정리 요청
                // logMemoryUsage("Processed " + lineCount + " lines");
            }
            // ...
            
        } catch (IOException e) {
            log.error("Error processing CSV file: {}", fileName, e);
            throw new RuntimeException("Failed to process CSV file", e);
        }
    }
    // ... 생략하기
}

3. 페이징 처리

설명: 데이터베이스를 대상으로 할 때, Spring Data JPA의 Pageable 인터페이스나 MyBatis의 커서 기능을 활용하여 데이터베이스 레코드를 잘라서 가져옵니다.
LimitOffset을 사용하는 일반적인 페이징 방식은 대규모 데이터셋에서는 성능 저하가 발생할 수 있으므로, 커서 기반 또는 No-Offset 방식을 고려하는 것이 좋다 생각을 합니다.

@Service
@RequiredArgsConstructor
@Slf4j
public class PaginationDataProcessor {
    // ... (필드, Repository 생략)

    /**
     * 페이징을 이용한 대용량 데이터 처리
     * PageRequest.of()를 사용하여 한 번에 처리할 메모리 양을 제한합니다.
     */
    public void processAllData() {
        // ... 생략
        
        // do-while 루프를 통해 hasNext()가 false가 될 때까지 반복
        do {
            // Spring Data JPA의 Pageable 사용 Limit/Offset 기반
            Page<DataEntity> dataPage = dataRepository.findAll(PageRequest.of(page, pageSize));
            
            if (!dataPage.getContent().isEmpty()) {
                // 페이지 처리
                processPage(dataPage.getContent()); 
                // ... 로그, 메모리 모니터링, GC 가 사용됩니다.
            }
            
            page++;
            
            // dataPage.hasNext()가 페이징을 종료하는 안전한 조건입니다.
        } while (dataPage.hasNext());
        
        // ... 생략
    }
    // ... 생략
}

🔐 민감한 정보 보호를 위한 통합 마스킹 하기

민감한 정보를 로그, 응답, 데이터베이스에 저장하기 전 노출되지 않도록 마스킹하는 것은 정보 보안의 기본입니다.

1. 통합 마스킹 유틸리티

설명: 사업자번호, 이메일, 전화번호, 이름 등 다양한 민감 정보를 일관된 방식으로 마스킹하는 유틸리티 클래스를 구현합니다.
정규 표현식Pattern.compile()을 사용하여 문자열 내에서 민감한 정보를 정확하게 식별하고 마스킹하는 것이 핵심입니다.

@Component
@Slf4j
public class DataMaskingUtil {
    // 생략
    
    /**
     * 전화번호 마스킹 (010-1234-5678 -> 010-****-5678)
     * 정규식을 사용하여 그룹 캡처 를 통해 원하는 패턴만 마스킹합니다.
     */
    public static String maskPhone(String phone) {
        if (isNullOrEmpty(phone)) return phone;
        
        // 정규 표현식의 백레퍼런스를 이용해 그룹을 재사용하고 중간 그룹만 마스킹합니다.
        return phone.replaceAll("(\\d{3})-?(\\d{4})-?(\\d{4})", "$1-****-$3");
    }
    
    /**
     * 이름 마스킹
     * 한국어 이름 패턴에 맞게 첫 글자와 마지막 글자를 제외하고 마스킹합니다.
     */
    public static String maskName(String name) {
        // 생략
        return masked.toString();
    }
    
    // 생략하기
}

2. 로그 마스킹 AOP

설명: AOP를 사용하여 모든 서비스, 컨트롤러 메서드 호출 전후에 전달되는 인자와 반환 값을 자동으로 가로채 민감 정보를 마스킹할 수 있습니다.
이는 개발자가 매번 log.info()를 호출할 때 마스킹 코드를 추가하는 실수를 방지하고,
보안 로직을 횡단 관심사(Cross-cutting Concern)로 분리하여 코드의 응집도를 높여줍니다.

@Aspect
@Component
@Slf4j
public class LogMaskingAspect {
    // ... 생략하기

    /**
     * 메서드 호출 로그 마스킹
     * @Around 어드바이스를 사용하여 메서드 실행 전후에 인자를 검사하고 마스킹된 인자값으로 로그를 남깁니다.
     */
    @Around("execution(* com.antock..*.*(..))") // 패키지 범위 지정
    public Object maskSensitiveDataInLogs(ProceedingJoinPoint joinPoint) throws Throwable {
        // 인자값 마스킹
        Object[] maskedArgs = maskArguments(joinPoint.getArgs());
        
        log.debug("Method: {}, Args: {}", joinPoint.getSignature().getName(), Arrays.toString(maskedArgs));
        
        Object result = joinPoint.proceed(); // 원본 메서드 실행
        
        // 결과값 마스킹
        // Object maskedResult = maskResult(result); 
        
        return result; // 실제 결과 반환
    }
    // ... 생략하기
}

3. Jackson 커스터마이징

설명: API 응답으로 나가는 JSON/XML 데이터에 포함된 민감 정보를 마스킹하기 위해 Jackson 라이브러리의 커스터마이징 기능을 사용합니다.
DTO/Response 클래스 필드 위에 @JsonSerialize 어노테이션과 커스텀 시리얼라이저를 적용하면, 해당 필드가 직렬화될 때마다 자동으로 마스킹됩니다.
이를 통해 API 게이트웨이API 응답 로깅 단계에서 민감 정보 노출을 근본적으로 차단할 수 있습니다.

// ... 생략하기

/**
 * 법인 정보 마스킹 Mixin
 * 실제 DTO/Entity 클래스에는 영향을 주지 않고 마스킹 로직을 주입합니다.
 */
@JsonIgnoreType
public static class CorpMastMaskingMixin {
    @JsonProperty("bizNo")
    // bizNo 필드가 JSON으로 직렬화될 때 BizNoMaskingSerializer가 실행됩니다.
    @JsonSerialize(using = BizNoMaskingSerializer.class) 
    private String bizNo;
    
    // 생략하기
}

/**
 * 사업자번호 마스킹 시리얼라이저
 */
public class BizNoMaskingSerializer extends JsonSerializer<String> {
    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) 
            throws IOException {
        // DataMaskingUtil을 사용하여 마스킹된 문자열을 JSON 출력합니다.
        gen.writeString(DataMaskingUtil.maskBizNo(value)); 
    }
}
// ... 생략하기

⚡ 시스템 안정성 및 성능 최적화

대용량 처리를 안정적으로 수행하고 전체 시스템의 응답 속도를 개선하기 위한 추가적인 입니다.

1. JVM 튜닝 및 GC 설정

설명: JVM 메모리 설정을 최적화하여 OOM을 방지하고, GC일시 정지 시간을 최소화해야 합니다.
특히 대용량 처리가 많은 환경에서는 G1GC(Garbage First Garbage Collector)를 사용하는 것이 일반적이며,
-XX:MaxGCPauseMillis 옵션으로 GC 일시 정지 목표 시간을 설정하여 애플리케이션 반응성을 개선할 수 있습니다.

옵션설명권장 값
-Xms초기 Heap Size물리 메모리의 1/4 ~ 1/2
-Xmx최대 Heap Size물리 메모리의 1/2 ~ 3/4
-XX:+UseG1GCG1GC 사용대용량/다중 코어 환경에서 권장
-XX:MaxGCPauseMillisGC 일시 정지 목표 시간100 ~ 200ms
# 운영 환경 예시: 초기 2GB, 최대 8GB 할당, G1GC 사용
java -Xms2g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+UseStringDeduplication -jar app.jar

2. 데이터베이스 최적화

설명: JPA/Hibernate 사용 시 대용량의 데이터 저장/수정 작업은 JDBC Batching을 활용하여 성능을 대폭 향상시킬 수 있습니다.
여러 개의 INSERT/UPDATE 쿼리를 하나의 배치로 묶어 DB에 한 번에 전송함으로써 네트워크 통신 횟수를 줄입니다.

# application.yml
spring:
  datasource:
    # HikariCP 설정: 커넥션 풀을 적절히 관리하여 DB 부하 분산
    hikari:
      maximum-pool-size: 20 # 최대 커넥션 수
      # 생략하기
      
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000 # 한 번에 묶을 SQL 쿼리 수
        order_inserts: true # Insert 쿼리 순서 최적화
        order_updates: true # Update 쿼리 순서 최적화
        batch_versioned_data: true # Versioned 데이터도 Batching 허용

3. 캐시 전략 (Caffeine/Redis)

설명: 반복적으로 조회되는 데이터, 특히 대용량 데이터 처리 과정에서 반복 호출되는 공통 코드, 설정값, 마스터 데이터 등은 캐싱하여 데이터베이스 부하를 줄여야 합니다.
Spring Boot에서는 Caffeine(로컬 캐시)이나 Redis(분산 캐시)를 활용해 효율적인 캐싱 전략을 구축합니다.

@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        // CaffeineCacheManager를 사용하여 JVM 메모리 내부에 빠른 캐시를 구축합니다.
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(10000) // 최대 캐시 엔트리 수
                .expireAfterWrite(5, TimeUnit.MINUTES) // 쓰기 후 5분 만료
                .recordStats());
        return cacheManager;
    }
}

🎯 결론

이 가이드를 통해 Spring Boot 애플리케이션에서 대용량 데이터 처리 시 메모리 효율성을 확보하는 방법과, AOP, Jackson 시리얼라이저 등을 활용하여 민감한 정보를 안전하게 보호하는 통합 보안 전략을 모두 구현할 수 있습니다.

  1. 데이터 처리: 데이터의 성격에 따라 배치, 스트리밍, 페이징 기법을 선택하고, 각 처리 단위가 끝날 때마다 메모리 해제를 고민하게 되었습니다..
  2. 보안: DataMaskingUtil을 중심으로 AOPJackson 커스터마이징을 통해 로그 및 응답 데이터의 민감 정보 노출을 원천적으로 차단해야 합니다.
  3. 최적화: JVM 튜닝(G1GC)DB Batching 설정을 통해 프로덕션 환경에서의 안정성과 성능을 극대화를 최대한 노력하게되었습니다..

안정적인 대용량 처리 시스템은 곧 신뢰할 수 있는 서비스의 기반이라 생각하게 되었습니다.
제시된 포스팅을 통해 시스템을 한 단계 업그레이드 고민하게 된 계기가 되었습니다! 🚀

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글