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() 하려는 데이터가 너무 많다면, 버퍼가 다시 준비될 때까지 스레드가 블로킹될 수 있습니다.

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

 

 

 

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)을 검증하고
  • 엣지 케이스와 장애 상황까지 고려하며
  • 구현이 바뀌어도 쉽게 깨지지 않는 테스트

라고 생각합니다.

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

 

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 같은 경우는 적은 공수로 코드를 깔끔하게 만드는 정도인것 같아요

 

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 로 간단하게 처리 가능하다니.. 아름답네요

마무리

다만 본글에서 말하는 VT 와 같은 도구들로 인해 Webflux 같은 기술들은 생명이 다했다 라는 부분은
동의할 수 없을 것 같습니다.

이유는... 제가 Webflux 에 대해 깊은 이해가 없어서 정확히 어떤점이 해결되는지 모르기 떄문입니다.

다만, VT와 Webflux 모두 스레드가 고정적이지 않아 스레드에 의존적인 코드가 있다면 문제가 발생할 수 있을 것 같습니다.
e.g) ThreadLocal 

 

 

𝗪𝗵𝘆 𝗶𝘀 𝗠𝗮𝗽 𝗡𝗼𝘁 𝗜𝘁𝗲𝗿𝗮𝗯𝗹𝗲 𝗶𝗻 𝗝𝗮𝘃𝗮?— Most Asked Interv

“Why is Map not directly Iterable in Java?”

blog.stackademic.com

 

왜 Map은 반복 가능하지 않은가?

이 글에서는 Map의 반복 가능하지 않도록 설계된 이유에 대해서 말합니다.
이를 확장해 class, interface 상속에 대한 내용도 추가해 놓았습니다.


Java Collection 프레임워크의 구조를 보면 알 수 있듯이, MapCollection이 아니며 Iterable 또한 상속받지 않습니다.
(Iterable 을 상속의 의미는 Enhanced For Loop 를 사용할 수 있음을 말합니다)

위의 글에서는 왜 "MapIterable 하지 않은가?" 에 대해 말합니다.
주된 주장은 MapCollection, 즉 요소의 집합이 아니며 Pair의 묶음이라는 것 입니다.
Map은 기본적으로 key-value 형태의 쌍이기 때문에, 명시하지 않는다면 어떤 원소값의 반복인지 알 수 없습니다.

  1. key 값을 반복
  2. value를 반복
  3. key-value 쌍을 반복

이렇게 다양한 옵션들이 있기 때문에 정확히 무엇을 순회할건지를 알 수 없는 모호함이 발생한다는 것으로
정확히 어떤것을 순회할지를 나타낸 후에야 Iterable해지는 것을 알 수 있습니다.

Map<String, Integer> scores = new HashMap<>();
scores.put("Madhavi", 90);
scores.put("Kiran", 85);
scores.put("Anita", 88);

// Iterate over entrySet
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}
// Iterate over keys
for (String key : scores.keySet()) {
    System.out.println("Key: " + key);
}
// Iterate over values
for (Integer value : scores.values()) {
    System.out.println("Value: " + value);
}

class를 여러개 상속 받을 수 없는 비슷한 이유

이것과 비슷한 개념으로 class의 상속이 존재하는데요
class는 기본적으로 상태를 가지게 됩니다.

상태를 가진다는 것은 Field 값이 존재한다는 것이고, method 를 통해 이를 조작하게 됩니다.
재밌는 상상을 해볼까요?

만약 dragon, bird 두 개의 클래스를 상속받는다 가정하고
각각이 fly() 라는 메서드와 ,wings 라는 필드가 존재한다고 하겠습니다.

class Bird{

    int wings;

    public void fly(){
        // do something
    }

}

class Dragon{
    int wings;

    public void fly(){
        // do something
    }
}

class bat extends Dragon, Bird{
    // 누구의 날개를 사용해서 어떻게 날 것인가?
    // error 
}

이를 상속받은 클래스에서는 과연 누구의 wings를 가지고 어떻게 fly() 연산을 수행해야 할까요?
이렇게 직접적인 구현을 가지고 있는 클래스는 여러개를 상속받게 하는 순간 모호함에서 오는 이상이 발생할 수 있기 때문에
자바에서는 원칙적으로 막아두는 것 입니다.

