앱을 첫 구축할 때, 결정하는건 아무래도 아키텍처가 아닐까 합니다. 그래서 1편에 이미 작성해둔 바 있는데 못보고 오신분은 보고오시길 추천드립니다. 각 아키텍처엔 무엇이 있고, 어떤 특징이 있는지 개괄적으로 적어놨는데요. 조금이나마 도움이 되지 않을까 합니다.
iSIGN+ Pass v2은 안드로이드 공식 가이드 문서에 따라 마이그레이션이 진행되었습니다 :)
UI Layer는 순수 View
와 State Holder Class
(이하, UiState
라 하겠습니다.)로 이루어져 있습니다. View
의 경우, xml기반의 ViewSystem이나, kotlin기반의 Compose로 이루어져 있으며, UiState
의 경우, 해당 View
에 바인딩될 데이터를 보유하고 있습니다. 해당 데이터들은 ViewModel
내에 저장되는데 이를 아울러 UI Layer
라 합니다.
Domain Layer는 순수 비즈니스 로직이 위치하는 Layer로써, 안드로이드 권장 아키텍처 가이드에선 아래 2가지 경우의 사용을 권장하고 있습니다.
[참고]
해당Domain Layer
는 Clean Architecture의Domain Layer
와는 다릅니다.
ViewModel
이 재활용할 수 있는 비즈니스 로직예를 들어, UseCase/Repository의 데이터 또는 Flow Stream를 조합하여 새로운 데이터를 만들어 내거나, 문자열/날짜/Byte 등을 파싱하는 경우가 있습니다.
Data Layer는 Repository
라는 클래스를 사용하여 UI/Domain Layer에 API를 노출하는 Layer입니다. 여기서 Repository
란 각종 Data Source로부터 받은 데이터를 조합 및 가공하여 앱에서 사용되는 데이터들로 그룹화한 클래스를 의미합니다. 또한, 각종 Data Source란, 여러 원천지로부터 데이터가 생상되는 모듈을 의미하며 File
/RemoteServer
/Room
/Realm
/DataStore
/AndroidKeyStore
등, 다양하게 존재할 수 있습니다.
의존도 그래프가 상위로부터, UI -> Domain -> Data Layer순으로 진행됩니다. 따라서 Data Layer가 변경이 된다면, 그 상위 Layer들이 연쇄적으로 변경이 전파될 수 있습니다. 따라서 Data Layer들의 설계를 먼저 진행하는 것이 의존성 전파에 따른 수정을 최소화하는 방법이라 생각했습니다. 그 후엔, UI Layer작업을 진행했으며, 개발 도중 중복하여 사용될 수 있는 비즈니스 로직을 DomainLayer로 추가하는 방향성을 선택했죠.
[설계 순서]
1. 의존성 전파 최소화를 위한DataLayer
작업
2.UI Layer
작업
3. 개발 도중, 비즈니스 로직 추출한Domain Layer
작업
iSIGN+ Pass v2에서 여러군데의 DataSource를 주입받아 사용하고 있으며, 이는 Room
/DataStore
/File
/RemoteServer
등이 있으며, 이들을 Repository
에 주입해서 사용하고 있죠. 의사코드를 보여드리면 아래와 같습니다.
펜타 시큐리티의 보안 특성상, 해당 소스들이 어떤 데이터를 주는지는 알려드릴 수 없습니다.
class UafRepositoryImpl @Inject constructor(
private val pentaApi: PentaApi,
private val pentaDao: PentaDao,
private val pentaPreferenceDataStore: PentaPreferenceDataStore,
@IoDispatcher private val dispatcher: CoroutineDispatcher
) : BiometricRepository
그 외적으로 Repository
를 어떻게 설계했는지 대표적인 예시를 말씀드리려 합니다. 현재 iSIGN+ Pass에선 6가지 인증기능(PIN, PATTERN, UAF, OTP, QR, PKI)을 사용자에게 제공중입니다. 따라서 각각 인증 기능에 맞게 해당 데이터들을 보유할 수 있는 Repository
또한 설계를 진행했습니다.
class PinRepositoryImpl @Inject(
// 여러 DataSource들의 주입...
): PinRepository
class PatternRepositoryImpl @Inject(
// 여러 DataSource들의 주입...
): PatternRepository
class UafRepositoryImpl @Inject(
// 여러 DataSource들의 주입...
): UafRepository
class OtpRepositoryImpl @Inject(
// 여러 DataSource들의 주입...
): OtpRepository
class QrRepositoryImpl @Inject(
// 여러 DataSource들의 주입...
): QrRepository
class PkiRepositoryImpl @Inject(
// 여러 DataSource들의 주입...
): PkiRepository
그리고 각각의 Repository
는 추후, DomainLayer
에서 데이터를 가공할 수 있도록 내부적 파싱을 거치지 않은 순수 모델로만 반환하도록 설정하였습니다. 이를 통해, DomainLayer
에선 여러 Repository
를 주입받아 독립적인 비즈니스 모듈이 만들어질 수 있죠. 자세한 부분은 추후, DomainLayer
작성 시 말씀드리려 합니다.
해당 레이어는 Ui Element
/State Holder Class
로 구성되어 있는데요. 풀어 말하자면 Compose
/ViewModel
이라는 뜻입니다. 따라서 각 부분 작업에 있어 주의할 점을 말씀드려볼까 합니다.
[Compose]
기존에는 xml기반의 ViewSystem을 통한 명령형 UI방식의 개발이 진행되었습니다. 이 또한 물론 상태값(UiState
)를 보유하여 개발이 가능하지만, UI갱신을 위해선 해당 xml전체를 갱신해야하는 문제가 있습니다. 따라서 ViewSystem개발시엔 'ConstraintLayout을 사용하고 View depth를 최소화 해야한다' 라는 말을 많이 들어보셨을 겁니다.
하지만 Compose는 kotlin기반의 선언형 UI방식으로 개발을 진행합니다. 이는 상태값을 보유하고 해당 상태값이 변경될 때 화면이 갱신(=리컴포지션)되는데요. 변경된 상태값에 의존하고 있는 컴포저블 함수의 경우에만 갱신이 진행됩니다. 즉, 컴포즈는 변경된 상태값에 해당하는 UI만 적절하게 바꿔줄 수 있기에, 굳이 ViewSystem처럼 ConstraintLayout을 사용하지 않아도 됩니다.
하지만 Compose를 진행할 때 주의해야할 점이 있습니다. 바로, 불필요한 리컴포지션을 방지하는 것입니다. 이를 신경쓰지 못할 경우, 무한 리컴포지션이 발생하여 화면의 버벅임과 동시에 사용성이 매우 안좋아질 수 있습니다. 따라서 Compose로 작업할 때엔 '스마트 리컴포지션'을 염두한 개발을 진행해야 합니다. 요약하면 아래와 같습니다.
- 컴포저블 함수의 파라미터 타입이 적어도 1개라도
unstable
할 경우, 리컴포지션 대상에 무조건적으로 포함된다.- 따라서 컴포저블 함수 파라미터는 아래 원칙을 따라야 한다.
ㄴ> 1. 원시 타입/원시 타입을 파라미터로 받고 반환하는 함수 타입/함수 타입은stable
하다.
ㄴ> 2.@Stable
/@Immutable
어너테이션이 붙여진 참조 타입은stable
하다.
ㄴ> 3. 함수 타입을 사용할 경우,unstable
한 참조 타입을 캡쳐할 경우,unstable
하다.
ㄴ> 4. 리스트 사용할 경우, 기존 List는unstable
하다. 하지만,Jetbrains
/ImmutableCollection
API를 사용할 경우,stable
해진다.
그 외, 더 자세한 사항은 [Composable함수의 Stability안정성을 위해 알아야 할 것]을 참고하면 좋을것 같습니다.
[ViewModel]
MVI패턴으로 작업한 만큼, ViewModel
에서는 UiState
/UiEvent
/UiSideEffect
를 신경써야 합니다. 우선 View
에서는 사용자 이벤트를 받습니다. 그 후, View
는 ViewModel
의 UiEvent
를 호출 합니다. 그 후, ViewModel
은 최종적으로 DataSource
로부터 데이터를 반환받게 되는데요. 이때부터 ViewModel
은 2가지 행위가 가능합니다.
UiState
갱신을 통한 View
를 컴포지션/리컴포지션 하기UiSideEffect
를 통한 1회성으로 UI 핸들링하기(다이얼로그/토스트 메시지 로딩, 화면 이동 등..)예를 들어, 특정 버튼을 눌러 사용자 리스트를 가져온 후, 토스트 메시지로 '완료'를 띄우는 기능이 있다고 가정하겠습니다. 그럴 경우, 특정 버튼을 눌렀다는 것이 UiEvent
가 됩니다. 그 후, 사용자 리스트를 가져와서 UiState
를 갱신함으로써 View
에 사용자 리스트를 보여줍니다. 그 후, 토스트 메시지 로딩을 위해 UiSideEffect
호출을 통해 View
에 1회성으로 토스트 메시지를 로딩하게되는 방식인 것입니다.
ViewModel
로부터 사용자 이벤트를 요청하고, 각종 UseCase
/Repository
로부터 받은 데이터들을 앱의 비즈니스에 맞게 조합합니다. 그 후, ViewModel
이 필요로 하는 형태로 내려주는 것이 DomainLayer
의 책임입니다.
현 iSIGN+ Pass v2에서의 대표적인 예시를 말씀드려보자면, OTP번호 생성과 ByteArray타입의 PKI 인증서 파싱이 있습니다.
[OTP번호 생성]
순수 비즈니스 로직을 작성하기에 앞서, 해당 도메인을 깊게 이해하는 것은 무엇보다 중요합니다. 마찬가지로, OTP기능 구현을 위해 OTP의 뜻을 정확히 이해할 필요가 있는데요. OTP란 One Time Password라는 뜻으로 1회성 비밀번호라는 뜻입니다. 대중적으로 아시다시피, 우리가 은행 거래를 하다보면 OTP를 심심치않게 마주칠 수 있습니다.
OTP번호는 특정 주기(약 30초~1분)를 가지고 해당 번호가 주기적으로 갱신이 되는데, 이 기능을 구현할 때, iSIGN+ Pass v2는 DomainLayer를 사용하여 구현합니다. 그렇다면 아래와 같은 생각을 가질 수 있습니다.
아니... 번호를 '생성'하는 기능인데, 그렇다면
Repository
에서 가지고 있어야 하는 기능이 아냐?
처음엔 저도 위와 같은 생각이 들었지만 이는 올바르지 않습니다. OTP번호는 특정 주기를 가지고 지속적 갱신이 진행됩니다. 따라서 해당 번호를 Room
/DataStore
등, 영속성 라이브러리를 써서 저장할 경우, 앱의 백그라운드 작업을 활성화시켜 OTP번호를 갱신해야 하므로 앱의 로직 복잡성이 증가합니다. 뿐만 아니라, 앱에서 백그라운드 비활성화
를 설정한다면 어떻게 될까요? 그렇다면 OTP번호는 갱신되지 못하는 이유로 인증에 실패하게 되고, 이는 큰 문제에 다다를 수 있습니다.
따라서 OTP번호 구현을 위해선, OTP번호를 생성하는 핵심 데이터들만 Room
에 저장시켜두고, 이들의 활용 및 TimeMillis와의 조합을 통해 OTP번호를 생성하게 되는 것입니다. 따라서 OTP번호를 생성하는 의사 코드는 아래와 같습니다.
class ObserveOtpNumberUseCase @Inject constructor(
private val otpRepository: OtpRepository,
private val getOtpMaterialUseCase1: GetOtpMaterialUseCase1,
private val getOtpMaterialUseCase2: GetOtpMaterialUseCase2,
private val getOtpMaterialUseCase3: GetOtpMaterialUseCase3,
private val computeOtpMaterialsAndGenerateOtpNumberUseCase: ComputeOtpMaterialsAndGenerateOtpNumberUseCase,
) {
operator fun invoke(target: String): Flow<String> {
return otpRepository.observeOtpInfo(target)
.flatMapLatest { otpInfo ->
flow {
while (true) {
val otpMaterial1 = getOtpMaterialUseCase1(otpInfo)
val otpMaterial2 = getOtpMaterialUseCase2(otpMaterial1)
val otpMaterial3 = getOtpMaterialUseCase3(otpMaterial2)
val otpNumber = computeOtpMaterialsAndGenerateOtpNumberUseCase(
otpMaterial1, otpMaterial2, otpMaterial3
)
emit(otpNumber)
delay(1000L)
}
}
}
}
}
즉, OTP번호는 시시각각 변하기 때문에, 이를 영속적으로 저장하는게 아니라, OTP를 생성하는데 필요한 각종 비즈니스 로직들을 조합합니다. 이로써 '현재'시간에 맞는 OTP번호가 생성되는 것이죠!
[ByteArray타입의 PKI인증서 파싱]
PKI 또한 구현을 위해 해당 도메인 지식의 이해가 필요합니다. PKI는 Public Key Infrastructure의 약자로, 공개키 기반 시설이라는 뜻입니다. 어떤 시설을 의미할까요?
바로 세상에 자유롭게 배포된, 상위 CA가 배포한 공개키를 사용하여 인증서를 검증한다는 뜻입니다. 만약 인증서 검증에 성공한다면 이는 상위 CA의 개인키로 서명한 신뢰할 수 있는 인증서를 의미할 것입니다. 하지만 검증에 실패한다면 상위 CA의 개인키로 서명하지 않았거나, 인증서의 내부가 변조되었다는 등, 신뢰할 수 없는 데이터라는걸 의미합니다.
따라서 iSIGN+ Pass v2에선 신뢰할 수 있는 CA기관을 토대로 인증을 진행하며 해당 CA가 신뢰할 수 있다면 PKI 방식으로 인증을 진행하는 방식은 좀 더 안전하다고 볼 수 있겠습니다.
PKI구현을 위해 해당 인증서는 간략이 아래의 구조로 이뤄져 있습니다.
즉, iSIGN+ Pass v2에서 PKI인증을 구현하기 위해, 인증서 데이터 수신 후, 위 데이터 추출을 위해 데이터를 파싱할 필요가 있습니다. 이러한 데이터 파싱을 마찬가지로 DomainLayer
에서 진행하며, 대략적인 의사 코드는 아래와 같습니다.
사내 기밀을 위해 정확한 코드 작성은 하지 않았습니다.
class GetPkcs12KeyPairsUseCase @Inject constructor(
private val pkiRepository: pkiRepository,
private val parsePkcs12KeyPairUseCase: ParsePkcs12KeyPairUseCase
) {
operator fun invoke(): Flow<List<Pkcs12KeyPair>> {
pkiRepository.observePkiInfos()
.map { parsePkcs12KeyPair(it) }
}
}
class ParsePkcs12KeyPairUseCase @Inject constructor() {
operator fun invoke(certByteArray: ByteArray): Flow<Pkcs12KeyPair> {
// 펜타 시큐리티의 내부 SDK 및 비즈니스 로직 사용하여 파싱
// ...
// ...
val result =
return Pkcs12KeyPair(
certificate = result.certificate
privateKey = result.privateKey
hash = result.hash
signitureByCA = result.signitureByCA
)
}
}
data class Pkcs12KeyPair(
val certificate: Certificate
val privateKey: List<Byte>
val hash: String,
val signitureByCA: String
) {
data class Certificate (
val publicKey: List<Byte>,
val issuerName: String,
val expireDate: String,
val uid: String
)
}
글이 길어질 것을 우려해 좀 더 깊은 세부사항까진 다루진 못하였습니다. 다만, 펜타시큐리티 모바일 팀의 iSIGN+ Pass v2를 마이그레이션하는 1가지 방식을 참고하시고, 1가닥의 기억이라도 남으면 그것만으로 좋을것 같습니다.
읽어주셔서 감사하며 즐거운 코딩되시기 바랍니다 :)