본문 바로가기
프로그래밍/Go

고 (Golang) | 이것만 알면 나도 개발 전문가 | Gin 을 이용한 CRUD RESTful API 개발 Part7 - JWT 인증 기능 추가하기 (2) - 회원가입 로직 개발

by K-인사이터 2023. 3. 5.
반응형

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

 

 

 

안녕하세요

오늘도 k-in 입니다.

1. 서론

"이것만 알면 나도 개발 전문가" 시리즈입니다.

"Gin 을 이용한 CRUD RESTful API 개발 Part7" 를 진행하겠습니다. 

 

Part7 에서는 회원가입 기능을 추가해보도록 하겠습니다. 

Part6 의 내용을 아직 학습하지 않았다면 아래의 링크로 접속하여 학습을 순서대로 이어가주세요. 

 

2023.03.05 - [Go] - 고 (Golang) | 이것만 알면 나도 개발 전문가 | Gin 을 이용한 CRUD RESTful API 개발 Part6 - JWT 인증 기능 추가하기 (1)

 

고 (Golang) | 이것만 알면 나도 개발 전문가 | Gin 을 이용한 CRUD RESTful API 개발 Part6 - JWT 인증 기능

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다. HTML 삽입 미리보기할 수 없는 소스 안녕하세요 오늘도 k-in 입니다. 1. 서론 "이것만 알면 나도 개발 전

k-in.tistory.com

 

 

2. 데이터 모델을 정의 

사용자에게 데이터 입력을 받기 위해 우선 모델을 정의해보겠습니다.

모델에는 "데이터 베이스에 저장할 자료구조", "사용자로부터 송수신할 데이터의 구조" 를 정의하는 역할을 합니다. 

 

데이터 구조의 정의란 어떤 언어에도 통용되는 개발 방법이기 때문에 Go 언어를 이용한 웹 어플리케이션 개발 뿐만 아니라

다른 언어 개발에도 공통적으로 적용되는 부분입니다. 

 

우선 소스파일을 생성해볼까요? 프로젝트 Root 에서 models/user.model.go 파일을 생성합니다. 

 

$ touch models/user.model.go

우리는 사용자로부터 회원 가입을 위한 데이터를 전송받아야 합니다. 따라서 아래와 같이 데이터 요구사항을 정의합니다. 

  • name : 사용자의 이름
  • email : 이메일 주소 (로그인 아이디로 사용됨)
  • password : 패스워드 
  • password_confirm : 패스워드가 정확히 입력되었는지를 확인하기 위한 중복 필드

매우 간단한 구조입니다. 여기에 하나의 개념을 더해봅시다. (매우 중요)

model 에 정의된 데이터는 곧바로 사용자 DB 로 맵핑되는 기능을 더하면 편합니다. 

가령 별다른 노력없이 입력 데이터를 DB 에 입력할 수 있다면 더할 나위 없이 편합니다. 

 

 

그렇다면 DB 에서는 어떤 필드들이 필요한지 한번 검토해볼까요? 

  • role : 사용자의 권한 (관리자 혹은 일반 사용자)
  • verified : 이메일 인증을 통해 인증된 사용자의 여부 
  • created_at : 회원 정보 등록 일시 
  • updated_at : 회원 정보 변경 일시 

이제 user.model.go 소스코드에 정의한 요구사항을 구현해보겠습니다. 

package models

import "time"

type SignUpInput struct {
	Name            string    `json:"name" bson:"name" binding:"required"` // (1) 
	Email           string    `json:"email" bason:"email" binding:"required"`
	Password        string    `json:"password" bason:"password" binding:"required"`
	PasswordConfirm string    `json:"passwordConfirm" bson:"passwordConfirm,omitempty" binding:"required"` // (2)
	Role            string    `json:"role" bson:"role"`// (3)
	Verified        bool      `json:"verified" bson:"verified"`
	CreatedAt       time.Time `json:"created_at" bson:"created_at"`
	UpdatedAt       time.Time `json:"updated_at" bson:"updated_at"`
}


