프로젝트를 진행하면서 서버를 띄워야 하는 순간이 왔습니다.

이런저런 방법을 알아본 결과, aws의 ecs라는 서비스를 선택해서 진행했습니다.

서버를 띄우는데까지 성공했고 이제 서버에 로그를 남기려고 확인해보니, 로컬에서 프로젝트를 실행했을 때 나오는 포맷 로그가 그대로 cloudwatch에 표출되고 있었습니다.

물론 로컬에서 개발 할 당시에는 기본 제공되는 로그 뿐만 아니라, db 쿼리, 네트워크 통신 내용 등 여러가지를 볼 일이 많기 때문에 포맷에 크게 손을 대지는 않았습니다.

 

하지만 서버의 로그를 확인하기에는 불편한점이 한 둘이 아니었습니다.

예를들면 놓치고 예외처리를 못한 Exception이 발생했을 때, stackTrace가 출력되는 경우가 있는데, 이런 경우에는 라인 수 가 너무 길어서 확인하기 어려울 정도입니다.

 

그래서 편하게 볼 수 있는 방법이 없을 까 고민하던 찰나에, Log4j2 라이브러리를 이용하면 로그를 json 형식으로 남길 수 있다는 사실을 알게되었습니다.

프로젝트는 적용 완료해서 잘 사용하고 있고, 까먹을 것 같아 정리해둡니다.

 

Log4j2를 선택한 이유

  1. Json format의 Log
  2. Json 속성 커스터마이징

cloudwatch에서 로그를 편하게 보기 위해서 제가 가장 중요하다고 생각했던 두 가지 입니다.

저는 Spring boot를 사용하기 때문에, spring-boot-starter 의존성을 추가하면 그 안에는 기본적으로 logback이 포함되어있습니다.

logback도 로그를 json 포맷으로 설정할 수 있게 지원하고있지만, 제가 필요로 하는 속성들 외에 서버를 운영하는데 크게 필요하지 않다고 느껴지는 속성들이 많이 있었습니다. (mdc, label, tags 등 …)

 

하지만 이 부분들을 제외하는 방법을 찾지 못했습니다.

Log4j2는 이런 부분들을 제거할 수 있고, 내가 원하는 속성들로만 구성할 수 있기 때문에 Log4j2를 사용하게 되었습니다.



Build.gradle 의존성 수정

 

우선, spring에서 기본으로 제공하는 로깅 프레임워크를 사용하지 않을 것이기 때문에 의존성을 제외해주어야 합니다.

제외하지 않으면 어떻게 되나 궁금해서 그냥 해봤는데, 오류가 발생하면서 프로젝트가 종료되더라구요.

둘 다 @Slf4j의 구현체들 이기 때문에 스프링이 올라오면서 충돌이 일어나는 것 같습니다.

 

Spring 기본 제공 로깅 프레임워크 exclude

configurations {
    ...
    all {
        exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
    }
}

이제 Log4j2 의존성을 추가해줍니다.

그리고 log4j2 설정을 yml로 작성할 것이기 때문에, yml을 json형태로 읽을 수 있도록 도와주는 라이브러리도 추가했습니다.

 

log4j2 의존성 추가

dependencies {
    ...
    implementation 'org.springframework.boot:spring-boot-starter-log4j2' // Log4j2
    implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.14.2' // log4j2.yml을 json 형태로 읽을 수 있게 도와주는 라이브러리
    ...
}



Log4j2 설정

 

저는 local, dev, prod라는 세가지 환경을 구성할 것인데, local은 제 컴퓨터에서 작업하는 것이기 때문에 dev와 prod에만 json 설정을 진행하겠습니다.



Log4j2에서 기본적으로 지원하는 JSON 포맷 - JSON_LAYOUT_APPENDER

 

우선, Log4j2에서 기본으로 지원하는 JSON포맷을 한번 살펴보겠습니다.

log4j2.yml에 JSON_LAYOUT_APPENDER를 적용하면 Log가 Json 포맷으로 만들어지는데, JsonTemplateLayout이라는 plugin 클래스와 Jackson이 사용됩니다.

만약 Jackson이 없다면 추가해주어야 합니다.



log4j2.yml

