본문 바로가기
Personal Project

나의 다이어리 서비스 구현

by applepick 2021. 1. 14.
반응형

개발 기술 스택은 

프레임워크 -> Django 

사용언어 -> Python, HTML, Javascript, Css(bootstarp)

을 사용해서 제작했습니다.


배워 본 것들을 활용해보기위해 혼자 직접 다이어리 서비스를 구현해보고 싶었습니다. 기본적인 틀은

├── README.md
├── myvenv
│   ├── bin
│   ├── include
│   ├── lib
│   └── pyvenv.cfg
└── project
    ├── accounts
    ├── db.sqlite3
    ├── manage.py
    ├── mydiary
    ├── mymap
    ├── myproject
    └── static

 

회원가입, 로그인, 로그아웃, 소셜로그인 등... 로그인과 관련된 부분은 acoounts쪽에서 작업하고, mydiary는 일기쓰기, 삭제, 수정 등... 일기에 관련된 것들을 작업하고 mymap은 지도api를 써보고싶어 지도를 볼수있는 기능을 추가하고싶어 만들었습니다.


완성된 페이지는

 

로그인 된 메인 화면이다. 그냥 깔끔하게 켈린더를 메인에 나오고 상단바에 기능을 넣어놓았습니다.

로그인이 안된 페이지는

이런식으로 깔끔하게 제작했습니다. 시간과 움직이는 이미지 하나로 디자인했습니다. 


각 파일의 디렉토리를 살펴보겠습니다. 일단 accounts부분의 모델링을 보자면 따로 DB는 사용하지않고 django에서제공되는 내부 DB를 사용했습니다. 회원가입 폼을 보자면

이렇게 아이디와 비밀번호, 비밀번호 확인 정도만 넣어놓았습니다. 필요하면 이메일을 받아서 인증까지 하려고 시도했으나 조금 가져오는 것이 복잡해 구현하지못했습니다. 추후 다시 추가해보겠습니다. 회원가입의 로직을 한 번 보겠습니다. 

#project/accounts/views.py

def signup(request):
    if request.method  == 'POST':
        if User.objects.filter(username=request.POST['username']).exists(): #아이디 중복 체크 
            return render(request, 'signup_error.html')
        if request.POST['password1'] == request.POST['password2']:
            user = User.objects.create_user(
                request.POST['username'], password= request.POST['password1'])
            user.backend = 'Django.contrib.auth.backends.ModelBackend' ##백엔드에게 정보를 넘겨줌 인증받아서 넘겨준다.
            auth.login(request, user)
        return redirect('home')
    return render(request, 'signup.html')

    return render(request, 'signup.html')

회원가입의 로직을 간단하게 설명하자면 일단 폼에서 POST로 값을 받아온다면 내부 유저모델을에서 중복된 아이디가있는지 체크합니다. 만약 있다면 가입오류 창을 띄워줍니다.(뭔가 이부분이 비효율적이라 고쳐야할것같습니다. JS를 통해서 페이지를 넘기지않고 창을 띄울수도있을것같았습니다.) 그리고 페스워드를 두번입력한 값이 정말 맞는지 확인합ㄴ디ㅏ. 만약같다면 django의 내부 유저모델인 auth를 사용해서 DB에 저장합니다, 그리고 메인페이지로 연결해줍니다.


두 번째 기능인 로그인 기능을 설명해보겠습니다

로그인 페이지입니다. 회원가입한 회원이라면 누구나 사용할 수 있도록 하였습니다. 카카오api를 사용해서 카카오톡로그인으로 서비스를 사용할 수 있도록 만들었습니다. 로직을 한 번 보겠습니다.

#/project/accounts/views.py

