본문으로 바로가기
728x90
반응형

목차

     

    개요

    SQLAlchemy의 imperative mapping 방식을 계속 다루다 보니 Repository라고 정의한 계층에서 Session Query를 많이 작성하게 된다. 예를 들어 다음과 같은 형식이다.

    session.query(MemberEntity).filter(MemberEntiy.id == member_id)
    

    만약 join을 사용한다면 Session Query를 아래와 같이 작성한다.

    session.query(MemberEntity)
    .filter(MemberEntity.id == member_id)
    .outerjoin(MemberProfileEntity.member_id == member_id)
    

    위와 같이 Session Query를 작성하는 게 어색하게 느껴지진 않지만 특정 Resource와 이에 관련된 데이터를 한꺼번에 가져오는 방식은 따로 없는지 조사하게 됐다.

    Resource와 관련된 데이터

    “개요”에서 언급한 특정 Resource와 관련된 데이터를 한꺼번에 가져오는 방식은 간단히 말해 ForeignKey 관계를 의미한다. TABLE에서 ForeignKey가 지정된 경우 session query를 날리는 경우 이를 쉽게 가져올 수 있지만 시도하려고 했던 건 논리적으로 연결된 테이블(logical Foreignkey)인 경우이다.

    CREATE TABLE `member` (
      `pk` int(1) unsigned NOT NULL AUTO_INCREMENT,
      `nanoid` char(24) NOT NULL,
      `name` varchar(32) DEFAULT NULL,
      `age` int(1) unsigned DEFAULT NULL,
      PRIMARY KEY (`pk`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    CREATE TABLE `member_profile` (
      `pk` int(1) unsigned NOT NULL AUTO_INCREMENT,
      `nanoid` char(24) NOT NULL COMMENT 'ref, member.nanoid',
      `description` varchar(32) DEFAULT NULL,
      PRIMARY KEY (`pk`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    
    INSERT INTO demo.`member` (pk, nanoid, name, age) VALUES(1, 'NKioSel8UEgPp7eZT5ncioho', 'test1', 0);
    INSERT INTO demo.member_profile (pk, nanoid, description) VALUES(2, 'NKioSel8UEgPp7eZT5ncioho', 'test');
    

    위 테이블에서 member_profile의 nanoid는 member의 nanid를 입력하는 column이지만 ForeignKey로 연관관계를 설정해놓지 않은 구조이다. sqlacodgen으로 Declarative Model을 생성하면 다음과 같다.

    class Member(Base):
        __tablename__ = 'member'
    
        pk = Column(INTEGER(1), primary_key=True)
        nanoid = Column(CHAR(24), nullable=False)
        name = Column(String(32))
        age = Column(INTEGER(1))
    
    class MemberProfile(Base):
        __tablename__ = 'member_profile'
    
        pk = Column(INTEGER(1), primary_key=True)
        nanoid = Column(CHAR(24), nullable=False, comment='ref, member.nanoid')
        description = Column(String(32))
    

     

     

    Imperative Mapping에서 Eager/Lazy 설정하기

    앞서 소개한 방식을 SQLAlchemy에서 Imperative Mapping을 이용해서 데이터를 가져올 때는 두 가지 방식으로 동작시킬 수 있었다.. 흔히 “즉시(Eager) 로딩”과 “지연(Lazy) 로딩”으로 알려진 방법이다. 

    Eager loading

    Eager Loading은 연관된 데이터를 같이 로딩하는 것을 뜻한다. 한국말로는 “즉시 로딩”이라 번역되는 듯하다. logical ForeignKey를 Imperative Mapping에서 Eager loading 방식으로 가져오는 경우엔 registry를 다음과 같이 설정할 수 있다.

    def start_mapper():
        orm_mapper = registry(orm.metadata)
        orm_mapper.map_imperatively(MemberProfileEntity, orm.MemberProfile)
    
        orm_mapper.map_imperatively(
            MemberEntity, orm.Member,
            properties={
                'member_profile': relationship(
                    MemberProfileEntity,
                    primaryjoin='foreign(member.c.nanoid) == member_profile.c.nanoid',
                    lazy='joined',
                    uselist=False
                )
            }
        )
        return orm_mapper
    # Session Query
    query = self.session.query(MemberEntity).filter(MemberEntity.nanoid == member_id)
    query = query.one_or_none()
    

    이 경우 SQLAlchemy에서 생성한 SQL은 다음과 같다.

    2024-02-25 19:42:58,943 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'sql_mode'
    2024-02-25 19:42:58,944 INFO sqlalchemy.engine.Engine [raw sql] ()
    2024-02-25 19:42:58,959 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'lower_case_table_names'
    2024-02-25 19:42:58,959 INFO sqlalchemy.engine.Engine [generated in 0.00028s] ()
    2024-02-25 19:42:58,972 INFO sqlalchemy.engine.Engine SELECT DATABASE()
    2024-02-25 19:42:58,972 INFO sqlalchemy.engine.Engine [raw sql] ()
    2024-02-25 19:42:58,974 INFO sqlalchemy.engine.Engine BEGIN (implicit)
    2024-02-25 19:42:58,976 INFO sqlalchemy.engine.Engine SELECT `member`.pk AS member_pk, `member`.nanoid AS member_nanoid, `member`.name AS member_name, `member`.age AS member_age, member_profile_1.pk AS member_profile_1_pk, member_profile_1.nanoid AS member_profile_1_nanoid, member_profile_1.description AS member_profile_1_description
    FROM `member` LEFT OUTER JOIN member_profile AS member_profile_1 ON `member`.nanoid = member_profile_1.nanoid
    WHERE `member`.nanoid = %s
    2024-02-25 19:42:58,976 INFO sqlalchemy.engine.Engine [generated in 0.00013s] ('NKioSel8UEgPp7eZT5ncioho',)
    

    로그를 보면 “LEFT OUTER JOIN”이 발생하는 것을 알 수 있다. “Eager loading”의 경우엔 Entity 간의 관계가 복잡해질수록 join으로 인한 성능 저하가 발생하기 때문에 신중히 사용하도록 하자.

    Lazy Loading

    Lazy Loading은 Eager Loading과는 다르게 해당 속성이 필요할 때 SQL을 발생시켜서 가져온다. Imperative Mapping 방식에서는 다음과 같이 설정할 수 있었다.

    def start_mapper():
        orm_mapper = registry(orm.metadata)
        orm_mapper.map_imperatively(MemberProfileEntity, orm.MemberProfile)
    
        orm_mapper.map_imperatively(
            MemberEntity, orm.Member,
            properties={
                'member_profile': relationship(
                    MemberProfileEntity,
                    backref=backref('member'),
                    primaryjoin='foreign(member.c.nanoid) == member_profile.c.nanoid',
                )
            }
        )
        return orm_mapper
    # Session Query
    query = self.session.query(MemberEntity).filter(MemberEntity.nanoid == member_id)
    query = query.one_or_none()
    

    이 경우엔 당연히 Eager Loading과는 다른 SQL이 발생한다.

    2024-02-25 19:50:07,011 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'sql_mode'
    2024-02-25 19:50:07,012 INFO sqlalchemy.engine.Engine [raw sql] ()
    2024-02-25 19:50:07,028 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'lower_case_table_names'
    2024-02-25 19:50:07,028 INFO sqlalchemy.engine.Engine [generated in 0.00048s] ()
    2024-02-25 19:50:07,045 INFO sqlalchemy.engine.Engine SELECT DATABASE()
    2024-02-25 19:50:07,045 INFO sqlalchemy.engine.Engine [raw sql] ()
    2024-02-25 19:50:07,047 INFO sqlalchemy.engine.Engine BEGIN (implicit)
    2024-02-25 19:50:07,056 INFO sqlalchemy.engine.Engine SELECT `member`.pk AS member_pk, `member`.nanoid AS member_nanoid, `member`.name AS member_name, `member`.age AS member_age
    FROM `member`
    WHERE `member`.nanoid = %s
    2024-02-25 19:50:07,056 INFO sqlalchemy.engine.Engine [generated in 0.00041s] ('NKioSel8UEgPp7eZT5ncioho',)
    MemberEntity({'name': 'test1', 'age': '0', 'pk': '1', 'nanoid': 'NKioSel8UEgPp7eZT5ncioho'})
    

    Session Query를 통해 얻은 “member”를 기준으로 “member_profile”에 접근하려면 orm_mapper에 설정한 properties의 설정한 “member_profile”의 속성에 접근하면 된다.

    query = self.session.query(MemberEntity) \\
          .filter(MemberEntity.nanoid == member_id)
    query = query.one_or_none()
    print(query)
    print(query.member_profile)
    # Log
    2024-02-25 19:52:42,948 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'sql_mode'
    2024-02-25 19:52:42,948 INFO sqlalchemy.engine.Engine [raw sql] ()
    2024-02-25 19:52:42,969 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'lower_case_table_names'
    2024-02-25 19:52:42,969 INFO sqlalchemy.engine.Engine [generated in 0.00079s] ()
    2024-02-25 19:52:42,994 INFO sqlalchemy.engine.Engine SELECT DATABASE()
    2024-02-25 19:52:42,995 INFO sqlalchemy.engine.Engine [raw sql] ()
    2024-02-25 19:52:42,997 INFO sqlalchemy.engine.Engine BEGIN (implicit)
    2024-02-25 19:52:42,999 INFO sqlalchemy.engine.Engine SELECT `member`.pk AS member_pk, `member`.nanoid AS member_nanoid, `member`.name AS member_name, `member`.age AS member_age
    FROM `member`
    WHERE `member`.nanoid = %s
    2024-02-25 19:52:42,999 INFO sqlalchemy.engine.Engine [generated in 0.00011s] ('NKioSel8UEgPp7eZT5ncioho',)
    MemberEntity({'name': 'test1', 'age': '0', 'pk': '1', 'nanoid': 'NKioSel8UEgPp7eZT5ncioho'})
    2024-02-25 19:52:43,003 INFO sqlalchemy.engine.Engine SELECT member_profile.pk AS member_profile_pk, member_profile.nanoid AS member_profile_nanoid, member_profile.description AS member_profile_description
    FROM member_profile
    WHERE %s = member_profile.nanoid
    2024-02-25 19:52:43,003 INFO sqlalchemy.engine.Engine [generated in 0.00014s] ('NKioSel8UEgPp7eZT5ncioho',)
    MemberProfileEntity({'description': 'test', 'nanoid': 'NKioSel8UEgPp7eZT5ncioho', 'pk': '2'}) 
    

    Eager Loading에서 Join 쿼리가 발생한 것과 다르게 SELECT 쿼리가 발생하는 것을 확인할 수 있다. 이때 Session Query를 통해 얻어온 query의 namespace를 보면 다음과 같은 속성이 들어있다.

    [..., '_sa_class_manager', '_sa_instance_state', 'age', 'member_profile', 'name', 'nanoid', 'new', 'of', 'pk']
    

    이 member_profile은 registey에 mapping 한 class의 type을 가진다.

    <class 'core.domain.entity.member_profile_entity.MemberProfileEntity'>
    

     

    마치며

    일반적인 참조에서는 ORM Class와 테이블 상에 ForeignKey를 설정한 뒤 관련 코드를 작성하는데 굳이 logical ForeignKey를 설정해서 사용하는 이유는 “파이썬으로 살펴보는 아키텍처 패턴”이라는 책에서 설명하는 Entity나 Repository를 구현하기 위함과 TABLE의 필드가 자주 변경되는 상황을 가정했기 때문이다.
     
    TABLE의 필드가 자주 변경되면 그때마다 ORM Class를 업데이트해줘야 하기 때문에 최대한 건드리지 않고 작업하는 방법이 뭘까를 고민해 봤는데 Imperative Mapping에서는 Logical ForeignKey에 대한 자료가 부족하기 때문에 조사한 내용들의 방법을 적용하는데 신중해야겠다.

    728x90
    반응형