티스토리 뷰

이번 포스팅에서는 실제로 코드를 통해 회원가입 및 JWT를 이용해 토큰을 발급해주고, 페이지 권한까지 해보는 시간을 가지도록 하겠다. 메인 기능인 로그인 구현을 하기 전에 간단하게 회원가입 로직을 짜보자. 로그인 구현에 중점을 두기 때문에 설명은 생략하고 코드만 보여주도록 하겠다.

 

회원가입 패키지 구조

 

 

Member

public class Member extends BaseTimeEntity {

        @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;

        private String email;

        private String nickname;

        private int age;

        private String password;

        @Enumerated(EnumType.STRING)
        private Role role;

        public void addUserAuthority() {
            this.role = Role.USER;
        }

        public void passwordEncode(PasswordEncoder passwordEncoder) {
            this.password = passwordEncoder.encode(password);
        }
}

 

Role

public enum Role {
    ROLE_USER, ROLE_ADMIN
}

 

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findByEmail(String email);
}

 

MemberSignUpRequestDto

@Getter
@AllArgsConstructor
@Builder
public class MemberSignUpRequestDto {

    private String email;
    private String nickname;
    private int age;
    private String password;

    public Member toEntity() {
        return Member.builder()
                .email(email)
                .nickname(nickname)
                .age(age)
                .password(password)
                .build();
    }
}

 

MemberSignUpResponseDto

@Getter
public class MemberSignUpResponseDto {

    private Long id;
    private String email;
    private String nickname;
    private int age;
    private String password;

    public MemberSignUpResponseDto(Member member) {
        this.id = member.getId();
        this.email = member.getEmail();
        this.nickname = member.getNickname();
        this.age = member.getAge();
        this.password = member.getPassword();
    }
}

 

MemberService

public interface MemberService {

    Long join(MemberSignUpRequestDto requestDto);
}

 

MemberServiceImpl

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public Long join(MemberSignUpRequestDto requestDto) {
        if(memberRepository.findByEmail(requestDto.getEmail()).isPresent()) {
            throw new IllegalArgumentException("이미 가입된 이메일입니다.");
        }

        Member member = memberRepository.save(requestDto.toEntity());
        member.passwordEncode(passwordEncoder);
        member.addUserAuthority();
        return member.getId();
    }
}

 

MemberApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberApiController {

    private final MemberService memberService;

    @PostMapping("/join")
    public Long join(@RequestBody MemberSignUpRequestDto requestDto) {
        return memberService.join(requestDto);
    }
}

 

BaseTimeEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseTimeEntity {

    @CreatedDate
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime modifiedDate;
}

 

여기까지 회원가입 로직이 완성되었다. 이제 로그인 구현을 하면서 설명하도록 하겠다.

구현하기전에 Security를 사용하기 위한 준비를 해보자.

 

build.gradle

