Skip to main content
Summary

빠른 학습은 좋은 모델을 만드는 힘이다

이전 장에서 우리는 하이퍼파라미터를 바꿔 가며 실험하는 법을 배웠다. 학습률을 조금 바꾸고, 배치 크기를 바꾸고, 드롭아웃 비율을 바꾸고, 검증 성능을 비교했다. 그런데 이런 실험에는 조용하지만 아주 현실적인 문제가 따라온다. 바로 시간이다.

모델 하나를 학습하는 데 10초가 걸리면 우리는 과감해진다. 여러 설정을 시도하고, 실패도 부담 없이 해 본다. 반대로 한 번 학습하는 데 5시간이 걸리면 어떨까? 실험은 갑자기 신중해지고, 좋은 아이디어가 떠올라도 "일단 나중에"라고 미루게 된다. 딥러닝에서 학습 속도는 단순한 편의가 아니다. 더 많이 시도하고, 더 빨리 틀리고, 더 빨리 고칠 수 있게 해 주는 실험의 엔진이다.

이 장에서는 그 엔진의 중심에 있는 GPU를 다룬다. GPU를 쓰면 왜 딥러닝 학습이 빨라지는지, PyTorch에서 모델과 텐서를 어떻게 GPU로 옮기는지, 그리고 GPU가 있어도 왜 항상 빠르지는 않은지 살펴보자.

GPU와 학습 속도

CPU와 GPU는 잘하는 일이 다르다

CPU는 컴퓨터의 만능 요리사에 가깝다. 운영체제 관리, 파일 읽기, 브라우저 실행, 조건문이 많은 프로그램 처리처럼 다양한 일을 빠르고 유연하게 해낸다. 코어 수는 상대적으로 적지만 각 코어가 강력하고 복잡한 명령을 잘 처리한다.

GPU는 조금 다르다. GPU는 수많은 작은 일꾼이 한꺼번에 같은 종류의 계산을 처리하는 공장에 가깝다. 원래는 화면의 수많은 픽셀을 동시에 계산하기 위해 발전했지만, 딥러닝과도 궁합이 매우 좋다. 신경망 학습에서 반복되는 핵심 계산이 대부분 행렬 곱셈, 벡터 연산, 텐서 연산이기 때문이다.

예를 들어 MLP의 한 층을 생각해 보자.

z = x @ W + b

여기서 x는 입력 배치, W는 가중치 행렬이다. 배치 안의 여러 샘플에 대해, 수많은 입력 값과 수많은 가중치를 곱하고 더해야 한다. CNN에서는 필터가 이미지 위를 훑으며 비슷한 곱셈과 덧셈을 엄청나게 반복한다. Transformer에서는 토큰 벡터들이 서로의 관계를 계산하기 위해 또 거대한 행렬 연산을 수행한다. 앞으로 배울 Attention도 결국 큰 관점에서는 텐서끼리 곱하고, 더하고, 정규화하는 계산의 연속이다.

이런 계산은 "한 명의 천재가 순서대로 처리하는 일"보다 "많은 사람이 동시에 나눠 처리하는 일"에 가깝다. 그래서 GPU가 빛난다. PyTorch 공식 성능 가이드에서도 GPU 학습 성능을 높이는 중요한 방향으로 비동기 데이터 로딩, pinned memory, 불필요한 그래디언트 계산 제거 같은 방법을 제시한다1.

GPU를 사용한다는 것은 단순히 "더 비싼 장비를 쓴다"는 뜻이 아니다. 딥러닝 계산의 성격에 맞는 도구를 사용하는 것이다.

모든 텐서는 같은 장소에 있어야 한다

PyTorch에서 GPU를 사용하는 기본 흐름은 간단하다. 먼저 사용할 장치를 정하고, 모델과 데이터를 그 장치로 옮긴다.

import torch

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device)

torch.cuda.is_available()은 현재 PyTorch가 CUDA GPU를 사용할 수 있는지 확인한다. CUDA는 NVIDIA GPU에서 범용 병렬 계산을 실행하기 위한 플랫폼이다. CUDA를 사용할 수 있으면 device는 cuda가 되고, 그렇지 않으면 cpu가 된다.

