[데브인턴십 1기] GPT2 Chitchat REST API Server 개발 후기

손용기

업데이트:

배경

헬로우봇 앱에서는 다양한 캐릭터들과 잡담(chitchat)을 나눌 수 있습니다. 특정 콘텐츠 내에서 정해진 말을 하는 것이 아닌, 틀 없이 자유롭게 대화를 나누는 것이죠. 현재는 머신러닝 모델이 chitchat 기능의 엔진 역할을 하고 있으며 꽤 괜찮은 성능을 보이고 있으나 엉뚱한 대답을 하는 경우도 종종 있습니다. 이러한 현상을 개선하고 챗봇의 대화 품질을 향상시키기 위하여 GPT2라는 기술을 도입하고자 합니다.

image

GPT2 - KoGPT2

1. GPT2와 KoGPT2

image

GPT2는 2019년 11월 5일 테슬라 CEO인 일론 머스크가 공동 설립한 인공지능 연구기관인 OpenAI 🤖 단체에서 공개한 언어 모델입니다. 언어 모델은 주어진 단어들로부터 다음에 등장할 단어의 확률을 예측하는 모델입니다. 구글의 검색어 자동완성 기능과 유사하다고 볼 수 있습니다. 그리고 작년 4월, SKT-AI팀이 AWS 연구진들과 협력하여 만든 GPT2의 한국어 버전이 KoGPT2입니다.

2. 트랜스퍼 러닝(Transfer Learning)이란?

개발진들은 GPT2를 ‘카멜레온🦎’ 같다고 부릅니다. 이는 GPT2에 트랜스퍼 러닝(Transfer Learning) 기법이 적용되었기 때문입니다. 이해를 돕기 위해 트랜스퍼 러닝의 개념을 이번 프로젝트에 비유해 보겠습니다.

image

트랜스퍼 러닝은 특정 Task를 위해 학습한 모델을 다른 Task 수행에 사용하는 기법입니다. 위 그림은 Upstream Task(다음 단어 예측)를 위해 Wiki+News 데이터로 학습된 모델 KoGPT2가 Chitchat 모델이 Downstream Task를 수행하는 데 영향을 준 것을 보여줍니다. KoGPT2로부터 Knowledge Transfer가 이루어지면 Chitchat 모델은 학습을 진행할 때 KoGPT2 모델의 경험을 재활용합니다. 쉽게 비유하면 사람이 새로운 지식을 배울 때 그동안 평생 쌓아왔던 지식을 써먹는 것과 같습니다.

Upstream Task를 학습하는 과정을 프리트레인(Pretrain)이라고 합니다. Downstream Task를 수행하기에 앞서(pre) 학습(train)한다는 의미입니다. Downstream Task를 학습하는 과정은 여러 가지 방식이 있습니다. 이번 프로젝트에서는 그 중 파인 튜닝(Fine tuning)기법을 사용했습니다. 파인 튜닝은 Downstream Task학습에 데이터(일상 대화) 전부를 사용하여 모델 전체의 컨셉을 바꿔 새롭게 업데이트하는 것입니다.

이제 GPT2를 ‘카멜레온🦎’이라고 부른 이유를 눈치채셨나요? 카멜레온의 몸 색이 환경에 따라 변하는 것처럼 GPT2 모델이 어떤 데이터를 만나 어떻게 튜닝되는지에 따라 모델의 컨셉이 변하기 때문입니다.

KoGPT2 Chatbot

1. KoGPT2를 이용한 Chatbot

SKT-AI팀에서는 프리트레인 모델인 KoGPT2를 파인튜닝하여 간단한 Chatbot을 만들고 이를 오픈소스로 공개하였습니다. 이 챗봇은 약10,000줄의 일상 대화 데이터로 학습되었으며 무료 Jupyter Notebook 개발 환경인 Google Colab과 Demo사이트에서 테스트해볼 수 있습니다.(현재는 비공개 처리되어 접근이 불가능합니다)

image

KoGPT2 Chatbot은 기본적으로 위와 같은 과정으로 실행됩니다. 데이터 파일(.csv)로 학습(GPU)하여 학습 파라미터 파일(.params)을 생성합니다. 이 파일을 Load하여 모델이 생성되고, 모델은 사용자의 질문을 받아 추론 과정을 거쳐 적절한 응답을 합니다. 이 과정을 이해해야 아래 이어질 내용도 이해할 수 있습니다.

