현재 회사에서 운용하고 있는 Galera Cluster 관련 이슈를 해결하기 위해 학습했던 내용을 공유하는 글입니다. 개인의 이해도 부족으로 인하여 잘못된 내용이 포함되어 있을 수 있으며 트러블 슈팅이 아닌 원론적인 이야기만 나열하더라도 감안해주시고 피드백 부탁드립니다.

 

 

 

Galera Cluster?

Galera Cluster는 Mutl Master Cluster 구조를 지원하는 MySQL, MariaDB 등의 계열에 호환되는 Open Source입니다.

 

복제 구성시 Origin node와 Replica node 사이의 Eventual consistency로 일정 시간 동안의 데이터 부정합 문제를 해결하기 위해 적용할 수 있는 방법인 Synchronous replication에는 몇 가지 문제점이 존재합니다.

  • 네트워크 문제로 인한 영구적인 Node 간의 Blocking 상태 발생 가능성
  • Node 가 확장될수록 증가하는 복제 지연 시간에 의한 서비스 중단 발생 등

Galera Cluster는 이를 우회적으로 해결하는 Wsrep API 기반의 Semi-synchronous replication과 Asynchronous replication 방식을 제공함으로써 데이터 부정합 문제를 해결하고 복제 시 서비스 중단 시간을 최소화할 수 있도록 지원합니다. (실제로는... 음..)

Galera Cluster 또한 rsync라고 불리는 Synchronous replication 방식도 존재합니다.

 

 

 

 

Galera Cluster Multi Master 구성의 확장성?

경험 상 Galera Cluster의 Multi Master 구조는 Scale-out 기반 확장과 어울리지는 않습니다.

 

첫 번째로는 Multi Master 구성 시 네트워크 문제로 인해 발생하는 Split Brain 문제가 발생할 수 있다는 점입니다.

  • 해당 문제를 해결하기 위해서는 클러스터 내의 네트워크 분할 시에도 최소한 분단된 한쪽의 Cluster Group이 Node가 많을 수 있도록(결정할 수 있도록) 홀수로 구성하여야 합니다.
  • 하나 이상의 Node가 죽고, 네트워크 분단 현상으로 인해 Cluster가 짝수로 분할되어 Split Brain 문제가 발생하지 않도록 Galera arbitrator를 구성하여 합의 알고리즘에서 항상 홀수를 만족하게 구성할 수도 있습니다.

두 번째로는 Multi Master 구성 시 Write Node가 많아짐으로써 Replica Node(Joiner)들의 recv queue에 쌓이는 Transaction의 량이 많아지기 때문에 처리 부하, Flow Control 발생 빈도와 지연 시간이 비례해서 증가합니다. 

 

세 번째로는 여러 Originnode가 하나의 레코드를 수정하는 경우를 해결하기 위해 X lock과 First commit one 전략을 사용하는데 이 전략의 경우 반영하지 못한 Node는 Dead lock이 발생한다는 점입니다.

 

다른 이유 또한 존재하나 이정도로 정리하겠습니다. 이러한 문제들은 현재 Galera Cluster의 구조적 한계로 보입니다.

 

 

 

 

Galera Cluster의 Master Node는 최소한으로 유지하자.

서비스의 데이터와 관련하여서 부하를 측정하고 Write가 가능한 Node가 더 필요한 상황이라면 개별 Cluster Group으로 나누는 것이 현명한 선택인 것 같습니다. 이는 앞서 언급한 상황이 발생하기 때문입니다.

  • 현재 경험 상 관리하는 서비스 데이터를 기준으로 클러스터를 분리하고 각 클러스터마다 총 3개~5개의 node (하나의 Write node, 나머지는 Read node)를 두어 Query Off 하는 것이 좋다고 생각합니다.
  • ex) 채팅 Group: Read/Write 1 : Read 2 | 인증, 계정 Group: Read/Write 1 : Read 2

 

 

 

Gcache를 잘 설정하여 최대한 IST(증분 상태 복제) 방식의 복제를 사용하게 하자.

IST, 즉 증분 상태 전송을 유지하는 방법은 Gcache size를 적절하게 잡는 것으로 수행할 수 있으나, 해결 방법이 그렇게 단순한 것은 아닙니다. Size를 결정할 때 유의해야 하는 점은 Gcache size를 IST 발생 이전에만 넘지 않도록 하면 되는 것이 아니라, IST 중에서도 발생하는 Transaction 양 또한 고려해야 한다는 점입니다. 또한 서비스 트래픽이 급증하여 기존에 계산했던 용량을 넘어갈 수 있습니다.

 

IST를 통해 처리되는 Transaction 양이 Write로 인해 쌓이는 Transcation 양을 넘어서지 못하고 Gcache size를 넘어서게 되어 IST가 SST 방식로 변경되는 경우는 빈번하게 일어납니다.

 

그럼에도 이러한 점을 고려하여 굳이 IST를 유지해야하는 이유는 이렇습니다.

  • IST는 SST와 달리 wirte set에서 반영되지 않은 부분만 식별하여 복제하는 방식으로 최소한의 리소스만을 이용해 데이터 동기화를 진행할 수 있으며, non-blocking으로 진행되기 때문에 복제 대상(Read Node, Joiner)에게 성능 부하를 최대한 주지 않습니다.
  • IST 방식은 다른 xtrabackup, mariabackup 방식보다 50% 정도 빠르며 mysqldump 방식보다 5배 이상 빠르기 때문에 이를 지속적으로 유지하도록 하는 것은 Galera Cluster를 통한 서비스 운영 시 큰 최적화 요소가 됩니다. 

 

Gcache Size를 설정하는 기본적인 공식은 여기서 확인할 수 있습니다.

 

추가적으로 gcache.recover=yes 설정을 통해 생성하였던 Gcache file을 삭제하지 않고 재사용하도록 함으로써 누락된 Write set을 식별할 데이터가 없을 때 발생하는 SST을 방지함으로써 IST 방식을 유지할 수 있습니다.

이는 Gcache file이 지속적으로 유지되어야 Read Node의 Gcache와 비교가 가능하기 때문입니다. (반대의 경우도 포함)

 

 

 

 

Flow Control의 발생 빈도를 조절하자.

Flow Control이란 복제를 지속하던 Cluster node (Write -> Read node) 외에 아직 반영되지 않았거나 commit 되지 않은 Transaction을 write Set(Gcache)으로 저장하여 순서를 정렬하고 recv queue에 쌓아두었다가 queue가 일정 크기를 만족하면 기존 복제 작업을 중지하고 쌓여있는 Transaction을 반영하는 작업을 의미합니다.

 

 

Multi Master 인 경우의 Flow Control

Write node가 많다는 것은 결국 Write가 여러 node에서 동시 다발 적으로 일어나고 있다는 것을 뜻합니다. 이 경우 큰 Gcache(write set)과 긴 recv queue를 둔다면 한번 발생한 Flow Control의 지연 시간이 길어질 수 있습니다. 또한 recv queue가 길수록 API에서 인증 충돌이 발생할 수 있는 여지가 비례적으로 증가합니다.

  • 이때에는 recv queue를 낮은 값으로 설정하여 일으키도록 하는 것이 낫다고 합니다. (권장 사항)

 

Single Master 인 경우의 Flow Control

Single Master는 위와 같이 인증 충돌이 발생할 여지가 없습니다. 다른 Write node와 동일한 영역을 commit 했을 경우 수행하는 first committer win 전략을 사용할 필요가 없기 때문에 그 과정 중에 발생하는 Dead lock이 발생하지 않기 때문입니다.

  • 그렇기에 recv queue를 크게 잡아도 된다고 합니다.

 

 

 

 

Flow Control의 처리 속도를 향상 시키자.

wsrep_slave_threads 속성을 통해 Parallel Replica(Slave) threads를 지정하여 recv queue에 있는 wirte set을 병렬로 처리하도록 설정할 수 있습니다. 이를 사용하면 데이터의 유실이 발생하지 않는 상태에서 대량의 DML을 처리하여 복제 지연 시간을 최소화할 수 있으며, Write 부하가 급증하더라도 대응할 수 있는 기반이 됩니다.

  • SHOW STATUS LIKE 'wsrep_cert_deps_distance'; 를 통해 권장하는 thread 개수를 확인할 수 있습니다.
  • InnoDB를 엔진으로 사용하는 경우 innodb_autoinc_lock_mode를 2로 설정합니다. 자동 증가 잠금 모드
  • 이를 적용하더라도 노드 간의 통신 왕복 시간(RTT) and Gcache(write set) size에 의해 처리량이 제한될 수 있습니다.

 

하지만 그럼에도 데이터의 부정합 문제가 발생할 수 있습니다. ( Eventual consistency ) 이를 해결하기 위해 더 많은 thread를 지정할 수 있으나 권장 사항으로는 Core당 4개의 thread를 권장하고 있기에 그 이상을 넘기지 않는 것이 좋아 보입니다.

  • 최대 설정으로 처리를 하더라도 일관성 문제가 발생한다면 Parallel threads 설정을 1로 고정하고 다른 방법을 고려하여야 합니다. (클러스터 그룹의 단위를 적게 조정 등)

 

 

 

 

최적화된 설정을 위해 Galera Cluster의 작업 상태를 지속적으로 모니터링 하자.

  • 제일 기본적이자 필수적인 부분입니다. MariaDB, MySQL의 자체적인 메트릭 지표(connection, Slow query 등) 들을 제외하고도 Galera Cluster의 recv queue, send queue, Flow control 등의 지표들을 수집하여 지속적인 모니터링을 하여야 합니다.
    • 이는 Prometheus, Grafana 등의 opensource나 Galera Manager로 구성할 수 있습니다.
    • SHOW GLOBAL STATUS LIKE 'wsrep_%'; 를 지속적으로 Query 함으로써 (이는 기본적으로 그 상황의 Snapshot 정보이기 때문에 PromQL의 rate, irate를 통해 평균값이나 변동 폭을 나타내는 메트릭으로 변경하여야 합니다.)

 

 

 

참고 링크


이 글은 Java 환경에서 Dynamic Proxy 기반의 기능들을 사용할 때 주로 발생하는 문제인 Self-Invocation을 Lambda를 통해 어떻게 회피할 수 있는지에 대한 내용을 담고 있는 글입니다.

Dynamic Proxy, AOP에 대한 이론적인 부분(발생 원인, 호출 흐름 등)들은 다루지 않을 것이기 때문에 다른 글들을 참고하시길 바랍니다.


Lambda와 관련해서는 학습을 진행하며 작성한 글이기 때문에 잘못된 내용이 있을 수도 있습니다. 틀린 내용이 있다면 채널톡 혹은 댓글을 통해 피드백해주시길 바랍니다. ㅎㅎ


Self Invocation

Self Invocation은 Dynamic Proxy 기반의 기능들을 사용할 때 사소한 실수로 인하여 자주 발생하는 문제입니다. 쉽게 설명하자면, 객체 외부에서 보내는 메시지(요청)에 대해서만 반응하도록 설계되어 있기에 내부의 요청에 대해서는 반응하지 못하기 때문입니다.

JVM 생태계에서 많은 사랑을 받는 Spring Framework는 다양한 기능들을 Dynamic Proxy 방식으로 제공하고 있습니다.

  • @Transcational, @Async, @Cacheable, AOP(Before, Around) 등의 Aspect 기능들이 속합니다.


이러한 기능들에 대한 보편적인 해결 방법으로는

  • AspectJ으로 전환 혹은 부분 적용 (Weaving 방식 전환)
    • @EnableTransactionManagement(proxyTargetClass = true, mode = AdviceMode.ASPECTJ)
  • AopContext의 currentProxy() 메서드를 통해 해당 객체를 감싸고 있는 Proxy 객체를 반환
    • ((Type) AopContext.currentProxy()). someMethod();
  • 상태 변수를 통한 자기 참조 (@Autowired나 ApplicationContext.getBean() 활용)
  • 객체 외부에서 호출하는 메서드에 Dynamic Proxy가 반응하도록 설정하기

등이 있지만, 이것들을 적용하기에는 과한 상황이거나 Spring Container에 종속되는 좋지 않은 코드를 작성하게 될 수 있습니다.


Lambda를 이용하여 Self-Invocation 회피

Lambda로 어떻게 Self-Invocation을 회피할 수 있을까요?

결론만 이야기하자면 Lambda를 통해 실행되는 메서드를 접근하기 위해서 현재 호출 객체 외부로 메시지가 나가고, 최종적으로 호출해야 되는 메서드를 찾아 요청하였을 때, 외부에서 전달되기 때문에 감싸고 있는 Proxy가 해당 요청을 인지할 수 있기 때문입니다.

