로그인 처리

oh_eol·2024년 3월 13일
0

Spring MVC2

목록 보기
1/4
post-thumbnail

1_쿠키, 세션

✔️강의 밑작업

  • login 프로젝트를 열고 스프링 3 버전으로 바꾼 뒤 환경설정 마치기.
  • 홈 화면과 회원 가입 화면 html 파일을 작성한다.
  • domain 패키지 안에 핵심 객체인 Member와 이를 저장 및 관리할 저장소 역할의 MemberRepository를 작성하고, web 패키지 안에 MemberController를 작성한다.
    • 이 때 도메인은 핵심 비즈니스 업무 영역으로, 향후 web을 바꾸더라도 도메인은 그대로 유지할 수 있어야 한다.
    • 다시 말 해 domain은 web을 참조하면 안 된다!
  • domain 패키지 안에 LoginService를, web 패키지 안에 LoginFormLoginController 를 작성한다.
    • LoginService : 로그인의 핵심 비즈니스 로직을 작성한다. 회원을 조회한 다음 파라미터로 넘어온 password와 비교해서 같으면 회원을, 다르면 null 을 반환한다.
    • LoginForm : 로그인 시 받아올 파라미터인 loginId와 password을 검증 조건과 함께 작성한다.
    • LoginController : 로그인 서비스를 호출하여 로그인에 성공하면 홈 화면으로 이동하고, 실패하면 bindingResult.reject()를 사용해서 글로벌 오류(ObjectError)를 생성한다. 그리고 정보를 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.
  • loginForm.html 파일을 작성한다.(로그인 뷰)

✔️강의 목표

  • 여기까지 로그인의 성공과 실패 처리를 마쳤다. 이제 이번 강의의 목표인 로그인의 상태유지에 대해서, 실제 사용하는 세션과 그 작동방식 위주로 알아보자.

01. 쿠키를 사용하는 처리 방식

쿠키는 어떻게 동작하나?

  1. 클라이언트가 로그인을 시도한다.
  2. 서버에서 로그인 성공 시 HTTP 응답 안에 쿠키를 담아 클라이언트에 전달한다.
  3. 클라이언트는 받은 쿠키를 쿠키 저장소에 넣고 이후 모든 요청에 쿠키 정보를 자동으로 포함한다.

영속 쿠키, 세션 쿠키

  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
  • ✔️ 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

우리는 브라우저 종료 시 로그아웃을 기대하므로 세션 쿠키를 알아보자.

쿠키 생성 로직

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
  • 로그인 성공 시 쿠키를 생성하고 HttpServletResponse 에 담는다.
  • 쿠키 이름을 memberId로, 값은 회원의 id로 담아줬다.
  • 웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내준다.

이후 로그인 사용자 전용 홈 화면을 위한 HomeController 에서는 @CookieValue를 사용하여 편리하게 쿠키를 조회할 수 있다.

‼️ 쿠키와 보안 문제

  • 쿠키의 값은 임의로 변경될 수 있다.
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • 실습에서 id가 그대로 드러나는 것 처럼, 신용 카드 등의 중요 정보가 대놓고 보관된다→ 털린다!
  • 해커가 쿠키를 한 번 훔쳐가면 평생 사용할 수 있다.

✅ 대안

  • 중요한 값 대신 사용자 별 (예측 불가능한)랜덤 토큰을 노출하고, 서버에서 토큰과 사용자 id를 맵핑해서 인식한다.
  • 한 번 털려도 일정 시간 이상 지나면 사용할 수 없도록 해당 토큰의 만료시간을 짧게(기준 30분) 유지하고, 해킹 의심 시 해당 토큰을 서버에서 강제로 제거한다.

02. 세션 동작 방식

세션은 어떻게 동작하나?

  1. 클라이언트가 로그인을 시도한다.
  2. 서버에서 로그인 성공 시 세션 저장소에 세션 ID와 사용자 객체를 맵핑하여 저장하고, 세션 ID만!! 쿠키에 담아 클라이언트에 전달한다.
    • 이 때 세션 ID 는 java에서 지원하는 UUID를 사용하여 추정 불가능한 값으로 생성한다.
  3. 클라이언트는 받은 쿠키를 쿠키 저장소에 넣고 이후 모든 요청에 쿠키 정보를 자동으로 포함한다.
    • 클라이언트는 세션 ID만 알고, 회원과 관련된 정보는 모른다.

