sqlalchemy 외래키 없이 사용하는 관계 지정하기

dev hyeon·2023년 7월 18일
0
post-thumbnail

이번 포스팅은 실제 Database에 외래키를 걸지 않고 python의 유명한 DB 모듈인 sqlalchemy의 orm 기능을 사용하여 외래키 없이 사용하는 방법을 설명하고자 한다.

❓왜 외래키 없이 DB를 사용하려고 할까

다른 이유도 있겠지만, 간단히 이유를 정리해보자.

  1. 수작업 으로 데이터를 다루는 경우
  • 부모(참조되는 테이블)의 데이터가 자식(참조하는 테이블)의 데이터 보다 먼저 생성되어있어야한다. 관계가 많아질 수록 수작업으로 데이터 삽입,삭제등의 작업을 할 때 복잡해진다
  1. 참조되는 테이블은 참조하는 테이블보다 먼저 생성되어있어야한다
  • 이는 곧, 생성, 수정할 때마다 순서를 신경써줘야한다
  1. 모델(테이블)을 수정, 확장의 어려움
  • 외래키는 DB에서 데이터 무결성을 보장해주지만 이에 따른 제약 조건이 있어서, 모델간의 관계를 수정하거나 새로운 모델을 추가할 때 기존의 외래키 때문에 복잡해진다.

예시 상황

회원이 있고, 회원은 글을 쓸 수 있으며, 글에 태그을 작성할 수 있는 상황을 예시로 보자.

위 상황에서는

일대다 관계

  • 회원은 글을 여러개를 가질 수 있음(1:M o2m)

다대다 관계

  • 여러개의 글은 여러개의 태그을 가질 수 있음 (M:M m2m)

이런 관계를 생성해 볼 수 있다.

일대다 관계 (one to many)

디렉터리 및 파일 구조

일대다 git 링크

📦one_to_one
 ┣ 📂model
 ┃ ┣ 📜base.py
 ┃ ┣ 📜post.py
 ┃ ┗ 📜user.py
 ┣ 📜one_to_many.py
 ┗ 📜README.md

base.py

# model/base.py
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr

@as_declarative()  # sqlalchemy의 declarative base로 동작하도록 클래스를 설정
class Base:
    id: Any
    __name__: str

    @declared_attr
    def __tablename__(cls) -> str:
        # 자동으로 __tablename__을 상속한 클래스 이름 소문자로 만들어 주는 기능
        return cls.__name__.lower()

sqlalchemy에서 모델(테이블)을 만들 기본 내용이다. 위 Base 클래스를 상속받으면 주석 내용처럼, 상속 받은 클래스의 이름을 받아 소문자로 만들어 tablename 속성이름으로 지정하게 된다.

user.py(모델)

# model/user.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base

class User(Base):
    id = Column(Integer, primary_key=True, autoincrement=True)
    nickname = Column(String(100), nullable=False)
    password = Column(String(100), nullable=False)

    posts = relationship(  # post랑 1대다 관계
        'Post',
        primaryjoin='User.id == Post.owner_id',
        back_populates='owner',
        foreign_keys='Post.owner_id',
        lazy='selectin'
    )

    @property
    def my_posts(self):
        """ 해당 user가 작성한 글들 조회"""
        return [post.title for post in self.posts]

일대다 관계중 에 해당하는 회원 모델 내용이다.

post.py(모델)

# model/post.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base

class Post(Base):
    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(255))
    description = Column(String(500))
    owner_id = Column(Integer)

    owner = relationship(  # User랑 1대다 관계
        'User',
        primaryjoin='Post.owner_id == User.id',
        back_populates='posts',
        foreign_keys=owner_id,
        lazy='selectin'
    )

    @property
    def owner_name(self):
        """ 해당 post의 작성자 조회"""
        return self.owner.nickname if self.owner else None

일대다 관계중 에 해당하는 글 모델 내용이다.

확인용 one_to_many.py 파일

# one_to_many.py
from model.user import User
from model.post import Post

from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker

engine = create_engine("sqlite:///one_to_many.db")

session_factory = sessionmaker(autocommit=False,
                               autoflush=False,
                               bind=engine)

def init_db() -> None:
    """
    one_to_many.db파일 생성 및 테이블 생성
    """
    # 모든 model들은 Base를 상속받아서 다른 테이블은 따로 생성해주지 않아도 자동으로 생성됨
    User.metadata.create_all(bind=engine)

def create_user(*, user_in: dict) -> User:
    """
    사용자 만드는 기능
    """
    with session_factory() as session:
        db_obj = User(**user_in)
        session.add(db_obj)
        session.commit()
        # relationship에 매핑시킨 속성 refresh
        session.refresh(db_obj, attribute_names=['posts'])
    return db_obj

