Skip to content

✏️ 반갑개 Tech 에디

JunJangE edited this page Aug 25, 2024 · 3 revisions

에디 기술 블로그

PR에 대한 단위 테스트 자동화

PR에 대한 단위 테스트 자동화

프로젝트 개발 과정에서 코드 품질을 보장하고, 버그를 사전에 검출하기 위해 자동화된 테스트와 CI 파이프라인의 도입이 필요하다는 점을 인식했다. 이러한 자동화 과정은 수동 테스트의 비효율성을 극복하고, 배포 전에 코드의 안정성을 확인할 수 있도록 도움을 준다. 이와 관련하여, 대표적인 CI 도구인 Jenkins와 GitHub Actions를 비교하게 되었다.

Jenkins

Jenkins는 오랜 역사를 가진 CI/CD 도구로, 높은 확장성과 유연성을 제공한다. 하지만, Jenkins의 설정은 복잡하고 관리가 어려울 수 있으며, 플러그인 충돌이나 업데이트 문제로 인해 안정성이 저하될 수 있다. 또한, 서버를 직접 호스팅해야 하는 점도 운영의 부담을 가중시킬 수 있다.

GitHub Actions

GitHub Actions는 GitHub와 원활하게 통합되어 있어, GitHub 저장소와의 긴밀한 연동이 가능하며, 설정과 사용이 직관적이다. 무료로 제공되는 액션과 워크플로우 템플릿을 통해 쉽게 CI/CD 파이프라인을 구축할 수 있고, 직접적인 GitHub 환경 내에서 모든 작업을 관리할 수 있다. 또한, YAML 기반의 설정 파일로 파이프라인을 정의할 수 있어, 변경 사항에 대한 자동화 테스트와 배포를 간편하게 설정할 수 있다. GitHub Actions는 또한 높은 유연성과 다양한 커뮤니티 지원으로 최신 트렌드에 빠르게 대응할 수 있는 장점이 있다.

이러한 비교를 통해, GitHub Actions가 프로젝트의 요구 사항에 가장 적합하다는 결론을 내렸다. GitHub Actions를 사용하여 CI 파이프라인을 구성함으로써, 코드 변경 사항이 있을 때마다 자동으로 테스트가 실행되도록 설정했다.

단위 테스트 CI 환경 구축

