6. 로그인 처리1- 쿠키,세션

ys·2024년 1월 10일

Spring-mvc2

목록 보기
6/10

김영한 강사님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 듣고 정리한 내용입니다. 자세한 내용은 강의를 참고해주세요

  • 이번 강의에서는 로그인 기능을 구현해볼 것이다
  • 다음은 로그인 요구사항이다

패키지 구조 설계

  • Spring 첫 번째 강의에서 MVC , 도메인 , 서비스 , 레파지토리 + DB 이렇게 나뉜다고 배웠다.

  • 지금까지 스프링을 공부하다 보니 SOLID의 5원칙 중 SRP를 가지고 기능에 맞게 패키지를 나눠나야지 -> 유지,보수적인 측면에서 유용하다는 것을 알게 되었다.

  • 이번강의는 MVC강의이므로, MVC부분에 초점이 맞춰저 있어 레파지토리를 인터페이스와 구현체로 나누지 않고, 그냥 메모리에 저장하는 레파지토리 한개만 두었다(물론 db 없이 메모리에 저장)

  • 아직 로그인 로직인 서비스 부분은 고려하이 않고, MVC와 도메인 부분을 봐보자!

  • 도메인핵심 비지니스가 들어가는 부분이다

  • 우리가 만든 컨트롤러,form,화면 ui같은 부분을 핵심 업무 도메인이라고 하지 않는다

  • 즉 어느정도 분리가 되어있어야 한다.

  • 우리가 향후 web기술을 다른 web기술로 바꾸거나

  • 지금은 Form으로 데이터를 받고 있지만, api로 받는 구조로 코드를 바꿀때, 핵심 도메인 부분은 그대로 유지되어야 한다!!!

    그럴려면 web부분은 domain에 의존해도 되지만, domainweb부분에 의존하면 안된다
    -> 단방향으로 코드를 설계해야 한다!!!

  • 우리가 저번시간에 만든 코드를 볼 때, ItemSaveForm, ItemUpdateForm은 저장과 수정을 위한 객체이고

  • 이를 바로 ItemRepository에 저장하는 것이 아니라, 생성자나 프로퍼티 접근법을 이용해서 domain 객체를 생성해 레파지토리에 저장했다

  • 이렇게 의존관계를 잘 설정해주어야지 좋은 객체지향 프로그래밍이라고 할 수 있다.

회원가입

  • 회원가입을 위한 도메인, 레파지토리, 컨트롤러 클래스를 만들어준다
  • 이때 회원 도메인에 -> 검증 처리 @Bean Valdiation을 추가해준다(@NotEmpty)정도?
  • 레파지토리는 item의 레파지토리와 비슷하고 추가적으로 로그인아이디로 Member객체를 찾는 메서드를 추가해준다
  • 이때 아이디가 있을 수도 있고 없을 수도 있으니 -> Optional<Memeber>로 반환해준다
public Optional<Member>findByLoginId(String loginId){
        return findAll().stream()
                .filter(m -> m.getLoginId().equals(loginId))
                .findFirst();
    }

컨트롤러

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member")Member member){
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Validated @ModelAttribute("member") Member member, BindingResult bindingResult){
        if (bindingResult.hasErrors()){
            return "members/addMemberForm";
        }
        memberRepository.save(member);
        return "redirect:/";
    }
}
  • 회원가입 페이지는 -> get방식("/add")
  • 회원가입 실제로직 -> post방식("/add")
  • ModelAttribute Member member로 form에서 전송받는 Memeber객체를 view로 이동
  • 실제로직에서는 @Validated로 검증을 한다
  • 만약 오류가 추가된다면 -> bindingResult.hasErrors()면 다시 등록 화면으로 이동
  • 오류가 없다면, 회원가입을 하고(save) 홈화면으로 redirect:/한다

