MyBatis에서 @Table 및 @Column 어노테이션 기반 자동 ResultMap 생성 구현

MyBatis를 사용할 때 반복적인 XML 설정, 특히 데이터베이스 컬럼과 엔티티 속성 간의 매핑을 정의하는 ResultMap 작성이 번거롭게 느껴질 수 있습니다. 이러한 문제를 해결하기 위해, @Table@Column 어노테이션 정보를 기반으로 MyBatis의 ResultMap을 자동으로 생성하는 방법을 소개합니다.

핵심 구현 아이디어

  1. 애플리케이션 내 모든 클래스 스캔
  2. @Table 어노테이션이 적용된 엔티티 클래스 필터링
  3. @Column 어노테이션을 통해 필드와 컬럼 매핑 관계 추출
  4. MyBatis 설정 완료 후 해당 매핑 정보로 ResultMap 동적 등록

구현 코드

다음은 MyBatis 설정 시점에 ResultMap을 자동으로 추가하는 구성 클래스입니다:

import org.apache.ibatis.mapping.ResultMap;
import org.apache.ibatis.mapping.ResultMapping;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;

import javax.persistence.Table;
import java.util.*;

@Configuration
@Order(1)
public class AutoResultMapConfigurer {

    private final SqlSessionFactory sqlSessionFactory;

    public AutoResultMapConfigurer(SqlSessionFactory factory) {
        this.sqlSessionFactory = factory;
        initializeResultMaps();
    }

    private void initializeResultMaps() {
        var config = sqlSessionFactory.getConfiguration();
        List<Class<?>> entities = scanAnnotatedEntities();

        for (Class<?> entity : entities) {
            String resultMapId = entity.getName();
            List<ResultMapping> mappings = buildColumnMappings(config, entity);
            
            ResultMap resultMap = new ResultMap.Builder(config, resultMapId, entity, mappings).build();
            config.addResultMap(resultMap);
        }
    }

    private List<Class<?>> scanAnnotatedEntities() {
        return EntityScanner.findClassesWithAnnotation(Table.class);
    }

    private List<ResultMapping> buildColumnMappings(org.apache.ibatis.session.Configuration config, Class<?> entityType) {
        List<ResultMapping> mappings = new ArrayList<>();
        Map<String, Map<String, Object>> fieldInfo = ColumnMapper.extractColumnRelations(entityType);

        for (Map.Entry<String, Map<String, Object>> entry : fieldInfo.entrySet()) {
            String property = entry.getKey();
            Map<String, Object> info = entry.getValue();
            
            String columnName = (String) info.get("column");
            Class<?> javaType = (Class<?>) info.get("type");

            ResultMapping mapping = new ResultMapping.Builder(config, property, columnName, javaType).build();
            mappings.add(mapping);
        }

        return mappings;
    }
}

엔티티 스캐닝 및 어노테이션 분석 유틸리티 클래스:

import javax.persistence.Column;
import javax.persistence.Table;
import java.io.File;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

public class EntityScanner {
    
    private static final Set<Class<?>> scannedClasses = new HashSet<>();
    
    static {
        scannedClasses.addAll(scanPackage("com.example.domain"));
    }

    public static List<Class<?>> findClassesWithAnnotation(Class<? extends Annotation> annotation) {
        List<Class<?>> result = new ArrayList<>();
        
        for (Class<?> clazz : scannedClasses) {
            if (clazz.isAnnotationPresent(annotation)) {
                result.add(clazz);
            }
        }
        
        return result;
    }

    private static Set<Class<?>> scanPackage(String basePackage) {
        Set<Class<?>> classes = new HashSet<>();
        String path = basePackage.replace('.', '/');
        
        try {
            Enumeration<URL> resources = Thread.currentThread().getContextClassLoader().getResources(path);
            
            while (resources.hasMoreElements()) {
                URL resource = resources.nextElement();
                if ("file".equals(resource.getProtocol())) {
                    scanFileSystem(resource.getPath(), basePackage, classes);
                } else if ("jar".equals(resource.getProtocol())) {
                    scanJarFile(resource, path, classes);
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("클래스 스캔 중 오류 발생", e);
        }
        
        return classes;
    }

    private static void scanFileSystem(String path, String packageName, Set<Class<?>> classes) {
        File directory = new File(path);
        if (!directory.exists()) return;

        File[] files = directory.listFiles();
        if (files == null) return;

        for (File file : files) {
            if (file.isDirectory()) {
                scanFileSystem(file.getPath(), packageName + "." + file.getName(), classes);
            } else if (file.getName().endsWith(".class")) {
                String className = packageName + "." + file.getName().substring(0, file.getName().length() - 6);
                try {
                    classes.add(Class.forName(className));
                } catch (ClassNotFoundException ignored) {}
            }
        }
    }

    private static void scanJarFile(URL url, String path, Set<Class<?>> classes) throws Exception {
        JarFile jar = ((java.net.JarURLConnection) url.openConnection()).getJarFile();
        Enumeration<JarEntry> entries = jar.entries();

        while (entries.hasMoreElements()) {
            JarEntry entry = entries.nextElement();
            String name = entry.getName();
            
            if (name.startsWith(path) && name.endsWith(".class") && !entry.isDirectory()) {
                String className = name.replace('/', '.').substring(0, name.length() - 6);
                try {
                    classes.add(Class.forName(className));
                } catch (ClassNotFoundException ignored) {}
            }
        }
    }
}

class ColumnMapper {
    
    public static Map<String, Map<String, Object>> extractColumnRelations(Class<?> entityClass) {
        Map<String, Map<String, Object>> relations = new HashMap<>();

        processFields(entityClass, relations);
        processMethods(entityClass, relations);

        return relations;
    }

    private static void processFields(Class<?> clazz, Map<String, Map<String, Object>> relations) {
        for (Field field : clazz.getDeclaredFields()) {
            Column column = field.getAnnotation(Column.class);
            if (column != null) {
                Map<String, Object> info = new HashMap<>();
                info.put("column", column.name());
                info.put("type", field.getType());
                relations.put(field.getName(), info);
            }
        }
    }

    private static void processMethods(Class<?> clazz, Map<String, Map<String, Object>> relations) {
        for (Method method : clazz.getDeclaredMethods()) {
            Column column = method.getAnnotation(Column.class);
            if (column != null) {
                String fieldName = deriveFieldName(method);
                if (fieldName != null) {
                    Map<String, Object> info = new HashMap<>();
                    info.put("column", column.name());
                    info.put("type", method.getReturnType());
                    relations.put(fieldName, info);
                }
            }
        }
    }

    private static String deriveFieldName(Method method) {
        String methodName = method.getName();
        if (methodName.startsWith("get")) {
            return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
        } else if (methodName.startsWith("set")) {
            return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
        }
        return null;
    }
}

이 방식을 사용하면 엔티티 클래스에 정의된 @Table@Column 어노테이션 정보를 기반으로 MyBatis가 자동으로 ResultMap을 생성하여 XML 설정 없이도 데이터베이스 매핑을 처리할 수 있습니다.

태그: MyBatis Spring Boot JPA Annotations ResultMap automation

7월 28일 11:57에 게시됨