티스토리 뷰

사용자 인증 방식은 일반적으로 세션 기반 방식토큰 기반 방식(JWT)이 있습니다.

각 방식의 장단점이 있어 상황에 맞게 결정하여 사용하는 것이 중요합니다.

 

지난 팀 프로젝트의 경우에는 서버 사이드 렌더링 방식이기 때문에 세션 방식을 사용해 구현했었는데요.

이번 개인 프로젝트에서는 클라이언트 사이드 렌더링 방식으로 한 웹 페이지이기 때문에 확장성을 고려하여 토큰 기반 방식으로 구현해보려고 합니다.

 

한참을 공부하고 헤매이면서 구현한 과정이라

추후에 필요한 부분은 수정, 또 수정을 거쳐서 상세히 기록해보려고 합니다.

 


 

1️⃣ 라이브러리 설정

Spring Security와 JWT를 사용하기 위해 다음 라이브러리들을 추가해줍니다.

build.gradle

dependencies {
 
   // Spring Security
   implementation 'org.springframework.boot:spring-boot-starter-security'
 
   // JWT
   implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
   implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
   implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

 


 

2️⃣ JwtToken DTO 생성

클라이언트에 토큰을 보내기 위해 JwtToken DTO를 생성합니다.

JwtToken.java

package com.example.demo.jwt;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Builder;

@Builder
@Data
@AllArgsConstructor
public class JwtToken {
    private String grantType;
    private String accessToken;
    private String refreshToken;
}
  1. grantType: JWT의 인증 타입을 나타내는 문자열 필드입니다.
  2. accessToken: 접근 토큰을 나타내는 문자열 필드입니다.
  3. refreshToken: 갱신 토큰을 나타내는 문자열 필드입니다.
  • grantType은 OAuth 2.0과 같은 인증 프로토콜에서 사용되는 용어이며, 클라이언트가 서버로부터 액세스 토큰을 요청할 때 사용되는 인증 유형을 지정합니다. 일반적으로 OAuth 2.0의 grantType 중 하나로는 "authorization_code", "password", "client_credentials", "refresh_token" 등이 있습니다.
  • Bearer 인증 방식은 JWT의 토큰을 HTTP 요청의 Authorization 헤더에 포함하여 전송하는 방식을 의미합니다. 

 

3️⃣ application.properties 추가

jwt.secret=qwrmlasfmlqw2491u4291urjeiqowfjkwklfnlksdnfi13fnou3nounf13

 

 


 

4️⃣ JwtTokenProvider

JSON Web Token (JWT) 을 생성하고 유효성을 검사하는 데 사용되는 클래스 입니다.

사용자가 로그인하거나 권한이 필요한 엔드포인트에 액세스할 때 JWT를 생성하고 제공하며, 나중에 클라이언트로부터 제출된 JWT를 사용하여 사용자를 인증하고 권한을 확인합니다.

이 클래스에서 JWT 토큰의 생성, 복호화, 검증 기능을 구현했습니다.

package com.example.demo.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j // 롬복을 이용하여 로깅을 위한 Logger 선언
@Component
public class JwtTokenProvider {

    private final Key key; // JWT 서명을 위한 Key 객체 선언

   // @Autowired
   // private RefreshTokenInfoRedisRepository refreshTokenInfoRepository; // RefreshToken 정보를 저장하기 위한 Repository

    // 생성자를 통한 JWT 서명용 Key 초기화
    // application.property에서 secret 값 가져와서 key에 저장
    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey); // Base64로 인코딩된 Secret Key 디코딩
        this.key = Keys.hmacShaKeyFor(keyBytes); // Secret Key를 이용하여 Key 객체 생성
    }

    // 유저 정보를 이용하여 AccessToken과 RefreshToken을 생성하는 메서드
    public JwtToken generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime(); // 현재 시각 가져오기
        Date issuedAt = new Date(); // 토큰 발급 시각

        //Header 부분 설정
        Map<String, Object> headers = new HashMap<>();
        headers.put("alg", "HS256");
        headers.put("typ", "JWT");

        // Access Token 생성
        String accessToken = Jwts.builder()
                .setHeader(createHeaders()) // Header 부분 설정
                .setSubject("accessToken") // 토큰 주제 설정
                .claim("iss", "off") // 토큰 발급자 설정
                .claim("aud", authentication.getName()) // 토큰 대상자 설정
                .claim("auth", authorities) // 사용자 권한 설정
                .setExpiration(new Date(now + 1800000)) // 토큰 만료 시간 설정 (30분)
                .setIssuedAt(issuedAt) // 토큰 발급 시각 설정
                .signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘 설정
                .compact(); // 토큰 생성

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setHeader(createHeaders()) // Header 부분 설정
                .setSubject("refreshToken") // 토큰 주제 설정
                .claim("iss", "off") // 토큰 발급자 설정
                .claim("aud", authentication.getName()) // 토큰 대상자 설정
                .claim("auth", authorities) // 사용자 권한 설정
                .claim("add", "ref") // 추가 정보 설정
                .setExpiration(new Date(now + 604800000)) // 토큰 만료 시간 설정 (7일)
                .setIssuedAt(issuedAt) // 토큰 발급 시각 설정
                .signWith(key, SignatureAlgorithm.HS256) // 서명 알고리즘 설정
                .compact(); // 토큰 생성

        // TokenInfo 객체 생성 및 반환
        return JwtToken.builder()
                .grantType("Bearer") // 토큰 타입 설정
                .accessToken(accessToken) // Access Token 설정
                .refreshToken(refreshToken) // Refresh Token 설정
                .build(); // TokenInfo 객체 생성
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내 Authentication 객체를 생성하는 메서드
    public Authentication getAuthentication(String token) {
        // Jwt 토큰 복호화
        Claims claims = parseClaims(token);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 가져오기
        Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication return
        // UserDetails: interface, User: UserDetails를 구현한 class
        UserDetails principal = new User((String) claims.get("aud"), "", authorities);

        // UsernamePasswordAuthenticationToken 객체 반환
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // JWT 토큰의 유효성을 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // 토큰 파싱하여 유효성 검증
            return true; // 유효한 토큰일 경우 true 반환
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            throw new ApiException(ExceptionEnum.INVALID_TOKEN); // 토큰이 잘못된 경우 예외 처리
        } catch (ExpiredJwtException e) {
            throw new ApiException(ExceptionEnum.TIMEOUT_TOKEN); // 토큰이 만료된 경우 예외 처리
        } catch (UnsupportedJwtException | IllegalArgumentException e) {
            throw new ApiException(ExceptionEnum.INVALID_TOKEN); // 지원하지 않는 토큰이거나 잘못된 형식의 경우 예외 처리
        } catch (Exception e){
            throw new ApiException(ExceptionEnum.INVALID_TOKEN); // 그 외 예외 처리
        }
    }

    // RefreshToken을 이용하여 AccessToken을 재발급하는 메서드
    public JwtToken refreshToken(String refreshToken) {
        try {
            // Refresh Token 복호화
            Authentication authentication = getAuthentication(refreshToken);
            // Redis에 저장된 Refresh Token 정보 가져오기
            RefreshTokenInfo redisRefreshTokenInfo = refreshTokenInfoRepository.findById(authentication.getName()).orElseThrow();

            JwtToken refreshGetToken = null;
            // Redis에 저장된 Refresh Token과 요청된 Refresh Token이 일치할 경우
            if (refreshToken.equals(redisRefreshTokenInfo.getRefreshToken())) {
                refreshGetToken = generateToken(authentication); // 토큰 재발급

                saveToken(refreshGetToken, authentication); // Redis에 새로운 Refresh Token 정보 저장
                return refreshGetToken; // 새로운 토큰 반환
            } else {
                log.warn("does not exist Token"); // Redis에 저장된 Refresh Token이 존재하지 않을 경우
                throw new ApiException(ExceptionEnum.TOKEN_DOES_NOT_EXIST); // 해당 예외 처리
            }
        } catch (NullPointerException e) {
            log.warn("does not exist Token"); // Refresh Token이 존재하지 않을 경우
            throw new ApiException(ExceptionEnum.TOKEN_DOES_NOT_EXIST); // 해당 예외 처리
        } catch (SignatureException e) {
            log.warn("Invalid Token Info"); // 토큰 정보가 잘못된 경우
            throw new ApiException(ExceptionEnum.INVALID_TOKEN_INFO); // 해당 예외 처리
        } catch (NoSuchElementException e) {
            log.warn("no such Token value"); // Redis에 해당 토큰이 존재하지 않을 경우
            throw new ApiException(ExceptionEnum.TOKEN_DOES_NOT_EXIST); // 해당 예외 처리
        }
    }

    // JWT 토큰을 파싱하여 클레임 정보를 반환하는 메서드
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); // 토큰 파싱하여 클레임 정보 반환
        } catch (ExpiredJwtException e) {
            return e.getClaims(); // 만료된 토큰의 경우 클레임 정보 반환
        }
    }

    // JWT 토큰의 Header 정보를 생성하는 메서드
    private static Map<String, Object> createHeaders() {
        Map<String, Object> headers = new HashMap<>();
        headers.put("alg", "HS256"); // 알고리즘 정보 설정
        headers.put("typ", "JWT"); // 토큰 타입 정보 설정
        return headers; // 생성된 Header 정보 반환
    }
}

 