type DBResponse struct { // (4)
	ID              primitive.ObjectID `json:"id" bson:"_id"` // (5)
	Name            string             `json:"name" bson:"name"`
	Email           string             `json:"email" bson:"email"`
	Password        string             `json:"password" bson:"password"`
	PasswordConfirm string             `json:"passwordConfirm,omitempty" bson:"passwordConfirm,omitempty"`
	Role            string             `json:"role" bson:"role"`
	Verified        bool               `json:"verified" bson:"verified"`
	CreatedAt       time.Time          `json:"created_at" bson:"created_at"`
	UpdatedAt       time.Time          `json:"updated_at" bson:"updated_at"`
}

 

  1. Tag 절에 json: "name" 은 json 데이터로 부터 입력 받을 때 name 이라는 입력 값이 Name 필드에 맵핑됨을 지시합니다. 그리고 bson 은 MongoDB 에서 사용하는 키워드입니다. 즉, DB 에 맵핑될 필드의 이름을 지시합니다. binding 은 입력이 필수인지 아닌지를 지시합니다. 여기서 name 은 필수항목이므로 "required" 라고 지정합니다. 
  2. passwordConfirm 은 단순히 사용자가 입력한 password 가 올바른지 체크하기 위한 검증 파라미터입니다. 따라서 DB 에는 저장될 필요는 없습니다. 따라서 bson 절에 omitempty 를 입력하면 empty value 일 경우에 DB 에 저장되지 않습니다. 
  3. Role 파라미터는 회원가입일때 입력받을 필요가 없습니다. 그러나 사용자 정보를 업데이트 할 때 권한 부여등의 기능이 추가될 수 있으므로 선언만 한 상태입니다. 
  4. DBResponse 타입은 DB 에 저장된 데이터를 불러올 때 사용됩니다. SignUpInput 과 보통 필드 목록이 유사하나 DB 에서 데이터를 가지고 오므로 일부 필드의 태그가 다를 수 있습니다. 
  5. ID 필드는 MongoDB 에서만 존재하는 필드이며 저장된 정보를 가리키는 유일한 식별자입니다. DB 에서 자동으로 생성하는 값으로 응답을 받아올 때 이 값은 필수 입니다. 

3. DB 입력을 처리하는 서비스 로직 정의

회원가입 시 DB에 데이터를 저장하도록 구현해야 합니다. 

 

 

sevices/auth.service.go 와 sevices/auth.service.impl.go 소스코드를 생성합니다. 

auth.service.go 는 서비스 인터페이스를 정의하는 소스코드입니다. 

auth.service.impl.go 는 정의된 서비스 인터페이스를 구현하는 소스코드입니다. (impl 은 implementation 의 줄임말입니다. )

$ touch services/auth.service.go services/auth.service.impl.go

우선 auth.service.go 소스를 먼저 보겠습니다. 

 

package services

import "k-in.com/gin-tutorial/models"

type AuthService interface { // (1) 
	SignUpUser(*models.SignUpInput) (*models.DBResponse, error) // (2)
}
  1. AuthService 타입은 인터페이스로 합니다. 인터페이스는 정의된 함수를 구현하는 모든 타입들을 포괄할 수 있는 추상 타입입니다. 즉, 전혀 다른 구현을 여러개 정의하는 것이 가능함을 의미합니다. 
  2. SignUpUser 함수는 사용자의 입력(SignUpInput)을 받아 DB 에 저장 후에 저장된 데이터를 다시 불러오는(DBResponse) 역할을 합니다. 만약 오류가 발생한다면 error 객체를 내뱉고 DBResponse 리턴은 zero value 인 nil 을 반환합니다.  

 

AuthService 인터페이스의 구현은 auth.service.impl.go 소스에서 진행합니다.

 

package services

import (
	"context"
	"errors"
	"strings"
	"time"

	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
	"k-in.com/gin-tutorial/models"
	"k-in.com/gin-tutorial/utils"
)

type AuthServiceImpl struct { // (1) 
	collection *mongo.Collection
	ctx        context.Context
}

// (2)
func NewAuthService(collection *mongo.Collection, ctx context.Context) AuthService {
	return &AuthServiceImpl{collection: collection, ctx: ctx}
}

