Spring Security - JWT 발급 및 검증기능

TopOfTheHead·2025년 11월 22일

Spring Security

목록 보기
14/21

JWT 발급 절차

  • JWT 생성
    로그인계정을 기반으로 사용자 자격증명 ( Credential ) , 사용자 데이터 ( payload ) , RSA 키쌍을 인코딩하여 JWT 생성.

  • JWTHTTP RequestAuthentication Header의 일부로서 전송.
    Authorization:Bearer JWT코드

  • 서버에서 전송된 JWT를 Decoding
    JWT는 암호화가 적용되어 있으므로, EncodingJWTDecodingRSA 키쌍을 필요로 한다.

nimbus 의존성 추가

  • spring-boot-starter-oauth2-resource-server
    Spring Boot ApplicationSpring Security와 통합하여 OAuth 2.0 Resource Server로 설정하는 Library.

    。해당 라이브러리에 대한 의존성 추가 시 Nimbus JOSE+JWT 라이브러리도 추가

    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

@Configuration ClassJWT Encoder 및 필요한 Spring Bean 정의하기
KeyPair , RSAKey , JWKSource , JwtDecoder , JwtEncoder를 각각 Spring Bean으로 등록

@Configuration
@RequiredArgsConstructor
public class KeyConfiguration {
	private final PojoJwtProperties pojoJwtProperties;
	@Bean
	public KeyPair generateKeyPair() throws NoSuchAlgorithmException {
		// RSA 알고리즘을 사용하는 KeyPairGenerator 설정 및 Key객체 생성하여 SpringBean으로 등록
        // Key 크기 : 2048bit 설정
		KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
		return  keyPairGenerator.generateKeyPair();
	}
	@Bean
	// Spring Bean에 등록된 KeyPair을 매개변수로 의존성주입
	public RSAKey generateRSAKey(KeyPair keyPair){
		String keyId = pojoJwtProperties.getJwtProperties().keyId();
		return new RSAKey
			// Public Key객체를 RSAPublicKey로 casting하여 설정.
			.Builder((RSAPublicKey)keyPair.getPublic())
			// Private Key 객체를 RSAPrivateKey로 casting하여 설정.
			.privateKey((RSAPrivateKey)keyPair.getPrivate())
			// application.yml에 설정한 Key ID를 가져오기
			.keyID(keyId)
			.build();
	}
	@Bean
	// Spring Bean에 등록된 RSAKey를 매개변수로 의존성주입
	public JWKSource generateJWKSource(RSAKey rsaKey){
		JWKSet jwkSet = new JWKSet(rsaKey);
		return (((jwkSelector, securityContext) -> jwkSelector.select(jwkSet)));
	}
	@Bean
	// Spring Bean에 등록된 RSAKey를 매개변수로 의존성주입
	public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
		return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
	}
	@Bean
	// Spring Bean에 등록된 JWKSource<SecurityContext>를 매개변수로 의존성주입
	public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) throws JOSEException {
		return new NimbusJwtEncoder(jwkSource);
	}
}

RSA 암호화 알고리즘을 설정한 KeyPairGenerator를 통해 Key Pair 객체를 생성하여 Spring Bean으로 등록
@Configuration Class@Bean Method 정의

RSA 암호화 알고리즘을 통해 Public Key , Private Key로 구성된 Key Pair 객체를 생성하여 Spring Bean으로 등록
KeyPair을 필요로하는 Method의 매개변수로 자동으로 의존성 주입

@Bean
	public KeyPair generateKeyPair() throws NoSuchAlgorithmException {
		// RSA 알고리즘을 사용하는 KeyPairGenerator객체 생성 및 Key 크기 : 2048bit 설정
		KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
		return  keyPairGenerator.generateKeyPair();
	}

RSA 알고리즘으로 생성된 2048 bit 크기의 Public Key, Private Key를 포함한 KeyPair 객체가 생성되어 Spring Bean으로서 등록

KeyPair : import java.security.KeyPair;
KeyPairGenerator객체.generateKeyPair()를 통해 생성된 Public Key, Private Key로 구성된 Key Pair객체.

  • KeyPair객체.getPublic() :
    Key Pair객체의 Public Key를 호출하는 Method.

  • KeyPair객체.getPrivate() :
    Key Pair객체의 Private Key를 호출하는 Method.

