들어가는 글

프로그래밍 강사로 아이들을 가르친 지도 어느덧 7개월이 됐습니다. 주로 파이썬과 C 언어를 가르치고 있는데, 언어를 다시 설명하는 과정이 의외로 신선하게 느껴집니다. 알고 있다고 생각했던 개념도 아이들에게 풀어서 설명하다 보면, 제대로 이해하고 있었는지 스스로를 점검하게 되는 것 같기도 합니다.

가르치는 직업이란

강사를 하다 보면, 아이들에게 영향력을 전한다는 것이 가치 있는 일이라는 생각을 자주 하게 됩니다. 누군가의 인생에서 갈림길을 함께 선택해 준다는 게 얼마나 큰 책임감을 동반하는지.

제가 처음으로 가르친 학생 중 한 명은 벌써 COSPro 자격증을 취득했고, 소프트웨어 고등학교 진학을 목표로 준비하고 있습니다. 또 어떤 제자는 제 수업을 들은 후 실제로 원하던 소프트웨어 고등학교에 진학하기도 했습니다.

코딩 학원의 특수성

대치동 학원가라고하면, 까다롭고 날 선 학부모님들, 문제푸는 기계 같은 학생들이 떠오릅니다. 그런데 막상 강의를 해보니 아이들은 생각보다 너무나 착하고, 어렵다고 숙제 내주지 말라는 모습들을 보면 한편으론 귀엽게 느껴지기도 합니다. 

아무래도 코딩 학원의 특성상 필수적인 학원이 아니라 정말 코딩을 하고싶은 친구들이 등록을 하기 때문에 더욱 이런 경향이 나타나는 것 같습니다. 국영수 학원은 또 모르겠네요

마무리하며

남을 가르치는 직업은 참 특별한 것 같습니다. 너무나 보람찬 일이고, 제 지식과 기술로 가치를 창출한다는 느낌을 받습니다. 취업을 준비하고 있는 취준생인 저에게 사회적 효용성을 느끼게 하는 이 일은 참으로 사랑스러운 일입니다.

이제는 현업에 들어가서 제가 배워온 지식을 활용하고 싶은 욕구도 있는데요, 부디 그렇게 됐으면 좋겠습니다.
읽어주셔서 감사합니다.

 

아케오 | AI 기반 교육 플랫폼

아이들의 배움을 아케오가 책임집니다

www.akeoedu.com

 

 

 

Is Redis Single-Threaded? How Does It Handle Millions of Requests Per Second?

Yes, Redis is primarily single-threaded, but it can still handle millions of requests per second due to its highly optimized design.

blog.stackademic.com

 

오랜만에 재밌는 글을 가져왔습니다.
Redis는 굉장히 자주 사용되며, 특유의 원자성과 빠른 처리량으로 사랑받는 In-Memory DB 입니다.

원자성을 보장받는 이유는 Singlne Thread이기 때문인데요 "아무리 RAM 에 올라간다 하더라도 어떻게 수백만건의 요청을 처리하지?" 라는 의문을 한번쯤은 가져봤을 것 입니다.

개인적으로 "I/O는 멀티스레드를 써서 빠르다더라" 정도만 알고있었는데요, 이 글이 딥하게 알아볼 수 있는 계기가 되었습니다.

 

글의 내용

위 글에서 말하는 내용은 다음과 같습니다.

  1. Redis는 모든 것을 RAM에 저장하므로 느린 디스크 I/O 를 방지
  2. HashMaps, SortedSets, HyperLogLogs 등 최적화된 데이터 구조 사용
  3. 고성능 I/O Multiplexing 사용
  4. Redis 6.0 이상부터 I/O 처리가 멀티스레드 방식으로 변경

이 글을 읽으면서 햇갈렸던 개념은 I/O Multiplexing 인데요, "성능이 좋아진단건 알겠는데 그래서 그게 뭐지?" 라는 생각이 들어 자세히 찾아봤습니다.

 

Multiplexing (다중화)란 무엇인가?

 

Multiplexing은 간단히 말해 하나를 여러 개처럼 보이게 하는 기법이며, Redis는 버전과 상관없이 항상 I/O Multiplexing 기반으로 동작합니다.
운영체제 관점에서는, 하나의 프로세스가 여러 파일(혹은 소켓)을 동시에 관리할 수 있도록 하는 방법을 의미합니다.

리눅스에서 파일(File)은 프로세스가 커널에 접근할 수 있게 해주는 인터페이스이며, 네트워크 환경에서는 파일 = 소켓으로 대응됩니다. 즉, 하나의 서버가 여러 개의 소켓을 관리하여 동시에 많은 클라이언트를 처리할 수 있는 구조가 됩니다.

또 프로세스가 특정 파일에 접근할 때는 파일 디스크립터(File Descriptor, FD) 라는 정수 값을 사용하는데요
이 FD는 커널 내부의 파일 객체(소켓)를 가리키며, I/O Multiplexing은 FD의 상태 변화(readable/writable)를 효율적으로 감시하는 데 초점이 맞춰져 있습니다.

리눅스에서의 I/O Multiplexing: epoll

 

리눅스는 I/O Multiplexing을 위해 여러 메커니즘을 제공해왔습니다.
초기에는 select, poll을 사용했지만, 이들은 FD 개수에 비례하는 오버헤드가 있어 수천~수만 연결을 처리하기엔 비효율적이었습니다.

이를 해결한 것이 epoll입니다. epoll은 다음과 같이 동작합니다.

  1. 애플리케이션이 epoll_ctl()을 통해 소켓을 등록하고, “이 소켓이 읽기/쓰기 가능해지면 알려달라”고 요청
  2. 이벤트 루프 스레드는 epoll_wait()을 호출해 대기
  3. 커널은 수천 개 소켓 중 실제로 준비된 것만 골라 이벤트 루프에 전달

