리얼월드를 지탱하는 기술 #2 — Entity Framework Core ORM (활용편)

Yechanny
Uniquegood
Published in
10 min readDec 17, 2021

리얼월드를 지탱하는 기술 #1 — Entity Framework Core ORM (소개편)
리얼월드를 지탱하는 기술 #2 — Entity Framework Core ORM (활용편) <<

안녕하세요 유니크굿컴퍼니 책임 개발자 이예찬입니다.

지난 시간에 공유했던 리얼월드를 지탱하는 기술 #1 — Entity Framework Core ORM (소개편) 에 이어 이번에는 저희 서비스인 리얼월드에서 실제로 어떻게 사용 되고 있는지 구체적인 사례를 소개해 드리려고 합니다. 직접적으로 데이터베이스에 대해 얘기 하기 전에, 저희 리얼월드 서비스의 데이터 구조가 어떻게 되어 있는지 간략하게 설명 먼저 드리겠습니다.

리얼월드 게임의 구조

리얼월드 게임은 다층적인 구조를 가지고 있습니다. 일단 게임이 발행 되는 ‘채널’이라는 개념이 있고, 그 아래로 ‘게임’들이 있습니다. 그리고 그 게임 아래로 지금은 사용하지 않는 ‘시나리오’라는 개념이 있습니다. 예전에 태양단의 비밀 게임이 서울/전주/부산 세 개의 시나리오로 되어 있었는데요, 지금은 각각의 게임으로 분리하게 되어 이제 더이상 시나리오라는 개념을 사용하지는 않습니다. 그러나 논리적으로는 존재하지 않더라도 여전히 물리적으로는 존재하고 있습니다. 원래 하나의 게임이 여러 개의 시나리오를 가질 수 있었으나, 현재는 하나의 게임은 하나의 시나리오를 가지도록 논리적으로 제약을 만들어 둔 상태입니다.

시나리오 아래로는 ‘미션’들이 있습니다. 시나리오가 완결성이 있는 하나의 이야기 콘텐츠를 말하는 것이었다면, 미션은 사용자가 경험해야 할 이야기 토막을 말하는 것입니다. 예컨대 ‘출근길 에피소드’라는 게임과 시나리오가 있다고 하면, 미션은 ‘지하철 타러 가기’ 정도가 될 수 있을 것 같네요. 그리고 그 미션의 아래로 ‘퀘스트’들이 있어서 사용자가 수행 해야 할 행동 하나 하나를 지정합니다. ‘지하철 타기’ 미션에서는 ‘계단을 내려간다’, ‘교통카드를 구매한다’, ‘개찰구에 카드를 찍는다’ 같은 것이 퀘스트가 될 수 있을 것 같습니다.

정리하자면 채널 → 게임 (→ 시나리오) → 미션 → 퀘스트 의 구조가 되는 것입니다.

테이블 구조

기본 구조

위에서 설명한 구조의 모든 내용은 현재 Azure SQL Server Database에 저장되고 있습니다. 흔한 관계형 데이터베이스죠. 채널과 게임, 미션, 퀘스트 모두 각각의 테이블에 저장 되고 있고 차례로 One-to-Many 관계가 적용되어 있습니다.

EF Core에서는 모델을 만들 때 설정(Configuration) 코드를 통해서 만들 수도 있지만, 기본 키나 관계 등 일부 기능들은 규약(Convention)을 통해서 좀 더 손쉽게 모델을 만들 수 있습니다. 따라서 이러한 채널-게임-미션-퀘스트 간의 단순한 One-to-Many 관계는 EF Core에서 모델의 코드로 쉽게 표현 됩니다. 이해에 무리가 없을 정도의 일부 예시 코드를 가져왔습니다.

위와 같은 코드만 있으면 마이그레이션 시에 자동으로 One-to-Many 관계를 만들어줍니다.

사용자 맥락

그리고 여기에 중요한 개념이 하나 더 들어가는데요, 바로 각 항목에 대한 사용자의 맥락(Context)입니다. 채널을 제외하고, 게임과 시나리오, 미션, 그리고 퀘스트는 각 항목별로 사용자의 맥락을 가지고 있습니다. ‘이 미션이 사용자에게 표시가 될지 안 될지’, ‘이 퀘스트를 사용자가 완료 했는지’, ‘이 게임을 사용자가 완료 했는지’ 등 같은 게임/시나리오/미션/퀘스트라도 사용자마다 다르게 사용 될 필요가 있습니다.

이를 위해 아래와 같이 각각 사용자 맥락(User Context)이라는 것을 만들었습니다. 이번에도 이해에 무리가 없을 정도의 일부 예시 코드를 가져왔습니다.

복합 키 사용

그리고 각 맥락은 사용자별로, 그리고 엔티티(게임, 시나리오, 미션, 퀘스트)별로 하나씩 있으므로 사용자 ID와 엔티티 ID를 복합 키(Composite Key)로 구성해 기본 키로 사용합니다. 복합 키 구성을 위해 아래와 같은 Fluent API 코드를 사용했습니다.