dependencies {
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'mysql:mysql-connector-java'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

 

완성된 패키지 구조

 

 

yml

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/DB이름?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: root
    password: 비밀번호

  security:
    jwt:
      header: Authorization
      #echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
      secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
      token-validity-in-seconds: 604800

  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    generate-ddl: true
    hibernate:
      ddl-auto: create-drop
    show_sql: true
    format_sql: true

  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher
#    default_batch_fetch_size: 1000

logging.level:
  org.hibernate.SQL: debug
  org.hibernate.type: trace
  # parameter Binding

 

그럼 이제 구현을 해보자.

 

Member

public class Member extends BaseTimeEntity implements UserDetails {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String nickname;

    private int age;

    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    public void addUserAuthority() {
        this.role = Role.USER;
    }

    public void passwordEncode(PasswordEncoder passwordEncoder) {
        this.password = passwordEncoder.encode(password);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> auth = new ArrayList<>();
        auth.add(new SimpleGrantedAuthority(role.name()));
        return auth;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

Spring Security는 UserDetails객체를 통해 권한 정보를 관리하기 때문에 UserDetails를 구현한 구현체가 반드시 있어야 한다. 그래서 엔티티에 상속받아 구현하였다. 즉 Member은 UserDetails의 구현체이다.

 

SecurityConfig

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin().disable()
                .httpBasic().disable()
                .cors().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/member/join", "/member/login").permitAll()
                .antMatchers("/member").hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

}

 

시큐리티 기본세팅을 하기 위해 WebSecurityConfigureAdapter 클래스를 상속받았다.

기본세팅을 하려면 configure(HttpSecurity http)를 오버라이딩해서 재정의를 하면 된다.

 

간단하게 설명하자면,

 

  • formLogin().disable() 

시큐리티를 의존성에 추가하게 되면 기본적으로 지원하는 페이지가 뜬다. 그 페이지를 비활성화 시켰다. 우리는 rest api로 개발할 것이기 때문. (그런데 굳이 formLogin은 disable처리를 할 필요가 없다. configure를 오버라이딩 시 자동으로 disable처리가 되는 것 같다. 

 

  • httpBasic().disable()

이것도 마찬가지다. 기본적으로 alert창이 띄워지는데, 우리는 rest api로 개발할 것이기 때문에 비활성화 시켜주었다.

 

  • cors().disable()

프론트와 백엔드와의 협업에서 하는 설정이라는데, 우리는 백엔드만 할 것이기 때문에 비활성화 시켜주자.

 

  • csrf().disable()

위 이유와 마찬가지다. (cors는 disable처리를 하지 않아도 상관없지만, csrf는 API 서버를 개발한다면 명시해줘야한다. 

 

  • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

세션을 STATELESS 시켜주는 코드다. SpringSecurity는 기본적으로 쿠키, 세션을 지원하고 있는데, 우리는 JWT를 사용할 것이기 때문에 세션을 사용하지 않기 위해 STATELESS 시켜주었다.

 

  • and()

딱히 이거다 할 정의는 없으며 대부분 구역을 나눌 때 사용된다고 한다.

 

  • authorizeRequests()

시큐리티 처리를 ServletHttpRequest 방식으로 처리한다고 나와있는데, 나는 이 코드 밑부분에 적은 코드들에 대해서 요청에 대한 권한을 지정할 수 있게 하기 위함이라고 이해했다.

 

  • antMatchers("/member/join", "/member/login")

""안에 경로를 지정해준다. 

 

  • permitAll()

누구나 다 접근이 가능하다. 라는 뜻이다. 만약 로그인과 회원가입 페이지도 권한이 있어야 한다면, 말이 안된다.

 

  • hasRole("USER")

우리는 아까 회원가입 로직을 작성할 때 회원가입 요청을 하면 USER권한을 준것을 볼 수 있다.

antMatchers("/특정경로").hasRole("USER") 이 코드를 해석하면, "특정경로에 대해서는 USER권한이 있는 사용자만 접근이 가능합니다." 라고 할 수 있다. 

 

  • anyRequest().authenticated();

나머지 요청들은 다 인증을 하겠습니다. 라는 뜻이다.

 

@Bean
public PasswordEncoder passwordEncoder() {
	return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

 

이 코드는 비밀번호를 암호화 시키기 위함이다.

 

CustomUserDetailsService

@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return memberRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다."));
    }
}

 

코드를 먼저 확인하기 전에 흐름부터 다시 짚고 넘어가자.

동작원리를 자세하게 알고싶다면 이 포스팅을 보고 오자.

 

 

로그인 요청이 들어오면, AuthenticationFilter가 받는다고 했다.

아이디와 비밀번호를 받은 AuthenticationFilterUsernamePasswordAuthenticationToken을 만들어 그 안에 넣는다.

AuthenticationFilterAuthenticationManager에게 UsernamePasswordAuthenticationToken을 전달한다고 했는데,

정확히 말하면 AuthenticationManager의 구현체인 ProviderManager에게 전달한다. 

그리고 다시 ProviderManagerUsernamePasswordAuthenticationToken을 처리할 수 있는 AuthenticationProvider에게 전달한다. 전달받은 AuthenticationProvider는 로그인을 요청한 사용자의 정보와 DB에 저장되어있는 사용자의 정보를 비교해야 하는데,

이미 로그인을 요청한 사용자의 정보는 사용자의 정보가 담겨있는 UsernamePasswordAuthenticationToken을 전달받은  AuthenticationProvider가 가지고 있다. 그럼 DB에 저장되어있는 사용자의 정보를 가져와야 하는데, 이 역할을 UserDetailsService가 하는 것이다. 그럼 사용자의 정보를 찾기 위해 AuthenticationProviderUserDetailsService의 메서드인 loadUserByUsername 파라미터로 로그인을 요청한 사용자의 정보인 아이디를 넘겨준다. 

 

여기서 잠깐 멈춰서, 다시 CustomUserDetailsService를 설명하겠다.

 

@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return memberRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("사용자가 존재하지 않습니다."));
    }
}

 

