두 개의 물리적으로 다른 Database에 트랜잭션을 적용하기 위해서 JTA를 사용해야 한다는 것을 듣고 그 중에 Atomikos를 사용하면 된다는 이야기에 구현을 해 보았다.

구글링을 해 보아도 이런말 저런말들이 많고 예전 기준으로 구현된 것도 많고 소스들이 너무나도 달라 뭘 어떻게 따라 해야 할지 막막했는데...

그리고 주로 MyBatis 기준으로 작업이 되어 있어서 답답한 감이 있었는데 JPA 기준으로 작업을 할 수 있도록 4일 정도 삽질 끝에 완성하여 백업 차원에서 기록으로 남긴다.

 

먼저 Legacy Database와 새롭게 사용하는 Database가 있다는 가정하에 아래와 같이 작업을 해야 한다.

먼저 사용한 의존성은 아래와 같다.

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation('org.springframework.boot:spring-boot-starter-aop')
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation('org.springframework.data:spring-data-envers')
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }

    // providedRuntime('org.springframework.boot:spring-boot-starter-tomcat')
    implementation('org.hibernate:hibernate-java8:5.1.0.Final')
    implementation('org.springframework.boot:spring-boot-starter-jta-atomikos')

    compileOnly('org.projectlombok:lombok:1.18.4')
    testCompileOnly('org.projectlombok:lombok:1.18.4')

    runtimeOnly 'mysql:mysql-connector-java'
}

 

그리고 사용하게 된 설정 파일은 아래와 같다.

 

application.yml

spring:
  main:
    allow-bean-definition-overriding: true
  application:
    name: goodchoice-user
    description: "User internal api for goodchoice"
  jackson:
    time-zone: "Asia/Seoul"
  host: host주소값
  # datasource 이하 값들은 Legacy Hikari datasource를 위한 값들
  datasource:
    lw:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://legacy-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: write
      readOnly: false
    lr:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://legacy-read-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: read
      readOnly: true
  # userbenefit 이하 값들은 신규 Hikari datasource를 위한 값들
  userbenefit:
    ubw:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://new-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: write
      readOnly: false
    ubr:
      driverClassName: com.mysql.cj.jdbc.Driver
      jdbcUrl: jdbc:mysql://new-read-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
      username: id
      password: password
      maximumPoolSize: 30
      minimumIdle: 5
      poolName: read
      readOnly: true
  # jta 이하 값들은 jta atomikos에서 사용하기 위한 값들
  jta:
    enabled: true
    atomikos:
      datasource:
        lgyw:
          unique-resource-name: xaForLegacyDataSource
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource	# class로 사용되는 MysqlXADataSource 클래스는 mysql-connector-java의 버전에 따라 위치가 다르기 때문에 확인 필요
          xa-properties:
            user: id
            password: password
            url: jdbc:mysql://legacy-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
        ubgw:
          unique-resource-name: xaForUserbenefitDataSource
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
          xa-properties:
            user: id
            password: password
            url: jdbc:mysql://new-write-server:3306/databasename?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        show_sql: fase
        use_sql_comments: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL55Dialect

 

먼저 Atomikos 사용을 위해서 필요한 UserTransation object와 TransactionManager를 담을 수 있는 클래스를 만들어 보자.

 

AtomikosJtaPlatform.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import org.hibernate.engine.transaction.jta.platform.internal.AbstractJtaPlatform;

import javax.transaction.TransactionManager;
import javax.transaction.UserTransaction;

public class AtomikosJtaPlatform extends AbstractJtaPlatform {
    private static final long serialVersionUID = 1L;

    static TransactionManager transactionManager;
    static UserTransaction transaction;

    @Override
    protected TransactionManager locateTransactionManager() {
        return transactionManager;
    }

    @Override
    protected UserTransaction locateUserTransaction() {
        return transaction;
    }
}

 

위와 같이 담을 그릇이 준비 되었다면 해당 Object에 값을 담을 수 있는 클래스를 작성하자.

 

XaDataManagerConfig.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import com.atomikos.icatch.jta.UserTransactionImp;
import com.atomikos.icatch.jta.UserTransactionManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.jta.JtaTransactionManager;

import javax.transaction.TransactionManager;
import javax.transaction.UserTransaction;

@Slf4j
@Configuration
@ComponentScan
@EnableTransactionManagement
public class XaDataManagerConfig {
    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setShowSql(true);
        hibernateJpaVendorAdapter.setGenerateDdl(false);
        hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);

        return hibernateJpaVendorAdapter;
    }

    @Bean(name = "userTransaction")
    public UserTransaction userTransaction() throws Throwable {
        log.info("========= userTransaction =========");
        UserTransactionImp userTransactionImp = new UserTransactionImp();
        userTransactionImp.setTransactionTimeout(10000);

        return userTransactionImp;
    }

    @Bean(name = "atomikosTransactionManager", initMethod = "init", destroyMethod = "close")
    public TransactionManager atomikosTransactionManager() throws Throwable {
        log.info("========= atomikosTransactionManager =========");
        UserTransactionManager userTransactionManager = new UserTransactionManager();
        userTransactionManager.setForceShutdown(false);

        AtomikosJtaPlatform.transactionManager = userTransactionManager;

        return userTransactionManager;
    }

    @Bean(name = "multiTxManager")
    @DependsOn({"userTransaction", "atomikosTransactionManager"})
    public PlatformTransactionManager transactionManager() throws Throwable {
        log.info("========= transactionManager =========");
        UserTransaction userTransaction = userTransaction();

        AtomikosJtaPlatform.transaction = userTransaction;

        JtaTransactionManager manager = new JtaTransactionManager(userTransaction, atomikosTransactionManager());

        return manager;
    }
}

 

위 파일에서 중요한 부분은 AtomikosJtaPlatform.transactionManager와 AtomikosJtaPlatform.transaction에 값을 담는 부분이다.

그리고 multiTxManager bean에서 두 개의 bean (userTransation, atomikosTransactionManager)을 묶어서 JtaTransactionManager로 묶는 것이다.

 

이렇게까지 작업을 한 이후 실제 물리적으로 떨어져 있는 각각의 데이터베이스를 이용한 DataSource를 만들어 보자

 

먼저 Atomikos에서 사용하기 위한 Legacy DataSource 클래스를 작성해 보자.

파일 이름은 XaForLegacyDataSourceConfig 로 작성했다.

사용된 XA 이름은 아래 참고 링크에서 볼 수 있듯이 분산트랜잭션 처리를 위한 표준 스팩 이름이기 때문에 사용하였다.

 

https://d2.naver.com/helloworld/5812258

불러오는 중입니다...

 

XaForLegacyDataSourceConfig.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.Properties;

@Slf4j
@Configuration
@DependsOn("multiTxManager")
@EnableTransactionManagement
@EnableJpaAuditing
@EnableJpaRepositories(
        basePackages = {"kr.co.within.goodchoice.user.jta.domain.legacy"}
        , entityManagerFactoryRef = "xaEntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
@EntityScan("kr.co.within.goodchoice.user.jta.domain.legacy")
public class XaForLegacyDataSourceConfig {
    @Value("${spring.jta.atomikos.datasource.lgyw.unique-resource-name}")
    private String uniqueResourceName;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-data-source-class-name}")
    private String dataSourceClassName;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-properties.user}")
    private String user;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-properties.password}")
    private String password;

    @Value("${spring.jta.atomikos.datasource.lgyw.xa-properties.url}")
    private String url;

    @Autowired
    private JpaVendorAdapter jpaVendorAdapter;

    @Bean(name = "xaForLegacyDataSource")
    public DataSource xaForLegacyDataSource() {
        log.info("==================== xaForLegacyDataSource");
        Properties properties = new Properties();
        properties.setProperty("url", url);
        properties.setProperty("user", user);
        properties.setProperty("password", password);

        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(uniqueResourceName);
        dataSource.setXaDataSourceClassName(dataSourceClassName);
        dataSource.setXaProperties(properties);

        return dataSource;
    }

    @Bean(name = "xaEntityManagerFactory")
    @DependsOn("multiTxManager")
    public LocalContainerEntityManagerFactoryBean xaEntityManagerFactory() {
        log.info("==================== legacyEntityManagerFactory");
        Properties properties = new Properties();
        properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
        properties.put("javax.persistence.transactionType", "JTA");

        LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean();
        entityManager.setDataSource(xaForLegacyDataSource());
        entityManager.setJpaVendorAdapter(jpaVendorAdapter);
        entityManager.setPackagesToScan("kr.co.within.goodchoice.user.jta.domain.legacy");
        entityManager.setPersistenceUnitName("legacy_write_unit");
        entityManager.setJpaProperties(properties);

        return entityManager;
    }
}

 

필요한 접속 정보를 셋팅하고 해당 값으로 Datasource를 만든 다음 entityManagerFactory를 생성 한다.

접속 정보를 @Value로 가지고 와도 되지만 아래처럼 해도 된다. (해보지는 않았지만 될 듯?)

DataSource를 만드는 메소드 위에 아래와 같은 어노테이션을 붙이면 된다.

 

@ConfigurationProperties(prefix = "spring.jta.atomikos.datasource.lgyw.xa")

 

그리고 나서 중요한 것은 위에 XaDataManagerConfig.java에서 만들어 둔 multiTxManager bean에 대한 의존성을 적용해 줘야 한다.

클래스 상단과 entityManagerFactory 상단에 @DependsOn("multiTxManager") 를 넣으면 된다.

 

이 처럼 legacy쪽 Datasource 작업이 끝났다면 신규 Datasource쪽을 작업해 주면 된다. 소스는 비슷하다.

 

XaForUserbenefitDataSourceConfig.java

package kr.co.within.goodchoice.user.jta.infrastructure.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.Properties;

