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

 

 

 

복제가 필요한 이유?

우리가 제공하는 API 서비스는 일반적으로 Stateless 한 형태를 준수하여 개발하여야 합니다.

 

이는 State를 다른 서비스(RDBMS, Global Session Storage - Redis 등)에서 관리하게 하고, API 서비스는 필요에 의해 조회하도록 하여 Stateful로 개발하였을 때 발생하는 API 서비스 간의 상태 동기화 이슈나 확장성 저하 문제를 최소화할 수 있기 때문입니다.

 

이러한 구조는 트래픽 급증에 대응하는 Scale-out을 가능케 합니다.

 

 

이런 형태로 개발하게 되면 결국 다수의 API 서비스가 조회하는 State를 관리하는 서비스에는 대량의 부하가 전달되어 자연스레 해당 서비스에 장애가 발생할 확률이 증가하게 됩니다. 이때 저장되어야 할 데이터가 유실되거나 (Transaction 실패, Connection Timeout 등) 또는 서비스가 중단된다면 이는 전체 서비스에 영향을 미치는 큰 문제가 됩니다. (SPoF)

 

이를 대비하기 위해 우리는 High Availability 이라는 속성을 만족하도록 서비스 설계를 진행하여야만 합니다. 그리고 이 속성을 지원하는 설계 방법 중 하나로는 복제 구조가 있습니다.

 

 

 

 

복제?

복제란 기본적으로 네트워크로 연결된 여러 물리 장비에 동일한 데이터를 분산 및 복사하여 저장하는 것을 의미합니다.

 

이를 통해 우리는 서비스 데이터를 좀 더 안정적으로 보관할 수 있으며, 다른 네트워크 간의 복제 시 특정 네트워크의 전체 장애를 대비할 수 있고 또한 동일한 데이터를 가지고 있기 때문에 이를 활용하여 서비스 API의 조회 요청을 여러 장비로 분산시킬 수도 있습니다.

  • 이렇게 서비스 API의 조회 요청을 여러 장비(RDBMS, MongoDB, Redis 등)로 분산 시키는 행위는 Read Query Load Balancing 또는 Query Off Loading이라고 합니다.
이후 설명에서는 Master - Slave 용어가 특정 시기의 차별을 나타냄으로 이제 사용되지 않기에 Origin - Replica로 대체하여 작성하도록 하겠습니다.

MongoDB나 특정 Opensource 에서는 Primary - Secondary로 명명하기도 합니다.

 

 

 

 

복제 구현 방식?

복제 로그 구현 방식으로는 크게 Statement based, Write-ahead logging based, Row based log 3가지가 존재합니다.

 

 

Statement based는 Origin 모든 쓰기 요청의 구문을 특정 영역(버퍼, 큐 등)에 기록하고 write를 수행한 다음 Replica에게 해당 기록을 전송하는 방식입니다.

 

Origin이 INSERT, UPDATE, DELETE 구문 수행 기록을 전송하면 각 Replica는 이를 파싱 하고 실행하여 보유한 데이터를 동일한 상태로 만들어 갑니다.

  • 식별 값 자동 생성, 노드가 보유한 데이터를 의존하는 처리 (UPDATE ~ WHERE) 등은 각 노드마다 정확하게 같은 순서로 실행되어야만 합니다. 인과성의 보장 필요
  • 이 방식의 장점은 복제하는 데이터가 경량이기에 Network I/O 비용이 감소하고, 감사 등의 운영 상 이슈가 발생하였을 때 대처하기 좋다는 장점이 있습니다.
  • 이 방식의 단점은 현재 날짜나 시간, 임의의 숫자를 구하는 비 결정적 함수 혹은 요청에 대해서 각 Replica마다 다른 결과를 얻고 반영하여 데이터 일관성 문제가 발생할 수 있다는 점입니다.
또한 부수 효과를 일으키는 요청 (Trigger, Procedure) 또한 결과가 비 결정적이라면 다른 결과를 얻을 수 있습니다.

 

 

이러한 문제를 해결하기 위해 Origin이 구문을 기록할 때 모든 비결정적 함수 호출과 부수 효과 요청에 대해서 고정 값을 반환하게 끔 할 수 있으나, 여러 예외 케이스가 있기 때문에 일반적으로 다른 복제 방법을 사용하는 것을 선호합니다.

  • MySQL에서는 지속적으로 구문 기반 복제를 사용해왔으며 사용된 구문에 비 결정적 함수가 존재한다면 논리적 로그 기반 복제로 변경하여 사용도록 되어 있습니다.
  • VoltDB도 구문 기반 복제를 사용하고 있지만, MySQL과 다르게 트랜잭션 단위를 결정적이 되게 끔 제약하여 문제를 해결하고 있습니다.
  • Redis 또한 구문 기반의 복제를 사용하고 있습니다.

 

 

 

 

Write ahead logging based는 말 그대로 저 수준의 로그인 WAL을 활용하여 Replica에도 동일한 복제 데이터를 구축할 수 있다는 점을 이용하는 방법입니다. 이 방식은 Postgresql과 Oracle 등에서 사용하고 있습니다.

Origin는 모든 write 요청에 대해서 WAL을 기록하고 Replica에게 Network I/O를 통해 전송합니다. 그리고 Replica는 전송받은 WAL을 그대로 반영하여서 최종적으로 Origin와 동일한 데이터를 구성하게 됩니다.

  • 이 방식의 장점은 Replica에 데이터를 복제할 때의 성능이 다른 방법보다 매우 빠르다는 점입니다. 또한 로그는 인과적으로 저장되는 것을 보장하기 때문에 WAL 동기화 비용 또한 최소화됩니다.
  • 이 방식의 단점은 WAL가 코어 수준에서 사용되는 데이터를 기술하기 때문에 저장소에서 사용하는 엔진에 대한 버전 의존성이 생겨 다른 버전을 사용하는 Origin, Replica 간의 복제가 거의 불가능하다는 점입니다.
이러한 제약 때문에 버전 업데이트 시에는 Replica를 우선 업데이트한 뒤 Origin를 업데이트해야 합니다

 

 

 

 

Row based log는 WAL에서 발생하던 문제점인 저장소 엔진 의존성을 제거하기 위해 구상된 방법으로 추상화된 다른 형태의 로그를 활용하여 데이터를 복제하도록 구현되어 있습니다. 일반적으로 로우 단위로 구성된 데이터베이스 테이블을 기준으로 write 로그를 작성하여 모든 칼럼에 속한 값을 같이 로그로 남깁니다.

 

