최원종의 개발 블로그

V14 소셜로그인(카카오) 리팩토링 본문

Spring boot 입문/Spring boot 입문 -1

V14 소셜로그인(카카오) 리팩토링

chl6698 2026. 6. 9. 10:46

카카오 소셜 로그인


 

카카오 소셜 로그인 리팩토링

- Controller → Service 로 책임 이동 (카카오소셜로그인 한 곳에서 전체 흐름 관리)
- 토큰 발급, 프로필 조회, 사용자 생성/조회 로직을 메서드로 분리해 가독성과 재사용성 향상.

UserController 코드 수정

// 1. 동의 항목 승인 이후 카카오 인가 서버에서 인가 코드가 리다이렉트 됨.
@GetMapping("/kakao-redirect")
public String kakaoCallback(@RequestParam(name = "code") String code, HttpSession session) {
    // 외부 통신을 할 때 예외처리 권장 사항
    try {
        User sessionUser = userService.카카오소셜로그인(code);
        // 우리 서버 세션에 회원 정보 저장해야 로그인 처리 됨.
        session.setAttribute(Define.SESSION_USER, sessionUser);
    } catch (Exception e) {
        log.error("카카오 로그인 실패 " + e.getMessage());
        throw new Exception401("소셜 로그인 실패");
    }
    return  "redirect:/";
}

UserService 코드(초기 파라미터 선언)

@Slf4j
@Service // IoC
@RequiredArgsConstructor // DI
@Transactional(readOnly = true) // 기본적인 읽기 전용 트랜잭션 처리 , 조회시 더티 체킹 안 일어남
public class UserService {

    private final UserRepository userRepository;
    // DI
    private final PasswordEncoder passwordEncoder;

    // 초기 파라미터 값을 가져 오는 방법
    @Value("${oauth.kakao.client-id}")
    private String kakaoClientId;

    @Value("${oauth.kakao.client-secret}")
    private String kakaoClientSecret;

    @Value("${tenco.key}")
    private String tencoKey;
    