// (3) 
func (as *AuthServiceImpl) SignUpUser(user *models.SignUpInput) (*models.DBResponse, error) {
	// (4) 초기값 설정 블록 
	user.CreatedAt = time.Now()
	user.UpdatedAt = user.CreatedAt
	user.Email = strings.ToLower(user.Email)
	user.PasswordConfirm = ""
	user.Verified = false
	user.Role = "user"
	
    // (5) 패스워드 해시화
	hashedPassword, _ := utils.HashPassword(user.Password)
	user.Password = hashedPassword
	res, err := as.collection.InsertOne(as.ctx, &user)
	// (6) 중복된 이메일 발생 시 에러 
	if err != nil {
		if er, ok := err.(mongo.WriteException); ok && er.WriteErrors[0].Code == 11000 {
			return nil, errors.New("user with that email already exist")
		}
		return nil, err
	}
	// (7) 인덱스 생성 및 Unique 설정 (이메일 중복 방지)
	opt := options.Index()
	opt.SetUnique(true)
	index := mongo.IndexModel{Keys: bson.M{"email": 1}, Options: opt}

	if _, err := as.collection.Indexes().CreateOne(as.ctx, index); err != nil {
		return nil, errors.New("could not create index for email")
	}
	// (8) DB 데이터 저장을 확인하기 위한 불러오기 
	var newUser *models.DBResponse

	query := bson.M{"_id": res.InsertedID}
	err = as.collection.FindOne(as.ctx, query).Decode(&newUser)
	if err != nil {
		return nil, err
	}
	// (9) 불러온 데이터를 리턴 
	return newUser, nil

}
  1. AuthServiceImpl 타입을 정의하고 필요한 필드들을 정의합니다. context 필드는 Timeout 등의 Operation 중단 요인이 발생할 경우 이를 하위 Operation 에 전파(propagtion)하기 위해 필요합니다. 가령 타임아웃을 걸어두는 경우 유용합니다. 또한, collection 필드는 MongoDB 의 테이블 역할을 하며 데이터에 저장을 대리하여 수행하는 역할을 합니다. 
  2. AuthServiceImpl 의 생성자 함수이며 AuthService 인터페이스 타입으로 반환합니다. 이로써 AuthServiceImpl 타입 사용자는 세부적인 내용은 무시하고 AuthService 인터페이스 타입을 통해 정의된 함수들을 빠르게 파악할 수 있으며 다른 Impl 타입들과 호환할 수 있습니다. 
  3. 인터페이스에서 정의한 함수의 구현입니다. 리시버 영역을 제외하고 함수의 시그니처와 함수의 이름이 모두 동일합니다. 
  4. 사용자 입력 데이터를 전처리합니다. 현재 시간을 기준으로 update, create 시간을 설정합니다. passwordConfirm 은 불필요한 필드이므로 이를 empty value 로 설정합니다. 다음 시간에 배울 예정이지만 회원가입 후 이메일 인증을 통해 검증이 완료되어야 합니다. 따라서 Verified 필드는 default 값이 False 입니다. 사용자의 역할은 일반 사용자("user") 권한입니다. 관리자 권한은 "admin" 으로 정의하며 나중에 사용하도록 하겠습니다. 
  5. 패스워드는 원문 그대로 DB 에 저장해서는 안됩니다. 반드시 일방향 해시를 적용해야합니다. 해시화된 패스워드는 사용자 입력의 Password 필드에 다시 저장합니다. 
  6. 중복된 이메일로 가입 시 회원가입을 허용해서는 안됩니다. 인덱스를 통해서 Unique 설정을 DB 의 email 필드에 걸어둘 예정입니다. 그런데 이 코드는 (7) 번 절에 정의되어 있습니다. 이 이유는 가장 최초에 가입을 하는 경우는 유니크한 email 이라고 분류할 수 있기 때문입니다. 
  7. MongoDB 에 인덱스를 생성하고 Unique 설정을 합니다. 이를 통해 중복 이메일 가입을 방지할 수 있습니다. 
  8. 데이터를 저장하면 MongoDB 는 저장된 데이터의 "_id" 라 불리는 고유한 키 값을 리턴합니다. 이 값으로 다시 조회하여 사용자 데이터를 불러옵니다. 
  9. 불러온 데이터를 리턴하여 정상적으로 로그인이 완료되었는지 알려줍니다. 또한, 사용자는 자신의 회원 가입 요청이 정상적으로 이루어졌는지 확인이 필요합니다. 이를 위해서도 해당 데이터는 필요합니다. 

 