이렇게 논리적 로그를 데이터와 WAL 중간에 추가함으로써 엔진 간의 하위 호환성을 유지하는 것이 수월해졌으며, 다른 버전이나 다른 저장소 엔진 기반으로 동작하는 노드와의 복제 또한 가능하게 되었습니다.

또한 기록된 로그를 파싱을 하는 것도 수월하여 분석, 로그 시스템, 외부 시스템에 내용 전송 등 여러 측면에서 유용한 방식입니다.

 

 

MySQL에서 사용하는 이진 로그 복제는 해당 방식을 활용합니다.

  • 이 방식의 장점은 WAL의 장점을 대부분 수용하면서도 하위 호환성을 지키고, 복제할 수 있다는 점입니다.
  • 이 방식의 단점은 Origin과 Replica의 테이블 Schema가 동일하여야 한다는 점, 실제 값을 복제하기 때문에 반영하는데 상대적으로 지연 시간이 발생하는 점 그리고 특정 기록이나 대용량의 로그를 반영하기 위해 Bin log로 쓰는데 동시성 문제가 발생할 수 있다는 점입니다.

 

이 방식에서 제공하는 로그 정보는 이렇게 기록됩니다.

  • 삭제된 Row based log는 고유하게 식별하기 위한 메타 데이터를 포함하는데 일반적으로 PK를 사용하고 PK가 없다면 모든 칼럼의 예전 값들을 Logging 하는 방식으로 구현됩니다.
  • 갱신된 Row based log는 모든 칼럼의 새로운 값과 식별하는데 필요한 메타 데이터를 포함하여 기록됩니다.
  • 여러 Row를 동시에 수정하는 Transaction의 경우 우선 그 Row 수만큼 로그 레코드를 생성한 다음 해당 Transaction이 커밋되었음을 나타내는 정보를 같이 표시합니다.

 

 

 

복제 동기화 방식?

복제 동기화 방식으로는 크게 Synchronous, Semi-synchronous, Asynchronous 3가지가 존재합니다.

 

 

Synchronous Replication은 Origin에 데이터를 반영한 직후나 반영하기 전에 Replica node에 먼저 복제 요청을 전송하여 정상 수신이 되었을 때 이후 처리를 진행하는 방식입니다. 이를 통해 분산 저장된 데이터의 일관성과 무결성을 지키는데 집중합니다.

  • 이 방식의 장점은 분산된 장비 간의 데이터 일관성과 무결성을 가지는 것을 보장한다는 점이며, Origin가 작동하지 않는 상황에서도 Replica를 통해 올바른 데이터를 사용할 수 있음을 알 수 있다는 것입니다.
  • 이 방식의 단점은 네트워크 지연 문제, Replica의 처리 지연, 장애 발생 등으로 인해 확인 요청에 대한 응답을 하지 않는다면, 새로운 데이터를 쓰는 것이 불가능한 상태에 빠진다는 점입니다. 이때 기본적으로 Origin은 Replica가 응답하기 전까지 Blocking 상태에 빠집니다.
해당 구조에서는 Origin에 장애가 발생하지 않더라도 Replica 문제로 인해 이를 바라보고 Write 하는 모든 서비스로 장애가 전파될 수 있습니다.

 

 

또한 Scale-out이 될수록 발생하는 응답을 수신하는 지연 시간이 길어지고 위와 같은 상황이 발생할 확률이 증가하여 서비스의 확장성이 매우 떨어지게 됩니다.

 

 

 

Semi-synchronous Replication은 Synchronous Replication을 현실적으로 풀어내는 방법 중 하나입니다. 해당 방식은 한 시점에 최소한 하나의 Replica와 Synchronous Replication를 진행하는데, 해당 Replica가 추가된 Transaction을 반영하면 Origin에 해당 내용을 반영합니다. 이를 통해 최소한의 데이터 무결성을 보장합니다.

  • 그 외 Replica들은 Asynchronous Replication을 수행하는 방식이며, Synchronous Replication를 진행하는 대상은 현재 (Synchronous Replication) 대상에 장애 혹은 처리 지연이 발생하여 변경될 수 있습니다.
  • 이 방식의 장점은 적어도 하나 2개의 서비스에서 데이터 일관성과 무결성을 보장한다는 점이며 그러면서도 Synchronous Replication의 단점을 최소화한다는 것입니다.
  • 이 방식의 단점은 최신의 데이터를 가진, 즉 데이터 일관성과 무결성을 보장하는 Replica가 Synchronous Replication을 수행 중인 Replica가 아닐 수 있다는 점이며 또한 여전히 다른 Replica와 Origin 간의 데이터 불일치가 발생한다는 점입니다.

조회하는 Node에 따라 데이터 일관성 깨질 수 있는 문제는 여전하지만, 현재 Node 개수와 대비하여 읽어올 읽기-쓰기 Node 개수를 조정하면 최종적으로 지연 시간과 일관성을 보완할 수 있습니다.

또한 Synchronous Replication을 통해 데이터를 넘겨받는 Node가 꼭 최신 상태가 아닐 수 있습니다. MySQL의 Bin Log를 활용한 Semi-Sync Replication은 모든 Replica가 동일한 Bin log를 바라보고 있고, 결국에는 동일한 Transaction을 같은 시점에 수신받을 수 있기 때문에 Asynchronous Replication을 수행하는 Replica가 오히려 먼저 최신 상태에 도달할 수 있습니다.

기타 설명된 동작은 구현 방식에 따라 달라질 수 있습니다.

 

 

 

Asynchronous Replication은 Origin에 반영된 데이터 정보를 Replica들이 지속적으로 수집하여 반영하는 High Availability에 좀 더 초점을 맞춘 방식입니다.

  • 이 방식의 장점은 서비스에서 요청한 데이터의 Write를 재시도를 통해 계속 보장할 수 있다는 점이며, Synchronous Replication에 비해 쓰기에 대한 서비스 지연 시간이 짧아지고 확장성이 좋다는 것입니다.
  • 이 방식의 단점은 Origin에 장애가 발생하였을 경우 네트워크 지연 등으로 인해 최신 데이터가 유실될 수 있다는 점과 Origin와 Replica 간의 데이터 일관성이 일정 시간이 지나야 만족된다는 점이 있습니다.(Eventual Consistency)
부가적으로 Asynchronous Replication을 수행하면서 새로운 Origin을 선출하였더라도 이전 Origin이 반영했던 혹은 반영 중이던 데이터를 온전히 가지지 못하게 됩니다.

 

 

이러한 문제를 해결하는 방법은 단순히 무시하고 넘어가는 것이지만 이 경우 서비스의 내구성이 매우 떨어지게 됩니다. 시스템의 확장성이나 HA가 높은 것은 좋지만 저장되어야 할 데이터의 유실은 도메인, 서비스에 따라서 심각한 문제가 되기 때문입니다.

 

