오늘 배운 것

어제 배운 것을 정리해보면, 우선 AWS provider를 선언한 다음에 VPC, Route53, VPC 보안그룹, 서브넷, EC2 spot 인스턴스 순서대로 생성하면 된다고 이해했다. (물론 아직 RDS, ECS, ECR 등의 리소스는 생성하지 않아서 추가적인 정보가 필요하다.)

 

우선 AWS provider부터 정의해보자. 'terraform aws provider'라고 검색하면 나오는 문서의 코드를 가져와서 변형해봤다. 여기서부터는 vscode 에디터에 입력하면서 해 보자. 다만 access_key와 secret_key 값은 github에 올라가면 안 되는 값이므로 aws의 secrets manager에서 가져오도록 작성해야 하겠다. 

 

💻 aws provider 선언하기

우선 variables.tf 파일을 별도로 만들어서 해당 파일 안에다가 사용할 변수 값들을 집어넣어 주자. 

variable "aws_access_key" {
    type = "string"
}
variable "aws_secret_key" {
    type = "string"
}
variable "aws_region" {
    type = "string"
}

 

그리고 terraform.tfvars 파일을 만들어서 해당 variable의 값으로 사용될 실제 값들을 넣어주자. 

aws_access_key = "your-access-key-id"
aws_secret_key = "your-secret-key"
aws_region     = "us-east-1"

 

마지막으로 main.tf 파일에다가 provider를 선언하는 로직을 만들어주자. 이렇게 하면 provider(일종의 plugin)를 사용해서 terraform에서 aws 서비스의 리소스들을 사용할 수 있다. 

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = var.aws_region
}

 

💻 aws vpc module 선언하기

다음으로는 퍼블릭 DNS와는 독립적인, aws 내부에서 동작하는 private network라고 할 수 있는 vpc를 정의하는 작업이 우선이겠다. rds도, ecr이나 ecs, ec2도 vpc 내부에서 선언되기 때문이다. 'aws vpc'라고 검색하면 나오는 문서를 참고해서 코드를 일부 바꿔 보았다. 참고로 해당 코드는 modules 디렉토리 안에 vpc.tf라는 파일에 따로 저장해 주었다. 

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "vpc_onestep"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c", "ap-northeast-2d"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24", "10.0.4.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24", "10.0.104.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = true

  tags = {
    Terraform = "true"
    Environment = "dev"
  }
}

 

이게 정확히 무슨 의미일까? 하나씩 봐 보자. 

 

우선 cidr 변수의 의미가 궁금했다. 

해당 [코드]에서 cidr 변수가 무엇을 의미하는지 설명해줘

 

cidr 변수는 예전 네트워크 시간에서 배웠다시피 Classless Inter Domain Routing의 약자로, 이전에 주소 공간을 A, B, C, D, E라는 클래스로 나눠서 정의하던 방식 대신 클래스로 구분하지 않으면서 IP 주소를 할당하는 새로운 방식이었다. 다만 여기서의 cidr 변수는 VPC 내부의 주소를 할당하는 방식을 나타냈다. 

 

그리고 이후에 정의할 subnet(서브넷)들은 이 cidr에서 정의한 블록 내부의 주소 공간을 나눠서 사용하게 된단다. public subnet과 private subnet 모두 마찬가지였다. 그리고 여기서 az(available zone, 가용 영역)s, private_subnets, public_subnets의 length는 모두 같아야 하며, 각 subnet들의 i번째 원소에 해당하는 cidr 주소 블록이 i번째 az에 할당되는 주소 블록이 된다고 이해했다. 

 

그런데 왜 aws에서 vpc를 설정할 때 이런 정보들이 필요한 것일까?

aws의 vpc를 설정할 때 왜 az(availability zone), private_subnet, public_subnet이라는 개념이 필요할까? 각각의 변수들이 어떤 의미이고 서로 어떻게 연결되는지를 설명해줘

 

AZ(가용 영역)가 필요한 이유는 고가용성(한 AZ에서 장애가 발생해도 서비스를 정상적으로 이용할 수 있도록 함)과 재해 복구를 위해서라고 한다. 가용 영역은 한 리전에 여러 개가 있다고 한다. 예를 들면 eu-west-1이라는 리전(region)에는 총 3개(eu-west-1a, eu-west-1b, eu-west-1c)의 가용 영역(AZ)이 있다. 그래서 하나의 리전에 있는 하나의 가용 영역에서 장애가 발생해도 나머지 두 개의 가용 영역을 통해 서비스는 정상적으로 운영된다. 

 

그리고 하나의 AZ에는 여러 개의 서브넷이 포함될 수 있다. 즉 내가 이해하기로는 하나의 VPC 안에는 여러 개의 가용 영역이 포함될 수 있고, 하나의 가용 영역 안에는 여러 개의 서브넷(public, private 서브넷이 모두 포함됨)이 포함될 수 있다고 이해했다. 질의를 통해 이 이해가 맞는지를 점검해 보고 넘어가자. 

내가 이해하기로는 '하나의 VPC 안에는 여러 개의 가용 영역(AZ)이 포함될 수 있고, 하나의 가용 영역 안에는 여러 개의 서브넷(private, public 모두 포함)이 포함될 수 있다'고 이해했어. 이 이해가 맞으면 맞다고, 틀리면 틀리다고 말해주고 모르면 검색해서 정확히 알려줘

 

GPT 피셜로는 맞다고 한다. 그리고 하나의 VPC는 하나의 리전 내에서만 존재할 수 있다고 한다.

 

 오늘 배운 것

저번 포스트에서 IaC를 통해 기존 소마 계정의 AWS 설정을 가져오는 데에는 실패했다. 그런데 다르게 생각해보면 IaC 도구인 terraform을 통해서 코드로 여전히 새 설정을 세팅하는 건 가능하지 않을까? 그렇다면 이 복잡한 과정을 코드로 풀어본다면 문제가 아주 조금은 더 간단해질 수도 있다는 희망을 가져보았다. 

 

그리고 두 가지의 질의도 해 보았다. 

  1. terraform 파일은 버전 관리를 어떻게 할까? 일반 코드와 똑같이 Github에다 올려놓으면 되려나
  2. 만약 그렇다면 terraform 파일에 들어가는 환경변수들은 또 어떻게 관리하면 좋을까

1번은 내가 생각하는 것처럼 github에서 버전 관리를 하는 경우도 많았다. 그리고 2번의 경우는 코드에서 사용하는 환경변수 관리와 똑같았다. 나는 주로 환경변수 관리를 위해서 AWS Secrets Manager를 이용하는데 그 작업을 해 주면 되겠다. 

 

그런데 궁금한 점이 또 있다. AWS의 ECR, ECS, EC2 인스턴스를 terraform에서 새로 생성한다고 가정해 보자. 그러려면 인스턴스가 아직 생성되지 않았으므로 인스턴스의 ID 값 등이 없을 것이다. 어떻게 생성할까? 검색하다 terraform 공식 사이트의 ec2 생성 가이드를 발견하고 읽어보았다. 여러 EC2 인스턴스 중에서도 나는 비용이 저렴한 spot 인스턴스를 사용할 것이라서 관련 코드를 복사해 주었다. 

module "ec2_instance" {
  source  = "terraform-aws-modules/ec2-instance/aws"

  name = "spot-instance"

  create_spot_instance = true
  spot_price           = "0.60"
  spot_type            = "persistent"

  instance_type          = "t2.micro"
  key_name               = "user1"
  monitoring             = true
  vpc_security_group_ids = ["sg-12345678"]
  subnet_id              = "subnet-eddcdzz4"

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}

 

그런데 코드를 복사하고 보니 EC2 인스턴스 관련 정보를 정의하기 위해서는 가령 subnet id나 vpc 보안그룹 id 등의 추가 정보가 필요했다. 다른 정보에 의존하고 있었다.

 

그러면 subnet id랑 vpc 보안그룹 id는 또 어떻게 생성하나. 공식 사이트 검색창에 'aws subnet'을 입력하니 또 뭔가가 나왔다. 이번엔 이 코드를 사용해 보자. 

resource "aws_subnet" "main" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Name = "Main"
  }
}

 

vpc 보안그룹도 검색해 보았다. 'aws security group'을 검색해보니 또 다른 뭔가가 나왔다. 이 코드도 일단 복붙해 보자. 이 코드는 그나마 좀 익숙한 감이 있다. 

resource "aws_security_group" "allow_tls" {
  name        = "allow_tls"
  description = "Allow TLS inbound traffic and all outbound traffic"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "allow_tls"
  }
}

