포스트

2023-06-28-TIL

2023-06-28-TIL

Today I Learned

Redis 고가용성

Redis를 캐시가 아니라 세션 저장소나 주요 데이터 저장소로 쓰면 Redis 한 대의 장애가 곧 서비스 장애가 된다. 링크한 자료들이 다루는 고가용성 구성은 크게 세 단계다.

  • 복제(replication): 마스터의 데이터를 복제본에 비동기로 복사한다. 읽기를 분산할 수 있지만 마스터가 죽으면 사람이 승격해야 한다.
  • Sentinel: 복제 구성을 감시하다가 마스터 장애 시 복제본을 자동으로 승격하고, 클라이언트에 새 마스터 주소를 알려 준다. Redis 문서는 독립적으로 장애가 나는 위치에 최소 세 개의 Sentinel을 두라고 권한다.
  • Cluster: 데이터를 여러 마스터에 샤딩하고 각 마스터에 복제본을 붙여 장애 조치까지 스스로 처리한다.

어느 구성이든 복제가 비동기라서 장애 조치 순간 마지막 쓰기 일부를 잃을 수 있다. 장애 유형을 정리한 글은 물리 메모리를 넘겨 스왑이 일어나는 메모리 문제, 기본 RDB 저장 설정이 일으키는 메모리와 디스크 부하, KEYS나 큰 컬렉션 전체 조회 같은 O(N) 명령이 단일 스레드를 막는 문제처럼 구성과 무관하게 운영에서 겪는 문제를 다룬다. 목록 중 Helm 글은 Redis를 쿠버네티스에 배포할 때 쓰는 패키지 관리 도구를 다룬 것이다.

  • https://nesoy.github.io/articles/2020-05/Redis-HA
  • https://redis.io/docs/management/sentinel/
  • https://redis.com/redis-enterprise/technology/highly-available-redis/
  • https://success.outsystems.com/documentation/how_to_guides/infrastructure/configuring_outsystems_with_redis_in_memory_session_storage/redis_cluster_high_availability_architecture/
  • https://lifeplan-b.tistory.com/35
  • https://charsyam.wordpress.com/2020/11/20/%EC%9E%85-%EA%B0%9C%EB%B0%9C-redis-%EC%9E%A5%EC%95%A0-%EC%A2%85%EB%A5%98-%EC%A0%95%EB%A6%AC/
  • https://s-core.co.kr/insight/view/redis-%EB%82%B4%EB%B6%80-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%A9%EC%95%88/
  • https://www.oss.kr/storage/app/public/festival/track2/2-1.pdf
  • https://zdnet.co.kr/view/?no=20131119174125

Lettuce

Lettuce는 Spring Boot의 spring-boot-starter-data-redis가 기본으로 쓰는 Redis 클라이언트다. README는 Lettuce를 동기, 비동기, 리액티브 방식으로 쓸 수 있는 확장 가능하고 스레드 안전한 클라이언트로 소개하고, Netty 기반이며 Sentinel, Cluster, 파이프라이닝, 자동 재연결을 지원한다고 적는다. 여러 스레드가 연결 하나를 공유할 수 있다는 점이 커넥션 풀이 필요한 Jedis와의 큰 차이다. 단, README가 밝히듯 BLPOP 같은 블로킹 명령이나 MULTI/EXEC 트랜잭션을 쓰는 경우는 예외라 이때는 별도 연결이나 풀이 필요하다. 링크한 글은 Spring Boot에서 Lettuce의 연결과 클러스터 토폴로지 갱신 옵션을 설정하는 방법을 다룬다.


Redis Cluster

Redis Cluster는 키 공간을 해시 슬롯으로 나눠 여러 마스터에 분산한다. 기본적으로 읽기와 쓰기는 모두 해당 슬롯의 마스터가 처리하고, 복제본은 장애 조치용으로 대기한다. 브런치 글은 읽기 부하를 줄이려고 복제본에서 읽게 하는 설정(Lettuce의 ReadFrom.REPLICA_PREFERRED 같은 옵션)을 다루는데, 복제가 비동기라 방금 쓴 값을 바로 읽지 못할 수 있다는 trade-off를 감수해야 한다. 이동욱 님의 글은 Spring Boot에서 내장 Redis로 로컬과 통합 테스트 환경을 구성하는 방법이다.

  • https://brunch.co.kr/@springboot/218
  • https://jojoldu.tistory.com/297

Spring Data JPA 성능 함정