(5) 번 항목에서 패스워드를 해시화할 필요가 있다고 언급드렸습니다. 그리고 utils 패키지에서 사용자 정의 함수인 HashPassword 를 불러와서 사용하였습니다. utils 폴더에 password.go 파일을 생성합니다. 

 

$ touch utils/password.go
$ vim utils/password.go 

package utils

import (
	"fmt"

	"golang.org/x/crypto/bcrypt"
)

func HashPassword(password string) (string, error) {
    // (1) 
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return "", fmt.Errorf("could not hash password %w", err)
	}
    // (2)
	return string(hashedPassword), nil
}
  1. go 언어에서 제공하는 bcrypt 패키지를 이용해 GeneratedFromPassword 를 호출합니다. 이 함수는 평문 문자열을 지정된 Cost 상수에 따라 Hash 값을 생성합니다.
  2. 생성된 Hash 값은 []byte 타입이므로 string 으로 캐스팅하여 리턴합니다. 

 

4. 컨트롤러 로직을 정의 

이제 남은 것은 비즈니스 로직 정의와 라우팅을 적용하는 단계만 남았습니다.

내용이 길더라도 조금만 참는다면 정석대로 완전한 회원가입 로직을 얻을 수 있으니 힘내주시길 바랍니다. 

 

비즈니스 로직을 정의하기 위해 컨트롤러 로직에 정의해보겠습니다. 

controllers/auth.controller.go 파일을 생성하고 소스코드를 입력해볼까요? 

 

package controllers

import (
	"context"
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"go.mongodb.org/mongo-driver/mongo"
	"k-in.com/gin-tutorial/models"
	"k-in.com/gin-tutorial/services"
)

// (1) 
type AuthController struct {
	authService services.AuthService
	ctx         context.Context
	collection  *mongo.Collection
}

// (2) 
func NewAuthController(
	authService services.AuthService,
	ctx context.Context,
	collection *mongo.Collection) AuthController {
	return AuthController{authService: authService, ctx: ctx, collection: collection}
}

// (3) 
func (ac *AuthController) SignUpUser(ctx *gin.Context) {
	var user *models.SignUpInput
    // (4) 
	if err := ctx.ShouldBindJSON(&user); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": err.Error()})
		return
	}
	// (5) 
	if user.Password != user.PasswordConfirm {
		ctx.JSON(http.StatusBadRequest, gin.H{"status": "fail", "message": "Passwords do not match"})
	}
	// (6)
	_, err := ac.authService.SignUpUser(user)
	if err != nil {
		if strings.Contains(err.Error(), "email already exist") {
			ctx.JSON(http.StatusConflict, gin.H{"status": "error", "message": err.Error()})
			return
		}
		ctx.JSON(http.StatusBadGateway, gin.H{"status": "error", "message": err.Error()})
		return
	}
	// (7)
	ctx.JSON(http.StatusCreated, gin.H{"status": "success", "message": "ok"})
}

 

  1. 컨트롤러 타입은 AuthController 라고 명명하였습니다. 앞서 정의한 AuthService 객체, context 객체, collection 객체를 필드로 합니다. 
  2. AuthController 의 생성자 함수입니다. 필드 정보가 제공되어 초기화됩니다. 초기화 정보는 main 함수에서 제공할 예정으로 아직은 이것은 사용하겠다 정도로 해석합니다. 
  3. SignUpUser 함수를 선언합니다. 여기서 중요한 것은 포인터 리시버 함수라는 점과 gin.Context 포인터를 인자로 받는 것입니다. Gin 웹 프레임워크는 컨트롤러를 정의 할 때 gin.Context 포인터를 받도록 구성한다는 것을 이전 시간에 배웠습니다. 맞습니다. SignUpUser 함수는 컨트롤러 함수이며 Gin 엔진 인스턴스에 등록되어 사용될 것입니다. 
  4. gin.Conext 포인터는 여러 기능을 포함합니다. HTTP 요청으로부터 데이터를 끌어오고 객체에 바인딩하는 함수가 필요합니다. 여기서 사용되는 것이 ShouldBindJSON 함수입니다. 이 함수를 호출하면 gin.Context 객체는 JSON 데이터를 우리가 정의한 대로 models.SignUpInput 모델 타입에 바인딩합니다. (별도의 처리는 필요하지 않습니다 편하죠!)
  5. 사용자가 입력한 패스워드는 오류의 확률이 있습니다. 따라서 패스워드 확인을 통해 총 두번의 패스워드 입력을 받습니다. 그리고 만약 이 입력이 서로 다르다면 사용자 오입력으로 에러 처리가 필요합니다. 
  6. 앞서 정의한 AuthService 인터페이스의 SignUpUser 함수를 호출해 DB 에 저장합니다. DB 에 저장된 값은 현재는 사용자에게 노출할 필요가 없기 때문에 error 발생 만 체크를 하고 에러가 발생했다면 이를 사용자에게 알려줍니다. 
  7. 모든 과정에서 에러가 없었다면 사용자에게 정상 처리 완료 메시지를 리턴합니다. 

