SniffMEET. DTO와 Entity의 경계 점검하기

들어가며 SniffMeet에 기능이 붙으면서 DTO와 Entity가 빠르게 늘어났다. 처음에는 필요한 타입을 그때그때 만들면 됐지만, 어느 순간부터 어떤 타입이 어느 레이어에서 쓰이는지 헷갈리기 시작했다. 특히 UserInfoDTO, UserInfo, DogInfo, Mate처럼 비슷한 정보를 담는 타입들이 여러 곳에서 서로 다른 이름으로 사용되고 있었다. 그래서 바로 코드를 고치기보다, 먼저 현재 타입들이 어디에서 쓰이고 있는지 정리해보기로 했다. 먼저 한 일: 사용처 조사 가장 먼저 DTO, Entity, Supabase 관련 Request/Response 타입을 나눠서 사용처를 훑어봤다. 전체 목록을 보면 타입 수가 많은 것도 문제였지만, 더 큰 문제는 몇몇 타입의 사용처가 너무 넓다는 점이었다. ...

January 11, 2025

Data Structure. Hash Table

소개 키, 값 쌍으로 데이터를 저장하는 자료구조. 데이터 삽입, 검색, 삭제가 빠르다. (스위프트에서 사용하는 딕셔너리도 해시 테이블이다.) 해시 테이블은 키를 배열의 인덱스로 직접 사용하는 대신, 해시 함수를 통해 키를 일정한 범위의 해시 값으로 변환한다. 이 덕분에 배열 전체를 순회하지 않고도 데이터가 저장된 위치에 빠르게 접근할 수 있다. 구조 해시 테이블은 배열과 해시 함수로 구성된다. 배열: 데이터를 저장할 배열, 배열의 인덱스는 해시 값을 뜻함 해시 함수: 키를 해시 값으로 변환하는 함수 키를 해시 함수에 넣어서 계산된 해시 값을 기반으로, 배열의 인덱스에 값을 저장한다. ...

January 11, 2025

SniffMEET. 비동기 태스크와 액터 점검하기 (2)

메이트 요청 didTabAcceptButton과 saveMateInfo를 따라가 보니 유스케이스 실행을 위해 Task가 존재했고, Combine의 sink 클로저 안에서도 Task를 생성하고 있었다. Interactor에서 태스크를 묶으면 저장 완료를 알리는 메서드가 따로 필요해 보였다. Presenter 메서드는 async로 바꾸고, View의 sink에서는 Task를 통해 호출하도록 두었다. acceptButton.publisher(event: .touchUpInside) .sink { [weak self] in Task { await self?.presenter?.didTapAcceptButton(id: self?.profile.id ?? DogProfileDTO.example.id) self?.presenter?.closeTheView() } } .store(in: &cancellables) // RequestMatePresenter func closeTheView() { if let view { router?.dismissView(view: view) } } func didTapAcceptButton(id: UUID) async { SNMLogger.info("id: \(id)") await interactor?.saveMateInfo(id: id) } 산책 요청 및 응답 RespondWalkPresenter 산책 요청은 Task로 Interactor에 존재했고, 위치 변환은 Presenter에서 await로 처리했다. ...

January 8, 2025

SniffMEET. 비동기 태스크와 액터 점검하기 (1)

작업 내역 이번 작업에서는 Task, async/await, @MainActor가 섞여 있던 부분을 화면별로 점검했다. 앱 시작 흐름부터 프로필 등록, 메이트 목록, 산책 응답까지 비동기 경계를 다시 살펴보고, 각 화면에서 책임이 어디에 있어야 하는지 정리했다. 앱 시작 플로우 UIWindow의 루트 뷰 컨트롤러를 결정하는 흐름은 @MainActor로 두고, 그 안에서 세션 복원이 끝나기를 기다렸다. await로 대기하는 동안에는 메인 액터를 점유하지 않는다. displayInitialScreen이 MainActor 안에서 실행되는지 breakpoint로 확인했고, displayOnBoardingView는 그대로 MainActor에 남겨 두었다. // AppRouter.swift func displayInitialScreen() { Task { @MainActor in do { try await SupabaseAuthManager.shared.restoreSession() displayTabBar() } catch { displayOnBoardingView() } } } 프로필 입력 및 등록 프로필 입력 부분은 전달만 하는 역할이라 비동기 처리가 필요하지 않았다. ...

January 8, 2025

SniffMEET. Instruments로 성능 개선 후보 찾기