Thorben Janssen은 Spring Data JPA에서 흔한 성능 함정으로 개발 중 SQL을 확인하지 않는 것, 업데이트마다 saveAndFlush()를 부르는 것, 비효율적인 기본 키 생성 전략, 즉시 로딩, 함께 가져올 연관관계를 지정하지 않아 생기는 N+1, 연관관계를 포함한 복잡한 인터페이스 프로젝션을 꼽는다. 마지막 항목은 프로젝션을 썼는데도 결국 엔티티 전체를 조회하게 되는 경우라 놓치기 쉽다.

  • https://thorben-janssen.com/6-performance-pitfalls-when-using-spring-data-jpa/

Spring Data JPA findById Anti-Pattern

findById.orElseThrow로 엔티티를 가져와서 child에 set하고 저장하는 방식의 단점은 일일이 select쿼리가 나간다는 것이다. 따라서 getReferenceById로 프록시 객체를 통해서 세팅하고 저장하면 select쿼리는 나가지 않고, 만약 존재하지 않는 ID라면 제약조건에서 걸릴것이다. 하지만 외래키로 매핑되어있지 않다면?

Vlad Mihalcea의 글도 같은 결론이다. 부모 참조를 설정할 때 필요한 것은 외래 키 값뿐이므로 프록시면 충분하고, findById로 미리 확인해도 그 직후 다른 트랜잭션이 부모를 지울 수 있으니 최종 보장은 외래 키 제약이 맡는다는 것이다.

메모의 마지막 질문에 답하면, DB에 외래 키 제약이 없으면 존재하지 않는 ID도 그대로 저장되어 고아 참조가 생긴다. 이 경우에는 저장 전에 existsById처럼 가벼운 쿼리로 존재를 확인하고, 동시 삭제까지 막아야 한다면 부모 행에 잠금을 거는 수밖에 없다. 결국 getReferenceById로 SELECT를 줄이는 최적화는 외래 키 제약이 무결성을 지켜 준다는 전제 위에서 안전하다.

  • https://vladmihalcea.com/spring-data-jpa-findbyid/

Soft Delete

Soft delete는 행을 실제로 지우지 않고 deleted 플래그나 deleted_at 시각을 표시해 삭제된 것으로 취급하는 방식이다. 링크한 질문의 답변들은 의견이 갈린다. 반대 쪽은 모든 쿼리와 조인에 deleted = false 조건을 빠짐없이 붙여야 하고, 빠뜨린 실수가 늦게 발견되며, 유니크 제약과 외래 키 cascade가 복잡해진다고 지적한다. 찬성 쪽은 실수로 지운 데이터를 복구할 수 있고 감사 이력이 남는다는 점을 든다. 법적으로 데이터를 실제로 지워야 하는 경우도 있다는 답변도 있다.

JPA에서는 Hibernate의 @SQLDelete로 DELETE를 UPDATE로 바꾸고, @Where(Hibernate 6.3부터는 @SQLRestriction)로 조회 조건을 자동으로 붙이는 방식을 흔히 쓴다. 조건 누락 문제를 프레임워크에 맡기는 셈이지만, 네이티브 쿼리에는 적용되지 않는다.

  • https://stackoverflow.com/questions/2549839/are-soft-deletes-a-good-idea

findAllById와 findAllBy

findAllById(Iterable<ID>)는 CrudRepository에 미리 정의된 예약 메서드로, ID 목록으로 IN 조회를 해서 반복문으로 findById를 부르는 것보다 쿼리 수가 적다. 결과는 요청한 ID 순서를 보장하지 않고 없는 ID는 그냥 빠진다.

이름이 비슷한 findAllByName과 findByName은 사실 같은 쿼리다. Spring Data 레퍼런스의 쿼리 주어 키워드 표에 따르면 find와 By 사이의 단어는 설명용으로 취급되고, 의미를 가지는 것은 Distinct, First<number>, Top<number> 같은 키워드뿐이다. 반환 타입이 컬렉션이면 여러 건, 단일 객체면 한 건을 기대한다. 목록 중 Specification 글은 findAll에 동적 WHERE 조건을 넘기는 방법을 다룬다.

  • https://dabok407.tistory.com/73
  • https://www.baeldung.com/spring-data-jpa-find-by-vs-find-all-by

JPA 쿼리 메서드 키워드

Spring Data JPA 레퍼런스의 부록은 메서드 이름 쿼리에 쓸 수 있는 키워드를 정리한다. 주어 부분에는 find…By, exists…By, count…By, delete…By와 결과 수를 제한하는 First, Top, 중복을 없애는 Distinct가 오고, 조건 부분에는 And, Or, Between, LessThan, Like, In, IsNull, OrderBy 같은 술어 키워드가 온다. 조건이 세 개를 넘어가 메서드 이름이 길어지면 @Query나 동적 쿼리 도구로 옮기는 편이 읽기 쉽다.

이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다