Spring WebFlux 이해하기

코-드 텐카이·2025년 4월 6일

Spring Boot

목록 보기
10/10

리액티브 프로그래밍과 서블릿 API 이해하기

WebFlux를 이해하기 위해서는 먼저 리액티브 프로그래밍과 기존 서블릿 API의 차이를 이해하는 것이 중요합니다.

리액티브 프로그래밍이란?

리액티브 프로그래밍은 데이터 스트림과 변화의 전파에 중점을 둔 비동기 프로그래밍 패러다임입니다. 쉽게 말해서, 데이터가 생성되거나 변경될 때 이를 관찰하고 있다가 반응하는 방식으로 동작합니다.

리액티브 프로그래밍의 핵심 특징은 다음과 같습니다:

  1. 비동기-논블로킹 처리: 작업이 완료될 때까지 기다리지 않고 다른 작업을 수행합니다.
  2. 데이터 스트림: 모든 것(이벤트, 메시지, 호출 등)을 데이터 스트림으로 간주합니다.
  3. 생산자-소비자 모델: 데이터를 생산하는 쪽과 소비하는 쪽으로 구분됩니다.
  4. 백프레셔(Backpressure): 소비자가 처리할 수 있는 속도에 맞춰 데이터 흐름을 조절합니다.

서블릿 API란?

서블릿 API는 Java에서 웹 애플리케이션을 개발할 때 사용하는 표준 인터페이스입니다. 클라이언트의 요청을 받아 처리하고 응답을 생성하는 서버 측 컴포넌트를 개발하기 위한 규약입니다.

서블릿 API의 주요 특징은 다음과 같습니다:

  1. 요청-응답 모델: 하나의 요청에 대해 하나의 응답을 생성하는 구조입니다.
  2. 동기-블로킹 방식: 전통적인 서블릿은 요청당 하나의 스레드를 할당하고, 응답이 완성될 때까지 해당 스레드가 블로킹됩니다.
  3. 컨테이너 관리: 서블릿은 톰캣, 제티 같은 서블릿 컨테이너에 의해 생명주기가 관리됩니다.

Spring WebFlux란?

Spring WebFlux는 Spring 5에서 도입된 리액티브 웹 프레임워크입니다. 전통적인 서블릿 API를 사용하는 Spring MVC와 달리, WebFlux는 비동기-논블로킹 방식으로 동작하며 Project Reactor를 기반으로 합니다.

Project Reactor란?

Project Reactor는 Spring WebFlux의 핵심 기반이 되는 리액티브 프로그래밍 라이브러리입니다. JVM에서 동작하는 완전한 비동기-논블로킹 리액티브 프로그래밍 기반을 제공합니다.

Project Reactor의 주요 특징:

  1. 리액티브 스트림 구현체:

    • 리액티브 스트림 사양(Reactive Streams Specification)을 완전히 구현
    • Publisher, Subscriber, Subscription, Processor 인터페이스 지원
  2. 핵심 타입:

    • Mono<T>: 0 또는 1개의 결과를 표현하는 리액티브 타입
    • Flux<T>: 0에서 N개의 결과를 표현하는 리액티브 타입
  3. 풍부한 연산자:

    • 변환: map, flatMap, reduce
    • 필터링: filter, distinct, take
    • 결합: zip, merge, concat
    • 에러 처리: onErrorResume, retry, timeout
  4. 스케줄러:

    • Schedulers.immediate(): 현재 스레드에서 실행
    • Schedulers.single(): 단일 재사용 가능한 스레드에서 실행
    • Schedulers.elastic(): 신축적인 스레드 풀에서 실행 (deprecated)
    • Schedulers.boundedElastic(): 제한된 신축적 스레드 풀에서 실행
    • Schedulers.parallel(): 고정 크기 워커 풀에서 실행

간단한 Project Reactor 예시:

// 간단한 데이터 스트림 생성 및 처리
Flux<String> names = Flux.just("홍길동", "김철수", "이영희");
// 이 부분은 세 개의 문자열("홍길동", "김철수", "이영희")을 포함하는 Flux 스트림을 생성합니다.
// Flux는 0개에서 N개의 데이터를 발행할 수 있는 Publisher입니다.