2. Demo Test

image

KoGPT2 Chatbot에게 ‘배고프다’ 라고 질문을 하면 ‘맛있는 거 드세요’라고 답변합니다. ‘배고프다’ 라는 질문에 한 번에 응답한 것이 아니라 위 그림처럼 다음 단어를 예측하는 Task를 반복하여 하나의 문장을 완성해냅니다. 각 Task에서 단어를 선택하는 기준은 정확하게 확인되지 않았지만 Greedy Search, Beam Search, Sampling 3개 중 하나로 추측하고 있습니다.

REST API

1. 배포 환경

image

KoGPT2 Chatbot을 AWS 서버에 올리고 질문을 받아 응답하는 REST API를 만들기 위해 Flask 프레임워크를 사용했습니다. Flask는 파이썬 웹 프레임워크의 한 종류입니다. Django와의 차이점이 있다면 매우 단순하고 가벼운 API 서버에 특화되어 있다는 점입니다. KoGPT2 Chatbot이 MxNet이라는 딥러닝 프레임워크를 사용하고 있고 이를 AWS의 SageMaker와 함께 배포하는 방법도 있었으나 한정된 시간과 학습 난이도로 인하여 Flask를 사용하기로 결정하였습니다.

2. API 개발

기존의 KoGPT2 Chatbot에서 추론코드를 수정하여 요청으로 들어오는 챗봇 번호에 맞는 모델이 응답하도록 chat()메소드를 만들고 API에 적용하였습니다. chatbotSeq로 챗봇 번호를 받아 27이면 라마마가, 52이면 판밍밍이 응답하는 것을 볼 수 있습니다.

@app.route('/chitchat')
def chitchat():
    chatbotSeq = int(request.args.get('chatbotSeq'))
    q = request.args.get('q')
    a = ''
    if chatbotSeq == 27:
        a = chat(model_lamama, q)
    elif chatbotSeq == 52:
        a = chat(model_panmingming, q)
        
    return jsonify({'a' : a})

학습 자동화

1. Github Action이란?

image

Github Action은 Github Repository를 기반으로 소프트웨어 개발 Workflow를 자동화 할 수 있는 도구입니다. 간단하게 설명하면 Jenkins나 Travis와 같은 CI / CD 도구라고 할 수 있습니다.

image

image

Github Repository의 Actions탭에서 workflow를 생성할 수 있으며 미리 만들어져 있는 template를 사용할 수도 있습니다. workflow를 생성하게 되면 Repository에 ‘.github/workflows’라는 디렉토리 하위에 yml파일이 하나 생성되고 PushPull Request같은 이벤트가 발생하면 Github에서 호스팅 해주는 Runner라는 환경을 통해 해당 파일에 입력되어 있는 명령어를 실행하게 됩니다.

2. Spot Instance란?

 					온디맨드 및 스팟 인스턴스 비교

‘Spot Instance는 온디맨드 가격보다 저렴한 비용으로 사용할 수 있는 미사용 EC2 인스턴스입니다.’ AWS 공식문서에서는 Spot Instance를 이렇게 정의하고 있습니다.

일반적으로 우리가 EC2에서 생성하여 사용하는 인스턴스는 온디맨드 인스턴스입니다. 오직 나에게만 주어진, 고정된 서버라고 볼 수 있죠. Spot Instance는 이렇게 온디맨드 인스턴스로 사용되고 있지 않은 남는 부분들을 ‘AWS가 언제든 회수해갈 수 있다’ 라는 계약을 하고 저렴한 비용으로 대여해주는 서비스입니다. 위 그림에서도 온디맨드 인스턴스와 달리 언제 회수될지 모르는 Spot Instance는 점선으로 표시한 것을 볼 수 있습니다.