복합 키는 추후 기본 키를 이용한 항목 검색 시에 키의 순서가 중요하므로 헷갈림 방지를 위해 UserId를 무조건 복합키의 첫번째 순서로 오도록 하는 약속을 했습니다. 그러면 아래와 같이 항목을 검색 해올 수 있습니다.

데이터베이스 쿼리

중첩 데이터 (테이블 Join)

미션 하나는 여러 개의 퀘스트를 가질 수 있습니다. 만약 어떤 미션의 상세 정보와 그 미션에 포함 된 퀘스트 목록을 한번에 조회하고 싶다면 Join을 사용하면 됩니다. 그러나 EF Core에서는 코드로써 명시적으로 Join을 사용하지는 않고, Include 함수를 통해 관계에 있는 데이터를 가져옵니다. 아래와 같은 코드를 통해 중첩 된 데이터를 가져올 수 있습니다.

조건부 Join

사용자 맥락이 있기 때문에 데이터 조회에 있어서 중요한 차이점이 있습니다. 바로 어떤 데이터의 목록을 조회할 때 그 사용자의 맥락까지 한번에 불러 와야 한다는 것이죠. 그래야 사용자의 맥락을 반영한 표현이 가능하니까요. 예컨대 미션들은 기본적으로 사용자에게 보이지 않도록 저장 되어 있지만, 사용자의 맥락에 따라 잠김 표시가 되거나 미션에 접근이 가능하도록 표시가 되어야 할 때도 있습니다. 그렇게 하기 위해 미션 별 사용자의 맥락을 함께 불러 와야 합니다.

Include 함수를 통해 Join 데이터를 가져오지만, 그 안에서 Where 함수와 Take 함수로 가져오는 데이터에 대한 제약을 할 수 있습니다.

Full Text Search

리얼월드 게임에는 도움글과 후기글이라는 커뮤니티 기능이 있습니다. 여기서 커뮤니티 글들을 검색할 수 있는데, 검색 결과를 가져오기 위해서는 단순하게 아래와같이 String.Contains 메소드를 사용해도 됩니다.

그러나 이걸로는 단순히 문자열이 완전 일치하는 경우만 검색이 되므로, 저희는 형태소 분석을 통한 좀 더 나은 결과를 얻기 위해 Azure SQL Server에서 제공하는 Full Text Search를 사용하고 있습니다.

EF Core는 쿼리 내부에서 사용할 수 있는, DB 엔진 종속적인 함수들을 DbFunctions라는 클래스의 확장 메소드로 모아두고 있습니다. EF.Functions를 이용해 DB 엔진 종속적인 함수들을 호출할 수 있으며 FreeText는 Full Text Search를 할 수 있게 해주는 SQL Server의 함수입니다.

EF.Functions.FreeText 메소드의 세번째 인자는 언어 처리에 관한 인자입니다. 한국어는 1042번으로 지정 되어 있으며, 이렇게 언어를 지정하게 되면 한국어로 형태소 분석을 하여 쿼리를 처리합니다.

Full Text Search는 전용 인덱스가 필요합니다. 그런데 현재 EF Core에서는 이 인덱스를 만들기 위한 마이그레이션 옵션을 제공하지 않으므로 직접 마이그레이션 코드에 raw SQL문을 작성하여 인덱싱을 해주어야 합니다. 여기서는 후기글/도움글의 ContentWriterName에 Full Text Search 인덱스를 걸어주었습니다.

이 문서가 EF Core에 Full Text Search를 적용하는 데에 도움이 될 것 같습니다.

쿼리 필터

커뮤니티의 글들은 삭제 했을 때 바로 삭제 되는 게 아니라 IsDeleted라는 플래그를 사용해서 소프트 삭제 처리 합니다. 글에 대한 신고 처리나 스포일러 처리 등 관리자가 처리한 기록을 남기기 위해서인데요, 매 쿼리 마다 comments.Where(c => !c.IsDeleted)를 붙여서 조회 해오면 번거롭고 실수 하기도 쉬우니 커뮤니티 글 전역에 걸쳐서 IsDeleted가 false인 것만 조회 할 수 있어야 합니다.

모델을 만들 때 ModelBuilder.HasQueryFilter 메소드를 사용하면 Where함수를 전부 가져다 쓴 것 처럼 만들 수 있습니다.

조금 특이한(?) 사용 케이스

일반적으로 어떻게 사용하는지는 모르겠으나, 저희가 EF Core를 활용하는 방식 중에 조금 특이하다고 생각하는 방식들도 있습니다.

JSON value