즉, 준비된 소켓 목록만 반환하기 때문에, Redis는 수만 개 연결 중 필요한 소켓만 빠르게 처리할 수 있습니다.

 

Redis의 이벤트 루프 처리 흐름


Redis는 다음과 같은 순서로 이벤트(사용자 요청)를 처리합니다.

  1. 소켓을 논블로킹 모드로 오픈
    • 일반적으로 소켓은 Blocking 모드
    • 소켓 수신 버퍼에 데이터가 없으면 -> 스레드가 멈춘 채 데이터 도착할 때까지 기다림
    • 송신 버퍼 가득 차면 -> 버퍼 여유 생길 때까지 기다림
    • Non Blocking 모드
    • 소켓에 데이터가 있으면 즉시 읽고 반환, 데이터가 없으면 에러코드 EAGAIN 반환
    • 송신 버퍼에 들어가는 만큼만 쓰고, 가득 차면 에러코드 EAGAIN 반환
    • 즉 준비된 만큼만 처리하고, 나머지는 다음 이벤트에서 처리
  2. I/O Multiplexing 등록
    • Redis 이벤트 루프는 서버 시작 시 OS별로 다중화 방식 선택, Linux(epoll)
    • 논블로킹 소켓의 준비됨(readable/writable) 상태를 커널이 모아 이벤트 루프 스레드에 알려줌
    • 이벤트 루프 = 스레드가 발생한 이벤트를 지속적으로 감지하고, 콜백을 실행하는구조 
    • 소켓에 읽을 게 있으면 REDABLE, 소켓에 쓸 게 있으면 WRITABLE
  3. 이벤트 루프 실행
    • 메인 스레드는 준비된 소켓 목록을 받음
    • 준비된 소켓마다 등록된 콜백을 실행 
      • READABLE - 요청 읽기 + 명령 실행
      • WRITABLE - 응답 전송

이 과정을 통해 단일 스레드 이벤트 루프만으로도 수천~수만 개 동시 연결을 처리할 수 있으며, 락(lock)도 필요하지 않습니다.

6.0 이전에는위 과정 전체(읽기, 명령 실행, 응답 전송)를 메인 이벤트 루프 단일 스레드가 처리했으며
6.0 이후에는 I/O 전용 스레드를 도입하여 read/write 작업을 여러 스레드로 분산 처리를 하도록 변경됐습니다. 
(명령 실행 자체는 여전히 단일 스레드에서 순차적으로 처리됩니다.)

 

Blocking vs Non-blocking 소켓

 

여기까지 정리한 후 한가지 의문이 생겼는데요 
“어차피 epoll이 준비된 소켓만 알려주면, 소켓을 블로킹 모드로 열어도 괜찮지 않을까?” 라는 생각이었습니다.

하지만 준비된 소켓이라고 해서 read/write가 항상 성공하는 것은 아닙니다.
예를 들어, 수신 버퍼 크기보다 더 많은 데이터를 read() 하거나, 송신 버퍼에 여유가 있음에도 write() 하려는 데이터가 너무 많다면, 버퍼가 다시 준비될 때까지 스레드가 블로킹될 수 있습니다.

따라서 소켓은 논블로킹 모드로 열어주는게 적절하겠네요. 

 

 

Code-Study 프로젝트에서의 경험을 다룬 글입니다.

RAG 챗 봇을 만들기 위해 PDF 파일을 받아 백터DB에 임베딩하는 기능이 필요했습니다.

이번 글에서는 제가 적용한 PDF 스트리밍 기반 임베딩 방식을 소개하고, 기존 방식 대비 메모리 사용량을 어떻게 줄였는지 공유해보려 합니다


Spring AI -> PDFBox 변경

처음에는 Spring AI가 제공하는 PagePdfDocumentReader를 사용했습니다.
서비스가 이미 Spring AI에 강하게 의존하고 있고, 잘 추상화된 기능이 있다면 굳이 새로 구현할 이유가 없다고 생각했기 때문입니다.
그래서 다음과 같이 임시 디렉토리에 파일을 저장하고, PagePdfDocumentReader를 이용해 문서를 읽어 처리했습니다.

로컬에서 간단히 테스트했을 때는 정상적으로 데이터가 잘 적재되는 것을 확인할 수 있었습니다.
(테스트 데이터로는 제 이력서를 사용했습니다.)

이력서를 임베딩한 모습

이번에는 실제 서비스 상황을 가정해 아틸러리(Artillery)로 부하 테스트를 진행했습니다.


결과는 예상 밖이었습니다.
효율적이진 못해도 전체가 성공할줄은 알았는데 에러 응답이 절반정도 발생했고, 안정적인 처리가 불가능했습니다.

파일을 다루는 로직이다 보니 메모리와 관련한 문제일것이라 생각했고 JFR로 메모리 부분을 모니터링 해보니 다음과 같았다. 

메모리 할당을 GB 단위로 하고있는 걸 확인함, 아마 한번에 너무 많은 메모리를 할당해 써서 에러 응답이 나오는 것 같음 

원인을 분석해보니 PagePdfDocumentReader는 내부적으로 Apache PDFBox의 PDFParser를 사용해 전체 PDF 파일을 한 번에 로딩하고 있었습니다.


또 내부 구현을 살펴본 결과 저 방법외에 다른 방법을 지원하지 않음을 확인했다 

 

개선 시도: Apache PDFBox 기반 직접 구현

 

이 문제를 해결하기 위해 PagePdfDocumentReader 대신 Apache PDFBox를 직접 활용한 스트리밍 방식을 구현했습니다.

개선 아이디어는 단순합니다.

  • PDF 파일을 페이지 단위로 읽고
  • 읽은 내용을 바로 청크 분할
  • 일정 배치 단위로 모아 벡터DB에 flush

