스마트 해상물류 x ICT멘토링 블렌디드 러닝 지원을 통해 수강한 스파르타코딩의 웹개발 플러스 강좌에서 개발한
프로젝트를 복습하는 겸 다시 분석하며 정리하는 게시물이다.
전체 코드는 밑의 깃허브에 업로드해두었다.
https://github.com/dong5854/my_dictionary
이번 프로젝트의 핵심 목표는 동적 웹페이지와 템플릿 언어의 차이를 알고 동적 웹페이지를 만들어보는 것이다.
- 정적 웹페이지(static web page)는 서버에 저장되어있는 HTML+CSS 파일을 그대로 보여주는 것이다.
- 반면 동적 웹페이지(dynamic web page)는 상황에 따라 서버에 저장되어있는 HTML에 데이터 추가/가농을 해서 보여주는 방법이다.
- 정적 웹페이지는 추가적인 통신&계산이 필요 없기 때문에 속도가 빠르고 서버에 부담이 적은 반면, 추가/수정/삭제 등 내용 변경이 필요할 떄 HTML자체를 수정해야 하기 때문에 번거롭다는 단점이있다.
- 동적 웹페이지는 한 페이지가 상황이나 시간 또는 사용자의 요청에 따라 동적으로 변하며 다른 모습을 보여주는 장점이 있으나, 정적 웹페이지에 비해 보안에 취약하고 검색엔진 최적화(search engine optimization,SEO)가 어렵다.
- 보통 요즘의 웹사이트는 전부 동적으로 작동하나 회사소개, 음식 메뉴, 혹은 포트폴리오등의 웹 페이지의 내용이 잘 변하지 않는 웹페이지는 정적 웹페이지가 더 적합하다.
동적 웹 페이지의 종류
동적 웹 페이지의 종류는 CSR(Client-side rendering), SSR(server-side rendering), 또는 복합적인 방법이 있는데,
CSR, 즉 클라이언트 사이드 렌더링은 HTML 문서 자체에 모든 내용이 들어가 있는것이 아니라, 자바스크립트에 데이터를 포함해서 보낸 후, 클라이언트 쪽에서 HTML을 완성하는 방법이다.
SSR, 즉 서버 사이드 렌더링은 서버 쪽에서 HTML에 필요한 데이터를 끼워넣어 변환한 후 보내주는 방법이다.
또한 마지막으로 복합적인 방법으로는 클라이언트 쪽에서 Ajax 요청을 보내 서버에서 데이터를 받아와 HTML을 완성하는 방법이 있다.
Jinja2
이번 프로젝트에서 사용하는 jinja2는 파이썬에서 사용하는 템플릿 언어로 '템플릿'이 되는 HTML 문서에 서버에서 받아온 데이터가 들어갈 곳을 표시해주고 조건문, 반복문 등의 제어문를 사용할 수 있게 해주는 역할도 한다.
프로젝트 분석
이번 프로젝트는 두개의 페이지가 페이지 간의 이동을 해야하는 구조이기 때문에 2개의 HTML파일을 각각 detail.html 그리고 index.html이란 이름으로 templates폴더 안에 넣어 주었고 서버가 가동될 app.py 파이썬 파일가 있다. 또한 static 폴더 안에는 favicon이 될 favicon.png와 배너가 될 logo_red.png, 그리고 mystyle.css이라는 css파일이 담겨있다.
favicon(파비콘)은 크롬 기준으로 위의 탭에 나타나는 웹 사이트를 대표하는 이미지로
좌측의 이미지에서 빨간 동그라미를 친 부분이다.
프로젝트 분석
#app.py
from flask import Flask, render_template, request, jsonify, redirect, url_for
from pymongo import MongoClient
import requests
app = Flask(__name__)
client = MongoClient('mongodb://몽고디비아이디:몽고디비비밀번호@몽고디비가있는IP', 27017)
db = client.dbsparta_plus_week2
app = Flask(__name__)은 Flask 인스턴스를 생성해주는 코드이다. __name__변수에 대해서 간단히 이야기해 보자면__name__변수는 .py 파일이 직접 실행될 때 __main__이 들어가고 다른 파일에서 import되어 실행이 되면 모듈, 즉 파일의 이름이 들어간다. 깃허브에서 여러 파이썬 코드를 보다보면 if __name__ == "__main__": 다음과 같은 구문을 자주 볼 수 있을텐데 이는 해당 모듈이 import된 경우가 아니라 직접 실행이 되었을 때만 if문 이하의 구문을 실행하라는 의미이다.
client = MongoClient('mongodb://몽고디비아이디:몽고디비비밀번호@몽고디비가있는IP', 27017)
db = client.dbsparta_plus_week2
위의 두 줄의 코드는 몽고디비가 aws서비스의 ec2 인스턴스와 같은 곳에 설치가 되어 있을 시 이를 원격으로 접속하고 db를 dbsparta_plus_week2라는 이름으로 생성하기 위한 코드이다. aws를 사용하지 않고 로컬에 설치된 몽고디비를 이용할 것이라면 "몽고디비가있는IP"라고 적혀있는 부분을 localhost 또는 127.0.0.1로 적으면 된다.
#app.py
@app.route('/')
def main():
msg = request.args.get("msg")
# DB에서 저장된 단어 찾아서 HTML에 나타내기
words = list(db.words.find({}, {"_id": False}))
return render_template("index.html", words=words, msg=msg)
@app.route('/') 앞에 @가 붙는데 파이썬에서 이런 표현을 데코레이터라고 한다. 플라스크에서 @app.route('')는 URL을 연결할 때 사용되는데 이 장식자 다음행의 함수가 해당 URL에서 실행되는 것으로 앞으로도 계속 나올 표현이다.
위 코드에서@app.route('/')는 서버가 로컬에서 돌아간 다고 했을 때 localhost:5000/ 에서 main()함수가 돌아가도록 한다.
msg = request.args.get("msg") 코드는 밑에 나올 코드와 연관되어 있는 부분으로 밑의 코드에 있을redirect(url_for("main", msg="Word not found in dictionary; Try another word")) 에서 msg 에 해당하는 부분을 msg변수에 넣어주는 역할을 한다. 이 msg 변수는 index.html을 render할 때 넘어가
//index.html의 script태그
{% if msg %}
alert("{{ msg }}")
{% endif %}
let words = {{ words|tojson }};
let word_list = [];
for (let i = 0; i < words.length; i++) {
word_list.push(words[i]["word"])
}
다음 코드에서 msg가 있을 때 alert창을 띄우고 해당 메시지를 출력해준다. {% if msg %} ~ {% endif %} 와 alert("{{ msg }}")에서 {% %} 혹은 {{ }}은 전부 jinja2의 템플릿 언어를 사용하는 부분으로 서버에서 보낸 값인 msg를 html에서 받아서 사용할 수 있게 해주는 것을 확인할 수 있다.
#app.py
@app.route('/detail/<keyword>')
def detail(keyword):
# API에서 단어 뜻 찾아서 결과 보내기
status_receive = request.args.get("status_give", "old")
r = requests.get(f"https://owlbot.info/api/v4/dictionary/{keyword}",
headers={"Authorization": "Token [owlbot오픈API의 토큰입니다]"})
if r.status_code != 200:
return redirect(url_for("main", msg="Word not found in dictionary; Try another word"))
result = r.json()
print(result)
return render_template("detail.html", word=keyword, result=result, status=status_receive)
위의 코드에서
if r.status_code != 200:
return redirect(url_for("main", msg="Word not found in dictionary; Try another word"))
부분이 위에서 언급한 부분으로 API에서 값을 잘 받아왔을 때 상태 코드가 200이므로 200이 아닐 떄 main으로 리다이렉팅 시키며 msg에도 메시지를 같이 전달하는 파트이다.
r = requests.get(f"https://owlbot.info/api/v4/dictionary/{keyword}"
headers={"Authorization": "Token [owlbot오픈 API의 토큰입니다"})
코드에서 https://owlbot.info/api/v4/dictionary/{keyword} 는 {keyword}에 검색하고 싶은 단어가 들어가며
headers={"Authorization": "Token [owlbot오픈 API의 토큰입니다"}) 는 HTTP헤더를 해당 dict값으로 추가를 하거나 오버라이드하여 덮어쓴다. 여시서는 인증을 위한 토큰 키를 헤더에 넣어주는 역할을 한다.
<!--html 배경 & 배너-->
<div class="wrap">
<div class="banner" onclick="window.location.href = '/'">
</div>
</div>
html에서 배너에 해당되는 부분을 나타낼 banner클래스와 배경에 해당되는 부분이 나타날 wrap 클래스에 해당되는 부분으로 index.html과 detail.html에 전부 들어있는 코드이다.
#mystyle.css
.wrap {
background-color: RGBA(232, 52, 78, 0.2); //배경색을 정하는 css
min-height: 100vh; //요소의 최소 높이를 설정해주는 css, 설정한 값 이하로 축조되지 않는다.
padding-bottom: 50px; //padding속성은 contetnt(내용)과 border(테두리)의 여백을 설정한다.
}
.banner {
width: 100%; //width가 100%일 때에는 요소가 부모 요소의 너비에 맞춰 늘어난다.
height: 200px; //높이를 설정해주는 css
background-color: white; //배경색을 정하는 css
background-image: url('{{ url_for("static", filename="logo_red.png") }}');
//이미지를 배경으로 사용하게 해주는 속성으로 static폴더 안의 logo_red.png파일을 지정하고 있다.
background-position: center; //배경이미지를 가운데로 옮겨주는 css속성
background-size: contain; //background의 contain은 너비와 높이가 내용 안쪽에 알맞은 크기로 조절
background-repeat: no-repeat; //backgound-image로 추가한 배경이 반복하지 않게 하는 css
cursor: pointer;// 해당 속성 위에 마우스를 올릴때 커서 모양이 바뀐다.
}
mystyle.css에 들어있는 코드로 위에서 wrap과 banner클래스에 대한 css를 적용해주는 코드이다. 이렇게 분리한 CSS를 사용하기 위해서는 html에 아래와 같은 링크를 걸어주어야 한다.
<!--index.html과 detail.html-->
<link href='{{ url_for("static", filename="mystyle.css") }}' rel="stylesheet">
<!--detail.html-->
<div class="d-flex justify-content-between align-items-end">
<div>
<h1 id="word" style="display: inline;">{{ result.word }}</h1>
{% if result.pronunciation %}
<h5 id="pronunciation" style="display: inline;">/{{ result.pronunciation }}/</h5>
{% endif %}
</div>
{% if status=="new" %}
<button id="btn-save" class="btn btn-outline-sparta btn-lg" onclick="save_word()"><i
class="fa fa-floppy-o"
aria-hidden="true"></i></button>
{% else %}
<button id="btn-delete" class="btn btn-sparta btn-lg" onclick="delete_word()"><i
class="fa fa-trash-o"
aria-hidden="true"></i></button>
{% endif %}
</div>
<hr>
<div id="definitions">
{% set definitions = result.definitions %}
{% for definition in definitions %}
<div style="padding:10px">
<i>{{ definition.type }}</i>
<br>{{ definition.definition.encode('ascii', 'ignore').decode('utf-8') }}<br>
{% if definition.example %}
<span class="example">{{ definition.example.encode('ascii', 'ignore').decode('utf-8')|safe }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
단어의 뜻을 보여주는 html이다. jinja2를 활용한 if문들로 예외처리를 진행한다. 단어의 삭제와 저장을 위한 아이콘은
https://fontawesome.com/v4.7.0/
위의 Font Awesome이라는 곳에서 가져와 사용하는데 이 아이콘을 사용하기 위해서는 head에
<link href="//maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
링크가 삽입되어야하고 html에서는 class형태로 들어간다. ex) class="fa fa-floppy-o", class="fa fa-trash-o"
.encode('ascii', 'ignore').decode('utf-8')의 의미는 .encode앞의 문자열을 아스키 코드의 형태로 인코딩을 해줄 건데 아스키코드로 바꿀 수 없는 부분은 무시를 하고 다시 utf-8형태로 바꾸어 주라는 의미의 코드이다.
그 다음으로 있는 |safe는 |(파이프)앞에 값이 html일 수 있는데 이 html이 안전한 것이니 태그 사용을 허용한다는 뜻이다.
#app.py
@app.route('/api/save_word', methods=['POST'])
def save_word():
# 단어 저장하기
word_receive = request.form['word_give']
definition_receive = request.form['definition_give']
doc = {"word": word_receive, "definition": definition_receive}
db.words.insert_one(doc)
return jsonify({'result': 'success', 'msg': f'word "{word_receive}" saved'})
목록 페이지에서는 단어 당 뜻을 하나만 보여줄 것이기 때문에 단어와 첫 번째 정의만 POST 요청으로 보내고, 서버에서 단어와 뜻을 받아 words 컬렉션에 저장한다.
//detail.html의 script태그
function save_word() {
$.ajax({
type: "POST",
url: `/api/save_word`,
data: {
word_give: "{{ word }}",
definition_give: "{{ result.definitions[0].definition }}"
},
success: function (response) {
alert(response["msg"])
window.location.href = "/detail/{{ word }}?status=old"
}
});
}
클라이언트에서는 단어와 첫번째 정의만 POST요청으로 보내준다. 단어 저장에 성동하면 alert을 실행해 app.py에서 응답으로 받은 msg를 출력하고 status=old로 바뀐 페이지를 띄워준다. 위의 함수를 저장 버튼에 onclick=save_word()로 연결해준다.
#app.py
@app.route('/api/delete_word', methods=['POST'])
def delete_word():
# 단어 삭제하기
word_receive = request.form['word_give']
db.words.delete_one({"word": word_receive})
db.examples.delete_many({"word": word_receive})
return jsonify({'result': 'success', 'msg': f'word "{word_receive}" deleted'})
단어를 삭제할 때는 단어만 있으면 되므로 POST 요청으로 단어를 보내주고, 서버에서는 해당 단어를 찾아 삭제해준다.
//detail.html의 script태그
function delete_word() {
$.ajax({
type: "POST",
url: `/api/delete_word`,
data: {
word_give: '{{ word }}',
},
success: function (response) {
alert(response["msg"])
window.location.href = "/"
}
});
}
클라이언트에서는 단어를 보내주고, 단어 삭제에 성공하면 더이상 보여줄 정보가 없으므로 alert을 띄운 후 메인 페이지로 이동한다.
<!--index.html-->
<div class="search-box d-flex justify-content-center">
<input id="input-word" class="form-control" style="margin-right: 0.5rem">
<button class="btn btn-light" onclick="find_word()"><i class="fa fa-search"></i></button>
</div>
위의 코드는 검색창을 만들어 주는 html코드이다. 이 검색창을 위한 CSS 코드 또한 아래와 같이 넣어준다.
.search-box {
width: 70%;
margin: 50px auto;
max-width: 700px;
}
<!--index.html-->
<table class="table">
<thead class="thead-light">
<tr>
<th scope="col" style="width:30%">WORD</th>
<th scope="col">MEANING</th>
</tr>
</thead>
<tbody id="tbody-box">
{% for word in words %}
<tr id="word-{{ word.word }}">
<td><a href="/detail/{{ word.word }}?status_give=old">{{ word.word }}</a></td>
<td>{{ word.definition|safe }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
위의 코드로 단어들이 들어갈 테이블을 만들어주고 CSS는 아래와 같이 넣어준다.
.table {
width: 80%;
max-width: 800px;
margin: auto;
table-layout: fixed;
}
.table th {
border-top-style: none;
} //table의 head 즉 표의 제목에 적용되는 CSS로 boarder(테두리)의 위쪽에 스타일 지정을 하지 않는다는 뜻
td {
background-color: white; //배경 색을 white로 지정
text-overflow: ellipsis; //텍스트가 너무 길어 질 경우 '...'으로 마무리 해주는 속성
overflow: hidden; // 요소 내의 컨텐츠가 너무 커서 요소내에 모두 보여주기 힘들때 숨겨주는 속성
white-space: nowrap; // white-space는 스페이스와 탭, 줄바꿈, 자동줄바꿈을 어떻게 처리할지 정하는 속성
} //td, table data의 약자로 셀을 만드는 역할을 하는곳에 적용되는 CSS
td > a, a:visited, a:hover, a:active {
color: black;
} //td태그 안의 a태그에 적용되는 CSS로
//a:visited는 방문 후 링크의 상태, a:hover은 마우스를 올렸을 때 링크의 상태, a:active는 클릭했을 때 링크의 상태이다.
thead:first-child tr:first-child th:first-child {
border-radius: 10px 0 0 0;
} //: first-child 혹은 : last-child같은 부분은 의사클래스라고 불리며 각각 첫번째 자식요소, 마지막 자식요소를 뜻한다.
thead:first-child tr:first-child th:last-child {
border-radius: 0 10px 0 0;
}
tbody:last-child tr:last-child td:first-child {
border-radius: 0 0 0 10px;
}
tbody:last-child tr:last-child td:last-child {
border-radius: 0 0 10px 0;
}
//index.html의 script태그
let words = {{ words|tojson }};
let word_list = [];
for (let i = 0; i < words.length; i++) {
word_list.push(words[i]["word"])
}
단어를 검색했을 때 이미 저장된 단어진지 알기 위해서 있는 단어 리스트를 만든다.
//index.html안의 script태그
function find_word() {
let word = $("#input-word").val().toLowerCase();
if (word == "") {
// 빈 문자열이면 alert
alert("please write something first :)")
return
}
if (word_list.includes(word)) {
// 리스트에 있으면 하이라이트
$(`#word-${word}`).addClass('highlight').siblings().removeClass('highlight');
$(`#word-${word}`)[0].scrollIntoView();
} else {
// 리스트에 없으면 상세 페이지로
window.location.href = `/detail/${word}?status_give=new`
}
}
단어를 검색했을 때 단어 리스트에 있는 경우에는 해당 행에 highlight 클래스를 추가해주고 그 외의 siblings에는 highlight 속성을 지워준다. 그리고 scrollOntoView()를 통해 타겟이 있는 화면으로 스크롤을 해준다. 만약 리스트에 없다면 상세표시로 넘어가는데 이때 status_give는new이다.
highlight 클래스가 부여된 행은 아래와 같은 CSS로 나타난다.
tr.highlight > td {
background-color: #e8344e;
color: white;
}
tr.highlight a {
color: white;
}
<meta property="og:title" content="Sparta Vocabulary Notebook"/>
<meta property="og:description" content="mini project for Web Plus"/>
<meta property="og:image" content="{{ url_for('static', filename='logo_red.png') }}"/>
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
위의 코드는 og(open Graph)태그와 favicon을 추가하기 위한 코드로 static폴더 안의 이미지들을 사용한다.
아래의 코드들은 각 단어에 내가 만든 예문을 저장/삭제하는 기능을 위한 코드들이다.
<!--detail.html-->
{% if status=="old" %}
<div id="examples" class="container">
<h3 style="text-align: center;margin-bottom:1rem">Write your own sentences!</h3>
<ul id="example-list">
<li id="ex-0">This sentence contains the word 'word'. <a
href="javascript:delete_ex(0)">delete</a></li>
<li id="ex-1">I don't like using the MS Word program. <a
href="javascript:delete_ex(1)">delete</a></li>
</ul>
<div class="d-flex justify-content-between" style="margin-left:20px;">
<input id="new-example" class="form-control form-control-sm" style="margin-right: 0.5rem">
<button class="btn btn-outline-secondary btn-sm" onclick="add_ex()">add</button>
</div>
</div>
{% endif %}
detail.html에 예문이 들어갈 칸을 만들어 준다. 새 단어일 때는 보이지 않고 기존 단어일 때만 나타나야 하기 때문에 status가 old일때만 나타나도록 if문을 넣어준다.
//detail.html의 scrip태그
function get_examples() {
$("#example-list").empty()
$.ajax({
type: "GET",
url: `/api/get_examples?word_give=${word}`,
data: {},
success: function (response) {
let examples = response["examples"];
for (let i = 0; i < examples.length; i++) {
let example = examples[i]["example"];
console.log(example)
let html_temp = `<li id="ex-${i}">${example} <a
href="javascript:delete_ex(${i})">delete</a></li>`
$("#example-list").append(html_temp)
}
}
});
}
function add_ex() {
let new_ex = $('#new-example').val();
if (!new_ex.toLowerCase().includes(word.toLowerCase())) {
alert(`the word '${word}' is not included.`);
return;
}
console.log(new_ex)
$.ajax({
type: "POST",
url: `/api/save_ex`,
data: {
word_give: word,
example_give: new_ex
},
success: function (response) {
get_examples();
$('#new-example').val("");
}
});
}
function delete_ex(i) {
console.log("deleting", i)
$.ajax({
type: "POST",
url: `/api/delete_ex`,
data: {
word_give: word,
number_give: i
},
success: function (response) {
get_examples()
}
});
}
변화가 있을 때 페이지 전체를 로딩하지 않고 예문 칸만 새로 채워넣는 방법이 효율적이기 때문에 jinja보다는 ajax를 사용하는 것이 더 좋다.
add_ex()함수에서는 예문이 해당 단어를 포함하는지 확인하고 포함하지 않는다면 alert를 띄운다. POST 요청을 보내 DB에 저장해준다.
add_ex()와 delete_ex() 함수의 ajax가 성공적이 었을 때 get_examples()함수를 새로 실행함으로서 새 예문을 저장했을 때, 기존 예문을 삭제했을 때 다시 예문을 보여준다.
get_examples()함수에서 예문을 불러올 때 각 줄에 id를 <li id="ex-${i}">방식으로 주는데 이는 예문을 삭제 할 때 여러 예문 중 어떤 것인지 구분을 하기 위해 부여되는 것이다.
#app.py
@app.route('/api/get_examples', methods=['GET'])
def get_exs():
#예문 가져오기
word_receive = request.args.get("word_give")
result = list(db.examples.find({"word": word_receive}, {'_id': 0}))
print(word_receive, len(result))
return jsonify({'result': 'success', 'examples': result})
@app.route('/api/save_ex', methods=['POST'])
def save_ex():
#예문 저장하기
word_receive = request.form['word_give']
example_receive = request.form['example_give']
doc = {"word": word_receive, "example": example_receive}
db.examples.insert_one(doc)
return jsonify({'result': 'success', 'msg': f'example "{example_receive}" saved'})
@app.route('/api/delete_ex', methods=['POST'])
def delete_ex():
#예문 삭제하기
word_receive = request.form['word_give']
number_receive = int(request.form["number_give"])
example = list(db.examples.find({"word": word_receive}))[number_receive]["example"]
print(word_receive, example)
db.examples.delete_one({"word": word_receive, "example": example})
return jsonify({'result': 'success', 'msg': f'example #{number_receive} of "{word_receive}" deleted'})
get_exs() 함수는 DB에 저장된 예문들 중 해당 단어에 관한 것만 찾아와서 보여주는 함수를 만든다. 페이지가 로딩되었을 때
save_ex() 함수에서는 examples라는 컬렉션을 따로 만들어 예문들을 저장 해준다.
#app.py
@app.route('/api/delete_word', methods=['POST'])
def delete_word():
# 단어 삭제하기
word_receive = request.form['word_give']
db.words.delete_one({"word": word_receive})
db.examples.delete_many({"word": word_receive})
return jsonify({'result': 'success', 'msg': f'word "{word_receive}" deleted'})
db.examples.delete_many({"word": word_receive})를 통해 단어를 삭제할 때 단어에 해당하는 예문들을 같이 지워준다.
'프로젝트 > 간단한 프로젝트' 카테고리의 다른 글
노마드 코더 바닐라JS 챌린지 졸업작품 (0) | 2021.12.27 |
---|---|
나만의 다이어리 만들기 (0) | 2021.12.21 |
나만의 SNS 만들기 프로젝트(스파르타 웹개발 플러스) (1) | 2021.09.05 |