names.map(String::toUpperCase)
     .filter(name -> name.length() > 2)
     .subscribe(
         name -> System.out.println("Name: " + name),
         error -> System.err.println("Error: " + error),
         () -> System.out.println("Completed")
     );
// map 연산자는 스트림의 각 항목을 변환합니다. 
// 여기서는 각 이름을 대문자로 변환합니다. 

// filter 연산자는 조건을 만족하는 항목만 통과시킵니다. 
// 여기서는 길이가 2보다 큰 이름만 선택합니다. 모든 이름이 2글자 이상이므로 모두 통과될 것입니다.

// subscribe 메서드는 세 개의 콜백을 받습니다:
// 첫 번째 콜백: 각 데이터 항목이 발행될 때 호출됨 (onNext)
// 두 번째 콜백: 오류가 발생했을 때 호출됨 (onError)
// 세 번째 콜백: 스트림이 정상적으로 완료되었을 때 호출됨 (onComplete)

// 중요한 점:

// 지연 실행: 위 코드에서 map과 filter 연산자를 호출해도 실제 처리는 일어나지 않습니다. 
// subscribe가 호출될 때까지 실제 데이터 처리는 지연됩니다.
// 선언적 스타일: 무엇을 처리할지 명령적으로 지시하는 대신, 데이터 스트림을 어떻게 변환하고 처리할지 선언적으로 정의합니다.

WebFlux는 다음과 같은 특징을 가지고 있습니다:

  • 비동기-논블로킹 I/O: 적은 수의 스레드로 많은 요청을 처리
  • 리액티브 스트림 API: Publisher, Subscriber, Subscription, Processor 인터페이스 기반
  • 백프레셔(Backpressure): 데이터 소비자가 처리할 수 있는 속도로 데이터 생산 제어
  • 함수형 스타일 프로그래밍: 선언적 프로그래밍 방식 지원

기존 Spring MVC와의 차이점

Spring MVC와 WebFlux의 주요 차이점을 알아보겠습니다:

특징Spring MVCSpring WebFlux
I/O 모델동기-블로킹비동기-논블로킹
기반 기술서블릿 API리액티브 스트림
서버톰캣, 제티 등네티, 언더토우, 톰캣, 제티 등
프로그래밍 모델명령형함수형, 리액티브
데이터 처리컬렉션, 단일 값Flux<T>, Mono<T>

Spring MVC는 요청당 하나의 스레드를 할당하는 모델로, 대량의 동시 요청을 처리할 때는 스레드 수가 증가하여 컨텍스트 스위칭 비용과 메모리 사용량이 증가합니다. 반면 WebFlux는 이벤트 루프 모델을 사용하여 적은 수의 스레드로 많은 요청을 처리할 수 있습니다.

[Spring MVC]
클라이언트 요청 -> 서블릿 스레드 할당 -> 블로킹 처리 -> 응답 -> 스레드 반환

[Spring WebFlux]
클라이언트 요청 -> 이벤트 루프에 등록 -> 비동기 처리 -> 콜백으로 응답

HTTP 연결 유지(Connection Keep-Alive) 개념

HTTP/1.0의 한계

초기 HTTP/1.0에서는 기본적으로 요청마다 새로운 TCP 연결을 열고 응답 후 즉시 닫는 방식이었습니다. 이는 매 요청마다 TCP 연결 설정(3-way handshake)과 종료 과정이 필요해 오버헤드가 컸습니다.

HTTP/1.1의 개선 - Keep-Alive

HTTP/1.1(1997년)부터 Keep-Alive가 기본 기능으로 도입되었습니다. 이는 하나의 TCP 연결을 여러 HTTP 요청/응답에 재사용하는 기술입니다.

Client                   Server
   |                        |
   |------- 요청 1 -------->|
   |<------ 응답 1 ---------|
   |                        | (연결 유지)
   |------- 요청 2 -------->|
   |<------ 응답 2 ---------|
   |                        | (연결 유지)
   |------- 요청 3 -------->|
   |<------ 응답 3 ---------|

