[FastAPI] 14. SQLAlchemy의 One-to-Many, Many-to-Many, Self referential relationship

반응형

지난 포스트에서는 단순 Column 데이터와 그리고, 단일 외래키 하나의 관계 데이터를 Pydantic으로 매핑하여 Serialize/Deserialize 하는 것에 대해 알아봤습니다.

 

이번 포스트에서는 Many-to-Many, One-to-Many 형태의 Foreign Key가 걸려 있는 관계형 데이터를 Join해서 가져올 떄 어떻게 Pydantic 모델과 매핑할 수 있는지에 대해 알아보겠습니다.

 

 

 

DB에서 단순하게 접근

지난 포스트에서 사용한 ERD를 그대로 사용해보겠습니다.

 

Category와 Post가 있고, Post는 category_id를 외래키로 사용하고 있습니다. 따라서 카테고리의 데이터를 category 테이블이 가지고 있는건데, 우리가 이 정보를 모두 데이터베이스로 가져오려면 어떻게 해야 할까요?

 

select * from post as p join category as c on p.category_id = c.id where p.id = ?

특정 Post 컨텐츠에 대한 Post, Category 데이터를 모두 가져오고자 하는 경우, 간단히 위 Query를 사용해서 가져올 수 있습니다. 이런 경우는 단순 관계가 하나이기 때문에 그리 어렵지 않게 데이터를 가져올 수 있습니다.

 

 

 

 

 

ORM에서 접근

테이블 간 연관 관계가 있을 때 객체 지향적으로 사용하는 방법에는 두 가지 방법이 있습니다.

 

  • DB의 외래키 사용
  • SQLAlchemy의 entity 객체 참조

 

그러나 ORM을 사용할 때는 가급적 객체를 참조해서 관계를 맺는 것이 좋습니다. DB의 외래키를 사용한 관계는 테이블 중심의 모델링 설계시 사용하는 방법인데, 이렇게 하면 외래키에 의존하므로 객체지향스럽지 못한 형태를 보여주게 됩니다.

 

따라서 객체 중심으로 관계를 매핑하고 모델링 하는 것이 중요합니다. 

단, 필요에 따라서 외래키를 사용하고 그 관계된 데이터의 삭제나 삽입을 DB에 의존하고자 하는 경우, 설계는 객체지향적으로 설계하되, 외래키를 사용하는 방법도 나쁘지 않은 선택입니다.

 

SQLAlchemy에서는 Core와 ORM이 존재하는데, Core는 SQLAlchemy에서 제공하는 Syntax(such as Linq)이고, ORM은 객체를 사용하여 데이터베이스의 데이터를 접근하는 것입니다.

 

우리는 여기서 ORM을 사용할 것인데, ORM에서는 relationship을 이용해 외래키가 걸려 있는 테이블을 Join하여 가져올 수 있습니다.

 

 

 

 

 

지난 포스트의 내용을 보면 Post가 하나의 카테고리를 가질 수 있는 Many-to-One 형태의 관계 데이터를 매핑하는 경우를 살펴봤는데요. 이런 경우는 간단히 relationship에 foreign_key 파라미터를 사용하여 쉽게 매핑할 수 있었습니다.

 

그러면 자기 자신의 테이블을 바라보는 One-to-Many 형태의 relationship은 어떻게 구현할 수 있을까요?

 

 

 

자기 참조 관계 (Self referential relationship)

자기 자신의 테이블의 ID를 참조하는 경우의 관계 데이터를 SQLAlchemy에서는 Self referential relationship이라고 이야기 합니다. 

 

우리는 카테고리 테이블에서 하나의 카테고리는 부모 카테고리를 지닐 수도 있는 형태로 설계하였습니다. 그 말은 자신이 부모 카테고리가 될 수도 있고, 자식 카테고리도 될 수 있는 것입니다. 이를 객체지향적으로 설계하면 아래와 같습니다.

 

 

 

 

 

나의 부모 카테고리는 단 한 개만 존재할 수 있으나, 그 부모에 대한 부모 카테고리도 하나 존재할 수 있으므로 parents라고 표기합니다. children은 나를 부모 카테고리로 선택한 카테고리들이 모두 선택되기 때문에 여러개가 될 수 있습니다.

 

단, 이렇게 설계하는 경우 몇 가지 주의해야하는 점이 있습니다.

 

  • 원칙적으로 깊이 갯수를 제한하지 않으나, 깊이를 많이 줄수록 로딩 속도가 느려지므로 정해주는 것이 좋다
  • 부모와 자식 카테고리를 동시에 로딩하지 않도록 해야 한다.

 

보다시피 트리 형태로 구성되어지기 때문에 서로의 관계 로딩을 두 개 동시에 이룰 경우 서로가 서로의 부모, 자식 카테고리를 계속 로딩하기 때문에 재귀 지옥(무한 참조)이 발생합니다. 따라서 둘 중 한 가지의 관계만을 로딩해야 합니다.

 

 

 

 

 

SQLAlchemy에서는 lazy 옵션이 있습니다. 이 옵션은 relationship과 같은 관계 객체를 불러오는 다양한 옵션을 선택하는 파라미터로 이를 noload로 설정하면 기본적으로 ORM 객체를 로딩할 때 해당 관계 객체는 로딩하지 않습니다.

 

하지만 API를 호출할 때는 parents를 호출하고 싶을 때도 있고, children을 호출하고 싶을 때도 있습니다. 이런 경우, Entity 클래스를 API 갯수만큼 만들어 적용해야 하나요?

 