@Slf4j
@Primary
@Configuration
@DependsOn("multiTxManager")
@EnableTransactionManagement
@EnableJpaAuditing
@EnableJpaRepositories(
        basePackages = {"kr.co.within.goodchoice.user.jta.domain.userbenefit"}
        , entityManagerFactoryRef = "userbenefitEntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
@EntityScan("kr.co.within.goodchoice.user.jta.domain.userbenefit")
public class XaForUserbenefitDataSourceConfig {
    @Value("${spring.jta.atomikos.datasource.ubgw.unique-resource-name}")
    private String uniqueResourceName;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-data-source-class-name}")
    private String dataSourceClassName;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-properties.user}")
    private String user;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-properties.password}")
    private String password;

    @Value("${spring.jta.atomikos.datasource.ubgw.xa-properties.url}")
    private String url;

    @Autowired
    private JpaVendorAdapter jpaVendorAdapter;

    @Primary
    @Bean(name = "xaForUserbenefitDataSource")
    public DataSource xaForUserbenefitDataSource() {
        log.info("==================== xaForUserbenefitDataSource");
        Properties properties = new Properties();
        properties.setProperty("url", url);
        properties.setProperty("user", user);
        properties.setProperty("password", password);

        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(uniqueResourceName);
        dataSource.setXaDataSourceClassName(dataSourceClassName);
        dataSource.setXaProperties(properties);

        return dataSource;
    }

    @Primary
    @Bean(name = "userbenefitEntityManagerFactory")
    @DependsOn("multiTxManager")
    public LocalContainerEntityManagerFactoryBean userbenefitEntityManagerFactory() {
        log.info("==================== userbenefitEntityManagerFactory");
        Properties properties = new Properties();
        properties.put("hibernate.transaction.jta.platform", AtomikosJtaPlatform.class.getName());
        properties.put("javax.persistence.transactionType", "JTA");

        LocalContainerEntityManagerFactoryBean entityManager = new LocalContainerEntityManagerFactoryBean();
        entityManager.setDataSource(xaForUserbenefitDataSource());
        entityManager.setJpaVendorAdapter(jpaVendorAdapter);
        entityManager.setPackagesToScan("kr.co.within.goodchoice.user.jta.domain.userbenefit");
        entityManager.setPersistenceUnitName("write_unit");
        entityManager.setJpaProperties(properties);

        return entityManager;
    }
}

 

legacy쪽 소스와 다른 점은 클래스와 각 메소드 위에 @Primary 를 사용했다는 점이다.

해당 어노테이션을 붙이지 않을 경우 오류로그에서 기대하는 datasource는 1개인데 그 이상이 들어왔다와 비슷한 오류를 보게 될 것이다.

 

이렇게까지 해 놓고 실제 사용을 해 보자.

필요한 것은 legacy에서 사용할 entity, repository, service 그리고 신규에서 사용할 entity, repository, service 이다.

또한 두 개의 서비스를 묶어 줄 또 하나의 상위 개념 service가 필요하다.

각각의 entity, repository, service는 간단하게 save만 하는 것이 필요하므로 생략하고 두 개를 묶어 줄 service의 소스 코드는 아래와 같다.

 

ApiService.java

package kr.co.within.goodchoice.user.jta.domain;

import kr.co.within.goodchoice.user.jta.domain.legacy.entity.LegacyUser;
import kr.co.within.goodchoice.user.jta.domain.legacy.service.LegacyUserService;
//import kr.co.within.goodchoice.user.jta.domain.userbenefit.entity.UserAddInfo;
//import kr.co.within.goodchoice.user.jta.domain.userbenefit.service.UserAddInfoService;
import kr.co.within.goodchoice.user.jta.domain.userbenefit.entity.UserAddInfo;
import kr.co.within.goodchoice.user.jta.domain.userbenefit.service.UserAddInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Slf4j
@Service
public class ApiService {
    @Autowired
    private LegacyUserService legacyUserService;

    @Autowired
    private UserAddInfoService userAddInfoService;

    @Transactional(propagation = Propagation.REQUIRED, transactionManager = "multiTxManager")
    public void addUser() {
        // legacy DB에서 사용되는 entity객체
        LegacyUser legacyUser = LegacyUser.builder()....build();

        // 신규에서 사용될 entity 객체
        UserAddInfo userAddInfo = UserAddInfo.builder()....build();

        legacyUserService.addUser(legacyUser);
        userAddInfoService.addUserInfo(userAddInfo);
    }
}

 

atomikos를 통해 만든 multiTxManager를 사용한다고 메소드 상단에 명시하고 각가 다른 두 개의 서비스를 호출해 보자.

userAddInfoService.addUserInfo 메소드에서는 다음과 같이 작업을 한다.

 

UserAddInfoService.java

package kr.co.within.goodchoice.user.jta.domain.userbenefit.service;

import kr.co.within.goodchoice.user.jta.domain.userbenefit.entity.UserAddInfo;
import kr.co.within.goodchoice.user.jta.domain.userbenefit.repository.UserAddInfoRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class UserAddInfoService {
    @Autowired
    private UserAddInfoRepository userAddInfoRepository;

    public void addUserInfo(UserAddInfo userAddInfo) {
        userAddInfoRepository.save(userAddInfo);
        throw new RuntimeException("add UserInfo fail...");
    }
}

 

일부러 Exception을 일으켜 보면 legacy와 신규 DB 모두에 값이 안들어 가게 될 것이고 exception 부분을 지우고 테스트 하면 잘 들어가게 될 것이다.

개인적으로 테스트 시 이상 없이 동작함을 볼 수 있었다.

 

참고로 테스트를 unitTest를 사용하지 않은 이유는 unitTest를 사용하게 될 경우 자동으로 rollback이 되기 때문이다.

 

이와는 별도로 Hikari Pool을 이용하여 Datasource와 TransactionManager를 만들어 두고 ApiService.java 파일에서 해당 transactionManager를 명시하게 된다면 원하는대로 동작하지 않고 두 Database 모두에 커밋 되거나 하나만 커밋 되는 경우를 볼 수 있다.

 

Hikari Pool을 이용하여 다수의 Datasource를 사요하는 예제는 아래의 포스트를 참고하기 바란다.

2019/08/13 - [Java/Spring] - 멀티 DataSource 접속 방법 정리

 

이상으로 삽질을 마치며 아래는 구글링 하면서 알게 된 참조 사이트 들이다. 훨씬 더 많지만 기억이 안나기 때문에 아래 사이트만 남긴다.

 

https://d2.naver.com/helloworld/5812258

https://bigzero37.tistory.com/65

 

Atomikos를 이용한 이기종 DB 트랜잭션(Springboot + Mybatis) - 2. Application 환경구성 및 샘플코드

Atomikos를 이용한 이기종 DB 트랜잭션(Springboot + Mybatis) - 2. Application 환경구성 및 샘플코드 Springboot 및 Mybatis 를 위한 Config 설정 application.properties 정의 spring.jta.enabled=true # DATAS..

bigzero37.tistory.com

https://supawer0728.github.io/2018/03/22/spring-multi-transaction/

 

(Spring)다중 DataSource 처리

서론Spring Application을 만들면서 여러 DataSource와 transaction이 존재하고 하나의 transaction 내에 commit과 rollback이 잘 동작하도록 하려면 어떻게 설정해야 할까? 실제로 구현을 해본 적은 없지만 세 가지 방법이 머릿속에 떠올랐다. @Transactional의 propagation을 이용 spring-

supawer0728.github.io

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


아래와 같은 Enum 이 있다.

public enum InterestGoodsCategoryType {
    MOTEL("MOTEL"),
    HOTEL("HOTEL"),
    PENSION("PENSION"),
    GUESTHOUSE("GUESTHOUSE"),
    ACTIVITY("ACTIVITY");

    String typeCode;
    private static List<InterestGoodsCategoryType> allList;

    InterestGoodsCategoryType(String typeCode) {
        this.typeCode = typeCode;
    }

    public String getCode() {
        return this.typeCode;
    }

    @JsonValue
    public InterestGoodsCategoryType jsonValue() {
        return this.typeCode;
    }

    public static InterestGoodsCategoryType fromValue(String value) {
        switch (value) {
            case "MOTEL":
                return InterestGoodsCategoryType.MOTEL;
            case "HOTEL":
                return InterestGoodsCategoryType.HOTEL;
            case "PENSION":
                return InterestGoodsCategoryType.PENSION;
            case "GUESTHOUSE":
                return InterestGoodsCategoryType.GUESTHOUSE;
            case "ACTIVITY":
                return InterestGoodsCategoryType.ACTIVITY;
        }
        return null;
    }

    public static List<InterestGoodsCategoryType> getAll() {
        if (Objects.isNull(allList)) {
            allList = new ArrayList<>(
                    Arrays.asList(MOTEL, HOTEL, PENSION, GUESTHOUSE, ACTIVITY)
            );
        }

        return allList;
    }
}

이럴 경우 Response에서 해당 Enum을 반환하면 typeCode 즉 MOTEL, HOTEL 등등의 코드 값이 리턴된다.

클라이언트의 요구 사항이 각 코드 값에 맞는 한글 이름을 반환해 달라고 해서 구조를 살짝 바꾸었다.

 

public enum InterestGoodsCategoryType {
    MOTEL("MOTEL", "모텔"),
    HOTEL("HOTEL", "호텔"),
    PENSION("PENSION", "펜션"),
    GUESTHOUSE("GUESTHOUSE", "게스트\n하우스"),
    ACTIVITY("ACTIVITY", "엑티\n비티");

    String typeCode;
    String typeName;
    private static List<InterestGoodsCategoryType> allList;

    InterestGoodsCategoryType(String typeCode, String typeName) {
        this.typeCode = typeCode;
        this.typeName = typeName;
    }

    public String getCode() {
        return this.typeCode;
    }

    public String getName() {
        return this.typeName;
    }

    @JsonValue
    public InterestGoodsCategoryTypeDto jsonValue() {
        return InterestGoodsCategoryTypeDto.builder().category(this.typeCode).name(this.typeName).build();
    }

    public static InterestGoodsCategoryType fromValue(String value) {
        switch (value) {
            case "MOTEL":
                return InterestGoodsCategoryType.MOTEL;
            case "HOTEL":
                return InterestGoodsCategoryType.HOTEL;
            case "PENSION":
                return InterestGoodsCategoryType.PENSION;
            case "GUESTHOUSE":
                return InterestGoodsCategoryType.GUESTHOUSE;
            case "ACTIVITY":
                return InterestGoodsCategoryType.ACTIVITY;
        }
        return null;
    }

    public static List<InterestGoodsCategoryType> getAll() {
        if (Objects.isNull(allList)) {
            allList = new ArrayList<>(
                    Arrays.asList(MOTEL, HOTEL, PENSION, GUESTHOUSE, ACTIVITY)
            );
        }

        return allList;
    }
}
@Builder
@Getter
public class InterestGoodsCategoryTypeDto {
    private String category;
    private String name;
}

 

typeCode에 더불어 typeName 이란 것을 추가 하였고, 생성자에서 이름을 받아 들이도록 수정하였다.

그리고 @JsonValue 어노테이션이 붙은 메소드에서는 별도의 DTO 객체를 통해서 구조화 되어 내려가도록 만들었다.

이렇게 만들 경우 json response는 아래와 같은 모습으로 나오게 된다.

{
    "data": [
        {
            "category": "MOTEL",
            "name": "모텔"
        },
        {
            "category": "HOTEL",
            "name": "호텔"
        },
        {
            "category": "PENSION",
            "name": "펜션"
        },
        {
            "category": "GUESTHOUSE",
            "name": "게스트\n하우스"
        },
        {
            "category": "ACTIVITY",
            "name": "엑티\n비티"
        }
    ]
}

 

