Situation
사용자들이 프로필 화면에서 자신을 표현하는 다양한 요소들을 추가할 수 있도록 하는 기능을 개발해야했습니다..
쉽게 얘기해서 노션에서 사용하는 멀티 태그 같은 것들을 개발한다고 보면 됩니다.
예를 들어, 직장인이라고 하면 (이름, 사원번호, 직급, 부서, 입사일시) 등이 될 수 있고 학생이라면 (이름, 학번, 학과, 입학일시, 거주 기숙사) 등이 될 수 있을 것입니다.
각각의 요소를 필드라고 하며, 해당 필드가 가질 수 있는 값을 태그라고 칭하겠습니다.
필드에는 여러 타입이 있습니다. (boolean, string, number, 단일선택, 다중 선택, 일시 ) 등을 가질 수 있습니다.
각 필드를 지정할 수 있는 사람은 관리자만 지정할 수 있습니다.
Task
이런 커스텀 프로필 기능은 다음과 같은 요구사항을 만족해야합니다.
1.
조직 관리자가 중앙 집중형으로 필드를 관리할 수 있습니다.
a.
조직 관리자가 필드를 생성/수정/삭제 시 해당 조직원들의 필드는 모두 생성/수정/삭제가 됩니다.
2.
조직 관리자의 조직원 대시보드에 컬럼으로 그 값을 볼 수 있습니다.
a.
해당 값으로 필터링,ordering 등이 가능해야합니다.
b.
노션의 데이터베이스가 그 예시입니다.
Action
ERD
우선 ERD는 다음과 같이 구성하였습니다.
erDiagram CustomField ||--o{ CustomFieldTag: relation CustomFieldTag ||--o{ CustomFieldToMember: relation InstituteMember ||--o| CustomFieldToMember: relation CustomField { id primary name string dataType Enum selectType Enum settingType Enum } CustomFieldTag { id primary value string } CustomFieldToMember { instituteMemberId foreign fieldTagId foreign unique instituteMemberIdFieldTagId }
Mermaid
복사
customField
•
커스텀필드를 담는 테이블입니다. 각 조직에서 사용하고 싶은 속성이 그 예시가 됩니다.
•
이를 위한 dataType과 selectType이 Enum으로 관리됩니다.
•
각 조직 내 공간에 붙는 필드인지, 멤버에 붙는 필드인지에 따라 settingType으로 관리할 수 있습니다.
cusotmFieldTag
•
필드의 선택지를 담는 테이블입니다.
•
이 때 선택지가 아닌 타입의 경우, 사실상 선택지의 개수 제한이 없는 타입으로 간주합니다.
•
예를 들어 숫자 타입의 경우 - 정수만 생각하겠습니다. - 1,2,3,4 라는 값으로 볼 수 있을텐데, 이 경우 1,2,3,4 라는 customFieldTag가 생겨나게 됩니다.
•
즉 한 사람이 특정 값을 입력하면 이에 맞는 선택지가 새로 생겨나는 식입니다.
•
이는 선택지가 있는 경우와 데이터 모델을 통일성 있게 맞추기 위함입니다.
customFieldToMember
•
어떤 사람이 어떤 선택지를 선택했는지를 담는 테이블입니다. 선택하지 않았다면 row가 존재하지 않습니다.
구현
구현에서 특히 신경을 썼던 부분은 순서와 관한 부분입니다. 여러 조직 관리자가 동시에 customField의 order를 설정할 수 있는 상황이었으므로 order가 항상 다른 값을 가질 것임을 보장할 수 없었습니다. 따라서 순서를 update하는 경우, pessimistic_write로 순서를 보장합니다.
이 때 해당 트랜잭션이 시작할 때 pessimistic_write를 걸어, 동시에 일어날 수 있는 수정 트랜잭션의 읽기 또한 막습니다.
public async saveCustomField<T extends SettingType>(
instituteId: number,
body: ICustomField.MutateListDto,
type: T,
qr?: QueryRunner,
): Promise<CustomField[]> {
const appended = body.appended
const deleted = body.deleted
const updated = body.updated
const needToUpdateInstituteMemberList = []
let manager: EntityManager
let queryRunner: QueryRunner
if (qr) {
queryRunner = qr
manager = queryRunner.manager
} else {
queryRunner = this.connection.createQueryRunner()
manager = queryRunner.manager
}
try {
await queryRunner.connect()
await queryRunner.startTransaction()
const instituteRepo =
queryRunner.manager.getCustomRepository(InstituteRepository)
const instituteMemberRepo = queryRunner.manager.getCustomRepository(
InstituteMemberRepository,
)
const customFieldRepo = queryRunner.manager.getCustomRepository(
CustomFieldRepository,
)
const institute = await instituteRepo.findOne({ id: instituteId })
/**
* 0. validation
* dto를 순회하면서 validator로 검사합니다.
*/
const validator = new CustomFieldMutateDtoValidator()
...
...
/**
* 삭제 -> 생성 -> 업데이트 순서로 진행
* 해당 save 프로세스 진행 전 pessimistic_write를 설정함.
* mysql 특성 상 phantom read 발생하지 않음
*/
const customFieldsBeforeDelete = await customFieldRepo.find({
where: {
instituteId: instituteId,
settingType: type,
},
lock: {
mode: 'pessimistic_write',
},
})
TypeScript
복사
Result
•
기본 프로필이긴 하지만 위와 같은 프로필에서 항목을 계속 추가할 수 있습니다.