Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WEEK4] 4주차 필수과제 구현 #10

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open

[WEEK4] 4주차 필수과제 구현 #10

wants to merge 21 commits into from

Conversation

HAJIEUN02
Copy link
Contributor

Related issue 🛠

Work Description ✏️

  • base url 숨기기
  • 서버통신 기초세팅(retrofit, clean architecture, di)
  • Token Interceptor 구현
  • 유저 등록 API 구현
    • 회원가입 뷰 구조 변경(이메일, 비밀번호 -> username, password, hobby 입력하는 구조로 변경)
    • username, password, hobby가 조건을 만족하지 않는 경우(8자 초과인 경우) 토스트 or 스낵바 표시
  • 로그인 API 구현
    • 로그인 뷰 구조 변경(이메일, 비밀번호 -> username, password 받는 구조로 변경)
    • 로그인 시 받는 token값 저장
    • 로그인 실패, 성공 안내 문구 표시
  • 내 취미 조회 API 구현
    • MY 뷰 구조 변경(이메일 -> 취미 표시)

Screenshot 📸

XRecorder_05112024_115538.mp4

Uncompleted Tasks 😅

  • MVI 적용

To Reviewers 📢

My뷰 같은 경우에는 BaseViewModel을 사용하기에는 아직 상호작용하는 부분이 많지 않아서 StateFlow를 이용해 간단하게 구현했습니다.
조만간 로그인 뷰와 회원가입 뷰에 MVI를 적용해 깔끔하게 로직을 수정하겠습니다.

@HAJIEUN02 HAJIEUN02 self-assigned this Nov 5, 2024
@HAJIEUN02 HAJIEUN02 linked an issue Nov 5, 2024 that may be closed by this pull request
12 tasks
@HAJIEUN02 HAJIEUN02 changed the title [WEEK3] 3주차 필수과제 구현 [WEEK4] 4주차 필수과제 구현 Nov 5, 2024
value = email,
onValueChange = { onEmailChange(it) }
value = username,
onValueChange = { onUsernameChange(it) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

람다 안에서 추가 동작이 필요하지 않으면 onValueChange = onUserNameChange처럼 바로 넘겨도 됩니다
이렇게 하면 Stable 상태로 봐서 퍼포먼스가 좋아진다고 하네요

val userHobby: StateFlow<String> = _userHobby

init {
getUserHobby()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ViewModel에서 init 사용이 좋지 않다고 하네요
UI초기화 할 때 오래 걸리거나 핸들링 하기 어렵고 테스트도 힘들다고 하더라고요

Copy link

@greedy0110 greedy0110 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

di에 network에 아키텍처 고민까지 정말 많은 고민하신게 보이네요. 고생많으셨습니다 지은님. 코멘트는 추가로 생각해 볼 만한 지점에만 남겨두었어요.

Comment on lines +21 to +23
override var accessToken: String
get() = pref.getString(ACCESS_TOKEN, "") ?: ""
set(value) = pref.edit { putString(ACCESS_TOKEN, value) }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

과제이므로 구현까지 하실 필요는 없는데요. 그냥 간단하게 리뷰 남깁니다.

SharedPreferences에 저장하는 값은 디바이스에 평문으로 저장됩니다. 지금 토큰을 평문으로 디바이스에 적고 있는 건데요. 이는 보안에 문제가 될 수 있습니다.
공격자가 이 토큰을 디바이스에서 얻어내는 순간 그 토큰을 가지고 서버에 유저인 척 요청을 보낼 수 있게 됩니다.

그러므로 이렇게 사용자를 식별할 수 있는 데이터는 왠만하면 별도의 암호화 과정을 거친 이후 디바이스에 저장해야 합니다. 이를 위해 암호화된 SharedPreferences 도 제공하고 있으니 확인 한번 해보셔요.
https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

암호화된 SharedPreference가 있는 것을 처음 알았는데 저도 리뷰를 보면서 함께 배워갑니다 감사합니다 :)

Comment on lines +9 to +10
@GET("/$USER/$HOBBY")
suspend fun getMyHobby(): NullableBaseRespone<ResponseHobbyDto>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 스타일에 대한 질문인데요.

이렇게 변수명으로 url을 적는 것이 좋은 방법인지는 고민해볼 주제인 듯 합니다.
보통 api 명세는 get /user/hobby 요청으로 사용자 취미 목록을 받는다.는 식으로 전달받게 될 텐데요.

지금과 같은 형태로 사용하면 /user/hobby 를 에디터에서 검색해봐도 getMyHooby 함수를 찾기는 어렵습니다.

또한, 나중에 /user/like 와 같은 새로운 요구사항이 들어올 때 작업자가 AuthService에 USER라는 필드가 있었다는 걸 인지하기도 어렵구요.

어떻게 판단하시나요?

Comment on lines +7 to +8
import org.sopt.and.presentation.util.BaseResponse
import org.sopt.and.presentation.util.NullableBaseRespone

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

패키지 구조를 나누고 계시는 것 같아서요.
그렇다면 data 모듈이 presentation 모듈에 의존성을 가지고 있는게 좋지 않아보입니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉 감사합니다! util 패키지를 프로젝트 단에 두는 것이 좋을까요 아니면 base 패키지를 만들어서 data 패키지 내부에 두는 것이 좋을까요?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 @HAJIEUN02 님이 어떤 아키텍처를 생각하시는지 몰라서요. 위치는 의도하시는 대로 두시면 될 것 같아요.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 data, domain, presentation 별로 각 레이어에 필요한 util 패키지를 따로 둡니다! 참고하셔요!

Copy link

@hyeeum hyeeum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저와 비슷한 구조로 작성하셔서 보는동안 편안했습니다..ㅎㅎ
역시 지은언니 네이밍 실력은 감탄만 나오네요 이런게 짬인가!!
그리고 유즈케이스 부분을 잘모르는데 보면서 이해하도록 해보겠습니다 : )
이번 과제도 수고많으셨어요 !!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유즈케이스 미미나 해주시면 안되나요