Configuration:
  name: DefaultConfiguration
  status: INFO
    Properties:
    Property:
      - name: pattern
        value: "%msg%n"

  Appenders:
    Console:
      - name: JSON_LAYOUT_APPENDER
        target: SYSTEM_OUT
        JSONLayout:
          objectMessageAsJsonObject: true
          compact: true
          eventEol: true

      - name: CONSOLE_ROOT
        target: SYSTEM_OUT
        PatternLayout:
          alwaysWriteExceptions: true
          pattern: ${pattern}

    Root:
      level: INFO
      AppenderRef:
        ref: CONSOLE_ROOT

위는 전체 코드이고, 여기에서 Appenders.Console 부분을 살펴보겠습니다.



Appenders.Console

...
  Appenders:
    Console:
      - name: JSON_LAYOUT_APPENDER
        target: SYSTEM_OUT
          JSONLayout:
            objectMessageAsJsonObject: true
            compact: true
            eventEol: true
...

Console 속성은 말 그대로 로그가 출력될 때의 설정을 해주는 곳이고, 여기에 해당하는 여러가지 옵션들이 있지만, 저는 name과 target만 설정해주겠습니다.

JsonLayout 하위에 여러가지 옵션을 적용할 수 있는데, 이 부분은 공식문서 링크를 남겨놓겠습니다.

여기까지 설정을 했으면, 이제 아래와 같은 Json 포맷으로 만들어진 로그를 볼 수 있습니다.

{ "field" : "value" }



실제 프로젝트에 적용해보면 아래와 같은 형태입니다.

{"instant":{"epochSecond":1698385086,"nanoOfSecond":838851300},"thread":"main","level":"INFO","loggerName":"org.springframework.boot.web.embedded.tomcat.TomcatWebServer","message":"Tomcat started on port(s): 8080 (http) with context path ''","endOfBatch":false,"loggerFqcn":"org.apache.commons.logging.LogAdapter$Log4jLog","threadId":1,"threadPriority":5}

 


 

한계

 

Log4j2에서 기본으로 제공하는 JsonLayout을 이용해도 로그를 Json 형태로 얻을 수는 있지만, 실제로 사용하기에는 무리가 있다고 생각합니다.

시스템에서 발생하는 로그의 경우엔, 기본 제공 형태를 사용해도 무리는 없을것 같은데, 제가 보고싶었던 로그는 사용자의 요청 정보를 한 눈에 볼 수 있는 형태였습니다. (페이지 하단으로 가시면 제가 보고싶었던 로그의 형태를 볼 수 있습니다.)

 

그래서 이런저런 설정을 시도해보았지만, 실제 프로젝트에 적용된 로그처럼 시스템 정보를 제외하는 방법을 찾지 못했습니다.

그래서 아래에서는 커스텀 플러그인을 추가해서 제가 원하는 속성 (ex - url, http method, user-agent ..)들만 Json에 담길 수 있도록 설정을 해보겠습니다.



CustomLayout 적용



CustomLayout Plugin class 생성

  • 공식 홈페이지를 참고하면 CustomLayout Plugin을 손쉽게 만들 수 있습니다.
@Plugin(name = "CustomLayout", category = Node.CATEGORY, elementType = Layout.ELEMENT_TYPE, printObject = true)
public class CustomLayout extends AbstractStringLayout {

    private static final String DEFAULT_EOL = "\r\n";

    protected CustomLayout(Charset charset) {
        super(charset);
    }

    @PluginFactory
    public static CustomLayout createLayout(@PluginAttribute(value = "charset", defaultString = "UTF-8") Charset charset) {
        return new CustomLayout(charset);
    }

    @Override
    public String toSerializable(LogEvent event) {
        return event.getMessage().getFormattedMessage() + DEFAULT_EOL;
    }
}
  • @Plugin
    • CustomLayout이라는 클래스를 Log4j2 플러그인으로 등록합니다.
  • @PluginFactory
    • Log4j2에게 이 메서드가 플러그인을 생성하기 위한 팩토리 메서드임을 알려줍니다.
    • 팩토리 메서드이기 때문에 메서드명은 마음대로 지정해도 됩니다.
      동작 과정
      1. yml 파일에 미리 설정한 정보를 읽습니다.(Appenders.Console.name.CustomLayout: 이 부분)
      2. Log4j2는 위 파일을 읽어서 @PluginFactory 어노테이션이 붙은 createLayout 메서드를 호출하고, yml파일에 저는 주석을 해놓았지만 만약 charset에 값을 설정해주었다면, 그 부분을 파라미터로 전달합니다.
      3. createLayout 메서드는 전달된 charset을 이용하여 CustomLayout 인스턴스를 생성하고 반환하면서 사용할 수 있도록 만들어줍니다.

 