def login(request):
    if request.method  == 'POST':
        username = request.POST['username']
        password = request.POST['password']
        user = auth.authenticate(request, username=username, password= password)
        if user is not None:
            auth.login(request, user)
            return redirect('home')
        else:
            return render(request, 'login.html', {'error': '아이디 또는 비밀번호를 확인해주세요'})
    else:
        return render(request, 'login.html')
        
 def kakao_login(request):
    app_rest_api_key = os.environ.get("KAKAO_REST_API_KEY")
    redirect_uri = main_domain + "users/login/kakao/callback"
    return redirect(
        f"https://kauth.kakao.com/oauth/authorize?client_id={'비밀~'}&redirect_uri={'home'}&response_type=code"
    )

사실 로그인은 간단합니다. 폼에 값이 POST형식이면 값을 변수에 저장해 내부 DB모델을 가져와서 확인합니다. 만약 유저가 있다면 로그인을 시켜주고 비밀번호나 아이디가 틀렸다면 에러값을 페이지에 넘겨줍니다. 다시 로그인할 수 있도록 리다이렉트를 걸어줍니다. 카카오톡 로그인은 따로 구현했습니다.  api값을  잘이용해서 사용할수있도록 했습니다. 이부분은 저의 블로그에 잘 정리해놓았습니다.

applepick.tistory.com/27

 

django에서 kakao 로그인 api 사용하기 +(allauth 사용)

일단 어느 정도 구현했으니 카카오 로그인 기능도 추가하고 싶었습니다. 완성된 페이지를 보여드리겠습니다. 이런 식으로 구현했습니다. pip install django-allauth  일단 allauth를 깔아줍시다. INSTALLED

applepick.tistory.com


이제 비밀번호를 변경하는 로직을 확인해보겠습니다. 사실 이 부분이 제일 애먹었던 것 같습니다.  DB에서 어떻게 유저값을 가져오는 방식에따라 구현하는 방법이 다를 것 같았습니다.

로그인 되어있는 상태로 상단에 계정관리라는 버튼을누르면 비밀번호 수정이 있습니다. 기존 비밀번호를 입력하고 변경할 비밀번호, 변경할 비밀번호를 다시 입력해달라는 폼을 구현했습니다. 코드부분을 보겠습니다.

#/project/accounts/views.py

@login_required
def change_pw(request):
    context= {}
    if request.method == "POST":
        current_password = request.POST.get("origin_password")
        user = request.user
        if check_password(current_password,user.password):
            new_password = request.POST.get("password1")
            password_confirm = request.POST.get("password2")
            if new_password == password_confirm:
                user.backend = 'Django.contrib.auth.backends.ModelBackend' ##백엔드에게 정보를 넘겨줌 인증받아서 넘겨준다.
                user.set_password(new_password)
                user.save()
                auth.login(request,user)
                return redirect("home")
            else:
                context.update({'error':"새로운 비밀번호를 다시 확인해주세요."})
    else:
        context.update({'error':"현재 비밀번호가 일치하지 않습니다."})

    return render(request, "change_pw.html",context)

 로그인세션이 유지되어있는지 확인해주는것이 바로 @login_required입니다. Django에서는 이런 간편한 기능들을 제공해줍니다. 대신 제약이있다. 클래스형 뷰로 구현한다면 사용할 수 없습니다. 폼의 값이 POST로 들어온다면 변수에 기존 비밀번호를 저장해놓습니다. 유저 모델을 다 가져옵니다.(이부분에서 만약 회원수가 많아진다면 다른방법으로 구현해야할 것 같습니다. 시간이 오래걸릴 것 같기 때문에...) 새로운 비밀번호와 다시한번 비밀번호를 확인한 값을 가져와 비교해서 아니면 다시 확인해달라는 메시지를 프론트에 넘깁니다. 만약 맞다면 유저모델에 수정된 값을 요청해 바꿔 유저정보를 저장해줍니다. 


여기까지 accounts부분을 정리해보았습니다. 다음으로는 mydiary부분을 보겠습니다. 
첫 번째로 글의 모델을 살펴보겠습니다.

