Supabase 쿼리 전달 구조 개선하기 ‐ 최진원 - Team-HGD/SniffMEET GitHub Wiki

작성자: 최진원

작업 내역

백로그

기본 구조

SNMNetwork의 동작을 간단하게 설명하면, SNMRequestConvertible을 컨펌하는 객체의 정보로 네트워크 요청을 생성하고, 통신한 다음, 응답을 반환하는 방식으로 동작한다.

따라서 각 Supabase Manager는 (Auth, Session, DB, Storage 모두) SNMRequestConvertible을 도입한 enum 타입의 요청을 생성하고, SNMNetworkProvider에 넘겨주는 방식으로 구현되어 있다.

기존 Supabase가 통신하는 방법 (fetchData 예제)

spdbfd

이 구조 자체는 변하지 않지만, DBManager에서 DBRequest를 만드는 부분이 수정되었다.

  • 유즈케이스가 fetchData(from:query:) 호출
struct RequestMateInfoUsecaseImpl: RequestMateInfoUseCase {
		...
    func execute(mateId: UUID) async throws -> UserInfoDTO? {
        let mateInfoData = try await remoteDBManager.fetchData(
            from: "user_info",
            query: ["id": "eq.\(mateId.uuidString)"]
        )
        ...
    }
}
  • SupabaseDBManager에서 SupabaseDBRequest 생성 및 초기화
extension SupabaseDBRequest: SNMRequestConvertible {
    var endpoint: Endpoint {
        switch self {
        case .fetchData(let table, _, let query):
            return Endpoint(
                baseURL: SupabaseConfig.baseURL,
                path: "rest/v1/\(table)",
                method: .get,
                query: query
            )
        ...
        }
    }
    var requestType: SNMRequestType {
        switch self {
        case .fetchData(_, let accessToken, _):
            return .header(with: createAuthHeader(accessToken: accessToken))
        ...
    }
}
  • 생성된 SupabaseDBRequestSNMNetworkProviderrequest(with:) 호출
func fetchData(from table: String, query: [String: String]) async throws -> Data {
    do {
        ...
        let response = try await networkProvider.request(
            with: SupabaseDBRequest.fetchData(
                table: table,
                accessToken: session.accessToken,
                query: query
            )
        )
        return response.data
    } catch {
        throw SupabaseDBError.fetchDataFailed
    }
}
  • 응답 결과 리턴

쿼리 문제

이 과정에서 쿼리는 Dictionary<String, String> 꼴로 유즈케이스에서 작성되어, NetworkProvider로 그대로 전달된다.

회의 과정에서 이런 방식으로 사용하면 NetworkProvider를 그대로 쓰는거랑 크게 다를바가 없다는 이야기가 나와서 Supabase Layer에서 자체적으로 쿼리를 구성하는 방법을 도입하려고 했다.

SDK에선?

SDK에선 SupabaseClient가 전체적인 Supabase 관련 동작을 관리하고, 메소드 체이닝을 통해 요청을 생성한다.

  1. 클라이언트 객체
  2. 쿼리 빌더(SQL DML 구성)객체 호출
  3. 필터 빌더(요청 쿼리 구성) 객체 호출
  4. 트랜스폼 빌더(JOIN 등 수행)
  5. execute로 통신 수행

PostgrestQueryBuilder (이외에도 빌더 클래스 더 있음):

public final class PostgrestQueryBuilder: PostgrestBuilder, @unchecked Sendable {
  ...
  public func select(...) -> PostgrestFilterBuilder {
    ...
    return PostgrestFilterBuilder(self)
  }
  public func insert(...) throws -> PostgrestFilterBuilder {
    ...
    return PostgrestFilterBuilder(self)
  }
  public func update(...) throws -> PostgrestFilterBuilder {
		...
    return PostgrestFilterBuilder(self)
  }
  ...
}

호출:

try await supabase // SupabaseClient
  .from("profiles") // PostgrestQueryBuilder
  .select() // PostgrestFilterBuilder
  .eq("id", value: currentUser.id) // PostgrestFilterBuilder
  .single() // PostgrestTransformBuilder
  .execute() // PostgrestResponse
  .value // PostgrestResponse

개선 사항

요청 구성 개선하기 (DBManager 기준)

위에서 말한 것 처럼 현재 쿼리를 구성하기 위해선 직접 스트링으로 모든 쿼리를 만들어서 딕셔너리로 전달해줘야 한다.

SDK의 빌더를 사용한 메소드 체이닝 컨셉을 조금 가져와서 다음 순서로 개선해보자.