Untitled

  • “반갑개”에서는 Upstream Repository를 Fork하여 기능을 구현하고, PR을 보내는 형태로 프로젝트를 관리하기로 했다.
  • CI에서는 빌드가 잘 되는지, 코드 스타일에 문제가 없는지, 테스트 결과에 대한 것을 확인하도록 했다.
  • Upstream Repository가 아닌 외부 Repository에서 보내는 PR의 테스트 결과를 확인하기 위해서는 추가 설정이 필요했고, [깃허브 문서](https://github.com/EnricoMi/publish-unit-test-result-action)를 참고하여 해결했다.

그러나 문제가 발생했다.

✅ 문제 상황

  • “반갑개”에서는 소셜 로그인, 카카오 맵을 사용하기 때문에, 빌드가 잘 되는지 확인하기 위해서 깃허브 secrets에 키값과 google.service.json 정보를 담아야했다.
  • 그러나 Forked Repository로부터 온 Pull Request는 secrets에 접근할 수 없었다.

✅ 해결 방안

위 문제 상황을 해결하기 위해서는 2가지 해결 방안이 있었다.

  1. pull_request_target 키워드를 사용하는 방법
    • pull_request_target을 통해 풀 리퀘스트가 머지 대상으로 지정한 base를 기준으로 실행되게 하여 Collaborator가 아닌 외부인이 PR을 날리더라도 저장소에 쓰기 권한과 시크릿 접근 권한을 가지게 하는 것
  2. 협업 방식을 바꾸는 방법
    • 원본 repository의 branch를 만들고 push 후 PR로 머지하는 방식
💡 위에 첫 번째 방식은 정확한 동작 방식의 이해 없이 사용하면 보안에 매우 취약하다는 글을 읽었고, CI에 이미 많은 시간을 투자한 상태였기 때문에 과감하게 협업 방식을 바꾸는 방법을 채택했다.

단위 테스트 CI 워크플로우 추가

안드로이드 팀은 Android 프로젝트의 Pull Request 또는 develop 브랜치에 푸시될 때 자동으로 CI 작업을 실행하도록 설정했다.

워크플로우 설명

1. 워크플로우 트리거

on:
  push:
    branches: [ develop ]
    paths:
      - 'android/**'
  pull_request:
    branches: [ develop ]
    paths:
      - 'android/**'
  • push 이벤트: develop 브랜치에 푸시가 발생하면 워크플로우가 실행된다. android/ 경로에 대한 변경 사항이 있는 경우에만 실행된다.
  • pull_request 이벤트: develop 브랜치로의 Pull Request가 생성되거나 업데이트될 때 워크플로우가 실행된다. 역시 android/ 경로에 대한 변경 사항이 있는 경우에만 실행된다.

2. 기본 설정

defaults:
  run:
    working-directory: ./android
  • 모든 run 단계에서 작업 디렉토리를 ./android로 설정한다.

3. Job 정의

jobs:
  verify:
    runs-on: ubuntu-latest
  • verify라는 이름의 작업이 정의되어 있으며, ubuntu-latest 환경에서 실행됩니다.

4. 권한 설정

    permissions:
      checks: write
      pull-requests: write
      contents: read
      issues: read
      actions: read
  • 다양한 권한을 설정하여 GitHub API에 접근할 수 있도록 합니다. 예를 들어, Pull Request에 대한 체크를 작성하거나, 콘텐츠를 읽는 등의 권한이 필요하다.
  • 이부분은 이전에 Forked Repository로부터 온 Pull Request의 CI를 적용하기 위해 작성한 것이다.

5. steps 정의

코드 체크아웃

- name: Checkout the code
  uses: actions/checkout@v3
  • 리포지토리의 최신 코드를 체크아웃한다.

Gradle 캐시 설정

- name: Gradle cache
  uses: actions/cache@v3
  with:
    path: |
      ~/.gradle/caches
      ~/.gradle/wrapper
    key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
    restore-keys: |
      ${{ runner.os }}-gradle-
  • Gradle 캐시를 설정하여 빌드 속도를 향상시키고, Gradle 종속성의 재다운로드를 방지한다.

JDK 17 설정

- name: set up JDK 17
  uses: actions/setup-java@v3
  with:
    distribution: 'corretto'
    java-version: '17'
  • JDK 17을 설치한다.

Android SDK 설정

- name: set up Android SDK
  uses: android-actions/setup-android@v2
  • Android SDK를 설치한다.

Gradle 실행 권한 설정

- name: Grant execute permission for gradlew
  run: chmod +x gradlew
  • gradlew 스크립트에 실행 권한을 부여한다.

local.properties 파일 생성

- name: Create local.properties
  env:
    LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }}
  run: |
    echo "$LOCAL_PROPERTIES" > local.properties
  • LOCAL_PROPERTIES 환경 변수를 사용하여 local.properties 파일을 생성한다.

keystore.properties 파일 생성

- name: Create keystore.properties
  env:
    KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
  run: |
    echo "$KEYSTORE_PROPERTIES" > keystore.properties
  • KEYSTORE_PROPERTIES 환경 변수를 사용하여 keystore.properties 파일을 생성한다.

Keystore 디렉토리 생성

- name: Create keystore directory
  run: mkdir -p /home/runner/work/2024-friendogly/2024-friendogly/android/app/keystore
  • Keystore 디렉토리를 생성한다.

Keystore 데이터 삽입

- name: Putting data
  env:
    DATA: ${{ secrets.RELEASE_KEYSTORE }}
  run: echo $DATA | base64 -d > /home/runner/work/2024-friendogly/2024-friendogly/android/app/keystore/friendogly.jks
  • Base64로 인코딩된 RELEASE_KEYSTORE 데이터를 디코딩하여 Keystore 파일을 생성한다.

google-services.json 파일 생성

- name: Create google-services.json
  env:
    GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
  run: |
    touch ../android/app/google-services.json
    echo "$GOOGLE_SERVICES_JSON" >> ../android/app/google-services.json
    cat ../android/app/google-services.json
  • GOOGLE_SERVICES_JSON 환경 변수를 사용하여 google-services.json 파일을 생성한다.

