[SpringBoot] JWT 사용하기

P__.mp4·2022년 11월 15일
0

Spring

목록 보기
6/6

우선 알아둬야 할 내용

1. 주요 용어

인증 (Authentication) : 누구인지 확인하는 단계

인가 (Authorization) : 인증을 통해, 리소스에 접근할 권리가 있는지 확인하는 과정

접근주체 (principal) : 애플리케이션의 기능을 사용하는 주체

2. Session VS Token

2.1 Session

HTTP 프로토콜의 특징이자 약점을 보완하고자 사용된다. (Connectionless, Stateless)

클라이언트의 최초 요청 시, 서버에서는 세션을 생성을 하고, 해당 세션에 접근할 수 있는 쿠키를 응답헤더에 담아주어 연결 정보를 유지시켜준다. 생성된 세션에 로그인 정보를 저장하여 로그인 방식을 구현할 수 있다.

문제점

요청에 따라 서버에 직접 데이터를 저장하므로 세션의 양이 많아질수록 서버의 부담이 커진다. 또한 쿠키를 전달하는 과정에서 탈취 위험 → 보안에 안좋음.

트래픽 부하를 줄이기 위해 서버를 증설 (로드밸런싱) → 1번 서버에 접속되어 세션 생성 → 나중에 2번 서버로 접속될 시 세션을 다시 생성해야함 → 해결방법이 있지만 복잡함(DB, 레디스 메모리)

2.2 Token

클라이언트의 요청 시, 서버는 토큰을 발행해 응답해준다. 세션과 다르게 서버에는 어떠한 공간도 생성하지 않는다. 서버는 인증, 인가에 필요한 데이터를 암호화하여 토큰을 생성하고, 해당 토큰을 클라이언트에 전달해주기만 할 뿐이다.

이렇게 생성된 토큰을 갖고 클라이언트가 요청할 때, 서버는 해당 토큰이 유효한지만 체크한다.

정리

세션 기반인증은 클라이언트마다 서버에 공간을 계속 만들고 데이터를 저장 → 서버에 부담. 또한 쿠키를 전달하는 과정에서 탈취 위험 → 보안에 안좋음.

토큰 기반인증은 인증, 인가에 필요한 데이터를 담은 데이터를 암호화하여 클라이언트에 줌 → 서버 부담 없음. 또한 쿠키를 전달하지 않기 때문에 상대적으로 보안이 좋다.

SpringBoot에서 Token을 사용하기 전

스프링 시큐리티

애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 라이브러리. 서블릿 필터(Servlet Filter)를 기반으로 동작하며, DispatcherServlet 앞 FilterChain에 존재한다.

JWT

JWT (JSON Web Token)는 당사자간의 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다. 문자열로만 구성되어 있으며, 디지털 서명이 적용되어 있다.

1. 구조

JWT는 '.'으로 구분된 세 부분으로 구성된다.

  • 헤더(Header)
  • 내용(Payload)
  • 서명(Signature)

[Header] . [Payload] . [Signature]

1.1 헤더

JWT의 헤더는 검증과 관련된 내용을 담고 있다.

{
  "alg": "HS256",
  "typ": "JWT",
}

alg 속성에서는 해싱 알고리즘을 지정한다. 보통 SHA256 또는 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부분에서 사용된다.

typ 속성에서는 토큰의 타입을 지정한다.

1.2 내용

JWT의 내용 부분에는 토큰에 담는 정보를 포함한다. 포함된 속성들을 클레임(Claim)이라 하여, 세 가지로 분류된다.

  • 등록된 클레임(Registered Claims)
  • 공개 클레임(Public Claims)
  • 비공개 클레임(Private Claims)

등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻한다.

  • its : 발급자
  • sub : 제목
  • and : 수신인
  • exp : 만료시간
  • nbc : 'Not Before' 의미
  • iat : 발급된 시간
  • jai : 식별자, 중복 처리 방지

공개 클레임의 키, 값은 충돌이 나지 않는 선에서 마음대로 정의 가능하다.

{
  "sub": "pmp4 payload",
  "exp": "1234567890",
  "userId": "pmp4Id",
  "username" "피엠피포"
}

1.3 서명

JWT의 서명 부분은 인코딩된 헤더, 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성된다.

스프링 시큐리티와 JWT 사용 준비

우선 필자는 JPA를 사용하지 않았다. 추후 JPA를 적용 후, 추가적으로 글을 포스팅 할 예정이다.

의존성 추가

<!-- spring security -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- spring security -->

<!-- jwt -->
<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.1</version>
</dependency>
<!-- jwt -->

