이전 1편에서는 각 모델들의 역할과 핵심 설계 근거들을 설명하였다. 2편에서는 공개용 대체 키를 사용하기로 한 이유, novel 테이블에 last_episode_at 같은 비정규화 컬럼을 둔 이유 등 성능과 기술적인 이야기들을 해보려고 한다.

ERD 는 아래와 같다.

novel-service.png

공개용 대체 키 사용

ERD 에서 회원, 소설, 회차 테이블에는 public_id 라는 대체 키가 존재한다. 이 대체 키는 외부에서 리소스를 단건 조회할 때 PK 를 대체하는 식별자로 사용된다.

이전에 공개용 대체 키에 대해 고민하는 글을 썼었는데, 그때는 일단 PK 를 사용하는 쪽으로 결정했었다. 하지만 이번에 재설계를 하며 다시 그 부분에 대해 고민하였고, 결론적으로 처음부터 public id 를 사용하기로 결정하였다.

이제부터 자세한 근거와 트레이드 오프들을 설명해보겠다.

비용

첫번째는 비용이다. 일단 공개용 대체 키를 구현하는 비용은 굉장히 작다. 딱 한번만 아래와 같은 클래스를 만들면 된다.

@MappedSuperclass
@Getter
public abstract class PublicEntity extends DateEntity {

    @Column(unique = true, nullable = false, updatable = false)
    private String publicId;
    
		@PrePersist
		protected void init() {
			// 예시로 UUID를 사용하였다.
			// 실제로는 nano id 를 사용하기로 하였다. 이유는 이 글의 마지막에서 설명한다.
			publicId = UUID.randomUUID().toString();
		}
}

이 클래스만 만들어두면 아래처럼 상속을 통해 바로 공개용 대체 키를 가지는 엔티티를 만들 수 있다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class TestEntity extends PublicEntity {
	
	@Id
  @GeneratedValue
  private Long id;
}

또 Spring Data JPA 리포지토리도 아래처럼 미리 공통 인터페이스를 만들어두면 매번 반복적인 메서드를 직접 정의할 필요도 없다.

@NoRepositoryBean
public interface PublicEntityRepository<T, ID> extends JpaRepository<T, ID> {

    Optional<T> findByPublicId(String publicId);
    Optional<IdResult> findIdByPublicId(String publicId);
}

public interface IdResult {

    Long getId();
}

이런 이유로 애플리케이션 입장에서 구현 자체는 간단하고 빠르게 할 수 있다.

하지만 DB 입장에서는 조금 신경써야 할 부분들이 있다.

첫번째로 추가 컬럼이다. 당연히 공개용 대체 키를 저장할 추가적인 컬럼이 필요하다. ERD에서 나와있듯 public_id 컬럼을 추가하고 VARCHAR(25) 타입으로 nano id 의 길이인 21자보다는 살짝 더 넉넉하게 최대 크기를 지정하였다. 이 컬럼을 외부에 공개되는 엔티티들 마다 하나씩 추가해야 하므로 약 21~24 byte 정도의 저장공간이 더 필요하다.

하지만 사실 이 정도는 요즘 하드웨어 기준으로 무시할 수 있는 수준이다. 어짜피 PK 도 아니라서 다른 파생 테이블에 이 컬럼이 전파되지도 않고 딱 그 엔티티에만 존재하므로 비용이 크지 않다. 물론 이런 대체 키를 PK 로 사용하게 된다면 조금 더 저장공간과 순차성을 신경써야 할 것이다.

두번째는 인덱스이다. 외부에서는 이 대체 키만을 사용해 단건 조회를 하게 되므로 full scan 이 일어나게 할수는 없다. 또 대체 키의 역할을 보장하기 위해 UNIQUE 제약 조건을 거는 경우도 많으므로 보통 공개용 대체 키에 대한 인덱스는 필연적으로 생성된다. 그래서 그만큼 추가적인 저장 공간이 또 필요하게 된다.