즉, “읽은 건 바로 처리하고 버린다”는 원칙입니다.

이렇게 하면 메모리에 전체 PDF를 쌓아두지 않고, 데이터 양을 제한할 수 있으며
테스트 결과 또한 확연히 달랐습니다.

 

 

Why Most Unit Tests Are Useless (And What to Do Instead)

The Holy War That Never Ends

medium.com

 

흥미로운 글을 가져왔습니다.
개인적으로는 공감이 많이 가는 글이고, 고민을 해본 부분이라 재밌게 읽었네요

본글에서는 의미있는 테스트에 대해서 말하고 있습니다.

Unit Test에 대한 개인적인 경험

    @Test
    @DisplayName("일기 생성")
    void createDiaryTestSuccess(@Mock User user) {

        ArgumentCaptor<Diary> diaryCaptor = ArgumentCaptor.forClass(Diary.class);

        when(userRepository.findById(1L)).thenReturn(Optional.of(user));
        DiaryCreateRequest request = new DiaryCreateRequest("test-content", true);
        when(diaryRepository.save(any())).thenReturn(Diary.of(request, user));

        diaryService.createDiary(request, 1L);

        verify(diaryRepository, times(1)).save(diaryCaptor.capture());
        verify(userRepository, times(1)).findById(1L);

        Diary savedDiary = diaryCaptor.getValue();
        FindDiaryResponse findDiaryResponse = savedDiary.convertToFindDiaryResponse();

        assertNotNull(savedDiary);
        assertFalse(savedDiary.isDeleted());
        assertEquals(true, findDiaryResponse.isLongEntry());
        assertEquals("test-content", findDiaryResponse.content());
    }

실제 제가 작성한 유닛 테스트의 일부입니다.

모든 의존성을 모의로 집어넣어, 정해진 값을 응답하게하고, 정해진 순서대로 메서드를 호출하고, 이를 검증합니다.
이 테스트는 의미있는 테스트일까요?? 제 생각은 "그렇지 않다" 입니다.

대부분의 버그는 시스템간의 통합이나 엣지 케이스에서 발생합니다.

외부 API가 장애가 발생했을 때, 옳지않은 값이 요청으로 들어왔을 때, 중간에 시스템이 다운됐을 때 등등..
하지만 유닛 테스트로는 이를 검증하기 어렵습니다.

또한 세부적인 구현을 검증하고 있습니다.

이 테스트 코드를 이해하려면 세부적인 구현 사항을 전부다 알아야할 것이며, 메서드 시그니처라도 변경된다면 해당 구현을 의존하는 모든 테스트 코드가 실패하게 됩니다.

따라서 의미있는 테스트 코드를 작성하기 위해선, 행위에 집중해야 합니다.

송금 기능을 검증한다면 다음과 같을 것 입니다.

  1. 잔액이 음수가 될 수 없다.
  2. 잔액보다 더 많은 돈을 송금할 수 없다.
  3. 상태는 SUCESS,FAILED,PENDING 셋중 하나여야 한다.
  4. 최대로 송금할 수 있는 금액은 백만원이다.
  5. 그외의 다양한 규칙들...

세부적인 구현이 아닌 행위에 집중하는 것이죠

테스트 코드도 "코드"이기 때문에 유지 보수성을 가지고, 관리되어야 합니다.
구현은 언제든지 변경될 수 있는 반면, 행위와 그에대한 규칙들은 쉽게 변경되지 않기 때문입니다.

이러한 문제점들을 해결하기 위해, 본 글에서는 전체 흐름을 검증하는 통합 테스트의 작성을 추천합니다.

의미있는 테스트 코드

@Test
public void paymentServiceShouldReturnValidResponse() {
    PaymentRequest request = PaymentRequest.builder()
        .amount(BigDecimal.valueOf(100))
        .currency("USD")
        .paymentMethod("CREDIT_CARD")
        .build();

    PaymentResponse response = paymentService.processPayment(request);

    // Test the contract, not the implementation
    assertThat(response.getTransactionId()).isNotEmpty();
    assertThat(response.getStatus()).isIn("SUCCESS", "FAILED", "PENDING");
    assertThat(response.getAmount()).isEqualTo(request.getAmount());
}

구현이 아닌, 비즈니스 규칙에 대한 검증

@Property
public void userEmailShouldAlwaysBeValid(@ForAll("validEmails") String email) {
    User user = new User(email);
    assertThat(user.getEmail()).isEqualTo(email);
    assertThat(EmailValidator.isValid(user.getEmail())).isTrue();
}

@Provide
Arbitrary<String> validEmails() {
    return Arbitraries.emails();
}

여러 엣지 케이스들에 대한 검증

@Test
public void shouldHandleDatabaseFailureGracefully() {
    // Kill the database mid-request
    databaseContainer.stop();

    String response = restTemplate.postForObject("/api/orders", request, String.class);

    // System should degrade gracefully
    assertThat(response).contains("SERVICE_TEMPORARILY_UNAVAILABLE");
    assertThat(response).doesNotContain("500 Internal Server Error");
}

시스템 다운에 대한 검증

테스트에 대한 개인적인 생각 

테스트할 레이어를 크게 Repository, Domain, Service, Controller 로 나눈다면  

Domain
POJO이거나 DB의존성을 물고 있다 해도 도메인 서비스는 순수 자바임으로 유닛 테스트

Repository
@Testcontainsers 를 이용해 실제 환경과 동일한 DB에서 테스트

Service, Controller
비즈니스 규칙에 대해서만 유닛 테스트, 그 외에는 통합 테스트를 작성할 것 같습니다.

 

마무리

