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

 

✅ 과제 #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하는 것 까지는 완료했다. 이제는 해당 정보를 새 계정으로 옮기는 방법에 대해 생각해 보자. 

 

 오늘 배운 것

발등에 불이 떨어진 이슈가 있다. 바로 고도화에 당첨되지 않아서, 11월 29일날 소마 AWS 계정 서버 인프라가 모두 닫힌다는 것이다. 남은 시간은 2일인데, 이 안에 기존 서버 세팅과 똑같이 옮겨야 한다. 그런데 이런 작업은 처음 해 봐서, 어떻게 해야 할지 감이 오지 않았다. 우선은 GPT로 워밍업을 해 보았다. 

 

녀석은 멘토링에서 몇 번 들어본 IaC(Infrastructure as Code)를 추천해 주었다. 인프라를 하나하나 설정하고 기록하는 것은 매우 번거로울 수 있으니 IaC 도구를 통해서 AWS 설정 정보를 저장해 두고, 이걸 다시 내 기존 계정에서 사용하라는 거였다. 오케이. 

우선은 terraform을 설치하고, 'terraform init'을 통해 관리를 시작하라고 했다. git과 비슷한데 인프라를 관리하는 버전 관리 시스템 정도로 일단은 이해했다. Terraform 홈페이지의 Download 페이지에서 쉽게 다운로드 받을 수 있었다. homebrew 다운로드도 있고 binary 다운로드도 가능했는데 나는 좀 더 간단해 보이는 homebrew 다운로드 방법을 선택했다. 

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

 

버전 명령어를 실행시켜서 버전이 잘 나오는지를 확인해보자. 잘 설치되었다. 

 

이제 git처럼 특정 디렉토리에서 terraform을 초기화 시켜주고, 파일을 작성해보자. (아직 원리에 대해서 100% 이해하진 못했다. 그냥 이런 게 있구나 싶다.) 디렉토리를 만들어주고 명령어를 실행해 주었다. 

terraform init

 

이제 .tf 확장자를 가진 terraform 파일을 만들어서 작업을 시작하면 된다. vscode로 디렉토리를 열어보자. 그런데 .tf 확장자를 가진 파일을 만들고 GPT의 예제 코드를 따라하려는 찰나 의문이 생겼다. 나는 아직 내 AWS 계정에 관련된 어떠한 정보도 제공하지 않았고 예제 코드에서도 관련된 부분이 없는데, 어떻게 특정 AWS 서비스의 정보를 가져오나 싶어 의아했다. 

 

알고보니 누락된 부분이 있었다. 해당 작업 상황에서는 우선 aws cli가 설치되었다는 것을 가정으로 하는데, 나는 aws 명령어를 터미널에서 인식할 수 있어서 이 과정은 패스했다. 

 

GPT 피셜, aws configure 명령어로 aws 인증 프로필을 생성한 뒤, 해당 프로필 정보를 terraform 파일에 집어넣으면 된다고 했다. 그렇다면 aws cli에서 입력한 정보를 terraform 파일에서 인식할 수 있는 건가?

알고보니 aws cli에서 입력한 정보는, aws 프로바이더가 자동으로 인식할 수 있어서 terraform에서도 가져와서 사용할 수 있다고 했다. aws 프로바이더가 terraform과 관련 있는 장치냐고 묻자 그렇다고 했다. aws 프로바이더는 terraform에서 aws와 같은 클라우드 서비스를 관리하기 위한 플러그인이라고 한다. 

 

 궁금한 점

1. plugin이 정확히 뭘까?

plugin은 소프트웨어의 코어를 바꾸지 않고도 소프트웨어에 새로운 기능을 추가해 줄 수 있는 구성요소라고 한다. 

 

지난 번의 이슈를 가져와 다시 올려본다... 

 