하지만 RequestBody로 값이 넘어오는 경우 해당 Enum에 값을 맵핑해 주지 못하는 경우가 발생한다.

이럴 경우 어떻게 해야 하는지 고민을 하고 검색도 했는데 들인 시간에 비해 해결 방법은 너무 간단하였다.

Setter를 변형 해야 할 거라고 생각 하였지만 단 1개의 어노테이션을 붙여주면 되었다.

 

public enum InterestGoodsCategoryType {
    MOTEL("MOTEL", "모텔"),
    HOTEL("HOTEL", "호텔"),
    PENSION("PENSION", "펜션"),
    GUESTHOUSE("GUESTHOUSE", "게스트\n하우스"),
    ACTIVITY("ACTIVITY", "엑티\n비티");

    String typeCode;
    String typeName;
    private static List<InterestGoodsCategoryType> allList;

    InterestGoodsCategoryType(String typeCode, String typeName) {
        this.typeCode = typeCode;
        this.typeName = typeName;
    }

    public String getCode() {
        return this.typeCode;
    }

    public String getName() {
        return this.typeName;
    }

    @JsonValue
    public InterestGoodsCategoryTypeDto jsonValue() {
        return InterestGoodsCategoryTypeDto.builder().category(this.typeCode).name(this.typeName).build();
    }

    @JsonCreator
    public static InterestGoodsCategoryType fromValue(String value) {
        switch (value) {
            case "MOTEL":
                return InterestGoodsCategoryType.MOTEL;
            case "HOTEL":
                return InterestGoodsCategoryType.HOTEL;
            case "PENSION":
                return InterestGoodsCategoryType.PENSION;
            case "GUESTHOUSE":
                return InterestGoodsCategoryType.GUESTHOUSE;
            case "ACTIVITY":
                return InterestGoodsCategoryType.ACTIVITY;
        }
        return null;
    }

    public static List<InterestGoodsCategoryType> getAll() {
        if (Objects.isNull(allList)) {
            allList = new ArrayList<>(
                    Arrays.asList(MOTEL, HOTEL, PENSION, GUESTHOUSE, ACTIVITY)
            );
        }

        return allList;
    }
}

그렇다. @JsonCreator 어노테이션을 붙여 주면 된다.

넘어오는 RequestBody의 모습은 아래와 같다.

{
	"birthMonth" : "2019-11",
    "interestGoodsCategoryTypeList" : ["HOTEL", "PENSION"]
}

HOTEL, PENSION 과 같은 코드 값만 넘오 오더라도 해당 값을 @JsonCreator 어노테이션을 달은 메소드에서 맵핑해서 실제 Enum을 리턴해 주도록 만들면 된다.

 

오늘의 삽질은 여기까지...




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


한 개의 프로젝트에서 하나의 Database에만 접속하는 경우가 대부분이지만, 2개 이상의 데이터 베이스에 접속하는 경우도 발생하게 된다.

이럴 경우 어떻게 셋팅을 해야 하는지 정리한다.

 

application.yml 설정파일

spring:
    profiles:
        active: local
    application:
        name: usercms

application-local.yml

server:
    port: 9090
spring:
    profiles: local
    domain: localhost
    datasource-a-write:
        driverClassName: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://a-db-url:3306/a?autoReconnect=true&useSSL=false
        username: id
        password: password
    datasource-a-read:
        driverClassName: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://a-db-url:3306/a?autoReconnect=true&useSSL=false
        username: id
        password: password
    datasource-b-write:
        driverClassName: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://b-db-url:3306/b?autoReconnect=true&useSSL=false
        username: id
        password: password
    datasource-b-read:
        driverClassName: com.mysql.cj.jdbc.Driver
        jdbcUrl: jdbc:mysql://b-db-url:3306/b?autoReconnect=true&useSSL=false
        username: id
        password: password

 

Database의 read, write 구분을 위한 설정

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        String dataSourceType = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "read" : "write";
        return dataSourceType;
    }
}

 

a 서버에 접속하기 위한 DataSource

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
public class ADataSourceConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource-a-read")
    public DataSource aReadDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource-a-write")
    public DataSource aWriteDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    public DataSource aRoutingDataSource(@Qualifier("aWriteDataSource") DataSource writeDataSource, @Qualifier("aReadDataSource") DataSource readDataSource) {
        ReplicationRoutingDataSource aRoutingDataSource = new ReplicationRoutingDataSource();
        Map<Object, Object> dataSourceMap = new HashMap<Object, Object>();
        dataSourceMap.put("write", writeDataSource);
        dataSourceMap.put("read", readDataSource);
        aRoutingDataSource.setTargetDataSources(dataSourceMap);
        aRoutingDataSource.setDefaultTargetDataSource(aReadDataSource());

        return aRoutingDataSource;
    }

    @Primary
    @Bean
    public DataSource aDataSource(@Qualifier("aRoutingDataSource") DataSource routingDataSource) {
        log.debug("#### DATA SOURCE");
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

 

a entity에 a DataSource를 맵핑하기 위한 소스. a를 메인으로 사용하기 위해 @Primary 어노테이션 사용

import org.hibernate.jpa.HibernatePersistenceProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;

@Configuration
//@ComponentScan
@EnableTransactionManagement
@EnableJpaRepositories(
        basePackages = {
                "a DataSource를 이용하는 도메인의 entity가 위치한 패키지"
        },
        entityManagerFactoryRef = "aEntityManagerFactory",
        transactionManagerRef = "aTransactionManager"
)
public class ADataManagerConfig {
    @Primary
    @Bean
    public LocalContainerEntityManagerFactoryBean aEntityManagerFactory(@Qualifier("aDataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean emfb = new LocalContainerEntityManagerFactoryBean();
        emfb.setDataSource(dataSource);
        emfb.setPersistenceProvider(new HibernatePersistenceProvider());
        emfb.setPersistenceUnitName("aEntityManager");
        emfb.setPackagesToScan(
                "a DataSource를 이용하는 도메인의 entity가 위치한 패키지"
        );
        HibernateJpaVendorAdapter jpaVendorAdapter = new HibernateJpaVendorAdapter();
        jpaVendorAdapter.setShowSql(true);
        jpaVendorAdapter.setGenerateDdl(false);
//        //properties.setProperty(“hibernate.hbm2ddl.auto”, “none”);
//        Properties properties = new Properties();
//        properties.setProperty("show_sql", "true");
//        emfb.setJpaProperties(properties);
        emfb.setJpaVendorAdapter(jpaVendorAdapter);

        return emfb;
    }

    @Primary
    @Bean
    public PlatformTransactionManager aTransactionManager(EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);
        return transactionManager;
    }

    @Bean
    public PersistenceExceptionTranslationPostProcessor aExceptionTranslationPostProcessor() {
        return new PersistenceExceptionTranslationPostProcessor();
    }
}

 

b 서버에 접속하기 위한 DataSource

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Configuration
public class BDataSourceConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource-b-read")
    public DataSource bReadDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource-b-write")
    public DataSource bWriteDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    public DataSource bRoutingDataSource(@Qualifier("bWriteDataSource") DataSource writeDataSource, @Qualifier("bReadDataSource") DataSource readDataSource) {
        ReplicationRoutingDataSource bRoutingDataSource = new ReplicationRoutingDataSource();
        Map<Object, Object> dataSourceMap = new HashMap<Object, Object>();
        dataSourceMap.put("write", writeDataSource);
        dataSourceMap.put("read", readDataSource);
        bRoutingDataSource.setTargetDataSources(dataSourceMap);
        bRoutingDataSource.setDefaultTargetDataSource(bReadDataSource());

        return bRoutingDataSource;
    }

    @Primary
    @Bean
    public DataSource bDataSource(@Qualifier("bRoutingDataSource") DataSource routingDataSource) {
        log.debug("#### DATA SOURCE");
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }
}

 

b entity에 b DataSource를 맵핑하기 위한 소스

import org.hibernate.jpa.HibernatePersistenceProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;

@Configuration
//@ComponentScan
@EnableTransactionManagement
@EnableJpaRepositories(
        basePackages = {
                "b DataSource를 이용하는 도메인의 entity가 위치한 패키지"
        },
        entityManagerFactoryRef = "bEntityManagerFactory",
        transactionManagerRef = "bTransactionManager"
)
public class BDataManagerConfig {
    @Bean
    public LocalContainerEntityManagerFactoryBean bEntityManagerFactory(@Qualifier("bDataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean emfb = new LocalContainerEntityManagerFactoryBean();
        emfb.setDataSource(dataSource);
        emfb.setPersistenceProvider(new HibernatePersistenceProvider());
        emfb.setPersistenceUnitName("bEntityManager");
        emfb.setPackagesToScan(
                "b DataSource를 이용하는 도메인의 entity가 위치한 패키지"
        );
        HibernateJpaVendorAdapter jpaVendorAdapter = new HibernateJpaVendorAdapter();
        jpaVendorAdapter.setShowSql(true);
        jpaVendorAdapter.setGenerateDdl(false);
//        //properties.setProperty(“hibernate.hbm2ddl.auto”, “none”);
//        Properties properties = new Properties();
//        properties.setProperty("show_sql", "true");
//        emfb.setJpaProperties(properties);
        emfb.setJpaVendorAdapter(jpaVendorAdapter);

        return emfb;
    }

    @Bean
    public PlatformTransactionManager bTransactionManager(EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager transactionManager = new JpaTransactionManager();
        transactionManager.setEntityManagerFactory(entityManagerFactory);
        return transactionManager;
    }

    @Bean
    public PersistenceExceptionTranslationPostProcessor bExceptionTranslationPostProcessor() {
        return new PersistenceExceptionTranslationPostProcessor();
    }
}

 

이와 같이 하게 되면 2개의 별도 DB에 접속할 수 있다.

다만 QueryDSL을 사용하게 될 경우 기존에는 QuerydslRepositorySupport을 상속받아 사용했는데, 이렇게 되면 2개 중 어떤 것을 사용해야 할지 몰라 에러를 내게 된다.

따라서 다음과 같이 QuerydslRepositorySupport을 상속받아 특정 메소드를 OverRide 한 클래스를 만들어야 한다.

그리고 QueryDSL을 사용하는 곳에서는 새롭게 상속받아 작성한 클래스를 상속받아 사용하면 된다.

 

a QuerydslRepositorySupport

import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Repository
public abstract class AQueryDslRepositorySupport extends QuerydslRepositorySupport {
    public AQueryDslRepositorySupport(Class<?> domainClass) {
        super(domainClass);
    }

    @Override
    @PersistenceContext(unitName = "aEntityManager")
    public void setEntityManager(EntityManager entityManager) {
        super.setEntityManager(entityManager);
    }
}

 

b QuerydslRepositorySupport

import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Repository
public abstract class BQueryDslRepositorySupport extends QuerydslRepositorySupport {
    public BQueryDslRepositorySupport(Class<?> domainClass) {
        super(domainClass);
    }