그다음 모델을 장치로 옮긴다.

model = MLP()
model = model.to(device)

입력 데이터와 정답도 같은 장치로 옮겨야 한다.

for x_batch, y_batch in train_loader:
    x_batch = x_batch.to(device)
    y_batch = y_batch.to(device)

    pred = model(x_batch)
    loss = loss_fn(pred, y_batch)

여기서 가장 중요한 규칙은 이것이다. 모델 파라미터, 입력 텐서, 정답 텐서는 같은 장치에 있어야 한다. 모델은 GPU에 있는데 입력 데이터가 CPU에 있으면 PyTorch는 연산을 진행할 수 없다. 보통 다음과 비슷한 오류를 만난다.

Expected all tensors to be on the same device

이 오류는 초보자에게 매우 흔하다. 하지만 원인은 단순하다. 계산에 참여하는 재료들이 서로 다른 방에 놓여 있는 것이다. 모델은 GPU 방에 있는데 데이터는 CPU 방에 있다. 둘을 같은 방으로 옮겨야 계산이 시작된다.

학습 루프에 GPU 연결하기

이제 우리가 앞에서 여러 번 사용한 학습 루프에 GPU를 붙여 보자.

import torch
import torch.nn as nn
import torch.optim as optim

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

model = MLP().to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(num_epochs):
    model.train()

    for x_batch, y_batch in train_loader:
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)

        optimizer.zero_grad()

        pred = model(x_batch)
        loss = loss_fn(pred, y_batch)

        loss.backward()
        optimizer.step()

    print(f'Epoch {epoch+1}, loss: {loss.item():.4f}')

바뀐 부분은 많지 않다. device를 만들고, model.to(device)를 호출하고, 배치마다 x_batch.to(device), y_batch.to(device)를 호출했다. 코드 변화는 작지만 실행되는 장소는 완전히 달라진다. CPU가 하던 큰 행렬 계산을 GPU가 맡기 시작한다.

검증 루프에서도 장치를 맞춰야 한다. 그리고 검증 단계에서는 가중치를 업데이트하지 않으므로 그래디언트를 계산할 필요가 없다.

model.eval()
correct = 0
total = 0

with torch.no_grad():
    for x_batch, y_batch in val_loader:
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)

        pred = model(x_batch)
        predicted = pred.argmax(dim=1)

        correct += (predicted == y_batch).sum().item()
        total += y_batch.size(0)

accuracy = correct / total
print(f'val accuracy: {accuracy:.4f}')

torch.no_grad()는 검증이나 추론처럼 그래디언트가 필요 없는 구간에서 중간 계산 저장을 줄여 메모리 사용량과 실행 시간을 아끼는 데 도움이 된다1. 학습에서는 역전파를 위해 계산 과정을 기억해야 하지만, 검증에서는 "맞혔는지 확인"만 하면 된다. 굳이 공책에 풀이 과정을 전부 적어 둘 필요가 없는 셈이다.

배치 크기: GPU를 바쁘게 만들기

GPU는 많은 계산을 한꺼번에 처리할 때 강하다. 그래서 배치 크기를 너무 작게 잡으면 GPU의 능력을 충분히 쓰지 못할 수 있다. 예를 들어 배치 크기가 1이면 GPU는 샘플 하나를 처리하고 다음 샘플을 기다린다. 마치 대형 고속도로에 자동차 한 대만 달리는 것과 비슷하다.

배치 크기를 32, 64, 128처럼 키우면 한 번에 더 많은 샘플을 처리하므로 GPU의 병렬 처리 능력을 더 잘 활용할 수 있다. 하지만 여기에는 대가가 있다. 배치가 커지면 GPU 메모리에 올라가는 데이터도 늘어난다.

학습 중 GPU 메모리에 올라가는 것은 입력 데이터만이 아니다. 각 층의 활성값, 손실 계산에 필요한 중간 결과, 역전파로 계산되는 그래디언트, Adam 같은 옵티마이저가 관리하는 추가 상태까지 함께 저장된다. 그래서 배치 크기를 무작정 키우다 보면 어느 순간 다음과 같은 오류를 만난다.