보통 테이블은 식별자(기본 키)를 필요로 하는 엔티티(Entity) 정보가 들어갑니다. 앞서 말한 채널, 게임 등은 물론이고 사용자 정보나 게시글 등등 대부분의 객체가 식별자를 가집니다. 그러나 식별자가 필요 없는, 그러면서도 INTCHAR 등과 달리 표준 SQL이 지원하지 않는 복잡한 '값 객체'라면 테이블에 집어넣기 조금 애매합니다. 이런 경우 JSON 문자열로 직렬화(Serialize) 하여 테이블에 집어넣고 있습니다.

대표적으로 리얼월드 앱의 메인 페이지에 대한 정보를 가져올 때 JSON 값이 사용 되고 있습니다. 메인 페이지에 표시 될 정보는 제목을 가지고 게임들을 모아 보여주는 섹션들과, 각 섹션 안에 포함 된 게임들에 대한 정보가 들어갑니다. 이러한 데이터들을 JSON 문자열로 SQL 데이터베이스에 그대로 저장하고 있습니다.

그러나 매번 값을 읽고 쓸 때마다 JSON 직렬화와 역직렬화를 하면 번거롭고 실수 하기도 쉬우니 ModelBuilder.HasConversion 메소드를 활용하여 값을 읽고 쓸 때 자동으로 우리가 원하는 타입으로 변환시켜줍니다. 문자열 배열을 이용하는 간단한 예시를 가져왔습니다.

게임 클래스가 위와 같이 있습니다. 이걸 DbContext.OnModelCreating 메소드에서 아래와 같이 작성해주면 값을 읽고 쓸 때 자동으로 JSON 문자열로 역직렬화/직렬화 하게 됩니다.

포괄 열 인덱스 (Index with Included Columns)

지난번 블로그 글, 신입 개발자, DB를 최적화 하다! 2편에서 소개 해드린 대로 저희는 수많은 채팅 메시지들을 빠르게 조회하기 위해 포괄 열 인덱스라는 것을 사용하고 있습니다. 이건 Microsoft SQL Server에서만 사용되는 기능인데요, 보통 비클러스터형 인덱스는 레코드에 대한 기본 키 값을 가지고 있어서 기본 키 값을 통해 다시 레코드를 검색 하여 데이터를 가지고 옵니다. 이에 반해 포괄 열 인덱스는 인덱스에 기본 키 뿐만 아니라 다른 열에 대한 데이터도 포함하고 있어서 한번에 데이터를 가져올 수 있습니다.

일반적인 인덱싱도 충분히 빠르긴 하지만 저장된 데이터 수와 조회할 데이터 수가 절대적으로 늘어난다면 기본 키 값으로 다시 레코드를 검색하는 것도 만만찮은 시간이 필요합니다. 그래서 포괄 열 인덱스를 사용하고 있고, 아래와 같은 코드로 적용이 가능합니다.

비동기 호출

지난번 소개편에서 보여드렸던 쿼리 코드와 이번에 보여드린 쿼리 코드는 약간 다릅니다. 지난번 코드는 쿼리를 만드는 코드를 보여드렸고, 이번에는 만들어진 쿼리를 실제로 DB에 호출 하여 데이터를 가져오는 부분 까지 포함합니다. FindAsync, FirstOrDefaultAsync, ToArrayAsync 등이 바로 쿼리를 호출하는 부분인데요, Async라는 이름에서 알 수 있듯이 전부 비동기 호출입니다.

C#은 2012년에 추가 된 async / await 키워드로 비동기 생태계가 많이 발전했는데요, EF Core에서도 물론 비동기호출을 사용할 수 있고 권장 됩니다. async / await 키워드를 이용한 비동기 호출은 물론이고, 아직 잘 쓰고 있지는 않지만 await foreach 키워드를 이용해 비동기적으로 데이터를 스트리밍 해올수도 있습니다.

저희 리얼월드 서비스의 백엔드에서 EF Core ORM을 어떻게 사용하고 있는지 활용 사례들을 소개해드렸습니다. EF Core는 간단하고 강력하긴 하지만, 여느 ORM이 그렇듯 실제 작업을 하다 보면 어느 시점에 어느정도로 복잡한 쿼리를 호출해서 메모리에 적재 후 메모리 상에서 작업을 해야 할지 DB 부하를 고려해 전략적으로 사용할 필요도 있습니다. 특히 IEnumerable 확장 메소드들을 동일하게 IQueryable에서 사용하는 만큼, 어떤 시점에 쿼리 호출이 일어나는지 헷갈리지 않고 유의해서 사용해야 합니다.

이번 리얼월드를 지탱하는 기술 #1 — Entity Framework Core ORM 소개편과 활용편을 읽어주셔서 감사합니다. 다음에도 저희 리얼월드 개발에 사용 되는 기술 소개 글로 돌아오겠습니다!

아울러 저희와 함께 EF Core를 활용해서 개발하실 백엔드 개발자를 모십니다! 백엔드 뿐만 아니라 다양한 채용 포지션이 열려 있으니 함께 해주세요 ❤

--

--