💚 JwtTokenProvider()

application.properties에서 secret 값을 가져와 key에 저장하는 메서드 입니다.

입력된 시크릿 키는 Base64로 인코딩되어 있으며, 이를 디코딩하여 HMAC 알고리즘을 사용하여 키 객체를 생성합니다.

 

💚 generateToken()

  • 인증(Authentication) 객체를 기반으로 Access Token과 Refresh Token 생성
  • Access Token: 인증된 사용자의 권한 정보와 만료 시간을 담고 있는 토큰
  • Refresh Token: Access Token의 갱신을 위해 사용되는 토큰

 

💚 getAuthentication()

  • 주어진 Access token을 복호화하여 사용자의 인증 정보(Authentication)를 생성
  • 토큰의 Claims에서 권한 정보를 추출하고, User 객체를 생성하여 Authentication 객체로 반환

 

💚 validateToken()

  • 주어진 토큰을 검증하여 유효성을 확인
  • Jwts.parserBuilder를 사용하여 토큰의 서명 키를 설정하고, 예외 처리를 통해 토큰의 유효성 여부를 판단

 

💚 parseClaims()

  • 클레임(claims): 토큰에서 사용할 정보의 조각
  • 주어진 Access token을 복호화하고, 만료된 토큰의 경우에도 Claims 반환
  • parseClaimsJws() 메서드가 JWT 토큰의 검증과 파싱을 모두 수행

 


 

