9장에서는 웹 사이트를 꾸며줄 CSS나 이미지 파일 등을 어떻게 처리해야 하는지에 대하여 다루고 있다.
4장에서 부트스트랩을 연습할 때 만들었던 blog_list.html 내용을 복사해서 post_list.html에 덮어 씌우자. 그리고 서버를 실행시켜 blog 페이지로 가보자. 그러면 CSS가 적용되지 않은 것을 볼 수 있다.
4장에서 연습할 때는 잘 적용이 되었는데, 지금은 왜 적용이 안된걸까?
F12를 눌러서 개발자 도구를 열고 Console을 봐보면, bootstrap.min.css 파일이 없다고 나와있다.
한 번 부트스트랩을 사용할 때의 기억을 되살려 보자. 웹 브라우저는 먼저 서버에 접속하여 html 파일을 불러온다. html 파일에 css 파일을 사용한다고 명시되어 있다면 그 파일을 불러와서 그 안에 정의된 모양을 적용한 다음, 웹 브라우저 화면에 렌더링한다. 즉, HTML에서 bootstrap.min.css 파일을 불러와서 적용한다.
하지만, 지금은 post_list.html 파일이 있는 곳에 4장에서 했던 것 처럼 bootstrap 폴더를 만들고 bootstrap.min.css 파일을 넣어두고 post_list.html에 bootstrap.min.css의 경로를 넣는다고 해결되지 않는다. 왜냐하면 장고는 모든 경로를 urls.py에서 제어하고 있기 때문이다.
장고는 MTV 구조로 동작한다. 즉, 앱 폴더에 있는 templates 폴더의 html 파일은 views.py에 정의한 내용에 따라 그 빈 칸을 채워 사용자에게 정보를 제공하므로 정적 파일이 아니다. 정적인 파일이 아니라 함은 변화가 있는 파일이라는 뜻이다. 반면 css 파일, jpg 등 이미지 파일 또는 javascript 파일은 따로 변화하지 않는 파일, 즉 변수가 따로 안들어가므로 정적인 파일이라 할 수 있다.
따라서 templates 폴더에 css, js 파일을 함께 넣어 둬도 해당 파일에 접근할 수 없다.
그러면 어떻게 정적 파일을 관리해야 할까?
css, js 파일은 templates 폴더의 html 파일과 달리 고정된 내용만 제공하면 된다. 따라서 최종적으로 웹 서버를 운영할 때는 특정 URL로 접근을 하면 해당 css, js 파일을 제공할 수 있도록 설정해 두면 된다. 그 방법을 알아보자.
각 앱 폴더 아래에 static 폴더를 만들고 css, js와 같은 정적 파일을 넣는다.
예를 들어 blog/static/blog/bootstrap 폴더를 만든다. 그리고 bootstrap.min.css 파일과 bootstrap.min.css.map 파일을 그 안에 넣어주자.
Point. 왜 blog/static/bootstrap/이 아니라 blog/static/blog/bootstrap/ 일까?
나중에 모든 앱들의 static 파일들을 묶어서 관리할건데, static폴더 안에 바로 bootstrap 폴더를 생성해버리면 장고가 헷갈려한다.
즉, app1/static/bootstrap/...
, app2/static/bootstrap/...
처럼 static폴더 안에 바로 bootstrap 폴더를 생성한 경우, 모든 앱의 static 파일을 묶으면 어떤 파일이 어떤 앱의 것인지 혼선이 온다.
반면, app1/static/app1/bootstrap/...
, app2/static/app2/bootstrap/...
처럼 static 폴더 안에 한번 더 앱 이름의 폴더를 생성해주면, static 파일을 묶었을때 blog/bootstrap, single_pages/bootstrap 처럼 어떤 앱의 파일인지 파악하기 쉽다.
이는 앱의 독립성을 위한 작업이며, templates 폴더 또한 마찬가지이다.
post_list.html 파일에서 <!DOCTYPE html>
바로 아래에 {% load static %}
를 추가하여 static 파일을 사용하겠다고 선언한다.
그리고 <head>
태그 안에 원래 bootstrap.min.css 파일 링크가 있던 부분을 다음과 같이 수정하자.
<!DOCTYPE html>
{% load static %}
<html>
<head>
<title>Blog</title>
<link href="{% static 'blog/bootstrap/bootstrap.min.css' %}" rel="stylesheet" media="screen">
<script src="https://kit.fontawesome.com/**********.js" crossorigin="anonymous"></script>
</head>
지금 post_list.html은 포스트가 2개 있는 것처럼 모양만 만들어 놓은 상태이다.
실제 내용이 보이도록 수정하자.
현재 post_list.html 파일에는 <!-- Blog Post -->
로 표시되어 있는 div
요소가 2개 있다. 이 중 하나는 지우고, 앞에서 배운 for 문을 이용하여 다음과 같이 수정하자.
제목이 있어야 하는 자리에 {{ p.title }}
, 본문이 있어야 하는 자리에 {{ p.content }}
, 작성일이 있어야 하는 자리에 {{ p.created_at }}
으로 대체한다.
그리고 <Read More>
버튼 위치의 href
에 있던 #을 지우고 {{ p.get_absolute_url }}
로 고치자.
<div class="container my-3">
<div class="row">
<div class="col-md-8 col-lg-9">
<h1>Blog</h1>
{% for p in post_list %} <!-- 추가 -->
<!-- Blog post-->
<div class="card mb-4">
<a href="#!"><img class="card-img-top" src="{{ p.head_image.url }}" alt="..." /></a>
<div class="card-body">
<h2 class="card-title h4">{{ p.title }}</h2> <!-- 수정 -->
<p class="card-text">{{ p.content }}</p> <!-- 수정 -->
<a class="btn btn-primary" href="{{ p.get_absolute_url }}">Read more →</a> <!-- 수정 -->
</div>
<div class="card-footer small text-muted">
Posted on {{ p.created_at }} by <!-- 수정 -->
<a href="#">작성자명 쓸 위치(개발예정)</a>
</div>
</div>
{% endfor %}
포스트 상세 페이지에도 부트스트랩을 적용해보자.
포스트 목록 페이지를 만들 때 활용했던 Start Bootstrap에서 포스트 상세 페이지 디자인을 벤치마킹하자.
필자는 해당 링크에서 index.html 파일을 사용하였다.
해당 내용을 전부 복사한후 blog/templates/blog/post_detail.html의 기존내용을 모두 지우고 붙여 넣는다.
그리고 맨 위에 {% load static %}
을 추가하고, bootstrap.min.css를 가져오는 부분을 포스트 목록 페이지를 참고하여 수정하자.
<title>{{ post.title }} - Blog</title>
로 title 필드 값이 웹 브라우저 탭의 타이틀이 되도록 수정한다.
마지막으로 js 파일 링크도 원래 있던 jquery.min.js와 bootstrap 관련 코드는 삭제하고, post_list.html에 적용했던 링크로 수정한다.
<!DOCTYPE html>
{% load static %} <!-- 추가 -->
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ post.title }} - Blog</title> <!-- 수정 -->
<!-- Bootstrap core CSS -->
<!-- 수정 -->
<link rel="stylesheet" href="{% static 'blog/bootstrap/bootstrap.min.css' %}" media="screen">
<!-- Custom styles for this template -->
<link href="css/blog-post.css" rel="stylesheet">
</head>
(...생략...)
<!-- Bootstrap core JavaScript -->
<!-- 여기부터 -->
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"
integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
crossorigin="anonymous"></script>
<!-- 여기까지 수정 -->
</body>
</html>
웹 페이지에서 확인해보면 포스트 제목이 내비게이션 바에 가려서 보이지 않는다.
이는 <nav>
태그의 class에 추가된 fixed-top
때문이다.
post_list.html과 post_detail.html의 <nav>
태그를 비교해보자.
<!-- blog/templates/blog/post_list.html -->
(..생략..)
<nav class="navbar navbar-expand-lg navbar-light bg-light">
(..생략..)
<!-- blog/templates/blog/post_detail.html -->
(..생략..)
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
(..생략..)
post_detail.html의 <nav>
태그에만 fixed-top
이 있다.
이 설정을 지우면 내비게이션 바에 페이지 윗부분이 가려지는 문제를 해결할 수 있다. 하지만 화면을 아래로 내리면 내비게이션 바가 위로 올라가 보이지 않는다.
내비게이션 바를 위에 고정시키면서 페이지 내용도 가려지지 않게 하려면, blog/static/blog 폴더 안에 css 폴더를 만들자. 그리고 해당 링크의 css 폴더에서 blog-post.css 파일을 찾아 넣는다.
blog-post.css 파일은 어떤 역할을 할까?
파일 내용을 살펴보니 <body>
태그의 padding-top
을 56픽셀로 설정한 것을 볼 수 있다. 페이지의 윗부분이 내비게이션 바에 가려지므로 그 가려지는 크기만큼 body
요소에 패딩을 주는 간단한 해결책이다.
그리고 해당 css 파일을 사용하기 위해 blog/templates/blog/post_detail.html 파일을 다음과 같이 수정하자.
<!DOCTYPE html>
{% load static %}
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ post.title }} - Blog</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="{% static 'blog/bootstrap/bootstrap.min.css' %}" media="screen">
<!-- Custom styles for this template -->
<!-- 수정 -->
<link rel="stylesheet" href="{% static 'blog/css/blog-post.css' %}" media="screen">
</head>
blog/templates/blog/post_detail.html을 다음과 같이 수정하자.
(..생략..)
<!-- Page Content -->
<div class="container">
<div class="row">
<!-- Post Content Column -->
<div class="col-lg-8">
<!-- Title -->
<h1 class="mt-4">{{ post.title }}</h1>
<!-- Author -->
<p class="lead">
by
<a href="#">작성자명 쓸 위치(개발예정)</a>
</p>
<hr>
<!-- Date/Time -->
<p>Posted on {{ post.created_at }}</p>
<hr>
<!-- Preview Image -->
<img class="img-fluid rounded" src="{{ post.head_image.url }}" alt="">
<hr>
<!-- Post Content -->
<p>{{ post.content }}</p>
<hr>
(..생략..)
이번에는 이미지를 작성자가 선택해서 업로드할 수 있는 기능을 구현해보자.
장고는 이미지 업로드를 위한 ImageField
를 제공한다.
ImageField
를 사용하려면
사용자가 업로드한 이미지를 어디에 저장할지 설정.
업로드된 이미지들이 모여 있는 폴더의 URL을 어떻게 할지 설정해야한다.
우선 settings.py를 열어 맨 아래에 다음과 같이 두 줄을 추가하자. 그리고 os모듈도 사용해야 하니 import 하자.
# do_it_django/setting.py
import os
from pathlib import Path
(..생략..)
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, '_media')
MEDIA_ROOT
는 실제로 유저가 올리는 이미지 등의 파일이 올라가는 루트 위치이며, MEDIA_URL
은 이 루트 폴더를 어느 url과 맵핑시킬지를 나타낸다.
즉, 이미지 파일은 프로젝트 폴더 아래 _media
라는 이름의 폴더 안에 저장하도록 하고, 웹 브라우저에서 도메인 뒤에 /media/라는 경로가 따라오면 미디어 파일을 사용하겠다는 의미다.
이제 blog/models.py를 다음과 같이 수정한다.
from django.db import models
# Create your models here.
class Post(models.Model): # models 모듈의 Model 클래스를 확장하여 만든 클래스
"""
포스트의 형태를 정의하는 Post 모델
제목(title), 내용(content), 작성일(created_at), 작성자 정보(author)
"""
title = models.CharField(max_length=30) # CharField : 문자를 담는 필드
content = models.TextField() # TextField : 문자열의 길이 제한이 없는 필드
head_image = models.ImageField(upload_to='blog/images/%Y/%m/%d/', blank=True)
# upload_to에 이미지를 저장할 폴더의 경로 규칙을 지정, blog/images/연도/월/일/ 저장
# blank=True -> 해당 필드는 필수 항목은 아니라는 뜻!!!
# 즉, Post모델의 경우 관리자 페이지에서 title이나 content 필드를 비워 두고 save 버튼을 클릭하면 경고 메시지가 나오지만,
# blank=True를 설정하면 그 필드를 채우지 않더라고 경고 메시지 없이 저장됨.
created_at = models.DateTimeField(auto_now_add=True) # DateTimeField : 월, 일, 시, 분, 초까지 기록하게 해주는 필드
updated_at = models.DateTimeField(auto_now=True)
# auto_now_add=True 는 django model 이 최초 저장(insert) 시에만 현재날짜(date.today()) 를 적용
# auto_now=True 는 django model 이 save 될 때마다 현재날짜(date.today()) 로 갱신
# author: 추후 작성 예정, 외래키를 구현할 시 다룰 것.
head_image = models.ImageField(upload_to='blog/images/%Y/%m/%d/', blank=True)
:
upload_to에 이미지를 저장할 폴더의 경로 규칙을 지정. 여기서는 vlog 폴더 아래 images라는 폴더를 만들고, 연도 폴더, 월 폴더, 일 폴더까지 내려간 위치에 저장하도록 설정.
blank=True
: 해당 필드는 필수 항목은 아니라는 뜻!!!
blank=True
를 설정하면 그 필드를 채우지 않더라고 경고 메시지 없이 저장됨.당연하게도 모델을 변경했으니 마이그레이션을 해야한다. ( 익숙해지고 까먹지 말기!! )
단, ImageField를 사용하려면 Pillow 라이브러리가 필요하다.
터미널에 pip install Pillow
를 한 이후 마이그레이션을 진행하자.
이제 관리자 페이지에 들어가 포스트를 만들어 보면 파일 선택 버튼이 생성된 것을 볼 수 있다.
이미지를 올려보자. 그러면 프로젝트 폴더에 _media
폴더가 생성되고, blog/images/년/월/일 폴더 안에 업로드한 이미지 파일이 저장된 것을 볼 수 있다!!
그런데 업로드된 이미지 파일의 링크를 클릭해서 접속해보면 'Page not found' 오류가 발생한다.
그 이유는, 아직 urls.py에서 media URL에 대한 설정을 하지 않았기 때문이다.
do_it_django/urls.py 파일을 열어 다음과 같이 수정하자.
from django.contrib import admin
from django.urls import path, include
from django.conf import settings # 추가
from django.conf.urls.static import static # 추가
urlpatterns = [
path('admin/', admin.site.urls),
path('blog/', include('blog.urls')),
path('', include('single_pages.urls')),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # 추가
static()
함수를 사용하여 미디어 파일에 대한 URL 패턴을 설정한다.
이렇게 하면 Django 개발 서버에서 settings.MEDIA_URL
로 시작하는 URL로 접근할 때, settings.MEDIA_ROOT
에서 해당 미디어 파일을 찾아서 제공한다.
또한 위 코드에서 urlpatterns에 static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
를 직접 추가하는 대신 urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
를 사용하는 이유는 다음과 같다.
path()
함수를 사용하여 URL 패턴을 추가할 수 있다.static()
함수는 URL 패턴이 아니라 미디어 파일을 제공하기 위한 설정이므로, path()
함수를 통해 직접 URL 패턴을 추가하는 것보다는 +=
연산자를 사용하여 urlpatterns에 추가하는 것이 더 일관성 있고 명확하기 때문이다.이제 포스트 목록 페이지에서도 대표 이미지가 보이도록 수정해보자.
post_list.html에서 포스트 이미지에 대한 내용을 담고 있는 코드 한 줄을 다음과 같이 수정하자.
(..생략..)
{% for p in post_list %}
<!-- Blog post-->
<div class="card mb-4">
<a href="#!"><img class="card-img-top" src="{{ p.head_image.url }}" alt="..." /></a>
단, 주의할 점은 아직 템플릿 파일에서 if
문을 사용하는 방법을 배우지 않았기 때문에, 모든 포스트에 대하여 대표 이미지를 업로드 해야 에러가 발생하지 않는다.
_media 폴더는 로컬에서 테스트를 위해 만들어진 폴더이므로 버전 관리를 할 필요가 없다. 또한 불필요한 이미지 파일을 서버로 올려버릴 수도 있다. .gitignore
에 _media/
를 추가하자.
이제 포스트 상세 페이지에 이미지가 나타나도록 해보자.
<img>
태그의 이미지 경로(src)를 포스트 목록 페이지에서 했던 것처럼 {{ post.head_image.url }}
로 수정하자.
Post 모델에 이미지뿐만 아니라 다른 종류의 파일도 업로드 하고, 필요한 파일을 내려받을 수 있는 기능을 구현해보자.
장고에 있는 FileField
를 사용하면 된다.
blog/models.py에 FileField
로 file_upload 필드를 추가하자. 사용법은 ImageField
와 거의 유사하다. upload_to
에는 ImageField
에 사용한 경로에서 images 폴더를 files 폴더로만 바꿔준다.
from django.db import models
class Post(models.Model): # models 모듈의 Model 클래스를 확장하여 만든 클래스
"""
포스트의 형태를 정의하는 Post 모델
제목(title), 내용(content), 작성일(created_at), 작성자 정보(author)
"""
title = models.CharField(max_length=30) # CharField : 문자를 담는 필드
content = models.TextField() # TextField : 문자열의 길이 제한이 없는 필드
# upload_to에 이미지를 저장할 폴더의 경로 규칙을 지정, blog/images/연도/월/일/ 저장
# blank=True -> 해당 필드는 필수 항목은 아니라는 뜻!!!
# 즉, Post모델의 경우 관리자 페이지에서 title이나 content 필드를 비워 두고 save 버튼을 클릭하면 경고 메시지가 나오지만,
# blank=True를 설정하면 그 필드를 채우지 않더라고 경고 메시지 없이 저장됨.
head_image = models.ImageField(upload_to='blog/images/%Y/%m/%d/', blank=True)
# 추가!!!!
file_upload = models.FileField(upload_to='blog/files/%Y/%m/%d/', blank=True)
created_at = models.DateTimeField(auto_now_add=True) # DateTimeField : 월, 일, 시, 분, 초까지 기록하게 해주는 필드
updated_at = models.DateTimeField(auto_now=True)
# auto_now_add=True 는 django model 이 최초 저장(insert) 시에만 현재날짜(date.today()) 를 적용
# auto_now=True 는 django model 이 save 될 때마다 현재날짜(date.today()) 로 갱신
# author: 추후 작성 예정, 외래키를 구현할 시 다룰 것.
(..생략..)
까먹지 말고!!! 새로운 필드를 만들었으니 마이그레이션을 하자!!
이제 관리자 페이지에서 포스트를 열어보면 file_upload
필드가 반영된 File upload 입력란이 추가되어있는 것을 확인할 수 있다.