[척척학사] 소셜 로그인 정보 변경으로 인한 중복 계정 생성 문제, 어떻게 해결했을까?

박상민·2025년 10월 12일
0

척척학사

목록 보기
17/17
post-thumbnail

📎 관련 Github PR: #120
📎 관련 Github Issue: #99


문제 상황

다음과 같은 실제 상황을 가정해보겠습니다.

  • 사용자 A는 카카오 계정 test@naver.com 으로 최초 로그인한 뒤, 포털 연동을 완료했습니다.
  • 이후, 사용자 A는 카카오 계정 이메일을 test@kakao.com으로 변경한 뒤, 다시 척척학사 서비스에 로그인했습니다.

그러나,

  • 기존 사용자임에도 다시 포털 연동을 요구받고,
  • 포털 연동 과정에서 예외가 발생하며 진행이 되지 않았습니다.

왜 이런 일이 벌어진 걸까요?


기존 구조

기존 로그인 흐름은 아래와 같았습니다.

  • OIDC 로그인 시, email 필드를 기반으로 User를 조회하거나 생성했습니다.
  • 포털 연동을 성공하면, 해당 User와 @OneToOne 관계로 Student를 연결했습니다.
@Entity
@Table(name = "students")
public class Student {
    @Column(name = "student_code", nullable = false, unique = true)
    private String studentCode; // 학번
}
  • Student 테이블의 studentCode 필드는 UNIQUE 제약 조건이 걸려 있었습니다.

문제 원인

  • 소셜 로그인 시 email을 기준으로만 유저를 식별했기 때문에,
  • 이메일이 바뀌면 새로운 User가 생성되었습니다.
  • 그런데 포털 연동 시도 → 동일한 studentCode로 Student 생성 시도 → ❌ UNIQUE 제약 조건 위반!

ERROR:

ERROR: duplicate key value violates unique constraint "uk_student_code"

🙅 이 문제는 이메일 변경뿐만 아니라 아래의 경우에도 발생할 수 있습니다:

  • 기존에 카카오 로그인으로 서비스 이용 중 → 애플 로그인으로 다시 로그인
  • 두 provider에서 동일한 사용자인데도 서로 다른 User로 인식 → 중복 Student 생성 시도 → 예외 발생

개선 방향: SocialAccount 도입

이 문제를 해결하기 위해 SocialAccount 테이블을 도입했습니다.

목적

  • 유저 식별 책임을 User.email이 아닌 (provider + socialId)로 위임
  • 하나의 User가 여러 소셜 계정을 가질 수 있도록 구조 변경

설계 구조

@Entity
@Table(name = "social_accounts", uniqueConstraints = {
    @UniqueConstraint(name = "uk_provider_social_id", columnNames = {"provider", "social_id"})
})
public class SocialAccount {

    @Enumerated(EnumType.STRING)
    private OidcProvider provider;

    @Column(name = "social_id", nullable = false)
    private String socialId;

    @Column(name = "email", nullable = true)
    private String email;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public void updateUser(User user) {
        this.user = user;
    }
}
  • (provider + socialId) 로만 유저 식별
  • email은 nullable로 허용 (중복 허용)
  • 하나의 User에 여러 SocialAccount 연결 가능 (N:1)

병합 로직

@Transactional
public User tryMergeWithExistingUser(UUID currentUserId, String studentCode) {
    User currentUser = getUserById(currentUserId);
    Optional<User> existingUserOpt = userRepository.findByStudent_StudentCode(studentCode);

    if (existingUserOpt.isEmpty()) {
        return currentUser;
    }

    User existingUser = existingUserOpt.get();

    // 기존 유저로 소셜 계정 이전
    List<SocialAccount> accounts = socialAccountRepository.findAllByUserId(currentUserId);
    for (SocialAccount sa : accounts) {
        sa.updateUser(existingUser);
    }

    // 임시 유저 제거
    userRepository.delete(currentUser);
    log.info("[BIZ] user.merged currentUserId={} → existingUserId={}", currentUserId, existingUser.getId());

    return existingUser;
}

개선 후 구조 흐름

  1. 카카오 로그인
    • (provider=KAKAO, socialId=xxx)로 SocialAccount 생성
    • 연결된 User가 없다면 임시 User 생성
  2. 포털 연동
    • 학번(studentCode)로 기존 User 있는지 조회
    • ✅ 있다면: 현재 임시 User의 SocialAccount를 기존 User로 병합, 임시 User 삭제
    • ❌ 없다면: Student 새로 생성 후 현재 User에 연결
  3. 애플 로그인
    • (provider=APPLE, socialId=yyy)로 로그인
    • 이전에 포털 연동한 User가 있으면 거기에 SocialAccount만 추가

마무리 및 결론

이번 구조 개선을 통해 다음과 같은 이점을 얻었습니다.

  • 소셜 로그인 수단 변경에도 기존 계정 유지
  • 포털 연동은 최초 1회만, 재사용 가능
  • 이메일 중복 또는 변경으로 인한 예외 제거
  • 소셜 Provider 확장에 대한 대비 (Apple, Naver 등)

기존에는 '이메일'이 식별 기준이었다면,
이제는 '소셜 Provider + 고유 ID(socialId)'가 확실한 유저 구분 기준입니다.
앞으로 소셜 로그인 확장에 있어서도 더 이상 충돌 없이 안정적인 확장이 가능합니다.

0개의 댓글