하석형 하석형 05-26
Merge branch 'master' of http://210.180.118.83/jhpark/cms_backend
# Conflicts:
#	src/main/java/com/takensoft/common/config/SecurityConfig.java
@972ddb0f307e996d4dc3f309e5fe18d9a0feb0a1
build.gradle
--- build.gradle
+++ build.gradle
@@ -47,6 +47,7 @@
     implementation 'org.springframework.boot:spring-boot-starter-data-redis'
     implementation 'org.springframework.boot:spring-boot-starter-cache'
 
+
     implementation ('org.egovframe.rte:org.egovframe.rte.ptl.mvc:4.2.0'){
         exclude group: 'commons-logging', module: 'commons-logging'
     }
@@ -83,6 +84,8 @@
     implementation 'org.jsoup:jsoup:1.19.1'
     // Gmail SMTP
     implementation 'org.springframework.boot:spring-boot-starter-mail'
+    //oauth2
+    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
 
     testImplementation 'org.springframework.boot:spring-boot-starter-test'
     testImplementation 'org.springframework.security:spring-security-test'
src/main/java/com/takensoft/cms/mber/dao/MberDAO.java
--- src/main/java/com/takensoft/cms/mber/dao/MberDAO.java
+++ src/main/java/com/takensoft/cms/mber/dao/MberDAO.java
@@ -66,4 +66,53 @@
      * 사용자 정보 조회
      */
     MberVO findByMber(String mbrId);
