Skip to content
Published on

LLM 멀티모달 비전-언어 모델 서빙과 최적화 실전 가이드

Authors
  • Name
    Twitter
LLM 멀티모달 비전-언어 모델 서빙과 최적화 실전 가이드

VLM 서빙이 텍스트 전용 LLM 서빙과 다른 이유

비전-언어 모델(Vision-Language Model, VLM)은 이미지 인코더(ViT)와 언어 디코더(LLM)를 결합한 아키텍처다. 텍스트 전용 LLM 서빙에서는 토큰 시퀀스만 처리하면 되지만, VLM 서빙에서는 이미지 전처리, 비전 인코더 추론, 크로스모달 프로젝션, 언어 디코더 생성이라는 네 단계 파이프라인을 관리해야 한다.

이 구조적 차이가 서빙 엔지니어링에 미치는 영향은 크다.

  1. 메모리 이중 부담: ViT 인코더가 추가로 GPU 메모리를 소비한다. Qwen2.5-VL-72B의 경우 언어 디코더만 약 144GB(BF16)인데, ViT 인코더가 추가로 수 GB를 점유한다.
  2. TTFT(Time-To-First-Token) 증가: 이미지를 패치로 분할하고 인코더를 통과시키는 데 수백 ms가 소요되며, 이 지연이 언어 디코더의 prefill 이전에 발생한다.
  3. 배치 효율 저하: 이미지 해상도가 요청마다 다르면 패치 수가 달라져 배칭이 비효율적이다.
  4. KV Cache 크기 급증: 이미지 토큰이 수백~수천 개 추가되므로, 동일 컨텍스트 길이 대비 KV Cache 사용량이 크게 늘어난다.

이 글에서는 2025-2026년 기준 프로덕션 환경에서 VLM을 효율적으로 서빙하기 위한 아키텍처, 프레임워크 선택, 최적화 기법, 운영 패턴을 다룬다.

주요 VLM 모델 비교

서빙 전략은 모델 아키텍처에 따라 달라진다. 현재 프로덕션에서 주로 사용되는 오픈소스 VLM을 비교한다.

모델파라미터아키텍처비전 인코더최소 GPU 요구(BF16)특징
Qwen2.5-VL-7B7BDenseViT-600M1x A100 40GB동적 해상도, 비디오 지원
Qwen2.5-VL-72B72BDenseViT-600M4x A100 80GB고성능 범용, TP4 권장
Qwen3-VL-235B-A22B235B(A22B)MoEViT8x A100 80GBMoE 기반, Expert Parallel 필요
InternVL2.5-78B78BDenseInternViT-6B4x A100 80GB6B 대형 비전 인코더
InternVL3다양Dense/MoEInternViT모델별 상이최신 SOTA급 멀티모달
LLaVA-OneVision7B/72BDenseSigLIP1~4x A100멀티이미지, 비디오 지원

선택 기준은 명확하다. 단일 GPU로 빠르게 배포해야 하면 7B급, 품질이 최우선이면 72B+ Dense, 비용과 품질의 균형이 필요하면 MoE 모델을 선택한다.

서빙 프레임워크 비교: vLLM vs SGLang vs LMDeploy

VLM 서빙을 지원하는 주요 프레임워크 세 가지를 프로덕션 관점에서 비교한다.

기능vLLM (v0.8+)SGLang (v0.4+)LMDeploy (v0.7+)
멀티모달 지원이미지/비디오/오디오이미지/비디오이미지/비디오
OpenAI API 호환완전 지원완전 지원부분 지원
인코더 캐싱V1에서 GPU 캐시RadixAttention 활용TurboMind 엔진
ViT DP + LLM TP지원미지원지원
양자화AWQ, GPTQ, FP8AWQ, GPTQW4A16, KV 양자화
Continuous Batching지원지원지원(Persistent Batch)
멀티턴 캐시Prefix CachingRadixTree(자동)지원
커뮤니티/생태계가장 넓음빠르게 성장중국 생태계 강점

