마인크래프트 플러그인에 Hibernate JPA도입하기

Daeyoung Nam·2021년 7월 15일
3

프로젝트

목록 보기
3/16
post-thumbnail

도입하게된 계기

기본적으로 마인크래프트 플러그인에서 데이터를 저장할 때 flat file database 방식을 많이 쓴다.

flat file database란?

A flat-file database is a database stored in a file called a flat file. Records follow a uniform format, and there are no structures for indexing or recognizing relationships between records. - wikipedia

flat file은 평범한 텍스트나 텍스트와 바이너리가 혼합된 파일로서 보통 프로그램 한 줄, 또는 물리적인 매체에 하나의 레코드를 담는다

하지만 flat file database는 File I/O를 사용하게되고 서버 내에서 데이터를 자주 저장해야하는 일이 있다면 I/O 오버헤드가 발생하게된다.

또한 따로 flat file은 따로 Handler 클래스를 만들어 줘서 관리해야하기 때문에 개발 생산성 면에서도 떨어진다.

flat file의 장점은 무겁지 않은 데이터들을 저장할 때 굉장히 편리하지만 데이터를 많이 저장해야하는 경우가 있다면 비효율적인 면모를 보여줄 수 있다.

그래서 개발 생산성과 기술적 퍼포먼스 측면에서 도입하게 되었다.

프로젝트 구성

gradle multi module project로 구성하였다.

Entity Class 얻어오기

스프링 쓸때는 몰랐다..
JPA를 일반 프로젝트에 도입하려고 하니 @Entity가 붙은 클래스를 리플렉션으로 다 얻어와야 한다.
리플렉션 Util인 'Reflections'를 사용하였고

코드는 다음과 같다.

public class JpaClassScanner {

    private static final String JPA_CLASSPATH = "javax.persistence";
    private static final String PROJECT_PACKAGE_NAME = "mc.felix.*";

    private FelixJpaSupporter supporter;

    public JpaClassScanner(FelixJpaSupporter supporter) {
        this.supporter = supporter;
    }

    public SessionFactory initialize() {
        Configuration configuration = new Configuration();
        configuration.setProperties(getProperties());

        for(Class<?> clazz : scan()) {
            System.out.println("class: " + clazz);
            configuration.addAnnotatedClass(clazz);
        }

        return configuration.buildSessionFactory();
    }

    public Properties getProperties() {
        Properties settings = new Properties();

        settings.setProperty(Environment.DRIVER, "com.mysql.jdbc.Driver");
        settings.setProperty(Environment.URL, supporter.getDatabaseUrl());
        settings.setProperty(Environment.USER, supporter.getDatabaseUser());
        settings.setProperty(Environment.PASS, supporter.getDatabasePassword());
        settings.setProperty(Environment.HBM2DDL_AUTO, supporter.getAction());
        settings.setProperty(Environment.SHOW_SQL, String.valueOf(supporter.isShowSQLToConsole()));
        settings.setProperty(Environment.FORMAT_SQL, String.valueOf(supporter.isShowSQLToConsole()));
        settings.setProperty(Environment.CURRENT_SESSION_CONTEXT_CLASS, "thread");
        settings.setProperty(Environment.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
        settings.setProperty(Environment.POOL_SIZE, String.valueOf(supporter.getPoolSize()));

        return settings;
   }

    public Set<Class<?>> scan() {
        List<ClassLoader> classLoaders = new LinkedList<>();

        classLoaders.add(ClasspathHelper.contextClassLoader());
        classLoaders.add(ClasspathHelper.staticClassLoader());

        Reflections jpaClassReflection = getJpaReflectionHelper();
        Set<Class<? extends Annotation>> jpaAnnotations = getJpaAnnotations(jpaClassReflection);
        Set<Class<? extends Annotation>> allAnnotations = getAllAnnotations(jpaAnnotations);

        ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
                .setScanners(new SubTypesScanner(false), new ResourcesScanner(), new TypeAnnotationsScanner())
                .setUrls(ClasspathHelper.forClassLoader(classLoaders.toArray(new ClassLoader[0])))
                .filterInputsBy(new FilterBuilder().include(PROJECT_PACKAGE_NAME));
        Reflections jpaDependencyReflection = new Reflections(configurationBuilder);

        return getAnnotatedDependencyClasses(jpaDependencyReflection, allAnnotations);
    }

    private Set<Class<?>> getAnnotatedDependencyClasses(Reflections jpaDependencyReflection,
                                                                           Set<Class<? extends Annotation>> allJpaAnnotations) {
        Set<Class<?>> dependencyClasses = new HashSet<>();

        for(Class<? extends Annotation> annotation : allJpaAnnotations) {
            System.out.println(annotation.getName() + " " + jpaDependencyReflection.getTypesAnnotatedWith(annotation));
            dependencyClasses.addAll(jpaDependencyReflection.getTypesAnnotatedWith(annotation));
        }

        return dependencyClasses;
    }

    private Set<Class<? extends Annotation>> getJpaAnnotations(Reflections jpaClassReflection) {
        return jpaClassReflection.getSubTypesOf(Annotation.class);
    }

    private Set<Class<? extends Annotation>> getAllAnnotations(Set<Class<? extends Annotation>> jpaAnnotations) {
        Set<Class<? extends Annotation>> annotations = new HashSet<>();

        for(Class<? extends Annotation> annotation : jpaAnnotations) {
            if(Arrays.stream(annotation.getAnnotation(Target.class).value())
                    .anyMatch(s -> s.equals(ElementType.TYPE))) {
                annotations.add(annotation);
            }
        }

        return annotations;
    }

    private Reflections getJpaReflectionHelper() {
        return new Reflections(JPA_CLASSPATH,
                new SubTypesScanner(false),
                new TypeAnnotationsScanner()
        );
    }

}

Reflections lib으로 종속 프로젝트의 모든 패키지를 읽어와서
Entity 어노테이션이 붙은 클래스를 찾고 등록해준다.

다른 모듈에서도 사용가능한 공통 JPA Module 제공

핵심은 로드, 저장하는부분은 모두 공통적인부분이기때문에 이것을 모듈화시켜서 다른 모듈에서도 사용가능하게 만들어야 한다.

@Getter
public class FelixJpaSupporter {