세션 직접 만들기

  • 세션 생성
    • sessionId 생성 (임의의 추정 불가능한 랜덤 값)
    • 세션 저장소에 sessionId와 보관할 값 저장
    • sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
  • 세션 조회
    • 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
  • 세션 만료
    • 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
package hello.login.web.session;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 세션 관리
 */
@Component
public class SessionManager {

    public static final String SESSION_COOKIE_NAME = "mySessionId";
    public Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response) {
        // 세션 id를 생성하고, 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value); // sessionId, 사용자 객체를 맵핑해서 저장

        // 쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션 조회
     */
    public Object getSession(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null) {
            return null;
        }

        return sessionStore.get(sessionCookie.getValue());
    }

    /**
     * 세션 종료
     */
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }

    private Cookie findCookie(HttpServletRequest request, String cookieName) {
        if (request.getCookies() == null) {
            return null;
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
}
package hello.login.web.session;

import hello.login.domain.member.Member;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.*;

class SessionManagerTest {

    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest(){
        // 세션 생성(서버->클라이언트 : 로그인 성공 시 세션 생성하여 클라이언트로 전달)
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response); // mySessionId=13524q243-1543e

        // 요청에 응답 쿠키 저장(클라이언트->서버 : 클라이언트는 요청 시 매번 응답에서 받은 쿠키를 전달한다)
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());

        // 세션 조회(클라이언트에서 온 요청의 쿠키 정보로 세션 저장소 조회 후 로그인 시 보관한 세션 정보를 사용한다)
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

        // 세션 만료
        sessionManager.expire(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();

    }

}

직접 만든 세션 적용 - 로그인 로그아웃

    private final SessionManager sessionManager;
   // ...
    // 로그인 성공 처리
    sessionManager.createSession(loginMember, response);
  • @Component 로 등록된 SessionManager 빈을 주입한다.
  • 로그인 성공 시 세션을 생성하고, 세션에 loginMember를 보관한다. 쿠키도 함께 발행한다.
 		@PostMapping("/logout")
    public String logoutV2(HttpServletRequest request) {
        sessionManager.expire(request);
        return "redirect:/";
    }
  • 로그아웃 시 expire 를 이용하여 해당 세션의 정보를 제거한다.
 		@GetMapping("/")
    public String homeLoginV2(HttpServletRequest request, Model model) {

        // 세션 관리자에 저장된 회원 정보 조회
        Member member = (Member) sessionManager.getSession(request);
        if (member == null) {
            return "home";
        }
        // 로그인
        model.addAttribute("member", member);
        return "loginHome";
    }
  • (로그인 후)클라이언트에서 요청을 받았을 때, 서버의 세션 저장소에서 sessionID와 맵핑된 회원 정보를 가져온다.
  • 이를 모델에 담아 로그인 홈 화면을 반환하면, 해당 사용자의 정보가 담긴 화면을 볼 수 있다.
  • 회원 정보가 없으면 쿠키나 세션이 없는 것이므로 로그인 되지 않은 것으로 처리한다.

03. 서블릿이 제공하는 HTTP 세션 처리 방식

지금까지 공부한 내용들 모두 서블릿이 제공하는 HttpSession에 더 잘 구현되어 있다. 서블릿을 통해 HttpSession을 생성하면 JSESSIONID 라는 이름의 쿠키를 생성하고, 그 값은 추정 불가능한 랜덤 값으로 만들어진다.

  • HttpSession에 데이터를 보관하고 조회할 때 같은 이름이 중복되어 사용되므로 세션 상수 정의
        // 로그인 성공 처리		 
        // 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
        HttpSession session = request.getSession();
        // 세션에 로그인 회원 정보 보관
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
        return "redirect:/";
  • request.getSession(true) = request.getSession()
    • 세션이 있으면 기존 세션을 반환한다.
    • 세션이 없으면 새로운 세션을 생성해서 반환한다.
  • request.getSession(false)
    • 세션이 있으면 기존 세션을 반환한다.
    • 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.
	  @PostMapping("/logout") // request response 알아보기
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);  // 세션 없으면 새로운 세션 생성 X
        if (session != null) {
            session.invalidate();
        }
        return "redirect:/";
    }