#/project/mydiary/models.py

from django.db import models
from django.contrib.auth.models import User
from datetime import datetime
from django.utils import timezone

class Post(models.Model):
    username = models.ForeignKey(User, on_delete=models.CASCADE)
    title = models.CharField(max_length=100)
    photo = models.ImageField(null=True,blank=True)
    content = models.CharField(max_length=4000)
    pub_date = models.DateTimeField(auto_now=True)
    weather = models.CharField(max_length=15)
    emotion = models.CharField(max_length=15)

    def publish(self):
        self.pub_date = timezone.now()
        self.save()
        
    def __str__(self):
        return "%s - %s" % (self.username, self.title)

제가 다이어리를 쓸 때 어떤 값들이 필요한지 구성해보았습니다. 사용자이름, 일기 제목, 사진, 글, 날짜, 날씨, 기분 정도로 구성해서 모델을 제작했습니다. 일기 쓰는 페이지를 보겠습니다.

이런식으로 깔끔하게 제작했습니다. 감정과 날씨는 이모티콘을 사용해서 제작했습니다. 페이지의 프론트쪽으로 설명드리겠습니다.

#/project/mydiary/templates/write_diary.html

{% extends 'menu_bar.html' %}<!--위에 상단바-->
{% block contents %}
{% load static %}
<link rel="stylesheet" href="{% static 'css/home.css'%}">
<script type="text/javascript">
	function autoTextarea(obj,limit) {
		obj.style.height = "100px";
		obj.style.height = (70+obj.scrollHeight)+"px";
		console.log(obj.value.length);
	    if(obj.value.length > limit) {
	    	alert("일기는 최대 2000자 까지만 작성가능해요 :'(");
	    	obj.value = obj.value.substring(0,limit);  
	    	obj.focus();
	    }
	}
	</script>
	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<body>
{% if user.is_active %}
	<main>
		<div class="main_wrap">
			<form action="{% url 'create' %}" method="POST">{% csrf_token %}
			<div class="diary_wrap">
			<h6 align ="right">글쓴이 : {{ user.username}}</h6>
				<div class="title_input clearfix">
						<label for="inp" class="inp">
							<input type="title" name="title" id="inp" placeholder="">
							<span class="label">일기 제목</span>
							<span class="border"></span>
						</label>
					
				</div>
				
				<div class="date_input">
					<input type="date" value="2020-07-29" name="published">
				</div>
				
					<div class="emotion_input">
						감정 : 
						<span class="emotion_happy">
							<input type="radio" id="happy" name="radio_emotion" value="happy" checked="checked"><label for="happy">&nbsp;</label>
						</span>
						<span class="emotion_love">
							<input type="radio" id="love" name="radio_emotion" value="love"><label for="love">&nbsp;</label>
						</span>
						<span class="emotion_angry">
							<input type="radio" id="angry" name="radio_emotion" value="angry"><label for="angry">&nbsp;</label>
						</span>
						<span class="emotion_crying">
							<input type="radio" id="crying" name="radio_emotion" value="crying"><label for="crying">&nbsp;</label>
						</span>
						<span class="emotion_serious">
							<input type="radio" id="serious" name="radio_emotion" value="serious"><label for="serious">&nbsp;</label>
						</span>
						<span class="emotion_sleepy">
							<input type="radio" id="sleepy" name="radio_emotion" value="sleepy"><label for="sleepy">&nbsp;</label>
						</span>
					</div>
				
				
					<div class="weather_input">
						날씨 : 
						<span class="weather_sunny">
							<input type="radio" id="sunny" name="radio_weather" value="sunny" checked="checked"><label for="sunny">&nbsp;</label>
						</span>
						<span class="weather_umbrella">
							<input type="radio" id="umbrella" name="radio_weather" value="umbrella"><label for="umbrella">&nbsp;</label>
						</span>
						<span class="weather_cloud">
							<input type="radio" id="cloud" name="radio_weather" value="cloud"><label for="cloud">&nbsp;</label>
						</span>
						<span class="weather_snow">
							<input type="radio" id="snow" name="radio_weather" value="snow"><label for="snow">&nbsp;</label>
						</span>
					</div>
				
				<div class="write_diary_image_input">
					<!-- <input type="submit" value="등록" class="upload"> -->
					<input type="file" name="image" value="파일 업로드" class="upload">
					
				</div>
				<div class="content_input">
						<textarea placeholder="일기 내용" onkeyup="autoTextarea(this,2000)" id="text" name="text" rows="4" style="word-wrap: break-word; resize: none; height: 100px; "></textarea>  
						<br>
				</div>
				<div class="diary_submit_button_wrap">
					<input type="submit" class="diary_submit_button" value="일기 저장">
				</div>
			</div>
			</form>
		</div>
	</main>