그렇기에 지속적으로 좋은 성능과 HA를 제공하면서도 데이터 유실이 없는 복제 방법이 개발되고 있는 상황입니다. 이를 통해 성공적인 결과물로 구현된 방식 중 하나로는 체인 복제 방식이 있습니다.

 

 

 

 

기타

Trigger-Based Replication은 특정 데이터나 이기종 데이터베이스 간의 Replication을 위해 서비스에서 Oracle의 Golden gate, Batch Framework, 데이터베이스의 Trigger나 Procedure를 활용하는 방식입니다.

 

서비스에 복제를 위한 코드를 등록하고 데이터베이스 시스템에서 데이터가 변경이 있을 때 이벤트 등을 받거나 직접 실행하도록 하여 데이터를 가공하고 다른 서비스로 전달시킬 수 있습니다.

  • 이 방식의 장점은 해당 방식은 다른 복제 방식에 비해 유연하다는 점입니다.
  • 이 방식의 단점은 다른 복제 방식에 비해 많은 Overhead를 동반한다는 점이며, 이를 수행하는 서비스 또한 장애 지점이 될 수 있고, 작성된 코드를 통한 버그가 발생할 확률이 매우 높습니다.
Oracle의 Databus나 Postgresql의 Bucardo가 이러한 방식으로 동작합니다.

 

 

 

 

마치면서. 현재 제가 있는 팀에서는 Galera Cluster라는 Opensource를 도입하여 사용하고 있습니다. 이것은 설정에 따라 특정 동작에서 Semi-synchronous Replication과 Asynchronous Replication 방식을 지원하는 MySQL, MariaDB Cluster입니다. 요약하고 넘어가자면,

  • Multi Master 구조의 경우에는 데이터 일관성 유지를 위해 Certification-Based Replication를 사용하게 됩니다. 이는 Dead lock의 주범입니다..
  • Replica들은 기본적으로 Asynchronous Replication을 통해 데이터를 처리하다가 recv queue의 정해진 임계 값을 만족했을 때 해당 Replica와 Flow Control이라는 것을 발생시켜 이때에는 Asynchronous Replication를 중지하고 recv queue에 있는 것을 처리하는 방식으로 구현되어 있습니다.

 

Galera Cluster에서 설명하는 것을 보면 virtually synchronous replication을 수행한다고 합니다. 이는 논리적으로 완전 동기이나 write나 commit이 별개로 수행되고 replication은 비동기로 동작하기 때문에 그렇게 명명되었다고 하네요..

 

 

 

 

참고 자료

 

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

 

 

 

장애 감지 알고리즘이 필요한 이유?

우리가 개발하는 서비스가 단일 시스템이 아닌 분산 시스템 구조를 지닌다면 특정 서비스에 장애가 발생하더라도 모든 서비스 노드에게 전파되지 않기 때문에, 사용자 요청으로 인해 식별되기 전까지 모를 수 있습니다.

 

장애가 발생한 서비스에 따라서 시스템에 잘못된 값을 설정하거나 중복이 발생할 수 있고 (Index Sharding Service) 사용자가 보는 캐싱된 데이터가 소실되는 경우도 발생할 수 있습니다. (Feed Cache Service 등)

  • 물론 모든 장애가 서비스 종료, 충돌과 같은 완전 중단 상태를 의미하는 것은 아니며, 상황에 따라 부하로 인한 응답 지연, 네트워크 지연 이슈 혹은 데드락으로 인한 것일 수 있습니다.(이런 상태는 시간이 지나면 해결되는 문제이기 때문입니다.) 그렇기에 좀 더 정확한 감지 방식이 필요합니다.
이러한 장애 상황을 빠르게 대처하기 위해 필요한 것이 장애 감지 알고리즘 혹은 감지 방식 입니다. 이러한 방식은 실제 구현하는 장애 감지 서비스뿐만 아니라, Redis, Zookeeper, Kafka, Cassandra, Akka 등의 Opensource 등에서도 사용되고 있습니다.

 

 

 

 

장애 감지 알고리즘의 속성?

장애 감지 알고리즘의 속성으로는 크게 완전성, 효율성, 정확성이 있으며 이중 효율성과 정확성은 사용하는 방법에 따라서 조정이 가능한 속성입니다.

특정 자료에서는 완성도와 정확도 만을 이야기하기도 합니다.

 

 

완전성

특정 서비스에 장애가 발생하면 모든 서비스는 해당 서비스에 장애가 발생한 사실을 인지하고 이와 관련된 처리를 수행하여 결과를 반환하여야 한다는 속성입니다.

 

 

효율성

얼마나 빨리 장애를 감지하는지 평가하는 속성이자 척도입니다. 이는 장애 상태에 빠진 서비스를 얼마나 빨리 감지하느냐 에 따라서 효율성을 결정하게 됩니다.

 

 

정확성

얼마나 정확하게 장애를 감지했는지 평가하는 속성이자 척도입니다. 이 속성은 위에서 잠깐 언급했던 것과 같이 정상적인 서비스이지만 다량의 요청을 처리하며 발생하는 부하, 네트워크 지연 이슈 혹은 데드락으로 인해 일정 시간 동안 응답하지 못하는 상태의 서비스를 장애로 판단하느냐 에 따라서 정확성을 결정하게 됩니다.

 

 

효율성과 정확성의 트레이드 오프

정확성을 설명하면서 장애 감지 시스템의 결과가 거짓일 수 있다는 점을 알려드렸습니다. 즉 정상 프로세스에 장애가 발생하였다고 잘못 판단하는 경우가 있는데 이러한 경우는 효율성이 높은 알고리즘에서 주로 발생하게 됩니다.

 

효율성을 중시하는 알고리즘일수록 빠르게 결과를 도출하기 위해 검증하는 시간과 로직을 최소한으로 하기 때문입니다. 그렇다면 반대로 정확성을 중시하는 알고리즘은 장애를 감지하고 판단하는데 시간이 오래 걸리지만 좀 더 적은 리소스로 서비스의 상태를 관리할 수 있습니다.

  • 여기서 말하는 리소스는 이는 잘못된 결과로 인해 서비스를 죽이고 다시 배포하는 등의 작업 리소스를 이야기합니다.

 

결국 효율성과 정확성을 완벽하게 충족하는 장애 감지 서비스는 없으며, 상황에 따라서 올바른 감지 시스템을 구성하고 사용하는 것이 중요합니다.

 

 

 

 