KeyPairGenerator : import java.security.KeyPairGenerator;
Java에서 KeyPair객체 = 비대칭키 ( Public Key, Private Key )를 생성 시 활용하는 Class.
RSA , EC 등의 암호화 알고리즘에 적합한 Key Pair을 생성 가능.

  • KeyPairGenerator.getInstance("RSA") :
    。특정 암호화 알고리즘을 기반으로하는 KeyPairGenerator 객체를 생성.
    ( "RSA", "EC" , "DSA" 등 )
    ex) KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");

    해당 암호화 알고리즘이 없을 경우의 Exception을 반드시 정의
    try ~ catch문 또는 throws NoSuchAlgorithmException 정의

  • KeyPairGenerator객체.initialize(키크기) :
    KeyPairGenerator객체의 생성할 Key 크기를 설정.
    2048 설정 시 2048 bitKey가 생성됨.

  • KeyPairGenerator객체.generateKeyPair() :
    。비대칭키( Public Key + Private Key 쌍 )를 포함한 KeyPair 객체로서 생성.

생성될 Public KeyKeyIDapplication.yml에 정의 및 가져오기
application.yml개인정보정적으로 저장하면 안되므로 placeholder를 통해 사용자에 의해 동적으로 환경변수를 설정하여 입력받도록 설정
▶ 해당 key idKeyPairGenerator에 의해 생성된 Public Key는 향후 JWT 토큰 검증 시 식별되어 사용됨

。 추가적으로 AccessTokenRefreshToken만료시간yml파일에 지정하여 개발자소스코드를 수정하지않아도 단순하게 yml파일을 수정하여 만료시간을 수정하도록 설정

jwt:
  keyId: ${ketId:TopOfTheHeadKey}
  accessTokenExpiration: ${accssTokenMinute:5}
  refreshTokenExpiration: ${refreshTokenMinute:720}

@ConfigurationProperties를 통해 해당 값을 가져오기
@ConfigurationProperties

@ConfigurationProperties(prefix = "jwt")
public record JwtProperties(
                    String keyId, 
                    Long accessTokenExpiration, 
                    Long refreshTokenExpiration
                    ) {
}
@Getter
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(JwtProperties.class)
public class PojoJwtProperties {
	private final JwtProperties jwtProperties;
}

KeyPair 객체의존성 주입 받아서 RSAKey 객체 생성 후 Spring Bean으로 등록 : import com.nimbusds.jose.jwk.RSAKey;
Nimbus JOSE+JWT 라이브러리를 활용하여 RSAKey 객체 생성.

@Configuration Class@Bean Method를 정의하여 Spring Bean으로 주입된 KeyPair객체를 기반으로 Private Key, Public Key, Key ID를 각각 설정하여 RSAKey 객체를 생성 후 Spring Bean으로 등록

@Configuration
@RequiredArgsConstructor
public class KeyConfiguration {
	private final PojoJwtProperties pojoJwtProperties;
	@Bean
	// Spring Bean에 등록된 KeyPair을 매개변수로 의존성주입
	public RSAKey generateRSAKey(KeyPair keyPair){
		String keyId = pojoJwtProperties.getJwtProperties().keyId();
		return new RSAKey
			// Public Key객체를 RSAPublicKey로 casting하여 설정.
			.Builder((RSAPublicKey)keyPair.getPublic())
			// Private Key 객체를 RSAPrivateKey로 casting하여 설정.
			.privateKey((RSAPrivateKey)keyPair.getPrivate())
			// application.yml에 설정한 Key ID를 가져오기
			.keyID(keyId)
			.build();
	}
}

RSAKey : import com.nimbusds.jose.jwk.RSAKey; :
Nimbus JOSE+JWT 라이브러리에서 RSA기반 JWK( JSON Web Key )를 다루는 클래스