5️⃣ JwtAuthenticationFilter 구현

Spring Security에서 사용되는 필터 중 하나로, JWT 토큰을 사용하여 사용자의 인증을 처리하는 역할을 합니다.

  • HTTP 요청의 인증
    클라이언트가 HTTP 요청을 보낼 때, JwtAuthenticationFilter는 헤더나 요청의 다른 부분에서 JWT 토큰을 추출합니다.
  • JWT 토큰의 유효성 검사
    추출된 JWT 토큰의 유효성을 검사합니다. 토큰의 서명을 확인하고 만료 여부 등을 확인하여 유효한 토큰인지 확인합니다.
  • 사용자 인증
    유효한 JWT 토큰이 확인되면 해당 토큰에서 사용자 정보를 추출합니다. 이를 기반으로 Spring Security의 Authentication 객체를 생성합니다.
  • 인증된 사용자의 보안 컨텍스트 설정
    Authentication 객체를 SecurityContext에 저장하여 현재 사용자의 인증 정보를 설정합니다. 이렇게 함으로써 사용자는 보호된 엔드포인트에 액세스할 수 있습니다.
package com.example.demo.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider; // JWT 토큰을 처리하기 위한 JwtTokenProvider 객체

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException, IOException {
        // Request Header에서 JWT 토큰 추출
        String token = resolveToken(request);

        // 토큰 유효성 검사 후 인증 처리
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 인증 객체 생성
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext에 인증 객체 설정
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 다음 필터로 이동
        filterChain.doFilter(request, response);
    }

    // Request Header에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7); // "Bearer " 부분 제거하고 토큰 반환
        }
        return null;
    }
}

 