Ping과 Heartbeat

Ping과 Heartbeat는 유사한 장애 감지 방식입니다. 장애를 감지하는 서비스는 주기적으로 다른 서비스들에게 메시지를 보내고(broadcasting) 결과를 받아 정상적인 상태임을 판단하게 됩니다.

이 2가지 방식은 Timeout을 사용하느냐(Ping), 하지 않느냐(Heartbeat)로 구분 지을 수 있습니다.

여기에서 설명하는 Heartbeat는 All to All 방식의 Heartbeat입니다. 

 

 

Ping은 메시지를 보내는 주기와 Timeout 설정을 기준으로 감지 정확도가 좌우됩니다. 그렇기에 서비스 A에게 보낸 1번째 메시지의 응답이 2번째 메시지 전송 이후에 도착한다면, 장애 감지 시스템은 이 서비스를 장애가 발생한 서비스로 판단할 수 있습니다.

  • 즉 특정 서비스의 상태를 파악할 수 있는 방법이 아니며, 요청에 대한 결과라는 단순한 지표를 기준으로 장애를 탐지하게 됩니다.
  • 이는 완전성을 지키고 효율성 또한 극대화되었으나, 정확도가 낮은 장애 알고리즘임을 의미합니다.

 

 

Heartbeat는 Timeout을 사용하지 않는 대신에 각각의 서비스가 특정 서비스에 몇 번의 요청을 전송하였는지 기록하여 설정된 임계 값에 도달하였을 때 장애로 판단하는 방식입니다. 그렇기에 정확도를 높이기 위해서는 적절한 임계 값 설정이 필요합니다.

  • 초기에 전송되는 메시지에는 발신자의 정보와 동일 메시지의 중복 전송을 방지하기 위해 고유한 식별자를 기록하게 됩니다.

 

 

 

Heartbeat outsourcing

Heartbeat outsourcing은 기본적으로 Heartbeat의 동작 방식을 따르나 각각의 서비스가 모든 서비스에게 메시지 요청을 보내는 것(broadcasting) 이 아니라 인접한 서비스에만 메시지를 날려 장애를 감지하고 판단하는 방식입니다.

 

그렇기에 각 서비스는 모든 서비스 존재를 알고 있지 않도록 범위를 나누어 메시지 요청으로 인한 장애 감지의 신뢰성과 떨어진 효율성을 어느 정도 보완하게 됩니다.

  • 각 서비스는 주변에 위치하는 서비스들에게만 요청을 전파하는 방식으로 동작합니다.
  • Heartbeat를 사용하는 서비스의 수가 수백 또는 수천 개라고 가정하였을 때, 패킷이 지나는 네트워크의 크기가 커지고 자연스레 네트워크 이슈가 발생할 여지도 증가하여 장애로 잘못 판단하는 경우가 늘어날 수 있기 때문입니다.

 

 

 

φι-accrual detection

https://medium.com/@arpitbhayani/phi-φ-accrual-failure-detection-79c21ce53a7a

 

이 방식은 서비스의 상태를 프로세스 충돌 확률을 연속적으로 계산하고(Time series) 이를 통해 장애를 결정하는 방식입니다.

  • cassandra, akka, Hazelcast 등의 opensource가 해당 방식을 사용 및 지원하고 있습니다.

 

우선 특정 배열이나 리스트 형태의 자료구조 또는 TDB에 메시지 응답 도착 시간을 저장하고 Sliding Window 알고리즘을 활용하여 일정 범위의 값을 읽어와 다음 메시지의 응답 시간 또는 응답 확률을 예측한 뒤 실제 메시지를 요청하여 연산된 결과와 비교합니다.

 

그리고 비교한 결과를 통해 Suspicion level을 계산하여 이를 기준으로 노드의 중단, 부하 또는 정상 상태를 판단하게 됩니다.

  • Suspicion level 임계 값을 동적으로 조정할 수 있으며, 다양한 임계 값을 지정하여 여러 가지 행위를 수행할 수도 있습니다.

 

φι-accrual detection을 사용하기 위해서는 지속적인 메시지에 대한 표본을 구하고 이를 저장할 자료구조를 가지고 있어야 하며, 표본에 대한 평균과 분산 값을 계산하여 분포 도를 추정한 뒤 도착할 확률과 프로세스 활성 상태의 정확도를 나타내는 φι를 계산해야 합니다.

  • 또한 특정 임계 값을 지정하여 상태를 결정하는 방법과 상태에 따른 Callback을 구성하여야 합니다.

 

 

 

 

참고 자료

 

이 글은 특정 구현에 종속되는 내용을 제외한 이론 위주의 정리 글입니다. 그림에는 Redis가 표시되어 있지만 Ehcache, Memcached, Hazelcast, Caffeine Cache 등 다양한 구현체로 이를 구현할 수 있습니다. 

 

 

 

Read Cache Strategy

 

Look Aside Cache

서비스에 사용자 조회 요청이 들어오면 우선 Cache에 저장된 데이터가 있는지 확인하는 전략입니다.

  • 초반 Cache 성능 향상 및 트래픽 급증 대비를 위해 (Cache miss 감소) cache warm up을 수행하는 것이 좋습니다.

 

이 방식은 Cache에 장애가 발생하더라도 데이터베이스에 요청을 전달함으로써 Cache 장애로 인한 서비스 중단 발생을 대비할 수 있습니다. 하지만 조회 요청이 많을 경우 Cache에서 발생한 장애가 데이터베이스로 전파되어 전체 장애가 발생할 수 있습니다..

  • 그리고 Cache에서 사용되는 데이터 모델이 데이터베이스의 모델(테이블)과 다른 것 또한 하나의 특징입니다. 예시로 최근에 조회한 글의 조회 수라거나, Query를 통해 만들어진 결과가 Cache에 저장될 수 있습니다.
  • Spring Abstraction Cache module에서 제공하는 기본 구현체가 이 방식을 따르고 있습니다.

 

Cache warm-up or Cache warming : 미리 Cache로 데이터베이스의 데이터를 밀어 넣어두는 작업을 의미합니다. 이 작업을 수행하지 않는다면 서비스 초반, 트래픽 급증 시 발생하는 대량의 Cache miss + Cacheable 작업으로 인해 Cache와 데이터베이스의 부하가 급증하는 Thundering Herd가 발생할 수 있습니다.

물론 warm-up을 통해 추가되었던 Cache가 expire 된다면 다시 Thundering Herd가 발생할 여지가 있으므로 이를 원천적으로 해결하기 위해서는 Probabilistic Early Recomputation 방식 등과 같이 Cache의 TTL이 만료되기 전에 갱신시키는 방법 등을 고려하여야 합니다.