HTTP/1.1 요청 헤더:

Connection: keep-alive

HTTP/1.1에서는 이 설정이 기본값이므로 명시적으로 지정하지 않아도 됩니다.

HTTP/1.1의 한계

HTTP/1.1은 연결 유지를 지원했지만, "Head-of-Line Blocking" 문제가 있었습니다. 하나의 연결에서 여러 요청을 보내도 응답은 순서대로 받아야 했기 때문에, 앞선 요청의 처리가 지연되면 뒤의 요청도 함께 지연되었습니다.

HTTP/2의 발전 - 멀티플렉싱

HTTP/2(2015년)는 연결 유지 개념을 더 발전시켰습니다. 단일 TCP 연결 내에서 여러 요청과 응답을 동시에 처리할 수 있는 멀티플렉싱(multiplexing)을 도입했습니다.

Client          한 TCP 연결          Server
   |                                   |
   |------- 요청 1 ------------------->|
   |------- 요청 2 ------------------->| (동시 처리)
   |------- 요청 3 ------------------->|
   |                                   |
   |<-------- 응답 2 ------------------|
   |<-------- 응답 1 ------------------|
   |<-------- 응답 3 ------------------|

HTTP/2의 주요 특징

  • 스트림: 하나의 TCP 연결 내에서 여러 독립적인 양방향 데이터 흐름
  • 멀티플렉싱: 여러 요청/응답을 병렬로 처리
  • 헤더 압축: HPACK 압축으로 중복 헤더 전송 최소화
  • 서버 푸시: 클라이언트 요청 없이도 서버가 리소스 전송 가능

WebFlux와의 관계

WebFlux는 이러한 HTTP 프로토콜의 특성을 활용하여 비동기-논블로킹 방식으로 요청을 처리합니다:

  1. HTTP/1.1 Keep-Alive로 연결 재사용: 연결 설정 오버헤드 감소
  2. HTTP/2 멀티플렉싱으로 병렬 요청 처리: 효율적인 리소스 사용
  3. 비동기 처리와 결합: 제한된 수의 스레드로 많은 연결 관리 가능

따라서, HTTP 연결 유지 기능은 오래전부터 존재했던 개념이며, WebFlux는 이런 기존 프로토콜 기능을 최대한 활용하여 효율적인 비동기 웹 애플리케이션을 구현할 수 있게 해줍니다.

비동기 스트리밍의 구조적 이해

비동기 스트리밍은 데이터를 하나의 큰 덩어리로 처리하는 것이 아니라, 작은 조각으로 나누어 처리하는 방식입니다. 이를 통해 메모리 효율성을 높이고 응답성을 개선할 수 있습니다.

전통적인 접근 방식 vs 비동기 스트리밍

전통적인 접근 방식:
1. 클라이언트가 요청 보냄
2. 서버가 전체 데이터를 메모리에 로드
3. 데이터 처리 완료 후 전체 응답 반환
4. 클라이언트가 응답 받음

클라이언트 ---- 요청 ----> 서버
           <--- 대기 ----
           <--- 전체 응답 ----

비동기 스트리밍 방식:
1. 클라이언트가 요청 보냄
2. 서버가 데이터를 조금씩 처리하면서 바로 전송
3. 클라이언트가 데이터를 수신하는 동시에 처리 시작
4. 모든 데이터가 전송 완료될 때까지 반복

클라이언트 ---- 요청 ----> 서버
           <--- 데이터 조각 1 ----
           <--- 데이터 조각 2 ----
           <--- 데이터 조각 3 ----
           ...
           <--- 데이터 조각 N ----
           <--- 완료 신호 ----

비동기 스트리밍 처리 과정

  1. 발행(Publish): 데이터 소스에서 데이터를 생성
  2. 변환(Transform): 필요에 따라 데이터 필터링, 매핑, 결합 등의 처리
  3. 구독(Subscribe): 최종 소비자가 처리된 데이터를 수신
  4. 제어(Control): 백프레셔를 통해 데이터 흐름 속도 조절

이벤트 루프 모델