💚 doFilter()

 

  1. resolveToken() 메서드를 사용하여 요청 헤더에서 JWT 토큰을 추출
  2. JwtTokenProvider의 validateToken() 메서드로 JWT 토큰의 유효성 검증
  3. 토큰이 유효하면 JwtTokenProvider의 getAuthentication() 메서드로 인증 객체 가져와서 SecurityContext에 저장 ➡︎ 요청을 처리하는 동안 인증 정보가 유지됩니다. 
  4. chain.doFilter()를 호출하여 다음 필터로 요청을 전달

 

💚 resolveToken()

  • 주어진 HttpServletRequest에서 토큰 정보를 추출하는 역할
  • "Authorization" 헤더에서 "Bearer" 접두사로 시작하는 토큰을 추출하여 반환

 


 

6️⃣ SecurityConfig 설정

Spring Security의 설정을 담당하는 SecurityConfig를 작성합니다.

			// (버전에 따라 변경된 부분) 기본 로그인 폼을 사용하지 않도록 설정
			.formLogin((form) -> form.disable())   
			// 로그아웃은 아래 코드 말고 따로 설정할거임.
			.logout((logout) -> logout.permitAll());   // 로그아웃 허용
			
		// 필터 통해서 토큰 기반 로그인을 할거다.
		// UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되도록 등록
		http.addFilterBefore(new JwtAuthenticationFilter(JwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
				.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);

 

전체 코드

package com.example.demo.config;

import com.example.demo.jwt.JwtAuthenticationFilter;
import com.example.demo.jwt.JwtExceptionFilter;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.Collections;

@Configuration
@EnableWebSecurity    // 스프링 시큐리티 필터가 스프링 필터체인에 등록됨.
@RequiredArgsConstructor
public class WebSecurityConfig {

	private final JwtTokenProvider jwtTokenProvider;

	@Bean
	public PasswordEncoder passwordEncoder(){
		return new BCryptPasswordEncoder();
	}

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
            @Override
            public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                CorsConfiguration config = new CorsConfiguration();
                config.setAllowedOrigins(Collections.singletonList("http://localhost:3000/"));
                config.setAllowedMethods(Collections.singletonList("*"));
                config.setAllowCredentials(true);
                config.setAllowedHeaders(Collections.singletonList("*"));
                config.setMaxAge(3600L); //1시간
                return config;
            }
        }));
		
		// csrf 토큰 없이 요청하면 해당 요청을 막기 때문에 잠깐 비활성화
		 http.csrf(csrf -> csrf.disable())
			// 인증절차 설정 시작 => 특정 URL에 대한 권한 설정.
			.authorizeHttpRequests((requests) -> requests	
					
				// 모두 허용
				// "/api/v1/auth/n/**" 라고 설정해줄 수도 있음. => 사용자 토큰 없이(n) 접근 허용.
				.requestMatchers("/api/v1/auth/n/**").permitAll()	
				
				// 관리자=선생님(ROLE_ADMIN)만 접근 허용
				// "/api/v1/auth/admin/**" 라고 설정해줄 수도 있음.
				//.requestMatchers("/admin/**").hasRole("ADMIN") => ROLE_ 접두사가 자동으로 들어감.
				.requestMatchers("/admin/**").hasAnyAuthority("ROLE_ADMIN")
				
				// 로그인한 사용자(ROLE_USER)만 접근 허용
				// "/api/v1/auth/y/**" 라고 설정해줄 수도 있음.
				.requestMatchers("/api/v1/auth/y/**").hasAnyAuthority("ROLE_USER")
				
				// TODO: 최고 권한의 관리자 필요함. ( 필수는 아님. 추후 고려해보기. )
				// 나머지 요청은 인증된 사용자에게만 접근 허용
				.anyRequest().authenticated()    
			)

			// (버전에 따라 변경된 부분) 기본 로그인 폼을 사용하지 않도록 설정
			.formLogin((form) -> form.disable())   
			// 로그아웃은 아래 코드 말고 따로 설정할거임.
			.logout((logout) -> logout.permitAll());   // 로그아웃 허용
			
		// 필터 통해서 토큰 기반 로그인을 할거다.
		// UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되도록 등록
		http.addFilterBefore(new JwtAuthenticationFilter(JwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
				.addFilterBefore(new JwtExceptionFilter(), JwtAuthenticationFilter.class);


		return http.build();
	}

}

 