@SessionAttrubute

이미 로그인 된 사용자를 찾을 때 다음과 같이 사용한다.

@SessionAttribute(name = "loginMember", required = false) Member loginMember
  • 이 기능은 세션을 생성하지는 않는다.
  • 세션을 찾고 세션에 들어있는 데이터를 찾는 번거로운 과정을 처리해준다.

세션 타임아웃 설정

만약 사용자가 로그아웃을 선택하지 않고 그냥 웹 브라우저를 종료하면, 서버는 세션 데이터를 언제 삭제해야 할까?

lastAccessedTime 을 이용하여, 사용자가 서버에 최근 요청한 시간을 기준으로 서버의 생존 시간을 30분으로 계속해서 늘린다! 이 경우 다음과 같은 효과가 있다.

  • 남은 쿠키에 대한 악의적 요청을 방지한다.
  • 세션이 필요한 경우에 한해 유지한다.
  • 사이트 이용 중 번거로운 로그인을 방지한다.

적용 방법은 다음과 같다.

		server.servlet.session.timeout=1800 // 30분
  • LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.

2_필터, 인터셉터

✔️ 강의 목표

  • 로그인 하지 않은 사용자의 화면 접근을 제한하자.
  • 이를 위해 상품 관리 컨트롤러의 등록, 수정, 삭제, 조회 등 모든 컨트롤러 로직에 대해 공통으로 로그인 여부 확인하자.

웹과 관련된 공통 관심사를 처리하는 데는 서블릿에서 제공하는 필터와, 스프링에서 제공하는 인터셉터가 있다. 이 중 보편적으로 많이 쓰이는 인터셉터 위주로 알아보자.

01. 서블릿 필터

필터 흐름과 제한

  • 로그인 사용자 : HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러
  • 비 로그인 사용자 : HTTP 요청 → WAS → 필터(적절하지 않은 요청으로 판단, 서블릿 호출 X)

필터 인터페이스

public interface Filter {

     public default void init(FilterConfig filterConfig) throws ServletException {}
     public void doFilter(ServletRequest request, ServletResponse response,
             FilterChain chain) throws IOException, ServletException;
     public default void destroy() {}
}
  • init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
  • doFilter(): 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
  • destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.

이 중 init() 과 destroy() 는 default 메서드로 따로 구현하지 않아도 사용 가능하다.

서블릿 필터를 이용한 인증 체크

간단히 알아보자. 필터는 다음과 같이 사용한다.

  • whitelist = {"/", "/members/add", "/login", "/logout","/css/*"};
    • 홈, 회원가입 등 화이트 리스트의 경로를 설정하여 인증과 무관하게 항상 허용한다.
  • isLoginCheckPath(requestURI)
    • 화이트 리스트를 제외한 나머지 모든 경로에는 인증 체크 로직을 적용한다.
  • httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
    • 미 인증 사용자의 경우 로그인 화면으로 리다이렉트 한다.
    • 이 때 로그인 성공 시 원래 이동하려 했던 페이지로 이동해주기 위해 requestURI를 /login 에 쿼리 파라미터로 함께 전달한다. (해당 기능은 loginController 에서 추가로 개발해야 한다.)
  • return;
    • 위에서 미 인증 사용자를 로그인 화면으로 리다이렉트 한 뒤 필터를 더는 진행하지 않는다. 이후 필터는 물론 서블릿, 컨트롤러까지 더는 호출되지 않는다.

02. 스프링 인터셉터

인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다. 서블릿 필터보다 편리하고, 더 정교하고 다양한 기능을 지원한다.

인터셉터 흐름과 제한

  • 로그인 사용자: HTTP 요청 ->WAS-> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
  • 비 로그인 사용자: HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X)

인터셉터 체인

  • HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
    • 인터셉터는 체인으로 구성되어 중간에 인터셉터를 자유롭게 추가할 수 있다.

인터셉터 인터페이스

