Supabase Session 구조 개선하기 ‐ 최진원 - Team-HGD/SniffMEET GitHub Wiki

작성자: 최진원

작업 내역

백로그

SessionManager 사용성 개선

세션을 갱신하는 방법

Supabase 서버와의 통신이 필요할 때, 우선 세션의 유효성을 검증해야 합니다.

기존 방식에선 직접 AuthManager의 싱글톤 객체에서 세션을 갱신하는 작업을 했지만

  • AuthManager와 SessionManager의 역할 명확하게 분리
  • AuthManager의 싱글톤 해제

와 같은 변화점이 있었고, 기존 AuthManager가 담당하던 세션 갱신, 복원 작업이 SessionManager로 옮겨졌습니다. SessionManager의 싱글톤 객체에서 세션의 유효성을 체크하는 컴퓨티드 프로퍼티의 부울리언 값을 체크하여 세션을 갱신하는 메소드를 호출하는 방식을 사용했습니다:

if try SessionManager.shared.isExpired {
    try await SessionManager.shared.refreshSession()
}

이 방식은 SessionManager의 싱글톤 객체 호출이 두 번 일어나고, refreshSession이라는 메소드를 직접 노출해서, SessionManager가 세션을 갱신하는 방법을 직접 노출하는 문제가 있습니다.

refreshSession메소드는 세션을 갱신하는 메소드이지만, 외부에서 직접적으로 세션을 갱신을 요청할 필요는 없습니다. 즉 외부에서

요청 전에 세션이 만료 되었는지 확인 → 만료 되었으면 세션 갱신을 요청한다.

이런 로직을 직접 처리하는 것 보다는,

요청 전에 세션을 유효하게 만든다.

이런 방식으로 처리하는 게 더 캡슐화 원칙을 잘 지키면서, 자동으로 세션이 유지되도록 할 수 있습니다. refreshSession() 메소드를 private으로 처리함으로써, 세션 갱신 로직을 SessionManager 내부에서만 관리할 수 있도록 변경했습니다.

func checkSession() async throws {
    guard let session else { throw SupabaseSessionError.sessionNotExist }
    if Date(timeIntervalSince1970: TimeInterval(session.expiresAt + 30)) < Date() {
        try await refreshSession()
    }
}

따라서 개선된 방식에선 Supabase 요청 전에 다음과 같이 세션을 체크하면 자동으로 갱신이 됩니다:

try await sessionManager.checkSession()

세션 정보를 불러오는 방법

기존 방식에서는 userIDaccessToken과 같은 세션 정보를 불러올때, 직접 SessionManager의 싱글톤 객체에 접근한 다음 session에서 필요한 프로퍼티 값을 가져와서 옵셔널 바인딩을 하는 방식을 이용했습니다.

guard let accessToken = SessionManager.shared.session?.accessToken else {
    throw SupabaseSessionError.sessionNotExist
}

디미터 법칙의 관점에서 봤을 때, 이 방법은 체인 호출이 매우 깊어져 좋은 방법이 아니라고 생각했으며, 프로퍼티가 실제로 존재하지 않을 때(nil일 때) 에러를 직접 발생시켜야 하는 문제가 있었습니다.

그래서 세션에서 가장 많이 쓰는 정보인 accessTokenuserID를 직접 접근할 수 있도록 따로 분리했습니다:

protocol SessionManageable {
    var userID: Result<UUID, SupabaseSessionError> { get }
    var accessToken: Result<String, SupabaseSessionError> { get }
    ...
}

따로 꺼내서 접근할 수 있도록 한 userIDaccessToken 프로퍼티를 옵셔널 바인딩과 직접 없이 직접 사용할 수 있도록 Result 타입의 객체로 선언했습니다.

세션에서 정보를 호출할 때는 get() 메소드를 try와 함께 써서 호출할 수 있습니다.

let accessToken = try sessionManager.accessToken.get()

세션 정보를 필요로 하는 메소드 대부분이 throws메소드라 try를 사용하면 옵셔널 바인딩이 필요 없고, 직접 에러를 발생시키지 않고 세션 정보를 호출하여 사용할 수 있습니다.

생각해보기

Interactor에서 직접 매니저 호출하지 않기

매니저가 변하면 인터랙터를 직접 변경해야합니다. 매니저와 인터랙터 간의 의존성을 줄이고, 인터랙터는 “비즈니스 로직”만, 그리고 비즈니스 로직의 구현체인 유즈케이스에서 네트워크 요청이나 데이터 관리와 같은 “인프라 로직”을 담당하는 것이 좋다고 생각합니다.

또한 싱글톤 객체여도 현재 주입하는 방식으로 사용중인데, 인터랙터에서 사용하면 직접 매니저의 싱글톤 객체를 호출해서 사용해야 합니다:

func sendWalkRequest(message: String, latitude: Double, longtitude: Double, location: String) {
    Task {
        do {
            let myInfo = try loadUserInfoUseCase.execute()
            let id = try SessionManager.shared.userID.get()
            ...
            try await requestWalkUseCase.execute(walkNoti: walkNoti)
        } catch {
           ...
        }
    }
    presenter?.didSendWalkRequest()
}

위 코드에서 id를 얻기 위해 SessionManager의 싱글톤 객체를 호출해야 합니다. 싱글톤 객체 호출 자체는 크게 문제가 되지 않을 수 있지만, 다음과 같이 생각해볼 주제가 있습니다.

위 코드의 전체 버전을 보면:

func sendWalkRequest(message: String, latitude: Double, longtitude: Double, location: String) {
    Task {
        do {
            let myInfo = try loadUserInfoUseCase.execute()
            let id = try SessionManager.shared.userID.get()
            let walkNoti = WalkNotiDTO(
                id: UUID(),
                createdAt: Date().convertDateToISO8601String(),
                message: message,
                latitude: latitude,
                longtitude: longtitude,
                senderId: id,
                receiverId: mate.userID,
                senderName: myInfo.name,
                category: .walkRequest
            )
            try await requestWalkUseCase.execute(walkNoti: walkNoti)
        } catch {
            // TODO: 이 부분은 Mapper를 통해 정리할 수 있을 것 같습니다.
            SNMLogger.error("RequestWalkInteractor: \(error.localizedDescription)")
        }
    }
    presenter?.didSendWalkRequest()
}

인터랙터에서 직접 엔티티를 생성해서 유즈케이스에 전달하는 형태로 코드가 동작합니다. 엔티티를 생성하는 것 자체를 팩토리로 감싼 다음 또 다른 유즈케이스로 감싸서 호출하는 방식으로 고치는 것이 아니라. 엔티티 생성과 같은 작업도 해당 엔티티가 필요한 유즈케이스에서 동작하는 것이 좋다고 생각합니다.

특히 유저 ID와 같은 정보를 SessionManager에서 가져오는 작업을 프로젝트 특성상 많이 하게 되는데, 이 작업을 유즈 케이스 내부에서 처리하는 것을 제안합니다.

또한 위 코드와 같은 상황에서는 DTO → 엔티티 상황이 아니라 엔티티 → DTO 상황이기 때문에, 기존에 서로 호환되는 엔티티가 존재하지 않는 상황(사용하고 있지 않는 상황)이라면, 굳이 엔티티를 생성한 다음 이를 DTO로 변환하는 것이 아닌 처음부터 DTO로 만드는 것도 제안하고 싶습니다.

이런 상황을 정리하기 위해 Mapper 클래스의 도입도 필요할 것 같다고 생각합니다.

정리

  • 인터랙터 말고 유즈케이스 내부에서 필요한 작업을 모두 처리하자
    • id 불러오기, 엔티티 생성과 같은 작업들
    • 유즈케이스까지 SRP를 엄격하게 적용할 필요는 없다고 생각하기 때문에…
  • Mapper 클래스가 도입되면 좋을 것 같다.
    • 엔티티 ↔ DTO 변환을 더 깔끔하고 재사용성 좋게 처리할 수 있을것으로 기대된다.

인사이트

프로토콜 메소드의 어트리뷰트 동작

RemoteDBRequestBuildable이라는 프로토콜을 생성하면서 request()메소드가 콘크리트 타입에서 가져오는 것이 아닌 프로토콜을 통해 디스패치하는 것으로 바뀌었습니다.

코드를 살펴보니 기존 메소드 구현에서는 @discardableResult 어트리뷰트가 적용되어 있지만, 프로토콜에는 적용되지 않은 상태였습니다:


protocol RemoteDBRequestBuildable {
		...
    func request() async throws -> Data
}

final class SupabaseDBRequestBuilder: RemoteDBRequestBuildable {
    @discardableResult
    func request() async throws -> Data {
		    ...
    }
}

실제 사용되는 request()메소드는 해당 어트리뷰트가 적용되어 있는데, 경고가 떠서 알아보니, 프로토콜에는 적용되어있지 않기 때문입니다.

→ 어트리뷰트는 함수 구현의 일부가 아닌, 함수 시그니처의 일부분 입니다.

→ 프로토콜을 통해 디스패치하는 메소드는 시그니처는 콘크리트 타입이 아닌 프로토콜을 따라갑니다.

→ 대부분의 경우에는 프로토콜 메소드에 대응하는 콘크리트 타입 메소드의 시그니처가 완전히 동일하기 때문에(혹은 그렇게 작성하기 때문에…) 이제야 알게 되었습니다.

추가 사항

레퍼런스