로그인 기능

  • 앞서 말했듯이 로그인 기능은 핵심적인 비지니스 로직이다
  • 그렇다면 도메인 경로 아래에 /domain/login/LoginService 라고 만들어주자
  • 만약에 서비스 부분이 복잡해지면 도메인과, 서비스를 분리하는 방안도 생각을 해보자!
  • 컨트롤러에서 로그인을 해서 로그인 화면으로 넘겨줄려면, 먼저 이게 로그인 정보가 맞는지 확인을 해야한다 -> 이게 바로 LoginService가 해주는 것이다
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;
    
    public Member login(String loginId,String password){
        return memberRepository.findByLoginId(loginId)
                .filter(m->m.getPassword().equals(password))
                .orElse(null);
    }
}
  • 로그인은 아이디와, 비밀번호로 한다
  • 먼저 우리가 만든findById를 통해 optional<Member> 객체를 찾고, 그 중에 비밀번호가 맞는지 확인을 한다
  • 즉 null이 나오는 경우는 다음과 같다
    1. 아이디가 틀릴 때
    2. 아이디는 맞지만 비밀번호가 틀릴 때

컨트롤러

  • 우선 로그인 컨트롤러에서만 사용할 form 만들어준다
  • 당연히 이는 domain과 분리된 web/login에 있는 loginForm이다
@Data
public class LoginForm {
    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
}
  • 다음은 컨트롤러이고, web/login/LoginController이다
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm form){
        return "login/loginForm.html";
    }

    @PostMapping("/login")
    public String login(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult){
        if (bindingResult.hasErrors()){
            return "login/loginForm.html";
        }
        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        if (loginMember==null){
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다");
            return "login/loginForm.html";
        }

        // 로그인 성공 TODO

        return "redirect:/";
    }
}
  • post방식을 보면, 먼저 bindingResult.hasErrors()@Bean Validator검정 오류가 있는지 확인을 한다
  • 있으면 다시 입력 폼으로 보내버린다
  • 그런데 입력은 됐는데, 만약 로그인이 틀렸다면 -> null값이 전달이 되겠지!!! 이거는 바로 Object Error이다
  • 우리가 저번에 Object Error는 자바 코드로 해주리고 했다.
  • if문으로 null일때 rejectObject ErrorbindingResult에 추가해주고
  • 다시 loginForm.html으로 보내버린다
  • 로그인이 성공이 됐다면 다시 홈 화면으로 redirect:/된다

그런데 우리는 홈 화면에 고객이름을 보여달라는 요구사항을 만족시키지 못했다
-> 쿠키로 이부분을 해결해보자

로그인 처리하기 - 쿠키 사용!

  • 쿠키로 저장을하고, 다시 요청할 때 쿠키를 보내서 로그인 상태를 유지하자!
  • 그렇다면 성공을 하면 서버에서 웹 브라우저로 쿠키를 보내야 한다
  • 응답에서 SetCookie를 해줘야 한다
@PostMapping("/login")
    public String login(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult, HttpServletResponse response){
        if (bindingResult.hasErrors()){
            return "login/loginForm.html";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember==null){
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다");
            return "login/loginForm.html";
        }

        // 로그인 성공  -> 로그인일 유지할 수 있는 쿠키를 만들어서 보내주자

        // 쿠키에 시간정보를 넣어주지 않았으므로 -> [세션 쿠키]가 된다(브라우져 종료시 모두 종료)
        Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
        response.addCookie(idCookie);

        return "redirect:/";
    }
  • 요청메시지에 담아서 보내야 하기 때문에, HttpServletRequest를 컨트롤러의 파라미터로 받는다
  • 새로운 쿠키 객체를 하나 만들어주고, (쿠키 이름, 쿠키 값을) 넣어준다
  • 이때, 쿠키 값은 String으로 넣어줘야돼서, String.valueOf()로 바꿔준다
  • 만든 쿠키를 response에, addCookie를 해준다!
  • 그리고 클라이언트가 다시 서버에 요청하면 쿠키 정보를 함께 보내준다

  • 지금은 세션쿠키이기 때문에 브라우저가 끊기면 쿠키도 없어지게 된다
  • 그러면 쿠키를 어떻게 삭제해서 로그아웃을 할까???