제목은 매우 도발적이지만, 전반적으로 글을 읽어보면 전하고자 하는 메시지는 하나입니다. 
"의미 있는 테스트 코드를 작성해라"

본 글에서는 테스트 커버리지는 단순히 “얼마나 많은 코드 줄이 실행되었는가”를 보여줄 뿐, 실제로 얼마나 많은 경우의 수를 검증했는지는 말해주지 않는다고 주장합니다.

유닛 테스트를 작성 하는것이 정말 필요에 의해서 작성이 되는 것 인지, 아니면 커버리지라는 지표를 만족시키기 위해 작성 되는 것 인지
고민할 여지를 던져 주네요

결국 좋은 테스트란

  • 시스템의 행동(Behavior)을 검증하고
  • 엣지 케이스와 장애 상황까지 고려하며
  • 구현이 바뀌어도 쉽게 깨지지 않는 테스트

라고 생각합니다.

기능을 구현하는 것 보다 좋은 테스트 코드를 작성하는 것이 훨씬 어려운 영역인 것 같습니다. 

GPT 요약

이 글은 AWS Lambda에서 I/O 중심 로직을 처리하며, 동기 방식의 한계를 Virtual Thread + CompletableFuture로 극복한 경험을 다룹니다.
JMH 테스트 결과, VT가 가장 뛰어난 성능을 보였으며 JDK 24에서는 pinning 문제까지 해결되어 안정성이 강화되었습니다.
JFR 분석을 통해 확인한 결과, VT 자체보다 byte[]·String 처리 비용이 더 큰 메모리 요인이었으며, 앞으로는 메모리 최적화와 리팩토링을 진행할 예정입니다.



Dawn-Cs-Study 프로젝트에서의 경험을 다룬 글입니다.

Dawn-Cs-Study 프로젝트에서 여러 I/O 작업을 처리해야 했습니다.
이번 글에서는 비동기 로직을 구현하고 테스트한 과정을 공유하려 합니다.

 

필요할 때만 빌려오는 컴퓨팅 파워 Lambda

제가 진행 중인 프로젝트에서는 GitHub에 새로운 md, json 파일이 추가되면, 아래의 작업을 수행해야 합니다.

1. Markdown을 HTML로 변환하고, Spring AI로 OpenAI에 연동하여 임베딩을 생성한 뒤 벡터 데이터베이스에 저장
2. JSON 파싱해 데이터베이스에 적재 

이벤트 기반 아키텍처를 직접 적용해보고 싶었고, main 브랜치에 PR이 병합될 때만 처리가 필요하므로, Lambda를 사용해 이벤트가 발생할 때만 함수를 실행하도록 설계했습니다.

 

동기 처리의 늪에 빠지다

이벤트 파이프라인을 구축한 뒤 확인해 보니, 전체 흐름이 외부 API 호출과 DB 입출력에 크게 의존하는 I/O 중심 작업이었습니다.
이를  동기적으로 순차 실행을 하게 된다면, 이벤트가 많아질수록 처리량은 떨어지고 지연 시간은 길어질 수밖에 없었습니다.

이 작업들을 최적화 해보자는 생각이 들었고, 이전에 학습했던 VirtualThread를 도입하기로 결정했습니다. 

 

[읽은 글] 동기·비동기, IO·NIO, 그리고 Virtual Thread

Reactive Programming in Java — Good Time to DieThis article explains the reason for Reactive Programming, why it is not popular with Developers and with the introduction of Java Virtual…medium.com 동기·비동기, IO·NIO, 그리고 Virtual Threa

masiljangajji-coding.tistory.com

 

Thread to Virtual Thread, 새로운 패러다임 · Issue #124 · masiljangajji/self-study

Thread란? 스레드는 경량 프로세스로, 프로세스 내에서 실행되는 여러 흐름의 단위를 의미합니다. 프로세스 내의 공통된 부분은 공유하면서(Code,Data,Heap), Stack 영역만 따로 할당받아 적은 컨텍스트

github.com

 

처음 설계한 처리 흐름

처음 버전은 S3Event의 모든 레코드를 스트림으로 돌리며 각 레코드를 Virtual Thread + CompletableFuture.runAsync 로 비동기 실행하는 구조였습니다.

이벤트 유형(ObjectCreated/ObjectRemoved)과 확장자(md/json)에 따라 람다(Runnable) 를 만들어 제출하고, 마지막에 allOf(...).join()으로 전부 끝날 때까지 기다리는 방식이죠, 의도는 단순하게 "레코드마다 비동기로 빠르게 처리하면 되겠지" 였지만 곧 바로 문제가 바로 드러났습니다.

 

실패 재시도 단위가 “전체 배치”라 정합성이 깨진다.


공식 문서에 따르면 Lambda는 실패 시 기본적으로 최대 2번까지 재시도를 수행하는데요, 여기서 중요한 점은 실패한 레코드만 재시도되는 것이 아니라, 전체 레코드가 다시 실행된다는 것입니다.

현재 구현에서는 Lambda가 HTML 변환과 DB 적재를 담당하고 있으며, 이는 멱등하지 않은 작업입니다. 따라서 재시도가 발생하면 중복 데이터가 생성될 위험이 있습니다. 정합성을 보장하기 위해 로직 보완이 필요했고 두 가지 방법을 고려했습니다.

  1. AWS DLQ 사용
    • Lambda가 실패하면 이벤트를 DLQ에 적재
    • 이후 DLQ를 읽어 중복 여부를 체크한 뒤 DB 반영
    • 단점: 추가 인프라 의존성 발생
  2. 실패 이벤트 DB 기록 후 후처리
    • 실패 이벤트를 DB에 적재
    • 별도의 워커가 이를 감지해 재처리 및 정합성 회복

최종적으로는 두 번째 방법을 선택했습니다. 추가 인프라 없이도 일관성을 확보할 수 있고, 자연스럽게 연계할 수 있기 때문입니다.

