← Back to all posts

Foreign Key 쓰지말라고요?

databaseforeign_keylarge-scale

Note : Ruby on Rails 7, Ruby 3.4.1, MySQL 8

Foreign key를 이용하면 어떤 점이 좋을까요? 애초에 만들어진 데는 이유가 있을텐데, 왜 사용하지 말라고 하는걸까요? 서비스에서 어떻게 쓰이는지 살펴보며 그 이유를 생각해보겠습니다.

Entities

하나의 call에 여러 명의 call participant가 있습니다. 각 테이블 명은 calls, call_participants 입니다. call participant는 call_id라는 column을 foreign key로 가지고 있습니다.

Separating FK constraints from ORM features

보통 어플리케이션에서는 DB에 직접 접근하지 않고 ORM을 이용합니다. ORM 이용시 어떻게 쿼리가 작동하는지를 살펴보겠습니다. 그 과정에서 외래키 제약이 어떤 역할을 하는지도 같이 생각해보겠습니다.

먼저 DB 수준뿐만 아니라 어플리케이션 레벨에서도 관계를 정의해 활용합니다. Ruby on Rails의 ORM인 ActiveRecord를 예로 들겠습니다. 아래 코드는 DB level의 외래키 제약과는 별개로 ORM 수준에서의 관계 정의입니다. FK를 사용하지 않는다는 것은, 아래와 같은 ORM relationship은 그대로 둔 채 DB level에서만 외래키 제약을 제거한다는 뜻입니다.

class Call < ActiveRecord::Base
    has_many :call_participants, dependent: :destroy
end

class CallParticipant < ActiveRecord::Base
    belongs_to :call
end

Read

call_participant.call 을 호출해보겠습니다. ORM은 call_particpant가 call_id column을 이용해 calls table의 id column에 연결되었다는 사실을 알고 있습니다. (belongs_to 에 많은 기본값들이 생략되어있습니다.) 이를 이용해 SELECT * FROM calls WHERE id = call_id와 같은 쿼리를 요청합니다.

Insert

새로운 call participant를 생성하겠습니다. Application memory에 있는 call_participant를 DB에 쓰기 전, 기본 설정에서는 ActiveRecord가 call_id를 calls table에 조회(SELECT)해서 실제 해당 call이 존재하는지 먼저 확인합니다. 존재하지 않는다면 application level에서 에러를 일으키고, 존재한다면 그제서야 INSERT query를 요청합니다. DB 에서는 새로운 call participant를 생성하기 전에 외래키 제약을 통해 연결된 call이 있는지 다시 한 번 확인합니다. Update도 비슷한 메커니즘으로 동작하기 때문에 생략하겠습니다.

Delete

call을 삭제해보겠습니다. ActiveRecord에서 call 삭제에 앞서, 자체적으로 연결된 call participants를 삭제합니다. DB의 FK 자체에도 cascade 옵션이 있지만 Rails에서는 대체로 ORM에 그 역할을 맡깁니다.

사실 대부분의 로직은 FK 제약없이도 ORM이 잘 처리해주는 것으로 보입니다. 그렇다면 Foreign key 제약은 대체 어디에서 무엇을 하는걸까요?

What does FK constraint do?

Race condition

ORM의 unhappy path 중 하나인 race condition을 보강합니다. 앞서 이야기했듯, call participant 생성 과정에서 DB에 생성하기 이전에, ORM이 SELECT query로 call 존재 여부를 확인합니다. 하지만 이 사이에 DB에서 referenced call이 삭제 될 수 있습니다. 이런 경우에 foreign key constraint에 의해 integrity가 보장됩니다.

Bypassing the application layer

Application layer를 거치지 않고 DB에 직접 연결하는 경우, 혹은 여러 개의 다른 서비스가 DB를 사용하는 경우의 ORM 작동을 기대할 수 없습니다. 이런 경우 DB level의 constraint가 작동합니다.

Large-scale systems avoid FK

하지만 대규모 시스템에서 외래키 사용에는 주의가 필요합니다. 어떤 경우에 쓰면 안되는지, 혹은 언제 쓰면 문제가 되는지 알아보겠습니다.

