현재 회사에서 운용하고 있는 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 하는 것이 좋다고 생각합니다.
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.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를 통해 평균값이나 변동 폭을 나타내는 메트릭으로 변경하여야 합니다.)
Lambda와 관련해서는 학습을 진행하며 작성한 글이기 때문에 잘못된 내용이 있을 수도 있습니다. 틀린 내용이 있다면 채널톡 혹은 댓글을 통해 피드백해주시길 바랍니다. ㅎㅎ
Self Invocation
Self Invocation은 Dynamic Proxy 기반의 기능들을 사용할 때 사소한 실수로 인하여 자주 발생하는 문제입니다. 쉽게 설명하자면, 객체 외부에서 보내는 메시지(요청)에 대해서만 반응하도록 설계되어 있기에 내부의 요청에 대해서는 반응하지 못하기 때문입니다.
JVM 생태계에서 많은 사랑을 받는 Spring Framework는 다양한 기능들을 Dynamic Proxy 방식으로 제공하고 있습니다.
@Transcational, @Async, @Cacheable, AOP(Before, Around) 등의 Aspect 기능들이 속합니다.
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를 호출하는 흐름
Reflection API를 통해 실행 대상이 되는 메서드 정보를 가져옵니다.
MethodHandle Lookup API에 정의된 Factory 메서드를 통해 Lookup 객체를 가져옵니다.
1번에서 가져온 정보를 Lookup.unreflect() 메서드에 전달함으로써 해당 메서드의 구현, 수행 정보를 알고 있는 MethodHandle 객체를 가져옵니다. (실제 메서드를 바라보고 있는 일종의 포인터)
LambdaMetafactory.metafactory() 메서드에 필요한 인자를 넘겨 CallSite 객체를 반환받습니다. 해당 객체는 Functional Interface를 객체로 다룰 수 있으며, 매개 변수를 설정하고 응답을 반환합니다. 인자 목록은 밑에 나열하였습니다.
접근 권한을 가지고 있는 Lookup 객체
구현할 메서드 이름(Supplier Interface를 사용했을 경우 get이라는 문자열을 넘긴다.)
메서드의 매개 변수와 응답 값의 Class 정보. methodType(Supplier.class, {Type}. class)
함수 객체(Lambda)에 의해 반환될 응답 값의 유형. methodType(Object.class)
메서드의 구현 및 수행 방식을 알고 있는 MethodHandle 객체
호출 시 동적으로 적용되어야 할 응답 값의 세부 정보. methodType({Type}. class)
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);
}
}
단순한 예시여서 실제 효용성과 조금 동떨어진 감은 있지만, 실제 업무 중 활용할 수 있을만한 부분을 특정하실 수 있을 것이라고 생각합니다. ㅎㅎ 이 글은 여기까지입니다. 감사합니다.
회원 정보를 저장하는 기본적인 형태의 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를 이용합니다.
@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");
}
}
다 작성하였다면 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를 작성하고 있는데요. 필요하시다면 참고하시고, 피드백을 남겨주셔도 좋을 것 같습니다. ㅎㅎ
우선 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에서 작성합니다.
이 글은 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 명을 명시해야 하는 불편함이 존재한다.
이 글에서 언급된 column들은 운영이나 통계 등의 도메인 외부의 요소를 위한 것들로 객체의 상태 관점보다는 데이터 그 자체로써 사용되는 의미가 짙다고 생각된다.
embedded type은 특정 의미(도메인, 비즈니스 성격)를 가진 column들이 중복적으로 정의되어 코드 수정 시 불편함이나 해당 값을 다루는 메서드를 한 곳에 응집시키기 위해 사용되는 방법이다. 대부분의 상황에서 이 방식을 고려하는 것이 좋겠지만 단순한 데이터를 다룰 때에는 단순한 방식을 선택하여 처리하는 것이 더 나은 경우도 있다.
위에서 언급된 상황에서는 embedded type을 사용하는 것보단 MapperdSuperClass를 사용하는 것이 좋다고 생각한다.
Prometheus는 오픈소스 기반의 모니터링 시스템으로 Service discovery pattern을 통해 데이터 수집대상을 발견하고 주기적으로 풀링하여 매트릭 데이터를 수집합니다.
수집된 매트릭 정보들은 로컬 디스크에 있는 시계열 데이터베이스에 저장되어 Prometheus의 도메인 특화 언어인 PromQL을 통해 빠르게 검색할 수 있습니다.
Prometheus는 매트릭 수집을 위한 서버나 컨테이너 구성이 불필요(Single host)하며, 클라이언트가 매트릭 푸시를 위해 CPU를 사용할 필요도 없습니다. (Metric Push를 통한 응답 병목이나 서버 부하를 예방합니다.)
부가적으로 중앙 집중식 구성 방식과 관리 콘솔을 제공하기에 설치나 사용이 매우 쉽습니다.
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을 수집하지 않거나 수집 주기를 늘리는 식으로 대처하여야 합니다.
관련 설정 중 다룰 수 있는 것은 페이지 캐시의 크기만 존재하며, 다른 값은 버전이 업데이트 됨에 따라서 달라집니다.
# 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 관련 의존성을 추가합니다.
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에게 전송할 수 있다.
RabbitMQ dispatch 방법은 기본적으로 round robin 방식이며 MessageQueue에 담는 순서대로 worker들에게 전달한다.
균등한 메세지 처리 가 필요한 상황에선 위 방식으로 충분할 수 있으나 worker들이 메시지 중 특정 순서로 오랜 처리 시간이 필요한 상황 등의 특정한 경우 알맞지 않을 수 있다.
이런 경우 순차적으로 메세지를 제공받더라도 처리 시간으로 인해 다른 worker는 쉬는 상태에서 무거운 작업을 처리하는 worker에게 지속적으로 message가 전달되는 문제가 발생한다.
Fair dispatch 하도록 설정하기 : Prefetch Count
Prefetch Count는 Consumer에게 보내는 메시지 수를 지정하는 데 사용하는 옵션이며, 요청을 처리했음을 의미하는 Ack가 RabbitMQ에 전달되기 전까지 consumer가 전달받을 수 있는 message의 개수이다.
기본 설정 값은 클라이언트에게 크기 제한이 없는 버퍼를 제공하며, 기본적으로 요청을 받을 수 있는 Consumer에게 최대한 많은 메시지를 전달한다.
전송된 메시지는 클라이언트의 클라이언트에 존재하는 Prefetch Buffer에 캐시 된다.
Prefetch 된 메시지는 Message Queue의 대기열에서 제거되고 다른 Consumer에게 표시되지 않는다.
rabbit.default_consumer_prefetch
Prefetch Count에 따른 성능 조정
1개로 설정해 두는 경우 (작을 수록 Fair Dispatch 하다.)
하나의 메시지가 처리되기 전에는 새로운 메시지를 받지 않게 되므로, 여러 worker에게 동시에 작업을 분산시킬 수 있지만 여러 worker가 포함되어 있으나 각 단위 요청이 빨리 처리되는 상황에서는 각 worker의 다음 작업까지 대기시간이 증가할 수 있다.
worker가 많거나 한 작업 단위의 처리 시간이 긴 경우 모든 worker에게 균등하게 나눠지도록 값을 작게 설정하는 것이 좋다.
값을 크게 해 둘 경우
메시지 큐에서 다량의 메시지를 하나의 worker에게 전달하여 Buffer에 요청을 쌓고 계속 처리할 수 있도록 하기에 각 worker 대기 시간은 감소할 수 있지만 특정 요청의 처리 시간이 긴 경우에 다른 worker들이 일을 하지 않고 대기하는 상황이 발생할 수 있다.
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 스펙을 제공하기보다는 기능을 간편하게 적용하면서 메서드 래밸의 중복 코드의 제거와 객체 간의 강결합을 해결하기 위함입니다.
인터페이스를 가져도 사용할 수는 있습니다 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가 사용됩니다.