CustomLayout - yml 파일에 추가

  • 위에서 만든 Layout을 사용하기 위해서 yml에 파일에 추가를 해줍니다.
  ...    

  Appenders:
    Console:
      # 추가
      - name: CUSTOM_LAYOUT_APPENDER # 위에서 추가한 plunin
        target: SYSTEM_OUT
        CustomLayout: {}

      - name: JSON_LAYOUT_APPENDER
                ... 

  Loggers:
    logger:
      # 추가
      - name: CUSTOM_JSON_LAYOUT_LOGGER # 실제로 사용할 것
        level: info
        additivity: false
        AppenderRef:
          ref: CUSTOM_LAYOUT_APPENDER

      - name: CONSOLE_ROOT
        ...

    ...
  • Appenders에는 새롭게 만든 layout plugin을 추가해주었습니다.
  • 해당 layout을 자바 클래스에서 사용하기 위해 logger에 이름을 부여해서 추가해줍니다.

 

logger 사용

  • 로그를 남길 클래스에서 CUSTOM_JSON_LAYOUT_LOGGER를 사용하기 위해 LogManager에 넣어줍니다.
  • 저의 경우에는 로그를 남기는 곳이 filter이기 때문에 filter에 적용했습니다.
public class Filter {
        private static final String CUSTOM_JSON_LAYOUT_LOGGER = "CUSTOM_JSON_LAYOUT_LOGGER";
        private Logger logger = LogManager.getLogger(CUSTOM_JSON_LAYOUT_LOGGER);

        ...
}

여기까지 됐으면 비어있는 layout으로 된 json log 포맷을 사용할 준비가 됐습니다.

이대로 로그를 찍으면 {} 이렇게만 나올것입니다.

 

Json 속성 만들기

  • 이제 위 layout에 채워줄 속성을 만들어줄 차례인데, 크게 Type과 Body로 나눌것이고, Body에 들어가는 속성들은 고정하지 않고, 상황에 맞게 설정할 수 있도록 Map을 사용하겠습니다.
public class CustomMessage implements Message {

    private static final String TYPE = "type";
    private static final String BODY = "body";

    private final Map<String, Object> log;

    public CustomMessage(Map<String, Object> log) {
        this.log = log;
    }

    @Override
    public String getFormattedMessage() {

        JSONObject realLog = new JSONObject(log);
        JSONObject createdLog = new JSONObject(new HashMap<String, Object>() {{
            put(TYPE, "Request Log");
            put(BODY, realLog);
        }});
        return createdLog.toString();
    }

    @Override
    public String getFormat() {
        return log.toString();
    }

    @Override
    public Object[] getParameters() {
        return new Object[0];
    }

    @Override
    public Throwable getThrowable() {
        return null;
    }
}

 


 

실제 요청 적용

 

Controller

@RestController
public class Controller {
    private static final String CUSTOM_JSON_LAYOUT_LOGGER = "CUSTOM_JSON_LAYOUT_LOGGER";
    private Logger logger = LogManager.getLogger(CUSTOM_JSON_LAYOUT_LOGGER);

    @GetMapping("/test")
    public void test(@RequestParam String name, @RequestParam String mail) {
        Map<String, String> map = new HashMap<>();
        map.put("name", name);
        map.put("mail", mail);
        CustomMessage message = new CustomMessage(map);
        logger.info(message);
    }
}

 

Custom Layout이 적용된 Log

{
    "type": "Request Log",
    "body": {
        "name": "test", 
        "mail": "test@google.com"
    }
}

사용자마다 사용하는 방식은 다르겠지만 저는 실제로 사용할 떄 CustomMessage 클래스의 log변수의 타입인 Map으로는 아쉬워서 별도의 속성을 담을 클래스를 만들어서 사용했습니다.

이 부분은 원하시는 방법을 선택하셔서 유연하게 사용하시면 좋을 것 같습니다.

지난 시간에는 두 개의 물리적 데이터베이스 연결하는 방법을 소개하다가 문제점을 발견했습니다.
이번 시간에는 그 문제를 해결하면서 두 개의 데이터베이스를 연결하는 방법을 소개하려고 하는데, 그 전에 문제점에 대해서 잠시만 짚고 넘어가보도록 하겠습니다.