<!--로그인 안됬을떄 접근처리-->
{% else %}<br>
<div class="bgwrite" style="max-width: 30rem; float: none; margin: 0 auto;">
    <br><br><br><br><br><br><br><br><br><br>
    </div>
	<div class="card text-white bg-dark mb-3" style="max-width: 30rem; float: none; margin: 0 auto;">
    <div class="form-group" align="center">
    <br><h3 align="center">잘못된 접근입니다.<br> 로그인해주세요</h3>
    <br>
    <a href="{% url 'signup' %}"><input class="btn btn-dark" type="submit" value="회원가입"></a>
    <a href="{% url 'login' %}"><input class="btn btn-dark" type="submit" value="로그인"></a>
    <a href="{% url 'home' %}"><input class="btn btn-dark" type="submit" value="홈으로 돌아가기"></a>
	</div>
    </div>
{% endif %}
{% endblock %}
</body>

로그인이 안되어있다면, url로 치고들어와 사용할수없도록 만들었습니다. 물론 로그인 세션이 있어야 사용할수있도록 구현도 해놓았습니다.

다이어리인 만큼 다른사람들이 글을 볼 수 없도록 만들었습니다. 자신의 일기는 자신만 볼 수 있도록 하여 제가 방금 글하나를 작성했습니다.

일기보기를 누르면 이런식으로 자신의 일기를 볼 수 있습니다.  이부분은 어떻게 구현했냐면

@csrf_exempt
def view_diary(request):
    posts = Post.objects.all().order_by('-id')
    return render(request, 'view_diary.html',{'posts':posts})

일단 포스트를 다가져옵니다. 그뒤에

프론트에서 작업을 했습니다.

{% if user.is_active %}
{% for post in posts.all %}
{% if user == post.username %}

를 사용해서 로그인 세션이 유지된 상태에서  로그인된 유저와 값을 비교해 쓴글 만 추출해서 보이도록 했습니다.(지금 방법은 비효율적인것같습니다. 다시한번 방법을 찾아 효율적으로 활용해보겠습니다.) 

일기를 가져와 수정하는것과 삭제하는 방법을 쉬우니 생략하겠습니다.


다음으로 카카오맵 api를 사용해서 지도를 활용하는 방법을 보겠습니다. 코드를 카카오에서 제공하여 조금 다듬어서 사용해보았습니다.

<script type="text/javascript" src="//dapi.kakao.com/값"></script>
<script>
// 마커를 담을 배열입니다
var markers = [];

var mapContainer = document.getElementById('map'), // 지도를 표시할 div 
    mapOption = {
        center: new kakao.maps.LatLng(37.566826, 126.9786567), // 지도의 중심좌표
        level: 3 // 지도의 확대 레벨
    };  

// 지도를 생성합니다    
var map = new kakao.maps.Map(mapContainer, mapOption); 

// 장소 검색 객체를 생성합니다
var ps = new kakao.maps.services.Places();  