RSA 암호화 알고리즘으로 생성한 KeyPair instance의 Public KeyPrivate KeyJWK Format으로 변환하는 역할을 수행.
▶ 생성된 JWKJWT 검증시 사용.

  • RSAKey객체.Builder( RSAPublicKey객체 )
    RSAPublicKey 객체를 기반으로 RSAKey 객체 생성.
    (RSAPublicKey)keyPair.getPublic()

    RSAKey객체.privateKey() , RSAKey객체.keyID() 등의 추가 정의 없이 RSAKey객체.Builder( RSAPublicKey객체 )만 사용 시 Public Key만 포함된 JWK 객체 생성.

  • RSAKey객체.privateKey( RSAPrivateKey객체 )
    RSAKey 객체RSAPrivateKey 객체Private Key으로서 포함하는 메서드
    JWT Signature의 검증수행 시 Private Key가 필요하므로, 해당 메서드를 통해 JWKPrivate Key를 추가.
    (RSAPrivateKey)keyPair.getPrivate()

    RSAKey객체.Builder()에 추가 정의 후 .build()를 추가 선언하여 Private Key가 추가정의된 RSAKey 객체 생성.

  • RSAKey객체.toRSAPublicKey() :
    RSAKey 객체RSAPublicKey type의 객체로 변환

  • RSAKey객체.keyID(ID문자열)
    JWK의 고유식별자 ( kid )를 지정 시 사용.
    Authetication Server에서 JWKSet을 통해 여러개의 RSAPublicKey를 관리하므로 kid를 지정.
    kid를 정의할경우, 차후 JWT Signature의 검증 시 특정 Key의 선택 시 식별에 유용함.

  • RSAKey객체.build()
    。추가 설정된 RSAKey 객체를 생성.

RSAKey 객체의존성 주입 받아서 JWKSource ( JSON Web Key Source )을 생성 후 Spring Bean으로 등록
Spring Bean으로 주입된 RSAKey 객체를 통해 JWKSet 객체를 생성 후 JWKSource 객체를 생성하는 @Bean Method 정의

JWKSet 객체JWKSource 객체를 생성
JWKSource Interface의 instance를 생성 및 Abstract Method get()를 상속하여 JWKSelector.select(JWKSet객체)를 return
Abstract Method get()JWKSet으로부터 JWKSelector를 통해 특정조건에 부합하는 JWK를 선택하여 List<JWK>로 반환.

@Bean
	// Spring Bean에 등록된 RSAKey를 매개변수로 의존성주입
	public JWKSource generateJWKSource(RSAKey rsaKey){
		JWKSet jwkSet = new JWKSet(rsaKey);
		JWKSource jwkSource = new JWKSource(){
			@Override
			public List<JWK> get(JWKSelector jwkSelector, SecurityContext securityContext){
    // // JWKSet 객체로부터 JWKSelector를 통해 특정조건에 부합하는 JWK를 선택하여 List<JWK>로 반환.
				return jwkSelector.select(jwkSet);
			}
		};
		return jwkSource;
	}

람다식을 통해 간단하게 표현 가능.

@Bean
	// Spring Bean에 등록된 RSAKey를 매개변수로 의존성주입
	public JWKSource generateJWKSource(RSAKey rsaKey){
		JWKSet jwkSet = new JWKSet(rsaKey);
		return (((jwkSelector, securityContext) -> jwkSelector.select(jwkSet)));
	}

JWK ( JSON Web Key )
Public Key 정보를 JSON Format으로 표현한 데이터구조.
JWT Signature의 검증을 수행하기 위해서 Public Key가 필요하므로 주로 RSA방식의 JWTSignature를 검증 시 활용.


JWKSet :
。여러 개의 JWK를 하나로 묶어 관리하는 객체

  • JWKSet.toJSONObject() :
    JWKSet객체Object객체JSON Conversion

  • JWKSet.getKeyByKeyId(kid) :
    JWKSet이 포함한 특정 kid를 가진 Public Key를 포함하는 JWK를 검색하여 return.
    OAuth2.0 Authentication Server에 특정 Public KeyJWK를 제공 시 사용.

JWKSource :
JWT발급검증을 수행 시 활용하는 인터페이스.
JWT를 처리하는 Business logic를 추상화.

JWT의 검증 수행 시 JWKSource에 의해 JWT 검증에 필요한 Public Key를 제공.