Online schema migration

첫번째 이유는 pt-osc, gh-ost와 같은 대규모 데이터 스키마 변경 솔루션 때문입니다. 이 솔루션들을 이용하지 않으면, 특정 테이블 스키마를 업데이트하는데에 상당시간이 소요되고 쓰기락으로 인해 서비스 중단을 야기합니다. 그래서 이 솔루션들은 기존 테이블을 복사하고 업데이트를 끝마친 뒤, 원래 테이블을 대체하는 방식으로 스키마를 업데이트합니다. 이 과정에서 FK 제약은 문제를 일으킵니다. Call의 schema 업데이트가 있다고 해보겠습니다. calls_new table을 calls table로부터 생성합니다. calls_new table이 완성되면, RENAME calls TO calls_old, calls_new TO calls;와 같은 쿼리를 사용해 원자적으로 테이블 이름을 바꾸어줍니다. 이때, RENAME의 작동 방식에 의해 call participants의 call_id는 원래 테이블인 calls_old를 참조하게 됩니다. 새로운 테이블을 참조하게 하는 것은 또다른 schema update이기 때문에 솔루션을 사용하지 않는 것과 다를 바가 없습니다. 이와 같은 이유때문에 대규모 시스템에서 외래키 사용이 어렵습니다. 참고로, pt-osc는 특정 조건 하에 FK 사용을 지원하고, gh-ost는 아예 허용하지 않습니다.

Sharding

두번째 이유는 Database sharding 입니다. FK는 DB 단일 인스턴스에서만 지원하는 기능입니다. 대용량 데이터베이스 환경에서는 성능 등의 이유로, 서로 다른 두 테이블(참조 - 피참조 테이블) 혹은 하나의 테이블을 여러 인스턴스에 나눠서 저장할 수 있습니다. 이런 경우 FK 사용이 불가합니다.

Parent row lock contention

상기 이유들은 기능적으로 FK 사용이 어려운 이유이지만, 성능의 제약도 있습니다. referencing table의 data row를 새롭게 생성할때 referenced data row에는 shared lock이 걸립니다. 우리 예시에서 많은 양의 call_participant가 계속해서 쓰인다면, call의 data update의 성능이 크게 저하됩니다. integrity를 지켜주기 위한 lock이, 대규모 환경에서는 경합의 원인이 됩니다.

How large-scale systems replace FK

대규모 시스템에서 외래키를 사용하기 어렵기에 Application layer에 제약에 대한 관리를 위임합니다. 위에서 언급한 FK의 역할은 아래와 같이 대체합니다.

Race condition

이 부분은 FK를 포기함으로써 잃는 trade-off 이고 완벽한 대체가 어렵습니다. business logic 상 피참조 데이터 삭제를 지양하거나, 참조 데이터가 삭제된 케이스에 대한 처리를 강화함으로써 문제를 우회할 수 있습니다. 예를 들면, orphaned data row에 대한 삭제를 cron job으로 주기적으로 시행해줄 수 있습니다(reconciliation).

Bypassing the application layer

특정 서비스에 DB에 대한 exclusive ownership을 제공하여 이 문제를 해결할 수 있습니다. 다른 서비스들은 주인 서비스를 거치지 않고서는 DB에 접근할 수 없기에, 주인 서비스가 가지고 있는 application level constraints가 보장됩니다.

Conclusion : Use FK constraints until you have the issues

처음 시작하는 소규모 서비스에서 외래키 제약을 사용하는 것이 훨씬 안전합니다. 하지만 대규모 데이터에 대한 Online schema migration이 빈번하게 필요해지거나 성능 문제에 맞닥뜨리는 경우에, 전체에서 혹은 특정 테이블들에서 FK 제약을 제거하면 됩니다. ORM 수준에서 관계를 엄밀히 설정하고, 기존 FK에 대한 index도 유지하면 대규모 환경에서 무결성의 일부를 trade-off로 내주는 대신 참조 관계를 실용적으로 운용할 수 있습니다.