권장 선택 기준:

  • vLLM: 범용 프로덕션 환경, OpenAI API 드롭인 교체, ViT DP 최적화가 필요한 경우
  • SGLang: 멀티턴 대화에서 KV Cache 재사용이 핵심인 경우, 구조화된 출력이 필요한 경우
  • LMDeploy: InternVL 시리즈 모델의 최적 성능이 필요하거나 KV 양자화를 적극 활용하는 경우

vLLM으로 VLM 서빙하기: 단계별 구현

vLLM은 V1 아키텍처에서 멀티모달 추론 성능을 크게 개선했다. 인코더 캐시를 도입해 동일 이미지에 대한 비전 인코딩을 한 번만 수행하고, 인코더 인식 스케줄러가 멀티모달 임베딩 위치를 추적한다.

서버 실행

# Qwen2.5-VL-7B 단일 GPU 서빙
vllm serve Qwen/Qwen2.5-VL-7B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.92 \
  --limit-mm-per-prompt '{"image": 4}' \
  --dtype bfloat16 \
  --enable-prefix-caching

# Qwen2.5-VL-72B 멀티 GPU 서빙 (ViT DP + LLM TP)
vllm serve Qwen/Qwen2.5-VL-72B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --tensor-parallel-size 4 \
  --mm-encoder-tp-mode data \
  --max-model-len 16384 \
  --gpu-memory-utilization 0.95 \
  --limit-mm-per-prompt '{"image": 8}' \
  --dtype bfloat16 \
  --enable-prefix-caching

--mm-encoder-tp-mode data 옵션이 핵심이다. ViT 인코더는 파라미터가 작아 Tensor Parallel로 분할해도 통신 오버헤드만 증가한다. 대신 Data Parallel로 각 GPU에서 독립적으로 인코딩하면 TTFT가 극적으로 줄어든다. AMD ROCm 블로그에 따르면 이 한 줄 옵션만으로 멀티모달 추론 성능이 수배 향상되었다.

OpenAI 호환 API 클라이언트

from openai import OpenAI
import base64
from pathlib import Path


def encode_image_to_base64(image_path: str) -> str:
    """이미지 파일을 base64로 인코딩"""
    with open(image_path, "rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")


client = OpenAI(
    api_key="EMPTY",
    base_url="http://localhost:8000/v1",
)

# URL 기반 이미지 입력
response = client.chat.completions.create(
    model="Qwen/Qwen2.5-VL-7B-Instruct",
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "이 이미지에서 텍스트를 추출해주세요."},
                {
                    "type": "image_url",
                    "image_url": {
                        "url": "https://example.com/document.png",
                        "detail": "high",
                    },
                },
            ],
        }
    ],
    max_tokens=2048,
    temperature=0.1,
)
print(response.choices[0].message.content)

# base64 인코딩 이미지 입력
b64_image = encode_image_to_base64("/data/images/receipt.jpg")
response = client.chat.completions.create(
    model="Qwen/Qwen2.5-VL-7B-Instruct",
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": "이 영수증의 총 금액은?"},
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{b64_image}",
                    },
                },
            ],
        }
    ],
    max_tokens=512,
)
print(response.choices[0].message.content)

Qwen3-VL MoE 모델 서빙

Qwen3-VL-235B-A22B는 MoE 아키텍처로, Expert Parallel을 활용한 서빙이 필요하다.

# Qwen3-VL-235B-A22B MoE 서빙 (8 GPU 필수)
vllm serve Qwen/Qwen3-VL-235B-A22B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --tensor-parallel-size 8 \
  --mm-encoder-tp-mode data \
  --enable-expert-parallel \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.95 \
  --limit-mm-per-prompt '{"image": 4, "video": 1}' \
  --dtype bfloat16

--enable-expert-parallel 플래그는 MoE 레이어의 Expert를 GPU 간 분산 배치하여 메모리 효율을 높인다. 이 옵션 없이 TP8만 사용하면 Expert 중복 로드로 메모리가 부족해질 수 있다.

이미지 전처리 파이프라인 최적화

VLM 서빙에서 이미지 전처리는 예상보다 큰 병목이다. 고해상도 이미지를 매 요청마다 리사이즈하고 정규화하면 CPU 바운드로 전체 처리량이 제한된다.