RuntimeError: CUDA out of memory

줄여서 OOM, 즉 Out Of Memory라고 부른다. GPU 메모리 공간이 부족하다는 뜻이다.

이때 가장 먼저 시도할 일은 배치 크기를 줄이는 것이다.

train_loader = DataLoader(
    train_dataset,
    batch_size=64,
    shuffle=True
)

만약 batch_size=128에서 OOM이 났다면 64, 32로 낮춰 본다. 반대로 GPU 메모리가 많이 남고 사용률도 낮다면 배치 크기를 조금씩 키워 볼 수 있다. 배치 크기는 속도와 메모리 사이의 손잡이다. 크게 잡으면 한 번에 많이 처리할 수 있지만 메모리를 더 쓴다. 작게 잡으면 안정적이지만 GPU를 충분히 활용하지 못할 수 있다.

GPU 메모리를 대략 확인하고 싶다면 다음 명령을 사용할 수 있다.

if torch.cuda.is_available():
    allocated = torch.cuda.memory_allocated() / 1024**3
    reserved = torch.cuda.memory_reserved() / 1024**3

    print(f'allocated: {allocated:.2f} GB')
    print(f'reserved: {reserved:.2f} GB')

allocated는 실제 텐서가 사용 중인 메모리에 가깝고, reserved는 PyTorch가 재사용하기 위해 확보해 둔 캐시 메모리까지 포함한다. PyTorch는 성능을 위해 GPU 메모리를 바로 운영체제에 돌려주지 않고 재사용하는 경우가 있다. 그래서 작업 관리자나 nvidia-smi에서 보이는 메모리와 코드에서 보는 값이 조금 다를 수 있다.

GPU가 놀고 있을 때: 데이터 로딩 병목

GPU를 사용한다고 해서 항상 학습이 빨라지는 것은 아니다. GPU가 아무리 빠르게 계산할 준비를 하고 있어도, CPU가 데이터를 늦게 가져오면 GPU는 기다려야 한다. 이 현상을 흔히 GPU starvation이라고 부른다. 이름 그대로 GPU가 먹을 데이터를 받지 못해 굶는 상황이다.

학습 루프를 공장으로 비유해 보자. GPU는 빠른 조립 기계다. 그런데 창고에서 부품을 가져오는 사람이 너무 느리면 조립 기계는 멈춰 선다. 문제는 조립 기계가 아니라 부품 공급 라인이다.

PyTorch에서는 DataLoader 설정으로 이 병목을 줄일 수 있다. 기본값인 num_workers=0은 데이터를 메인 프로세스에서 동기적으로 읽는다. 즉, 학습 코드가 다음 배치를 기다리는 동안 멈출 수 있다. num_workers를 1 이상으로 설정하면 별도 worker 프로세스들이 데이터를 미리 읽고 전처리할 수 있다. PyTorch 성능 가이드에서도 num_workers > 0을 사용하면 데이터 로딩과 학습 계산을 겹쳐 실행할 수 있다고 설명한다1.

train_loader = DataLoader(
    train_dataset,
    batch_size=128,
    shuffle=True,
    num_workers=4,
    pin_memory=True
)

pin_memory=True도 GPU 학습에서 자주 사용한다. pinned memory는 CPU 메모리 중 GPU로 더 효율적으로 복사할 수 있도록 고정해 둔 영역이라고 생각하면 된다. PyTorch 공식 가이드는 GPU를 사용할 때 pin_memory=True를 설정하면 호스트에서 GPU로의 메모리 복사를 더 빠르고 비동기적으로 수행하는 데 도움이 된다고 설명한다1.

이 설정을 사용했다면 배치를 GPU로 옮길 때 non_blocking=True를 함께 사용할 수 있다.

for x_batch, y_batch in train_loader:
    x_batch = x_batch.to(device, non_blocking=True)
    y_batch = y_batch.to(device, non_blocking=True)

    pred = model(x_batch)
    loss = loss_fn(pred, y_batch)