반면에 interface는 상태를 가지지않고, 그저 어떤 연산이 가능한지만 명시해놓는 명세서입니다.
따라서 여러개를 상속받아도 모호함에서 오는 이상이 없는 것 이죠

그렇기 때문에 interface의 경우는 여러개를 상속받는 것을 막아두지 않습니다.

 

 

Why Java Streams Have No Place in Production — Here’s the Brutal Truth Nobody Admits

Introduction:

medium.com

 

Stream 성능문제에 관한 글

본 글에서는 For loop 연산에서 Stream으로 변경하니 더 많은 메모리와, GC부담, 낮은 성능이 발생했다는 말을 합니다.

변경 전

    public List<TransactionSummary> processTransactions(List<Transaction> transactions) {
        List<TransactionSummary> summaries = new ArrayList<>();
        for (Transaction txn : transactions) {
            if (txn.getAmount() > 0) {
                double fee = calculateFee(txn);
                summaries.add(new TransactionSummary(txn.getId(), txn.getAmount(), fee));
            }
        }
        summaries.sort(Comparator.comparing(TransactionSummary::getAmount).reversed());
        return summaries;
    }

변경 후

    public List<TransactionSummary> processTransactions(List<Transaction> transactions) {
        return transactions.stream()
                .filter(transaction -> transaction.getAmount() > 0)
                .map(transaction -> {
                    double fee = calculateFee(transaction);
                    return new TransactionSummary(transaction.getId(), transaction.getAmount(), fee);
                })
                .sorted(Comparator.comparing(TransactionSummary::getAmount).reversed())
                .collect(Collectors.toList());
    }

느려진 이유로는 filter, map, sorted 연산을 거칠 때 마다 변환된 temp List 가 생기게되고
이로인해 연산에 더 많은 메모리와, 시간이 필요하다는 주장입니다.

그런데 조금 의문인 것이, 자바 패러다임 자체가 함수형으로 작성하는 방향으로 가고있으며
대규모 트래픽을 다루는 조직에서도 Stream을 적극적으로 사용하는 추세인데.. 더 느리다면 왜 사용할까요?

중간 연산 Vs 최종 연산

Stream은 중간 연산과 최종 연산의 개념이 존재합니다.

대부분의 중간 연산에서는 Lazy Evaluation이 발생하며, 이는 연산의 절차를 기록만 하다가 최종 연산(collect/forEach)등 을 만날때에 실제로 실행됨을 의미합니다.

따라서 연산 단계마다 변환된 temp List가 생기는 것이 아니라 한 요소에 대해서 filter, map 연산을 적용하고 이를 최종 연산에 전달하게 됩니다.

따라서 중간 연산을 거칠때마다 temp list가 발생해 느려졌다는 주장은 틀렸습니다.

그럼 왜 느려졌는가?

대부분의 중간 연산에서는 Lazy Evaluation 처리를 합니다.
즉, 모든 중간 연산이 지연 평가가 발생하는 것은 아닌데요

위에서 사용된 sorted 연산은 정렬하는 중간 연산입니다.
하지만 정렬의 특성상 앞서 나온 모든 요소가 필요하며, 실제로 map 연산을 거친 원소를 최종 연산에 바로 전달하는 것이 아닌
모든 원소에 map을 적용한 결과를 메모리에 쌓은 후 sorted하게 됩니다.

For loop 에서는 전체에 대해서 변환 작업을 실행한 후 sort를 시행하기 때문에 성능이 잘 나오는 것으로
Stream 마지막에 따로 sort 연산을 한다면 비슷한 성능이 나오게 됩니다.

굉장히 자주 사용하지만, 모호한 부분들이 많은 것 같아요.

 

We Used List Everywhere — And It Broke Our API Design

And What Happened When We Finally Refactored Ours

medium.com

 

개발을 하다보면 수많은 곳에서 List 형태를 사용합니다.

왜 그런가?

  1. 쓰기 쉽다
  2. 대부분 효과 있음
  3. Collection Framework 의 일환으로 다양한 기능 제공

일종의 기본 소양처럼 사용하곤 합니다.

또 List 라는 자료구조는.. 다음을 내포합니다

  1. 순서가 중요하다.
  2. 변경 가능하다.
  3. 인덱스 접근이 예상된다.
