From 4e690d34f09a6e6235054462e281622336780baa Mon Sep 17 00:00:00 2001 From: vkflco08 Date: Wed, 18 Jun 2025 19:37:58 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20Google=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [목적] - 사용자가 Google 계정을 통해 서비스에 로그인할 수 있도록 소셜 로그인 기능 제공 - 소셜 로그인 과정의 안정성 및 오류 처리 개선 [변경 내용] - Spring Security OAuth2 클라이언트 설정 및 연동 활성화 - `CustomOidcUserService`를 통해 Google OAuth2 사용자 정보(이메일, 이름, providerId) 기반 회원가입 또는 로그인 처리 로직 구현 - `OAuth2SuccessHandler` 및 `OAuth2FailureHandler`를 활용한 인증 성공/실패 후 리디렉션 처리 - `CustomAuthorizationRequestRepository`를 커스터마이징하여 OAuth2 인증 요청 세션 관리 로직 강화 - `SecurityConfig`에서 `DelegatingAuthenticationEntryPoint`를 설정하여 불필요한 인증 리다이렉션 방지 및 오류 처리 개선 [결과] - 사용자는 Google 계정으로 로그인하여 JWT 토큰을 발급받고 프론트엔드로 리디렉션될 수 있음 - `authorization_request_not_found` 오류 및 `saveAuthorizationRequest` 다중 호출 문제 해결로 로그인 흐름 안정화 --- .../authority/OAuth2SuccessHandler.java | 43 +-- .../member/config/SecurityConfig.java | 63 ++++- .../member/dto/MemberPrincipal.java | 35 ++- .../member/entity/MemberAuthProvider.java | 39 +++ .../member/entity/MemberEntity.java | 20 +- .../CustomAuthorizationRequestRepository.java | 139 +++++++++ .../MemberAuthProviderRepository.java | 32 +++ .../member/repository/MemberRepository.java | 4 - .../service/CustomOAuth2UserService.java | 84 ------ .../member/service/CustomOidcUserService.java | 266 ++++++++++++++++++ .../member/service/MemberService.java | 9 +- .../src/main/resources/application.yml | 11 +- 12 files changed, 600 insertions(+), 145 deletions(-) create mode 100644 sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java create mode 100644 sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java create mode 100644 sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java delete mode 100644 sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java create mode 100644 sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java index f9ba072..646c9ac 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java @@ -5,33 +5,27 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.web.util.UriComponentsBuilder; -import sequence.sequence_member.global.enums.enums.AuthProvider; +import sequence.sequence_member.member.dto.MemberPrincipal; import sequence.sequence_member.member.entity.MemberEntity; import sequence.sequence_member.member.jwt.JWTUtil; -import sequence.sequence_member.member.repository.MemberRepository; import sequence.sequence_member.member.service.TokenReissueService; import java.io.IOException; -import java.util.Optional; -@Slf4j +@Slf4j // Lombok 어노테이션 @Component public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { - private final MemberRepository memberRepository; private final TokenReissueService tokenReissueService; private final JWTUtil jwtUtil; - private final long ACCESS_TOKEN_EXPIRED_TIME = 600000L*60*1; // 1시간 - private final long REFRESH_TOKEN_EXPIRED_TIME = 600000L*60*24*7; // 7일 + private final long ACCESS_TOKEN_EXPIRED_TIME = 600000L * 60 * 1; // 1시간 + private final long REFRESH_TOKEN_EXPIRED_TIME = 600000L * 60 * 24 * 7; // 7일 public OAuth2SuccessHandler( - MemberRepository memberRepository, TokenReissueService tokenReissueService, JWTUtil jwtUtil) { - this.memberRepository = memberRepository; this.tokenReissueService = tokenReissueService; this.jwtUtil = jwtUtil; } @@ -41,27 +35,20 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - DefaultOidcUser oidcUser = (DefaultOidcUser) authentication.getPrincipal(); + MemberPrincipal memberPrincipal = (MemberPrincipal) authentication.getPrincipal(); - log.info("OAuth2 oidcUser={}", oidcUser); + log.info("OAuth2 인증 성공, Principal: {}", memberPrincipal); - String email = oidcUser.getEmail(); - String name = oidcUser.getFullName(); - String providerId = oidcUser.getName(); // 고유 사용자 ID + MemberEntity member = memberPrincipal.getMemberEntity(); - Optional optionalUser = memberRepository.findByEmail(email); + String email = member.getEmail(); + if (email == null || email.isEmpty()) { + log.error("인증된 Principal에서 이메일(email)이 null 또는 비어있습니다. 로그인 처리 실패."); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Email not found in OAuth2 principal."); + return; + } - MemberEntity member = optionalUser.orElseGet(() -> { - MemberEntity newUser = MemberEntity.createSocialMember( - email, - name, - AuthProvider.GOOGLE, - providerId - ); - return memberRepository.save(newUser); - }); - - log.info("OAuth2 로그인 성공: email={}, provider={}", member.getEmail(), member.getProvider()); + log.info("OAuth2 로그인 성공: email={}", email); String username = member.getUsername(); @@ -76,7 +63,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, // 쿠키에 refreshToken 설정 Cookie refreshTokenCookie = new Cookie("refresh", refresh); refreshTokenCookie.setHttpOnly(true); - refreshTokenCookie.setSecure(false); // 배포 시 true (https) + refreshTokenCookie.setSecure(false); // 로컬 HTTP 환경에서는 false, 배포 HTTPS 시 true로 변경해야 합니다. refreshTokenCookie.setPath("/"); refreshTokenCookie.setMaxAge((int) (REFRESH_TOKEN_EXPIRED_TIME / 1000)); response.addCookie(refreshTokenCookie); diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java b/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java index eafc8f8..5f43c5a 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java @@ -11,9 +11,16 @@ 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.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import sequence.sequence_member.member.authority.OAuth2FailureHandler; @@ -22,13 +29,16 @@ import sequence.sequence_member.member.jwt.JWTFilter; import sequence.sequence_member.member.jwt.JWTUtil; import sequence.sequence_member.member.jwt.LoginFilter; +import sequence.sequence_member.member.repository.CustomAuthorizationRequestRepository; import sequence.sequence_member.member.repository.MemberRepository; import sequence.sequence_member.member.repository.RefreshRepository; -import sequence.sequence_member.member.service.CustomOAuth2UserService; +import sequence.sequence_member.member.service.CustomOidcUserService; import sequence.sequence_member.member.service.TokenReissueService; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; @Configuration @EnableWebSecurity @@ -43,7 +53,7 @@ public class SecurityConfig { private final MemberRepository memberRepository; private final OAuth2FailureHandler oAuth2FailureHandler; private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOidcUserService customOidcUserService; @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { @@ -55,6 +65,11 @@ public BCryptPasswordEncoder bCryptPasswordEncoder(){ return new BCryptPasswordEncoder(); } + @Bean + public AuthorizationRequestRepository authorizationRequestRepository() { + return new CustomAuthorizationRequestRepository(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ // Custom LoginFilter 등록 @@ -100,24 +115,54 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { //경로별 인가 작업 http .authorizeHttpRequests((auth)->auth - .requestMatchers("/api/login", "/api/users/join", "/api/token", "/api/users/check_username", "/api/users/check_email", "/api/users/check_nickname", "/api/skills/**", "/api/users/test", "/api/auth/**").permitAll() + // 인증이 필요 없는 경로들을 먼저 정의합니다. + .requestMatchers( + "/api/login", + "/api/users/join", + "/api/token", + "/api/users/check_username", + "/api/users/check_email", + "/api/users/check_nickname", + "/api/skills/**", + "/api/users/test", + "/api/auth/**", + "/error", + "/actuator/**", + "/oauth2/**", + "/login/oauth2/code/**", + "/favicon.ico", + "/.well-known/**", + "/" + ).permitAll() .requestMatchers(HttpMethod.GET,"/api/projects/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/archive/projects/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/archive/{archiveId}").permitAll() - .requestMatchers("/api/archive/**").authenticated() - .requestMatchers("/error").permitAll() - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/oauth2/**").permitAll() - .anyRequest().authenticated()); + .requestMatchers("/api/archive/**").authenticated() // 인증 필요 + .anyRequest().authenticated()); // 나머지 모든 요청은 인증 필요 http .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(authEndpoint -> authEndpoint + .authorizationRequestRepository(authorizationRequestRepository()) + ) .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService) + .oidcUserService(customOidcUserService) ) .successHandler(oAuth2SuccessHandler) .failureHandler(oAuth2FailureHandler) ); + LinkedHashMap entryPoints = new LinkedHashMap<>(); + entryPoints.put(new AntPathRequestMatcher("/oauth2/authorization/**"), new Http403ForbiddenEntryPoint()); + entryPoints.put(new AntPathRequestMatcher("/favicon.ico"), new Http403ForbiddenEntryPoint()); + entryPoints.put(new AntPathRequestMatcher("/.well-known/**"), new Http403ForbiddenEntryPoint()); + + http + .exceptionHandling(exceptionHandling -> { + DelegatingAuthenticationEntryPoint delegatingEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints); + delegatingEntryPoint.setDefaultEntryPoint(new Http403ForbiddenEntryPoint()); // 기본 엔트리포인트 명시적으로 설정 + exceptionHandling.authenticationEntryPoint(delegatingEntryPoint); + }); + http .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java b/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java index 1b3ce39..98aac0b 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/dto/MemberPrincipal.java @@ -3,21 +3,27 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import sequence.sequence_member.member.entity.MemberEntity; import java.util.Collection; import java.util.Collections; import java.util.Map; -public class MemberPrincipal implements OAuth2User, UserDetails { +public class MemberPrincipal implements OidcUser, UserDetails { private final MemberEntity member; private final Map attributes; + private final OidcIdToken idToken; + private final OidcUserInfo userInfo; - public MemberPrincipal(MemberEntity member, Map attributes) { + public MemberPrincipal(MemberEntity member, Map attributes, OidcIdToken idToken, OidcUserInfo userInfo) { this.member = member; this.attributes = attributes; + this.idToken = idToken; + this.userInfo = userInfo; } public MemberEntity getMember() { @@ -69,7 +75,26 @@ public String getName() { return member.getName(); } - public static MemberPrincipal create(MemberEntity member, Map attributes) { - return new MemberPrincipal(member, attributes); + public static MemberPrincipal create(MemberEntity member, Map attributes, OidcIdToken idToken, OidcUserInfo userInfo) { + return new MemberPrincipal(member, attributes, idToken, userInfo); + } + + @Override + public Map getClaims() { + return Map.of(); + } + + @Override + public OidcUserInfo getUserInfo() { + return null; + } + + @Override + public OidcIdToken getIdToken() { + return null; + } + + public MemberEntity getMemberEntity() { + return this.member; } } \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java new file mode 100644 index 0000000..cc46a1e --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberAuthProvider.java @@ -0,0 +1,39 @@ +package sequence.sequence_member.member.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import sequence.sequence_member.global.enums.enums.AuthProvider; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member_auth_provider") +public class MemberAuthProvider { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private MemberEntity member; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 50) + private AuthProvider provider; + + @Column(name = "provider_id", nullable = true, unique = true, length = 255) + private String providerId; + + public MemberAuthProvider(MemberEntity member, AuthProvider provider, String providerId) { + this.member = member; + this.provider = provider; + this.providerId = providerId; + } + + protected void setMember(MemberEntity member) { + this.member = member; + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java index 71eb921..48d5fb8 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/entity/MemberEntity.java @@ -3,7 +3,6 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.Setter; -import sequence.sequence_member.global.enums.enums.AuthProvider; import sequence.sequence_member.global.utils.BaseTimeEntity; import sequence.sequence_member.member.dto.MemberDTO; @@ -99,18 +98,19 @@ public static MemberEntity toMemberEntity(MemberDTO memberDTO){ return memberEntity; } - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private AuthProvider provider = AuthProvider.LOCAL; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private List authProviders = new ArrayList<>(); - @Column(length = 100) - private String providerId; + public void addAuthProviderIfNotExists(MemberAuthProvider authProvider) { + if (!this.authProviders.contains(authProvider)) { + this.authProviders.add(authProvider); + authProvider.setMember(this); + } + } public static MemberEntity createSocialMember( String email, - String name, - AuthProvider provider, - String providerId + String name ) { MemberEntity member = new MemberEntity(); member.setUsername(email); @@ -124,8 +124,6 @@ public static MemberEntity createSocialMember( member.setNickname("user_" + UUID.randomUUID().toString().substring(0, 8)); member.setSchoolName("소셜 로그인 사용자"); member.setIntroduction("소셜 로그인 사용자입니다."); - member.setProvider(provider); - member.setProviderId(providerId); return member; } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java new file mode 100644 index 0000000..7b6c5f9 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java @@ -0,0 +1,139 @@ +package sequence.sequence_member.member.repository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +public class CustomAuthorizationRequestRepository implements AuthorizationRequestRepository { + + private static final Logger logger = LoggerFactory.getLogger(CustomAuthorizationRequestRepository.class); + + public static final String SESSION_ATTR_NAME = "SPRING_SECURITY_OAUTH2_AUTHORIZATION_REQUEST"; + public static final String SPRING_SECURITY_OAUTH2_BINDING_DATA = "SPRING_SECURITY_OAUTH2_BINDING_DATA"; + + private final HttpSessionOAuth2AuthorizationRequestRepository delegate = new HttpSessionOAuth2AuthorizationRequestRepository(); + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + HttpSession session = request.getSession(false); + logger.debug("DEBUG: loadAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); + if (session != null) { + StringBuilder attributes = new StringBuilder("["); + Enumeration attributeNames = session.getAttributeNames(); + while (attributeNames.hasMoreElements()) { + attributes.append(attributeNames.nextElement()); + if (attributeNames.hasMoreElements()) { + attributes.append(", "); + } + } + attributes.append("]"); + logger.debug("DEBUG: loadAuthorizationRequest - Session attributes: {}", attributes.toString()); + } + return delegate.loadAuthorizationRequest(request); + } + + @Override + public void saveAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ) { + logger.info("🔐 saveAuthorizationRequest 호출됨 - URI: {} (Method: {})", request.getRequestURI(), request.getMethod()); // ⭐ 요청 URI 및 메서드 로그 추가 + + HttpSession session = request.getSession(); + logger.debug("Session ID (save): {}", session.getId()); + logger.debug("Session attributes BEFORE delegate save: {}", getSessionAttributesString(session)); + + if (authorizationRequest == null) { + delegate.saveAuthorizationRequest(null, request, response); + logger.debug("Session attributes after null request cleanup: {}", getSessionAttributesString(session)); + return; + } + + // Spring Security가 생성한 기본 state 값 (CSRF 방어용) + String originalSpringSecurityState = authorizationRequest.getState(); + // 클라이언트(프런트엔드)로부터 전달된 커스텀 파라미터 (계정 연동 요청 시 전달됨) + String bindingUserIdStr = request.getParameter("binding_user_id"); + String bindingStateToken = request.getParameter("binding_state_token"); + + logger.debug("DEBUG: Original SS State = {}", originalSpringSecurityState); + logger.debug("DEBUG: binding_user_id Parameter = {}", bindingUserIdStr); + logger.debug("DEBUG: binding_state_token Parameter = {}", bindingStateToken); + + // 1. Spring Security의 OAuth2AuthorizationRequest 객체는 delegate를 통해 세션에 저장 + delegate.saveAuthorizationRequest(authorizationRequest, request, response); + logger.debug("DEBUG: After setting {}: {}", SESSION_ATTR_NAME, getSessionAttributesString(session)); + + // 2. 만약 커스텀 연동 파라미터(userId와 bindingStateToken)가 존재하고 유효하면, + // 이 정보를 Map 형태로 구성하여 세션에 별도로 저장 + if (bindingUserIdStr != null && bindingStateToken != null && bindingStateToken.startsWith("bind:")) { + // Map의 키를 originalSpringSecurityState와 조합하여 세션에 저장 + String bindingDataKey = SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + originalSpringSecurityState; + + Long userId = null; + try { + userId = Long.parseLong(bindingUserIdStr); + } catch (NumberFormatException e) { + logger.warn("WARN: 'binding_user_id' 파라미터({})를 Long으로 파싱할 수 없습니다.", bindingUserIdStr); + } + + Map bindingMap = new HashMap<>(); + bindingMap.put("bindState", bindingStateToken); + if (userId != null) { + bindingMap.put("userId", userId); + } else { + logger.warn("WARN: bindingMap에 userId가 null로 저장됩니다. 파라미터 값: {}", bindingUserIdStr); + } + + session.setAttribute(bindingDataKey, bindingMap); + logger.info("📦 세션에 연동 데이터 저장 완료 (키: {}, 값: {})", bindingDataKey, bindingMap); + logger.debug("Session attributes after save (detailed): {}", getSessionAttributesString(session)); + } else { + logger.debug("📦 커스텀 연동 파라미터가 없거나 유효하지 않습니다. (bindingUserIdStr: {}, bindingStateToken: {})", bindingUserIdStr, bindingStateToken); + logger.debug("Session attributes after save (no binding data stored): {}", getSessionAttributesString(session)); + } + + logger.info("🚀 IdP로 리다이렉트될 때 사용될 state는 '{}'입니다. (이 값이 Google로 보내집니다)", originalSpringSecurityState); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest( + HttpServletRequest request, + HttpServletResponse response + ) { + HttpSession session = request.getSession(false); + logger.debug("DEBUG: removeAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); + logger.debug("DEBUG: removeAuthorizationRequest - Session attributes before removal: {}", getSessionAttributesString(session)); + + OAuth2AuthorizationRequest authorizationRequest = delegate.removeAuthorizationRequest(request, response); + + logger.debug("DEBUG: removeAuthorizationRequest - Session attributes after removal: {}", getSessionAttributesString(session)); + + return authorizationRequest; + } + + private String getSessionAttributesString(HttpSession session) { + if (session == null) { + return "[]"; + } + StringBuilder attributes = new StringBuilder("["); + Enumeration attributeNames = session.getAttributeNames(); + while (attributeNames.hasMoreElements()) { + attributes.append(attributeNames.nextElement()); + if (attributeNames.hasMoreElements()) { + attributes.append(", "); + } + } + attributes.append("]"); + return attributes.toString(); + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java new file mode 100644 index 0000000..7437dc3 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberAuthProviderRepository.java @@ -0,0 +1,32 @@ +package sequence.sequence_member.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import sequence.sequence_member.global.enums.enums.AuthProvider; +import sequence.sequence_member.member.entity.MemberAuthProvider; + +import java.util.Optional; + +@Repository +public interface MemberAuthProviderRepository extends JpaRepository { + + /** + * 특정 회원 ID와 제공자(Provider)로 MemberAuthProvider를 조회합니다. + * 계정 연동 시 해당 회원이 이미 해당 제공자로 연동되어 있는지 확인할 때 사용합니다. + * + * @param memberId 조회할 회원 ID + * @param provider 소셜 로그인 제공자 타입 (예: AuthProvider.GOOGLE) + * @return MemberAuthProvider 객체 (존재하지 않으면 Optional.empty()) + */ + Optional findByMemberIdAndProvider(Long memberId, AuthProvider provider); + + /** + * 특정 제공자(Provider)와 해당 제공자의 사용자 고유 ID(providerId)로 MemberAuthProvider를 조회합니다. + * 소셜 로그인 시 기존 가입자를 찾거나, 새로운 연동을 시도할 때 해당 소셜 계정이 이미 다른 회원에게 연동되어 있는지 확인할 때 사용합니다. + * + * @param provider 소셜 로그인 제공자 타입 (예: AuthProvider.GOOGLE) + * @param providerId 소셜 로그인 제공자의 사용자 고유 ID + * @return MemberAuthProvider 객체 (존재하지 않으면 Optional.empty()) + */ + Optional findByProviderAndProviderId(AuthProvider provider, String providerId); +} \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java index 1b2d746..7e5ca9f 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/MemberRepository.java @@ -11,8 +11,6 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; -import sequence.sequence_member.global.enums.enums.AuthProvider; -import sequence.sequence_member.global.enums.enums.Status; import sequence.sequence_member.member.entity.MemberEntity; import java.util.Optional; @@ -59,8 +57,6 @@ WHEN m.nickname LIKE CONCAT(:nickname, '%') THEN 1 """) List searchMemberNicknames(@Param("nickname") String nickname, Pageable pageable); - Optional findByEmailAndProvider(String email, AuthProvider provider); - // 아이디 찾기 @Query(""" SELECT m FROM MemberEntity m diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java deleted file mode 100644 index 5e790f7..0000000 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOAuth2UserService.java +++ /dev/null @@ -1,84 +0,0 @@ -package sequence.sequence_member.member.service; - -import jakarta.transaction.Transactional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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; -import sequence.sequence_member.global.enums.enums.AuthProvider; -import sequence.sequence_member.member.dto.MemberPrincipal; -import sequence.sequence_member.member.entity.MemberEntity; -import sequence.sequence_member.member.oauth.OAuth2UserInfo; -import sequence.sequence_member.member.oauth.OAuth2UserInfoFactory; -import sequence.sequence_member.member.repository.MemberRepository; - -@Service -@Transactional -public class CustomOAuth2UserService implements OAuth2UserService { - - private static final Logger logger = LoggerFactory.getLogger(CustomOAuth2UserService.class); - - private final MemberRepository memberRepository; - - public CustomOAuth2UserService(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oauth2User = delegate.loadUser(userRequest); - - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - - // OAuth2 사용자 정보 파싱 - OAuth2UserInfo oauth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo( - registrationId, oauth2User.getAttributes()); - - if (oauth2UserInfo.getEmail() == null || oauth2UserInfo.getEmail().isEmpty()) { - throw new OAuth2AuthenticationException("OAuth2 provider에서 이메일을 찾을 수 없습니다."); - } - - AuthProvider provider = getAuthProvider(registrationId); - MemberEntity member = memberRepository.findByEmailAndProvider(oauth2UserInfo.getEmail(), provider) - .map(existing -> updateExistingMember(existing, oauth2UserInfo)) - .orElseGet(() -> registerNewMember(userRequest, oauth2UserInfo)); - - return MemberPrincipal.create(member, oauth2User.getAttributes()); - } - - private MemberEntity registerNewMember(OAuth2UserRequest userRequest, OAuth2UserInfo userInfo) { - AuthProvider provider = getAuthProvider(userRequest.getClientRegistration().getRegistrationId()); - - MemberEntity member = MemberEntity.createSocialMember( - userInfo.getEmail(), - userInfo.getName() != null ? userInfo.getName() : "사용자", - provider, - userInfo.getId() - ); - - return memberRepository.save(member); - } - - private MemberEntity updateExistingMember(MemberEntity existing, OAuth2UserInfo userInfo) { - if (userInfo.getName() != null) { - existing.setName(userInfo.getName()); - } - return memberRepository.save(existing); - } - - private AuthProvider getAuthProvider(String registrationId) { - switch (registrationId.toUpperCase()) { - case "GOOGLE": - return AuthProvider.GOOGLE; -// case "KAKAO": -// return AuthProvider.KAKAO; - default: - throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인: " + registrationId); - } - } -} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java new file mode 100644 index 0000000..44da53e --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java @@ -0,0 +1,266 @@ +package sequence.sequence_member.member.service; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import sequence.sequence_member.global.enums.enums.AuthProvider; +import sequence.sequence_member.member.dto.MemberPrincipal; +import sequence.sequence_member.member.entity.MemberAuthProvider; +import sequence.sequence_member.member.entity.MemberEntity; +import sequence.sequence_member.member.oauth.OAuth2UserInfo; +import sequence.sequence_member.member.oauth.OAuth2UserInfoFactory; +import sequence.sequence_member.member.repository.MemberAuthProviderRepository; +import sequence.sequence_member.member.repository.MemberRepository; + +import java.util.Enumeration; +import java.util.Map; +import java.util.Optional; + +import static sequence.sequence_member.member.repository.CustomAuthorizationRequestRepository.SPRING_SECURITY_OAUTH2_BINDING_DATA; + +@Service +@Transactional +public class CustomOidcUserService implements OAuth2UserService { + + private static final Logger logger = LoggerFactory.getLogger(CustomOidcUserService.class); + + private final MemberRepository memberRepository; + private final MemberAuthProviderRepository memberAuthProviderRepository; // 의존성 주입 + + public CustomOidcUserService(MemberRepository memberRepository, MemberAuthProviderRepository memberAuthProviderRepository) { + this.memberRepository = memberRepository; + this.memberAuthProviderRepository = memberAuthProviderRepository; + } + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + logger.info("✅ CustomOidcUserService loadUser 호출됨"); + OidcUserService delegate = new OidcUserService(); + OidcUser oidcUser = delegate.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + logger.debug("DEBUG: OidcUser Attributes: {}", oidcUser.getAttributes()); + + OAuth2UserInfo oauth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oidcUser.getAttributes()); + + logger.debug("DEBUG: OAuth2UserInfo Extracted Email: {}", oauth2UserInfo.getEmail()); + + if (oauth2UserInfo.getEmail() == null || oauth2UserInfo.getEmail().isEmpty()) { + logger.error("ERROR: OAuth2UserInfo에서 이메일이 null이거나 비어있습니다. 예외 발생."); + throw new OAuth2AuthenticationException("OAuth2 provider에서 이메일을 찾을 수 없습니다."); + } + + AuthProvider provider = getAuthProvider(registrationId); // registrationId로 AuthProvider 가져옴 + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + HttpServletRequest request = null; + if (attributes != null) { + request = attributes.getRequest(); + } + if (request == null) { + throw new IllegalStateException("HttpServletRequest not available in RequestContextHolder."); + } + + logger.debug("Session ID (load): {}", request.getSession().getId()); + StringBuilder sessionAttributes = new StringBuilder("["); + Enumeration attributeNames = request.getSession().getAttributeNames(); + while (attributeNames.hasMoreElements()) { + sessionAttributes.append(attributeNames.nextElement()); + if (attributeNames.hasMoreElements()) { + sessionAttributes.append(", "); + } + } + sessionAttributes.append("]"); + logger.debug("Session attributes before extract: {}", sessionAttributes.toString()); + + String returnedSpringSecurityState = request.getParameter("state"); + logger.debug("반환된 state (IdP로부터): {}", returnedSpringSecurityState); + + String BIND_STATE_PREFIX = "bind:"; + + boolean isBindingRequest = false; + Map bindingData = null; + + if (returnedSpringSecurityState != null) { + // CustomAuthorizationRequestRepository에서 저장한 바인딩 데이터를 세션에서 조회 + bindingData = (Map) request.getSession().getAttribute( + SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + returnedSpringSecurityState + ); + + if (bindingData != null) { + String bindStateFromMap = (String) bindingData.get("bindState"); + Long userIdFromMap = (Long) bindingData.get("userId"); + + if (bindStateFromMap != null && bindStateFromMap.startsWith(BIND_STATE_PREFIX) && userIdFromMap != null) { + isBindingRequest = true; + } + // 세션에서 사용한 바인딩 데이터 제거 (한번 사용하면 제거) + request.getSession().removeAttribute( + SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + returnedSpringSecurityState + ); + logger.debug("DEBUG: Removed binding data map from session in CustomOidcUserService."); + } + } + + logger.debug("추출된 연동용 데이터: {}", bindingData); + logger.debug("연동 요청 여부: {}", isBindingRequest); + + MemberEntity member; + if (isBindingRequest && bindingData != null) { + member = bindSocialAccount(bindingData, oauth2UserInfo, provider); + } else { + member = loginOrRegister(oauth2UserInfo, provider, userRequest); + } + + logger.debug("DEBUG: Member created/retrieved Email (before MemberPrincipal.create): {}", member.getEmail()); + + return MemberPrincipal.create(member, oidcUser.getAttributes(), oidcUser.getIdToken(), oidcUser.getUserInfo()); + } + + /** + * 기존 회원에게 소셜 계정을 연동하는 로직 + * @param bindingData 세션에서 추출된 연동 관련 데이터 (userId, bindState) + * @param userInfo 소셜 제공자로부터 받은 사용자 정보 + * @param provider 소셜 제공자 타입 + * @return 연동 완료된 MemberEntity + * @throws OAuth2AuthenticationException 계정 연동 실패 시 발생 + */ + private MemberEntity bindSocialAccount( + Map bindingData, + OAuth2UserInfo userInfo, + AuthProvider provider + ) throws OAuth2AuthenticationException { + String bindState = (String) bindingData.get("bindState"); + Long userId = (Long) bindingData.get("userId"); + + if (userId == null || bindState == null) { + logger.warn("WARN: 계정 연동을 위한 데이터가 불완전합니다. (userId: {}, bindState: {})", userId, bindState); + throw new OAuth2AuthenticationException( + new OAuth2Error("INVALID_BINDING_REQUEST"), + "계정 연동을 위한 데이터가 불완전합니다. 다시 시도해주세요." + ); + } + logger.debug("DEBUG: bindSocialAccount에서 가져온 userId: {} (bindState: {})", userId, bindState); + + Optional memberOptional = memberRepository.findById(userId); + MemberEntity member = memberOptional.orElse(null); + + if (member == null) { + logger.warn("WARN: 연동을 시도한 사용자를 찾을 수 없습니다. userId: {}", userId); + throw new OAuth2AuthenticationException( + new OAuth2Error("USER_NOT_FOUND"), + "연동하려는 계정을 찾을 수 없습니다." + ); + } + + // 이미 연동된 구글 계정 유무 확인 + Optional existingSocialLinkToAnyMember = memberAuthProviderRepository.findByProviderAndProviderId(provider, userInfo.getId()); + if (existingSocialLinkToAnyMember.isPresent()) { + if (!existingSocialLinkToAnyMember.get().getMember().getId().equals(member.getId())) { + logger.warn("WARN: 소셜 계정({}, {})이 이미 다른 회원({})에게 연동되어 있습니다.", provider, userInfo.getId(), existingSocialLinkToAnyMember.get().getMember().getEmail()); + throw new OAuth2AuthenticationException( + new OAuth2Error("SOCIAL_ACCOUNT_ALREADY_LINKED"), + "이미 다른 계정에 연동된 소셜 계정입니다. 해당 계정으로 로그인해주세요." + ); + } else { + logger.info("INFO: 소셜 계정({}, {})이 이미 현재 로그인된 회원({})에게 연동되어 있습니다. 재로그인 처리.", provider, userInfo.getId(), member.getEmail()); + return member; + } + } + + // 새로운 MemberAuthProvider 생성 및 Member에 추가 + MemberAuthProvider authProvider = new MemberAuthProvider( + member, + provider, + userInfo.getId() + ); + member.addAuthProviderIfNotExists(authProvider); + memberRepository.save(member); + + logger.info("🔗 계정 연동 완료: userId = {}, Provider: {}", userId, provider); + return member; + } + + /** + * 소셜 로그인 또는 신규 회원 등록 로직 + * @param userInfo 소셜 제공자로부터 받은 사용자 정보 + * @param provider 소셜 제공자 타입 + * @param userRequest OIDC 사용자 요청 객체 + * @return 로그인 또는 등록된 MemberEntity + */ + private MemberEntity loginOrRegister( + OAuth2UserInfo userInfo, + AuthProvider provider, + OidcUserRequest userRequest + ) { + Optional existingAuthProviderOptional = memberAuthProviderRepository.findByProviderAndProviderId(provider, userInfo.getId()); + + MemberEntity member; + if (existingAuthProviderOptional.isPresent()) { + member = existingAuthProviderOptional.get().getMember(); + logger.info("[소셜 계정 로그인] 기존 사용자 로그인: email={}, Provider: {}", member.getEmail(), provider); + } else { + member = registerNewMember(userRequest, userInfo); + logger.info("[소셜 계정 로그인] 새로운 사용자 등록: email={}, Provider: {}", member.getEmail(), provider); + } + + return member; + } + + /** + * 새로운 소셜 로그인 회원을 등록하는 로직 + * @param userRequest OIDC 사용자 요청 객체 + * @param oAuth2UserInfo OAuth2UserInfo (표준화된 사용자 정보) + * @return 새로 등록된 MemberEntity + */ + private MemberEntity registerNewMember(OidcUserRequest userRequest, OAuth2UserInfo oAuth2UserInfo) { + AuthProvider provider = getAuthProvider(userRequest.getClientRegistration().getRegistrationId()); + + MemberEntity member = MemberEntity.createSocialMember( + oAuth2UserInfo.getEmail(), // 이메일은 OAuth2UserInfoFactory에서 검증됨 + oAuth2UserInfo.getName() != null ? oAuth2UserInfo.getName() : "사용자" + ); + + MemberAuthProvider memberAuthProvider = new MemberAuthProvider( + member, + provider, + oAuth2UserInfo.getId() + ); + member.addAuthProviderIfNotExists(memberAuthProvider); + + logger.info("새로운 소셜 로그인 사용자 등록 완료: email={}, Provider: {}", member.getEmail(), provider); + return memberRepository.save(member); + } + + /** + * registrationId (clientRegistration.registrationId)로부터 AuthProvider Enum 값을 가져옵니다. + * @param registrationId 클라이언트 등록 ID (예: "google", "kakao") + * @return 해당 AuthProvider Enum 값 + * @throws OAuth2AuthenticationException 지원하지 않는 제공자일 경우 발생 + */ + private AuthProvider getAuthProvider(String registrationId) throws OAuth2AuthenticationException { + switch (registrationId.toUpperCase()) { + case "GOOGLE": + return AuthProvider.GOOGLE; + // case "KAKAO": + // return AuthProvider.KAKAO; + default: + logger.warn("WARN: 지원하지 않는 소셜 로그인 서비스입니다: {}", registrationId); + throw new OAuth2AuthenticationException( + new OAuth2Error("UNSUPPORTED_SOCIAL_PROVIDER"), + "지원하지 않는 소셜 로그인 서비스입니다: " + registrationId + ); + } + } +} \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java index 78d2b0b..4f106d9 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/MemberService.java @@ -16,7 +16,6 @@ import sequence.sequence_member.member.entity.*; import sequence.sequence_member.member.repository.*; - import java.util.*; @Service @@ -65,7 +64,13 @@ public void save(MemberDTO memberDTO, MultipartFile authImgFile, List Date: Sat, 21 Jun 2025 01:48:22 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat=20:=20BaseException=EC=97=90=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseException에 에러 로깅 추가 --- .../global/exception/BaseException.java | 10 ++++++++++ sequence_member/src/main/resources/application.yml | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java index f23090e..46b9692 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BaseException.java @@ -1,10 +1,12 @@ package sequence.sequence_member.global.exception; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import sequence.sequence_member.global.response.Code; import static sequence.sequence_member.global.response.Code.INTERNAL_SERVER_ERROR; +@Slf4j @Getter public class BaseException extends RuntimeException { @@ -14,48 +16,56 @@ public class BaseException extends RuntimeException { public BaseException() { super(INTERNAL_SERVER_ERROR.getMessage()); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 에러 메시지를 받는 생성자 public BaseException(String message) { super(message); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 에러 메시지와 원인을 받는 생성자 public BaseException(String message, Throwable cause) { super(message, cause); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 원인만을 받는 생성자 public BaseException(Throwable cause) { super(cause); this.errorCode = INTERNAL_SERVER_ERROR; + log.error("BaseException 발생"); } // 에러 코드를 지정하는 생성자 public BaseException(Code errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; + log.error("BaseException 발생"); } // 에러 코드와 메시지를 받는 생성자 public BaseException(Code errorCode, String message) { super(message); this.errorCode = errorCode; + log.error("BaseException 발생"); } // 에러 코드, 메시지, 원인을 받는 생성자 public BaseException(Code errorCode, String message, Throwable cause) { super(message, cause); this.errorCode = errorCode; + log.error("BaseException 발생"); } // 에러 코드와 원인을 받는 생성자 public BaseException(Code errorCode, Throwable cause) { super(cause); this.errorCode = errorCode; + log.error("BaseException 발생"); } } diff --git a/sequence_member/src/main/resources/application.yml b/sequence_member/src/main/resources/application.yml index 8c46be7..ffe9ed9 100644 --- a/sequence_member/src/main/resources/application.yml +++ b/sequence_member/src/main/resources/application.yml @@ -84,6 +84,10 @@ management: endpoint: prometheus: enabled: true + prometheus: + metrics: + export: + enabled: true server: tomcat: mbeanregistry: From 2915bd71d2b1836f25976c1c8c4854f808806b60 Mon Sep 17 00:00:00 2001 From: High-Quality-Coffee <125748258+High-Quality-Coffee@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:36:37 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat=20:=20project=20service=20=20?= =?UTF-8?q?=EC=97=90=20=EC=97=90=EB=9F=AC=20=EB=A1=9C=EA=B9=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project service 에러 로깅 추가 --- .../global/exception/AuthException.java | 3 +++ .../global/exception/BAD_REQUEST_EXCEPTION.java | 4 ++++ .../exception/CanNotFindResourceException.java | 3 +++ .../global/exception/UserNotFindException.java | 4 ++++ .../project/service/ProjectService.java | 16 +++++++++++++--- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java index 6b8f698..ec41f90 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/AuthException.java @@ -1,9 +1,12 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; import sequence.sequence_member.global.response.Code; +@Slf4j public class AuthException extends BaseException { public AuthException(String message) { super(Code.ACCESS_DENIED,message); + log.error("접근 불가"); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java index a48c8eb..aee2f13 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/BAD_REQUEST_EXCEPTION.java @@ -1,7 +1,11 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class BAD_REQUEST_EXCEPTION extends RuntimeException { public BAD_REQUEST_EXCEPTION(String message) { super(message); + log.error("Bad Request error"); } } \ No newline at end of file diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java index ebfcac3..ebab0d5 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/CanNotFindResourceException.java @@ -1,9 +1,12 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; import sequence.sequence_member.global.response.Code; +@Slf4j public class CanNotFindResourceException extends BaseException { public CanNotFindResourceException(String message) { super(Code.CAN_NOT_FIND_RESOURCE,message); + log.error("해당 리소스 발견 불가"); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java b/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java index 4b7a716..56d52e5 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java +++ b/sequence_member/src/main/java/sequence/sequence_member/global/exception/UserNotFindException.java @@ -1,7 +1,11 @@ package sequence.sequence_member.global.exception; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class UserNotFindException extends RuntimeException { public UserNotFindException(String message) { super(message); + log.error("해당 유저를 찾을 수 없습니다."); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java index 2914376..64daf08 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectService.java @@ -56,27 +56,35 @@ public class ProjectService { public ProjectOutputDTO updateProject(Long projectId, CustomUserDetails customUserDetails, ProjectUpdateDTO projectUpdateDTO,HttpServletRequest request) { Project project = projectRepository.findById(projectId) .orElseThrow(() -> new CanNotFindResourceException("해당 프로젝트가 존재하지 않습니다.")); + MemberEntity writer = memberRepository.findByUsernameAndIsDeletedFalse(customUserDetails.getUsername()) .orElseThrow(() -> new UserNotFindException("요청하는 유저가 존재하지 않습니다.")); + if (!project.getWriter().equals(writer)) { throw new AuthException("작성자만 수정할 수 있습니다."); } + // Project Entity에 ProjectInputDTO의 정보를 업데이트 project.updateProject(projectUpdateDTO); // 삭제된 멤버들은 ProjectMember에서 삭제 List deletedMembers = memberRepository.findByNicknameIn(projectUpdateDTO.getDeletedMembersNicknames()); + for(MemberEntity deletedMember : deletedMembers){ ProjectMember projectMember = projectMemberRepository.findByMemberIdAndProjectId( deletedMember.getId(), projectId); + if(projectMember==null){ log.error("삭제된 멤버가 프로젝트에 존재하지 않습니다."); continue; } + if(deletedMembers.contains(writer)){ throw new BAD_REQUEST_EXCEPTION("작성자는 멤버에서 삭제할 수 없습니다."); } + projectMember.softDelete(customUserDetails.getUsername()); + projectMemberRepository.save(projectMember); } @@ -105,11 +113,14 @@ public ProjectOutputDTO updateProject(Long projectId, CustomUserDetails customUs public void deleteProject(Long projectId, CustomUserDetails customUserDetails){ Project project = projectRepository.findById(projectId) .orElseThrow(() -> new CanNotFindResourceException("해당 프로젝트가 존재하지 않습니다.")); + MemberEntity writer = memberRepository.findByUsernameAndIsDeletedFalse(customUserDetails.getUsername()) .orElseThrow(() -> new UserNotFindException("해당 유저가 존재하지 않습니다.")); + if (!project.getWriter().equals(writer)) { throw new AuthException("작성자만 삭제할 수 있습니다."); } + project.softDelete(customUserDetails.getUsername()); //북마크 삭제 @@ -129,6 +140,7 @@ private void deleteProjectInvitedMember(Project project, List projectMe for(ProjectMember member : projectMembers){ member.softDelete(username); } + projectMemberRepository.saveAll(projectMembers); } @@ -214,7 +227,4 @@ public List getAllProjects(){ } - - - } From df47cbd8d6525bfd88cb291a6826a684bbe46eeb Mon Sep 17 00:00:00 2001 From: High-Quality-Coffee <125748258+High-Quality-Coffee@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:43:30 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat=20:=20project=20controller=20api=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projectController에서 api 요청 로깅 --- .../project/controller/ProjectController.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java b/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java index 80fb095..dfbccf9 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/controller/ProjectController.java @@ -41,24 +41,34 @@ public class ProjectController { @PostMapping() public ResponseEntity> registerProject(@Valid @RequestBody ProjectInputDTO projectInputDTO, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + log.info("프로젝트 등록 요청 : /api/projects POST request 발생"); + projectCreateService.createProject(projectInputDTO, customUserDetails.getUsername()); + return ResponseEntity.ok(ApiResponseData.success(null, "프로젝트 등록 성공")); } @GetMapping("/{projectId}") public ResponseEntity> getProject(@PathVariable("projectId") Long projectId, HttpServletRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + log.info("프로젝트 상세 조회 요청 : /api/projects/{projectId} GET request 발생"); + return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "프로젝트 조회 성공", projectGetService.getProject(projectId, request, customUserDetails))); } @PutMapping("/{projectId}") public ResponseEntity> updateProject(@PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestBody ProjectUpdateDTO projectUpdateDTO, HttpServletRequest request){ + log.info("프로젝트 수정 요청 : /api/projects/{projectId} PUT request 발생"); + return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "프로젝트 수정 성공",projectService.updateProject(projectId, customUserDetails, projectUpdateDTO,request))); } @DeleteMapping("/{projectId}") public ResponseEntity> deleteProject(@PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails){ + log.info("프로젝트 삭제 요청 : /api/projects/{projectId} DELETE request 발생"); + projectService.deleteProject(projectId, customUserDetails); + return ResponseEntity.ok().body(ApiResponseData.success(null, "프로젝트 삭제 성공")); } @@ -68,6 +78,8 @@ public ResponseEntity> deleteProject(@PathVariable("proj // 북마크 등록 @PostMapping("/{projectId}/bookmark") public ResponseEntity> addProjectBookmark(@PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails){ + log.info("프로젝트 북마크 등록 요청 : /api/projects/{projectId}/bookmark POST request 발생"); + return ResponseEntity.ok().body(ApiResponseData.success(null,projectBookmarkService.addBookmark(customUserDetails, projectId))); } // 북마크 삭제 @@ -75,6 +87,8 @@ public ResponseEntity> addProjectBookmark(@PathVariable( public ResponseEntity> removeProjectBookmark( @PathVariable("projectId") Long projectId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { + log.info("프로젝트 북마크 삭제 요청 : /api/projects/{projectId}/bookmark DELETE request 발생"); + return ResponseEntity.ok().body(ApiResponseData.success(null, projectBookmarkService.removeBookmark(customUserDetails, projectId))); } @@ -88,6 +102,8 @@ public ResponseEntity> filterKeyword(@Re @RequestParam(name="sortBy", required = false, defaultValue = "createdDateTime") String sortBy, @RequestParam(name="page", required = false, defaultValue = "0") int page, @RequestParam(name="size", required = false, defaultValue = "12") int size){ + log.info("프로젝트 키워트 필터링 요청 : /api/projects/filter/keyword GET request 발생"); + Page projectFilterOutputDTOS = projectService.getProjectsByKeywords(category,periodKey,roles,skills,meetingOption,step,sortBy,page,size); //조회된 프로젝트가 하나도 없는 경우 @@ -104,15 +120,21 @@ public ResponseEntity> filterSearch(@Req @RequestParam(name="sortBy", required = false, defaultValue = "createdDateTime") String sortBy, @RequestParam(name="page", required = false, defaultValue = "0") int page, @RequestParam(name="size", required = false, defaultValue = "12") int size){ + log.info("프로젝트 검색 필터링 요청 : /api/projects/filter/search GET request 발생"); + Page projectFilterOutputDTOS = projectService.getProjectsBySearch(title, sortBy, page, size); + if(projectFilterOutputDTOS.isEmpty()){ return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "검색어와 일치하는 프로젝트가 없습니다.",ProjectFilterResultDTO.of(0, 0L,projectFilterOutputDTOS.getContent()))); } + return ResponseEntity.ok().body(ApiResponseData.of(Code.SUCCESS.getCode(), "프로젝트 조회가 완료되었습니다.",ProjectFilterResultDTO.of(projectFilterOutputDTOS.getTotalPages(), projectFilterOutputDTOS.getTotalElements(),projectFilterOutputDTOS.getContent()))); } @GetMapping("/list") public ResponseEntity>> findProjects(){ + log.info("프로젝트 전체 조회 요청 : /api/projects/list GET request 발생"); + List projectEntities = new ArrayList<>(projectService.getAllProjects()); if(projectEntities.isEmpty()){ From 42494b26f5067bfd3f0db041b72e75c1f4b2f8b1 Mon Sep 17 00:00:00 2001 From: High-Quality-Coffee <125748258+High-Quality-Coffee@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:54:23 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat=20:=20=EB=8C=93=EA=B8=80,=20?= =?UTF-8?q?=EB=A0=88=EB=94=94=EC=8A=A4=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A1=9C=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 controller api 요청 로깅 - 레디스 테스트 controller api 요청 요깅 --- .../project/controller/CommentController.java | 11 +++++++++++ .../project/controller/RedisTestController.java | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java b/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java index ce84f04..9c1cae2 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/controller/CommentController.java @@ -1,6 +1,7 @@ package sequence.sequence_member.project.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -16,6 +17,7 @@ import sequence.sequence_member.project.dto.CommentUpdateDTO; import sequence.sequence_member.project.service.CommentService; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/projects/{projectId}/comments") @@ -26,20 +28,29 @@ public class CommentController { @PostMapping public ResponseEntity> writeComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long projectId, @RequestBody CommentInputDTO commentInputDTO){ + log.info("프로젝트 댓글 등록 요청 : /api/projects/{projectId}/comments POST request 발생"); + commentService.writeComment(customUserDetails, projectId , commentInputDTO); + return ResponseEntity.ok().body(ApiResponseData.success(null, "댓글 작성 성공")); } @PutMapping("/{commentId}") public ResponseEntity> updateComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long projectId, @PathVariable Long commentId, @RequestBody CommentUpdateDTO commentUpdateDTO){ + log.info("프로젝트 댓글 수정 요청 : /api/projects/{projectId}/comments/{commentId} PUT request 발생"); + commentService.updateComment(customUserDetails, projectId, commentId, commentUpdateDTO); + return ResponseEntity.ok().body(ApiResponseData.success(null, "댓글 수정 성공")); } @DeleteMapping("/{commentId}") public ResponseEntity> deleteComment(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable Long projectId, @PathVariable Long commentId){ + log.info("프로젝트 댓글 삭제 요청 : /api/projects/{projectId}/comments/{commentId} DELETE request 발생"); + commentService.deleteComment(customUserDetails, projectId, commentId); + return ResponseEntity.ok().body(ApiResponseData.success(null, "댓글 삭제 성공")); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java b/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java index 216fc07..53f64a7 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/controller/RedisTestController.java @@ -1,11 +1,13 @@ package sequence.sequence_member.project.controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import sequence.sequence_member.project.service.RedisTestService; +@Slf4j @RestController @RequestMapping("/test") @RequiredArgsConstructor @@ -14,7 +16,10 @@ public class RedisTestController { @GetMapping("/redis") public String testRedis() { + log.info("레디스 테스트 요청 : /test/redis GET request 발생"); + redisTestService.testRedisConnection(); + return "Redis 테스트 완료!"; } } From 5cb9345fd4c609628ce6a8ce1bb19826d5053b88 Mon Sep 17 00:00:00 2001 From: High-Quality-Coffee <125748258+High-Quality-Coffee@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:42:51 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat=20:=20project=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project 도메인 서비스 로직에 로깅 추가 완료 --- .../project/service/ProjectBookmarkService.java | 8 ++++++++ .../project/service/ProjectGetService.java | 5 +++++ .../project/service/ProjectMemberService.java | 5 +++++ .../project/service/ProjectViewBackupSchedule.java | 2 +- .../project/service/ProjectViewService.java | 3 +++ sequence_member/src/main/resources/application.yml | 6 +++++- .../src/main/resources/prometheus/prometheus.yml | 8 ++++++++ 7 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 sequence_member/src/main/resources/prometheus/prometheus.yml diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java index 7a7e8dd..a1b68b0 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectBookmarkService.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import sequence.sequence_member.global.exception.CanNotFindResourceException; @@ -15,6 +16,7 @@ import sequence.sequence_member.project.repository.ProjectBookmarkRepository; import sequence.sequence_member.project.repository.ProjectRepository; +@Slf4j @Service @RequiredArgsConstructor public class ProjectBookmarkService { @@ -31,9 +33,11 @@ public String addBookmark(CustomUserDetails customUserDetails, Long projectId) { if(member == null){ errMessgage.append("멤버를 찾을 수 없습니다.\n"); + log.error("멤버를 찾을 수 없음"); } if(project == null){ errMessgage.append("프로젝트를 찾을 수 없습니다.\n"); + log.error("프로젝트를 찾을 수 없음"); } if(!errMessgage.isEmpty()){ throw new CanNotFindResourceException(errMessgage.toString()); @@ -65,9 +69,11 @@ public String removeBookmark(CustomUserDetails customUserDetails, Long projectId if(member == null){ errMessgage.append("멤버를 찾을 수 없습니다.\n"); + log.error("멤버를 찾을 수 없음"); } if(project == null){ errMessgage.append("프로젝트를 찾을 수 없습니다.\n"); + log.error("프로젝트를 찾을 수 없음"); } if(!errMessgage.isEmpty()){ throw new CanNotFindResourceException(errMessgage.toString()); @@ -99,10 +105,12 @@ public void deleteByProject(Project project, String username){ // 프로젝트 북마크 여부 확인 ( 북마크 되어있으면 true, 아니면 false, 로그인 안한 사용자는 false) public boolean isBookmarked(Long projectId, CustomUserDetails customUserDetails) { if (customUserDetails == null) { + log.error("로그인 재시도 필요 : No customUserDetail here"); return false; } MemberEntity member = memberRepository.findByUsernameAndIsDeletedFalse(customUserDetails.getUsername()).orElse(null); if (member == null) { + log.error("멤버를 찾을 수 없음"); return false; } return bookmarkRepository.existsByMemberIdAndProjectId(member.getId(), projectId); diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java index 10534e1..870e8f2 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectGetService.java @@ -32,10 +32,15 @@ public class ProjectGetService { @Transactional(readOnly = true) public ProjectOutputDTO getProject(Long projectId, HttpServletRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails){ Project project = projectRepository.findById(projectId).orElseThrow(()-> new CanNotFindResourceException("해당 프로젝트가 존재하지 않습니다.")); + List projectMemberOutputDTOS = projectMemberService.getProjectMemberOutputDTOS(project); + List commentOutputDTOS = commentService.getCommentOutputDTOS(project); + int views = projectViewService.getViews(projectId, request, project); + boolean bookmarked = projectBookmarkService.isBookmarked(projectId, customUserDetails); + return ProjectOutputDTO.from(project,projectMemberOutputDTOS, commentOutputDTOS, views, bookmarked); } } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java index bf2284e..7149cfd 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectMemberService.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +17,7 @@ import sequence.sequence_member.project.repository.ProjectInvitedMemberRepository; import sequence.sequence_member.project.repository.ProjectMemberRepository; +@Slf4j @Service @RequiredArgsConstructor public class ProjectMemberService { @@ -28,6 +30,7 @@ public class ProjectMemberService { @Transactional public void saveProjectInvitedMember(ProjectInputDTO projectInputDTO, MemberEntity writer, Project project) { if(projectInputDTO.getInvitedMembersNicknames()==null || projectInputDTO.getInvitedMembersNicknames().isEmpty()){ + log.error("멤버를 찾을 수 없음"); return; } projectInputDTO.getInvitedMembersNicknames().remove(writer.getNickname()); // 본인은 제거 @@ -54,7 +57,9 @@ public void saveProjectMember(Project project, MemberEntity member) { public List getProjectMemberOutputDTOS(Project project) { //Member정보중 memberId, nickname, profileImg만을 추출하여 응답데이터에 포함함 List projectMembers = project.getMembers(); + List projectMemberOutputDTOS = new ArrayList<>(); + for (ProjectMember projectMember : projectMembers) { projectMemberOutputDTOS.add(ProjectMemberOutputDTO.builder() .nickname(projectMember.getMember().getNickname()) diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java index 523a9f2..33c9948 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewBackupSchedule.java @@ -28,7 +28,7 @@ public void projectViewBackUpToDB(){ Set keys = redisTemplate.keys("viewCount:*"); if (keys == null || keys.isEmpty()) { - log.info("저장된 조회수 데이터가 없습니다."); + log.error("저장된 조회수 데이터가 없습니다."); return; } diff --git a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java index 713818f..d7580bc 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/project/service/ProjectViewService.java @@ -5,12 +5,14 @@ import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import sequence.sequence_member.global.annotation.MethodDescription; import sequence.sequence_member.project.entity.Project; import sequence.sequence_member.project.repository.ProjectRepository; +@Slf4j @Service @RequiredArgsConstructor public class ProjectViewService { @@ -25,6 +27,7 @@ public int getViews(Long projectId, HttpServletRequest request, Project project) views = getViewsFromRedis(request, projectId); }catch (Exception e){ views = project.getViews()+1; + log.error("view 조회 에러 발생", e); } return views; } diff --git a/sequence_member/src/main/resources/application.yml b/sequence_member/src/main/resources/application.yml index ffe9ed9..0fe4b46 100644 --- a/sequence_member/src/main/resources/application.yml +++ b/sequence_member/src/main/resources/application.yml @@ -80,8 +80,12 @@ management: endpoints: web: exposure: - include: prometheus + include: "*" + # /actuator/ 이하의 모든 엔드포인트 노출 설정 endpoint: + health: + show-details: always + # 이 설정은 /actuator/health 엔드포인트에서 헬스 체크 정보를 항상 상세히 보여주도록 설정합니다. 기본적으로, 헬스 체크 엔드포인트는 요약된 상태 정보만 제공하며, 상세 정보는 노출되지 않습니다. prometheus: enabled: true prometheus: diff --git a/sequence_member/src/main/resources/prometheus/prometheus.yml b/sequence_member/src/main/resources/prometheus/prometheus.yml new file mode 100644 index 0000000..b330793 --- /dev/null +++ b/sequence_member/src/main/resources/prometheus/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'spring-boot' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] \ No newline at end of file From 1e33d9102f3e251c93bcc22ace7241f5380cceac Mon Sep 17 00:00:00 2001 From: vkflco08 Date: Tue, 24 Jun 2025 18:18:55 +0900 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20=EC=95=84=EC=B9=B4=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EB=AA=A9=EB=A1=9D=20DTO=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [목적] - 아카이브 목록 데이터 처리 방식을 `Page`에서 `List` 기반으로 변경하고 DTO 매핑을 일관되게 적용 [변경 내용] - `toPortfolioDto` 메서드의 `archiveList` 파라미터 타입을 `Page`에서 `List`로 변경 --- .../mypage/controller/MyPageController.java | 9 ++----- .../mypage/dto/MyPageMapper.java | 27 ++++++++++--------- .../mypage/dto/PortfolioDTO.java | 4 +-- .../mypage/service/MyPageService.java | 24 +++++------------ 4 files changed, 24 insertions(+), 40 deletions(-) diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/controller/MyPageController.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/controller/MyPageController.java index 3882dba..a2516b3 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/controller/MyPageController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/controller/MyPageController.java @@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -33,14 +32,12 @@ public class MyPageController { @GetMapping("/api/mypage") public ResponseEntity> getMyProfile( - @RequestParam(defaultValue = "0") int page, // 페이지 기본값 0 - @RequestParam(defaultValue = "10") int size, // 사이즈 기본값 10 @AuthenticationPrincipal CustomUserDetails customUserDetails ) { String username = customUserDetails.getUsername(); try { - MyPageResponseDTO myPageDTO = myPageService.getMyProfile(username, page, size, customUserDetails); + MyPageResponseDTO myPageDTO = myPageService.getMyProfile(username, customUserDetails); // 성공 응답 생성 return ResponseEntity.ok(ApiResponseData.success(myPageDTO, "사용자 정보를 성공적으로 가져왔습니다.")); } catch (Exception e) { @@ -73,12 +70,10 @@ public ResponseEntity> updateMyProfile( @GetMapping("/api/mypage/{nickname}") public ResponseEntity> getUserProfile( @PathVariable String nickname, - @RequestParam(defaultValue = "0") int page, // 페이지 기본값 0 - @RequestParam(defaultValue = "10") int size, // 사이즈 기본값 10 @AuthenticationPrincipal CustomUserDetails customUserDetails ) { try { - MyPageResponseDTO userProfile = myPageService.getUserProfile(nickname, page, size, customUserDetails); + MyPageResponseDTO userProfile = myPageService.getUserProfile(nickname, customUserDetails); return ResponseEntity.ok(ApiResponseData.success(userProfile, nickname + "님의 정보를 성공적으로 가져왔습니다.")); } catch (Exception e) { ApiResponseData errorResponse = ApiResponseData.failure( diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java index dc13131..e68de36 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/MyPageMapper.java @@ -1,7 +1,6 @@ package sequence.sequence_member.mypage.dto; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import sequence.sequence_member.archive.dto.MyPageEvaluationDTO; @@ -47,18 +46,18 @@ public class MyPageMapper { * 멤버와 아카이브 페이지네이션 객체를 ResponseDTO 매핑하는 메인 함수입니다. * * @param member ResponseDTO로 매핑할 멤버 객체 - * @param archivePage ResponseDTO로 매핑할 archive 페이지네이션 객체 + * @param archiveList ResponseDTO로 매핑할 archive 페이지네이션 객체 * @return 사용자의 마이페이지 정보를 담은 MyPageResponseDTO */ public MyPageResponseDTO toMyPageResponseDto( MemberEntity member, - Page archivePage, + List archiveList, List invitedProjects ) { return new MyPageResponseDTO( toBasicInfoDto(member), toCareerHistoryDto(member), - toPortfolioDto(archivePage, invitedProjects), + toPortfolioDto(archiveList, invitedProjects), toTeamFeedbackDto(member), getMyActivity(member) ); @@ -140,17 +139,19 @@ private CareerHistoryDTO toCareerHistoryDto(MemberEntity member) { /** * 내가 작성한 아카이브와 초대받은 프로젝트 리스트를 PortfolioDTO로 변환합니다. * - * @param archivePage + * @param archiveList * @return PortfolioDto 객체 */ - private PortfolioDTO toPortfolioDto(Page archivePage, List invitedProjects) { - Page archiveDTO = archivePage.map(archive -> ArchiveSummaryDTO.builder() - .id(archive.getId()) - .title(archive.getTitle()) - .thumbnail(archive.getThumbnail()) - .startDate(archive.getStartDate()) - .endDate(archive.getEndDate()) - .build()); + private PortfolioDTO toPortfolioDto(List archiveList, List invitedProjects) { + List archiveDTO = archiveList.stream() + .map(archive -> ArchiveSummaryDTO.builder() + .id(archive.getId()) + .title(archive.getTitle()) + .thumbnail(archive.getThumbnail()) + .startDate(archive.getStartDate()) + .endDate(archive.getEndDate()) + .build()) + .collect(Collectors.toList()); return new PortfolioDTO(archiveDTO, invitedProjects); } diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java index 4484dc1..26b5d26 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/dto/PortfolioDTO.java @@ -3,19 +3,17 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import org.springframework.data.domain.Page; import java.util.List; /** * 사용자가 참여한 아카이브 이력 DTO - * * 마이페이지 화면에서 '포트폴리오'에 해당하는 객체 */ @Getter @AllArgsConstructor @NoArgsConstructor public class PortfolioDTO { - private Page archivePage; + private List archiveList; private List invitedProjects; } diff --git a/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java b/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java index bb4b3bb..fdc75f9 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/mypage/service/MyPageService.java @@ -3,10 +3,6 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -15,6 +11,7 @@ import org.springframework.web.multipart.MultipartFile; import sequence.sequence_member.archive.entity.Archive; import sequence.sequence_member.archive.repository.ArchiveRepository; +import sequence.sequence_member.global.enums.enums.Status; import sequence.sequence_member.global.exception.BAD_REQUEST_EXCEPTION; import sequence.sequence_member.global.response.ApiResponseData; import sequence.sequence_member.global.response.Code; @@ -48,24 +45,19 @@ public class MyPageService { * 주어진 사용자명(username)에 해당하는 마이페이지 정보를 조회합니다. * * @param username 조회할 사용자의 이름 - * @param page archive 페이지네이션 파라미터 - * @param size archive 페이지네이션 파라미터 * @param customUserDetails 포트폴리오 객체에서 사용하는 파라미터 * * @return 사용자의 마이페이지 정보를 담은 DTO * @throws EntityNotFoundException 사용자를 찾을 수 없는 경우 발생 */ - public MyPageResponseDTO getMyProfile(String username, int page, int size, CustomUserDetails customUserDetails) { + public MyPageResponseDTO getMyProfile(String username, CustomUserDetails customUserDetails) { MemberEntity member = memberRepository.findByUsernameAndIsDeletedFalse(username) .orElseThrow(() -> new EntityNotFoundException("해당 사용자를 찾을 수 없습니다.")); - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDateTime").descending()); - Page archivePage = archiveRepository.findByWriterAndIsDeletedFalse(member, pageable); - + List archiveList = archiveRepository.findTop5ByMemberIdAndStatus(member.getId(), Status.평가완료); List invitedProjects = getInvitedProjects(customUserDetails); - return myPageMapper.toMyPageResponseDto(member, archivePage, invitedProjects); - + return myPageMapper.toMyPageResponseDto(member, archiveList, invitedProjects); } /** @@ -99,18 +91,16 @@ public void updateMyProfile( * @return 초대받은 프로젝트 정보와 각 프로젝트의 댓글 수를 담은 DTO 리스트 * @throws EntityNotFoundException 사용자를 찾을 수 없는 경우 발생 */ - public MyPageResponseDTO getUserProfile(String nickname, int page, int size, CustomUserDetails customUserDetails) { + public MyPageResponseDTO getUserProfile(String nickname, CustomUserDetails customUserDetails) { MemberEntity member = memberRepository.findByNickname(nickname) .orElseThrow(() -> new EntityNotFoundException("해당 사용자를 찾을 수 없습니다.")); if(member.isDeleted()) throw new BAD_REQUEST_EXCEPTION("탈퇴한 사용자입니다."); - Pageable pageable = PageRequest.of(page, size, Sort.by("createdDateTime").descending()); - Page archivePage = archiveRepository.findByWriterAndIsDeletedFalse(member, pageable); - + List archiveList = archiveRepository.findTop5ByMemberIdAndStatus(member.getId(), Status.평가완료); List invitedProjects = getInvitedProjects(customUserDetails); - return myPageMapper.toMyPageResponseDto(member, archivePage, invitedProjects); + return myPageMapper.toMyPageResponseDto(member, archiveList, invitedProjects); } /** From d1daa0aa4aad9e924b5d19c301e751b429bf0223 Mon Sep 17 00:00:00 2001 From: vkflco08 Date: Fri, 27 Jun 2025 19:22:08 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=97=B0=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [목적] - 이미 가입된 사용자가 자신의 계정에 소셜 계정을 연결하여, 이후 해당 소셜 로그인을 통해 간편하게 로그인할 수 있도록 한다. [변경 내용] - Spring Security가 생성한 고유 state값을 키의 일부로 사용하여, 연동에 필요한 정보를 세션에 저장한다. - `binding_state_token` 파라미터의 유무와 유효성을 검사하여, 일반 소셜 로그인 요청과 계정 연동 요청을 구분한다. [확인 필요] - 리다이렉션 url을 실제 프런트 주소로 변경해야함. - 로컬 환경에서 소셜 로그인 연동 시 세션이 유지되지 않아 로그를 통해 문제가 있는지 확인해야 함. --- .../authority/OAuth2FailureHandler.java | 9 +-- .../authority/OAuth2SuccessHandler.java | 4 +- .../member/config/SecurityConfig.java | 1 - .../controller/AuthenticationController.java | 39 +++++++++++ .../CustomAuthorizationRequestRepository.java | 69 +++++++++---------- .../member/service/CustomOidcUserService.java | 30 ++++---- .../src/main/resources/application.yml | 1 + 7 files changed, 92 insertions(+), 61 deletions(-) create mode 100644 sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java index 65be19e..1e6c0a1 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2FailureHandler.java @@ -2,27 +2,24 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; import java.io.IOException; +@Slf4j @Component public class OAuth2FailureHandler implements AuthenticationFailureHandler { - private static final Logger logger = LoggerFactory.getLogger(OAuth2FailureHandler.class); - @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { - logger.error("소셜 로그인 실패: {}", exception.getMessage()); - exception.printStackTrace(); // 에러 로그 확인 + log.error("소셜 로그인 실패: {}", exception.getMessage()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"error\": \"소셜 로그인에 실패했습니다: " + exception.getMessage() + "\"}"); diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java index 646c9ac..5a6094c 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/authority/OAuth2SuccessHandler.java @@ -15,12 +15,12 @@ import java.io.IOException; -@Slf4j // Lombok 어노테이션 +@Slf4j @Component public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final TokenReissueService tokenReissueService; private final JWTUtil jwtUtil; - private final long ACCESS_TOKEN_EXPIRED_TIME = 600000L * 60 * 1; // 1시간 + private final long ACCESS_TOKEN_EXPIRED_TIME = 600000L * 60; // 1시간 private final long REFRESH_TOKEN_EXPIRED_TIME = 600000L * 60 * 24 * 7; // 7일 public OAuth2SuccessHandler( diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java b/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java index 5f43c5a..4a6c409 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/config/SecurityConfig.java @@ -38,7 +38,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; -import java.util.Map; @Configuration @EnableWebSecurity diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java b/sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java new file mode 100644 index 0000000..81d6834 --- /dev/null +++ b/sequence_member/src/main/java/sequence/sequence_member/member/controller/AuthenticationController.java @@ -0,0 +1,39 @@ +package sequence.sequence_member.member.controller; + +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; +import sequence.sequence_member.global.response.ApiResponseData; + +import java.util.UUID; + +@Slf4j +@RestController +@RequestMapping("/api/social") +public class AuthenticationController { + @GetMapping("/bind/google") + public ResponseEntity> bindGoogle( + HttpSession session + ) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + log.info("계정 연동 요청: username = {}, provider = google", username); + + String bindingDataSessionKey = "oauth2_binding_username"; + session.setAttribute(bindingDataSessionKey, username); + log.info("세션에 바인딩할 사용자 이름 저장 완료: {}", username); + + String bindingStateToken = "bind:" + UUID.randomUUID(); + String redirectUri = UriComponentsBuilder + .fromUriString("http://localhost:8080" + "/oauth2/authorization/google") + .queryParam("binding_state_token", bindingStateToken) + .build() + .toUriString(); + + return ResponseEntity.ok().body(ApiResponseData.success(redirectUri)); + } +} diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java b/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java index 7b6c5f9..a4144fa 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/repository/CustomAuthorizationRequestRepository.java @@ -3,8 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; @@ -13,10 +12,9 @@ import java.util.HashMap; import java.util.Map; +@Slf4j public class CustomAuthorizationRequestRepository implements AuthorizationRequestRepository { - private static final Logger logger = LoggerFactory.getLogger(CustomAuthorizationRequestRepository.class); - public static final String SESSION_ATTR_NAME = "SPRING_SECURITY_OAUTH2_AUTHORIZATION_REQUEST"; public static final String SPRING_SECURITY_OAUTH2_BINDING_DATA = "SPRING_SECURITY_OAUTH2_BINDING_DATA"; @@ -25,7 +23,7 @@ public class CustomAuthorizationRequestRepository implements AuthorizationReques @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { HttpSession session = request.getSession(false); - logger.debug("DEBUG: loadAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); + log.debug("DEBUG: loadAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); if (session != null) { StringBuilder attributes = new StringBuilder("["); Enumeration attributeNames = session.getAttributeNames(); @@ -36,7 +34,7 @@ public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest re } } attributes.append("]"); - logger.debug("DEBUG: loadAuthorizationRequest - Session attributes: {}", attributes.toString()); + log.debug("DEBUG: loadAuthorizationRequest - Session attributes: {}", attributes); } return delegate.loadAuthorizationRequest(request); } @@ -47,62 +45,59 @@ public void saveAuthorizationRequest( HttpServletRequest request, HttpServletResponse response ) { - logger.info("🔐 saveAuthorizationRequest 호출됨 - URI: {} (Method: {})", request.getRequestURI(), request.getMethod()); // ⭐ 요청 URI 및 메서드 로그 추가 + log.info("🔐 saveAuthorizationRequest 호출됨 - URI: {} (Method: {})", request.getRequestURI(), request.getMethod()); HttpSession session = request.getSession(); - logger.debug("Session ID (save): {}", session.getId()); - logger.debug("Session attributes BEFORE delegate save: {}", getSessionAttributesString(session)); + log.debug("Session ID (save): {}", session.getId()); + log.debug("Session attributes BEFORE delegate save: {}", getSessionAttributesString(session)); + + String bindingUsernameStr = (String) session.getAttribute("oauth2_binding_username"); + session.removeAttribute("oauth2_binding_username"); if (authorizationRequest == null) { delegate.saveAuthorizationRequest(null, request, response); - logger.debug("Session attributes after null request cleanup: {}", getSessionAttributesString(session)); + log.debug("Session attributes after null request cleanup: {}", getSessionAttributesString(session)); return; } // Spring Security가 생성한 기본 state 값 (CSRF 방어용) String originalSpringSecurityState = authorizationRequest.getState(); + // 클라이언트(프런트엔드)로부터 전달된 커스텀 파라미터 (계정 연동 요청 시 전달됨) - String bindingUserIdStr = request.getParameter("binding_user_id"); String bindingStateToken = request.getParameter("binding_state_token"); - logger.debug("DEBUG: Original SS State = {}", originalSpringSecurityState); - logger.debug("DEBUG: binding_user_id Parameter = {}", bindingUserIdStr); - logger.debug("DEBUG: binding_state_token Parameter = {}", bindingStateToken); + log.debug("DEBUG: Original SS State = {}", originalSpringSecurityState); + log.info("DEBUG: binding_user_name Parameter = {}", bindingUsernameStr); + log.info("DEBUG: binding_state_token Parameter = {}", bindingStateToken); // 1. Spring Security의 OAuth2AuthorizationRequest 객체는 delegate를 통해 세션에 저장 delegate.saveAuthorizationRequest(authorizationRequest, request, response); - logger.debug("DEBUG: After setting {}: {}", SESSION_ATTR_NAME, getSessionAttributesString(session)); + log.debug("DEBUG: After setting {}: {}", SESSION_ATTR_NAME, getSessionAttributesString(session)); - // 2. 만약 커스텀 연동 파라미터(userId와 bindingStateToken)가 존재하고 유효하면, + // 2. 만약 커스텀 연동 파라미터(username와 bindingStateToken)가 존재하고 유효하면, // 이 정보를 Map 형태로 구성하여 세션에 별도로 저장 - if (bindingUserIdStr != null && bindingStateToken != null && bindingStateToken.startsWith("bind:")) { + if (bindingUsernameStr != null && bindingStateToken != null && bindingStateToken.startsWith("bind:")) { // Map의 키를 originalSpringSecurityState와 조합하여 세션에 저장 String bindingDataKey = SPRING_SECURITY_OAUTH2_BINDING_DATA + "_" + originalSpringSecurityState; - Long userId = null; - try { - userId = Long.parseLong(bindingUserIdStr); - } catch (NumberFormatException e) { - logger.warn("WARN: 'binding_user_id' 파라미터({})를 Long으로 파싱할 수 없습니다.", bindingUserIdStr); - } - Map bindingMap = new HashMap<>(); bindingMap.put("bindState", bindingStateToken); - if (userId != null) { - bindingMap.put("userId", userId); - } else { - logger.warn("WARN: bindingMap에 userId가 null로 저장됩니다. 파라미터 값: {}", bindingUserIdStr); - } + bindingMap.put("username", bindingUsernameStr); session.setAttribute(bindingDataKey, bindingMap); - logger.info("📦 세션에 연동 데이터 저장 완료 (키: {}, 값: {})", bindingDataKey, bindingMap); - logger.debug("Session attributes after save (detailed): {}", getSessionAttributesString(session)); + log.info("📦 세션에 연동 데이터 저장 완료 (키: {}, 값: {})", bindingDataKey, bindingMap); + log.debug("Session attributes after save (detailed): {}", getSessionAttributesString(session)); + + // 저장된키 바로 확인 + Object storedData = session.getAttribute(bindingDataKey); + log.info("⭐ 세션에 방금 저장된 연동 데이터 확인: 키 = {}, 값 = {}", bindingDataKey, storedData); + } else { - logger.debug("📦 커스텀 연동 파라미터가 없거나 유효하지 않습니다. (bindingUserIdStr: {}, bindingStateToken: {})", bindingUserIdStr, bindingStateToken); - logger.debug("Session attributes after save (no binding data stored): {}", getSessionAttributesString(session)); + log.debug("📦 커스텀 연동 파라미터가 없거나 유효하지 않습니다. (bindingUsernameStr: {}, bindingStateToken: {})", bindingUsernameStr, bindingStateToken); + log.debug("Session attributes after save (no binding data stored): {}", getSessionAttributesString(session)); } - logger.info("🚀 IdP로 리다이렉트될 때 사용될 state는 '{}'입니다. (이 값이 Google로 보내집니다)", originalSpringSecurityState); + log.info("🚀 IdP로 리다이렉트될 때 사용될 state는 '{}'입니다. (이 값이 Google로 보내집니다)", originalSpringSecurityState); } @Override @@ -111,12 +106,12 @@ public OAuth2AuthorizationRequest removeAuthorizationRequest( HttpServletResponse response ) { HttpSession session = request.getSession(false); - logger.debug("DEBUG: removeAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); - logger.debug("DEBUG: removeAuthorizationRequest - Session attributes before removal: {}", getSessionAttributesString(session)); + log.debug("DEBUG: removeAuthorizationRequest - Session ID: {}", session != null ? session.getId() : "null"); + log.debug("DEBUG: removeAuthorizationRequest - Session attributes before removal: {}", getSessionAttributesString(session)); OAuth2AuthorizationRequest authorizationRequest = delegate.removeAuthorizationRequest(request, response); - logger.debug("DEBUG: removeAuthorizationRequest - Session attributes after removal: {}", getSessionAttributesString(session)); + log.debug("DEBUG: removeAuthorizationRequest - Session attributes after removal: {}", getSessionAttributesString(session)); return authorizationRequest; } diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java index 44da53e..0b0503d 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/CustomOidcUserService.java @@ -72,7 +72,7 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio throw new IllegalStateException("HttpServletRequest not available in RequestContextHolder."); } - logger.debug("Session ID (load): {}", request.getSession().getId()); + logger.info("Session ID (load): {}", request.getSession().getId()); StringBuilder sessionAttributes = new StringBuilder("["); Enumeration attributeNames = request.getSession().getAttributeNames(); while (attributeNames.hasMoreElements()) { @@ -82,10 +82,10 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio } } sessionAttributes.append("]"); - logger.debug("Session attributes before extract: {}", sessionAttributes.toString()); + logger.debug("Session attributes before extract: {}", sessionAttributes); String returnedSpringSecurityState = request.getParameter("state"); - logger.debug("반환된 state (IdP로부터): {}", returnedSpringSecurityState); + logger.info("반환된 state (IdP로부터): {}", returnedSpringSecurityState); String BIND_STATE_PREFIX = "bind:"; @@ -100,9 +100,9 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio if (bindingData != null) { String bindStateFromMap = (String) bindingData.get("bindState"); - Long userIdFromMap = (Long) bindingData.get("userId"); + String usernameFromMap = (String) bindingData.get("username"); - if (bindStateFromMap != null && bindStateFromMap.startsWith(BIND_STATE_PREFIX) && userIdFromMap != null) { + if (bindStateFromMap != null && bindStateFromMap.startsWith(BIND_STATE_PREFIX) && usernameFromMap != null) { isBindingRequest = true; } // 세션에서 사용한 바인딩 데이터 제거 (한번 사용하면 제거) @@ -113,8 +113,8 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio } } - logger.debug("추출된 연동용 데이터: {}", bindingData); - logger.debug("연동 요청 여부: {}", isBindingRequest); + logger.info("추출된 연동용 데이터: {}", bindingData); + logger.info("연동 요청 여부: {}", isBindingRequest); MemberEntity member; if (isBindingRequest && bindingData != null) { @@ -130,7 +130,7 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio /** * 기존 회원에게 소셜 계정을 연동하는 로직 - * @param bindingData 세션에서 추출된 연동 관련 데이터 (userId, bindState) + * @param bindingData 세션에서 추출된 연동 관련 데이터 (username, bindState) * @param userInfo 소셜 제공자로부터 받은 사용자 정보 * @param provider 소셜 제공자 타입 * @return 연동 완료된 MemberEntity @@ -142,22 +142,22 @@ private MemberEntity bindSocialAccount( AuthProvider provider ) throws OAuth2AuthenticationException { String bindState = (String) bindingData.get("bindState"); - Long userId = (Long) bindingData.get("userId"); + String username = (String) bindingData.get("username"); - if (userId == null || bindState == null) { - logger.warn("WARN: 계정 연동을 위한 데이터가 불완전합니다. (userId: {}, bindState: {})", userId, bindState); + if (username == null || bindState == null) { + logger.warn("WARN: 계정 연동을 위한 데이터가 불완전합니다. (username: {}, bindState: {})", username, bindState); throw new OAuth2AuthenticationException( new OAuth2Error("INVALID_BINDING_REQUEST"), "계정 연동을 위한 데이터가 불완전합니다. 다시 시도해주세요." ); } - logger.debug("DEBUG: bindSocialAccount에서 가져온 userId: {} (bindState: {})", userId, bindState); + logger.debug("DEBUG: bindSocialAccount에서 가져온 username: {} (bindState: {})", username, bindState); - Optional memberOptional = memberRepository.findById(userId); + Optional memberOptional = memberRepository.findByUsernameAndIsDeletedFalse(username); MemberEntity member = memberOptional.orElse(null); if (member == null) { - logger.warn("WARN: 연동을 시도한 사용자를 찾을 수 없습니다. userId: {}", userId); + logger.warn("WARN: 연동을 시도한 사용자를 찾을 수 없습니다. username: {}", username); throw new OAuth2AuthenticationException( new OAuth2Error("USER_NOT_FOUND"), "연동하려는 계정을 찾을 수 없습니다." @@ -188,7 +188,7 @@ private MemberEntity bindSocialAccount( member.addAuthProviderIfNotExists(authProvider); memberRepository.save(member); - logger.info("🔗 계정 연동 완료: userId = {}, Provider: {}", userId, provider); + logger.info("🔗 계정 연동 완료: username = {}, Provider: {}", username, provider); return member; } diff --git a/sequence_member/src/main/resources/application.yml b/sequence_member/src/main/resources/application.yml index c3d5e19..8e41645 100644 --- a/sequence_member/src/main/resources/application.yml +++ b/sequence_member/src/main/resources/application.yml @@ -61,6 +61,7 @@ spring: servlet: session: cookie: + same-site: None secure: false # 로컬 HTTP 환경에서'false'로 설정 httponly: true From d52243741cde426551741c8855f26a23d2fc0680 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Jun 2025 12:10:59 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EC=95=84=EC=B9=B4=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../archive/dto/ArchiveListDTO.java | 1 + .../archive/entity/ArchiveMember.java | 2 +- .../archive/repository/ArchiveRepository.java | 20 +++++++++++++++++++ .../archive/service/ArchiveService.java | 12 ++++++----- .../src/main/resources/application.yml | 5 +++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java index 9e16f60..4169a64 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveListDTO.java @@ -18,6 +18,7 @@ public static class ArchiveSimpleDTO { private Long id; private String title; private String writerNickname; + private String writerProfileImg; // 작성자 프로필 이미지 추가 private String thumbnail; private int commentCount; private int view; // 조회수 추가 diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java b/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java index 050935e..f14bbe4 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/entity/ArchiveMember.java @@ -43,7 +43,7 @@ public class ArchiveMember extends BaseTimeEntity { @JoinColumn(name = "archive_id") private Archive archive; - @Column(name = "profile_img") + @Column(name = "profile_img", columnDefinition = "TEXT") private String profileImg; // 멤버의 프로필 이미지 URL @Builder diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java b/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java index dee37c0..c1d2663 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/repository/ArchiveRepository.java @@ -20,30 +20,50 @@ public interface ArchiveRepository extends JpaRepository { // 기본 조회 - 삭제되지 않은 아카이브만 Optional findByIdAndIsDeletedFalse(Long id); + // 작성자 정보를 함께 조회하는 최적화된 메서드 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.id = :id AND a.isDeleted = false") + Optional findByIdAndIsDeletedFalseWithWriter(@Param("id") Long id); + // 전체 목록 조회 - 삭제되지 않은 것만 Page findByIsDeletedFalse(Pageable pageable); // 상태별 조회 - 삭제되지 않은 것만 Page findByStatusAndIsDeletedFalse(Status status, Pageable pageable); + // 상태별 조회 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.status = :status AND a.isDeleted = false") + Page findByStatusAndIsDeletedFalseWithWriter(@Param("status") Status status, Pageable pageable); + // 카테고리별 조회 - 삭제되지 않은 것만 Page findByCategoryAndIsDeletedFalse(Category category, Pageable pageable); // 카테고리와 상태로 조회 - 삭제되지 않은 것만 Page findByCategoryAndStatusAndIsDeletedFalse(Category category, Status status, Pageable pageable); + // 카테고리와 상태로 조회 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.category = :category AND a.status = :status AND a.isDeleted = false") + Page findByCategoryAndStatusAndIsDeletedFalseWithWriter(@Param("category") Category category, @Param("status") Status status, Pageable pageable); + // 제목으로 검색 - 삭제되지 않은 것만 Page findByTitleContainingIgnoreCaseAndIsDeletedFalse(String title, Pageable pageable); // 제목으로 검색하고 상태로 필터링 - 삭제되지 않은 것만 Page findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse(String title, Status status, Pageable pageable); + // 제목으로 검색하고 상태로 필터링 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.title LIKE %:title% AND a.status = :status AND a.isDeleted = false") + Page findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter(@Param("title") String title, @Param("status") Status status, Pageable pageable); + // 카테고리와 제목으로 검색 - 삭제되지 않은 것만 Page findByCategoryAndTitleContainingIgnoreCaseAndIsDeletedFalse(Category category, String title, Pageable pageable); // 카테고리와 제목으로 검색하고 상태로 필터링 - 삭제되지 않은 것만 Page findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse(Category category, String title, Status status, Pageable pageable); + // 카테고리와 제목으로 검색하고 상태로 필터링 - 작성자 정보 포함 (FETCH JOIN 사용) + @Query("SELECT a FROM Archive a JOIN FETCH a.writer WHERE a.category = :category AND a.title LIKE %:title% AND a.status = :status AND a.isDeleted = false") + Page findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter(@Param("category") Category category, @Param("title") String title, @Param("status") Status status, Pageable pageable); + // 멤버 ID로 아카이브 검색 - 삭제되지 않은 것만 @Query("SELECT a FROM Archive a JOIN a.archiveMembers am WHERE am.member.id = :memberId AND a.isDeleted = false") Page findByMemberId(@Param("memberId") Long memberId, Pageable pageable); diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java index 65f8e70..ba29383 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java @@ -191,7 +191,7 @@ public ArchiveListDTO getAllArchives(int page, SortType sortType, String usernam } Pageable pageable = createPageableWithSort(page, sortType); - Page archivePage = archiveRepository.findByStatusAndIsDeletedFalse(Status.평가완료, pageable); + Page archivePage = archiveRepository.findByStatusAndIsDeletedFalseWithWriter(Status.평가완료, pageable); List archives = archivePage.getContent().stream() .map(archive -> { @@ -203,6 +203,7 @@ public ArchiveListDTO getAllArchives(int page, SortType sortType, String usernam .id(archive.getId()) .title(archive.getTitle()) .writerNickname(archive.getWriter().getNickname()) + .writerProfileImg(archive.getWriter().getProfileImg()) .thumbnail(archive.getThumbnail()) .commentCount(archive.getComments().size()) .view(archive.getView()) @@ -236,15 +237,15 @@ public ArchiveListDTO searchArchives( Page archivePage; if (category != null && keyword != null && !keyword.trim().isEmpty()) { - archivePage = archiveRepository.findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse( + archivePage = archiveRepository.findByCategoryAndTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter( category, keyword.trim(), Status.평가완료, pageable); } else if (category != null) { - archivePage = archiveRepository.findByCategoryAndStatusAndIsDeletedFalse(category, Status.평가완료, pageable); + archivePage = archiveRepository.findByCategoryAndStatusAndIsDeletedFalseWithWriter(category, Status.평가완료, pageable); } else if (keyword != null && !keyword.trim().isEmpty()) { - archivePage = archiveRepository.findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalse( + archivePage = archiveRepository.findByTitleContainingIgnoreCaseAndStatusAndIsDeletedFalseWithWriter( keyword.trim(), Status.평가완료, pageable); } else { - archivePage = archiveRepository.findByStatusAndIsDeletedFalse(Status.평가완료, pageable); + archivePage = archiveRepository.findByStatusAndIsDeletedFalseWithWriter(Status.평가완료, pageable); } List archives = archivePage.getContent().stream() @@ -257,6 +258,7 @@ public ArchiveListDTO searchArchives( .id(archive.getId()) .title(archive.getTitle()) .writerNickname(archive.getWriter().getNickname()) + .writerProfileImg(archive.getWriter().getProfileImg()) .thumbnail(archive.getThumbnail()) .commentCount(archive.getComments().size()) .view(archive.getView()) diff --git a/sequence_member/src/main/resources/application.yml b/sequence_member/src/main/resources/application.yml index 8c46be7..b1c4aaf 100644 --- a/sequence_member/src/main/resources/application.yml +++ b/sequence_member/src/main/resources/application.yml @@ -84,6 +84,11 @@ management: endpoint: prometheus: enabled: true + health: + show-details: always + health: + mail: + enabled: false server: tomcat: mbeanregistry: From 60fecd53b033ba4efe6e42c6c6dfa30448a88b94 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Jun 2025 12:18:35 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../archive/dto/ArchiveCommentOutputDTO.java | 1 + .../sequence_member/archive/service/ArchiveService.java | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java index 00a0a30..57dd92e 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java @@ -24,6 +24,7 @@ public class ArchiveCommentOutputDTO { public static class CommentDTO { private Long id; private String writer; + private String writerProfileImg; private String content; private boolean isDeleted; private LocalDateTime createdDateTime; diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java index ba29383..25005ed 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java @@ -289,6 +289,13 @@ private Pageable createPageableWithSort(int page, SortType sortType) { return PageRequest.of(page, 18, sort); } + // 댓글 작성자의 프로필 이미지를 가져오는 헬퍼 메서드 + private String getCommentWriterProfileImg(String writerNickname) { + return memberRepository.findByNickname(writerNickname) + .map(MemberEntity::getProfileImg) + .orElse("default.png"); // 기본 이미지 + } + // Archive 엔티티를 DTO로 변환 private ArchiveOutputDTO convertToDTO(Archive archive, String username, int viewCount) { List memberDTOs = archive.getArchiveMembers().stream() @@ -322,6 +329,7 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view ArchiveCommentOutputDTO.CommentDTO parentDTO = ArchiveCommentOutputDTO.CommentDTO.builder() .id(parentComment.getId()) .writer(parentComment.getWriter()) + .writerProfileImg(getCommentWriterProfileImg(parentComment.getWriter())) .content(parentComment.isDeleted() ? "삭제된 댓글입니다." : parentComment.getContent()) .isDeleted(parentComment.isDeleted()) .createdDateTime(parentComment.getCreatedDateTime()) @@ -336,6 +344,7 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view ArchiveCommentOutputDTO.CommentDTO childDTO = ArchiveCommentOutputDTO.CommentDTO.builder() .id(childComment.getId()) .writer(childComment.getWriter()) + .writerProfileImg(getCommentWriterProfileImg(childComment.getWriter())) .content(childComment.isDeleted() ? "삭제된 댓글입니다." : childComment.getContent()) .isDeleted(childComment.isDeleted()) .createdDateTime(childComment.getCreatedDateTime()) From dffc340372f361e3a6f08c8ea8f60836c1374548 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Jun 2025 15:18:23 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=EC=95=84=EC=B9=B4=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=9E=91=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../archive/dto/ArchiveCommentOutputDTO.java | 4 ++-- .../sequence_member/archive/dto/ArchiveOutputDTO.java | 2 ++ .../archive/service/ArchiveService.java | 11 +++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java index 57dd92e..6e1cd74 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveCommentOutputDTO.java @@ -23,8 +23,8 @@ public class ArchiveCommentOutputDTO { @Builder public static class CommentDTO { private Long id; - private String writer; - private String writerProfileImg; + private String commentWriter; + private String commentWriterProfileImg; private String content; private boolean isDeleted; private LocalDateTime createdDateTime; diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java index c8bfa36..b15dca8 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/dto/ArchiveOutputDTO.java @@ -16,7 +16,9 @@ @AllArgsConstructor public class ArchiveOutputDTO { private Long id; + private String writerUsername; private String writerNickname; + private String writerProfileImg; private String title; private String description; private LocalDate startDate; diff --git a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java index 25005ed..c66fe68 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/archive/service/ArchiveService.java @@ -299,6 +299,7 @@ private String getCommentWriterProfileImg(String writerNickname) { // Archive 엔티티를 DTO로 변환 private ArchiveOutputDTO convertToDTO(Archive archive, String username, int viewCount) { List memberDTOs = archive.getArchiveMembers().stream() + .filter(archiveMember -> !archiveMember.getMember().getId().equals(archive.getWriter().getId())) .map(archiveMember -> ArchiveOutputDTO.ArchiveMemberDTO.builder() .username(archiveMember.getMember().getUsername()) .nickname(archiveMember.getMember().getNickname()) @@ -328,8 +329,8 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view for (ArchiveComment parentComment : parentComments) { ArchiveCommentOutputDTO.CommentDTO parentDTO = ArchiveCommentOutputDTO.CommentDTO.builder() .id(parentComment.getId()) - .writer(parentComment.getWriter()) - .writerProfileImg(getCommentWriterProfileImg(parentComment.getWriter())) + .commentWriter(parentComment.getWriter()) + .commentWriterProfileImg(getCommentWriterProfileImg(parentComment.getWriter())) .content(parentComment.isDeleted() ? "삭제된 댓글입니다." : parentComment.getContent()) .isDeleted(parentComment.isDeleted()) .createdDateTime(parentComment.getCreatedDateTime()) @@ -343,8 +344,8 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view for (ArchiveComment childComment : childComments) { ArchiveCommentOutputDTO.CommentDTO childDTO = ArchiveCommentOutputDTO.CommentDTO.builder() .id(childComment.getId()) - .writer(childComment.getWriter()) - .writerProfileImg(getCommentWriterProfileImg(childComment.getWriter())) + .commentWriter(childComment.getWriter()) + .commentWriterProfileImg(getCommentWriterProfileImg(childComment.getWriter())) .content(childComment.isDeleted() ? "삭제된 댓글입니다." : childComment.getContent()) .isDeleted(childComment.isDeleted()) .createdDateTime(childComment.getCreatedDateTime()) @@ -358,7 +359,9 @@ private ArchiveOutputDTO convertToDTO(Archive archive, String username, int view return ArchiveOutputDTO.builder() .id(archive.getId()) + .writerUsername(archive.getWriter().getUsername()) .writerNickname(archive.getWriter().getNickname()) + .writerProfileImg(archive.getWriter().getProfileImg()) .title(archive.getTitle()) .description(archive.getDescription()) .startDate(archive.getStartDate()) From bccd5adad6c51a6f45462a725ada0c5c30c03b46 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Jun 2025 15:23:32 +0900 Subject: [PATCH 12/15] test --- .../sequence_member/member/entity/EducationEntity.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java b/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java index 6990e3e..1182eca 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/entity/EducationEntity.java @@ -2,7 +2,7 @@ import jakarta.persistence.*; import lombok.Data; - +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import sequence.sequence_member.global.enums.enums.Degree; import sequence.sequence_member.global.enums.enums.ProjectRole; @@ -18,6 +18,7 @@ @Entity @Data +@EqualsAndHashCode(callSuper = false) @Table(name="education") @NoArgsConstructor public class EducationEntity extends BaseTimeEntity { From f6353b6402f1c345d7aea3ffe12a65d9ec4c5bea Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 30 Jun 2025 15:24:37 +0900 Subject: [PATCH 13/15] application.yml roll back --- sequence_member/src/main/resources/application.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sequence_member/src/main/resources/application.yml b/sequence_member/src/main/resources/application.yml index b1c4aaf..405312f 100644 --- a/sequence_member/src/main/resources/application.yml +++ b/sequence_member/src/main/resources/application.yml @@ -84,11 +84,7 @@ management: endpoint: prometheus: enabled: true - health: - show-details: always - health: - mail: - enabled: false + server: tomcat: mbeanregistry: From 445455a33942cb49001e2746ddcec490c0ff6184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=98=ED=97=8C=EC=B0=AC?= <127941120+HaHeonchan@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:25:36 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat:=20=EA=B0=84=EB=8B=A8=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=ED=8F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/EmailAuthController.java | 1 - .../member/service/EmailAuthService.java | 41 +++++++----------- .../main/resources/templates/emailAuth.html | 43 +++++++++++++++++++ 3 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 sequence_member/src/main/resources/templates/emailAuth.html diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java b/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java index b339769..a7f613f 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/controller/EmailAuthController.java @@ -4,7 +4,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; diff --git a/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java b/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java index f2c93df..1222895 100644 --- a/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java +++ b/sequence_member/src/main/java/sequence/sequence_member/member/service/EmailAuthService.java @@ -3,27 +3,29 @@ import java.time.LocalDateTime; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import sequence.sequence_member.member.entity.EmailAuthTokenEntity; import sequence.sequence_member.member.repository.EmailAuthTokenRepository; -import org.springframework.beans.factory.annotation.Value; - @RequiredArgsConstructor @Service public class EmailAuthService { private final JavaMailSender mailSender; + private final EmailAuthTokenRepository tokenRepo; + private final SpringTemplateEngine templateEngine; @Value("${NAVER_MAIL_USERNAME:dev_mj_@naver.com}") private String fromEmail; - private final EmailAuthTokenRepository tokenRepo; public void requestEmailVerification(String email) { tokenRepo.findAllByEmail(email).forEach(token -> { @@ -31,7 +33,7 @@ public void requestEmailVerification(String email) { tokenRepo.save(token); }); - String token = UUID.randomUUID().toString().substring(0, 6); + String token = UUID.randomUUID().toString().substring(0, 6).toUpperCase(); EmailAuthTokenEntity emailAuthToken = EmailAuthTokenEntity.builder() .email(email) @@ -41,11 +43,9 @@ public void requestEmailVerification(String email) { .build(); tokenRepo.save(emailAuthToken); - sendAuthEmail(email, token); } - // 인증 확인 public void verifyEmailToken(String email, String token) { EmailAuthTokenEntity authToken = tokenRepo.findByEmailAndToken(email, token) .orElseThrow(() -> new IllegalArgumentException("잘못된 인증 정보입니다.")); @@ -61,42 +61,33 @@ public void verifyEmailToken(String email, String token) { throw new IllegalStateException("시간 초과로 인하여 토큰이 만료되었습니다."); } - // 해당 이메일의 모든 토큰 만료 처리 tokenRepo.findAllByEmail(email).forEach(t -> { - t.setVerified(false); // 기존 것들 다 false로 초기화 + t.setVerified(false); tokenRepo.save(t); }); - // 현재 토큰만 인증 처리 authToken.setVerified(true); tokenRepo.save(authToken); } - - - // MimeMessage 방식으로 인증 메일 전송 private void sendAuthEmail(String email, String token) { try { MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, true); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + Context context = new Context(); + context.setVariable("token", token); + + String htmlContent = templateEngine.process("emailAuth", context); helper.setFrom(fromEmail); helper.setTo(email); - helper.setSubject("[Sequence] 이메일 인증 코드"); + helper.setSubject("[Sequence] 이메일 인증 안내"); + helper.setText(htmlContent, true); - String content = "" - + "