지난 시간에서의 문제점은 각 기능이 하나의 데이터베이스에만 작업하면 문제가 없지만, 한 기능에서 두개의 데이터베이스 작업을 했을 때 트랜잭션 문제가 있었습니다.
이게 만약 물리적 데이터베이스 두개가 아니라, 물리적 데이터베이스 하나에 두개의 Database를 연결하는 것이었다면, Transaction Propagation(트랜잭션 전파)으로 해결할 수 있었을 것입니다.

물리적 데이터베이스 두 개 연결



하나의 데이터베이스에 database 두 개 연결



하지만 우리가 하려는건 물리적 데이터베이스 두개를 연결하는 것이기 때문에 트랜잭션 전파로는 해결이 되지 않습니다.
(이 글과 동일한 주제로 설명과 테스트를 해주신 분이 있는데 트랜잭션 전파로 테스트도 해주셨기 때문에 아래 블로그를 한번 읽어보시는 것을 추천드립니다.)
https://supawer0728.github.io/2018/03/22/spring-multi-transaction/

그래서 아래와 같은 문제점이 있습니다.

예시 - 한 메서드에서 두개의 데이터베이스 작업

public class AService {
        private final ARepository aRepository;
        public void save() {
                aRepository.save();
        }
}

public class BService {
        private final BRepository bRepository;

        public void save() {
                bRepository.save();
                throw new RuntimeException();
        }
}

public class CommonService {

        private final AService aService;
        private final BService bService;

        @Transactional
        public void commonSave() {
                aService.save();
                bService.save();
        }
}

문제 발생 과정

스프링 프레임워크에서 @Transactional 어노테이션을 사용하여 트랜잭션 관리를 하는 경우, 기본 설정은 단일 데이터 소스 및 연결된 단일 트랜잭션 매니저에 최적화되어 있습니다. 이러한 설정은 commonSave 메서드에서 aService.save()만 호출되는 경우와 같이 단일 데이터베이스 작업에는 문제가 없습니다.

그러나 문제는 CommonService 클래스 내 commonSave 메서드에서 A, B 두 데이터베이스에 대한 작업을 동시에 처리할 때 발생합니다. 앞서 얘기한 것 처럼 @Transactional 어노테이션은 하나의 트랜잭션 매니저를 사용하여 트랜잭션을 관리합니다. 특별한 옵션을 지정하지 않았기 때문에, @Primary로 지정된 트랜잭션 매니저가 사용될 것이고, 이 예제에서는 A 데이터베이스가 @Primary 트랜잭션 매니저로 설정되어 있다고 가정하겠습니다.

bService.save() 메소드를 보면, 데이터베이스에 저장한 뒤 예외를 발생시키는 코드가 있습니다. A 데이터베이스를 관리하는 @Primary 트랜잭션 매니저는 B 데이터베이스 작업에서 발생한 예외를 적절히 처리하지 못합니다. 결과적으로, aRepository의 작업은 예외 발생 시 롤백될 수 있지만, bRepository의 작업은 롤백되지 않고 완료됩니다. 이는 A와 B 데이터베이스 간의 데이터 불일치를 발생시키며, 두 개의 독립된 데이터베이스 작업을 관리할 수 있는 새로운 해결책이 필요합니다.

아래에서 JtaTransactionManager를 이용해서 위 문제를 해결해보겠습니다.



Jta 적용하기

JTA(Java Transaction Api)는 자바 표준으로써, 분산 transaction을 관리할 수 있게 해주는 인터페이스를 제공합니다. 인터페이스를 제공하는다는 뜻은, 트랜잭션 처리가 필요한 자원이 특정 벤더 (mysql, oracle …)에 의존할 필요가 없음을 의미합니다.
그러면 트랜잭션 처리를 해줄 수 있는 구현체를 등록해주어야 하는데, 이 글에서는 오픈소스로 제공하는 Atomikos를 사용할 예정입니다.

XA는 데이터베이스 트랜잭션과 같은 자원 관리자 간의 트랜잭션을 도와주는 프로토콜인데, 2PC(Two Phase Commit) - 즉, 2단계 커밋 방식으로 분산 트랜잭션을 처리합니다.
2단계 커밋이란, 분산 트랜잭션에 참여하는 리소스들이 하나의 글로벌 트랜잭션에서 동일한 커밋 또는 롤백을 할 수 있도록 보장하는 과정이고, 준비 단계커밋 단계로 나뉘어집니다.