resource "aws_vpc_security_group_ingress_rule" "allow_tls_ipv4" {
  security_group_id = aws_security_group.allow_tls.id
  cidr_ipv4         = aws_vpc.main.cidr_block
  from_port         = 443
  ip_protocol       = "tcp"
  to_port           = 443
}

resource "aws_vpc_security_group_ingress_rule" "allow_tls_ipv6" {
  security_group_id = aws_security_group.allow_tls.id
  cidr_ipv6         = aws_vpc.main.ipv6_cidr_block
  from_port         = 443
  ip_protocol       = "tcp"
  to_port           = 443
}

resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv4" {
  security_group_id = aws_security_group.allow_tls.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1" # semantically equivalent to all ports
}

resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv6" {
  security_group_id = aws_security_group.allow_tls.id
  cidr_ipv6         = "::/0"
  ip_protocol       = "-1" # semantically equivalent to all ports
}

 

vpc security group 리소스를 정의하기 위해서 더 이상 필요한 정보가 없을까? 잘 모르겠다. GPT에게 질의를 해 보자. 

terraform을 사용해서 해당 [링크]를 보고 AWS의 VPC 보안그룹을 설정하려고 해. 위의 코드를 가져와서 사용하려면 추가로 더 필요한 정보가 있을까? 있으면 있다고 없으면 없다고 말해주고, 모르면 검색해서 알려줘

 

GPT 피셜 'VPC'의 id가 필요하단다. 'vpc'라고 검색하니 또 다른 module 문서가 나왔다. 일단 기본 코드를 냅다 가져와 보자. 

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = true

  tags = {
    Terraform = "true"
    Environment = "dev"
  }
}

 

소스 코드 상 해당 코드를 작성하기 위해서 더 이상 다른 정보가 필요하지는 않아 보인다. 다만 의문이 생긴다. 아직 Route53과 같은 호스팅 서비스를 이용해서 IP주소를 할당한 것도 아닌데 어떻게 알아서 CIDR 주소를 할당할 수 있을까? 이 역시 질의로 물어보았다. 

[코드]를 보고 든 [의문점]에 대해서 해답을 알려줘

 

여기서 정의한 cidr, private_subnets, public_subnets 등의 값은 VPC와 관련된 것으로, 퍼블릭 DNS와는 관계 없는 주소였다. VPC는 AWS의 내부 네트워크 구조이기 때문이다. 애초의 VPC는 Virtual Private Cloud의 약자였다. AWS 내부의 주소 체계이며, 퍼블릭 주소 체계와는 관련 없는 별도의 주소 체계여서 호스팅 서비스와 연결하지 않고 이용이 가능했던 것이다. 

 

그렇다면 두 가지 의문이 생긴다. 

  1. 이 상황에서 Route53과 같은 호스팅 서비스와는 어떻게 연결할 수 있을까?
  2. 호스팅 서비스와 연결하지 않는다면 VPC 내에서만 서비스가 동작한다는 것인데, 어떻게 이게 가능할까?

이 역시 질의를 통해 물어보았다. 1번 질문은 웹사이트에서 찾아보면 되니 2번에 대해서만 일단 물어봤다. 

그렇다면 VPC는 DNS와는 별도의 주소 체계인 것이고 private network라고 이해했어. 그렇다면 만약 Route53과 같은 별도의 호스팅 서비스를 정의하지 않고 VPC 내부에서만 서비스가 동작하게 한다면, 어떻게 해당 서비스에 접속할 수 있어?

 

같은 VPC 내부에서만 접속이 가능하다고 한다. 복잡한 아키텍처나 보안 수준이 높은 아키텍처의 경우는 이러한 방식으로 접속이 이뤄질 수 있겠다. 우선은 우리 서비스의 일은 아니었다. 호스팅 서비스와 연결해서 공공 네트워크에서도 접속이 가능해야 했다. 'aws route53'을 입력하니 역시나 또 다른 module이 정의되어 있었다. 

 

이쯤 되니 궁금한 것이 생겼다. 공식문서 사이트에서 검색할 때 provider랑 module별로 검색결과가 구분되어 나오는데, provider와 module의 정확한 정의는 뭘까?

 

우선 module 공식문서에서는 module을 '같이 사용되는 여러 리소스들을 담아두는 container'라고 정의했다. 그에 비해 provider의 정의는 거의 연관이 없었다. 이전에도 한번 찾아봤던 것 같은데, terraform이 여러 클라우드 서비스 제공자나 SaaS 제공 업체들의 리소스를 잘 가져오고 다루기 위해 사용하는 플러그인이었다. 

 

그렇다면 providers와 modules 중 무엇을 클릭해야 할지 헷갈린다면, 지금 찾아보려는 내용이 아예 'AWS와 terraform 연결하기'처럼 독립적인 개별 서비스와 terraform을 연결하려는 활동인지 아닌지를 생각해보면 되겠다. AWS를 직접 terraform과 연결하기 위해서 맨 처음에 aws provider를 선언하는 것 외에는 내가 aws의 리소스들만 사용하는 이상은 다른 provider를 선언할 일이 없을 것 같다. 

 

아무튼 이 module의 코드도 가져와 보자. 여기서는 zones와 records라는 두 개의 모듈이 있었다.

module "zones" {
  source  = "terraform-aws-modules/route53/aws//modules/zones"
  version = "~> 3.0"

  zones = {
    "terraform-aws-modules-example.com" = {
      comment = "terraform-aws-modules-examples.com (production)"
      tags = {
        env = "production"
      }
    }

    "myapp.com" = {
      comment = "myapp.com"
    }
  }

  tags = {
    ManagedBy = "Terraform"
  }
}

module "records" {
  source  = "terraform-aws-modules/route53/aws//modules/records"
  version = "~> 3.0"

  zone_name = keys(module.zones.route53_zone_zone_id)[0]

  records = [
    {
      name    = "apigateway1"
      type    = "A"
      alias   = {
        name    = "d-10qxlbvagl.execute-api.eu-west-1.amazonaws.com"
        zone_id = "ZLY8HYME6SFAD"
      }
    },
    {
      name    = ""
      type    = "A"
      ttl     = 3600
      records = [
        "10.10.10.10",
      ]
    },
  ]

  depends_on = [module.zones]
}

 

records는 이전에 멘토님과 같이 route53을 연결했을 때 보았던 그 호스팅 레코드가 맞는 것 같았다. 다만 zones은 무엇인지 잘 모르겠다. 일단 질의를 해 봤다. 

[코드]에서 module별로 분리되는 'zones'와 'records'가 어떤 것인지 알려줘

 

zones는 AWS Route53에 DNS hosting zone을 생성하는 데 사용된다고 한다. 여기서부터는 VPC 내부에서만 사용되는 것이 아니라 VPC 내부에 정의된 여러 리소스들이 DNS와 연결되는 지점인 것이라고 이해했다. 그리고 이 zone은 해당 도메인에 대한 모든 record들의 컨테이너 역할을 한단다. 

 

 궁금한 점

  1. VPC gateway와 NAT gateway의 정의가 각각 무엇일까?
  2. terraform에서 provider와 module은 각각 무슨 개념이고 무슨 차이가 있을까?

 

4월부터 지금까지 오랜 시간이 흘렀다. 12월을 조금 넘기고 있는 시점이라 싱숭생숭할 때 마침 후기를 적으면 좋을 것 같아 글을 올려본다. 
 

✅ 개인적인 회고

소마에서 얻어갈 수 있는 것은 '사람'이라는 말을 많이 들었다. 나도 크게 다르지 않은데 다만 얻을 수 있는 것은 크게 두 가지가 있다고 생각한다. 첫 번째는 같은 팀원들과 멘토님들 및 오며가며 알고 지내던 연수생분들을 모두 포함한 열정 있는 좋은 사람들, 두 번째는 구르고 엎어지는 등 수많은 서사가 쌓인 완벽하지 않은 프로젝트이다. 
 
나는 인증을 하였냐면 그건 아니다. 창업을 하지도 않았고, 막바지에 취업에 성공한 케이스이다. 실제로도 우리 팀은 창업 or 취업 중에서는 취업을 목적으로 한 팀이었다. 소마를 수료하면서 참 행운이고 다행이었다고 생각했던 점들이 있고, '그럼에도 이건 더 잘 해볼걸'이라는 후회가 남는 점도 있다. 뿌듯함, 아쉬움, 시원섭섭함 등등 여러 감정이 교차하는 만큼 위에 언급한 두 가지에 대해서 적어보고자 한다. 
 