다만 num_workers는 무조건 클수록 좋은 값이 아니다. CPU 코어 수, 저장장치 속도, 데이터 전처리 비용, 운영체제에 따라 적절한 값이 달라진다. 작은 데이터셋에서는 worker를 늘려도 차이가 거의 없거나 오히려 느려질 수 있다. 보통은 0, 2, 4, 8 정도를 비교해 보며 자신의 환경에서 가장 빠른 값을 찾는다.

정확한 시간 측정에는 동기화가 필요하다

GPU 연산은 종종 비동기적으로 실행된다. Python 코드가 GPU에 일을 맡기고 바로 다음 줄로 넘어갈 수 있다는 뜻이다. 그래서 단순히 time.time()으로 앞뒤 시간을 재면 실제 GPU 계산이 끝나기 전에 시간이 측정될 수 있다.

정확한 시간을 재고 싶다면 torch.cuda.synchronize()를 사용해 GPU 작업이 모두 끝날 때까지 기다린 뒤 시간을 측정한다.

import time

if torch.cuda.is_available():
    torch.cuda.synchronize()

start = time.time()

for x_batch, y_batch in train_loader:
    x_batch = x_batch.to(device)
    y_batch = y_batch.to(device)

    optimizer.zero_grad()
    pred = model(x_batch)
    loss = loss_fn(pred, y_batch)
    loss.backward()
    optimizer.step()

if torch.cuda.is_available():
    torch.cuda.synchronize()

elapsed = time.time() - start
print(f'time: {elapsed:.2f} sec')

작은 모델에서는 GPU가 CPU보다 항상 압도적으로 빠르지 않을 수 있다. 데이터를 GPU로 옮기는 비용, 커널을 실행하는 비용, DataLoader 준비 시간이 계산 시간보다 커질 수 있기 때문이다. 하지만 모델이 커지고, 배치가 커지고, 이미지나 텍스트의 텐서 연산이 많아질수록 GPU의 장점은 점점 커진다.

자동 혼합 정밀도: 더 작게 계산하고 더 빠르게 움직이기

딥러닝 학습은 보통 32비트 부동소수점, 즉 FP32를 기본으로 한다. 그런데 최신 GPU는 FP16이나 BF16처럼 더 낮은 정밀도의 계산을 매우 빠르게 처리할 수 있다. 낮은 정밀도를 쓰면 숫자 하나가 차지하는 메모리가 줄어들고, 일부 연산이 더 빨라진다.

물론 모든 계산을 무조건 낮은 정밀도로 바꾸면 학습이 불안정해질 수 있다. 그래서 PyTorch는 자동 혼합 정밀도, AMP를 제공한다. AMP는 어떤 연산은 낮은 정밀도로, 어떤 연산은 안정성을 위해 높은 정밀도로 실행하도록 도와준다. PyTorch의 AMP 문서는 torch.autocast와 GradScaler를 사용해 혼합 정밀도 학습을 구성하는 흐름을 안내한다2.

기본 형태는 다음과 같다.

from torch.amp import autocast, GradScaler

scaler = GradScaler('cuda')

for x_batch, y_batch in train_loader:
    x_batch = x_batch.to(device, non_blocking=True)
    y_batch = y_batch.to(device, non_blocking=True)

    optimizer.zero_grad(set_to_none=True)

    with autocast('cuda'):
        pred = model(x_batch)
        loss = loss_fn(pred, y_batch)

    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

여기서 autocast는 연산마다 적절한 정밀도를 자동으로 선택한다. GradScaler는 낮은 정밀도에서 그래디언트 값이 너무 작아져 0처럼 사라지는 문제를 줄이기 위해 손실 값을 스케일링한다. 쉽게 말해 아주 작은 글씨를 잠시 확대해서 계산한 뒤, 다시 원래 크기로 돌려놓는 장치다.

AMP는 특히 CNN, Transformer처럼 큰 행렬 연산이 많은 모델에서 효과를 체감하기 쉽다. 메모리 사용량이 줄어 더 큰 배치 크기를 사용할 수 있고, GPU의 특수 연산 장치를 더 잘 활용할 수 있기 때문이다. 다만 모든 환경에서 동일한 속도 향상을 보장하지는 않는다. GPU 종류, 모델 구조, 입력 크기, 연산 종류에 따라 효과가 다르다.