구현할 객체

  1. UserDetails 의 구현체인 PrincipalDetails(VO)

  2. DB에서 User 정보를 가져오는 Service, DAO

  3. JWT 토큰 생성/관리/인증 하는 객체 JwtTokenProvider

  4. FilterChain에서 JWT 인증을 담당해주는 JwtAuthenticationFilter

  5. 스프링 시큐리티 설정을 담당하는 SecurityConfiguration

  6. 인증, 인가 과정에서 발생하는 예외를 처리할 CustomAccessDeniedHandlerCustomAuthenticationEntryPoint

1. UserDetails 의 구현체인 PrincipalDetails(VO)

public interface UserDetails extends Serializable {
  // 계정이 가지고 있는 권한 목록을 리턴
	Collection<? extends GrantedAuthority> getAuthorities();
  
  // 계정의 비밀번호를 리턴
	String getPassword();
  
  // 계정의 이름을 리턴, 일반적으로 ID
	String getUsername();
  
  // 계정이 만료됐는지 리턴, true는 만료되지 않음
	boolean isAccountNonExpired();
  
  // 계정이 잠겨있는지 리턴, true는 잠기지 않음
	boolean isAccountNonLocked();
  
  // 계정의 비밀번호가 만료됐는지 리턴, true는 만료되지 않음
	boolean isCredentialsNonExpired();
  
  // 계정이 활성화돼 있는지 리턴, true는 활성화 상태
	boolean isEnabled();
}

Spring Security에서 제공하는 인터페이스인 UserDetails 가 있다. 인터페이스의 구현체를 만들어줘야 한다. 구현체의 이름은 PrincepalDetails으로 했다.

// PrincepalDetails.java

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PrincipalDetails implements UserDetails {
    private UserVO userVO;

    public PrincipalDetails(UserVO userVO) {
        this.userVO = userVO;
    }

    public UserVO getUserVO() {
        return userVO;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    private Collection<? extends GrantedAuthority> authorities;


    // 계정이 가지고 있는 권한 목록을 리턴
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    // 계정의 이름을 리턴, 일반적으로 아이디
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return userVO.getUserId();
    }

    // 계정의 비밀번호를 리턴
    @Override
    public String getPassword() {
        return userVO.getPassword();
    }

    // 계정이 만료됐는지 리턴, true는 만료되지않았다.
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겼있는지 리턴. true는 잠기지 않았다.
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 비밀번호가 만료됐는지 리턴. true는 만료되지 않았다.
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정이 활성화돼 있는지 리턴. true는 활성화
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

2. DB에서 User 정보를 가져오는 Service, DAO

JPA에서는 DAO가 아닌 Repository 가 되겠다. User의 고유 키를 가지고 데이터 베이스에서 User의 프로퍼티를 리턴하는 형태를 만들어주면 된다. 메소드의 이름은 getByUid() 로 하였다.

3. JWT 토큰 생성/관리/인증 하는 클래스 JwtTokenProvider

//JWT Token 관련 객체
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserService userService;

    // application.properties
    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60;     //1시간
    private final long tokenValidRefresh = 1000L * 60 * 60 * 24 * 7;     //1주일


    //PostConstruct : 해당 객체(클래스)가 빈 객체로 주입된 이후 수행된다는 걸 명시
    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

  
  	// AccessToken 생성
    public String createAccessToken(String userUid, String userNo, String name) {
        LOGGER.info("[createAccessToken] 토큰 생성 시작 name : {}", name);

        return createToken(userUid, userNo, name, "access");
    }

  
  	// RefreshToken 생성
    public String createRefreshToken(String userUid, String userNo, String name) {
        LOGGER.info("[createRefreshToken] 토큰 생성 시작 name : {}", name);


        return createToken(userUid, userNo, name, "refresh");
    }



    // SecurityContextHolder 에 저장할 Authentication 을 생성
    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작") ;
        PrincipalDetails principalDetails = new PrincipalDetails(userService.getByUid(this.getUsername(token)));

        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}", principalDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities());
    }




    //토큰에 저장한 데이터 추출(payload 에서 sub 을 꺼냄)
    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();

        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
        return info;
    }



    //클라이언트의 요청 헤더에 있는 'X-AUTH-TOKEN' 을 가져옴
    public String resolveToken(HttpServletRequest httpServletRequest) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출 getServletPath : {}", httpServletRequest.getServletPath());
        return httpServletRequest.getHeader("X-AUTH-TOKEN");
    }



    // 토큰의 클레임에 저장된 유효기간을 체크하고 boolean 타입의 값으로 리턴한다.
    public boolean validateToken(String token) {
        LOGGER.info("[validateToken] 토큰 유효 체크 시작 : {}", token);
        try {
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);

            return !claimsJws.getBody().getExpiration().before(new Date());
        } catch (Exception e) {     //유효기간이 끝난 토큰일 경우, 예외 발생
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }


		// 토큰을 생성함
    public String createToken(String userUid, String userNo, String name, String type) {
        LOGGER.info("[createToken] 토큰 생성 시작 name : {}, type : {}", name, type);
        Claims claims = Jwts.claims().setSubject(userUid);  //JWT의 제목
        claims.put("no", userNo);       //공개 클레임
        claims.put("username", name);

        Date now = new Date();
        Date expirationDate;
        if(type.equals("access")) {
            expirationDate = new Date(now.getTime() + tokenValidMillisecond);
        } else {
            expirationDate = new Date(now.getTime() + tokenValidRefresh);
        }

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }
}