1.세션 쿠키이므로 웹 브라우저 종료시
2.서버에서 해당 쿠키의 종료 날짜를 0으로 지정

  • LoginContoller에 로그아웃 기능을 추가해보자
  • 2번 방안을 써서 setMaxAge(0)을 이용해 쿠키의 세션을 만료시킬 것이다
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
	expireCookie(response, "memberId");
	return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
	Cookie cookie = new Cookie(cookieName, null);
	cookie.setMaxAge(0);
	response.addCookie(cookie);
}
  • 이렇게 되면 Max-age가 0이되고 해당 쿠키는 즉시 종료된다

쿠키를 사용해서 로그인Id를 전달해서 로그인을 유지할 수 있었다. 그런데 여기에는 심각한 보안 문제가 있다.

보안 문제

  • 쿠키 값은 임의로 변경할 수 있다.
  • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
  • 실제 웹브라우저 개발자모드 Application Cookie 변경으로 확인
  • Cookie: memberId=1 Cookie: memberId=2 (다른 사용자의 이름이 보임)
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
  • 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
    • 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
    • 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
  • 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
  • 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

대안

  • 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다.
  • 그리고 서버에서 토큰을 관리한다.
  • 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
  • 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다.
  • 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다

세션(session)

  • 이런 쿠키의 문제점을 해결하기 위해서 세션이 등장하였다
  • 세션 : 서버에 중요한 정보를 보관, 연결을 유지하는 방법
  • 다음은 세션의 작동방식이다(웹 브라우저 <--> 서버)

  • 먼저 서버에 로그인을 성공적으로 한다

  • 성공적으로 로그인이 됐으면, 서버서버 메모리(저장소)에, 세션 Id를 생성하고 value값에 값을 보관한다

  • 세션 ID를 생성하는데, 추정 불가능해야 한다.
    (UUID는 추정이 불가능하다.)

    • Cookie: mySessionId=zz0101xx-bab9-4b92-9b32-dadb280f4b61
  • 생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.

  • 저장한 Session ID를 Set-Cookkie로 쿠키에 담아서 전달한다

  • 이때, 클라이언트는 쿠키 저장소에서 받은 CookiesessionId를 보관한다

  • 중요한점은, 회원과 관련된 정보서버에서 클라이언트로 전달되지 않는 다는 것이다

  • 추적 블가능한 sessionId만이 쿠키를 통해서, 서버에서 클라이언트로 전송된다

  • 이제 다시 클라이언트가 서버에 요청을 보낸다고 생각을 해보자
  • 클라이언트는 요청할 때, 항상 seesionId 쿠키를 요청 메시지에 담아서 보낸다
  • 서버에서는 클라이언트가 전달한 sessionId를 세션 저장소에 조회해, 일치하면 로그인시 보관한 세션 정보를 사용한다

정리

  • 세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다. 덕분에 다음과 같은 보안 문제들을 해결할 수 있다.
  • 쿠키 값을 변조 가능, 예상 불가능한 복잡한 세션Id를 사용한다.
  • 쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. 세션Id가 털려도 여기에는 중요한 정보가 없다.
  • 쿠키 탈취 후 사용 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게(예: 30분) 유지한다.
  • 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다

  • Spring은 세션을 지원하지만,,,
  • Session에 대해 잘 이해하기 위해서 한번 구현을 해보자!!!
  • 그리고 뭔가 세션 저장소 메모리일 뿐만 아니라, 되게 Map같이 생겼다

Session 구현

  • 세션관리는 다음 3가지 기능을 구현한다
  1. 세션생성
  2. 세션 조회
  3. 세션 만료
@Component
public class SessionManager {
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
    /**
     * 세션 생성
     */
    public void createSession(Object value, HttpServletResponse response){

        // sessionId 생성하고 값을 세션에 저장 (임의의 추정 불가능한 랜덤 값) -> [UUID]
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId,value);

        // 쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }
 