전처리 워커 분리 아키텍처

# docker-compose.yml - 전처리 워커 + VLM 서버 분리
version: '3.8'
services:
  image-preprocessor:
    image: vlm-preprocessor:latest
    deploy:
      replicas: 4
    environment:
      - MAX_IMAGE_SIZE=2048
      - OUTPUT_FORMAT=base64
      - REDIS_URL=redis://redis:6379/0
    volumes:
      - /data/image-cache:/cache
    cpus: '4'
    mem_limit: 8g

  vlm-server:
    image: vllm/vllm-openai:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 4
              capabilities: [gpu]
    command: >
      --model Qwen/Qwen2.5-VL-72B-Instruct
      --tensor-parallel-size 4
      --mm-encoder-tp-mode data
      --max-model-len 16384
      --gpu-memory-utilization 0.95
    ports:
      - '8000:8000'

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

이미지 전처리 최적화 코드

import hashlib
from io import BytesIO
from PIL import Image
import redis
import base64


class ImagePreprocessor:
    """VLM 서빙을 위한 이미지 전처리 파이프라인"""

    def __init__(
        self,
        max_size: int = 2048,
        min_size: int = 28,
        redis_url: str = "redis://localhost:6379/0",
    ):
        self.max_size = max_size
        self.min_size = min_size
        self.cache = redis.from_url(redis_url)
        self.cache_ttl = 3600  # 1시간

    def _compute_hash(self, image_bytes: bytes) -> str:
        return hashlib.sha256(image_bytes).hexdigest()

    def _smart_resize(self, image: Image.Image) -> Image.Image:
        """해상도를 VLM 패치 크기에 맞게 조정 (28의 배수)"""
        w, h = image.size

        # 최대 크기 제한
        if max(w, h) > self.max_size:
            ratio = self.max_size / max(w, h)
            w = int(w * ratio)
            h = int(h * ratio)

        # 28의 배수로 맞춤 (Qwen-VL 패치 크기)
        w = max(self.min_size, (w // 28) * 28)
        h = max(self.min_size, (h // 28) * 28)

        return image.resize((w, h), Image.LANCZOS)

    def process(self, image_bytes: bytes) -> str:
        """이미지를 전처리하고 캐시 적용, base64 반환"""
        img_hash = self._compute_hash(image_bytes)

        # 캐시 히트 확인
        cached = self.cache.get(f"img:{img_hash}")
        if cached:
            return cached.decode("utf-8")

        # 이미지 처리
        image = Image.open(BytesIO(image_bytes)).convert("RGB")
        image = self._smart_resize(image)

        # JPEG 품질 85로 압축 (품질과 크기 균형)
        buffer = BytesIO()
        image.save(buffer, format="JPEG", quality=85)
        b64_result = base64.b64encode(buffer.getvalue()).decode("utf-8")

        # 캐시 저장
        self.cache.setex(f"img:{img_hash}", self.cache_ttl, b64_result)

        return b64_result

해상도를 28의 배수로 맞추는 이유는 Qwen-VL 계열의 ViT가 28x28 패치 크기를 사용하기 때문이다. 패치 크기의 배수가 아닌 해상도를 입력하면 내부적으로 패딩이 발생해 불필요한 연산이 추가된다. InternVL은 14x14 패치를 사용하므로 모델에 따라 조정해야 한다.

VLM 양자화 전략

VLM의 양자화는 텍스트 전용 LLM보다 복잡하다. 비전 인코더와 언어 디코더의 양자화 민감도가 다르기 때문이다. MBQ(Modality-Balanced Quantization, CVPR 2025) 연구에 따르면, 비전 토큰과 언어 토큰에 동일한 양자화를 적용하면 비전 이해 성능이 불균형하게 하락한다.

양자화 방법별 비교

양자화 방법비트 수메모리 절감속도 향상품질 영향적용 대상
BF16 (기준)16-bit--없음전체 모델
FP8 (E4M3)8-bit~50%~1.5x매우 적음언어 디코더
AWQ (4-bit)4-bit~75%~2.0x소폭 하락언어 디코더
GPTQ (4-bit)4-bit~75%~1.8x소폭 하락언어 디코더
KV Cache INT88-bitKV 50%-미미KV Cache
Q-VLM (PTQ)4-bit~75%~1.4x최소화전체 VLM

권장 양자화 조합

프로덕션에서 검증된 양자화 조합은 다음과 같다.

고품질 요구 (문서 OCR, 의료 이미지 분석):

  • 비전 인코더: BF16 유지 (양자화하지 않음)
  • 언어 디코더: FP8 또는 BF16
  • KV Cache: FP16

비용 최적화 (일반 이미지 설명, 챗봇):

  • 비전 인코더: BF16 유지
  • 언어 디코더: AWQ 4-bit
  • KV Cache: INT8

핵심 원칙은 비전 인코더는 가능한 양자화하지 않는 것이다. ViT의 파라미터 수는 전체 모델의 1~5%에 불과하므로 양자화해도 메모리 절감이 미미하지만, 이미지 이해 품질은 크게 저하될 수 있다.

# AWQ 양자화 모델로 서빙 (비전 인코더는 BF16 유지)
vllm serve Qwen/Qwen2.5-VL-72B-Instruct-AWQ \
  --host 0.0.0.0 \
  --port 8000 \
  --tensor-parallel-size 2 \
  --mm-encoder-tp-mode data \
  --max-model-len 8192 \
  --quantization awq \
  --gpu-memory-utilization 0.95 \
  --kv-cache-dtype fp8_e5m2 \
  --enable-prefix-caching

AWQ 4-bit 양자화를 적용하면 72B 모델이 2x A100 80GB에서 서빙 가능해진다. BF16에서 4x A100이 필요했던 것과 비교하면 GPU 비용이 절반으로 줄어든다.

ViT Data Parallel + LLM Tensor Parallel 하이브리드 전략

vLLM V1에서 도입된 가장 영향력 있는 VLM 최적화는 하이브리드 병렬 전략이다. 이 전략의 핵심 아이디어는 비전 인코더와 언어 디코더의 병렬화 방식을 분리하는 것이다.

기존 방식 (TP-Only): 4 GPU에서 TP4로 서빙하면 ViT도 4-way로 분할된다. ViT는 파라미터가 작아(600M~6B) 분할 효과는 미미한데, All-Reduce 통신이 매 레이어마다 발생해 오히려 느려진다.

하이브리드 방식 (ViT DP + LLM TP): 4 GPU에서 ViT는 각 GPU에 전체를 복제하고(DP4), LLM만 TP4로 분할한다. 배치 내 4개의 이미지를 각 GPU가 하나씩 독립적으로 인코딩하므로 통신 오버헤드가 제거된다.

성능 차이는 극적이다. Qwen2.5-VL-72B 기준으로 4 GPU에서 하이브리드 전략 적용 시 TTFT가 기존 대비 2~4배 개선된다. 특히 고해상도 이미지나 멀티이미지 입력에서 차이가 더 크다.

프로덕션 모니터링과 메트릭

VLM 서빙에서는 텍스트 전용 LLM 대비 추가 메트릭을 모니터링해야 한다.

핵심 모니터링 메트릭

from prometheus_client import Histogram, Counter, Gauge

# VLM 전용 메트릭 정의
vlm_image_preprocess_duration = Histogram(
    "vlm_image_preprocess_seconds",
    "이미지 전처리 소요 시간",
    buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0],
)

vlm_encoder_duration = Histogram(
    "vlm_encoder_inference_seconds",
    "비전 인코더 추론 시간",
    buckets=[0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0],
)

vlm_total_image_tokens = Counter(
    "vlm_total_image_tokens",
    "처리된 총 이미지 토큰 수",
)

vlm_image_cache_hits = Counter(
    "vlm_image_cache_hits_total",
    "이미지 인코딩 캐시 히트 수",
)

vlm_image_cache_misses = Counter(
    "vlm_image_cache_misses_total",
    "이미지 인코딩 캐시 미스 수",
)

vlm_active_image_count = Gauge(
    "vlm_active_image_count",
    "현재 처리 중인 이미지 수",
)

vlm_kv_cache_image_tokens_ratio = Gauge(
    "vlm_kv_cache_image_tokens_ratio",
    "KV Cache 중 이미지 토큰 비율",
)

알림 설정 기준

메트릭경고 임계값위험 임계값조치
인코더 추론 시간P99 500ms 초과P99 2s 초과이미지 해상도 제한 확인
TTFTP99 3s 초과P99 10s 초과배치 크기/이미지 수 제한
KV Cache 사용률85% 초과95% 초과max-model-len 축소 또는 GPU 증설
이미지 캐시 히트율50% 미만30% 미만캐시 TTL/크기 조정
OOM 발생 횟수1회/시간3회/시간즉시 이미지 수 제한 또는 max-model-len 축소

장애 사례와 복구 절차

사례 1: 고해상도 이미지로 인한 OOM

증상: 4K 이상 이미지 입력 시 GPU OOM 발생, 서버 프로세스 크래시

원인: 이미지 해상도에 비례해 패치 수가 급증한다. 4096x4096 이미지를 28x28 패치로 분할하면 약 21,000개 패치가 생성되고, 이 패치 임베딩이 GPU 메모리를 순간적으로 점유한다.

복구 절차:

  1. --limit-mm-per-prompt 옵션으로 요청당 이미지 수 제한
  2. 전처리 단계에서 최대 해상도를 2048 이하로 강제 리사이즈
  3. vLLM의 --max-num-seqs를 낮춰 동시 처리 요청 수 제한
  4. 재발 방지를 위해 API 게이트웨이에서 이미지 크기 검증 미들웨어 추가

사례 2: 멀티이미지 요청에서 배칭 효율 급락

증상: 이미지 수가 요청마다 다를 때 처리량이 단일 이미지 대비 30% 이하로 떨어짐

원인: 배치 내 요청들의 이미지 토큰 수가 크게 다르면 패딩이 발생하거나 스케줄러가 보수적으로 배치 크기를 줄인다.

해결 방법:

  1. 이미지 수 기준으로 요청을 버킷팅 (0개, 1개, 2-4개, 5개 이상)
  2. 버킷별 별도 서빙 인스턴스 배포
  3. 로드밸런서에서 이미지 수 기반 라우팅 적용

사례 3: 인코더 캐시 메모리 누수

증상: 장시간 운영 시 GPU 메모리 사용량이 점진적으로 증가하다가 OOM 발생

원인: vLLM의 인코더 캐시가 만료 정책 없이 계속 쌓이는 경우 발생 가능

복구 절차:

  1. vLLM 서버의 --max-num-seqs 값 조정으로 동시 캐시 엔트리 제한
  2. 주기적 서버 재시작 스케줄 설정 (graceful shutdown 후 rolling restart)
  3. GPU 메모리 모니터링 알림 설정으로 조기 감지

성능 벤치마크 가이드

VLM 서빙 성능을 정확하게 측정하려면 텍스트 전용 벤치마크와 다른 방법론이 필요하다.

import asyncio
import time
import statistics
from openai import AsyncOpenAI


async def benchmark_vlm_serving(
    base_url: str,
    model: str,
    image_urls: list[str],
    num_requests: int = 100,
    concurrency: int = 10,
):
    """VLM 서빙 벤치마크: TTFT, 처리량, 이미지별 레이턴시 측정"""
    client = AsyncOpenAI(api_key="EMPTY", base_url=base_url)
    ttfts = []
    total_latencies = []
    semaphore = asyncio.Semaphore(concurrency)

    async def single_request(image_url: str):
        async with semaphore:
            start = time.monotonic()
            first_token_time = None

            stream = await client.chat.completions.create(
                model=model,
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": "이 이미지를 설명해주세요."},
                            {
                                "type": "image_url",
                                "image_url": {"url": image_url},
                            },
                        ],
                    }
                ],
                max_tokens=256,
                stream=True,
            )

            async for chunk in stream:
                if first_token_time is None and chunk.choices[0].delta.content:
                    first_token_time = time.monotonic()
                    ttfts.append(first_token_time - start)

            end = time.monotonic()
            total_latencies.append(end - start)

    # 벤치마크 실행
    tasks = [
        single_request(image_urls[i % len(image_urls)])
        for i in range(num_requests)
    ]
    bench_start = time.monotonic()
    await asyncio.gather(*tasks)
    bench_duration = time.monotonic() - bench_start

    # 결과 출력
    print(f"총 요청: {num_requests}, 동시성: {concurrency}")
    print(f"총 소요시간: {bench_duration:.2f}s")
    print(f"처리량: {num_requests / bench_duration:.2f} req/s")
    print(f"TTFT P50: {statistics.median(ttfts)*1000:.0f}ms")
    print(f"TTFT P99: {sorted(ttfts)[int(len(ttfts)*0.99)]*1000:.0f}ms")
    print(f"총 레이턴시 P50: {statistics.median(total_latencies)*1000:.0f}ms")
    print(f"총 레이턴시 P99: {sorted(total_latencies)[int(len(total_latencies)*0.99)]*1000:.0f}ms")