🍀 열정 있는 좋은 사람들 (Special Thanks)

우선 프로젝트를 끝까지 마쳤다는 점이 너무 다행이고 운이 좋았다고 생각했다. 이는 물론 나의 노력도 있지만 팀원들과 멘토님들이 없었더라면 매우 어렵지 않았을까 싶다. 개발 프로젝트를 하면서도 이렇게 긴 기간동안 프로젝트를 해본 적이 없었다. 그래서 열심히 하고자 하는, 의지를 가진 사람들과 팀을 함께하는 것이 얼마나 중요하고 또 어려운 일인지를 실감했다. 
 
먼저 우리 팀원들에게 고마움의 말을 전하고 싶다. 이런 긴 프로젝트에서 팀장을 맡아본 적은 처음이라 분명히 미숙한 점도 많았을 것이다. 그래도 잘 믿고 따라와 줘서 너무 고맙다. 덕분에 팀장은 단순히 일을 잘해야만 할 수 있는 것이 아니라, 팀원들과 좋은 관계를 유지하면서도 어떻게 하면 팀의 성과를 끌어올릴 수 있을지를 둘 다 고민해야 하는 영역임을 알 수 있었다. 그리고 사실 나는 일할 때 그렇게 분위기를 잘 풀어주는 성격은 아니라서, 아마 나와 같은 사람들 세 명이 모였다면 일은 어찌저찌 잘 해낼 수 있었겠으나 팀 분위기가 엄청 화기애애할 것 같지는 않았다. 회의도 하면서 중간중간 얘기를 조잘조잘 해 준 팀원들 덕분에 분위기가 더 좋았던 것 같아서 고맙다. 실제로 이런 분위기라 다른 연수생 분은 우리 팀을 '동물의 숲 주민들' 같다고 해주셨고, 우리 멘토님은 '어떻게 이렇게 무해한 사람들만 모으셨나요'라고도 해주셨다...ㅋㅋㅋㅋㅋㅋ 암튼 그만큼 화기애애하게 지냈던 기억이 많다. 
 
그 다음은 우리 멘토님들이다! 사실 4월에는 팀원 매칭 다음이 바로 멘토 매칭으로 이어져서 스케줄이 정말 빡셌다. 덕분에 파워 J인 나도 멘토 세 분을 각각 어떤 포지션으로 모실지에 대해서 청사진을 그리지는 못했고, 최대한 여러 분을 만나뵈면서 '이분이다..!' 라는 느낌이 드는 분을 모시려고 했다. 이름을 언급하면 특정이 될 수 있으니 멘토님들의 역할과 특징으로 언급해 보겠다.
 
헤드 멘토님은 항상 일관된 조언을 주셨고, 나는 이분 덕분에 개발자로써 구체적인 내 진로를 고민해볼 수 있었다. 취업만 하면 다가 아니구나, 완벽한 개발과 완벽한 커리어라는 것은 없구나, 일단 해보고 뭔가를 깨닫고 고치는 거구나, 라는 깨달음을 이분 덕분에 얻었고 여전히 체화 중이다. 곰돌이 멘토님은 AI와 백엔드 멘토님이셨는데 프론트 디버깅까지도 하시는 능력자셨다. 항상 우리가 어떤 문제를 말씀드려도(심지어 프론트 코드를 냅다 보여드린 적도 있다...) 뚝딱뚝딱 솔루션을 같이 찾아주셨던 점이 너무 감사했고, 스타트업의 CEO이신데도 항상 편안하게 우리를 대해 주셨다. 이분 덕분에 '개발자는 나중에 어떤 직급을 맡게 되어도 개발을 할 수 있어야 하고, 문제를 해결할 수 있어야 하겠구나'라는 깨달음을 얻었다. 마지막으로 프론트 멘토님은 항상 열정 있게 우리를 대해주시고, 회사 업무를 하면서 사이드 프로젝트도 하시면서 우리 멘토링도 해 주시는 시간의 마법사셨다. 프론트 지식을 따로 정리해서 멘토링도 해 주시고, vscode로 직접 라이브코딩을 하면서 문제에 대해 조언을 주셨어서 온오프라인에 관계 없이 뵐 때마다 든든했던 기억이 있다. 이분 덕분에 '나도 저렇게 취업한 이후에도 계속해서 뭔가를 해 나가는 열정 있는 개발자가 되고 싶고, 꾸준히 회고하고 지식을 공유하는 개발자가 되고 싶다'는 새로운 지향점을 얻었다. 
 
그 다음엔 소마에서 오며가며 뵈었던 모든 연수생들이다. 나는 그렇게 발이 넓은 편은 아니라 엄청 많은 분들과 알며 지내지는 못했다. 아마 6월까지 학교를 다니고 7월부터 센터를 많이 나와서 그렇게 된 부분도 컸던 것 같다. 그럼에도 몇 분들과는 오며가며 뵙고 종종 안부를 물으며 지냈다. 소마 센터에 꾸준히 나오시는 분들은 대부분 이 프로젝트에 열정이 있고, 개발이나 기획에도 열심히 참여하시는 분들이 많았다. 덕분에 잠시 열정을 잃어버렸을 때 다른 분들과도 얘기를 나누며 그 열정을 조금 더 받아갈 수 있었다. 오며가며 날 보신 분들이 계시다면 덕분에 열정 있고 재밌는 소마 생활을 할 수 있었어서 감사하다는 말씀을 전하고 싶다. 
 

🎓 완벽하지 않은 프로젝트

분명 소마를 붙고 활동을 시작하기 전에는 기깔나는 프로젝트를 만들어서 내 포트폴리오로 삼아야겠다는 취준생의 세속적인 생각이 있었다. 지금도 그런 생각이 아예 없다고는 못 하겠는데, 이 완벽하지 않은 나의 프로젝트를 만나면서 이 생각에 하나의 전환점이 생겼다. 완벽한 것은 앞으로도 없을 것이고, 문제를 마주하고 고치는 과정 자체를 즐겨야 내가 개발자로서 성취감 있고 재밌게 살아갈 수 있겠다는 생각이었다. 
 
이 프로젝트에 나름의 노력을 다했었다. 그리고 최고라고는 못 하겠지만 매 순간 나름대로 생각하는 최선이라는 선택을 했었다. 당연하게도 이 프로젝트는 완벽하지 않았다. 사용자를 거의 모으지 못했고, 최초에 기획했던 것을 전부 다 구현하지는 못했으며, 성능 이슈도 있다. 그럼에도 불구하고 나한테 가장 기억에 남는 프로젝트임은 확실하다. 모든 것을 결정하는 과정에 나름의 이유와 과정이 있었기 때문이다. 
 
가령 우리 프로젝트에는 동기화 기능이 있다. 사용자가 태블릿이나 스마트폰 등 여러 기기에서 앱을 동시에 사용할 때 동기화가 되도록 구현해 놓았다. 다만 '동기화'하면 생각나는 '웹소켓'이나 '롱 폴링(long polling)'을 쓰지는 않았다. 우리는 백그라운드 푸시 알림으로 동기화 기능을 구현했다. 이유는 명확했다. 최대한 빠르게 구현하기 위해서였다. 
 
당시 프로젝트의 사용자가 없었고, 중요한 것은 일단 빠르게 구현해서 배포를 하는 것이었다. 물론 이 방법을 선택했음에도 다른 문제들이 있어서 결과적으로 빠른 배포가 되지는 않았었다. 그리고 웹소켓과 롱 폴링이라는 방법도 완벽한 방법은 아니었다. 단지 문제를 해결하는 하나의 방법일 뿐이었다. 지금은 사용자가 없으니 가장 간단한 방법으로 동기화를 구현해 볼 뿐이었다. 당연히 이 방법도 단점이 있었다. 우선은 푸시 알림의 본 목적과 다른 목적으로 동기화를 위해 사용하므로 취지에 맞지 않는다는 문제가 있었고, 백그라운드 푸시 알람이 너무 빈번하게 오게 되면 FCM 서버 등의 다른 곳에 부하가 될 수도 있었다. 그러면 그 문제가 주는 불편함이 현재의 편리함보다 더 커졌을 때 구현 방법을 바꾸면 되는 일이었다. 
 
