JWT ์ค์ ๊ณผ ๋ฆฌํ๋ ์ฌ ํ ํฐ ์ ์ฉ ํด๋ณด๊ธฐJWT๋?๊ณ ๋ ค ์ฌํญDBRefreshToken์ ์ฌ์ฉ ์ด์ ํ ํฐ์ ์ ์ฅ ์์นJwtAuthenticationFilter(์ปค์คํ
ํํฐ) ๊ตฌํAccessToken๋ง ์ฌ์ฉRefreshToken ์ถ๊ฐ ์ฌ์ฉJwtAuthenticationFilter.java๋ก๊ทธ์์ ๊ธฐ๋ฅ์ ๋ํ ์๊ฐ
JWT ์ค์ ๊ณผ ๋ฆฌํ๋ ์ฌ ํ ํฐ ์ ์ฉ ํด๋ณด๊ธฐ
ํ์ฌ ์งํํ๋ ํํ๋ก์ ํธ์ ์ ์ฉํ JWT์ ๋ํด์ ์์๋ณด๊ณ ์ ์ฉํด๋ด
๋๋ค.
์ด ํฌ์คํ
์์๋ ๊ตฌํ์ ๋ํ ์ธ์ธํ ์ฝ๋๋ฅผ ๋ชจ๋ ๋ณด์ฌ๋๋ฆฌ์ง ์์ต๋๋ค. JWT๋ฅผ ์ฌ์ฉํ๊ณ ์ ํ์ง๋ง Refresh Token์ ์์ง ์ฌ์ฉํด๋ณด์ง ๋ชปํ๊ฑฐ๋ ๋ก์ง์ ํ๋ฆ์ ์ดํดํ์ง ๋ชปํ ์ฌ๋๋ค์ ์ํด ๋ก์ง์ ํ๋ฆ์ ์ค๋ช
ํ๊ณ ์ดํด์ํค๋๊ฒ ๋ชฉ์ ์
๋๋ค! ์์ธํ ์ฝ๋๋ ์ถํ์ ๊นํ ๋งํฌ๋ฅผ ๊ณต์ ํ๊ณ ์ ํฉ๋๋ค.
JWT๋?
Json Web Token์ ์ค์๋ง์ผ๋ก์จ ์ธ์
๋ฐฉ์์ ๋จ์ ์ ๋ณด์ํ๊ธฐ ์ํด ์ฌ์ฉ๋๋ ๊ธฐ์ ์
๋๋ค.
์๋ฒ ์ธก์ ์ธ์
์ ๋ณด๋ฅผ ๋ชจ๋ ๊ฐ์ง๊ณ ๊ด๋ฆฌ๋๋ ์ธ์
๋ฐฉ์์ ๋ถ์ฐ ์๋ฒ ํ๊ฒฝ์์ ์ ํฉ์ฑ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ณ , ๋ถํ๋ฅผ ์ค์ผ ์ ์๋ ๋ฐฉ๋ฒ ์ค์ ํ๋์
๋๋ค.
์์ธํ ๋ด์ฉ์ ์์ ์ ์์ฑํ ๊ธ์ ํ์ธ ํด๋ณด๋ฉด ์ข์ต๋๋ค.
[JWT] Session๊ณผ JWT์ ๋ํด ์์๋ณด๊ธฐ
๊ณ ๋ ค ์ฌํญ
DB
ํ์ฌ ์งํํ๋ ํ ํ๋ก์ ํธ์์๋ Redis๋ฅผ ์ฌ์ฉํ์ง ์๊ณ RDB๋ฅผ ์ด์ฉํด JWT๋ฅผ ์ฌ์ฉํ ์์ ์
๋๋ค. (Redis ์ ์ฉ์ ๋์ค์)
RefreshToken์ ์ฌ์ฉ ์ด์
- JWT๊ฐ ๊ฐ์ง ๋จ์ ์ค ํ๋๋ ์ก์ธ์ค ํ ํฐ ํ์ทจ์ ์ฐ๋ ค์, ์๋ฒ ์ธก์ ์ธ์ ์ ๋ณด๊ฐ ์ ์ฅ๋๋ ์ธ์ ๋ฐฉ์๊ณผ๋ ๋ค๋ฅด๊ฒ ์๋ฒ์์ ์ธ์ฆ์ ์ ์ดํ ์ ์์ด ๋ก๊ทธ์์ ๊ธฐ๋ฅ์ ๊ตฌํ์ด ์ด๋ ต์ต๋๋ค. ๋ํ ๋น๊ต์ ์งง์ ํ ํฐ์ ๋ง๋ฃ๊ธฐํ ๋๋ฌธ์ ์ง์์ ์ผ๋ก ๋ก๊ทธ์ธ์ ๋ค์ ํด์ค์ผํ๋ ๋ฌธ์ ์ ์ด ์์ต๋๋ค.
- ์ก์ธ์ค ํ ํฐ ํ์ทจ๊ฐ ๋์ด๋ ์๋ฒ ์ธก์์ ์ก์ธ์ค ํ ํฐ์ ์ ์ดํ ์ ์๋ ๋ฐฉ๋ฒ์ด ํ์ํฉ๋๋ค.
- ๋ก๊ทธ์ธ์ ์๋์ผ๋ก ํด์ค ์ ์๋ ๋ฐฉ๋ฒ์ด ํ์ํฉ๋๋ค.
- ์๋ฒ ์ธก์์ ํด๋ผ์ด์ธํธ์ ์ธ์ฆ ์ ๋ณด๋ฅผ ๋ถ๋ถ์ ์ผ๋ก ์ ์ดํ ์ ์์ด์ผํฉ๋๋ค.
์ด๋ฌํ ๋ฌธ์ ์ ๋ค์ ํด๊ฒฐํ๊ธฐ ์ํด RefreshToken์ด๋ผ๋ ๊ฒ์ด ํ์ํฉ๋๋ค. RefreshToken์ ๋ํ ์ถ๊ฐ์ ์ธ ์ค๋ช
์ [JWT] Session๊ณผ JWT์ ๋ํด ์์๋ณด๊ธฐ์ ๋์์์ต๋๋ค.
ํ ํฐ์ ์ ์ฅ ์์น
์ ๊ฐ ๋ณธ JWT ๊ฐ์์์๋ ํ ํฐ์ ์์ฑํ๊ณ ๋ณ๋๋ก ํ ํฐ์ ์ ์ฅํ์ง ์๊ณ ๊ทธ๋ฅ ๋ณต์ฌ ๋ถ์ฌ๋ฃ๊ธฐ๋ก Postman์ Request Header์ ํ ํฐ์ ์ง์ ์
๋ ฅํ์ต๋๋ค.
์ค์ ์ฌ์ฉ์์๋ ์ฌ์ฉ์๊ฐ ์ผ์ผ์ด ํ ํฐ์ Request์ ์ง์ ๋ฃ์ด์ค ์ ์์ต๋๋ค.
ํ ํฐ ์ ๋ณด๋ฅผ ๋ธ๋ผ์ฐ์ ์ storage์ ์ ์ฅํ๊ฑฐ๋ ์ฟ ํค์ ์ ์ฅํด์ผ๋ฉ๋๋ค.
์ ๋ ํ ํฐ์ ์ฟ ํค์ ์ ์ฅํ๊ธฐ๋ก ํ์ต๋๋ค. ๊ทธ ์ด์ ๋ ์ญ์ ์ ๋งํฌ์ ์์ธํ ์ ํ์์ต๋๋ค.
JwtAuthenticationFilter(์ปค์คํ ํํฐ) ๊ตฌํ
JWT accessToken๋ง ์ฌ์ฉํ๋ ํํฐ์ ๊ตฌํ์ ์์ฃผ ๊ฐ๋จํฉ๋๋ค. ์๋๋ JWT ๊ฐ์๋ฅผ ๋ณด๊ณ ์ค์ตํ ์ฝ๋ ์
๋๋ค.
๊ฐ์์์๋ AccessToken๋ง ์ฌ์ฉํ๊ณ ๋ก๊ทธ์์, ํ ํฐ์ด ๋ง๋ฃ๋์ ๋ ์ฌ์ฉ์๊ฐ ๋ค์ ๋ก๊ทธ์ธํด์ผ ํ๋ ์ํฉ์ ๋ํด์๋ ๊ตฌํํ์ง ์์์ต๋๋ค.
AccessToken๋ง ์ฌ์ฉ
๋ก์ง์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- ์ธ์ฆ์ด ํ์ํ ๊ฒฝ์ฐ Request์ Header์์ ํ ํฐ์ ๊ฐ์ ธ์ต๋๋ค.
- ํ ํฐ์ด ์๋ค๋ฉด ํ ํฐ์ ๊ฒ์ฆํ๊ณ ๋์ฝ๋ฉํฉ๋๋ค.
- ํ ํฐ์ด ์ฌ๋ฐ๋ฅด๋ค๋ฉด JwtAuthenticationToken์ ์์ฑํด SecurityContext์ ์ธ์ฆ ์ ๋ณด๋ฅผ ์ ์ฅํฉ๋๋ค.
- ํ ํฐ์ด ์ฌ๋ฐ๋ฅด์ง ์๋ค๋ฉด ์์ธ๋ฅผ ๋ฐ์์ํต๋๋ค.
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (SecurityContextHolder.getContext().getAuthentication() == null) { String token = getToken((HttpServletRequest)request); if (token != null) { try { Jwt.Claims claims = verify(token); log.debug("Jwt parse result: {}", claims); String username = claims.username; List<GrantedAuthority> authorities = getAuthorities(claims); if (isNotEmpty(username) && authorities.size() > 0) { JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(new JwtAuthentication(token,username) , null, authorities); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails( (HttpServletRequest)request)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } catch (JWTVerificationException e) { log.warn("Jwt processing failed: {}", e.getMessage()); } } } else { log.debug("SecurityContextHolder not populated with security token, as it already contained: {}", SecurityContextHolder.getContext().getAuthentication()); } chain.doFilter(request, response); }
RefreshToken ์ถ๊ฐ ์ฌ์ฉ
RefreshToken์ ์ฌ์ฉํ ๋์๋ ์กฐ๊ธ ๋ ๋ณต์กํฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ์ถ๊ฐ์ ์ผ๋ก ์์์ ๊ณ ๋ ค์ฌํญ์์ ๋งํ๋ฏ์ด ์ฟ ํค๋ฅผ ์ฌ์ฉํ ์์ ์
๋๋ค.
์ ๊ฐ ๋ณธ ๊ฐ์์์๋ GenericFilterBean์ ์์ ๋ฐ์์ ์ปค์คํ
ํํฐ๋ฅผ ๊ตฌํํ๋๋ฐ ์ ๋ OncePerRequestFilter๋ฅผ ์์ ๋ฐ์์ต๋๋ค.
์ด์ ๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- GenericFilterBean์ ๋งค ์๋ธ๋ฆฟ๋ง๋ค doFilter๋ฅผ ์ํํฉ๋๋ค. ์ค์ ๋ก GenericFilterBean์ ์์๋ฐ์์ ๊ตฌํํ๋ฉด ํ๋ฒ Request๋ฅผ ๋ณด๋ผ ๋ ์ฌ๋ฌ๋ฒ JwtAuthenticationFilter๋ฅผ ๊ฑฐ์นฉ๋๋ค. ๋งค ์๋ธ๋ฆฟ ๋ง๋ค ๊ฐ์ ํํฐ๋ฅผ ๊ฑฐ์น ํ์๋ ์์ต๋๋ค. ์ธ์ฆ์ ๋จ ํ๋ฒ๋ง ์ํํ๋ฉด ๋ฉ๋๋ค.
- ShouldNotFilter ํจ์๋ฅผ ์ ๊ณตํด์ค๋๋ค. ํ์ ๊ฐ์ , ๋ก๊ทธ์ธ๊ฐ์ ๊ฒฝ์ฐ์๋ JWT Authentication Filter๋ฅผ ๊ฑฐ์น ํ์๊ฐ ์์ต๋๋ค. OncePerRequestFilter๋ ShouldNotFilter() ๋ผ๋ ํน์ ์กฐ๊ฑด์ ๋ฐ๋ผ Filter๋ฅผ ์คํตํ๋ ์ ์ฉํ ๋ฉ์๋๋ฅผ ์ ๊ณต ํฉ๋๋ค. ํ์ ์์ JWT Filter๋ง ์คํตํ ์ ์์ต๋๋ค.
JwtAuthenticationFilter.java
package com.kdt.instakyuram.security.jwt; import java.io.IOException; import java.util.Arrays; import java.util.List; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.web.filter.OncePerRequestFilter; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.exceptions.TokenExpiredException; import com.kdt.instakyuram.auth.dto.TokenResponse; import com.kdt.instakyuram.auth.service.TokenService; import com.kdt.instakyuram.exception.EntityNotFoundException; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final Jwt jwt; private final TokenService tokenService; private final Logger log = LoggerFactory.getLogger(getClass()); public JwtAuthenticationFilter(Jwt jwt, TokenService tokenService) { this.jwt = jwt; this.tokenService = tokenService; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { logRequest(request); try { authenticate(getAccessToken(request), request, response); } catch (JwtTokenNotFoundException e) { this.log.warn(e.getMessage()); } filterChain.doFilter(request, response); } private void logRequest(HttpServletRequest request) { log.info(String.format( "[%s] %s %s", request.getMethod(), request.getRequestURI().toLowerCase(), request.getQueryString() == null ? "" : request.getQueryString()) ); } private String getAccessToken(HttpServletRequest request) { if (request.getCookies() != null) { return Arrays.stream(request.getCookies()) .filter(cookie -> cookie.getName().equals(this.jwt.accessTokenProperties().header())) .findFirst() .map(Cookie::getValue) .orElseThrow(() -> new JwtAccessTokenNotFoundException("AccessToken is not found")); } else { throw new JwtAccessTokenNotFoundException("AccessToken is not found."); } } private void authenticate(String accessToken, HttpServletRequest request, HttpServletResponse response) { try { Jwt.Claims claims = verify(accessToken); JwtAuthenticationToken authentication = createAuthenticationToken(claims, request, accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); this.log.info("set Authentication"); } catch (TokenExpiredException exception) { Cookie cookie = new Cookie(jwt.accessTokenProperties().header(), ""); cookie.setPath("/"); cookie.setMaxAge(0); cookie.setHttpOnly(true); response.addCookie(cookie); this.log.warn(exception.getMessage()); refreshAuthentication(accessToken, request, response); } catch (JWTVerificationException exception) { log.warn(exception.getMessage()); } } private JwtAuthenticationToken createAuthenticationToken(Jwt.Claims claims, HttpServletRequest request, String accessToken) { List<GrantedAuthority> authorities = this.jwt.getAuthorities(claims); if (claims.memberId != null && !authorities.isEmpty()) { JwtAuthentication authentication = new JwtAuthentication(accessToken, claims.memberId, claims.username); JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authentication, null, authorities); authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); return authenticationToken; } else { throw new JWTDecodeException("Decode Error"); } } private void refreshAuthentication(String accessToken, HttpServletRequest request, HttpServletResponse response) { try { String refreshToken = getRefreshToken(request); if (isValidRefreshToken(refreshToken, accessToken)) { String reIssuedAccessToken = accessTokenReIssue(accessToken); Jwt.Claims reIssuedClaims = verify(reIssuedAccessToken); JwtAuthenticationToken authentication = createAuthenticationToken(reIssuedClaims, request, reIssuedAccessToken); SecurityContextHolder.getContext().setAuthentication(authentication); Cookie cookie = new Cookie(this.jwt.accessTokenProperties().header(), reIssuedAccessToken); cookie.setHttpOnly(true); cookie.setPath("/"); cookie.setMaxAge(this.jwt.accessTokenProperties().expirySeconds()); response.addCookie(cookie); } else { log.warn("refreshToken expired"); } } catch (JwtTokenNotFoundException | JWTVerificationException e) { this.log.warn(e.getMessage()); } } private String getRefreshToken(HttpServletRequest request) { if (request.getCookies() != null) { return Arrays.stream(request.getCookies()) .filter(cookie -> cookie.getName().equals(this.jwt.refreshTokenProperties().header())) .findFirst() .map(Cookie::getValue) .orElseThrow(() -> new JwtRefreshTokenNotFoundException("RefreshToken is not found.")); } else { throw new JwtRefreshTokenNotFoundException(); } } private boolean isValidRefreshToken(String refreshToken, String accessToken) { try { TokenResponse foundRefreshToken = this.tokenService.findByToken(refreshToken); Long memberId = this.jwt.decode(accessToken).memberId; if (memberId.equals(foundRefreshToken.memberId())) { this.jwt.verify(foundRefreshToken.token()); return true; } } catch (EntityNotFoundException | JWTVerificationException e) { log.warn(e.getMessage()); return false; } return false; } private String accessTokenReIssue(String accessToken) { return jwt.generateAccessToken(this.jwt.decode(accessToken)); } private Jwt.Claims verify(String token) { return jwt.verify(token); } }
๋ก์ง์ ํ๋ฆ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- getAccessToken()
- ์ก์ธ์ค ํ ํฐ์ ์ฟ ํค๋ก๋ถํฐ ๊ฐ์ ธ ์ต๋๋ค. ํ ํฐ์ด ์๋ค๋ฉด ์์ธ๋ฅผ ๋ฐ์ํฉ๋๋ค.
- authenticate()
- accessToken์ด ์๋ค๋ฉด ํ ํฐ์ ๊ฒ์ฆ๊ณผ ๋์์ ๋์ฝ๋ฉ ํฉ๋๋ค. (์๋ณ์กฐ ๋์ง ์์๋์ง, ํ ํฐ์ด ๋ง๋ฃ๋์ง ์์๋์ง ๋ฑ)
- ํ ํฐ์ด ๋ง๋ฃ๋๋ฉด ๋ฆฌํ๋ ์ฌ ํ ํฐ์ ํตํด AccessToken์ ์ฌ๋ฐ๊ธ ๋ฐ์ต๋๋ค.
- ๊ทธ ์ธ ํ ํฐ ๊ฒ์ฆ ์ค๋ฅ๊ฐ ๋ฐ์ํ๋ฉด ์์ธ๋ฅผ ๋ฐ์์ํค๊ณ ์ข ๋ฃํฉ๋๋ค.
- ๊ฒ์ฆ์ ํต๊ณผ ํ๋ค๋ฉด ๋์ฝ๋ฉ๋ ๋ฐ์ดํฐ๋ฅผ ํ ๋๋ก authenticationToken์ ๋ง๋ค๊ณ SecurityContextHolder์ ์ธ์ฆ ์ ๋ณด๋ฅผ ์ธํ ํฉ๋๋ค. (createAuthenticationToken(), setAuthentication())
2-a. AccessToken์ด ๋ง๋ฃ๋์์ ๊ฒฝ์ฐ (refreshAuthentication())
- ์ฟ ํค๋ก๋ถํฐ RefreshToken์ ์ฐพ์ต๋๋ค. ์๋ค๋ฉด ์์ธ๋ฅผ ๋ฐ์์ํค๊ณ ์ข ๋ฃํฉ๋๋ค.
- ํ ํฐ์ด ์๋ค๋ฉด ๊ฒ์ฆ์ ํฉ๋๋ค. (isValidRefreshToken()) DB์ ์ ์ฅ๋ RefreshToken์ userID์ AccessToken๋ด์ userID๊ฐ ๊ฐ์์ง ํ์ธ์ ํฉ๋๋ค.
- ํ ํฐ์ด ์๋ค๋ฉด ๋ง๋ฃ๋ accessToken์ ๋์ฝ๋ฉํด ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ค๊ณ ์ accessToken์ ๋ง๋๋ ๋ฐ ์ฌ์ฉํฉ๋๋ค. (accessTokenReIssue())
- ์ฌ๋ฐ๊ธ๋ ํ ํฐ์ ๋ค์ SecurityContextHolder์ ์ธ์ฆ ์ ๋ณด๋ฅผ ์ธํ ํด์ค๋๋ค.
๋ก๊ทธ์์ ๊ธฐ๋ฅ์ ๋ํ ์๊ฐ
JWT๋ฅผ ์ฌ์ฉํ ๋ ๋ก๊ทธ์์ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ ๋ํ์ ์ธ ๋ฐฉ๋ฒ์ด ๋ก๊ทธ์์ํ AccessToken์ DB์ ์ ์ฅํด ํด๋น AccessToken์ผ๋ก ์์ฒญ์ด ๋ค์ด์ค๋ฉด DB ํ์ธ์ ํตํด์ ์ธ์ฆ์ ๊ฑฐ๋ถํ๋ ๋ฐฉ์์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
JWT ์ฅ์ ์ค์ ํ๋๋ ๋งค๋ฒ Session Storage๋ฅผ ์กฐํํ๋ ์ธ์
๋ฐฉ์๊ณผ๋ ๋ค๋ฅด๊ฒ ํ ํฐ์ ๋ํ ์ ๋ณด๋ฅผ ํตํด ์ธ์ฆ์ ํ ์ ์๋ค๋ ์ ์ด์๊ณ RefreshToken์ ์ฌ์ฉํ๋ฉด์ DB๋ฅผ ์ฌ์ฉํ๋๋ผ๋ ํ ํฐ์ด ๋ง๋ฃ๋์์ ๋๋ง ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ์ธ์
๋ฐฉ์๋ณด๋ค ํฐ ์ด์ ์ ๊ฐ์ ธ๊ฐ ์ ์์์ต๋๋ค.
ํ์ง๋ง ๋ธ๋๋ฆฌ์คํธ-๋ก๊ทธ์์ ๊ธฐ๋ฅ์ ์ถ๊ฐํ๋ค๋ฉด AccessToken์ด ๋ธ๋๋ฆฌ์คํธ์ ๋ฑ๋ก์ด ๋์๋์ง ํ์ธํ๊ธฐ์ํด ๋งค๋ฒ DB๋ฅผ ์กฐํํ๋ฏ๋ก JWT๊ฐ ๊ฐ์ง ์ด๋ฌํ ์ฅ์ ์ด ํด์๋ ์ ์๋ค๋ ๊ฒฐ๋ก ์ด ๋ฌ์ต๋๋ค.
์ฟ ํค์์ ํ ํฐ์ ์ญ์ ํ๋๋ผ๋ ํ ํฐ ์์ฒด๋ ์ฌ์ ํ ์ฌ์ฉํ ์ ์๋ ์ํ์ด๊ธฐ ๋๋ฌธ์ ์ค๊ฐ์ ํ์ทจ๋๋ค๋ฉด ์๋ฌด๋ฐ ์กฐ์ทจ๋ฅผ ํ ์ ์๋ค๋๊ฒ์ด ๊ฑฑ์ ๋์์ง๋ง ํ ํฐ์ ์ ํจ์๊ฐ์ ์งง๊ฒ ์ค์ ํ๊ณ ์ด๋์ ๋์ Trade-off๋ฅผ ๊ฐ์ํ๋ฉด์ JWT์ ์ฅ์ ์ ์ต๋ํ ์ด๋ฆฌ๋๊ฒ ์ข๋ค๊ณ ํ๋จ๋์์ต๋๋ค.