💚  필터를 통해서 토큰 기반 로그인을 하겠다고 알리는 부분

addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class): JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가하여 JWT 인증을 처리 ( UsernamePasswordAuthenticationFilter 이전에 JwtAuthenticationFilter가 실행되도록 등록 )

 


 

7️⃣ 인증을 위한 Domain, Repository 설정

 

UserDetails interface를 구현합니다.

package com.example.demo.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Entity(name = "user")
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder  
public class UserEntity implements UserDetails {
	
	 @Id
	 @GeneratedValue(strategy = GenerationType.IDENTITY)
	 private Long id;
	 
	 @Column(name = "user_Id", nullable = false, length = 30, unique = true)
	 private String userId;
	 
	 @Column(name = "password", nullable = false, length = 50)
	 private String password;
	 
	 @Column(name = "name", nullable = false)
	 private String name;
	 
	 @Column(name = "birth", nullable = false)
	 private String birth;   
	 
	 @Column(name = "email", nullable = false)
	 private String email;
	 
	 @Column(name = "addr1", nullable = true)
	 private String addr1;
	 
	 @Column(name = "addr2", nullable = true)
	 private String addr2;
	 
	 // PuppyEntity 일대다 관계 추가
	 @OneToMany(cascade = CascadeType.ALL)   
	 private List<PuppyEntity> puppyEntityList;
		 
	 // CommentEntity 일대다 관계 추가 & 조인
	 @OneToMany(cascade = CascadeType.ALL)   
	 @JoinColumn(name = "id")   // 외래키 지정
	 private List<CommentEntity> commentEntityList;
	 
	 // BoardEntity 일대다 관계 추가 & 조인
	 @OneToMany(cascade = CascadeType.ALL)
	 @JoinColumn(name = "id")
	 private List<BoardEntity> boardEntityList;
	 
	 // LikeListEntity 일대다 관계 추가 & 조인
	 @OneToMany(cascade = CascadeType.ALL)
	 @JoinColumn(name = "id")
	 private List<LikeListEntity> likeListEntityList;
	 

	 
	 
	/**
	* 해당 유저의 권한 목록
	*/	 
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return null;
	}

	/**
	* 사용자의 비밀번호
	*/
	@Override
	public String getPassword() {
		return password;
	}

	/**
	* PK값
	*/
	@Override
	public String getUsername() {
		return userId;
	}

    /**
     * 계정 만료 여부
     * true : 만료 안됨
     * false : 만료
     * @return
     */
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

    /**
     * 계정 잠김 여부
     * true : 잠기지 않음
     * false : 잠김
     * @return
     */
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

    /**
     * 비밀번호(인증정보) 만료 여부
     * true : 만료 안됨
     * false : 만료
     * @return
     */
	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

    /**
     * 사용자 활성화 여부
     * true : 활성화
     * false : 비활성화
     * @return
     */
	@Override
	public boolean isEnabled() {
		return true;
	}
	 
}
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함