Java Lambda는 Reflection API, MethodHandle, LambdaMetaFactory 인터페이스를 이용하여 기능을 제공합니다.


Lambda Method를 호출하는 흐름

  1. Reflection API를 통해 실행 대상이 되는 메서드 정보를 가져옵니다.
  2. MethodHandle Lookup API에 정의된 Factory 메서드를 통해 Lookup 객체를 가져옵니다.
  3. 1번에서 가져온 정보를 Lookup.unreflect() 메서드에 전달함으로써 해당 메서드의 구현, 수행 정보를 알고 있는 MethodHandle 객체를 가져옵니다. (실제 메서드를 바라보고 있는 일종의 포인터)
  4. LambdaMetafactory.metafactory() 메서드에 필요한 인자를 넘겨 CallSite 객체를 반환받습니다. 해당 객체는 Functional Interface를 객체로 다룰 수 있으며, 매개 변수를 설정하고 응답을 반환합니다. 인자 목록은 밑에 나열하였습니다.
    1. 접근 권한을 가지고 있는 Lookup 객체
    2. 구현할 메서드 이름(Supplier Interface를 사용했을 경우 get이라는 문자열을 넘긴다.)
    3. 메서드의 매개 변수와 응답 값의 Class 정보. methodType(Supplier.class, {Type}. class)
    4. 함수 객체(Lambda)에 의해 반환될 응답 값의 유형. methodType(Object.class)
    5. 메서드의 구현 및 수행 방식을 알고 있는 MethodHandle 객체
    6. 호출 시 동적으로 적용되어야 할 응답 값의 세부 정보. methodType({Type}. class)
  5. callSite.getTarget()을 통해 호출할 메서드 정보를 가져오고 bindTo({Type}. class)를 통해 메서드에 넘길 인자 값을 지정한 뒤 Invoke를 통해 메서드를 호출합니다.

(사실상 그 호출하는 형태는 Dynamic Proxy와 유사한 것 같습니다)

꽤 복잡하지만, 결론적으로는 Lambda를 통해 호출되는 인터페이스를 인스턴스 화 하고, 메서드를 호출하기 때문에 객체 외부 요청으로 다시 돌아오는 것입니다.

  • 이 흐름은 Bytecode의 invokedynamic이 호출된 경우에 수행 흐름을 나타냅니다. Lambda에서 Function Interface를 사용하지 않고 단순한 로직을 사용하는 경우 static 메서드를 생성하여 활용하기도 합니다.
    • invokedynamic은 Bootstrap 메서드라는 특정한 메서드를 호출하고, 이를 통해 위의 호출 프로세스를 초기화 및 실행하여 CallSite 객체를 반환받습니다. (InnerClassLambdaMetafactory를 활용하여 내부 클래스 생성 후 반환) 
  • 한번 Bootstrap 메서드가 실행된다면 이후에는 기존의 CallSite와 MethodHandle를 재사용하여 요청을 처리합니다.

 

 

 

구현 예시

Self-Invocation을 회피하기 위해 구현한 TransactionalHandler입니다.

@Service
public class TransactionHandler {

    @Transactional(propagation = Propagation.REQUIRED)
    public <T> T runInTransaction(Supplier<T> supplier) {
        return supplier.get();
    }
}


Sample Service.

@Service
@RequiredArgsConstructor
public class SampleService {

    private final SampleRepository someRepository;
    private final TransactionHandler transactionHandler;

    // 특정 객체에서 호출하는 Method
    public void addNewSamples(List<Sample> samples) {
        return samples.forEach(sample ->
            transactionHandler.runInTransaction(() -> addSample(sample.toEntity()))
        )
    }

    // 외부에서 호출되는 Method
    @Transcational
    public SomeEntity addSample(SampleEntity newSample) {
        return someRepository.insertSample(newSample);
    }
}


단순한 예시여서 실제 효용성과 조금 동떨어진 감은 있지만, 실제 업무 중 활용할 수 있을만한 부분을 특정하실 수 있을 것이라고 생각합니다. ㅎㅎ 이 글은 여기까지입니다. 감사합니다.


참고 자료

(2022-10-23 build.gradle.kts 포맷 및 써볼만한 기능 예시 추가)

 

이 글은 Service, Repository와 Test code만 가지는 간단한 예제만 포함합니다.

(회사에서 도입된 것을 공부하고 있는 중이라 복잡한 예제를 글로 쓰기는 어렵네요. ㅎㅎ;)

 

상세한 내용은 JOOQ Document와 https://github.com/jOOQ/jOOQ/tree/main/jOOQ-examples 등의 링크를 참고하시길 바랍니다.

 

 

 

JOOQ 란?

Compile 시점에서 구문 오류를 파악 가능한 Type Safe 한 Native Query를 만들 수 있고, DB의 Table 정보를 기준으로 Entity를 생성하는 상용 소프트웨어 라이브러리입니다.

 

활발한 컨트리뷰트 활동과 질문이 이루어지고 있으며 계속 기능들이 확장되고 참고할 만한 레퍼런스가 늘어나는 프로젝트이기에 더욱더 매력적인 라이브러리가 되어가고 있습니다.

 

JOOQ는 QueryDSL 라이브러리와 비슷한 형태의 Java code를 사용해 Query를 생성할 수 있으며, JPQL에서 지원하지 못하는 여러 표준 SQL을 지원합니다.

Generate Configuration에 따라 POJO, DAO, Procedure 등을 생성하는 편리성도 제공합니다.

 

Spring Framework의 Boot Project는 JOOQ를 지원하는 AutoConfiguration(COC)도 제공합니다.

 

 

 

Project 생성

Data JPA, Postgres, JOOQ와 Lombok을 추가합니다. (해당 예제에서는 Web 관련 구현을 하지 않고 통합 테스트만 작성합니다.)

 

 

docker script

docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_USER=name -e POSTGRES_DB=sample --name postgres -d postgres

해당 예제에서 사용할 DB는 Docker Image를 통해 간단하게 구성합니다.

 

 

build.gradle

// Build.gradle
jooq {
    version = dependencyManagement.importedProperties['jooq.version'] // use jOOQ version defined in Spring Boot
    configurations {
	    main {
            generateSchemaSourceOnCompilation = true // default (can be omitted)
            generationTool {
                logging = org.jooq.meta.jaxb.Logging.DEBUG
				jdbc {
					driver = 'org.postgresql.Driver'
					url = 'jdbc:postgresql://localhost:5432/sample'
					user = 'name'
					password = 'password'
				}
				generator {
					name = 'org.jooq.codegen.DefaultGenerator'
					database {
						name = 'org.jooq.meta.postgres.PostgresDatabase'
						unsignedTypes = false
						inputSchema = 'public'
						forcedTypes {
							forcedType {
								name = 'varchar'
								includeExpression = '.*'
								includeTypes = 'JSONB?'
							}
							forcedType {
								name = 'varchar'
								includeExpression = '.*'
								includeTypes = 'INET'
							}
						}
					}
					generate {
						deprecated = false
						records = true
						immutablePojos = true
						fluentSetters = true
						javaTimeTypes = true
					}
					target {
						packageName = 'jooq.dsl'
						directory = 'src/generated/jooq/'
					}
					strategy.name = 'org.jooq.codegen.DefaultGeneratorStrategy'
				}
			}
		}
	}
}

// Build.gradle.kts
jooq {
	configurations {
		create("main") {
			generateSchemaSourceOnCompilation.set(true)
			jooqConfiguration.apply {
				jdbc.apply {
					driver = "org.postgresql.Driver"
					url = "jdbc:postgresql://localhost:5432/sample"
					user = "name"
					password = "password"
				}
				generator.apply {
					name = "org.jooq.codegen.DefaultGenerator"
					database.apply {
						name = "org.jooq.meta.postgres.PostgresDatabase"
						unsignedTypes = false
						inputSchema = "public"
						forcedTypes.addAll(listOf(
							org.jooq.meta.jaxb.ForcedType().apply {
								name = "varchar"
								includeExpression = ".*"
								includeTypes = "JSONB?"
							},
							org.jooq.meta.jaxb.ForcedType().apply {
								name = "varchar"
								includeExpression = ".*"
								includeTypes = "INET"
							},
						))
					}
					generate.apply {
						withDeprecated(false)
						withRecords(true)
						withImmutablePojos(true)
						withFluentSetters(true)
						withJavaTimeTypes(true)
					}
					target.apply {
						withPackageName("jooq.dsl")
						withDirectory("src/generated/jooq")
						withEncoding("UTF-8")
					}
					strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy"
				}
			}
		}
	}
}

 

JOOQ가 데이터베이스 정보를 통해 객체를 생성하기 위해 (JType, Dao...), 사용되는 Gradle Task를 정의합니다. 

 

현재 작성된 jooq Task에는

  • jdbc datasource 설정 정보(jdbc {..}),
  • 관련된 객체를 생성할 데이터베이스, 스키마 정보{database {..}}
  • 생성할 객체에 대한 설정 정보(generate {..}),
  • JType이 생성될 디렉터리를 지정하는 설정 정보(target {..})

들이 정의되어 있습니다.

해당 글에서는 간단한 구성 정보를 사용하고 있으며 쉽게 알 수 있는 내용이기 때문에 따로 설명하지는 않습니다.

 

더 많은 구성 정보는 https://www.jooq.org/doc/3.0/manual/code-generation/ 를 확인하시길 바랍니다. **

 

이제 Domain 구현을 진행합니다.

 

 

 

Account Domain 구현

 

Directory structure

 

Account Domain Directory

 

 

AccountEntity

@Getter
@Entity
@Table(name = "Account")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AccountEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "username", nullable = false, unique = true)
    private String username;

    @Column(name = "password", nullable = false)
    private String password;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @Column(name = "roles", nullable = false)
    private String role;

    @Builder
    public AccountEntity(Long id, String username, String password, String email, String role) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    @Override
    public String toString() {
        return "AccountEntity{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                ", email='" + email + '\'' +
                ", role='" + role + '\'' +
                '}';
    }
}

회원 정보를 저장하는 기본적인 형태의 AccountEntity를 정의합니다. 비즈니스 로직은 존재하지 않고, JOOQ가 정상적으로 동작하는지만 파악할 것이기 때문에 객체 상태, 빌더와 toString()만 정의합니다.

 

 

Account

@Getter
public class Account {

    private final Long id;
    private final String password;
    private final String username;
    private final String email;
    private final String role;

    @Builder
    public Account(Long id, String username, String password, String email, String role) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    public AccountEntity toEntity() {
        return AccountEntity.builder()
                .id(id)
                .username(username)
                .password(password)
                .email(email)
                .role("USER")
                .build();
    }
}

AccountEntity 이전에 값을 가지고 있을 Model을 정의합니다. 마찬가지로 비즈니스 로직이 존재하지 않기에 특별한 로직을 포함하지 않고 getter와 toEntity 메서드만 포함합니다.

 

 

AccountWriteRepository

해당 예제에서는 큰 의미를 가지지 않지만, Read와 Write Repository를 분리하여 정의합니다.

  • Read와 Write Model가 다르기 때문에 Repository를 분리하기도 합니다만 (대표적으로 DDD) 해당 에제는 Root Entity만 존재하기 때문에 의미가 없습니다.
public interface AccountWriteRepository extends JpaRepository<AccountEntity, Long> {
}

WriteRepository는 Data JPA에서 제공하는 SimpleJpaRepository API를 사용합니다.

 

 

AccountReadRepository

ReadRepository는 JOOQ의 DSLContext를 이용해 구현합니다.

existsUserInfo()와 findByUsername() 메서드를 작성하였습니다.

@Repository
@RequiredArgsConstructor
public class AccountReadRepository {

    private final DSLContext dsl;
    private final Account account = Account.ACCOUNT;

    public boolean existsUserInfo(String email, String username) {
        return dsl.fetchExists(
                dsl.selectOne()
                        .from(account)
                        .where(account.EMAIL.eq(email), account.USERNAME.eq(username))
        );
    }

    public AccountEntity findByUsername(String username) {
        /*  명시적 매핑 alias
            dsl.select(
                        account.ID.as("id"),
                        account.USERNAME.as("username"),
                        account.PASSWORD.as("password"),
                        account.EMAIL.as("email"),
                        account.ROLES.as("roles")
                )
                .from(account)
                .where(account.USERNAME.eq(username))
                .fetchOneInto(AccountEntity.class);
        */

        // 묵시적 매핑 - ResultSet 과 Entity field 명 자동 매칭
        return dsl.select()
                .from(account)
                .where(account.USERNAME.eq(username))
                .fetchOneInto(AccountEntity.class);
    }
}