Lint 검사 실행

- name: Lint Check
  run: ./gradlew ktlintCheck
  • 코드 스타일 및 정적 분석을 수행하는 lint 검사를 실행한다.

이벤트 파일 업로드

- name: Upload Event File
  uses: actions/upload-artifact@v3
  with:
    name: Event File
    path: ${{ github.event_path }}
  • GitHub 이벤트 파일을 아티팩트로 업로드한다.
  • 아티팩트는 워크플로우의 실행 결과로 생성된 파일이나 데이터를 의미한다.

google-services.json 파일 Base64로 인코딩

- name: Create file
  run: cat /home/runner/work/2024-friendogly/2024-friendogly/android/app/google-services.json | base64
  • google-services.json 파일을 Base64로 인코딩하여 출력한다.

단위 테스트 실행

- name: Run unit tests
  run: ./gradlew testDebugUnitTest --stacktrace
  • 디버그 모드에서 단위 테스트를 실행하고, 스택 트레이스를 출력한다.

테스트 결과 업로드

- name: Upload Test Results
  if: always()
  uses: actions/upload-artifact@v3
  with:
    name: Test Results
    path: "**/test-results/**/*.xml"
  • 테스트 결과를 XML 파일 형식으로 아티팩트로 업로드한다.

이제 반갑개의 코드는 안전하다.

image.png

이로 인해 코드의 안정성을 유지하며, 배포 전 버그를 사전에 검출할 수 있게 되었다. GitHub Actions의 직관적인 설정과 GitHub과의 원활한 통합 덕분에 효율적인 CI 환경을 구축할 수 있었고, 코드 품질을 높이는 데 큰 도움이 되었다.

반갑개 OAuth 2.0 로그인을 해보아요

OAuth 2.0 로그인이란?

“반갑개”에서는 사용자 로그인 과정을 간소화하고 보안을 강화하기 위해 소셜 로그인 기능을 도입하고자 하였으며, 특히 카카오톡 계정을 활용한 간편한 로그인 방식이 필요하다고 판단하였다.

OAuth 2.0이 뭐야?

OAuth 2.0은 웹 애플리케이션 및 모바일 앱에서 사용자 인증과 권한 부여를 안전하게 관리하기 위해 사용되는 인증 프로토콜이다. 이 프로토콜은 사용자에게 비밀번호를 노출하지 않고, 제3자 애플리케이션이 사용자의 계정에 제한된 액세스 권한을 가질 수 있도록 한다. 여기서 제3자 애플리케이션은 카카오, 네이버, 구글 등이 있다.

OAuth 2.0이 있다면 1.0도 있는거 아니야?

OAuth 1.0도 당연히 있다. OAuth 1.0은 OAuth 2.0의 이전 버전으로, 주로 웹 애플리케이션에서 사용되었고, OAuth 2.0은 OAuth 1.0의 후속 버전으로, 더 다양한 환경에서 사용될 수 있도록 설계되었다.

앞서 말했듯이, OAuth 2.0에서 "2.0"은 이 프로토콜의 두 번째 주요 버전을 의미한다. OAuth 1.0은 인증과 권한 부여를 관리하기 위해 설계된 초기 버전으로, 모든 요청에 서명을 요구하는 복잡한 방식이었다. 이러한 방식은 구현이 어렵고 오류가 발생하기 쉬운 단점이 있었다. (여기서 서명은 데이터의 진위와 무결성을 검증하기 위해 사용되는 암호화된 코드이다.)

반면, OAuth 2.0은 서명 대신 액세스 토큰을 사용하는 방식으로 변경되었다. 이로 인해 구현이 간단해졌고, 다양한 인증 흐름이 가능해졌다. 또한, OAuth 2.0은 HTTPS를 통해 전송 계층의 보안을 강화하고, 액세스 토큰과 리프레시 토큰을 사용하여 보안을 더욱 단순하고 관리하기 쉬운 방식으로 개선했다.

결국, OAuth 2.0은 이전 버전인 OAuth 1.0의 한계를 보완하고, 현대의 다양한 인증 요구 사항에 맞게 진화한 프로토콜이다.