Comment on lines +21 to +23
override var accessToken: String
get() = pref.getString(ACCESS_TOKEN, "") ?: ""
set(value) = pref.edit { putString(ACCESS_TOKEN, value) }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

암호화된 SharedPreference가 있는 것을 처음 알았는데 저도 리뷰를 보면서 함께 배워갑니다 감사합니다 :)

Comment on lines +16 to +17
private val _userHobby = MutableStateFlow<String>("")
val userHobby: StateFlow<String> = _userHobby
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 화면에서 필요한 데이터값(상태값)등은 state data class를 통해 관리해보는 건 어떨까요?
뷰모델은 로직과 관련된 부분들에 집중하고 데이터 값들도 따로 관리해보면 좋을 것 같아요 : -)

Comment on lines +41 to +43
connectTimeout(10, TimeUnit.SECONDS)
writeTimeout(10, TimeUnit.SECONDS)
readTimeout(10, TimeUnit.SECONDS)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쇽샥해갈게요

@Auth
fun provideAuthInterceptor(interceptor: AuthInterceptor): Interceptor = interceptor

@ExperimentalSerializationApi
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 이 어노테이션은 어떻게 사용되는걸까요..! (진짜 몰라서 물어보는..)

import org.sopt.and.domain.model.UserLoginEntity
import org.sopt.and.domain.repository.AuthRepository

class PostUserLoginUseCase(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거는 저도 다른분께 배웠던 내용인데요!
domain의 경우 data/presentation layer를 몰라야하므로(분리되어야하므로)
post/patch 같이 data layer와의 의존도가 없는 네이밍을 하는 것을 지향한다고 합니다 : - )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅎ ㅓ ㄱ!!!!

Copy link

@kamja0510 kamja0510 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

대장님 mvi 가르침 받으러 가겠습니다.
코드 보는데 먼가 술술 하신 느낌이네요(장인 느낌)
고생하셨습니다~