준비 단계(Prepare Phase)

  • 각 트랜잭션 매니저에게 트랜잭션을 커밋할 준비가 되었는지 묻는 단계 입니다

커밋 단계(Commit Phase)

  • 모든 트랜잭션 매니저가 준비 완료 응답을 보냈을 때, 글로벌 트랜잭션을 실제로 커밋/롤하는 단계입니다.
  • 만약에 어느 하나라도 실패 응답을 보냈다면, 글로벌 트랜잭션은 모든 트랜잭션 매니저에게 롤백 명령을 보냅니다.

이와 같은 방법으로 데이터베이스를 벤더와 관계없이 하나 이상 사용할 때 트랜잭션을 보장할 수 있는데, 매번 2PC 방식으로 트랜잭션을 처리하게 되면 아무래도 단일 트랜잭션을 사용할 때 보다는 성능에 문제가 생긴다는 단점이 존재합니다.

코드 적용

1. dependency 추가

yml부터 자바 코드까지 이전 글과 설정하는 순서는 비슷한데, 의존성이 하나 추가되고, 설정하는 내용이 조금 많이 바뀝니다.
참고로, 스프링 부트 3.0 이전까지는 spring-boot-starter-jta-atomikos를 지원했다가, 3.0 이후로는 지원 종료되었기 때문에 공식문서를 보고 적절한 의존성을 찾아야합니다.
https://www.atomikos.com/Blog/ExtremeTransactions6dot0


build.gradle

...
implementation 'com.atomikos:transactions-spring-boot3-starter:6.0.0'
implementation 'jakarta.transaction:jakarta.transaction-api:2.0.1'
...

 

2. yml 파일 작성

spring:
  jta:
    enabled: true
    atomikos:
      datasource:
        db1:
          unique-resource-name: db1DataSuorce
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
          xa-properties:
            user: username
            password: password
            url: jdbc:mysql://<DB1_주소>

        db2:
          unique-resource-name: db2DataSource
          xa-data-source-class-name: com.mysql.cj.jdbc.MysqlXADataSource
          xa-properties:
            user: username
            password: password
            url: jdbc:mysql://<DB2_주소>
  jpa:
    open-in-view: false
    properties:
      hibernate:
        show_sql: false
        format_sql: false
        use_sql_comments: false
        dialect: org.hibernate.dialect.MySQL8Dialect
        hbm2ddl:
          auto: none

우리는 두 개의 데이터베이스를 사용하기 때문에 위에 작성한 정보는 자바 코드로 직접 등록해주어야 합니다.
여기서 중요한 부분은 XA 프로토콜을 이용해서 분산 트랜잭션을 사용할 수 있도록 XA 전용 드라이버인 MysqlXADataSource을 지정한 것입니다.

 

3. Java 코드 설정

yml 파일을 읽어 객체에 저장

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.jta.atomikos.datasource")
public class AtomikosDataSourceProperty {

    private AtomikosConfig db1;
    private AtomikosConfig db2;

    @Getter
    @Setter
    public static class AtomikosConfig {
        private String uniqueResourceName;
        private String xaDataSourceClassName;
        private XaProperties xaProperties;
    }

    @Getter
    @Setter
    public static class XaProperties {
        private String url;
        private String user;
        private String password;
    }
}



DB1 - DataSource 설정

@Configuration
@RequiredArgsConstructor
public class Db1DataSourceConfig {

    private final AtomikosDataSourceProperty jtaProperty;

    @Bean
    public DataSource db1DataSource() {
        var db1 = jtaProperty.getDb1();
        return getAtomikosDataSourceBean(db1);
    }

    private DataSource getAtomikosDataSourceBean(AtomikosDataSourceProperty.AtomikosConfig db1) {
        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(db1.getUniqueResourceName());
        dataSource.setXaDataSourceClassName(db1.getXaDataSourceClassName());
        dataSource.setXaDataSource(getMysqlXADataSource(db1));
        dataSource.setMaxPoolSize(10);
        dataSource.setMinPoolSize(2);
        dataSource.setTestQuery("select 1;");
        return dataSource;
    }

    private MysqlXADataSource getMysqlXADataSource(AtomikosDataSourceProperty.AtomikosConfig property) {
        MysqlXADataSource xaDataSource = new MysqlXADataSource();
        xaDataSource.setUser(property.getXaProperties().getUser());
        xaDataSource.setPassword(property.getXaProperties().getPassword());
        xaDataSource.setUrl(property.getXaProperties().getUrl());
        return xaDataSource;
    }
}