비동기 스트리밍은 이벤트 루프 모델을 기반으로 합니다:

       +-------------+
       | 이벤트 루프  |
       +-------------+
              |
  +------------------------+
  |                        |
+-------+    +-------+    +-------+
| 작업 1 |    | 작업 2 |    | 작업 3 |
+-------+    +-------+    +-------+
  1. 이벤트 루프는 지속적으로 실행되며 작업 큐를 감시
  2. 새로운 이벤트(I/O 완료, 타이머 등)가 발생하면 해당 콜백을 큐에 추가
  3. 이벤트 루프는 큐에서 작업을 꺼내 실행
  4. 블로킹 작업이 없어 스레드가 대기 상태로 전환되지 않음

리액티브 스트림

리액티브 스트림은 비동기 스트림 처리를 위한 표준으로, 다음 네 가지 인터페이스를 정의합니다:

// 데이터를 생성하고 발행하는 역할
public interface Publisher<T> {
    void subscribe(Subscriber<? super T> subscriber);
}

// 발행된 데이터를 구독하는 역할
public interface Subscriber<T> {
    void onSubscribe(Subscription s);
    void onNext(T t);
    void onError(Throwable t);
    void onComplete();
}

// 발행자와 구독자 사이의 연결을 제어
public interface Subscription {
    void request(long n);  // 백프레셔 구현의 핵심
    void cancel();
}

// 데이터를 변환하는 역할
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {
}

백프레셔(Backpressure)

백프레셔는 구독자가 처리할 수 있는 속도보다 발행자가 데이터를 빠르게 생성할 때, 구독자가 처리 가능한 양을 제어하는 메커니즘입니다. 이를 통해 시스템 과부하를 방지할 수 있습니다.

백프레셔 동작 원리

  1. PULL 모델: 구독자가 발행자에게 처리할 수 있는 데이터 양을 요청
  2. 요청 기반 흐름 제어: Subscription.request(n) 메서드로 n개의 항목 요청
  3. 동적 조절: 처리 속도에 따라 요청 양을 조절
[빠른 생산자]
     |
     | (초당 1000개 생성)
     v
[백프레셔 메커니즘]
     | Subscription.request(100) // 100개만 요청
     v
[느린 소비자]
     | (초당 100개 처리)

백프레셔가 없는 경우:

  • 구독자의 메모리가 부족해질 수 있음
  • OutOfMemoryError 발생 가능
  • 시스템 응답성 저하

백프레셔 전략:

  • 버퍼링: 초과 데이터를 버퍼에 저장
  • 드롭: 처리할 수 없는 데이터 폐기
  • 최신 데이터 유지: 가장 최근 데이터만 유지
  • 에러 발생: 과부하 상태에서 에러 발생

Mono와 Flux 이해하기

WebFlux는 Project Reactor 라이브러리를 사용하며, MonoFlux 두 가지 핵심 타입이 있습니다.

Mono

Mono는 0 또는 1개의 결과를 표현하는 리액티브 타입입니다. Optional과 유사하지만, 비동기-논블로킹 방식으로 동작합니다.

// 단일 값을 가지는 Mono 생성
Mono<String> mono = Mono.just("Hello");
// 이것은 정상적으로 하나의 값을 발행하는 Mono입니다
// 구독하면 onNext("Hello")가 호출되고 이어서 onComplete()가 호출됩니다

// 비어있는 Mono 생성
Mono<String> emptyMono = Mono.empty();
// 이것은 값 없이 즉시 완료되는 Mono입니다
// 구독하면 onNext() 없이 바로 onComplete()가 호출됩니다
// API 호출 결과가 없거나, 조건에 맞는 데이터가 없을 때 사용합니다

// 에러를 포함하는 Mono 생성
Mono<String> errorMono = Mono.error(new RuntimeException("에러 발생"));
// 이것은 오류를 발행하는 Mono입니다
// 구독하면 onNext() 없이 바로 onError(exception)가 호출됩니다
// 비즈니스 로직 오류나 네트워크 문제 등을 표현할 때 사용합니다

// Mono 구독하기
mono.subscribe(
    value -> System.out.println("값: " + value),
    error -> System.err.println("에러: " + error.getMessage()),
    () -> System.out.println("완료")
);

