포스트

2023-03-20-TIL

2023-03-20-TIL

Today I Learned

EFS Performance Issues

AWS re:Post의 EFS 성능 문제 해결 글은 먼저 구조적인 특성을 짚는다. EFS는 여러 가용 영역에 분산된 네트워크 파일 시스템이라 파일 작업마다 약간의 지연 오버헤드가 있고, 이 오버헤드가 큰 I/O에 나눠지므로 평균 I/O 크기가 클수록 전체 처리량이 오른다. 그래서 작은 파일을 많이 읽고 쓰거나 메타데이터 작업이 많은 워크로드에서 특히 느리게 느껴진다. 글이 꼽는 성능 요인은 스토리지 클래스, 성능 모드(범용, 최대 I/O), 처리량 모드(버스팅, 탄력적, 프로비저닝), 작업 유형, 파일 크기와 개수, 마운트 옵션, 클라이언트 측 제한이다. 버스팅 모드에서는 처리량이 저장된 데이터 양에 비례하므로, 데이터가 적은 파일 시스템은 기대보다 처리량이 낮을 수 있다.

  • https://aws.amazon.com/ko/premiumsupport/knowledge-center/efs-troubleshoot-slow-performance/

S3 Transfer Acceleration

멀리 떨어진 클라이언트가 S3 버킷으로 파일을 올리거나 받을 때 속도를 높이는 기능이다. AWS 설명에 따르면 트래픽을 전 세계에 분산된 CloudFront 엣지 로케이션으로 먼저 받은 뒤 AWS 백본 네트워크로 버킷까지 보내고, 네트워크 프로토콜 최적화를 함께 적용한다. 공용 인터넷 구간을 짧게 만드는 셈이다. 가속된 전송에 대해서만 추가 요금을 내고, 속도 비교 도구로 효과를 미리 확인할 수 있다.

  • https://aws.amazon.com/ko/s3/transfer-acceleration/

AWS Secrets Manager

DB 접속 정보, API 키, OAuth 토큰 같은 비밀 값을 저장하고 조회하며 주기적으로 교체(rotation)하는 서비스다. 문서가 강조하는 이점은 소스 코드에 자격 증명을 하드코딩하지 않아도 된다는 것이다. 애플리케이션은 실행 중에 Secrets Manager API로 값을 받아 오고, 접근 권한은 IAM으로 통제한다.

  • https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html

꼬리 재귀, Tail Recursion

재귀 호출이 함수의 마지막 연산이어서 호출이 끝난 뒤 할 일이 남지 않는 재귀를 꼬리 재귀라고 한다. 이 경우 현재 스택 프레임이 더 필요 없으므로, 새 프레임을 쌓지 않고 현재 프레임을 재사용하는 꼬리 호출 최적화(tail-call elimination)가 가능하다. 결과적으로 반복문처럼 동작해 깊은 재귀에서도 스택 오버플로가 나지 않는다.

1
2
3
4
5
6
// 꼬리 재귀가 아님: 재귀 호출 뒤에 곱셈이 남는다
fun factorial(n: Long): Long = if (n <= 1) 1 else n * factorial(n - 1)

// 꼬리 재귀: 누적값을 인자로 넘긴다
tailrec fun factorial(n: Long, acc: Long = 1): Long =
    if (n <= 1) acc else factorial(n - 1, n * acc)

언어마다 지원이 다르다. Wikipedia의 Tail call 문서에 따르면 Go는 지원하지 않고, JVM에서는 꼬리 재귀는 제거할 수 있지만 일반적인 꼬리 호출은 제거할 수 없다. Kotlin은 tailrec 수정자를 붙이면 조건을 만족하는 재귀를 컴파일러가 반복문으로 바꿔 준다.

  • https://welikecse.tistory.com/m/54
  • https://seokba.tistory.com/14
  • https://bozeury.tistory.com/entry/%EA%BC%AC%EB%A6%AC-%EC%9E%AC%EA%B7%80-%EC%B5%9C%EC%A0%81%ED%99%94Tail-Recursion
  • https://medium.com/@soyoung823/tail-recursion-%EA%BC%AC%EB%A6%AC-%EC%9E%AC%EA%B7%80-a84c2cd9a7e8

LISP