Thundering Herd는 모든 지점에서 발생되는 것은 아니며, 서비스의 첫 페이지와 같은 대부분의 조회가 몰리는 지점에서 주로 발생됩니다.

 

 

 

Read Through (Inline-Cache)

서비스에 사용자 조회 요청이 들어오면 Cache를 조회하고 Cache miss가 났을 때 데이터베이스에 저장된 데이터를 조회하여 Cache를 업데이트한 뒤 값을 반환하는 전략입니다. 

  • 이 방식 또한 서비스 운영 초반에 cache warm up을 수행하는 것이 좋습니다.

 

이 방식은 직접적인 데이터베이스 접근을 최소화하고 Read에 대한 소모되는 Resource를 최소화할 수 있습니다. 하지만 Cache에 장애가 발생하였을 경우 이는 바로 서비스 전체 중단으로 빠질 수 있습니다. 그렇기에 Cache를 제공하는 Redis과 같은 구성 요소를 Replication 또는 Cluster로 구성하여 가용성을 높여야 합니다.

  • 이 방식은 앞서 설명한 Look Aside 방식과는 달리 데이터베이스와 동일한 데이터 구조를 지니며, Query의 대상(모델)은 Cache에 저장되어 있습니다.

 

 

 

 

Write Cache Strategy

 

Write Around

데이터베이스에 데이터를 저장하고 Cache miss가 발생했을 때 Cache로 데이터를 가져오는 쓰기 전략입니다. 이는 Look aside cache의 Cache miss 발생 시 흐름을 이야기합니다.

  • Cache miss가 발생하기 전에 데이터베이스에 저장된 데이터가 수정되었을 때, 사용자가 조회하는 Cache와 데이터베이스 간의 데이터 불일치가 발생하게 됩니다.

 

데이터 불일치를 방지하기 위해서는 데이터베이스에 저장된 데이터가 수정, 삭제될 때마다 저장해둔 Cache 또한 삭제하거나 변경해야 하며 데이터 수정 사항이 빈번할 수록 Cache의 expire를 짧게 조정하는 식으로 대처하여야 합니다.

데이터 수정, 삭제 요청이 너무 빈번한 경우 Cache를 변경하거나 삭제하는 비용이 조회하는 비용보다 커지게 될 수 있기 때문에 잘 고려하여 적용하여야 합니다.

 

 

 

Write Through

데이터베이스와 Cache에 동시에 데이터를 저장하는 전략입니다. 이 방법은 Write Around 전략에서 발생하는 데이터 불일치 문제가 발생하지 않지만 (Cache에도 계속 최신의 데이터가 반영되기 때문에) 매 요청마다 2번의 Write가 발생하게 됨으로써 빈번한 생성, 수정이 발생하는 서비스 영역에서는 성능 이슈(부하)가 가중될 수 있습니다.

  • 이 전략을 Read through 전략과 결합하여 사용한다면 최신 데이터 보장을 통해 Cache의 수정이나 변경을 하지 않으면서도 안정적으로 Read Through 전략의 이점을 끌어올릴 수 있습니다.

 

 

 

Write Back

Cache에 데이터를 저장, 반영하고 일정 주기(스케줄링)의 배치 작업을 통해 데이터를 데이터베이스에 반영하는 전략입니다.

  • 이 방법은 Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스 영역의 경우 (data set이 큰 경우) 적용해볼 수 있는 방법입니다. (데이터베이스를 조회하지 않고 지속적인 접근이 가능하도록 합니다.)
  • 데이터의 불일치나 조회 요청으로 인한 서비스 부하는 발생하지 않으나, In-memory에 데이터가 일정 기간 저장되기 때문에 장애가 발생하였을 때 데이터 손실이 발생할 수 있습니다.
    • 이 또한 Replication이나 Cluster 구조를 적용함으로써 Cache 서비스의 가용성을 높여야만 합니다.

 

이 전략을 통해 새로운 데이터를 추가하거나 변경하는 성능을 끌어올릴 수 있고 또한 읽어오는 데이터의 크기를 의식하지 않을 수 있습니다. 부가적으로 데이터베이스 장애가 발생하더라도 지속적인 서비스를 제공할 수 있도록 보장합니다.

 

 

 

 

참고 자료

 

 

현재 회사에서 운용하고 있는 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를 통해 평균값이나 변동 폭을 나타내는 메트릭으로 변경하여야 합니다.)

 

 

 

참고 링크

대학이라는 울타리에서 나와 첫 직장을 가지고, 또 다른 직장으로 이직하고, 제가 공부했던 방법들을 회고하고 그것을 발표하는.. 인생 사에서 제일 뜻깊을 것 같은 한 해가 지나갔습니다.

올 한 해를 돌아보며 저는 “역동적”이었다고 표현하고 싶습니다. 변화를 위해 생각하고, 행동하고 준비해온 이 기간을 쉽게 잊지 못할 것 같습니다.



나는 얼마나 공부했나?

중점으로 공부했던 것들 : Spring Core, Security, Oauth, Mybatis, JPA, Docker, Kotlin, Redis, Database Concepts, Infra Concepts, Nginx, Prometheus, Grafana, JOOQ

아이디어, 개념, 도입 수준으로 공부했던 것들 : Spring Cloud, Batch, Node.js, Nest.js, Flyway, Kafka, RabbitMQ, Linux, HAProxy, JVM, React.js, AWS Resources, Fast API, Gitlab-ci

보았던 서적 : TDD 실천법과 도구, Clean Code, 자바 최적화, 모던 자바스크립트 핵심 가이드, 자바스크립트 코딩의 기술, 한 권으로 끝내는 Node & Express, 러닝 리액트, 업무에 바로 쓰는 SQL 튜닝, 김상형의 SQL 정복, 그림으로 공부하는 IT 인프라 구조, 그림으로 공부하는 시스템 성능 구조, 누가 IT 시장 취업에 성공하는가, 오늘도 우리는 코딩을 합니다, 데이터 중심 애플리케이션 설계, DDD Start!, 도메인 주도 설계로 시작하는 마이크로서비스 개발, RabbitMQ In Depth..... 등

 

한 주에 최소한 2챕터 정도씩은 보는 것을 목표로 했었고, 개인적으로 모르는 내용이라도 일단 진도를 나가는 편이라 그런지 많은 서적을 읽어볼 수 있었습니다. (그렇기에 깊이가 없어 다시 정독하는 책들도 많습니다.)  

 

 

 