    @Override
    @PersistenceContext(unitName = "bEntityManager")
    public void setEntityManager(EntityManager entityManager) {
        super.setEntityManager(entityManager);
    }
}

 

각 entityManger에 맞게 QueryDSL을 사용하게 되는 경우...

import kr.co.within.cms.user.api.infrastructure.config.AQueryDslRepositorySupport;

public class AQueryRepositoryImpl extends AQueryDslRepositorySupport implements AQueryRepositoryCustom {
    public AQueryRepositoryImpl() {
        super(A.class);
    }
}
import kr.co.within.cms.user.api.infrastructure.config.BQueryDslRepositorySupport;

public class BQueryRepositoryImpl extends BQueryDslRepositorySupport implements BQueryRepositoryCustom {
    public BQueryRepositoryImpl() {
        super(B.class);
    }
}

 

기억이 짧아 자꾸 까먹는 나를 위하여 기록한다.

 

참고. 멀티 DataSource에서 트랜잭션을 처리하기 위해서는 아래 포스팅 참조.

https://supawer0728.github.io/2018/03/22/spring-multi-transaction/

 

(Spring)다중 DataSource 처리

서론Spring Application을 만들면서 여러 DataSource와 transaction이 존재하고 하나의 transaction 내에 commit과 rollback이 잘 동작하도록 하려면 어떻게 설정해야 할까? 실제로 구현을 해본 적은 없지만 세 가지 방법이 머릿속에 떠올랐다. @Transactional의 propagation을 이용 spring-

supawer0728.github.io

 

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


회사에서 진행하는 새로운 프로젝트를 위해서 회원 정보 1,000만건에 가까운 데이터를 마이그레이션 해야 하는 작업이 필요 했다.

회원 정보만 1,000만건이고 기타 회원이 가지고 있는 부가적인 정보들을 포함하자면 거의 1억건에 가까운 데이터로 보여졌는데 이를 어떻게 마이그레이션 해야 하나 고민이 깊었다.

기존 레거시 시스템에서 새로운 시스템으로 이전을 해야 해서 내부적으로 새로운 데이터 베이스 시스템에 맞게 구조를 변경하고 적절한 로직을 가미해서 옮겨야 했기 때문에 단순 데이터 이전은 아니였다.

할줄 아는 거라고는 별로 없으니 Spring Batch를 사용할 수 밖에...

 

한동안 JPA를 편하게 써 와서  JPA를 사용하고 싶었지만...

Bulk Insert가 없어서 저 많은 Insert문을 날리느니 안하느니만 못하여 오랜만에 MyBatis를 이용하게 되었다.

MyBatis를 사용하여 로직을 구성하고 돌려보니 회원정보 기준 200만건이 넘어가면서부터 슬슬 느려지기 시작하더니 230만건 쯤에 OOM을 내면서 배치 시스템이 죽었다.

 

결론적으로 보자면 느려진 것의 원인과 OOM의 원인이 조금 달라 따로 따로 기록해 둔다.

우선 배치와 관련된 기본적인 정보는 아래의 블로그를 참고하였다.

 

https://jojoldu.tistory.com/324

 

1. Spring Batch 가이드 - 배치 어플리케이션이란?

Spring Batch In Action이 2011년 이후 개정판이 나오지도 않고, 한글 번역판도 없고, 국내 Spring Batch 글 대부분이 튜토리얼이거나 공식 문서 중 일부분을 짧게 번역한 내용들이라 대용량 시스템에서 사용할때..

jojoldu.tistory.com

위에서 배치 가이드를 시리즈 별로 2, 3, 4... 죽 이거가면서 보면 어느정도 배치에 대해 알게 될 수 있다.

나도 뭐 많이 해 보지 않고 이제 2개의 배치만 만들어 봤기에 제대로 만든 것인지도 모르겠다.

 

우선 application.yml 의 내용이다.

spring:
    profiles:
      active: local
    application:
      name: ferrari

spring.batch.job.names: ${job.name:NONE}

job.name:NONE은 job name이 파라미터로 들어오지 않을 경우 실행하지 않는다는 의미이며, 위에 참고한 블로그네 나와 있다.

 

다음은 ativie local을 반영한 application-local.yml 의 환경 구성 파일이다.

spring:
    profiles: local
    domain: localhost
    datasource-user:
        datasource-write:
            driverClassName: com.mysql.cj.jdbc.Driver
            jdbcUrl: jdbc:mysql://dburl:3306/yeogi_user?autoReconnect=true&useSSL=false
            username: id
            password: password
            maximumPoolSize: 30
            minimumIdle: 5
            poolName: write
            readOnly: false
        datasource-read:
            driverClassName: com.mysql.cj.jdbc.Driver
            jdbcUrl: jdbc:mysql://dburl:3306/yeogi_user?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
            username: id
            password: password
            maximumPoolSize: 30
            minimumIdle: 5
            poolName: read
            readOnly: true
        legacy-datasource-write:
            driverClassName: com.mysql.cj.jdbc.Driver
            jdbcUrl: jdbc:mysql://dburl:3306/yeogi?autoReconnect=true&useSSL=false
            username: id
            password: password
            maximumPoolSize: 30
            minimumIdle: 5
            poolName: write
            readOnly: false
        legacy-datasource-read:
            driverClassName: com.mysql.cj.jdbc.Driver
            jdbcUrl: jdbc:mysql://dburl:3306/yeogi?autoReconnect=true&useSSL=false&zeroDateTimeBehavior=convertToNull
            username: id
            password: password
            maximumPoolSize: 30
            minimumIdle: 5
            poolName: read
            readOnly: true
logging:
  level:
    kr.co.within.batch.ferrari: DEBUG
    jpa:
        show-sql: true
        hibernate:
            ddl-auto: none
        properties:
            hibernate:
                show_sql: true
                use_sql_comments: true
                format_sql: true
                type: trace
                dialect: org.hibernate.dialect.MySQL55Dialect
                hbm2ddl:
                  auto: none

 

다음은 위 환경 구성을 반영한 infrastructure 단의 ReplicationRoutingDataSource.java, UserDataSourceConfig.java 소스이다.

import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        String dataSourceType = TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? "read" : "write";
        log.info("### batch current dataSourceType : {}", dataSourceType);
        return dataSourceType;
    }
}
import kr.co.within.batch.ferrari.infrastructure.config.ReplicationRoutingDataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.mybatis.spring.boot.autoconfigure.SpringBootVFS;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

@Configuration
@EnableTransactionManagement
@MapperScan(basePackages = "kr.co.within.batch.ferrari.domain.user.repository", sqlSessionFactoryRef = "userSqlSessionFactory")
public class UserDataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource-user.datasource-read")
    public DataSource readUserDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource-user.datasource-write")
    public DataSource writeUserDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSource routingUserDataSource(@Qualifier("writeUserDataSource") DataSource writeUserDataSource, @Qualifier("readUserDataSource") DataSource readUserDataSource) {
        ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put("write", writeUserDataSource);
        dataSourceMap.put("read", readUserDataSource);
        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.setDefaultTargetDataSource(readUserDataSource());

        return routingDataSource;
    }

    @Bean
    public DataSource userDataSource(@Qualifier("routingUserDataSource") DataSource routingUserDataSource) {
        return new LazyConnectionDataSourceProxy(routingUserDataSource);
    }


    @Bean(name = "userSqlSessionFactory")
    public SqlSessionFactory userSqlSessionFactory(@Qualifier("userDataSource") DataSource userDataSource,
                                                   ApplicationContext applicationContext)
            throws Exception {
        SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
        factory.setDataSource(userDataSource);
        factory.setVfs(SpringBootVFS.class);
        factory.setTypeAliasesPackage("kr.co.within.batch.ferrari.domain.user");
        factory.setMapperLocations(applicationContext.getResources("classpath*:mapper/user/*Mapper.xml"));
        factory.setConfigurationProperties(getMyBatisProperties());
        return factory.getObject();
    }

    private Properties getMyBatisProperties() {
        Properties properties = new Properties();
        properties.put("cacheEnabled", false);
        properties.put("lazyLoadingEnabled", true);
        properties.put("localCacheScope", "STATEMENT");
        properties.put("defaultExecutorType", "BATCH");
        return properties;
    }

    @Bean(name = "userSqlSession", destroyMethod = "clearCache")
    public SqlSessionTemplate userSqlSessionTemplate(@Qualifier("userSqlSessionFactory") SqlSessionFactory userSessionFactory) {
        return new SqlSessionTemplate(userSessionFactory);
    }

    @Bean(name = "userTransactionManager")
    public DataSourceTransactionManager userTransactionManager(@Qualifier("userDataSource") DataSource userDataSource) {
        return new DataSourceTransactionManager(userDataSource);
    }
}

 

실제 배치 소스

@Configuration
public class Migration5BatchConfig {
    @Autowired
    public JobBuilderFactory jobBuilderFactory;

    @Autowired
    public StepBuilderFactory stepBuilderFactory;

    @Bean
    public Step0CommonTasklet step0CommonTasklet() {
        return new Step0CommonTasklet();
    }

    @Bean
    public Step1UserTasklet step1UserTasklet() {
        return new Step1UserTasklet();
    }
    
    ...

    @Bean
    public Job migration5JobForUser(Step step0Common, Step step1User) {
        return jobBuilderFactory.get("migration5JobForUser")
                .flow(step0Common)
                .next(step1User)
                .end()
                .build();
    }

    @Bean
    public Job migration5ForDevice(Step step2Device) {
        return jobBuilderFactory.get("migration5ForDevice")
                .flow(step2Device)
                .end()
                .build();
    }
    
    ...

    @Bean
    public Job migration5ForNicknameHistory(Step step9NicknameHistory, Step step10NicknamePool) {
        return jobBuilderFactory.get("migration5ForNicknameHistory")
                .flow(step9NicknameHistory)
                .next(step10NicknamePool)
                .end()
                .build();
    }

    /**
     * 공통 정보 미리 셋팅
     * @param step0CommonTasklet
     * @return
     */
    @Bean
    public Step step0Common(Step0CommonTasklet step0CommonTasklet) {
        return stepBuilderFactory.get("step0Common")
                .tasklet(step0CommonTasklet)
                .build();
    }

    /**
     * Step1 회원 정보 이관
     * @param step1UserTasklet
     * @return
     */
    @Bean
    public Step step1User(Step1UserTasklet step1UserTasklet) {
        return stepBuilderFactory.get("step1User")
                .tasklet(step1UserTasklet)
                .build();
    }
    