UserDetailsService를 상속받아 loadUserByUsername을 재정의 했다. 그럼 파리미터로 들어온 email은 로그인 요청한 사용자의 아이디가 되는 것이다. 파라미터 이름때문에 헷갈릴 수 있는데, 정리하면 다음과 같다.

 

1. 사용자는 로그인페이지에서 이메일(아이디)이랑 비밀번호를 입력함

2. 이메일이랑 비밀번호는 서버로 전송됨

3. ... (위에서 말했던 과정이 진행중)

4. AuthenticationProvider는 loadUserByUsername 파라미터로 로그인을 요청한 사용자의 정보인 아이디(이메일)를 넘겨준다. 

 

파라미터로 받은 후 DB에 사용자 정보를 찾은 후에 UserDetails 형태로 반환해준다. 

즉, CustomUserDetailsService의 역할은 로그인 요청을 보낸 사용자의 정보(아이디)를 DB에서 찾는 역할이다.

 

JwtTokenProvider

코드를 설명하기 전에 어떻게 토큰으로 로그인 인증과 로그인을 유지하는지 알아보자.

 

 

1. 사용자가 아이디와 비밀번호를 입력함으로써 로그인 요청을 서버한테 보낸다.

2. 서버는 받은 아이디와 비밀번호로 회원가입한 사용자인지 인증한다.

3. 인증된 사용자라면 토큰을 발급해준다.

4. 응답과 함께 토큰을 사용자한테 보내준다.

5. 이후 사용자는 요청을 할 때마다 토큰과 함께 보낸다.

6. 서버는 토큰을 검증하고 그에 대응하는 응답을 보낸다. 

 