DB2 - DataSource 설정

@Configuration
@RequiredArgsConstructor
public class Db2DataSourceConfig {

    private final AtomikosDataSourceProperty jtaProperty;

    @Bean
    public DataSource db2DataSource() {
        var db2 = jtaProperty.getDb2();
        return getAtomikosDataSourceBean(db2);
    }

    private DataSource getAtomikosDataSourceBean(AtomikosDataSourceProperty.AtomikosConfig db2) {
        AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
        dataSource.setUniqueResourceName(db2.getUniqueResourceName());
        dataSource.setXaDataSourceClassName(db2.getXaDataSourceClassName());
        dataSource.setXaDataSource(getMysqlXADataSource(db2));
        dataSource.setMaxPoolSize(10);
        dataSource.setMinPoolSize(2);
        dataSource.setTestQuery("select 1;");
        return dataSource;
    }

    private MysqlXADataSource getMysqlXADataSource(AtomikosDataSourceProperty.AtomikosConfig property) {
        MysqlXADataSource xaDataSource = new MysqlXADataSource();
        xaDataSource.setUser(property.getXaProperties().getUser());
        xaDataSource.setPassword(property.getXaProperties().getPassword());
        xaDataSource.setUrl(property.getXaProperties().getUrl());
        return xaDataSource;
    }
}



DB1 - JPA 설정