이런 류의 여러 문제들을 마주하면서 어려운 기술을 쓰는 게 중요한 게 아니고, 어떤 문제를 풀기 위해서 어떤 기술이나 접근을 썼고, 그걸 왜 사용하였는지가 더 중요함을 깨달았다. 지금까지 나는 내가 어렵고 복잡한 기술을 쓰지 못해서 개발을 잘 못 한다고 생각했고, 더 잘 하기 위해서는 그런 기술을 알아야만 한다고 생각했었다. 그런데 그건 아니었다. 오히려 내가 자신이 없었던 이유는 매 순간 내가 무엇을 문제라고 생각했고, 그래서 무엇을 해결책으로 생각했으며, 왜 그걸 선택했고 어떤 장단점이 있는지가 명확하지 않아서였다. 물론 이 과정조차 완벽하진 않은데, 그럼에도 그런 '나만의 생각'이 없어서 내가 더 확신이 없었던 것이라고 생각한다. 개발자도 결국은 문제를 해결하는 사람의 부분집합이었다. 그 당연한 사실을 소마를 통해 조금이나마 더 깨달을 수 있었다. 
 

➡️ What's next (Solopreneur)

1월부터는 어엿한 사회인 개발자가 되어 다른 곳에서 또 개발을 이어간다. 여기서 배운 것들을 잊지 않고, 현업에서 이 마음가짐을 갖고 문제를 해결하는 개발자가 되고 싶다. 물론 나는 주니어 개발자가 맞지만, 스스로를 주니어라고 한정짓지는 말아야 하겠다. 스스로에게 주어지는 일들은 잘 해내려고 노력하고 모르면 모르는 대로 고민하고 도움을 요청하는 것도 좋지만, '나는 주니어니까 이것까지만 하면 되겠지'라는 마음가짐을 경계하자. 
 
최근에 참여한 다른 특강에서 Solopreneur, 1인 창업가라는 단어를 들었는데 인상깊었다. 'AI가 개발자를 대체할까'와 비슷한 주제의 특강에서 내가 이런 질문을 했었고, 여기에 대한 강연자분의 답변이 '1인 창업가가 되어라'는 말이었다. 

주니어 개발자는 상대적으로 대체되기 쉽고, CTO나 테크 리드는 직접 문제를 정의하는 사람이라 상대적으로 대체되기 어려울 것 같다는 인상을 받았습니다. 어떻게 하면 주니어 개발자인 상황에서도 스스로 문제를 정의하는 개발자로써 기능할 수 있을까요? 일단 생각되는 방법은 주니어 개발자인 상황에서도 꾸준한 성찰과 회고를 통해 문제를 직접 정의하는 과정을 연습해보는 것인데, 혹시 또 다른 좋은 방법이 있을지 궁금합니다.

 
정말로 1인 창업을 하라는 말씀이었을 수도 있지만, 모두가 회사를 안 다닐 수는 없지 않을까? 나는 이 말을 이렇게 해석했다. '아직 스스로가 회사에서는 주니어 레벨이어도, 혼자 사이드 프로젝트를 하면서나, 주니어로 임하면서도 마치 창업을 하고 있다고 생각하면 그 깊이를 끝까지 달려볼 수 있다.' 스스로의 역할을 주니어 개발자라고 한정짓지 말고, 만약 사이드 프로젝트를 한다면 이 사이드 프로젝트에서만큼은 내가 기획자이고, 개발자이고, 동시에 마케터도 되는 것이다. 결국 중요한 것은 문제를 해결하는 것이니 말이다. 개발자의 본질도 결국은 문제 해결이라고 느꼈다. 
 
그러나 이 때문에 현업을 소홀히 하는 것 또한 있어서는 안 될 일이었다. 어떻게 하면 밸런스를 맞출 수 있을까? 멘토님께 여쭤봤더니 일단 한 회사에 몸담고 있는 상황에서는 그 조직에서 내가 맡은 일을 잘, 효율적으로 해내는 것이 목표가 되어야 한다고 하셨다. 그리고 그러한 일을 잘 하면서도 여유 시간이 남는다면, 그때 시도해 보는 것은 괜찮을 것 같다는 조언을 해주셨다. 그래서 일단은 이렇게 해보려고 한다. 아마 이렇게 호기롭게 말을 했어도 이후 몇 달간은 신입으로써 적응하는 데 시간을 많이 쓸 것 같고, 그 영역도 분명 내가 생각하는 것보다 훨씬 더 넓고 깊은 세계일 것이다. 그때 가서 내가 너무 무모했나 싶은 허탈한 마음의 자책성 회고가 올라올 수도 있겠다... 그래도 일단은 이 마음을 잊지 말자. 
 

이제는 과제를 해보자. 슬라이드 뒤편에 과제 슬라이드를 첨부해 주셨으니 하나씩 읽으면서 해 보자. 

 

✅ 과제 #1: 이해하기

슬라이드를 직접 캡처하기가 애매해서 말로 풀어서 써보겠다.

 

과제 #1에서는 테이블을 만들 때 '코멘트'를 넣는 작업을 하면 된다. 정확히는 django ORM에서 Model의 verbose_name, Field의 verbose_name 값으로 입력한 값이 필드의 '코멘트'가 되도록 하는 작업이다. 

 

사실 나는 '코멘트'가 뭔지 잘 몰랐다. 블로그 글을 보면서 이해한 바로는 '코멘트'는 SQL(mysql, oracle 등) db에서 제공하는 하나의 기능으로, 필드나 테이블의 의미를 나타내기 위한 기능이라고 한다. 

 

아래는 oracle db에서 코멘트를 추가하는 문법이다. 

COMMENT ON TABLE 테이블명 IS '코멘트 내용';

COMMENT ON COLUMN 테이블명.컬럼명 IS '코멘트 내용';

 

어쨌든, 이를 알았으니 과제를 이해해볼 수 있겠다. 내가 알기로는 기본적으로 django model, field에서 있는 verbose_name 값은 db의 comment와 아무런 관계가 없는 것이 기본값이다. 

 

관계를 만들고 싶다면 아마도 DatabaseSchemaEditor를 사용해야 한다고 이해했다. 과제에서 원하는 것은 verbose_name 속성이 create, update, delete 될 때 db의 comment도 같이 업데이트 되는 것이다. verbose_name 속성은 필드나 테이블의 속성을 수정하는 것이므로 migration의 영역이지 queryset의 영역은 아니다. 

 

그리고 일반 migration에서는 이러한 기능을 제공하지 않는다. 정확히 말하면 migration에서도 SQL.runpython(정확한 명령이 맞나 모르겠다)로 raw SQL을 실행시킬 수 있지만, 이러면 매번 필드나 테이블을 변경할 때마다 raw SQL 쿼리를 짜야 한다. 비효율적이라는 말이겠다.

 

그러므로 그 밑단에 있는 DatabaseSchemaEditor의 기능을 재정의하거나 일부 수정해서, 다음과 같은 일들을 하게 하면 되겠다. 

  1. create table/column 작업 시 verbose_name 속성이 있다면 comment 추가하기
  2. update table/column 작업 시 verbose_name 속성이 수정/삭제되었다면 comment 수정하거나 삭제하기

그렇다면 DatabaseSchemaEditor의 source code를 봐 보자.

 

보려고 했는데 source code가 2000줄이 넘는다... 일단은 문서를 보고 이해가 안 되면 코드를 보자. 다행히 이럴 줄 알았는지 SchemaEditor 문서에는 메소드들이 잘 설명되어 있었다. 여기서 필드나 모델을 생성하는 경우, 지우는 경우(지우려는 field나 table에 comment가 있다면 지워야 하므로), 수정하는 경우에 대한 메소드들만 정리해 보았다. 

  • create_model
  • delete_model
  • alter_db_table_comment
  • add_field
  • remove_field
  • alter_field

한 가지 의문은 왜 table의 경우 'alter_db_table_comment' 메소드가 따로 있는데 필드의 경우는 없는지 모르겠다. 정 없으면 alter_field 메소드에서 작업을 대신하면 되니 일단 넘어가자. 

 

그 다음에 할 일은 이 메소드들의 소스 코드만 보고, 작업을 아주 대략적으로 이해하는 것이다. 우선 'create_model' 메소드를 봐 봤다. table_sql이란 다른 메소드를 통해 실행할 SQL을 받아온 다음, 추가적인 작업을 더 하는 것으로 보였다. 그 중에서도 중요한 부분은 db_comment와 관련된 이 부분이었다. 

