통합 검색에서 정규식보다 먼저 정해야 할 것
목차
검색 API에서 a.b를 문자 그대로 찾으려는데 입력을 정규식으로 쓰면 axb도 일치한다. 검색 결과를 합친 뒤 권한을 검사하면 다른 사람의 높은 순위 결과가 첫 페이지를 채워 내 자료가 있어도 빈 페이지가 나올 수 있다. 검색 조건과 권한 범위를 먼저 정해야 입력의 뜻과 사용자에게 보이는 결과를 함께 지킬 수 있다.
아래는 개인 메모와 할 일을 함께 찾는 가상 예제다. 제목과 점수는 설명용이며 실제 서비스의 권한 규칙, 자료 구조, 검색 점수나 성능 수치가 아니다. 이 예제의 계약은 “서버가 확인한 사용자에게 보이는 제목 중, 입력 문자열을 문자 그대로 포함하는 결과의 첫 페이지”다.
먼저 실패할 결과를 예측해 본다
a.b로 검색한 후보를 점수순으로 세웠다고 하자. 점수는 이 작은 예제에서만 두 자료원에 공통인 숫자다.
| 자료 | 소유자 | 제목 | 예시 점수 |
|---|---|---|---|
| 메모 | 다른 사용자 | a.b private | 100 |
| 할 일 | 다른 사용자 | a.b private | 90 |
| 메모 | 현재 사용자 | a.b notes | 10 |
| 할 일 | 현재 사용자 | a.b task | 9 |
전체 상위 2개를 먼저 고른 뒤 권한 없는 행을 제거하면 화면에는 0개가 남는다. 그러나 권한을 먼저 적용하면 첫 페이지는 메모와 할 일 각 1개다. 나중에 필터링해도 비밀 본문을 직접 반환하지 않을 수는 있다. 그래도 다른 사람의 자료가 내 검색의 순위·페이지·총수에 영향을 미친다는 점에서 조회 계약이 깨진다. 자동완성, 집계, 강조 문장, 캐시에도 같은 경계를 적용해야 한다.
입력의 의미와 조회 범위를 따로 고정한다
문자 그대로 포함 검색을 선택했다면 Go의 regexp.QuoteMeta는 정규식 메타 문자를 이스케이프한다. 예제의 a.b는 a\.b가 되어 axb를 매칭하지 않는다. Go regexp.QuoteMeta
예제의 핵심 순서는 아래와 같다.
literal, err := regexp.Compile("(?i)" + regexp.QuoteMeta(query))
// 각 자료원에서 후보를 만들 때 서버가 확인한 사용자 범위를 먼저 적용한다.
if row.owner == verifiedUser && literal.MatchString(row.title) {
rows = append(rows, row)
}
// 그 자료원의 정렬과 제한은 이 뒤에 온다.verifiedUser는 요청 본문의 owner가 아니라 인증을 확인한 서버가 전달한 값이어야 한다. 공유 자료라면 실제 읽기 권한을 구성하는 조건을 각 자료원 질의에 포함한다. 메모리 예제에서는 owner == verifiedUser가 전부지만, 이를 범용 권한 규칙으로 복사하면 안 된다. 거부된 자료를 모두 읽고 애플리케이션에서만 지우는 방식은 저장소 조회 비용과 부수 출력까지 통제하지 못한다.
검색 언어를 정말 제공하려는 제품이라면 정규식을 이스케이프하는 대신, 허용 문법과 비용을 명시한 별도 질의 인터페이스가 필요하다. 여기서는 사용자가 입력한 글자 그대로 찾는 계약을 택했다. 64바이트 길이 상한은 예제의 임의 예산이다. 이스케이프는 의미를, 길이 제한은 입력 비용을 다룬다.
MongoDB의 $regex로 같은 기능을 구현할 때는 Go 메모리 예제의 성능을 옮겨 적을 수 없다. MongoDB는 대소문자를 구분하는 단순 접두 정규식에 인덱스 이점이 있을 수 있다고 설명하지만, 대소문자를 구분하지 않는 $regex는 대소문자 무시 인덱스를 활용하지 못한다고 명시한다. 이 글의 (?i) 포함 검색은 접두 검색보다 넓다. 실제 필터 문법·정규식 방언·실행 계획은 쓰는 저장소에서 확인해야 한다. MongoDB $regex 인덱스 설명
여러 자료원에 같은 예산을 무작정 나누지 않는다
자료원마다 독립 조회가 필요하다면 한 요청에 동시 실행 수, 전체 마감 시간, 자료원별 반환 상한을 정한다. 예제는 동시 조회를 최대 2개, 자료원별 후보를 최대 2개, 최종 첫 페이지를 2개로 제한한다. context.WithTimeout은 하위 작업에 마감 신호를 전달하지만, 실제 저장소 호출이 그 컨텍스트를 존중하는지도 확인해야 한다. Go context
자료원 하나가 실패한 결과를 빈 배열로만 합치면 “찾았지만 없음”과 “조회하지 못함”이 같은 화면이 된다. 예제에서 archive는 일부러 실패한다. 성공한 자료의 2개 결과와 failed source: archive를 함께 돌려준다. 이것을 사용자에게 부분 결과로 보여 줄지, 전체 요청을 실패시킬지는 제품 정책이다. 선택한 정책과 무관하게 서버 내부 상태에서는 어느 자료원이 빠졌는지 보존해야 재시도와 장애 조사가 가능하다.
전체 Go 실행 예제를 저장해 go run main.go로 실행하면 다음을 확인한다.
raw a.b matches axb: true
literal a.b matches axb: false
page before authorization: 0 visible hits
authorized first page: n1 t1
failed source: archive실패 모델과 수정 모델은 같은 합성 데이터를 사용한다. 처음 두 줄은 검색 문자열의 의미를, 뒤의 두 결과는 권한을 페이지 제한 전에 적용해야 한다는 사실을 검증한다. 예제의 세마포어는 동시에 시작하는 자료원 조회를 제한하고 결과 배열은 자료원 순서로 모아, 완료 순서가 최종 결과를 바꾸지 않게 한다. 이 코드는 DB 인덱스 사용이나 실제 저장소의 취소 동작을 증명하지 않는다.
점수와 페이지는 합친 뒤 다시 정의한다
예제는 모든 자료원이 같은 척도의 합성 점수를 준다고 가정하고, 동점이면 자료원 이름과 ID로 순서를 고정한다. 실제 검색 엔진 둘의 점수는 그대로 비교 가능하다고 가정하면 안 된다. 한 자료원이 상위를 독식할지, 자료원별 몫을 둘지, 점수를 보정하거나 순위 자체를 합칠지 제품의 검색 목적에 맞춰 정해야 한다. 어느 방식을 고르든 권한 있는 후보를 대상으로 최종 순위를 계산한다.
자료원별 상한 2개는 이 예제의 첫 페이지 2개를 만들기에 충분하다. 이 상한으로 다음 페이지까지 완전하다고 주장할 수는 없다. 다음 페이지에는 각 자료원의 추가 후보나 커서가 필요하고, 합친 결과에 최종 정렬·중복 제거·페이지 경계를 적용해야 한다. MongoDB도 skip()을 쓸 때 고유값을 포함한 정렬을 권하고, 오프셋이 커지면 처음부터 건너뛸 행을 스캔해야 한다고 설명한다. MongoDB cursor.skip()
권한, 검색어, 정렬 방식이 바뀌면 이전 페이지 커서를 그대로 재사용할 수 없다. 페이지 사이에 원본이 바뀌는 경우도 있다. 제품이 “같은 검색의 연속 페이지”를 보장해야 한다면 자료원별 커서와 읽기 시점의 계약까지 정해야 한다. 첫 페이지만 필요한 검색창이라면 그 복잡성을 만들 필요는 없다.
자기 검색을 점검할 때는 같은 제목을 두 사용자에게 넣고 먼저 첫 페이지를 예상해 보자. 입력 a.b가 axb를 잡는지, 권한 없는 고순위 자료가 내 결과를 밀어내는지, 한 자료원이 실패하면 빈 결과처럼 보이는지 각각 다른 실패다. 이 셋을 구분해야 검색 품질, 보안, 가용성을 제대로 고칠 수 있다.