그럼 다시 코드를 보면서 설명하겠다.

 

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    @Value("spring.jwt.secret")
    private String secretKey;

    // 토큰 유효시간 168 시간(7일)
    private long tokenValidTime = 1440 * 60 * 7 * 1000L;
    private final CustomUserDetailsService customUserDetailsService;

    // secretKey 를 Base64로 인코딩합니다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public String createToken(String email, String role) {
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("role", role); // 정보는 key/value 쌍으로 저장됩니다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 유효 시간
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(this.getUserEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserEmail(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request 의 Header 에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

yml에 적었던 문자열로 된 토큰을 @Value를 통해서 가져옴

@Value("spring.jwt.secret")
private String secretKey;

 

가져온 문자열로 된 토큰을 Base64로 인코딩

@PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

 

토큰을 생성하는 메서드

public String createToken(String email, String role) {
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("role", role); // 정보는 key/value 쌍으로 저장됩니다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // 토큰 유효 시간
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘
                .compact();
    }

 

파라미터로 로그인요청을 한 사용자의 아이디와 권한을 받는것을 볼 수 있다.

그리고 페이로드에 사용자의 아이디와 권한, 토큰을 생성한 시간, 토큰의 유효시간, 사용할 알고리즘과 토큰문자열을 넣는다.

발급받은 토큰을 미리 봐보자.

 

Postman에서 회원가입을 하고 로그인을 하면 다음과 같은 토큰을 주는 것을 볼 수 있다.

토큰을 복사해서 복호화시켜주는 사이트에 가서 넣으면 우리가 토큰을 생성하면서 payload에 넣어주었던 요소들이 보인다.

 

토큰에서 인증정보를 조회하는 메서드

public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(this.getUserEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
public String getUserEmail(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

 

일단 getUserEmail 메서드부터 보자.

토큰을 파라미터로 받고 getBody().getSubject() 를 하면 사용자의 아이디가 반환된다.

반환된 사용자의 아이디를 우리가 구현했던 customUserDetailsService.loadUserByUsername에 넣고 UserDetails 형태로 사용자의 정보가 반환된다. 그리고 UsernamePasswordAuthenticationToken() 안에 넣는것을 볼 수 있는데, 

UsernamePasswordAuthenticationToken은 인증된 사용자의 아이디, 비밀번호, 권한을 파라미터로 받는다.

비밀번호를 ""한 이유는 사용자의 아이디는 우리가 직접 CustomUserDetailsService를 만들어서 찾았지만, 비밀번호는 SpringSecurity에서 알아서 다 해준다고 한다. 

 

조금 이해가 가지 않을 수도 있다. UsernamePasswordAuthenticationToken을 반환하는 이유는 JwtAuthenticationFilter부분에서 나온다. 

 

사용자가 요청했을 때 헤더에서 토큰을 가져오는 메서드

public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

 

토큰 유효성 검사

public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

 

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtAuthenticationProvider;

    public JwtAuthenticationFilter(JwtTokenProvider provider) {
        jwtAuthenticationProvider = provider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtAuthenticationProvider.resolveToken(request);

        if (token != null && jwtAuthenticationProvider.validateToken(token)) {

            Authentication authentication = jwtAuthenticationProvider.getAuthentication(token);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);

    }
}

 

JwtAuthenticationFilter의 역할이 무엇일까 ?

토큰의 인증정보를 검사한 후 SecurityContext에 저장한다.

 

계속해서 말하고 있지만 로그인 요청한 사용자의 정보는 AuthenticationFilter에 전달받는다고 했다.

하지만 우리는 커스터마이징을 해서 JwtAuthentication필터를 AuthenticationFilter보다 앞에 두어서 사용자의 정보를

먼저 받을 것이다. 왜 이렇게 할까 ?

 

로그인을 한 사용자가 요청을 했다고 가정해보자. 요청을 할 때는 헤더에 토큰을 함께 실어서 보낸다.

아까 JwtAuthenticationFilter의 역할은 인증정보를 검사한 후 SecurityContext에 저장한다고 했다. 

JwtAuthenticationFilter를 AuthenticationFilter보다 앞에 두면 헤더에 있는 토큰을 인증된 토큰인지 검사한 후,

인증되었으면 바로 응답을 날릴 수 있고, 인증되지 않았으면 다음 필터로 넘어간다. 즉 효율적인 처리가 가능하다는 것이다. 

 

Request의 헤더에서 토큰을 불러와서 저장한다.

String token = jwtAuthenticationProvider.resolveToken(request);

 

토큰이 유효한지 검사한다.

 if (token != null && jwtAuthenticationProvider.validateToken(token))

 

만약 유효하다면 토큰안에 있는 유저정보를 authentication객체로 저장한 후 SecurityContext에 저장한다.

Authentication authentication = jwtAuthenticationProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);

 

Custom한 필터 등록

filterChain.doFilter(request, response);

 

로그인 로직

먼저 dto 패키지 안에 로그인 요청 dto를 만들어주자.

 

MemberSignInRequestDto

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberSignInRequestDto {

    private String email;
    private String password;
}

 

그리고 MemberService에 아래 메서드를 추가해주자. 

MemberService

String login(MemberSignInRequestDto requestDto);

 

MemberServiceImpl

@Transactional
@Override
public String login(MemberSignInRequestDto requestDto) {
    Member member = memberRepository.findByEmail(requestDto.getEmail())
             .orElseThrow(() -> new new IllegalArgumentException("가입된 이메일이 아닙니다."));
    validateMatchedPassword(requestDto.getPassword(), member.getPassword());

    String role = member.getRole().name();
    return jwtTokenProvider.createToken(member.getUsername(), role);
    }

 

그럼 한번 Postman으로 확인해보자.

 

회원가입을 해보면 200OK가 잘 떴다.

 

Db에 잘 저장된 것을 볼 수 있다.

 

그럼 로그인을 해보자.

 

토큰이 발급된 것을 볼 수 있다!

 

 

최종코드는 깃허브에 있으므로 참고해주길 바란다. 

그리고 코드를 고치면서 문제점들이 발생하면 아래를 참고해보자. 

 

회원가입 하는데 loadUserByUsername Exception이 터지면 여기

 

회원가입 로직보다 loadUserByUsername 메서드가 먼저 실행되는 현상

오류 Spring Security를 구현하고 있을 때 회원가입을 시도했을때의 오류다. 이 예외는 내가 UserDetailsService를 커스텀한 UserDetailsService에서 로그인요청할 때 입력한 아이디가 DB에 존재하지 않을 때 터

skatpdnjs.tistory.com

 

401, 403 에러가 뜨면 여기

 

Request의 Header에서 토큰값이 안가져와지는 현상

오류 요청에서 Header에 토큰을 넣었는데도 서버에서 Header에 있는 토큰을 가져오지 못하는 상황이 발생했다. 헤더 키 값을 다르게 했더니 나타나는 반응이 다른것을 보니까 키값을 읽어들이는 건

skatpdnjs.tistory.com

혹시 로그를 찍어봤는데 값이 찍히지 않는다면 , 대신 +를 사용해보길 바랍니다. 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday