BackEnd/WEB

세션 트래킹_국비_Day68

Leo.K 2022. 6. 8. 16:34

전체 소스코드 보기

어제에 이어서 세션 트래킹을 활용한 로그인 처리를 이어서 정리하겠다. 65~66일차에 실습한 Member(회원관리)와 67일차에 실습한 Photo(파일업로드) 내용을 병합하여 실습을 진행하고자 하니 부족한 내용은 이전의 블로그를 참고해주길 바란다. 실습이 끝나고 전체적인 파일은 필자의 깃허브 주소에 업로드하겠다.

세션트레킹은 현재 페이지의 정보를 다른 페이지에서도 유지한 상태로 사용하는 기술이라고 했다. 로그인을 진행하기 전에 아이디가 맞는지, 비밀번호가 맞는지 한 번의 검증과정을 거치는데, 이번에는 로그인 폼에서 비동기로 확인하는 것이 아니라, 로그인 서블릿으로 넘어간 후에 redirect하는데, 이때 쿼리에 오류정보를 담아서 전송해주어야 한다. 리다이렉트를 할시에 사용자는 url변화를 감지하지 못하기 때문에 쿼리정보를 함께 넘겨서 해당 쿼리의 파라미터를 받았을 때, 처리하도록 해주어야 한다. 

밑의 코드를 참고하면 알겠지만, 모든 요소가 document에 배치가 완료되면 타임아웃 함수를 사용해서 0.1초 후에 함수를 호출하는데, 이 함수는 쿼리형식의 파라미터가 있는 경우만, 즉 서블릿을 거치고 검증과정에서 걸려서 다시 돌아온 경우에만 실행되는 함수이다.

처음에는 어차피 조건을 성립해야만 호출한 함수가 실행되기 때문에 굳이 0.1초의 시간간격을 줄 필요가 있나? 하고 생각하면서 setTimeout함수를 지우기 전에 시간 간격을 0으로 주었더니, 로그인에 실패했을때, 로그인 폼으로 리다이렉트 하면 폼의 요소가 배치된 것이 나타나기 전에 showMessage함수가 바로 실행되기 때문에 보기 좋지 않다. 

필자는 로그인 화면이 보이는 상태에서 실패한 경우 alert창이 자연스레 내려오는 상황을 연출하기 위해서 0.1초의 텀을 주었다.

[ member_login_form.jsp <script> ]

비밀번호만 틀린 경우 : 리다이렉트할 때 m_id값을 쿼리에 넘겨줌으로써 검증을 걸친 내용은 다시 작성하지 않도록 한다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
<script type="text/javascript">
    function send(f){
        var m_id  = f.m_id.value.trim();
        var m_pwd = f.m_pwd.value.trim();
        
        if(m_id==''){
            alert("아이디를 입력하세요.");
            f.m_id.value='';
            f.m_id.focus();
            return;
        }
        
        if(m_pwd==''){
            alert("비밀번호를 입력하세요.");
            f.m_pwd.value='';
            f.m_pwd.focus();
            return;
        }
        
        f.action = "login.do";
        f.submit();
    }
</script>
 
<script type="text/javascript">
$(document).ready(function(){
    //0.1초 후에 showMessage함수 호출
    setTimeout(showMessage, 100);
});
 
function showMessage(){
    // /member/login_form.do?reason=fail_id
    if("${param.reason eq 'fail_id'}" == "true"){
        alert('아이디가 틀렸습니다.');
        return;
    }
 
    // /member/login_form.do?reason=fail_pwd
    if("${param.reason eq 'fail_pwd'}" == "true"){
        alert('비밀번호가 틀렸습니다.');
        return;
    }
}
</script>
cs

 

[ MemberLoginAction ]

현재 필자는 테이블별로 따로 폴더화 시켜서 저장했다. 현재 작업하는 위치는 member테이블에 있고, 메인 페이지인 photogallery는 photo테이블에 있기 때문에  redirect 하는 경우 경로 설정을 아래 코드와 같이 명확히 해야 한다.

서버를 이용하는 사용자가 많아질수록 사용자마다 세션을 주어야 하는데, 이 때 각 세션에 너무 많은 데이터를 저장하면 개발은 편하겠지만, 서버의 부담이 너무 커서 다운될 위험이 크다. 따라서 사용자를 인식할 수 있는 최소한의 사용자 정보만을 세션에 저장해야 한다. 

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package action.member;
 
import java.io.IOException;
 
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
 
import dao.MemberDao;
import vo.MemberVo;
 
/**
 * Servlet implementation class MemberLoginAction
 */
@WebServlet("/member/login.do")
public class MemberLoginAction extends HttpServlet {
    private static final long serialVersionUID = 1L;
 
    /**
     * @see HttpServlet#service(HttpServletRequest request, HttpServletResponse response)
     */
    protected void service(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
        
        //1. 수신 인코딩
        request.setCharacterEncoding("utf-8");
        
        //2. 파라미터 수신 
        String m_id  = request.getParameter("m_id");
        String m_pwd = request.getParameter("m_pwd");
 
        //3. m_id에 해당되는 회원정보 가져오기
        MemberVo user = MemberDao.getInstance().selectOne(m_id);
        
        if(user == null) {
            //Session Tracking
            response.sendRedirect("login_form.do?reason=fail_id");
            return;
        }
        
        //m_pwd 체크
        if(user.getM_pwd().equals(m_pwd)==false) {
            response.sendRedirect("login_form.do?reason=fail_pwd&m_id=" + m_id);
            return;
        }
        
        
        //로그인 정보 세션에 넣기 
        //사용자의 요청정보를 관리하는 request객체가 가지고 있는 세션id에 해당되는 공간 정보를 읽어온다.
        HttpSession session = request.getSession();
        session.setAttribute("user", user);
        
        
        //메인 페이지 이동(URL)
        //현재 경로 :              /member/login.do
        response.sendRedirect("../photo/list.do");
    }
 
}
 
 
cs

프로그램을 작성할 때, 페이지를 옮기는 경우가 다반사인데, 이동 경로를 상대경로로 표시해주면 처음에는 편하겠지만, 후에 혹시라도 파일의 저장위치가 변경되거나 구조가 바뀌게 되면, 초기에 설정해준 상대경로를 모두 수정해주어야 한다. 따라서 주소를 좀더 방어적으로 코딩하기 위해 현재 경로에서 Root Context를 구하는 방식을 사용하면 현재 작업중인 파일이 어느 폴더에 있던지 상관없이 경로 설정(절대경로)이 가능하고 수정의 필요가 없다. 

현재 Root Context 경로 : ${pageContext.request.contextPath }

 

[ photo_list.jsp <script> ]

로그인이 성공적으로 수행되었다면 이제 사진을 업로드 해야 하는 과정이 남아있다. 필자는 로그인을 해야만 사진을 등록할 수 있도록 다음 코드와 같이 구분하였다. 로그인을 했다면 세션에 값이 있을 것이고, 안 했다면 세션에 값이 null이다.

사진 올리기 버튼을 누른 경우 onclick 메소드를 작성하여 로그인 여부에 따라 다른 페이지로 이동시켰다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script type="text/javascript">
 
function upload_photo(){
    
    //로그인 여부 체크
    if("${ empty user}" == "true"){//로그인하지 않았다면,,,
        
        if(confirm("로그인 후에 파일 업로드가 가능합니다.\n로그인 하시겠습니까?")==falsereturn;
        
        //로그인 폼으로 이동 
        location.href="${pageContext.request.contextPath}/member/login_form.do";
        
        return;
        
    }
    
    //로그인 된 경우
    location.href="insert_form.do";; // PhotoInsertFormAction
}
 
</script>
cs

 

  • 로그인을 한 상태로 버튼을 누르면 바로 파일을 업로드 할 수 있도록 미리 만들어둔 폼을 호출한다.  sql에러를 방지하기 위해서 폼의 데이터가 입력되었는지 유효성을 체크한 후 서블릿으로 폼의 데이터를 전송한다.
  • 이때, 전 시간에 정리한 것처럼, 파일을 업로드 하는 경우 폼 데이터는 반드시 POST방식으로, encType은 multipart/form-data로 해야하는 것을 유의하자. 
  • 정말 그럴일은 없겠지만, 사진을 등록하기 위해 버튼을 눌렀다가 세션의 유효시간이 종료된 경우 다시 로그인 하도록 한다. 

[ PhotoInsertFormAction ] -> photo_insert_form.jsp를 포워딩 하는 역할만 한다.

[ photo_insert_form.jsp ]

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
 
 
<!-- BootStrap 3.x -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
 
<style type="text/css">
    #box{
        width: 500px;
        margin: auto;
        margin-top: 50px;
    }
    
    textarea{
        width: 100%;
        height: 150px;
        resize: none;
    }
</style>
 
<script type="text/javascript">
 
function send(f){
    
    var p_subject = f.p_subject.value.trim();
    var p_content = f.p_content.value.trim();
    var p_photo   = f.p_photo.value;
    
    if(p_subject==''){
        alert('제목을 입력하세요!!!');
        f.p_subject.value='';
        f.p_subject.focus();
        return;
    }
    
    if(p_content==''){
        alert('내용을 입력하세요!!!');
        f.p_content.value='';
        f.p_content.focus();
        return;
    }
    
    if(p_photo==''){
        alert('사진을 선택하세요!!!');
        return;
    }
    
    f.action="insert.do" //PhotoInsertAction
    f.submit();
}
</script>
 
</head>
<body>
    <form method="POST" enctype="multipart/form-data">
        <div id="box">
            <div class="panel panel-success">
                <div class="panel-heading">사진등록</div>
                <div class="panel-body">
                    <table class="table table-striped">
                        <tr>
                            <th>제목</th>
                            <td><input name="p_subject" style="width: 100%;"></td>
                        </tr>
                        
                        
                        <tr>
                            <th>내용</th>
                            <td><textarea name="p_content"></textarea></td>
                        </tr>
                        
                        
                        <tr>
                            <th>사진</th>
                            <td><input type="file" name="p_photo"></td>
                        </tr>
                        
                        <tr>
                            <td colspan="2" align="center">
                                <input class="btn btn-primary" type="button" value="등록하기" onclick="send(this.form);">
                                <input class="btn btn-info" type="button" value="목록보기" onclick="location.href='list.do';">
                            </td>
                        </tr>
                    </table>
                </div>
            </div>    
        </div>    
    </form>
</body>
</html>
cs

 

[ PhotoInsertAction ]

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package action.photo;
 
import java.io.File;
import java.io.IOException;
 
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
 
import com.oreilly.servlet.MultipartRequest;
import com.oreilly.servlet.multipart.DefaultFileRenamePolicy;
 
import dao.PhotoDao;
import vo.MemberVo;
import vo.PhotoVo;
 
/**
 * Servlet implementation class PhotoInsertAction
 */
@WebServlet("/photo/insert.do")
public class PhotoInsertAction extends HttpServlet {
    private static final long serialVersionUID = 1L;
 
    /**
     * @see HttpServlet#service(HttpServletRequest request, HttpServletResponse response)
     */
    protected void service(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // TODO Auto-generated method stub
        // /photo/insert.do?p_subject=제목&p_content=내용&p_photo=a.jpg
        
        //로그인 정보 읽어오기
        MemberVo user = (MemberVo)request.getSession().getAttribute("user");
        
        if(user == null) { //세션이 만료된 경우
            response.sendRedirect("../member/login_form.do?reason=session_timeout");
            return;
        }
        
        
        
        
        //1. 저장위치 설정
        //ServletContext app = request.getServletContext();
        //String path = app.getRealPath(web_path);
        
        String web_path = "/upload/"//웹 경로
        String path = request.getServletContext().getRealPath(web_path); //절대경로(메서드 체이닝 방식)
        
        //2. maxsize
        int max_size = 1024*1024*100;
        
        //3. 파일 업로드 처리 객체                           저장위치 저장크기  인코딩   동일파일명 -> 변경 저장
        MultipartRequest mr = new MultipartRequest(request, path, max_size, "utf-8"new DefaultFileRenamePolicy());
        
        //4. 업로드된 파일명 구하기 
        String p_filename = "no_file";
        File f           = mr.getFile("p_photo");
        if(f != null) {
            p_filename = f.getName();//업로드된 파일이름 구한다.
        }
        
        //5. 파라미터 수신
        String p_subject = mr.getParameter("p_subject");
        String p_content = mr.getParameter("p_content").replaceAll("\r\n""<br>"); //json으로 포장하는 과정에서 특수문자가 존재하면 에러가 발생하므로, 
        
        
        //6. ip정보 수신
        String p_ip = request.getRemoteAddr();
        
        //6-1. 로그인한 사용자의 m_idx 구하기 
        int m_idx   = user.getM_idx();
        
        //7. Vo로 포장
        PhotoVo vo = new PhotoVo(p_subject, p_content, p_filename, p_ip, m_idx);
        
        //8. DB insert
        int res = PhotoDao.getInstance().insert(vo);
        
        //9. 메인 페이지 호출
        response.sendRedirect("list.do");
    }
 
}
 
cs

 

[  photo_list.jsp <div id="photo_box>]

이제 DB에 insert한 데이터를 photo_list.jsp에 출력을 해줄 것이다. 출력을 할 때, 제목이 일정 이상 길어지면 제목을 표현하는 input태그가 커지면서 지정한 레이아웃을 벗어나게 되는데 이를 해결하기 위해 css속성을 다음과 같이 설정했다. 

1
2
3
4
5
6
7
8
9
10
.photo_class{
        border: 1px solid gray;
        margin-top: 2px;
        margin-bottom: 2px;
        padding: 3px;
        /*말줄임표 css*/
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
}
cs

 

※ 주의사항 ※

tomcat을 실행할 때, run on server의 add or remove에서 현재 작업중인 프로젝트 즉, 업로드한 파일을 remove하면 업로드 해두었던 이미지들이 모두 삭제된다. 이는 웹 서버인 톰캣이 실행하는 동안 웹 어플리케이션(다이나믹 프로젝트)이 실행되는데 우리가 저장한 업로드 파일은 결론적으로 웹 어플리케이션이 웹 서버와 클라이언트 사이에서 미들웨어 역할을 하는 동안(add되어 run on server하는 동안) 임시적으로 생성되는 것이기 때문에 이러한 프로젝트를 진행하는 동안은 이미지를 따로 백업을 해두거나 remove를 하면 안된다.

 

[ download ]

이제 업로드한 파일을 다운로드 하는 방법을 구현할 것이다. 

다운로드도 업로드와 마찬가지로 로그인한 사용자만 수행할 수 있도록 javaScript를 사용해서 함수를 만들어준다.

선생님께 받은 외부 서블릿 파일을 사용해서 다운로드를 진행할 것인데 우리는 이 서블릿을 사용하는 방법만 알면 된다.

  1. 다운로드 버튼을 누르면 함수를 실행한다. 
  2. 로그인 여부를 확인하고 로그인한 경우에만 파일을 다운로드 할 수 있도록 한다. 
  3. 외부 서블릿이 필요로 하는 파라미터명을 명확히 하고, 쿼리 형식으로 인자를 전달한다. 
  4. 이때, 파일명이 한글 또는 특수문자가 포함되어 있으면 상위 버전의 브라우저는 자동으로 인코딩 해주지만, 하위 버전은 자동으로 인코딩이 되지 않기 때문에 수동으로 인코딩을 설정해주지 않으면 하위 버전의 브라우저를 사용하는 사용자는 다운로드를 이용할 수 없다. 
  5. 모든 사용자에게 동일한 서비스를 제공해주기 위한 호환성을 지키지 못한다면 개발자로서의 기본 소양이 부족하다고 배웠으니, 반드시 파일명은 직접 인코딩을 해서 파라미터로 전송하도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function download(p_filename) {
    //로그인 여부 체크
    if("${ empty user}" == "true"){//로그인하지 않았다면,,,
        
        if(confirm("로그인 후에 파일 다운로드가 가능합니다.\n로그인 하시겠습니까?")==falsereturn;
        
        //로그인 폼으로 이동 
        location.href="${pageContext.request.contextPath}/member/login_form.do";
        
        return;
        
    }
    
    //로그인 된 경우
    //파일 다운로드 서블릿 호출 -> 외부 서블릿이 필요로 하는 파라미터 정보를 쿼리 형식으로 전달한다. 하지만,, 지원되지 않는 웹 브라우저도 존재한다.
    //다운로드 받을 파일명이 한글(특수문자)면 인코딩 해서 전송한다. 
    location.href="../FileDownload.do?dir=/upload/&filename=" + encodeURIComponent(p_filename); // PhotoInsertFormAction
}
cs

[ 외부 서블릿 파일 ]

더보기
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package action.util;
 
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URLEncoder;
 
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
 
/**
 * IE 8.0인경우 화일다운로드 요청을 2회실시
 * (이유는 정확이 모르겠지만 화일다운로드 다이아로그가 띄어지면서 다시 호출하는것 같음.)
 * 첫번째 요청인경우 한글인코딩이 제대로 이뤄지는데
 * 두번째는 한글이 깨진다
 * 그래서 첫번째값만 저장해놓고 그값을 사용한다.
 */
 
@WebServlet("/FileDownload.do")
public class FileDownload extends HttpServlet {
    private static final long serialVersionUID = 1L;
 
    
    /**
     * @see HttpServlet#service(HttpServletRequest request, HttpServletResponse response)
     */
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //request.setCharacterEncoding("utf-8");
        String dir = request.getParameter("dir");
        String fullpath = getServletContext().getRealPath(dir);
        String filename = "";
        filename = request.getParameter("filename");
        String fullpathname = String.format("%s/%s", fullpath,filename);
        //System.out.println(fullpathname);
        File file = new File(fullpathname);
        byte [] b = new byte[1024*1024*4];
        
         // 사용자 브라우저 타입 얻어오기
        String strAgent = request.getHeader("User-Agent");
        String userCharset = request.getCharacterEncoding();
        if(userCharset==null)userCharset="utf-8";
        
        //System.out.println("filename:"+filename+"\nagent:"+strAgent+"\ncharset:"+userCharset);
        //System.out.println("----------------------------------------------------------------");
        String value = "";
        // IE 일 경우
        if (strAgent.indexOf("MSIE"> -1
        {
            // IE 5.5 일 경우
            if (strAgent.indexOf("MSIE 5.5"> -1
            {
                value = "filename=" + filename ;
            }
            // 그밖에
            else if (strAgent.indexOf("MSIE 7.0"> -1
            {
                if ( userCharset.equalsIgnoreCase("UTF-8") ) 
                {
                    filename = URLEncoder.encode(filename,userCharset);
                    filename = filename.replaceAll("\\+"" ");
                    value = "attachment; filename=\"" + filename + "\"";
 
                }    
                else 
                {
                    value = "attachment; filename=" + new String(filename.getBytes(userCharset), "ISO-8859-1");
                   
                }
            }
            else{
                //IE 8.0이상에서는 2회 호출됨..
                if ( userCharset.equalsIgnoreCase("UTF-8") ) 
                {
                    filename = URLEncoder.encode(filename,"utf-8");
                    filename = filename.replaceAll("\\+"" ");
                    value = "attachment; filename=\"" + filename + "\"";
                    
                }    
                else 
                {
                    value = "attachment; filename=" + new String(filename.getBytes(userCharset), "ISO-8859-1");
                   
                }
            }
            
            
        }else if(strAgent.indexOf("Firefox"> -1){
            //Firefox : 공백문자이후은 인식안됨...
            value = "attachment; filename=" + new String(filename.getBytes(), "ISO-8859-1");
        }
       else {
            // IE 를 제외한 브라우저
            value = "attachment; filename=" + new String(filename.getBytes(), "ISO-8859-1");
        }
        
   
        response.setContentType("Pragma: no-cache"); 
 
        //전송 데이터가 stream 처리되도록 : 웹상전송 문자셋은 : 8859_1
        response.setContentType("application/octet-stream;charset=8859_1;");
        //모든 화일에 대하고 다운로드 대화상자가 열리게 설정
        //Content-Disposition : attachment
         response.setHeader("Content-Disposition", value);
        //전송타입은 binary(이진화일)
        response.setHeader("Content-Transfer-Encoding""binary;");
        if(file.isFile())
        {
            BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file));
            BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream());
            int i=0;
            try
            {
                while((i=bis.read(b))!=-1)
                {
                    bos.write(b,0,i);
                }
            }catch(Exception e){
                //e.printStackTrace();
            }finally {
                if(bos!=null)bos.close();
                if(bis!=null)bis.close();
                
            }
        }
    }
    
}
 
cs

'BackEnd > WEB' 카테고리의 다른 글

XML_국비_DAY70  (0) 2022.06.10
PhotoGallery[완결]_국비_DAY69  (0) 2022.06.09
파일업로드_국비_DAY67  (0) 2022.06.07
백준[자바]_2206_벽부수고이동하기_BFS_너비우선탐색  (0) 2022.06.07
세션트래킹_국비_DAY66  (0) 2022.06.03