드디어 마이페이지 관련 기능을 마무리하는 단계까지 왔습니다. 이번 포스팅에서는 로그아웃과 유저 탈퇴 기능을 추가해보도록 하겠습니다. 로그아웃과 회원 탈퇴에는 Redis 데이터베이스가 이용될 예정입니다. Redis에 대해 잘 모르신다면, 아래의 링크를 참조해주세요.
>> Redis 데이터베이스
① UserController에 아래의 API를 추가한다.
/**
* 로그아웃
*/
@PostMapping("/log-out") // Redis가 켜져있어야 동작한다.
public BaseResponse<String> logoutUser() {
try {
Long userId = jwtService.getLogoutUserIdx(); // 토큰 만료 상황에서 로그아웃을 시도하면 0L을 반환
return new BaseResponse<>(userService.logout(userId));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
② UserService에 아래의 메서드를 추가한다.
/**
* 로그아웃
*/
@Transactional
public String logout(Long userId) throws BaseException {
try {
if (userId == 0L) { // 로그아웃 요청은 access token이 만료되더라도 재발급할 필요가 없음.
User user = tokenRepository.findUserByAccessToken(jwtService.getJwt()).orElse(null);
if (user != null) {
Token token = tokenRepository.findTokenByUserId(user.getId()).orElse(null);
tokenRepository.deleteTokenByAccessToken(token.getAccessToken());
return "로그아웃 되었습니다.";
}
else {
throw new BaseException(BaseResponseStatus.INVALID_JWT);
}
}
else { // 토큰이 만료되지 않은 경우
User logoutUser = utilService.findByUserIdWithValidation(userId);
Token token = utilService.findTokenByUserIdWithValidation(logoutUser.getId());
String accessToken = token.getAccessToken();
//엑세스 토큰 남은 유효시간
Long expiration = jwtProvider.getExpiration(accessToken);
//Redis Cache에 저장
redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS);
//리프레쉬 토큰 삭제
tokenRepository.deleteTokenByUserId(logoutUser.getId());
return "로그아웃 되었습니다.";
}
} catch (Exception e) {
throw new BaseException(BaseResponseStatus.FAILED_TO_LOGOUT);
}
}
① layout 디렉토리 하위로, logout_dialog라는 리소스 파일을 추가하자.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_margin="20dp"
android:text="로그아웃 하시겠습니까?"
android:textSize="30sp"
android:textColor="#000000"
android:gravity="center"
android:background="@android:color/transparent"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="100dp">
<Button
android:id="@+id/ok"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginTop="15dp"
android:layout_marginLeft="50dp"
android:layout_marginRight="30dp"
android:background="@drawable/main_border"
android:text="OK"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/cancel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintHorizontal_weight="1" />
<Button
android:id="@+id/cancel"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginTop="15dp"
android:layout_marginRight="50dp"
android:background="@drawable/main_border"
android:text="Cancel"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/ok"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintHorizontal_weight="1" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
② MyPageApi 인터페이스에 아래의 API를 추가한다.
@POST("/users/log-out")
suspend fun logoutUser(
@Header("Authorization") accessToken : String
): BaseResponse<String>
③ MyPageFragment를 아래와 같이 수정한다.
class MyPageFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
val view = inflater.inflate(R.layout.fragment_my_page, container, false)
val nickName = view.findViewById<Button>(R.id.nickNameBtn)
nickName.setOnClickListener {
val intent = Intent(requireActivity(), NickNameActivity::class.java)
startActivity(intent)
}
val password = view.findViewById<Button>(R.id.passwordBtn)
password.setOnClickListener {
val intent = Intent(requireActivity(), PasswordActivity::class.java)
startActivity(intent)
}
val profile = view.findViewById<Button>(R.id.profileBtn)
profile.setOnClickListener {
val intent = Intent(requireActivity(), ProfileActivity::class.java)
startActivity(intent)
}
val logout = view.findViewById<Button>(R.id.logoutBtn)
logout.setOnClickListener {
val dialogView = LayoutInflater.from(requireActivity()).inflate(R.layout.logout_dialog, null)
val builder = AlertDialog.Builder(requireActivity())
.setView(dialogView)
.setTitle("로그아웃")
val alertDialog = builder.show()
val ok = alertDialog.findViewById<Button>(R.id.ok)
ok.setOnClickListener {
getAccessToken { accessToken ->
if (accessToken.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch {
val response = logoutUser(accessToken)
if (response.isSuccess) {
withContext(Dispatchers.Main) {
Toast.makeText(
requireActivity(), "로그아웃 되었습니다.", Toast.LENGTH_SHORT
).show()
val intent = Intent(requireActivity(), LoginActivity::class.java)
startActivity(intent)
}
} else {
Log.d("MyPageFragment", "로그아웃 실패")
val message = response.message
Log.d("PasswordActivity", message)
withContext(Dispatchers.Main) {
Toast.makeText(
requireActivity(), message, Toast.LENGTH_SHORT
).show()
}
}
}
} else {
Log.e("MyPageFragment", "Invalid Token")
}
}
}
val cancel = alertDialog.findViewById<Button>(R.id.cancel)
cancel.setOnClickListener {
alertDialog.dismiss()
}
}
val freind = view.findViewById<ImageView>(R.id.freind)
freind.setOnClickListener {
it.findNavController().navigate(R.id.action_myPageFragment_to_userListFragment)
}
val chat = view.findViewById<ImageView>(R.id.chat)
chat.setOnClickListener {
it.findNavController().navigate(R.id.action_myPageFragment_to_chatListFragment)
}
return view
}
private suspend fun logoutUser(accessToken : String): BaseResponse<String> {
return RetrofitInstance.myPageApi.logoutUser(accessToken)
}
private fun getAccessToken(callback: (String) -> Unit) {
val postListener = object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
val accessToken = data?.accessToken ?: ""
callback(accessToken)
}
override fun onCancelled(databaseError: DatabaseError) {
Log.w("NickNameActivity", "onCancelled", databaseError.toException())
}
}
FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
}
}
이제 코드를 실행시켜보자. 로그아웃 > OK 버튼을 클릭해보면, LoginActivity로 이동될 것이다. 해당 로그아웃 유저의 토큰은 DB에서 삭제되고, Redis에 Black Token으로 올라간다.
① User Controller에 아래의 API를 추가한다.
/**
* 유저 탈퇴
*/
@DeleteMapping("")
public BaseResponse<String> deleteUser(@RequestParam String agreement){
try{
Long userId = jwtService.getUserIdx();
return new BaseResponse<>(userService.deleteUser(userId, agreement));
} catch(BaseException exception){
return new BaseResponse<>(exception.getStatus());
}
}
② UserService에 아래의 메서드를 추가한다.
/**
* 유저 탈퇴
*/
@Transactional
public String deleteUser(Long userId, String agreement) throws BaseException{
if(!agreement.equals("I agree")) {
throw new BaseException(BaseResponseStatus.AGREEMENT_MISMATCH);
}
User user = utilService.findByUserIdWithValidation(userId);
tokenRepository.deleteTokenByUserId(userId);
Profile profile = profileRepository.findProfileById(userId).orElse(null);
if(profile != null) {
profileService.deleteProfile(profile);
profileRepository.deleteProfileById(userId);
}
userRepository.deleteUser(userId);
String result = "요청하신 회원에 대한 삭제가 완료되었습니다.";
return result;
}
① mypage 패키지 하위로, WithdrawalActivity를 추가한다.
② activity_withdrawal.xml 파일에 아래의 내용을 입력한다.
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/main_border"
tools:context=".mypage.WithdrawalActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="100dp"
android:src="@drawable/mypage"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="500dp">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="35dp"
android:layout_marginTop="120dp"
android:gravity="center"
android:text='회원 탈퇴를 원하시면,\n"I agree"를 입력해주세요.'
android:textColor="@color/black"
android:textSize="25sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.571"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.045" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/agreement"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginHorizontal="50dp"
android:gravity="center"
android:background="@drawable/main_border"
android:hint="I agree"
android:padding="5dp"
android:textColorHint="#808080"
android:textSize="30sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.562"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.501" />
<Button
android:id="@+id/check"
android:layout_width="150dp"
android:layout_height="60dp"
android:layout_marginBottom="72dp"
android:background="@color/skyBlue"
android:text="확인"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</ScrollView>
③ 마이페이지에서 회원 탈퇴 버튼을 클릭했을 때, 회원 탈퇴 페이지로 전환되도록 MyPageFragment의 onCreateView에 아래의 내용을 추가한다.
val quit = view.findViewById<Button>(R.id.quitBtn)
quit.setOnClickListener {
val intent = Intent(requireActivity(), WithdrawalActivity::class.java)
startActivity(intent)
}
④ MyPageApi 인터페이스에 아래의 API를 추가한다.
@DELETE("/users")
suspend fun deleteUser(
@Header("Authorization") accessToken : String,
@Query("agreement") agreement : String
): BaseResponse<String>
⑤ WithdrawalActivity에 아래의 내용을 입력한다.
class WithdrawalActivity : AppCompatActivity() {
private lateinit var auth: FirebaseAuth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_withdrawal)
auth = Firebase.auth
val withdrawal = findViewById<Button>(R.id.check)
withdrawal.setOnClickListener {
val agreement = findViewById<TextInputEditText>(R.id.agreement)
val agreementStr = agreement.text.toString()
getAccessToken { accessToken ->
if (accessToken.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch {
val response = deleteUser(accessToken, agreementStr)
if (response.isSuccess) {
FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).removeValue()
FirebaseAuth.getInstance().currentUser?.delete()
withContext(Dispatchers.Main) {
Toast.makeText(this@WithdrawalActivity, "회원 탈퇴가 완료되었습니다", Toast.LENGTH_SHORT).show()
val intent = Intent(this@WithdrawalActivity, IntroActivity::class.java)
startActivity(intent)
}
}
else {
Log.d("WithdrawalActivity", "회원 탈퇴 실패")
val message = response.message
Log.d("WithdrawalActivity", message)
withContext(Dispatchers.Main) {
Toast.makeText(this@WithdrawalActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
} else {
Log.e("WithdrawalActivity", "Invalid Token")
}
}
}
}
private fun getAccessToken(callback: (String) -> Unit) {
val postListener = object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
val accessToken = data?.accessToken ?: ""
callback(accessToken)
}
override fun onCancelled(databaseError: DatabaseError) {
Log.w("WithdrawalActivity", "onCancelled", databaseError.toException())
}
}
FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
}
private suspend fun deleteUser(accessToken : String, agreement : String) : BaseResponse<String> {
return RetrofitInstance.myPageApi.deleteUser(accessToken, agreement)
}
}
코드를 실행시켜보자. 회원 탈퇴 시, 해당 유저에 대한 정보가 RDS, Realtime Database, Authentication에서 모두 없어져야 한다. 또한 탈퇴 이후에는 IntroActivity로 전환되어야 한다.
로그아웃을 하면, 로그인 화면으로 전환된다. 그러나 이렇게 구현하면, 로그인 화면에서 바로 회원가입을 갈 방법이 없기 때문에 새로운 계정을 생성하고자 하는 사람이 어려움을 겪게 될 수 있다. 따라서 LoginActivity에서 JoinActivity로 이동할 수 있는 버튼을 만들어주는 게 좋다.
① activity_login.xml 파일에 맨 아래 부분에 아래의 TextView 태그를 추가한다.
<TextView
android:id="@+id/noAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="아직 계정이 없으신가요?"
android:textSize="15sp"
android:textColor="#000000"
android:layout_marginTop="20dp"
android:gravity="center"/>
② LoginActivity에서 JoinActivity로 넘어갈 수 있도록 위 TextView의 클릭 이벤트 리스너를 등록하자.
val noAccountBtn = findViewById<TextView>(R.id.noAccount)
noAccountBtn.setOnClickListener {
val intent = Intent(this, JoinActivity::class.java)
startActivity(intent)
}
이제 로그인 페이지에서 "아직 계정이 없으신가요?"라는 텍스트를 클릭하면 회원가입 페이지로 이동될 것이다.