-
Notifications
You must be signed in to change notification settings - Fork 1
소셜 로그인 설계(Spring Security 없이 OAuth2.0)
나경호(Na Gyeongho) edited this page May 27, 2025
·
1 revision
-
전체 흐름
-
1단계: 인가 코드 요청
클라이언트가 소셜 로그인 버튼 클릭 시, 백엔드에서 각 소셜에 맞는 인가 코드 요청 URL을 제공합니다.
(예:
/OAuth/{OAuthType}
→ 리다이렉트 (ex:/OAuth/GOOGLE
)). -
2단계: 인가 코드 수신 및 로그인 요청
프론트는 소셜 서버에서 전달된 인가 코드를 백엔드 로그인 API(
/OAuth/login/{OAuthType}?code=...
)로 전달합니다. -
3단계: Access Token 및 사용자 정보 조회
각 소셜별
UserClient
가 인가 코드를 받아 WebClient를 통해 토큰을 발급받고 사용자 정보를 조회합니다. - 4단계: 사용자 등록/조회 및 인증 조회된 사용자 정보를 기준으로 DB에서 중복(OAuthId 기준) 여부를 확인한 후 신규 등록하거나 기존 사용자를 반환합니다.
-
5단계: JWT 발급 및 응답
인증에 성공하면, 사용자 정보를 기반으로 Access Token과 Refresh Token을 생성합니다.
생성된 토큰은 HTTP 응답 헤더에 각각
Authorization: Bearer <access token> <refresh token>
으로 설정하여 클라이언트에 전달합니다. -
6단계: JWT 검증 및 핸들링
클라이언트의 후속 요청에는 JWT가 포함되며, 별도의 커스텀 필터(예:
JwtAuthenticationFilter
또는 HandlerInterceptor)를 통해 JWT의 유효성 및 만료를 검증합니다.- 로그인 성공 핸들러: JWT 생성 후, 성공 응답(예: 사용자 ID, 토큰 정보)을 반환하고 추가 로깅 및 후처리를 수행합니다.
- 로그인 실패 핸들러: 토큰 발급 실패나 JWT 검증 실패 시 적절한 오류 응답(HTTP 401 등)을 반환하며, 실패 로그를 기록합니다.
-
1단계: 인가 코드 요청
클라이언트가 소셜 로그인 버튼 클릭 시, 백엔드에서 각 소셜에 맞는 인가 코드 요청 URL을 제공합니다.
(예:
-
확장성 고려
-
인터페이스 분리
-
AuthCodeRequestUrlProvider
: 소셜별 인가 코드 요청 URL 생성 -
UserClient
: 인가 코드를 통해 토큰 및 사용자 정보를 조회
-
-
Composite 패턴 적용
- 각 인터페이스 구현체를 모아 관리하는 Composite 클래스로 소셜 공급자 추가 시 변경을 최소화
- Composite 패턴 참고
-
WebClient 활용
- HttpServiceProxyFactory를 사용해 간결한 HTTP 인터페이스를 구현
-
JWT 관련 추가 구현
- Spring Security 없이 JWT 검증 및 핸들링을 위해 별도의 필터(예:
JwtAuthenticationFilter
또는 HandlerInterceptor)를 구현하여 보호 대상 API에서 JWT 유효성을 확인합니다. - 로그인 성공 시 JwtTokenProvider를 통해 Access/Refresh Token을 생성한 후, 성공 핸들러에서 HTTP Header로 클라이언트에 전달하며, 실패 시 오류 핸들러에서 적절한 응답을 반환합니다.
- Spring Security 없이 JWT 검증 및 핸들링을 위해 별도의 필터(예:
-
Refresh Token 관리
- 실제 운영에서는 Refresh Token을 DB에 저장하여 관리하도록 구성합니다.
- Redis 등 캐시 시스템을 활용하는 방법도 논의할 수 있습니다.
-
인터페이스 분리
(아래 코드는 기존 OAuth 흐름에 더해 JWT 발급/검증 및 성공/실패 핸들링, Access/Refresh Token HTTP Header 전송과 Refresh Token의 DB 저장을 반영한 예시입니다.)
public enum OAuthType {
GOOGLE, KAKAO, APPLE;
public static OAuthType fromName(String name) {
return OAuthType.valueOf(name.toUpperCase());
}
}
public interface AuthCodeRequestUrlProvider {
OAuthType supportType();
String provide();
}
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "OAuth.google")
public record GoogleOAuthConfig(
String clientId,
String clientSecret,
String redirectUri,
String[] scope
) {}
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
@Component
@RequiredArgsConstructor
public class GoogleAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider {
private final GoogleOAuthConfig googleOAuthConfig;
@Override
public OAuthType supportType() {
return OAuthType.GOOGLE;
}
@Override
public String provide() {
return UriComponentsBuilder
.fromUriString("https://accounts.google.com/o/OAuth2/v2/auth")
.queryParam("client_id", googleOAuthConfig.clientId())
.queryParam("redirect_uri", googleOAuthConfig.redirectUri())
.queryParam("response_type", "code")
.queryParam("scope", String.join(" ", googleOAuthConfig.scope()))
.queryParam("access_type", "offline")
.toUriString();
}
}
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
@Component
public class AuthCodeRequestUrlProviderComposite {
private final Map<OAuthType, AuthCodeRequestUrlProvider> mapping;
public AuthCodeRequestUrlProviderComposite(Set<AuthCodeRequestUrlProvider> providers) {
mapping = providers.stream()
.collect(Collectors.toMap(AuthCodeRequestUrlProvider::supportType, Function.identity()));
}
public String provide(OAuthType OAuthType) {
return Optional.ofNullable(mapping.get(OAuthType))
.orElseThrow(() -> new BusinessException(ErrorCode.OAuth_TYPE_NOT_FOUND))
.provide();
}
}
public interface UserClient {
OAuthType supportType();
User fetch(String authCode);
}
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.PostExchange;
import org.springframework.util.MultiValueMap;
@HttpExchange
public interface GoogleApiClient {
@PostExchange(url = "https://OAuth2.googleapis.com/token", contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
GoogleToken fetchToken(@RequestBody MultiValueMap<String, String> params);
@GetExchange(url = "https://www.googleapis.com/OAuth2/v3/userinfo")
GoogleUserResponse fetchUser(@RequestHeader(HttpHeaders.AUTHORIZATION) String bearerToken);
}
import org.springframework.util.MultiValueMap;
public interface GoogleApiClient {
GoogleToken fetchToken(MultiValueMap<String, String> params);
GoogleUserResponse fetchUser(String bearerToken);
}
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.http.client.RestClient;
import org.springframework.web.http.client.RestClientResponseException;
@Component
@RequiredArgsConstructor
public class GoogleApiClientImpl implements GoogleApiClient {
private final RestClient restClient;
@Override
public GoogleToken fetchToken(MultiValueMap<String, String> params) {
try {
return restClient.post()
.uri("https://OAuth2.googleapis.com/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(params)
.retrieve()
.body(GoogleToken.class);
} catch (RestClientResponseException e) {
throw new BusinessException(ErrorCode.TOKEN_FETCH_ERROR, e);
}
}
@Override
public GoogleUserResponse fetchUser(String bearerToken) {
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", bearerToken);
return restClient.get()
.uri("https://www.googleapis.com/OAuth2/v3/userinfo")
.headers(httpHeaders -> httpHeaders.addAll(headers))
.retrieve()
.body(GoogleUserResponse.class);
} catch (RestClientResponseException e) {
throw new BusinessException(ErrorCode.USER_INFO_FETCH_ERROR, e);
}
}
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record GoogleUserResponse(
String sub, // Google 사용자 고유 ID
String email,
Boolean emailVerified,
String name,
String picture,
String locale
) {
public User toEntity() {
return User.builder()
.OAuthId(OAuthId.builder()
.OAuthId(sub)
.OAuthType(OAuthType.GOOGLE)
.build()
)
.name(name)
.profileImage(picture)
.build();
}
}
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record GoogleToken(
String accessToken,
String idToken,
String tokenType,
Integer expiresIn,
String refreshToken
) {}
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@Component
@RequiredArgsConstructor
public class GoogleUserClient implements UserClient {
private final GoogleApiClient googleApiClient;
private final GoogleOAuthConfig googleOAuthConfig;
@Override
public OAuthType supportType() {
return OAuthType.GOOGLE;
}
@Override
public User fetch(String authCode) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("code", authCode);
params.add("client_id", googleOAuthConfig.clientId());
params.add("client_secret", googleOAuthConfig.clientSecret());
params.add("redirect_uri", googleOAuthConfig.redirectUri());
params.add("grant_type", "authorization_code");
GoogleToken tokenInfo = googleApiClient.fetchToken(params);
GoogleUserResponse response = googleApiClient.fetchUser("Bearer " + tokenInfo.accessToken());
return response.toEntity();
}
}
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
@Component
public class UserClientComposite {
private final Map<OAuthType, UserClient> mapping;
public UserClientComposite(Set<UserClient> clients) {
mapping = clients.stream()
.collect(Collectors.toMap(UserClient::supportType, Function.identity()));
}
public User fetch(OAuthType OAuthType, String authCode) {
return Optional.ofNullable(mapping.get(OAuthType))
.orElseThrow(() -> new BusinessException(ErrorCode.OAuth_TYPE_NOT_FOUND))
.fetch(authCode);
}
}
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 실제 토큰 값
@Column(nullable = false)
private String token;
// 만료 시간
@Column(nullable = false)
private LocalDateTime expiryDate;
// 토큰을 발급받은 사용자와 1:1 연관관계
@OneToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Builder
public RefreshToken(String token, LocalDateTime expiryDate, User user) {
this.token = token;
this.expiryDate = expiryDate;
this.user = user;
}
}
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUser(User user);
}
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class OAuthService {
private final AuthCodeRequestUrlProviderComposite authCodeComposite;
private final UserClientComposite userClientComposite;
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtTokenProvider jwtTokenProvider; // JWT 생성/검증 클래스
public String getAuthCodeRequestUrl(OAuthType OAuthType) {
return authCodeComposite.provide(OAuthType);
}
public JwtResponse loginAndGenerateToken(OAuthType OAuthType, String authCode) {
User user = userClientComposite.fetch(OAuthType, authCode);
User savedUser = userRepository.findByOAuthId(user.getOAuthId())
.orElseGet(() -> userRepository.save(user));
// Access Token과 Refresh Token 생성
String accessToken = jwtTokenProvider.createAccessToken(savedUser);
String refreshToken = jwtTokenProvider.createRefreshToken(savedUser);
// Refresh Token을 DB에 저장 (이미 존재하면 업데이트)
refreshTokenRepository.findByUser(savedUser)
.ifPresentOrElse(existingToken -> {
existingToken.setToken(refreshToken);
existingToken.setExpiryDate(LocalDateTime.now().plusSeconds(jwtTokenProvider.getRefreshTokenValidity()));
refreshTokenRepository.save(existingToken);
}, () -> {
RefreshToken newToken = RefreshToken.builder()
.token(refreshToken)
.expiryDate(LocalDateTime.now().plusSeconds(jwtTokenProvider.getRefreshTokenValidity()))
.user(savedUser)
.build();
refreshTokenRepository.save(newToken);
});
return new JwtResponse(accessToken, refreshToken);
}
}
참고: jwtTokenProvider.getRefreshTokenValidity() 메서드는 REFRESH_TOKEN_VALIDITY를 반환하는 getter로 추가할 수 있습니다.
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth")
public class OAuthController {
private final OAuthService oauthService;
@GetMapping("/{oauthType}")
public void redirectAuthRequestUrl(@PathVariable OAuthType OAuthType, HttpServletResponse response) throws IOException {
String url = oauthService.getAuthCodeRequestUrl(OAuthType);
response.sendRedirect(url);
}
@GetMapping("/login/{OAuthType}")
public ApiResponse<String> login(@PathVariable OAuthType OAuthType, @RequestParam("code") String code) {
// JWT 토큰 생성 및 반환
JwtResponse jwtResponse = oauthService.loginAndGenerateToken(OAuthType, code);
return ApiResponse.ok(jwtResponse.getAccessToken());
}
}
@Configuration
public class ClientConfig {
private static final Duration DEFAULT_CONNECTION_TIMEOUT = Duration.ofSeconds(60);
private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(30);
@Bean
public RestClient.Builder restClientBuilder() { // 목 서버 테스트를 위해 Builder를 빈으로 등록함
return RestClient.builder()
.requestFactory(requestFactory()); // 시간 초과 설정을 위한 메서드 (시간은 각 서버가 제시하는 시간으로 설정)
}
private ClientHttpRequestFactory requestFactory() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(DEFAULT_CONNECTION_TIMEOUT);
requestFactory.setReadTimeout(DEFAULT_READ_TIMEOUT);
return requestFactory;
}
}
@RestClientTest({/* 빈으로 등록할 클래스들 */})
class OdsaySubwayClientTest extends BaseRestClientTest {
OdsaySubwayClient subwayClient;
@Autowired
OdsayUriGenerator uriGenerator;
@Autowired
OdsayProperty property;
@BeforeEach
void setUp() {
mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
subwayClient = new OdsaySubwayClient(restClientBuilder, property);
}
// ...
}
- mockServer를 통해 외부 서버(우리의 경우 카카오, 구글, 애플)를 모킹하여 외부 로직을 제외한 나머지 로직만 테스트 가능하도록 한다.
- 위 코드에서 BaseRestClientTest 클래스는 RestClientTest할 때 필요한 변수랑 메서드 옮겨놓은 곳이고, 우리 구현 시작하면 나중에 PR 올려둘게요 - 채영
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class HttpInterfaceConfig {
@Bean
public GoogleApiClient googleApiClient() {
WebClient client = WebClient.builder().build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(client))
.build();
return factory.createClient(GoogleApiClient.class);
}
}
import io.jsonwebtoken.*;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtTokenProvider {
private final String SECRET_KEY = "your-secret-key"; // 환경 변수 등으로 관리 권장
private final long ACCESS_TOKEN_VALIDITY = 3600000; // 1시간
private final long REFRESH_TOKEN_VALIDITY = 604800000; // 7일
public String createAccessToken(User user) {
Claims claims = Jwts.claims().setSubject(user.getId().toString());
claims.put("name", user.getName());
Date now = new Date();
Date expiry = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
public String createRefreshToken(User user) {
Claims claims = Jwts.claims().setSubject(user.getId().toString());
Date now = new Date();
Date expiry = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
// 로그 기록 및 오류 처리
return false;
}
}
public String getUserId(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY)
.parseClaimsJws(token).getBody().getSubject();
}
public long getRefreshTokenValidity() {
return REFRESH_TOKEN_VALIDITY / 1000; // 초 단위 반환 (예: 604800초)
}
}
public record JwtResponse (
private final String accessToken,
private final String refreshToken
)
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
// 성공: JWT 검증 성공 시 후속 처리를 진행 (예: 사용자 정보 로드 등)
} else {
// 실패: JWT가 없거나 유효하지 않으면 HTTP 401 오류 응답 전송
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
return;
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
주의:
JWT 검증 필터는 Spring Security 없이도 스프링 MVC의 HandlerInterceptor나 서블릿 필터로 적용할 수 있으며, 보호 대상 API에 대해 별도 구성해야 합니다.
- 로그인 성공 핸들러: OAuthController의 로그인 API에서 JWT를 발급한 후 HTTP Header에 토큰을 설정합니다.
- 로그인 실패 핸들러: 인증 실패나 토큰 발급 실패 시 예외 처리 및 적절한 오류 응답(API Response 또는 HTTP status)을 반환합니다.
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.*;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class OAuthId {
@Column(name = "OAuth_id", nullable = false)
private String OAuthId;
@Enumerated(EnumType.STRING)
@Column(name = "OAuth_type", nullable = false)
private OAuthType OAuthType;
@Builder
public OAuthId(String OAuthId, OAuthType OAuthType) {
this.OAuthId = OAuthId;
this.OAuthType = OAuthType;
}
}
import jakarta.persistence.*;
import lombok.*;
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "users", uniqueConstraints = {
@UniqueConstraint(
name = "OAuth_id_unique",
columnNames = {"OAuth_id", "OAuth_type"}
)
})
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 소셜 로그인 공급자 식별 정보 (예: Google의 sub 값)
@Embedded
private OAuthId OAuthId;
// 사용자 이름 (예: Google의 name)
@Column(name = "user_name")
private String name;
// 프로필 이미지 URL (예: Google의 picture)
@Column(name = "user_profile_image")
private String profileImage;
@Builder
public User(OAuthId OAuthId, String name, String profileImage) {
this.OAuthId = OAuthId;
this.name = name;
this.profileImage = profileImage;
}
}
- 전체 흐름은 인가 코드 요청 → 코드 수신 및 로그인 요청 → Access Token 및 사용자 정보 조회 → 사용자 등록/조회 및 인증 → JWT 발급(Access/Refresh Token) 및 최종 응답 → 후속 요청에 대한 JWT 검증/핸들링으로 구성됩니다.
-
로그인 성공 시:
- Access Token과 Refresh Token을 생성한 후, HTTP Header에 각각
Authorization: Bearer <access token>
와Refresh-Token: <refresh token>
으로 전송합니다.
- Access Token과 Refresh Token을 생성한 후, HTTP Header에 각각
-
JWT 검증:
- 후속 요청은 JwtAuthenticationFilter(또는 HandlerInterceptor)를 통해 JWT의 유효성 및 만료를 검증하며, 검증 실패 시 HTTP 401 오류를 반환합니다.
- BaseEntity는 생성/수정 시간을 자동 관리합니다.
-
OAuthId는 소셜 공급자별 고유 식별자(예, Google의 sub)와 공급자 타입을 함께 저장하여,
*
@UniqueConstraint
를 사용해(OAuth_id, OAuth_type)
조합의 유일성을 보장합니다. 동일한 OAuth_id라도 다른 공급자(Google, Kakao 등)에서는 별도 사용자로 저장할 수 있습니다. - User Entity는 소셜 로그인 사용자의 기본 정보를 저장하며, 확장성이 용이하도록 설계되었습니다.
-
JWT 관련
- JwtTokenProvider를 통해 Access Token과 Refresh Token을 생성하고, 로그인 성공 시 HTTP Header에 전송합니다.
- 후속 요청은 별도의 JWT 검증 필터 또는 인터셉터를 통해 토큰 유효성을 확인합니다.
- 실제 운영에서는 Refresh Token을 DB에 저장하고, Redis 등 다른 저장 방식을 활용하는 방안도 논의해볼 수 있습니다.
- 로그인 성공/실패에 따른 추가 후처리는 별도 핸들러로 구현할 수 있습니다.
- 백엔드 컨벤션
- TestContainer 적용 이유
- DB Read replica 구성 (AWS RDS)
- RDS 프라이빗 설정 후 로컬 터널링
- 공통 성공/에러 응답 정의
- 테스트 환경 통합 작업
- 소셜 로그인 설계(Spring Security 없이 OAuth2.0)
- 클라우드 환경변수 관리 AWS SSM Parameter Store
- 코드단 제안 사항 (카카오톡 내용 요약)
- Spring에서 JWT 인증/인가 위치 제안(Filter와 Interceptor)
- 알림 아키텍처 정리
- 외부 API Call 및 무거운 작업 Serverless Lambda 로 분리
- Redis GEO (유저 위치 저장 및 검색)
- 푸시알림 권한 받는 시점
- TestConfig 및 (이전) Fixture 활용 방법
- 테스트 코드 개선 제안: Mock과 Fixture 패턴
- 테스트 객체 자동 생성
- 푸시알림 설계
- Fixture 및 Factory 활용 방법
- 로그 모니터링 시스템 구축
- 성능테스트 케이스