벤치마크 시 주의사항: 다양한 해상도의 이미지를 혼합해야 실제 프로덕션 워크로드를 반영할 수 있다. 동일 이미지만 반복하면 인코더 캐시로 인해 비현실적으로 좋은 결과가 나온다.

운영 체크리스트

배포 전 점검

  • 모델 선택 근거 문서화 (벤치마크 결과, 비용 분석 포함)
  • 입력 이미지 최대 해상도 제한 설정 확인
  • --limit-mm-per-prompt 옵션으로 요청당 이미지/비디오 수 제한
  • 전처리 파이프라인에서 지원하지 않는 이미지 포맷 거부 로직 확인
  • GPU 메모리 사용률 95% 이하에서 안정적으로 동작하는지 부하 테스트 완료
  • 양자화 적용 시 비전 인코더 BF16 유지 확인
  • OOM 발생 시 자동 재시작 설정 (systemd/k8s liveness probe)
  • OpenAI API 호환 엔드포인트 응답 형식 검증

운영 중 점검

  • TTFT P99이 SLA 이내인지 주기적 확인
  • KV Cache 사용률 트렌드 모니터링
  • 인코더 캐시 히트율 확인 (50% 미만이면 캐시 전략 재검토)
  • GPU 메모리 사용량 점진적 증가 여부 확인 (메모리 누수 징후)
  • 입력 이미지 크기 분포 로깅 (비정상적 대용량 이미지 탐지)
  • 에러율 모니터링 (특히 이미지 디코딩 실패, 타임아웃)
  • 모델 업데이트 시 양자화 호환성 재검증

스케일링 판단 기준

  • GPU 사용률이 지속적으로 85% 초과하면 인스턴스 추가
  • 요청 큐 대기시간이 P99 5초 초과하면 스케일아웃
  • KV Cache 부족으로 요청 거부가 발생하면 max-model-len 축소 또는 GPU 증설
  • 멀티이미지 요청 비율이 50% 초과하면 이미지 수 기반 라우팅 도입 검토

요약

VLM 서빙은 텍스트 전용 LLM 서빙의 확장이 아니라 별도의 엔지니어링 도메인이다. 비전 인코더와 언어 디코더의 비대칭적 특성을 이해하고, 이에 맞는 병렬화 전략(ViT DP + LLM TP), 양자화 전략(인코더 BF16 유지, 디코더만 양자화), 캐싱 전략(인코더 캐시 + 이미지 전처리 캐시)을 적용해야 프로덕션 수준의 성능과 안정성을 달성할 수 있다.

핵심 세 가지를 기억하자.

  1. ViT는 양자화하지 말고, LLM만 양자화하라: 비전 인코더의 파라미터 비중은 작지만 이미지 이해 품질에 대한 영향은 크다.
  2. ViT DP + LLM TP 하이브리드 전략을 기본으로 사용하라: 멀티 GPU 환경에서 TTFT를 극적으로 개선하는 가장 효과적인 방법이다.
  3. 이미지 해상도를 반드시 제한하라: 제한 없는 해상도 입력은 OOM의 가장 흔한 원인이며, 전처리 파이프라인에서 강제해야 한다.

참고 자료