상반기에는 많은 것을 학습하는 것보단 서비스를 개발하는데 기본적인 포인트라고 볼 수 있는 Security, Oauth, Basic Token 등의 인증 인가 방식이나 Persistence Framework (Mybatis, JPA), HTTP와 REST API 설계 방식 등을 학습하는데 집중하였고 이건 첫 회사에 들어가서도 비슷했습니다.

하반기에는 개념을 확장하는데 집중하였기에 대부분의 키워드는 이때 학습하게 된 것 같습니다. 특히 회사를 이직하면서 서버 운영 경험을 쌓아보게 되었고, 이때 Nginx, Linux, infra Concepts, Database Concepts, CI 등을 공부하는데 집중하게 되었습니다.

 

 

또한 회사 사수 님의 노력을 통해 운영 서비스에 모니터링 시스템인 Prometheus, Grafana를 도입하게 되었고 이를 하나의 구축사에 도입해보면서 모니터링 서비스를 구축하는 방법과 매트릭 수집 방식, 정보에 대한 어느 정도 공부하고 이해할 수 있었습니다.

 

 

그 외에도 JOOQ나 Lambda를 통한 Transaction Handler 등 여러 기법을 접해볼 수 있었고 이를 통해 기존에 이해하고 있던 개념을 다시 되새기는 좋은 기회가 되었습니다.

공부했다며 여러 키워드를 나열해두는 것은 학습 수준의 차이가 극명할 수 있기 때문에 개인적으로 좋아하지 않지만 제가 아는 개념의 컨택스트를 넓히는데 노력한 한 해인 것은 확실한 것 같습니다.



내게 뜻 깊었던 활동 : 공유와 상담

첫 직장에 입사하고 나서는 제가 학습했던 것들을 기반으로 공유하는 활동을 시작하게 되었습니다. 이런 저장소를 만들게 된 가장 큰 이유는 백엔드 개발 입문 질문에 대해서 여러 사람들이 국비 학원 커리큘럼이 다라는 식 이라거나 Mybatis만 할줄 알면 된다거나 JSP로 다된다고 하는 등 실제로는 곁가지에 불과한 것들에 대해 집중해서 이야기하고 질문자들의 시간을 버리는 것처럼 보였기 때문입니다. (물론 잘 대답해주시는 분들도 많습니다. ㅎㅎ)

 

 

정말 많은 리소스를 소모하는 활동이였지만 “누구에게는 도움이 되겠지”라고 생각하며 지속할 수 있었습니다. 특히 최근에는 오픈 프로필 채팅을 통해 해당 자료로 큰 도움을 받아 취업할 수 있었다는 연락을 받게 되어 큰 기쁨을 얻기도 했었고 그 덕분에 더욱 열심히 해보려고 노력하고 있습니다.

 

 

이런 지속적인 활동과 개인 공부를 통해 기회가 되었는지 최근에는 아는 개발자 분의 권유를 통해 온라인으로 대학교를 다니는 분들에게 제 공부 방법을 공유하는 시간도 가질 수 있었습니다.

 

 

프로젝트 리뷰 일부 발췌

그 외에도 한 해 동안 총 8분의 국비 수강생, 개발 입문자의 연락을 받아 오픈 채팅 또는 디스코드를 통해 1시간 정도의 상담을 진행해왔고, 간단하게 3분의 개인 프로젝트를 리뷰 해드리기도 하였습니다. 이러한 시간을 통해 많은 즐거움을 느낄 수 있었습니다.



내게 뜻깊었던 활동 : 이력서 첨삭과 모의 면접

한 해 동안 4분의 이력서 첨삭을 도와드렸고, 2분에게 모의 면접을 해드리는 시간을 가지기도 했습니다. 이력서에는 정답이 없기 때문에 제가 지향하는 부분들을 추려서 알려드리고 좋지 않아 보이는 부분을 피드백하는 식으로 진행하였습니다.

TMI 이지만 저도 이력서를 잘 쓰기 위해 많은 노력을 해왔었고 그 덕분인지 아는 분이 만드신 주니어 취업 개발자 강의에서 이력서 모범 사례 파트에 제 이력서가 올라가게 되었습니다.

 

 

 

모의 면접 일부 발췌

모의 면접의 경우에는 채용 공고에 적힌 기술 스택과 본인의 이력서를 기반으로 면접 질문을 만들어서 진행하였고 왜 이런 질문을 했는지 설명하고 피드백 해드림으로써 해당 모의 면접에서 최대한 많은 것들을 가져가시도록 노력하기도 했습니다.



마무리하며

내가 아는 것을 공유하는 것은 정말 기쁜 활동이라고 생각합니다. 이런 활동을 하려고 노력하다 보면 잘못 알고 있었거나 애매하게 알고 있던 것들을 다시 정정하고 보완하는 선 순환을 경험하기도 합니다.


제게 여유 시간이라는 것이 있는 한, 이러한 활동을 지속적으로 하고 싶은 마음이 큽니다. 제가 지향하는 것은 다른 사람들에게 선한 영향력을 끼치는 것이기 때문입니다. 2022년에는 더 좋은 모습으로, 더 많은 경험으로 도움을 바라시는 분들에게 최선의 방법을 알려드릴 수 있는 그런 개발자가 되고 싶습니다.

그리고 2022년도에도 처음 개발 공부를 시작했을 때의 갈급함을 잊지 않고, 부족한 점을 계속 성찰해가며 성장을 멈추지 않는 그런 개발자 되도록 노력하려고 합니다.




부족한 글을 여기까지 봐주신 분들에게 감사의 인사를 드립니다. 새해 복 많이 받으세요!

금일 온라인으로 1시간 40분(Q&A 포함) 정도 진행했던 "(내가) 성장할 수 있었던 공부 방법 발표" 내용을 공유합니다.

개인 학습 방법을 공유하는 것이기 때문에 모두에게 맞지 않을 수 있습니다. 또한 말로써 전달된 부분은 누락되어 있기 때문에 참고해주시기 바랍니다.

 

 

 

 

목차



 

저는 이랬던 사람입니다.

대학 입학 전까지 컴퓨터 공학이라는 것을 몰랐습니다.

  • 고등학생 때에는 수학, 영어보다 문학, 사회, 역사를 좋아했고 고3 때에는 일러스트레이터, 포토샵 등을 이용하는 그래픽스 디자인 작업을 1년 동안 공부하고 자격증을 취득했습니다.
  • 저와 맞지 않아서 진로 고민을 하게되었고 고등학교 선생님을 통해 컴퓨터 학과를 알게되어 제 수시 성적으로 갈 수 있는 수도권 전문대학의 컴퓨터 정보학과로 무작정 입학했습니다.