GPU 인스턴스를 필요로 하는 GPT2학습에서 가장 저렴한 p2.xlarge 타입의 인스턴스를 사용해도 요금이 만만치가 않습니다. 학습을 할 때마다 인스턴스를 재가동하는 것도 상당히 비효율적입니다. Spot Instance를 사용하면 인스턴스를 늘 켜놓지 않아도 되고 온디맨드 가격에서 70%할인된 스팟가격으로 인스턴스를 사용할 수 있어 매우 효율적으로 학습을 진행할 수 있습니다.

3. 학습 과정

image

학습 자동화 과정은 위와 같습니다. 이해를 돕기 위해 라마마 챗봇의 학습을 가정하여 학습 과정을 자세하게 알아보겠습니다.

hellobot_chitchat_GPT2 Repository의 train-lamama 브랜치에 새로운 학습 데이터가 업로드됩니다. Github Action이 작동하고 작업이 큐에 등록되며 아래 yml파일에 입력되어 있는 작업들을 순차적으로 하나씩 실행합니다.

image

name: Train Lamama

#train-lamama 브랜치에 push 이벤트가 발생하면 실행됩니다.
on:
  push:
    branches:
      - train-lamama

jobs:
  train-lamama:
  	#실행환경은 ubuntu 입니다.
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2
  	  
  	  #AWS Configure
      - name: Configure AWS_ACCESS_KEY_ID
        run: |
          aws configure set aws_access_key_id $
      
      - name: Configure AWS_SECRET_ACCESS_KEY
        run: |
          aws configure set aws_secret_access_key $
          
      #Switch Role
      - name: Switch Role 1
        run: |
          aws configure set role_arn arn:aws:iam::{계정번호}:role/{역할명} --profile {역할명}
      - name: Switch Role 2
        run: |
          aws configure set source_profile default --profile {역할명}
      - name: Switch Role 3
        run: |  
          aws configure set region ap-northeast-2 --profile {역할명}
      
      # SpotConfig.json파일을 참조하여 Spot Instance 생성 요청을 보냅니다.
      - name: Create Spot Instance Request
        run: |
          aws ec2 request-spot-fleet --spot-fleet-request-config file://SpotConfig.json --profile {역할명}

AWS 계정 인증, 역할전환 후 Spot Instance 생성 요청을 하였습니다.

작업이 완료된 후에, 상세내용을 보면 위 내용이 그대로 실행됨을 확인할 수 있습니다.

image

image

이제 Spot Instance 생성 요청 과정을 좀 더 자세히 알아보겠습니다. yml파일의 마지막 작업을 보면 SpotConfig.json 파일을 참조하여 Spot Instance 생성 요청을 보내고 있습니다. SpotConfig.json 파일에는 아래처럼 Spot Instance ImageId, InstanceType 등 생성에 필요한 설정들이 입력되어 있습니다. UserData는 Instance가 생성되고 실행될 명령어이며 학습에 필요한 작업들을 정의할 수 있습니다. 좌측처럼 파일에는 base64로 인코딩되어 입력되며 디코딩하면 우측처럼 변환됩니다.

image

디코딩된 명령어를 보면 train-lamama 브랜치를 clone하고 해당 폴더에 권한을 부여한 뒤 학습을 진행하는 것을 확인할 수 있습니다.(권한 부여는 학습 시 생성되는 파라미터 파일을 저장하기 위해서 입니다) 학습이 끝나면 python 코드에서 파라미터를 S3에 업로드하고 ‘shoutdown now’명령어를 통해 인스턴스를 자동으로 종료합니다.

실제로 요청이 이루어지면 아래처럼 ‘스팟 요청’ 탭에 새로운 요청이 생성됩니다.

image

동시에 인스턴스도 하나 생성되고 ‘UserData’의 명렁어들을 수행합니다. 모든 작업이 끝나면 인스턴스가 자동으로 종료됨을 확인할 수 있습니다.

image

image

이제 S3를 확인해보면…! 성공적으로 파라미터가 업데이트 되었음을 확인할 수 있습니다.

image

배포 자동화

1. Docker란?

image