Flux

Flux는 0에서 N개의 결과를 표현하는 리액티브 타입입니다. Java의 Stream과 유사하지만, 비동기-논블로킹 방식으로 동작하며 백프레셔를 지원합니다.

// 여러 값을 가지는 Flux 생성
Flux<String> flux = Flux.just("사과", "바나나", "오렌지");
// 이 방식은 명시적으로 지정한 요소들로 구성된 Flux를 생성합니다.
// 구독하면 "사과", "바나나", "오렌지"를 순서대로 발행한 후 완료됩니다.
// 고정된 데이터 집합으로 작업할 때 유용합니다.
  
// 범위의 값을 생성하는 Flux
Flux<Integer> rangeFlux = Flux.range(1, 5);  // 1, 2, 3, 4, 5
// 이 방식은 지정된 범위의 정수값을 순서대로 발행하는 Flux를 생성합니다.
// 시작값(1)부터 시작해서 개수(5)만큼의 값을 생성합니다.
// 구독하면 1, 2, 3, 4, 5를 순서대로 발행한 후 완료됩니다.
// 페이지네이션이나 반복적인 작업에 유용합니다.

// 시간 간격으로 값을 생성하는 Flux
Flux<Long> intervalFlux = Flux.interval(Duration.ofSeconds(1));  // 매 초마다 값 생성
// 이 방식은 일정 시간 간격으로 값을 발행하는 Flux를 생성합니다.
// 시작부터 매 초마다 0, 1, 2, 3, ... 등의 증가하는 Long 값을 발행합니다.
// 중요한 점은 이 Flux는 자동으로 완료되지 않습니다(무한 스트림).
// 주기적인 작업이나 폴링, 타이머 기반 이벤트에 유용합니다.
  
  
// Flux 구독하기
flux.subscribe(
    value -> System.out.println("값: " + value),
    error -> System.err.println("에러: " + error.getMessage()),
    () -> System.out.println("완료")
);

연산자(Operators)

Mono와 Flux는 다양한 연산자를 제공합니다:

Flux<String> names = Flux.just("홍길동", "김철수", "이영희", "박영수");

// 필터링
Flux<String> filteredNames = names.filter(name -> name.length() > 2);

// 변환
Flux<Integer> nameLengths = names.map(String::length);

// 데이터 조합
Flux<String> combined = Flux.zip(
    names, 
    nameLengths,
    (name, length) -> name + ": " + length + "글자"
);

// 에러 처리
Flux<String> withErrorHandling = names
    .flatMap(name -> {
        if (name.equals("김철수")) {
            return Mono.error(new RuntimeException("김철수는 거부됨"));
        }
        return Mono.just(name);
    })
    .onErrorResume(e -> Mono.just("알 수 없음"));

간단한 WebFlux 애플리케이션 만들기

이제 간단한 WebFlux 애플리케이션을 만들어보겠습니다. 먼저 의존성을 추가해야 합니다:

// Gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
}

간단한 컨트롤러 만들기

import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/books")
public class BookController {
    
    private final BookRepository bookRepository;
    
    public BookController(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }
    
    @GetMapping
    public Flux<Book> getAllBooks() {
        return bookRepository.findAll();
    }
    
    @GetMapping("/{id}")
    public Mono<Book> getBookById(@PathVariable String id) {
        return bookRepository.findById(id)
            .switchIfEmpty(Mono.error(new BookNotFoundException(id)));
    }
    
    @PostMapping
    public Mono<Book> createBook(@RequestBody Book book) {
        return bookRepository.save(book);
    }
    
    @DeleteMapping("/{id}")
    public Mono<Void> deleteBook(@PathVariable String id) {
        return bookRepository.deleteById(id);
    }
}

함수형 엔드포인트로 라우팅 정의하기

WebFlux는 애노테이션 기반 컨트롤러 외에도 함수형 엔드포인트 스타일을 지원합니다.
함수형 엔드포인트 스타일은 WebFlux에서만 제공하는 독특한 기능이며, Spring WebFlux를 사용하는 경우에만 이용할 수 있습니다.
Spring MVC에서는 애노테이션 기반 방식만 사용 가능합니다.
리액티브 모델에서 함수형 엔드포인트는 비동기 이벤트 흐름을 더 잘 표현하기 위한 선택지입니다.
WebFlux에서 애노테이션 방식도 여전히 사용할 수 있습니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.*;