OAuth 2.0 프로토콜과 카카오 로그인

OAuth 2.0 프로토콜의 복잡성과 카카오 API 연동에서 어려움을 겪었다. 처음에는 OAuth 2.0의 인증 흐름을 이해하는 데 시간이 걸렸고, 서버 개발자와 함께 Access Token과 Refresh Token 관리 및 안전하게 처리하는 방법을 파악하는 데 어려움이 있었다. 또한, 원할하지 못한 소통때문에 서버 개발자는 REST API 문서를 참고했고, 안드로이드 개발자는 Android 문서를 참고했기 때문에 협업 과정에서의 복잡성이 증가했다.

이러한 문제를 해결하기 위해, 서버 개발자와 함께 우리가 현재까지 알고있는 정보를 공유하며 어떤식으로 구현하는 것이 좋을지 이야기를 나누었다. OAuth 2.0의 인증 흐름이 생소하게 느껴졌던 점을 극복하기 위해서는 관련 개념을 심도 있게 학습하고, 인증 코드 발급과 토큰 관리 방법을 이해하는 데 집중하여 보안 측면을 강화하였다.

OAuth 2.0 프로토콜의 인증 흐름

  1. 사용자가 앱에서 카카오 로그인 버튼을 클릭하면, 카카오 SDK를 통해 인증 서버의 로그인 페이지로 리디렉션을 한다.
  2. 사용자는 카카오톡 앱 또는 카카오 계정 로그인을 통해 로그인하고, 애플리케이션에 대한 권한을 부여한다.
  3. 카카오 서버는 사용자 인증 후 로그인 인증 정보를 콜백을 통해 전달한다.
  4. 카카오 서버에서 받은 AccessToken을 반갑개 서버에 전달한다.
  5. 반갑개 서버에서는 AccessToken 과 로그인 인증 정보를 통해 사용자를 생성합니다.
  6. 생성된 사용자의 JWT를 만들어 클라이언트에게 전달합니다.

구현 후에는 다양한 시나리오에서 로그인 기능을 테스트하여 예기치 않은 오류를 사전에 발견하고, 이에 대한 예외 처리 로직을 추가하였다.

카카오 소셜 로그인을 반갑개 도입하다.

최종적으로 카카오 소셜 로그인 기능을 서비스에 도입하였고, 이로 인해 사용자가 간편하게 로그인할 수 있게 되었다. 로그인 과정의 단순화로 신규 사용자의 허들이 낮아졌고, 로그인 실패율이 감소하는 긍정적인 성과를 얻을 수 있었다.

이번 경험을 통해 OAuth 2.0의 인증 메커니즘과 보안의 중요성을 깊이 이해하게 되었다. 단순히 기능 구현에 그치지 않고 사용자 데이터를 안전하게 처리하는 방법을 배우면서 보안이 사용자 경험의 핵심 요소임을 깨달았다. 마지막으로, 서버 개발자와 지식을 공유하는 과정에서 협업의 중요성도 재확인할 수 있었다.

네트워크 통신 에러 핸들링

네트워크 통신 에러 핸들링

네트워크 통신 과정에서 발생할 수 있는 다양한 에러를 처리하기 위한 에러 핸들링 전략을 구축한 배경은 앱의 안정성과 사용자 경험을 향상시키기 위한 것이었습니다. 프로젝트가 진행됨에 따라 네트워크 통신을 담당하는 코드가 점점 복잡해지면서, 다양한 종류의 네트워크 에러가 발생하기 시작했습니다. 네트워크 요청 실패, 응답 지연 등으로 인해 앱이 예기치 않게 중단되는 경우가 발생했고, 사용자에게 적절한 피드백을 제공하지 못하는 상황이 많았습니다. 이러한 문제는 사용자 경험을 크게 저하시킬 수 있으며, 앱의 신뢰성에도 영향을 미쳤습니다.

이런 상황에서 예외를 던지는 대신 안전하게 객체를 전달하여 에러를 핸들링하는 전략이 필요하다고 판단했습니다. 이를 통해 네트워크 문제로 인해 앱이 중단되지 않도록 하고, 에러 발생 시 사용자에게 적절한 피드백을 제공하여 앱의 신뢰성을 높일 수 있었습니다.