    //... 생략

1. 카카오소셜로그인 틀 작업

// 0
@Transactional
public User 카카오소셜로그인(String code) {

    // 1. 발급 받은 인가 코드로 액세스 토큰 발급 요청
    UserResponse.OAuthToken oAuthToken  = 카카오액세스토큰발급(code);

    // 2. 발급 받은 액세스 토큰으로 사용자 카카오 프로필 조회
    UserResponse.KakaoProfile kakaoProfile =  카카오프로필조회(oAuthToken.getAccessToken());

    // 3. 응답 받은 결과로 우리 서버에 가입 여부 조회 및 자동 회원 가입 처리
    User userEntity = 카카오조회및자동회원가입처리(kakaoProfile);

    // 4. 컨트롤러로 User 반환
    return userEntity;
}

2. 카카오액세스토큰발급

// 1
    private UserResponse.OAuthToken 카카오액세스토큰발급(String code) {
        RestTemplate restTemplate1 = new RestTemplate();

        HttpHeaders headers1 = new HttpHeaders();
        headers1.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

        LinkedMultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap();
        multiValueMap.add("grant_type", "authorization_code");
        multiValueMap.add("client_id", kakaoClientId);
        multiValueMap.add("redirect_uri", "http://localhost:8080/kakao-redirect");
        multiValueMap.add("code", code);
        multiValueMap.add("client_secret", kakaoClientSecret);

        // 순서 중요 : 바디 + 헤더 결합 ( HTTP 요청 메세지 구축)
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity(multiValueMap, headers1);

        // HTTP 요청 후 응답
        ResponseEntity<UserResponse.OAuthToken> response1 = restTemplate1.exchange(
                "https://kauth.kakao.com/oauth/token",
                HttpMethod.POST,
                request,
                UserResponse.OAuthToken.class
        );
        UserResponse.OAuthToken oAuthToken = response1.getBody();
        return oAuthToken;
    }

3. 카카오 프필 조회 코드

// 2
private UserResponse.KakaoProfile 카카오프로필조회(String token) {
    String accessToken = token;
    RestTemplate restTemplate2 = new RestTemplate();

    HttpHeaders headers2 = new HttpHeaders();
    // 주의! 반드시 Bearer + "공백한칸" + 토큰
    headers2.add("Authorization", "Bearer " + accessToken);
    headers2.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

    HttpEntity request2 = new HttpEntity(headers2);

    // HTTP 요청 2
    ResponseEntity<UserResponse.KakaoProfile> response2 = restTemplate2.exchange(
            "https://kapi.kakao.com/v2/user/me",
            HttpMethod.POST,
            request2,
            UserResponse.KakaoProfile.class
    );
    UserResponse.KakaoProfile  kakaoProfile = response2.getBody();
    return kakaoProfile;
}

4. 카카오조회 및 자동 회원가입 처리

// 3
    private User 카카오조회및자동회원가입처리(UserResponse.KakaoProfile kakaoProfile) {
        // 고유한 username 생성(중복 방지 용)
        String username = kakaoProfile.getKakaoAccount().getProfile().getNickname() + "_" + kakaoProfile.getId();
        // 회원 가입 여부 확인
        User user = 사용자이름조회(username);

        if(user == null) {
            log.info("기본 회원이 아님 자동 회원 가입 진행");
            User newUser = User.builder()
                    .username(username)
                    .password(passwordEncoder.encode(tencoKey)) // 임시 비밀번호 (노출 절대 금지)
                    .email(username + "@kakao.com") // 임의 이메일 설정 (추후 DB 제약 방지)
                    .oAuthProvider(OAuthProvider.KAKAO) // 로그인 경로 설정
                    .build();

            String profileImage = kakaoProfile.getKakaoAccount().getProfile().getProfileImageUrl();
            if(profileImage != null && !profileImage.isEmpty()) {
                newUser.setProfileImage(profileImage); // 카아오 서버에서 받은 이미지 설정
            }
            // 회원 가입 후 상태 저장
            user = userRepository.save(newUser);
        } else {
            System.out.println("이미 가입된 사용자라 바로 로그인 처리");
        }
        return user;
    }

User 에 빌더 생성자 수정 코드

@Builder
public User(Integer id, String username, String password,
            String email, Timestamp createdAt,
            String profileImage, OAuthProvider oAuthProvider, List<UserRole> roles) {
    this.id = id;
    this.username = username;
    this.password = password;
    this.email = email;
    this.createdAt = createdAt;
    this.profileImage = profileImage;

    this.oAuthProvider = (oAuthProvider != null) ? oAuthProvider : OAuthProvider.LOCAL;

    // 1. roles(ArrayList 타입 임) 가 null 빈 리스트로 초기회 (NPE 방지 처리)
    this.roles = (roles != null) ? roles : new ArrayList<>();

    // 2. roles 가 비어있으면 - USER 기본 권한으로 자동 설정
    if(this.roles.isEmpty()) {
        this.roles.add(UserRole.builder().role(Role.USER).build());
    }
}

UserService 에서 회원가입 부분 수정 (중복 선언 제거)

/**
     * 회원 가입 처리
     * @param joinDTO (사용자 회원가입 요청 정보)
     * @return User (저장된 사용자 정보)
     */
    @Transactional
    public User 회원가입(UserRequest.JoinDTO joinDTO) {
        log.info("회원가입 서비스 시작");
        // 회원가입시 사용자 이름 중복 체크
        userRepository.findByUsername(joinDTO.getUsername()).ifPresent(user -> {
            log.warn("회원가입 실패 - 중복된 사용자명 : {}", user.getUsername());
            throw new Exception400("이미 존재하는 사용자 이름입니다");
        });
        // 프로필 이미지 저장 기능 구현 (선택 사항 임)
        String profileImageFilename = null;
        if(joinDTO.getProfileImage() != null && joinDTO.getProfileImage().isEmpty() == false) {
            try {
                // 이미지 파일이 맞는지 검증
                if(FileUtil.isImageFile(joinDTO.getProfileImage()) == false) {
                    throw new Exception400("이미지 파일만 업로드 가능합니다");
                }
                profileImageFilename = FileUtil.saveFile(joinDTO.getProfileImage(), FileUtil.IMAGES_DIR);
            } catch (Exception e) {
                // 디스크 공간 없거나, 권한 없음
                throw new Exception500("프로필 이미지 저장 실패");
            }
        }
        // 코드 수정
        User user = joinDTO.toEntity(profileImageFilename);
        String hashPwd = passwordEncoder.encode(joinDTO.getPassword());
        user.setPassword(hashPwd);
        return userRepository.save(user);
    }

application-dev.yml 파일 추가 코드