cuDNN benchmark: 같은 모양을 반복할 때의 작은 가속 장치

이미지 모델을 학습할 때 입력 크기가 일정한 경우가 많다. 예를 들어 CIFAR-10 이미지는 3 x 32 x 32, ImageNet 학습에서는 흔히 3 x 224 x 224처럼 고정된 크기를 사용한다. 이런 경우 다음 설정이 도움이 될 수 있다.

import torch

torch.backends.cudnn.benchmark = True

cuDNN은 NVIDIA GPU에서 딥러닝 연산, 특히 convolution 연산을 빠르게 실행하도록 도와주는 라이브러리다. torch.backends.cudnn.benchmark=True를 설정하면 cuDNN이 여러 알고리즘을 시험해 보고 현재 입력 크기에 빠른 방법을 선택할 수 있다. PyTorch 문서에서도 convolution 네트워크의 성능 최적화와 관련해 cuDNN 및 메모리 포맷, AMP 같은 설정을 함께 다룬다1.

다만 입력 크기가 계속 바뀌는 경우에는 benchmark 과정 자체가 부담이 될 수 있다. 예를 들어 매 배치마다 이미지 크기가 달라지는 학습이라면 오히려 이득이 줄어들 수 있다. 규칙은 간단하다. 입력 모양이 일정한 CNN 학습에서는 켜 볼 만하고, 입력 모양이 자주 바뀌면 신중하게 확인한다.

조금 더 빠른 기본 학습 루프

지금까지 배운 내용을 합쳐 보자. 아래 코드는 GPU 사용, DataLoader 최적화, AMP, 검증 시 no_grad()를 함께 넣은 기본 학습 구조다.

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torch.amp import autocast, GradScaler

# 입력 크기가 일정한 CNN 학습에서 유용할 수 있음
torch.backends.cudnn.benchmark = True

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
use_amp = (device.type == 'cuda')

train_loader = DataLoader(
    train_dataset,
    batch_size=128,
    shuffle=True,
    num_workers=4,
    pin_memory=use_amp
)

val_loader = DataLoader(
    val_dataset,
    batch_size=256,
    shuffle=False,
    num_workers=4,
    pin_memory=use_amp
)

model = CNN().to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = optim.AdamW(model.parameters(), lr=1e-3)
scaler = GradScaler('cuda', enabled=use_amp)

for epoch in range(num_epochs):
    model.train()
    train_loss = 0.0

    for x_batch, y_batch in train_loader:
        x_batch = x_batch.to(device, non_blocking=True)
        y_batch = y_batch.to(device, non_blocking=True)

        optimizer.zero_grad(set_to_none=True)

        with autocast('cuda', enabled=use_amp):
            pred = model(x_batch)
            loss = loss_fn(pred, y_batch)

        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()

        train_loss += loss.item() * x_batch.size(0)

    train_loss /= len(train_loader.dataset)

    model.eval()
    correct = 0
    total = 0

    with torch.no_grad():
        for x_batch, y_batch in val_loader:
            x_batch = x_batch.to(device, non_blocking=True)
            y_batch = y_batch.to(device, non_blocking=True)

            with autocast('cuda', enabled=use_amp):
                pred = model(x_batch)

            predicted = pred.argmax(dim=1)
            correct += (predicted == y_batch).sum().item()
            total += y_batch.size(0)

    val_acc = correct / total
    print(f'Epoch {epoch+1}: loss={train_loss:.4f}, val_acc={val_acc:.4f}')

여기서 optimizer.zero_grad(set_to_none=True)도 눈여겨볼 만하다. PyTorch 성능 가이드는 그래디언트를 0으로 채우는 대신 None으로 설정하면 메모리 연산을 줄이는 데 도움이 될 수 있다고 설명한다1. 초보 단계에서 반드시 써야 하는 것은 아니지만, 학습 루프를 조금 더 효율적으로 만드는 습관으로 알아 두면 좋다.