DomainResult<Data, Error> 에러 핸들링

구체적으로, DomainResult 타입을 도입하여 성공과 에러 상태를 명확히 구분하고, 이를 통해 코드의 가독성과 유지보수성을 개선했습니다. DomainResult는 성공 데이터와 에러 정보를 안전하게 전달할 수 있는 방법을 제공하여, 네트워크 통신에서 발생할 수 있는 다양한 에러를 체계적으로 처리할 수 있었습니다.

typealias RootError = Error

sealed interface DomainResult<out D, out E : Error> {
    data class Success<out D, out E : RootError>(val data: D) : DomainResult<D, E>

    data class Error<out D, out E : RootError>(val error: E) : DomainResult<D, E>
}

inline fun <D, E : RootError, R> DomainResult<D, E>.fold(
    onSuccess: (data: D) -> R,
    onError: (error: E) -> R,
): R =
    when (this) {
        is DomainResult.Success -> onSuccess(data)
        is DomainResult.Error -> onError(error)
    }

에러 핸들링 전략을 구축하는 과정에서의 어려움

에러 핸들링 전략을 구축하는 과정에서 여러 어려움이 있었습니다. 첫째, 다양한 에러 유형을 처리하는 것이 도전적이었습니다. 서버 응답 지연, 네트워크 연결 실패, 잘못된 응답 형식 등 여러 가지 에러 상황을 일관된 방식으로 처리하기 위해 많은 시간이 소요되었습니다. 특히 각 에러 유형에 맞는 적절한 핸들링 방식을 설계하는 데 어려움이 있었습니다.

둘째, 사용자에게 적절한 피드백을 제공하는 것도 어려운 부분이었습니다. 사용자가 이해할 수 있는 명확하고 친절한 에러 메시지를 작성하는 것이 중요하지만, 어떤 메시지가 가장 효과적일지 결정하는 데 고민이 많았습니다. 기술적 용어나 모호한 설명은 사용자 경험을 저하시킬 수 있기 때문에, 직관적이고 구체적인 피드백을 제공하는 것이 필요했습니다.

셋째, 기존의 에러 처리 방식을 새로운 DomainResult 타입으로 전환하는 과정에서도 문제가 발생했습니다. 새로운 타입을 설계하고 적용하는 데 있어 다양한 에러 상황을 효과적으로 표현하는 것이 어려웠고, 기존 코드와의 호환성을 유지하는 것도 큰 도전이었습니다.

마지막으로, 코드의 가독성과 유지보수성을 유지하는 것이 어려웠습니다. 새로운 에러 핸들링 전략을 도입하면서 코드의 복잡도가 증가할 수 있었고, 이를 해결하기 위해 명확한 규칙과 문서화가 필요했습니다. 팀원들과의 협업과 코드 리뷰를 통해 문제를 해결하며, 보다 안정적이고 사용자 친화적인 에러 핸들링 방식을 구현할 수 있었습니다.

에러 핸들링 전략을 통한 사용자 경험 향상

이러한 과정을 통해 에러 핸들링 전략을 성공적으로 구축할 수 있었으며, 사용자 경험을 향상시키고 앱의 신뢰성을 높일 수 있었습니다. 네트워크 통신에서 발생할 수 있는 다양한 에러를 사전에 처리하고 적절한 피드백을 제공함으로써 사용자가 앱이 중단되는 문제 없이 원활하게 사용할 수 있었습니다. 에러 메시지는 명확하고 이해하기 쉬운 형태로 제공되어 사용자들이 문제를 인식하고 필요한 조치를 취할 수 있었습니다.

앱의 신뢰성도 크게 향상되었습니다. 네트워크 문제나 서버 오류가 발생하더라도 앱이 안정적으로 동작하게 되었으며, 예기치 않은 앱 크래시나 비정상적인 종료가 줄어들었습니다. 이로 인해 사용자들이 앱을 계속 신뢰하고 사용할 수 있게 되었습니다.

코드의 가독성과 유지보수성도 강화되었습니다. DomainResult 타입을 도입하여 에러 처리 로직이 명확하게 분리되었으며, 코드의 복잡성을 줄이고 에러 처리 부분의 수정을 보다 쉽게 할 수 있었습니다. 또한, 표준화된 에러 메시지와 문서화 덕분에 팀 내 협업이 원활해졌습니다