 servlet:
    # URL 에 jsessionid 가 노출 되는 것 방지
    session:
      tracking-modes: cookie

 

application-dev.yml 전체 코드⬇️

더보기
server:
  servlet:
    # USL 에 jsessionid 가 노출 되는것 방지
    session:
      tracking-modes: cookie
    encoding:
      charset: utf-8
      force: true
    #파일 업로드 설정
    multipart:
      enabled: true #파일 업로드 활성화
      max-file-size: 50MB #단일 파일 최대 크기 설정 (1MB)
      max-request-size: 100MB # 전체 요청(Request) 최대 크기 설정
  port: 8080

logging:
  level:
    root: INFO #모든 라이브러리는 INFO 이상만 출력
    com.tenco: DEBUG # 내 프로젝트는 DEBUG 이상 모두 출력

spring:
  mustache:
    servlet:
      # 머스태치 템플릿 엔진에서 request 객체와 세션 객체에 접근할 수 있도록 허용하는 설정 추가
      expose-session-attributes: false
      expose-request-attributes: true


  #데이터베이스 연결 설정 (MySQL)
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/myblog?serverTimezone=Asia/Seoul
    username: root
    password: root
  #    driver-class-name: org.h2.Driver
  #    url: jdbc:h2:mem:test
  #    username: sa
  #    password:
  h2:
    console:
      enabled: true

  #      초기 데이터 설정
  # SQL 초기화 설정
  sql:
    init:
      mode: always  # 외부 DB(MySQL)에서도 실행되도록 설정
      #mode: never # 절대 실행 안됨
      data-locations:
        - classpath:db/data.sql

  jpa:
    # Open Session In View -> false 설정
    # true (기본 값) -> 뷰 렌더링까지 DB 세션 유지 됨(LAZY 로딩 가능)
    # false : 트랜잭션 종료시 DB 세션 종료 ( Controller, View LAZY 로딩 불가)
    # false 사용시 Service 필요한 데이터를 모두 조회하고 응답시 DTO 설계 방식으로 처리 한다.
    open-in-view: false
    hibernate:
      # create 애플리케이션 시작시 테이블 새로 생성
      # 기존 데이터는 모드 삭제됨 (개발용)
      ddl-auto: create
    #SQL 쿼리를 콘솔에 출력 (개발용)
    show-sql: true
    properties:
      hibernate:
        # SQL 쿼리를 보기 좋게 포맷팅
        format_sql: true
        # N + 1 문제를 완화 하기위한 완충 장치 설정
        # 기본 WHERE 절에서 in 절(in 쿼리로 변경 해준다)
        default_batch_fetch_size: 100
    # data.sql 파일을 하이버네티트 초기화 이후에 실행

    # data.sql 파일을 Hibernate 초기화 이후에 실행
    defer-datasource-initialization: true

  # 이메일 발송 설정
  mail:
    # SMTP 서버 호스트 주소 (Google의 경우 smtp.gmail.com 고정)
    host: smtp.gmail.com
    # SMTP 서버 포트 번호 (587: TLS(Transport Layer Security) 암호화 통신 전용 포트)
    # 인터넷으로 정보를 주고받을 때 정보를 암호화해서 아무도 훔쳐보지 못하게 하는 보안 기술
    port: 587
    # 발신자 계정 (본인의 전체 Gmail 주소)
    username: chl66953@gmail.com
    # 발신자 인증 키 (Google 보안 설정에서 생성한 16자리 앱 비밀번호)
    # 주의: 일반 구글 로그인 비밀번호가 아님
    password: buxdtqfsjsxlxydf
    # JavaMail 추가 속성 설정
    properties:
      mail:
        smtp:
          # SMTP 서버 인증 활성화 (ID/PW 검사 여부)
          auth: true
          # STARTTLS 설정 (보안 연결)
          starttls:
            # 비암호화 연결을 암호화 연결(TLS)로 승격 활성화
            enable: true
            # TLS 연결을 강제할지 여부 (보안상 true 권장)
            required: true
          # [타임아웃 설정 - 서버 간 통신 기준]
          # 소켓 연결 제한 시간 (단위: 밀리초)
          # 서버에 접속을 시도할 때 5초(5000ms) 동안 응답이 없으면 연결 실패 처리
          connectiontimeout: 5000
          # 소켓 읽기 제한 시간 (단위: 밀리초)
          # 메일 전송 요청 후 서버로부터 응답(Response)을 기다리는 최대 시간
          timeout: 5000
          # 소켓 쓰기 제한 시간 (단위: 밀리초)
          # 데이터를 서버로 전송할 때 걸리는 최대 허용 시간
          writetimeout: 5000