findByUsername()에 작성된 주석에 관하여 더 정리하자면, JOOQ의 Select 결과를 매핑하는 것은 Alias를 통한 명시적 매핑과 묵시적 매핑이 있으며 테이블 조회 결과에 중복되는 필드가 존재하거나, Table과 Entity, DTO 필드 명이 다를 경우, 여러 내부의 객체를 매핑해야 하는 경우 등에는 명시적 매핑을 사용합니다.

 

 

AccountService

AccountService는 구현된 Repository를 호출하는 간단한 로직만 작성합니다.

@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountReadRepository accountReadRepository;
    private final AccountWriteRepository accountWriteRepository;

    public AccountEntity save(Account account) {
        if (accountReadRepository.existsUserInfo(account.getEmail(), account.getUsername())) {
            throw new RuntimeException("This account states is duplicated");
        }
        return accountWriteRepository.save(account.toEntity());
    }

    @Transactional(readOnly = true)
    public AccountEntity findOne(String username) {
        final AccountEntity targetAccount = accountReadRepository.findByUsername(username);
        Assert.notNull(targetAccount, format("account is not Found || username = %s || dateTime = %s", username, LocalDateTime.now()));
        return targetAccount;
    }
}

기존에 등록된 동일한 email과 username를 가지는 유저 정보가 존재하는 경우 Exception을 발생시키는 간단한 검증 로직을 포함합니다.

 

 

Test code 작성

정의된 기능이 정상 동작하는지 파악하는 통합 테스트를 작성합니다. 예제의 간소화를 위해 (학습 편의성..ㅎㅎ) 기본적으로 생성되는 ApplicationTest를 이용합니다.

@Transactional
@SpringBootTest
class JooqApplicationTests {

    @Autowired
    private AccountService accountService;

    //..//

    @Test
    void account_save() {

        // Arrange
        final Account newAccount = Account.builder()
                .email("helloWorld@test.com")
                .username("name")
                .password("password")
                .role("USER")
                .build();

        // Act
        AccountEntity savedAccount = accountService.save(newAccount);

        // Assert
        assertNotNull(savedAccount);
    }

    @Test
    void account_findOne() {

        // Arrange
        final Account newAccount = Account.builder()
                .email("helloWorld@test.com")
                .username("name")
                .password("password")
                .role("USER")
                .build();

        accountService.save(newAccount);

        // Act
        AccountEntity targetAccount = accountService.findOne("name");

        // Assert
        assertNotNull(targetAccount);
    }

    //..//

}

지금까지 진행하면서 설정 정보가 잘못 구성되었거나 JOOQ Task를 통해 필요한 객체를 생성하지 않은 경우를 제외한다면 지금까지 작성한 코드는 정상적으로 동작합니다. 버전에 따른 차이가 존재할 수 있으니, 의존성은 최대한 같은 버전으로 지정해주시길 바랍니다.

 

 

 

Article Domain 구현

 

Directory structure

Aritcle Domain Directory

 

 

ArticleEntity

@Getter
@Entity
@Table(name = "Article")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ArticleEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "description", nullable = false)
    private String description;

    @Column(name = "AUTHOR_ID", nullable = false)
    private Long authorId;

    public boolean isOwner(Long authorId) {
        return this.authorId.equals(authorId);
    }

    public void updateStates(String title, String description) {
        this.title = title;
        this.description = description;
    }

    @Builder
    public ArticleEntity(Long id, String title, String description, Long authorId) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.authorId = authorId;
    }

    @Override
    public String toString() {
        return "ArticleEntity{" +
                "id=" + id +
                ", title='" + title + '\'' +
                ", description='" + description + '\'' +
                ", author=" + authorId +
                '}';
    }
}

게시글 정보를 저장하는 기본적인 형태의 ArticleEntity를 정의합니다. 부가적으로 Account id를 기준으로 소유자를 판단하는 검증 로직을 작성합니다.

 

 

Article

@Getter
public class Article {

    private final Long id;
    private final String title;
    private final String description;
    private final Long authorId;

    @Builder
    public Article(Long id, String title, String description, Long authorId) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.authorId = authorId;
    }

    public ArticleEntity toEntity() {
        return ArticleEntity.builder()
                .id(id)
                .title(title)
                .description(description)
                .authorId(authorId)
                .build();
    }
}

ArticleEntity 이전에 값을 가지고 있을 Model을 정의합니다.

 

 

ArticleWriteRepository

설명 생략

public interface ArticleWriteRepository extends JpaRepository<ArticleEntity, Long> {
}

 

 

ArticleReadRepository

ArticleReadRepository에는 getPage와 findById 메서드를 구현합니다.

@Repository
@RequiredArgsConstructor
public class ArticleReadRepository {

    private final DSLContext dsl;
    private final Article article = Article.ARTICLE;

    public Page<ArticleEntity> getPage(Pageable pageable) {
        List<ArticleEntity> articles = dsl.select()
                .from(article)
                .limit(pageable.getPageSize())
                .offset(pageable.getOffset())
                .fetchInto(ArticleEntity.class);
        return new PageImpl<>(articles, pageable, dsl.fetchCount(dsl.selectFrom(article)));
    }

    public ArticleEntity findById(Long id) {
        return dsl.select()
                .from(article)
                .where(article.ID.eq(id))
                .fetchOneInto(ArticleEntity.class);
    }
}

Spring Project에서 Pagination을 위해 주로 사용하는 Pageable Interface를 이용해 getPage 메서드를 정의하였습니다.

 

 

ArticleService

Account id를 통해 요청을 검증한 뒤 Article을 수정하거나 삭제하는 메서드와 조회 메서드를 정의합니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleWriteRepository articleWriteRepository;
    private final ArticleReadRepository articleReadRepository;

    public Page<ArticleEntity> gets(Pageable pageable) {
        return articleReadRepository.getPage(pageable);
    }

    public ArticleEntity getOne(Long articleId) {
        final ArticleEntity targetArticle = articleReadRepository.findById(articleId);
        Assert.notNull(targetArticle, "article not found");
        return targetArticle;
    }

    public ArticleEntity addArticle(Article article) {
        return articleWriteRepository.save(article.toEntity());
    }

    public ArticleEntity updateArticle(Article article, Long authorId) {
        final ArticleEntity targetArticle = getOne(article.getId());
        if (targetArticle.isOwner(authorId)) {
            targetArticle.updateStates(article.getTitle(), article.getDescription());
            return targetArticle;
        }
        log.info("This account is not owner || targetId = {} || author id = {} || date = {}", article.getId(), authorId, LocalDateTime.now());
        throw new RuntimeException("This account is not owner");
    }

    public void deleteArticle(Long articleId, Long authorId) {
        final ArticleEntity targetArticle = getOne(articleId);
        if (targetArticle.isOwner(authorId)) {
            articleWriteRepository.delete(targetArticle);
            return;
        }
        log.info("This account is not owner || targetId = {} || author id = {} || date = {}", articleId, authorId, LocalDateTime.now());
        throw new RuntimeException("This account is not owner");
    }
}

 

 

Test code 작성

이제 ArticleService와 관련된 테스트 코드를 추가로 작성합니다.

@Transactional
@SpringBootTest
class JooqApplicationTests {

    //..//

    @Autowired
    private ArticleService articleService;

    //..//

    @Test
    void article_gets() {

        // Arrange
        final Account newAccount = Account.builder()
                .email("helloWorld@test.com")
                .username("name")
                .password("password")
                .role("USER")
                .build();

        AccountEntity author = accountService.save(newAccount);

        for (int i = 0; i < 10; i++) {
            Article article = Article.builder()
                    .title("title" + i)
                    .description("description" + i)
                    .authorId(author.getId())
                    .build();

            articleService.addArticle(article);
        }

        // Act
        Page<ArticleEntity> page = articleService.gets(PageRequest.of(0, 10));

        // Assert
        assertFalse(page.isEmpty());
    }

    @Test
    void article_addArticle() {

        // Arrange
        final Account newAccount = Account.builder()
                .email("helloWorld@test.com")
                .username("name")
                .password("password")
                .role("USER")
                .build();

        AccountEntity author = accountService.save(newAccount);

        Article article = Article.builder()
                .title("title")
                .description("description")
                .authorId(author.getId())
                .build();

        ArticleEntity savedArticle = articleService.addArticle(article);

        // Act
        ArticleEntity targetArticle = articleService.getOne(savedArticle.getId());

        // Assert
        assertNotNull(targetArticle);
    }

    @Test
    void article_updateArticle() {

        // Arrange
        final Account newAccount = Account.builder()
                .email("helloWorld@test.com")
                .username("name")
                .password("password")
                .role("USER")
                .build();

        AccountEntity author = accountService.save(newAccount);
        Long authorId = author.getId();

        Article article = Article.builder()
                .title("title")
                .description("description")
                .authorId(authorId)
                .build();

        ArticleEntity savedArticle = articleService.addArticle(article);

        final Article updateArticle = Article.builder()
                .id(savedArticle.getId())
                .title("update title")
                .description("update description")
                .authorId(authorId)
                .build();

        // Act
        ArticleEntity targetArticle = articleService.updateArticle(updateArticle, authorId);

        // Assert
        assertNotNull(targetArticle);
    }

    @Test
    void article_deleteArticle() {

        // Arrange
        final Account newAccount = Account.builder()
                .email("helloWorld@test.com")
                .username("name")
                .password("password")
                .role("USER")
                .build();

        AccountEntity author = accountService.save(newAccount);
        Long authorId = author.getId();

        Article article = Article.builder()
                .title("title")
                .description("description")
                .authorId(authorId)
                .build();

        ArticleEntity savedArticle = articleService.addArticle(article);

        // Act & Assert
        articleService.deleteArticle(savedArticle.getId(), authorId);
    }
}

다 작성하였다면 ArticleService에 정의된 메서드 동작을 확인하는 테스트 코드를 작성하고 실행해봅니다.

 

 

이번 글은 여기까지입니다.

 

최근 단순한 구현 예제나 제가 학습한 내용을 기준으로 작성하는 글을 소소한 글이라는 prefix를 붙여 게시하고 있는데요. 꾸준한 학습을 하는데 나름 도움이 되고 있는 것 같습니다.

(정작 영양가는 별로 없는 것 같지만요. ㅎㅎ; 노력하겠습니다..)

 

이후에 JOOQ를 학습하여 해당 에제에서 제외된 한 Entity 내부의 여러 객체에 필드를 매핑하는 것이나 JOOQ 최적화와 관련된 내용을 정리하고 해당 글을 좀 더 정제하기 위해 노력할 예정입니다. 여기까지 봐주셔서 감사합니다.

 

잘못된 내용은 댓글이나 우측 하단에 있는 메신저를 통해 말씀해주시길 바랍니다.

 

 

추가로 사용해볼만한 기능들

 

DefaultRecordListener

class AuditingListener : DefaultRecordListener() {

    override fun insertStart(ctx: RecordContext) {
        when (val record = ctx.record()) {
            is Article -> {
                record.createDate = LocalDateTime.now(ZONE_OF_KOREA)
                record.updateDate = LocalDateTime.now(ZONE_OF_KOREA)
            }
            is Account -> {
                record.createDate = LocalDateTime.now(ZONE_OF_KOREA)
                record.updateDate = LocalDateTime.now(ZONE_OF_KOREA)
            }
            else -> super.insertStart(ctx)
        }
    }

    override fun updateStart(ctx: RecordContext) {
        when (val record = ctx.record()) {
            is Article -> {
                record.updateDate = LocalDateTime.now(ZONE_OF_KOREA)
            }
            is Account -> {
                record.updateDate = LocalDateTime.now(ZONE_OF_KOREA)
            }
            else -> super.updateStart(ctx)
        }
    }
    
    /*
    override fun storeStart(RecordContext ctx) {}
    override fun storeEnd(RecordContext ctx) {}
    override fun insertEnd(RecordContext ctx) {}
    override fun updateEnd(RecordContext ctx) {}
    override fun mergeStart(RecordContext ctx) {}
    override fun mergeEnd(RecordContext ctx) {}
    public void deleteStart(RecordContext ctx) {}
    override fun deleteEnd(RecordContext ctx) {}
    override fun loadStart(RecordContext ctx) {}
    override fun loadEnd(RecordContext ctx) {}
    override fun refreshStart(RecordContext ctx) {}
    override fun refreshEnd(RecordContext ctx) {}
    override fun exception(RecordContext ctx) {}
    */
}