세상에. 소마 발표가 끝나고 나니 이 이슈 업데이트가 끊긴 지가 거의 한달이 다 되어간다. 실화인가 싶다. 생각보다 금방 해결될 수 있는 이슈겠지 싶었는데, 이것도 나의 지속적인 노력이 있어야 가능한 일임을 깨닫고 반성해 본다. 

 

진행 상황을 잠깐 리뷰해보자면, 같은 팀 Djangonaut인 Tai가 코드를 고칠 방향을 제안해 주었고, 내가 OK라고 했다가 그걸 반영 못한 지 약 2주가 넘게 지난 상황이다... 정말 죄책감이 느껴지는데, 죄책감을 더 느끼지 말고 어서 이 이슈를 다시 잡아 보자. 

 

우선 이슈 자체는 이미 이해한 상황이고, 테스트도 통과하는 상황이다. 그런데 정말 잘 고쳐서 테스트를 통과하는 것인지는 확신할 수가 없어서 navigator Mariusz에게 조언을 구하니, 아마도 테스트가 커버하지 못하는 부분이 있을 거라고 했다. 결론은 직접 django 프로젝트를 하나 만들어서, 그 안에서 직접 dbshell 명령어를 실행시켜서 제대로 동작하는지를 확인해봐야 했다. 

 

그런데 순간 의문이 들었다. 프로젝트를 만든다 쳐도, 지금 나는 python 3.14 버전의 환경에서 해당 소스 코드를 좀 바꾼 django 버전을 테스트 하고 싶은 것인데, 이걸 어떻게 하지?

 

GPT 피셜, 다음 명령어를 사용하면 django의 상태를 현 수정 중인 브랜치의 디렉토리에 맞게 적용할 수 있다고 했다. 일단 믿어보고 안 되면 다른 방법을 찾아보자. 

pip install -e .

 

그리고 django-admin 명령어로 (django는 현재 install -U 명령어를 통해 전역 설치되어 있는 상황이었다) test_project라는 테스트용 django 프로젝트를 만들어 주었다. 그리고 테스트용 앱도 만들어 주었다. 

 

그리고 python manage.py migrate 명령어까지 실행해 주었으니 migration도 잘 실행되었을 것이다. 이제 공식 문서에 있는 dbshell 명령어 부분을 참고해서, sqlite에서 유저를 조회하는 쿼리를 dbshell 명령어로 실행해 보자. 

DJANGO_SETTINGS_MODULE=test_project.settings django-admin dbshell -- 'select * from user'

 

그런데 이런 오류가 떴다. 

 

현재 있는 test_project 디렉토리 안에 또 test_project 디렉토리가 있는 게 맞고, 여기 안에 settings 파일이 있는 것도 맞는데 경로 인식에서 오류가 난 것 같다. 

 

✅ 비고

오늘 팀원들과 다 같이 멘토님을 온라인으로 뵈었다. 온라인 멘토링을 하면서 느낀 점을 간략히 적어본다. 


1. '내가 어떤 회사를 가고 싶은지'는 생각해 봤어도, '내가 뭘 할 수 있는 회사에 가고 싶은지', 또는 '그 회사에 가서 뭘 하고 싶은지'를 구체적으로 생각해 본 적이 없다. 새삼 사고의 전환과 함께 머리를 띵 하고 맞은 기분이었다. 그래도 막연히 그냥 좋은 회사가 아니라 '어떤 회사를 가고 싶은지'를 생각해 본 것은 꽤 잘한 것 같다. 

2. 사이드 프로젝트는 결국 사이드여야 한다. 내가 일할 수 있는 곳에서 더 많은 것을 얻고 배울 수 있고, 그걸 최대한 뽑아내야 한다. 

3. 그래서, 내가 바라는 건 뭔가? 여러 사회적인 조건을 적절히 고려한다면, 나는 어떤 환경에서 뭘 하면서 살고 싶은가?

 

요즘 회고가 뜸했던 것 같다. 찾아보니 지난 회고를 3주 전에 썼더라.... 반성하고 앞으로는 다시 꾸준히 써 보자.

 