2025/09/18 추가
------------------------------------------------------------------------------------------------------
기존 : S3 Event -> Lambda 
변경 : S3 Event > SQS > Spring Application(EC2)
필요시 객체 단위 처리가 아닌, 배치처리를 하기 위함

1. Lambda 는 기본적으로 단일 건으로 처리됩니다. 배치처리를 하기 위해서는 SQS 를 통해 처리해줘야 함으로 위에서 말한 내용은 틀렸습니다.
2. 의존성이 많은 로직입니다, SpringBoot 돌아야하고, DB, AI 관련 많은 의존성이 필요합니다. 따라서 람다에 적절하지 않습니다.

따라서 Lambda가 아닌, SQS -> Boot 가 더 적절한 것 같습니다. 

------------------------------------------------------------------------------------------------------

추가적으로  기존에 한 메서드 안에 있던 복잡한 switch-case 분기 로직은 buildTask라는 private 메서드로 분리해 가독성을 높였고, 예외 발생 시 전체가 실패하는 runAsync 대신 supplyAsync를 사용해 개별 실패를 분리했습니다.


추가적으로 테스트해서 얼마나 속도가 개선되는지 검증도 해보았습니다.

 

JMH 성능 테스트


테스트 케이스는 크게 3가지였습니다.

  1. 동기 처리 – 기존 방식 (순차 실행)
  2. ExecutorService (FixedThreadPool) – 스레드풀 크기를 10, 100, 200으로 변경하며 측정
  3. VT (JDK 21) – Executors.newVirtualThreadPerTaskExecutor() 사용

JMH Benchmark

I/O 지연은 Thread.sleep()으로 단순하게 시뮬레이션했습니다.

Stubs

결과는 다음과 같았습니다.

(ops/ms)
ms/ops

  • 동기 처리: 요청이 늘어날수록 처리량이 급격히 감소, 평균 응답 시간도 비례해서 증가
  • FixedThreadPool(10개): 일부 개선되지만 스레드가 포화되면 급격히 성능 저하
  • FixedThreadPool(100, 200개): 병렬성이 늘지만 스레드 전환 비용 증가로 한계 존재
  • VT (JDK 21): I/O 대기 시간을 효율적으로 숨기며 가장 높은 처리량 유지

즉, VT가 I/O 바운드 상황에서 최적의 성능을 보여줬습니다.


테스트의 허점: Thread.sleep()


하지만 여기에는 중요한 허점이 있는데요, 테스트에서 I/O 대기 시간을 단순히 Thread.sleep()으로 흉내냈다는 점입니다.
I/O 작업, 특히 JPA의 EntityManager.persist(), merge(), flush() 같은 메서드는 내부적으로 synchronized 블록을 자주 사용합니다.
또한 네트워크 I/O, 디스크 I/O에서도 종종 synchronized 기반 락을 볼 수 있습니다.
그렇게 synchronized 블록을 추가해 다시 테스트를 돌리자, 싱글스레드와 비슷한 성능을 보였습니다.

ops/ms
ms/op

왜 그런걸까요?

 

synchronized와 Carrier Thread의 함정


그 이유는 바로 모니터락(monitor lock) 매커니즘에 있습니다.
모든 자바 객체는 1개의 모니터를 가지며, synchronized는 이 모니터를 보유해 Critical Section을 보호합니다.
여기서 문제는 모니터를 보유하는 주체가 Carrier Thread 라는 것 입니다.

간단한 예시

  1. 객체 X의 synchronized 메서드를 VT₁이 실행 시작하게 되면, 모니터 소유자가 VT₁이 아니라, VT₁이 올라탄 캐리어 P₁로 기록됩니다.
  2. 메서드 안에서 I/O 블로킹이 발생하면,이 순간 VT₁을 캐리어에서 떼어내(언마운트)고 P₁을 다른 일을 하게 해야됩니다.
  3. 하지만 모니터의 소유자가 P₁ 이기 때문에 스케줄러가 VT₂P₁ 위에 올려 실행하면, JVM 관점에선 VT₂가 모니터 락을 해체해버리게 됩니다.

따라서, JVM은 synchronized 내에 있을 땐 언마운트를 금지하게되고, 결과적으로는 VT를 사용해도 병렬성이 사라지게 됩니다.

 

Pinning 문제의 두 가지 해법

 

  1. 모니터락 사용하지 않기

synchronized 대신 ReentrantLock 같은 명시적 락을 사용하면, VT가 Carrier Thread에서 안전하게 분리(unmount)됩니다.

ReentrantLock 은 모니터락을 사용하지 않으며 경합 시 스레드를 AQS 대기열로 처리


하지만 이 방법은 의존하는 모든 라이브러리의 내부 구현을 확인해야 합니다.
경우에 따라서는 기존 코드를 대규모 수정해야할 수 있기 때문에, 선택하기가 쉽지 않죠

2. JDK 24 업그레이드

다행히 JEP 491: Synchronize Virtual Threads without Pinning 이 JDK 24에 반영되었습니다.

 

JEP 491: Synchronize Virtual Threads without Pinning

JEP 491: Synchronize Virtual Threads without Pinning AuthorPatricio Chilano Mateo & Alan BatemanOwnerAlan BatemanTypeFeatureScopeImplementationStatusClosed / DeliveredRelease24Componenthotspot / runtimeDiscussionhotspot dash dev at openjdk dot org,

openjdk.org

 
JVM이 synchronized를 VT 기준으로 추적하도록 변경되어, VT가 synchronized 안에서 블록되면 언마운트하여 캐리어를 즉시 반환하게 됩니다.