Record를 처리하는 Cycle마다 하고 싶은 로직을 추가할 수 있는 Interface로 Entity의 생성, 수정 날짜를 조정하는 위 예시 이외에도 Record를 조작할 필요가 있다면 사용해볼 수 있습니다.

 

 

DefaultExecuteListener

class SlowQueryListener : DefaultExecuteListener() {
    private val logger: KLogger = KotlinLogging.logger { }
    private lateinit var watch: StopWatch

    override fun executeStart(ctx: ExecuteContext) {
        super.executeStart(ctx)
        watch = StopWatch()
    }

    override fun executeEnd(ctx: ExecuteContext) {
        super.executeEnd(ctx)
        if (watch.split() > ONE_SEC_TO_NANO) {
            logger.info { "${ctx.connection().metaData.url} Slow Query Detected! ${ctx.query()}" }
        }
    }

    /*
    override fun start(ctx: ExecuteContext?) {}
    override fun renderStart(ctx: ExecuteContext?) {}
    override fun renderEnd(ctx: ExecuteContext?) {}
    override fun prepareStart(ctx: ExecuteContext?) {}
    override fun prepareEnd(ctx: ExecuteContext?) {}
    override fun bindStart(ctx: ExecuteContext?) {}
    override fun bindEnd(ctx: ExecuteContext?) {}
    override fun outStart(ctx: ExecuteContext?) {}
    override fun outEnd(ctx: ExecuteContext?) {}
    override fun fetchStart(ctx: ExecuteContext?) {}
    override fun resultStart(ctx: ExecuteContext?) {}
    override fun recordStart(ctx: ExecuteContext?) {}
    override fun recordEnd(ctx: ExecuteContext?) {}
    override fun resultEnd(ctx: ExecuteContext?) {}
    override fun fetchEnd(ctx: ExecuteContext?) {}
    override fun end(ctx: ExecuteContext?) {}
    override fun exception(ctx: ExecuteContext?) {}
    override fun warning(ctx: ExecuteContext?) {}
    */

    companion object {
        const val ONE_SEC_TO_NANO: Long = 1000000000L
    }
}

Query를 처리하는 Cycle마다 하고 싶은 로직을 추가할 수 있는 Interface로 StopWatch를 통해 Slow Query를 파악하는 위 예시 이외에도 사용되는 Connection을 통해 실제 요청이 가는 Node의 정보를 확인하거나 SQL에 사용될 변수를 조작하는 등의 일을 해볼 수 있습니다. 

 

 

 

다른 예제들

최근에 작성한 글 이외에도 학습을 위해 Github에 간단한 Project를 작성하고 있는데요. 필요하시다면 참고하시고, 피드백을 남겨주셔도 좋을 것 같습니다. ㅎㅎ

 

이 예제는 https://github.com/Lob-dev/The-Joy-Of-Java/tree/main/Study-Spring-Boot-JOOQ에서 확인할 수 있습니다.

 

 

 

참고 자료

 

이 글은 간단한 구현 예제만을 포함하고 있습니다. AOP에 대한 이론적인 부분은 기존에 포스팅한

https://lob-dev.tistory.com/entry/Spring-AOP와-요청-인터셉트-개념

을 참고해주시길 바랍니다.

 


프로젝트 구성

Web과 Lombok을 추가합니다. (해당 예제에서는 Web 관련 로직을 사용하진 않고, 통합 테스트만 작성합니다. )

추가적으로

implementation 'org.springframework.boot:spring-boot-starter-aop'

의존성을 추가합니다.

 

우선 GreetingAspect를 통해서 Aspect를 적용할 수 있는 각 시점들을 확인해보겠습니다.

  • Spring AOP는 xml를 통한 기본적인 설정 방식과 AspectJ에서 제공하는 Annotation을 통해 적용할 수 있습니다.

 

 

GreetingService

해당 서비스는 메서드가 호출되었음을 알 수 있도록 간단하게 작성합니다.

  • AfterReturning과 AfterThrowing을 따로 호출하기 위해 bool 값을 통해 분기 처리합니다.
@Slf4j
@Service
public class GreetingService {

    public void greeting(boolean condition) {
        log.info("GreetingService.greeting");
        if(condition) {
            throw new RuntimeException();
        }
    }
}

개인적인 편의를 위해서 테스트 코드는 ApplicationTest class에서 작성합니다.

@SpringBootTest
class TestApplicationTest {

    //...
    
    @Test
    void greeting() {
        greetingService.greeting(false);
    }
}

이제 이 코드를 실행하면 GreetingService.greeting이 Console에 출력되는 것을 확인할 수 있습니다.

 

이제 GreetingAspect를 작성합니다.

 

 

GreetingAspect

@Around는 LoggingAspect를 작성해볼 때 사용해보는 것으로 하고 다른 Advice Annotation들만 사용합니다.

@Slf4j
@Aspect
@Component
public class GreetingAspect {

    @Before(value = "execution( public void aop.domain.GreetingService.*(*) )")
    public void beforeAdvice() {
        log.info("GreetingAspect.BeforeAdvice");
    }

    @After(value = "execution( public void aop.domain.GreetingService.*(*) )")
    public void afterAdvice() {
        log.info("GreetingAspect.AfterAdvice");
    }

    @AfterReturning(value = "execution( public void aop.domain.GreetingService.*(*) )")
    public void afterReturningAdvice() {
        log.info("GreetingAspect.AfterReturningAdvice");
    }

    @AfterThrowing(value = "execution( public void aop.domain.GreetingService.*(*) )", throwing = "exception")
    public void afterThrowingAdvice(RuntimeException exception) {
        log.info("GreetingAspect.AfterThrowingAdvice"+ exception.getClass());
    }
}

GreetingAspect 역시 앞선 서비스와 같이 log만 출력합니다. 이제 앞선 테스트를 다시 실행해보면

INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.BeforeAdvice
GreetingService.greeting
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterAdvice
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterReturningAdvice

이렇게 출력되는 것을 확인할 수 있습니다.

  • execution 내부에 정의된 내용은 public 접근 지시자를 가지고, Return 타입이 void이며, 하나의 인자를 가지는 GreetingService의 모든 메서드를 대상으로 한다는 의미입니다.
    • 해당 코드에서는 메서드 명을 작성하거나 GreetingService.* 등으로도 설정할 수 있습니다.

 

 

그럼 앞선 Service 메서드가 Exception을 던지도록 한 다음 AfterThrowing이 정상적으로 호출되는지 확인해보겠습니다. ApplicationTest에 하나의 Test 메서드를 추가합니다

@Test
void greeting_throw() {
    assertThrows(RuntimeException.class, () -> greetingService.greeting(true));
}
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.BeforeAdvice
GreetingService.greeting
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterAdvice
INFO 37028 --- [main] aop.aspect.GreetingAspect : GreetingAspect.AfterThrowingAdviceclass java.lang.RuntimeException

AfterReturning과 AfterThrowing은 각각 Aspect target이 호출된 후 정상, 비정상(exception) 여부에 따라 동작하는 Advice입니다.

 

이제 @Around advice를 사용하여 위에서 작성한 greeting 메서드의 실행 시간에 관한 로깅을 남겨보도록 하겠습니다.

 

 

LoggingAspect

LoggingAsepct는 execution 표현식을 사용하지 않고 Annotation 방식으로 사용할 생각이므로, 우선 Logging이라는 Annotation Interface를 정의합니다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Logging {
}

이는 point cut 표현식 중 @annotation()을 통해 지정할 수 있습니다.

@Slf4j
@Aspect
@Component
public class LoggingAspect {

    @Around(
            value = "@annotation(aop.annotation.Logging)",
            argNames = "joinPoint"
    )
    public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
        log.info("LoggingAspect.logging");

        final Signature signature = joinPoint.getSignature();
        final String className = signature.getDeclaringTypeName();

        final StopWatch watch = new StopWatch();
        watch.start();
        Object result = joinPoint.proceed();
        watch.stop();

        log.info("logging : component = {} : execution time(ms) = {}", className, watch.getTotalTimeMillis());
        return result;
    }
}

joinPoint를 통해 Signature interface를 가져오면 이를 통해 Aspect target 메서드의 여러 정보들을 가져올 수 있습니다. 그리고 Spring Utils에서 제공하는 StopWatch를 통해 시간을 측정할 수 있습니다.

  • joinPoint.proceed()을 통해 Aspect target의 메서드를 호출한 다음 return 값이 넘어왔을 때 StopWatch를 정지시켜 처리에 걸린 시간을 측정합니다.

이제 Logging과 권한 검사를 테스트할 도메인 모델인 Account와 AccountService를 작성합니다.

 

 

Account

@Getter
@ToString
public class Account {
    private final String username;
    private final String password;
    private final String email;
    private final String role;

    public Account(String username, String password, String email, String role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }
}

Account는 간단한 도메인 모델이므로 별도의 설명을 달지는 않겠습니다.

 

 

AccountService

@Slf4j
@Service
public class AccountService {

    @Logging
    public void userRoleCheck(Account account) {
        log.info("{}", account.toString());
    }

    public void adminRoleCheck(Account account) {
        log.info("{}", account.toString());
    }
}

단순한 도메인 모델인 Account를 구현하였고, Service 로직은 인자로 넘어온 account를 로깅하는 로직만 작성하였습니다.

 

이제 테스트 메서드를 추가하고 실행해봅니다.

@Logging
public void userRoleCheck(Account account) {
    log.info("{}", account.toString());
}

------

@Test
void account() {
    final Account account = new Account("lob", "password", "coffeescript@kakao.com", "USER");
    accountService.userRoleCheck(account);
}
INFO 37028 --- [main] aop.domain.AccountService : Account{username='lob', password='password', email='coffeescript@kakao.com', role='USER'}
INFO 37028 --- [main] aop.aspect.LoggingAspect  : logging : component = aop.domain.AccountService : execution time(ms) = 19

정상적으로 결과가 나오는 것을 확인할 수 있습니다.

 

이제 마지막으로 RoleCheck Annotation과 이를 통해 부여된 Role을 기준으로 exception을 발생시키는 Aspect를 작성해보겠습니다.

 

 

RoleCheck

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RoleCheck {
    String value() default "ADMIN";
}

RoleCheck 어노테이션에는 하나의 String Field를 정의하여 값을 지정할 수 있도록 정의합니다.

 

 

RoleCheckAspect

RoleCheckAspect는 JoinPoint를 통해 메서드 인자를 가져오고, Annotation 정보와 비교하여 권한 검사를 진행합니다.

@Slf4j
@Aspect
@Component
public class RoleCheckAspect {

    @Before(value = "@annotation(aop.annotation.RoleCheck)")
    public void roleCheckByExecution(JoinPoint joinPoint) {
        log.info("RoleCheckAspect.roleCheckByExecution");

        final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        final Method method = signature.getMethod();

        final RoleCheck annotation = method.getAnnotation(RoleCheck.class);
        final String methodRole = annotation.value();
        log.info("method role = {}", methodRole);

        final Account account = (Account) joinPoint.getArgs()[0];
        final String accountRole = account.getRole();
        log.info("account role = {}", accountRole);

        if (methodRole.equals(accountRole)) {
            log.info("request success : method = {} : username = {} : email = {} : role = {}",
                    method.getName(), account.getUsername(), account.getEmail(), accountRole);
            return;
        }

        log.info("request failed : method = {} : username = {} : email = {} : role = {}",
                method.getName(), account.getUsername(), account.getEmail(), accountRole);
        throw new RuntimeException("권한 부족");
    }
}

이제 AccountService 메서드에 @RoleCheck Annotation을 붙이고 테스트를 다시 실행합니다.

@Logging
@RoleCheck(value = "USER")
public void userRoleCheck(Account account) {
    log.info("{}", account.toString());
}

------

INFO 37028 --- [main] aop.aspect.RoleCheckAspect : method role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : account role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : request success : method = userRoleCheck : username = lob : email = coffeescript@kakao.com : role = USER
INFO 37028 --- [main] aop.domain.AccountService  : Account{username='lob', password='password', email='coffeescript@kakao.com', role='USER'}
INFO 37028 --- [main] aop.aspect.LoggingAspect   : logging : component = aop.domain.AccountService : execution time(ms) = 19