@Configuration
@EnableJpaRepositories(
        basePackages = {"com.practice.**.infrastructure.repository.jpa.db1"}
        , entityManagerFactoryRef = "db1EntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
public class Db1JpaConfig extends CommonDataSourceOption {

    private final JpaConfigProperty jpaConfig;

    public Db1JpaConfig(JpaConfigProperty jpaConfig) {
        this.jpaConfig = jpaConfig;
    }

    @Bean
    @Primary
    public EntityManagerFactory db1EntityManagerFactory(@Qualifier("db1DataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.practice.**.domain.entity.db1");
        factory.setPersistenceUnitName("db1EntityManager");
        factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        factory.setJpaProperties(hibernateProperties(jpaConfig));
        factory.afterPropertiesSet();
        return factory.getObject();
    }
}



DB2 - JPA 설정

@Configuration
@EnableJpaRepositories(
        basePackages = {"com.practice.**.infrastructure.repository.jpa.db2"}
        , entityManagerFactoryRef = "db2EntityManagerFactory"
        , transactionManagerRef = "multiTxManager"
)
public class Db2JpaConfig extends CommonDataSourceOption {

    private final JpaConfigProperty jpaConfig;

    public Db2JpaConfig(JpaConfigProperty jpaConfig) {
        this.jpaConfig = jpaConfig;
    }

    @Bean
    public EntityManagerFactory db2EntityManagerFactory(@Qualifier("db2DataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.practice.**.domain.entity.db2");
        factory.setPersistenceUnitName("db2EntityManager");
        factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        factory.setJpaProperties(hibernateProperties(jpaConfig));
        factory.afterPropertiesSet();
        return factory.getObject();
    }
}



JtaTransactionManager Config

@Configuration
@RequiredArgsConstructor
public class JtaTransactionManagerConfig {

    public static final String USER_TRANSACTION = "userTransaction";
    public static final String ATOMIKOS_TRANSACTION_MANAGER = "atomikosTransactionManager";
    public static final String MULTI_TX_MANAGER = "multiTxManager";

    @Bean
    @Qualifier(USER_TRANSACTION)
    public UserTransaction userTransaction() throws SystemException {
        UserTransactionImp userTransactionImp = new UserTransactionImp();
        userTransactionImp.setTransactionTimeout(1000);
        return userTransactionImp;
    }

    @Bean(initMethod = "init", destroyMethod = "close")
    @Qualifier(ATOMIKOS_TRANSACTION_MANAGER)
    public UserTransactionManager atomikosTransactionManager() {
        UserTransactionManager utm = new UserTransactionManager();
        utm.setForceShutdown(true);
        return utm;
    }

    @Bean(MULTI_TX_MANAGER)
    @DependsOn({USER_TRANSACTION, ATOMIKOS_TRANSACTION_MANAGER})
    public PlatformTransactionManager jtaTransactionManager() throws SystemException {
        return new JtaTransactionManager(userTransaction(), atomikosTransactionManager());
    }
}

 

분산 트랜잭션을 적용하기 위한 JtaTransactionManager config 파일입니다.

  • userTransaction()
    • 개별 트랜잭션을 처리하는데 사용니다.
    • userTransaction - 개별 트랜잭션 관리
    • userTransactionManager - 글로벌 트랜잭션 관리
  • atomikosTransactionManager()
    • 글로벌 트랜잭션을 처리하는데 사용2단계 커밋 프로토콜을 구현하고 있어, 다중 데이터 소스에서 트랜잭션을 안정적으로 처리할 수 있습니다.
    • begin(), commit(), rollback() 메서드들을 처리하면서 데이터의 정합성 및 격리(Isolation)를 보장합니다.
  • jtaTransactionManager()
    • 트랜잭션을 시작하거나 커밋/롤백하는데 사용되며, 글로벌 트랜잭션에 참여할 수 있도록 도와줍니다.
    • 특히 여러 DataSource나 JMS와 같은 분산 리소스에서 트랜잭션을 처리하는데 유용한 기능을 제공합니다.
    • 아래 두가지 트랜잭션 매니저를 PlatformTransactionManager 하나로 묶어줍니다.
      • 글로벌 트랜잭션 - userTransactionManager
      • 개별 트랜잭션 - userTransaction

여기까지 구현한 뒤 프로젝트를 실행하면, 아래와 같은 Atomikos에서 찍어주는 로그를 확인할 수 있습니다.

 


 

여기까지 atomikos를 이용해서 여러개의 데이터베이스를 연결한 뒤 트랜잭션을 보장하는 방법을 알아보았습니다.
여기까지 하면 트랜잭션은 보장되지만, 이대로만 사용하게 되면, DB1이나 DB2 단일 작업을 할 때도 multiTxManager를 사용하기 때문에 성능에 이슈가 생길 수 있습니다.

실제 운영환경에서 사용할 때에는 분산 트랜잭션 매니저는 정말 분산 트랜잭션이 필요할 때만 사용할 수 있게끔 설정을 하거나 혹은 다른 방법을 고려해야 할 것 같습니다.

자바 코드를 설정하는 부분에서 클래스가 생각보다 많아졌는데, 어떻게 보면 과하다고 생각할 수 있을지 모르겠으나, 저는 이렇게 이름에 맞게 분리하는게 보기도 편하고 관리하기도 편해서 저만의 스타일로 클래스를 나눠봤습니다.
혹시라도 이 글을 참고하시는 분들께서는 각자에 맞는 방법으로 작성해주시길 바랍니다.

회사에서 개발을 진행하다가 원래는 하나의 데이터베이스만 사용했는데, 이번에 새로운 기능이 추가되면서 다른 데이터베이스를 붙여야 하는 일이 있었습니다.

처음 시도해보는거라 까먹을 것 같아서 나중에 필요하면 찾아볼 겸 데이터베이스를 두개 연결해서 사용하는 방법을 정리해봅니다.

1. YML 설정

spring:
  datasource:
    db1:
      jdbc-url: jdbc:log4jdbc:mysql://<DB1_주소>
      username: username
      password: password
      driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

    db2:
      jdbc-url: jdbc:log4jdbc:mysql://<DB2_주소>
      username: username
      password: password
      driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy

원래 dataSource가 하나였을땐 스프링이 yml파일을 읽은 뒤 빈으로 등록해주지만, 두개부터는 각 dataSource마다 이름을 만들어주어야 하기 때문에 자바 코드로 직접 설정을 해주어야 합니다.


2. Java 코드 설정

1. DataSource 설정

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties(prefix = "spring.datasource.db1")
    public DataSource db1DataSource() {
        return new DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.db2")
    public DataSource db2DataSource() {
        return new DataSourceBuilder.create().build();
    }

}

@Primary 어노테이션을 붙인이유는, @Primary를 붙이지 않고 실행을 하게 되면 빈을 주입할 때 어떤걸 주입해야할지 애매하기 때문에 예외가 발생합니다.
그래서 기본으로 등록할 dataSource에 @Primary를 설정해줍니다.

@ConfigurationProperties 어노테이션의 속성에 yml파일에서 설정한 prefix를 입력하면, 설정파일을 읽어서 값을 세팅해줍니다.
정확하진 않지만 궁금해서 내부 구현을 살펴보니, 아마 이부분에 의해서 값이 세팅되는 것 같습니다.

 

2. JPA 설정

@Configuration
@EnableJpaRepositories(
        basePackages = <컴포넌트 스캔할 대상이 있는 패키지>
        , entityManagerFactoryRef = "db1EntityManagerFactory"
        , transactionManagerRef = "db1JpaTransactionManager"
)
public class Db1JpaConfig {

    @Bean
    @Primary
    public EntityManagerFactory db1EntityManagerFactory(@Qualifier("db1DataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("..."); <JPA Entity가 존재하는 패키지를 작성>
        factory.setPersistenceUnitName("db1EntityManager");
        factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        factory.afterPropertiesSet();
        return factory.getObject();
    }

    @Bean
    @Primary
    public PlatformTransactionManager db1JpaTransactionManager(@Qualifier("db1EntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager tm = new JpaTransactionManager();
        tm.setEntityManagerFactory(entityManagerFactory);
        return tm;
    }
@Configuration
@EnableJpaRepositories(
        basePackages = <컴포넌트 스캔할 대상이 있는 패키지>
        , entityManagerFactoryRef = "db2EntityManagerFactory"
        , transactionManagerRef = "db2JpaTransactionManager"
)
public class Db2JpaConfig {

    @Bean
    public EntityManagerFactory db2EntityManagerFactory(@Qualifier("db2DataSource") DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("..."); <JPA Entity가 존재하는 패키지를 작성>
        factory.setPersistenceUnitName("db2EntityManager");
        factory.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
        factory.afterPropertiesSet();
        return factory.getObject();
    }

    @Bean
    public PlatformTransactionManager db2JpaTransactionManager(@Qualifier("db2EntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        JpaTransactionManager tm = new JpaTransactionManager();
        tm.setEntityManagerFactory(entityManagerFactory);
        return tm;
    }



3. 사용방법

@Service
public class ProductService {
    @Transactional("db1JpaTransactionManager") // 생략 가능
    public void db1Products() {
        ...
    }

    @Transactional("db2JpaTransactionManager")
    public void db2Products() {
        ...
    }
}

db1JpaTransactionManager에 생략가능을 적어놓은 이유는,
JpaConfig에서 설정할 때 db1JpaTransactionManager에 @Primary를 적용했기 때문에 기본 트랜잭션 매니저가 db1JpaTransactionManager로 지정되어있어서 생략가능하다고 적어놓은 것입니다.


4. 문제

여기까지 하면 데이터베이스 두개 연결을 완료했습니다.

만약 하나의 기능에서 두개의 데이터베이스에 insert, update하는 기능이 없고, 각 기능에서 각각의 데이터베이스만 사용한다면 당분간은 큰 문제 없이 이대로 사용할 수 있을 것입니다.
하지만 그런 기능이 있다면 문제는 이때 발생합니다.

보통 데이터베이스에 접근해서 어떤 작업을 할 때 @Transactional 이용하는데, 트랜잭션을 시작하려면 커넥션이 필요한데, 이때 트랜잭션 매니저가 내부에서 dataSource를 사용해서 저장되어있는 커넥션을 꺼내옵니다.
그리고 그 커넥션을 이용해서 실제 데이터베이스에 쿼리를 보내는 등 작업을 시작하게됩니다.

근데 지금까지 작성한 코드를 보면 각 트랜잭션 매니저는 각각의 데이터소스만 가지고있고, 서로에 대한 데이터소스는 모르는 상태입니다.

@Transactional
public void db1_db2() {
    ...
}

위의 코드에서 db1_db2() 메서드를 살펴보면 @Transactional에 옵션이 지정되어있지 않아서 db1JpaTransactionManager 트랜잭션 매니저를 사용하게 될 것입니다.

db1JpaTransactionManager 트랜잭션 매니저가 알고 있는 dataSource는 db1DataSource이기 때문에 db2DataSource에 대한 작업을 시도하면 어떤 문제가 발생할지 모르고, 만약 작업 중 예외가 발생했을 때 제대로 rollback을 시켜줄 수도 없습니다.

예외가 발생했을 때 rollback이 안되는 일은 데이터 정합성에 크게 문제가 될 수 있기 때문에 트랜잭션을 보장해 줄 수 있는 방법을 찾아야 합니다.


다음 글에서는 Atomikos에서 제공하는 JtaTransactionManager를 이용해서 멀티 데이터베이스 연결하는 방법을 작성해보겠습니다.

+ Recent posts