들어가며
* 본 스터디의 자료는 아래의 책을 기반으로 합니다.
테라폼으로 시작하는 IaC | 김민수, 김재준, 이규석, 이유종 | 링크
이번 주차에는 프로바이더와, 테라폼 스테이트 관리 기법, 테라폼 백엔드, 그리고 워크스페이스 관리 방법에 대해서 학습했습니다. 블로그를 작성하면서 사내에서 스터디를 같이 하시는 분들과 과제 진도 이야기도 하고, 테라폼 적용에 대해서 편하게 이야기하는 시간을 가졌었습니다. 아무래도 작년에는 혼자 진행하던 것을 몇 명이서 같이 진행하게 되니까 오프라인에서도 다양한 이야기를 할 수 있어서 재밌네요!
다들 아직까지는 살아있는걸로..
몇 년 전부터 CloudNet@ 스터디를 처음 할 때는 연차를 깎아 과제를 하곤 했었는데,
우리 팀원분들은 척척 잘해내시는 것 같아서 멋져요~!
Shout out, Peter and Watermelon 그리고 King Gasida님!
프로바이더
테라폼은 사용자가 작성한 코드를 읽어서 분석한 후 프로바이더의 API를 호출하여 리소스를 생성합니다.
대상의 명세를 알지 못해도 필요한 자원을 쉽게 만들 수 있습니다.
프로바이더를 사용하려면 프로바이더의 연결과 인증에 필요한 정보가 제공되어야 하고, 하드코딩이나 변수 처리등을 통해서 해당 정보를 설정합니다.
아래의 링크를 통해 다양한 테라폼 프로바이더를 확인할 수 있습니다.
프로바이더의 구성
레지스트리의 Tier는 3가지 종류로 구성되어 있습니다.
- Official : 테라폼이 직접 관리
- Partner : 파트너사가 테라폼 프로바이더를 관리
- Community : 개발 관리자와 그룹에서 테라폼의 레지스트리를 관리
로컬 이름과 프로바이더 지정
bucket_name = "devlos-hello-t1014-remote-backend"
terraform {
required_providers {
architech-http = {
source = "architect-team/http"
version = "~> 3.0"
}
http = {
source = "hashicorp/http"
}
aws-http = {
source = "terraform-aws-modules/http"
}
}
}
data "http" "example" {
provider = aws-http
url = "https://checkpoint-api.hashicorp.com/v1/check/terraform"
request_headers = {
Accept = "application/json"
}
}
단일 프로바이더에서의 다중 정의
동일 프로바이더에서 다양한 리소스를 설정해야할 경우 alias를 통해 설정이 가능합니다.
provider "aws" {
region = "ap-southeast-1"
}
provider "aws" {
alias = "seoul"
region = "ap-northeast-2"
}
resource "aws_instance" "app_server1" {
ami = "ami-06b79cf2aee0d5c92"
instance_type = "t2.micro"
}
resource "aws_instance" "app_server2" {
provider = aws.seoul
ami = "ami-0ea4d4b8dc1e46212"
instance_type = "t2.micro"
}
과제 트러블 슈팅
terraform apply를 수행하다 defalut subnet이 생성이 되어 있지 않아, 다음과 같은 오류가 발생했습니다.
아래와 같이 create-default-subnet을 활용하여 subnet을 생성하여 해결했습니다.
aws ec2 create-default-subnet --availability-zone ap-northeast-2a
# region 변경이 필요한 경우 --region 옵션 사용!
aws ec2 --region=ap-southeast-1 create-default-subnet --availability-zone ap-southeast-1a
프로바이더 포맷
terraform {
required_providers {
<프로바이더 로컬 이름> = {
source = [<호스트 주소>/]<네임스페이스>/<유형>
version = <버전 제약>
}
...
}
}
프로바이더의 포맷에는 프로바이더의 로컬이름, 그리고 프로바이더를 배포하는 호스트의 주소, 지정된 레지스트리에서 구분하는 네임스페이스, 마지막으로 프로바이더에서 관리되는 플랫폼이나 서비스의 이름, 버전 제약성으로 구성됩니다.
프로바이더의 예시는 다음과 같습니다.
참고로 뒤에 배우는 state관리 기법중 하나인 terraform backend를 providers에서 설정할 수 있습니다.
예시
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "5.4.0"
}
}
backend "s3" {
bucket = "devlos-hello-t1014-remote-backend"
key = "terraform/state-test/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "terraform-lock"
}
required_version = ">= 1.4"
}
provider "aws" {}
프로바이더 에코시스템
테라폼 프로바이더는 사용자가 사용하는 방식과 구조에 따라 모든 곳에 테라폼을 적용할 수 있도록 설계되고 서비스화 되어 있습니다. 크게 워크플로우 파트너와 인프라스트럭처 파트너로 구성되어 있습니다.
출처 - https://developer.hashicorp.com/terraform/docs/partnerships
실습 - 프로바이더 경험해보기
프로바이더를 이용하여 서로 다른 region에 우분투를 배포하는 실습을 진행했습니다.
provider "aws" {
region = "ap-northeast-2"
alias = "region_1"
}
provider "aws" {
region = "ap-southeast-1"
alias = "region_2"
}
data "aws_region" "region_1" {
provider = aws.region_1
}
data "aws_region" "region_2" {
provider = aws.region_2
}
data "aws_ami" "ubuntu_region_1" {
provider = aws.region_1
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
}
data "aws_ami" "ubuntu_region_2" {
provider = aws.region_2
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
}
resource "aws_instance" "region_1" {
provider = aws.region_1
ami = data.aws_ami.ubuntu_region_1.id
instance_type = "t2.micro"
}
resource "aws_instance" "region_2" {
provider = aws.region_2
ami = data.aws_ami.ubuntu_region_2.id
instance_type = "t2.micro"
}
# [터미널1] ap-northeast-2
while true; do aws ec2 describe-instances --region ap-northeast-2 --query "Reservations[*].Instances[*].{PublicIPAdd:PublicIpAddress,InstanceName:Tags[?Key=='Name']|[0].Value,Status:State.Name}" --filters Name=instance-state-name,Values=running --output text ; echo "------------------------------" ; sleep 1; done
# [터미널2] ap-southeast-1
while true; do aws ec2 describe-instances --region ap-southeast-1 --query "Reservations[*].Instances[*].{PublicIPAdd:PublicIpAddress,InstanceName:Tags[?Key=='Name']|[0].Value,Status:State.Name}" --filters Name=instance-state-name,Values=running --output text ; echo "------------------------------" ; sleep 1; done
# init & plan & apply
terraform init && terraform plan && terraform apply -auto-approve
terraform state list
도전과제 - 서로 다른 두 개의 region에 AWS S3 버킷 배포하기
기세를 몰아, s3버킷을 두개의 리전에 각각 배포해 보는 과제를 풀어보았습니다.
스터디에서 배운 alias를 이용하여 서로 다른 region을 이용하여 s3 버킷을 생성했습니다.
국내외에 서비스를 배포하는 사업을 수행할 때 유용하게 사용될 것 같았지만, 지역 간 지연시간이나, 두 region 배포 시 한 region이 다운되면 모든 apply 시도가 실패하기 때문에, 완전히 환경을 격리하는 것이 좋다고 합니다.
provider "aws" {
alias = "seoul"
region = "ap-northeast-2"
}
provider "aws" {
alias = "singapore"
region = "ap-southeast-1"
}
resource "aws_s3_bucket" "singapore_bucket" {
provider = aws.singapore
bucket = "devlos-singapore-bucket" # 싱가폴 리전에 배포!!
tags = {
Name = "Singapore bucket"
Environment = "Prod"
}
}
resource "aws_s3_bucket" "seoul_bucket" {
provider = aws.seoul
bucket = "devlos-seoul-bucket" # 서울 리전에 배포!!
tags = {
Name = "Seoul bucket"
Environment = "Dev"
}
}
Terraform State
Terraform state는 Terraform이 관리하는 인프라의 현재 상태를 저장하는 파일입니다. 이 파일은 Terraform이 인프라의 리소스를 추적하고 관리하는 데 중요한 역할을 합니다. Terraform state 파일은 JSON 형식으로 저장됩니다.
Terraform state의 주요 역할을 요약하자면 다음과 같습니다.
- 리소스 추적: Terraform은 상태 파일을 사용하여 배포된 리소스의 현재 상태를 추적합니다. 이를 통해 Terraform은 리소스가 생성되었는지, 업데이트되었는지 또는 삭제되었는지 알 수 있습니다.
- 계획 및 적용: Terraform은 상태 파일을 참조하여 terraform plan 명령을 실행할 때 현재 상태와 원하는 상태를 비교합니다. 이 비교를 통해 어떤 변경 사항이 필요한지 결정하고, terraform apply 명령을 통해 실제 변경 사항을 적용합니다.
- 리소스 종속성 관리: 상태 파일은 리소스 간의 종속성을 관리하는 데 도움이 됩니다. Terraform은 종속성을 고려하여 리소스를 올바른 순서로 생성, 업데이트 또는 삭제합니다.
- 백업 및 복구: 상태 파일은 인프라의 현재 상태를 기록하므로, 이를 백업해 두면 인프라를 복구하거나 다른 환경으로 복제하는 데 사용할 수 있습니다.
실습 - 상태파일 확인
리소스의 정보를 수정해 가며 Terraform state의 serial이 수정되는 것을 확인하는 실습을 진행했습니다.
provider "aws" {
region = "ap-northeast-2"
}
resource "aws_vpc" "myvpc" {
cidr_block = "10.10.0.0/16"
tags = {
Name = "devlos"
}
}
terraform init && terraform plan && terraform apply -auto-approve
# 상태 파일 확인(VSCODE) : JSON 형식
ls
cat terraform.tfstate | jq
# 아래 정보는 terraform.tfstate 정보를 통해 출력
terraform state list
terraform state show aws_vpc.myvpc
# tag 정보를 수정한 후 다음 실행
# 배포 : plan 시 tfstate 상태와 코드 내용을 비교해서 검토
terraform plan && terraform apply -auto-approve
# 상태 파일 비교 : 백업 파일 생성됨
ls terraform.tfstate*
terraform.tfstate terraform.tfstate.backup
diff terraform.tfstate terraform.tfstate.backup
state사용에는 다음과 같은 단점이 있습니다.
팀단위 사용 시 state의 한계
여러 명의 개발자가 동시에 하나의 리소스 접근 시 충돌이 발생할 수 있으므로 Locking 기능이 필요합니다.
이러한 문제를 해결하기 위해서 Backend 사용이 추천됩니다. - 링크
민감정보 노출 가능성
테라폼 상태 파일의 모든 데이터는 평문으로 저장되기 때문에 민감 정보가 노출될 위험이 있습니다.
아래는 해당 내용에 대한 실습 예제입니다.
resource "random_password" "mypw" {
length = 16
special = true
override_special = "!#$%"
}
다음과 같이 패스워드 결과가 sensitive value로 보이지 않게 세팅되어 있고, console에서 확인 시 노출되지 않습니다.
하지만 tfstate에는 해당 내용이 평문으로 저장되어 있어 state파일의 관리가 매우 중요합니다.
State 동기화
테라폼의 스테이트는 기존 state 파일과 실제 배포된 리소스를 비교하여 생성, 수정, 삭제 실행 여부를 결정합니다.
출처 - 구구달스님 블로그 링크
구구달스 님께서 정리해 주신 도식을 살펴보면 기본적인 흐름은 Plan에서 기존 state와 테라폼 코드를 비교한 다음, 배포된 리소스와 한 번 더 비교하는 refresh 과정 이후 최종적으로 state 파일을 갱신하는 형태입니다.
이때 -refresh=false 옵션을 활용하면, 테라폼 state 파일과의 비교만을 수행하여 plan의 실행속도를 빠르게 할 수 있습니다.
(단 실제 환경과 차이가 발생할 수 있는 단점이 있습니다.)
State를 이해하기 위해서는 구성 리소스와, state 구성 데이터, 실제 리소스 간의 5가지 상황을 보는 것이 도움이 되었습니다.
유형 | 구성 리소스 정의(*.tf) | State 구성 데이터 | 실제 리소스 | 기본 예상동작 |
1 | 있음 | 리소스 생성 | ||
2 | 있음 | 있음 | 리소스 생성 | |
3 | 있음 | 있음 | 있음 | 동작 없음 |
4 | 있음 | 있음 | 리소스 삭제 | |
5 | 있음 | 동작 없음 |
해당 유형에 대해 스터디에서 각각 실습을 진행했습니다.
실습 - 유형 1 : 신규 리소스를 정의하고 Apply를 통해 리소스 생성
locals {
name = "mytest"
}
resource "aws_iam_user" "myiamuser1" {
name = "${local.name}1"
}
resource "aws_iam_user" "myiamuser2" {
name = "${local.name}2"
}
terraform init && terraform apply -auto-approve
terraform state list
terraform state show aws_iam_user.myiamuser1
#
ls *.tfstate
cat terraform.tfstate | grep id
# 아래 실행 시 어떻게 되나? 테라폼은 멱등성 한가?
terraform apply -auto-approve
ls *.tfstate
코드를 실행하면 아래와 같이 iam 계정이 두 개 생기게 됩니다. 기존에는 없던 state가 생기게 되고, 실제로 배포 환경에도 계정이 생성됩니다. 또한 반복적으로 apply를 수행해도 리소스가 변경되지 않는 멱등성을 보입니다.
실습 - 유형 2 : 실제리소스를 수동 제거한 후 apply 시 리소스 재생성
# 실제 리소스 수동 제거
aws iam delete-user --user-name mytest1
aws iam delete-user --user-name mytest2
aws iam list-users | grep mytest
terraform state list
# 아래 명령어 실행 결과 차이는?
terraform plan
terraform plan -refresh=false
cat terraform.tfstate | jq .serial
#
terraform apply -auto-approve
terraform state list
cat terraform.tfstate | jq .serial
실습 - 유형 3 : apply시 코드와 state, 배포 형상 모두 일치한 경우
terraform apply -auto-approve
cat terraform.tfstate | jq .serial
terraform apply -auto-approve
cat terraform.tfstate | jq .serial
terraform apply -auto-approve
cat terraform.tfstate | jq .serial
실습 - 유형 4 : 코드에서 일부 리소스를 삭제한 후 apply를 수행할 경우
locals {
name = "mytest"
}
resource "aws_iam_user" "myiamuser1" {
name = "${local.name}1"
}
terraform apply -auto-approve
terraform state list
terraform state show aws_iam_user.myiamuser1
#
ls *.tfstate
cat terraform.tfstate | jq
# iam 사용자 리스트 확인
aws iam list-users | jq
실습 - 유형 6 : 실수로 tfstate 파일 삭제 → plan/apply
Plan을 사용하면 클라우드의 상태와 state plan 상태를 비교하는 과정을 거칩니다. 하지만 대량의 데이터가 있을 때 시간이 많이 걸릴 수 있으므로, 빠르게 배포하고 싶을 경우 -refresh=false를 사용하여 빠르게 plan을 수행할 수 있습니다.
하지만 다음의 예시와 같이 실제 배포환경을 테라폼이 인지하지 못하여 에러가 발생할 수도 있습니다.
terraform state list
aws_iam_user.myiamuser1
rm -rf terraform.tfstate*
#
terraform plan
aws iam list-users | jq
terraform plan -refresh=false
#
terraform apply -auto-approve
실습 - 유형 7 : 실수로 tfstate 파일 삭제 시 → import로 tfstate 파일 복구
aws iam list-users | jq
# ADDR은 리소스주소 , ID는
# terraform [global options] import [options] ADDR ID
terraform import aws_iam_user.myiamuser1 mytest1
terraform state list
Terraform Backend - AWS S3와 Dynamo DB를 이용하여 관리
Terraform Backend는 Terraform 상태 파일을 저장하고 관리하는 위치와 방법을 정의하는 설정입니다.
Backend를 사용하면 state 파일의 관리 위치를 로컬에서 원격 환경으로 변경할 수 있습니다.
Backend를 구성하는 기법은 앞서 설명한 것처럼 여러 가지 방식이 있지만, 이번 스터디에서는 AWS S3와 Dynamo DB를 이용하여 구성하는 방법에 대해 배웠습니다. 크게 두 서비스의 역할은 다음과 같습니다.
- AWS S3:
- 상태 파일 저장: Terraform 상태 파일을 중앙에서 관리하기 위해 사용합니다. S3 버킷에 상태 파일을 저장하면 모든 팀원이 동일한 상태 파일을 공유하고 접근할 수 있습니다.
- 버전 관리: S3 버킷에 저장된 상태 파일은 자동으로 버전 관리가 되어 상태 파일의 변경 이력을 추적할 수 있습니다.
- DynamoDB:
- 잠금 관리: Terraform 작업의 동시 실행을 방지하기 위해 DynamoDB 테이블을 사용하여 상태 파일 잠금을 관리합니다. 여러 사용자가 동시에 같은 상태 파일을 수정하는 것을 방지할 수 있습니다.
실습 - Terraform Backend 구축
1. [사전 준비 1] 공용 저장소 AWS S3 생성 후 조회
1-1. 예제 코드에서 나의 S3 버킷 이름 등록
git clone https://github.com/sungwook-practice/t101-study.git example
cd example/state/step3_remote_backend/s3_backend
tree
# VSCODE에서 코드 파일들 확인 : main.tf, variables.tf , terraform.tfvars
## S3 버킷에 버저닝 활성화 <- 권장 옵션 설정
# terraform.tfvars 파일 내용 수정 : 각자 자신의 '닉네임' 추가하여 S3 버킷 이름 작성
bucket_name = "<닉네임>-hello-t1014-remote-backend"
# 생성
terraform init && terraform apply -auto-approve
# 확인
terraform state list
aws s3 ls
2. [사전 준비 1] provider에서 backend 등록
terraform init
# tfstate 파일 로컬 확인
terraform apply -auto-approve
terraform state list
ls #로컬에 없다..!!
# AWS S3 버킷 내에 tfstate 파일 확인
MYBUCKET=devlos-hello-t1014-remote-backend
aws s3 ls s3://$MYBUCKET --recursive --human-readable --summarize
3. [사전 준비 2] Locking을 위한 DynamoDB 활용을 위해 DynamoDB 생성
4. 2번 테라폼 자원 배포에 백엔드 설정 수정 및 적용
cd ../vpc
cat provider.tf
# VSCODE에서 provider.tf 수정 : dynamodb_table = "terraform-lock" 주석 제거
...
backend "s3" {
bucket = "devlos-hello-t1014-remote-backend"
key = "terraform/state-test/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "terraform-lock"
}
...
# backend설정이 달라졌으므로 terraform init 으로 적용
# Reconfigure a backend, and attempt to migrate any existing state.
terraform init -migrate-state
5. VPC tag 수정 후 apply 와서 Locking 확인
버킷의 버전 활성화를 사용하면 다음과 같이 terraform state 버전을 관리하여 backup 형태로 안전하게 사용할 수 있습니다.
# main.tf 수정 : '3' 추가
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = {
Name = "terraform VPC 3"
}
}
# apply
terraform apply -auto-approve
# 버저닝된 파일 확인
aws s3api list-object-versions --bucket $MYBUCKET | egrep "Key|VersionId|LastModified"
테라폼 백엔드 단점
테라폼의 backend 블록에는 변수나 참조를 사용할 수 없다고 합니다. 이런 것들을 보완해 주는 여러 가지 방법이 있는데 그중에 오픈소스인 테라 그런트가 있다고 합니다. - 링크
워크스페이스
워크스페이스는 여러가지 배포환경을 위해 테라폼 상태를 격리하여 사용하는 방법입니다.
예를 들면 다음과 같이 격리할 수 있습니다.
- stage : 테스트 환경과 같은 사전 프로덕션 워크로드 workload 환경
- prod : 사용자용 맵 같은 프로덕션 워크로드 환경
- mgmt : 베스천 호스트 Bastion Host, 젠킨스 Jenkins와 같은 데브옵스 도구 환경
- global : S3, IAM과 같이 모든 환경에서 사용되는 리소스를 배치
더욱 자세한 내용은 이전 스터디에서 정리해 놓은 글을 참고하면 좋을 것 같습니다! - 링크
워크스페이스의 기본적인 작업공간은 default입니다.
실습 - 워크스페이스 (ec2 만들기)
먼저 default 워크스페이스에서 ec2 인스턴스 하나를 생성합니다.
resource "aws_instance" "mysrv1" {
ami = "ami-0ea4d4b8dc1e46212"
instance_type = "t2.micro"
tags = {
Name = "t101-study"
}
}
# [분할/터미널1] 모니터링
export AWS_PAGER=""
while true; do aws ec2 describe-instances --query "Reservations[*].Instances[*].{PublicIPAdd:PublicIpAddress,InstanceName:Tags[?Key=='Name']|[0].Value,Status:State.Name}" --filters Name=instance-state-name,Values=running --output text ; echo "------------------------------" ; sleep 1; done
#
terraform init && terraform apply -auto-approve
terraform state list
#
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.private_ip'
# 워크스페이스 확인
terraform workspace list
# graph 확인
terraform graph > graph.dot
다음으로 새로운 워크스페이스인 mywork1을 생성하여 인스턴스 생성이 이루어지는지 확인해 보았습니다.
terraform workspace new mywork1
terraform workspace show
# 서브 디렉터리 확인
tree terraform.tfstate.d
terraform.tfstate.d
└── mywork1
# plan 시 어떤 결과 내용이 출력되나요?
terraform plan
# apply 해보자!
terraform apply -auto-approve
# 워크스페이스 확인
terraform workspace list
#
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
cat terraform.tfstate.d/mywork1/terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
이번에는 새로운 mywork2라는 워크스페이스에서 한번 더 ec2를 생성해 보았습니다.
# 새 작업 공간 workspace 생성 : mywork2
terraform workspace new mywork2
# 서브 디렉터리 확인
tree terraform.tfstate.d
...
# plan & apply
terraform plan && terraform apply -auto-approve
cat terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
cat terraform.tfstate.d/mywork1/terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
cat terraform.tfstate.d/mywork2/terraform.tfstate | jq -r '.resources[0].instances[0].attributes.public_ip'
# workspace 정보 확인
terraform workspace show
terraform workspace list
이를 통해 워크스페이스를 생성하여 리소스를 관리하면 state가 워크스페이스별로 격리되기 때문에 격리되어 리소스가 생성되는 것을 확인할 수 있었습니다.
삭제하기
워크스페이스별로 이동하며 state를 통해 리소스를 삭제해야 하기 때문에 다소 번거로운 부분이 있습니다. (개인 실습이기 때문에!!! )
terraform workspace select default
terraform destroy -auto-approve
terraform workspace select mywork1
terraform destroy -auto-approve
terraform workspace select mywork2
terraform destroy -auto-approve
마치며
이번 포스팅에서는 T101 4주 차 스터디 주제인 테라폼 provider 블록과 state, backend 그리고 workspace에 대해 배운 내용을 정리하였습니다.
어느덧 스터디가 중후반부로 가는군요. 항상 스터디를 하면서 느끼는 것이지만, 시간이 엄청 쏜살같이 흘러가네요 ㅎㅎ 벌써 7월이라니..
그럼 다음 주 스터디 내용에서 뵙겠습니다!
'클라우드 컴퓨팅 & NoSQL > [T101] 테라폼 4기 스터디' 카테고리의 다른 글
[6주차 - Well-Architected 방식으로 워크로드를 안전하게 마이그레이션 및 현대화하기 Workshop ] T101 4기 스터디 (24.07.14) (0) | 2024.07.17 |
---|---|
[5주차 - 모듈 & Runner] T101 4기 스터디 (24.07.07) (1) | 2024.07.13 |
[3주차 - 기본사용#3] T101 4기 스터디 (24.06.23) (0) | 2024.06.29 |
[2주차 - 기본사용#2] T101 4기 스터디 (24.06.16) (0) | 2024.06.22 |
[1주차 - 기본사용] T101 4기 스터디 (24.06.09) (1) | 2024.06.15 |