Gin 웹 어플리케이션의 컨트롤러의 특징 중에 하나는 naked return 문입니다. ctx.JSON 을 호출하여도 함수는 종료되지 않습니다. 그렇다고 컨트롤러 함수는 리턴하는 값이 없으므로 return 문만 타이핑하면 됩니다. 만약 return 문이 없다면 컨트롤러 로직은 모든 라인을 실행하게 될것입니다. 따라서 return 문 누락에 주의하며 코딩합니다. 

 

5. 라우팅 정의 

대망의 회원가입 로직 반영의 시간입니다. 여기까지 오느라 고생하셨습니다. 

 

향후에는 이미 설명한 내용은 스킵하며 좀더 간결하고 컴팩트하게 내용을 전달할 예정입니다.

처음 배우는 시간이라 세세한 설명이 길어졌습니다.

 

미리 생성해둔 routes 폴더에 auth.routes.go 파일을 생성합니다. 그리고 아래의 코드를 입력합니다. 

 

$ touch routes/auth.routes.go
$ vim routes/auth.routes.go

package routes

import (
	"github.com/gin-gonic/gin"
	"k-in.com/gin-tutorial/controllers"
)

// (1) 
type AuthRouteController struct {
	authController controllers.AuthController
}

// (2) 
func NewAuthRouteController(authController controllers.AuthController) AuthRouteController {
	return AuthRouteController{authController: authController}
}


// (3) 
func (arc *AuthRouteController) AuthRoute(rg *gin.RouterGroup) {
	router := rg.Group("/auth")
	router.POST("/register", arc.authController.SignUpUser)
}

 

  1. Auth 기능을 담당하는 라우팅을 모두 AuthRouteController 에 정의하기 위해 타입을 생성합니다. 이렇게 기능단위로 라우팅을 구분해 두어야 나중에 혼동이 없습니다. 
  2. 생성자 함수를 생성합니다. 필드로는 AuthController 가 있으니 이를 전달 받아 초기화하도록 합니다. 
  3. AuthRoute 는 Gin 프레임워크에서 제공하는 RouterGroup 이라는 객체를 전달 받아 라우팅을 실제로 등록합니다. 우리는 /auth/register 와 같은 형태로 URL 을 제공할 것이므로 /auth 상위 경로의 그룹을 생성하고 여기에 /register 경로를 추가한 뒤에 SignUpUser 컨트롤러 함수를 등록합니다. HTTP 메소드는 POST 로 지정하겠습니다. 

 

6. 웹 어플리케이션에 반영 

모든 준비가 완료되었습니다. 

사용자 입력 처리, 회원 정보 저장, 라우팅 정의 등등 모든 것을 완료하였고 남은 것은 Gin Engine 객체에 이를 반영하는 일만 남았습니다. 

cmd/main.go 소스코드 파일에 접근하여 회원가입 로직을 반영해보겠습니다. 

 

 

// $ vim cmd/main.go

var (
	server      *gin.Engine
	ctx         context.Context
	mongoclient *mongo.Client
	redisclient *redis.Client

    // (1) 
	authCollection      *mongo.Collection
	authService         services.AuthService
	AuthController      controllers.AuthController
	AuthRouteController routes.AuthRouteController
)

