다형성 연관관계로 알림 기능과 이벤트 로그 테이블 설계하기
여러 도메인을 참조하는 테이블, 세 가지 선택지 사이에서
필자는 이벤트 로그를 적재하는 로직과 알림 기능을 각각 개발한 경험이 있다. 이번 글에서는 그 경험의 연장선으로 두 가지 상황의 테이블을 재설계해본다.
이 두 상황은 모두 여러 테이블의 레코드를 하나의 테이블에 기록해야 한다는 공통 조건을 갖는다. 이에 따라 참조를 표현하는 방법을 먼저 비교하고, 그 선택에 따라 테이블 설계가 어떻게 달라지는지 살펴본다.
두 상황의 일반적인 특징
이벤트 로그 적재와 알림 기능은 보편적으로 다음과 같은 기능적 요구사항을 갖는다.
이벤트 로그
- 도메인 엔티티에 이벤트가 발생하면 이를 시간순으로 적재한다.
- 도메인 엔티티의 종류는 요구사항에 따라 늘어날 수 있다.
알림 기능
- 서비스 내에 일정 이벤트가 일어나면 사용자에게 알림이 간다.
- 사용자는 알림 목록을 최신 순으로 본다.
- 읽음/안 읽음 상태가 존재한다.
- 도메인 엔티티의 종류는 요구사항에 따라 늘어날 수 있다.
참조를 어떻게 표현할 것인가
이벤트 로그와 알림은 특정 도메인에 속하지 않지만 시스템 내 다수 도메인을 참조해야 한다. 로그든 알림이든 해당 도메인의 '속성'과 '내용'을 가져와 써야 하기 때문이다.
이때 참조를 표현하는 방법은 여러 가지가 있다.
도메인 별로 이벤트 로그/알림 테이블 분리
렌더링 중...
- 위의 ERD와 같이
post_event_log,comment_event_log처럼 도메인 엔티티마다 테이블을 따로 두는 방식이다. - FK를 온전히 사용할 수 있어 무결성이 DB 레벨에서 보장된다.
- 엔티티가 늘 때마다 테이블이 늘어난다.
- 알림처럼 통합 목록이 필요하면 UNION이 되어 페이징이나 정렬처리가 번거롭다. DB에서 해결할 수 없기 때문에 어플리케이션 레벨에서 해결해야 한다.
Exclusive arc 패턴 사용
Exclusive arc란?
참조 대상이 여러 테이블일 때, 도메인마다 nullable FK 컬럼을 두고 레코드마다 그중 무조건 하나만 채우는 방식이다.
event_log 테이블 예시
| id | post_id | comment_id | follow_id | actor_id | event_type | created_at |
|---|---|---|---|---|---|---|
| 1 | 42 | null | null | 7 | updated | 2026-08-30 10:12:03 |
| 2 | null | 17 | null | 12 | deleted | 2026-08-30 10:15:41 |
| 3 | null | null | 903 | 3 | updated | 2026-08-30 11:40:07 |
-
도메인 별 nullable FK 컬럼을 두며 CHECK 제약으로 딱 하나만 non-null이도록 강제한다.
sql-- CHECK 제약 쓴 SQL 예시 CREATE TABLE event_log ( id BIGSERIAL PRIMARY KEY, post_id BIGINT REFERENCES post(id), comment_id BIGINT REFERENCES comment(id), follow_id BIGINT REFERENCES follow(id), actor_id BIGINT NOT NULL REFERENCES users(id), -- 세 컬럼 중 하나에만 값이 들어가도록 하는 CHECK 제약 CONSTRAINT exactly_one_target CHECK ( (post_id IS NOT NULL)::int + (comment_id IS NOT NULL)::int + (follow_id IS NOT NULL)::int = 1 ) ); -
각 컬럼이 특정 테이블의 PK를 나타내기 때문에 FK로 온전히 사용할 수 있어, 이 또한 무결성이 DB 레벨에서 보장된다.
-
도메인이 늘 때마다 컬럼을 추가하고 CHECK 제약을 변경하기 위한 마이그레이션이 필요하다.
- 따라서 도메인 목록이 고정된 경우에만 사용하는 것이 좋다.
다형성 연관관계
다형성 연관관계(polymorphic association)란?
하나의 자식 테이블이 여러 부모 테이블을 참조해야 할 때, 참조 대상의 도메인과 ID를 각각 컬럼으로 두는 방식이다.
event_log 테이블 예시
| id | object_type | object_id | actor_id | event_type | payload | created_at |
|---|---|---|---|---|---|---|
| 1 | post | 42 | 7 | updated | {"title": ["기존 제목", "수정한 제목"]} | 2026-08-30 10:12:03 |
| 2 | comment | 17 | 12 | deleted | null | 2026-08-30 10:15:41 |
| 3 | post | 108 | 7 | created | null | 2026-08-30 11:02:19 |
object_type컬럼이 어떤 테이블을 참조하는지 알려주고,object_id컬럼이 그 테이블의 PK를 가리킨다.- 도메인의 정보를 통합(횡단하는 역할)하는 테이블이기 때문에 페이징이나 정렬이 필요할 때 용이하다.
- 각 레코드마다 참조할 테이블이 다르므로 FK를 걸 수 없다. 따라서 참조 무결성은 어플리케이션 레벨에서 직접 다뤄야 한다.
- 참조 대상의 정보가 필요한 조회에서는 도메인별로 나눠 조회해야 한다.
필자는 다형성 연관관계로 테이블 설계하기를 채택했다.
왜 다형성 연관관계를 선택했는가
- 이벤트 로그와 알림 기능에 참조할 도메인의 개수가 계속 늘어날 가능성이 있기 때문이다.
- 서비스에 도메인이 추가되는 일은 드물지 않다. 예를 들어 결제 기능이 생기면 결제 이벤트도 남겨야 하고, 알림도 보내야 한다.
- 이때 도메인별 테이블이나 Exclusive arc를 쓰고 있다면 결제 도메인 작업뿐만 아니라 이벤트 로그와 알림 테이블의 마이그레이션이 함께 동반된다.
- 반면 다형성은
object_type에 값 하나를 추가하는 것으로 끝난다.
- 도메인 개수가 고정되는 것이 명확하다면 Exclusive arc를 사용하는 게 나을 수 있다.
- 서비스에 도메인이 추가되는 일은 드물지 않다. 예를 들어 결제 기능이 생기면 결제 이벤트도 남겨야 하고, 알림도 보내야 한다.
- 도메인 별로 테이블을 추가하거나 Exclusive arc 패턴을 이용하면 스키마가 도메인 추가에 의존적이다. 테이블을 추가하거나 컬럼/제약을 추가하는 데 비용이 들기 때문이다.
- 도메인의 유연성을 가져가고 싶다면 다형성을 고르는 대신 FK 참조 포기를 감수해야 한다. 즉 스키마를 변경하는 비용을 줄이는 대신 무결성 유지 비용을 어플리케이션이 상시 부담하게 된다.
FK 없이 무결성을 지키는 방법
FK를 포기한다면 어플리케이션 레벨에서 이를 follow-up 해줘야 한다. 따라서 다음을 고려한다.
- **
object_type**을 문자열로 두지 않는다.- 테이블명이나 클래스명을 그대로 저장하면 이름이 바뀔 때, 이미 저장되어 있는 데이터의
object_type으로는 현재의 테이블을 찾을 수 없게 된다. 따라서 Enum이나 별도 참조 테이블로 관리한다.
- 테이블명이나 클래스명을 그대로 저장하면 이름이 바뀔 때, 이미 저장되어 있는 데이터의
- 주기적으로 고아 레코드를 점검한다.
- 특히 알림은 원본이 삭제되면 정리하거나 무효 표시를 하는 배치가 필요하다.
같은 참조 구조, 다른 설계
둘 다 다형성으로 설계하지만, 그 외의 결정은 다른 방향으로 간다. 스키마를 정하기 전에 다음 항목들을 먼저 결정해야 한다.
| 설명 | 이벤트 로그 | 알림 기능 | |
|---|---|---|---|
| 참조 대상 삭제 시 처리 | 참조 대상이 삭제되면 이 레코드도 삭제해야 하는지 | 남김 | 정책에 따라 다름 |
| 참조 대상 정보 필요 여부 | 참조 대상의 정보를 이 레코드에서 알아야 하는지 | 필요 없음 (이벤트 정보만 남김) | 필요함 (생성 시점 값 / 최신 값 중 선택) |
| 읽기 / 쓰기 비중 | 레코드가 쓰이는 양이 많은지 읽히는 양이 많은지 | 쓰기 중심 | 읽기 중심 |
위의 세 항목이 테이블 설계의 차이를 만든다. 상황 별로 하나씩 정리한다.
이벤트 로그
sqlCREATE TABLE event_log ( id BIGSERIAL PRIMARY KEY, object_type VARCHAR(50) NOT NULL, object_id BIGINT NOT NULL, actor_id BIGINT NOT NULL REFERENCES users(id), event_type VARCHAR(50) NOT NULL, -- created / updated / deleted 등 payload JSONB, -- {"title": ["before", "after"]} created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ix_event_log_object ON event_log (object_type, object_id, created_at DESC);
테이블 예시
| id | object_type | object_id | actor_id | event_type | payload | created_at |
|---|---|---|---|---|---|---|
| 1 | post | 42 | 7 | updated | {"title": ["기존 제목", "수정한 제목"]} | 2026-08-30 10:12:03 |
| 2 | comment | 17 | 12 | deleted | null | 2026-08-30 10:15:41 |
| 3 | post | 108 | 7 | created | null | 2026-08-30 11:02:19 |
- 이벤트가 발생한 대상은
object_type(엔티티 종류)과object_id(해당 엔티티 내 ID)로 표현한다. - 인덱스는 최소한으로 사용한다. 쓰기가 많으므로 인덱스마다 INSERT 비용이 붙기 때문이다.
- 이벤트의 부가 정보는 jsonb로 작성한다. 각 도메인 엔티티는 갖고 있는 항목이 다르기 때문이다.
- 로그는 적재 시점의 내용을 통째로 읽는 것이 보통이므로 jsonb가 관리하기 편하다.
- 도메인 엔티티의 컬럼별로 부가 정보를 행으로 쌓는 EAV 방식도 있지만, 한 번의 이벤트에 여러 필드가 담기면 행 수가 그만큼 늘고 조회 시 다시 하나로 조립해야 한다.
알림 기능
sqlCREATE TABLE notification ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), type VARCHAR(50) NOT NULL, -- comment_created 등 object_type VARCHAR(50) NOT NULL, object_id BIGINT NOT NULL, payload JSONB NOT NULL, -- 표시에 필요한 값의 스냅샷 read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ix_notification_user ON notification (user_id, created_at DESC); CREATE INDEX ix_notification_unread ON notification (user_id) WHERE read_at IS NULL;
테이블 예시
| id | object_type | object_id | user_id | type | payload | read_at | created_at |
|---|---|---|---|---|---|---|---|
| 1 | post | 42 | 7 | comment_created | {"actor": "김철수", "title": "게시글 제목"} | 2026-08-30 09:30:22 | 2026-08-30 09:12:10 |
| 2 | comment | 17 | 7 | comment_replied | {"actor": "이영희", "excerpt": "댓글 내용 일부"} | null | 2026-08-30 10:05:33 |
| 3 | follow | 903 | 12 | follow_created | {"actor": "박민수"} | null | 2026-08-30 10:22:47 |
- 인덱스를 적극적으로 사용한다. 쓰기가 적으니 유지 비용을 감당할 수 있다.
- 표시용 데이터를 payload(jsonb)에 복사한다.
- 목록을 그릴 때 원본 테이블을 조회하지 않아도 되므로 다형성의 조인 문제가 사라진다. 원본이 삭제됐을 때 목록이 깨지는 문제도 함께 완화된다.
- 이런 역정규화를 감수하는 이유는 읽기가 압도적으로 많기 때문이다. 조회 비용을 최소화 하기 위해 참조 테이블을 보는 대신 payload에 알림 레코드가 생길 당시의 정보를 저장해두는 것으로 감수한다.
- 다만 참조 테이블 쪽 원본 데이터가 변경되면 payload와의 격차가 생긴다. 이를 채택한다는 것은,
- 표시되는 정보가 생성 시점 값이어도 된다는 전제 하에 있으며,
- 알림은 '그때 일어난 일'의 기록이므로 대체로 문제가 되지 않는다는 걸 의미한다.
object_type/object_id는 그대로 둔다. 클릭 시 해당 리소스로 이동을 시킬 때 사용할 수 있기 때문이다.
비교
| 이벤트 로그 | 알림 | |
|---|---|---|
| 인덱스 | 최소한으로 | 적극적으로 |
| 정규화 | 유지 | 역정규화 |
| 참조 대상 정보 | 저장하지 않음 | payload에 스냅샷 |
SQLAlchemy에서
SQLAlchemy는 다형성 연관관계를 기본 제공하지 않는다. 문서에서도 관계로 매핑하기보다 명시적으로 다루기를 권하는 편이다.
이번 경우에는 FK를 걸 수 없으므로 relationship() 없이 모델을 작성한다.
pythonclass Notification(Base): __tablename__ = "notification" id = Column(BigInteger, primary_key=True) user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False) type = Column(Enum(NotificationType), nullable=False) object_type = Column(Enum(ObjectType), nullable=False) object_id = Column(BigInteger, nullable=False) payload = Column(JSONB, nullable=False) read_at = Column(DateTime(timezone=True)) created_at = Column(DateTime(timezone=True), server_default=func.now())
object_type을 String 대신 Enum으로 두면 오타와 유령 타입은 막을 수 있다. 값을 추가할 때 마이그레이션이 필요해지는 건 감수한다.
pythonclass ObjectType(enum.Enum): POST = "post" COMMENT = "comment" FOLLOW = "follow"
목록 조회에서 원본 정보가 필요한 경우(payload 복사를 쓰지 않기로 했다면) 도메인 별로 묶어서 조회해야 N+1을 피할 수 있다. 참조 대상이 레코드마다 다르므로 ORM이 관계를 대신 풀어줄 수 없다. 아래 예시처럼 mapper를 직접 선언해야 하며, 이것이 곧 다형성 테이블을 쓸 때의 비용이다.
python# 1. 도메인 enum 값과 모델 간 매핑 MODEL_MAP = { ObjectType.POST: Post, ObjectType.COMMENT: Comment, ObjectType.FOLLOW: Follow, } # 2. 알림 목록 조회 notifications = ( session.query(Notification) .filter(Notification.user_id == user_id) .order_by(Notification.created_at.desc()) .limit(20) .all() ) # 3. 도메인 별로 object_id를 모은다 by_type = defaultdict(list) for n in notifications: by_type[n.object_type].append(n.object_id) # 4. 도메인 수만큼만 쿼리 objects = {} for obj_type, object_ids in by_type.items(): model = MODEL_MAP[obj_type] for row in session.query(model).filter(model.id.in_(object_ids)): objects[(obj_type, row.id)] = row # 5. 알림과 원본을 매칭 for n in notifications: origin = objects.get((n.object_type, n.object_id)) # 삭제됐으면 None
다형성 연관관계, 선택의 대가는 무엇인가
지금까지의 결정은 요구사항과 각 패턴의 특성에서 도출한 것이며, 절대적으로 옳다고 할 수는 없다. 참고로 Bill Karwin은 참조 무결성을 지킬 수 없다는 이유로 이 패턴을 SQL 안티패턴으로 분류하기도 했다.
다형성 연관관계는 스키마 유연성을 얻는 대신 참조 무결성을 어플리케이션이 책임지는 trade-off다. 이 선택이 옳은지는 참조 대상이 늘어나는 요구사항인지, 그리고 무결성 관리를 감당할 수 있는지에 달려 있다.