안녕하세요! 개발자 최영철입니다.
이번 포스트에서는 React의 Hook에 대해 학습한 내용을 설명하고 Custom Hook과 Django에서 방명록과 관련된 모든 기능을 구현하는 이야기를 하려합니다.
목차
1. React Hook & useState
저번 포스트에서 useState를 통해 모달창을 구현할 수 있다는 사실을 알았습니다. 그렇다면 useState는 무엇일까요?useState는 상태 유지 값과 그 값을 갱신하는 함수를 반환합니다. 예를 들어 아래의 코드와 같이 value로 상태가 유지된 값을 참조 가능하고 setValue 함수로 이 값을 바꿀 수 있습니다. 그런데 이것이 왜 필요할까요? Foo에 props를 받고 props.value를 const value로 사용하면 되지 않을까요? 이러한 의문은 React의 Hook가 무엇인지 알면 해결할 수 있습니다. React의 Hook은 상태가 변화하는 어떤 값이 있고 이것이 화면에 렌더링 된 어느 요소에 영향을 준다면 값을 변화시킨 후 컴포넌트를 리렌더링 시켜서 DOM에 변경 사항을 적용시키는데 사용합니다.
function Foo() {
const [value, setValue] = useState(1)
return (
<div>
{value}
</div>
)
}
위의 예제처럼 화면에는 value의 값인 1이 렌더링 되는데 만약 setValue(value + 1)로 값을 바꾼다면, 화면에 표시되는 값을 2로 바꾸기 위해 함수를 다시 호출 시켜 value를 2로 바꾸고 컴포넌트를 다시 렌더링을 시켜 화면에 2를 표시합니다. React가 제공하는 이러한 강력한 이점때문에 useState를 사용하는 것입니다.
재밌는 점은 선언을 const로 했는데 setModal을 통해 원하는 값을 설정하면 해당 값으로 변한다는 점입니다. 그 이유는 위에서 설명한 것처럼 Hook은 값이 바뀌면 "함수를 다시 호출"시킵니다. 함수를 새로 호출시키므로 새로운 변수 스코프로 바뀌면서 새로운 isModal이 생성되는 원리입니다. 그렇게 생긴 isModal은 기존의 isModal과 관련이 없는 새로운 존재인 것입니다. 값이 바뀔 때마다 컴포넌트가 리렌더링되면 불필요한 렌더링이 발생해 성능저하가 일어날 수 있을 것 같지만 VirtualDOM에 미리 적용시켜봐서 React DOM이 Virtual DOM의 내용과 실제 DOM의 내용을 비교해서 다른 부분을 실제 DOM에 적용시키기 때문에 성능하락은 없다고 합니다.
React에는 다양한 Hook이 존재하고 직접 Custom Hook을 만들 수 있습니다. 방명록 기능 구현에는 렌더링 후 원하는 함수를 실행할 수 있는 useEffect와 API에서 받은 데이터를 fetch하는 Custom Hook을 사용합니다.
2. useEffect & Custom Hook
구현하기 전에 useEffect가 무엇인지 알아봅시다. useEffect는 컴포넌트 렌더링이 끝난 후 호출되는 Hook입니다. 주로 이벤트 리스너를 추가하거나, 특정 값이 변함에 따라 특정 함수를 실행시켜야할 때 사용합니다.
function Foo() {
const [value, setValue] = useState(1)
useEffect(() => {
console.log(value)
}, [])
return (
<div>
{value}
</div>
)
}
{/*
초기 상태
value: 1
console: 1
*/}
{/*
value 변화: 1-> 2
console: 아무것도 나타내지 않음
*/}
function Bar() {
const [value, setValue] = useState(1)
useEffect(() => {
console.log(value)
return (() => {
console.log("clean up")
})
}, [value])
return (
<div>
{value}
</div>
)
}
{/*
초기 상태
value: 1
console: 1
*/}
{/*
value 변화: 1-> 2
console: clean up
console: 2
*/}
Foo와 Bar함수를 비교하면 배열에 value라는 값이 들어있는 것과 return이 있다는 것이 다릅니다. useEffect에서 사용하는 뒤에 있는 배열을 deps (의존성, dependencies)이라고 하는데 이 deps 배열에 있는 값이 하나라도 변하면 리렌더링이 끝난후 useEffect가 작동하는 방식입니다. 만약 이 배열을 Foo처럼 빈 칸으로 놓고 사용한다면 해당 컴포넌트가 로드 될 때 단 한번만 useEffect에 등록한 함수를 실행합니다. 위의 예제 코드에서 적은 주석처럼 Foo의 경우 처음 컴포넌트가 호출 될 때만 useEffect가 실행되고 이후에는 값이 바뀌더라도 실행되지 않습니다. 이에 반해 Bar는 value가 바뀔 때마다 useEffect가 실행되므로 console에 2가 제대로 나타나는 걸 볼 수 있습니다. 그런데 Bar의 경우 value가 변화하면서 콘솔에 clean up이라는 메시지가 나타났습니다. 그 이유는 useEffect에서 return 값으로 함수를 지정하면 clean up 함수가 되는데 이 clean up 함수는 라이프사이클에서 componentWillUnmount의 역할과 비슷한 역할로 작동합니다.
이렇게 useEffect의 기본 작동 방식을 알 수 있었습니다. 다음으로 Custom Hook을 구현해보겠습니다.
3. useFetch (custom hook)
useState와 useEffect를 같이 사용하면 API를 호출시킨 후 데이터를 fetch하는 커스텀 훅을 구현할 수 있습니다.
먼저 호출할 API의 Path를 받을 state와 fetch한 데이터를 저장할 state를 다음과 같이 정의해줍니다.
const useFetch = (initValue, initPath) {
const [path, setPath] = useState(initPath)
const [value, setValue] = useState(initValue)
}
다음으로 fetch한 데이터를 저장할 state와 API를 호출해서 데이터를 받을 함수를 axios로 구현합니다. fetch에 실패했을 때 반환할 값이 필요하므로 failValue도 추가해줍니다.
const useFetch = (initValue, failValue, initPath,) => {
const [path, setPath] = useState(initPath)
const [value, setValue] = useState(initValue)
const fetchData = async (headers) => {
axios.get(path,
{
baseURL: config.BASE_BACKEND_URI,
headers: headers,
}
).then(
(resp) => setValue(resp.data)
).catch(
() => setValue(failValue)
)
}
}
useFetch를 처음 호출했을 때와 API의 path가 바뀔 때마다 새로운 데이터를 fetch 해야하므로 useEffect 안에 fetchData를 사용합니다. 그리고 결과값으로 value, fetchData 함수, setPath 함수를 반환합니다.
const useFetch = (initValue, failValue, initPath) => {
const [path, setPath] = useState(initPath)
const [value, setValue] = useState(initValue)
useEffect(() => fetchData(), [path])
const fetchData = async (headers) => {
axios.get(path,
{
baseURL: config.BASE_BACKEND_URI,
headers: headers,
}
).then(
(resp) => setValue(resp.data)
).catch(
() => setValue(failValue)
)
}
return [value, fetchData, setPath]
}
이렇게 완성된 useFetch는 다음과 같이 사용할 수 있습니다.
function Foo() {
const [value, fetchData, setPath] = useFetch([], [], "path/to/api")
...
fetchData({Authorization: `Bearer ${token}`})
...
setPath("path/to/api2")
...
console.log(value)
}
4. Django GuestBook 모델 & API 구현
먼저 GuestBook 모델을 구현하기 전에 필요한 필드가 무엇인지 생각했습니다. 처음에는 방명록의 pk인 gid, 방명록 제목에 들어갈 title 필드, 방명록의 내용을 담당할 content, 해당 방명록이 언제 작성했는지 알려주는 created_at 필드를 생각했습니다. 그렇게 다음과 같이 모델을 구현했습니다.
class GuestBook(models.Model):
gid = models.BigAutoField(primary_key=True)
title = models.CharField(max_length=50)
content = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
해당 모델을 migrate로 적용한 후 다시 생각해보니 방명록을 삭제할 때 해당 방명록이 요청한 유저의 것인지 verify를 할 수 없었습니다. 그래서 author 필드를 만들어 user.id를 Foreign Key로 사용했습니다. 추가적으로 익명으로도 방명록을 남기고 삭제할 수 있는 기능을 만들고 싶었습니다. 익명이면 author 필드에서 참조할 user가 없으므로 author 필드가 null 값을 가질 수 있도록 변경했습니다. 익명이라도 방명록을 삭제할 수 있으려면 비밀번호를 등록하고 이를 통해 verify 하면 되므로 password 필드를 추가했습니다.
class GuestBook(models.Model):
gid = models.BigAutoField(primary_key=True)
author = models.ForeignKey('users.User', on_delete=models.CASCADE, null=True)
content = models.CharField(max_length=200)
password = models.TextField(null=True, max_length=100)
created_at = models.DateTimeField(auto_now_add=True)
이렇게 모델을 구현한 후 유저가 admin인지 검사하기 위해 Django에서 기본적으로 User에게 제공하는 Groups를 사용하면 다음과 같은 최종적인 ERD가 그려지게 됩니다.
DB 구성이 끝났으므로 API 시나리오를 구상했습니다.
- GET /guestbook
모든 방명록의 gid, nickname, content, created_at 데이터를 리스트로 response 한다.
- POST /guestbook
클라이언트에서 받은 데이터를 받아 익명인지 유저인지 구별해서 익명이면 추가적으로 password를 암호화해서 DB에 저장하고 성공한다면 status=200을 반환한다.
- DELETE /guestbook
클라이언트에서 받은 gid를 활용해 해당 방명록이 익명으로 작성된 것인지 로그인해서 작성한 것인지 확인하고 익명이면 password를 verify한다. verification이 성공적이면 해당 방명록을 삭제하고 status=200을 반환한다.
먼저 구현하기 쉬운 GET 메소드부터 구현에 시작했습니다.
5. GET Method
Django는 Django Rest Framework의 serializer를 이용한다면 해당 모델의 데이터를 쉽게 다른 형식으로 표현할 수 있습니다. 추가적으로 many=True 옵션을 키면 Query Set에 존재하는 모든 쿼리를 쉽게 원하는 데이터로 이루어진 리스트로 변환할 수 있습니다. 이러한 사실을 이용해 다음과 같이 serializer를 구현했습니다.
class GuestBookInfoSerializer(serializers.ModelSerializer):
type = serializers.SerializerMethodField('get_type')
nickname = serializers.ReadOnlyField(source='author.nickname')
class Meta:
model = GuestBook
fields = ('nickname', 'type', 'gid', 'content', 'created_at')
def get_type(self, obj):
author = obj.author
return 1 if author else 0
type은 serializer에서 get_type 함수에 의해 생성되는 필드로 해당 방명록이 익명인지 아닌지 프론트엔드에서 구별할 수 있도록 추가했습니다. author 필드는 User를 foreign key로 사용하는 필드이므로 이 필드를 참조해 User의 데이터를 가져올 수 있습니다. 이것을 활용해 nickname 필드를 만들고 User의 닉네임을 가지고 오도록 했습니다. 이렇게 만든 serializer를 이용해 다음과 같이 GET Method를 쉽게 구현할 수 있습니다.
class GuestBookView(APIView):
def get(self, request):
qs = GuestBook.objects.all().order_by('-gid')
serializer = GuestBookInfoSerializer(qs, many=True)
return Response(serializer.data)
all() 뒤에 붙은 .order_by() 함수는 개발자가 설정한 기준으로 qs을 오름차순, 내림차순으로 정렬해주는 함수입니다.
6. POST & DELETE Method
Serializer의 경우 자체적으로 저장할 수 있는 함수인 save()를 지원합니다. 이를 이용해 GuestBook 테이블에 데이터를 저장하려고 합니다. GuestBookInfoSerializer에서 save를 할 경우 fields 목록에 따라 validate를 진행하는데 ReadOnlyField인 type과 nickname, 그리고 password 필드를 validate 할 수 없습니다. 그래서 DB에 저장하기 위해 필요한 모든 필드를 포함하고 type이 writable field인 새로운 serializer가 필요합니다. 이를 위해 GuestBookManageSerializer를 새롭게 구현했습니다.
import bcrypt
class GuestBookManageSerializer(serializers.ModelSerializer):
type = serializers.IntegerField()
class Meta:
model = GuestBook
fields = '__all__'
def validate(self, attrs):
validated_data = super().validate(attrs)
if validated_data['type'] == 0:
pw = validated_data['password'].encode('utf-8')
if len(pw) < 8:
raise
pw_crypt = bcrypt.hashpw(pw, bcrypt.gensalt())
pw_crypt = pw_crypt.decode('utf-8')
validated_data['password'] = pw_crypt
if type == 1 and not validated_data['author']:
raise
elif type == 0 and validated_data['author']:
raise
validated_data.pop('type')
return validated_data
type 필드를 writable field인 IntegerField로 설정하고 validate를 진행하고 마지막에 type을 validated_data에서 지워줍니다. 이러한 이유는 serializer가 validated_data를 가지고 create를 진행할 때 DB에 포함되지 않은 type 필드때문에 예외가 발생하기 때문입니다. 익명으로 올릴 때 사용하는 비밀번호의 암호화는 bcrypt를 이용했습니다. 비밀번호를 암호화 할 때 encode와 decode를 해주는 이유는 문자를 byte로 바꾼 후 암호화를 진행한 후 다시 byte를 문자로 바꾸기 때문입니다. 이렇게 validation이 가능한 Serializer를 만들었으니 POST Method API를 구현해봅시다.
#tokens.py
class TokenManager:
@classmethod
def decode(cls, token=None, header=None):
try:
if not token and header:
token = header['Authorization'].split(' ')[1]
data = jwt.decode(token, SECRET_KEY, 'HS256')
return data['user_id']
except:
return None
#guestbook/views.py
def post(self, request):
try:
data = request.data
if data['type'] == 0:
data.update({
'author': None,
})
else:
author = TokenManager.decode(header = request.headers)
if author:
data.update({
'author': author,
'password': None,
})
else:
raise
serializer = GuestBookManageSerializer(data=data)
if serializer.is_valid(raise_exception=True):
serializer.save()
return Response('')
except Exception as e:
return Response(f'failed {e}', status=400)
해당 API의 작동 원리는 Serializer가 validate를 진행할 때 password와 author 데이터가 모두 필요한데 클라이언트에서 받은 type에 따라 필요한 데이터를 수정해서 원하는 데이터를 저장할 수 있도록 만듭니다. type이 1인 경우에는 헤더에 포함된 토큰을 decode해 id를 얻고 이를 author 필드에 삽입해서 요청한 유저가 해당 방명록의 author가 될 수 있도록 합니다.
DELETE의 경우에는 gid를 통해 해당 방명록을 가져와 author가 null인지 확인하고 값이 존재한다면 request 헤더와 author id를 비교해서 해당 방명록을 삭제합니다. 익명의 경우에는 password 비교를 통해 삭제를 진행합니다.
#tokens.py
class TokenManager:
@classmethod
def decode(cls, token=None, header=None):
try:
if not token and header:
token = header['Authorization'].split(' ')[1]
data = jwt.decode(token, SECRET_KEY, 'HS256')
return data['user_id']
except:
return None
@classmethod
def authenticate(cls, id, token=None, header=None):
try:
user_id = cls.decode(token, header)
return id == user_id
except:
return False
#guestbook/views.py
def delete(self, request):
try:
data = request.data
obj = GuestBook.objects.get(gid=data['gid'])
if obj.author:
if TokenManager.authenticate(obj.author.id, header=request.headers):
obj.delete()
return Response('deleted', status=200)
raise
else:
if bcrypt.checkpw(data['password'].encode('utf-8'), obj.password.encode('utf-8')):
obj.delete()
return Response('deleted', status=200)
raise
except Exception as e:
return Response('failed', status=400)
7. 마무리
이렇게 React에서 유용한 Hook, Django에서 유용한 Serializer에 대해 공부하고 원하는 기능을 구현할 수 있었습니다. 다음에는 이렇게 구현한 기능들을 여러가지 컴포넌트로 나눠서 연동해 프론트 엔드에 방명록 기능을 만들어보도록 하겠습니다.
감사합니다!!!
'FE & BE' 카테고리의 다른 글
7. 프로젝트 기능 백엔드 구현 [MyHomepage: 개인 홈페이지 만들기] (0) | 2022.11.18 |
---|---|
6. 방명록 프론트엔드 구현 [MyHomepage: 개인 홈페이지 만들기] (0) | 2022.11.17 |
4. React 모달창 구현 + Styled Components [MyHomepage: 개인 홈페이지 만들기] (0) | 2022.11.15 |
3. User모델과 OAuth 2.0 [MyHomepage: 개인 홈페이지 만들기] (0) | 2022.11.15 |
2. 홈페이지 기획, AWS S3, Project 모델 구성 [MyHomepage: 개인 홈페이지 만들기] (0) | 2022.11.15 |
댓글