하석형 하석형 06-20
Merge branch 'master' of http://210.180.118.83/jhpark/cms_backend
@fec68772e0755c84d9c20382117f28f70188f35a
src/main/java/com/takensoft/cms/mber/service/Impl/LgnHstryServiceImpl.java
--- src/main/java/com/takensoft/cms/mber/service/Impl/LgnHstryServiceImpl.java
+++ src/main/java/com/takensoft/cms/mber/service/Impl/LgnHstryServiceImpl.java
@@ -1,37 +1,49 @@
 package com.takensoft.cms.mber.service.Impl;
 
-
 import com.takensoft.cms.codeManage.service.CodeManageService;
 import com.takensoft.cms.codeManage.vo.CodeManageVO;
 import com.takensoft.cms.mber.dao.LgnHstryDAO;
 import com.takensoft.cms.mber.service.LgnHstryService;
 import com.takensoft.cms.mber.vo.LgnHstryVO;
+import com.takensoft.cms.mber.vo.MberVO;
 import com.takensoft.common.Pagination;
 import com.takensoft.common.exception.CustomInsertFailException;
+import com.takensoft.common.util.HttpRequestUtil;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl;
 import org.springframework.dao.DataAccessException;
 import org.springframework.stereotype.Service;
 
+import jakarta.servlet.http.HttpServletRequest;
 import java.util.HashMap;
 import java.util.List;
+
 /**
  * @author takensoft
  * @since 2024.04.09
  * @modification
  *     since    |    author    | description
  *  2024.04.09  |  takensoft   | 최초 등록
+ *  2025.06.18  |  takensoft   | 로그인 이력 저장 로직 통합 및 중복 제거
  *
- * 로그인 이력 정보 관련 구현체
+ * 로그인 이력 정보 관련 구현체 - 로그인 이력 저장 로직 통합
  * EgovAbstractServiceImpl : 전자정부 상속
  * LgnHstryService : 로그인 이력 정보 인터페이스 상속
- *
  */
 @Service("lgnHstryService")
 @RequiredArgsConstructor