def create_post(*, post_in: dict) -> Post:
    """
    글 만드는 기능
    """
    with session_factory() as session:
        db_obj = Post(**post_in)
        session.add(db_obj)
        session.commit()
        # relationship에 매핑시킨 속성 refresh
        session.refresh(db_obj, attribute_names=['owner'])
    return db_obj

def get_user_posts(*, id: int):
    with session_factory() as session:
        query = select(User).filter(User.id == id).limit(1)
        result = session.execute(query)
        data = result.scalars().first()
        return data

if __name__ == '__main__':
    init_db()  # 테이블 생성
    # user는 id,nickname,password 컬럼을 가짐
    user = create_user(user_in={'nickname': 'dev', 'password': 'dev'})
    # post는 id,title,description,owner_id 컬럼을 가짐
    post1 = create_post(post_in={'title': 'o2m test1글 제목입니다',
                                 'description': 'o2m test1 설명', 'owner_id': user.id})
    post2 = create_post(post_in={'title': 'o2m test2글 제목입니다',
                                 'description': 'o2m test2 설명', 'owner_id': user.id})
    # post 에서 글 쓴 user의 nickname 출력 가능
    print(f'post1 글을 쓴 사람은 : {post1.owner_name}입니다.')
    print(f'post2 글을 쓴 사람은 : {post2.owner_name}입니다.')
    # user가 쓴 글 목록 확인
    user = get_user_posts(id=user.id)
    print(
        f'user가 쓴 글목록은 : {user.my_posts}입니다.')

위에 작성한 user 와 post 모델로 실제 외래키가 없는지 확인하고 , 어떻게 서로 관계된 데이터를 가져오는지에 대해 알아보자.

위 one_to_many.py 의 파일을 실행해보면, 실제 user 테이블은 init_db()에 의해

CREATE TABLE user (
        id INTEGER NOT NULL,
        nickname VARCHAR(100) NOT NULL,
        password VARCHAR(100) NOT NULL,
        PRIMARY KEY (id)
)

위 쿼리로 테이블이 생성된다.

post 테이블도 보면

CREATE TABLE post (
        id INTEGER NOT NULL,
        title VARCHAR(255),
        description VARCHAR(500),
        owner_id INTEGER,
        PRIMARY KEY (id)
)

위 쿼리로 테이블이 생성되는 걸 확인 할 수 있다.

테이블을 생성하는 쿼리를 보면 서로 간의 외래키(FK)가 걸려있지 않음을 확인할 수 있다.

실제 외래키는 걸려있지 않지만, 모델을 생성할 때 작성한 코드를 봐보면

모델끼리의 relationship

# model/user.py
posts = relationship(  # post랑 1대다 관계
        'Post',
        primaryjoin='User.id == Post.owner_id',
        back_populates='owner',
        foreign_keys='Post.owner_id',
        lazy='selectin'
    )
# model/post.py
owner = relationship(  # User랑 1대다 관계
        'User',
        primaryjoin='Post.owner_id == User.id',
        back_populates='posts',
        foreign_keys=owner_id,
        lazy='selectin'
    )

서로 간의 관계를 relationship()을 활용하여 지정해 주었기에 실제 Database에 외래키가 없어도,

User와 Post 객체간의 관계를 맺어줌으로써 FK대신 사용해줄 수 있다.

위 부분에서 lazy=’selectin’ 은 sqlalchemy의 전략 로딩인데 해당 내용은

sqlalchemy 로딩 전략 공식문서 에 자세히 나와있다. ‘selectin’은 eager loading 이라 부르는데, 이는 객체에 접근할 때 연관되어 있는 모든 내용을 불러오겠다라고 생각하면된다.

위 관계를 활용해 사용해보면,

print(f'post1 글을 쓴 사람은 : {post1.owner.nickname}입니다.')
print(f'post2 글을 쓴 사람은 : {post2.owner.nickname}입니다.')

출력

post1 글을 쓴 사람은 : dev입니다.
post2 글을 쓴 사람은 : dev입니다.

출력에서 볼수 있듯이. Post는 실제 table에 nickname이라는 컬럼이 없지만, relationship()로 맺어준 관계를 이용해 글쓴 사람의 이름(nickname)을 조회 할 수 있다.

반대로 user(일) 은 여러개의 post()를 조회할 수 있다.

user = get_user_posts(id=user.id)
print(f'user가 쓴 글목록은 : {user.my_posts}입니다.')

출력