3 - 1 SecretKey

SecretKey는 고유의 토큰 패턴을 만들기 위해 사용된다. 만약 토큰을 검증할 때, 다른 곳에서 만들어진 JWT 토큰으로 인증되면 안되기 때문에 SecretKey를 정의해주고 Base64 형식으로 인코딩해준다.

// 인코딩 전 원본 문자열
testApp

// base64 인코딩 후
dGVzdEFwcA==

해당 secretKey는 숨겨져야하기 때문에 application.properties 에 정의하고, @Value 어노테이션을 사용해 값을 가져올 수 있게 해준다.

Git commit 할 때, 해당 key의 기록이 안남도록 할 것...

3 - 2 SecretKey의 초기화

@PostConstruct 어노테이션은 해당 객체가 Bean 객체로 주입된 이후 수행되는 메서드를 가리킨다. JwtTokenProvider는 @Component 어노테이션이 지정되어 있어 Spring에 의해 관리되는 객체이고, Bean 에 주입되면서 @PostConstruct 이 지정되어 있는 init() 가 스프링에 의해 자동으로 호출된다.

3 - 3 getAuthentication()

public Authentication getAuthentication(String token) {
	LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작") ;
	PrincipalDetails principalDetails = new PrincipalDetails(userService.getByUid(this.getUsername(token)));

	LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}", principalDetails.getUsername());
	return new UsernamePasswordAuthenticationToken(principalDetails, "", principalDetails.getAuthorities());
    }

이 메서드는 필터에서 인증에 성공했을 때 SecurityContenxtHolder에 저장할 정보를 생성하는 역할을 한다. SecurityContextHolder 는 시큐리티가 인증한 내용들을 가지고 있으며, 인증된 사용자의 정보를 꺼낼 수 있다.


4. FilterChain에서 JWT 인증을 담당해주는 JwtAuthenticationFilter

JwtAuthenticationFilter 는 JWT 토큰을 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스이다.

//JWT 토큰의 유효성 확인 후, SecurityContextHolder 에 추가하는 필터 (보안필터 체인에 추가됨, SecurityConfiguration에 의해)
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest,
                                    HttpServletResponse httpServletResponse,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String token = jwtTokenProvider.resolveToken(httpServletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);

        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
				if(token != null && jwtTokenProvider.validateToken(token)) {
					Authentication authentication = jwtTokenProvider.getAuthentication(token);
					SecurityContextHolder.getContext().setAuthentication(authentication);
					LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
				}
      
      	filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

메서드 내부 로직을 살펴보면 JwtTokenProvider 를 통해 httpServletRequest에서 토큰을 추출하고, 토큰에 대한 유효성을 검사한다. 토큰이 유효하다면 Authentication 객체를 생성하여 SecurityContextHolder에 추가하는 작업을 수행한다.


5. 스프링 시큐리티 설정을 담당하는 SecurityConfiguration

스프링 시큐리티 활성화와 설정을 위한 클래스로 SecurityConfiguration 을 만들어준다. 해당 클래스는 앞서 만들었던 JwtAuthenticationFilter 을 FilterChain에 등록될 수 있게 해주고, 인증/인가에 관한 설정 등등 여러가지 보안 관련된 설정을 해줄 수 있다.