// 검색 결과 목록이나 마커를 클릭했을 때 장소명을 표출할 인포윈도우를 생성합니다
var infowindow = new kakao.maps.InfoWindow({zIndex:1});

// 키워드로 장소를 검색합니다
searchPlaces();

// 키워드 검색을 요청하는 함수입니다
function searchPlaces() {

    var keyword = document.getElementById('keyword').value;

    if (!keyword.replace(/^\s+|\s+$/g, '')) {
        alert('키워드를 입력해주세요!');
        return false;
    }

    // 장소검색 객체를 통해 키워드로 장소검색을 요청합니다
    ps.keywordSearch( keyword, placesSearchCB); 
}

// 장소검색이 완료됐을 때 호출되는 콜백함수 입니다
function placesSearchCB(data, status, pagination) {
    if (status === kakao.maps.services.Status.OK) {

        // 정상적으로 검색이 완료됐으면
        // 검색 목록과 마커를 표출합니다
        displayPlaces(data);

        // 페이지 번호를 표출합니다
        displayPagination(pagination);

    } else if (status === kakao.maps.services.Status.ZERO_RESULT) {

        alert('검색 결과가 존재하지 않습니다.');
        return;

    } else if (status === kakao.maps.services.Status.ERROR) {

        alert('검색 결과 중 오류가 발생했습니다.');
        return;

    }
}

// 검색 결과 목록과 마커를 표출하는 함수입니다
function displayPlaces(places) {

    var listEl = document.getElementById('placesList'), 
    menuEl = document.getElementById('menu_wrap'),
    fragment = document.createDocumentFragment(), 
    bounds = new kakao.maps.LatLngBounds(), 
    listStr = '';
    
    // 검색 결과 목록에 추가된 항목들을 제거합니다
    removeAllChildNods(listEl);

    // 지도에 표시되고 있는 마커를 제거합니다
    removeMarker();
    
    for ( var i=0; i<places.length; i++ ) {

        // 마커를 생성하고 지도에 표시합니다
        var placePosition = new kakao.maps.LatLng(places[i].y, places[i].x),
            marker = addMarker(placePosition, i), 
            itemEl = getListItem(i, places[i]); // 검색 결과 항목 Element를 생성합니다

        // 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해
        // LatLngBounds 객체에 좌표를 추가합니다
        bounds.extend(placePosition);

        // 마커와 검색결과 항목에 mouseover 했을때
        // 해당 장소에 인포윈도우에 장소명을 표시합니다
        // mouseout 했을 때는 인포윈도우를 닫습니다
        (function(marker, title) {
            kakao.maps.event.addListener(marker, 'mouseover', function() {
                displayInfowindow(marker, title);
            });

            kakao.maps.event.addListener(marker, 'mouseout', function() {
                infowindow.close();
            });

            itemEl.onmouseover =  function () {
                displayInfowindow(marker, title);
            };

            itemEl.onmouseout =  function () {
                infowindow.close();
            };
        })(marker, places[i].place_name);

        fragment.appendChild(itemEl);
    }

    // 검색결과 항목들을 검색결과 목록 Elemnet에 추가합니다
    listEl.appendChild(fragment);
    menuEl.scrollTop = 0;

    // 검색된 장소 위치를 기준으로 지도 범위를 재설정합니다
    map.setBounds(bounds);
}

// 검색결과 항목을 Element로 반환하는 함수입니다
function getListItem(index, places) {

    var el = document.createElement('li'),
    itemStr = '<span class="markerbg marker_' + (index+1) + '"></span>' +
                '<div class="info">' +
                '   <h5>' + places.place_name + '</h5>';

    if (places.road_address_name) {
        itemStr += '    <span>' + places.road_address_name + '</span>' +
                    '   <span class="jibun gray">' +  places.address_name  + '</span>';
    } else {
        itemStr += '    <span>' +  places.address_name  + '</span>'; 
    }
                 
      itemStr += '  <span class="tel">' + places.phone  + '</span>' +
                '</div>';           

    el.innerHTML = itemStr;
    el.className = 'item';

    return el;
}