    ...
}
@Slf4j
@Component
public class Migration5NotificationListener extends JobExecutionListenerSupport {
    @Override
    public void beforeJob(JobExecution jobExecution) {
        log.info("### beforeJob() ###");
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        log.info("### afterJob() ###");

        if( jobExecution.getStatus() == BatchStatus.COMPLETED ){
            log.info("Batch Job Completed.");
        }
        else if(jobExecution.getStatus() == BatchStatus.FAILED){
            log.error("Batch Job Failed.");

        }
    }
}

 

각 Tasklet

@Slf4j
public class Step0CommonTasklet implements Tasklet {
    @Autowired
    private Migration5Service migration5Service;

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        log.info("=================== Step0CommonTasklet Start ===================");

        migration5Service.makeAgreementMeta();

        migration5Service.makeGroupMeta();

        log.info("=================== Step0CommonTasklet End ===================");
        return RepeatStatus.FINISHED;
    }
}
@Slf4j
public class Step1UserTasklet implements Tasklet {
    @Autowired
    private Migration5Service migration5Service;

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        log.info("=================== Step1UserTasklet Start ===================");

        // TODO 1. 유저 마이그레이션
        migration5Service.migrationUser();

        log.info("=================== Step1UserTasklet End ===================");
        return RepeatStatus.FINISHED;
    }
}

 

위와 같이 해 둔 상태에서 각 Job을 실행하게 되면 OOM이 발생 하게 되었다.

증상은 위에서 설명 했듯이 200만건이 넘게 되면 점점 느려지게 되었고 230만건 정도에 어김없이 OOM이 발생하게 되었다.

일단 느려지게 된 이유는 MySQL에서 Limit의 사용이 원인이였다.

limit를 사용하게 될 경우 페이지가 뒤로 갈 수록 느려지게 되는데 이게 200만건 부터 기하 급수적으로 느려지기 시작했기에 이대로라면 마이그레이션을 할 수 없는 수준이였다.

따라서 앱에서 무한스크롤로 다음 페이지를 가져오는 방식과 비슷하게 Primary Key를 가지고 페이징을 하게 되었다.

가령 100번까지 가지고 왔다면 다음 페이지는 Primary Key가 100보다 크면서 1,000개 가지고 오기 식으로...

 

이렇게 해서 속도문제는 개선이 되었지만 여전히 OOM이 발생하였다.

실제 로직을 구현하는 소스 단에서 다음과 같은 부분을 튜닝 하였다.

    private List<UserVo> userVoList = new ArrayList<>();
    private List<FriendRecommendHistoryVo> friendRecommendHistoryVoList = new ArrayList<>();
    private List<AgreementInstanceVo> agreementInstanceVoList = new ArrayList<>();
    private List<GroupInstanceVo> groupInstanceVoList = new ArrayList<>();
    private List<BlockVo> blockVoList = new ArrayList<>();

    /**
     * 회원정보 마이그레이션, 친구 추천 목록까지 이관
     */
    public void migrationUser() {
        int page = 1;
        long start = 0;
        
        ...
        
        while (true) {
            legacyCommonPagingParam.setPage(page);
            legacyCommonPagingParam.setStart(start);
            legacyCommonPagingParam.setOffset(5000);

            log.info("======================= start ======================= : {}", start);
            List<LegacyUserVo> legacyUserVoList = legacyUserService.selectMigrationTargetUser(legacyCommonPagingParam);

            userVoList.clear();
            friendRecommendHistoryVoList.clear();
            agreementInstanceVoList.clear();
            groupInstanceVoList.clear();
            blockVoList.clear();

            for ( LegacyUserVo legacyUserVo:legacyUserVoList) {
                // TODO. 각 비즈니스 로직 처리...

                start = legacyUserVo.getUno();
            }
            legacyCommonPagingParam.setStart(start);

            // 회원 목록과 친구추천 이력을 저장
            userService.bulkInsert(userVoList);
            userCount += userVoList.size();

            log.info("Heap : {}", ManagementFactory.getMemoryMXBean().getHeapMemoryUsage());
            log.info("NonHeap : {}", ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage());

            page++;
            if(Optional.ofNullable(legacyUserVoList).orElse(new ArrayList<>()).size() == 0) {
                break;
            }

            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
            }
        }

        snsAgreementMetaVo = null;
        pushAgreementMetaVo = null;
        recentAdAgreementMetaVo = null;
        groupMetaVoList = null;
        groupMetaVoMap = null;
    }

유의해 보아야 할 부분은 멤버 변수로 마이그레이션 해야 할 내용들을 빼 놓고 재사용한 점이다.

보통의 경우 instance를 재생성 하게 되는 비용이 크기 때문에 위에 처럼 만들어 놓고 한번 사용 했다면 clear로 메모리를 비워 줬다.

메모리를 null 처리 하는 경우에는 메모리 주소까지 없애는 것이기에 loop를 돌 때마다 instance를 새롭게 생성해야 하지만 clear로 비워주는 경우에는 instance 생성 비용을 아낄 수 있게 된다.

 

이러한 아이디어는 꽤 괜찮아 보이긴 했는데 실제 이 부분이 원인은 아니였다.

역시나 비슷한 부분에서 OOM이 발생 하였다.

그래서 사용하게 된 MyBatis의 문제는 아닐까란 생각에 해당 부분을 검색하고 찾아 보았다.

 

그래서 Service 부분에서 MyBatis를 사용하는 부분에 다음과 같이 수정 하였다.

@Slf4j
@Service
public class UserService {
    @Autowired
    private UserQueryMapper userQueryMapper;

    @Autowired
    private DormancyUserQueryMapper dormancyUserQueryMapper;

    public void bulkInsert(List<UserVo> userVoList) {
        if(userVoList.size() == 0) {
            return;
        }

        userQueryMapper.bulkInsert(userVoList);
        userQueryMapper.flush();
    }
    
    ...
}

userQueryMapper.flush()를 통해 MyBatis 내부적으로 가지고 있는 캐쉬 데이터를 플러싱 해 주었다.

또한 SQL문을 작성한 xml문에도 다음과 같이 해 주었다.

 

    <insert id="bulkInsert" parameterType="list" flushCache="true">
        INSERT INTO `User`
          (
            ...
          )
        VALUES
        <foreach collection="list" item="userVo" separator=",">
          (
            ...
          )
        </foreach>
    </insert>

flushCache="true" 부분을 추가해 주었다.

 

이렇게 해 둔 상태로 마이그레이션을 돌리니 1,000만건 정도 되는 회원 데이터가 큰 무리없이 잘 마이그레이션 됨을 볼 수 있었다.

 

결과론적으로 보자면 크게 어려운 부분은 없지만, 이 내용을 찾고 알게 되기까지 많은 삽질이 있었다.

추후 다른 분들은 삽질하지 않도록 글로 남겨 둔다.

 

 

 

 

 

 

 

 

 

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


JPA를 사용하면서 QueryDSL을 셋팅하고 사용하는 부분에 있어서 매번 헷깔려 정리한다.

 

QueryDSL을 사용하기 위해서 build.gradle 파일에 아래의 내용을 추가 해 준다.

plugins {
	...
    
    id 'idea'
    id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
    
    ...
}

dependencies {
	...
    
    implementation 'com.querydsl:querydsl-apt:4.1.4'
    implementation 'com.querydsl:querydsl-jpa:4.1.4'
    
    ...
}

ext {
    querydslSrcDir = 'src/main/generated'
    queryDslVersion = '4.1.4'
}

configurations {
    querydsl.extendsFrom compileClasspath
}

querydsl {
    library = "com.querydsl:querydsl-apt"
    querydslSourcesDir = 'src/main/generated'
    jpa = true
    querydslDefault = true
}

sourceSets {
    main {
        java {
            srcDirs += file(querydslSrcDir)
        }
    }
}

idea {
    module {
        generatedSourceDirs += file(querydslSrcDir)
    }
}

 

Entity 작성

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@EntityListeners(value = { AuditingEntityListener.class })
@Table(name = "user")
@Getter
@Setter
public class YeogiUser {
    @Id
    @Column(name="uno")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;
    
    ....
    
    @UpdateTimestamp
    @Column(name = "uedit")
    private LocalDateTime updatedAt;

    @CreationTimestamp
    @Column(name="ureg", updatable = false)
    private LocalDateTime createdAt;
    
    ....
    
    @QueryProjection
    public YeogiUser(long userId, LocalDateTime updatedAt, LocalDateTime createdAt) {
        this.userId = userId;
        this.updatedAt = updatedAt;
        this.createdAt = createdAt;
    }
}

 

기본 JPA Interface 작성

public interface YeogiUserQueryRepository extends JpaRepository<YeogiUser, Long>, YeogiUserQueryRepositoryCustom {
    ...
}

 

Custom으로 QueryDSL을 사용할 interface 작성

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface YeogiUserQueryRepositoryCustom {
    Page<YeogiUser> findByUserParamForIpcc(Pageable pageable, UserParamForIpcc userParamForIpcc);
}

 

Custom interface 구현 클래스 작성

import com.querydsl.core.QueryResults;
import com.querydsl.core.types.ConstructorExpression;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import kr.co.within.cms.user.api.application.params.UserParamForIpcc;
import kr.co.within.cms.user.api.domain.yeogi.user.entity.QYeogiUser;
import kr.co.within.cms.user.api.domain.yeogi.user.entity.YeogiUser;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;

public class YeogiUserQueryRepositoryImpl extends QuerydslRepositorySupport implements YeogiUserQueryRepositoryCustom {
    public YeogiUserQueryRepositoryImpl() {
        super(YeogiUser.class);
    }

    @Override
    public Page<YeogiUser> findByUserParamForIpcc(Pageable pageable, UserParamForIpcc userParamForIpcc) {
        JPAQueryFactory query = new JPAQueryFactory(this.getEntityManager());
        QYeogiUser yegiUser = QYeogiUser.yeogiUser;

        JPAQuery<YeogiUser> jpaQuery = query
                .select(getYeogiUserProjection())
                .from(yegiUser);

        if (CollectionUtils.isEmpty(userParamForIpcc.getUserIdList()) == false) {
            jpaQuery = jpaQuery.where(yegiUser.userId.in(userParamForIpcc.getUserIdList()));
        }

        if (StringUtils.isNotEmpty(userParamForIpcc.getLoginId())) {
            jpaQuery = jpaQuery.where(yegiUser.loginId.eq(userParamForIpcc.getLoginId()));
        }

        if (StringUtils.isNotEmpty(userParamForIpcc.getNickname())) {
            jpaQuery = jpaQuery.where(yegiUser.nickname.eq(userParamForIpcc.getNickname()));
        }

        if (StringUtils.isNotEmpty(userParamForIpcc.getPhone())) {
            jpaQuery = jpaQuery.where(yegiUser.phone.eq(userParamForIpcc.getPhone()));
        }

        QueryResults<YeogiUser> list = jpaQuery
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(new OrderSpecifier(Order.ASC, yegiUser.userId))
                .fetchResults();

        return new PageImpl<>(list.getResults(), pageable, list.getTotal());
    }

