한 줄 요약
- 예전:
- Spring Security formLogin() + HttpSession 기반 인증
- 서버가 세션을 들고 있고, 브라우저는 JSESSIONID 쿠키로만 인증
- 프론트/백이 한 몸(같은 도메인 기준)일 때 편하지만, S3 + EC2 분리, JWT 기반 SPA에는 안 어울림
- 지금:
- /api/auth/login 에서 JWT 발급 → 프론트(localStorage)에 저장
- 이후 모든 요청에 Authorization: Bearer <JWT> 헤더로 인증
- 서버는 완전 stateless(세션 안 들고 있음)
- 구글 OAuth는 “연동용”으로만 사용, 연동 결과는 User 엔티티에 저장해서 JWT 유저랑 연결
백엔드에서 어떻게 바뀌었는지
# 로그인 방식: 폼 로그인 → JWT 로그인
(1) 예전: formLogin + HttpSession
- SecurityConfig 구조
http
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", true)
)
.logout(...)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
);
- 유저가 /login 으로 폼 제출 → 스프링 시큐리티가 직접 로그인 처리 →
성공하면 서버가 세션(JSESSIONID) 을 만들고 쿠키로 내려줌. - 이후 요청마다 쿠키(JSESSIONID)가 자동으로 붙고, 서버는 세션 저장소에서 Authentication 찾아서 사용.
👉 특징
- 서버가 “누가 로그인 중인지” 상태를 들고 있음 (stateful).
- 프론트는 JWT 같은 거 몰라도 되고, 쿠키만 있으면 됨.
- 단점: 프론트/백 분리(S3 + EC2), 도메인 다름, CORS, SameSite, HTTPS 등 문제가 복잡해짐.
(2) 지금: /api/auth/login + JWT 발급
- AuthController
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody @Valid LoginRequestDto req,
BindingResult result
) {
// 1) DTO 검증
if (result.hasErrors()) { ... }
try {
// 2) AuthenticationManager로 아이디/비번 검증
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword());
Authentication authentication = authenticationManager.authenticate(authToken);
// 3) DB에서 유저 조회
User user = userRepository.findByUsername(authentication.getName())
.orElseThrow(...);
// 4) JWT 발급 (username + role)
String token = jwtUtil.generateToken(user.getUsername(), user.getUserRole().name());
// 5) 프론트로 내려줄 DTO
LoginResponseDto responseDto = new LoginResponseDto(
user.getUsername(),
user.getName(),
user.getBirth(),
user.getUserRole().name(),
token
);
return ResponseEntity.ok(responseDto);
} catch (BadCredentialsException ex) {
return ResponseEntity.status(401)
.body(Map.of("error", "아이디 또는 비밀번호가 올바르지 않습니다."));
}
}
핵심 차이점:
- 로그인 성공 시 세션을 만드는 대신
→ JWT 문자열을 발급해서 JSON 응답으로 내려줌. - 그 JWT 안에는 sub = username, role 클레임, 만료시간 등이 들어 있음.
👉 이 JWT는 서버가 들고 있는 상태가 아니라, 브라우저가 들고 다니는 “증명서” 같은 것.
# SecurityConfig: 세션 → stateless + JWT 필터
- securityConfig
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
// ✅ JWT 기준으로 stateless
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 공개 API
.requestMatchers("/", "/index.html", "/login", "/error").permitAll()
.requestMatchers("/api/auth/login", "/api/auth/signup").permitAll()
// OAuth2 시작/콜백
.requestMatchers("/oauth2/**", "/login/oauth2/**").permitAll()
// 권한 분리
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasRole("USER")
// 그 외는 모두 JWT 인증 필요
.anyRequest().authenticated()
)
// ✅ 폼 로그인은 완전 비활성화
.formLogin(form -> form.disable())
// ✅ OAuth2 로그인은 "구글 연동용"으로만 사용
.oauth2Login(oauth2 -> oauth2
.loginPage("/login").permitAll()
.authorizationEndpoint(authz -> authz
.authorizationRequestResolver(customOAuth2AuthorizationRequestResolver())
)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
.failureHandler(oauth2LoginFailureHandler)
.defaultSuccessUrl("http://프론트도메인/schedule", true)
);
...
// ✅ JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
- JwtAuthenticationFilter 동작
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserSecurityService userSecurityService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null;
String username = null;
// 1) Authorization 헤더에서 JWT 꺼내기
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
try {
username = jwtUtil.extractUsername(token);
} catch (Exception e) {
// 토큰 파싱 실패 → 그냥 통과 (컨트롤러에서 401 나게)
}
}
// 2) 아직 인증 안 돼 있고, username이 있으면
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userSecurityService.loadUserByUsername(username);
if (jwtUtil.validateToken(token, userDetails)) {
// 3) 토큰이 유효하면 Authentication 구성해서 SecurityContext에 넣기
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
👉 결과적으로:
- 일반 요청 흐름
- 프론트가 Authorization: Bearer xxx.yyy.zzz 헤더와 함께 요청
- JwtAuthenticationFilter가 토큰 파싱 → username/role 확인
- 유효하면 SecurityContextHolder에 Authentication 세팅
- 컨트롤러에서 Authentication authentication 파라미터로 그대로 사용 가능
- 서버는 세션에 유저정보를 저장하지 않음
→ 같은 유저 요청이 여러 서버로 분산돼도 상관없는 구조(stateless)
/api/auth/me – 로그인된 유저 정보 조회
@GetMapping("/me")
public ResponseEntity<?> me(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(401).build();
}
String username = authentication.getName();
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String googleEmail = user.getGoogleEmail();
boolean googleLinked = (googleEmail != null && !googleEmail.isBlank());
Map<String, Object> body = new HashMap<>();
body.put("username", user.getUsername());
body.put("name", user.getName());
body.put("birth", user.getBirth());
body.put("userRole", user.getUserRole().name());
body.put("googleEmail", googleEmail);
body.put("googleLinked", googleLinked);
return ResponseEntity.ok(body);
}
- 여기서 authentication 은 JWT 필터가 만들어준 Authentication.
- 이 엔드포인트는 프론트에서:
- 앱 처음 로드할 때
- 새로고침 후
- OAuth2 연동 성공 후
“지금 누가 로그인되어 있는지, 구글연동 되어 있는지” 확인하는 용도.
구글 OAuth 연동 – 세션 대신 DB + JWT 유저에 연결
(1) OAuth2AuthorizationRequestResolver 커스터마이징
@Bean
public OAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver() {
DefaultOAuth2AuthorizationRequestResolver defaultResolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
return new OAuth2AuthorizationRequestResolver() {
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
saveJwtTokenToSession(request);
OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request);
return customize(authorizationRequest);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request,
String clientRegistrationId) {
saveJwtTokenToSession(request);
OAuth2AuthorizationRequest authorizationRequest =
defaultResolver.resolve(request, clientRegistrationId);
return customize(authorizationRequest);
}
private void saveJwtTokenToSession(HttpServletRequest request) {
String jwt = request.getParameter("jwt_token");
if (jwt != null && !jwt.isEmpty()) {
request.getSession(true).setAttribute("LINK_JWT", jwt);
}
}
private OAuth2AuthorizationRequest customize(OAuth2AuthorizationRequest authorizationRequest) {
if (authorizationRequest == null) {
return null;
}
Map<String, Object> additionalParameters =
new HashMap<>(authorizationRequest.getAdditionalParameters());
additionalParameters.put("prompt", "select_account");
return OAuth2AuthorizationRequest.from(authorizationRequest)
.additionalParameters(additionalParameters)
.build();
}
};
}
- 프론트에서 /oauth2/authorization/google?jwt_token=<현재로그인사용자의JWT>로 보내면
- 여기서 jwt_token을 세션에 LINK_JWT로 저장.
- OAuth2 로그인 플로우가 끝난 후 CustomOAuth2UserService에서 이 LINK_JWT를 보고
- 이 JWT가 가리키는 User 엔티티를 찾고
- 구글 이메일 + 액세스 토큰을 그 User에 저장
- 중복연동, 다른 계정에 이미 연동된 구글 계정이면 OAuth2AuthenticationException 던지고, OAuth2LoginFailureHandler에서 에러 페이지로 리다이렉트.
=> 여기서 **HttpSession은 오직 “구글 OAuth 플로우 동안만 임시로 사용하는 용도”
로그인/인증은 여전히 JWT가 담당.
GmailController – 세션 대신 User 엔티티 + JWT Authentication 사용
@GetMapping("/messages/save-top10")
public ResponseEntity<?> saveTop10Messages(Authentication authentication) {
User user = getUserWithGoogleToken(authentication);
String accessToken = user.getGoogleAccessToken();
int top10 = 10;
String mailListTop10Response = gmailWebClient.get()
.uri(uriBuilder -> uriBuilder
.path("/users/me/messages")
.queryParam("maxResults", top10)
.build())
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.bodyToMono(String.class)
.block();
// ... 파싱 + Gmail 엔티티 저장 ...
}
getUserWithGoogleToken(authentication):
private User getUserWithGoogleToken(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new UsernameNotFoundException("인증되지 않은 사용자입니다.");
}
User user = userRepository.findByUsername(authentication.getName())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (user.getGoogleEmail() == null || user.getGoogleAccessToken() == null) {
throw new UsernameNotFoundException("구글 계정이 연동되어 있지 않거나 액세스 토큰이 없습니다.");
}
return user;
}
👉 중요한 변화:
- 예전에는 HttpSession이나 OAuth2AuthenticationToken에서 구글 토큰을 꺼내 쓰려 했다면,
- 지금은 User 엔티티에 저장된 googleAccessToken을 사용.
- 어떤 요청이든 JWT로 유저 식별 → DB의 User에서 구글 토큰 꺼냄 구조.
프론트에서 어떻게 바뀌었는지
axios 설정: 쿠키 → Authorization 헤더
예전에는 withCredentials: true + 세션 쿠키 의존
변경 axiosConfig.js
import axios from "axios";
import { getToken } from "./tokenHelper";
const api = axios.create({
baseURL: "백엔드 주소 (로컬/EC2)",
withCredentials: true, // (필요하면 유지)
});
api.interceptors.request.use((config) => {
const token = getToken();
const skipAuthUrls = ["/api/auth/me2"]; // 세션 기반일 때 썼던 것, 지금은 거의 안 쓰거나 삭제 예정
const url = config.url || "";
const shouldSkip = skipAuthUrls.some((u) => url.startsWith(u));
if (token && !shouldSkip) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`; // ✅ JWT 붙이기
}
return config;
});
export default api;
👉 모든 보호된 API 호출은 JWT를 헤더에 실어서 보냄.
로그인 페이지 – form submit → /api/auth/login (JWT 받기)
const handleLogin = async () => {
try {
const res = await api.post("/api/auth/login", {
username,
password,
});
// res.data = LoginResponseDto
const { token, username, name, birth, userRole } = res.data;
// 1) JWT 저장
localStorage.setItem("token", token);
// 2) UserContext 또는 상위 App 상태 업데이트
// setUser(username), setUserRole(userRole), setBirth(birth)
// 3) 홈이나 일정 페이지로 이동
navigate("/schedule");
} catch (e) {
// 401이면 "아이디 또는 비밀번호가 올바르지 않습니다." 표시
}
};
UserContext – 전역으로 유저 / 토큰 / 구글연동 상태 관리
export const UserContext = createContext(null);
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [userRole, setUserRole] = useState(null);
const [token, setToken] = useState(getToken());
const [googleLinked, setGoogleLinked] = useState(false);
const [googleEmail, setGoogleEmail] = useState(null);
const [authLoading, setAuthLoading] = useState(true);
const fetchMe = async () => {
const jwt = getToken();
if (!jwt) {
setUser(null);
setUserRole(null);
setGoogleLinked(false);
setGoogleEmail(null);
setAuthLoading(false);
return;
}
try {
const res = await api.get("/api/auth/me");
const data = res.data;
setUser(data.username);
setUserRole(data.userRole);
setGoogleLinked(!!data.googleLinked);
setGoogleEmail(data.googleEmail || null);
} catch (e) {
setUser(null);
setUserRole(null);
setGoogleLinked(false);
setGoogleEmail(null);
} finally {
setAuthLoading(false);
}
};
useEffect(() => {
fetchMe();
}, []);
const handleLoginSuccess = (loginResponseDto) => {
const jwt = loginResponseDto.token;
localStorage.setItem("token", jwt);
setToken(jwt);
fetchMe(); // 로그인 후 사용자 정보/구글연동 상태 재조회
};
const logout = () => {
localStorage.removeItem("token");
setToken(null);
setUser(null);
setUserRole(null);
setGoogleLinked(false);
setGoogleEmail(null);
};
const value = {
user,
userRole,
token,
googleLinked,
googleEmail,
authLoading,
login: handleLoginSuccess,
logout,
refetchUser: fetchMe,
};
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
👉 이걸 쓰면
- 앱이 처음 로딩될 때 /api/auth/me 한 번 호출해서
- 로그인 상태인지,
- 어떤 유저인지,
- 구글 연동 여부까지 전역으로 관리.
- Navbar, Schedule, CalendarNav 등에서 같은 UserContext 값 공유.
폼로그인 → JWT 전환의 효과
- 프론트/백 완전 분리에 적합
- 프론트: S3 정적 호스팅, 백엔드: EC2
- 도메인이 달라도 JWT로 인증이 가능
- 서버 stateless → 확장성↑
- 세션 공유/클러스터링 없이 서버를 여러 대로 늘릴 수 있음
- React SPA와 자연스럽게 연동
- 로그인부터 라우팅, 에러 처리까지 프론트에서 주도
- 백엔드는 깔끔한 REST API 역할만 수행
- 구글 연동, Gmail, AI 요약 기능 확장에 유리
- JWT로 “누구인지” 항상 알 수 있고
- User 엔티티에 외부 서비스 토큰/이메일을 안전하게 저장
- 보안/구조적으로 명확해짐
- 인증: JWT + Security 필터
- 인가(권한): UserRole + hasRole("ADMIN"/"USER")
- 외부 연동: OAuth2는 “연동용”으로만 사용
전체적인 흐름 파악하기
# 로그인 흐름
[React 로그인 페이지]
↓ (1. username/password 로 POST)
[백엔드 /api/auth/login]
↓ (2. AuthenticationManager로 계정 검증)
[JwtUtil]
1) React에서 POST /api/auth/login (JSON: { username, password })
2) Spring Security AuthenticationManager가 아이디/비번 검증
3) 성공 시:
- User 엔티티 조회 (role, name, birth 등)
- JwtUtil.generateToken(username, role) 로 JWT 생성
4) 백엔드 → 프론트:
- JSON 응답: { username, name, birth, userRole, token }
5) 프론트:
- token을 localStorage에 저장
- UserContext / App state에 user, userRole 세팅
- /schedule 같은 페이지로 라우팅
6) 이후 모든 API 호출 시:
- Authorization: Bearer <JWT> 헤더를 붙여서 백엔드 호출
- 서버는 로그인 상태를 저장하지 않고, 매 요청마다 토큰만 검증 → stateless
# JWT필터 요청 인증 처리 흐름
[React / axios]
↓ (1. Authorization: Bearer <JWT> 헤더 포함)
[Spring Security Filter Chain]
↓ (2. JwtAuthenticationFilter)
[컨트롤러 (@RestController)]
1) axios가 보호된 API 호출:
- 예: GET /api/auth/me
- 헤더: Authorization: Bearer xxx.yyy.zzz
2) JwtAuthenticationFilter:
- Authorization 헤더에서 JWT 추출
- JwtUtil.extractUsername(token) 로 username 파싱
- JwtUtil.validateToken(token, userDetails) 로 유효성 검증
3) 토큰 유효 시:
- UserSecurityService.loadUserByUsername(username)
- UsernamePasswordAuthenticationToken 생성
- SecurityContextHolder에 Authentication 저장
4) 컨트롤러 진입:
- 파라미터로 Authentication 주입 가능
- 예: public ResponseEntity<?> me(Authentication authentication)
# 현재 로그인 유저 조회 (/api/auth/me)
[React (앱 처음 로딩 / 새로고침 시)]
↓ (1. GET /api/auth/me)
[JwtAuthenticationFilter]
↓ (2. Authentication 설정)
[AuthController.me]
1) React가 localStorage에 남아있는 JWT를 확인
2) 있으면 axios로 GET /api/auth/me 호출
3) JwtAuthenticationFilter에서 토큰 검증 → Authentication 세팅
4) AuthController.me(Authentication auth):
- auth.getName() 으로 username 확인
- UserRepository.findByUsername(username)
- 사용자 정보 + googleEmail + googleLinked 계산
- JSON 응답: { username, name, birth, userRole, googleEmail, googleLinked }
5) React UserContext:
- user, userRole, googleLinked, googleEmail 상태 세팅
- Navbar, Schedule, CalendarNav 등에서 공통으로 사용
- 앱이 켜질 때마다 “지금 누가 로그인 되어 있는가 + 구글 연동 여부”를 하나의 API로 해결
- 세션 없이 JWT만으로 유저 상태를 전역 관리하는 기반 제공
# 구글 연동(OAuth) 전체 흐름
[1. 프론트에서 연동 버튼 클릭]
Navbar.handleGoogleLogin()
↓
- 로그인 안 되어 있으면: /login 으로 이동
- 로그인 되어 있고 token 존재:
const redirectUrl =
`${API_BASE_URL}/oauth2/authorization/google?jwt_token=${token}`;
window.location.href = redirectUrl;
[2. 백엔드: OAuth2AuthorizationRequestResolver]
↓
- 요청 파라미터 jwt_token 읽어서
HttpSession에 LINK_JWT 로 저장
- 구글 OAuth2 authorization 요청 생성
- 추가로 prompt=select_account 설정
[3. 구글 로그인/계정 선택 화면]
↓
- 유저가 구글 계정 선택 & 동의
[4. 백엔드: CustomOAuth2UserService]
↓
- 세션에서 LINK_JWT 꺼냄
- JWT 검증 → 우리 서비스 User 찾기
- 구글에서 받은 이메일, 액세스 토큰을
User 엔티티(googleEmail, googleAccessToken)에 저장
- 이미 다른 계정에 연동된 이메일이면
OAuth2AuthenticationException 던져서 실패 처리
[5. OAuth2LoginSuccess / defaultSuccessUrl]
↓
- 성공 시 프론트 /schedule 등으로 리다이렉트
- 이후 /api/auth/me 호출 시 googleLinked = true
- **로그인 수단이 아니라 “연동 수단”**으로 구글 OAuth를 사용
- JWT로 이미 로그인된 MailBuddy 계정과 구글 계정을 1:1로 매칭
- 구글 액세스 토큰은 User 엔티티에 암호화(Jasypt)하여 저장
# Gmail 가져오기 & AI 요약 흐름
Gmail 저장
[1. 사용자가 캘린더 상단에서 버튼 클릭]
CalendarNav.handleSaveEmails()
↓
- GET /api/gmail/messages/save-top10
[2. GmailController.saveTop10Messages(Authentication auth)]
↓
- getUserWithGoogleToken(auth):
- JWT 기반 Authentication에서 username 가져오기
- UserRepository로 User 조회
- googleEmail, googleAccessToken 있는지 확인
- gmailWebClient 로 구글 Gmail API 호출:
- /users/me/messages?maxResults=10
- 각 메시지 상세 조회 → 발신자, 제목, 날짜, 본문 파싱
- Base64 디코딩, HTML/텍스트 추출
- EmailTimeParser로 한국 시간 변환
- Gmail 엔티티로 DB에 저장 (messageId로 중복 방지)
- 결과: "N개 메일을 DB에 저장 완료했습니다." 응답
AI 요약
[1. 사용자가 "AI 요약하기" 버튼 클릭]
CalendarNav.handleSummarize()
↓
- POST /api/summarize
[2. Summary 관련 서비스]
↓
- JWT Authentication 기반으로 User 찾기
- 해당 User의 Gmail 엔티티 목록 조회
- 각 메일 본문 + 프롬프트로 Mistral API 호출
- JSON 형태의 SummaryDto(title, eventDate, time, place, notes ...) 생성
- Summary를 DB의 일정 엔티티로 저장/업데이트
[3. 프론트]
↓
- AI 요약 완료 후 loadSummaries() 호출
- 달력/메모 영역에서 요약된 일정 표시
- 모든 Gmail/요약 기능은 항상 JWT → User 엔티티 → 구글 토큰/메일
📌 전체 정리
로그인하면 /api/auth/login에서 서버가 세션을 만들지 않고 → JWT를 발급해서 프론트가 localStorage에 저장
이후 모든 API 요청은 Authorization: Bearer <JWT> 헤더로 인증하고,
스프링 시큐리티의 JwtAuthenticationFilter가 토큰을 검증해 SecurityContext에 Authentication을 세팅
서버는 세션 상태를 갖지 않는 구조가 되었고, /api/auth/me를 통해 현재 로그인한 사용자 정보와 구글 연동 여부를 언제든지 조회할 가능
구글 로그인은 로그인 수단이 아니라, JWT로 인증된 기존 사용자 계정에 구글 이메일과 액세스 토큰을 연결하는 “연동” 용도로만 사용 → 연동 정보는 User 엔티티에 저장됩니다.
프론트에서는 React의 UserContext로 user, userRole, token, googleLinked를 전역 관리하고, axios 인터셉터에서 JWT를 자동으로 붙여 모든 보호된 API를 호출합니다.
이 덕분에 로그인 UI, 구글 연동 버튼, Gmail/AI 요약 기능 노출 여부를 모두 프론트에서 제어할 수 있고, 서버는 세션 없는 REST API 서버로 깔끔하게 정리되었습니다.
'Project > Project01.MailBuddy' 카테고리의 다른 글
| detail , summary 이용 FAQ 페이지 구현 (0) | 2025.11.24 |
|---|---|
| React 전역 상태 디버깅: selectedItem null 오류 해결 (0) | 2025.11.21 |
| loading overlay 구현 해보기 (0) | 2025.11.20 |
| 앵커 이용 Guide_Nav 만들어서 해당 section 이동 (0) | 2025.11.20 |
| Gmail 연동 중복 에러 처리 (0) | 2025.11.18 |