들어가며 성능 개선이 필요해 보이는 지점을 감으로 고르지 않기 위해, 먼저 SniffMeet의 주요 흐름에서 CPU와 네트워크 사용량을 훑어봤다. 이 측정만으로 병목 원인을 확정할 수는 없다. 다만 어떤 화면과 기능을 먼저 의심해야 하는지, 이후 리팩토링 후보를 정하는 기준으로는 충분했다. CPU 사용량 측정 Instruments의 Time Profiler로 주요 사용자 흐름을 따라가며 CPU 사용량이 튀는 구간을 확인했다. 측정 중 비교적 사용량이 높았던 지점은 다음과 같았다. 시점 동작 관찰 0:26 프로필 입력 뷰 로드 CPU 70% 0:53 텍스트 필드 입력 CPU 70% 1:44 사진, 닉네임 입력 뷰 로드 CPU 70% 2:08 포토피커 CPU 90% 3:00 등록 완료 버튼 터치 CPU 100%, 약 0.7초 유지 3:28 메이트 리스트 뷰 로드 CPU 50% 3:41 산책 요청 보내기 터치 CPU 80% 4:10 지도 로드 CPU 95% 5:05 요청 보내기 CPU 100%, 약 0.2초 6:36 MPC 연결 CPU 100%, 약 1.2초 특히 회원가입 완료 버튼을 눌렀을 때와 MPC 연결 시점이 눈에 띄었다. 회원가입 완료 시점에는 메인 스레드에서만 작업이 몰리는 것처럼 보였고, MPC 연결 시점에는 MultipeerConnectivity 관련 스레드가 지속적으로 동작했다. ...

January 7, 2025

Algorithm. LCS(Subsequence)