소프트웨어 개발자라는 직업 또한 몰랐습니다.

  • 학과 강의나 잘 따라가면서 예식장 주차요원, 전산실 계약직 등의 아르바이트를 하며 생활비와 용돈 벌이를 했습니다.
  • 한 학기를 남겨 놓고 공군에 입대하여 청주 17 전비에서 복무하다가 2019년 5월에 전역했습니다.

졸업이 눈앞에 보일 때 취업을 위한 공부를 시작했습니다.

  • 마지막 학기를 한달 남긴 시점까지 물류 센터와 B마트에서 계약직으로 일을 하다가 늦게 취업 고민을 하였습니다.
  • "전공이라도 살려보자" 라는 생각으로 관련 직업 군을 찾아보게 되었고 취업을 위해 개발 공부를 시작하게 되었습니다.

 

 

 

공부, 취업 이력

- 2020년 9 ~ 10월
    - 스프링 기반 API를 만들어보는 1달 짜리 온라인 과정 수강
    - 대학 강의와 병행 (월~일, 하루 14시간 투자)
    - 채용 공고, 면접 자료, 서적에서 키워드 수집 및 학습
- 2020년 11 ~ 12월
    - 대학 생활비 대출을 이용하여 온라인 부트캠프, 멘토링 참여
    - 개인 프로젝트, 대학 강의와 개인 공부 병행 (월~토, 하루 12시간 투자)
    - 채용 공고, 면접 자료, 서적 + 개발 커뮤니티 키워드 수집 및 학습, 블로그 시작
- 2020년 12 ~ 21년 2월
    - IT 회사 지원, 면접 진행
    - 개인 프로젝트 중단, 대학 졸업, 면접 준비, 스터디 진행 (월~토, 하루 10시간 투자)
- 2021년 3 ~ 7월
    - KT 계열 협력사에서 Admin CMS, OTT Service 개발
    - 5월 말부터 이직 준비, 개인 프로젝트, 스터디, 개인 공부 병행 (월~토, 하루 4시간)
    - Unicorn, Solution, PropTech 3개 기업의 상시 채용 지원
- 2021년 8 ~ 2023년 5월 초 퇴사 예정
    - 3개 기업 중 이스트소프트로 이직 후 온보딩 및 업무 진행
    - 12월 중 개인 프로젝트 진행 예정, 스터디, 개인 공부 병행 (월~토, 하루 3시간)
- 2023년 5월 중
    - 카카오 공동체 이직 후 온보딩 및 업무 진행

저는 뒤쳐진 시간을 만회하기 위해 자세한 학습 계획을 세우고 실천하여 유의미한 성과를 거둘 수 있었습니다.
당연히 운도 따라준 것 같고요.

저는 수학이나 영어 등 개발을 하면서 영향을 미치는 영역에 대해서도 오늘 발표를 듣는 분들에 비해 많이 부족합니다. 그렇기에 여러분들이 좋은 방향성을 가지고 집중하여 좋은 이력들을 남겨 정리하신다면 훨씬 좋은 곳에 취업하실 수 있다고 생각합니다.

 

 

개발자 공부 방법, 학습 로드맵이라는 키워드를 검색하면?

이러한 로드맵 이미지에는 개발하면서 알아야 할 모든 주제가 나열되어 있지만 어디까지 알아야 취업할 수 있는지 알기 어렵다.

 

 

 

학습할 것들을 찾는 방법

 

채용 공고를 통해 공부할 키워드를 추출하기

  • 내가 좋아하는, 취업하기 원하는 기업 5~10 곳을 선정하기 (직무는 동일하게 설정)
  • 분야 별 기업을 5~10 곳을 선정하기 (SI, SM, Fintech, E-Commerce, Messenger 등의 분야) 
  • 뽑은 기술 스택을 유사 항목 별로 그룹으로 나누고 중복된 수를 적기
  • 각 그룹 별로 제일 많이 중복되는 것들을 선정하고 연계하여 학습하기


키워드 추출 예시

  • 예시로 사용되는 공고는 카카오, 네이버 스노우, 당근 마켓입니다.

  • 수집한 공고들의 업무 내용, 지원 자격, 우대 사항을 확인하여 학습 키워드를 뽑아내면 됩니다.
  • 우대 사항을 통해 뽑은 키워드는 지원 자격으로 뽑은 키워드보다 우선순위를 미룰 수도 있습니다.



추출한 키워드

  • 추출한 이후 키워드마다 중복된 횟수를 적고 정렬해봅니다. (엑셀, 노션 등을 활용)
  • 이걸 바로 공부하면 될까?
    • 중복도가 높다고 무조건 공부하는 것은 아닙니다.
    • 각 항목을 그룹으로 나누고 그 안에서 1 ~ 2개 정도를 선택해서 장기적으로 학습하는 것이 낫습니다.

 

 

 

각 추출한 키워드 별로 입문/기본 서적을 선택하고 목차를 확인하기

  • 이렇게 키워드를 선정했다고 가정합니다.
    • 이제 각 항목 별로 많이들 추천하는 입문, 기본 서적을 선정해봅니다.
Java 이것이 자바다, 자바의 신, 자바의 정석 중 1택
Spring 스프링 입문을 위한 자바 객체 지향의 원리와 이해, 코드로 배우는 스프링..
RDBMS SQL 첫걸음, 러닝 SQL
NoSQL NoSQL 철저 입문
REST (HTTP) 학교에서 알려주지 않는 17가지 실무 개발 기술, 그림으로 배우는 HTTP..
Docker 시작하세요 도커/쿠버네티스, 15단계로 배우는 도커와 쿠버네티스..
Test Code 자바와 Junit을 활용한 실용주의 단위 테스트, 단위 테스트

 

  • 학습량이 너무 많다고 생각되면 적어도 언어, RDBMS, Network 분야의 기본 서적 1개씩은 보는 것을 추천합니다.
  • 만약 의지력이나 집중력이 부족하거나, 그렇다고 생각하여 서적 등으로 공부하기 어렵다 느껴진다면 유료 인강이나 코멘토의 부트캠프, 멘토링 플랫폼에서 멘토를 구해서 비용을 지불하고 공부하는 것을 추천합니다.

 

  • 처음 학습할 때에는 어떤 것을 공부해야 하는 것인지 분간하기가 어렵습니다. 그렇다면 서적 등을 이용해 공부할 최대한의 범위를 설정하는 것이 낫다고 생각합니다.
  • 목차를 이용하여 학습 흐름을 결정하고 공부하는 것은 의미 없이 소모되는 시간을 줄일 수 있는 방법 중 하나입니다.

 

 

 

