마이페이지 - 로그아웃 & 회원 탈퇴

변현섭·2023년 9월 4일
0

드디어 마이페이지 관련 기능을 마무리하는 단계까지 왔습니다. 이번 포스팅에서는 로그아웃과 유저 탈퇴 기능을 추가해보도록 하겠습니다. 로그아웃과 회원 탈퇴에는 Redis 데이터베이스가 이용될 예정입니다. Redis에 대해 잘 모르신다면, 아래의 링크를 참조해주세요.
>> Redis 데이터베이스

5. 로그아웃

1) 백엔드

① 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);
    }
}

2) 프론트엔드

① 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)
    }
}
  • 참고로, 로그아웃할 때 Firebase.auth.signOut()은 사용하지 않는다. 이유는 currentUser가 null이 되면서, 다시 로그인할 때 uid가 null이 되는 문제가 발생하기 때문이다.
  • 토큰을 Redis에 올리는 것만으로 로그아웃은 충분하기 때문에 굳이 필요없는 Firebase.auth.signOut()을 쓰기 위해 코드를 추가할 이유는 없을 것 같다.

이제 코드를 실행시켜보자. 로그아웃 > OK 버튼을 클릭해보면, LoginActivity로 이동될 것이다. 해당 로그아웃 유저의 토큰은 DB에서 삭제되고, Redis에 Black Token으로 올라간다.

6. 회원 탈퇴

1) 백엔드

① 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;
}

2) 프론트엔드

① 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)
}

이제 로그인 페이지에서 "아직 계정이 없으신가요?"라는 텍스트를 클릭하면 회원가입 페이지로 이동될 것이다.

profile
Java Spring, Android Kotlin, Node.js, ML/DL 개발을 공부하는 인하대학교 정보통신공학과 학생입니다.

0개의 댓글