이메일 인증 안내

" - + "

아래의 인증 코드를 입력해주세요.

" - + "

인증 코드: " + token + "

" - + ""; - - helper.setText(content, true); mailSender.send(message); - } catch (MessagingException e) { - throw new RuntimeException("이메일 전송 실패", e); + throw new RuntimeException("이메일 발송 중 오류가 발생했습니다.", e); } } - } - diff --git a/sequence_member/src/main/resources/templates/emailAuth.html b/sequence_member/src/main/resources/templates/emailAuth.html new file mode 100644 index 0000000..3403263 --- /dev/null +++ b/sequence_member/src/main/resources/templates/emailAuth.html @@ -0,0 +1,43 @@ + + + + + Sequence 이메일 인증 + + + + + + + +
+ + + + + + + + + + +
+

Sequence

+
+

이메일 인증 안내

+

회원가입을 완료하려면 아래 인증 코드를 입력해주세요.

+ + + + + +
+ A1B2C3 +
+

본인이 요청하지 않은 인증 메일이라면 이 메일을 무시하셔도 좋습니다.

+
+

© 2025 Sequence. All Rights Reserved.

+
+
+ + From 8cac5ba8f881c96f5b4d2afe0ab66645b8d76898 Mon Sep 17 00:00:00 2001 From: Daeyeon Kim <58037317+kim946509@users.noreply.github.com> Date: Mon, 30 Jun 2025 22:16:48 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat=20:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/main-ci-workflow.yml | 2 ++ .github/workflows/main-deploy-workflow.yml | 4 ++++ .github/workflows/test-server-prTest.yml | 3 +++ .github/workflows/test-server-release.yml | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/.github/workflows/main-ci-workflow.yml b/.github/workflows/main-ci-workflow.yml index b0bb7ce..dc49eb5 100644 --- a/.github/workflows/main-ci-workflow.yml +++ b/.github/workflows/main-ci-workflow.yml @@ -52,6 +52,8 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/main-deploy-workflow.yml b/.github/workflows/main-deploy-workflow.yml index c836bbb..7c9a9be 100644 --- a/.github/workflows/main-deploy-workflow.yml +++ b/.github/workflows/main-deploy-workflow.yml @@ -59,6 +59,8 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} steps: - uses: actions/checkout@v4 @@ -178,6 +180,8 @@ jobs: -e GOOGLE_AUTHORIZATION_URI=${{secrets.GOOGLE_AUTHORIZATION_URI}} \ -e GOOGLE_TOKEN_URI=${{secrets.GOOGLE_TOKEN_URI}} \ -e GOOGLE_USER_INFO_URI=${{secrets.GOOGLE_USER_INFO_URI}} \ + -e NAVER_MAIL_USERNAME=${{secrets.NAVER_MAIL_USERNAME}} \ + -e NAVER_MAIL_PASSWORD=${{secrets.NAVER_MAIL_PASSWORD}} \ ${{secrets.DOCKER_USERNAME}}/sequence:latest # monitoring 네트워크 연결 추가 diff --git a/.github/workflows/test-server-prTest.yml b/.github/workflows/test-server-prTest.yml index 8e3fdec..28c10bb 100644 --- a/.github/workflows/test-server-prTest.yml +++ b/.github/workflows/test-server-prTest.yml @@ -52,6 +52,9 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} + steps: - uses: actions/checkout@v4 - name: Set up JDK 17 diff --git a/.github/workflows/test-server-release.yml b/.github/workflows/test-server-release.yml index 7f4664d..1b18a75 100644 --- a/.github/workflows/test-server-release.yml +++ b/.github/workflows/test-server-release.yml @@ -59,6 +59,8 @@ jobs: GOOGLE_AUTHORIZATION_URI: ${{secrets.GOOGLE_AUTHORIZATION_URI}} GOOGLE_TOKEN_URI: ${{secrets.GOOGLE_TOKEN_URI}} GOOGLE_USER_INFO_URI: ${{secrets.GOOGLE_USER_INFO_URI}} + NAVER_MAIL_USERNAME: ${{secrets.NAVER_MAIL_USERNAME}} + NAVER_MAIL_PASSWORD: ${{secrets.NAVER_MAIL_PASSWORD}} steps: - uses: actions/checkout@v4 @@ -178,6 +180,8 @@ jobs: -e GOOGLE_AUTHORIZATION_URI=${{secrets.GOOGLE_AUTHORIZATION_URI}} \ -e GOOGLE_TOKEN_URI=${{secrets.GOOGLE_TOKEN_URI}} \ -e GOOGLE_USER_INFO_URI=${{secrets.GOOGLE_USER_INFO_URI}} \ + -e NAVER_MAIL_USERNAME=${{secrets.NAVER_MAIL_USERNAME}} \ + -e NAVER_MAIL_PASSWORD=${{secrets.NAVER_MAIL_PASSWORD}} \ ${{secrets.DOCKER_USERNAME}}/test-sequence:latest # monitoring 네트워크 연결 추가 docker network connect monitoring test-sequence-spring-container