통합테스트나 인수테스트를 위해 @SpringBootTest를 이용하는 테스트 환경에서 테스트들을 격리하는 방법들 중 주로 널리 알려진 것들에 대해 정리하고 TestExecutionListener 사용하여 격리했던 방법을 공유해보고자 한다.
테스트 격리란
테스트 격리란 테스트 순서에 상관없이 독립적으로 실행되며, 결정적으로 동작되는 것을 의미한다.
비결정적인 테스트는 쉽게 말해서 테스트 실행 시 같은 입력값에 대해 항상 같은 출력하지 않는 테스트를 의미한다.
따라서 "결정적으로 동작"의 의미란 멱등성과 비슷하다. 같은 입력 값이면 항상 같은 결과를 반환하는 것을 의미한다.
@SpringBootTest 테스트 격리 방법
@Transactional
첫 번째로 트랜잭션 어노테이션이다. 많이 알려진 내용으로 해당 어노테이션을 테스트 코드에 사용하면 테스트가 종료되고 자동으로 롤백하는 기능을 이용하는 것이다.
test transactions will be automatically rolled back after completion of the test.
하지만 해당 방법은 API 접점에서 검증하는 E2E 테스트를 인수테스트의 의도로 인수테스트 환경을 구축했다면 @Transactional 어노테이션으로는 테스트 격리를 하기 힘들 수도 있다.
인수테스트는 보통 블랙박스 성격의 테스트이므로 WebTestClient 혹은 RestAssured을 이용하는 경우가 많은데 그러려면 @SpringBootTest 어노테이션을 RANDOM_PORT나 DEFINED_PORT를 이용하여 실제 웹 한경을 구성하기 때문이다
따라서 포트 번호를 따로 주어 WebEnvironment를 구성하는 경우 Http client와 테스트 서버는 별도의 스레드에서 수행되기 때문에 자동 롤백이 이루어지지 않는다.
@DirtiesContext
효과적인 테스트 수행을 위해 스프링에서는 context caching 기능을 지원한다. 해당 어노테이션은 테스트와 관련된 ApplicationContext가 Dirty하기 때문에 Context Cache에서 종료되고 삭제되어야 함을 나타내는 Test Annotation이다.
@DirtiesContext 어노테이션을 통해 테스트를 수행하기 전, 수행한 이후, 그리고 테스트의 각 테스트 케이스마다 수행하기 전, 수행한 이후에 context를 다시 생성하도록 지시하여 테스트를 격리할 수 있다.
하지만 매 테스트마다 Application Context를 매번 생성해야 하기 때문에 테스트 속도가 현저히 느려진다.
테스트 코드상으로 매번 삭제
테스트에 필요한 데이터를 JUnit 생명주기인 @BeforeEach, @AfterEach를 활용하여 테스트가 시작되기 전이나 후에 데이터들을 삭제하여 테스트 격리를 하는 방식이다. application context를 매번 띄우는 것보다 낮은 비용이므로 속도 측면에서 효율적이라고 볼 수 있다.
하지만 이 방식의 단점은 생성해야 할 데이터가 많거나, 연관관계를 모를 경우 제대로 데이터를 삭제하지 못하는 문제가 발생할 수 있다.
또한, 테스트 클래스도 길어지기에 가독성도 안 좋을 수 있다.
TRUNCATE를 통한 테이블 초기화
TRUNCATE (DDL)는 DELETE (DML)와 다르게 행마다 락을 걸지 않고, 트랜잭션 로그 공간을 적게 사용하므로 초기화하는 속도가 더 빠르다.
테스트 격리를 위해 TRUNCATE 하는 방법은 보통 크게 2가지이다.
@Sql 사용
코드에서 truncate 쿼리 사용
@Sql
@Sql은 Spring Boot에서 제공하는 애노테이션이며, 클래스 테스트가 실행되기 전 @Sql이 가리키는 경로에 있는 SQL문이 먼저 실행되게 된다. 이 SQL 파일 안에 TRUNCATE관련 내용을 넣어두는 방식을 통해 DB 테스트 격리를 할 수 있다.
하지만 테이블이 추가/변경/삭제될 때마다 수정이 필요하므로 관리가 필요하다.
코드에서 테이블 TRUNCATE 쿼리 사용
JPA 사용 시 EntityManager를 이용하여 혹은 jpa를 사용하지 않는 경우 DataSource를 이용하여 테이블 이름을 조회하여 각 테이블들을 truncate 시켜주는 쿼리를 수행하는 것이다. 해당 방법을 사용하면 테이블 상태에 의존하지 않는 초기화 환경 구축 가능하다.
해당 방법을 사용해서 테스트 격리를 적용해 보자.
코드에서 테이블TRUNCATE 쿼리 사용 - TestExecutionListener 이용하기
TestExecutionListener
TestExecutionListener는 스프링에서 제공하는 테스트 실행 주기에서 콜백 메서드를 통해 사용자가 추가 작업을 수행하도록 지원하는 인터페이스이다.
사실 위에서 살펴본 transactional test의 기본 롤백, @DirtiesContext, @Sql의 기능들 모두 해당 TestExecutionListener 인터페이스를 상속받은 기본 리스너를 통해 스프링에서 제공하고 있는 것이다.
자주 사용하는 데이터나 값을 미리 저장해 놓는 임시 저장소 서버나 데이터베이스에 가해지는 부하를 줄이고 성능을 높이기 위해 사용한다. 자주 변경되고 삭제되는 데이터에 적용하면 오히려 성능 저하를 일으킬 수 있다.
종류
Local Cache
서버 내부에 저장한다.
서버 내부에서 동작하기 때문에 속도가 빠르다.
서버 Resource(Memory, Disk)를 사용한다.
서버 간에 데이터를 공유할 수 없다.
분산 서버인 경우 데이터 정합성이 깨지는 문제가 발생할 수 있다.
종류
Ehcache, Caffeine
Global Cache
Cache Server를 별도로 사용한다.
서버 간에 데이터를 공유할 수 있다.
네트워크 트래픽이 발생하기 때문에 Local Cache보다 속도가 느리다.
종류
Redis, Memcached
CDN (Content Delivery Network), Web Caching
프록시 서버(Proxy Server)를 물리적으로 분산하여 사용자 위치를 기준으로 가장 가까운 프록시 서버에서 캐싱되어 있는 웹 콘텐츠를 제공함으로써 웹 페이지 로드 속도를 높이는 서버 네트워크
트래픽이 각 서버로 분산된다.
고려 사항
일반적으로 캐시는 메모리(RAM)에 저장되기 때문에 무분별하게 저장해버리면 용량 부족 현상이 발생하여 시스템이 다운될 수 있다.
캐시 서버에 장애가 발생하면 트래픽이 데이터베이스로 몰리게 되어 과부하로 인해 데이터베이스가 다운될 수 있다. 캐시 서버가 장애로부터 복구되는 동안 데이터베이스가 버틸 수 있도록 대비해야 한다. (캐시 서버를 계층적으로 구축하기도 한다)
캐시를 언제까지 유지시킬 건지 Expire Time 또는 Time-To-Live(TTL)정책과 삭제 알고리즘을 고려해야 한다.
Cache Stampede: 부하가 높은 상태에서 캐시가 만료되어 순간적으로 데이터베이스 읽기 작업과 캐시 쓰기 작업이 중복으로 발생하는 현상
Hot Keys: 하나의 키에 읽기가 집중될 때도 성능이 떨어질 수 있습니다. 위 글에서는 그 대책으로 키 이름 앞에 Prefix를 붙여 여러 복제본을 만든 뒤, 그 Prefix가 붙은 복제본에 랜덤으로 읽기를 분산시키는 방법을 소개하고 있습니다.
Compression: 크기가 큰 데이터를 레디스에 저장할 때도 성능 저하가 발생할 수 있습니다. 이때 적절한 압축을 적용하는 것만으로도 속도와 메모리 사용량에서 큰 이득을 볼 수 있습니다. 적절한 압축 방법과 비율은 상황과 환경에 따라 다를 수 있기 때문에, 이를 적용할 때는 사용 환경을 재현한 벤치마크 테스트가 필수입니다.
만료 주기가 너무 길면 메모리 부족 현상이 발생하거나 데이터 정합성이 깨질 수 있다.
중요한 정보나 민감한 정보는 저장하지 않는다.
Local Cache VS Global Cache
데이터 정합성이 깨져도 비즈니스에 영향이 없는 부분은 Local Cache, 데이터 정합성이 중요한 부분에는 Global Cache를 선택할 수 있다.
Cloud 환경(Docker, AWS EC2 등)이라면 JVM Memory를 사용하는 Cache는 적합하지 않을 수 있다.
Typical caching setupLocal Cache를 첫 번째 수준 Cache로 사용하고 Global Cache를 두 번째 수준 Cache로 사용할 수 있다.
Local Cache는 Memory를, Global Cache는 네트워크 트래픽을 많이 사용하는 점을 고려하여 하나 이상을 결합해서 사용하면 효율성이 높아질 수 있다.
캐시(Cache) 전략
캐싱 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술이다.
일반적으로 캐시(cache)는 메모리(RAM)를 사용하기 때문에 데이터베이스 보다 훨씬 빠르게 데이터를 응답할 수 있어 이용자에게 빠르게 서비스를 제공할 수 있다. 하지만 기본적으로 RAM의 용량은 커봐야 16 ~ 32G 정도라, 데이터를 모두 캐시에 저장해버리면 용량 부족 현상이 일어나 시스템이 다운될 수 있다.
따라서 어느 종류의 데이터를 캐시에 저장할지, 얼만큼 데이터를 캐시에 저장할지, 얼마동안 오래된 데이터를 캐시에서 제거하는지에 대한 '지침 전략' 을 숙지할 필요가 있다.
Tip
캐시를 효율적으로 이용하기 위해서는 캐시에 저장할 데이터 특성도 고려해야 한다. 예를 들어 자주 조회되는 데이터, 결괏값이 자주 변동되지 않고 일정한 데이터, 조회하는데 연산이 필요한 데이터를 캐싱해 두면 좋다.
노드나 스프링으로 서버를 만드는데 있어, 자신의 프로젝트에 캐시 메모리를 적용하려 할 때, 요청(request)이 라우터에 오면 어느 상황에서 어떤 식으로 캐시를 사용할 것인지 보다 효율적으로 서비스가 빠릿빠릿하게 돌아가도록 설계하는 데 있어 참고가 되기를 바란다.
참고로 들어가기 앞서 선수 지식이 필요한데, 바로 cache hit 과 cache miss이다.
cache hit : 캐시 스토어(redis)에 데이터가 있을 경우 바로 가져옴 (빠름)
cache miss : 캐시 스토어(redis)에 데이터가 없을 경우 어쩔 수 없이 DB에서 가져옴 (느림)
캐싱 전략 패턴 종류
캐시를 이용하게 되면 반드시 닥쳐오는 문제점이 있는데 바로 데이터 정합성 문제이다. 데이터 정합성이란, 어느 한 데이터가 캐시(Cache Store)와 데이터베이스(Data Store) 이 두 곳에서 같은 데이터임에도 불구하고 데이터 정보값이 서로 다른 현상을 말한다.
쉽게 말하면, 캐시에는 어떤 게시글의 좋아요 개수가 10개로 저장되어 있는데 데이터베이스에는 7개로 저장되어 있을 경우 정보 불일치가 발생하게 된다. 이전에는 그냥 DB에서 데이터 조회와 작성을 처리하였기 때문에 데이터 정합성 문제가 나타나지 않았지만, 캐시라는 또 다른 데이터 저장소를 이용하기 때문에, 결국 같은 종류의 데이터라도 두 저장소에서 저장된 값이 서로 다를 수 있는 현상이 일어날 수밖에 없는 것이다.
따라서 적절한 캐시 읽기 전략(Read Cache Strategy)과 캐시 쓰기 전략(Write Cache Strategy)을 통해 캐시와 DB 간의 데이터 불일치 문제를 극복하면서도 빠른 성능을 잃지 않게 하기 위해 고심히 연구를 할 필요가 있다.
캐시 읽기 전략 (Read Cache Strategy)
Look Aside 패턴
Cache Aside 패턴이라고도 불림.
데이터를 찾을 때 우선 캐시에 저장된 데이터가 있는지 우선적으로 확인하는 전략. 만일 캐시에 데이터가 없으면 DB에서 조회함.
반복적인 읽기가 많은 호출에 적합.
캐시와 DB가 분리되어 가용되기 때문에 원하는 데이터만 별도로 구성하여 캐시에 저장
캐시와 DB가 분리되어 가용되기 때문에 캐시 장애 대비 구성이 되어있음. 만일 redis가 다운되더라도 DB에서 데이터를 가져올 수 있어 서비스 자체는 문제가 없음.
대신에 캐시에 붙어있던 connection이 많았다면, redis가 다운된 순간순간적으로 DB로 몰려서 부하 발생.
Look Asdie Cache 패턴은 애플리케이션에서 캐싱을 이용할 때 일반적으로 사용되는 기본적인 캐시 전략이다.
이 방식은 캐시에 장애가 발생하더라도 DB에 요청을 전달함으로써 캐시 장애로 인한 서비스 문제는 대비할 수 있지만, Cache Store와 Data Store(DB) 간 정합성 유지 문제가 발생할 수 있으며, 초기 조회 시 무조건 Data Store를 호출해야 하므로 단건 호출 빈도가 높은 서비스에 적합하지 않다. 대신 반복적으로 동일 쿼리를 수행하는 서비스에 적합한 아키텍처이다.
이런 경우 DB에서 캐시로 데이터를 미리 넣어주는 작업을 하기도 하는데 이를 Cache Warming이라고 합니다.
Info
[ Cache Warming ] 미리 cache로 db의 데이터를 밀어 넣어두는 작업을 의미한다. 이 작업을 수행하지 않으면 서비스 초기에 트래픽 급증 시 대량의 cache miss 가 발생하여 데이터베이스 부하가 급증할 수 있다. (Thundering Herd) 다만, 캐시 자체는 용량이 적어 무한정으로 데이터를 들고 있을 수는 없어 일정시간이 지나면 expire 되는데, 그러면 다시 Thundering Herd가 발생될 수 있기 때문에 캐시의 TTL을 잘 조정할 필요가 있다. (뒤에서 자세히 설명)
Tip
Thundering Herd는 모든 지점에서 발생되는 것은 아니고, 서비스의 첫 페이지와 같은 대부분의 조회가 몰리는 지점에서 주로 발생된다고 보면 된다.
Read Through 패턴
캐시에서만 데이터를 읽어오는 전략 (inline cache)
Look Aside와 비슷하지만 데이터 동기화를 라이브러리 또는 캐시 제공자에게 위임하는 방식이라는 차이가 있음.
따라서 데이터를 조회하는 데 있어 전체적으로 속도가 느림.
또한 데이터 조회를 전적으로 캐시에만 의지하므로, redis가 다운될 경우 서비스 이용에 차질이 생길 수 있음.
대신에 캐시와 DB 간의 데이터 동기화가 항상 이루어져 데이터 정합성 문제에서 벗어날 수 있음
역시 읽기가 많은 워크로드에 적합
Read Through 방식은 Cache Aside 방식과 비슷하지만, Cache Store에 저장하는 주체가 Server이냐 또는 Data Store 자체이냐에서 차이점이 있다.
이 방식은 직접적인 데이터베이스 접근을 최소화하고 Read에 대한 소모되는 자원을 최소화할 수 있다.
하지만 캐시에 문제가 발생하였을 경우 이는 바로 서비스 전체 중단으로 빠질 수 있다. 그렇기 때문에 redis과 같은 구성 요소를 Replication 또는 Cluster로 구성하여 가용성을 높여야 한다.
Tip
이 방식 또한 서비스 운영 초반에 cache warming을 수행하는 것이 좋다.
캐시 쓰기 전략 (Write Cache Strategy)
Write Back 패턴
Write Behind 패턴 이라고도 불림.
캐시와 DB 동기화를 비동 기하기 때문에 동기화 과정이 생략
데이터를 저장할 때 DB에 바로 쿼리하지않고, 캐시에 모아서 일정 주기 배치 작업을 통해 DB에 반영
캐시에 모아놨다가 DB에 쓰기 때문에 쓰기 쿼리 회수 비용과 부하를 줄일 수 있음
Write가 빈번하면서 Read를 하는데 많은 양의 Resource가 소모되는 서비스에 적합
데이터 정합성 확보
자주 사용되지 않는 불필요한 리소스 저장.
캐시에서 오류가 발생하면 데이터를 영구 소실함.
Write Back 방식은 데이터를 저장할때 DB가 아닌 먼저 캐시에 저장하여 모아놓았다가 특정 시점마다 DB로 쓰는 방식으로 캐시가 일종의 Queue 역할을 겸하게 된다.
캐시에 데이터를 모았다가 한 번에 DB에 저장하기 때문에 DB 쓰기 횟수 비용과 부하를 줄일 수 있지만, 데이터를 옮기기 전에 캐시 장애가 발생하면 데이터 유실이 발생할 수 있다는 단점이 존재한다. 하지만 오히려 반대로 데이터베이스에 장애가 발생하더라도 지속적인 서비스를 제공할 수 있도록 보장하기도 한다.
Tip
이 전략 또한 캐시에 Replication이나 Cluster 구조를 적용함으로써 Cache 서비스의 가용성을 높이는 것이 좋으며, 캐시 읽기 전략인 Read-Through와 결합하면 가장 최근에 업데이트된 데이터를 항상 캐시에서 사용할 수 있는 혼합 워크로드에 적합하다.
Write Through 패턴
데이터베이스와 Cache에 동시에 데이터를 저장하는 전략
데이터를 저장할 때 먼저 캐시에 저장한 다음 바로 DB에 저장 (모아놓았다가 나중에 저장이 아닌 바로 저장)
Read Through와 마찬가지로 DB 동기화 작업을 캐시에게 위임
DB와 캐시가 항상 동기화되어 있어, 캐시의 데이터는 항상 최신 상태로 유지
캐시와 백업 저장소에 업데이트를 같이 하여 데이터 일관성을 유지할 수 있어서 안정적
데이터 유실이 발생하면 안 되는 상황에 적합
자주 사용되지 않는 불필요한 리소스 저장.
매 요청마다 두 번의 Write가 발생하게 됨으로써 빈번한 생성, 수정이 발생하는 서비스에서는 성능 이슈 발생
기억장치 속도가 느릴 경우, 데이터를 기록할 때 CPU가 대기하는 시간이 필요하기 때문에 성능 감소
Write Through 패턴은 Cache Store에도 반영하고 Data Store에도 동시에 반영하는 방식이다. (Write Back은 일정 시간을 두고 나중에 한꺼번에 저장)
그래서 항상 동기화가 되어 있어 항상 최신정보를 가지고 있다는 장점이 있다.
하지만 결국 저장할 때마다 2단계 과정을 거쳐 치기 때문에 상대적으로 느리며, 무조건 일단 Cache Store에 저장하기 때문에 캐시에 넣은 데이터를 저장만 하고 사용하지 않을 가능성이 있어서 리소스 낭비 가능성이 있다.
Tip
write throuth 패턴과 write back 패턴 둘 다 모두 자주 사용되지 않는 데이터가 저장되어 리소스 낭비가 발생되는 문제점을 안고 있기 때문에, 이를 해결하기 위해 TTL을 꼭 사용하여 사용되지 않는 데이터를 반드시 삭제해야 한다. (expire 명령어)
Tip
Write-Through 패턴과 Read-Through 패턴을 함께 사용하면, 캐시의 최신 데이터 유지와 더불어 정합성 이점을 얻을 수 있다. 대표적인 예로 AWS의 DynamoDB Accelerator(DAX)가 있다. DAX 패턴을 통해 DynamoDB에 대한 읽기 및 쓰기를 효율적으로 수행할 수 있다.
Write Around 패턴
Write Through 보다 훨씬 빠름
모든 데이터는 DB에 저장 (캐시를 갱신하지 않음)
Cache miss가 발생하는 경우에만 DB와 캐시에도 데이터를 저장
따라서 캐시와 DB 내의 데이터가 다를 수 있음 (데이터 불일치)
Write Around 패턴은 속도가 빠르지만, cache miss가 발생하기 전에 데이터베이스에 저장된 데이터가 수정되었을 때, 사용자가 조회하는 cache와 데이터베이스 간의 데이터 불일치가 발생하게 된다.
따라서 데이터베이스에 저장된 데이터가 수정, 삭제될 때마다, Cache 또한 삭제하거나 변경해야 하며, Cache의 expire를 짧게 조정하는 식으로 대처해야 한다.
Tip
Write Around 패턴은 주로 Look aside, Read through와 결합해서 사용된다. 데이터가 한 번 쓰이고, 덜 자주 읽히거나 읽지 않는 상황에서 좋은 성능을 제공한다.
기타
Refresh Ahead
자주 사용되는 데이터를 캐시 만료 전에 미리 TTL (Expire time)을 갱신합니다.
캐시 미스 발생을 최소화할 수 있지만 Warm Up 작업과 마찬가지로 자주 사용되는 데이터를 잘 예측해야 합니다.
캐시 읽기 + 쓰기 전략 조합
Look Aside + Write Around 조합
가장 일반적으로 자주 쓰이는 조합
Read Through + Write Around 조합
항상 DB에 쓰고, 캐시에서 읽을 때 항상 DB에서 먼저 읽어오므로 데이터 정합성 이슈에 대한 완벽한 안전장치를 구성할 수 있음
Read Through + Write Through 조합
데이터를 쓸 때 항상 캐시에 먼저 쓰므로, 읽어올때 최신 캐시 데이터 보장
데이터를 쓸때 항상 캐시에서 DB로 보내므로, 데이터 정합성 보장
캐시 저장 방식 지침
캐시 솔루션은 자주 사용되면서 자주 변경되지 않는 데이터의 경우 캐시 서버에 적용하여 반영할 경우 높은 성능 향상을 이뤄낼 수 있다. 이를 Cache Hit Rating이라고 한다. 일반적으로 캐시는 메모리에 저장되는 형태를 선호한다.
메모리 저장소로는 대표적으로 Redis와 MemCached가 있으며 이와 같은 솔루션은 메모리를 1차 저장소로 사용하기 때문에 디스크와 달리 제약적인 저장 공간을 사용하게 된다. 많아야 수십 기가 정도의 저장소를 보유하게 되며, 이는 결국 자주 사용되는 데이터를 어떻게 뽑아 캐시에 저장하고 자주 사용하지 않는 데이터를 어떻게 제거해 나갈 것이냐를 지속적으로 고민해야 할 필요성이 있다.
따라서 캐시를 저장하는 시점은 자주 사용되며 자주 변경되지 않는 데이터를 기준으로 하는 것이 좋다.
또한 한 가지 고민해야 할 사항은 캐시 솔루션은 언제든지 데이터가 날아갈 수 있는 휘발성을 기본으로 한다는 점이다.
이는 데이터를 주기적으로 디스크에 저장함으로서 어느 정도 해결을 볼 수는 있지만, 실시간으로 모든 데이터를 디스크에 저장할 경우 성능 저하를 일으킬 수 있어 어느 정도 데이터 수집과 저장 주기를 가지도록 설계해야 된다.
즉 데이터의 유실 또는 정합성이 일정 부분 깨질 수 있다는 점을 항상 고려해야 한다.
따라서 캐시에 저장되는 데이터는 중요한 정보, 민감 정보 등은 저장하지 않는 것이 좋으며, 캐시 솔루션이 장애가 발생했을 경우 적절한 대응방안을 모색해 두는 것이 바람직하다. (TimeOut, 데이터베이스 조회 병행 등)
[ 파레토 법칙 - 8:2 법칙 ] 파레토 법칙이란 전체 결과의 80%가 전체 원인의 20%에서 일어나는 현상을 가리킨다. 서비스에 빗대어 표현하자면 80%의 활동을 20%의 유저가 하기 때문에 20%의 데이터만 캐시 해도 서비스 대부분의 데이터를 커버할 수 있게 된다는 말이다. 즉, 캐시에 모든 데이터를 저장할 필요 없이 "파레토 법칙"에 따라 일부만 저장해도 대부분의 데이터를 커버할 수 있다는 저장 지침이다.
캐시 제거 방식 지침
캐시 데이터의 경우 캐시 서버에만 단독으로 저장되는 경우도 있지만, 대부분 영구 저장소에 저장된 데이터의 복사본으로 동작하는 경우가 많다. 이는 2차 저장소(영구 저장소)에 저장되어 있는 데이터와 캐시 솔루션의 데이터를 동기화하는 작업이 반드시 필요함을 의미하며, 개발 과정에 고려사항이 늘어난다는 점을 반드시 기억해야 한다.
예를 들어 캐시 서버와 데이터베이스에 저장되는 데이터의 commit 시점에 대한 고려 등이 예가 될 수 있다.
캐시의 만료 정책이 제대로 구현되지 않은 경우 클라이언트는 데이터가 변경되었음에도 오래된 정보가 캐싱되어있어 오래된 정보를 사용할 수 있다는 문제점이 발생한다.
따라서 캐시를 구성할 때 기본 만료 정책을 설정해야 한다. 캐시 된 데이터가 기간 만료 되면 캐시에서 데이터가 제거되고, 응용 프로그램은 원래 데이터 저장소에서 데이터를 검색해야 한다. 그래서 캐시 만료 주기가 너무 짧으면 데이터는 너무 빨리 제거되고 캐시를 사용하는 이점은 줄어든다. 반대로 너무 기간이 길면 데이터가 변경될 가능성과 메모리 부족 현상이 발생하거나, 자주 사용되어야 하는 데이터가 제거되는 등의 역효과를 나타낼 수도 있다.
Cache Stampede 현상
대규모 트래픽 환경에서 TTL 값이 너무 작게 설정하면 cache stampede 현상이 발생할 가능성이 있다.
캐시 공간은 한정되어 있으므로 저장된 데이터에 만료시간(TTL)을 정하는 것이 보통인데, 해당 데이터에 계속 읽기 요청이 들어오고 있을 때 캐시 만료시간이 닥치면 순간적으로 DB로 가서 찾게 되는 duplicate read 가 발생한다.
또 읽어온 값을 각 각 redis에 쓰는 duplicate write 도 발생되어, 처리량도 다 같이 느려질 뿐 아니라 불필요한 작업이 굉장히 늘어나 요청량 폭주로 장애로 이어질 가능성 도 있다.
PER(Probablistic Early Recomputation)
이 현상을 해결하기 위해 PER 알고리즘을 도입할 수 있습니다. 이 알고리즘은 키의 TTL이 실제로 만료되기 전에 일정 확률로 캐시를 갱신하는 방법입니다.
데이터베이스에서 키가 완전히 만료되기 전에 데이터를 먼저 읽어오게 함으로써 Cache Stampede 현상을 막을 수 있습니다.
Debouncing
여러 번 반복되는 이벤트 중 마지막 이벤트만 실행하기
캐시 공유 방식 지침
캐시는 애플리케이션의 여러 인스턴스에서 공유하도록 설계된다. 그래서 각 애플리케이션 인스턴스가 캐시에서 데이터를 읽고 수정할 수 있다. 따라서 어느 한 애플리케이션이 캐시에 보유하는 데이터를 수정해야 하는 경우, 애플리케이션의 한 인스턴스가 만드는 업데이트가 다른 인스턴스가 만든 변경을 덮어쓰지 않도록 해야 한다.
그렇지 않으면 데이터 정합성 문제가 발생하기 때문이다. (각 애플리케이션마다 표시되는 개수가 달라지는 현상)
데이터의 충돌을 방지하기 위해 다음과 같은 애플리케이션 개발 방식을 취해야 한다.
먼저, 캐시 데이터를 변경하기 직전에 데이터가 검색된 이후 변경되지 않았는지 일일이 확인하는 방법이다. 변경되지 않았다면 즉시 업데이트하고 변경되었다면 업데이트 여부를 애플리케이션 레벨에서 결정하도록 수정해야 한다. 이와 같은 방식은 업데이트가 드물고 충돌이 발생하지 않는 상황에 적용하기 용이하다.
두 번째로, 캐시 데이터를 업데이트하기 전에 Lock을 잡는 방식이다. 이와 같은 경우 조회성 업무를 처리하는 서비스에 Lock으로 인한 대기현상이 발생한다. 이 방식은 데이터의 사이즈가 작아 빠르게 업데이트가 가능한 업무와 빈번한 업데이트가 발생하는 상황에 적용하기 용이하다.
캐시 가용성 지침
캐시를 구성하는 목적은 빠른 성능 확보와 데이터 전달에 있으며, 데이터의 영속성을 보장하기 위함이 아니라는 점을 기억하고 설계해야 한다. 데이터의 영속성은 기존 데이터 스토어에 위임하고, 캐시는 데이터 읽기에 집중하는 것이 성능 확보의 지침 사항이 될 수 있다.
또한, 캐시 서버가 장애로 인해 다운되었을 경우나 서비스가 불가능할 경우에도 지속적인 서비스가 가능해야 한다. 이는 캐시에 저장되는 데이터는 결국 기존 영구 데이터 스토어에 동일하게 저장되고 유지된다는 점을 뒷받침 하는 설계방식이다. (Write Through)
즉, 캐시 서버가 장애로부터 복구되는 동안 성능상의 지연은 발생할 수 있지만, 서비스가 불가능한 상태가 되지 않도록 고려해야 한다는 말이다.
이상훈 님은 수천, 수만의 동시 요청을 처리하는 서버를 구현하기 위한 다양한 기술 중, 전통적인 웹 방식(thread per request)부터 시작해 리액티브 프로그래밍, 그중에서 코틀린의 코루틴과 JDK 21에서 정식 릴리즈된 버추얼 스레드를 중점적으로 기술의 차이와 통합 방법에 대해 설명해 주셨다.
코루틴
핵심 내용 중 하나는 Kotlin 코루틴의 작동 방식을 이해하는 것이었다. 다수의 코루틴은 플랫폼 스레드에 매핑되어 사용되기 때문에 매우 가벼워서 기존 스레드보다 더 효율적이다. 그러나 코루틴은 여전히 플랫폼 스레드에서 실행되므로 결국 스레드가 블로킹되는 환경에서 성능이 저하될 수 있다.
버츄얼 스레드
그다음으로는 버츄얼 스레드를 설명해 주셨는데 해당 키워드를 이 강의를 통해 처음 알게 되었다. 버츄얼 스레드는 jdk 21에 릴리즈 된 기능으로 코루틴과 유사하게 JVM 레벨의 경량 스레드로 다수의 버츄얼 스레드가 캐리어 스레드에 매핑되어 사용된다는 것이다. 따라서 코루틴과 다르게 전통적인 동기식으로 코드 작성이 가능하며 논블로킹 환경의 코루틴 성능과 비슷하다는 것을 알았다. 하지만 아직 성능을 100% 내기는 어려운 환경이므로 리서치가 필수적으로 보인다.
코루틴과 버츄얼 스레드의 통합
이뿐만 아니라, 코루틴의 환경과 버츄얼 스레드를 통합하여 사용하면 코루틴의 단점인 블로킹 상황의 성능 하락 이슈를 버츄얼 스레드가 해결 가능할 것으로 보이며 상호운용을 통해 서로의 단점을 해결할 수 있어 보인다는 말씀도 주셨는데 굉장히 인상 깊었다.
특히 이번 스프링 캠프에서 전반적으로 코루틴에 대한 이야기가 꽤 많았다. 커지는 서비스에 비례하는 요청량, 다수의 MSA로 분리되는 서비스들, 길어지는 네트워크 구간을 해결하기 위해 모두 동시성 기법을 많이 채택하여 사용하는 것 같아 코루틴에 관심이 많이 가지게 되었다.
코루틴은 다음 포스팅을 통해 살펴보며 프로젝트에 사용해 볼 생각이다.
2.Spring Coroutine in action (최진영 님)
최진영 님은 spring에서 kotlin coroutine 활용에 초첨을 맞추어, 비동기 프로그래밍과 coroutine의 동작구조부터 spring 프레임워크에서의 실제 적용을 중점적으로 이야기해 주셨다.
코루틴과 구조적 프로그래밍
기존의 비동기 코드는 새 스레드의 실행이 새로운 실행 흐름으로 넘어가므로 흐름의 제어가 되고 있지 않기 때문에 이러한 관점에서 구조적 프로그래밍이라고 볼 수 없다는 것이다.
하지만 비동기를 사용하지 않을 수 없는 것은 모두 다 알 것이다.
코루틴은 각 스레드의 실행 후 실행한 부모 스레드의 흐름으로 돌아오게끔 보장하여 Structured Concurrency로 인해 코루틴이 손실되거나 누수되지 않음 등 코루틴의 전반적인 내용들은 굉장히 상세하게 말씀 주셨다.
해당 강의는 코루틴에 대해 공부할 때 한번 더 자세히 파악해보려고 한다.
3. 왜 나는 테스트를 작성하기 싫을까? (조성아 님)
조성아 님은 정말 직관적인 강의 제목으로 이론적이거나 추상적인 테스트 이야기 대신에, 간단한 예제와 실제 경험을 토대로 빠르게 작성하고 피드백을 받을 수 있는 테스트에 대해 얘기해 주셨다. 특히 픽스쳐 몽키란 테스트 관련 오픈소스 라이브러리를 개발하신 분으로 픽스쳐 몽키를 사용해 본 적 있던 나에게는 내적친밀감을 주셨다.
테스트 코드를 작성하기 싫은 이유
특히 요즘은 테스트 코드에 중요성에 대해 모두 다 알고 있는 만큼 왜 작성하기 싫은지에 대해 아주 간결하게 말씀 주시는 부분이 인상 깊었다. 테스트 코드란 무안단물이 아니며 테스트 코드를 작성하기 싫은 이유는 비용이 이득보다 크기 때문이라고 하셨다. 테스트 코드의 비용이란 작성 비용과 유지 비용이 있는데 테스트에 많은 기대를 하면 그게 모두 비용이 되며 각 테스트 단계마다 기대하는 정도가 다르기 때문이라고 하셨다.
비용을 결정하는 요소
특히 가장 공감이 됐던 부분은 테스트 코드 작성 비용을 줄일 수 있는 부분을 말씀 주셨는데 그게 바로 given과 then이었다. 필요하지 않은 복잡한 테스트 작성(given)많은 검증(then)하려 하는 부분들을 줄여야 한다고 말씀 주셨는데 통합테스트와 인수테스트에 무거운 given, then 공감하고 있던 나에게 의미가 깊던 강의였다.
물론 각 프로젝트와 조직에 맞춰서 적절한 테스트 비용을 결정하는 것이 가장 좋은 방법일 것 같다. 끝으로 픽스쳐 몽키의 유용성에 대해서도 굉장히 많이 말씀 주셨는데 한번 사용해 볼 때 굉장히 간단히 기능들을 사용해 보았으나 꼭 필요한가? 란 생각이 들던 나에게 다시 한번 해당 라이브러리의 자세히 기능들을 살펴봐야겠다고 생각이 들었던 강의였다.
4. 실전! MSA 개발 가이드 (김용욱 님)
김영욱 님은 마이크로서비스를 개발할 때 가장 어려워하는 부분인 서비스 별로 데이터베이스를 분리하여 API로 대체한다는 점에 걱정하는 부분과 실전에서 마이크로서비스를 개발할 때의 구현 이슈를 소개하고, 실전에서는 어떻게 풀어내는지 소개해주셨다.
API로 속도가 나올까??
서비스가 api로 데이터를 많이 조회하고 많이 조합할 때 속도 저하가 일어날 수 있다. 그러나 이런 부분도 데이터의 성격을 데이터의 참조빈도와 변경 빈도 를 확인해보아야 한다. 참조 빈도가 높고, 변경 빈도가 높다면 매우 다루기 까다롭다. 하지만 이런 유형의 데이터의 종류는 다행히 많지 않으며 잘 알려진 분야이기에 솔루션도 많다
ex) 로그인 상태 정보 -> JWT 등을 이용
참조 빈도가 높고, 변경 빈도가 낮다면 다양한 방법을 통해 튜닝을 고려해볼 수 있다.
데이터 복제
동기화 하는 부분은 물론 단점이다.
하지만 “필요한 부분”만 복사하는 방향은 괜찮을 수 있다.
원본 소스의 서비스가 다운되어도 문제없기 때문이다.
모델링 변경
일괄 조회
n+1 problem 같은 경우
api 를 매번 조회 하지 말아야 한다.
마치 in query 같은 경우
병렬 조회?
대부분 안티 패턴이다.
순간적으로 큰 부하가 생길 수 있다.
꼭 필요한 경우에만, “일괄 조회”를 병렬로 실행해야 한다.
로컬 캐시
ex) eh cache
실질적 네트워크 호출을 줄여주기 때문에 성능적으로 좋아지는 경우 많다.
사이즈는 작게 유지하는게 좋고 모니터링 하는게 좋다.
로컬 캐시는 동기화하지 않는다.
안티 패턴이다.
노드가 늘어날수록 동기화의 효율이 적기 때문이다.
트랜잭션 없이 정합성이 보장될까??
마찬가지로 API를 사용하는 경우 원자성과 독립성이 보장이 안된다. - 원자성: db rollback을 못함 - 독립성: read committed이 안되고 read uncommited 수준으로 떨어진다.
원자성 보완이 필요한 경우
DB 롤백이 불가능하기 때문에 원자성이 보장되지 않는다.
ex) 만약 A 서비스에 쓰기 요청에 B서비스의 쓰기 요청이 의존되는 경우
원칙적으로는 쓰기에 실패하면 타 서비스에서 커밋된 데이터를 API로 직접 삭제하고, 로컬에 생성된 데이터는 롤백하여 직접 처리해야 한다.
하지만 만약 B서비스의 데이터 삭제하다 실패하면?
그럼 A 서비스에서는 롤백되기 때문에 정합성 깨진다.
API Retry??
어차피 재시도를 바로 해도 이미 안될 가능성이 높다.
안티 패턴
따라서 차라리 이벤트의 재시도하는 방식이 나을 수 있다.
이벤트는 무조건 전달 보장한다.
간혹 여러번 전달될수도 있을 수 있다.
그러므로 여러번 이벤트가 일어나도 결과는 동일하도록 구현해야한다.
결국 네트워크로 쓰기 작업을 하면 멱등성을 보장해야한다.
긴 TX 나누기
실패해도 전체를 취소할 필요가없다면 이벤트로 분리한다.
취소할 수 없는 쓰기는 이벤트로 분리한다.
역할 분리
다른 서비스들이 각자 알아서 하는 경우가 좋을수도 있다.
책임 분리, 의존 분리
모델링 변경
고객 <> 상담
상담 유의사항 속성이 필요하여 고객 참고 정보 테이블이 필요한 상황
해당 테이블을 고객 디비에 배치하는 것보단, 상담 서비스 디비에 하는것이 좋음
DDD의 바운더리 컨텍스트 단위 개념과 비슷
즉 데이터는 오너십 가진 서비스에서 가지고 있기
서비스 경계 변경
너무 구현하기 힘든 상황이라면 서비스를 합치거나 경계를 변경해도 괜찮다.
독립성 보완이 필요한 경우
MSA는 서비스 간의 트랜잭션 격리 레벨은 Read Uncommitted 이다.
따라서 서비스 간의 데이터가 순간적으로 일치하지 않을 수 있다
정교하게 맞아야 한다면 어플리케이션 코드레벨에서 조절해야함
데이터베이스의 동기화 메커니즘을 사용할 수 없음
select for update, 등 db 락 사용
원래도 사실 안티패턴이다.
어플리케이션 lock
msa는 이 경우만 사용한다.
평소 MSA에 대해서 많은 정의가 있지만 구체적으로 어떤 것이 MSA를 의미하는지 항상 헷갈리고 구체적인 구현측면에서 어떤점이 맞는지 헷갈려왔던 나에게 어느정도 판단할 수 있는 기준점이 되는 내용들이라고 생각이 들었고 많은 의미가 있다고 생각이 들었다. 특히 안티패턴을 피하는 것만으로도 큰 도움이 될 것 같다.
5. 구해줘 홈즈! 은행에서 3천만 트래픽의 홈 서비스 새로 만들기 (이영규님)
이영규님은 안정성이 중요한 은행 환경에서 트래픽이 많은 홈 서비스를 새로 개발하며, 동료들과 함께 고민하고 결정했던 경험과 회고들을 중점적으로 공유해주셨다.
특히 다음과 같이 구조적 문제와 성능 문제를 중점적으로 해결하려 하셧다고 한다.
구조적 문제
레거시 프로젝트로 인한 계층간 의존성 꼬여 있음
외부 의존성과 도메인 정책이 혼재되어 섞여 있음
이러한 문제들을 헥사고날 아키텍쳐를 기반으로 도메인 계층을 외부 의존성과 독립되도록 분리하셨다.
성능 문제 해결 - 기술 부채
외부 서비스 호출 증가에 따른 성능 이슈
MSA로 인한 길어지는 네트워크 구간
마찬가지로 코루틴을 사용하셨고 async - await 패턴을 통해 해결하셨다고 말씀 주셨다.
안정적 이관 전략
개인적으로 이쪽 파트를 말씀주실때 가장 크게 인상깊게 다가왔던 것 같다. 서버만의 분리 작업이기 때문에 외부 인터페이스는 변하면 안되므로 안정적인 이관을 위해 정말 다양한 전략들을 사용하셨는데 모두 도움이 되었다고 하셨다.
응답 비교
기존 서비스 → (응답 전달(async), 응답 비교서비스) → 신규 서비스
기존 서비스에서 응답 비교 서비스 호출 한다.
응답 비교 서비스에서 신규 서비스 호출하여 비교 한다.
단점
응답 비교 서비스에서 호출하는 찰나의 순간 데이터 변경으로 달라지는 경우 있었다.
기존 다른 서비스들의 트래픽 2배가 된다.
표본 검사
하루 약 3천만 호출
검증 비율을 동적으로 변경할 수 있도록 설정
점진적으로 응답 비교 비율을 확대 및 모니터링
A/B
게이트웨이에서 A/B 테스트 하듯이 트래픽을 일정 비율로 전환 가능하도록 설계
기존서비스, 신규서비스
단점
하이럼의 법칙
별칭 보정 기능 이슈
별칭이 존재하지 않으면 보정해주는 기능이 있었음.
신규 서비스에서는 이 기능을 제거
만약의 상황을 위한 기능이었기 떄문에
최근 호출 기록도 확인
하지만 나중에 다른 시스템에서 이 별칭 보정 기능을 의존하고 있었다는 걸 알게 됨
fallback
신규 서비스에서 예외가 발생하면 기존 서비스 응답으로 fallback
nginx가 다운된 사례가 있었는데 fallback 기능을 통해 보완
이러한 안정적 이관을 위해 사용하셨던 전략들을 알게 되어서 굉장히 의미 있었고 아주 무거운 레거시 프로젝트를 전환하게 된다면 안정적인 이관을 위해 노력을 많이 쏟아야 한다는 것을 알게 되었다. 물론 이관용 서비스를 구축하는 것이 쉽지만은 않은 것은 알지만 안정성이 매우 중요한 서비스라면 필요해보인다고 생각이 들었다.
후기
이렇게 인상깊었던 세션들의 내용을 정리해봤는데 오프라인 세션에서는 사진 촬영과 한정된 시간으로 인해 모두 이해하고 맥락을 짚기엔 어려운 부분들도 많았지만 오프라인으로 참여하여 직접 들으며 경험과 노하우를 들을 수 있던 부분들이 좋았다. 아무래도 QnA는 오프라인 참여자들의 가장 큰 장점이지 않을까 싶다.
이 외에도 IT 회사들의 부스들에서 진행하는 채용 관련 상담과 굿즈, 간식 타임까지 있어서 준비를 많이 하셨다고 생각이 들었고 1분도 안되어 선착순 마감에 오프라인 세션을 열심히 듣던 개발자 분들을 보며 많은 자극을 얻었고, 유튜브를 통해 자세히 한번 볼 예정이다.
단일 레프리카 셋으로 구성된 서버에 접속할 때, 응용 프로그램에서는 레플리카 셋을 구성하는 멤버들의 목록을 connection string에 사용해야 하며, 나열된 목록의 몽고 디비 서버만 접속할 것으로 보이지만, 몽고디비 드라이버는 나열된 서버 목록들을 seed로만 사용할 뿐이다. 즉, connection string에 나열된 멤버 중 가용 멤버에 접속해서 레플리카 셋을 구성하는 멤버 목록들을 확인하여 모든 멤버들의 적절히 접속한다.
샤딩된 클러스터
아래와 같이 샤딩된 클러스터 구조에서는 하나 이상의 레플리카 셋이 필요하며 각 레플리카 셋은 자신만의 파티션 된 데이터를 가지게 된다.
샤딩된 클러스터에 참여하고 있는 각각의 레플리카 셋을 샤드라고 하는데, 이 샤드들이 어떤 데이터를 가지는지에 대한 정보는 몽고디비 Config 서버거 관리한다.
샤딩된 클러스터 구조에서는 응용 프로그램의 드라이버가 직접 몽고디비 서버에 접근하면 안된다. 몽고 디비 드라이버는 몽고 디비 라우터(mongos)로 연결하고, 라우터는 컨피그 서버로 부터 각 샤드가 가지고 있는 메타 정보들을 참조하여 쿼리를 실행
그 뿐만 아니라 라우터는 사용자를 대신해서 모든 샤드로 쿼리를 요청하고 결과를 정렬 및 병합해서 반환하는 처리도 수행
JVM은 Java virtual machine을 줄인 것으로 자바를 실행하기 위한 가상 컴퓨터이다.
자바로 작성된 애플리케이션은 모두 JVM에서만 실행되기 때문에, 자바 애플리케이션을 실행하기 위해서는 JVM이 반드시 필요하다. 컴파일러는 Java 파일을 .class 파일로 컴파일한 다음 해당 .class 파일을 JVM에 입력하여 클래스 파일을 로드하고 실행한다.
WORA(Write once, run anywhere)
이 과정을 통하여 Java 는 높은 이식성이라는 큰 장점을 얻을 수가 있었다. 어느 기기나 운영체제에 상관없이 JVM 이 설치 및 구동될 수 있는 환경이라면 Java 로 작성한 프로그램은 실행이 가능하기 때문에 다른 운영체제에 맞춰서 컴파일을 해줘야 하는 다른 언어보다 높은 이식성을 가질 수 있게 되었다.
단, JVM은 OS에 종속적이기 때문에 해당 OS에서 실행가능한 JVM이 필요하다.
JVM은 바이트 코드를 이해하는 것이지 자바 코드를 이해하는 것이 아니다. 코틀린 또한 코틀린 코드를 바이트 코드로 컴파일해서 JVM 위에서 동작한다.
JVM의 특성
스택 기반의 가상 머신
단일 상속 형태의 객체 지향 프로그래밍을 가상 머신 수준에서 구현
포인터를 지원. 단, C와 같이 주소 값을 임의로 조작이 가능한 포인터 연산은 불가능
Garbage collection 수행
플랫폼의 독립성 보장
Data Flow Analysis에 기반한 자바 바이트코드 검증기를 통해 문제를 실행 전에 검증하여 실행 시 안전을 보장하고 별도의 부담을 줄여줌
바이트코드란 무엇인가
바이너리 코드
CPU가 이해하기 위한 기계어는 0과 1로 구성된 바이너리 코드(이진 코드)이다. 기계어가 이진 코드로 이루어졌을 뿐 모든 이진 코드가 기계어인 것은 아니다.
바이너리 코드 != 기계어
바이트 코드
0과 1로 이루어진 이진 코드이지만 바이너리 코드와 달리 가상머신이 이해할 수 있는 코드이다. 사람에게 친숙한 고급 언어보다는 덜 추상적이지만 기계어보다는 추상적이다.
고급언어로 작성된 코드를 가상머신이 이해할 수 있도록 컴파일한 것이다. CPU에게 넘어가기 전에 실시간 번역기 또는 JIT(just-in-time) 컴파일러에 의해 바이너리 코드로 변환된다.
정리
Java는 OS와 직접적으로 대화할 수 없다. 오로지 JVM하고만 상호작용을 한다. 자바는 JVM을 거쳐야만 OS와 대화할 수 있다.
바이너리 코드와 바이트 코드 둘 다 0과 1로 이루어져 있다. 바이너리 코드는 CPU가 이해할 수 있는 언어, 바이트 코드는 가상 머신이 이해할 수 있는 언어이다.
JDK 에서 개발하고, JRE 를 통해서 환경을 제공받은 JVM 은 compile 된 바이트 코드를 탑재하여 로직을 실행하게 됩니다. 그렇다면 JVM 에 Class 는 어떻게 로드되는 것일까??
바로 그 역할을 하는 것이 자바 클래스로더이다. 클래스 로더는 자바 클래스를 JVM으로 동적 로드하는 JRE(자바 런타임 환경)의 일부이다. 클래스 파일을 로드하는데 사용되는 하위 시스템이다.
Compile time이 아닌, Runtime시 처음으로 한 번만 동적으로 클래스를 로드하며, jar 파일 내에 저장된 클래스들을 JVM 위에 탑재하고 사용하지 않는 클래스들은 메모리에서 삭제한다.변환된 바이트 코드 파일(.class)을 JVM이 운영체제로부터 할당 받은 메모리 영역인 Runtime Data Area로 “적재”하는 역할을 한다.
ClassLoader 는 클래스 파일을 찾아서 탑재하는 역할뿐만이 아니라 jvm 에 관련된 다른 일들도 같이 한다.
크게 Loading, Linking, 그리고 Initialization 3가지 역할을 맡게 된다.
Loading 은 클래스 파일을 탑재하는 과정
Linking 은 클래스 파일을 사용하기 위해 검증하고, 기본 값으로 초기화하는 과정
Initialization 은 static field 의 값들을 정의한 값으로 초기화를 하는 과정
Runtime Data Area
이렇게 탑재하는 클래스 파일들은 JVM 에서 어떤 영역을 차지하고 있을까? JVM 의 Run-Time Data Area는 프로그램을 수행하기 위해 OS에서 할당받은 메모리 공간이며, 크게 Method Area , Heap , Java Stacks , PC registers 그리고 Native Method Stacks 가 존재한다.
Method Area 에는 인스턴스 생성을 위한 객체 구조, 생성자, 필드 등이 저장된다. Runtime Constant Pool 과 static 변수, 그리고 메소드 데이터와 같은 Class 데이터들도 이곳에서 관리 된다.
즉, 정적 변수를 포함하여 모든 클래스 수준 데이터가 여기에 저장된다.
JVM당 하나의 메소드 영역만 있으며 공유 자원입니다.다른 스레드에서도 활용 가능한 공유자원이다.
다중 스레드에 대한 메모리를 공유하므로 저장된 데이터는 스레드에 안전하지 않다
Heap
모든 객체와 해당 인스턴스(instance) 변수 및 배열, String pool이 여기에 저장됩니다.
JVM 당 역시 하나만 생성이 되고, 해당 영역이 가진 데이터는 모든 Java Stack 영역에서 참조되어, Thread 간 공유가 됩니다.
다중 스레드에 대한 메모리를 공유하므로 저장된 데이터는 스레드에 안전하지 않다
GC의 주 대상
Native Method Stack
순수하게 Java 로 구성된 코드만을 사용할 수 없는 시스템의 자원이나 API 가 존재합니다.
다른 프로그래밍 언어로 작성된 메소드들을 Native Method 라고 합니다.
일반적인 메소드를 실행하는 경우 JVM Language Stack에 적재되지만, 네이티브 메소드 스택은 네이티브 라이브러리에 따라 네이티브 코드 명령(C언어와 같이 네이티브 방식으로 작성된 메소드)을 보관한다.
PC Register
Java 에서 Thread 는 각자의 메소드를 실행하게 됩니다. 이때, Thread 별로 동시에 실행하는 환경이 보장되어야 하므로 최근에 실행 중인 JVM 에서는 명령어 주소값을 저장할 공간이 필요합니다.
이 부분을 PC Registers 영역이 관리하여 추적해주게 됩니다. Thread 들은 각각 자신만의 PC Registers 를 가지고 있습니다.
만약 실행했던 메소드가 네이티브하다면 undefined 가 기록이 됩니다. 실행했던 메소드가 네이티브하지 않다면, PC Registers 는 JVM 에서 사용된 명령의 주소 값을 저장하게 됩니다.
Stack
프로그램 실행과정에서 임시로 할당되었다가 메소드를 빠져나가면 바로 소멸되는 특성의 데이터를 저장하기 위한 영역이다.
각종 형태의 변수나 임시 데이터, 스레드나 메소드의 정보를 저장하고 호출된 메서드의 매개변수, 지역변수, 리턴 값 및 연산 시 일어나는 값들을 임시로 저장한다.
각 스레드에는 자체 JVM 스택이 있고, 스레드가 생성될 때 동시에 생성된다.
각 Thread 별로 따로 할당되는 영역이므로 Heap 메모리 영역보다 비교적 빠르다는 장점이 있다. 또한, 각각의 Thread 별로 메모리를 따로 할당하기 때문에 동시성 문제에서 자유롭다.
Execution Engine
런타임 데이터 영역에 할당된 바이트코드는 실행 엔진에 의해 실행된다. Execution Engine은 바이트코드를 읽고 자바 바이트 코드를 JVM 내부에서 컴퓨터가 실행할 수 있는 형태인 바이너리 코드로 변경하며 하나씩 실행한다.
변경하는 방식은 두가지가 있는데, 인터프리터 방식과 JIT 방식이 있다.
인터프리터 방식
기본 바이트 코드를 실행하는 방법은 인터프리터 방식이 기본이다. 자바 바이트 코드를 명령어 단위로 읽어서 실행하기 때문에 느리다.
JIT(just-in-time) Compiler
JIT 컴파일러는 인터프리터의 단점을 해결한다. 실행 엔진은 바이트 코드를 변환하는 데 인터프리터의 도움을 사용할 것이지만 반복되는 코드를 발견하면 전체 바이트코드를 컴파일하여 네이티브 코드로 변경하는 JIT 컴파일러를 사용한다. 이 네이티브 코드는 반복 메서드 호출에 직접 사용되어 시스템 성능을 향상시킨다.
Garbage Collector
참조되지 않은 객체를 수집하고 제거한다. JVM의 가비지 컬렉션은 생성된 객체를 수집한다.
JVM 동작 간단 정리
JVM 구성 요소는 다음과 같다.
클래스 로더 컴파일러가 내부에 만든 .class(바이트 코드)를 런타임 데이터 공간에 “적재”한다.
런타임 데이터 공간 OS로부터 메모리를 할당받은 공간으로 스택, 힙, 메소드, 네이티브 메소드, PC 레지스터가 있다.
실행 엔진인터프리터 방식 또는 JIT 컴파일러를 이용하여 데이터 영역에 배치된 바이트 코드를 실행한다.
JIT 컴파일러는 바이트 코드를 바이너리 코드로 변환하는 속도가 느린 인터프리터 방식을 보완하기 위해 나온 것이다.
인터프리터 방식으로 기계어 코드를 생성하면서 그 코드를 캐싱하여, 같은 함수가 여러 번 불릴 때 매번 기계어 코드를 생성하는 것을 방지한다.
JVM 내부에서는 자바 컴파일러가 자바 프로그램 코드를 바이트 코드로 변환시킨 후 실제 바이트 코드가 실행하는 시점에서 JIT 컴파일러를 통해 기계어로 변환한다.
GC는 JVM 상에서 더 이상 사용되지 않는 데이터가 할당되어있는 메모리를 해제시킨다.
컴파일 하는 방법
컴파일이란?
컴파일러는 특정 프로그래밍 언어로 쓰여 있는 문서를 다른 프로그래밍 언어로 옮기는 프로그램을 말한다. 기존 문서를 소스 코드 혹은 원시 코드라고 부르고, 출력된 문서를 목적 코드라고 부른다.원시 코드에서 목적 코드로 옮기는 과정을 컴파일이라고 한다.
자바 컴파일 과정
소스 파일 생성한다. (Hello.java)
자바 컴파일러(javac.exe)를 사용하여 컴파일한다. $ javac Hello.java
컴파일이 정상적으로 완료되면 클래스 파일 생성된다. (Hello.class)
실행하는 방법
자바 인터프리터(java.exe)로 실행한다. $ java Hello실행 시에는 확장자를 붙이지 않는다.
내부적인 진행순서는 다음과 같다.
프로그램의 실행에 필요한 클래스(*.class파일)을 로드한다.
클래스파일을 검사한다.(파일형식, 악성코드 체크)
지정된 클래스(Hello)에서 main(String[] args)을 호출한다.
JDK와 JRE의 차이
JDK란?
JDK는 Java Development Kit으로 자바 프로그래밍 시 필요한 컴파일러 등을 포함한다. JDK는 JRE를 포함하며, 개발을 위해 필요한 도구(java, javac 등)를 포함한다.
JRE란?
JRE는 Java Runtime Enviroment로 컴파일된 자바 프로그램을 실행시킬 수 있는 자바 환경을 말한다. JRE는 JVM의 실행환경을 구현했다고 볼 수 있으며, JVM이 자바 프로그램을 동작시킬 때 필요한 라이브러리 파일들과 기타 파일들을 가지고 있다.
사용자가 어떤 게시글을 작성하면 조건에 맞는 다른 사용자에게 쪽지같은 알림을 구현해야 하는 상황
게시글 작성
알림
처음에는 하나의 transaction으로 처리로 구현을 진행했으나 알림 기능은 부가적인 기능이고 게시글 작성 기능에 영향을 주면 안된다고 생각이 들었다. 따라서 게시글 작성 후 알림 처리가 지연되는 경우 게시글 작성 자체를 지연하는 것이 아니라, 게시글 작성은 완료시키고 다른 Thread에서 알림을 처리하도록 비동기 처리를 진행할 수 있을 것이다.
스프링에서는 @Async Annotation을 이용하여 간단하게 비동기 처리를 할 수 있다.
Java 비동기방식 처리
그전에 먼저 자바의 비동기 작업 처리를 알아보자.
따라서 method가 실행되면 새로운 thread를 만들고 그 thread에서 메시지를 저장하도록 처리하면 될 것 같다.
publicclassAsyncService{
publicvoidasyncMethod(String message)throws Exception {
// do something
}
}
하지만 해당 방법은 thread를 관리할 수 없기 때문에 위험한 방법이다.
Thread를 관리하기 위해서 ExecutorService 사용해보자.
ExecutorService는 쉽게 비동기로 작업을 실행할 수 있도록 도와주는 JDK(1.5부터)에서 제공하는 interface 이다. 일반적으로 ExecutorService는 작업 할당을 위한 스레드 풀과 API를 제공한다.
하지만 @Async의 기본설정은 SimpleAsyncTaskExecutor를 사용하도록 되어있기 때문입니다.
본인의 개발 환경에 맞게 Customize하기에는 직접 AsyncConfigurerSupport를 상속받는 Class를 작성하는 것이 좋다.
@Async with ThreadPoolTaskExecutor
Thread pool을 이용해서 thread를 관리가능한 방식다. 아래와 같은 AsyncConfigurerSupport를 상속받는 Customize Class를 구현하자.
그리고 Application 클래스에 @EnableAutoConfiguration(혹은 @SpringBootApplication) 설정이 되어있다면 런타임시 @Configuration가 설정된 SpringAsyncConfig 클래스의 threadPoolTaskExecutor bean 정보를 읽어들이기 때문에 앞서 설정한 Application 클래스의 @EnableAsync을 제거한다.
해당 @Async method를 가로챈 후, 다른 Class에서 호출이 가능해야 하므로,private method는 사용할 수 없는 것이다. 또한 inner method의 호출은 해당 메서드를 직접호출 하기 때문에 self-invocation이 불가능하다. @Transactional 사용시 주의점과 비슷하다고 할 수 있다.
스프링 어플리케이션에서 HTTP 요청할 때 사용하는 방법으로 RestTemplate과 WebClient가 있다. 스프링 5.0 이전까지는 클라이언트에서 HTTP 접근을 위해 사용한 것은 RestTemplate 이었다. 스프링 5.0 에서 WebClient가 나왔고 현재는 WebClient를 사용하기를 권고하고 있다. 이번 팀 프로젝트를 진행하면서 외부 api호출 시 WebClient을 사용해보았다. 그럼 RestTemplate과 WebClient는 어떤 특징이 있으며 왜 WebClient를 사용하길 권고하는지 알아보도록 하자.
RestTemplate
스프링 3.0에서부터 지원하며 HTTP 통신에 유용하게 쓸 수 있는 템플릿이다. REST 서비스를 호출하도록 설계되어 HTTP 프로토콜의 메서드 (GET, POST, DELETE, PUT)에 맞게 여러 메서드를 제공한다. RestTemplate은 다음과 같은 특징이 있다
RestTemplate 특징
통신을 단순화하고 RESTful 원칙을 지킨다
멀티쓰레드 방식을 사용
Blocking 방식을 사용
1) 클라이언트 애플리케이션 구동 시 쓰레드 풀을 만든다.
2) Request는 먼저 queue에 쌓이고 가용 쓰레드가 있으면 해당 쓰레드에 할당된다. (1요청 당 1쓰레드 할당)
3) 각 쓰레드는 블로킹 방식이기 때문에 완료 응답이 올 때까지 다른 요청에 할당될 수 없다.
4) 쓰레드가 다 찼다면 이후 요청은 queue에 대기하게 된다.
동작원리
RestTemplate은 Multi-Thread와 Blocking방식을 사용한다.
HttpClient는 HTTP를 사용하여 통신하는 범용 라이브러리이고, RestTemplate은 HttpClient 를 추상화(HttpEntity의 json, xml 등)해서 제공해준다. 따라서 내부 통신(HTTP 커넥션)에 있어서는 Apache HttpComponents 를 사용한다.
어플리케이션이 RestTemplate를 생성하고, URI, HTTP메소드 등의 헤더를 담아 요청한다.
RestTemplate 는 HttpMessageConverter 를 사용하여 requestEntity 를 요청메세지로 변환한다.
RestTemplate 는 ClientHttpRequestFactory 로 부터 ClientHttpRequest 를 가져와서 요청을 보낸다.
ClientHttpRequest 는 요청메세지를 만들어 HTTP 프로토콜을 통해 서버와 통신한다.
RestTemplate 는 ResponseErrorHandler 로 오류를 확인하고 있다면 처리로직을 태운다.
ResponseErrorHandler 는 오류가 있다면 ClientHttpResponse 에서 응답데이터를 가져와서 처리한다.
RestTemplate 는 HttpMessageConverter 를 이용해서 응답메세지를 java object(Class responseType) 로 변환한다.
RestTemplate을 생성할 때 어떤 HttpClient를 사용할 것인지 ClientHttpRequestFactory를 전달하여 지정할 수 있다. 기본 생성자의 경우 내부적으로 ClientHttpRequestFactory 의 구현체SimpleClientHttpRequestFactory를 사용하여 초기화한다.
RestTemplate을 사용하기 위해서는 restTemplate.메소드명() 을 사용하면 된다.
RestTemplate 객체를 생성할때 별도의 파리미터 없이 new RestTempalte()으로 생성한다면 Connection Pool을 활용하지 않는 객체이다. 이말은 즉, 요청때 마다 새로운 TCP Connection 을 연결한다는 의미이며 이 때 소켓이 close () 된 이후 소켓이 "TIME_WAIT" 상태가 되는데 만약 요청이 많아진다면 TIME_WAIT 상태의 소켓들을 재사용하지 못해서 요청에 대한 응답에 지연이 생길 수 있다.
이러한 응답 지연 상황을 대비하여 DB가 Connection Pool을 이용하듯이 RestTemplate도 Connection Pool을 이용할 수 있다. 그러기 위해선 RestTemplate 내부 구성을 설정해줘야한다.
단, 호출하는 API 서버가 Keep-Alive를 지원해야지 RestTemplate의 Connection Pool을 활용할 수 있다. 타겟 서버가 Keep-Alive를 지원하지 않는다면 미리 Connection Pool을 만들어 놓지 못하고 요청마다 새로운 Connection이 연결되어 매번 핸드쉐이크가 발생된다. 따라서 Connection Pool을 위한 RestTemplate의 내부 설정이 무의미하게 된다.
@ConfigurationpublicclassRestTemplateConfig{
@BeanHttpClient httpClient(){
return HttpClientBuilder.create()
.setMaxConnTotal(100) //최대 오픈되는 커넥션 수
.setMaxConnPerRoute(5) //IP, 포트 1쌍에 대해 수행할 커넥션 수
.build();
}
@BeanHttpComponentsClientHttpRequestFactory factory(HttpClient httpClient){
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setReadTimeout(5000); //읽기시간초과, ms
factory.setConnectTimeout(3000); //연결시간초과, ms
factory.setHttpClient(httpClient);
return factory;
}
@BeanRestTemplate restTemplate(HttpComponentsClientHttpRequestFactory factory){
returnnew RestTemplate(factory);
}
}
WebClient
WebCleint는 스프링 5.0에서 추가된 인터페이스다. Spring WebFlux는 HTTP request를 수행하는 client인 WebClient 를 포함하고 있으며 반응형으로 동작하도록 설계되었다. 스프링 5.0 이후부터는 RestTemplate 의 대안으로 WebClient를 사용할 것을 권장한다. 실제로는 spring-webflux 라이브러리에 속하지만 이 솔루션은 동기 및 비동기 작업을 모두 지원하므로 서블릿 스택에서 실행되는 애플리케이션에도 적용 가능하다.
bodyToMono 는 가져온 body를 Reactor의 Mono 객체로 바꿔준다. Mono 객체는 0-1개의 결과를 처리하는 객체이다. Flux는 0-N개의 결과를 처리하는 객체이다.
block() 을 사용하면 RestTemplate 처럼 동기식으로 사용할 수 있다.
4xx, 5xx의 응답 코드를 받으면 WebClientResponseException 또는 HTTP 상태에 해당하는 WebClientResponseException.BadRequest 등 과 같은 하위 exception을 던진다. onStatus 메서드로 상태별 exception을 커스텀도 가능하다.
스프링에서는 기본적으로 jdk dynamic proxy, 스프링 부트에서는 CGLib Proxy 방식으로 AOP를 사용한다.
JDK Dynamin Proxy, CGLib Proxy의 차이를 알아보자.
JDK Dynamic Proxy
JDK Dynamic Proxy는 Proxy Factory에 의해 런타임시 동적으로 만들어지는 오브젝트이다. JDK Dynamic Proxy는 반드시 인터페이스가 정의되어있고, 인터페이스에 대한 명세를 기준으로 Proxy를 생성한다. 즉, 인터페이스 선언에 대한 강제성이 있다는 단점이 있다.
내부적으로 JDK Dynamic Proxy에서는 InvationHandler라는 인터페이스를 구현해 만들어지는데, invoke 함수를 오버라이딩하여 Proxy의 위임 기능을 수행한다. 이 과정에서 객체에 대한 Reflection 기능을 사용해 구현하기 때문에 퍼포먼스 하락의 원인이 되기도 한다.
이 방식이 Spring AOP의 근간이 되는 방식이다.
인터페이스를 기준으로 Proxy 객체를 생성해준다.
인터페이스가 반드시 필요하다.
리플렉션을 사용하기때문에 성능적으로 좋지 못 하다.
CGLib Proxy
CGLIB Proxy는 순수 Java JDK 라이브러리를 이용하는 것이 아닌 CGLIB라는 외부 라이브러리를 추가해야만 사용할 수 있다. CGLIB의 Enhancer 클래스를 바탕으로 Proxy를 생성하며, 인터페이스가 없어도 Proxy를 생성할 수 있다. CGBLIB Proxy는 타겟 클래스를 상속받아 생성하기 때문에 Proxy를 생성하기 위해 인터페이스를 만들어야하는 수고를 덜 수 있다.
하지만, 상속을 이용하므로 final이나 private와 같이 상속에 대해 오버라이딩을 지원하지 않는 경우에는 Aspect를 적용할 수 없다는 단점이 있다.
CGLIB Proxy는 바이트 코드를 조작해서 프록시 객체를 생성하므로 JDK Dynamic Proxy보다 퍼포먼스가 빠른 장점이 있다.
상속을 이용하기 때문에 클래스나 메소드에 final이 있으면 안된다.
스프링 부트에서 AOP 사용을 위해 채택했다.
CGLIB은 고성능의 코드 생성 라이브러리로 인터페이스를 필요로 하는 JDK Dynamic Proxy 대신 사용될 수 있다. 바이트코드를 조작하는 프레임워크인 ASM을 사용하여 리플렉션보다 빠르다.
스프링 부트에서는 왜 cglib?
스프링에선 CGLib은 3가지 한계가 존재했다.
해당 라이브러리를 추가해야 한다.
CGLib을 구현하기 위해 반드시 파라미터가 없는 defalut 생성자가 필요하다.
생성된 Proxy의 메소드를 호출하게 되면 타깃의 생성자가 2번 호출된다.
하지만 문제되는 부분들을 개선하여 안정화 시켰다.
스프링 부트에서는 AOP를 사용할 때 인터페이스로 선언되어 있어도 CGLib 방식으로 프록시 객체를 생성한다.
spring-boot-test 패키지는 Mockito를 포함하고 있기 때문에 기존에 사용하던 방식대로 Mock 객체를 생성해서 테스트하는 방법도 있지만, spring-boot-test에서는 새로운 방법도 제공한다.
바로 @MockBean 어노테이션을 사용해서 이름 그대로 Mock 객체를 빈으로써 등록할 수 있다.
기존에 사용되던 스프링 Bean이 아닌 Mock Bean을 주입한다.
그렇기 때문에 만일 @MockBean으로 선언된 빈을 주입받는다면 Spring의 ApplicationContext는 Mock 객체를 주입한다.
새롭게 @MockBean을 선언하면 Mock 객체를 빈으로써 등록하지만, 만일 @MockBean으로 선언한 객체와 같은 이름과 타입으로 이미 빈으로 등록되어있다면 해당 빈은 선언한 Mock 빈으로 대체된다.
해당 어노테이션은 테스트 내용 중 외부 서비스를 호출하는 부분을 Mock해서 쉽게 처리할 수 있다.
@SpringBootTestpublicclassXXXControllerTest{
@MockBean// 외부 서비스 호출에 사용되는 RestTemplate Bean을 Mockprivate RestTemplate mockRT;
@MockBean// 외부 서비스 호출에 사용되는 Service Bean을 Mockprivate XXXService xXXService;
}
@DataJpaTest
Spring Data JPA를 테스트하고자 한다면 @DataJpaTest 기능을 사용해볼 수 있다.
해당 테스트는 기본적으로 in-memory embedded database를 생성하고 @Entity 클래스를 스캔한다.
일반적인 다른 컴포넌트들은 스캔하지 않는다. 따라서 특정 bean의 의존성이 필요한 경우 아래의 방법 사용