함수형 프로그래밍 언어나 도구에서 map, reduce, filter 등의 용어를 종종 사용하는데, 이 용어의 유래는 무엇일까? ChatGPT에게 물어보니 LISP라는 언어에서 유래가 되었다고 설명하고 있다.

Wikipedia로 확인해 보면 Lisp는 John McCarthy가 1958년 MIT에서 개발을 시작한 언어로, 이름처럼 리스트 처리(LISt Processing)가 중심이다. 함수를 인자로 받는 고차 함수가 자연스럽게 쓰였고, 문서는 Scheme의 map, Common Lisp의 mapcar를 예로 든다. 함수 하나와 리스트를 받아 각 원소에 함수를 적용한 결과를 새 리스트로 모으는 함수다. 예를 들어 (mapcar #'+ '(1 2 3 4 5) '(10 20 30 40 50))은 (11 22 33 44 55)를 돌려준다. 오늘날 자바 스트림의 map이 하는 일과 같다.

  • https://en.wikipedia.org/wiki/Lisp_(programming_language)

JSR

JSR(Java Specification Request)은 자바 플랫폼에 추가할 규격을 제안하고 기술한 문서다. JSR은 JCP(Java Community Process)라는 절차를 거쳐 검토되고, 승인되면 자바 표준의 일부가 된다. ZDNet Korea의 2002년 기사는 어떤 JSR이 논의되는지 보면 자바의 앞날을 짐작할 수 있다고 소개한다. 지금도 Bean Validation, JPA 같은 이름 옆에 붙는 번호(예: JSR 380)가 이 규격 번호다. 자바 언어와 JDK 기능 자체는 OpenJDK의 JEP로도 관리된다.

  • https://zdnet.co.kr/view/?no=00000010046325

암묵적 입력, 암묵적 출력

향로(jojoldu)의 글은 부수 효과를 두 가지로 나눈다. 암묵적 입력은 인자로 명시적으로 받지 않은 값을 함수 안에서 가져다 쓰는 것으로, 전역 변수, 쿠키나 LocalStorage, DB와 API, 파일에서 값을 읽는 경우다. 암묵적 출력은 반환값 말고 함수 실행 중에 바깥에 영향을 주는 것으로, 전역 변수나 쿠키를 바꾸거나 외부 서비스를 호출하거나 콘솔에 출력하는 경우다. 둘 중 하나라도 있으면 같은 인자에 같은 결과를 보장할 수 없으므로 테스트하기 어려워진다. 암묵적 입력은 인자로, 암묵적 출력은 반환값으로 바꿔 함수를 순수하게 만들고, 부수 효과는 바깥쪽 얇은 계층에 모으는 것이 글의 방향이다.

  • https://jojoldu.tistory.com/703

Coroutine Dispatcher

코루틴 컨텍스트에 포함된 디스패처는 그 코루틴이 어떤 스레드에서 실행될지 정한다. Kotlin 문서에 따르면 특정 스레드에 고정하거나, 스레드 풀에 보내거나, 제한 없이(unconfined) 실행할 수 있다. 자주 쓰는 것은 다음과 같다.

  • Dispatchers.Default: 디스패처를 지정하지 않았을 때 쓰이며 공유 백그라운드 스레드 풀을 사용한다. CPU 연산에 맞는다.
  • Dispatchers.IO: 블로킹 I/O 작업을 떠넘기기 위한 공유 스레드 풀로, 필요하면 스레드를 더 만든다.
  • Dispatchers.Unconfined: 특정 스레드에 묶이지 않고 호출한 스레드에서 시작한다.
  • newSingleThreadContext: 전용 스레드를 만든다. 비싼 자원이므로 다 쓰면 close로 해제해야 한다.

블로킹 JDBC 호출 같은 작업을 Default에서 실행하면 CPU 작업용 스레드를 막으므로 withContext(Dispatchers.IO)로 옮긴다.

  • https://kotlinworld.com/141
  • https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/
  • https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html

Webflux vs Coroutine

둘은 대립하는 선택지라기보다 같은 논블로킹 스택 위의 다른 프로그래밍 방식이다. Spring 블로그의 “Going Reactive with Spring, Coroutines and Kotlin Flow”는 코루틴을 논블로킹 코드를 명령형으로 쓰게 해 주는 경량 스레드로 소개한다. suspend 함수가 비동기 연산을 추상화하고, Flow가 Reactor Flux에 대응한다. kotlinx-coroutines-core와 kotlinx-coroutines-reactor 의존성이 있으면 WebFlux 컨트롤러를 suspend 함수로 작성할 수 있다.

Digital Frontiers 글은 두 방식으로 같은 요구 사항을 구현해 본 경험을 정리한다. 리액티브 방식은 연산자 체인에 맞춰 흐름을 짜야 해서 익숙해지는 데 시간이 걸렸고, 코루틴은 비동기로 실행되지만 순차 코드처럼 보여 기존 프로그래밍 습관을 그대로 쓸 수 있었다며, 앞으로는 코루틴 쪽을 택하겠다고 결론짓는다.

  • https://spring.io/blog/2019/04/12/going-reactive-with-spring-coroutines-and-kotlin-flow/
  • https://medium.com/digitalfrontiers/reactive-or-coroutines-between-a-rock-and-a-hard-place-6a41a151195a
  • https://foojay.io/today/build-and-test-non-blocking-web-applications-with-spring-webflux-kotlin-and-coroutines/

Spring CacheManager

CacheManager는 Spring 3.1부터 있는 캐시 추상화의 중심 인터페이스로, 이름으로 Cache를 꺼내는 getCache(String name)과 캐시 이름 목록을 돌려주는 getCacheNames()를 정의한다. 실제 저장소는 구현체가 정한다. ConcurrentMapCacheManager(로컬 메모리), Caffeine, Redis용 구현을 바꿔 끼워도 비즈니스 코드는 그대로다. 코드에서는 @EnableCaching을 켜고 @Cacheable("addresses")처럼 붙이면, Baeldung 설명대로 메서드를 실행하기 전에 캐시를 먼저 확인하고 없을 때만 실행한 뒤 결과를 저장한다. 갱신과 삭제는 @CachePut, @CacheEvict로 한다. @Async와 마찬가지로 프록시 방식이라 같은 클래스 안의 내부 호출에는 적용되지 않는다.

  • https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/CacheManager.html
  • https://www.baeldung.com/spring-cache-tutorial
  • https://shinsunyoung.tistory.com/50

Key Vault in AWS

Azure Key Vault는 클라우드 앱이 쓰는 암호화 키와 비밀번호 같은 작은 비밀 값을 보관하는 서비스다. AWS에는 이 하나에 정확히 대응하는 서비스가 없고 역할이 둘로 나뉜다. 암호화 키를 만들고 관리하며 암호화, 서명에 쓰는 것은 AWS KMS(Key Management Service), DB 비밀번호나 API 키 같은 비밀 값을 저장하고 교체하는 것은 위의 Secrets Manager다.

  • https://azure.microsoft.com/en-us/products/key-vault
  • https://aws.amazon.com/ko/kms/

Config Server vs Config Map

둘 다 설정을 애플리케이션 밖으로 빼는 방법이다. Spring Cloud Config는 Git 같은 저장소의 설정을 Config Server가 HTTP로 제공하고, 클라이언트가 시작할 때(또는 refresh할 때) 받아 간다. Kubernetes ConfigMap은 클러스터 리소스로 설정을 저장하고, 파드에 환경 변수나 파일로 주입한다. 비교 글의 저자는 오래 Config Server를 써 왔지만, 시스템이 커지면서 Config Server의 구성 요소를 따로 운영하고 감시하는 부담이 커졌고 그 구성 요소가 잠시라도 내려가면 배포가 늦어졌다며 ConfigMap으로 옮겼다고 정리한다.

  • https://joshgunh.medium.com/spring-cloud-config-vs-kubernetes-configmap-detailed-comparison-bce64b594af8
  • https://velog.io/@borab/Spring-Cloud-vs-ConfigMap

Config Server Advantages

Spring Cloud Config 문서는 Config Server를 분산 시스템에서 모든 환경의 외부 설정을 한곳에서 관리하는 수단으로 설명한다. 설정이 Git에 있으므로 변경 이력과 리뷰를 그대로 쓸 수 있고, 프로필과 라벨로 환경별, 버전별 설정을 나눌 수 있으며, 개념이 Spring의 Environment, PropertySource에 그대로 대응해 스프링 애플리케이션과 잘 맞는다. 쿠버네티스 밖에서 여러 서비스를 운영하거나, 런타임에 설정을 바꿔야 할 때 장점이 크다.

  • https://stackoverflow.com/questions/73282023/what-are-the-advantages-of-using-an-spring-cloud-config-server-externalised-con
  • https://dzone.com/articles/microservices-and-spring-cloud-config-server
  • https://docs.spring.io/spring-cloud-config/docs/current/reference/html/

Best Practices for API Documentation

저장해 둔 Pandium 글은 지금 404를 반환해서 내용을 다시 확인할 수 없다. 일반적으로 권하는 것은 인증 방법과 빠른 시작 예제를 맨 앞에 두고, 엔드포인트마다 요청과 응답 예시, 에러 코드를 함께 적으며, 문서를 코드와 함께 갱신하는 것이다. 테스트 기반으로 문서를 만드는 Spring REST Docs나 OpenAPI 명세가 마지막 항목을 돕는다.

  • https://www.pandium.com/blogs/3-best-practices-for-api-documentation

MySQL BOOLEAN Column

당근마켓 기술블로그 글에 따르면 MySQL에는 네이티브 BOOLEAN 타입이 없다. BOOL이나 BOOLEAN으로 컬럼을 만들면 실제로는 TINYINT(1)로 생성되고, TRUE, FALSE는 1과 0의 별칭이다. TINYINT이므로 -128부터 127까지 아무 정수나 들어갈 수 있는데, 0과 1 이외의 값이 섞이면 결과가 엇갈린다. 3이 저장된 행은 WHERE enabled에는 잡히지만 WHERE enabled = TRUE에는 잡히지 않는다. 글은 0과 1만 저장되도록 일관성을 지키고, ORM이 WHERE enabled = TRUE 대신 성능에 불리한 WHERE enabled 형태의 쿼리를 만들 수 있으니 실행 계획을 직접 확인하라고 권한다.

  • https://medium.com/daangn/mysql-boolean-%EC%BB%AC%EB%9F%BC-7abd9b35c664

Java Parallel Stream Side Effect

java.util.stream 패키지 문서는 스트림 연산에 넘기는 람다(동작 파라미터)의 부수 효과를 일반적으로 권장하지 않는다. 상태를 갖지 않아야 한다는 요구를 모르는 사이에 어기기 쉽고, 별도 명시가 없으면 부수 효과가 다른 스레드에 보인다는 보장도 없기 때문이다. 순차 스트림에서는 우연히 맞던 코드가 병렬 스트림에서 깨지는 전형적인 예가 람다 안에서 ArrayList에 add하는 코드다. 결과는 collect나 reduce로 모아야 한다.

1
2
3
4
5
6
// 잘못된 예: 병렬 실행 시 ArrayList에 동시 접근한다
List<Integer> result = new ArrayList<>();
numbers.parallelStream().map(n -> n * 2).forEach(result::add);

// 올바른 예
List<Integer> result2 = numbers.parallelStream().map(n -> n * 2).collect(Collectors.toList());

병렬 스트림은 기본적으로 공용 ForkJoinPool에서 실행된다. Baeldung 글은 네트워크에서 데이터를 받는 것처럼 오래 걸리는 작업으로 공용 풀을 묶어 두지 않으려면 별도 ForkJoinPool 안에서 실행하는 방법을 보여 주고, 그 풀을 매번 새로 만들고 닫지 않으면 메모리 누수가 생길 수 있다고 경고한다.

  • https://stackoverflow.com/questions/47041144/what-is-the-danger-of-side-effects-in-java-8-streams
  • https://wjdtn7823.tistory.com/89
  • https://www.logicbig.com/tutorials/core-java-tutorial/java-util-stream/side-effects.html
  • https://www.reddit.com/r/java/comments/efepp6/when_virtual_threads_are_added_to_java_would_the/
  • https://www.baeldung.com/java-8-parallel-streams-custom-threadpool
  • https://solr.apache.org/docs/7_2_0/solr-solrj/org/apache/solr/client/solrj/io/stream/ParallelStream.html
  • https://devdocs.io/openjdk~17/java.base/java/util/collection#parallelStream()
참고한 자료외부 출처 10

외부 출처

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

댓글

아직 댓글이 없습니다