SQLAlchemy에서는 하나의 테이블에 복수의 Entity 객체를 허용하지 않습니다. 예를 들면 테이블 이름이 category 일 때, 동일한 테이블 이름이 갖는 Entity 객체를 만나면 바로 오류를 발생시킵니다. 따라서 이럴 때는 ORM 모델을 바로 로딩하기 보단, SQLAlchemy Core를 사용하는 것이 좋습니다.

 

 

 

 

 

위와 같이 사용하면 원하는 relationship을 함수별로 나눌 수 있습니다. 다만 이렇게 하는 경우 ORM 모델에 있는 lazy 옵션을 모두 noload로 해야하며, 그렇지 않으면 해당 모델을 따라가게 됩니다.

 

 

 

Self referential in Pydantic

그렇다면 Pydantic 모델로 변환할 때는 어떻게 할 수 있을까요? 파이썬에서 클래스를 정의하고 자기 자신의 모델을 property 타입으로 넣을 때는 '' 따옴표를 넣어 진행할 수 있습니다.

 

 

 

하지만 Pydantic에서 이런 코드를 작성하면 ForwardRef error를 내뿜게 됩니다. 오류 메시지에서는 update_forward_refs 메서드를 사용하라고 지시하는데, 이 메서드는 무엇일까요?

 

update_forward_refs는 문자열로 선참조된 모델 프로퍼티를 다시 class 참조할 수 있도록 해주는 녀석입니다.

 

위 코드를 보면 모든 필드의 값을 가져와 클래스 참조하고, json_encoders의 옵션도 같이 적용시켜주는 걸 볼 수 있습니다. 이 방법으로 자기 참조가 적용된 SQLAlchemy 모델을 Pydantic 모델로 쉽게 바꿀 수 있습니다.

 

 

 

 

Many-to-Many

many-to-many 연관 관계는 다대다 관계라고도 하며 실제 테이블 중심의 모델링에서는 잘 나오지 않는 연관 관계입니다. 객체지향적인 방법으로 연관관계를 맺었을 때 양쪽 서로 모두가 참조할 수 있는 관계를 말하는데, 이 때는 각 테이블에 서로의 외래키를 지정하기 보단, 외래키가 있는 테이블을 대변하는 Entity 객체를 별도로 지정해야 차후 성능 이슈가 발생하지 않습니다.

 

SQLAlchemy에서는 이를 SQLAlchemy ORM과 SQLAlchemy Core 두 가지로 표현할 수 있습니다.

 

 

 

post_tags는 Post Entity와 Tag Entity의 관계를 관리하는 관리 테이블입니다. 하나의 컨텐츠는 동일한 태그를 하나만 가질 수 있으므로 PK로 지정하면 중복된 태그가 들어갈 수 없고, 특정 태그에 어떤 글이 있는지 빠르게 색인할 수 있습니다.

 

여기서 두 관계를 나타내는 post_tags는 ORM을 사용하지 않고 Table을 사용했는데 그 이유는 API에서는 이들의 테이블을 조회할 일이 없으므로 ORM 모델이 아닌 단순 테이블로 정의한 것입니다. 이는 SQLAlchemy의 Core 모듈에 해당합니다.

 

또 TagEntity의 posts는 viewonly를 True로 지정했는데, 이는 Tag를 만들 때 특정 컨텐츠를 지정하지 않겠다는 것이며 단순히 조회만 사용하겠다는 것입니다. SQLAlchemy에서 Many-to-Many를 지정할 때는 서로의 Entity가 relationship을 가직고 조회/삽입하는 형태를 만들 수도 있는데, 이렇게 하면 둘 중 하나의 relationship은 viewonly로 두어야 성능 이슈가 발생하지 않습니다. 그렇지 않으면 두 객체 데이터 모두가 영향을 받게됩니다.

 

거기에 loading에 대해서도 재귀 지옥(무한 참조)가 발생하지 않도록 서로의 relationship이 동시에 로딩하지 않도록 해야 합니다. (자기 참조 관계에서 했던대로 진행하면 됩니다.)

 

 

 

마치며...

ORM의 매핑 관계에는 One-to-One, One-to-Many, Many-to-One, Many-to-Many가 있습니다. 특히 Many-to-Many는 객체지향적인 방법에서 주로 사용하는 방법인데, SQLAlchemy에서는 이를 가급적 ORM으로 모두 표현하지 않고, 그들이 어떻게 참조하고 있는지에 대해서 별도의 테이블을 만들고 관리하는 방식으로 진행합니다.

 

특히 ORM으로 관계 데이터를 로딩할 때 가장 주의해야 할 것은 무한 참조입니다. 가급적 무한 참조가 발생하지 않도록 설계하는 것이 중요하겠지만 무한 참조할 수 밖에 없는 설계가 나왔다면 이를 적절히 로딩할 수 있도록 분리하는 것도 하나의 방법일 수 있습니다.

 

Pydantic을 이용해서 DTO로 변환할 때 orm_mode를 True 값으로 주어 SQLAlchemy Entity 모델을 변환할 수 있는데, 여기서 relationship을 DTO 모델에 명시하지 않으면 relationship의 로딩 방법과 관계 없이 지나가는 걸 볼 수 있습니다. 하지만 이는 DTO 모델에 변환 데이터로 넣지 않을 뿐 실제로 DB에서 쿼리를 실행하기 때문에 성능 최적화를 위해서 Pydantic에 단순히 relationship 프로퍼티를 주지 않는 것이 끝나지 않고 필요하지 않은 데이터에 대해서는 loading 하지 않도록 구현해주세요.

 

반응형

Tistory Comments 0