class BaseDatabaseSchemaEditor: 

    def create_model(self, model):
        """
        Create a table and any accompanying indexes or unique constraints for
        the given `model`.
        """
        sql, params = self.table_sql(model)
        # Prevent using [] as params, in the case a literal '%' is used in the
        # definition on backends that don't support parametrized DDL.
        self.execute(sql, params or None)

        if self.connection.features.supports_comments:
            # Add table comment.
            if model._meta.db_table_comment:
                self.alter_db_table_comment(model, None, model._meta.db_table_comment)
            # Add column comments.
            if not self.connection.features.supports_comments_inline:
                for field in model._meta.local_fields:
                    if field.db_comment:
                        field_db_params = field.db_parameters(
                            connection=self.connection
                        )
                        field_type = field_db_params["type"]
                        self.execute(
                            *self._alter_column_comment_sql(
                                model, field, field_type, field.db_comment
                            )
                        )
        # Add any field index (deferred as SQLite _remake_table needs it).
        self.deferred_sql.extend(self._model_indexes_sql(model))

        # Make M2M tables
        for field in model._meta.local_many_to_many:
            if field.remote_field.through._meta.auto_created:
                self.create_model(field.remote_field.through)

 

한 메소드만 가져와봤는데도 길다... 암튼 여기서 중요한 부분은 'alter_db_table_comment'와 'if field.db_comment' 부분이다. 이 부분에서는 각각 table comment와 column comment를 더해준다는 주석이 적혀있어서 금방 알아볼 수 있었다.

 

그럼 이 부분을 override 해 주면 되겠다. 그런데 migration 수정은 해 봤어도 DatabaseSchemaEditor 수정은 처음 해 본다. 이 부분은 잘 모르겠어서 GPT 찬스를 써 봤다. 

 

크게 세 단계를 거치면 되었다. 

  1. BaseDatabaseSchemaEditor를 상속받는 새 DatabaseSchemaEditor 클래스를 작성한다. 
  2. 각 DB(여기서는 SQLite)의 DatabaseWrapper 클래스를 상속받아서 1번에서 정의한 새 SchemaEditor를 사용함을 선언한다.
  3. 1번과 2번 파일이 들어있는 모듈을 만들고, 1번은 schema.py에, 2번은 base.py 파일에 위치시킨다. 
  4. settings.py의 DATABASES 변수 값 안의 'engine' 속성을 3번의 프로젝트 상대 경로로 지정한다.

그럼 이제 이 방식대로 코딩을 해 보자. 

 

우선 1번, 새 DatabaseSchemaEditor 클래스를 작성해 보자. 관건은 어떻게 하면 '적은 코드를 바꿔서 comment를 수정하도록 할 것인가'였다. 이를 위해서 'comment'로 전체검색을 해 보았다. 그랬더니 다음과 같은 코드가 나오는 게 아닌가. 

sql_alter_table_comment = "COMMENT ON TABLE %(table)s IS %(comment)s"
sql_alter_column_comment = "COMMENT ON COLUMN %(table)s.%(column)s IS %(comment)s"

 

이 변수들을 사용하는 코드를 찾아보았다. alter_table_comment 변수는 alter_db_table_comment에서 사용하고 있었다. 그리고 이 alter_db_table_comment는 create_model 메소드에서 호출되고 있었다. 그렇다면 모델을 만들 때 외에는 comment를 별도로 업데이트하지 않는 것인가? 일단 이 의문을 갖고 넘어가 보자. 

def alter_db_table_comment(self, model, old_db_table_comment, new_db_table_comment):
    if self.sql_alter_table_comment and self.connection.features.supports_comments:
        self.execute(
            self.sql_alter_table_comment
            % {
                "table": self.quote_name(model._meta.db_table),
                "comment": self.quote_value(new_db_table_comment or ""),
            }
        )

 

이번에는 sql_alter_column_comment 변수의 사용처를 찾아보았다. _alter_column_comment_sql에서 사용되고 있었고, 해당 함수는 create_model, add_field, _alter_column_type_sql 함수에서 사용되고 있었다. _alter_column_type_sql에서 사용된 것은 의외였다. column type을 바꾸는 데 comment가 관여할 여지가 있나 의문이다. 

def _alter_column_comment_sql(self, model, new_field, new_type, new_db_comment):
    return (
        self.sql_alter_column_comment
        % {
            "table": self.quote_name(model._meta.db_table),
            "column": self.quote_name(new_field.column),
            "comment": self._comment_sql(new_db_comment),
        },
        [],
    )

 

여기서 왜 코멘트 관련 문법이 이렇게 간단하지 싶었는데, 알고보니 코멘트의 create, update, delete 문법이 모두 같았다. 그래서 위의 두 가지 sql문으로 모두 커버가 되는 것이었다. 그러면 이제는 위에서 언급된, comment 관련 필드를 사용하는 메소드를 override 해 주면 되겠다. 

  • create_model
  • add_field
  • _alter_column_type_sql

 

✅ 과제 #1: 결과물

세 개의 메소드를 재정의하였고, 주 변경 내용은 필드를 바꾼 것이었다. 예를 들면 기존의 table._meta.db_comment로 되어있는 변수 대신 table._meta.verbose_name으로 바꿔 주는 작업이 주였다. 구현해 본 코드는 다음과 같다. (구현한 부분에다가만 주석을 달아보았다)

더보기

 

# custom_engine/schema.py

from django.db.backends.mysql.schema import DatabaseSchemaEditor
from django.db.backends.utils import split_identifier
from django.db.models import NOT_PROVIDED