    private ConstructorExpression<YeogiUser> getYeogiUserProjection() {
        QYeogiUser yegiUser = QYeogiUser.yeogiUser;

        return Projections.constructor(YeogiUser.class,
                yegiUser.userId, yegiUser.userStatus, yegiUser.userType, yegiUser.ano, yegiUser.loginId, yegiUser.loginPassword,
                yegiUser.nickname, yegiUser.name, yegiUser.facebookId, yegiUser.updatedAt, yegiUser.createdAt,
                yegiUser.deviceId, yegiUser.myRecommendationCode, yegiUser.friendRecommendationCode, yegiUser.recommededCount,
                yegiUser.snsAgree, yegiUser.uaname, yegiUser.group, yegiUser.alarmStatus, yegiUser.latestLodging,
                yegiUser.alarmAgreeDate, yegiUser.phone, yegiUser.lastLoginAt);
    }
}

 

사용

    @Autowired
    private YeogiUserQueryRepository yeogiUserQueryRepository;

    public Page<YeogiUser> getUserList(Pageable pageable, UserParamForIpcc userParamForIpcc) {
        return yeogiUserQueryRepository.findByUserParamForIpcc(pageable, userParamForIpcc);
    }

 

자세한 설명은 생략한다.

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


인종 차별이나 인격에 대한 비하 발언도 아니고....

단지 팩트는...

사람 간에 똑똑함과 미련함의 차이가 생각보다 크게 난다는 것이다.

이미 알고 있는 사실이고...

많이 느껴 왔지만...

오늘 새삼 더 느끼게 된다.


취업이 힘들다 말하지 말고...

본인이 왜 취업이 안되는 것인지 돌아보고 조금 더 노력해 보자...


취업이 잘 되는 사람들이 본인들보다 왜 더 잘 되는 것인지...

얼마나 더 많이 노력하는 것인지...

그래서 나보다 얼마나 무엇을 더 알고 있는지 돌아보자.


그 갭이 생각보다 매우 크다는 것을 깨닫게 될 것이고..

그 갭을 채우려면 지금보다 더욱더 치열하게 노력하지 않은 이상...

그들과 같은 레벨로 올라서기에는 한계가 있다는 것을 알게 될 것이다.






WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


Java로 Entity를 만들고 멤버 변수로 1 ~ 31일을 만들어 둔 다음 넘오는 날짜에 따라 특정 날짜 변수에 값을 담는 작업을 하다 알게 된 내용이다.

역시 새롭게 알게 된 내용이라 정리 차원으로 올린다.

 

MontTimeTable Entity는 아래와 같다.

@Entity
@EntityListeners(value = {AuditingEntityListener.class})
@Data
@Table(name = "month_timetable")
public class MonthTimetable {
    @Id
    @Column
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private Integer policy_instance_id;

    @Column
    private Integer year;

    @Column
    private Integer month;

    @Column
    private Integer d1;
    @Column
    private Integer d2;
    @Column
    private Integer d3;
    @Column
    private Integer d4;
    @Column
    private Integer d5;
    @Column
    private Integer d6;
    @Column
    private Integer d7;
    @Column
    private Integer d8;
    @Column
    private Integer d9;
    @Column
    private Integer d10;
    @Column
    private Integer d11;
    @Column
    private Integer d12;
    @Column
    private Integer d13;
    @Column
    private Integer d14;
    @Column
    private Integer d15;
    @Column
    private Integer d16;
    @Column
    private Integer d17;
    @Column
    private Integer d18;
    @Column
    private Integer d19;
    @Column
    private Integer d20;
    @Column
    private Integer d21;
    @Column
    private Integer d22;
    @Column
    private Integer d23;
    @Column
    private Integer d24;
    @Column
    private Integer d25;
    @Column
    private Integer d26;
    @Column
    private Integer d27;
    @Column
    private Integer d28;
    @Column
    private Integer d29;
    @Column
    private Integer d30;
    @Column
    private Integer d31;

    @CreatedDate
    @Column(updatable = false, name = "created_at")
    private Date createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    @Temporal(TemporalType.TIMESTAMP)
    private Date updatedAt;
}

 

그리고 넘어온 날짜 기반으로 각 필요한 날짜 변수에 값을 셋팅을 해야 하는데 아래와 같은 방법으로 Set 하게 된다.

@Service
@Slf4j
public class MonthTimetableService {
    public void createTimeTable(Long id, List<Date> timetableList) {
        Map<String, MonthTimetable> monthTimetableMap = new HashMap<>();

        for (Date date:timetableList) {
            LocalDate localDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
            int year  = localDate.getYear();
            int month = localDate.getMonthValue();
            int day   = localDate.getDayOfMonth();

            MonthTimetable monthTimetable = monthTimetableMap.get(year+""+month);
            if(monthTimetable == null) {
                monthTimetable = new MonthTimetable();
                monthTimetable.setYear(year);
                monthTimetable.setMonth(month);

                monthTimetableMap.put(year+""+month, monthTimetable);
            }

            this.setDay(monthTimetable, day);
        }
    }

    private void setDay(MonthTimetable monthTimetable, int day) {
        try {
            String setterMethodName = "d" + day;
            Field field = MonthTimetable.class.getDeclaredField(setterMethodName);
            field.setAccessible(true);
            field.set(monthTimetable, 1);
        } catch (IllegalAccessException | NoSuchFieldException e) {
            throw new PolicyException(ErrorMessage.FAIL_POLICY_ADD);
        }
    }
}

 

 

이렇게 하게 될 경우 동적으로 필요한 변수를 찾아 값을 Set 할 수 있게 된다.

 

참고 : https://stackoverflow.com/questions/11652598/how-to-instantiate-and-call-methods-dynamically-of-a-class-member-in-java




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


JPA, QueryDsl 쓰기 참 힘들다.

그래도 하나 하나 어렵게 해 나가고 있는데 오늘은 또 Group By한 Count 개수를 반환하는 것을 만든 삽질 내용을 올린다.

 

리파지토리 단 소스는 아래와 같다.

@Override
public QueryResults<Tuple> getBusinessTotalCount() {
    QUser user = QUser.user;

    return from(user).where(user.userType.eq(UserType.B2B)).groupBy(user.platform).select(user.platform, user.platform.count()).fetchResults();
}

 

 

그리고 가지고 온 내용을 가지고 맵으로 이쁘게 정렬해서 반환해 주면 된다.

public Map<String,Integer> getBusinessTotalCount() {
	QueryResults<Tuple> queryResults = userQueryRepository.getBusinessTotalCount();

	Map<String,Integer> returnMap = new HashMap<>();
	List<Tuple> list = queryResults.getResults();
	for (Tuple tuple : list) {
		returnMap.put(tuple.get(0, String.class), tuple.get(1, Integer.class));
	}

	return returnMap;
}

 

 

코드만 놓고 보자면 별거 아닌데, 모르는 상태에서 찾아서 할려니 진도가 더디다. ㅠㅠ

 

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


http로 값을 전송 하면서 보통 GET, POST를 많이 쓰고 PUT 으로는 전송을 잘 안하게 되는데, PUT으로 받아야 하는 API를 만들다가 값이 잘 전송이 안된다는 것을 알게 되었고, 이럴 때 어떻게 값을 전송해야 하는지 삽질의 결과물을 남겨 둔다.

 

보통 postman을 사용하여 값을 전송하는데 POST일 경우에는 body의 Typ을 form-data 또는 raw를 하게 된다.

PUT일 경우에도 form-data 로 전송하니 값을 받을 수 없어서 이리 저리 확인해 보니

PUT일 경우에는 다음과 같이 전송 해야 한다.

 

 

 

이와 마찬가지로 Spring에서 RestTemplate를 이용하여 PUT 파라미터를 전송하게 될 경우 아래와 같이 하여야 한다.

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("reservePhone", phoneNumber);

HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

HttpEntity<?> httpEntity = new HttpEntity<>(body, httpHeaders);

ResponseEntity<String> responseEntity = restTemplate.exchange(apiUrl, HttpMethod.PUT, httpEntity, String.class);

 

 

위와 같이 header에 MediaType.APPLICATION_FORM_URLENCODED 를 추가해야지만 값이 전송 된다.




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


API 통신을 하면 보통 데이터 외에 Code나 Message가 같이 오고, 필요한 데이터는 한번 가공해서 받아야 한다.

그런데 이렇게 받게 되는 경우 안의 데이터가 무조건 HashMap으로 받아지게 된다.

이런 경우에 원하는 Object로 받기 위해 제네릭을 사용해 보았지만 역시 맵으로만 받아지게 되어서 구글링을 통해 해결하였다.

 

다음과 같은 방법으로 사용하면 된다.

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.core.type.TypeReference;

String response = restTemplate.postForObject(apiUrl, userDto, String.class);
ApiResponseDto<UserDto> apiResponseDto = jsonMapper.readValue(response, new TypeReference<ApiResponseDto<UserDto>>() {});

 

 

참고한 스택오버 플로우 : https://stackoverflow.com/questions/11664894/jackson-deserialize-using-generic-class




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


회사에서 sms 발송 시 특정 문자가 깨져 나가는 문제가 발생...

sms 발송 시스템은 "세X 텔레X"을 사용 하는데 특정 DB에 값을 넣어 두면 jar로 실행 되는 데몬이 해당 DB 값을 읽어서 발송 하는 방식임.

DB에 값이 들어갈 때까지 UTF-8 형식이고 깨지지 않고 들어가는데, 실제 폰에서 받아 보기만 하면 깨지는 상황.

깨지는 문자는 "–" 임. 일반 키보드에서 치는 "-"와는 다르고, 워드나 한글 등에서 copy & paste 한 것으로 보임.

실제 폰에서 받아 볼 경우 해당 문자는 "?"으로 치환 되어 옴.


해당 현상을 재현 하기 위해 php에서 테스트 해 보았으나 재현 안되고

Java에서는 아래와 같은 방식으로 재현 됨.



콘솔에 찍힌 결과 값은 아래와 같음.

euckrString : 201? 218

utf8String : 201– 218


해당 내용을 기초로 해당 텔레콤의 jar 파일 내부도 비슷하게 구현 되어 있을거라고 추정함.

위 내용을 위해 별도 api에서 검증 하는 방식을 생각해 봤으나 php로 된 앞단과 api 서버인 Java 연결이 귀찮아 고민...

php에서는 위에처럼 되는 부분을 찾기는 힘들고 iconv나 mb_convert_encoding을 이용 해야 만 함.