속도를 높일 때 놓치기 쉬운 것들

GPU 최적화는 마법 주문이 아니다. 몇 줄을 추가하면 빨라질 수 있지만, 동시에 확인해야 할 것도 있다.

먼저 정확도가 유지되는지 봐야 한다. AMP를 켰을 때 대부분의 모델은 잘 학습되지만, 특정 모델이나 손실 함수에서는 수치적으로 민감할 수 있다. 속도가 빨라졌더라도 검증 손실이 이상하게 흔들리거나 정확도가 떨어지면 AMP를 끄고 비교해 보자.

다음으로 병목이 어디인지 봐야 한다. GPU 사용률이 낮다면 모델이 너무 작거나, 배치 크기가 너무 작거나, 데이터 로딩이 느릴 수 있다. 반대로 GPU 사용률이 높은데도 느리다면 모델 자체의 연산량이 큰 것이다. 이때는 모델 구조, 입력 크기, 배치 크기, 정밀도 설정을 함께 봐야 한다.

마지막으로 실험 조건을 기록해야 한다. 학습 속도는 코드뿐 아니라 하드웨어, 드라이버, PyTorch 버전, CUDA 버전, 배치 크기, num_workers, AMP 여부에 영향을 받는다. 하이퍼파라미터 실험을 기록하듯 속도 관련 설정도 함께 기록해야 나중에 결과를 해석할 수 있다.

예를 들어 다음처럼 실험 설정에 속도 관련 항목을 넣어 둘 수 있다.

config = {
    'model': 'cnn-small',
    'batch_size': 128,
    'learning_rate': 1e-3,
    'num_workers': 4,
    'pin_memory': True,
    'amp': True,
    'cudnn_benchmark': True,
    'device': str(device),
}

이전 장에서 배운 하이퍼파라미터 튜닝이 "성능을 찾는 실험"이었다면, 이번 장의 GPU 활용은 "그 실험을 현실적인 시간 안에 끝내는 기술"이다.

더 큰 모델로 가는 다리

우리가 처음 만든 모델은 작은 MLP였다. 손글씨 숫자를 분류하고, 작은 이미지 데이터셋을 학습하고, 간단한 텍스트 분류기를 만들었다. 이 정도에서는 CPU로도 어느 정도 실습할 수 있다. 하지만 모델이 커질수록 이야기가 달라진다.

CNN의 채널 수가 늘어나고, 이미지 크기가 커지고, RNN의 시퀀스 길이가 길어지고, Transformer의 토큰 수와 층 수가 늘어나면 계산량은 빠르게 증가한다. 특히 Transformer는 Self-Attention에서 토큰들 사이의 관계를 한꺼번에 계산하기 때문에 텐서 연산의 규모가 커진다. LLM으로 갈수록 GPU는 선택이 아니라 거의 필수 도구가 된다.

하지만 GPU를 잘 쓴다는 것은 단순히 .to('cuda')를 붙이는 것에서 끝나지 않는다. 모델과 데이터를 같은 장치에 두고, 배치 크기를 메모리 한도 안에서 조절하고, 데이터 로딩 병목을 줄이고, 필요한 경우 AMP 같은 기법을 적용해야 한다. 그래야 GPU가 정말로 바쁘게 일한다.

딥러닝 학습은 결국 반복이다. 예측하고, 손실을 계산하고, 역전파로 기울기를 구하고, 가중치를 조금 수정한다. GPU는 이 반복을 더 빠르게 돌려 준다. 반복이 빨라지면 실험이 많아지고, 실험이 많아지면 모델을 이해하는 속도도 빨라진다.

다음 장에서는 빠르게 학습하는 것만큼 중요한 문제를 다룬다. 모델이 학습되지 않을 때, 손실이 이상하게 움직일 때, 정확도가 갑자기 멈출 때 우리는 어디를 봐야 할까? 이제 모델을 디버깅하는 법으로 넘어가 보자.

참고

1Performance Tuning Guide — PyTorch Tutorials

2Automatic Mixed Precision package — PyTorch documentation

책 목록

처음 만나는 딥러닝: 신경망에서 Transformer와 LLM까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기