class CustomDatabaseSchemaEditor(DatabaseSchemaEditor):
    
    def create_model(self, model):
        """
        Create a table and any accompanying indexes or unique constraints for
        the given `model`.
        """
        sql, params = self.table_sql(model)
        # Prevent using [] as params, in the case a literal '%' is used in the
        # definition on backends that don't support parametrized DDL.
        self.execute(sql, params or None)

        if self.connection.features.supports_comments:
            """
            추가한 부분 - 'db_table_comment' 대신 'verbose_name' 사용
            """
            if model._meta.verbose_name:
                self.alter_db_table_comment(model, None, model._meta.verbose_name)
            

            if model._meta.db_table_comment:
                self.alter_db_table_comment(model, None, model._meta.db_table_comment)

            """
            추가한 부분 - 'db_table_comment' 대신 'verbose_name' 사용
            """
            if not self.connection.features.supports_comments_inline:
                for field in model._meta.local_fields:
                    if field.verbose_name:
                        field_db_params = field.db_parameters(
                            connection = self.connection
                        )
                        field_type = field_db_params["type"]
                        self.execute(
                            *self._alter_column_comment_sql(
                                model, field, field_type, field.verbose_name
                            )
                        )
                        

            # Add column comments.
            if not self.connection.features.supports_comments_inline:
                for field in model._meta.local_fields:
                    if field.db_comment:
                        field_db_params = field.db_parameters(
                            connection=self.connection
                        )
                        field_type = field_db_params["type"]
                        self.execute(
                            *self._alter_column_comment_sql(
                                model, field, field_type, field.db_comment
                            )
                        )
        # Add any field index (deferred as SQLite _remake_table needs it).
        self.deferred_sql.extend(self._model_indexes_sql(model))

        # Make M2M tables
        for field in model._meta.local_many_to_many:
            if field.remote_field.through._meta.auto_created:
                self.create_model(field.remote_field.through)


    def add_field(self, model, field):
        """
        Create a field on a model. Usually involves adding a column, but may
        involve adding a table instead (for M2M fields).
        """
        # Special-case implicit M2M tables
        if field.many_to_many and field.remote_field.through._meta.auto_created:
            return self.create_model(field.remote_field.through)
        # Get the column's definition
        definition, params = self.column_sql(model, field, include_default=True)
        # It might not actually have a column behind it
        if definition is None:
            return
        if col_type_suffix := field.db_type_suffix(connection=self.connection):
            definition += f" {col_type_suffix}"
        # Check constraints can go on the column SQL here
        db_params = field.db_parameters(connection=self.connection)
        if db_params["check"]:
            definition += " " + self.sql_check_constraint % db_params
        if (
            field.remote_field
            and self.connection.features.supports_foreign_keys
            and field.db_constraint
        ):
            constraint_suffix = "_fk_%(to_table)s_%(to_column)s"
            # Add FK constraint inline, if supported.
            if self.sql_create_column_inline_fk:
                to_table = field.remote_field.model._meta.db_table
                to_column = field.remote_field.model._meta.get_field(
                    field.remote_field.field_name
                ).column
                namespace, _ = split_identifier(model._meta.db_table)
                definition += " " + self.sql_create_column_inline_fk % {
                    "name": self._fk_constraint_name(model, field, constraint_suffix),
                    "namespace": (
                        "%s." % self.quote_name(namespace) if namespace else ""
                    ),
                    "column": self.quote_name(field.column),
                    "to_table": self.quote_name(to_table),
                    "to_column": self.quote_name(to_column),
                    "deferrable": self.connection.ops.deferrable_sql(),
                }
            # Otherwise, add FK constraints later.
            else:
                self.deferred_sql.append(
                    self._create_fk_sql(model, field, constraint_suffix)
                )
        # Build the SQL and run it
        sql = self.sql_create_column % {
            "table": self.quote_name(model._meta.db_table),
            "column": self.quote_name(field.column),
            "definition": definition,
        }
        # Prevent using [] as params, in the case a literal '%' is used in the
        # definition on backends that don't support parametrized DDL.
        self.execute(sql, params or None)
        # Drop the default if we need to
        if (
            field.db_default is NOT_PROVIDED
            and not self.skip_default_on_alter(field)
            and self.effective_default(field) is not None
        ):
            changes_sql, params = self._alter_column_default_sql(
                model, None, field, drop=True
            )
            sql = self.sql_alter_column % {
                "table": self.quote_name(model._meta.db_table),
                "changes": changes_sql,
            }
            self.execute(sql, params)

        """
        추가한 부분 - 'db_table_comment' 대신 'verbose_name' 사용
        """
        if (
            field.verbose_name
            and self.connection.features.supports_comments
            and not self.connection.features.supports_comments_inline
        ):
            field_type = db_params["type"]
            self.execute(
                *self._alter_column_comment_sql(
                    model, field, field_type, field.verbose_name
                )
            )
            

        # Add field comment, if required.
        if (
            field.db_comment
            and self.connection.features.supports_comments
            and not self.connection.features.supports_comments_inline
        ):
            field_type = db_params["type"]
            self.execute(
                *self._alter_column_comment_sql(
                    model, field, field_type, field.db_comment
                )
            )
        # Add an index, if required
        self.deferred_sql.extend(self._field_indexes_sql(model, field))
        # Reset connection if required
        if self.connection.features.connection_persists_old_columns:
            self.connection.close()

    
    def _alter_column_type_sql(
        self, model, old_field, new_field, new_type, old_collation, new_collation
    ):
        """
        Hook to specialize column type alteration for different backends,
        for cases when a creation type is different to an alteration type
        (e.g. SERIAL in PostgreSQL, PostGIS fields).

        Return a 2-tuple of: an SQL fragment of (sql, params) to insert into
        an ALTER TABLE statement and a list of extra (sql, params) tuples to
        run once the field is altered.
        """
        other_actions = []
        if collate_sql := self._collate_sql(
            new_collation, old_collation, model._meta.db_table
        ):
            collate_sql = f" {collate_sql}"
        else:
            collate_sql = ""

        """
        추가한 부분 - 'db_table_comment' 대신 'verbose_name' 사용
        """
        comment_sql = ""
        if self.connection.features.supports_comments and not new_field.many_to_many:
            if old_field.verbose_name != new_field.verbose_name:
                sql, params = self._alter_column_comment_sql(
                    model, new_field, new_type, new_field.verbose_name
                )
                if sql:
                    other_actions.append((sql, params))


        # Comment change?
        if self.connection.features.supports_comments and not new_field.many_to_many:
            comment_sql = ""
            if old_field.db_comment != new_field.db_comment:
                # PostgreSQL and Oracle can't execute 'ALTER COLUMN ...' and
                # 'COMMENT ON ...' at the same time.
                sql, params = self._alter_column_comment_sql(
                    model, new_field, new_type, new_field.db_comment
                )
                if sql:
                    other_actions.append((sql, params))
            if new_field.db_comment:
                comment_sql = self._comment_sql(new_field.db_comment)
        return (
            (
                self.sql_alter_column_type
                % {
                    "column": self.quote_name(new_field.column),
                    "type": new_type,
                    "collation": collate_sql,
                    "comment": comment_sql,
                },
                [],
            ),
            other_actions,
        )

 

 

이렇게 구현해 보고, 2번 과정인 DatabaseWrapper 클래스를 선언해 주었다. 

# custom_engine/base.py
from custom_engine.schema import CustomDatabaseSchemaEditor
from django.db.backends.sqlite3.base import DatabaseWrapper as SQLite3DatabaseWrapper

class CustomSQLite3DatabaseWrapper(SQLite3DatabaseWrapper):
    SchemaEditorClass = CustomDatabaseSchemaEditor

 

이제는 settings.py의 DATABASES 속성에서 engine의 값을 바꿔보자. 

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "orm.custom_engine",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}

 

이렇게 해 주면 과제의 1차 설정은 끝났다. 이제는 과제를 구현한 대로 잘 되는지를 확인해 보자.

 

추가적인 질의를 통해 알게 된 사실인데 Sqlite는 comment 기능이 없다고 한다... 큰일날 뻔했다. comment 기능을 확인하려면 database를 바꿔 주어야 하겠다. 로컬에 설정되어 있는 mysql db로 일단 바꿔주었다.

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "custom_engine",
        "NAME": "django_orm_tutorial",
        "USER": "myuser",
        "PASSWORD": "mypassword",
        "HOST": "127.0.0.1",
        "PORT": "3306",
    }
}

 

DatabaseWrapper 클래스도 mysql의 DatabaseWrapper를 사용하도록 바꿔주었다. 

# custom_engine/base.py
from django.db.backends.mysql.base import DatabaseWrapper
from .schema import CustomDatabaseSchemaEditor

class CustomMysqlDatabaseWrapper(DatabaseWrapper):
    SchemaEditorClass = CustomDatabaseSchemaEditor

 

잘 동작하는지까지 확인하였다. 이제 해야 할 일은 다음과 같다. 

  1. 새 모델 생성하고, verbose_name 값을 설정하기
  2. python manage.py makemigrations && python manage.py migrate
  3. mysql db에 들어가서 해당 모델의 comment 속성이 잘 추가되었는지 확인하기

그리고 이 방법을 따랐다... 그런데 추가가 되지 않았다..!!

 

무슨 일일까. 우선 아래의 명령어를 사용하면 table에 생성된 comment를 볼 수 있다고 해서 그대로 따라해 보았다. 

SELECT 
    table_name, table_comment
FROM
    information_schema.tables
WHERE
    table_schema = 'DB 이름' AND table_name = '테이블 이름';

 

왜 없을까 생각해보니 짚이는 점은 두 가지였다. 

  1. custom backend가 제대로 동작(호출)되지 않았다. 
  2. custom backend의 코드가 잘못되었다. 

우선 1번의 가능성을 보기 위해서 새 모델에 임의의 필드를 추가하여 마이그레이션을 해보고, 그 상황에서 해당 custom backend의 코드가 호출되었는지를 디버거 등으로 살펴봐야 하겠다. 

 

우선 myapp의 모델에서 변경 사항이 실행될 수 있도록 마이그레이션을 되돌려 보자(revert migrations). 해당 명령어를 실행하니 마이그레이션이 잘 되돌려졌다. 

python manage.py migrate myapp 0004

 

마이그레이션을 한 단계 이전으로 되돌렸다. 이제는 다시 migrate 명령어를 실행해서 revert했다가 다시 실행하려는 그 migration이 잘 되는지를 테스트 할 차례인데, 그 전에 우선 custom backend에서 verbose_name 관련해서 새로 작성한 코드들에 전부 breakpoint를 걸어놓았다. 

 

디버깅을 하다 보니 이상한 점이 있었다. custom_engine 디렉토리의 base.py 파일까지는 breakpoint에 걸리는데, 그 안에서 'SchemaEditorClass' 변수 값으로 정의한 CustomDatabaseSchemaEditor 클래스의 세부 메소드는 breakpoint에 걸리지 않았다. 이유가 무엇일까? 일단은 위에서 짚이는 방법 중 1번 원인이 유력해 보였다. 코드가 잘못된 게 아니라 호출되지 않아서 반영되지 않은 것 같았다. 하지만 왜일까. 

 

다른 방법으로 현재 모듈에서 backend engine으로 위에서 정의해 준 backend wrapper를 사용하고 있는지를 알아보았다. 

python manage.py shell
from django.db import connection

print(type(connection))
with connection.schema_editor() as editor:
    print(type(editor))

 