/*
 * configure(HttpSecurity httpSecurity)
 * 리소스 접근 권한 설정
 * 인증 실패 시 발생하는 예외 처리
 * 인증 로직 커스텀마이징
 * csrf, cors 등의 스프링 시큐리티 설정
 */
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    private static final Logger LOGGER = LoggerFactory.getLogger(SecurityConfiguration.class);

    private final JwtTokenProvider jwtTokenProvider;

    @Autowired
    public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }



    /*
     * configure(HttpSecurity httpSecurity)
     * 리소스 접근 권한 설정
     * 인증 실패 시 발생하는 예외 처리
     * 인증 로직 커스텀마이징
     * csrf, cors 등의 스프링 시큐리티 설정
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable()  //UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화
                .csrf().disable()           //CSRF 비활성화, REST API 에서는 CSRF 보안이 필요 없음
                .cors().and()

                .sessionManagement()        // 세션 관련 설정
                .sessionCreationPolicy(     // 세션을 사용 여부
                        SessionCreationPolicy.STATELESS       // REST API 에선 필요없음
                ).and()



                .authorizeRequests()    //애플리케이션에 들어오는 요청에 대한 사용 권한
                .antMatchers(
          							"/sign-api/sign-in", 
          							"/sign-api/sign-up",
                        "/sign-api/exception")
          			.permitAll()  //해당 URI는 모두 허용, 권한이 필요없다는 뜻
                .antMatchers("**exception**").permitAll()   //'exception' 단어는 모두 허용
          
          			.anyRequest().hasRole("ADMIN")	// 해당 권한을 가진 토큰을 허용함
          																			// 앞에 .antMatchers([URL])이 없는 경우 모든

                .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())   //권한이 안맞을 때 exception

                .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) //인증 실패 exception

                .and()  //보안 필터체인에 필터 등록
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class);
    }



    /*
     * configure(WebSecurity webSecurity)
     * 인증, 인가가 적용되기 전 동작하는 설정 (인증, 인가가 필요없는 URI를 등록함)
     */
    @Override
    public void configure(WebSecurity webSecurity) throws Exception {
        webSecurity.ignoring()
          .antMatchers("/sign-api/sign-in")
          .antMatchers("/sign-api/sign-up");
    }
}

WebSecurityConfigurerAdapter 을 상속 받아 SecurityConfiguration 을 구현하게 되는데, 이때 주요 메서드는 두 가지이다. WebSecurityHttpSecurity 를 파라미터로 받게 되는 두가지의 configure() 메서드이다.

먼저 살펴볼 메서드는 HttpSecurity를 파라미터로 받는 configure() 이다. 스프링 시큐리티의 설정은 대부분 해당 메서드를 통해 진행한다.

  • 리소스 접근 권한 설정
  • 인증 실패 시 발생하는 예외 처리
  • 인증 로직 커스텀마이징
  • csrf, cors 등의 스프링 시큐리티 설정

WebSecurity 를 매개변수로 받는 configure()HttpSecurity 보다 앞단에서 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다. 즉, 인증과 인가가 필요 없는 요청은 여기서 등록하여 인증 처리를 안할 수 있다.

5 - 1. 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

필터체인에서 인증과 인가 과정에서 권한이 안맞을 때(accessDeniedHandler), 인증 실패 시(authenticationEntryPoint)에 대한 예외 처리를 해주는 클래스를 작성해야한다.

먼저 AccessDeniedHandler

// AccessDeniedHandler.java

// 토큰의 권한이 맞지 않을 때, 발생하는 에러
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
        LOGGER.info("[handle] 접근이 막힌 경우 경로 리다이렉트");
        response.sendRedirect("http://localhost:8080/rest/v1/sign-api/exception");
    }
}

AccessDeniedHandlerAccessDeniedHandler 를 상속받아 사용하는데, handle() 을 오버라이딩하여 사용한다. 위 방법에서는 예외를 처리하는 URL로 리다이렉트를 하여 처리하였다.

AuthenticationEntryPoint

// AuthenticationEntryPoint.java

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private static final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException, ServletException {
        ObjectMapper objectMapper = new ObjectMapper();
        LOGGER.info("[commence] 인증 실패로 response.sendError 발생");

        EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
        entryPointErrorResponse.setMsg("인증이 실패하였습니다.");

        response.setStatus(403);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
    }
}
// EntryPointErrorResponse.java

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
 private String msg;
}

AuthenticationEntryPoint에서는 인증에 실패한 상황을 처리하는 클래스이다. AuthenticationEntryPoint 를 상속 받아 commence() 를 오버라이딩하여 사용한다. 위 방법에서는 직접 Response에 에러를 정의하여 사용한다.

프로젝트에 위 방법대로 사용하고 나서 문득 생각이 났는데, 사용자가 직접 정의한 예외가 있다면 그걸 사용해도 될 거 같다... 이건 나중에 내가 테스트해서 '스프링 부트 예외처리'를 포스팅하면서 수정하려고한다.



Token 인증 흐름

1. 로그인

  1. 로그인 시, 아이디와 비밀번호를 DB와 비교하여 일치하는지 확인
  2. 일치 시, accessToken과 refreshToken을 생성
  3. refreshToken은 DB에 저장, accessToken은 클라이언트로 응답해줘서 사용할 수 있게함

2. 인증 절차

  1. 서버에 API 요청이 들어옴
  2. FilterChain에 등록해준 JwtAuthenticationFilter 의 의해서 Header에 있는 토큰을 확인함
    1. 유효하지 않으면 403 Error
    2. 만료된 토큰인 경우 401 Error로 refreshToken을 검사할 수 있게한다.
profile
개발은 자신감

0개의 댓글