Aspect가 정상 동작하는 것을 확인할 수 있습니다.

 

이제 반대의 경우를 간단히 테스트해보겠습니다.

@RoleCheck
public void adminRoleCheck(Account account) {
    log.info("{}", account.toString());
}

------

@Test
void account_throw() {
    final Account account = new Account("lob", "password", "coffeescript@kakao.com", "USER");
    assertThrows(RuntimeException.class, () -> accountService.adminRoleCheck(account));
}
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : method role = ADMIN
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : account role = USER
INFO 37028 --- [main] aop.aspect.RoleCheckAspect : request failed : method = adminRoleCheck : username = lob : email = coffeescript@kakao.com : role = USER

AccountService의 메서드는 호출되지 않고 Exception이 발생하였음을 확인할 수 있습니다.

 

 

이론을 제외하고 단순한 구현 코드만을 작성하다보니 코드 블럭과 "~다"만 가득한 글이 만들어졌네요.. ㅋㅋ;

해당 글과 관련하여 궁금하신 부분은 댓글을 통해 문의해주시길 바랍니다.

 

 

추천, 참고 링크

 

 

 

 

이 글은 3달 전쯤? 단순한 계기로 노션에 작성해둔 글을 수정해서 올려놓은 글입니다. 글 내용도 갑작스럽게 끝나는 느낌이 있어서 수정을 하는 와중에도 이렇게까지 다룰만한 내용인가, 올려놔도 되나? 하는 의구심도 들었는데요.  

 

그냥 이 사람은 그렇게 이해한 상태이구나 정도의 가벼운 마음으로 봐주시길 바랍니다. ㅎㅎ 

 

 

JPA에서는 특정 Column을 별도의 영역에 정의하는 2가지를 방법을 제공한다.

여러 column(field)을 하나의 embedded type으로 정의하여 객체에 포함하는 방법이고 다른 하나는 별도의 추상 클래스를 만들어 변수를 정의하고 @MappedSuperclass로 상속시키는 방법이다.

 

언급된 이 2가지 방식은 어떠한 차이가 있고 특정 column을 정의할 땐 어느 방법을 선택해야 할까?

 

??? : 이건 그냥 상속을 사용하는 것과 조합(위임)을 사용하는 것의 차이에요. 개발을 할 때에는 조합 방식을 쓰는 게 좋아요.

  • 이 질문에 대한 답변으로 가끔 나오는 내용이다. 이것만으로 사용 이유를 시원스럽게(?) 이해할 수 있을까?

 

우선 왜 저런 이야기가 나오는지 짚어보자.

 

 

 

객체지향 관점에서는 "상속보다는 조합을 사용하자"라는 이야기가 정설로 통한다.

이는

  • super type과 sub type이 강한 결합성을 가짐으로써 캡슐화가 깨지고 속한 객체들이 변경의 여파를 받게 된다는 것
  • 한 객체에서 정의된 상태나 행위에 대해서 관리하거나 확인해야 할 포인트(class)가 늘어난다는 것
  • sub type에서 메서드를 재정의하는 경우에 잘못된 이해로 특정 로직만 추가하고 super type의 메서드를 호출하고 있을 때 오동작할 수 있다는 것
  • super type의 메서드가 변경되어서 sub type의 메서드가 깨지거나 오동작할 수 있는 것

등의 문제점들을 기반으로 한다.

 

그렇기에 super type의 기능 혹은 다른 객체의 기능을 사용할 것이라면 이를 상속받는 것이 아니라 상태 값 즉 인스턴스 변수를 통해 기능을 사용하는 것을 추천하게 되는 것이다.

 

그럼 모든 column을 정의할 때 embedded type으로 묶어서 정의하는 것이 가장 좋을 것일까? 

  • createdDate, updatedDate, creator, updator와 같은 운영 상의 이유를 포함하는 Base column을 적용할 때에도 이 방식이 좋은 것일까?

위의 내용들을 이해했다면, 상속 방식 즉. MappedSuperclass를 사용해도 무관할 것 같은 상황이 있다고 느끼리라 생각한다.

 

 

 

우선 위에서 언급된 문제점을 바탕으로 생각해보자.

첫 번째는 위에서 나온 단점들은 객체의 캡슐화가 깨지는 것과 관리 포인트가 늘어난다는 점을 제외하고는 객체의 행위와 관련된 문제점들이라는 것을 알 수 있다. 즉 일반적으로 entity의 상태 변수를 상속하여서 발생하는 상황들은 아니다.

 

 

두 번째는 추상 클래스가 올바르게 사용되는 시점은 해당 type의 "공통된 속성"을 한 곳에 모아 관리할 때이며, 엔티티는 객체이기도 하지만, 데이터 영역에 접근하는 Adapter(JDBC API로 구현된 DAO, Repository)의 DTO. 즉 특정한 데이터 홀더라고 봐야 한다고 생각한다는 점이다. 나는 그렇게 생각한다.

 

여기서 말하고 싶은 것은 엔티티는 객체 지향 관점에서의 객체와는 어느 정도 거리가 있는 녀석이라는 것이다.

 

특히 entity의 공통된 속성들(Create, UpdateDate, creator, updator)의 경우 어떤 개념(객체)에 묶이는 것도 아니고 일반적으로 변경될만한 속성도 아니기에 (선택적으로 사용되는 경우에도 문제없다) 변경의 여파는 거의 발생하지 않을 것이다. 즉 각각의 엔티티의 상태로 조합하여 사용하는 것과 별반 차이가 없다.

 

이러한 데이터는 유연성을 고려할 필요도 없다. 사용하거나 하지 않거나 필요에 따라서 계층적으로 분리하여 사용할 수 있다.

 

 

 

이번엔 JPA Spec 기준으로 생각해보자.

첫 번째로 @MapperdSuperClass는 Spec에 적힌 대로 공통 정보를 상속하는 것에 목적을 두고 만들어진 방법이다.

부가적으로 내부에 정의된 상태를 별도로 나타내지 않고 상속받은 엔티티에서만 나타내기 때문에 결과적으로 완전한 상태의 엔티티를 바라보게 된다.

[2.11.2] Typically, the purpose of such a mapped superclass is to define state and mapping information that is common to multiple entity classes.

 

 

두 번째는 MapperdSuperClass를 사용하는 방식이 embedded type에 비해서 매우 간단하다는 점이다. 운영과 관련된 column의 경우 해당 방식을 통해 쉽게 적용하고 사용할 수 있다.

@MappedSuperclass 
public abstract class BaseEntity { 
    private LocalDate createDate; 
    private LocalDate updateDate; 
} 

@Entity 
@NoArgConstructor(access = AccessLevel.PROTECTED) 
public class PostEntity extend BaseEntity { //... }

 

위와 같은 상황에서 extend BaseEntity이라는 구문만을 추가함으로써 (상속의 간편함) 사용할 수 있으며, 자동으로 값을 생성하여 매핑해주는 @EntityListeners(AuditingEntityListener.class)와 같은 기능도 제공받을 수 있다.

 

 

세 번째로는 JPQL을 사용할 때인데, 엔티티에서 embedded type까지 쿼리 하기 위해서 type 명을 명시해야 하는 불편함이 존재한다.

 

이 부분은 영한님이 답변해주신 이 링크를 참고해보는 게 좋을 것 같다.

https://www.inflearn.com/questions/18578

 

 

갑자기 결론?!

 

결론

이 글에서 언급된 column들은 운영이나 통계 등의 도메인 외부의 요소를 위한 것들로 객체의 상태 관점보다는 데이터 그 자체로써 사용되는 의미가 짙다고 생각된다.

 

embedded type은 특정 의미(도메인, 비즈니스 성격)를 가진 column들이 중복적으로 정의되어 코드 수정 시 불편함이나 해당 값을 다루는 메서드를 한 곳에 응집시키기 위해 사용되는 방법이다. 대부분의 상황에서 이 방식을 고려하는 것이 좋겠지만 단순한 데이터를 다룰 때에는 단순한 방식을 선택하여 처리하는 것이 더 나은 경우도 있다. 

 

위에서 언급된 상황에서는 embedded type을 사용하는 것보단 MapperdSuperClass를 사용하는 것이 좋다고 생각한다. 

 

Prometheus 개요

Prometheus는 오픈소스 기반의 모니터링 시스템으로 Service discovery pattern을 통해 데이터 수집대상을 발견하고 주기적으로 풀링하여 매트릭 데이터를 수집합니다.

 

https://sysadmins.co.za/setup-prometheus-and-node-exporter-on-linux-for-epic-monitoring/

 

수집된 매트릭 정보들은 로컬 디스크에 있는 시계열 데이터베이스에 저장되어 Prometheus의 도메인 특화 언어인 PromQL을 통해 빠르게 검색할 수 있습니다.

 

Prometheus는 매트릭 수집을 위한 서버나 컨테이너 구성이 불필요(Single host)하며, 클라이언트가 매트릭 푸시를 위해 CPU를 사용할 필요도 없습니다. (Metric Push를 통한 응답 병목이나 서버 부하를 예방합니다.)

https://prometheus.io/docs/introduction/overview/

부가적으로 중앙 집중식 구성 방식과 관리 콘솔을 제공하기에 설치나 사용이 매우 쉽습니다.

 

 

 

Prometheus의 이점

  • Kubernetes 환경에서 설치하기가 쉽고 Grafana와 같은 Tool과 연동이 쉬우며 많은 Dashboard 템플릿들이 오픈소스로 공유되고 있습니다.
  • 기존에 구현된 다양한 Exporter를 제공합니다. (매트릭 수집을 위한 클라이언트들)
    • 또한 각 언어별 Client library를 제공함으로써 쉽게 Counter나 Custom 매트릭 들을 뽑아낼 수 있습니다.
      • Spring의 경우 Boot Actuator 모듈을 통해서 Hikari pool, memory 매트릭을 얻을 수 있습니다.
  • 수집된 매트릭은 용량을 압축하여 저장하고 시계열 데이터베이스의 뛰어난 성능을 기반으로 많은 매트릭을 빠르게 조회할 수 있습니다.

 

 

Prometheus의 한계, 주의할 점

  • 풀링을 기반으로 매트릭을 수집하기 때문에 장애 발생을 빠르게 감지하는 것에는 어려움이 있습니다.
    • 풀링하는 순간의 매트릭 정보만 가지고 있기 때문에 근삿값만을 알 수 있습니다.
  • 싱글 호스트 아키텍처 구조의 한계로 인해 이중화나 클러스터링을 적용하기가 매우 어렵습니다.
    • 저장용량이 부족한 경우에는 설치된 서버의 디스크 용량을 늘릴 수 밖에 없습니다.
      • 다른 오픈소스를 같이 사용하는 상황은 제외
  • 이중화 구성 시에는 Replication을 하지않고, 두개의 Prometheus를 띄워 같은 목록을 풀링시키고 저장하는 방법을 사용하게 됩니다.
    • Thanos라는 오픈소스를 사용하여 매트릭 정보의 집계하고, 스케일링 가능한 스토리지에 저장하여 특정 프로메테우스의 장애로 인한 매트릭 소실 등을 방지할 수 있습니다.
  • Prometheus는 매트릭 정보를 효율적으로 다루기 위해(Read, Write, Sampling 등) Memory에 Buffering을 진행합니다. 메모리와 관련된 직접적인 설정을 할 수 없기 때문에 여러 작업에서 사용하는 리소스의 량을 계산하여 램을 증설하거나 Cardinality가 높은(Selectivity가 낮은) label을 수집하지 않거나 수집 주기를 늘리는 식으로 대처하여야 합니다. 

 

이제 Prometheus를 통해 매트릭 정보를 뽑아올 Target Application 들을 구현해보겠습니다.

 

 

 

Sample Application Archtecture

이번 글에서 구현하게 될 구조입니다.

 

 

 

Publisher Application 구현

 

프로젝트 생성

우선은 RabbitMQ 메세지를 생산할 Publisher Application을 구현해보겠습니다.

 

제가 사용할 Spring Actuator Module에는 내장된 micrometer 라이브러리가 존재하는데, 이것은 JVM 기반의 매트릭 정보를 다루는 인터페이스의 역할을 합니다. (Facade Pattern)

 

해당 라이브러리를 통해 Prometheus에서 사용하는 매트릭 정보를 받기 위해서는 별도 의존성을 추가해야 합니다.

implementation 'io.micrometer:micrometer-registry-prometheus'

 

 

Application yml

# yml 출처 : https://meetup.toast.com/posts/237
# 추가한 micrometer 라이브러리을 이용하여 metric 데이터 응답을 제공할 API를 설정합니다.
management:
  endpoints:
    web:
      exposure:
        include: prometheus # {protocol}://{host:port}/prometheus
  metrics:
    tags:
      application: ${spring.application.name} # metric 정보에 대한 라벨링 설정
  endpoint:
    health:
      show-details: always

spring:
  application:
    name: "publisher_application" # 라벨링 설정 값

  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

logging:
  level:
    root: info

server:
  port: 8080

사용할 RabbitMQ 설정과 Logging, port 설정을 진행하고 마무리합니다.

 

 

RabbitMQConfiguration

@Configuration
public class RabbitMQConfiguration {

    @Bean
    public Queue queue() {
        return new Queue("event-queue", true);
    }

    @Bean
    public DirectExchange exchange() {
        return new DirectExchange("event");
    }

    @Bean
    public Binding binding(Queue queue, DirectExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with("event-pay");
    }

    @Bean
    public Jackson2JsonMessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter(new ObjectMapper());
    }

    @Bean
    public RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) {
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        rabbitTemplate.setMessageConverter(messageConverter());
        return rabbitTemplate;
    }
}

연결된 RabbitMQ Container에 설정할 Queue와 DirectExchange, Routing Key를 설정하고, 객체를 Json 형태로 Converting 할 수 있게 Jackson2JsonMessageConverter를 설정합니다.

 

 

ScheduledConfiguration

@Configuration
@EnableScheduling
public class ScheduledConfiguration implements SchedulingConfigurer {

    // Scheduled 과 같은 스케줄링 작업에 대한 구성 정보를 설정하는 Registrar
    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        final ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(3);
        taskScheduler.setThreadNamePrefix("event-");
        taskScheduler.initialize();
        registrar.setTaskScheduler(taskScheduler);
    }
}

메세지를 생산하는 역할을 하는 Scheduled이 설정된 메서드를 최대 몇 개까지 실행할지 그리고 실행할 때 사용되는 스레드 정보를 커스텀할 수 있는 ThreadPoolTaskScheduler를 설정합니다.

 

 

Application

예제의 단순화를 위해서 별도의 Component를 정의하지 않고 Application Class에서 코드를 작성합니다.

단순한 예제 코드이므로 따로 설명을 하진 않겠습니다.

@Slf4j
@SpringBootApplication
@RequiredArgsConstructor
public class PublisherApplication {

    private final RabbitTemplate rabbitTemplate;
    private static final Random generator;

    static {
        generator = new Random();
    }

    public static void main(String[] args) {
        SpringApplication.run(PublisherApplication.class, args);
    }

    @Scheduled(fixedRate = 5000) // fixedRate 는 작업을 실행한 시점부터 다음 작업 수행 시간을 측정한다.
    public void publishEvent() {
        final int userId = generator.nextInt(10);
        final LocalDateTime eventTime = LocalDateTime.now();

        final PayEvent payEvent = PayEvent.of(userId, "Transaction finished", eventTime.toString());
        log.info("{}", payEvent);
        rabbitTemplate.convertAndSend("event", "event-pay", payEvent);
    }
}

----------
// 사용될 도메인 모델

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PayEvent {
    private long userId;
    private String message;
    private String eventTime;

    protected PayEvent(long userId, String message, String eventTime) {
        this.userId = userId;
        this.message = message;
        this.eventTime = eventTime;
    }

    public static PayEvent of(long userId, String message, String eventTime) {
        return new PayEvent(userId, message, eventTime);
    }

    @Override
    public String toString() {
        return "PayEvent{" +
                "userId=" + userId +
                ", message='" + message + '\'' +
                ", eventTime='" + eventTime + '\'' +
                '}';
    }
}

PayEvent 라는 객체를 생성하여 메시지 큐에 넣을 Publisher Application 구현이 완료되었습니다. 이제 해당 요청을 In-memory DB에 저장하는 단순한 Subscriber Application을 구현해봅니다.

 

 

 

Subscriber Application 구현

프로젝트 생성

이미지와 별개로 Publisher application과 동일하게 micrometer 관련 의존성을 추가합니다.

 

 

Application yml

# yml 출처 : https://meetup.toast.com/posts/237
management:
  endpoints:
    web:
      exposure:
        include: prometheus
  metrics:
    tags:
      application: ${spring.application.name}
  endpoint:
    health:
      show-details: always

spring:
  application:
    name: "subscriber_application"

  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
    driver-class-name: org.h2.Driver

  h2:
    console:
      enabled: true

  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      show-sql: true
      format_sql: true
    open-in-view: false

logging:
  level:
    root: info

server:
  port: 8081

기존 Application과 동일하게 micrometer 설정을 진행하고, Datasource, JPA 설정을 진행합니다.

 

 

RabbitMQConfiguration

@Configuration
public class RabbitMQConfiguration {

    @Bean
    public Jackson2JsonMessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter(new ObjectMapper());
    }

    @Bean
    public RabbitTemplate rabbitTemplate(final ConnectionFactory connectionFactory) {
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        rabbitTemplate.setMessageConverter(messageConverter());
        return rabbitTemplate;
    }
}

Queue 관련 설정은 별도로 진행하지 않으며, 위와 같이 JSON 데이터를 객체로 변환하기 위해 ObjectMapper를 추가합니다.

 

 

Application

@Slf4j
@RequiredArgsConstructor
@SpringBootApplication
public class SubscriberApplication {

    private final PayEventRepository payEventRepository;

    public static void main(String[] args) {
        SpringApplication.run(SubscriberApplication.class, args);
    }

    @RabbitListener(queues = "event-queue")
    public void subscribeEvent(PayEvent payEvent) {
        log.info("{}", payEvent);
        payEventRepository.save(payEvent);
    }
}

----------
// 여기서는 JPA를 통한 영속화를 위해 Entity로 정의합니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PayEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private long userId;
    private String message;
    private String eventTime;

    public PayEvent(long userId, String message, String eventTime) {
        this.userId = userId;
        this.message = message;
        this.eventTime = eventTime;
    }

    @Override
    public String toString() {
        return "PayEvent{" +
                "userId=" + userId +
                ", message='" + message + '\'' +
                ", eventTime='" + eventTime + '\'' +
                '}';
    }
}

----------
// 어딘가 있을 JPA Repository...
public interface PayEventRepository extends JpaRepository<PayEvent, Long> {
}

@RabbitListener annotation을 통해 queue를 구독한 상태로 전달되는 PayEvent를 JPA로 영속화하는 단순한 Application입니다.

 

이제 Sample Application 코드들을 구현하였으니, RabbitMQ, Exporter, Prometheus와 Grafana를 등록하기 위해 yml file과 docker-compose.yml을 작성합니다.

 

 

 

RabbitMQ, Prometheus, Grafana 구성

Prometheus.yml

docker-compose.yml를 실행하기 전에 우선 Prometheus 정보를 설정합니다.

해당 yml은 https://katacoda.com/courses/prometheus/getting-started와 튜토리얼을 참고하여 작성하였습니다.

global:
  scrape_interval: 10s

scrape_configs:
  - job_name: "publisher_application"
    metrics_path: "/actuator/prometheus"
    static_configs:
      - targets: ["host.docker.internal:8080"]

  - job_name: "subscriber_application"
    metrics_path: "/actuator/prometheus"
    static_configs:
      - targets: ["host.docker.internal:8081"]

  - job_name: "rabbitmq_exporter"
    metrics_path: "/metrics"
    static_configs:
      - targets: ["host.docker.internal:9419"]

RabbitMQ_exporter의 매트릭 정보 응답 port는 9419가 default port 입니다.

 

 

docker-compose.yml

version: '3'

networks:
  back:

services:
  rabbitmq:
    image: rabbitmq:3-management
    container_name: rabbitmq
    environment:
      - RABBITMQ_NODENAME:rabbitmq
    ports:
      - "5672:5672"
      - "15672:15672"
    networks:
      - back

  rabbitmq-exporter:
    image: kbudde/rabbitmq-exporter
    container_name: rabbitmq-exporter
    environment:
      - RABBIT_URL=http://rabbitmq:15672
    depends_on:
      - rabbitmq
    ports:
      - "9419:9419"
    networks:
      - back

  prometheus-1:
    image: prom/prometheus
    container_name: prometheus-1
    environment:
      - --config.file=/etc/prometheus/prometheus.yml
    volumes:
      - C:\.\.\.\config\prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
    networks:
      - back

  grafana:
    image: grafana/grafana
    depends_on:
      - prometheus-1
    ports:
      - "3000:3000"
    networks:
      - back

해당 yml의 bind mounting은 windows를 기준으로 작성되었습니다.

 

기본적으로 RabbitMQ exporter는 RabbitMQ의 container와 네트워크 인터페이스를 공유하는 방식으로 동작합니다. https://github.com/kbudde/rabbitmq_exporter

 

이제 해당 compose file을 실행하면 {http}://{host:port}를 통해 Prometheus와 Grafana Admin으로 접근할 수 있습니다.

  • Grafana의 기본 계정 정보는 admin / admin입니다.

 

Prometheus Dashboard

상단 메뉴바의 Status를 클릭하여 Targets 목록을 선택하면 yml에서 구성한 정보를 기준으로 구성된 Target Application 목록을 볼 수 있습니다.

 

이렇게 등록된 Target Application의 상태(health-check)와 마지막으로 pulling한 시간 정보 등을 간단하게 확인할 수 있습니다.

 

그럼 이제 Grafana에 접속하여 패널을 추가하고 가시화되는 데이터를 확인해보겠습니다.

  • 해당 글은 연동하는 것에만 의의를 두었기에 후술된 내용은 다른 튜토리얼을 참고하는 것이 좋을 수 있습니다.

 

Grafana Dashboard

이제 Prometheus의 매트릭 정보를 가져오기 위해 Data source를 설정해보겠습니다.

  • 좌측 상단 수직 메뉴바에서 톱니바퀴(Configuration)를 클릭하고 data source를 선택합니다.
  • 그다음 Add data source 버튼을 클릭합니다.
  • Time series databases 항목에서 Prometheus를 선택합니다.

 

이제 Prometheus가 실행되는 host 정보를 HTTP 항목의 URL에 입력하시고 맨 밑에 Save & test를 눌러 연결 상태를 확인한 다음 저장합니다.

 

 

Dashboard 설정하기

이제 메인화면으로 돌아와서 좌측 메뉴바의 + 버튼을 클릭하고 Dashboard를 선택합니다.

 

Add a new row를 선택하여 하나의 row를 만들고 좌측 상단의 그래프와 + 가 합쳐진 버튼을 눌러서 하나 더 추가합니다. (이름은 임의로 정해주세요.)

 

다시 방금 그 버튼을 눌러서 Add an empty panel을 선택합니다.

 

이제 현재 JVM 메모리 사용량을 나타내는 jvm_memory_used_bytes metric를 이용해 Time series를 만듭니다. 별도 설정이 없다면 위에서 만든 pub/sub application 정보가 모두 등록됩니다.

설정이 완료되었다면 Apply를 누릅니다.

 

여기서부터 기존에 존재하던 내용은 필요가 없어보여 제거했습니다. 그대신 몇가지 PromQL 예시를 보여드리는 것으로 변경하였습니다.

 

 

MySQL QPS (Query Per Second)

rate(mysql_global_status_queries[{Time}])

PromQL의 Range Vector 중 하나인 rate를 이용하여 특정 기간{Time} 동안 평균 값 혹은 변동 폭을 연산해 가시화할 수 있습니다.

 

 

MySQL Slow Queries

irate(mysql_global_status_slow_queries[Time])

PromQL의 Range Vector 중 하나인 irate를 사용하면 특정 기간의 순간적인 수치 증가율을 연산해 가시할 수 있습니다.

 

 

MySQL Connection Error

rate(mysql_global_status_connection_errors_total[Time])

 

 

MySQL available connections

100 * mysql_global_status_threads_connected / mysql_global_variables_max_connections

 

 

 

RabbitMQ memory used

https://grafana.com/grafana/dashboards/4371

rate(node_mem_used[{Time}])

 

 

RabbitMQ messages published total

rate(queue_messages_published_total[{Time}])

 

등등 Exporter에서 제공하는 다양한 메트릭을 이용하여 다양한 성능 지표를 확인할 수 있습니다.

그리고 만약 혼자서 구성하는 것이 어렵다면, https://grafana.com/grafana/dashboards/ 에서 완성된 대시보드를 가져와 사용할 수 있습니다. 

 