그랬더니 이런 결과가 나왔다. 즉 CustomDatabaseSchemaEditor 클래스가 사용되지 않고 있었다. 이유가 무엇일까?

 

일단 여기서 막힌 상태이다. 좀 더 이유를 찾아봐야겠다...!!

 

✅ 과제 #1: 궁금한 점

  1. 왜 table에서 사용할 수 있는 'alter_db_table_comment' 메소드는 있는데 field에서 사용할 수 있는 전용 메소드를 따로 만들어 주지 않고 다른 방식으로 사용했을까? 
  2. DatabaseWrapper는 왜 필요할까? Django는 왜 SchemaEditor를 그대로 사용하는 대신 DatabaseWrapper에 한번 더 감싸는 구조를 사용했을까? 

 

'server-side > Django' 카테고리의 다른 글

Django ORM 톺아보기 세션: 이해한 내용 정리하기  (1) 2024.12.01
django customizing user  (0) 2024.01.07
django customizing authentication  (0) 2024.01.07
django sessions  (0) 2024.01.06
django migrations  (0) 2023.12.31

지난 달 11월 9일에 Django ORM 톺아보기 세션이 열렸어서 가서 재밌고 유익하게 들었었다. ORM을 쓰는 방법은 그래도 대강은 알고 있다고 생각했는데, 쓰는 방법 말고 동작 원리에 대해서는 모르는 부분이 많았다. 특히 Queryset보다 더 밑단의 동작 원리(SQLCompiler, SQL과 QueySet 사이의 계층)에 대해서는 거의 처음 들어봤다. 역시 배워도 배워도 새롭고 신기한 것들 투성이였다. 

 

다행히 슬라이드는 계속 열어 두신다고 하셔서 지금이라도 슬라이드를 훑어보면서 그때의 기억을 되살려 보았다. 예전에 듣기로는 학습을 하는 좋은 방법 중 하나는 내용을 보면서 무엇을 어떻게 이해했고, 무엇을 모르겠는지를 스스로 점검하는 거라고 했다. 오늘 그걸 해 볼 예정이다. 그러면서 과제도 해 보고, 궁금한 것들을 정리해 보려고 한다. 

 

✅ 흐름 이해

ORM의 정의에 대해서 간단히 다룬 뒤, 처음에는 간단한 유저 모델을 생성하고 유저를 조회하는 API를 만들고 이를 실행시켜 보면서 튜토리얼이 진행되었다. 이후 DDL과 DML을 다루면서 Django에서 ORM이 동작하는 대표적인 두 경우를 봤다. 

 

첫 번째는 DDL(data definition language)으로 ORM을 통해 migration을 쓰는 경우였다. 이 경우는 데이터를 CRUD하는 것이 아니라, 테이블 스키마 자체를 정의하고 바꾸는 작업이다. 두 번째는 DML(data manipulation language)로 ORM을 통해 queryset을 쓰는 경우였다. 데이터를 CRUD하는 경우 쿼리셋이 실행된다고 이해했다. 

 

내가 아는 것은 여기까지였다. 그리고 첫 번째와 두 번째 경우에서, migration과 queryset 아래에 또 동작하는 django의 계층이 있음을 새롭게 알았다. 

 

migration의 경우는 migration과 SQL 사이에 DatabaseSchemaEditor 클래스가 동작해서 python 언어로 쓰여진 migration 파일을 SQL로 바꿔 준다고 이해했다. (사실 깊게 파 보면 그게 다가 아니긴 할 것이다. 일단은 넘어가자!) queryset의 경우는 queryset과 SQL 사이에 SQLCompiler 클래스가 동작해서, python 언어로 쓰여진 queryset 조회 코드를 SQL로 바꿔 준다고 이해했다. 

 

그리고 나는 migration과 queryset이 django ORM하면 거론되는 대표적인 기능들이라서 그 밑단에서는 정보가 많이 없을 것이라고 생각했었는데, DatabaseSchemaEditor 공식 문서가 있었다. SQLCompiler는 'django sql compiler'로 검색했을 때는 정보가 안 나왔는데, 분명 다른 이름으로 뭔가 있을 것이라는 추측을 해 본다. 

 

공식문서 피셜, (Database)SchemaEditor는 db를 추상화하는 계층이란다. 그리고 django에서 지원하는 모든 database backend 별로 SchemaEditor가 있고, 그 모든 SchemaEditor 클래스들은 BaseDatabaseSchemaEditor를 상속받는다고 한다.

 

다른 부분은 그래도 잘 읽혔는데, 아래 부분은 잘 안 읽혔다. 'context manager와 같이 사용해야만 transaction, foreign key constraint 등의 상황에서 사용할 수 있으니, context manager와 같이 사용되어야 한다'는 말이었다. 잘 모르겠어서 GPT에게도 물어봤다. 

 

'왜 context manager가 transaction과 deferred SQL(외래키 관련 migration 등이 있을 때, 해당 SQL을 지연시키는 것)을 지원하는지, 이 context manager는 또 뭔지'가 궁금했었다. GPT 녀석은 질문의 의도를 잘 캐치하고 context manager가 무슨 역할을 하는지를 설명해 주었다. 그런데도 모호한 부분이 있어서, context manager에 대해 질문했다. 

 

알고보니 context manager라는 녀석은 django가 아니라 python 자체에서 지원하는 기능이었다. 설명을 보고선 'with statement와 같이 쓰이는 많은 공통 과제들을 좀 더 편리하게 선언/처리할 수 있게 도와주는 util 함수' 라고 이해했다. 그런데 나는 with statement도 사실 잘 몰랐다(어떻게 쓰는지는 아는데 그게 구체적으로 어떨 때 쓰고, 어떤 원리로 동작하는지는 몰랐다).

 

그래서 조금 삼천포로 빠진 것 같지만 with statement의 정의와 대략적인 동작 원리까지만 찾아보기로 했다. 공식문서를 찾아보니 더 구체적인 정의가 나왔다. with statement로 코드를 실행할 때, 어떤 runtime context를 사용할 것인지를 정의하는 객체가 context manager라고 했다. 

 

context manager는 with문이 실행되는 상황에서 context에 진입하고 종료할 때 부가적인 작업을 해 준다고 이해했다. 즉 with 구문은 일종의 문법이고, 그 문법을 사용해서 코드를 실행할 때는 해당 runtime context의 진입과 종료 작업을 관리하기 위해 context manager가 개입한다고 이해했다. 휴 복잡하다. 

 

암튼 context manager를 이렇게 이해하면 위에서의 말도 이해할 수 있겠다. DatabaseSchemaEditor가 사용되는 raw django code에서는 with문으로 deferred SQL이나 transaction을 처리하는 부분이 있나보다. 그래서 context manager와 같이 사용해야 한다는 설명을 덧붙인 것이겠다. 

 

 오늘 배운 것

A 계정에서 B 계정으로 terraform을 통해 인프라 정보를 넘기려면, 우선은 A 계정과 B 계정 모두 aws cli에 등록되어 있어야 했다. 아까 전에 cat 명령어로 조회했을 때는 default 계정만 갖고 있었고, 그 default 계정은 A 계정에 해당했다. 이제는 B 계정을 추가해 주어야 하겠다. 

 

B 계정을 추가하기 위한 명령어는 간단했다. 

aws configure --profile my-second-account	# 새 계정의 이름 (기본 계정: default)

 

그리고 이 명령어를 사용하려면 AWS 계정의 access id와 secret key 값이 필요했다. 확인해 보니 이미 사용하던 IAM 유저의 access key를 내가 하나 만들어 뒀었었다. 그런데 access key id는 계속 볼 수 있었지만 key secrets는 계속 볼 수 없었다. 그런데 이 값이 기억나지 않아서, 하나 더 만들 수밖에 없었다. '액세스 키 2'를 새로 만들었다. 

 

이제 위의 명령어를 입력해 주고, access key id와 key secrets 값을 잘 입력해 주었다. 

 

다시 aws cli에 등록된 계정 정보를 확인해 보니 새로운 계정이 잘 추가된 것을 볼 수 있었다. 

cat ~/.aws/credentials

 

이제 default 계정(기존 소마 계정)으로부터 terraform을 통해 등록된 인프라 정보를 earthyoung 계정으로 넘겨보자. 현재는 default 계정에 있는 정보를 'terraform import' 명령어를 통해 가져오기만 했을 뿐이다. 이를 새 계정에 적용하는 것은 또 다른 일이었으므로, 또 다른 질의를 날려 보았다. 

 