즉, 기존 코드를 대규모로 수정하지 않아도 JDK 24 업그레이드만으로도 성능 개선을 얻을 수 있습니다.
(물론 JDK 버전을 올리면서 더 많은 코드를 수정해야 될 수도 있습니다.) 

실제로 동일한 코드를 JDK 24 환경에서 다시 테스트해 보니, JDK 21에서는 synchronized 구간에서 VT가 pinning 되어 싱글스레드처럼 동작하던 문제가 사라졌습니다.

덕분에 비동기 처리의 장점이 그대로 살아나면서 처리량이 크게 개선되는 것을 확인할 수 있었습니다.


 

 

성능은 좋지만, 자원 사용은? (JFR 모니터링)


성능 개선에는 성공했지만, 한 가지 의문이 남았습니다. 바로 CPU와 메모리 자원은 얼마나 쓰이고 있을까? 하는 점입니다.
VT는 가볍지만 여전히 고유한 스택 영역(2~4kb)을 갖기 때문에, 처리 속도에는 유리하더라도 메모리 측면에서 오히려 부담이 될 수 있다는 걱정이 들었습니다.
빠른 처리 속도만큼이나 자원 사용의 효율성도 중요한 문제이기에, 직접 JFR로 프로파일링을 진행하며 CPU·메모리 사용량을 면밀히 확인해 보았습니다.


I/O 중심 시나리오를 가정한 테스트였기 때문에, CPU 사용률은 전체적으로 낮게 나타났습니다.
메모리는 어떨까요?

JFR / Heap Live Objects 통계를 통해 분석해 보니, 메모리 점유 양상은 조금 의외였습니다.
처음에는 Virtual Thread 관련 객체들이 메모리의 절반 이상을 차지할 것이라 예상했지만, 실제로는 byte 배열과 String이 더 큰 비중을 차지했습니다.
즉, 생각보다 VT 자체의 메모리 부담은 크지 않았고, 오히려 데이터 처리 과정에서 발생하는 byte[], String 객체들이 주요 원인이라는 점을 확인할 수 있었습니다.

실제로 많은 점유를 하네요

 
이번 글에서는 성능과 안정성 측면을 중점적으로 다뤘지만, 다음에는 메모리 최적화와 리팩토링 과정을 구체적으로 공유해 보겠습니다.
부족한 부분도 많지만, 계속 리팩토링하고 개선해 더 나은 모습으로 다시 오겠습니다.
리팩토링하고 다시 만나요

 

Stop Writing If-Else Like a Beginner: Try This Instead

Let’s go 💻🔥 — if you’re tired of writing those long, clunky if-else blocks in Java that scream "freshman year project", this one’s for…

medium.com

 

재밌는 글을 가져왔습니다.

if (action.equals("login")) {
    login();
} else if (action.equals("logout")) {
    logout();
} else if (action.equals("register")) {
    register();
} else {
    throw new IllegalArgumentException("Unknown action");
}

이런식으로 if-else 를 쓰지 말자! 라는 글인데요
if-else 제어문은 서비스를 만들면서 안쓰이는 곳이 없다해도 무방한만큼 흥미가 생겼습니다.

몇가지 재밌는 패턴들을 소개합니다.

Map + Functional Interface (Java 8+)

Map<String, Runnable> actionMap = Map.of(
    "login", this::login,
    "logout", this::logout,
    "register", this::register
);

actionMap.getOrDefault(action, () -> {
    throw new IllegalArgumentException("Unknown action");
}).run();

Map의 Value로 Runnable 을 받는 형태입니다.
if-else 제어대신 key에 맞는 행위를 받아와 실행시키는 방식이네요

다만, Runnable은 인자와 Return 값이 없습니다.
따라서.. 실제로 사용하기에는 좀 제약사항이 많을 것 같아요

보통 DTO나 도메인을 받아서 처리하는 경우가 많을테니, 대안으로 인자를 하나 허용하는 Consumer를 사용하거나
Return 값이 필요하다면 Function 을 사용할 수 있을 것 같습니다.

혹은 Functional Interface를 Cunstom하게 만들어서 사용도 가능해보입니다.

@FunctionalInterface
public interface DoThatThing<A, B, C, D> {
    D doSomeThing(A a, B b, C c);
}

다만 커스텀해서 만드는 경우는 표준이 아니기 때문에, 오히려 관리포인트가 증가할수도 있어 보이네요.

2025/09/12 수정 
프로젝트를 진행하면서 구현해보니, 외부에서 인자값을 받는 경우 Runnable 이어도 문제가 없네요
(아래에 나오는 Switch Expressions 의 예시이기도 합니다.)

private Runnable buildTask(S3EventNotificationRecord r) {

    String eventName = r.getEventName();
    String bucket = r.getS3().getBucket().getName();
    String key = URLDecoder.decode(r.getS3().getObject().getKey(), StandardCharsets.UTF_8);

    return switch (eventName.split(":")[0]) { // "ObjectCreated" or "ObjectRemoved"
        case "ObjectRemoved" -> switch (getExtension(key)) {
            case "md" -> () -> deleteMarkdownHtmlUseCase.deleteMarkdownHtml(bucket, key);
            case "json" -> () -> deleteJsonResourceUseCase.deleteJsonResourceUseCase(bucket, key);
            default -> throw new RuntimeException("잘못된 파일 확장자입니다. 확장자는 반드시 'md' 또는 'json' 이어야 합니다.");
        };
        case "ObjectCreated" -> switch (getExtension(key)) {
            case "md" -> () -> renderMarkdownToHtmlUseCase.renderHtml(bucket, key);
            case "json" -> () -> upsertSlugFromJsonUseCase.upsertSlugFromJson(key, Slug.class);
            default -> throw new RuntimeException("잘못된 파일 확장자입니다. 확장자는 반드시 'md' 또는 'json' 이어야 합니다.");
        };
        default -> throw new RuntimeException("잘못된 S3 이벤트입니다. 이벤트는 반드시 ObjectRemoved 또는 ObjectCreated 이어야 합니다.");
    };
}