+
+    /**
+     * @param email - 이메일
+     * @return MberVO - 사용자 정보
+     *
+     * 이메일로만 사용자 조회 (provider 무관)
+     */
+    MberVO findByEmail(String email);
+
+    /**
+     * @param email - 이메일
+     * @param mbrType - OAuth2 회원 유형 (K, N, G, F, S)
+     * @return MberVO - OAuth2 사용자 정보
+     *
+     * 이메일과 제공자로 사용자 조회
+     */
+    MberVO findByEmailAndProvider(String email, String mbrType);
+
+    /**
+     * @param mberVO - OAuth2 사용자 정보
+     * @return int - OAuth2 사용자 저장 결과
+     *
+     * OAuth2 사용자 저장
+     */
+    int saveOAuthUser(MberVO mberVO);
+
+    /**
+     * @param mberVO - OAuth2 사용자 정보
+     * @return int - OAuth2 사용자 업데이트 결과
+     *
+     * OAuth2 사용자 정보 업데이트
+     */
+    int updateOAuthUser(MberVO mberVO);
+
+    /**
+     * @param mberVO - OAuth2 연동 정보
+     * @return int - 연동 결과
+     *
+     * 기존 계정에 OAuth2 정보 연동
+     */
+    int linkOAuth2Account(MberVO mberVO);
+
+    /**
+     * @param mbrId - 회원 ID
+     * @return List<MberAuthorVO> - 회원 권한 목록
+     *
+     * 회원 ID로 권한 목록 조회
+     */
+    List<MberAuthorVO> findAuthoritiesByMbrId(String mbrId);
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/cms/mber/service/Impl/AdmMbrServiceImpl.java
--- src/main/java/com/takensoft/cms/mber/service/Impl/AdmMbrServiceImpl.java
+++ src/main/java/com/takensoft/cms/mber/service/Impl/AdmMbrServiceImpl.java
@@ -98,7 +98,8 @@
     @Override
     public AdmMbrDTO mbrDetail(String mbrId){
         try {
-        AdmMbrDTO admMbrDTO = new AdmMbrDTO();
+            verificationService.verifyAccess(mbrId); // 접근 검증
+            AdmMbrDTO admMbrDTO = new AdmMbrDTO();
 
         // mbrId가 있는 경우
         if (mbrId != null) {
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
@@ -23,7 +23,10 @@
 
 import jakarta.servlet.http.HttpServletRequest;
 
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
+
 /**
  * @author takensoft
  * @since 2024.04.01
@@ -57,9 +60,9 @@
     @Transactional(readOnly = true)
     public UserDetails loadUserByUsername(String username){
         try {
-             UserDetails userDetails = mberDAO.findByMberSecurity(username);
+            UserDetails userDetails = mberDAO.findByMberSecurity(username);
 
-             return userDetails;
+            return userDetails;
         } catch (UsernameNotFoundException Unfe) {
             throw Unfe;
         } catch (Exception e) {
@@ -107,15 +110,21 @@
     @Transactional(rollbackFor = Exception.class)
     public HashMap<String, Object> userJoin(HttpServletRequest req,  JoinDTO joinDTO){
         try {
-            // 회원 아이디 생성
-            String mbrId = mberIdgn.getNextStringId();
-            joinDTO.setMbrId(mbrId);
+            // 회원 아이디 생성 (이미 설정된 경우 건너뛰기)
+            if (joinDTO.getMbrId() == null || joinDTO.getMbrId().isEmpty()) {
+                String mbrId = mberIdgn.getNextStringId();
+                joinDTO.setMbrId(mbrId);
+            }
 
             // 아이디 소문자 변환
-            joinDTO.setLgnId(joinDTO.getLgnId().toLowerCase());
+            if (joinDTO.getLgnId() != null && !joinDTO.getLgnId().isEmpty()) {
+                joinDTO.setLgnId(joinDTO.getLgnId().toLowerCase());
+            }
 
-            // 비밀번호 암호화
-            joinDTO.setPswd(bCryptPasswordEncoder.encode(joinDTO.getPswd()));
+            // 비밀번호 암호화 (OAuth2는 비밀번호 없음)
+            if (joinDTO.getPswd() != null && !joinDTO.getPswd().isEmpty()) {
+                joinDTO.setPswd(bCryptPasswordEncoder.encode(joinDTO.getPswd()));
+            }
 
             // 연락처 암호화
             if(joinDTO.getMblTelno() != null && !joinDTO.getMblTelno().equals("")) {
@@ -126,15 +135,21 @@
             }
 
             // 아이피 조회 및 등록
-            joinDTO.setFrstRegIp(httpRequestUtil.getIp(req));
+            if (req != null) {
+                joinDTO.setFrstRegIp(httpRequestUtil.getIp(req));
+            } else {
+                joinDTO.setFrstRegIp("0.0.0.0"); // OAuth2의 경우 기본값
+            }
 
             // 등록된 토큰에서 사용자 정보 조회
-            String writer = verificationService.getCurrentUserId();
+            String writer = joinDTO.getRgtr();
             if (writer == null || writer.isEmpty()) {
-                throw new CustomNotFoundException("사용자 정보 조회에 실패했습니다.");
+                writer = verificationService.getCurrentUserId();
+                if (writer == null || writer.isEmpty()) {
+                    throw new CustomNotFoundException("사용자 정보 조회에 실패했습니다.");
+                }
+                joinDTO.setRgtr(writer);
             }
-            // 작성자 등록
-            joinDTO.setRgtr(writer);
 
             // 회원정보 등록
             HashMap<String, Object> result = new HashMap<>();
@@ -143,7 +158,7 @@
                 throw new CustomInsertFailException("회원 정보 등록에 실패했습니다.");
             }
 
-            result.put("mbrId", mbrId);
+            result.put("mbrId", joinDTO.getMbrId());
 
             // 권한 등록
             int authorResult = 0;
@@ -265,4 +280,183 @@
             throw e;
         }
     }
+
+
+
+    // OAuth2 관련 메서드 구현
+
+    /**
+     * @param email - 이메일
+     * @return MberVO - 사용자 정보
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * 이메일로만 사용자 조회 (provider 무관)
+     */
+    @Override
+    @Transactional(readOnly = true)
+    public MberVO findByEmail(String email) {
+        try {
+            return mberDAO.findByEmail(email);
+        } catch (DataAccessException dae) {
+            throw dae;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
+
+    /**
+     * @param email - 이메일
+     * @param mbrType - 회원 유형 (K, N, G, F, S)
+     * @return MberVO - 사용자 정보
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * 이메일과 회원 유형으로 사용자 조회
+     */
+    @Override
+    @Transactional(readOnly = true)
+    public MberVO findByEmailAndProvider(String email, String mbrType) {
+        try {
+            MberVO user = mberDAO.findByEmailAndProvider(email, mbrType); // 파라미터 직접 전달
+
+            // 권한 정보도 함께 조회
+            if (user != null) {
+                List<MberAuthorVO> authorities = mberDAO.findAuthoritiesByMbrId(user.getMbrId());
+                user.setAuthorList(authorities);
+            }
+            return user;
+        } catch (DataAccessException dae) {
+            throw dae;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
+
+    /**
+     * @param user - OAuth2 사용자 정보
+     * @return MberVO - 저장된 사용자 정보
+     * @throws CustomInsertFailException - 사용자 저장 실패 예외 발생 시
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * OAuth2 사용자 저장 - JoinDTO를 활용하여 기존 로직 재사용
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MberVO saveOAuthUser(MberVO user) {
+        try {
+            // OAuth2 사용자를 JoinDTO로 변환하여 기존 검증된 로직 활용
+            JoinDTO oauthJoinDTO = createOAuthJoinDTO(user);
+
+            // 기존 userJoin 메서드 활용 (검증된 로직)
+            HashMap<String, Object> result = userJoin(null, oauthJoinDTO);
+
+            // 생성된 회원 ID 설정
+            user.setMbrId(result.get("mbrId").toString());
+
+            return user;
+
+        } catch (DataAccessException dae) {
+            throw dae;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
+
+    /**
+     * OAuth2 사용자 정보를 JoinDTO로 변환
+     * @param user OAuth2 사용자 정보
+     * @return JoinDTO 변환된 회원가입 정보
+     */
+    private JoinDTO createOAuthJoinDTO(MberVO user) {
+        // 기본 권한 설정
+        List<MberAuthorVO> defaultAuthorities = createDefaultAuthorities();
+
+        JoinDTO joinDTO = new JoinDTO();
+        joinDTO.setMbrNm(user.getMbrNm());
+        joinDTO.setNcnm(user.getNcnm() != null ? user.getNcnm() : user.getMbrNm()); // 닉네임이 없으면 이름 사용
+        joinDTO.setLgnId(user.getEml().toLowerCase()); // 이메일을 로그인 ID로 사용
+        joinDTO.setEml(user.getEml());
+        joinDTO.setPswd("OAUTH2_NO_PASSWORD"); // OAuth2 사용자용 더미 비밀번호 (암호화됨)
+        joinDTO.setMbrStts("1");           // 승인 상태
+        joinDTO.setUseYn("Y");             // 사용 (문자열)
+        joinDTO.setSmsRcptnAgreYn("N");    // SMS 수신 거부 (기본값)
+        joinDTO.setEmlRcptnAgreYn("N");    // 이메일 수신 거부 (기본값)
+        joinDTO.setPrvcRlsYn("N");         // 개인정보 공개 거부 (기본값)
+        joinDTO.setMbrType(user.getMbrType()); // OAuth2 제공자 타입 (K, N, G, F)
+        joinDTO.setSysPvsnYn("1");         // 사용자 데이터 (문자열)
+        joinDTO.setRgtr("OAUTH2_SYSTEM");  // OAuth2 시스템 등록자
+        joinDTO.setAuthorList(user.getAuthorList() != null ? user.getAuthorList() : defaultAuthorities);
+
+        return joinDTO;
+    }
+
+    /**
+     * OAuth2 사용자 기본 권한 생성
+     * @return List<MberAuthorVO> 기본 권한 목록
+     */
+    private List<MberAuthorVO> createDefaultAuthorities() {
+        List<MberAuthorVO> authorities = new ArrayList<>();
+        MberAuthorVO userRole = new MberAuthorVO();
+        userRole.setAuthrtCd("ROLE_USER");
+        authorities.add(userRole);
+        return authorities;
+    }
+
+    /**
+     * @param user - OAuth2 사용자 정보
+     * @return MberVO - 업데이트된 사용자 정보
+     * @throws CustomUpdateFailException - 사용자 업데이트 실패 예외 발생 시
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * OAuth2 사용자 정보 업데이트
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MberVO updateOAuthUser(MberVO user) {
+        try {
+            user.setMdfr("OAUTH2_SYSTEM");
+
+            int result = mberDAO.updateOAuthUser(user);
+            if(result == 0) {
+                throw new CustomUpdateFailException("OAuth2 사용자 정보 업데이트에 실패했습니다.");
+            }
+
+            return user;
+        } catch (DataAccessException dae) {
+            throw dae;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
+
+    /**
+     * @param user - OAuth2 연동 정보가 추가된 사용자 정보
+     * @return MberVO - 연동된 사용자 정보
+     * @throws CustomUpdateFailException - 계정 연동 실패 예외 발생 시
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * 기존 계정에 OAuth2 정보 연동
+     */
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MberVO linkOAuth2Account(MberVO user) {
+        try {
+            user.setMdfr("OAUTH2_LINK");
+
+            int result = mberDAO.linkOAuth2Account(user);
+            if(result == 0) {
+                throw new CustomUpdateFailException("OAuth2 계정 연동에 실패했습니다.");
+            }
+
+            return user;
+        } catch (DataAccessException dae) {
+            throw dae;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/cms/mber/service/MberService.java
--- src/main/java/com/takensoft/cms/mber/service/MberService.java
+++ src/main/java/com/takensoft/cms/mber/service/MberService.java
@@ -62,4 +62,45 @@
      * 비밀번호 수정
      */
     public int updatePassword(PasswordDTO passwordDTO);
+
+    /**
+     * @param email - 이메일
+     * @return MberVO - 사용자 정보
+     *
+     * 이메일로만 사용자 조회
+     */
+    public MberVO findByEmail(String email);
+
+    /**
+     * @param email - 이메일
+     * @param mbrType - 회원 유형 (K, N, G, F, S)
+     * @return MberVO - 사용자 정보
+     *
+     * 이메일과 회원 유형으로 사용자 조회
+     */
+    public MberVO findByEmailAndProvider(String email, String mbrType);
+
+    /**
+     * @param user - OAuth2 사용자 정보
+     * @return MberVO - 저장된 사용자 정보
+     *
+     * OAuth2 사용자 저장
+     */
+    public MberVO saveOAuthUser(MberVO user);
+
+    /**
+     * @param user - OAuth2 사용자 정보
+     * @return MberVO - 업데이트된 사용자 정보
+     *
+     * OAuth2 사용자 정보 업데이트
+     */
+    public MberVO updateOAuthUser(MberVO user);
+
+    /**
+     * @param user - OAuth2 연동 정보가 추가된 사용자 정보
+     * @return MberVO - 연동된 사용자 정보
+     *
+     * 기존 계정에 OAuth2 정보 연동
+     */
+    public MberVO linkOAuth2Account(MberVO user);
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/common/config/SecurityConfig.java
--- src/main/java/com/takensoft/common/config/SecurityConfig.java
+++ src/main/java/com/takensoft/common/config/SecurityConfig.java
@@ -14,6 +14,10 @@
 import com.takensoft.common.util.LoginUtil;
 import com.takensoft.common.util.SessionUtil;
 import com.takensoft.common.verify.service.Impl.EmailServiceImpl;
+import com.takensoft.common.oauth.service.Impl.CustomOAuth2UserServiceImpl;
+import com.takensoft.common.oauth.handler.OAuth2AuthenticationSuccessHandler;
+import com.takensoft.common.oauth.handler.OAuth2AuthenticationFailureHandler;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -24,6 +28,7 @@
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.http.SessionCreationPolicy;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
 import org.springframework.security.web.SecurityFilterChain;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 import org.springframework.security.web.context.SecurityContextPersistenceFilter;
@@ -39,6 +44,8 @@
  * @modification
  *     since    |    author    | description
  *  2025.01.22  |  takensoft   | 최초 등록
+ *  2025.05.22  |  takensoft   | OAuth2 로그인 추가
+ *  2025.05.26  |    하석형     | 로그인 유틸 추가
  *
  * Spring Security 설정을 위한 Config
  */
@@ -63,6 +70,15 @@
     private final SessionUtil sessionUtil;
     private final LoginUtil loginUtil;
 
+    @Autowired
+    private CustomOAuth2UserServiceImpl customOAuth2UserServiceImpl;
+
+    @Autowired
+    private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
+
+    @Autowired
+    private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
+
     private static String FRONT_URL;    // 프론트 접근 허용 URL
     private static long JWT_ACCESSTIME; // access 토큰 유지 시간
     private static long JWT_REFRESHTIME; // refresh 토큰 유지 시간
@@ -71,16 +87,7 @@
     private final RedisTemplate<String, String> redisTemplate;
 
     /**
-     * @param authenticationConfiguration - 인증 구성 객체
-     * @param jwtUtil - JWT 유틸리티 객체
-     * @param authenticationEntryPoint - 인증 실패 시 처리 엔트리 포인트
-     * @param accessDenieHandler - 접근 거부 처리 핸들러
-     * @param fUrl - 프론트엔드 URL (application.yml에서 값을 읽어 옴)
-     * @param aTime - JWT 접근 토큰 유효 시간 (application.yml에서 값을 읽어 옴)
-     * @param rTime - JWT 리프레시 토큰 유효 시간 (application.yml에서 값을 읽어 옴)
-     * @param ctime - 쿠키 유효 시간 (application.yml에서 값을 읽어 옴)
-     * @param redisTemplate
-*
+     * SecurityConfig 생성자
      */
     public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil, RefreshTokenService refreshTokenService, CntxtPthService cntxtPthService, AccesCtrlService accesCtrlService, AppConfig appConfig,
                           LgnHstryService lgnHstryService, CustomAuthenticationEntryPoint authenticationEntryPoint, CustomAccessDenieHandler accessDenieHandler, HttpRequestUtil httpRequestUtil,
@@ -99,14 +106,12 @@
         this.appConfig = appConfig;
         this.loginModeService = loginModeService;
         this.loginPolicyService = loginPolicyService;
-        this.emailServiceImpl = emailServiceImpl;
         this.sessionUtil = sessionUtil;
         this.FRONT_URL = fUrl;
         this.JWT_ACCESSTIME = aTime;
         this.JWT_REFRESHTIME = rTime;
         this.COOKIE_TIME = ctime;
         this.redisTemplate = redisTemplate;
-        this.loginUtil = loginUtil;
     }
 
     /**
@@ -147,7 +152,7 @@
                         CorsConfiguration configuration = new CorsConfiguration();
                         configuration.setAllowedOrigins(Collections.singletonList(FRONT_URL)); // 허용할 프론트 포트 포함 경로 입력
                         configuration.setAllowedMethods(Collections.singletonList("*")); // 허용할 메소드(GET, POST, PUT 등)
-                        configuration.setAllowedHeaders(Collections.singletonList("*")); // 허용할 헤드
+                        configuration.setAllowedHeaders(Collections.singletonList("*")); // 허용할 헤더
                         configuration.setAllowCredentials(true); // 프론트에서 credentials 설정하면 true
                         configuration.setMaxAge(3600L); // 허용을 물고 있을 시간
                         configuration.setExposedHeaders(Collections.singletonList("Authorization")); // 서버에서 JWT를 Authorization에 담아 보내기 위해 허용을 함
@@ -155,6 +160,7 @@
                     }
                 })
         );
+
         // csrf disable
         http.csrf((auth) -> auth.disable());
         // formLogin disable
@@ -170,10 +176,24 @@
         );
 
         http.authorizeHttpRequests((auth) -> auth
-                .requestMatchers("/", "/mbr/**", "/refresh/**", "/sys/**", "/editFileUpload/**", "/fileUpload/**").permitAll() // 회원, 토큰, 시스템 제공, 파일 접근 모두 허용
+                .requestMatchers("/", "/mbr/**", "/refresh/**", "/sys/**", "/editFileUpload/**", "/fileUpload/**", "/oauth2/**", "/login/oauth2/**", "/.well-known/**").permitAll() // 회원, 토큰, 시스템 제공, 파일, OAuth2 접근 모두 허용
                 .requestMatchers("/admin/**").hasRole("ADMIN") // 관리자 페이지는 ADMIN 권한을 가진 사용자만 접근 가능
                 .anyRequest().authenticated() // 그 외에는 로그인한 사용자만 접근 가능
-//                .anyRequest().permitAll() // 모든 사용자 접근 가능
+        );
+
+        // OAuth2 로그인 설정
+        http.oauth2Login(oauth2 -> oauth2
+                .authorizationEndpoint(authorization -> authorization
+                        .baseUri("/oauth2/authorization")
+                )
+                .redirectionEndpoint(redirection -> redirection
+                        .baseUri("/login/oauth2/code/*")
+                )
+                .userInfoEndpoint(userInfo -> userInfo
+                        .userService(customOAuth2UserServiceImpl)
+                )
+                .successHandler(oAuth2AuthenticationSuccessHandler)
+                .failureHandler(oAuth2AuthenticationFailureHandler)
         );
 
         // Context Path 검증 필터
@@ -190,6 +210,7 @@
                  loginModeService, loginPolicyService, emailServiceImpl, sessionUtil, JWT_ACCESSTIME, JWT_REFRESHTIME, COOKIE_TIME, redisTemplate,
                 loginUtil), UsernamePasswordAuthenticationFilter.class);
 
+
         return http.build();
     }
 }
(파일 끝에 줄바꿈 문자 없음)
src/main/java/com/takensoft/common/filter/AccesFilter.java
--- src/main/java/com/takensoft/common/filter/AccesFilter.java
+++ src/main/java/com/takensoft/common/filter/AccesFilter.java
@@ -58,6 +58,14 @@
      */
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+
+        String requestURI = request.getRequestURI();
+
+        // OAuth2 관련 경로는 접근 제어 제외
+        if (isOAuth2Request(requestURI)) {
+            filterChain.doFilter(request, response);
+            return;
+        }
         try {
             // 아이피 정보
             String ipAdrs = httpRequestUtil.getIp(request);
@@ -105,6 +113,15 @@
     }
 
     /**
+     * OAuth2 관련 요청인지 확인
+     */
+    private boolean isOAuth2Request(String requestURI) {
+        return requestURI.startsWith("/oauth2/") ||
+                requestURI.startsWith("/login/oauth2/") ||
+                requestURI.startsWith("/oauth/");
+    }
+
+    /**
      * @param accesCtrlList 접근 제어 정보 리스트
      * @param req HttpServletRequest 객체
      * @return boolean 요청 URI에 따른 접근 제어 여부
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
@@ -71,6 +71,13 @@
      */
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+        String requestURI = request.getRequestURI();
+
+        // OAuth2 관련 경로는 JWT 검증 제외
+        if (isOAuth2Request(requestURI)) {
+            filterChain.doFilter(request, response);
+            return;
+        }
         try {
             String loginMode = loginModeService.getLoginMode();
             String accessToken = resolveToken(request, loginMode);
@@ -120,6 +127,16 @@
         }
     }
 
+    /**
+     * OAuth2 관련 요청인지 확인
+     */
+    private boolean isOAuth2Request(String requestURI) {
+        return requestURI.startsWith("/oauth2/") ||
+                requestURI.startsWith("/login/oauth2/") ||
+                requestURI.startsWith("/oauth/");
+    }
+
+
     private boolean isTokenValid(String mbrId, String accessToken) {
         String storedToken = redisTemplate.opsForValue().get("jwt:" + mbrId);
         return storedToken == null || storedToken.equals(accessToken);
 
src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationFailureHandler.java (added)
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationFailureHandler.java
@@ -0,0 +1,70 @@
+package com.takensoft.common.oauth.handler;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 로그인 실패 핸들러
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
+
+    @Value("${front.url}")
+    private String FRONT_URL;
+
+    @Override
+    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
+                                        AuthenticationException exception) throws IOException, ServletException {
+
+        log.error("OAuth2 로그인 실패: {}", exception.getMessage());
+
+        String errorMessage = mapErrorMessage(exception);
+        String encodedMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);
+
+        String redirectUrl = String.format("%s/login?error=oauth2_failed&message=%s",
+                FRONT_URL, encodedMessage);
+
+        getRedirectStrategy().sendRedirect(request, response, redirectUrl);
+    }
+
+    /**
+     * 에러 메시지 매핑
+     */
+    private String mapErrorMessage(AuthenticationException exception) {
+        String message = exception.getMessage();
+
+        if (message.contains("access_denied")) {
+            return "사용자가 인증을 취소했습니다.";
+        }
+        if (message.contains("invalid_request")) {
+            return "잘못된 OAuth2 요청입니다.";
+        }
+        if (message.contains("unauthorized_client")) {
+            return "인증되지 않은 클라이언트입니다.";
+        }
+        if (message.contains("server_error")) {
+            return "OAuth2 서버 오류가 발생했습니다.";
+        }
+
+        return "OAuth2 로그인에 실패했습니다.";
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java (added)
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2AuthenticationSuccessHandler.java
@@ -0,0 +1,176 @@
+package com.takensoft.common.oauth.handler;
+
+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.common.oauth.vo.CustomOAuth2UserVO;
+import com.takensoft.common.util.HttpRequestUtil;
+import com.takensoft.common.util.JWTUtil;
+import com.takensoft.common.util.SessionUtil;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 로그인 성공 핸들러
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
+
+    private final LgnHstryService lgnHstryService;
+    private final HttpRequestUtil httpRequestUtil;
+    private final LoginModeService loginModeService;
+    private final LoginPolicyService loginPolicyService;
+    private final SessionUtil sessionUtil;
+    private final JWTUtil jwtUtil;
+    private final RedisTemplate<String, String> redisTemplate;
+    private final ApplicationEventPublisher eventPublisher;
+
+    @Value("${jwt.accessTime}")
+    private long JWT_ACCESSTIME;
+
+    @Value("${front.url}")
+    private String FRONT_URL;
+
+    @Override
+    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+                                        Authentication authentication) throws IOException, ServletException {
+
+        CustomOAuth2UserVO oAuth2User = (CustomOAuth2UserVO) authentication.getPrincipal();
+
+        try {
+            log.info("OAuth2 로그인 성공 - Provider: {}, Email: {}",
+                    oAuth2User.getProvider(), oAuth2User.getEmail());
+
+            // 1. 비동기로 사용자 정보 저장/업데이트 처리
+            eventPublisher.publishEvent(new OAuth2UserSaveEvent(oAuth2User));
+
+            // 2. 로그인 이력 저장
+            saveLoginHistory(request, oAuth2User);
+
+            // 3. 로그인 모드에 따른 토큰/세션 처리
+            String loginMode = loginModeService.getLoginMode();
+            String tempUserId = createTempUserId(oAuth2User);
+            processLoginByMode(request, response, oAuth2User, tempUserId, loginMode);
+
+            // 4. 캐시 방지 헤더 설정
+            response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+            response.setHeader("Pragma", "no-cache");
+            response.setHeader("Expires", "0");
+
+            // 5. 프론트엔드 로그인 페이지로 리다이렉트 (OAuth 성공 파라미터 + 타임스탬프)
+            long timestamp = System.currentTimeMillis();
+            String redirectUrl = FRONT_URL + "/login.page?oauth_success=true&t=" + timestamp;
+            getRedirectStrategy().sendRedirect(request, response, redirectUrl);
+
+        } catch (Exception e) {
+            log.error("OAuth2 로그인 처리 중 오류 발생", e);
+            String errorUrl = FRONT_URL + "/login.page?error=oauth2_processing_failed";
+            getRedirectStrategy().sendRedirect(request, response, errorUrl);
+        }
+    }
+
+    /**
+     * 로그인 이력 저장
+     */
+    private void saveLoginHistory(HttpServletRequest request, CustomOAuth2UserVO oAuth2User) {
+        try {
+            LgnHstryVO lgnHstryVO = new LgnHstryVO();
+            lgnHstryVO.setLgnId(oAuth2User.getEmail());
+            lgnHstryVO.setLgnType("1"); // 일반 사용자
+            lgnHstryVO.setCntnIp(httpRequestUtil.getIp(request));
+            lgnHstryVO.setCntnOperSys(httpRequestUtil.getOS(httpRequestUtil.getUserAgent(request)));
+            lgnHstryVO.setDeviceNm(httpRequestUtil.getDevice(httpRequestUtil.getUserAgent(request)));
+            lgnHstryVO.setBrwsrNm(httpRequestUtil.getBrowser(httpRequestUtil.getUserAgent(request)));
+
+            lgnHstryService.LgnHstrySave(lgnHstryVO);
+        } catch (Exception e) {
+            log.error("OAuth2 로그인 이력 저장 실패", e);
+        }
+    }
+
+    /**
+     * 임시 사용자 ID 생성
+     */
+    private String createTempUserId(CustomOAuth2UserVO oAuth2User) {
+        return oAuth2User.getProvider() + "_" + oAuth2User.getId();
+    }
+
+    /**
+     * 로그인 모드에 따른 처리
+     */
+    private void processLoginByMode(HttpServletRequest request, HttpServletResponse response,
+                                    CustomOAuth2UserVO oAuth2User, String tempUserId, String loginMode)
+            throws IOException {
+
+        // JWT 토큰 생성
+        String accessToken = jwtUtil.createJwt(
+                "Authorization",
+                tempUserId,
+                oAuth2User.getEmail(),
+                oAuth2User.getName(),
+                null, // roles는 DB 저장 후 갱신 예정
+                JWT_ACCESSTIME
+        );
+
+        if ("S".equals(loginMode)) {
+            // 세션 모드 처리
+            processSessionMode(request, tempUserId, accessToken, oAuth2User);
+        } else {
+            // JWT 모드 처리 (기본값)
+            processJwtMode(response, tempUserId, accessToken);
+        }
+
+        response.setHeader("login-type", loginMode);
+    }
+
+    /**
+     * 세션 모드 처리
+     */
+    private void processSessionMode(HttpServletRequest request, String tempUserId,
+                                    String accessToken, CustomOAuth2UserVO oAuth2User) {
+        HttpSession session = request.getSession(true);
+        session.setAttribute("JWT_TOKEN", accessToken);
+        session.setAttribute("oauth2User", oAuth2User);
+
+        // 중복 로그인 정책 확인
+        if (!loginPolicyService.getPolicy()) {
+            sessionUtil.registerSession(tempUserId, session);
+        }
+    }
+
+    /**
+     * JWT 모드 처리
+     */
+    private void processJwtMode(HttpServletResponse response, String tempUserId, String accessToken) {
+        response.setHeader("Authorization", accessToken);
+
+        // 중복 로그인 정책 확인
+        if (!loginPolicyService.getPolicy()) {
+            redisTemplate.delete("jwt:" + tempUserId);
+            redisTemplate.opsForValue().set("jwt:" + tempUserId, accessToken,
+                    JWT_ACCESSTIME, TimeUnit.MILLISECONDS);
+        }
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/handler/OAuth2UserSaveEvent.java (added)
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2UserSaveEvent.java
@@ -0,0 +1,25 @@
+package com.takensoft.common.oauth.handler;
+
+import com.takensoft.common.oauth.vo.CustomOAuth2UserVO;
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 사용자 저장 이벤트
+ */
+@Getter
+public class OAuth2UserSaveEvent extends ApplicationEvent {
+
+    private final CustomOAuth2UserVO oAuth2User;
+
+    public OAuth2UserSaveEvent(CustomOAuth2UserVO oAuth2User) {
+        super(oAuth2User);
+        this.oAuth2User = oAuth2User;
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/handler/OAuth2UserSaveEventListener.java (added)
+++ src/main/java/com/takensoft/common/oauth/handler/OAuth2UserSaveEventListener.java
@@ -0,0 +1,119 @@
+package com.takensoft.common.oauth.handler;
+
+import com.takensoft.cms.mber.service.MberService;
+import com.takensoft.cms.mber.vo.MberAuthorVO;
+import com.takensoft.cms.mber.vo.MberVO;
+import com.takensoft.common.oauth.vo.CustomOAuth2UserVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 사용자 저장 이벤트 리스너
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class OAuth2UserSaveEventListener {
+
+    private final MberService mberService;
+
+    @EventListener
+    @Async
+    public void handleOAuth2UserSaveEvent(OAuth2UserSaveEvent event) {
+        try {
+            CustomOAuth2UserVO oAuth2User = event.getOAuth2User();
+            processOAuth2User(oAuth2User);
+        } catch (Exception e) {
+            log.error("OAuth2 사용자 저장 이벤트 처리 실패", e);
+        }
+    }
+
+    /**
+     * OAuth2 사용자 처리 (저장 또는 업데이트)
+     */
+    private void processOAuth2User(CustomOAuth2UserVO oAuth2User) {
+        String mbrType = convertProviderToMbrType(oAuth2User.getProvider());
+
+        // 이메일과 제공자로 기존 사용자 조회
+        MberVO existingUser = mberService.findByEmailAndProvider(oAuth2User.getEmail(), mbrType);
+
+        if (existingUser != null) {
+            updateExistingUser(existingUser, oAuth2User);
+        } else {
+            createNewUser(oAuth2User, mbrType);
+        }
+    }
+
+    /**
+     * 기존 사용자 정보 업데이트
+     */
+    private void updateExistingUser(MberVO existingUser, CustomOAuth2UserVO oAuth2User) {
+        try {
+            existingUser.setMbrNm(oAuth2User.getName());
+            // 프로필 이미지 URL이 있다면 업데이트
+            if (oAuth2User.getImageUrl() != null) {
+                // 필요시 프로필 이미지 필드 추가
+            }
+
+            mberService.updateOAuthUser(existingUser);
+            log.info("기존 OAuth2 사용자 정보 업데이트 완료: {}", existingUser.getEml());
+        } catch (Exception e) {
+            log.error("기존 OAuth2 사용자 업데이트 실패: {}", oAuth2User.getEmail(), e);
+        }
+    }
+
+    /**
+     * 새 OAuth2 사용자 생성
+     */
+    private void createNewUser(CustomOAuth2UserVO oAuth2User, String mbrType) {
+        try {
+            MberVO newUser = new MberVO();
+            newUser.setEml(oAuth2User.getEmail());
+            newUser.setMbrNm(oAuth2User.getName());
+            newUser.setNcnm(oAuth2User.getName());
+            newUser.setMbrType(mbrType);
+            newUser.setAuthorList(createDefaultAuthorities());
+
+            mberService.saveOAuthUser(newUser);
+            log.info("새 OAuth2 사용자 생성 완료: {}", newUser.getEml());
+        } catch (Exception e) {
+            log.error("새 OAuth2 사용자 생성 실패: {}", oAuth2User.getEmail(), e);
+        }
+    }
+
+    /**
+     * 기본 권한 생성
+     */
+    private List<MberAuthorVO> createDefaultAuthorities() {
+        List<MberAuthorVO> authorities = new ArrayList<>();
+        MberAuthorVO userRole = new MberAuthorVO();
+        userRole.setAuthrtCd("ROLE_USER");
+        authorities.add(userRole);
+        return authorities;
+    }
+
+    /**
+     * OAuth2 제공자를 회원 타입으로 변환
+     */
+    private String convertProviderToMbrType(String provider) {
+        return switch (provider.toLowerCase()) {
+            case "kakao" -> "K";
+            case "naver" -> "N";
+            case "google" -> "G";
+            case "facebook" -> "F";
+            default -> "S"; // 일반 회원
+        };
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/service/CustomOAuth2UserService.java (added)
+++ src/main/java/com/takensoft/common/oauth/service/CustomOAuth2UserService.java
@@ -0,0 +1,27 @@
+package com.takensoft.common.oauth.service;
+
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 사용자 정보 서비스 인터페이스
+ */
+public interface CustomOAuth2UserService extends OAuth2UserService<OAuth2UserRequest, OAuth2User> {
+
+    /**
+     * @param userRequest - OAuth2 사용자 요청
+     * @return OAuth2User - OAuth2 사용자 정보
+     * @throws OAuth2AuthenticationException - OAuth2 인증 예외
+     *
+     * OAuth2 사용자 정보 로드
+     */
+    OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException;
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/service/Impl/CustomOAuth2UserServiceImpl.java (added)
+++ src/main/java/com/takensoft/common/oauth/service/Impl/CustomOAuth2UserServiceImpl.java
@@ -0,0 +1,86 @@
+package com.takensoft.common.oauth.service.Impl;
+
+import com.takensoft.common.oauth.service.CustomOAuth2UserService;
+import com.takensoft.common.oauth.vo.CustomOAuth2UserVO;
+import com.takensoft.common.oauth.vo.OAuth2UserInfoVO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.egovframe.rte.fdl.cmmn.EgovAbstractServiceImpl;
+import org.springframework.dao.DataAccessException;
+import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+import org.springframework.stereotype.Service;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 사용자 정보 서비스 구현체
+ * EgovAbstractServiceImpl : 전자정부 상속
+ * CustomOAuth2UserService : OAuth2 사용자 정보 인터페이스 상속
+ * OAuth2UserService : Spring Security OAuth2 인터페이스 상속
+ */
+@Service("customOAuth2UserService")
+@RequiredArgsConstructor
+@Slf4j
+public class CustomOAuth2UserServiceImpl extends EgovAbstractServiceImpl
+        implements CustomOAuth2UserService, OAuth2UserService<OAuth2UserRequest, OAuth2User> {
+
+    private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
+
+    /**
+     * @param userRequest - OAuth2 사용자 요청
+     * @return OAuth2User - OAuth2 사용자 정보
+     * @throws OAuth2AuthenticationException - OAuth2 인증 예외 발생 시
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * OAuth2 사용자 정보 로드 (최소 처리)
+     */
+    @Override
+    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
+        try {
+            OAuth2User oAuth2User = delegate.loadUser(userRequest);
+            return processOAuth2User(userRequest, oAuth2User);
+        } catch (DataAccessException dae) {
+            log.error("OAuth2 사용자 처리 중 DB 오류 발생", dae);
+            throw new OAuth2AuthenticationException("OAuth2 사용자 처리 실패 - DB 오류");
+        } catch (Exception e) {
+            log.error("OAuth2 사용자 처리 중 오류 발생", e);
+            throw new OAuth2AuthenticationException("OAuth2 사용자 처리 실패");
+        }
+    }
+
+    /**
+     * @param userRequest - OAuth2 사용자 요청
+     * @param oAuth2User - OAuth2 사용자 정보
+     * @return OAuth2User - 처리된 OAuth2 사용자 정보
+     * @throws DataAccessException - db 관련 예외 발생 시
+     * @throws Exception - 그 외 예외 발생 시
+     *
+     * OAuth2 사용자 정보 처리 (단순히 CustomOAuth2User 객체만 생성)
+     */
+    private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
+        try {
+            // 제공업체 정보 추출
+            String registrationId = userRequest.getClientRegistration().getRegistrationId();
+
+            // 통합 OAuth2UserInfo 생성
+            OAuth2UserInfoVO oAuth2UserInfo = new OAuth2UserInfoVO(registrationId, oAuth2User.getAttributes());
+
+            // DB 작업 없이 단순히 CustomOAuth2User 객체만 반환
+            // 실제 사용자 저장/업데이트는 OAuth2SuccessHandler에서 처리
+            return new CustomOAuth2UserVO(oAuth2UserInfo, oAuth2User.getAttributes());
+        } catch (DataAccessException dae) {
+            throw dae;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/vo/CustomOAuth2UserVO.java (added)
+++ src/main/java/com/takensoft/common/oauth/vo/CustomOAuth2UserVO.java
@@ -0,0 +1,57 @@
+package com.takensoft.common.oauth.vo;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.core.user.OAuth2User;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 사용자 커스텀 클래스
+ */
+@RequiredArgsConstructor
+public class CustomOAuth2UserVO implements OAuth2User {
+
+    private final OAuth2UserInfoVO oAuth2UserInfoVO;
+    private final Map<String, Object> attributes;
+
+    @Override
+    public Map<String, Object> getAttributes() {
+        return attributes;
+    }
+
+    @Override
+    public Collection<? extends GrantedAuthority> getAuthorities() {
+        return Collections.emptyList(); // 권한 설정이 필요한 경우 여기서 처리
+    }
+
+    @Override
+    public String getName() {
+        return oAuth2UserInfoVO.getName();
+    }
+
+    // 추가 getter 메서드들
+    public String getId() {
+        return oAuth2UserInfoVO.getId();
+    }
+
+    public String getEmail() {
+        return oAuth2UserInfoVO.getEmail();
+    }
+
+    public String getImageUrl() {
+        return oAuth2UserInfoVO.getImageUrl();
+    }
+
+    public String getProvider() {
+        return oAuth2UserInfoVO.getProvider();
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/vo/OAuth2UserInfoVO.java (added)
+++ src/main/java/com/takensoft/common/oauth/vo/OAuth2UserInfoVO.java
@@ -0,0 +1,75 @@
+package com.takensoft.common.oauth.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.Map;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *
+ * OAuth2 사용자 정보 통합 VO
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+public class OAuth2UserInfoVO {
+
+    private Map<String, Object> attributes;    // OAuth2 응답 전체 데이터
+    private String provider;                   // 제공업체 (kakao, naver, google 등)
+    private String id;                         // 사용자 ID
+    private String name;                       // 사용자 이름
+    private String email;                      // 이메일
+    private String imageUrl;                   // 프로필 이미지 URL
+
+    // 제공업체별 데이터를 통일된 형태로 변환하는 생성자
+    public OAuth2UserInfoVO(String provider, Map<String, Object> attributes) {
+        this.provider = provider;
+        this.attributes = attributes;
+
+        switch (provider.toLowerCase()) {
+            case "kakao":
+                setKakaoUserInfo(attributes);
+                break;
+            case "naver":
+                setNaverUserInfo(attributes);
+                break;
+            case "google":
+                setGoogleUserInfo(attributes);
+                break;
+        }
+    }
+
+    private void setKakaoUserInfo(Map<String, Object> attributes) {
+        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
+        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");
+
+        this.id = String.valueOf(attributes.get("id"));
+        this.name = (String) profile.get("nickname");
+        this.email = (String) kakaoAccount.get("email");
+        this.imageUrl = (String) profile.get("profile_image_url");
+    }
+
+    private void setNaverUserInfo(Map<String, Object> attributes) {
+        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
+
+        this.id = (String) response.get("id");
+        this.name = (String) response.get("name");
+        this.email = (String) response.get("email");
+        this.imageUrl = (String) response.get("profile_image");
+    }
+
+    private void setGoogleUserInfo(Map<String, Object> attributes) {
+        this.id = (String) attributes.get("sub");
+        this.name = (String) attributes.get("name");
+        this.email = (String) attributes.get("email");
+        this.imageUrl = (String) attributes.get("picture");
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
src/main/java/com/takensoft/common/oauth/web/OAuth2Controller.java (added)
+++ src/main/java/com/takensoft/common/oauth/web/OAuth2Controller.java
@@ -0,0 +1,235 @@
+package com.takensoft.common.oauth.web;
+
+import com.takensoft.cms.mber.service.MberService;
+import com.takensoft.cms.mber.vo.MberVO;
+import com.takensoft.common.message.MessageCode;
+import com.takensoft.common.service.VerificationService;
+import com.takensoft.common.util.HttpRequestUtil;
+import com.takensoft.common.util.ResponseUtil;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author takensoft
+ * @since 2025.05.22
+ * @modification
+ *     since    |    author    | description
+ *  2025.05.22  |  takensoft   | 최초 등록
+ *  2025.05.26  |  takensoft   | OAuth 리다이렉트 기능 추가
+ *
+ * OAuth2 관련 통합 컨트롤러
+ */
+@RestController
+@RequiredArgsConstructor
+@Slf4j
+@RequestMapping(value = "/oauth2")
+public class OAuth2Controller {
+
+    private final MberService mberService;
+    private final VerificationService verificationService;
+    private final ResponseUtil resUtil;
+    private final HttpRequestUtil httpRequestUtil;
+
+    @Value("${front.url}")
+    private String FRONT_URL;
+
+    // 지원하는 OAuth 제공자 목록
+    private static final List<String> SUPPORTED_PROVIDERS = Arrays.asList("kakao", "naver", "google");
+
+    /**
+     * OAuth 로그인 리다이렉트 처리
+     * 프론트엔드에서 provider 정보를 받아 검증 후 OAuth2 서버로 리다이렉트
+     */
+    @GetMapping("/login")
+    public void redirectToOAuth(@RequestParam String provider,
+                                HttpServletRequest request,
+                                HttpServletResponse response) throws IOException {
+
+        String clientIP = httpRequestUtil.getIp(request);
+        String userAgent = httpRequestUtil.getUserAgent(request);
+
+        log.info("=== OAuth 로그인 시도 ===");
+        log.info("Provider: {}", provider);
+        log.info("Client IP: {}", clientIP);
+        log.info("User Agent: {}", userAgent);
+
+        try {
+            // 1. Provider 유효성 검증
+            validateProvider(provider);
+
+            // 2. 보안 검증 (필요시 추가)
+            validateSecurity(request);
+
+            // 3. CORS 헤더 설정 (브라우저 보안 문제 해결)
+            response.setHeader("Access-Control-Allow-Origin", FRONT_URL);
+            response.setHeader("Access-Control-Allow-Credentials", "true");
+            response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+            response.setHeader("Access-Control-Allow-Headers", "*");
+
+            // 4. OAuth2 Authorization Server로 리다이렉트
+            String redirectUrl = "/oauth2/authorization/" + provider;
+            log.info("OAuth 리다이렉트 URL: {}", redirectUrl);
+
+            // 리다이렉트 상태 코드를 명시적으로 설정
+            response.setStatus(HttpServletResponse.SC_MOVED_TEMPORARILY);
+            response.sendRedirect(redirectUrl);
+
+        } catch (IllegalArgumentException e) {
+            log.error("OAuth 로그인 실패 - 잘못된 Provider: {}", provider);
+            handleError(response, "invalid_provider", e.getMessage());
+        } catch (SecurityException e) {
+            log.error("OAuth 로그인 실패 - 보안 검증 실패: {}", e.getMessage());
+            handleError(response, "security_check_failed", e.getMessage());
+        } catch (Exception e) {
+            log.error("OAuth 로그인 실패 - 시스템 오류", e);
+            handleError(response, "system_error", "OAuth 로그인 중 오류가 발생했습니다.");
+        }
+    }
+
+    /**
+     * OAuth2 로그인 후 사용자 정보 조회
+     */
+    @GetMapping(value = "/user-info")
+    public ResponseEntity<?> getUserInfo(HttpServletRequest request) {
+        try {
+            // 현재 로그인된 사용자 ID 추출
+            String currentUserId = verificationService.getCurrentUserId();
+
+            if (currentUserId == null || currentUserId.isEmpty()) {
+                // 세션에서 OAuth2 정보 확인 (세션 모드인 경우)
+                HttpSession session = request.getSession(false);
+                if (session != null && session.getAttribute("oauth2User") != null) {
+                    return handleSessionOAuth2User(session);
+                }
+
+                return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
+            }
+
+            // DB에서 사용자 정보 조회
+            HashMap<String, Object> params = new HashMap<>();
+            params.put("mbrId", currentUserId);
+            MberVO userInfo = mberService.findByMbr(params);
+
+            if (userInfo == null) {
+                return resUtil.errorRes(MessageCode.LOGIN_USER_NOT_FOUND);
+            }
+
+            // 응답 데이터 구성
+            HashMap<String, Object> response = createUserResponse(userInfo);
+            return resUtil.successRes(response, MessageCode.COMMON_SUCCESS);
+
+        } catch (Exception e) {
+            log.error("사용자 정보 조회 실패", e);
+            return resUtil.errorRes(MessageCode.COMMON_UNKNOWN_ERROR);
+        }
+    }
+
+    /**
+     * 지원하는 OAuth 제공자 목록 조회 API
+     */
+    @GetMapping("/providers")
+    public ResponseEntity<?> getSupportedProviders() {
+        log.info("지원하는 OAuth 제공자 목록 조회");
+        return resUtil.successRes(SUPPORTED_PROVIDERS, MessageCode.COMMON_SUCCESS);
+    }
+
+    /**
+     * OAuth 제공자 유효성 검증
+     */
+    private void validateProvider(String provider) {
+        if (provider == null || provider.trim().isEmpty()) {
+            throw new IllegalArgumentException("OAuth 제공자가 지정되지 않았습니다.");
+        }
+
+        if (!SUPPORTED_PROVIDERS.contains(provider.toLowerCase())) {
+            throw new IllegalArgumentException("지원하지 않는 OAuth 제공자입니다: " + provider);
+        }
+
+        // TODO: 동적 설정으로 특정 제공자 비활성화 체크
+        // if (!oauthConfigService.isProviderEnabled(provider)) {
+        //     throw new IllegalArgumentException(provider + " 로그인이 일시 중단되었습니다.");
+        // }
+    }
+
+    /**
+     * 보안 검증 (필요시 확장)
+     */
+    private void validateSecurity(HttpServletRequest request) {
+        // TODO: 필요시 보안 검증 로직 추가
+        // 예: IP 화이트리스트, Rate Limiting, 사용자 상태 체크 등
+
+        String clientIP = httpRequestUtil.getIp(request);
+
+        // 예시: 로컬 개발 환경이 아닌 경우 추가 검증
+        if (!"127.0.0.1".equals(clientIP) && !"::1".equals(clientIP)) {
+            // 운영 환경 보안 검증 로직
+        }
+    }
+
+    /**
+     * 에러 발생 시 프론트엔드로 리다이렉트
+     */
+    private void handleError(HttpServletResponse response, String errorCode, String errorMessage) throws IOException {
+        String encodedMessage = java.net.URLEncoder.encode(errorMessage, "UTF-8");
+        String errorUrl = String.format("%s/login?error=%s&message=%s", FRONT_URL, errorCode, encodedMessage);
+
+        log.info("에러 리다이렉트 URL: {}", errorUrl);
+        response.sendRedirect(errorUrl);
+    }
+
+    /**
+     * 세션의 OAuth2 사용자 정보 처리
+     */
+    private ResponseEntity<?> handleSessionOAuth2User(HttpSession session) {
+        try {
+            // 세션에서 OAuth2 사용자 정보 추출
+            // 이는 DB 저장이 완료되기 전의 임시 상태
+            HashMap<String, Object> tempResponse = new HashMap<>();
+            tempResponse.put("mbrId", "TEMP_OAUTH2");
+            tempResponse.put("mbrNm", "OAuth2 User");
+            tempResponse.put("roles", new String[]{"ROLE_USER"});
+            tempResponse.put("isTemporary", true);
+
+            return resUtil.successRes(tempResponse, MessageCode.COMMON_SUCCESS);
+        } catch (Exception e) {
+            log.error("세션 OAuth2 사용자 정보 처리 실패", e);
+            return resUtil.errorRes(MessageCode.COMMON_UNKNOWN_ERROR);
+        }
+    }
+
+    /**
+     * 사용자 응답 데이터 생성
+     */
+    private HashMap<String, Object> createUserResponse(MberVO userInfo) {
+        HashMap<String, Object> response = new HashMap<>();
+        response.put("mbrId", userInfo.getMbrId());
+        response.put("mbrNm", userInfo.getMbrNm());
+        response.put("eml", userInfo.getEml());
+        response.put("ncnm", userInfo.getNcnm());
+        response.put("mbrType", userInfo.getMbrType());
+
+        // 권한 정보 변환
+        String[] roles = userInfo.getAuthorList().stream()
+                .map(auth -> auth.getAuthrtCd())
+                .toArray(String[]::new);
+        response.put("roles", roles);
+        response.put("isTemporary", false);
+
+        return response;
+    }
+}(파일 끝에 줄바꿈 문자 없음)
src/main/resources/application.yml
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
@@ -65,6 +65,55 @@
     verifyTime: 600000 # 인증가능 시간: 10분
     storeTime: 86400000 # 보관 시간: 24시간
 
+  # OAuth2 설정 추가
+  security:
+    oauth2:
+      client:
+        registration:
+          # 카카오 OAuth2 설정
+          kakao:
+            client-id: ${KAKAO_CLIENT_ID:bbaf1cd4db69d4b4a9be5b6917361ac3}
+            client-secret: ${KAKAO_CLIENT_SECRET:AvQ4lQ7HajbNa3AhuzOb979l5cMQDlEJ}
+            redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:9090/login/oauth2/code/kakao}
+            authorization-grant-type: authorization_code
+            scope: profile_nickname,profile_image,account_email
+            client-name: Kakao
+            client-authentication-method: client_secret_post
+
+          # 네이버 OAuth2 설정
+          naver:
+            client-id: ${NAVER_CLIENT_ID:Q7HdlZZhdCNjazYZtEwp}
+            client-secret: ${NAVER_CLIENT_SECRET:HkHD7Zedss}
+            redirect-uri: ${NAVER_REDIRECT_URI:http://localhost:9090/login/oauth2/code/naver}
+            authorization-grant-type: authorization_code
+            scope: name,email,profile_image
+            client-name: Naver
+
+          # 구글 OAuth2 설정
+          google:
+            client-id: ${GOOGLE_CLIENT_ID:AIzaSyB0FcOqHUlubnQzozH0G4fENpoq1pq3BxQ}
+            client-secret: ${GOOGLE_CLIENT_SECRET:your_google_client_secret_here}
+            redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:9090/login/oauth2/code/google}
+            authorization-grant-type: authorization_code
+            scope: profile,email
+            client-name: Google
+
+        provider:
+          # 카카오 제공업체 설정
+          kakao:
+            authorization-uri: https://kauth.kakao.com/oauth/authorize
+            token-uri: https://kauth.kakao.com/oauth/token
+            user-info-uri: https://kapi.kakao.com/v2/user/me
+            user-name-attribute: id
+
+          # 네이버 제공업체 설정
+          naver:
+            authorization-uri: https://nid.naver.com/oauth2.0/authorize
+            token-uri: https://nid.naver.com/oauth2.0/token
+            user-info-uri: https://openapi.naver.com/v1/nid/me
+            user-name-attribute: response
+
+
 # Mybatis settings
 #mybatis:
 #  type-aliases-package: com.takensoft.**.**.vo, com.takensoft.**.**.dto, com.takensoft.common
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
@@ -214,4 +214,115 @@
         FROM mbr_authrt_info mai
         WHERE mai.mbr_id = #{mbrId}
     </select>
+
+
+    <!-- 이메일로만 사용자 조회 -->
+    <select id="findByEmail" parameterType="String" resultType="MberVO">
+        SELECT
+            m.mbr_id as mbrId,
+            m.lgn_id as lgnId,
+            m.mbr_nm as mbrNm,
+            m.ncnm,
+            m.pswd,
+            m.mbl_telno as mblTelno,
+            m.telno,
+            m.eml,
+            m.zip,
+            m.addr,
+            m.daddr,
+            m.mbr_stts as mbrStts,
+            m.use_yn as useYn,
+            m.cntrl_dt as cntrlDt,
+            m.cntrl_rsn as cntrlRsn,
+            m.sms_rcptn_agre_yn as smsRcptnAgreYn,
+            m.eml_rcptn_agre_yn as emlRcptnAgreYn,
+            m.prvc_rls_yn as prvcRlsYn,
+            m.mbr_type as mbrType,
+            m.pswd_chg_dt as pswdChgDt,
+            m.frst_reg_ip as frstRegIp,
+            m.sys_pvsn_yn as sysPvsnYn,
+            m.rgtr,
+            m.reg_dt as regDt,
+            m.mdfr,
+            m.mdfcn_dt as mdfcnDt
+        FROM mbr_info m
+        WHERE m.eml = #{email}
+        AND m.use_yn = 'Y'
+        AND m.mbr_stts = '1'
+    </select>
+
+    <!-- 이메일과 회원 유형으로 사용자 조회 -->
+    <select id="findByEmailAndProvider" parameterType="map" resultMap="mberMap">
+        <include refid="selectMber" />
+        WHERE mi.eml = #{email}
+        AND mi.mbr_type = #{mbrType}
+        AND mi.use_yn = 'Y'
+        AND mi.mbr_stts = '1'
+    </select>
+
+    <!-- 회원 ID로 권한 목록 조회 -->
+    <select id="findAuthoritiesByMbrId" parameterType="String" resultMap="authMap">
+        SELECT mai.mbr_id
+             , mai.authrt_cd
+             , mai.rgtr
+             , TO_CHAR(mai.reg_dt, 'YYYY-MM-DD HH24:MI') AS reg_dt
+        FROM mbr_authrt_info mai
+        WHERE mai.mbr_id = #{mbrId}
+    </select>
+
+        <!-- OAuth2 사용자 저장 -->
+    <insert id="saveOAuthUser" parameterType="MberVO">
+        INSERT INTO mbr_info (
+            mbr_id,
+            lgn_id,
+            mbr_nm,
+            ncnm,
+            eml,
+            mbr_stts,
+            use_yn,
+            sms_rcptn_agre_yn,
+            eml_rcptn_agre_yn,
+            prvc_rls_yn,
+            mbr_type,
+            sys_pvsn_yn,
+            rgtr,
+            reg_dt
+        ) VALUES (
+            #{mbrId},
+            #{lgnId},
+            #{mbrNm},
+            #{ncnm},
+            #{eml},
+            #{mbrStts},
+            #{useYn},
+            'N',
+            'N',
+            'N',
+            #{mbrType},
+            #{sysPvsnYn},
+            #{rgtr},
+            NOW()
+        )
+    </insert>
+
+        <!-- OAuth2 사용자 정보 업데이트 -->
+        <update id="updateOAuthUser" parameterType="MberVO">
+        UPDATE mbr_info
+        SET
+            mbr_nm = #{mbrNm},
+            ncnm = #{ncnm},
+            mdfr = #{mdfr},
+            mdfcn_dt = NOW()
+        WHERE mbr_id = #{mbrId}
+    </update>
+
+        <!-- 기존 계정에 OAuth2 정보 연동 -->
+        <update id="linkOAuth2Account" parameterType="MberVO">
+        UPDATE mbr_info
+        SET
+            mbr_type = #{mbrType},
+            mdfr = #{mdfr},
+            mdfcn_dt = NOW()
+        WHERE mbr_id = #{mbrId}
+    </update>
 </mapper>
(파일 끝에 줄바꿈 문자 없음)
Add a comment
List