From 4f1dda42fb1a4c40a6ed91c86c514f9348dda863 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:37:37 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20core:common=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EC=B6=94=EC=83=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/common/CouncilDepartment.java | 15 +++++++++++++++ .../kookmin/stream/common/PrincipalProvider.java | 9 +++++++++ .../java/kr/ac/kookmin/stream/common/Role.java | 6 ++++++ 3 files changed, 30 insertions(+) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java new file mode 100644 index 0000000..2a61785 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CouncilDepartment.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.common; + +/** + * 학생회 부서. ADMIN에게만 부여되며, member 도메인의 학부(Department)와는 다른 개념이다. + */ +public enum CouncilDepartment { + PRESIDENCY, // 회장단 + EXECUTIVE, // 집행부 + GENERAL_AFFAIRS, // 총무부 + PLANNING, // 기획부 + PR, // 홍보부 + MEDIA, // 미디어부 + WELFARE, // 복지부 + COMMUNICATION // 소통부 +} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java new file mode 100644 index 0000000..ae97287 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/PrincipalProvider.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.common; + +import java.util.Set; + +public interface PrincipalProvider { + Long userId(); + Set roles(); + Set councilDepartments(); +} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java new file mode 100644 index 0000000..9ce4af1 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/Role.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.common; + +public enum Role { + STUDENT, + ADMIN +} From a4d0e157355d4066ec300ecd74e4b408329a2984 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:38:15 +0900 Subject: [PATCH 02/13] =?UTF-8?q?chore:=20JWT=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B0=94=EC=9D=B8=EB=94=A9=20=EA=B3=A8=EA=B2=A9=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 --- .env.example | 6 ++++++ .../kr/ac/kookmin/stream/StreamServerApplication.java | 2 ++ bootstrap/src/main/resources/application.yaml | 3 +++ .../ac/kookmin/stream/security/jwt/JwtProperties.java | 10 ++++++++++ .../src/main/resources/application-gateway-auth.yml | 4 ++++ 5 files changed, 25 insertions(+) create mode 100644 .env.example create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java create mode 100644 gateway/auth/src/main/resources/application-gateway-auth.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..357f9af --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +### gateway:auth — JWT ### +# HS256 서명 키. 최소 256비트(32바이트) 이상이어야 한다 +JWT_SECRET_KEY=replace-with-32-byte-or-longer-secret-key +JWT_ISSUER=stream-server +# Access Token 만료 시간 (ms) +JWT_ACCESS_TOKEN_EXPIRY=3600000 diff --git a/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java b/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java index 5702901..7e19e23 100644 --- a/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java +++ b/bootstrap/src/main/java/kr/ac/kookmin/stream/StreamServerApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class StreamServerApplication { public static void main(String[] args) { diff --git a/bootstrap/src/main/resources/application.yaml b/bootstrap/src/main/resources/application.yaml index 49237be..ca0f3a9 100644 --- a/bootstrap/src/main/resources/application.yaml +++ b/bootstrap/src/main/resources/application.yaml @@ -1,3 +1,6 @@ spring: application: name: stream-server + config: + import: + - classpath:application-gateway-auth.yml diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java new file mode 100644 index 0000000..530b678 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProperties.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.security.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties( + String secretKey, + String issuer, + long accessTokenExpiry +) {} diff --git a/gateway/auth/src/main/resources/application-gateway-auth.yml b/gateway/auth/src/main/resources/application-gateway-auth.yml new file mode 100644 index 0000000..f209f03 --- /dev/null +++ b/gateway/auth/src/main/resources/application-gateway-auth.yml @@ -0,0 +1,4 @@ +jwt: + secret-key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:3600000} From 6a4012bc67e4c5ef63d632d3497995276372eb12 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:39:19 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=C2=B7=ED=8C=8C=EC=8B=B1=20JwtProvider=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 --- .../stream/security/jwt/JwtPayload.java | 11 +++ .../stream/security/jwt/JwtProvider.java | 82 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java new file mode 100644 index 0000000..41af91b --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtPayload.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.security.jwt; + +import java.util.Set; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; + +public record JwtPayload( + Long userId, + Set roles, + Set councilDepartments +) {} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java new file mode 100644 index 0000000..794994c --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java @@ -0,0 +1,82 @@ +package kr.ac.kookmin.stream.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + private static final String ROLES_CLAIM = "roles"; + private static final String COUNCIL_CLAIM = "council"; + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + public JwtProvider(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secretKey().getBytes(StandardCharsets.UTF_8)); + } + + public String generateAccessToken(Long userId, Set roles, Set councilDepartments) { + Date issuedAt = new Date(); + Date expiration = new Date(issuedAt.getTime() + jwtProperties.accessTokenExpiry()); + + return Jwts.builder() + .issuer(jwtProperties.issuer()) + .subject(String.valueOf(userId)) + .issuedAt(issuedAt) + .expiration(expiration) + .claim(ROLES_CLAIM, names(roles)) + .claim(COUNCIL_CLAIM, names(councilDepartments)) + .signWith(secretKey) + .compact(); + } + + public JwtPayload parse(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .requireIssuer(jwtProperties.issuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + + return new JwtPayload( + Long.valueOf(claims.getSubject()), + toEnumSet(claims, ROLES_CLAIM, Role.class), + toEnumSet(claims, COUNCIL_CLAIM, CouncilDepartment.class) + ); + } catch (JwtException | IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + } + + private List names(Collection> values) { + return values.stream().map(Enum::name).toList(); + } + + private > Set toEnumSet(Claims claims, String claimName, Class type) { + List values = claims.get(claimName, List.class); + if (values == null) { + return EnumSet.noneOf(type); + } + return values.stream() + .map(value -> Enum.valueOf(type, String.valueOf(value))) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(type))); + } +} From e7f25de814dfd92477e8f3cffd7141016bde8e23 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:40:15 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=99=80=20PrincipalProvider=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/SecurityPrincipalProvider.java | 39 ++++++++++++ .../stream/security/jwt/JwtAuthFilter.java | 56 ++++++++++++++++++ .../security/jwt/UserAuthentication.java | 59 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java new file mode 100644 index 0000000..ae308fb --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/SecurityPrincipalProvider.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.security; + +import java.util.Set; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.PrincipalProvider; +import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.security.jwt.UserAuthentication; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityPrincipalProvider implements PrincipalProvider { + + @Override + public Long userId() { + return currentAuthentication().userId(); + } + + @Override + public Set roles() { + return currentAuthentication().roles(); + } + + @Override + public Set councilDepartments() { + return currentAuthentication().councilDepartments(); + } + + private UserAuthentication currentAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof UserAuthentication userAuthentication)) { + throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + } + return userAuthentication; + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..1e31db4 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import kr.ac.kookmin.stream.common.BusinessException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtProvider jwtProvider; + private final HandlerExceptionResolver handlerExceptionResolver; + + public JwtAuthFilter(JwtProvider jwtProvider, HandlerExceptionResolver handlerExceptionResolver) { + this.jwtProvider = jwtProvider; + this.handlerExceptionResolver = handlerExceptionResolver; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = resolveToken(request); + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + try { + SecurityContextHolder.getContext().setAuthentication(new UserAuthentication(jwtProvider.parse(token))); + } catch (BusinessException e) { + SecurityContextHolder.clearContext(); + handlerExceptionResolver.resolveException(request, response, null, e); + return; + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader(AUTHORIZATION_HEADER); + if (header == null || !header.startsWith(BEARER_PREFIX)) { + return null; + } + return header.substring(BEARER_PREFIX.length()); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java new file mode 100644 index 0000000..cd8fbf2 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.security.jwt; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import kr.ac.kookmin.stream.common.CouncilDepartment; +import kr.ac.kookmin.stream.common.Role; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +public class UserAuthentication extends AbstractAuthenticationToken { + + private static final String COUNCIL_AUTHORITY_PREFIX = "COUNCIL_"; + + private final Long userId; + private final Set roles; + private final Set councilDepartments; + + public UserAuthentication(JwtPayload payload) { + super(toAuthorities(payload)); + this.userId = payload.userId(); + this.roles = payload.roles(); + this.councilDepartments = payload.councilDepartments(); + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return userId; + } + + @Override + public Object getCredentials() { + return null; + } + + public Long userId() { + return userId; + } + + public Set roles() { + return roles; + } + + public Set councilDepartments() { + return councilDepartments; + } + + private static Collection toAuthorities(JwtPayload payload) { + return Stream.concat( + payload.roles().stream().map(Role::name), + payload.councilDepartments().stream().map(department -> COUNCIL_AUTHORITY_PREFIX + department.name()) + ) + .map(SimpleGrantedAuthority::new) + .toList(); + } +} From 4c2449d014302e74dcbc676e25e44c3e1be9f846 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:41:54 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20SecurityConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/application-gateway-auth.yml | 6 ++ .../security/config/SecurityConfig.java | 68 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 bootstrap/src/test/resources/application-gateway-auth.yml create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java diff --git a/bootstrap/src/test/resources/application-gateway-auth.yml b/bootstrap/src/test/resources/application-gateway-auth.yml new file mode 100644 index 0000000..26ee8b5 --- /dev/null +++ b/bootstrap/src/test/resources/application-gateway-auth.yml @@ -0,0 +1,6 @@ +# 테스트 클래스패스가 gateway:auth의 동명 파일을 가린다. +# 실제 값은 환경변수로 주입되므로 테스트에서는 더미 값을 쓴다. +jwt: + secret-key: test-secret-key-must-be-at-least-32-bytes-long + issuer: stream-server + access-token-expiry: 3600000 diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java new file mode 100644 index 0000000..3dcd478 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package kr.ac.kookmin.stream.security.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.security.jwt.JwtAuthFilter; +import kr.ac.kookmin.stream.security.jwt.JwtProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private static final String[] PERMIT_ALL_PATHS = { + "/actuator/health", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**" + }; + + private final JwtProvider jwtProvider; + private final HandlerExceptionResolver handlerExceptionResolver; + + public SecurityConfig( + JwtProvider jwtProvider, + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver + ) { + this.jwtProvider = jwtProvider; + this.handlerExceptionResolver = handlerExceptionResolver; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .requestMatchers(PERMIT_ALL_PATHS).permitAll() + .requestMatchers("/v1/admin/**").hasAuthority(Role.ADMIN.name()) + .requestMatchers("/v1/app/**").hasAuthority(Role.STUDENT.name()) + .anyRequest().authenticated()) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((req, res, e) -> delegate(req, res, CommonErrorCode.UNAUTHORIZED)) + .accessDeniedHandler((req, res, e) -> delegate(req, res, CommonErrorCode.FORBIDDEN))) + .addFilterBefore( + new JwtAuthFilter(jwtProvider, handlerExceptionResolver), + UsernamePasswordAuthenticationFilter.class) + .build(); + } + + private void delegate(HttpServletRequest request, HttpServletResponse response, ErrorCode errorCode) { + handlerExceptionResolver.resolveException(request, response, null, new BusinessException(errorCode)); + } +} From 70a05401fe236dcb321808d533a6d5b079c0f673 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:47:16 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EC=97=90=20Lombok=C2=B7=EC=A0=95?= =?UTF-8?q?=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfig.java | 2 +- .../stream/security/jwt/JwtAuthFilter.java | 10 ++++---- .../security/jwt/UserAuthentication.java | 23 ++++++++----------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java index 3dcd478..201d08a 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -57,7 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint((req, res, e) -> delegate(req, res, CommonErrorCode.UNAUTHORIZED)) .accessDeniedHandler((req, res, e) -> delegate(req, res, CommonErrorCode.FORBIDDEN))) .addFilterBefore( - new JwtAuthFilter(jwtProvider, handlerExceptionResolver), + JwtAuthFilter.of(jwtProvider, handlerExceptionResolver), UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java index 1e31db4..b5ce670 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java @@ -6,10 +6,13 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import kr.ac.kookmin.stream.common.BusinessException; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.servlet.HandlerExceptionResolver; +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class JwtAuthFilter extends OncePerRequestFilter { private static final String AUTHORIZATION_HEADER = "Authorization"; @@ -18,9 +21,8 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; private final HandlerExceptionResolver handlerExceptionResolver; - public JwtAuthFilter(JwtProvider jwtProvider, HandlerExceptionResolver handlerExceptionResolver) { - this.jwtProvider = jwtProvider; - this.handlerExceptionResolver = handlerExceptionResolver; + public static JwtAuthFilter of(JwtProvider jwtProvider, HandlerExceptionResolver handlerExceptionResolver) { + return new JwtAuthFilter(jwtProvider, handlerExceptionResolver); } @Override @@ -36,7 +38,7 @@ protected void doFilterInternal( } try { - SecurityContextHolder.getContext().setAuthentication(new UserAuthentication(jwtProvider.parse(token))); + SecurityContextHolder.getContext().setAuthentication(UserAuthentication.from(jwtProvider.parse(token))); } catch (BusinessException e) { SecurityContextHolder.clearContext(); handlerExceptionResolver.resolveException(request, response, null, e); diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java index cd8fbf2..5c631ff 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/UserAuthentication.java @@ -1,15 +1,18 @@ package kr.ac.kookmin.stream.security.jwt; import java.util.Collection; -import java.util.List; import java.util.Set; import java.util.stream.Stream; import kr.ac.kookmin.stream.common.CouncilDepartment; import kr.ac.kookmin.stream.common.Role; +import lombok.Getter; +import lombok.experimental.Accessors; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; +@Getter +@Accessors(fluent = true) public class UserAuthentication extends AbstractAuthenticationToken { private static final String COUNCIL_AUTHORITY_PREFIX = "COUNCIL_"; @@ -18,7 +21,7 @@ public class UserAuthentication extends AbstractAuthenticationToken { private final Set roles; private final Set councilDepartments; - public UserAuthentication(JwtPayload payload) { + private UserAuthentication(JwtPayload payload) { super(toAuthorities(payload)); this.userId = payload.userId(); this.roles = payload.roles(); @@ -26,6 +29,10 @@ public UserAuthentication(JwtPayload payload) { setAuthenticated(true); } + public static UserAuthentication from(JwtPayload payload) { + return new UserAuthentication(payload); + } + @Override public Object getPrincipal() { return userId; @@ -36,18 +43,6 @@ public Object getCredentials() { return null; } - public Long userId() { - return userId; - } - - public Set roles() { - return roles; - } - - public Set councilDepartments() { - return councilDepartments; - } - private static Collection toAuthorities(JwtPayload payload) { return Stream.concat( payload.roles().stream().map(Role::name), From 0b0f836958237ab5cde8b0992327bf47db430c40 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:48:27 +0900 Subject: [PATCH 07/13] =?UTF-8?q?docs:=20=EC=A0=95=EC=A0=81=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=C2=B7Lombok=20=EC=82=AC=EC=9A=A9=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/00-index.md | 3 +- docs/conventions/coding-style.md | 102 +++++++++++++++++++--------- docs/conventions/config-and-auth.md | 11 +-- 3 files changed, 74 insertions(+), 42 deletions(-) diff --git a/docs/conventions/00-index.md b/docs/conventions/00-index.md index 8f48814..b8bcedd 100644 --- a/docs/conventions/00-index.md +++ b/docs/conventions/00-index.md @@ -12,7 +12,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 | 문서 | 다루는 내용 | 언제 참조하는가 | | --- | --- | --- | | [`architecture.md`](./architecture.md) | 모듈 구조, 의존 방향, Modulith 경계 규칙, 레이어, 도메인 간 통신(UseCase·이벤트·아웃박스) | 새 모듈/도메인 설계, 의존성 리뷰 | -| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, Validation | 실제 코드 작성/리뷰 | +| [`coding-style.md`](./coding-style.md) | 네이밍, 도메인 객체(record)/DTO/Command/Entity/Repository/Service/UseCase 패턴, 정적 팩토리·Lombok, Validation | 실제 코드 작성/리뷰 | | [`error-handling.md`](./error-handling.md) | `ErrorCode`/`BusinessException`, `GlobalExceptionHandler`, `@ApiErrorCode` Swagger 문서화 | 에러 코드 추가, 예외 처리 | | [`config-and-auth.md`](./config-and-auth.md) | 설정 바인딩, 2계층 권한 모델(role + 부서), `PrincipalProvider`, `DepartmentAccessChecker` | 설정값 추가, 인증·인가 작업 | | [`logging.md`](./logging.md) | MDC 요청 추적, `MdcFilter`/`LoggingFilter`, 로그 레벨, JSON 로깅 | 로깅 코드, MDC 필드 추가 | @@ -28,6 +28,7 @@ Java 21 + Spring Boot 4.1 + Spring Modulith 기반, 단일 학생회 플랫폼 - "A 도메인 변화에 B가 반응" → `architecture.md` 6-2절 (이벤트 + 아웃박스) - "부서 권한으로 승인 제한" → `config-and-auth.md` 4-4절 (`DepartmentAccessChecker`) - "soft delete 컬럼 인덱스/유니크" → `flyway-migration.md` 3-4절 +- "객체를 어떻게 생성하지 / Lombok 어디까지" → `coding-style.md` 2-10·2-11절 - "에러 코드 추가" → `error-handling.md` - "도메인 내부 구현 숨기기" → `architecture.md` 4-3절 (최상위 공개 / `internal`) diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 10e9750..3cfde61 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -124,7 +124,7 @@ public record PageResult(List content, int page, int size, long totalCount ### 2-5. JPA Entity -- `infrastructure:db`에 선언한다. 도메인 객체를 받는 생성자와 `toDomain()`을 제공하고, 용도에 맞는 Base Entity를 상속한다. JPA용 `protected` 기본 생성자를 둔다. +- `infrastructure:db`에 선언한다. 도메인 객체를 받는 정적 팩토리 `from(...)`과 `toDomain()`을 제공하고, 용도에 맞는 Base Entity를 상속한다. JPA용 기본 생성자는 `@NoArgsConstructor(access = AccessLevel.PROTECTED)`로 둔다(2-10·2-11절). **Base Entity 선택 기준** (`infrastructure:db` 공통 패키지) @@ -143,6 +143,7 @@ public record PageResult(List content, int page, int size, long totalCount // infrastructure:db @Entity @Table(name = "members") +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA public class MemberJpaEntity extends BaseSoftDeleteEntity { @Id @@ -152,14 +153,16 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity { private String studentNo; private String name; - protected MemberJpaEntity() {} // JPA - - public MemberJpaEntity(Member member) { + private MemberJpaEntity(Member member) { this.id = member.id(); this.studentNo = member.studentNo(); this.name = member.name(); } + public static MemberJpaEntity from(Member member) { + return new MemberJpaEntity(member); + } + public Member toDomain() { return new Member(id, studentNo, name); } @@ -198,14 +201,11 @@ public interface MemberJpaRepository extends JpaRepository findById(Long id) { return memberJpaRepository.findById(id).map(MemberJpaEntity::toDomain); @@ -218,7 +218,7 @@ public class MemberRepositoryImpl implements MemberRepository { @Override public Member save(Member member) { - return memberJpaRepository.save(new MemberJpaEntity(member)).toDomain(); + return memberJpaRepository.save(MemberJpaEntity.from(member)).toDomain(); } } ``` @@ -241,14 +241,11 @@ public interface MemberService { ```java // core:domain:member/internal (감춰짐) @Service +@RequiredArgsConstructor class MemberServiceImpl implements MemberService { private final MemberRepository memberRepository; - MemberServiceImpl(MemberRepository memberRepository) { - this.memberRepository = memberRepository; - } - @Override @Transactional public Member register(MemberRegisterCommand command) { @@ -278,14 +275,11 @@ class MemberServiceImpl implements MemberService { // api:admin-api — 운영진 회원 등록 @RestController @RequestMapping("/v1/admin/members") +@RequiredArgsConstructor public class AdminMemberController { private final MemberService memberService; - public AdminMemberController(MemberService memberService) { - this.memberService = memberService; - } - @PostMapping public ApiResponse register(@Valid @RequestBody MemberRegisterRequest request) { Member member = memberService.register(request.toCommand()); @@ -298,14 +292,11 @@ public class AdminMemberController { // api:app-api — 학생 내 정보 조회 @RestController @RequestMapping("/v1/app/members") +@RequiredArgsConstructor public class AppMemberController { private final MemberService memberService; - public AppMemberController(MemberService memberService) { - this.memberService = memberService; - } - @GetMapping("/me") public ApiResponse me(StudentApiUser apiUser) { Member member = memberService.getById(apiUser.userId()); @@ -322,18 +313,13 @@ public class AppMemberController { ```java // api:admin-api — 조회 조합 (운영진 대시보드) @Component +@RequiredArgsConstructor public class AdminDashboardUseCase { private final MemberService memberService; private final EventService eventService; private final NoticeService noticeService; - public AdminDashboardUseCase(MemberService memberService, EventService eventService, NoticeService noticeService) { - this.memberService = memberService; - this.eventService = eventService; - this.noticeService = noticeService; - } - @Transactional(readOnly = true) public AdminDashboardResponse getDashboard() { long memberCount = memberService.countActive(); @@ -347,16 +333,12 @@ public class AdminDashboardUseCase { ```java // api:app-api — 원자적 쓰기 오케스트레이션 (유료 행사 신청) @Component +@RequiredArgsConstructor public class EventApplicationUseCase { private final EventService eventService; private final FeeService feeService; - public EventApplicationUseCase(EventService eventService, FeeService feeService) { - this.eventService = eventService; - this.feeService = feeService; - } - @Transactional // 정원 차감 + 회비 반영을 원자적으로 public EventApplicationResponse apply(Long memberId, EventApplyCommand command) { Event event = eventService.apply(memberId, command); // 정원 차감·신청 등록 @@ -366,6 +348,62 @@ public class EventApplicationUseCase { } ``` +### 2-10. 객체 생성 — 정적 팩토리 메서드 + +객체는 `new`로 직접 만들지 않고 정적 팩토리 메서드로 생성한다. 생성자는 `private`(JPA처럼 프레임워크가 요구하면 `protected`)으로 감춘다. + +| 이름 | 쓰임 | 예 | +| --- | --- | --- | +| `from` | 다른 타입 하나를 받아 변환 | `MemberResponse.from(member)`, `MemberJpaEntity.from(member)` | +| `of` | 값 여러 개를 받아 조합 | `JwtAuthFilter.of(jwtProvider, handlerExceptionResolver)` | +| `create` | 새로 만든다는 의미를 드러낼 때 | `Member.create(studentNo, name)` | + +```java +// gateway:auth +@Getter +@Accessors(fluent = true) +public class UserAuthentication extends AbstractAuthenticationToken { + + private final Long userId; + + private UserAuthentication(JwtPayload payload) { + super(toAuthorities(payload)); + this.userId = payload.userId(); + setAuthenticated(true); + } + + public static UserAuthentication from(JwtPayload payload) { + return new UserAuthentication(payload); + } +} +``` + +- 이름이 생성 의도를 드러내므로, 인자 목록만으로는 구분되지 않는 여러 생성 경로를 표현할 수 있다. +- 생성자를 감추면 호출부가 `new`로 우회할 수 없어 생성 경로가 하나로 모인다. +- **예외** — 아래는 `new`를 그대로 쓴다. + - `record`(도메인 객체·Command·Request/Response): 표준 생성자를 쓴다. 단 타입 변환이 끼면 `from(...)`/`toCommand()`를 둔다(2-2·2-3절). + - 예외 클래스: `throw new BusinessException(...)`. + - 스프링이 생성·주입하는 빈: 애초에 직접 생성하지 않는다(2-11절). + +### 2-11. Lombok + +루트 `build.gradle.kts`에서 전 모듈에 적용된다. 모듈별 `build.gradle.kts`에 다시 선언하지 않는다. + +| 어노테이션 | 용도 | +| --- | --- | +| `@Getter` | 필드 접근자. 클래스 단위로 붙인다 | +| `@Accessors(fluent = true)` | `getXxx()` 대신 `xxx()` 접근자. `record`와 표기를 맞출 때 (`CommonErrorCode`, `UserAuthentication`) | +| `@RequiredArgsConstructor` | 스프링 빈(`@Service`/`@Repository`/`@Component`/`@RestController`)의 생성자 주입 | +| `@RequiredArgsConstructor(access = AccessLevel.PRIVATE)` | 정적 팩토리(2-10절)와 짝지어 생성자를 감출 때 | +| `@NoArgsConstructor(access = AccessLevel.PROTECTED)` | JPA Entity의 기본 생성자 | +| `@AllArgsConstructor` | 필드를 갖는 enum (`{Domain}ErrorCode`) | + +- `@Data`·`@Setter`는 쓰지 않는다. 객체는 불변을 기본으로 하고, 상태 변경은 의도가 드러나는 메서드(`entity.delete()` 등)로 표현한다. +- `record`에는 Lombok을 붙이지 않는다. 접근자·`equals`/`hashCode`가 이미 제공된다. +- **다음 두 경우는 Lombok 대신 생성자를 직접 쓴다.** + - 주입받은 값으로 다른 필드를 초기화해야 할 때 — 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider` + - 생성자 파라미터에 `@Qualifier` 같은 어노테이션이 필요할 때 — Lombok은 기본 설정에서 이를 복사하지 않는다 + --- ## 3. Validation diff --git a/docs/conventions/config-and-auth.md b/docs/conventions/config-and-auth.md index 8162799..d061a83 100644 --- a/docs/conventions/config-and-auth.md +++ b/docs/conventions/config-and-auth.md @@ -133,14 +133,11 @@ Controller / UseCase — 부서 단위 인가는 DepartmentAccessChec ```java // gateway:auth — DepartmentAccessChecker.java @Component +@RequiredArgsConstructor public class DepartmentAccessChecker { private final PrincipalProvider principalProvider; - public DepartmentAccessChecker(PrincipalProvider principalProvider) { - this.principalProvider = principalProvider; - } - // 요구 부서 권한이 없으면 403 public void requireDepartment(Department required) { if (!principalProvider.departments().contains(required)) { @@ -153,16 +150,12 @@ public class DepartmentAccessChecker { ```java // api:admin-api — 사물함 승인은 복지부만 @Component +@RequiredArgsConstructor public class LockerApprovalUseCase { private final DepartmentAccessChecker departmentAccessChecker; private final LockerService lockerService; - public LockerApprovalUseCase(DepartmentAccessChecker departmentAccessChecker, LockerService lockerService) { - this.departmentAccessChecker = departmentAccessChecker; - this.lockerService = lockerService; - } - public void approve(Long userId, Long applicationId) { departmentAccessChecker.requireDepartment(Department.WELFARE); lockerService.approve(applicationId); From 691d9442c085830ee2681a3c4bb90b614f976e73 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:58:32 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor:=20MemberJpaEntity=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/db/member/MemberJpaEntity.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java index 5d1f7cb..b7d09d0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java @@ -22,12 +22,16 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity { private String studentNo; private String name; - public MemberJpaEntity(Member member) { + private MemberJpaEntity(Member member) { this.id = member.id(); this.studentNo = member.studentNo(); this.name = member.name(); } + public static MemberJpaEntity from(Member member) { + return new MemberJpaEntity(member); + } + public Member toDomain() { return new Member(id, studentNo, name); } From c355cc72eae2cf0023b24f1604205dc55103a21b Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:06:46 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20SecurityConfig=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EC=A3=BC=EC=9E=85=EC=9D=84=20@RequiredArg?= =?UTF-8?q?sConstructor=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 26 ++++++++++++++++--- .../security/config/SecurityConfig.java | 12 +++------ lombok.config | 5 ++++ 3 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 lombok.config diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index 3cfde61..dfe98d3 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -387,7 +387,7 @@ public class UserAuthentication extends AbstractAuthenticationToken { ### 2-11. Lombok -루트 `build.gradle.kts`에서 전 모듈에 적용된다. 모듈별 `build.gradle.kts`에 다시 선언하지 않는다. +루트 `build.gradle.kts`에서 전 모듈에 적용된다. 모듈별 `build.gradle.kts`에 다시 선언하지 않는다. 동작 설정은 루트 `lombok.config`에 둔다. | 어노테이션 | 용도 | | --- | --- | @@ -400,9 +400,27 @@ public class UserAuthentication extends AbstractAuthenticationToken { - `@Data`·`@Setter`는 쓰지 않는다. 객체는 불변을 기본으로 하고, 상태 변경은 의도가 드러나는 메서드(`entity.delete()` 등)로 표현한다. - `record`에는 Lombok을 붙이지 않는다. 접근자·`equals`/`hashCode`가 이미 제공된다. -- **다음 두 경우는 Lombok 대신 생성자를 직접 쓴다.** - - 주입받은 값으로 다른 필드를 초기화해야 할 때 — 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider` - - 생성자 파라미터에 `@Qualifier` 같은 어노테이션이 필요할 때 — Lombok은 기본 설정에서 이를 복사하지 않는다 +- 스프링 빈의 생성자 주입은 **항상 `@RequiredArgsConstructor`**로 한다. 생성자를 직접 쓰는 경우는 하나뿐이다 — 주입받은 값으로 다른 필드를 초기화해야 할 때. 예: `JwtProperties`로 `SecretKey`를 만드는 `JwtProvider`. +- 주입할 빈을 지목해야 하면 **필드에** `@Qualifier`를 붙인다. 루트 `lombok.config`의 `lombok.copyableAnnotations`가 이를 생성자 파라미터로 복사한다. + +```java +// gateway:auth — HandlerExceptionResolver 빈이 여럿이라 이름으로 지목 +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtProvider jwtProvider; + + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; +} +``` + +```properties +# lombok.config (루트) +config.stopBubbling = true +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier +``` --- diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java index 201d08a..c16afa8 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -8,6 +8,7 @@ import kr.ac.kookmin.stream.common.Role; import kr.ac.kookmin.stream.security.jwt.JwtAuthFilter; import kr.ac.kookmin.stream.security.jwt.JwtProvider; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,6 +22,7 @@ @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { private static final String[] PERMIT_ALL_PATHS = { @@ -31,15 +33,9 @@ public class SecurityConfig { }; private final JwtProvider jwtProvider; - private final HandlerExceptionResolver handlerExceptionResolver; - public SecurityConfig( - JwtProvider jwtProvider, - @Qualifier("handlerExceptionResolver") HandlerExceptionResolver handlerExceptionResolver - ) { - this.jwtProvider = jwtProvider; - this.handlerExceptionResolver = handlerExceptionResolver; - } + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..4fc7dd1 --- /dev/null +++ b/lombok.config @@ -0,0 +1,5 @@ +# 프로젝트 루트 기준으로 설정 탐색을 멈춘다 +config.stopBubbling = true + +# @RequiredArgsConstructor가 생성한 생성자 파라미터로 복사할 어노테이션 +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier From a42acd4e3d73badd353ff629db5556ebf2f5d0c8 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:13:33 +0900 Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=9D=91=EB=8B=B5=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20EntryPoint=C2=B7AccessDeniedHandler=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfig.java | 17 ++++------ .../handler/RestAccessDeniedHandler.java | 33 +++++++++++++++++++ .../handler/RestAuthenticationEntryPoint.java | 33 +++++++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java index c16afa8..c9a41cd 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -1,11 +1,8 @@ package kr.ac.kookmin.stream.security.config; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.common.CommonErrorCode; -import kr.ac.kookmin.stream.common.ErrorCode; import kr.ac.kookmin.stream.common.Role; +import kr.ac.kookmin.stream.security.handler.RestAccessDeniedHandler; +import kr.ac.kookmin.stream.security.handler.RestAuthenticationEntryPoint; import kr.ac.kookmin.stream.security.jwt.JwtAuthFilter; import kr.ac.kookmin.stream.security.jwt.JwtProvider; import lombok.RequiredArgsConstructor; @@ -33,6 +30,8 @@ public class SecurityConfig { }; private final JwtProvider jwtProvider; + private final RestAuthenticationEntryPoint authenticationEntryPoint; + private final RestAccessDeniedHandler accessDeniedHandler; @Qualifier("handlerExceptionResolver") private final HandlerExceptionResolver handlerExceptionResolver; @@ -50,15 +49,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/v1/app/**").hasAuthority(Role.STUDENT.name()) .anyRequest().authenticated()) .exceptionHandling(exception -> exception - .authenticationEntryPoint((req, res, e) -> delegate(req, res, CommonErrorCode.UNAUTHORIZED)) - .accessDeniedHandler((req, res, e) -> delegate(req, res, CommonErrorCode.FORBIDDEN))) + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) .addFilterBefore( JwtAuthFilter.of(jwtProvider, handlerExceptionResolver), UsernamePasswordAuthenticationFilter.class) .build(); } - - private void delegate(HttpServletRequest request, HttpServletResponse response, ErrorCode errorCode) { - handlerExceptionResolver.resolveException(request, response, null, new BusinessException(errorCode)); - } } diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java new file mode 100644 index 0000000..baa1876 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAccessDeniedHandler.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * 인증은 됐으나 권한이 없는 요청(403)을 공통 에러 응답으로 내보낸다. + */ +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) { + handlerExceptionResolver.resolveException( + request, response, null, new BusinessException(CommonErrorCode.FORBIDDEN)); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..e5181b5 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/handler/RestAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +/** + * 인증되지 않은 요청(401). 로그인 페이지로 리다이렉트하는 기본 동작 대신 공통 에러 응답으로 내보낸다. + */ +@Component +@RequiredArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Qualifier("handlerExceptionResolver") + private final HandlerExceptionResolver handlerExceptionResolver; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) { + handlerExceptionResolver.resolveException( + request, response, null, new BusinessException(CommonErrorCode.UNAUTHORIZED)); + } +} From a1f1de95ffb4610188232fad7bbecff71529cc62 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:14:26 +0900 Subject: [PATCH 11/13] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20=EA=B2=BD=EB=A1=9C=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 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index c2065bc..4364c23 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +.DS_Store + +docs/plans \ No newline at end of file From 4e4ca7815c2d8c5840153ed34d8d59b3c6383406 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:18:08 +0900 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20JwtAuthFilter=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20SecurityConfig=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 6 ++---- .../security/config/SecurityConfig.java | 12 +++--------- .../security/jwt/InvalidTokenException.java | 14 ++++++++++++++ .../stream/security/jwt/JwtAuthFilter.java | 19 +++---------------- .../stream/security/jwt/JwtProvider.java | 4 +--- 5 files changed, 23 insertions(+), 32 deletions(-) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index dfe98d3..fe8d892 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -405,11 +405,9 @@ public class UserAuthentication extends AbstractAuthenticationToken { ```java // gateway:auth — HandlerExceptionResolver 빈이 여럿이라 이름으로 지목 -@Configuration +@Component @RequiredArgsConstructor -public class SecurityConfig { - - private final JwtProvider jwtProvider; +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Qualifier("handlerExceptionResolver") private final HandlerExceptionResolver handlerExceptionResolver; diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java index c9a41cd..fa893ed 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -6,7 +6,6 @@ import kr.ac.kookmin.stream.security.jwt.JwtAuthFilter; import kr.ac.kookmin.stream.security.jwt.JwtProvider; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -14,8 +13,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.security.web.access.intercept.AuthorizationFilter; @Configuration @EnableWebSecurity @@ -33,9 +31,6 @@ public class SecurityConfig { private final RestAuthenticationEntryPoint authenticationEntryPoint; private final RestAccessDeniedHandler accessDeniedHandler; - @Qualifier("handlerExceptionResolver") - private final HandlerExceptionResolver handlerExceptionResolver; - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http @@ -51,9 +46,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .exceptionHandling(exception -> exception .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler)) - .addFilterBefore( - JwtAuthFilter.of(jwtProvider, handlerExceptionResolver), - UsernamePasswordAuthenticationFilter.class) + // ExceptionTranslationFilter 뒤에 두어야 필터가 던진 인증 예외가 EntryPoint로 넘어간다 + .addFilterBefore(JwtAuthFilter.of(jwtProvider), AuthorizationFilter.class) .build(); } } diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java new file mode 100644 index 0000000..ec4ab65 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/InvalidTokenException.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.security.jwt; + +import org.springframework.security.core.AuthenticationException; + +/** + * 토큰이 만료됐거나 서명·형식이 올바르지 않을 때. ExceptionTranslationFilter가 잡아 + * SecurityConfig에 설정된 AuthenticationEntryPoint로 넘긴다. + */ +public class InvalidTokenException extends AuthenticationException { + + public InvalidTokenException(Throwable cause) { + super("유효하지 않은 토큰입니다.", cause); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java index b5ce670..8bf8a8b 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtAuthFilter.java @@ -5,12 +5,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.servlet.HandlerExceptionResolver; @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class JwtAuthFilter extends OncePerRequestFilter { @@ -19,10 +17,9 @@ public class JwtAuthFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; private final JwtProvider jwtProvider; - private final HandlerExceptionResolver handlerExceptionResolver; - public static JwtAuthFilter of(JwtProvider jwtProvider, HandlerExceptionResolver handlerExceptionResolver) { - return new JwtAuthFilter(jwtProvider, handlerExceptionResolver); + public static JwtAuthFilter of(JwtProvider jwtProvider) { + return new JwtAuthFilter(jwtProvider); } @Override @@ -32,19 +29,9 @@ protected void doFilterInternal( FilterChain filterChain ) throws ServletException, IOException { String token = resolveToken(request); - if (token == null) { - filterChain.doFilter(request, response); - return; - } - - try { + if (token != null) { SecurityContextHolder.getContext().setAuthentication(UserAuthentication.from(jwtProvider.parse(token))); - } catch (BusinessException e) { - SecurityContextHolder.clearContext(); - handlerExceptionResolver.resolveException(request, response, null, e); - return; } - filterChain.doFilter(request, response); } diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java index 794994c..a30e0cb 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/jwt/JwtProvider.java @@ -12,8 +12,6 @@ import java.util.Set; import java.util.stream.Collectors; import javax.crypto.SecretKey; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.common.CouncilDepartment; import kr.ac.kookmin.stream.common.Role; import org.springframework.stereotype.Component; @@ -62,7 +60,7 @@ public JwtPayload parse(String token) { toEnumSet(claims, COUNCIL_CLAIM, CouncilDepartment.class) ); } catch (JwtException | IllegalArgumentException e) { - throw new BusinessException(CommonErrorCode.UNAUTHORIZED); + throw new InvalidTokenException(e); } } From fb7d351851963a5a57ce45ab31cf451a97aee747 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:20:32 +0900 Subject: [PATCH 13/13] =?UTF-8?q?refactor:=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20Public?= =?UTF-8?q?Endpoints=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/PublicEndpoints.java | 48 +++++++++++++++++++ .../security/config/SecurityConfig.java | 9 +--- 2 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java new file mode 100644 index 0000000..f634429 --- /dev/null +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/PublicEndpoints.java @@ -0,0 +1,48 @@ +package kr.ac.kookmin.stream.security.config; + +import java.util.Arrays; +import java.util.List; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * 인증 없이 여는 엔드포인트. SecurityConfig의 permitAll 대상이며 용도별로 묶어 관리한다. + */ +@Getter +@Accessors(fluent = true) +public enum PublicEndpoints { + + HEALTH_CHECK(List.of( + "/actuator/health" + )), + SWAGGER(List.of( + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**" + )); + + private final List patterns; + private final List pathPatterns; + + PublicEndpoints(List patterns) { + PathPatternParser parser = new PathPatternParser(); + this.patterns = patterns; + this.pathPatterns = patterns.stream().map(parser::parse).toList(); + } + + public static String[] allPatterns() { + return Arrays.stream(values()) + .flatMap(endpoints -> endpoints.patterns.stream()) + .toArray(String[]::new); + } + + public static boolean isPublic(String path) { + PathContainer pathContainer = PathContainer.parsePath(path); + return Arrays.stream(values()) + .flatMap(endpoints -> endpoints.pathPatterns.stream()) + .anyMatch(pathPattern -> pathPattern.matches(pathContainer)); + } +} diff --git a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java index fa893ed..e3fa55b 100644 --- a/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java +++ b/gateway/auth/src/main/java/kr/ac/kookmin/stream/security/config/SecurityConfig.java @@ -20,13 +20,6 @@ @RequiredArgsConstructor public class SecurityConfig { - private static final String[] PERMIT_ALL_PATHS = { - "/actuator/health", - "/swagger-ui/**", - "/swagger-ui.html", - "/v3/api-docs/**" - }; - private final JwtProvider jwtProvider; private final RestAuthenticationEntryPoint authenticationEntryPoint; private final RestAccessDeniedHandler accessDeniedHandler; @@ -39,7 +32,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(request -> request - .requestMatchers(PERMIT_ALL_PATHS).permitAll() + .requestMatchers(PublicEndpoints.allPatterns()).permitAll() .requestMatchers("/v1/admin/**").hasAuthority(Role.ADMIN.name()) .requestMatchers("/v1/app/**").hasAuthority(Role.STUDENT.name()) .anyRequest().authenticated())