새로운 내용을 공부할 때
새로운 내용의 공부를 시작할 때 용어의 정의를 이해하지 못하거나 정확하게 알지 못한다면 그 용어가 포함된 문장을 이해하지 못합니다.
작은 단어 하나가 내용을 이해하지 못하게 하기 때문에 용어를 정확하게 이해하는 것이 중요합니다.
TIL) 동기, 비동기, 리액티브 스트리밍
멘토님에게 질문을 받은 뒤 다시 동기, 비동기, 리액티브 스트리밍에 대해서 정리해보려고 합니다.
비동기 I/O를 사용하는 의미를 생각해봐야합니다.
비동기 I/O는 네트워크, 파일 등 스레드 상태가 대기로 전환되는 작업으로 되지 않고 I/O를 처리하는 방법입니다.
구성 요소는 I/O 작업을 대신 처리해줄 스레드나 스레드 풀이 필요합니다.
별도의 스레드가 필요한 이유는 I/O 호출을 대신하여 현재 스레드가 대기 상태로 가지 않고 기존 로직을 실행할 수 있도록 하기 위해서 입니다.
자바의 경우 스프링 부트나 CommonForkJoinPool 등을 활용하여 멀티 스레드가 요청하는 비동기 작업을 대신 처리해줍니다.
요청된 작업에 대해 FIFO 방식으로 처리하기 위해 큐 자료구조에 실행할 작업들을 저장하게 되는데
큐 자료 구조도 메모리 위에 동작하는 방식이라면 무제한으로 쌓을 수 없습니다.
그래서 멀티 스레드 환경에서 비동기 작업을 요청할 때 비동기 작업을 조율하는 방식을 선택해야합니다.
- 서킷브레이커
- 디스크 방식 전환
- 백프레셔
이런 3가지 방법이 있습니다.
준비큐에 넣다가 더이상 넣지 못하는 경우 이후 호출되는 작업은 모두 실패로 처리하는 서킷브레이커 방식은 서버의 안정화를 위해 사용되며 사용자의 호출이 크게 중요하지 않는 경우에 빠른 거절로 서버 자원을 안정적이게 실행할 수 있습니다.
다만 일정시간에 폭주한 트래픽을 빠르게 처리하여 응답을 빠르게 전달할 수 있지만 사용자는 뒤늦게 호출이 실패한다는 것을 알기에
이때 사용자 호출에 대한 retry도 고려해야합니다.
디스크 방식 전환은 메모리 큐에 저장하면 플래시 특성으로 서버가 다운되면 사라지고, 메모리 위에서 동작하기에 제한된 자원에서 큐 저장을 해야합니다.
그럴때 적절한 방식으로 작업 큐를 디스크에 저장하여 사용자의 요청을 무시하지 않고 모두 처리할 수 있습니다.
이런 방식으로 적절한 것은 인스타의 좋아요와 유사합니다. 이방식을 사용하면 지연 일관성과도 연결된다고 생각합니다.
백프레셔 방식은 비동기 방식에서 중요한 구성요소로 작업 큐에 있는 runnable 최소 단위 메서드를 실행할 때 처리량에 따라 속도를 소비자 측에서 결정하는 방식입니다. 소비자가 pull을 하는 경우에만 작업 큐에 실행할 단위 메서드를 넣습니다. 생산량이 처리량 보다 많을 경우에는 마찬가지로 대기 큐에 넣거나 디스크에 저장하는 방식 또는 서킷 브레이커를 통해 서버를 안정화할 수 있습니다.
GPT 질문 5개
동기 방식이 더 적절한 경우와 판단 기준 제시
“동기 I/O 방식은 단순하고 직관적이라 개발이 쉽지만, 확장성 측면에서는 불리하다는 평가를 받습니다.
그런데도 실무에서 동기 방식을 고수하거나 일부러 채택하는 사례는 여전히 존재합니다.
‘동기 방식이 오히려 더 적절한 상황’을 설명하고, 그 판단 기준을 논리적으로 제시해보세요.”
동기 방식은 프로세스 흐름이 하나의 스레드 내에서 순차적으로 처리되는 방식입니다.
프로세스 흐름 중에 예외가 발생하게 된다면 예외에 대한 처리를 현재 스레드 내에서 해결할 수 있게됩니다.
동기 방식은 정상적인 프로세스 흐름이 아닐 때 예외 처리가 직관적이며 일관성있게 관리할 수 있습니다.
단일 스레드 내에서 동작하기에 예외가 발생한다면 스택을 확인하여 어디서 언제 발생했는지 순차적으로 확인하기 좋습니다.
데이터 입력에 대한 일관성을 지킬 수 있으므로 데이터 정합성이 중요한 경우에 다시 롤백하는 과정이 발생하게 된다면 순차적으로 진행한 프로세스를 역순으로 정리하여 처리할 수 있지만, 비동기 방식은 비순차적이므로 예외에 대한 처리가 어렵습니다.
따라서 데이터 정합성이 중요한 프로세스의 경우에는 동기 방식이 더 적절할 수 있습니다.
질문 2: 비동기 큐 포화와 대응 전략
“Spring Boot에서 @Async를 활용해 로그성 데이터를 비동기로 처리한다고 합시다.
하지만 대량의 요청이 유입되어 Executor 작업 큐가 포화되면 어떤 문제가 발생할 수 있을까요?
그리고 이런 상황을 실무에서 어떻게 예방하거나 복구 가능한 구조로 만들 수 있을까요?”
비동기 큐가 포화되는 경우 Excutor는 이후에 생성된 요청을 모두 취소할 수 있습니다.
트래픽이 몰리는 구간에는 비동기 큐만큼만 로그성 데이터가 쌓이게 되며 나머지 로그 데이터는 유실됩니다.
- 중요도에 따른 비동기 스레드풀을 사용한다.
- 중요도가 높은 로그성 데이터는 작업 큐를 메모리가 아닌 디스크에 저장하여 유실을 방지한다.
- 중요도가 높은 로그성 데이터만 분리하여 백프레셔를 도입하여 데이터를 유실하지 않고 비동기 작업을 유지하도록 한다
이 정도 방법이 있을거 같습니다.
✅ 질문 3: 트랜잭션과 비동기의 충돌
“외부 API 호출을 포함한 작업을 @Async로 비동기 처리할 경우, 트랜잭션이나 예외 핸들링 측면에서 문제가 발생할 수 있습니다.
이때 발생할 수 있는 문제점들을 설명하고, 이를 해결할 수 있는 Spring 기반의 안전한 구조를 제안해보세요.”
트랜잭션이 적용되지 않는 이유는 트랜잭션은 스레드로컬을 기반으로 동작하기 때문입니다.
스레드 로컬은 스레드마다 고유의 저장공간이므로 비동기로 처리하는 경우 톰캣에서 관리하는 스레드 풀이 아닌 비동기 전용 스레드풀에서 실행되는 방식이기에 트랜잭션이 적용이 안됩니다.
두번째는 예외가 발생하는 경우 호출 스레드에서 확인하기 어렵습니다.
예외 전파는 동일한 스레드 내에 스택에서 전파되는 방식이기에 스레드가 달라지는 비동기의 경우에 여외를 처리하기 까다롭게 됩니다.
세번째는 트랜잭션이 동작하기에 비동기로 실행한 로직이 실제로 정상 처리가 되었는지 알기 어렵습니다.
이유는 비동기 방식은 비순차적으로 운영체제 스케줄링 상황에 따라 실행되는 시간이 달라질 수 있으므로 예외를 처리할 때 비동기 스레드에서 로직이 실행되고 있을 수 있기 때문입니다.
이러한 문제를 해결하기 위해 스프링에서는 트랜잭션 관리를 기존 방식이 아니라 새로운 트랜잭션 매니저 빈을 생성합니다.
그리고 트랜잭션인 커넥션을 보관할 때 오브젝트의 주소가 아닌 비동기에서도 꺼낼 수 있도록 key를 설정하는게 어떨까 생각듭니다.
✅ 질문 4: 리액티브 스트림과 백프레셔 실전 대응
“Spring WebFlux 기반으로 리액티브 스트림을 설계할 때, 소비자가 처리할 수 있는 속도보다 빠르게 생산자가 데이터를 발행하면 어떤 문제가 발생할까요?
그리고 Project Reactor에서 이를 제어하거나 방지할 수 있는 구체적인 메커니즘은 무엇인가요?”
리액티브 스트리밍에서 생성자가 데이터를 발행하는 속도가 소비자의 처리량보다 빠를 경우 작업 큐 크기가 초과하여 초과된 데이터가 유실될 수 있습니다.
그렇다고 작업 큐의 크기를 키우는 경우에는 자바 서버 자체에 메모리가 부족하여 OOM으로 서버가 종료될 수 있습니다.
이를 방지하기 위해 Reactor에서는 소비자가 생성자에게 작업 큐에 push하라는 요청 기능을 가지고 생성자의 데이터 발행 속도를 조절하는 방식으로 해결했습니다. 소비자와 작업자 스레드는 서로 추상화되어 작업 단위로 처리되었다는 호출을 소비자 오브젝트를 통해 호출하게 되어 소비자는 작업자의 내부 상황을 알지못해도 작업 속도를 조절할 수있는 매커니즘입니다.
✅ 질문 5: 기술 선택 판단력 테스트 (케이스 비교)
“다음은 대용량 엑셀 파일을 업로드 후 DB에 저장하고, 처리 결과를 클라이언트에게 알려주는 기능입니다.
이 기능을 ‘동기 / 비동기 / 리액티브’ 중 어떤 방식으로 구현하겠습니까?
각각의 장단점, 실패 가능성, 리소스 사용량, 사용자 경험 측면에서 비교하고 설계 판단을 내려주세요.”
대용량의 데이터를 업로드 한다는 것은 패킷 단위로 많은 전송이 온다는 이야기가 됩니다.
운영체제는 TCP 버퍼가 바로 바로 비워져야 브라우저가 전송한 데이터를 빠르게 버퍼에 복사할 수 있습니다.
-
동기방식
동기 방식을 선택하는 경우 하나의 스레드에서 아래와 같은 과정이 발생됩니다
- 블로킹 I/O > 데이터 읽기 > 블로킹 I/O > 데이터 읽기 > … > 종료
그러면 사용자의 모든 엑셀파일이 메모리에 올라오게 됩니다.
그후 다시 데이터를 DB에 저장하는 과정에서 배치로 DB 저장을 하게 된다고 해도 아래과 같은 상황이 발생됩니다.
DB에 저장하는 과정도 블라킹 I/O의 과정이 반복됩니다.
거의 모든 로직이 I/O인 상황인데 스레드 하나를 가지고 길게 잡게 되므로 스레드 풀이 부족할 수 있습니다.
다만 엑셀 데이터의 순서를 보장할 수 있으며 모든 작업이 완료되거나 실패하거나 트랜잭션도 쉽게 처리할 수 있습니다.
JPA를 사용하는 경우에는 1차 캐시로 인해 메모리 부족할 수 있으므로 중간에 flush를 해야하므로 중간에 실패하는 경우 어디까지 성공했는지 이후 재 트라이에 대한 로직이 필요하게 됩니다.
추가로 스레드 풀 내에서만 엑셀 업로드가 되므로 확장성과 처리량이 떨어지지만 데이터 일관성은 보장할 수 있습니다.
-
비동기
비동기 방식을 선택하는 경우 아래와 같은 과정이 반복됩니다.
- 논블로킹 I/O > 데이터 읽기 > … > 종료
별도의 스레드에서 엑셀파일을 읽으므로 모두 메모리에 올라가기 전까지 호출 스레드에서는 상황을 정확히 알기 어렵습니다.
엑셀 파일이 크기에 비동기 스레드내 작업 큐의 사이즈만 충분하다면 현재 메모리 확인 없이 무한대로 입력할 수 있습니다.
그러다가 서버 메모리 부족으로 서버가 죽을 수 있습니다.
다만 비동기이기 때문에 사용자는 기다리지 않고 다른 작업을 이어갈 수 있게 처리가 가능합니다.
비동기 I/O를 모두 사용하게 된다면 불필요한 컨택스트 스위칭이 발생하지 않을 수 있습니다.
데이터베이스에 저장하는 것도 비동기로 처리한다면 최종 결과는 불일치가 발생할 수 있습니다.
그리고 비동기로 요청하는 것 만큼 커넥션 풀이 부족할 수 잇으므로 타임아웃으로 중간에 데이터 유실이 발생할 수 있습니다.
-
리액티브
리액티브 방식을 선택하는 경우에는 공급량을 조절하여 스레드풀이나 커넥션 풀에 대한 자원을 천천히 하더라도 유실되지 않도록 할 수 있습니다. 다만 사용자의 요청을 버퍼나 디스크에 저장하는 과정에서 서버 부하로 문제가 발생할 수 있습니다.
처음 요청량을 설정하는 경우에 잘못 입력하는 경우엔 작업 큐에는 자리가 남아도 파일 크기로 인해 GC가 많아져서 서버가 부실해질 수 있습니다.
댓글남기기