  • 먼저 동시성 문제를 고려해 ConcurrentHashMap으로 내부 메모리 저장소를 구현한다
  • 먼저 세션 생성서버에서 -> 클라이언트로가기 때문에, HttpServletRequest를 객체로 받는다
  • UUID를 이용해 랜덤 아이디를 만들고
  • 저장소에 랜덤으로 만들어진 ID와, 저장할 값을 put으로 저장한다
  • 이제 쿠키로 클라이언트에게 전달해줘야겠지??
  • 쿠키에는 이름이랑, 렌덤으로 만든 ID가 들어간다
  • 그리고 응답 메시지 HttpServletRequestaddCookie로 쿠키를 추가한다음 응답한다
	/**
     * 세션 조회
     */

    public Object getSession(HttpServletRequest request){
        Cookie seesionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (seesionCookie==null){
            return null;
        }
        return sessionStore.get(seesionCookie.getValue());

    }
    public Cookie findCookie(HttpServletRequest request,String cookieName){

        if (request.getCookies() == null){
            return null;
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(SESSION_COOKIE_NAME))
                .findAny()
                .orElse(null);

    }
  • 다음은 세션 조회이다
  • 클라이언트는 요청시 항상 쿠키 저장소의 쿠키를 전달한다
  • HttpServletRequest에서 cookie들 중에, 우리가 등록한 쿠키의 이름과 같은 것을 반환한다
  • 그리고 서버에서 그 쿠키의 SeesionId를 보고 세션저장소에서 저장한 값을 찾는다
	/**
     * 세션 만료
     */
    public void expire(HttpServletRequest request){
        Cookie seesionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (seesionCookie !=null) {
            sessionStore.remove(seesionCookie.getValue());
        }
    }
  • 요청에서 온 쿠키가 있다면..
  • remove를 이용해서, Session 저장소에서 없애버린다

로그인 처리하기 - Servlet Http

  • 서블릿은 Session을 위해 HttpSession이라는 기능을 제공한다
  • 우리가 만든 것과 비교해보자
  • 서블릿이 제공하는 HttpSession을 생성하면 다음과 같은 쿠키를 생성
    • 쿠키 이름:JESSIONID
    • 쿠키 값 : UUID처럼 추정 불가능~
@PostMapping("/login")
    public String loginV3(@Validated @ModelAttribute("loginForm") LoginForm form, BindingResult bindingResult, HttpServletRequest request){
        if (bindingResult.hasErrors()){
            return "login/loginForm.html";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember==null){
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다");
            return "login/loginForm.html";
        }

        // 로그인 성공
        // 세션이 있으면 있는 세션 반환, 없으면 신규 세션을 생성
        HttpSession session = request.getSession(true);
        // 세션에 로그인 회원 정보를 보관
        session.setAttribute(SessionConst.LOGIN_NUMBER, loginMember);

        return "redirect:/";
    }
  • 처음 로그인을 요청해서 성공할 때,
  • HttpServletRequest의 request객체에
  • getSession()메서들을 이용해 HttpSession객체를 만들 수 있다
  • 이때, 기본값(default) = true이다
  1. request.getSession(true)
  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 새로운 세션을 생성해서 반환한다.
  1. request.getSession(false)
  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 새로운 세션을 생성하지 않는다. null 을 반환한다.
  • request.getSession() : 신규 세션을 생성하는 request.getSession(true) 와 동일하다.

    중요한 것은, 처음 로그인을 할 때, request객체에서 getSession(true)를 이용해 -> HttpSession 객체를 만든다는 것이다

다음은 로그아웃 버튼이다

@PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session !=null){
            session.invalidate();
        }
        return "redirect:/";
    }
  • 이번엔 getSession(flase)로 로그인 성공시 만들어진 HttpSession 객체를 찾은 후
  • session.invalidate() 메서드를 이용해 세션을 제거한다
  • 세션이 제거되었으니, 로그아웃이 된다

마지막으로 홈 화면이다

  • 기획자의 요구에 따라, 로그인이 되면 로그인한사람의 이름이 home화면에 나와야 되기 때문에, 새로운 loginHome을 만들어준다