    private String databaseUrl;
    private String databaseUser;
    private String databasePassword;
    private String action;
    private boolean isShowSQLToConsole;
    private int poolSize;

    protected FelixJpaSupporter(String databaseUrl,
                                String databaseUser,
                                String databasePassword,
                                String action,
                                boolean isShowSQLToConsole,
                                int poolSize) {
        this.databaseUrl = databaseUrl;
        this.databaseUser = databaseUser;
        this.databasePassword = databasePassword;
        this.action = action;
        this.isShowSQLToConsole = isShowSQLToConsole;
        this.poolSize = poolSize;
    }

    public JpaEntityProvider use() {
        JpaClassScanner scanner = new JpaClassScanner(this);
        SessionFactory sessionFactory = scanner.initialize();

        return new JpaEntityCrudProviderImpl(sessionFactory, "felix");
    }

}

다른 모듈에서 JpaSupporter만 있다면 JPA를 사용할 수 있다.

JPA ORM 제공

public interface JpaEntityProvider {

    void save(Object... jpaEntity);

    void update(Object jpaEntity);

    void delete(Object jpaEntity);

    <T> Optional<T> find(Class<T> clazz, Map<String, Object> where);

    <T> List<T> findAll(Class<T> clazz);

    void setMiddleware(DatabaseMiddlewareStrategy strategy);

}

JpaSupporter에서 JpaEntityProvider의 구현체인 JpaEntityCrudProviderImpl을 반환하는데 이는
CRUD가 구현된 클래스이다.

public class JpaEntityCrudProviderImpl implements JpaEntityProvider {

    private SessionFactory sessionFactory;
    private String database;

    private EntityManagerFactory factory;

    private DatabaseMiddlewareStrategy middlewareStrategy;

    public JpaEntityCrudProviderImpl(SessionFactory sessionFactory, String database) {
        this.sessionFactory = sessionFactory;
        this.database = database;
    }

    @Override
    public void setMiddleware(DatabaseMiddlewareStrategy strategy) {
        this.middlewareStrategy = strategy;
    }

    private EntityManagerFactory getEntityManagerFactory() {
        if(factory == null) {
            sessionFactory.getCurrentSession().beginTransaction();
            factory = sessionFactory.getCurrentSession().getEntityManagerFactory();
        }

        return factory;
    }

    @Override
    public void save(Object... jpaEntity) {
        EntityManagerFactory entityManagerFactory = getEntityManagerFactory();
        EntityManager entityManager = entityManagerFactory.createEntityManager();
        EntityTransaction transaction = entityManager.getTransaction();

        transaction.begin();
        for(Object obj : jpaEntity) {
            entityManager.persist(obj);

            if(middlewareStrategy != null) {
                middlewareStrategy.onDataLoaded(obj);
            }
        }
        transaction.commit();

        entityManager.close();
    }