func init() {
	// 생략 

    // (2) 회원가입 로직 반영 
	authCollection = mongoclient.Database("gin-tutorial").Collection("users")
	authService = services.NewAuthService(authCollection, ctx)
	AuthController = controllers.NewAuthController(authService, ctx, authCollection)
	AuthRouteController = routes.NewAuthRouteController(AuthController)
    
    server = gin.Default()
}

func main() {
	// 생략 
    
    // (3) 라우팅 그룹 생성
	router := server.Group("/api")
	router.GET("/health", func(ctx *gin.Context) {
		ctx.JSON(http.StatusOK, gin.H{"message": value, "status": "success"})
	})

	// (4) 라우팅 등록
	AuthRouteController.AuthRoute(router)

	log.Fatal(server.Run(":" + config.Port))
}

 

  1. Collection 객체, AuthService 객체, 컨트롤러 객체, 라우터 객체들을 모두 전역변수로 등록합니다. 
  2. 각 변수들을 초기화합니다. 보통 의존관계 순으로 하나씩 정의합니다. mongoclient 에서 gin-tutorial 이라는 DB 를 생성하고 users 라는 컬렉션을 생성합니다. (만약 없다면 자동으로 생성되고 있다면 기존의 컬렉션 정보를 불러옵니다.) 
  3. 라우팅 그룹 /api 를 생성합니다. 이제 health 체크 API 또한 api 그룹에 등록됩니다. 
  4. init 에서 정의된 라우터 객체를 이용해 /api 그룹 하위에 등록합니다. 

7. 테스트 

Postman 혹은 테스터 도구를 통해 회원가입이 정상적으로 가능한지 확인해보겠습니다. 

정상적으로 회원가입이 완료되었습니다. 

회원가입 완료

데이터 베이스에도 gin-tutorial 이라는 DB 가 생성되었고 users 라는 컬렉션도 잘생성되었습니다. 

 

 

회원가입 데이터도 의도대로 정상적으로 입력되었습니다. verified 는 아직 이메일 인증 전이므로 false 입니다. 

맺음말 

오늘은 "Gin 을 이용한 CRUD RESTful API 개발 Part7" 의 "JWT 인증 기능 추가하기 (2)"를 진행하였습니다. 

이번 과정을 통해서 여러분은 "회원 가입 기능 추가"를 위해 아래의 능력들을 습득하였습니다 (짝짝짝)

  • 서비스, 컨트롤러, 라우팅, 모델 등의 논리적 개념으로 분할하여 "회원가입" 기능을 개발
  • 컨트롤러의 정의 및 사용방법 
  • 모델 정의 및 JSON 데이터와 DB 데이터를 바인딩하는 방법 
  • 기능 단위로 라우팅 그룹을 생성하여 논리적으로 컨트롤러들을 분할하여 묶는 방법 
  • 서비스 단위로 추상화된 Persistence(MongoDB) 에 데이터를 삽입하고 불러오는 방법
  • 인터페이스를 통해 추상화하고 선언과 실제 구현을 분리하는 방법 
  • 등등 

이 튜토리얼을 숙지하셔서 응용하신다면 곧바로 현업 개발에 뛰어들 수준의 내용을 다루었습니다. 

전체적인 구조가 깔끔하게 잡혀있으니 코드가 번잡하지 않고 깔끔하게 떨어지는 것이 나중에 유지보수 할 때도 좋을 듯합니다. 

다음 시간에는 SMTP 클라이언트를 구현하고 Mailtrap 서비스를 이용해 "이메일 인증" 과정을 진행해보도록 하겠습니다 

 

오늘 진행한 튜토리얼의 소스는 아래의 경로에서 다운받아서 살펴볼 수 있습니다.

아래 웹 사이트에 방문하셔서 Clone 버튼을 꾹 눌러주세요. 

 

https://bitbucket.org/kinstory/gin-tutorial/src/part7/

 

Bitbucket

 

bitbucket.org

 

 

이상으로 K-IN 이었습니다.

즐거운 하루되세요 

 

 

반응형