@GetMapping("/")
    public String homeLoginV3(HttpServletRequest request, Model model){

        HttpSession session = request.getSession(false);
        if (session==null){
            return "home";
        }
        Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_NUMBER);

        // 세션에 회원 데이터가 없으면 home
        if (loginMember == null){
            return "home";
        }
        // 세션이 유지되면 로그인으로 이동
        model.addAttribute("member", loginMember);
        return "loginHome";
    }
  • session.getAttribute()를 이용해, 로그인이 성공했다면 Session 저장소에 보관된 값을 찾는다
  • Object반환이므로 캐스팅을 해주고
  • view로 데이터를 전달해야되기 때문에 Model객체에 addAttribute해서 컨트롤러에서 뷰로 데이터를 전달해준다

@SessionAttribute

  • 스프링이 세션을 더울 편리하게 사용할 수 있도록
  • @SessionAttribute를 지원한다
  • @SessionAttributeSession 저장소에서 이미 저장된 객체 값을 찾을 때 사용하는 에노테이션이다
  • 이 기능은 세션을 생성하지는 않는다 -> 조회만
@GetMapping("/")
    public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_NUMBER,required = false) Member loginMember
            , Model model){

        // 세션에 회원 데이터가 없으면 home
        if (loginMember == null){
            return "home";
        }
        // 세션이 유지되면 로그인으로 이동
        model.addAttribute("member", loginMember);
        return "loginHome";
    }
}
  • 조회만 하므로 required=False이다
  • 세션을 찾고, 세션에 들어있는 데이터를 찾는 번거로운 과정을 스프링이 한번에 편리하게 처리해주는 것을 확인할 수 있다

TrackingModes

  • 로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
  • 서버 입장에서 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로, 쿠키 값도 전달하고, URL에 jsessionid 도 함께 전달한다.
  • application.properties여기에
    server.servlet.session.tracking-modes=cookie를 넣어준다

세션 정보와 타임아웃 설정

  • Session에는 다음과 같은 정보들도 포함하고 있다
  • sessionId : 세션Id, JSESSIONID 의 값이다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime : 세션 생성일시
  • lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로
  • sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.
  • isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId( JSESSIONID )를 요청해서 조회된 세션인지 여부

세션 타임아웃 설정

  • 세션을 삭제하는 경우는 사용자가, session.invlaidate()를 직접 호출하는 경우다
  • 사용자는 보통 로그아웃을 하지 않고, 서버를 닫는다
  • 서버는 TCP/IP연결을 하고, 요청/응답 메시지가 오고 간 후에는 HTTP 비연결성에 따라 연결을 끊고 Cpu를 보존한다
  • 그러면 서버는 어떻게 해당 사용자가 웹 브라우저를 종료하였는지 알까?
  • 따라서 서버는 세션 데이터를 얼마 후에 삭제해야 하는지 판단이 어렵다...
  • 보통은 30분으로 잡는데... 내가 사용중에 30분이 지나면? 다시 로그인을 해야된다

더 좋은 대안은 세션 생성 시점아니라 사용자가 서버에 최근에 요청한 시간기준으로 30분 정도를 유지해주는 것
이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.
따라서 30분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession 은 이 방식을 사용한다.

  • application.properties파일에
    server.servlet.session.timeout=60 : 60초, 기본은 1800(30분)를 추가하면 된다
  • session.lastAccessedTime을 이용하는 것이다
  • LastAccessedTime이후로 timeout이 지나면, WAS는 내부에서 해당 세션을 제거하고 로그 아웃이 된다
  • 서블릿의 HttpSession 이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 사용할 수 있다

    실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다

  • 보관한 데이터 용량 * 사용자 수로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다
  • 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수
    있으므로 적당한 시간을 선택하는 것이 필요하다.
  • 기본이 30분이라는 것을 기준으로 고민하면 된다
profile
개발 공부,정리

0개의 댓글