소개 두 문자열이 있을 때, 가장 긴 공통 부분 문자열을 찾아내는 알고리즘, 부분 문자열이란 원소들이 원래 순서를 유지하면서 일부 원소를 생략할 수 있는 형태를 말함 예시: 문자열 A: A**B**CB**DAB** 문자열 B: **BD**C**AB**B A와 B의 LCS는 BDAB라고 할 수 있다. 아이디어 2차원 배열을 통해 중간 결과를 메모이제이션 한다. 이 배열은 두 문자열의 길이에 따라 결정된다. 알고리즘 편의상 첫 번째 열과 행은 0으로 초기화 한다. 빈 문자열과 비교했을 때의 경우를 처리할 수 있다. - A B C B D A B - 0 0 0 0 0 0 0 0 두 번째 문자열의 문자를 차례대로 가져와 비교한다. - A B C B D A B - 0 0 0 0 0 0 0 0 B 0 0 1 테이블의 각 원소는 그 시점의 substring간의 LCS를 의미한다. 즉 위 표에서 1인 원소는 substring “AB”와 “B”의 LCS의 길이가 1임을 의미한다. 앞선 결과를 기반으로 LCS의 크기를 계산한다. - A B C B D A B - 0 0 0 0 0 0 0 0 B 0 0 1 1 B와 C는 같지 않더라도, “ABC”와 “B”를 비교하는 것과 같으므로, 이전에 “AB”와 “B”를 비교한 값인 1이 유지된다. 만약 두 문자가 다른 경우 - A B C B D A B - 0 0 0 0 0 0 0 0 B 0 0 1 1 1 1 1 1 D 0 0 1 B와 D는 다르지만, “AB”와 “BD”의 LCS의 길이는 1이다. 3번의 경우와 합쳐서 두 원소가 다르지 않을 때, DP 테이블을 채우는 점화식을 세울 수 있다. dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) 두 문자가 같은 경우 - A B C B D A B - 0 0 0 0 0 0 0 0 B 0 0 1 1 1 1 1 1 D 0 0 1 1 1 2 두 문자가 같은 경우, 즉 위 표의 상황에서는 “ABCBD”와 “BD”의 LCS의 크기를 구하는 경우가 된다. 이 경우는 “ABCB”와 “B”의 LCS의 크기에서 원소 “D”가 더해진 것과 같으므로, 대각선 위에 있는 값에서 1을 더한 값이 된다. dp[i][j] = dp[i - 1][j - 1] + 1 동작 원리 DP 테이블을 채우면서 두 문자가 다른 경우 이전에 계산한 결과를 그대로 유지한다. 두 문자가 같은 경우 그 문자가 없는 경우의 LCS의 크기에서 1을 더한다. 점화식 if (A[i - 1] == B[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1 } else { dp[i][j] = (dp[i - 1][j] > dp[i][j - 1]) ? dp[i - 1][j] : dp[i][j - 1]; } 기타 길이와 실제 문자열 DP 테이블의 마지막 값인 dp[m][n]은 LCS의 길이만 나타낸다. ...

January 2, 2025

Algorithm. Binary Search off-by-one 에러 없이 구현하기

off by one error 바이너리 서치를 할 때, 경계나 중간 값을 처리하면서 인덱스가 하나 어긋나는 에러 주요 원인은 바이너리 서치에 대한 이해 부족 인덱스 레인지, high / low 갱신 방식, 중간값 계산 방식, 값 리턴 방식을 다양하게 생각해야 함 Binary Search 테크닉 else보다는 else if를 사용하여 조건을 명확하게 표현하기 오버플로우 방지를 위해서는 mid를 계산할 때, (low + high) / 2보다 low + (high - low) / 2를 사용하기 바이너리 서치는 단순히 정렬된 배열에서 값을 찾는 알고리즘이 아니다. 바이너리 서치를 이용하여, 정확한 값을 찾는 경우보다 조건을 만족하는 첫 번째 또는 마지막 위치를 찾는 경우가 더 많다. ...

December 31, 2024

SniffMEET. anon-key와 익명 사용자의 접근 권한 구분

anon-role과 anon-user의 접근 권한 차이 Public Scheme에 있는 테이블을 anon-key로는 접근할 수 있었지만, anon-user의 JWT 토큰으로는 접근하지 못하는 문제가 있었다. 처음에는 둘이 비슷한 범주라고 생각했지만, 실제로는 권한 체계와 접근 방식이 달라서 같은 문제로 볼 수 없었다. 문제를 확인하기 전 생각한 내용 anon-key, anon-role, anon-user의 차이부터 다시 정리했다. anon-key를 이용한 접근은 anon-role로 보고, anon-user는 이메일 주소나 전화번호를 등록하지 않은 유저를 의미한다. 이 경우 role 자체는 authenticated-role이었다. 익명 유저가 접근했을 때 200 OK가 뜨지만 실제 값을 가져오지 못하는 경우도 있었다. 그래서 접근 자체는 허용되지만 RLS에서 막히고 있다고 추정할 수 있었다. ...

November 28, 2024

SniffMEET. Storage 버킷과 접근 제어

SniffMEET의 프로필 이미지는 사용자 정보와 성격이 다르다. 사용자 이름이나 소개처럼 구조화된 데이터는 Database에 저장하고, 이미지 파일은 Storage에 저장하는 편이 적합하다. Storage의 Postgres 테이블에는 파일 자체가 아니라 버킷과 객체의 메타데이터가 기록된다. 버킷과 객체 경로 버킷은 파일을 묶는 최상위 컨테이너다. 사용자마다 버킷을 만드는 것보다 용도별 버킷을 만들고 객체 경로로 사용자를 구분하는 편이 관리하기 쉽다. profile-images/ ├── {user-id}/profile.jpg └── {user-id}/thumbnail.jpg 이 구조에서는 profile-images 버킷이 공통된 공개 범위와 파일 제한을 담당하고, 사용자 ID가 포함된 경로를 RLS 정책에서 검사할 수 있다. ...

November 24, 2024

SniffMEET. Supabase 세션 갱신 요청이 실패한 이유

문제 Supabase 세션 갱신 요청이 제대로 동작하지 않았다. 세션 로직이나 네트워크 레이어 문제처럼 보였지만, 실제 원인은 요청 형식이었다. 당시 메모는 이랬다. 수정 "grant_type": "refreshToken" grant_type 값을 Swift식 camelCase로 보내고 있었다. Supabase가 기대하는 값은 refresh_token이었다. "grant_type": "refresh_token" body도 마찬가지였다. Supabase가 기대하는 key는 refreshToken이 아니라 refresh_token이었다. refresh 요청에서는 기존 access token의 Authorization 헤더도 제거했다. (토큰을 갱신받기 위한 요청인데, 만료된 토큰을 보내는거니까 아무 의미 없다!) body: Data("{ \"refresh_token\": \"\(refreshToken)\" }".utf8) header["Authorization"] = nil refresh 요청은 기존 access token으로 리소스에 접근하는 요청이 아니라, refresh token으로 새 세션을 발급받는 요청이기 때문이다. ...

November 21, 2024