@Configuration
public class BookRouter {
    
    @Bean
    public RouterFunction<ServerResponse> bookRoutes(BookHandler bookHandler) {
        return RouterFunctions
            .route(RequestPredicates.GET("/functional/books"), bookHandler::getAllBooks)
            .andRoute(RequestPredicates.GET("/functional/books/{id}"), bookHandler::getBookById)
            .andRoute(RequestPredicates.POST("/functional/books"), bookHandler::createBook)
            .andRoute(RequestPredicates.DELETE("/functional/books/{id}"), bookHandler::deleteBook);
    }
}

// Handler 클래스
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;

@Component
public class BookHandler {
    
    private final BookRepository bookRepository;
    
    public BookHandler(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }
    
    public Mono<ServerResponse> getAllBooks(ServerRequest request) {
        return ServerResponse.ok()
            .contentType(MediaType.APPLICATION_JSON)
            .body(bookRepository.findAll(), Book.class);
    }
    
    public Mono<ServerResponse> getBookById(ServerRequest request) {
        String id = request.pathVariable("id");
        return bookRepository.findById(id)
            .flatMap(book -> ServerResponse.ok().bodyValue(book))
            .switchIfEmpty(ServerResponse.notFound().build());
    }
    
    // 다른 핸들러 메서드들...
}

WebFlux에서의 데이터 액세스

WebFlux 애플리케이션에서는 비동기-논블로킹 데이터 액세스가 중요합니다. Spring Data R2DBC와 Spring Data MongoDB Reactive를 살펴보겠습니다.

Spring Data R2DBC

R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스를 위한 리액티브 API입니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
    implementation 'io.r2dbc:r2dbc-h2'  // H2 데이터베이스 사용 시
}
import org.springframework.data.repository.reactive.ReactiveCrudRepository;

public interface BookRepository extends ReactiveCrudRepository<Book, String> {
    Flux<Book> findByAuthor(String author);
    Mono<Book> findByIsbn(String isbn);
}

Spring Data MongoDB Reactive

MongoDB는 리액티브 드라이버를 제공하여 WebFlux와 잘 통합됩니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
}
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;

public interface BookRepository extends ReactiveMongoRepository<Book, String> {
    Flux<Book> findByAuthor(String author);
    Mono<Book> findByIsbn(String isbn);
}

Spring WebFlux의 장단점

WebFlux를 학습하면서 파악한 장단점을 정리해보겠습니다.

장점

  1. 효율적인 리소스 사용: 적은 수의 스레드로 많은 요청 처리 가능
  2. 높은 동시성: 비동기-논블로킹 방식으로 동시에 많은 연결 처리
  3. 백프레셔 지원: 시스템 부하 관리에 효과적
  4. 함수형 프로그래밍 스타일: 선언적이고 간결한 코드 작성 가능
  5. 리액티브 스트림 표준: 다른 리액티브 라이브러리와 호환성 좋음

단점

  1. 높은 학습 곡선: 리액티브 프로그래밍 패러다임 이해 필요
  2. 디버깅 어려움: 스택 트레이스가 복잡하고 비직관적
  3. 리액티브 생태계 미성숙: 일부 라이브러리나 도구의 지원 부족
  4. 복잡한 에러 처리: 비동기 환경에서의 예외 처리가 복잡함
  5. 쉽게 블로킹 코드 유입 가능: 하나의 블로킹 작업이 전체 성능에 영향

마치며

Spring WebFlux와 리액티브 프로그래밍은 현대 애플리케이션 개발에 중요한 개념이라고 생각합니다. 특히 동시성이 중요한 서비스에서 리액티브 접근 방식은 큰 이점을 제공할 수 있습니다.
그러나 모든 상황에 WebFlux가 최선은 아니며, 적절한 사용 사례를 판단하는 것이 중요합니다.

0개의 댓글