Unauthorized 에러 분기 처리

Unauthorized 에러 분기 처리

이전부터 JWT를 사용하여 서버와의 사용자 인증을 수행하고 API 호출을 진행했다. 그러나 Access Token이나 Refresh Token이 만료되었을 때의 예외처리는 진행한 경험이 없었고, 매번 프로젝트를 진행할 때마다 아쉬움이 남았었기 때문에 이번 프로젝트에서는 Token Refresh를 제데로 진행하였다.

토큰은 Local 모듈에서 SharedPreferences를 통해 관리했고 Data 모듈을 통해 접근할 수 있게 구현했다. 실질적으로 Token을 사용해야하는 Remote 모듈에서, Data 모듈을 통해 토큰을 가져와 사용할 수 있도록 구현했다.

그렇다면 토큰 에러가 발생하는 것을 어떻게 알고 토큰을 갱신 시켜줘야 할까???

Remote 모듈을 보게되면 Retrofit을 통해 서버와 통신을 진행하고, OkHttp의 Intercepter에서 authenticator가 인증이 필요한 HTTP 요청에 대한 인증을 담당하게 된다. 토큰 인증 에러가 발생하면 authenticator의 authenticate 메서드가 실행된다. authenticate 메서드 안에서는 Refresh Token을 통해 Refresh API를 호출하여 새로운 토큰을 요청하고, 토큰 갱신에 성공하면 다시 시도하던 요청에 새로운 토큰을 적용하여 다시 시도한다. 만약 Refresh Token이 SharedPreferences에 없거나 만료되었다면 authenticationListener의 onSessionExpired()를 실행시켜 App 모듈에서 로그인 페이지로 이동하고 사용자에게 다시 로그인할 수 있도록 유도한다.

이렇게 토큰 에러가 발생하면 토큰을 갱신하고 관련된 동작을 수행하여 사용자 경험을 향상시켜보았습니다.

Firebase Analytics 및 Crashlytics 를 활용하여 앱 사용자 데이터 수집

📍 유저 행동 분석: Firebase Analytics


Untitled

Firebase Analytics를 통해 사용자의 행동에 대한 세부 정보를 추적하고 이를 분석하여 앱의 성능을 개선할 수 있도록 했습니다.

**“반갑개”**에서는 사용자가 어떤 기종을 사용해 서비스를 이용하는지, 각 화면 별 체류 시간은 어느 정도 되는지, 특정 이벤트(Event)를 정의하여 해당 이벤트가 얼마나 발생하는지 등 분석하려고 합니다.

특정 이벤트라고 하면, 모임 게시글의 어떠한 강아지 사이즈의 사용자가 확인하는지, 지도를 확인했을 때 몇개의 주변 발자국들이 찍히는지, 채팅 알림이 적절한 시간에 오는지, 로그아웃 및 회원탈퇴를 어느정도 하는지 등 이 있을 것 같습니다.

또 다른 예시로는, 지금은 임의로 (가는중)발자국 유지시간을 2시간으로 설정하고 있는데, 발자국을 남기고 사용자가 산책 장소까지 도착하는 시간이 얼마나 걸리는지 로깅을 통해 확인이 된다면 발자국 유지시간을 설정하는데 도움이 될 것으로 예상됩니다.

분석 결과를 통해서는 분할 테스트 또는 버킷 테스트라고 하는 A/B 테스트를 통해 두 가지 콘텐츠를 비교하여 뷰어가 더 높은 버전을 추출해 낼 수도 있을 것 같습니다.

📍 앱 오류 분석: Firebase Crashlytics


Untitled

Firebase Crashlytics를 통해 앱에서 발생한 크래시 및 오류의 상세 정보를 확인하고 해당 문제가 발생한 디바이스, 버전 및 사용자 정보를 파악할 수 있게 했습니다.

그 외에 크래시가 나지는 않지만, 예상치 못한 오류가 발생하는 경우에는 로그를 남겨 빠르게 원인을 파악하고 대응할 것입니다.

Google Play Store 배포 자동화

Google Play Store 배포 자동화

Clone this wiki locally