공개된 면접 질문들을 통해 중요한 키워드를 식별하기

Google에 개발자 면접 질문으로 검색하면 다양한 Github Repo, Blog가 나옵니다.

이런 자료에는 간단한 꼬리 물기 질문까지 정리된 경우가 있어 개념 학습 시에도 참고할 수 있습니다. 기술 면접을 준비하실 땐 본인의 프로젝트에 대해 어필한 부분 (이력서에 작성한 부분), 특정 라이브러리 사용 부분마다 면접 스크립트를 짜보고, 꼬리 물기를 해보는 것을 추천합니다.



 

지식을 습득하는 방법

 

공식 문서와 블로그를 통해 개념을 습득하기

  • "왜" 사용하는지 구분할 수 있게 된다.
    • 사람들이 어떤 프레임워크, 라이브러리 등을 만났을 때 자주 질문하는 것이 있습니다.
      • 왜 A라는 것을 사용하는 건가요? B라는 것으로도 "할 수" 있는 것이잖아요?
      • 이러한 질문이 나오는 이유들?
        • 해당 프레임워크 또는 라이브러리의 도입 의도를 이해하지 못한 상태
        • 유사한 문제를 해결하는 것들 차이(도입 비용, 도입 시 장, 단점 등)를 고려하지 못하는 상태
    • 문서를 참고하게 되면?
  • 어떻게 사용하는지 알 수 있게 된다.
    • 실무에서 프레임워크, 라이브러리를 사용하는 경우 대부분의 구성 방식, 구현은 공식 문서의 것을 잘 벗어나지 않습니다. (자체 SDK, 다른 프레임워크와의 통합 등이 필요하지 않은 이상.)
    • 각 공식 문서의 Use case, Getting Started, Documentation 항목 등을 살펴보면 각 기능에 대한 설명과 함께 이러한 예시들을 볼 수 있습니다.

  • 빠르게 기술 도입을 할 수 있다.
    • 우리가 쉽게 접할 수 있는 번역된 서적이나 강의 들은 자주 사용되는 프레임워크에 대해서만 정리하거나 또는 Hello World 수준의 내용을 다루는 경우가 많습니다. (생각보다)
    • 이러한 자료들로만 학습해왔고 그것에 익숙해진 상태라면 원서, 공식 문서만 존재하는 기술을 도입할 때에는 익숙하지 않아 어려움을 겪을 수 있습니다. 그렇기에 처음 학습할 때부터 공식 문서를 활용하는 것은 좋은 선택이라고 생각합니다.
    • 출처 : Naver D2 백엔드 개발자를 꿈꾸는 학생 개발자에게

 

 

(작은 것이라도) 유사한 기술들을 비교하기

  • 한 가지 문제를 해결하기 위해서 개발되는 프레임워크나 라이브러리는 다양하고, 주로 사용하던 것이라도 상황에 따라 적용하기가 어려울 수 있습니다.
  • 개발 생산성(기간, 인력), 제공되는 API나 기능, 성능이나 확장성, 유지 보수 고려 등 신규 개발이든 운영 상태던 간에 이러한 고민은 계속될 수밖에 없습니다.
**상황 예시**

1. 유저 트래픽 증가로 인해 인증 API 쪽의 부하가 발생 
2. 기존 모놀리식 구조의 문제점으로 인해 동일한 시스템이 다량 배포되는 중
3. 트래픽이 집중되는 인증 API만 배포할 수 없는 상태이기에 많은 운영 리소스가 소모되고 있음 
4. 이 문제를 개선하기 위해 어떤 기술을 도입해야 하는가? (Cache, Message Queue...)
5. 변경 된 후에도 한계를 봉착한다면? 빈번한 데이터 수정 요청을 Rabbit MQ를 통해 배치로 전송하고 처리하고 있었으나 메세지 처리량보다 트래픽이 더욱 커진다면?
6. Consumer의 추가 배포? 대용량 처리 파이프라인을 구성할 수 있는 Kafka 도입? 기타 등등

이러한 고민을 주니어 개발자부터 한다는 것은 아니지만, 개발 팀 인력 부족, 회의 시에 의견을 제시, 프로젝트를 리드하는 책임을 지게 되었다면 필수적인 역량입니다.

이런 것을 고려하는데 익숙해지기 위해 작은 것부터 비교해 나아가는 것을 추천합니다.

- Java Date or LocalDate
- ModelMapper or MapStruct or Dozer
- ActiveMQ or RabbitMQ or Kafka
- Redis or Memcached

등 다양한 것들을 비교하면서 어느 것을 고려해야 하는지 익숙해지시길 바랍니다.

 

 

개념을 공부할 땐 큰 그림부터 보기

  • 예시 1 : Spring MVC Flow

  • 예시 2 : Database Replication

  • 큰 그림 (청사진)을 보면서 학습하면 전체적인 흐름이 어떤 것인지, 어떤 요소가 추가될 수 있는지 이해하면서 학습할 수 있습니다. (학습한 개념과 엮어진 구성 요소들에 대한 전체적인 이해도가 늘어난다고 생각합니다.)

 

 

내가 학습했던 것을 되새기기

  • 학습한 키워드를 개인 Github, Notion, Blog 등에 정리하고 요약하기
  • 요약한 내용을 읽어보고, 들어보고, 매끄럽게 바꾸기 (기술 면접 대비용)
  • 일정 주기로 전에 정리한 내용을 다시 보기




학습 의지를 잃지 않았던 방법

 

매일 학습 시간을 정하기

  • 집중력을 잃지 않고 학습을 지속할 수 있는 시간을 알아내기
    • 그날의 총시간을 말하는 것이 아니라 한번 시작했을 때 유지 시간을 말하는 것
    • 뽀모도로 테크닉과 같은 도구를 활용해 학습 시간을 설정하는 것도 방법
    • Study buddy GONGPASSU공팟수
  • 휴식 시간을 설정하기 (번아웃 방지, 학습 의지 유지를 위함)
    • 매일 1~2 시간 취미 생활, 친구들과 시간 보내기
    • 매주 하루 정도는 공부하지 않기
    • 개발 생태계에서 공부를 끝낸다 라는 전제는 존재하지 않기 때문에 지속적인 학습이라는 것에 더 중점을 두어야 합니다.

 

 

매주 학습 목표 정하기

  • 내가 학습해야 할 주제를 기준으로 주간 목표를 설정하기
  • 최소한의 목표를 선정하고 진행하면서 분량과 목표의 수를 조절하기

 

 

매번 현실을 마주하기

 

 


끝!


이 글은 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를 사용하는 것이 좋다고 생각한다. 

+ Recent posts