728x90

 

개요

1편에서 기본적인 세팅은 끝이 났다. 이제 본격적으로 Security와 JWT를 사용하기 위한 코드를 짜볼 것이다.

2편에서는 SecurityConfig에 대해서 알아보자.

 

 

<- 대략적인 파일 구조

 

 

 

 

 

 

SecurityConfig.java

/**
 * 웹 애플리케이션의 보안을 설정
*/

@Configuration // 이 클래스가 Spring의 설정 클래스를 나타낸다는 것을 의미
@EnableWebSecurity // Spring Security를 활성화
@RequiredArgsConstructor // final로 선언된 필드를 위한 생성자 자동 생성
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService; // 사용자 세부 정보 로드 서비스
    private final JwtTokenizer jwtTokenizer; // JWT 토큰을 생성하고 검증하는 도구
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; // 인증 실패 시 처리할 커스텀 엔트리 포인트
    private final JwtBlacklistService jwtBlacklistService; // 블랙리스트에 등록된 JWT를 관리하는 서비스
    private final RefreshTokenService refreshTokenService; // 리프레시 토큰을 관리하는 서비스

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	// 특정 URL 패턴에 대한 접근 권한 설정
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers("/userregform", "/loginform", "/login", "/css/**", "/js/**", "/files/**", "/", "/api/login", "/api/**").permitAll() // 이 주소로 시작되면 인증 필요 없음
                        .requestMatchers("/posts/**", "/{username}", "/about/{username}", "/ws","/chat").permitAll() // 이 주소로 시작되면 인증 필요 없음
                        .requestMatchers("/admin/**", "/admin").hasRole("ADMIN") // 관리자 역할을 가진 사용자만 접근
                        .requestMatchers("/comments/post/**", "/likes/post/**").authenticated() // 인증된 사용자만 접근
                        .anyRequest().authenticated() // 그 외의 모든 요청은 인증된 사용자만 접근
                )
                // UsernamePasswordAuthenticationFilter 앞에 JWT 인증 필터를 추가하여 JWT를 통한 인증을 처리
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenizer, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable()) // 기본 폼 로그인 비활성화
                .sessionManagement(sessionManagement -> sessionManagement
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션을 사용하지 않음
                // CSRF 보호 비활성화
                .csrf(csrf -> csrf.disable())
                // http 기본 인증(헤더) 비활성화
                .httpBasic(httpBasic -> httpBasic.disable())
                // CORS (Cross-Origin Resource Sharing)를 구성 및 허용 
                .cors(cors -> cors.configurationSource(configurationSource()))
                // 사용자 정의 인증 진입 지점을 처리하기 위한 예외 처리를 구성
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                );
        return http.build(); // SecurityFilterChain을 빌드 후 반환
    }

    /**
     * 모든 origin, header, method를 허용하는 CORS 구성 소스 정의
     */
    @Bean
    public CorsConfigurationSource configurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("*"); //모든 도메인 허용
        config.addAllowedHeader("*"); //모든 HTTP 메서드 허용
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE")); // 명시적으로 GET, POST, DELETE 메서드 허용
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    /**
     * BCryptPasswordEncoder 빈 정의
     * 비밀번호를 암호화하는 데 사용
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

어노테이션에 대한 설명이나 기본적인 건 주석을 안할려고 했지만... 그래도 공부되고 좋으니깐^-^

 

Q. 여기서 세션을 사용하지 않은 이유는 ??

기본적으로 JWT의 정의를 살펴보면,  

- 서버가 클라이언트의 상태를 저장하지 않고, 클라이언트가 요청할 때마다 JWT를 서버로 전달하여 인증을 수행한다.

- 클라이언트의 모든 요청은 JWT에 의해 독립적으로 인증되며, 서버는 JWT를 검증하여 요청의 유효성을 확인한다. 이로 인해 서버는 클라이언트 상태를 유지할 필요가 없다.

즉, 

 

 

1. JWT 는 세션 기반 인증 방식의 한계를 극복하기 위해 설계된 것이다.

2. JWT 는 페이로드에 사용자 정보와 권한을 포함하고 서버는 JWT 를 검증하여 인증을 처리한다. 

    즉, 서버는 추가적인 상태 정보를 유지할 필요가 없다. 

3. 그렇기에 filterChain에서 세션을 비활성화 시킨 것이다.

 

 

User, Role, Enum 엔티티 설정

  • User.java
@Entity
@Getter@Setter
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;

    @Column(name = "user_name", nullable = false, unique = true)
    private String userName;

    @Column(name = "user_nick", nullable = false, unique = true)
    private String userNick;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;
}

유저 엔티티이다. 

 

  • Role.java
@Entity
@Table(name = "roles")
@Getter@Setter
public class Role {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long roleId;

    @Enumerated(EnumType.STRING)
//    @Column(nullable = false, unique = true, length = 50)
    private RoleName roleName;

}

역할 엔티티이다. 이때 역할 이름은 enum 타입으로 지정했다.

 

  • RoleName.enum
public enum RoleName {
    ROLE_ADMIN,
    ROLE_USER
}

 

 

 

 

 

 

3편에서... jwt를 사용하기에 앞서 필요한 엔티티와 레포지토리, 서비스를 작성하고 4편부터 본격적으로 jwt 를 사용할 것이다.

728x90
728x90

 

개요

개인 프로젝트를 진행하는 중 시큐리티 부분은 아주 중요하고 헷갈리기 때문에 정리해놓기 위해 포스팅 하기로 했다.

gradle 의존성을 추가 한 후 -> 프로젝트 파일 구조에 맞게 패키지와 관련 클래스를 작성한 후 -> 프로젝트에 필요한 환경변수를 세팅하면 기본적인 작업은 끝난다. 

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

 

프로젝트 파일 구조

 

<- 다음과 같은 구조로 프로젝트를 진행하는데
이 중 시큐리티+jwt를 사용하기에 필요한 패키지는 

"config, controller, domain, dto, repository, security, service" 이다.

이 패키지 중 만들 클래스들은 연두색으로 표시한 부분만 만들면 된다.

 

 

 

 

의존성 설치

// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'

// lombok
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// 타임리프에서 스프링시큐리티를 쓸수있게.
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
implementation 'org.jsoup:jsoup:1.14.3'

// jwt & json
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

// gson - json 메시지를 다루기 위한 라이브러리
implementation 'com.google.code.gson:gson'

// validation사용
implementation 'jakarta.validation:jakarta.validation-api:3.0.2'

 

필요한 환경변수 세팅

 

jwt secretKey와 refreshKey에 대해서 .yml 파일에 작성해준다. 

시크릿 키를 얻는 방법은 아래에 적어놨다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

PowerShell의 Get-Random cmdlet을 사용하여 64바이트의 랜덤 데이터를 생성하고 이를 16진수로 변환하여 시크릿 키를 얻는다.

# 64바이트의 랜덤 데이터 생성
$bytes = @(0..63 | ForEach-Object { Get-Random -Maximum 256 })

# 16진수로 변환
$hex = $bytes.ForEach({ "{0:x2}" -f $_ }) -join ""

# 결과 출력
$hex

 

참고로 맥에서는 다음과 같이 하면 시크릿 키를 얻을 수 있다.

openssl rand -hex 64

 

 

 

2편에서.. Security 설정에 대해 알아볼 것이다.

728x90
728x90

Spring Security(스프링 시큐리티) 란?

애플리케이션 내의 보안 중 사용자에 대한 ‘인증’과 ‘인가’에 대한 처리를 담당하는 프레임워크를 의미한다.
  • 접근 주체(Principal)
    • 보호된 대상에 접근하는 유저
  • 인증(Authenticate)
    • 현재 유저가 누구인지 확인 (ex 로그인)
    • 애플리케이션의 작업을 수행할 수 있는 주체임을 증명한다.
  • 인가(Authorization)
    • 현재 유저가 어떤 서비스, 페이지에 접근할 수 있는 권한이 있는지 검사한다.
    • 어떤것을 할 수 있는지
  • 권한 부여(Authorization)
    • 인증된 주체가 애플리케이션의 동작을 수행할 수 있도록 허락되었는지를 결정
    • 권한 승인이 필요한 부분으로 접근하려면 인증 과정을 통해 주체가 증명되어야 한다.
    • 권한 부여에도 두가지 영역이 존재하는데 웹 요청 권한, 메소드 호출 및 도메인 인스턴스에 대한 접근 권한 부여

스프링 시큐리티 특징과 구조

스프링 시큐리티에서는 주로 서블렛 필터(Filter)와 이들로 구성된 필터체인, 그리고 필터체인들로 구성된 위임모델을 사용한다.

Client (request) → Filter → DispatcherServlet → Interceptor → Controller

인증관리자(Authentication Manager), 접근 결정 관리자(Access Decision Manager)를 통해 사용자의 리소스 접근을 제어

  • 인증 관리자 ⇒ UsernamePasswordAuthenticationFilter
  • 접근 결정 관리자 ⇒ FilterSecurityInterceptor
  • 보안과 관련하여 체계적으로 많은 옵션을 제공하여 편리하게 사용할 수 있다.
  • Filter 기반으로 동작하여 MVC와 분리하여 동작한다.
  • 어노테이션을 통한 간단한 설정
  • 기본적으로 Session & Coockie 방식으로 인증한다. 

 

⚠️ Spring Security 5.7.x 버전에 대한 이슈 사항

public class SecurityConfig extends WebSecurityConfigurerAdapter{}
  • 5.6.x 버전 이하에서는 WebSecurityConfig 클래스에서 WebSecurityConfiguredAdapter를 상속받아서 사용했다
  • 5.7.x 버전부터는 Deprecated되어서 사용이 안됨!
    • 5.7.x 버전 이상부터는 컴포넌트 기반의 Configuration을 구성하는 것으로 권장된다. 

 


Spring Security Authentication Architecture

(1) 전체적인 Spring Security 흐름도와 (2) 아이디와 암호를 입력했을 때 이를 처리하는 필터는 AuthenticationFilter이다. 해당 필터는 다음 그림과 같은 순서로 동작한다.

  1. 사용자가 로그인 정보와 함께 인증 요청을 한다 → Http Request (사용자가 Form을 통해 로그인 정보를 입력하고 인증 요청을 보낸다.)
  2. AuthenticationFilter(사용할 구현체 UsernamePasswordAuthenticationFilter)가 HttpServletRequest에서 사용자가 보낸 아이디와 패스워드를 인터셉트(가로챈다, 확인)한다. 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
  3. HttpServletRequest에서 꺼내온 사용자 아이디와 패스워드를 진짜 인증을 담당할 AuthenticationManager인터페이스(구현체 - ProviderManager)에게 인증용 객체(UsernamePasswordAuthenticationToken) 객체를 전달한다.
  4. AuthenticationManager는 AuthenticationFilter에게 인증용 객체(UsernamePasswordAuthenticationToken)을 전달받는다.
  5. 실제 인증을 할 AuthenticationProvider에게 Authentication객체(UsernamePasswordAuthenticationToken)을 다시 전달한다.
  6. AuthenticationManager는 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구한다.
  7. 실제 DB에서 사용자 인증정보를 가져오는 UserDetailService에 사용자 정보를 넘겨준다.
    • DB에서 인증에 사용할 사용자 정보(사용자 아이디, 암호화된 패스워드, 권한 등)를 UserDetails라는 객체로 전달 받는다.
    • UserDetailsService는 인터페이스이다.
    • 즉, 해당 인터페이스를 구현한 빈(Bean)을 생성하면 스프링 시큐리티는 해당 빈을 사용하게 된다.
  8. UserDetailsService는 로그인한 ID에 해당하는 정보를 DB에서 읽어들여 UserDetails 객체를 만들어 세션에 저장한다.
  9. AuthenticationProvider는 UserDetails 객체를 전달 받은 이후 실제 사용자의 입력 정보와 UserDetails 객체를 가지고 인증을 시도한다.
  10. 인증이 완료되면 권한 등의 사용자 정보를 담긴 Authentication 객체를 반환한다.
  11. 다시 최초의 AuthenticationFilterAuthentication 객체가 반환된다.
  12. Authentication 객체를 인메모리 세션저장소인 SecurityContextHolder에 담는다.
    • 클라이언트(유저)에게 session ID(JSESSION ID)와 함께 응답을 한다. 이후 요청에서는 요청 쿠키에서 JSESSION ID정보를 통해 이미 로그인 정보가 저장되어 있는지 확인하고 이미 저장되어 있고 유효하면 인증처리를 한다.
    • 성공시 AuthenticationSuccessHandle를 실행한다.
    • 실패시 AuthenticationFailureHandler를 실행한다.
    • 사용자 정보를 저장한다는건 Session-Cookie 방식을 사용한다는걸 의미한다.

 

1. AuthenticationManager - 인증 담당

AuthenticationManager - 인증 담당 관리

  • 유저의 요청을 AuthenticationFilter에서 Authentication 객체로 변환해 AuthenticationManager(ProviderManager)에게 넘겨주고, AuthenticationProvider(DaoAuthenticationProvider)가 실제 인증을 한 이후에 인증이 완료되면 Authentication객체를 반환해준다.
  • AbstractAuthenticationProcessingFilter: 웹 기반 인증요청에서 사용되는 컴포넌트로 POST 폼 데이터를 포함하는 요청을 처리한다. 사용자 비밀번호를 다른 필터로 전달하기 위해서 Authentication 객체를 생성하고 일부 프로퍼티를 설정한다.
  • AuthenticationManager: 인증요청을 받고 Authentication을 채워준다.
  • AuthenticationProvider: 실제 인증이 일어나고 만약 인증 성공시 Authentication 객체의 authenticated = true로 설정해준다.
  • Spring Security 는 ProviderManager라는 AuthenticationManager인터페이스의 유일한 구현체를 제공한다. ProviderManager 는 하나 또는 여러 개의 AuthenticationProvider 구현체를 사용할 수 있다. AuthenticationProvider는 많이 사용되고 ProviderManager(AuthenticationManager 의 구현체) 와도 잘 통합되기 때문에 기본적으로 어떻게 동작하는 지 이해하는 것이 중요하다.

 

2. Security Context Holder

  • Spring Security는 인증이 완료되면 아이디, 패스워드를 가진 사용자의 principal 과 credential 정보를 Authentication에 담는다.
  • 그 Authentication 정보를 Security Context에 보관한다.
  • 그리고 그 Security Context를 Security Context Holder에 담아 보관한다.
  • 때문에, 사용자의 정보를 얻기 위한 코드는 다음과 같다.
Object principal = SecurityContextHolder.getContext.getAuthentication.getPrincipal();

if(principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

 

 

 

3. Password Authentication

 

  • DaoAuthenticationProvider 는 UserDetailsService 타입 오브젝트로 위임한다. UserDetailsService 는 UserDetails 구현체를 리턴하는 역할을 한다.
  • UserDetails 인터페이스는 이전에 설명한 Authentication 인터페이스와 상당히 유사하지만 서로 다른 목적을 가진 인터페이스이므로 헷갈리면 안된다.
  • Authentication : 사용자 ID, 패스워드와 인증 요청 컨텍스트에 대한 정보를 가지고 있다. 인증 이후의 사용자 상세정보와 같은 UserDetails 타입 오브젝트를 포함할 수도 있다.
  • UserDetails: 이름, 이메일, 전화번호와 같은 사용자 프로파일 정보를 저장하기 위한 용도로 사용

 


Spring Security Filter

Clinet -> FilterChain -> DelegatingFilterProxy (위임처리)  -> FilterChainProxy -> Security Filter Chain(시큐리티 필터 적용!)

ServletContainer → SpringContainer, Filter → Dispatcher Servlet

  • Spring Container와 Was 서버간에 Request 요청이 연결되어야 하는데 이를 수행하는 Filter가 DelegationFilterProxy다.
  • DelegatingFilterProxy의 내부에 FilterChainProxy라는 위임대상을 가지고 있다.
    • FilterChainProxy는 SpringSecurity에서 제공되는 특수 필터다.
    • SecurityFilterChain이라는 이름을 가진 Bean을 호출하여 SecurityFilter 역할을 수행한다.

 

 

  • 클라이언트(보통 브라우저)는 요청을 보내게 되고, 그 요청을 서블릿이나 JSP등이 처리하게 된다. 스프링 MVC에서는 요청을 먼저 받는것이 DispatcherServlet 이다.
  • 이 DispatcherServlet이 요청 받기 전에 다양한 필터들이 있을 수 있다.
  • 필터가 하는 역할은 클라이언트와 자원 사이에서 요청과 응답 정보를 이용해 다양한 처리를 하는데 목적이 있다.
    • 어떤 필터는 요청을 받은 후, 클라이언트가 원래 요청한 자원이 아닌 다른 자원으로 리다이렉트 시킬 수도 있다.
    • 또 다른 어떤 필터는 다음 필터에게 요청과 응답을 전달하지 않고, 바로 클라이언트에게 응답하고 끝낼 수 도 있다.
  • 스프링 시큐리티는 다양한 기능을 가진 필터들을 10개 이상 기본적으로 제공한다.
  • 이렇게 제공되는 필터들을 Security Filter Chain(시큐리티 필터 체인) 이라고 말한다.

 


SecurityFilterChain

 

SecurityFilterChain은 List의 형태로 구성되며, 이 리스트를 AuthenticationFilter라 부른다!

securityFilterChain 의 수많은 Filter (그냥 개념만 알면된다..)

 

 

  • (UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리함
    1. AuthenticationManager를 통한 인증 실행
  1. 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
  2. 인증 실패 시, AuthenticationFailureHandler 실행

 

 

 

 

728x90
728x90

기본적으로 Spring Security에서 사용자 인증을 처리하기 위해 UserDetailsService 인터페이스를 제공한다.

근데 실습을 진행하면서 두가지 방법으로 구현하는 게 있어서 차이가 뭔가 해서 정리하기로 했다.

 

1. 메모리를 사용한 사용자 인증 방법 - 1

@Bean
public UserDetailsService userDetailsService() {

    UserDetails user = User.withUsername("user")
            .password(passwordEncoder().encode("1234"))
            .roles("USER")
            .build();

    UserDetails admin = User.withUsername("admin")
            .password(passwordEncoder().encode("1234"))
            .roles("ADMIN")
            .build();

    UserDetails superuser = User.withUsername("superuser")
            .password(passwordEncoder().encode("1234"))
            .roles("SUPERUSER")
            .build();

    UserDetails ks = User.withUsername("ks")
            .password(passwordEncoder().encode("1234"))
            .roles("ADMIN", "USER")
            .build();

    return new InMemoryUserDetailsManager(user, admin, superuser, ks); // UserDetails 추가 필수
}

 

  • 저장소 : 메모리에 사용자 정보를 저장한다. 
    • 애플리케이션 재시작 시 사용자 정보가 초기화된다.
  • InMemoryUserDetailsManager를 사용하여 사용자를 저장한다.
  • User.withUsername, password, roles 메서드를 사용하여 사용자 정보를 설정한다.
  • passwordEncoder().encode("1234")를 사용하여 비밀번호를 암호화한다.
  •  테스트나 데모용으로 메모리에 사용자 정보를 저장하는 방식이다. 프로젝트 초기에 하면 좋을듯

 

 

 

 

2. 데이터베이스를 사용한 사용자 인증 방법 - 2

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
        if(user == null){
            throw new UsernameNotFoundException("사용자가 없습니다.");
        }
        // UserBuilder를 사용하여 UserDetails 객체를 생성 
        // withUsername(username)를 호출하여 사용자 이름을 설정
        UserBuilder userBuilder = org.springframework.security.core.userdetails.User.withUsername(username);
        userBuilder.password(user.getPassword()); // 암호화된 비밀번호를 설정
        userBuilder.roles(user.getRoles().stream().map(role -> role.getName()).toArray(String[]::new));

        return userBuilder.build(); // 완성된 UserDetails 객체를 반환
    }
}

 

  • 저장소 : 데이터베이스에서 사용자의 정보를 조회한다.
    • 사용자 정보의 변경, 추가, 삭제가 데이터베이스에서 이루어진다.
  • 사용자가 직접 만든 UserRepository를 사용하여 사용자 정보(데이터)를 가져온다.
  • UserBuilder를 사용하여 UserDetails 객체를 생성합니다.

 


두 번재 방식을 풀이 하자면,

 

UserDetailsService 인터페이스는 기본적으로 사용자 이름을 통해 사용자를 로드하는 기능을 제공한다. 

UserDetailsService 의 메서드 중  loadUserByUsername 메서드는 데이터베이스에서 주어진 사용자 이름(username)을 기반으로 사용자의 세부 정보를 검색하고 반환하는 역할을 하는 메서드이다.

 

  • UserBuilder를 사용하여 UserDetails 객체를 생성한다.
    • withUsername(username)를 호출하여 사용자 이름을 설정한다.
    • password(user.getPassword())를 호출하여 암호화된 비밀번호를 설정한다.
    • roles(user.getRoles().stream().map(role -> role.getName()).toArray(String[]::new))를 호출하여 사용자의 역할을 설정한다.
      • 이 과정에서 사용자의 역할을 문자열 배열로 변환한다.
  • loadUserByUsername 메서드를 통해 데이터베이스에서 사용자를 조회하고, UserDetails 객체로 변환하여 Spring Security가 이를 사용하여 인증을 처리할 수 있도록 한다.

 

 

결론:

두 방법 모두 UserDetailsService 인터페이스를 구현하여 사용자 인증을 처리하지만, 실제 애플리케이션에서는 두 번째 방법처럼 데이터베이스를 사용하는 방법이 더 일반적이다.

즉 첫 번째 방법은 주로 개발 초기 단계나 테스트 환경에서 유용하게 사용된다.

 

---> 2번재 방법을 사용하자!

 

728x90

+ Recent posts