제공되는 메트릭 정보는 각 (Exporter에 대한) Github Project를 들어가셔서 readme 를 확인하시면 됩니다. 

타노스를 통한 HA 구성 등의 내용들은 더 열심히 공부를 한 뒤에 새로운 글을 작성하거나 보강하도록 하겠습니다. 

 

이 게시글의 결과물은 https://github.com/Lob-dev/The-Joy-Of-Java/tree/main/Sample-Spring-Boot-Prometheus 에서 보실 수 있습니다.

 

 

 

참고 자료

추가로 정리 중

RabbitMQ Message Queue 및 Message 보존 설정

Queue 생성시 Durable 설정

RabbitMQ server가 종료 후 재기동하면, 기본적으로 Queue는 모두 제거되게 된다. 이를 막기 위해서는 Queue를 생성할 때 Durable 옵션을 True로 설정하여야 하며, 메시지의 경우 PERSISTENT_TEXT_PLAIN 옵션을 주어야 Message가 보존된다.

MessageProperties.PERSISTENT_TEXT_PLAIN 

추가적으로 Queue에 있는 Message를 보존하는 속성으로 delivery-mode라는 것이 존재하는데, 기본 값은 1으로 메모리에서 메시지를 관리하는 상태가 되며, 2로 설정할 경우 RabbitMQ가 디스크에 메시지를 영속화시킨다. 

 

금융 거래 이벤트와 같이 비즈니스와 밀접하게 연관된 지속성 메시지와 로그인 이벤트와 같이 비즈니스에 영향을 주지 않는 비지속성 메시지를 구분하여 값을 설정한다.

 

 

 

RabbitMQ Message 손실 방지 설정

RabbMQ는 Consumer에게 전달된 Message의 손실을 방지하기 위한 기능을 제공한다. 이 속성은 기본적으로 활성화 되어 있고, RabbitMQ는 메시지에 대한 응답(작업 처리)을 전달받지 못한다면 다시 Queue에 집어넣고 다른 worker에게 전송할 수 있다.

https://www.rabbitmq.com/confirms.html

 

 

 

Message dispatch?

RabbitMQ dispatch 방법은 기본적으로 round robin 방식이며 MessageQueue에 담는 순서대로 worker들에게 전달한다.

균등한 메세지 처리 가 필요한 상황에선 위 방식으로 충분할 수 있으나 worker들이 메시지 중 특정 순서로 오랜 처리 시간이 필요한 상황 등의 특정한 경우 알맞지 않을 수 있다.

Prefectch Count none : 하나의 Worker의 작업이 지연되고 있다.

이런 경우 순차적으로 메세지를 제공받더라도 처리 시간으로 인해 다른 worker는 쉬는 상태에서 무거운 작업을 처리하는 worker에게 지속적으로 message가 전달되는 문제가 발생한다.

 

 

 

Fair dispatch 하도록 설정하기 : Prefetch Count

Prefetch Count는 Consumer에게 보내는 메시지 수를 지정하는 데 사용하는 옵션이며, 요청을 처리했음을 의미하는 Ack가 RabbitMQ에 전달되기 전까지 consumer가 전달받을 수 있는 message의 개수이다.

 

기본 설정 값은 클라이언트에게 크기 제한이 없는 버퍼를 제공하며, 기본적으로 요청을 받을 수 있는 Consumer에게 최대한 많은 메시지를 전달한다.

Prefectch Count 1

  • 전송된 메시지는 클라이언트의 클라이언트에 존재하는 Prefetch Buffer에 캐시 된다.
  • Prefetch 된 메시지는 Message Queue의 대기열에서 제거되고 다른 Consumer에게 표시되지 않는다.

rabbit.default_consumer_prefetch

 

 

 

Prefetch Count에 따른 성능 조정

1개로 설정해 두는 경우 (작을 수록 Fair Dispatch 하다.)

  • 하나의 메시지가 처리되기 전에는 새로운 메시지를 받지 않게 되므로, 여러 worker에게 동시에 작업을 분산시킬 수 있지만 여러 worker가 포함되어 있으나 각 단위 요청이 빨리 처리되는 상황에서는 각 worker의 다음 작업까지 대기시간이 증가할 수 있다.
  • worker가 많거나 한 작업 단위의 처리 시간이 긴 경우 모든 worker에게 균등하게 나눠지도록 값을 작게 설정하는 것이 좋다.

 

값을 크게 해 둘 경우

  • 메시지 큐에서 다량의 메시지를 하나의 worker에게 전달하여 Buffer에 요청을 쌓고 계속 처리할 수 있도록 하기에 각 worker 대기 시간은 감소할 수 있지만 특정 요청의 처리 시간이 긴 경우에 다른 worker들이 일을 하지 않고 대기하는 상황이 발생할 수 있다.
  • worker가 적고 한 작업 단위의 처리 시간이 짧은 경우 값을 크게 설정할 수 있다.

 

 

참고 자료

추가로 정리 중

 

AMQP Frame Structure

AMQP Spec에서는 객체 지향 개념과 유사하게 Class와 Method라는 것을 사용하여 AMQP Command를 정의한다.

  • Class는 기능의 범위를 정의한다.
  • Method는 각 Class 내부에서 서로 다른 작업을 수행하는 작업 단위이다.
// example) Connection.Start
{class}.{method}

basic class : (메시지의 송신, 수신, 대기열의 접근, 클라이언트의 종료, 시작, 거부 등)

channel class : (채널 생성, 종료, 중지)

exchange class : (분기 설정, 생성, 삭제, 연결 등) 

queue class : (메시지 큐 생성, 삭제, 설정, 해제 등)

tx class : (트랜잭션 커밋, 롤백, 모드 설정 등)

 

 

 

AMQP Component

AMQP Spec에서 명령을 전송하거나 수신할 때 필요한 모든 인자들은 캡슐화되어 있는 Frame으로 Encoding 되어 전송된다.

  • Frame은 각각의 명령과 인자를 Encoding하여 각각 구분되어 관리하는 하나의 단위이다.
    • 이를 통해 여러가지 요청을 효율적으로 구분한다.
  • Frame의 기본 크기는 131KB이며 연결 과정 중에 32비트로 표현할 수 있는 범위 내에서 최대 크기를 서버와 협상한다.

 

저수준의 AMQP 프레임은 다섯 개의 별개 구성 요소를 지닌다.

  • Frame Type : 1Byte
  • Channel Number
  • Frame Size : size → Byte
  • Frame Payload
  • end byte marker : ASCII 206
  Frame Header  ||               Frame Payload              ||  
[ 1 ][ 0 ][ 335 ][ Frame Payload : Type 별로 Data가 다르다. ][ 0xce ]

Frame Payload는 각각의 Frame 간에 운반하는 내용을 무결성 있게 보호하도록 감싸게 설계되었다.

 

 

 

AMQP Frame Types

 

Protocol Header Frame

  • RabbitMQ Connection을 연결할 때 한 번만 사용되는 Frame
  • Client Library를 사용할 때 추상화되어 있는 Frame

 

Method Frame

  • RabbitMQ와 서로 주고받는 RPC 요청과 응답을 전달하는 Frame
  • Exchange, Routing key를 포함하여 전송한다.
  • data size를 최소화하기 위해 이진 데이터로 구성한다.

 

Content Header Frame

  • Message Size와 Message Property를 포함하는 Frame.
  • data size를 최소화하기 위해 이진 데이터로 구성한다.

 

Body Frame

  • Message Content를 포함하는 Frame
  • AMQP Spec에서는 Max Frame size가 지정되어 있으며, 이 크기를 초과하면 Body Frame을 여러 단위로 분할시킨다.
  • JPEG, JSON, XML, Text, Binary 형식으로 직렬 화한 데이터를 전송 가능하다.

 

Heartbeat Frame

  • Client와 Server가 주고받으며 서로 사용 가능한 상태인지 파악하는 것
  • RabbitMQ는 Client에게 해당 Frame을 보내며, 응답하지 않는 경우 연결을 끊는다.
    • 단일 쓰레드, 비동기 환경에서는 제한 시간을 약간 큰 값으로 늘린다.
    • 하트비트가 동작하기 어려운 상황의 경우 0으로 설정하여 disable 한다.
  • Client Library를 사용할 때 추상화되어 있는 Frame

 

 

RabbitMQ Message publishing

RabbitMQ에서 Message를 Publishing 할 때에는 Method, Header, Body Frame을 사용한다.

 

 

Publishing flow

  1. Method Frame
  2. Content Header Frame
  3. 1개 이상의 Body Frame

 

 

참고 자료

메인 이미지는 https://wiki.openjdk.java.net/display/zgc/Main 에서 가져왔습니다.

최근 ZGC에 대한 질문을 받았으나 대답하지 못해 아쉬웠던 상황이 있었습니다. 완벽하게 이해하고 쓰는 글이 아니라 학습을 위해 정리하는 글이기 때문에 잘못된 내용이 있을 수 있으니 그 점 양해 부탁드립니다.

 

 

ZGC

Heap Memory 공간이 커지더라도 S-T-W 시간이 증가하지 않는 특징을 가진 GC이며 각각의 객체를 단일 세대로써 관리되며, 객체들은 G1 GC와 유사한 Region 방식으로 관리된다.

https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/


해당 GC는 Java 11부터 Preview로 추가되었으며, 15에서 Production Ready 상태가 되었다.

 

 

ZGC의 목표

G1 보다 처리량이 15% 이상 떨어지지 않으면서, S-T-W 시간은 10ms를 초과하지 않는 것.

  • S-T-W 가 매우 짧다.
    http://cr.openjdk.java.net/~pliden/slides/ZGC-FOSDEM-2018.pdf

 

 

ZGC의 특징

대기 시간이 낮으면서 규모 확장이 가능한 GC이다.

  • GC에 관련된 모든 작업을 Application과 동시에 작업한다. (Concurrently)
    • CMS나 G1의 경우 Mark 작업의 일부를 Application과 Concurrently 하게 수행한다.
    • Application이 수행되는 동안 GC Thread를 여러 개 동작시킬 수 있다.
      • 동시 수행 Thread가 적은 경우에는 Garbage가 점점 누적되며 (메모리 누수), Thread가 많은 경우에는 Application의 CPU 수행 시간을 많이 소모함으로 요청 처리량을 떨어트린다.

 

 

ZGC Core Concepts

ZGC는 Colored pointers와 Load barriers라는 2가지 주요 요소를 사용한다.

 

 

Colored pointers

https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/

  • ZGC가 객체를 찾아낸 뒤, 마킹하고, 재 배치하는 등의 작업을 지원한다.
  • 객체 포인터의 메모리 공간을 활용하여 객체의 상태 값을 저장하고 사용한다.
  • 해당 알고리즘 방식은 64bit 메모리 공간을 필요로 하기 때문에 32 bit 기반의 플랫폼에서는 사용이 불가능하다.
  • 18 bit의 미 사용 공간, 42 bit의 객체의 참조 주소와 총 4 bit의 공간을 차지하는 4개의 color pointer가 존재한다. 이러한 bit들을 meta bits라고 한다.
    • Finalizable : Finalizer(Finalize queue??)을 통해서 참조되는 객체로 해당 pointer가 Mark 되어 있다면 non-live Object이다.
    • Remapped : 해당 객체의 재배치 여부를 판단하는 pointer이며, 해당 Bit의 값이 1이라면 최신 참조 상태임을 의미한다.
    • Marked 0, Marked 1 : 해당 객체가 Live된 상태 인지 확인하는 여부이다. - Load Barrier에 의해서도 사용되기도 한다.

 

 

Load Barriers

http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf

  • JIT가 특정 위치에 주입한 코드를 말한다. 이 코드를 통해 참조가 연결되는 객체의 Meta bits 상태를 확인한다.
  • Load barriers는 RemapMark와 Relocation Set을 확인하여 참조 값과 Mark 상태를 업데이트할 수 있다.

http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf

  • Load Barriers는 Thread가 Stack으로 Heap Object 참조 값을 불러올 때 실행된다.

 

 

Reference Value Check Flow

  • mark pointer의 색이 나쁜 경우 mark, relocate, re-mapping을 진행하여 좋은 상태 (색상)로 변경하는 작업을 진행한다. repair or heal
  • mark pointer의 색이 좋은 경우 그대로 작업을 진행한다.
  • Remap bit가 1인 경우 바로 참조 값을 반환하며 그렇지 않은 경우에는 참조된 개체가 Relocation Set에 있는지 확인한다.
  • Set에 없는 경우 Remap bit를 1로 설정한다. (재 배치 되었음을 의미하기에)
  • Set에 있는 경우에는 Relocation 하고 forwarding table에 해당 정보를 기록한 뒤 Remap bit를 1로 설정한다.
  • 참조 값을 반환한다.

 

 