public interface HandlerInterceptor {
     default boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                        Object handler) throws Exception {}
     default void postHandle(HttpServletRequest request, HttpServletResponse response,
										    Object handler, @Nullable ModelAndView modelAndView) throws Exception {}
     default void afterCompletion(HttpServletRequest request, HttpServletResponse response,
										    Object handler, @Nullable Exception ex) throws Exception {}
}
  • 서블릿 필터의 경우 단순하게 doFilter() 하나만 제공된다. 인터셉터는 컨트롤러 호출 전(preHandle), 호출 후(postHandle), 요청 완료 이후(afterCompletion)와 같이 단계적으로 잘 세분화 되어 있다.
  • 서블릿 필터의 경우 단순히 request, response 만 제공했지만, 인터셉터는 어떤 컨트롤러(handler)가 호
    출되는지 호출 정보도 받을 수 있다. 그리고 어떤 modelAndView 가 반환되는지 응답 정보도 받을 수 있다.
  • postHandle() 의 경우 예외가 발생하면 호출되지 않는데, 예외와 무관하게 공통 처리를 하려면 afterCompletion() 을 사용해야 한다.

인터셉터를 이용한 로그 남기기

package hello.login.web.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import java.util.UUID;

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        
        // 요청 로그를 구분하기 위한 uuid 생성
        String uuid = UUID.randomUUID().toString();
        // 여기(preHandle)에서 지정한 값을 다른 곳(postHandle, afterCompletion)에서도 사용할 것.
        // LogInterceptor 도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 다른 스레드에서 바꿔치기 당하는 등의 위험이 있다.
        // 따라서 request 에 담는다.
        request.setAttribute(LOG_ID, uuid);

        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
        }

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        // 위 preHandle 에서 담은 값을 찾아서 사용!
        String logId = (String) request.getAttribute(LOG_ID);
        // 예외가 발생한 경우 postHandle이 호출되지 않기 때문에 종료 로그는 afterCompletion에서 실행했다.
        log.info("RESPONSE [{}][{}]", logId, requestURI);
        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }
    }
}
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor()) // 작성한 LogInterceptor 등록
                .order(1)  // 호출 순서 지정(낮을수록 먼저)
                .addPathPatterns("/**") // 인터셉터 적용할 URI 패턴 지정
                .excludePathPatterns("/css/**", "/*.ico", "/error");  // 제외할 패턴
    }
}
  • 필터의 경우 인증 체크 필터를 작성하며 화이트 리스트를 직접 작성하고, 그 경우 인증 체크 하지 않는 로직을 만들었다.
  • 이에 비해서 인터셉터는 addPathPatterns(), excludePathPatterns() 를 이용하여 보다 간단하고 정밀하게 URL 패턴을 지정할 수 있다.

인터셉터를 이용한 인증 체크

package hello.login.web.interceptor;

import hello.login.web.SessionConst;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();

        log.info("인증 체크 인터셉터 실행 {}", requestURI);
        HttpSession session = request.getSession(false);

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");
            // 로그인으로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        
        return true;
    }
}
  • 서블릿 필터와 비교해 간결한 코드. 로그인 여부만 알면 되는 인증은 컨트롤러 호출 전에만 호출하면 된다. 따라서 preHandle만 구현했다.
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/", "/members/add", "/login", "/logout",
                        "/css/**", "/*.ico", "/error"
                );
    }
}

참고 - ArgumentResolver 활용

Spring에서 공통으로 사용하는 것들을 Annotation 으로 등록하여 간편하게 관리할 수 있다. (개발자가 직접 구현)

아래 두 코드를 비교해보면 알 수 있다.

public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model)
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model)
  • HomeController에서 세션의 회원정보 유무에 따라 다른 페이지를 로딩할 때, 이 회원정보 유무를 보다 간단하게(공용성 있게) 표현 가능하다.

구현 방법

  1. Login 어노테이션 생성
package hello.login.web.argumentresolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}
  1. HandlerMethodArgumentResolver 구현
package hello.login.web.argumentresolver;

import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation =
                parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType =
                Member.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest)
                webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}
  1. WebConfig 에 개발한 LoginMemberArgumentResolver 등록
  	@Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }
profile
공부 중입니다.

0개의 댓글