Docker는 컨테이너 기반의 오픈소스 가상화 플랫폼입니다. Docker를 사용하면 다양한 프로그램, 실행환경을 컨테이너로 추상화하여 배포 및 관리를 단순하게 할 수 있습니다. 어떠한 프로그램도 컨테이너로 추상화할 수 있으며 Docker만 설치되어 있으면 어느 곳에서든 실행할 수 있습니다. 프로그램을 잘게 쪼개서 관리하는 마이크로서비스 아키텍쳐가 유행하는 시대에서 Docker의 중요성은 더 커져만 가고 있습니다. Docker에 대한 더 깊은 지식은 여기에서 확인하실 수 있습니다.

image

배포 과정 이해를 위해 Docker의 핵심인 이미지와 컨테이너의 개념만 간단하게 알아보겠습니다. 이미지는 Dockerfile이 Build되어 만들어집니다. 이러한 이미지는 컨테이너 실행에 필요한 파일과 설정값 등을 포함하고 있으며 컨테이너는 이를 기반으로 실행되는 독립된 프로세스입니다. 간단하게 정리하면 이미지는 컨테이너 실행에 필요한 모든 정보를 가지고 있고 컨테이너는 이 정보들이 실체화되는 공간이라고 할 수 있습니다.

2. ECR, ECS란?

image

ECR(Elastic Container Registry)Docker Image를 관리할 수 있는 저장소입니다. 일반적으로 Docker에서 공식적으로 제공하는 DockerHub와 비슷하다고 볼 수 있습니다.

ECS(Elastic Container Service)컨테이너를 기반으로 서비스를 배포 및 운영할 수 있는 기능을 제공합니다. ECS의 가장 기본적인 단위는 Cluster입니다. Cluster는 컨테이너를 실행할 수 있는 가상의 공간으로, 프로젝트나 컨테이너의 성격에 따라 그룹처럼 나누어 사용합니다. Task컨테이너 실행을 위한 정보(ex : 이미지ID)를 담고 있으며 이를 바탕으로 컨테이너가 Fargate에서 실행됩니다. 여러 개의 Task를 관리하는 단위가 바로 Service입니다.

image

ECS에서는 Fargate가 아닌 EC2를 이용하여 컨테이너를 배포할 수도 있습니다. 다만 이럴 경우, EC2도 별도의 관리 포인트가 될 수 있어 신경 써야 할 부분이 늘어납니다. Fargate의 경우 각 컨테이너에 최적화된 별도의 환경을 제공하고 관리가 수월하며 서버리스 컨테이너 아키텍처라고도 볼 수 있습니다.

3. 무중단 배포

일반적으로 서버를 업데이트하여 배포할 때, 기존 서버의 연결을 해제하고 새로운 서버로 교체한 뒤 서비스를 하는 방식으로 진행합니다. 그런데 만약 서버를 가동하는데 10초의 시간이 소요된다면, 사용자는 그 시간만큼 서비스를 이용할 수가 없을 것입니다. 이런 현상을 막기 위해서 지속해서 서비스를 유지하는 ‘무중단 배포’를 구현할 수 있습니다.

image

무중단 배포 방법에는 대표적으로 ‘롤링 업데이트’, ‘블루그린’ 두 가지가 있습니다. 롤링 업데이트는 서버를 한 대씩 새 버전으로 교체하는 작업을 반복하는 방식입니다. 인프라에 구성된 현재 자원을 그대로 유지하고 배포할 수 있지만 업데이트 도중에 서버를 끊어야 하기 때문에 서버 과부하가 생길 수 있다는 치명적인 단점도 존재합니다. ‘블루그린’은 서버를 그대로 본 떠 새로운 서버를 만들고 전체를 업데이트하여 연결시키는 방식입니다. 업데이트 중에도 기존 서버의 상태를 그대로 유지하기 때문에 서버 과부하가 발생하지 않지만 서버를 본뜨는 과정에서 새로운 서버가 생성되기 때문에 인프라 자원이 두 배가 요구됩니다. 본 프로젝트에서는 롤링 업데이트 방식을 이용하였습니다.

4. 배포 과정

image

배포 자동화 과정은 위와 같습니다. 이해를 돕기 위해 시나리오 형식으로 배포 과정을 자세하게 알아보겠습니다.

hellobot_chitchat_GPT2 Repository의 deploy 브랜치에서 API코드를 수정하였습니다. Github Action이 작동하고 작업이 큐에 등록되며 아래 yml파일에 입력되어 있는 작업들을 순차적으로 하나씩 실행합니다.

image

name: Deploy

#deploy 브랜치에 push 이벤트가 발생하면 실행됩니다.
on:
  push:
    branches:
      - deploy

jobs:
  deploy:
    name: Deploy
    #실행환경은 ubuntu 입니다.
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2

	#AWS Configure
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: $
        aws-secret-access-key:  $
        aws-region: ap-northeast-2
        role-to-assume: arn:aws:iam::{계정번호}:role/{역할명}	
        role-duration-seconds: 900
    
    #AWS Configure
    - name: Configure AWS_ACCESS_KEY_ID
      run: |
        aws configure set aws_access_key_id $
      
    - name: Configure AWS_SECRET_ACCESS_KEY
      run: |
        aws configure set aws_secret_access_key $
     
    #Switch Role
    - name: Switch Role 1
      run: |
        aws configure set role_arn arn:aws:iam::{계정번호}:role/{역할명} --profile {역할명}
    - name: Switch Role 2
      run: |
        aws configure set source_profile default --profile {역할명}
    - name: Switch Role 3
      run: |  
        aws configure set region ap-northeast-2 --profile {역할명}
    
    #Download Params from S3
    - name: download params from s3
      run: |
        aws s3 cp s3://{BucketName}/v1.params v1.params --profile {역할명}
        aws s3 cp s3://{BucketName}/v2.params v2.params --profile {역할명}
        aws s3 cp s3://{BucketName}/v3.params v3.params --profile {역할명}
        aws s3 cp s3://{BucketName}/v4.params v4.params --profile {역할명}
    
    #ECR Login
    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1
    
    #Dockerfile Build and Push Image to ECR
    - name: Build, tag, and push image to Amazon ECR
      id: build-image
      env:
        ECR_REGISTRY: $
        ECR_REPOSITORY: {RepositoryName}
        IMAGE_TAG: $ #최근 Commit의 Hash값
      run: |
      	#최근 Commit의 Hash값과 latest를 태그로 동시 사용
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

AWS 계정 인증, 역할전환 후 S3로부터 학습 파라미터 파일을 다운로드합니다.

FROM python:3.7

#필요한 라이브러리를 설치합니다
RUN apt-get update -y
RUN pip3 install mxnet
RUN pip3 install pandas sentencepiece gluonnlp
RUN pip3 install flask
RUN pip3 install swagger-ui-py
RUN pip3 install git+https://github.com/SKT-AI/KoGPT2

#다운로드한 학습 파라미터 파일을 컨테이너로 COPY합니다.
COPY ./ ./
#배포할 포트를 적어줍니다.
EXPOSE 80

CMD python server.py

이후, 위 Dockerfile을 Build하여 Image를 생성하고 ECR에 Push합니다. Image Tag는 버전관리와 롤백을 효율적으로 하기 위하여 ‘최근 Commit의 Hash값’과 최신임을 나타내는 ‘latest’ 두 개로 설정하였습니다.

작업이 완료된 후, 상세 내용을 보면 학습 자동화 때와 동일하게 위 내용이 그대로 실행됨을 확인할 수 있습니다.

image

image

이제 ECR에 아래처럼 이미지가 업로드 된 것을 확인할 수 있습니다. 새 이미지가 업로드 되고 기존 이미지에 붙어 있던 latest태그도 잘 옮겨졌네요.

image

초기 배포자동화 구현 시, 위처럼 이미지가 업로드되면 ECS를 통해 자동으로 컨테이너가 배포되도록 설정하였습니다. 하지만 실제 개발 과정에서 개발 후 바로 배포를 하지 않고 배포주기를 정하는 경우도 있기 때문에 ECR까지만 이미지를 업로드하고 배포는 수동으로 하도록 변경하였습니다. 수동 배포는 AWS 콘솔에서 버튼 몇 번만 눌러 아주 쉽게 할 수 있습니다.

image

위에서 봤던 전체 배포 과정 중 수동으로 배포하는 부분을 발췌하였습니다. 새로운 버전이 배포되면 로드밸런서를 활용한 롤링 업데이트가 이루어집니다. 빨간색의 새로운 컨테이너를 로드밸런서에 연결하고 나서 초록색의 기존 컨테이너와의 연결을 해제함으로써 무중단 배포를 할 수 있습니다.

Swagger

image

Swagger는 REST API 서비스를 설계하는데 도움을 주는 라이브러리입니다. 또한 프론트엔드와 백엔드 개발자 사이의 협업에 있어 중요한 역할을 하는 API문서를 쉽고 깔끔하게 만들어주며 테스트까지 가능하게 해주는 만능 친구입니다.

image

좌측 그림은 제가 과거에 작성했던 API문서(?)입니다. 나름 깔끔해보일 수 있겠지만 API가 많아지고 파라미터도 다양해지면.. 끔찍합니다. Swagger를 사용하면 우측 그림처럼 이쁜 디자인으로 수많은 API들을 깔끔하게 관리할 수 있습니다.

image

실제 GPT2 Chitchat Server의 API입니다. API를 누르면 이렇게 파라미터들의 정보를 확인할 수 있으며 무엇보다 별도로 다른 API테스트툴을 사용하지 않고 바로 테스트를 진행할 수 있습니다.

Circuit Breaker와 Fallback Messaging

image

MSA(MicroService Architecture)는 전체 시스템을 여러개의 서비스로 나눠서 호출하는 개념을 가지고 있습니다. 장점도 많지만 하나의 서비스가 지연되거나 장애가 발생할 경우 해당 서비스를 호출하는 종속된 서비스들까지 장애가 전파된다는 단점도 가지고 있습니다. 예시로, 그림에서처럼 C를 B가 호출하고, B를 A가 호출하는 시스템 구조에서 C에 장애가 발생하여 B, A로 장애가 전파될 수 있습니다.

image

Circuit BreakerFallback Messaging의 기본적인 원리는 이렇습니다. Circuit Breaker는 ‘회로차단기’라는 의미로 평소에는 회로를 닫고 요청을 Pass 처리하지만 요청이 도달할 서버에 장애가 발생하면 회로를 열어 더 이상 요청을 받지 않도록 하고 Circuit Breaker에 미리 지정해둔 Fallback Message를 응답합니다.

Hellobot Server와 GPT2 Chitchat Server사이에 Circuit Breaker를 설치하고 Fallback Messaging기능을 구현함으로써 GPT2 Chitchat Server에 장애가 발생해도 사용자는 챗봇의 답변이 오기만을 무한정 기다리지 않고 나름대로 자연스러운 메시지를 응답받을 수 있을 것입니다.

성능 테스트

image

KoGPT2 모델의 성능을 판단하기 위해 위 그림처럼 테스트를 진행하였습니다. 그림에는 하나의 예시만 나타나 있지만 실제로 모델은 학습 데이터의 말투를 잘 따라하였고 동일 데이터를 기준으로 학습 횟수에 비례하여 더 구체적이고 자연스러운 답변을 하였습니다.

마치며

생에 처음으로 AI를 경험(?)해보았습니다. 덕분에, 리서치와 개발의 비율이 반반이었던 것 같습니다ㅎㅎ. 2개월이라는 짧은 시간이라 AI가 어떻다고 판단하기는 그렇지만 제가 맡았던 GPT2만큼은 다양한 매력을 가진 것 같습니다. 특히나 학습 데이터를 잘 흉내 내는 것을 봤을 때는 정말 소름이 돋았고 데이터를 늘려갈수록 GPT2의 한계가 어디인지 더 궁금해지더군요. 수억 건의 데이터를.. 꼭 한번 학습시켜보고 싶습니다. 주제는 GPT2였지만 DevOps적인 내용이 더 많았던(?) 부족한 포스팅 읽어주셔서 감사합니다😀

띵스플로우 팀은 자기의 일을 좋아하고 잘하는 사람들 입니다. 사용자와 서비스를 중심으로 빠르게 실행하고 학습하며, 다양한 직무의 사람들이 협업을 통해 시너지를 내고 있습니다. 다양한 콘텐츠 혁신을 이루고 있는 띵스플로우 팀에 함께할 분을 찾습니다! 언제든 people@thingsflow.com로 이메일을 주시기 바랍니다!

태그:

카테고리:

업데이트:

댓글남기기