public void sendNotifications(List<User> users) {
    for (User u : users) {
        notificationService.sendEmail(u);
    }
}

하지만 순서가 필요하지 않다면?, 인덱스 접근이 필요 없고 단순 순회만 한다면?
리스트를 사용해야할 이유가 있을까?, 또한 저 코드는 인자로 들어온 것이 가변 리스트인지, 불변 리스트인지 구분할 수 없습니다.

이러한 이유 때문에 글에서는  List<T>를 사용하기 보다는Collection<T>Interface에 의존하거나
Iteratable 을 메서드 시그니처로 사용하자고 주장합니다.

public void sendNotifications(Collection<User> users);
public void sendNotifications(Iterable<User> users);

 

public void sendNotifications(Stream<User> users);

이런식으로 코드 작성이 가능하겠죠

개인적으로는 Iterable<T> 이 마음에 드는데요, 단순하게 순회만 할꺼야! 라는 의도가 굉장히 명시적이라 생각합니다. 
또 들어오는 인자에 대해서 수정을 가하는 행동이 없음으로 가변/불변 리스트등의 사용에서도 자유롭지않을까요? 

글에서는 다음과 같은 체크리스트를 통해서 List<T>의 사용 여부를 다룹니다.

When You Should Actually Use List

There are valid cases! Use List<T> when:

You need to access elements by index (list.get(3))
You care about the exact order of items
You plan to mutate the collection (add/remove/sort)
You explicitly want to signal that caller should pass a List
Otherwise? Don’t.

✅ Our Refactor Checklist
Here’s how we audited all our method signatures:

Does the method use .get(index)? → Keep List<T>
Does the method add/remove elements? → Keep List<T>
Only iterates or filters? → Switch to Collection<T> or Iterable<T>
Works best with lazy evaluation or large data? → Use Stream<T>

Collection & Iterable 

Image

최상위에 Iterable 존재, Collection 은 이를 상속

List,Set,QueueCollection을 상속받고 Iterable

Image

따라서... Map의 경우 에러가 나는 모습을 볼 수 있습니다.

이런식으로 Collection으로 지정한다면, 확실히 Set,Queue 등 다양한 자료구조가 하나의 메서드를 재사용할 수 있음으로 장점이 있고

호출하는 쪽에서도 별도의 자료구조 변환을 해주지 않아도 사용 가능하기 때문에 충분히 매력적인 선택지라고 생각됩니다.

 

 

The Results Shocked Me — I Tried Writing Java Like It’s 2025

Legacy who? I pushed Java to the future — and it pushed back.

medium.com

 

자바를 2025년 답게 쓰자는 글 입니다.

몇몇 공감가고, 도입예정인 부분들에 대해서만 코멘트 남기려 합니다.

✨ Switched to Virtual Threads — Everything Changed

Virtual Thread (이하 VT)에 대한 사용이 가장 먼저 나오는데, 기본적인 개념은 있지만 사용해 본적은 없어
Dawn-Cs-Study 프로젝트를 하면서 사용해볼까 합니다.

VT의 개념자체가 자바가 사용하는 OS Thread를 VT를 핸들링하는 Carrier Thread로 사용하여 
한정된 스레드 갯수로 더 많은 스레드를 태우겠다는 것으로 알고있습니다.

 

Self Study • masiljangajji

Self Study

github.com

아래는 VT 도입 이전, Thread, Executor 관련해 공부하면서 남긴 내용들입니다.

Thead 쓸 때마다 생성해서 쓰면 안되나요?

기존에는 Thread 객체를 직접 생성해서 start() 해야 했음

Thread thread = new Thread(() -> {
    System.out.println("Hello World!");
    });

thread.start();  // 새로운 스레드 생성 후 실행 
thread.run();  // 기존 요청을 물고온 스레드로 실행 (스레드 생성 X)

이 방법은 조금 머리아픈게.. new Thread() 할 때마다 스레드가 생성 됨
start() 실행이 끝나면 OS Thread는 즉시 반납되지만, 자바 Thread 객체는 힙에남아서 공간을 점유함

또 한 번 실행이 끝난 Thread 객체는 다시 start() 못하며, 몇 개의 스레드가 만들어졌는지 예상하기 어려움.
실행자체는 큰 문제가 없을 수 있으나, 관리의 측면에서 생산성이 매우 떨어진다.