public interface JWKSource<C extends SecurityContext> {
    List<JWK> get(JWKSelector var1, C var2) throws KeySourceException;
}
  • get(JWKSelector var1, C var2)
    JWT의 검증을 위해 적절한 Public Key를 포함하는 JWK를 찾는 역할을 수행하는 JWTSource InterfaceAbstract Method.
    ▶ 선택한 JWKList<JWK> type으로 반환

    JWKSelector 객체를 이용하여 특정 조건을 만족하는 JWK를 선택하도록 Filtering을 수행.

JWKSelector
。복수 이상의 JWK 중 특정 조건을 만족하는 JWK를 선택하는 역할을 수행.
▶ 복수 이상의 JWK가 존재하는 경우 JWKkid를 기반으로 특정 JWK를 선택.

  • JWKSelector.select(JWKSet객체)
    JWKSet에서 특정 조건에 부합하는 JWK를 선택하여 Return.

JWT디코딩검증을 수행하는 JwtDecoder객체Spring Bean으로 등록
JwtDecoder객체 의 경우 구현체NumbusJwtDecoderNumbusJwtDecoder.withPublicKey(RSAPublicKey객체) 메서드를 통해 생성하여 Spring Bean으로 등록

  • NimbusJwtDecoder.withPublicKey(RSAPublicKey객체) :
    사용자RSAPublicKey객체를 인자로 전달하여 Base64인코딩JWT디코딩Signature유효성검증을 수행하는 NimbusJwtDecoder 객체를 생성하는 static method
// Spring Bean에 등록된 RSAKey를 매개변수로 의존성주입
    @Bean
    public JwtDecoder jwtDecoder(RSAKey rsaKey) throws JOSEException {
        return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
    }
  • JwtDecoder객체.decode(JWT토큰)
    Bearer 접두사를 제거한 JWT 토큰을 인자로 전달 시 JWKSource에 사전에 등록되어 JWT 검증에 필요한 Public Key를 제공받아서 JWT토큰Signature, exp, iat 등을 모두 검증하는 메서드
    ▶ 해당 JWT유효성 검증을 수행하여 만료여부도 판단.
    ▶ 해당 JwtDecoderJWKSource를 통해 검증에 필요한 Public Key를 참조하여 해당 서버의 Public Key로 발급한 JWT토큰인지 검증을 수행

    Oauth 2.0 Server에서 활용하는 경우 Spring SecurityOAuth2.0 Resource Server로 동작 시 JWT Token을 자동으로 검증하지만, 기본 설정으로 JWT를 검증하는 JWK Set을 알 수 없으므로, auth server에서 제공하는 공개키인 JWK Set를 통해 비대칭키인 JWT의 서명을 검증.
@Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri("https://auth-server-url/.well-known/jwks.json").build();
    }

OAuth2.0Public Key를 사용하는 경우 다음처럼 설정

JWT EncoderSpring Bean으로 등록
RSA 암호화 알고리즘을 통한 Encrypt하여 JWT를 생성하는 JWT Encoder 구현.

JWKSource 객체주입받아 JwtEncoder 인터페이스구현체NimbusJwtEncoder 객체를 생성하여 Spring Bean으로 등록하는 @Bean Method 생성

// Spring Bean에 등록된 JWKSource<SecurityContext>를 매개변수로 의존성주입
    @Bean
    public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource){
        return new NimbusJwtEncoder(jwkSource);
    }

JwtEncoder
JWT를 생성(Encrypt)하는 역할을 수행하는 인터페이스
▶ 기본 구현체로서 NimbusJwtEncoder를 제공.

JwtDecoder와 함께 사용 시 JWT 기반의 Authentication System을 구현가능.

  • JwtEncoder.encode(JwtEncoderParameters객체) :
    JwtEncoderParameters 객체를 매개변수로 전달받아 JWT를 생성하여 반환하는 메서드
    JWT의 내용 ( Claims ) , 서명 ( Signature )를 포함하여 JWT를 생성.

NimbusJwtEncoder :
Spring Security에서 제공하는 JwtEncoder구현 Class
RSA등의 암호화 알고리즘을 활용한 JWT의 생성이 가능.