    @Override
    public void update(Object jpaEntity) {
        EntityManagerFactory entityManagerFactory = getEntityManagerFactory();
        EntityManager entityManager = entityManagerFactory.createEntityManager();
        EntityTransaction transaction = entityManager.getTransaction();

        transaction.begin();

        entityManager.merge(jpaEntity);
        if(middlewareStrategy != null) {
            middlewareStrategy.onDataUpdated(jpaEntity);
        }

        transaction.commit();

        entityManager.close();
    }

    @Override
    public void delete(Object jpaEntity) {
        EntityManagerFactory entityManagerFactory = getEntityManagerFactory();
        EntityManager entityManager = entityManagerFactory.createEntityManager();

        entityManager.remove(jpaEntity);
        if(middlewareStrategy != null) {
            middlewareStrategy.onDataDeleted(jpaEntity);
        }
    }

    @Override
    public <T> Optional<T> find(Class<T> clazz, Map<String, Object> where) {
        EntityManager entityManager = getEntityManagerFactory().createEntityManager();
        CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
        CriteriaQuery<T> query = criteriaBuilder.createQuery(clazz);
        Root<T> root = query.from(clazz);

        query.select(root);
        query.where(makeWherePredicates(criteriaBuilder, root, where));

        List<T> entities = entityManager.createQuery(query).getResultList();
        Optional<T> result = entities.size() > 0 ? Optional.of(entities.get(0)) : Optional.empty();
        if(result.isPresent()) {
            middlewareStrategy.onDataLoaded(result.get());
        }

        entityManager.close();

        return result;
    }

    @Override
    public <T> List<T> findAll(Class<T> clazz) {
        EntityManager entityManager = getEntityManagerFactory().createEntityManager();
        CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
        CriteriaQuery<T> query = criteriaBuilder.createQuery(clazz);
        Root<T> root = query.from(clazz);

        query.select(root);
        List<T> entities = entityManager.createQuery(query).getResultList();
        entityManager.close();

        return entities;
    }

    private Predicate[] makeWherePredicates(CriteriaBuilder criteriaBuilder, Root root, Map<String, Object> where) {
        Predicate[] predicates = new Predicate[where.size()];

        int i = 0;
        for(Map.Entry<String, Object> entry : where.entrySet()) {
            predicates[i++] = criteriaBuilder.equal(root.get(entry.getKey()), entry.getValue());
        }

        return predicates;
    }

}

CRUD 기능에 더하여 중간에 데이터를 catch/handle할 수 있는 Middleware를 개발하였는데 이는 다른 모듈에서 오고가는 데이터를 중간에 컨트롤 할 수있게 하기위해 구조를 확장한케이스이다.

미들웨어를 왜 추가했냐면 사실 데이터베이스에서 오고가는 데이터중 굳이 db에 접근하지 않아도 캐싱하여 바로 값을 얻어오게 하기 위함이었다.

Jpa 모듈에 공통 캐싱 모듈만 구현하면 되지않냐? 라고 물어볼 수 있겠다.
하지만 어떤 모듈은 캐싱을 하지 않아도 되는 모듈이있고 캐싱을 하여 더 효율적으로 데이터 컨트롤을 해야하는 모듈이 있기 때문에 캐싱 구현책임을 하위 모듈로 위임한것이다.

그래서 미들웨어 같은 경우는 여러가지 전략의 미들웨어를 사용하기 때문에 전략패턴을 도입하여 개발하였다.

JPA의 EntityManager

JPA의 대부분의 기능은 EntityManager가 가지고 있다. (CRUD)
EntityManager는 다음과 같이 선언할 수 있다.

public EntityManager createEntityManager(EntityManagerFactory factory) {
  EntityManager entityManager = factory.createEntityManager();
  return entityManager;
}

EntityManagerFactory는 DB당 1개의 인스턴스만 사용하고 EntityManager는 EntityManagerFactory로부터 계속 생성할 수 있다.

구조

마치며

JPA를 도입하여 Spring boot가 얼마나 편한 프레임워크인지 깨닫게 되었다. 또한 소프트웨어의 구조적인 면을 1~10까지 직접 설계할 수 있는 마인크래프트 개발은 항상 재미있다.

profile
내가 짠 코드가 제일 깔끔해야하고 내가 만든 서버는 제일 탄탄해야한다 .. 😎

0개의 댓글