user가 쓴 글목록은 : ['test1글 제목입니다', 'test2글 제목입니다']입니다.

위 출력 처럼, user입장에서도 post들의 제목을 조회 가능하다.

다대다관계 (many to many)

다대다 관계는 one to many와 다르게 서로를 연결해 줄 수 있는 중간 모델(테이블)이 필요하다.

post와 tag 사이의 중간 테이블 즉, post_tag 와 같은 중간 테이블이 있어야 다대다 관계 매핑 을 해줄 수 있다.

다대다 매핑 git 링크

디렉터리 및 파일 구조

📦many_to_many
 ┣ 📂model
 ┃ ┣ 📜base.py
 ┃ ┣ 📜tag.py
 ┃ ┣ 📜post.py
 ┃ ┗ 📜post_tag.py
 ┣ 📜many_to_many.py
 ┗ 📜README.md

base.py

# one to many 의 base.py와 동일
# model/base.py
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr

@as_declarative()  # sqlalchemy의 declarative base로 동작하도록 클래스를 설정
class Base:
    id: Any
    __name__: str

    @declared_attr
    def __tablename__(cls) -> str:
        # 자동으로 __tablename__을 상속한 클래스 이름 소문자로로 만들어 주는 기능
        return cls.__name__.lower()

post.py

# model/post.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base

class Post(Base):
    id = Column(Integer, primary_key=True, autoincrement=True)
    title = Column(String(255), nullable=False)
    description = Column(String(500), nullable=False)

    tags = relationship(  # tag랑 다대다 관계
        'tag',
        secondary='post_tag',
        primaryjoin='Post.id == post_tag.c.post_id',
        secondaryjoin='post_tag.c.tag_id == tag.id',
        back_populates='posts',
        lazy='selectin'
    )

    @property
    def in_tags(self):
        return [tag.content for tag in self.tags]

tag.py

# model/tag.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base

class tag(Base):
    id = Column(Integer, primary_key=True, autoincrement=True)
    content = Column(String(500), nullable=False)

    posts = relationship(  # post랑 다대다 관계
        'Post',
        secondary='post_tag',
        primaryjoin='tag.id == post_tag.c.tag_id',
        secondaryjoin='post_tag.c.post_id == Post.id',
        back_populates='tags',
        lazy='selectin'
    )

    @property
    def in_posts(self):
        return [(post.title, post.description) for post in self.posts]

post_tag.py

# model/post_tag.py
from sqlalchemy import Column, Integer
from .base import Base

class Post_tag(Base):  # 테이블명 때문에 _ 로 이어줌
    post_id = Column(Integer, primary_key=True)
    tag_id = Column(Integer, primary_key=True)

one to many 관계 와 비슷하다고 생각할 수 있지만, 가장 큰 차이는 relationship()을 통해 관계를 맺어줄 때, secondaryjoin 매개변수를 사용해 중간 모델(테이블)인 post_tag 테이블을 사용한다는 것이다.

확인용 many_to_many.py 파일

# many_to_many.py
from typing import Union
from model.post import Post
from model.tag import tag
from model.post_tag import Post_tag

from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker, Session

engine = create_engine("sqlite:///many_to_many.db")

session_factory = sessionmaker(autocommit=False,
                               autoflush=False,
                               bind=engine)

def init_db() -> None:
    """
    many_to_many.db파일 생성 및 테이블 생성
    """
    # 모든 model들은 Base를 상속받아서 다른 테이블은 따로 생성해주지 않아도 자동으로 생성됨
    Post.metadata.create_all(bind=engine)

def create_post(*, session: Session, post_in: dict) -> Post:
    """
    글 만드는 기능
    """
    db_obj = Post(**post_in)
    session.add(db_obj)
    session.commit()
    # relationship에 매핑시킨 속성 refresh
    session.refresh(db_obj, attribute_names=['tags'])
    return db_obj

def create_tag(*, session: Session, tag_in: dict) -> tag:
    """
    태그 만드는 기능
    """
    db_obj = tag(**tag_in)
    session.add(db_obj)
    session.commit()
    # relationship에 매핑시킨 속성 refresh
    session.refresh(db_obj, attribute_names=['posts'])
    return db_obj

def get_model_multi(*, session: Session, model: Union[Post, Post_tag, tag]) -> Union[Post, Post_tag, tag]:
    query = select(model)
    result = session.execute(query)
    data = result.scalars().all()
    return data