우선 아래 명령어를 통해 default 계정에서 여러 설정들을 잘 가져왔는지를 확인해 볼 수 있겠다. 다행히 어제 열심히 가져왔던 설정들이 잘 들어있었다. 

terraform state list

 

그리고 현재 terraform plugin(aws provider)에서 earthyoung(새 계정)을 사용하도록 설정을 바꿔 주어야겠다.

# dev.tf
provider "aws" {
  region  = "ap-northeast-2"
  profile = "earthyoung"
}

 

이후 해당 터미널에서 aws cli에서도 계속 새 계정을 사용하도록 설정을 바꿔 주고 싶다면 다음 명령어를 이용하자. 

export AWS_PROFILE=earthyoung

 

참고로 이렇게 커맨드로 export 문을 실행하면 terminal을 종료 후 재시작할 경우 export 된 변수가 다시 남아있지 않을 수도 있다고 알고 있다. 영구적인 반영을 원한다면 해당 명령어를 .zshrc 파일에 넣어 주는 것이 안전하겠다. 

 

어찌되었건 .tf 파일에서도 새 계정을 사용하도록 명시해 주었고, 터미널의 기본 계정도 바뀌어 주었으니 이제는 명령어를 통해 해당 설정이 잘 반영되었는지를 확인하자. 그런데 명령어를 돌렸더니 에러가 난다. 아마도 현재 시점은 소마 계정 이관 시점보다 지나서, 해당 Route53 및 EC2 레코드가 지워진 것이라고 판단했다. 

terraform plan

 

그렇다면 일단은 남은 다른 설정이라도 가져오면 좋으련만. 그러려면 현재 에러가 나는 레코드를 import에서 제거해야 하겠다. 'terraform state rm' 명령어로 import 된 설정들 중 특정 설정들만 골라서 제거할 수 있겠다. 

terraform state rm aws_route53_zone.route53_zone
terraform state rm aws_instance.ec2_instance

 

그리고 다시 명령어를 실행해 보았더니, 또 다른 예상치 못한 오류와 마주했다. ECS 클러스터와 SecretsManager에서 난 오류였다. 아마도 소마 계정이 suspended 상태라서 400에러가 난 것이라고 추측했다.

 

그런데 사실 ECS 클러스터를 사용할 수 없으면 ECR, ECS task definition, ECS service에 대한 정보도 모두 무의미한 것은 마찬가지였다. 그리고 Secrets Manager까지 제외하면 새 계정으로 넘길 수 있는 리소스는 RDS 뿐이었다. 하지만 RDS는 사실 인스턴스를 새로 시작해서, Django ORM을 통해 migrate를 하면 바로 백엔드 서버와 똑같은 상태를 사용할 수 있는 것이라서 큰 의미가 없었다. 

 

그래서 결국 IaC를 통해 default 계정에서 earthyoung 계정으로 인프라 설정을 옮기는 이슈는 시작은 좋았으나, 다소 늦게 시작해서 여러 오류로 인해 막혔기 때문에 보류하기로 결정했다. 

 

하지만 이번 기회에 IaC에 대해서 알 수 있었고, GPT의 도움이 9할이었지만 어쨌든 여러 명령어를 알음알음 써볼 수 있었으며, 나중에 프로젝트를 관리할 때는 꼭 설정 정보를 IaC를 통해 관리하기 쉽게 빼 놓아야겠다는 생각을 해볼 수 있어서, 마냥 수확이 없지는 않았다!

 

 궁금한 점

1. terraform import, plan, apply 외의 핵심 명령어는 무엇이며, terraform은 어떤 원리나 추상적인 개념으로 동작할까? 

 

 오늘 배운 것

이제는 서버 이관 시간이 하루 남았다. 어제 남겨둔 이슈를 다시 잡아보자. 어제 끝부분에 물었던 대로 aws cli로 입력한 정보는 terraform의 plugin 중 하나인 aws provider가 인식할 수 있었다. 그러므로 aws cli에서 명령어를 실행해서 정보를 이관하려던 기존 소마 계정의 credential들을 입력해 보자. 

 

aws cli에서 기본으로 사용되는 정보는 다음 명령어를 통해 볼 수 있다고 한다. 명령어를 입력하자 aws의 access key id와 secret access key가 나타났다. 

cat ~/.aws/credentials

 

그러면 해당 프로파일의 이름(나의 경우는 'default')을 .tf 파일에 region(나의 경우는 'ap-northeast-2')과 같이 명시해주면 된다. 이렇게 말이다. 

provider "aws" {
  region  = "ap-northeast-2"
  profile = "default"
}

 

그런데 이렇게만 작성한다고 aws 계정의 모든 것을 가져올 수 있는 건 아니었다. 사실 '가져온다'는 개념이 무엇인지 아직도 모호하기만 하다. 그래서 추가적인 질의를 해 보았다.

 

그랬더니 GPT는 조금은 모호한 코드를 주었다. 코드는 대략 이런 방식으로 생겼는데, 처음 따옴표에 들어간 단어는 aws 서비스의 세부 리소스를 말하는 것 같았으나 그 다음 따옴표에 무엇이 들어가는 것인지를 잘 이해할 수 없었다. 어쩌면 "rds_instance" 대신에 instance의 이름이 들어가야 하는 것 같았는데, 확신을 얻고 싶어서 한번 더 물어봤다. 

# RDS
resource "aws_db_instance" "rds_instance" {
  # Import할 때 기본적으로 빈 블록으로 작성
}

알고보니 이는 RDS 인스턴스의 이름과는 관련이 없었다. 이는 terraform에서 가져올 RDS 정보를 어떻게 칭할지를 나타내는 별칭이었다. 'rds_instance'도 썩 나쁘지 않은 별칭이었으므로 그대로 두기로 했다.

 

그런데 모호한 건 이뿐만이 아니었다. GPT는 RDS resource를 정의할 때는 'import할 때 빈 블록으로 작성'이라는 주석을 달아 주었지만, 그를 제외한 나머지 서비스들은 모두 '~서비스를 가져오기 위한 정의'라는 주석이 있었다. 무언가가 필요하다는 의미였다. 물어보지 않을 수 없었다. 

녀석은 이번에야말로 각 서비스 별로 구체적인 예시를 주었다. 우선은 RDS부터 시작해보자면, 중괄호 안에 명시되어야 할 정보로는 identifier(실제 RDS 이름), engine(mysql, postgres와 같은 RDS 엔진), instance_class('db.t3.micro'와 같은 인스턴스 유형), allocated_storage(스토리지의 크기), publicly_accessible(퍼블릭 액세스가 가능한지에 대한 여부) 정보를 제공해 줘야 했다. 


나머지 경우도 마찬가지다. 실제 작성한 파일을 참고용으로 한번 올려 본다. 우선 AWS provider를 먼저 선언해 주자. 이게 있어야 AWS resource들을 가져올 수 있다. 

 

그리고 아래에는 resource를 선언한다. RDS부터 선언해 보자. 다음과 같이 선언하고, 명령어를 입력하면 성공적으로 import가 되었다고 뜬다. 

terraform import aws_db_instance.rds_instance my-rds-instance	# rds instance의 이름

 

이번에는 Route53을 선언해 보자. 

terraform import aws_route53_zone.route53_zone Z1234567890ABCDEFG	# route53 hosting zone의 ID

 

이번에는 ECR(elastic container registry)를 선언해 보자.

terraform import aws_ecr_repository.ecr_repo my-repo	# ECR repository 이름

 

이번에는 ECS(elastic container service)를 가져와 보자. ECS의 경우 cluster, task definition, service를 차례로 정의해야 해서 코드를 제법 작성해 주어야 했다. 

terraform import aws_ecs_cluster.ecs_cluster my-ecs-cluster		# 클러스터 이름
terraform import aws_ecs_task_definition.ecs_task_definition my-task-family:1	# 태스크 정의 이름
terraform import aws_ecs_service.ecs_service my-service		# 서비스 이름

 

이번에는 EC2 인스턴스 정보를 가져와 보자. 

terraform import aws_instance.ec2_instance i-1234567890abcdef0	# EC2 인스턴스 고유 ID

 

마지막으로 Secrets Manager를 import 해 보자. 

terraform import aws_secretsmanager_secret.secret arn:aws:secretsmanager:region:123456789012:secret:my-secret	# 보안 암호 ARN

 

관련된 설정을 import하는 것 까지는 완료했다. 이제는 해당 정보를 새 계정으로 옮기는 방법에 대해 생각해 보자. 

 

+ Recent posts