// 마커를 생성하고 지도 위에 마커를 표시하는 함수입니다
function addMarker(position, idx, title) {
    var imageSrc = 'https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png', // 마커 이미지 url, 스프라이트 이미지를 씁니다
        imageSize = new kakao.maps.Size(36, 37),  // 마커 이미지의 크기
        imgOptions =  {
            spriteSize : new kakao.maps.Size(36, 691), // 스프라이트 이미지의 크기
            spriteOrigin : new kakao.maps.Point(0, (idx*46)+10), // 스프라이트 이미지 중 사용할 영역의 좌상단 좌표
            offset: new kakao.maps.Point(13, 37) // 마커 좌표에 일치시킬 이미지 내에서의 좌표
        },
        markerImage = new kakao.maps.MarkerImage(imageSrc, imageSize, imgOptions),
            marker = new kakao.maps.Marker({
            position: position, // 마커의 위치
            image: markerImage 
        });

    marker.setMap(map); // 지도 위에 마커를 표출합니다
    markers.push(marker);  // 배열에 생성된 마커를 추가합니다

    return marker;
}

// 지도 위에 표시되고 있는 마커를 모두 제거합니다
function removeMarker() {
    for ( var i = 0; i < markers.length; i++ ) {
        markers[i].setMap(null);
    }   
    markers = [];
}

// 검색결과 목록 하단에 페이지번호를 표시는 함수입니다
function displayPagination(pagination) {
    var paginationEl = document.getElementById('pagination'),
        fragment = document.createDocumentFragment(),
        i; 

    // 기존에 추가된 페이지번호를 삭제합니다
    while (paginationEl.hasChildNodes()) {
        paginationEl.removeChild (paginationEl.lastChild);
    }

    for (i=1; i<=pagination.last; i++) {
        var el = document.createElement('a');
        el.href = "#";
        el.innerHTML = i;

        if (i===pagination.current) {
            el.className = 'on';
        } else {
            el.onclick = (function(i) {
                return function() {
                    pagination.gotoPage(i);
                }
            })(i);
        }

        fragment.appendChild(el);
    }
    paginationEl.appendChild(fragment);
}

// 검색결과 목록 또는 마커를 클릭했을 때 호출되는 함수입니다
// 인포윈도우에 장소명을 표시합니다
function displayInfowindow(marker, title) {
    var content = '<div style="padding:5px;z-index:1;">' + title + '</div>';

    infowindow.setContent(content);
    infowindow.open(map, marker);
}

 // 검색결과 목록의 자식 Element를 제거하는 함수입니다
function removeAllChildNods(el) {   
    while (el.hasChildNodes()) {
        el.removeChild (el.lastChild);
    }
}
</script>
</html>

지도를 띄우고 검색창을 사용하여 위치를 찾을수 있도록했습니다.

키워드를 입력하면 그위치를 찾아주는 것을 사용해보았습니다.


여기까지 혼자 설계부터 디자인까지 하려다보니 부족한 부분도 있고 깊게 생각하지 못한 부분이 있었습니다. 그런 부분들을 정리하면서 이거는 이렇게 구현했으면 효율적이 였을텐데가 머리에 스쳤습니다. 처음으로 혼자 개발기간을 잡고 만들어본 것이기때문에 뿌듯하였습니다. 처음에는 여자친구와 같이 일기를 써보려고 공유일기? 같은걸 만들어보고싶어 시작한것인데 하나하나 잡고 제작해보니 얼마나 힘든것인지 알수있었습니다. 못할 줄알았는데.... 어떻게든 만들어내긴하네요! 다음에는 더 좋은 퀄리티의 서비스를 제작해보고싶습니다. 긴 글을 읽어주시느라 수고하셨습니다. 감사합니다.!

반응형

댓글