iconv를 이용할 경우 utf-8 > euc-kr로 변환할 경우 변환할 수 없다는 에러 메시지 나옴.

mb_convert_encoding를 사용할 경우 utf-8 > euc-kr > utf-8 로 변환 하면 해당 문자가 사라짐.

이를 이용해서 원문과 변환된 결과물 사이의 유사도를 similar_text를 이용해서 100%가 아닐 경우 문자를 보내지 못하도록 하면 될 것이라 일단 생각 까지만...


구현은 나중에 ^^;;;

'Program!' 카테고리의 다른 글

캐릭터셋에 대한 고민...  (0) 2018.03.21



WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


WebSocket을 이용하여 특정 내용을 처리하는 로직을 개발 중에 삽질한 내용을 정리 차원에서 올린다.

클라이언트는 SockJS와 stompClient를 이용하였고, 서버는 Spring에서 기본적으로 정리한 내용을 크게 바꾸지 않은 상태로 코딩 하였다.

전반적으로 코드의 내용은 https://spring.io/guides/gs/messaging-stomp-websocket/ 에서 나오는 내용과 거의 다르지 않다.

다만 해당 내용을 코딩 중에 sockjs에서 websocket 서버의 "endpoint/info?timestamp" 와 같은 주소를 찾지 못하는 경우가 발생 했다.

이로 인해 서버의 특정 모듈 Dependency가 잘못 되던가, 알지 못하는 문제로 인해 발생 하는 것인줄 알고 프로젝트를 Spring Boot로 싹 다 새로 만들게 되었다.

 

스프링 부트로 만든 상태에서 테스트 해 보니 이상 없이 돌아 갔는데, 해당 내용을 실제 개발 플랫폼에 적용하려고 보니 다시 info 페이지가 404 에러를 내 뿜는게 아닌가 ㅠㅠ

https와 http 문제라고 생각해서 프로토콜도 맞추었지만 현상은 해결 되지 않았다.

 

그런데 크롬의 개발자 도구를 자세히 살펴 보니 info 호출 시 response에 "Invalid CORS request" 라고 response가 뜨는 것을 발견 ㅠㅠ

도메인 문제인 것 같은데...

그렇다면 서버에서 어떻게 설정을 해 주어야 다른 도메인에서도 접근 가능하도록 해 줄 수 있는것일까? 하고 다시 구글링 돌입...

 

결국 https://spring.io/guides/gs/messaging-stomp-websocket/#_configure_spring_for_stomp_messaging 부분에서 보이는 것 처럼 endpoint 설정 시 접근할 수 있는 도메인을 추가로 정해 주면 되는 것이었다.

내가 찾은 stackoverflow의 좌표는 https://stackoverflow.com/questions/30502943/spring-websocket-connecting-with-sockjs-to-a-different-domain

 

이전 코드

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
	// WebSocketMessageBrokerConfigurer.super.registerStompEndpoints(registry);
	registry.addEndpoint("/stork-websocket").withSockJS();
}

 

수정 된 코드

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
	// WebSocketMessageBrokerConfigurer.super.registerStompEndpoints(registry);
	registry.addEndpoint("/stork-websocket").setAllowedOrigins("*").withSockJS();
}

 

 

결국 setAllowedOrigins("*") 하나를 위해서 2주 정도 삽질을 하게 된 ㅠㅠ

물론 이것 뿐 아니라... 현재 웹 소켓에 동시 접속된 유저 수를 구하기 위해 프로젝트를 새롭게 구성할 필요가 있긴 했다.

 

동접자 구하는 소스는 참고로 아래와 같다.

다만... 공식적인 소스가 아니라 그냥 디버깅 해 가면서 만든 소스라서 정확하게 동작 안 할 수 도 있다.

 

Websocket Config 정의하는 Class 내에 아래와 같이 inbound interceptor를 정의 한다.

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
	registration.interceptors(new WebSocketInterceptor());
}

 

 

그리고 나서 인터셉터에서 세션을 Set에 담아 size를 구해와서 사용하면 된다.

package com.auctionblue.bluemessenger.interceptor;

import java.util.HashSet;
import java.util.Set;

import org.apache.commons.lang3.StringUtils;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.support.ChannelInterceptorAdapter;

public class WebSocketInterceptor extends ChannelInterceptorAdapter {
	Set<String> sessionSet = new HashSet<>();

	@Override
	public Message<?> preSend(Message<?> message, MessageChannel channel) {
		String simpMessageType = String.valueOf(message.getHeaders().get("simpMessageType"));
		
		if(StringUtils.equals(simpMessageType, "CONNECT")) {
			String simpSessionId = String.valueOf(message.getHeaders().get("simpSessionId"));
			sessionSet.add(simpSessionId);
		} else if(StringUtils.equals(simpMessageType, "DISCONNECT")) {
			String simpSessionId = String.valueOf(message.getHeaders().get("simpSessionId"));
			sessionSet.remove(simpSessionId);
		}
		
		int uniqueJoinSessionCount = sessionSet.size();
		System.out.println(uniqueJoinSessionCount);
		
		return super.preSend(message, channel);
	}
}

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  4개가 달렸습니다.
  1. 비밀댓글입니다
    • boot랑 spring이랑 차이 없지 않나요?
      maven pom.xml에서 의존성만 설정하고 필요한 라이브러리만 가지고 오면 상관 없을 것으로 보입니다.

      다만 주의 하실 것은 위 내용처럼 도메인 크로스 체크 부분인 것으로 보입니다.

      설정 역시 예제 스프링 소스가 있는 링크에 다 있는 것으로 압니다. ^^
  2. 감사감사 2018.11.28 13:45
    내가 찾던 내용
secret


특정 폼에 %가 들어가는 경우 DB에 저장이 안되는 이슈가 발생하여 수정하는 중에 삽질하게 된 내용을 정리 차원에서 기록한다.

대부분 %의 문제는 DB에 SQL Injection이나 XSS 등의 문제를 회피하기 위해 발생하는 현상이라서 DAO 처리 과정 중에 문제가 발생 한 것이 아닐까란 추측으로 디버깅을 해 보았으나 SQL Prepare가 잘 되어 있었다.

고로 DAO 상에서 발생하는 문제는 아니라는 결론...

 

그럼 서버 단의 Filter에서 파라미터를 잘라 먹는 것은 아닐까란 생각으로 이미 만들어진 보안 Filter를 찾아서 몇 시간 삽질 했으나 이미 Filter에 값이 들어오기 전 부터 없어진 다는 것을 확인.

 

그럼 스크립트의 문제인 것인가?란 의문을 가지고 개발자 도구에서 console.log 로 값을 확인.

참고로 다음과 같은 소스로 되어 있었음.

$.ajax({
	url : "action url...",
	type : "POST",
	dataType : "json",
	data : "idx=" + $("#idx").val() + "&" + mode + "=" + $("#"+mode).val(),
	timeout : 1000 * 5,
	error : function(){},
	success : function(json){
		var status	= json.status;
		var message	= json.message;
		if (status == "200")	swal("성공", "정상적으로 수정이 완료되었습니다.", "success");
		else					swal("에러", message, "error");
	}
});

 

 

여전희 값은 잘 찍히고 있었음.

그럼 뭐가 문제인가? 하고 보다가 개발자 도구의 network 탭에서 % 문자가 들어가는 경우에만 해당 form의 값이 사라지는 현상이 생김.

오류 메시지는 다음과 같이 나옴.

 

memo:
(unable to decode value)

 

"unable to decode value" 란 문장으로 구글링 해 보니 스크립트 단의 문제가 맞고, 해결 방법은 value에 encodeURIComponent를 사용하여 값을 인코딩 해야 한다고 함 ㅠㅠ

 

$("#"+mode).val() 를 encodeURIComponent($("#"+mode).val()) 처럼 수정하고 테스트 하니 서버에서 아무런 이상 없이 동작하고 DB에도 잘 저장 됨.

 

오늘도 삽질은 끝이 없구나 ㅠㅠ

 

 

 

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


아무래도 음주 운전 같은데... ㅎㄷㄷ
음주 운전은 절대 하면 안될 듯...

사실 저 당시에는 절대 음주라고 의심 안했는데 돌아보니 음주 같네요 ^^;;






WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


Java8에 들어간 stream, parallelStream이 좋다는 이야기만 듣고 대충 개념만 이해한 상태에서...
"그냥 좋겠지"란생각으로 쓰려다...

간단하게 테스트 해보고 정리 ^^ 참고로 로컬 PC에서 돌렸으며, 로컬 PC의 물리 cpu 코어 개수는 4개이다.

import java.util.ArrayList;
import java.util.List;

public class StreamTest {