+@Slf4j
 public class LgnHstryServiceImpl extends EgovAbstractServiceImpl implements LgnHstryService {
+
     private final LgnHstryDAO lgnHstryDAO;
     private final CodeManageService codeManageService;
+    private final HttpRequestUtil httpRequestUtil;
+
+    // 로그인 타입 상수 정의
+    private static final String LOGIN_TYPE_ADMIN = "0";
+    private static final String LOGIN_TYPE_USER = "1";
+    private static final String ROLE_ADMIN = "ROLE_ADMIN";
 
     /**
      * @param lgnHstryVO - 로그인 이력 정보
@@ -40,10 +52,10 @@
      * @throws DataAccessException - db 관련 예외 발생 시
      * @throws Exception - 그 외 예외 발생 시
      *
-     * 로그인 이력 등록
+     * 로그인 이력 등록 (기존 메서드)
      */
     @Override
-    public int LgnHstrySave(LgnHstryVO lgnHstryVO){
+    public int LgnHstrySave(LgnHstryVO lgnHstryVO) {
         try {
             int result = lgnHstryDAO.save(lgnHstryVO);
             if (result == 0) {
@@ -59,6 +71,130 @@
     }
 
     /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @param loginType - 로그인 타입 ("SYSTEM", "OAUTH2", etc.)
+     * @return int - 로그인 이력 저장 결과
+     * @throws CustomInsertFailException - 로그인 이력 등록 실패 예외 발생 시
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * 통합 로그인 이력 저장 메서드 - 모든 로그인 방식에서 사용
+     */
+    @Override
+    public int saveLoginHistory(MberVO mber, HttpServletRequest request, String loginType) {
+        try {
+            if (mber == null) {
+                throw new IllegalArgumentException("회원 정보가 null입니다.");
+            }
+
+            if (request == null) {
+                throw new IllegalArgumentException("HTTP 요청 정보가 null입니다.");
+            }
+
+            // 로그인 이력 VO 생성
+            LgnHstryVO loginHistory = createLoginHistoryVO(mber, request, loginType);
+
+            // 로그인 이력 저장
+            int result = lgnHstryDAO.save(loginHistory);
+            if (result == 0) {
+                throw new CustomInsertFailException("로그인 이력 등록에 실패했습니다.");
+            }
+
+            log.info("로그인 이력 저장 완료 - 사용자: {}, 타입: {}, IP: {}",
+                    mber.getLgnId(), loginType, loginHistory.getCntnIp());
+
+            return result;
+
+        } catch (DataAccessException dae) {
+            log.error("로그인 이력 저장 실패 - DB 오류: {}", dae.getMessage(), dae);
+            throw dae;
+        } catch (Exception e) {
+            log.error("로그인 이력 저장 실패 - 사용자: {}, 오류: {}",
+                    mber != null ? mber.getLgnId() : "unknown", e.getMessage(), e);
+            throw e;
+        }
+    }
+
+    /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @param loginType - 로그인 타입
+     * @return LgnHstryVO - 생성된 로그인 이력 VO
+     *
+     * 로그인 이력 VO 생성
+     */
+    private LgnHstryVO createLoginHistoryVO(MberVO mber, HttpServletRequest request, String loginType) {
+        // User-Agent 정보 추출
+        String userAgent = httpRequestUtil.getUserAgent(request);
+
+        // 관리자/사용자 구분
+        String lgnType = determineLoginType(mber);
+
+        // 로그인 이력 VO 생성
+        LgnHstryVO loginHistory = new LgnHstryVO();
+        loginHistory.setLgnId(mber.getLgnId());
+        loginHistory.setLgnType(lgnType);
+        loginHistory.setCntnIp(httpRequestUtil.getIp(request));
+        loginHistory.setCntnOperSysm(httpRequestUtil.getOS(userAgent));
+        loginHistory.setDvcNm(httpRequestUtil.getDevice(userAgent));
+        loginHistory.setBrwsrNm(httpRequestUtil.getBrowser(userAgent));
+
+        log.debug("로그인 이력 VO 생성 완료 - 사용자: {}, 타입: {}, OS: {}, 디바이스: {}, 브라우저: {}",
+                mber.getLgnId(), lgnType, loginHistory.getCntnOperSysm(),
+                loginHistory.getDvcNm(), loginHistory.getBrwsrNm());
+
+        return loginHistory;
+    }
+
+    /**
+     * @param mber - 회원 정보
+     * @return String - 로그인 타입 ("0": 관리자, "1": 사용자)
+     *
+     * 사용자 권한에 따른 로그인 타입 결정
+     */
+    private String determineLoginType(MberVO mber) {
+        if (mber.getAuthorities() != null) {
+            boolean isAdmin = mber.getAuthorities().stream()
+                    .anyMatch(authority -> ROLE_ADMIN.equals(authority.getAuthority()));
+            return isAdmin ? LOGIN_TYPE_ADMIN : LOGIN_TYPE_USER;
+        }
+
+        // 권한 정보가 없으면 기본적으로 사용자로 분류
+        log.warn("사용자 권한 정보가 없습니다. 기본값(사용자)으로 설정 - 사용자: {}", mber.getLgnId());
+        return LOGIN_TYPE_USER;
+    }
+
+    /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @return int - 로그인 이력 저장 결과
+     *
+     * 시스템 로그인 이력 저장 (편의 메서드)
+     */
+    @Override
+    public int saveSystemLoginHistory(MberVO mber, HttpServletRequest request) {
+        return saveLoginHistory(mber, request, "SYSTEM");
+    }
+
+    /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @param provider - OAuth2 제공자 (kakao, naver, google 등)
+     * @return int - 로그인 이력 저장 결과
+     *
+     * OAuth2 로그인 이력 저장 (편의 메서드)
+     */
+    @Override
+    public int saveOAuth2LoginHistory(MberVO mber, HttpServletRequest request, String provider) {
+        String loginType = "OAUTH2";
+        if (provider != null && !provider.trim().isEmpty()) {
+            loginType = "OAUTH2_" + provider.toUpperCase();
+        }
+        return saveLoginHistory(mber, request, loginType);
+    }
+
+    /**
      * @param params -회원정보
      * @return HashMap<String, Object>
      *  - list : 로그인 이력 목록
@@ -67,10 +203,10 @@
      * @throws DataAccessException - db 관련 예외 발생 시
      * @throws Exception - 그 외 예외 발생 시
      *
-     * 로그인 이력 목록 조회
+     * 로그인 이력 목록 조회 (기존 메서드)
      */
     @Override
-    public HashMap<String, Object> lgnHstryList(HashMap<String, String> params){
+    public HashMap<String, Object> lgnHstryList(HashMap<String, String> params) {
         try {
             Pagination search = new Pagination(0, params);
             int cnt = lgnHstryDAO.selectLgnHstryListCnt(search);
src/main/java/com/takensoft/cms/mber/service/Impl/MberServiceImpl.java
--- src/main/java/com/takensoft/cms/mber/service/Impl/MberServiceImpl.java
+++ src/main/java/com/takensoft/cms/mber/service/Impl/MberServiceImpl.java
@@ -160,7 +160,7 @@
     }
 
     /**
-     * 표준 회원가입 처리 (기존 로직)
+     * 표준 회원가입 처리
      */
     private HashMap<String, Object> performStandardJoin(HttpServletRequest req, JoinDTO joinDTO) {
         // 회원 아이디 생성 (이미 설정된 경우 건너뛰기)
src/main/java/com/takensoft/cms/mber/service/Impl/UnifiedLoginServiceImpl.java
--- src/main/java/com/takensoft/cms/mber/service/Impl/UnifiedLoginServiceImpl.java
+++ src/main/java/com/takensoft/cms/mber/service/Impl/UnifiedLoginServiceImpl.java
@@ -12,7 +12,11 @@
 import lombok.extern.slf4j.Slf4j;
 import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl;
 import org.springframework.dao.DataAccessException;
+import org.springframework.security.core.Authentication;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.security.oauth2.core.user.OAuth2User;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -20,6 +24,7 @@
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * @author takensoft
@@ -27,8 +32,9 @@
  * @modification
  *     since    |    author    | description
  *  2025.05.29  |  takensoft   | 최초 등록
+ *  2025.06.18  |  takensoft   | OAuth 사용자 정보 추출 로직 통합
  *
- * 통합 로그인 서비스 구현체 - 순환 의존성 해결
+ * 통합 로그인 서비스 구현체
  * EgovAbstractServiceImpl : 전자정부 상속
  * UnifiedLoginService : 통합 로그인 인터페이스 상속
  */
@@ -45,6 +51,35 @@
     private BCryptPasswordEncoder passwordEncoder;
 
     /**
+     * OAuth2 사용자 정보를 담는 내부 클래스
+     */
+    public static class OAuth2UserInfo {
+        private final String provider;
+        private final String id;
+        private final String name;
+        private final String email;
+
+        public OAuth2UserInfo(String provider, String id, String name, String email) {
+            this.provider = provider;
+            this.id = id;
+            this.name = name;
+            this.email = email;
+        }
+
+        // Getters
+        public String getProvider() { return provider; }
+        public String getId() { return id; }
+        public String getName() { return name; }
+        public String getEmail() { return email; }
+
+        @Override
+        public String toString() {
+            return String.format("OAuth2UserInfo{provider='%s', id='%s', name='%s', email='%s'}",
+                    provider, id, name, email);
+        }
+    }
+
+    /**
      * BCryptPasswordEncoder 지연 초기화 (순환 의존성 해결)
      */
     private BCryptPasswordEncoder getPasswordEncoder() {
@@ -53,6 +88,249 @@
         }
         return passwordEncoder;
     }
+
+    /**
+     * @param authentication - Spring Security Authentication 객체
+     * @return OAuth2UserInfo - 추출된 사용자 정보
+     *
+     * Authentication 객체에서 OAuth2 사용자 정보를 추출하는 통합 메서드
+     */
+    public OAuth2UserInfo extractUserInfoFromAuthentication(Authentication authentication) {
+        if (authentication == null) {
+            throw new IllegalArgumentException("Authentication 객체가 null입니다.");
+        }
+
+        try {
+            // 제공자 결정
+            String provider = determineProvider(authentication);
+
+            // Principal 타입에 따른 정보 추출
+            Object principal = authentication.getPrincipal();
+
+            if (principal instanceof OidcUser) {
+                return extractOidcUserInfo((OidcUser) principal, provider);
+            } else if (principal instanceof OAuth2User) {
+                return extractOAuth2UserInfo((OAuth2User) principal, provider);
+            } else {
+                throw new IllegalArgumentException("지원하지 않는 Principal 타입: " + principal.getClass().getName());
+            }
+
+        } catch (Exception e) {
+            log.error("OAuth2 사용자 정보 추출 실패: {}", e.getMessage(), e);
+            throw new CustomNotFoundException("OAuth2 사용자 정보 추출에 실패했습니다: " + e.getMessage());
+        }
+    }
+
+    /**
+     * @param attributes - OAuth2 제공자에서 받은 사용자 정보
+     * @param provider - OAuth2 제공자 (kakao, naver, google 등)
+     * @return OAuth2UserInfo - 추출된 사용자 정보
+     *
+     * Map 형태의 attributes에서 OAuth2 사용자 정보를 추출하는 메서드
+     */
+    public OAuth2UserInfo extractUserInfoFromAttributes(Map<String, Object> attributes, String provider) {
+        if (attributes == null || attributes.isEmpty()) {
+            throw new IllegalArgumentException("OAuth2 사용자 정보가 비어있습니다.");
+        }
+
+        if (provider == null || provider.trim().isEmpty()) {
+            throw new IllegalArgumentException("OAuth2 제공자 정보가 없습니다.");
+        }
+
+        try {
+            String normalizedProvider = provider.toLowerCase();
+
+            switch (normalizedProvider) {
+                case "kakao":
+                    return extractKakaoUserInfo(attributes);
+                case "naver":
+                    return extractNaverUserInfo(attributes);
+                case "google":
+                    return extractGoogleUserInfo(attributes);
+                default:
+                    throw new IllegalArgumentException("지원하지 않는 OAuth2 제공자: " + provider);
+            }
+
+        } catch (Exception e) {
+            log.error("OAuth2 사용자 정보 추출 실패 - Provider: {}, Error: {}", provider, e.getMessage(), e);
+            throw new CustomNotFoundException("OAuth2 사용자 정보 추출에 실패했습니다: " + e.getMessage());
+        }
+    }
+
+    /**
+     * OIDC 사용자 정보 추출 (주로 구글)
+     */
+    private OAuth2UserInfo extractOidcUserInfo(OidcUser oidcUser, String provider) {
+        try {
+            String id = oidcUser.getSubject(); // OIDC의 subject가 사용자 ID
+            String name = getValidString(oidcUser.getFullName());
+
+            // 이름이 없으면 given name 시도
+            if (name == null) {
+                name = getValidString(oidcUser.getGivenName());
+            }
+
+            // 여전히 없으면 이메일 사용
+            if (name == null) {
+                name = getValidString(oidcUser.getEmail());
+            }
+
+            String email = getValidString(oidcUser.getEmail());
+
+            validateRequiredFields(id, email, provider);
+
+            return new OAuth2UserInfo(provider, id, name, email);
+
+        } catch (Exception e) {
+            log.error("OIDC 사용자 정보 추출 실패: {}", e.getMessage(), e);
+            throw new CustomNotFoundException("OIDC 사용자 정보 추출에 실패했습니다.");
+        }
+    }
+
+    /**
+     * 일반 OAuth2 사용자 정보 추출
+     */
+    private OAuth2UserInfo extractOAuth2UserInfo(OAuth2User oauth2User, String provider) {
+        Map<String, Object> attributes = oauth2User.getAttributes();
+        return extractUserInfoFromAttributes(attributes, provider);
+    }
+
+    /**
+     * 카카오 사용자 정보 추출
+     */
+    private OAuth2UserInfo extractKakaoUserInfo(Map<String, Object> attributes) {
+        try {
+            String id = String.valueOf(attributes.get("id"));
+            String name = null;
+            String email = null;
+
+            // kakao_account에서 정보 추출
+            Map<String, Object> kakaoAccount = safeCastToMap(attributes.get("kakao_account"));
+            if (kakaoAccount != null) {
+                email = getValidString((String) kakaoAccount.get("email"));
+
+                // profile에서 닉네임 추출
+                Map<String, Object> profile = safeCastToMap(kakaoAccount.get("profile"));
+                if (profile != null) {
+                    name = getValidString((String) profile.get("nickname"));
+                }
+            }
+
+            validateRequiredFields(id, email, "kakao");
+
+            return new OAuth2UserInfo("kakao", id, name, email);
+
+        } catch (Exception e) {
+            log.error("카카오 사용자 정보 추출 실패: {}", e.getMessage(), e);
+            throw new CustomNotFoundException("카카오 사용자 정보 추출에 실패했습니다.");
+        }
+    }
+
+    /**
+     * 네이버 사용자 정보 추출
+     */
+    private OAuth2UserInfo extractNaverUserInfo(Map<String, Object> attributes) {
+        try {
+            // 네이버는 response 객체 안에 사용자 정보가 있음
+            Map<String, Object> naverResponse = safeCastToMap(attributes.get("response"));
+            if (naverResponse == null) {
+                throw new IllegalArgumentException("네이버 응답에서 response 객체를 찾을 수 없습니다.");
+            }
+
+            String id = getValidString((String) naverResponse.get("id"));
+            String name = getValidString((String) naverResponse.get("name"));
+            String email = getValidString((String) naverResponse.get("email"));
+
+            validateRequiredFields(id, email, "naver");
+
+            return new OAuth2UserInfo("naver", id, name, email);
+
+        } catch (Exception e) {
+            log.error("네이버 사용자 정보 추출 실패: {}", e.getMessage(), e);
+            throw new CustomNotFoundException("네이버 사용자 정보 추출에 실패했습니다.");
+        }
+    }
+
+    /**
+     * 구글 사용자 정보 추출
+     */
+    private OAuth2UserInfo extractGoogleUserInfo(Map<String, Object> attributes) {
+        try {
+            String id = getValidString((String) attributes.get("sub"));
+            if (id == null) {
+                id = getValidString((String) attributes.get("id"));
+            }
+
+            String name = getValidString((String) attributes.get("name"));
+            String email = getValidString((String) attributes.get("email"));
+
+            validateRequiredFields(id, email, "google");
+
+            return new OAuth2UserInfo("google", id, name, email);
+
+        } catch (Exception e) {
+            log.error("구글 사용자 정보 추출 실패: {}", e.getMessage(), e);
+            throw new CustomNotFoundException("구글 사용자 정보 추출에 실패했습니다.");
+        }
+    }
+
+    /**
+     * 제공자 결정 로직
+     */
+    private String determineProvider(Authentication authentication) {
+        if (authentication instanceof OAuth2AuthenticationToken) {
+            OAuth2AuthenticationToken oauth2Token = (OAuth2AuthenticationToken) authentication;
+            return oauth2Token.getAuthorizedClientRegistrationId().toLowerCase();
+        }
+
+        // Principal 이름에서 추론 시도
+        String name = authentication.getName();
+        if (name != null) {
+            String lowerName = name.toLowerCase();
+            if (lowerName.contains("google")) return "google";
+            if (lowerName.contains("kakao")) return "kakao";
+            if (lowerName.contains("naver")) return "naver";
+        }
+
+        // 기본값
+        log.warn("제공자를 결정할 수 없어 기본값(google)을 사용합니다. Authentication: {}", authentication.getClass().getName());
+        return "google";
+    }
+
+    /**
+     * 안전한 Map 캐스팅
+     */
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> safeCastToMap(Object obj) {
+        if (obj instanceof Map) {
+            return (Map<String, Object>) obj;
+        }
+        return null;
+    }
+
+    /**
+     * 유효한 문자열 반환 (null, 빈 문자열, "null" 문자열 처리)
+     */
+    private String getValidString(String value) {
+        if (value == null || value.trim().isEmpty() || "null".equalsIgnoreCase(value.trim())) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    /**
+     * 필수 필드 검증
+     */
+    private void validateRequiredFields(String id, String email, String provider) {
+        if (id == null || id.trim().isEmpty()) {
+            throw new IllegalArgumentException(provider + " 사용자 ID가 없습니다.");
+        }
+        if (email == null || email.trim().isEmpty()) {
+            throw new IllegalArgumentException(provider + " 이메일 정보가 없습니다.");
+        }
+    }
+
+    // ===== 기존 UnifiedLoginService 메서드들 =====
 
     /**
      * 통합 로그인 인증
@@ -85,7 +363,7 @@
     }
 
     /**
-     * OAuth2 사용자 처리 (가입 또는 연동)
+     * OAuth2 사용자 처리 (가입 또는 연동) - 통합된 정보 추출 로직 사용
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -346,7 +624,7 @@
      * 소셜 계정 정보 업데이트
      */
     private void updateSocialAccountInfo(MberSocialAccountVO socialAccount) {
-            mberDAO.linkSocialAccount(socialAccount);
+        mberDAO.linkSocialAccount(socialAccount);
     }
 
     /**
src/main/java/com/takensoft/cms/mber/service/LgnHstryService.java
--- src/main/java/com/takensoft/cms/mber/service/LgnHstryService.java
+++ src/main/java/com/takensoft/cms/mber/service/LgnHstryService.java
@@ -1,17 +1,20 @@
 package com.takensoft.cms.mber.service;
 
 import com.takensoft.cms.mber.vo.LgnHstryVO;
-import org.springframework.dao.DataAccessException;
+import com.takensoft.cms.mber.vo.MberVO;
 
+import jakarta.servlet.http.HttpServletRequest;
 import java.util.HashMap;
+
 /**
- * @author 박정하
+ * @author takensoft
  * @since 2024.04.09
  * @modification
  *     since    |    author    | description
- *  2024.04.09  |    박정하     | 최초 등록
+ *  2024.04.09  |  takensoft   | 최초 등록
+ *  2025.06.18  |  takensoft   | 로그인 이력 저장 메서드 추가 및 통합
  *
- * 로그인 이력 관련 인터페이스
+ * 로그인 이력 정보 관련 인터페이스 - 통합 로그인 이력 저장 기능 추가
  */
 public interface LgnHstryService {
 
@@ -19,9 +22,38 @@
      * @param lgnHstryVO - 로그인 이력 정보
      * @return int - 로그인 이력 결과
      *
-     * 로그인 이력 등록
+     * 로그인 이력 등록 (기존 메서드)
      */
-    public int LgnHstrySave(LgnHstryVO lgnHstryVO);
+    int LgnHstrySave(LgnHstryVO lgnHstryVO);
+
+    /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @param loginType - 로그인 타입 ("SYSTEM", "OAUTH2", etc.)
+     * @return int - 로그인 이력 저장 결과
+     *
+     * 통합 로그인 이력 저장 메서드 - 모든 로그인 방식에서 사용
+     */
+    int saveLoginHistory(MberVO mber, HttpServletRequest request, String loginType);
+
+    /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @return int - 로그인 이력 저장 결과
+     *
+     * 시스템 로그인 이력 저장
+     */
+    int saveSystemLoginHistory(MberVO mber, HttpServletRequest request);
+
+    /**
+     * @param mber - 회원 정보
+     * @param request - HTTP 요청 객체
+     * @param provider - OAuth2 제공자 (kakao, naver, google 등)
+     * @return int - 로그인 이력 저장 결과
+     *
+     * OAuth2 로그인 이력 저장
+     */
+    int saveOAuth2LoginHistory(MberVO mber, HttpServletRequest request, String provider);
 
     /**
      * @param params -회원정보
@@ -32,5 +64,5 @@
      *
      * 로그인 이력 목록 조회
      */
-    public HashMap<String, Object> lgnHstryList(HashMap<String, String> params);
+    HashMap<String, Object> lgnHstryList(HashMap<String, String> params);
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/common/filter/JWTFilter.java
--- src/main/java/com/takensoft/common/filter/JWTFilter.java
+++ src/main/java/com/takensoft/common/filter/JWTFilter.java
@@ -99,50 +99,60 @@
             throws ServletException, IOException {
         HttpSession session = request.getSession(false);
 
-        if (session == null) {
-            filterChain.doFilter(request, response);
+        // 세션이 없거나 사용자 정보가 없으면 처리
+        if (session == null || session.getAttribute("mbrId") == null) {
+            handleSessionInvalid(request, response, filterChain);
             return;
         }
 
         String mbrId = (String) session.getAttribute("mbrId");
-        if (mbrId == null) {
-            filterChain.doFilter(request, response);
-            return;
-        }
 
         // 세션에서 JWT 토큰 꺼내기
         String sessionToken = (String) session.getAttribute(SESSION_JWT_KEY);
-        if (sessionToken == null) {
-            // 토큰이 없어도 세션 자체는 유효할 수 있으므로 계속 진행
-            filterChain.doFilter(request, response);
-            return;
+        if (sessionToken != null) {
+            try {
+                // 토큰 만료 체크
+                Boolean isExpired = (Boolean) jwtUtil.getClaim("Bearer " + sessionToken, "isExpired");
+                if (isExpired != null && isExpired) {
+                    session.invalidate();
+                    handleSessionInvalid(request, response, filterChain);
+                    return;
+                }
+            } catch (Exception e) {
+                session.invalidate();
+                handleSessionInvalid(request, response, filterChain);
+                return;
+            }
         }
 
         // 중복 로그인 검증 (비허용 모드일 때만)
-        if (!loginPolicyService.getPolicy()) {
+        if (!loginPolicyService.getPolicy() && sessionToken != null) {
             if (!validateSessionToken(mbrId, sessionToken, session)) {
-                sendTokenExpiredResponse(response, request);
+                handleSessionInvalid(request, response, filterChain);
                 return;
             }
         }
 
         // 세션에서 꺼낸 JWT 토큰을 헤더에 설정하고 JWT 검증 로직 재사용
-        try {
-            HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(request) {
-                @Override
-                public String getHeader(String name) {
-                    if (AUTHORIZATION_HEADER.equals(name)) {
-                        return "Bearer " + sessionToken;
+        if (sessionToken != null) {
+            try {
+                HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(request) {
+                    @Override
+                    public String getHeader(String name) {
+                        if (AUTHORIZATION_HEADER.equals(name)) {
+                            return "Bearer " + sessionToken;
+                        }
+                        return super.getHeader(name);
                     }
-                    return super.getHeader(name);
-                }
-            };
+                };
 
-            // 기존 JWT 검증 로직 재사용 (단, 재귀 호출 방지)
-            processJwtToken(wrappedRequest, response, filterChain, sessionToken);
-
-        } catch (Exception e) {
-            // JWT 토큰에 문제가 있어도 세션은 유효할 수 있으므로 계속 진행
+                // 기존 JWT 검증 로직 재사용 (단, 재귀 호출 방지)
+                processJwtToken(wrappedRequest, response, filterChain, sessionToken);
+            } catch (Exception e) {
+                session.invalidate();
+                handleSessionInvalid(request, response, filterChain);
+            }
+        } else {
             filterChain.doFilter(request, response);
         }
     }
@@ -217,7 +227,7 @@
     }
 
     /**
-     * JWT 토큰 유효성 검증 (개선된 로직)
+     * JWT 토큰 유효성 검증
      */
     private boolean validateJwtToken(String mbrId, String accessToken) {
         try {
@@ -303,4 +313,21 @@
         response.setStatus(HttpStatus.UNAUTHORIZED.value());
         response.getOutputStream().write(appConfig.getObjectMapper().writeValueAsBytes(errorResponse));
     }
+
+    private void handleSessionInvalid(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws IOException, ServletException {
+        if (isApiRequest(request)) {
+            sendTokenExpiredResponse(response, request);
+        } else {
+            response.sendRedirect("/login.page");
+        }
+    }
+
+    private boolean isApiRequest(HttpServletRequest request) {
+        String requestURI = request.getRequestURI();
+        String acceptHeader = request.getHeader("Accept");
+
+        return requestURI.endsWith(".json") ||
+                (acceptHeader != null && acceptHeader.contains("application/json"));
+    }
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
--- src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
@@ -4,10 +4,8 @@
 import com.takensoft.cms.loginPolicy.service.LoginPolicyService;
 import com.takensoft.cms.mber.service.LgnHstryService;
 import com.takensoft.cms.mber.service.UnifiedLoginService;
-import com.takensoft.cms.mber.vo.LgnHstryVO;
+import com.takensoft.cms.mber.service.Impl.UnifiedLoginServiceImpl;
 import com.takensoft.cms.mber.vo.MberVO;
-import com.takensoft.common.oauth.vo.CustomOAuth2UserVO;
-import com.takensoft.common.util.HttpRequestUtil;
 import com.takensoft.common.util.LoginUtil;
 import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
@@ -16,15 +14,11 @@
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.core.Authentication;
-import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
-import org.springframework.security.oauth2.core.oidc.user.OidcUser;
-import org.springframework.security.oauth2.core.user.OAuth2User;
 import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
 import org.springframework.stereotype.Component;
 
 import java.io.IOException;
 import java.net.URLEncoder;
-import java.util.Map;
 
 /**
  * @author takensoft
@@ -36,8 +30,9 @@
  *  2025.05.29  |  takensoft   | OAuth2 통합 문제 해결
  *  2025.06.02  |  takensoft   | 세션 모드 중복로그인 처리 개선
  *  2025.06.09  |  takensoft   | OIDC 타입 캐스팅 문제 해결
+ *  2025.06.18  |  takensoft   | OAuth 사용자 정보 추출 및 로그인 이력 저장 로직 통합
  *
- * OAuth2 로그인 성공 핸들러
+ * OAuth2 로그인 성공 핸들러 - 최종 중복 제거 완료
  */
 @Slf4j
 @Component
@@ -46,7 +41,6 @@
 
     private final UnifiedLoginService unifiedLoginService;
     private final LgnHstryService lgnHstryService;
-    private final HttpRequestUtil httpRequestUtil;
     private final LoginUtil loginUtil;
     private final LoginModeService loginModeService;
     private final LoginPolicyService loginPolicyService;
@@ -57,15 +51,13 @@
     @Override
     public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
         try {
-
-            // OAuth2User 타입 확인 및 정보 추출
-            OAuth2UserInfo userInfo = extractUserInfo(authentication);
+            // 통합된 사용자 정보 추출 로직 사용
+            UnifiedLoginServiceImpl.OAuth2UserInfo userInfo = extractUserInfo(authentication);
 
             if (userInfo == null) {
                 handleOAuth2Error(response, new Exception("사용자 정보 추출 실패"));
                 return;
             }
-
 
             // 현재 설정된 로그인 모드 확인
             String currentLoginMode = loginModeService.getLoginMode();
@@ -73,15 +65,15 @@
 
             // 통합 로그인 서비스를 통한 OAuth2 사용자 처리
             MberVO mber = unifiedLoginService.processOAuth2User(
-                    userInfo.email,
-                    unifiedLoginService.convertProviderToMbrType(userInfo.provider),
-                    userInfo.id,
-                    userInfo.name,
+                    userInfo.getEmail(),
+                    unifiedLoginService.convertProviderToMbrType(userInfo.getProvider()),
+                    userInfo.getId(),
+                    userInfo.getName(),
                     request
             );
 
-            // OAuth2 로그인 이력 저장
-            saveLoginHistory(request, mber, userInfo.provider);
+            // 통합된 OAuth2 로그인 이력 저장 로직 사용
+            lgnHstryService.saveOAuth2LoginHistory(mber, request, userInfo.getProvider());
 
             request.setAttribute("loginType", "OAUTH2");
 
@@ -101,32 +93,14 @@
     }
 
     /**
-     * OAuth2User에서 사용자 정보 추출 (개선된 버전)
+     * OAuth2 사용자 정보 추출 - UnifiedLoginService의 통합 로직 사용
      */
-    private OAuth2UserInfo extractUserInfo(Authentication authentication) {
-        Object principal = authentication.getPrincipal();
-        String provider = determineProvider(authentication);
-
+    private UnifiedLoginServiceImpl.OAuth2UserInfo extractUserInfo(Authentication authentication) {
         try {
-            if (principal instanceof CustomOAuth2UserVO) {
-                // 커스텀 OAuth2 사용자
-                CustomOAuth2UserVO customUser = (CustomOAuth2UserVO) principal;
-                return new OAuth2UserInfo(
-                        customUser.getProvider(),
-                        customUser.getId(),
-                        customUser.getName(),
-                        customUser.getEmail()
-                );
-
-            } else if (principal instanceof OidcUser) {
-                // OIDC 사용자 (구글)
-                OidcUser oidcUser = (OidcUser) principal;
-                return extractOidcUserInfo(oidcUser, provider);
-
-            } else if (principal instanceof OAuth2User) {
-                // 일반 OAuth2 사용자
-                OAuth2User oauth2User = (OAuth2User) principal;
-                return extractOAuth2UserInfo(oauth2User, provider);
+            // UnifiedLoginService의 통합된 사용자 정보 추출 메서드 사용
+            if (unifiedLoginService instanceof UnifiedLoginServiceImpl) {
+                UnifiedLoginServiceImpl unifiedLoginServiceImpl = (UnifiedLoginServiceImpl) unifiedLoginService;
+                return unifiedLoginServiceImpl.extractUserInfoFromAuthentication(authentication);
             } else {
                 return null;
             }
@@ -136,141 +110,12 @@
     }
 
     /**
-     * OIDC 사용자 정보 추출 (구글)
-     */
-    private OAuth2UserInfo extractOidcUserInfo(OidcUser oidcUser, String provider) {
-        try {
-
-            String id = oidcUser.getSubject(); // OIDC의 subject가 사용자 ID
-            String name = oidcUser.getFullName();
-            if (name == null || name.trim().isEmpty()) {
-                name = oidcUser.getGivenName();
-            }
-            if (name == null || name.trim().isEmpty()) {
-                name = oidcUser.getEmail();
-            }
-            String email = oidcUser.getEmail();
-            return new OAuth2UserInfo(provider, id, name, email);
-        } catch (Exception e) {
-            return null;
-        }
-    }
-
-    /**
-     * 일반 OAuth2 사용자 정보 추출
-     */
-    private OAuth2UserInfo extractOAuth2UserInfo(OAuth2User oauth2User, String provider) {
-        try {
-            Map<String, Object> attributes = oauth2User.getAttributes();
-
-            String id = null;
-            String name = null;
-            String email = null;
-
-            // 제공자별 정보 추출
-            switch (provider.toLowerCase()) {
-                case "kakao":
-                    id = String.valueOf(attributes.get("id"));
-                    Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
-                    if (kakaoAccount != null) {
-                        email = (String) kakaoAccount.get("email");
-                        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
-                        if (profile != null) {
-                            name = (String) profile.get("nickname");
-                        }
-                    }
-                    break;
-
-                case "naver":
-                    Map<String, Object> naverResponse = (Map<String, Object>) attributes.get("response");
-                    if (naverResponse != null) {
-                        id = (String) naverResponse.get("id");
-                        name = (String) naverResponse.get("name");
-                        email = (String) naverResponse.get("email");
-                    }
-                    break;
-
-                case "google":
-                    id = (String) attributes.get("sub");
-                    if (id == null) id = (String) attributes.get("id");
-                    name = (String) attributes.get("name");
-                    email = (String) attributes.get("email");
-                    break;
-            }
-
-            return new OAuth2UserInfo(provider, id, name, email);
-        } catch (Exception e) {
-            return null;
-        }
-    }
-
-    /**
-     * 제공자 결정
-     */
-    private String determineProvider(Authentication authentication) {
-        if (authentication instanceof OAuth2AuthenticationToken) {
-            OAuth2AuthenticationToken oauth2Token = (OAuth2AuthenticationToken) authentication;
-            String registrationId = oauth2Token.getAuthorizedClientRegistrationId();
-            return registrationId.toLowerCase(); // 소문자로 통일
-        }
-
-        String name = authentication.getName();
-
-        if (name != null) {
-            String lowerName = name.toLowerCase();
-            if (lowerName.contains("google")) return "google";
-            if (lowerName.contains("kakao")) return "kakao";
-            if (lowerName.contains("naver")) return "naver";
-        }
-
-        // 기본값
-        return "google";
-    }
-
-    /**
-     * 로그인 이력 저장 - OAuth2 전용
-     */
-    private void saveLoginHistory(HttpServletRequest request, MberVO mber, String provider) {
-        try {
-            String userAgent = httpRequestUtil.getUserAgent(request);
-
-            LgnHstryVO loginHistory = new LgnHstryVO();
-            loginHistory.setLgnId(mber.getLgnId());
-            loginHistory.setLgnType(mber.getAuthorities().stream().anyMatch(r -> r.getAuthority().equals("ROLE_ADMIN")) ? "0" : "1");
-            loginHistory.setCntnIp(httpRequestUtil.getIp(request));
-            loginHistory.setCntnOperSysm(httpRequestUtil.getOS(userAgent));
-            loginHistory.setDvcNm(httpRequestUtil.getDevice(userAgent));
-            loginHistory.setBrwsrNm(httpRequestUtil.getBrowser(userAgent));
-
-            lgnHstryService.LgnHstrySave(loginHistory);
-        } catch (Exception e) {
-            log.error("로그인 이력 저장 실패", e);
-        }
-    }
-
-    /**
      * OAuth2 오류 처리
      */
     private void handleOAuth2Error(HttpServletResponse response, Exception e) throws IOException {
+
         String message = URLEncoder.encode("OAuth 로그인에 실패했습니다.", "UTF-8");
         String errorUrl = String.format("%s/?error=oauth2_failed&message=%s", frontUrl, message);
         getRedirectStrategy().sendRedirect(null, response, errorUrl);
-    }
-
-    /**
-     * OAuth2 사용자 정보 내부 클래스
-     */
-    private static class OAuth2UserInfo {
-        final String provider;
-        final String id;
-        final String name;
-        final String email;
-
-        OAuth2UserInfo(String provider, String id, String name, String email) {
-            this.provider = provider;
-            this.id = id;
-            this.name = name;
-            this.email = email;
-        }
     }
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/common/oauth/web/OAuth2Controller.java
--- src/main/java/com/takensoft/common/oauth/web/OAuth2Controller.java
+++ src/main/java/com/takensoft/common/oauth/web/OAuth2Controller.java
@@ -8,7 +8,6 @@
 import com.takensoft.common.util.HttpRequestUtil;
 import com.takensoft.common.util.JWTUtil;
 import com.takensoft.common.util.ResponseUtil;
-import jakarta.servlet.http.Cookie;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.servlet.http.HttpSession;
@@ -22,6 +21,7 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * @author takensoft
@@ -31,8 +31,10 @@
  *  2025.05.22  |  takensoft   | 최초 등록
  *  2025.05.26  |  takensoft   | OAuth 리다이렉트 기능 추가
  *  2025.05.28  |  takensoft   | 쿠키에서 OAuth 토큰 읽기 추가
+ *  2025.06.18  |  takensoft   | 토큰 추출 로직 통합 및 중복 제거
+ *  2025.06.18  |  takensoft   | 사용자 정보 응답 생성 로직 통합 완료
  *
- * OAuth2 관련 통합 컨트롤러
+ * OAuth2 관련 통합 컨트롤러 - 사용자 정보 응답 생성 로직 최종 통합
  */
 @RestController
 @RequiredArgsConstructor
@@ -52,6 +54,14 @@
 
     // 지원하는 OAuth 제공자 목록
     private static final List<String> SUPPORTED_PROVIDERS = Arrays.asList("kakao", "naver", "google");
+
+    // 응답 키 상수 정의
+    private static final String RESPONSE_KEY_MBR_ID = "mbrId";
+    private static final String RESPONSE_KEY_MBR_NM = "mbrNm";
+    private static final String RESPONSE_KEY_ROLES = "roles";
+    private static final String RESPONSE_KEY_LOGIN_MODE = "loginMode";
+    private static final String RESPONSE_KEY_TOKEN = "token";
+    private static final String RESPONSE_KEY_EMAIL = "email";
 
     /**
      * OAuth 로그인 리다이렉트 처리
@@ -106,6 +116,7 @@
             }
 
         } catch (Exception e) {
+            log.error("사용자 정보 조회 중 오류 발생", e);
             return resUtil.errorRes(MessageCode.COMMON_UNKNOWN_ERROR);
         }
     }
@@ -125,25 +136,18 @@
                 return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
             }
 
-            // 세션에서 정보 조회 및 최신 권한 정보 가져오기
-            HashMap<String, Object> params = new HashMap<>();
-            params.put("mbrId", currentUserId);
-            MberVO mberInfo = mberService.findByMbr(params);
-
+            // 사용자 정보 조회
+            MberVO mberInfo = getUserById(currentUserId);
             if (mberInfo == null) {
                 return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
             }
 
-            // 응답 데이터 구성
-            HashMap<String, Object> result = new HashMap<>();
-            result.put("mbrId", mberInfo.getMbrId());
-            result.put("mbrNm", mberInfo.getMbrNm());
-            result.put("roles", mberInfo.getAuthorList());
-            result.put("loginMode", "S");
-
+            // 통합된 응답 생성
+            Map<String, Object> result = createUserInfoResponse(mberInfo, "S", null, null);
             return resUtil.successRes(result, MessageCode.COMMON_SUCCESS);
 
         } catch (Exception e) {
+            log.error("세션 모드 사용자 정보 처리 중 오류", e);
             return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
         }
     }
@@ -153,97 +157,138 @@
      */
     private ResponseEntity<?> handleJWTModeUserInfo(HttpServletRequest request) {
         try {
-            String token = extractToken(request);
+            // 통합된 토큰 추출 로직 사용
+            String token = jwtUtil.extractTokenFromRequest(request);
             if (token == null) {
                 return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
             }
 
             // 토큰에서 사용자 ID 추출
-            String currentUserId;
-            try {
-                currentUserId = (String) jwtUtil.getClaim(token, "mbrId");
-                if (currentUserId == null || currentUserId.isEmpty()) {
-                    return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
-                }
-            } catch (Exception e) {
+            String currentUserId = extractUserIdFromToken(token);
+            if (currentUserId == null) {
                 return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
             }
 
-            // DB에서 최신 사용자 정보 조회
-            HashMap<String, Object> params = new HashMap<>();
-            params.put("mbrId", currentUserId);
-            MberVO mberInfo = mberService.findByMbr(params);
-
+            // 사용자 정보 조회
+            MberVO mberInfo = getUserById(currentUserId);
             if (mberInfo == null) {
                 return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
             }
 
-            // 응답 데이터 구성
-            HashMap<String, Object> result = new HashMap<>();
-            result.put("mbrId", mberInfo.getMbrId());
-            result.put("mbrNm", mberInfo.getMbrNm());
-            result.put("roles", mberInfo.getAuthorList());
-            result.put("loginMode", "J");
-
-            // JWT 토큰도 함께 전달
-            String authHeader = request.getHeader("Authorization");
-            if (authHeader != null && !authHeader.isEmpty()) {
-                result.put("token", authHeader);
-            } else {
-                // 쿠키에서 가져온 토큰이면 Bearer 형태로 반환
-                result.put("token", "Bearer " + token);
-            }
-
+            // 통합된 응답 생성 (토큰 포함)
+            Map<String, Object> result = createUserInfoResponse(mberInfo, "J", "Bearer " + token, null);
             return resUtil.successRes(result, MessageCode.COMMON_SUCCESS);
 
         } catch (Exception e) {
+            log.error("JWT 모드 사용자 정보 처리 중 오류", e);
             return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
         }
     }
 
-
     /**
-     * 토큰 추출 로직 통합 및 개선
+     * @param mberInfo - 사용자 정보
+     * @param loginMode - 로그인 모드 ("J", "S")
+     * @param token - JWT 토큰 (JWT 모드일 때만)
+     * @param additionalData - 추가 데이터 (필요시)
+     * @return Map<String, Object> - 표준화된 사용자 정보 응답
+     *
+     * 통합된 사용자 정보 응답 생성 메서드 - 모든 곳에서 사용 가능
      */
-    private String extractToken(HttpServletRequest request) {
-        // Authorization 헤더에서 토큰 추출 시도
-        String authHeader = request.getHeader("Authorization");
-        if (authHeader != null && !authHeader.isEmpty()) {
-            return jwtUtil.extractToken(authHeader);
+    public Map<String, Object> createUserInfoResponse(MberVO mberInfo, String loginMode, String token, Map<String, Object> additionalData) {
+        if (mberInfo == null) {
+            throw new IllegalArgumentException("사용자 정보가 null입니다.");
         }
 
-        // OAuth 전용 쿠키에서 토큰 추출 시도
-        String oauthToken = getTokenFromOAuthCookie(request);
-        if (oauthToken != null && !oauthToken.isEmpty()) {
-            return oauthToken;
+        Map<String, Object> result = new HashMap<>();
+
+        // 기본 사용자 정보 설정
+        result.put(RESPONSE_KEY_MBR_ID, mberInfo.getMbrId());
+        result.put(RESPONSE_KEY_MBR_NM, mberInfo.getMbrNm());
+        result.put(RESPONSE_KEY_ROLES, mberInfo.getAuthorList());
+        result.put(RESPONSE_KEY_LOGIN_MODE, loginMode);
+
+        // 이메일 정보 (있는 경우)
+        if (mberInfo.getEml() != null && !mberInfo.getEml().trim().isEmpty()) {
+            result.put(RESPONSE_KEY_EMAIL, mberInfo.getEml());
         }
 
-        // 일반 Authorization 쿠키에서 토큰 추출 시도
-        if (request.getCookies() != null) {
-            for (Cookie cookie : request.getCookies()) {
-                if ("Authorization".equals(cookie.getName())) {
-                    return jwtUtil.extractToken(cookie.getValue());
-                }
-            }
+        // JWT 모드인 경우 토큰 추가
+        if ("J".equals(loginMode) && token != null && !token.trim().isEmpty()) {
+            result.put(RESPONSE_KEY_TOKEN, token);
         }
 
-        return null;
+        // 추가 데이터가 있으면 병합
+        if (additionalData != null && !additionalData.isEmpty()) {
+            result.putAll(additionalData);
+        }
+
+        log.debug("사용자 정보 응답 생성 완료 - 사용자: {}, 모드: {}, 토큰포함: {}",
+                mberInfo.getMbrId(), loginMode, token != null);
+
+        return result;
     }
 
     /**
-     * OAuth 전용 쿠키에서 access token 추출
+     * @param mberInfo - 사용자 정보
+     * @param loginMode - 로그인 모드
+     * @return Map<String, Object> - 기본 사용자 정보 응답 (토큰 없음)
+     *
+     * 기본 사용자 정보 응답 생성
      */
-    private String getTokenFromOAuthCookie(HttpServletRequest request) {
-        if (request.getCookies() != null) {
-            for (Cookie cookie : request.getCookies()) {
-                if ("Authorization".equals(cookie.getName()) ||
-                        "refresh".equals(cookie.getName())) {
-                    String token = cookie.getValue();
-                    return token.startsWith("Bearer ") ? token.substring(7) : token;
-                }
-            }
+    public Map<String, Object> createBasicUserInfoResponse(MberVO mberInfo, String loginMode) {
+        return createUserInfoResponse(mberInfo, loginMode, null, null);
+    }
+
+    /**
+     * @param mberInfo - 사용자 정보
+     * @param token - JWT 토큰
+     * @return Map<String, Object> - JWT 사용자 정보 응답
+     *
+     * JWT 사용자 정보 응답 생성
+     */
+    public Map<String, Object> createJWTUserInfoResponse(MberVO mberInfo, String token) {
+        return createUserInfoResponse(mberInfo, "J", token, null);
+    }
+
+    /**
+     * @param mberInfo - 사용자 정보
+     * @return Map<String, Object> - 세션 사용자 정보 응답
+     *
+     * 세션 사용자 정보 응답 생성
+     */
+    public Map<String, Object> createSessionUserInfoResponse(MberVO mberInfo) {
+        return createUserInfoResponse(mberInfo, "S", null, null);
+    }
+
+    /**
+     * 사용자 ID로 사용자 정보 조회
+     */
+    private MberVO getUserById(String userId) {
+        try {
+            HashMap<String, Object> params = new HashMap<>();
+            params.put("mbrId", userId);
+            return mberService.findByMbr(params);
+        } catch (Exception e) {
+            log.error("사용자 정보 조회 실패 - 사용자 ID: {}", userId, e);
+            return null;
         }
-        return null;
+    }
+
+    /**
+     * 토큰에서 사용자 ID 추출
+     */
+    private String extractUserIdFromToken(String token) {
+        try {
+            String currentUserId = (String) jwtUtil.getClaim("Bearer " + token, "mbrId");
+            if (currentUserId == null || currentUserId.isEmpty()) {
+                log.warn("토큰에서 사용자 ID를 찾을 수 없습니다.");
+                return null;
+            }
+            return currentUserId;
+        } catch (Exception e) {
+            log.error("토큰에서 사용자 ID 추출 실패", e);
+            return null;
+        }
     }
 
     /**
src/main/java/com/takensoft/common/util/JWTUtil.java
--- src/main/java/com/takensoft/common/util/JWTUtil.java
+++ src/main/java/com/takensoft/common/util/JWTUtil.java
@@ -15,21 +15,29 @@
 import javax.crypto.SecretKey;
 import javax.crypto.spec.SecretKeySpec;
 import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
 import java.nio.charset.StandardCharsets;
 import java.util.*;
+
 /**
  * @author  : takensoft
  * @since   : 2025.01.22
  * @modification
  *     since    |    author    | description
  *  2025.01.22  |  takensoft   | 최초 등록
+ *  2025.06.18  |  takensoft   | 토큰 추출 로직 통합 및 중복 제거
  *
- * JWT 토큰 생성 및 검증, 쿠키 생성 등의 유틸리티
+ * JWT 토큰 생성 및 검증, 쿠키 생성 등의 유틸리티 - 토큰 추출 로직 통합
  */
 @Component
 public class JWTUtil {
 
     private static SecretKey JWT_SECRET_KEY;
+
+    // 토큰 추출 우선순위 정의
+    private static final String[] TOKEN_COOKIE_NAMES = {"Authorization", "refresh"};
+    private static final String BEARER_PREFIX = "Bearer ";
+    private static final String AUTHORIZATION_HEADER = "Authorization";
 
     /**
      * @param secret - JWT 서명을 위한 키 (application.yml에서 값을 읽어 옴)
@@ -38,6 +46,97 @@
      */
     public JWTUtil(@Value("${jwt.secret}")String secret) {
         this.JWT_SECRET_KEY = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
+    }
+
+    /**
+     * @param request - HTTP 요청 객체
+     * @return 추출된 JWT 토큰 (Bearer 접두사 제거된 순수 토큰) 또는 null
+     *
+     * HTTP 요청에서 JWT 토큰을 추출하는 통합 메서드
+     * 우선순위: Authorization 헤더 → Authorization 쿠키 → refresh 쿠키
+     */
+    public String extractTokenFromRequest(HttpServletRequest request) {
+        if (request == null) {
+            return null;
+        }
+
+        // 1. Authorization 헤더에서 토큰 추출 시도
+        String authHeader = request.getHeader(AUTHORIZATION_HEADER);
+        if (isValidTokenString(authHeader)) {
+            return removeBearerPrefix(authHeader);
+        }
+
+        // 2. 쿠키에서 토큰 추출 시도
+        String tokenFromCookie = extractTokenFromCookie(request);
+        if (isValidTokenString(tokenFromCookie)) {
+            return removeBearerPrefix(tokenFromCookie);
+        }
+
+        return null;
+    }
+
+    /**
+     * @param authHeader - Authorization 헤더 문자열 또는 토큰 문자열
+     * @return Bearer 접두사가 제거된 순수 토큰 문자열
+     *
+     * Bearer 접두사를 제거하는 기존 메서드 (하위 호환성 유지)
+     */
+    public String extractToken(String authHeader) {
+        return removeBearerPrefix(authHeader);
+    }
+
+    /**
+     * @param request - HTTP 요청 객체
+     * @return 쿠키에서 추출된 토큰 문자열 또는 null
+     *
+     * 쿠키에서 토큰을 추출하는 전용 메서드
+     * 우선순위: Authorization 쿠키 → refresh 쿠키
+     */
+    private String extractTokenFromCookie(HttpServletRequest request) {
+        Cookie[] cookies = request.getCookies();
+        if (cookies == null) {
+            return null;
+        }
+
+        // 쿠키 우선순위에 따라 토큰 추출
+        for (String cookieName : TOKEN_COOKIE_NAMES) {
+            for (Cookie cookie : cookies) {
+                if (cookieName.equals(cookie.getName())) {
+                    String cookieValue = cookie.getValue();
+                    if (isValidTokenString(cookieValue)) {
+                        return cookieValue;
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * @param tokenString - 검증할 토큰 문자열
+     * @return 유효한 토큰 문자열인지 여부
+     *
+     * 토큰 문자열 유효성 검증
+     */
+    private boolean isValidTokenString(String tokenString) {
+        return tokenString != null &&
+                !tokenString.trim().isEmpty() &&
+                !tokenString.equalsIgnoreCase("null") &&
+                !tokenString.equalsIgnoreCase("undefined");
+    }
+
+    /**
+     * @param authHeader - Bearer 접두사가 포함될 수 있는 토큰 문자열
+     * @return Bearer 접두사가 제거된 순수 토큰 문자열
+     *
+     * Bearer 접두사 제거 로직
+     */
+    private String removeBearerPrefix(String authHeader) {
+        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
+            return authHeader.substring(BEARER_PREFIX.length());
+        }
+        return authHeader;
     }
 
     /**
@@ -96,7 +195,7 @@
             // 토큰 값 검증
             if (tkn == null || tkn.trim().isEmpty()) {
                 throw new IllegalArgumentException("Token is null or empty");
-            }else{
+            } else {
                 tkn = extractToken(tkn);
             }
 
@@ -140,18 +239,4 @@
                 throw new IllegalArgumentException("Invalid knd : " + knd);
         }
     }
-
-    /**
-     * @param authHeader JWT 토큰 문자열
-     * @return 실제 토큰 부분만 추출
-     *
-     * Bearer 토큰에서 실제 토큰 부분만 추출하는 메서드
-     */
-    public String extractToken(String authHeader) {
-        if (authHeader != null && authHeader.startsWith("Bearer ")) {
-            return authHeader.substring(7);
-        }
-        return authHeader; // Bearer가 없으면 그대로 반환
-    }
-
-}
+}
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/common/util/LoginUtil.java
--- src/main/java/com/takensoft/common/util/LoginUtil.java
+++ src/main/java/com/takensoft/common/util/LoginUtil.java
@@ -4,7 +4,6 @@
 import com.takensoft.cms.loginPolicy.service.LoginModeService;
 import com.takensoft.cms.loginPolicy.service.LoginPolicyService;
 import com.takensoft.cms.mber.service.LgnHstryService;
-import com.takensoft.cms.mber.vo.LgnHstryVO;
 import com.takensoft.cms.mber.vo.MberVO;
 import com.takensoft.cms.token.service.RefreshTokenService;
 import com.takensoft.cms.token.vo.RefreshTknVO;
@@ -36,8 +35,9 @@
  *  2025.05.29  |  takensoft   | Redis 통합 중복로그인 관리
  *  2025.06.04  |  takensoft   | Redis 트랜잭션 및 타이밍 이슈 해결
  *  2025.06.09  |  takensoft   | 중복로그인 처리 로직 개선
+ *  2025.06.18  |  takensoft   | 로그인 이력 저장 로직 통합 및 중복 제거
  *
- * 통합 로그인 유틸리티 - 중복로그인 처리 개선
+ * 통합 로그인 유틸리티 - 로그인 이력 저장 로직 중복 제거
  */
 @Component
 @RequiredArgsConstructor
@@ -69,10 +69,11 @@
         res.setHeader("loginMode", loginMode); // J, S
         res.setHeader("policyMode", allowMultipleLogin ? "Y" : "N"); // Y, N
 
-        // 로그인 이력 등록
+        // 통합된 로그인 이력 저장 로직 사용
         String loginType = (String) req.getAttribute("loginType");
         if (!"OAUTH2".equals(loginType)) {
-            saveLoginHistory(mber, req);
+            // 시스템 로그인인 경우에만 이력 저장 (OAuth2는 핸들러에서 처리)
+            lgnHstryService.saveSystemLoginHistory(mber, req);
         }
 
         if ("S".equals(loginMode)) {
@@ -119,7 +120,6 @@
         if (!"OAUTH2".equals(loginType)) {
             sendSessionLoginResponse(res, mber);
         }
-
     }
 
     /**
@@ -166,7 +166,6 @@
         if (!"OAUTH2".equals(loginType)) {
             res.setStatus(HttpStatus.OK.value());
         }
-
     }
 
     /**
@@ -241,23 +240,5 @@
         String jsonResponse = new ObjectMapper().writeValueAsString(result);
         res.getWriter().write(jsonResponse);
         res.getWriter().flush();
-    }
-
-    /**
-     * 로그인 이력 저장
-     */
-    private void saveLoginHistory(MberVO mber, HttpServletRequest req) {
-        String userAgent = httpRequestUtil.getUserAgent(req);
-
-        LgnHstryVO lgnHstryVO = new LgnHstryVO();
-        lgnHstryVO.setLgnId(mber.getLgnId());
-        lgnHstryVO.setLgnType(mber.getAuthorities().stream()
-                .anyMatch(role -> role.getAuthority().equals("ROLE_ADMIN")) ? "0" : "1");
-        lgnHstryVO.setCntnIp(httpRequestUtil.getIp(req));
-        lgnHstryVO.setCntnOperSysm(httpRequestUtil.getOS(userAgent));
-        lgnHstryVO.setDvcNm(httpRequestUtil.getDevice(userAgent));
-        lgnHstryVO.setBrwsrNm(httpRequestUtil.getBrowser(userAgent));
-
-        lgnHstryService.LgnHstrySave(lgnHstryVO);
     }
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/resources/mybatis/mapper/mber/mber-SQL.xml
--- src/main/resources/mybatis/mapper/mber/mber-SQL.xml
+++ src/main/resources/mybatis/mapper/mber/mber-SQL.xml
@@ -489,15 +489,6 @@
 
     <!-- 메인 프로필 설정 -->
     <update id="setPrimaryProfile" parameterType="map">
-        <!-- 기존 메인 프로필 해제 -->
-        UPDATE mbr_sns_acnt_info
-        SET main_prfl_yn = false,
-        mdfr = #{mdfr},
-        mdfcn_dt = NOW()
-        WHERE mbr_id = #{mbrId}
-        AND main_prfl_yn = true;
-
-        <!-- 새로운 메인 프로필 설정 -->
         UPDATE mbr_sns_acnt_info
         SET main_prfl_yn = true,
         mdfr = #{mdfr},
Add a comment
List