set(value) = pref.edit { putString(USER_PASSWORD, value) }
override var accessToken: String
get() = pref.getString(ACCESS_TOKEN, "") ?: ""
set(value) = pref.edit { putString(ACCESS_TOKEN, value) }

companion object {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상수화 습관이 제대로...

override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val authRequest =
if (localStorage.isLogin) originalRequest.newAuthBuilder() else originalRequest

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

호옥시 여기 약간 자동로그인 구현한 부분인가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자동로그인 구현하려고 만들어놨는데 자동 로그인 구현은 아직 안했어요(?)
ㅋㅋㅋㅋㅋ이 인터셉터 코드 이용 + 로그인할 때 로컬에 isLogin = true로 변경해주기만 하면 구현 완성입니당


@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Auth

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 왜이렇게 하는거에요!

writeTimeout(10, TimeUnit.SECONDS)
readTimeout(10, TimeUnit.SECONDS)
addInterceptor(authInterceptor)
if (DEBUG) addInterceptor(loggingInterceptor)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이욜~ 디버그 할때만 하게 신경을 쓰시네요

Timber.tag("[회원가입]").d("이메일 변경 :${event.email}")
is RegisterContract.RegisterEvent.UsernameChanged -> {
setState(currentUiState.copy(username = event.username))
Timber.tag("[회원가입]").d("이름 변경 :${event.username}")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timber는 왜 사용하는거에요?

<string name="register_password">Wavve 비밀번호 설정</string>
<string name="register_password_information">비밀번호는 8~20자 이내로 영문 대소문자, 숫자, 특수문자 중 3가지 이상 혼용하여 입력해 주세요.</string>
<string name="register_title">이름과 비밀번호, 취미 입력만으로\nWavve를 즐길 수 있어요!</string>
<string name="register_username_hint">HA JIEUN</string>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엥 ㅋ

setState(currentUiState.copy(email = event.email))
Timber.tag("[회원가입]").d("이메일 변경 :${event.email}")
is RegisterContract.RegisterEvent.UsernameChanged -> {
setState(currentUiState.copy(username = event.username))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아무리 찾아봐도 currentUiState가 선언도 없고, UiState 파일도 없는데 currentUiState 어떻게 사용하는 거에요??

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseViewModel에 있을 듯한 느낌!

Timber.tag("[회원가입]").d("Email 검사 ${currentUiState.email}")
return currentUiState.email.matches(REGEX_EMAIL.toRegex())
}
private fun checkIsValidUsername(): Boolean = (currentUiState.username.length <= MAX_LENGTH)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빈칸으로 회원가입해도 되버릴거 같아요!(제 코드도 그럴 거 같은..)

localDataStorage.userPassword = currentUiState.password
Timber.tag("[회원가입]").d("비밀번호 저장 : ${localDataStorage.userPassword}")
}
private fun checkIsValidHobby(): Boolean = (currentUiState.hobby.length <= MAX_LENGTH)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isValid 3개가 결국 로직은 다 같으니까 하나로 묶는건 별로인가요?

Copy link

@1971123-seongmin 1971123-seongmin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다. 저랑 비슷한 구조로 만드셨는데 정말 많은 부분에서 배울점이 있었어요. 감사합니다.

@@ -2,6 +2,5 @@ package org.sopt.and.data.datasource.local

interface WaveLocalDataSource {
var isLogin: Boolean
var userEmail: String
var userPassword: String
var accessToken: String

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰을 따로 인터페이스로 정의해 두셨네요. 혹시 이유를 알려주실 수 있으실까요??


companion object {
const val FILE_NAME = "AuthSharedPreferences"
const val AUTO_LOGIN = "AutoLogin"
const val USER_EMAIL = "UserEmail"
const val USER_PASSWORD = "UserPassword"
const val ACCESS_TOKEN = "AccessToken"
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상수화 정말 철저하시네요. 네이밍도 완전 직관적이라 좋아요

override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val authRequest =
if (localStorage.isLogin) originalRequest.newAuthBuilder() else originalRequest

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 이 부분 간단히 설명부탁드려도 될까요??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래 Request.newAuthBuilder() 함수를 이용해서 Header에 토큰을 넣잖아요?! 근데 isLogin이 true(로그인된 유저)이냐 false(로그인 안된 유저)이냐에 따라서 request header에 token을 넣어주냐 안 넣어주냐를 구분한 거에요! 로그인한 유저의 경우에만 토큰이 존재할테니까용

) : MyRepository {
override suspend fun getMyHobby(): Result<UserHobbyEntity> =
runCatching {
myRemoteDataSource.getMyHobby().result.toUserHobbyEntity()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 하면 한줄로 표현할 수 있군요. 잘 배워가요

writeTimeout(10, TimeUnit.SECONDS)
readTimeout(10, TimeUnit.SECONDS)
addInterceptor(authInterceptor)
if (DEBUG) addInterceptor(loggingInterceptor)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

디버그 할 때만 할 수 있는지 몰랐는데 배워갑니다

username = username,
password = userPassword,
hobby = userHobby
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objcet에 mapper 모아서 정의해 두는 방법보다 이게 더 좋을까요??

with(waveLocalDataStorage) {
accessToken = token
isLogin = true
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

뷰모델에 토큰을 저장하는 부분을 따로 두셨네요. 혹시 어디에서 사용하기 위해 추가하셨나요??

Copy link
Contributor

@jihyunniiii jihyunniiii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나 영화 보러 간다 ㅋㅋ
이번주도 고생하셨습니다!
합세 파이띵 !!!

Comment on lines +7 to +8
import org.sopt.and.presentation.util.BaseResponse
import org.sopt.and.presentation.util.NullableBaseRespone
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 data, domain, presentation 별로 각 레이어에 필요한 util 패키지를 따로 둡니다! 참고하셔요!

Comment on lines +9 to +10
@SerialName("no")
val userId : Long
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋네오

import retrofit2.http.POST

interface AuthService {
@POST("/$USER")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base Url에 /까지 추가해주시는 것도 좋을 것 같아요 !

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BINDS@provides의 차이는 무엇일까요?

Comment on lines +9 to +12
fun toRequestUserLoginDto() = RequestUserLoginDto(
username = username,
password = userPassword
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapper 함수를 여기에 선언하게 되면서 domain레이어가 data 레이어에 의존성을 가지게 되었어요!
클린 아키텍처에서 의존성의 방향은 어떻게 되어야 할까요?
이를 지키기 위해선 어떤 식으로 수정을 해야할까요?

import org.sopt.and.domain.model.UserLoginEntity
import org.sopt.and.domain.repository.AuthRepository

class PostUserLoginUseCase(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅎ ㅓ ㄱ!!!!

@@ -2,12 +2,16 @@ package org.sopt.and.presentation.ui.auth.login

import dagger.hilt.android.lifecycle.HiltViewModel
import org.sopt.and.data.datasource.local.WaveLocalDataSource
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 ~ 클린 아키텍처에서 의존성에 대해 고민해봅시다 !

setState(currentUiState.copy(email = event.email))
Timber.tag("[회원가입]").d("이메일 변경 :${event.email}")
is RegisterContract.RegisterEvent.UsernameChanged -> {
setState(currentUiState.copy(username = event.username))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseViewModel에 있을 듯한 느낌!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어디까지가 비즈니스 로직인지 고민해보면 좋을 것 같아요 ~ (어디까지를 뷰모델에 담고 어디까지를 domain에 담을지 !!)
물론 이번주 세미나에서 고민해보는 시간? 알려주는 시간? 을 가질 것임

import kotlinx.serialization.Serializable

@Serializable
data class BaseResponse<T>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BaseResponse에 대해서 고민해봅시다.
지금 같은 명세에서는 어떤 걸 BaseResponse의 형태로 쓰면 좋을지를요

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[WEEK4] 4주차 필수과제 구현
7 participants