	public static void main(String[] args) throws InterruptedException {
		List<Integer> intList = new ArrayList<>();
		
		// 테스트할 loop의 개수...
		for (int i = 0; i<1000000; i++) {
			intList.add(i);
		}
		
		// normal for loop
		long startTime = System.currentTimeMillis();
		for (Integer integer : intList) {
			if(integer % 1000 == 0) {
				System.out.println(integer);
				//Thread.sleep(10);
			}
		}
		long endTime = System.currentTimeMillis();
		
		// stream foreach
		long startTime2 = System.currentTimeMillis();		
		intList.stream().forEach(integer -> {
			if(integer % 1000 == 0) {
				System.out.println(integer);
				try {
					//Thread.sleep(10);
				} catch (Exception e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		});
		long endTime2 = System.currentTimeMillis();
		
		// parallelStream foreach
		long startTime3 = System.currentTimeMillis();		
		intList.parallelStream().forEach(integer -> {
			if(integer % 1000 == 0) {
				System.out.println(integer);
				try {
					//Thread.sleep(10);
				} catch (Exception e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		});
		long endTime3 = System.currentTimeMillis();		
		
		System.out.println("##  1 소요시간(초.0f) : " + ( endTime - startTime )/1000.00f +"초");
		System.out.println("##  2 소요시간(초.0f) : " + ( endTime2 - startTime2 )/1000.00f +"초");
		System.out.println("##  3 소요시간(초.0f) : " + ( endTime3 - startTime3 )/1000.00f +"초");
	}
}

 

 


10만건, 100만건 테스트를 그냥 간단하게 2, 3회 정도 실시해 본 결과는 아래와 같다.

 

10만

##  1 소요시간(초.0f) : 0.005초

##  2 소요시간(초.0f) : 0.058초

##  3 소요시간(초.0f) : 0.013초

 

##  1 소요시간(초.0f) : 0.004초

##  2 소요시간(초.0f) : 0.042초

##  3 소요시간(초.0f) : 0.007초

 

100만

##  1 소요시간(초.0f) : 0.015초

##  2 소요시간(초.0f) : 0.063초

##  3 소요시간(초.0f) : 0.056초

 

##  1 소요시간(초.0f) : 0.015초

##  2 소요시간(초.0f) : 0.062초

##  3 소요시간(초.0f) : 0.058초

 

결과 1이 단순 for loop, 결과 2가 stream, 결과 3이 parallelStream 이다.

10만건이 되었든 100만건이 되었든... 단순 for loop가 빠르다.

이로 인해 내릴 수 있는 결론은 loop문 안에서 처리되는 비즈니스 로직에 block이나 delay 요소가 없다면 단순 for loop로 돌리는게 더 빠를 수 있다. stream이나 parallelStream은 list를 stream으로 바꾸고 내부적으로 라이브러리를 사용하는 비용이 소모 되므로 단순 작업에서는 더 느릴 수 있다고 어디선가 본 것 같다 ^^;;

 

그럼 loop문 안에 인위적으로 sleep을 넣는다면? 소스 코드 안의 sleep을 주석을 풀고 실행하게 되면 결과는 아래와 같다.

 

10만

##  1 소요시간(초.0f) : 1.001초

##  2 소요시간(초.0f) : 1.037초

##  3 소요시간(초.0f) : 0.144초

 

##  1 소요시간(초.0f) : 1.0초

##  2 소요시간(초.0f) : 1.077초

##  3 소요시간(초.0f) : 0.161초

 

100만

##  1 소요시간(초.0f) : 10.012초

##  2 소요시간(초.0f) : 10.09초

##  3 소요시간(초.0f) : 1.289초

 

##  1 소요시간(초.0f) : 10.004초

##  2 소요시간(초.0f) : 10.105초

##  3 소요시간(초.0f) : 1.291초

 

단순 for loop와 stream은 거의 차이가 없으며 parallelStream이 압도적으로 빠르다.

list를 parallelStream 으로 변환하고 라이브러리를 로드하고 사용하는 비용을 쓰더라도 loop문 안에서 지연이 발생해서 loop를 도는 속도가 현저히 떨어지게 된다면 병렬로 나눠 처리하는 것이 좋다는 결론을 얻게 된다.

 

하지만 loop 안에서 지연이 발생한다고 해서 무조건 parallelStream 을 쓰는 것이 좋을까?

경험 상 병렬로 작업을 처리한다 하더라도 loop 내부에 DB Insert, Update, Delete와 같은 것이 있다면 DB에 크나큰 부담으로 시스템 장애를 일으킬 수 있으니 조심해야 한다.

또한 parallelStream 이 CPU를 점유할 경우 다른 parallelStream 작업에도 영향을 미칠 수 있으므로 조심 ^^

 

덧. for loop보다 stream이 이론 상으로 더 빨라야 하는 것 같은데... 오히려 stream이 느리네..

stream이 더 빠른 경우가 무엇인지에 대한 고민이 필요 ^^;;

 

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


보통 개발 시에 파일 업로드를 하려 하면 html form에서 multipart/form-data로 파일을 선택해서 업로드 하고, 이를 서버 단에서 받아 처리를 하게 된다.

하지만 이런 방법이 아니라 원격지의 이미지 파일을 읽어온 후 필요 시 리사이지, 그리고 나서 다시 다른 곳에 있는 서버로 파일을 업로드 하는 기능이 필요해 개발을 하다 보니, 많이 사용되는 방법이 아니기에 정리해 둔다.

 

<원격지에서 파일을 읽어 들여 파일 객체로 만든 후 리사이징, 업로드하기>

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;

import javax.imageio.ImageIO;

private String uploadCdn(URL imageURL, int maxWidth, long maxSize) throws IOException, UploadSizeLimitExceededException, CDNUploadException {
	// 리사이징...
	BufferedImage originalMainImage = ImageIO.read(imageURL);
	String fileType = Files.probeContentType(new File(imageURL.getFile()).toPath());	// mime type
	
	String fileExtension = "";
	if(StringUtils.equals("image/jpeg", fileType)) {
		fileExtension = "jpg";
	} else if(StringUtils.equals("image/png", fileType)) {
		fileExtension = "png";
	} else if(StringUtils.equals("image/gif", fileType)) {
		fileExtension = "gif";
	} else {
		// throw Exception
	}

	// Upload FileName generate
	
	int imageWidth = originalMainImage.getWidth();
	int imageHeight = originalMainImage.getHeight();
	
	ByteArrayOutputStream baos = new ByteArrayOutputStream();
	byte[] imageInByte;
	
	if(originalMainImage.getWidth() > maxWidth) {
		int type = originalMainImage.getType() == 0 ? BufferedImage.TYPE_INT_ARGB : originalMainImage.getType();
		int resizeHeight = (maxWidth * imageHeight) / imageWidth;
		
		BufferedImage resizedMainImage = CommonUtil.resizeImage(originalMainImage, type,
				maxWidth, resizeHeight);
		
		ImageIO.write( resizedMainImage, fileExtension, baos );
	} else {
		ImageIO.write( originalMainImage, fileExtension, baos );
	}
	
	baos.flush();
	imageInByte = baos.toByteArray();
	baos.close();
	
	// 이미지 사이즈 체크
	long size = imageInByte.length;
	if(size > maxSize) {
		// throw Exception
	}
	
	// upload 후 업로드 된 이미지의 URL 리턴...
	return uploadedFullUrl;
}

 

 

<이미지 리사이징>

import java.awt.Graphics2D;
import java.awt.image.BufferedImage;

public static BufferedImage resizeImage(BufferedImage originalImage, int type, int width, int height){
	BufferedImage resizedImage = new BufferedImage(width, height, type);
	Graphics2D g = resizedImage.createGraphics();

	// 품질 관련 코드... 기본 품질은 글자가 다 깨짐...
	RenderingHints rh = new RenderingHints(
		RenderingHints.KEY_RENDERING,
		RenderingHints.VALUE_RENDER_QUALITY);
	g.setRenderingHints(rh);

	g.drawImage(originalImage, 0, 0, width, height, null);
	g.dispose();

	return resizedImage;
}

 

 

 




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


코딩하면서 유용하게 사용하고 있는 사이트들이 몇 개 있는데...

자주 사용하다 보니... 그냥 이렇게 정리해 두고 필요할 때 끄집어 내서 사용하면 좋겠다 싶어 정리 ^^

순서는 중요도랑 아무 상관 없이... 그냥...


1. Json online Editor


http://www.jsoneditoronline.org/


json editor 기능도 있고, 이쁘게 정리도 해 주고, object로 접었다 폈다 하면서 구조적으로 잘 볼 수 있도록 해 준다.



2. XML to Json


http://www.utilities-online.info/xmltojson/#.WIbKYFOLRVI


xml의 list 형태가 json으로 변하면 어떻게 변하는지 궁금해서 찾아본 사이트...

요즘은 대부분 json을 많이 쓰지만 xml을 쓰기 원하는 곳도 있으니 간간히 필요한 경우 사용하면 좋을 듯...



3. Online regex tester and debuger


https://regex101.com/


예전에 XE 개발 할 때 오픈소스라서 날마다 해킹 시도가 있어서 뚤리는거 막고 뚤리는거 막고 할 때 정규 표현식을 많이 썼는데, 바로 바로 해당 정규표현식이 잘 적용 되는지 확인할 수 있어서 좋다.



4. URL Decoder, Encoder


http://meyerweb.com/eric/tools/dencoder/


인코딩 된 문자를 디코딩 하거나 디코딩 된 문자를 인코딩 하고 싶을 경우 바로 바로 확인 가능하기에 괜찮은 사이트이다.









WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


요즘 주변에서 일어나는 일들을 보면서 드는 생각들을 적어본다.

정리된 생각이 아니니... 주저리 주저리 잡설이 될 듯 싶다.



* 개발을 잘 한다는 의미는 무엇일까?


한 쪽 사람은 다른 사람을 개발 못한다고 욕하고, 다른 쪽에서는 이 사람을 개발 못한다고 욕한다.

요즘 드는 생각으로는 뭐가 개발을 잘하는 것이고, 뭐가 못하는 것인지... 이제 잘 모르겠다는...

예전에는 뭔가 명확한 원칙, 기똥찬 코딩 실력...

여러가지 어려운 전문용어...

새로운 개념 도입... 깔끔한 정리 등등

뭔가 개발 잘하는 것에 따라오는 수식어들이 있었는데...

상황에 따라, 권력을 쥐고 있는 사람에 따라 관점이 달라지듯... 개발을 잘 하는 지의 여부도 달라지는 현상을 본다.


그냥 정확하게 알게 된 결론은...

서로 잘난척을 할 뿐...

뭐가 옳은지, 뭐가 잘못된 것인지 명확하지 않다는 것이다.



* 되도 않는 권력욕들... 욕망의 꿈틀거림...


정치인들이야 원래 그렇다고 치자.

조그마한 회사 안에서까지 별 그지 같은 타이틀 한번 달아보고...

별 그지 같은 쥐꼬리 만한 권력 가져보고자...

역겨운 정치질을 한다.

회사의 공동 목표는 멀어지고...

조그마한 파트 안에서 일부 사람들의 집단 이기주의가 꿈틀거리면서...

Showing이 시작 된다.


그 Showing이 회사에 무슨 이익이 되는 것인지의 고민은 없고...

그냥 그 Showing으로 인해 집단 내에서 자신을 돋보이고자 하는 행동들만 난무한다.


우리 그러지 말자...

정말 역겹다...





WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


야마하 mt-03 타고 싶은데...

배기량 때문에 2종 소형이 필요해서... 학원을 알아 봤는데...

마나님이 안된다고 하셔서 야마하 mt-03은 꿈을 버리게 되었습니다.



그래서 좀 배기량 작은 스쿠터는 어떨까 하고 검색하다가 우연찮게... 이뻐 보이는 스쿠터가 있어서...


http://storefarm.naver.com/lsl19690/products/485629786



요놈 ㅎㅎ

사고 싶어 마나님에게 링크를 보냈더니...

단박에 거절 ㅠㅠ ( 저 죽으면 어떻게 가족이 먹고 사냐고... 바퀴 2개 짜리는 안된다네요 ㅠㅠ)


그냥 오래된 구아방이나 몰고 다녀야 겠네요 ㅠㅠ





WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret


얍삽하다는 의미인 것 같다...

그런 사람들이 알아서 먼저 살길 찾아가는 것 같고...


그러다 보면 가만히 있는 사람이 쓰레기 청소 다 하고... 피해 보는 것 같다.


알아서 살길 찾아 가는 것 까지야 뭐라 안하는데...

가기 전까지 왜 이리 잘난척 다하면서, 조직에 혼신을 다 바칠 것처럼 하면서...

이제와서 나 몰라라 하는거냐 -.-;;


그렇게 살지마라...




WRITTEN BY
체리필터
프로그램 그리고 인생...

트랙백  0 , 댓글  0개가 달렸습니다.
secret