if __name__ == '__main__':
    init_db()  # 테이블 생성
    session = session_factory()

    post1 = create_post(session=session, post_in={'title': 'm2m test1글 제목입니다',
                                                  'description': 'm2m test1 설명'})
    post2 = create_post(session=session, post_in={'title': 'm2m test2글 제목입니다',
                                                  'description': 'm2m test2 설명'})

    tag1 = create_tag(session=session, tag_in={
                              'content': 'm2m test1 태그1 입니다.'})
    tag2 = create_tag(session=session, tag_in={
                              'content': 'm2m test1 태그2 입니다.'})
    tag3 = create_tag(session=session, tag_in={
                              'content': 'm2m test2 태그3 입니다.'})
    tag4 = create_tag(session=session, tag_in={
                              'content': 'm2m test2 태그4 입니다.'})

    # 중간 테이블인 post_tag에 post와 tag 저장

    tag1.posts.append(post1)
    tag2.posts.append(post1)
    tag3.posts.append(post2)
    tag4.posts.append(post2)
    session.commit()

    # post로 확인
    print('post1의 태그들')
    print(post1.in_tags)
    print('post2의 태그들')
    print(post2.in_tags)
    # tag로 확인
    print(f'tag1은 이 글의{tag1.in_posts} 태그입니다.')
    print(f'tag2은 이 글의{tag2.in_posts} 태그입니다.')
    print(f'tag3은 이 글의{tag3.in_posts} 태그입니다.')
    print(f'tag4은 이 글의{tag4.in_posts} 태그입니다.')

one to many 처럼 위 확인용 파일의 실행 결과를 보면,

post 테이블 생성 쿼리

CREATE TABLE post (
        id INTEGER NOT NULL,
        title VARCHAR(255) NOT NULL,
        description VARCHAR(500) NOT NULL,
        PRIMARY KEY (id)
)

tag 테이블 생성 쿼리

CREATE TABLE tag (
        id INTEGER NOT NULL,
        content VARCHAR(500) NOT NULL,
        PRIMARY KEY (id)
)

post_tag 생성 쿼리

CREATE TABLE post_tag (
        post_id INTEGER NOT NULL,
        tag_id INTEGER NOT NULL,
        PRIMARY KEY (post_id, tag_id)
)

생성 쿼리에서 확인할 수 있듯이, FK가 걸려있지 않은걸 확인할 수 있다.

모델끼리의 relationship

# model/post.py
tags = relationship(  # tag랑 다대다 관계
        'tag',
        secondary='post_tag',
        primaryjoin='Post.id == post_tag.c.post_id',
        secondaryjoin='post_tag.c.tag_id == tag.id',
        back_populates='posts',
        lazy='selectin'
    )
# model/tag.py
posts = relationship(  # post랑 다대다 관계
        'Post',
        secondary='post_tag',
        primaryjoin='tag.id == post_tag.c.tag_id',
        secondaryjoin='post_tag.c.post_id == Post.id',
        back_populates='tags',
        lazy='selectin'
    )

위 코드와 같이 secondaryjoin 매개변수를 활용해 중가테이블과의 관계도 설정해주어, 외래키 없이 다대다 매핑을 해줄 수 있다.

이의 데이터를 확인해보면,

post로 tag들 조회하기

print('post1의 태그들')
print(post1.in_tags)
print('post2의 태그들')
print(post2.in_tags)

출력

# post로 확인
post1의 태그들
['m2m test1 태그1 입니다.', 'm2m test1 태그2 입니다.']
post2의 태그들
['m2m test2 태그3 입니다.', 'm2m test2 태그4 입니다.']

tag로 post들 조회하기

# tag로 확인
print(f'tag1은 이 글의{tag1.in_posts} 태그입니다.')
print(f'tag2은 이 글의{tag2.in_posts} 태그입니다.')
print(f'tag3은 이 글의{tag3.in_posts} 태그입니다.')
print(f'tag4은 이 글의{tag4.in_posts} 태그입니다.')

출력

tag1은 이 글의[('m2m test1글 제목입니다', 'm2m test1 설명')] 태그입니다.
tag2은 이 글의[('m2m test1글 제목입니다', 'm2m test1 설명')] 태그입니다.
tag3은 이 글의[('m2m test2글 제목입니다', 'm2m test2 설명')] 태그입니다.
tag4은 이 글의[('m2m test2글 제목입니다', 'm2m test2 설명')] 태그입니다.

위 출력과 같이 다대다 관계를 외래키 없이 사용할 수 있음을 확인할 수 있다.

profile
정리 블로그

2개의 댓글

comment-user-thumbnail
2023년 7월 18일

아주 유용한 정보네요!

답글 달기
comment-user-thumbnail
2023년 7월 18일

덕분에 좋은 정보 얻어갑니다, 감사합니다.

답글 달기