Forwarding table

Relocation 대상인 객체의 현재 참조 값과 변경 후 참조 값을 기록하는 일종의 Mapping Table을 말한다. 이를 이용하여 현재 Relocation 된 객체를 바로 접근하고 참조할 수 있다.


 

ZGC Flow

http://cr.openjdk.java.net/~pliden/slides/ZGC-FOSDEM-2018.pdf

 

 

ZGC Marking Flow

해당 Flow는 Pause Mark Start → Pause Mark End를 포함한다.

 

Root Set Mark (S-T-W) :

  • 객체를 참조하는 Root set을 찾아 Marking 하는 작업이다. Root Set은 상대적으로 적기 때문에 매우 짧은 S-T-W를 가진다.


Concurrent Mark & Concurrent Remap

  • Application과 동시에 수행되는 단계로 Marking 된 Root set으로부터 객체 간의 참조 관계(그래프)를 추적하여 접근한 모든 객체를 Marking 한다. (Marked bit check)
  • Load barrier를 활용하여, Marking 되지 않은 Object load를 감지하고 해당 객체의 mark pointer도 표시한다.


Concurrent Prepare & Edge Handle ( Week Reference Clear... S-T-W)

  • Local Thread 간의 동기화를 진행한다. Thread local handshakes
  • 이후 Week, Phantom Reference와 같은 일부 edge case를 확인하고 정리한다.

 

 

 

ZGC Relocation Flow

해당 Flow는 Pause Relocation Start ~ End를 포함한다.



Concurrent Relocate

  • Mark Flow가 끝나고 재배치할 대상을 찾아 Relocation Set에 배치한다.
  • Mapping 되지 않은 대상들은 Heap Memory에서 정리한다.
  • Relocation Set에 연결된 대상 중 Root Set을 통해 참조되는 모든 객체를 재 배치 후 업데이트한다.


Concurrent Relocation and update

  • Relocation Set에 남아있는 대상들을 추적하며 재배치하고 이전 참조 값과 변경된 참조 값을 Mapping 하는 forwarding table에 저장한다.
  • Load barrier를 이용하여 Relocation Set에 배치된 대상을 참조하는 Pointer를 감지할 수 있다.


이후 생성되는 참조 관계는 다음 Mark 단계부터 다시 진행된다.


 

참고 자료, 출처

이 글은 특정 구현에 종속되는 내용을 제외한 이론 위주의 정리 글입니다.

 

AOP (Aspect-Oriented Programming) 란?


AOP는 프로그래밍 개발 사상 중 하나이며, Spring Framework을 구현한 주요한 개념입니다.

 

저는 이 개념을 애플리케이션 내부의 컴포넌트들에 존재하는 비즈니스 로직이 아닌 보일러 템플릿 코드들을 (저는 개인적으로 인프라 로직이라고 명명하여 부릅니다.) 모아 응집시켜 각각의 컴포넌트로 분리하는 것으로 이해하고 있으며, 개발자들은 이를 활용하여 애플리케이션 서비스에는 비즈니스 로직들만을 남겨 개발 유지보수 경험을 향상시킵니다.

 

 

인프라 로직?


Application 전 영역에서 나타날 수 있는, 중복될 수 있으며 비즈니스가 아닌 로직을 의미합니다.

  • 성능 검사, Flag 처리(활성화, 비활성화)
  • 로거 - 로깅
  • 알림
  • 예외처리
  • 인증 - 인가
  • 트랜잭션 처리
  • 의존성 주입

등 실제 도메인에서 필요한 비즈니스 로직이 아닌 것들을 의미합니다.

 

AOP는 OOP를 대체하는 것이 아니라 보완하는 성격의 프로그래밍 사상입니다. OOP는 클래스를 이용하여 역할에 맞게 로직을 응집하고, 가시하게끔 하는 것이고 AOP는 그런 클래스들을 바라보는 거시적인 시점에서 좀 더 역할에 맞게끔 로직들을 분리해냄으로써 컴포넌트의 결합성을 떨어트리고 재사용 가능케하는 것이기 때문입니다.

 

즉 "AOP와 OOP 중 무엇이 좋냐" 라고 비교하는 것은 잘못된 질문이라고 생각합니다.

 

이러한 AOP는 방문자, 데코레이터, 프록시 패턴 등을 통해 적용할 수 있습니다.

 

 

AOP의 개념?


  • Aspect : 비즈니스 로직을 제외한 부가 기능에 대한 코드들을 응집시켜 컴포넌트로 만든 것입니다.
  • Target : Aspect를 적용할 대상을 의미합니다. (Class, Method)
  • Advice : 어느 시점에 Aspect를 적용할지 결정하는 것을 의미합니다.
  • JoinPoint : Advice가 적용될 수 있는 위치들, 즉 Method 진입 지점, 생성자 호출 시점, 객체 동작 시점이나 필드에서 값을 꺼낼 때 등 적용 가능한 다양한 상황을 의미합니다.
  • PointCut : 실제 Advice가 적용될 지점을 설정합니다.

 

 

 

Spring AOP?


Spring AOP는 Spring에서 기본적으로 사용할 수 있는 Dynamic Proxy 기반의 AOP 구현체입니다.

  • JDK Dynamic proxy, CGLIB API 통해 동작합니다.
  • Spring Container에 등록되는 Bean들에만 적용 가능합니다.

해당 라이브러리의 목적은 모든 AOP 스펙을 제공하기보다는 기능을 간편하게 적용하면서 메서드 래밸의 중복 코드의 제거와 객체 간의 강결합을 해결하기 위함입니다.

 

 

DK Dynamic Proxy와 CGLIB가 사용되는 시점?


JDK Dynamic Proxy

대상 객체가 최소 하나의 인터페이스를 구현하였을 경우 사용합니다.

 

JDK Dynamic Proxy의 문제점

Advise 대상이든 아니든 모든 Method Call 마다 reflection API의 invoke를 진행하게 됩니다.

  • 즉 invoke를 우선 진행하고 Advise 유무를 판단합니다.

 

CGLIB

대상 객체가 인터페이스를 가지지 않았을 경우 사용합니다.

  • 인터페이스를 가져도 사용할 수는 있습니다 aop:config의 proxy-target-class를 true로 설정하면 됩니다.
  • 대상 객체가 정의한 모든 메서드를 프록시 하여야하는 경우 사용합니다. 하지만 final 지시자는 Override 할 수 없으므로 Advice 할 수 없습니다.

CGLIB의 문제점

  • 성능면에서 JDK 에 비해 우수하나 final method, class 은 Advice 할 수 없습니다.
  • 버전 별로 API가 급변함으로 호환성이 좋지 않습니다. 그렇기에 하이버네이트와 같은 프레임워크들은 특정 버전을 내장하여 개발됩니다.

 

AOP Weaving


Compile-Time Weaving : AspectJ
컴파일 시에 소스코드를 받아 바이트코드 변환할 때 Aspect를 적용합니다. ( .java → .class )

  • 기존 Java Compiler를 확장한 AspectJ Compiler 라는 것을 사용하게 됩니다.
  • 컴파일 시에 바이트 코드 조작을 통해 구현부에 코드를 직접 삽입하여 위빙을 수행합니다.
  • 해당 방법의 경우 Lombok, MapStruct 과 같은 Compile 시 간섭하는 라이브러리와 충돌이 일어날 수 있다고 합니다.
  • 위빙 방식 중에서 제일 빠른 퍼포먼스를 보여줍니다.

 

Post-Compile Weaving (Binary Weaving) : AspectJ
이미 컴파일된 클래스 파일에 바이트코드를 삽입하여 Weaving을 적용하는 방식입니다. (.class → .jar)

 

 

Class-Load Time Weaving : AspectJ
Class Loader가 클래스를 로딩할 때 바이트코드를 삽입하여 Weaving 합니다. (객체가 메모리에 올라갈 때)

  • Spring Container 에 객체가 로드되기 전에, 객체 정보를 핸들링함으로 성능이 저하됩니다.
  • JVM에서 제공하는 agent를 통해서 기능을 지원받아 적용합니다.

 

Runtime Weaving : Spring AOP
실제 코드에 변형이 존재하지 않으며, 메서드 호출 시 프록시를 통해 이루어지는 방식입니다.

  • Spring Container에 객체가 로드될 때, ProxyPostProcessor와 ProxyFactoryBean을 통해 객체 정보를 생성하고 Bean으로 반환하여 컨택스트에 저장하게 됩니다. 즉 Spring Bean에게만 적용되는 것입니다.
  • 메서드 수준의 AOP 만을 지원합니다.
  • Point Cut에 대한 Advice수가 늘어날수록 성능이 떨어진다는 단점이 있습니다. (성능 퍼포먼스 상 8~35배 차이)

Spring AOP의 ProxyFactoryBean 은 설정 대상 객체의 Interface 유무에 따라 proxy를 자동 설정합니다. 있으면 JDK, 없으면 CGLIB입니다. (Boot 2.0 이후는 밑에 언급하였습니다.)

 

DefaultAdvisorAutoProxyCreator 후처리기가 추가되어 있는 경우에는 ProxyFactoryBean이 없더라도 프록시 설정을 적용할 수 있습니다. 이 빈은 어드바이저 정보를 통해 Bean을 프록시로 Wrapping 합니다.

 

 

Spring AOP와 AspectJ를 언제 사용하여야 할까?


Spring AOP

  • Spring Bean에서 메서드 실행만을 Advice하는 것이 AOP 요구사항의 전부라면 Spring AOP를 도입할만 합니다.
    • AspactJ 컴파일러나 위버 등 별도의 도입 요구사항이 존재하지 않습니다.

AspectJ

  • Spring Container에서 관리하지 않는 객체(도메인 객체 등)를 Advice 해야한다면, AspectJ를 도입하여야 합니다.
  • Self Invocation 시 @Transaction, @Caching 처리를 적용하기 위해서는 AspactJ 를 고려할만 합니다.
  • Public 이외의 메서드, 필드, 클래스 등에 Advice를 적용하고 싶은 경우 AspactJ를 고려할만 합니다.

 

Spring AOP - JDK Dynamic Proxy는 Target 메서드 호출마다 인터셉팅하는가?


Spring은 Bean을 등록할 때 Reader를 통해 읽어들여진 Bean Definition을 Parser로 해석하고 대해 PostProcessor를 통해 등록 Process가 진행되게 됩니다.

 

객체 정보에 선언적인 AOP와 Transaction 등이 적용되었다면, ProxyFactoryBean을 통해 Proxy 객체를 생성하고, 해당 객체를 ApplicationContext에 반환하게 됩니다. 그리고 business Logic에서 DI가 있어야 하였을 때, 해당 Proxy 객체를 Injection하여 Proxy를 통해 Logic을 실행하게 됩니다.

 

이러한 흐름을 가지기 때문에, 어플리케이션에서 Business Logic을 처리할 때 AOP가 적용된 모든 객체 호출은 Proxy를 통해 인터셉트되는 요청 흐름을 가지게 됩니다.

@Autowired
XxxService xxxService;

// 위의 로직은 ApplicationContext에서 발생하는 DL, DI 생략하면 밑의 코드와 같다고 볼 수 있습니다.
XxxService xxxService = (XxxService) Proxy.newProxyInstance(
        XxxService.class.getClassLoader(), new Class[]{XxxService.class},
            (InvocationHandler) (proxy, method, args) -> {
                XxxService xxxService = new DefaultXxxService();
                                Method targetMethod = null;

                                // Verification?
                                if (proxyMethodMap.containsKey(method)) {
                                        targetMethod = cacheMethodMap.get(method);
                                } else {
                                        Object invoke = method.invoke(xxxService, args);
                                        return invoke;
                              }

                // Before Proxy....

                                // Invoke
                Object invoke = targetMethod.invoke(xxxService, args);

                                // After Proxy....

                return invoke;
            });

 

추가적으로 Spring Boot 2.0 부터는 CGLIB 설정을 변경하여 기본적으로 강제하게 됩니다.  spring.aop.proxy-target-class=true 그러므로 인터페이스 유무와 상관없이 CGLIB가 사용됩니다. 

 

 

잘못된 내용은 댓글로 작성 부탁드립니다!

 

 

참고자료

+ Recent posts