。생성자에 JWKSource 객체를 전달하여 NimbusJwtEncoder 객체를 생성 가능

JWT 발급검증 용도의 JWT Class 생성 및 Spring Bean으로 등록
로그인검증이 끝난 경우 해당 클래스를 통해 클라이언트에게 계정ID식별자로 하는 JWT 토큰을 발급하여 반환

RequestAuthorization Header에 포함된 JWT 토큰을 받아서 사전에 Spring Bean으로 등록한 JwtDecoder객체.decode(Bearer가 제거된 토큰)을 통해 검증을 수행
▶ 해당 JwtDecoderJWKSource를 통해 검증에 필요한 Public Key를 참조하여 해당 서버의 Public Key로 발급한 JWT토큰인지 검증을 수행
JWT검증 목적Filter 구현체에서 해당 검증기능을 사용
JWT를 검증하는 필터

@Component
@RequiredArgsConstructor
public class JwtService {
	private final JwtEncoder jwtEncoder;
	private final JwtDecoder jwtDecoder;
    private static final String ACCOUNTID_CLAIM_KEY = "accountId";
    private static final String ROLE_CLAIM_KEY = "role";
	private static final String EMAIL_CLAIM_KEY = "email";
	private final AccountRepository accountRepository;
	// JWT 토큰 발급
	public String issue(UUID accountId, Long expirationMinute){
        Account foundedAccount = accountRepository.findByIdOrThrow(accountId);
		var claims = JwtClaimsSet
			.builder()
			.subject(accountId.toString())
            .claim(ACCOUNTID_CLAIM_KEY,accountId.toString())
			.claim(ROLE_CLAIM_KEY, foundedAccount.getRole().toString())
			.claim(EMAIL_CLAIM_KEY, foundedAccount.getEmail())
			.issuer("all4runner")
			.issuedAt(Instant.now())
			.expiresAt(Instant.now().plusSeconds(60 * expirationMinute))
			.build();
		return jwtEncoder
        // 수집된 Claim 정보로 정적팩토리메서드로 JwtEncoderParameters 객체 생성 후
        // 해당 객체를 기반으로 JWT 토큰 생성하여 반환
			.encode(JwtEncoderParameters.from(claims))
			.getTokenValue();
	}
	// JWT 토큰 검증
	public boolean validate(String token){
		try {
			// 여기서 Signature, exp, iat 등을 모두 검증
			jwtDecoder.decode(token);
			return true;
		} catch (JwtException e) {
			// 서명 불일치, 만료, 포맷 오류 등 모든 예외
			return false;
		}
	}
	// Jwt Token Id 가져오기
	public Long parseId(String token){
		return Long.valueOf((String)jwtDecoder
			.decode(token)
			.getClaims()
			.get(ACCOUNTID_CLAIM_KEY));
	}
    // 로그인 시 구현된 JWT 필터에서 검증이 다 끝난 경우 해당 Token의 Claims 정보를 기반으로
    // SpringContext에 등록할 UserDetail 구현체
    public DefaultCurrentUser getUserDetailFromToken(String token){
		Map<String,Object> claims =  jwtDecoder
			.decode(token)
			.getClaims();
		UUID accountId = UUID.fromString(
			claims.get(ACCOUNTID_CLAIM_KEY).toString()
		);
		String email = claims.get(EMAIL_CLAIM_KEY).toString();
		AccountRole role = AccountRole.valueOf(
			claims.get(ROLE_CLAIM_KEY).toString()
		);
		return new DefaultCurrentUser(
				accountId,
				email,
				role
		);
	}
}

getUserDetailFromToken(String token)JWT 필터에서 검증이 완료된 Jwt 토큰Claims를 기반으로 UserDetail 구현체를 생성하여 반환
▶ 이후 해당 UserDetail 구현체를 기반으로 AuthenticationToken 클래스 객체를 생성하여 SecurityContext에 등록

Jwt :
org.springframework.security.oauth2.jwt.Jwt
Spring Security에서 JWT를 다룰때 사용하는 Class
JWT TokenHeader , Claims , Signature를 포함하는 Jwt instance를 생성하여 활용.