  1. Config, AuthManager, SessionManager, DBManager, StorageManager 구조는 유지한다.
  2. DBManager가 수행해야 할 작업을 세분화 한다.
    • fetch (SQL: SELECT, HTTP: GET)
    • insert (SQL: INSERT, HTTP: POST)
    • update (SQL: UPDATE, HTTP: PATCH)
    • rpc (SQL: - , HTTP: POST)
  3. 각 작업들이 필요로 하는 부분을 각각 구분한다.
    • 공용: path, method, accessToken, table
      • 패스 지정(기본 경로 + API 경로 + 테이블 경로) → 메소드 선택 → 헤더에 토큰 추가
    • fetch: query
      • 기본 작업 → 쿼리 추가(옵션)
    • insert: body
      • 기본 작업 → 바디 추가
    • update: body, query
      • 기본 작업 → 바디 추가 → 쿼리 추가
    • rpc: body, query
      • 기본 작업 → 바디 추가 → 쿼리 추가
  4. 이 부분에서 SupabaseRequest가 자동적으로 구성해주는 부분을 제외한다.
    • 테이블을 제외한 패스 지정
    • 메소드 선택
    • 헤더는 추가하지만, 액세스 토큰은 넘겨줘야 함
  5. 필요한 빌더 메소드와 각 빌더들이 어떻게 연결되어야 할지 결정한다.
    • 경로 구성하는 메소드
    • 바디 추가하는 메소드
    • 쿼리 추가하는 메소드
    • SNMResquestConvertible을 만드는 메소드 (request)
      • 액세스 토큰을 여기서 넘겨줘야 한다.
    • 마지막으로 통신을 수행하는 메소드
  • 생각해봐야 할 점
    • 요청이 다 구성되지 않았는데 request를 실행하면?

빌더 구현 후 호출 변경사항

// 이전
try await remoteDBManager.updateData(
    in: Environment.SupabaseTableName.userInfo,
    at: id,
    with: userData
)

// 이후
try await remoteDBManager.updateData()
    .setTable(Environment.SupabaseTableName.userInfo)
    .setBody(userData)
    .setQuery(key: "id", value: "eq.\(id)")
    .request()

SupabaseDBManager가 현재 프로젝트에 fit 하게 만들어진 부분은 개선할 수 있지만 (ex: updateData 부분에서 id를 요구하는 부분)

쿼리가 아직까진 String으로 넘어가는 상황이어서 자주 쓰는 쿼리를 래핑해야 할 필요성이 있다. 특히 오퍼레이터를 사용할 때, 리터럴로 오퍼레이터를 작성해야 하는 부분을 개선할 수 있다.

쿼리 파라미터 래핑하기

enum SupabaseQueryParameter {
    case equal(String, SupabaseQueryRepresentable)
    ...
    case custom(String, SupabaseQueryRepresentable)
    
    var key: String { ... }
    
    var value: String {
    switch self {
    case .equal(_, let value):
        return "eq." + value.queryValue
    ...
    case .custom(_, let value):
        return value.queryValue
    }
}

SupabaseQueryParameter enum을 만들어서 쿼리 오퍼레이터를 쉽게 만들 수 있도록 했다.

SupabaseQueryRepresentableInt, Bool, Double, UUID 등 다양한 타입들을 String으로 변환해주는 역할을 한다.

이제 다음과 같이 쿼리를 구성할 수 있다.

// 이전
try await remoteDBManager.updateData()
    .setTable(Environment.SupabaseTableName.userInfo)
    .setBody(userData)
    .setQuery(key: "id", value: "eq.\(id)")
    .request()

// 이후
try await remoteDBManager.updateData()
    .setTable(Environment.SupabaseTableName.userInfo)
    .setBody(userData)
    .setQuery(.equal("id", id))
    .request()

개선사항

인사이트

더 생각해볼만한 부분

  • fetch(select), insert, update 등 SQL 기반의 메소드보다, HTTP 메소드를 기준으로 만드는 것이 더 나을수도 있다.
    • 왜냐하면, fetchDatarpc의 경우 구조가 거의 동일한데, 이를 SQL 기반으로 나누니 요청을 따로 나눠야 했다.

추가 사항

프로토콜 conform(준수)과 adopt(도입)의 차이

  • conform은 특정 타입이 프로토콜을 모든 요구사항을 준수하도록 구현했다는 의미
  • adopt는 특정 타입에 프로토콜을 도입, 채택하겠다는 의미

→ 타입이 프로토콜을 adopt 하려면 conform 해야 함

레퍼런스