Switch Expressions (Java 14+)

switch (action) {
    case "login" -> login();
    case "logout" -> logout();
    case "register" -> register();
    default -> throw new IllegalArgumentException("Unknown action");
}

자바 14부터 추가된 Switch 문법인데요, 기존과 다르게 각 case를 독립적으로 봐서 break가 필요없습니다.
화살표 때문에 람다와 비슷해보이지만, Functional Interface가 필요하진 않습니다.

유연하게 사용이 가능해 괜찮아 보이네요

Early Return

이건 제가 자주 사용하는 방식인데요, 빠르게 Return 해주는 겁니다.

        if (str.equals("test")) {
            // doSomething
            return;
        }

        if (str.equals("test2")) {
            // doSomething
            return;
        }


        // doSomething
        return

개인적으로는 여러개의 else-if 가 있는 코드보다 가독성이 좋다 생각합니다.

        if (str.equals("test")) {

        } else if (str.equals("test2")) {

        } else if (str.equals("test3")) {

        }

만약 반복문 이라면 continue의 사용도 가능합니다.

        for (int i = 0; i < MAX_SIZE; i++) {

            if (str.equals("test")) {
                count++;
                continue;
            }

            if (str.equals("test2")) {
                count--;
                continue;
            }

            count *= 2;
        }

나의 생각

자바 17을 사용하는 환경이라면 Switch 문을 적극적으로 사용해보고
그럴 수 없다면 Map 형태로 처리하는게 명시적이고 구조화돼 보입니다.

Early Return 같은 경우는 적은 공수로 코드를 깔끔하게 만드는 정도인것 같아요

네이버웹툰 최종 탈락

네이버웹툰 개발직 최종 면접에서 탈락했습니다.
사실 결과는 8월 중순에 이미 나왔지만, 좋지 않은 소식을 굳이 기록해야 할까 싶어 미루다가 이제야 글을 씁니다.

회고를 작성하기로 마음을 바꾼 이유는 미래의 저를 위해서 입니다.
부디 더 큰 사람이 되어 돌아봤을때 "그땐 그랬지" 하며 추억에 잠기길 원하거든요.

후회가 남는지?

후회를 하지 않는다면 거짓말이겠죠, 당연히 아쉽고 시간을 되돌리고싶다는 생각도 했습니다.
하지만 엄청나게 자책하거나 억울하진 않아요, 카카오게임즈때와는 다르게 준비해간만큼 보여줬습니다.

물론 중요한 실수도 했으며, 그것이 합불을 판가름짓는 중요한 분기점이 됐겠지만.. 당시의 저에게는 최선이었습니다.
지금 다시 면접을 본다면 훨씬 더 잘할 수 있겠지만, 뭐든 부딪히면서 배우는 거니까요.

긍정적으로 살기

저는 긍정적인 사람이고 자존감도 높은편인데.
요즘은 참 쉽지 않습니다.

연달은 탈락의 고배를 마시고 있거든요

“코로나만 조금 늦게 터졌어도…”
“요즘 채용 시장이 너무 어렵다…”
“내가 중고신입이 아니라서 그래…”
이런 생각도 하고, 먼저 취업한 제 친구와 동료가 부럽고 열등감을 느끼기도 합니다.

하지만 제 자신이 부끄럽진 않습니다.
정말 노력하고 있거든요, 누구나 겪는 성장통이라 생각합니다.

면접 전, 저는 늘 스스로에게 짧은 편지를 쓰는데..
다음엔 꼭 최종 합격 후기를 쓰고 싶네요.

그럼에도 나는 개발을 한다

제가 좋아하는 말이 있습니다.
"중요한건 꺾이지 않는 마음"

힘들어도 저는 개발이 좋고 재밌고 가치있다 생각합니다.
코드를 짜고 서비스를 만들고, 누군가에게 영향을 주는 그 과정이 정말 재밌습니다.

그러니 오늘도 노트북을 켜고 코드를 짭니다.

 

Reactive Programming in Java — Good Time to Die

This article explains the reason for Reactive Programming, why it is not popular with Developers and with the introduction of Java Virtual…

medium.com

 

동기·비동기, IO·NIO, 그리고 Virtual Thread

글의 주장은 “리액티브 프로그래밍은 원래 블로킹 I/O 한계를 극복하기 위해 등장했지만, Java 21의 Virtual Thread 가 등장하면서 더 이상 필수적이지 않다고 합니다.

아래에 제 생각과 본문 내용에 대해 서술합니다.

헷갈리는 개념 1 - 동기 Vs 비동기

동기/비동기 처리는 일의 순서에 대한 이야기 입니다.

동기적인 작업의 의미는, "이 일은 순서가 정해져있어서 이대로 실행되어야 해!" 이고
비동기 작업의 의미는, "이 일은 순서가 정해져있지 않으니 따로 실행해도 괜찮아" 입니다.

예를들어 권한 확인이 필요한 작업이 있다면 선제적으로 권한 체크를 하고 후에 로직을 태우는 동기적인 처리가 필요합니다.
반면 이메일/인증 코드 등을 보내고 그것을 확인하는 작업은 같이 비동기적으로 별도로 처리되어도 상관 없는 것 이죠

헷갈리는 개념 2 - IO Vs NIO

IO/NIOThread 점유에 대한 이야기 입니다.
개발자들은 언제나 한정된 자원을 효율적으로 사용하고 싶어 합니다.

이건 Thread에 대해서도 마찬가지 인데요