오늘은 3주만에 회고를 쓰는 만큼 1주일 동안의 일을 KPT 회고로 점검하기보다는, 지난 3주간 어떤 일이 있었고 앞으로는 어떻게 하면 좋을지를 점검해 보면 좋을 것 같다. 

 

우선 3주 동안 많은 일이 있었다. 소마 최종발표가 끝났고, 확정은 안 됐지만 우선은 고도화 신청도 해 보고, 마지막 남은 최종 면접을 본 기업의 결과 발표를 기다리고 있다. 그러면서도 원서를 틈틈이 넣고 있다. Djangonaut도 물론 꾸준히 하고 있다. 지난 2주 동안은 소마 최종발표 이슈로 기여가 뜸했기에, 딱 이번주까지만 쉬어 보고 다음주부터는 Djangonaut 기여의 비중을 올려 봐야 하겠다. 

 

기분이 싱숭생숭하다. 소마가 끝나면 올해 6개월 간을 차지했던 큰 프로젝트가 막을 내리는 것이라서 정말 모든 것들이 끝나는 줄 알았는데, 그렇지는 않았기 때문이다. 프로젝트의 고도화 과정도 신청하게 되었고, 이때 쯤이면 뭔가 결정나 있겠지 싶었던 취준도 아직 결과 발표가 안 나왔다. 한 마디로 정해지지 않은 것들 투성이였다. 

 

그리고 약 한 달 동안은 최종발표를 향해 달려왔었는데, 길을 잃은 느낌도 든다. 이제는 무엇을 다시 목표로 잡아야 하지? 라는 물음이 든다. 번아웃인 것 같기도 하다. 번아웃 자가진단 리스트에서 본 물음인데, '내가 뭘 얼마나 했다고 지치지?' 라는 물음이 든다. 그런데 그 물음이 드는 것도 증상일 수 있다고 한다. 일단은 번아웃이 왔다고 가정하자. 

 

일단은 지금까지 소마를 하면서 등한시해 온 것들이 눈에 밟혔다. 대표적인 것이 운동과 나의 여유이다. 사실 바쁜 삶과 여유를 둘 다 얻을 순 없다... 그래도 운동은 같이 병행했어야 하는데, 라는 점에서 후회와 아쉬움이 남는다. 하지만 어쩌겠나. 일단 다시 해보는 수밖에 없다. 

 

이 글을 쓰는 이유는 회고 겸 동시에 싱숭생숭한 마음을 글로 풀어서 다음 일주일 동안은 내가 무엇을 중심으로 생활하면 좋을지 스스로 생각을 정리하기 위함이다. 

 

조금은 지친 상태이고, 그러나 지금 하고 있는 모든 것을 놓을 수는 없는 상태이다. 마치 무중단 배포처럼(비유가 맞나 모르겠다) 시스템을 계속 운행 중인 상태로 두되, 쿨다운을 하고 모드를 바꾸는 작업이 필요하다. 중간에 모든 걸 다 멈추면서 쉬어버리면 다시 잡을 수 없을 것 같았기 때문이다. 

 

그럼 모드를 바꾸려면 어떻게 해야 할까? 우선순위 재조정이 필요하다. 그 전까지는 소마 최종발표나 다른 취준이 '최우선'이었다면, 이제는 '우선'인 여러 과제들을 생각해 보자. 일단은 운동이 있을 것이고, 그동안 못 해왔던 게임 같은 여가생활도 조금은 넣어볼 수 있겠다(오랜만에 컴퓨터를 켜서 해 봤더니 재밌었다... 왜 내가 1년 전에 그 게임을 봉인시켰는지 바로 알 것 같았다). 그리고 취준을 하면서 챙기지 못했던 코테와 프로젝트의 세세한 부분들도 챙겨보자. 그리고 Djangonaut도 챙겨 보자. 

 

그리고 노션에 기록을 세워보자. 이렇게 12월을 잘 버텨보자..!

 

+ Recent posts