1. 개발 환경
SpringBootMybatisPlusMySQL
2. 개요
이 솔루션은 다음과 같은 기능을 제공합니다:
@Scheduled어노테이션을 사용한 일일 스케줄러를 통해 현재 데이터베이스의 모든 파티션 테이블을 조회합니다(여기서는 시간 범위로 파티셔닝).- 남은 파티션 수가 설정된 예비 파티션 수보다 적은 경우(사용자 정의 예비 파티션이 없는 경우 기본 설정 사용), 필요한 파티션을 자동으로 생성하여 설정된 개수를 유지합니다.
- 존재하는 파티션 수가 설정된 보관 파티션 수보다 많은 경우(사용자 정의 보관 파티션이 없는 경우 기본 설정 사용), 초과된 파티션을 자동으로 삭제하여 설정된 개수를 유지합니다.
주요 목적은 일일 파티션 생성/삭제 운영 작업을 자동화하는 것입니다.
여기서 파티션 테이블은 대용량 텍스트 필드를 포함한 로그 레코드 데이터 저장에 주로 사용됩니다.
파티션 판단은 현재 시간을 기준으로 필요한 예비/보관 수량을 계산합니다.
파티셔닝을 통한 자동 정리를 통해delete작업으로 인한 데이터베이스 조각화율 증가 문제를 방지할 수 있습니다.
3. 파티션 테이블 소개
MySQL의 파티션 테이블은 대용량 테이블을 여러 소형 테이블로 분할하는 기술입니다. 논리적으로는 하나의 테이블이지만 물리적으로는 여러 독립적인 테이블입니다. 각 소형 테이블을 파티션이라고 하며, 서로 다른 물리적 위치에 배치되고 다른 스토리지 엔진을 사용할 수 있습니다.
3.1. 파티션 방식 종류
MySQL의 파티션 테이블은 다음 방식으로 분할할 수 있습니다:
- 범위 파티셔닝(
RANGE partitioning): 특정 컬럼의 값 범위에 따라 데이터를 분할합니다. 예: 날짜 범위나 숫자 범위로 분할. - 리스트 파티셔닝(
LIST partitioning): 특정 컬럼의 값 목록에 따라 데이터를 분할합니다. 예: 국가나 지역 목록으로 분할. - 해시 파티셔닝(
HASH partitioning): 특정 컬럼의 해시 값을 기준으로 데이터를 분할합니다. 예: 사용자 ID의 해시 값으로 분할. - 키 파티셔닝(
KEY partitioning): 특정 컬럼의 키 값을 기준으로 데이터를 분할합니다. 해시나 범위 기반의 파티셔닝 방식입니다.
파티션 테이블 생성 시에는 파티션 키(
partition key)를 지정해야 합니다. 파티션 키는 파티셔닝에 사용되는 컬럼이며, 하나 또는 여러 컬럼의 조합일 수 있습니다. 파티션 방식도 조합될 수 있으며, 파티션 키는 정수형, 날짜, 문자열 등 모든 데이터 타입이 될 수 있습니다.
3.2. 파티션 테이블의 장점:
- 쿼리 성능 향상: 병렬 처리를 통해 쿼리 효율성을 높입니다.
- 유지보수성 및 가용성 향상: 특정 파티션만 독립적으로 유지보수, 최적화, 복구가 가능하며 다른 파티션에 영향을 주지 않습니다.
- 분산 데이터베이스의 수평 확장 능력 개선: 데이터를 서로 다른 노드에 분산 배치하여 시스템의 확장성과 부하 분산 능력을 향상시킵니다.
3.3. 파티션 테이블의 단점:
- 관리 및 유지보수가 상대적으로 복잡: 파티션 설계와 분할 전략, 데이터 마이그레이션, 백업 및 복구 등을 고려해야 합니다.
- 쿼리 성능이 파티션 키에 제약: 쿼리 조건이 파티션 키를 포함할 경우 해당 파티션만 조회되므로 다른 파티션은 활용되지 않아 쿼리 성능이 저하될 수 있습니다.
- 추가적인 저장 공간 및 IO 오버헤드 발생 가능성: 각 파티션이 독립적인 테이블이므로 일정한 중복 저장 공간과 IO 오버헤드가 발생할 수 있습니다.
종합적으로 보면,
MySQL의 파티션 테이블은 쿼리 성능, 유지보수성, 가용성을 향상시킬 수 있지만, 적절한 파티션 키와 분할 전략을 고려하고 추가적인 관리 및 유지보수 작업이 필요합니다.
4. 파티션 테이블 관리
4.1. 파티션 테이블 생성
CREATE TABLE 테이블명 (
컬럼1 데이터타입,
컬럼2 데이터타입,
...
)
PARTITION BY RANGE(파티션_컬럼) (
PARTITION p1 VALUES LESS THAN (값1),
PARTITION p2 VALUES LESS THAN (값2),
...
);
4.2. 파티션 테이블 수정
ALTER TABLE 테이블명
ADD PARTITION (PARTITION 파티션명 VALUES LESS THAN (값));
4.3. 파티션 테이블 삭제
ALTER TABLE 테이블명
DROP PARTITION 파티션명;
4.4. 파티션 테이블 조회
SELECT
TABLE_NAME AS tableName
FROM
INFORMATION_SCHEMA.PARTITIONS
WHERE
TABLE_SCHEMA = DATABASE()
AND PARTITION_EXPRESSION IS NOT NULL
GROUP BY
TABLE_NAME;
5. 자동 운영 기능 구현
스케줄러를 통해 일일 자동 추가/삭제 파티션 테이블 파티션
5.1. 설정 파일
# 파티션 추가 설정
table.partition.config.addEnable=true
table.partition.config.addMap.default=10
table.partition.config.addMap.zk_user_log_ext=10
# 파티션 정리 설정
table.partition.config.cleanEnable=true
table.partition.config.cleanMap.default=3
table.partition.config.cleanMap.zk_user_log_ext=3
5.2. 메인 진입점
스케줄러 PartitionScheduler
package com.example.scheduler;
import com.example.model.PartitionInfo;
import com.example.service.TablePartitionService;
import com.example.util.TimeUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ThreadPoolExecutor;
@Component
@ConfigurationProperties(prefix = "table.partition.config")
@Slf4j
public class PartitionScheduler {
private static final String PART_PREFIX = "p";
private static final String DEFAULT_CONFIG = "default";
@Autowired
private TablePartitionService tablePartitionService;
@Resource(name = "partition-executor")
private ThreadPoolExecutor executor;
private boolean addEnable;
private Map<String, Integer> addMap;
private boolean cleanEnable;
private Map<String, Integer> cleanMap;
@Scheduled(cron = "0 1 0 * * ?")
public void execute() {
long startTime = System.currentTimeMillis();
log.info("테이블 파티션 추가 및 정리 시작...");
Date currentTime = new Date();
List<PartitionInfo> partitionList = tablePartitionService.getAllPartitionTables();
for (PartitionInfo partition : partitionList) {
executor.execute(() -> {
try {
if (addEnable) {
createNewPartitions(partition, currentTime);
}
if (cleanEnable) {
removeOldPartitions(partition, currentTime);
}
} catch (ParseException e) {
throw new RuntimeException(e);
}
});
}
log.info("테이블 파티션 추가 및 정리 완료, 소요 시간:{}ms", System.currentTimeMillis() - startTime);
}
private void createNewPartitions(PartitionInfo partition, Date now) throws ParseException {
Integer requiredDays = addMap.get(partition.getTableName());
if (Objects.isNull(requiredDays)) {
requiredDays = addMap.get(DEFAULT_CONFIG);
}
Date latestPartitionDate = TimeUtils.parseWithoutSeparator(partition.getMaxPartition());
long remainingDays = TimeUtils.calculateDaysDifference(now, latestPartitionDate);
if (remainingDays < requiredDays) {
int partitionsToAdd = requiredDays - (int) remainingDays;
for (int i = 1; i <= partitionsToAdd; i++) {
Date newDate = TimeUtils.addDays(latestPartitionDate, i);
String partitionName = PART_PREFIX + TimeUtils.formatWithoutSeparator(newDate);
tablePartitionService.addPartition(
partition.getTableName(),
partitionName,
TimeUtils.formatWithSeparator(TimeUtils.addDays(newDate, 1))
);
log.info("{} 테이블에 {} 파티션 추가 완료!", partition.getTableName(), partitionName);
}
}
}
private void removeOldPartitions(PartitionInfo partition, Date now) throws ParseException {
Integer retentionDays = cleanMap.get(partition.getTableName());
if (Objects.isNull(retentionDays)) {
retentionDays = cleanMap.get(DEFAULT_CONFIG);
}
Date earliestPartitionDate = TimeUtils.parseWithoutSeparator(partition.getMinPartition());
long existingDays = TimeUtils.calculateDaysDifference(earliestPartitionDate, now);
if (existingDays > retentionDays) {
int partitionsToRemove = (int) existingDays - retentionDays;
for (int i = 0; i < partitionsToRemove; i++) {
Date removeDate = TimeUtils.addDays(earliestPartitionDate, i);
String partitionName = PART_PREFIX + TimeUtils.formatWithoutSeparator(removeDate);
tablePartitionService.removePartition(partition.getTableName(), partitionName);
log.info("{} 테이블에서 {} 파티션 삭제 완료!", partition.getTableName(), partitionName);
}
}
}
// getter/setter 생략
}
5.3. 파티션 테이블 작업 구현
5.3.1. TablePartitionService
package com.example.service;
import com.example.model.PartitionInfo;
import java.util.List;
public interface TablePartitionService {
List<PartitionInfo> getAllPartitionTables();
boolean addPartition(String tableName, String partitionName, String partitionValue);
boolean removePartition(String tableName, String partitionName);
}
5.3.2. TablePartitionServiceImpl
package com.example.service.impl;
import com.example.mapper.PartitionMapper;
import com.example.model.PartitionInfo;
import com.example.service.TablePartitionService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TablePartitionServiceImpl implements TablePartitionService {
@Autowired
private PartitionMapper partitionMapper;
@Override
public List<PartitionInfo> getAllPartitionTables() {
return partitionMapper.selectPartitionTables();
}
@Override
@Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public boolean addPartition(String tableName, String partitionName, String partitionValue) {
partitionMapper.createPartition(tableName, partitionName, partitionValue);
return true;
}
@Override
@Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public boolean removePartition(String tableName, String partitionName) {
partitionMapper.deletePartition(tableName, partitionName);
return true;
}
}
@Retryable은 재시도 프레임워크로, 파티션 추가/삭제 시 테이블 잠금 획득 실패를 방지합니다.
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
5.3.3. PartitionMapper
package com.example.mapper;
import com.example.model.PartitionInfo;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface PartitionMapper {
@Select("SELECT TABLE_NAME AS tableName, " +
"max(right(PARTITION_NAME,8)) AS maxPartition, " +
"min(right(PARTITION_NAME,8)) AS minPartition " +
"FROM INFORMATION_SCHEMA.PARTITIONS " +
"WHERE TABLE_SCHEMA = DATABASE() AND PARTITION_EXPRESSION IS NOT NULL " +
"GROUP BY TABLE_NAME")
List<PartitionInfo> selectPartitionTables();
@Update("ALTER TABLE ${tableName} ADD PARTITION (PARTITION ${partitionName} VALUES LESS THAN (#{partitionValue}))")
void createPartition(@Param("tableName") String tableName,
@Param("partitionName") String partitionName,
@Param("partitionValue") String partitionValue);
@Update("ALTER TABLE ${tableName} DROP PARTITION ${partitionName}")
void deletePartition(@Param("tableName") String tableName,
@Param("partitionName") String partitionName);
}
5.3.4. 엔티티 클래스
PartitionInfo
package com.example.model;
import lombok.Data;
@Data
public class PartitionInfo {
private String tableName;
private String maxPartition;
private String minPartition;
}
5.3.5. 스레드 풀 설정
ThreadPoolConfiguration
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;
@Configuration
public class ThreadPoolConfiguration {
@Bean("partition-executor")
public ThreadPoolExecutor partitionExecutor() {
return new ThreadPoolExecutor(
10, 20, 10,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new CustomThreadFactory("partition-worker"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
public static class CustomThreadFactory implements ThreadFactory {
private final String poolName;
private final AtomicInteger threadCounter = new AtomicInteger(1);
public CustomThreadFactory(String name) {
this.poolName = name;
}
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable);
thread.setName(poolName + "-" + threadCounter.getAndIncrement());
return thread;
}
}
}
5.3.6. 날짜 변환 유틸리티 클래스
TimeUtils
package com.example.util;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.Calendar;
import java.util.Date;
public class TimeUtils {
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final String DATE_FORMAT_NO_SEPARATOR = "yyyyMMdd";
private static final String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static Date parse(String dateString, String format) throws ParseException {
return new SimpleDateFormat(format).parse(dateString);
}
public static Date parseWithSeparator(String dateString) throws ParseException {
return parse(dateString, DATE_FORMAT);
}
public static Date parseWithoutSeparator(String dateString) throws ParseException {
return parse(dateString, DATE_FORMAT_NO_SEPARATOR);
}
public static Date parseDateTime(String dateString) throws ParseException {
return parse(dateString, DATETIME_FORMAT);
}
public static String format(Date date, String format) {
return new SimpleDateFormat(format).format(date);
}
public static String formatWithSeparator(Date date) {
return format(date, DATE_FORMAT);
}
public static String formatWithoutSeparator(Date date) {
return format(date, DATE_FORMAT_NO_SEPARATOR);
}
public static String formatDateTime(Date date) {
return format(date, DATETIME_FORMAT);
}
public static Date addDays(Date date, int days) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.DAY_OF_MONTH, days);
return calendar.getTime();
}
public static Date addHours(Date date, int hours) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(Calendar.HOUR_OF_DAY, hours);
return calendar.getTime();
}
public static long calculateDaysDifference(Date startDate, Date endDate) {
LocalDate start = startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate end = endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
return ChronoUnit.DAYS.between(start, end);
}
public static long calculateSecondsDifference(Date startDate, Date endDate) {
LocalDateTime start = startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
LocalDateTime end = endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return Duration.between(start, end).getSeconds();
}
}
5.4. 파티션 테이블 예제
user_operation_log_detail
CREATE TABLE `user_operation_log_detail` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '자동 증가 ID',
`log_id` bigint NOT NULL COMMENT '운영 로그 ID',
`content` mediumtext NOT NULL COMMENT '로그 상세 정보',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
`log_date` date NOT NULL COMMENT '로그 날짜',
`creator_id` bigint NOT NULL DEFAULT '0' COMMENT '생성자',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시간',
`updater_id` bigint NOT NULL DEFAULT '0' COMMENT '수정자',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '삭제 여부:0아니오;1예',
`notes` varchar(255) NOT NULL DEFAULT '' COMMENT '비고',
`version` bigint NOT NULL DEFAULT '0' COMMENT '버전 번호',
PRIMARY KEY (`id`,`log_date`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='운영 로그 상세 테이블'
PARTITION BY RANGE COLUMNS(log_date)
(PARTITION p20240722 VALUES LESS THAN ('2024-07-23') ENGINE = InnoDB,
PARTITION p20240723 VALUES LESS THAN ('2024-07-24') ENGINE = InnoDB,
PARTITION p20240724 VALUES LESS THAN ('2024-07-25') ENGINE = InnoDB,
PARTITION p20240725 VALUES LESS THAN ('2024-07-26') ENGINE = InnoDB,
PARTITION p20240726 VALUES LESS THAN ('2024-07-27') ENGINE = InnoDB,
PARTITION p20240727 VALUES LESS THAN ('2024-07-28') ENGINE = InnoDB,
PARTITION p20240728 VALUES LESS THAN ('2024-07-29') ENGINE = InnoDB,
PARTITION p20240729 VALUES LESS THAN ('2024-07-30') ENGINE = InnoDB,
PARTITION p20240730 VALUES LESS THAN ('2024-07-31') ENGINE = InnoDB,
PARTITION p20240731 VALUES LESS THAN ('2024-08-01') ENGINE = InnoDB,
PARTITION p20240801 VALUES LESS THAN ('2024-08-02') ENGINE = InnoDB,
PARTITION p20240802 VALUES LESS THAN ('2024-08-03') ENGINE = InnoDB,
PARTITION p20240803 VALUES LESS THAN ('2024-08-04') ENGINE = InnoDB,
PARTITION p20240804 VALUES LESS THAN ('2024-08-05') ENGINE = InnoDB);
여기서는 범위 파티셔닝을 사용합니다.
파티션 키:log_date