public final class Jwt {
    private final String tokenValue;   // 서명된 JWT 문자열
    private final Instant issuedAt;    // 발급 시간
    private final Instant expiresAt;   // 만료 시간
    private final Map<String, Object> headers;  // JWT 헤더 (alg, typ 등)
    private final Map<String, Object> claims;   // JWT 페이로드 (sub, exp, roles 등)
  	public String getTokenValue();         // 서명된 JWT 문자열 가져오기
	public Instant getIssuedAt();          // 발급 시간 가져오기
	public Instant getExpiresAt();         // 만료 시간 가져오기
	public Map<String, Object> getHeaders();  // JWT 헤더 가져오기
	public Map<String, Object> getClaims();   // JWT 페이로드(Claims) 가져오기
}

JwtEncoderParameters :
Spring Security에서 JWT를 생성 시 필요한 Parameter( Claim, Header 등 )를 포함하는 용도의 Class.
▶ 주로 JwtEncoder객체.encode(JwtEncoderParameters객체) method의 매개변수로 전달되어 JWT를 생성 시 활용됨.

JwtClaimSet 객체를 추가하여 사용자정보 ( 이름, 권한, 발급시간 ), expire date 등의 Claims를 설정.
JoseHeader 객체를 추가하여 JWT Header를 설정 가능.

  • JwtEncoderParameters.from(JoseHeader객체,JwtClaimsSet객체)
    JwtEncoder를 통해 JWTencrypt 하여 생성 시 필요한 parameter( JoseHeader, JwtClaimsSet )를 설정하는 역할을 수행하는 JwtEncoderParametersstatic method
    ▶ 해당 parameter를 포함한 JwtEncoderParameters instance를 생성하여 반환.

    。설정된 JwtEncoderParameters객체JwtEncoder객체.encode(JwtEncoderParameters객체)를 통해 JWT로 생성

JwtClaimSet :
JWTClaims 정보를 포함하는 용도로 사용되는 Class.
JWT에사용자정보 ( 이름, 권한, 발급시간 ), expire date을 포함할 수 있다.

JWT이 전달하는 Claim을 지시하는 JSON Object로서 활용됨.


JWT payloadClaim 종류
iss : ( issuer ) : 발급자
sub : ( subject ): 사용자 ID
aud : ( audiance ) : 대상자
exp : ( expiration ) : 토큰만료시간
iat ( Issued at ) : 토큰발급시간

JwtClaimsSet.builder()
        .issuer("my-app") // 발급자 설정
        .subject("user123") // 사용자 ID
        .issuedAt(Instant.now()) // 발급 시간
        .expiresAt(Instant.now().plusSeconds(60*60)) // 만료 시간 (1시간)
        .claim("roles", List.of("USER", "ADMIN")) // 사용자 역할 추가
        .claim("email", "user@example.com") // 추가 클레임
        .build();
  • JwtClaimsSet.builder() :
    JwtClaimsSet 객체 생성

  • JwtClaimsSet객체.issuer("발급자(iss)명칭") :
    JwtClaimsSet 객체에 발급자 추가.

  • JwtClaimsSet객체.subject("사용자ID(subject)") :
    JwtClaimsSet 객체에 사용자ID 추가.

  • JwtClaimsSet객체.issuedAt(Instant.now()) :
    JwtClaimsSet 객체에 발급시간(iat) 추가

  • JwtClaimsSet객체.expiresAt(Instant.now().plusSeconds(60 * 60)) :
    JwtClaimsSet 객체에 만료시간(exp) 추가

  • JwtClaimsSet객체.claim("설정할 Claim명", "설정될 Claim") :
    JwtClaimsSet객체Claim 추가
    Authentication 객체authorities -> authority key의 value를 할당하여 Jwt의 권한을 정의.
    ex) JwtClaimsSet객체.claim("roles",List.of("USER", "ADMIN")) :
    。사용자역할 역할의 커스텀 Claim 정의.

  • JwtClaimsSet객체.build() :
    Configuration이 정의된 JwtClaimsSet객체 생성.
profile
공부기록 블로그

0개의 댓글