Executor Vs Executors

Thread 실행 정책을 추상화 하여 만든 Interface
new Thread() 를 직접 다루지 않도록 하기 위해 도입됨

public interface Executor {
    void execute(Runnable command);
}

스레드 생성, 스케줄링 전략은 구현체에 위임합니다.
ThreadPoolExecutor, ScheduledThreadPoolExecutor 등이 대표적인 구현체

        return new ThreadPoolExecutor(
                core,
                max,
                60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(queueCapacity),
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

그런데.. ThreadPoolExecutor는 생성자 파라미터가 너무 많아서 설정이 귀찮습니다.
그렇기 때문에 자주 쓰는 패턴을 간단히 팩토리 메서드로 제공하기 위해 Executors 유틸 클래스가 만들어집니다.

Executors.newFixedThreadPool(int nThreads);   // 고정 크기 스레드풀
Executors.newCachedThreadPool();              // 필요시 스레드 생성, 유휴 시 종료
Executors.newSingleThreadExecutor();          // 1개 스레드만 사용
Executors.newScheduledThreadPool(int core);   // 주기적 실행 지원

하지만.. 실무에서는 보통 세부적인 커스텀을 하기 때문에 ThreadPoolExecutor 만들어 쓰는 경우가 더 많습니다.

여기서 한발자국 더 나아가면 이제는 OS Thread 하나에 여러 VT를 태워서 쓰는 방식으로 갑니다.
더 적은 Thread로 더 많은 작업을 수행할 수 있겠죠?

🧬 Embraced Records + Sealed Classes

레코드를 사용하자! 너무나 맞는 말입니다.
보통 DTO를 다룰때 Record 사용하거나 VO 같은 녀석들을 다룰때 사용하지 않나 싶습니다.

개인적으로는 불변성을 지켜줘야 한다!, 얘는 불변해!, 이런 의미를 내포하고 싶을때도 사용합니다.

sealed 키워드는 사용은 안해봤는데요, 이번 기회에 알아봤습니다.

이 타입이 어떤 클래스/인터페이스가 상속할 수 있는지를 명시적으로 제한합니다.

public sealed interface Event 
    permits LoginEvent, LogoutEvent {}

public final class LoginEvent implements Event {
    // 더 확장 불가
}

public final class LogoutEvent implements Event {
    // 더 확장 불가
}

이런식으로 구현이 되어지는데, sealed의 구현체는 항상 final, sealed, non-sealed 중 하나여야 합니다.

언제 유용한가?

sealed는 하위 타입의 집합이 닫혀있음을 의미합니다.
따라서...

public sealed interface Event permits LoginEvent, LogoutEvent {}

public final class LoginEvent implements Event {}
public final class LogoutEvent implements Event {}

LoginEvent,LogoutEvent 외에 다른 이벤트가 나오면 안되는 경우, 컴파일 타임때 Event 를 구현하는 상황을 막을 수 있습니다.
DDD에서는 클래스들을 집합화 하고, 이를 Context로 묶는 경향이 있기 때문에 DDD를 도입한다면 명시적으로 나타내기 좋지 않을까? 생각도 듭니다.

또한 switch문과 결합시키는 패턴도 유용합니다.

sealed interface Event permits LoginEvent, LogoutEvent, SignUpEvent {}

record LoginEvent(String email) implements Event {}
record LogoutEvent(Long userId) implements Event {}
record SignUpEvent(String email, String nickname) implements Event {}

public class EventHandler {

    public String handle(Event e) {
        return switch (e) {
            case LoginEvent login   -> "로그인 처리: " + login.email();
            case LogoutEvent logout -> "로그아웃 처리: " + logout.userId();
            case SignUpEvent signUp -> "회원가입 처리: " + signUp.email();
        };
    }

    public static void main(String[] args) {
        EventHandler handler = new EventHandler();

        System.out.println(handler.handle(new LoginEvent("user@test.com")));
        System.out.println(handler.handle(new LogoutEvent(42L)));
        System.out.println(handler.handle(new SignUpEvent("new@test.com", "승재")));
    }
}

또한 Pattern Matching for switch문법은 JDK 17은 preview고 21부터 정식지원입니다.

+ Recent posts