기본적으로 I/O 작업이 발생하게 되면 Thread 는 Blocking 하게 됩니다.
DB에 요청보낸 데이터 혹은, 외부 API를 호출한 내용을 응답받기전까지 기다리게 됩니다.

그런데.. Thread는 비싼 자원입니다, 그래서 Pool 형태로 사용하는 것이죠
또 실질적으로 코드를 실행시키는 매우 중요한 자원이기 때문에, 어떻게하면 Thread 를 조금도 쉬지 않고 굴릴 수 있을까? 라는 고민에서 NIO가 나오게 됩니다.

Synchronous Blocking Design

전통적인 방법의 구현입니다.
사용자 요청시 스레드를 물고와 여러 I/O 작업과, CPU 작업을 하나의 스레드로 처리하는 방식입니다.

Image

Platform Thread 는 기본적으로 1MB의 스택 메모리를 사용하기 때문에 꽤나 비싼 작업입니다.
그렇기 떄문에 Thread Pool 을 만들어 재사용 하는것이죠

Asynchronous Blocking Design

직렬로 실행되는 작업을 병렬화 시키는 작업입니다.
DB 및 API에 접근해 데이터를 가져온다 했을 때, 단계마다 새로운 Thread 를 할당해 처리합니다.

Image

@Async Vs Executor

본글에서는 Executor에 대해서만 언급하지만 @Async 어노테이션 또한 존재합니다.

@Async 어노테이션은 AOP 기반으로 동작하며 Executor 와 동일하게 지정된 스레드 풀에서 스레드를 가져와 비동기적으로 처리하게 됩니다.

몇가지 차이점은 Executor 구현이 더 복잡한 만큼 세밀한 제어가 가능하다는 것 이며, @Async는 제어의 주도권을 Spring 에게 맡기는 것에 있습니다.

하지만 Thread 를 여러 개 사용하는 것이기 때문에 엔터프라이즈 환경에서는 한계가 있어 보입니다.

Reactive Style Design

CompletableFuture를 사용하면 스레드 갯수를 줄일 수 있습니다.

요청 스레드는 DB조회, API호출, 응답 전송이라는 파이프라인을 만들고 곧바로 스레드풀에 반환됩니다.(끝까지 실행하지 않고, 할 일만 넘겨줌) 그 후 별도의 스레드에서 해당 작업들이 처리됩니다.

Image

CompletableFuture<String> dbFuture =
    CompletableFuture.supplyAsync(() -> fetchFromDB()); // DB 조회

CompletableFuture<String> apiFuture =
    CompletableFuture.supplyAsync(() -> fetchFromService()); // 외부 API 호출

// 두 결과 합치고 응답 보내는 파이프라인 구성
dbFuture.thenCombine(apiFuture, (db, api) -> db + " + " + api)
        .thenAccept(result -> sendDataToUser(result));

System.out.println("Request Thread는 여기서 반환됨");

하지만 여전히 I/O 작업 발생시 Thread가 Blocking 되는 문제는 해결하지 못합니다.

Fully Reactive Style Design

전통적인 Spring MVC -> Spring Webflux 의 전환을 말합니다.

즉 NIO 를 사용하겠다는 의미이며 스레드가 I/O 작업을 기다리지 않고 다른 작업을 처리하다가
I/O 작업이 완료되면 OS 커널 레벨에서 콜백해줍니다.

다만 사용에 있어서 복잡성이 크게 증가하고 전통적인 MVC 와 스타일이 많이 달라져 신규 프로젝트가 아닌 경우 대부분은 MVC 로 구성되어 동작하는 것으로 알고있습니다.

Image

Virtual Thread

Virtual Thread (이하 VT) 입니다.
WebFlux 는 프레임워크의 변경이 필요하지만 VT 는 자바 21로 버전만 올려주면 됩니다.

    public void test() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 5; i++) {
                int id = i;
                executor.submit(() -> {
                    System.out.println("Start " + id + " in " + Thread.currentThread());
                    Thread.sleep(1000); // 블로킹처럼 보이지만 Carrier는 반환됨
                    System.out.println("End   " + id + " in " + Thread.currentThread());
                    return null;
                });
            }
        }
    }

VT 는 일회용으로 사용되며 CPU 연산을 위해 Platform Thread (이하 Carrier Thread) 에 mount 되어 사용되다, I/O 작업이 발생되면 suspend 상태로 변하게 되며 Carrier Thread 를 unmount 해, 즉기 Thread Pool 로 반환되고 다른 VT 에서 재활용할 수 있도록 합니다.

2025-09-01T20:30:07.592+09:00  INFO 61502 --- [cs-study.content] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms
Start 0 in VirtualThread[#64]/runnable@ForkJoinPool-1-worker-1
Start 1 in VirtualThread[#66]/runnable@ForkJoinPool-1-worker-2
Start 3 in VirtualThread[#68]/runnable@ForkJoinPool-1-worker-4
Start 4 in VirtualThread[#69]/runnable@ForkJoinPool-1-worker-5
Start 2 in VirtualThread[#67]/runnable@ForkJoinPool-1-worker-3
End   4 in VirtualThread[#69]/runnable@ForkJoinPool-1-worker-3
End   2 in VirtualThread[#67]/runnable@ForkJoinPool-1-worker-4
End   3 in VirtualThread[#68]/runnable@ForkJoinPool-1-worker-1
End   1 in VirtualThread[#66]/runnable@ForkJoinPool-1-worker-2
End   0 in VirtualThread[#64]/runnable@ForkJoinPool-1-worker-5

처음 VT를 물고간 worker (carrier thread)의 변경이 있음을 볼 수 있습니다.
이는 스레드가 I/O 작업을 대기하는 것이 아님을 증명합니다.

Image

동기적으로 짜여진 코드를 NIO 로 간단하게 처리 가능하다니.. 아름답네요

+ Recent posts