뒤늦은 프로젝트 회고..올려봅니다.
소개글은 길어서 따로 뺐습니다. ㅎㅎ
이 포스팅에는 제 역할과 트러블 슈팅 과정 등을 적을 예정입니다.

예쁜 메인화면... (자화자찬 맞습니다)
예전에 [내 트리를 꾸며줘] 서비스를 보고 나도 저런 서비스를 만들어보고 싶다고 생각했었다.
따라서 냅다 기획 회의 때 추석 시즌에 알맞다는 점을 어필하며 말씀드렸었고, 반응이 좋아 진행하게 되었다. ㅎㅎ
결과는 완전 만족!!!
마음에 드는 프로젝트이다.ㅎㅎ
프로젝트 소개글은 여기
내 역할



function bakeCookie(name, value, expDay) {
let today = new Date();
today.setDate(today.getDate() + expDay);
document.cookie = name + '=' + value + ';expires=' + today.toGMTString();
}
function getCookie(name) {
let cookie = document.cookie;
if (document.cookie != '') {
// 쿠키 있으면
let cookieArr = cookie.split('; ');
for (let idx in cookieArr) {
let cookieName = cookieArr[idx].split('=');
if (cookieName[0] == 'bensCookie') {
return cookieName[1];
}
}
}
return;
}
let checkCookie = getCookie('bensCookie');
bakeCookie('bensCookie', 'guideCookie', 30);
if (checkCookie == 'guideCookie') {
guideDiv.style.display = 'none';
}

// 툴팁 js
let tooltipTriggerList = [].slice.call(
document.querySelectorAll('[data-bs-toggle="tooltip"]')
);
let tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});


// star position /size 정의
for (let i = 1; i <= starCnt; i++) {
const star = document.querySelector(`.star${i}`);
const p = document.querySelector(`#p${i}`);
if (i % 2 == 0) {
// 짝수 별이라면
if (myWidth <= 480) {
// 모바일용
star.style.top = myHeight / 4 + 'px';
star.style.left = ((1 * myWidth) / 11) * i + 'px';
} else {
star.style.top = myHeight / 3 + 'px';
star.style.left = ((1 * myWidth) / 10) * i + 'px';
}
} else {
//홀수 별이라면
if (myWidth <= 480) {
// 모바일용 크기
star.style.top = myHeight / 20 + 'px';
star.style.left = (myWidth / 11) * i + 'px';
} else {
star.style.top = myHeight / 10 + 'px';
star.style.left = (myWidth / 10) * i + 'px';
}
}
if (myWidth <= 480) {
// 모바일용 크기
star.style.width = myWidth / 8 + 'px';
star.style.height = myHeight / 10 + 'px';
} else {
star.style.width = myWidth / 8 + 'px';
star.style.height = myHeight / 8 + 'px';
}
}
function resizeStars() {
for (let i = 1; i <= starCnt; i++) {
const star = document.querySelector(`.star${i}`);
const p = document.querySelector(`#p${i}`);
if (i % 2 == 0) {
// 짝수 별이라면
if (window.innerWidth <= 480) {
// 모바일용
star.style.top = window.innerHeight / 4 + 'px';
star.style.left = ((1 * window.innerWidth) / 11) * i + 'px';
} else {
star.style.top = window.innerHeight / 3 + 'px';
star.style.left = ((1 * window.innerWidth) / 10) * i + 'px';
}
} else {
//홀수 별이라면
if (window.innerWidth <= 480) {
// 모바일용 크기
star.style.top = window.innerHeight / 20 + 'px';
star.style.left = (window.innerWidth / 11) * i + 'px';
} else {
star.style.top = window.innerHeight / 10 + 'px';
star.style.left = (window.innerWidth / 10) * i + 'px';
}
}
if (myWidth <= 480) {
// 모바일용 크기
star.style.width = window.innerWidth / 8 + 'px';
star.style.height = window.innerHeight / 10 + 'px';
} else {
star.style.width = window.innerWidth / 8 + 'px';
star.style.height = window.innerHeight / 8 + 'px';
}
}
}
const cmt = document.querySelector('#cmt');
const cmtArr = [
'"여름은 단번에 가을로 떨어진다."',
'"가을은 자연의 계절이기보다는 영혼의 계절임을 나는 알았다."',
'"추석은 야금야금 살찌는 날"',
'"가지마 추석 연휴"',
'"2023년도 벌써..."',
];
let randCmtNum = Math.floor(Math.random() * cmtArr.length);
cmt.innerText = cmtArr[randCmtNum];


function prevPage() {
if (curPage > 0) {
//현재 페이지가 1보다 큰 경우에만 이전 페이지로
// 데이터 -7(별 개수)
const p = document.querySelectorAll('p');
const a = document.querySelectorAll('.a');
const starImg = document.querySelectorAll('.starImg');
try {
axios({
method: 'GET',
url: `/prevPage?curPage=${curPage}`,
}).then((res) => {
curPage--; //앞에서 빼주어야 올바른 현재 페이지(이전 페이지)로 이동
const data = res.data.data;
const startIndex = (curPage - 1) * starCnt; //0
// step 1) a태그 내 링크 수정
for (let i = 0; i < a.length; i++) {
const dataIndex = startIndex + i + starCnt;
if (data[dataIndex]) {
a[i].setAttribute('href', `/letter/MyLetter/${data[dataIndex].id}`);
// a[i].href = `/letter/MyLetter/${data[dataIndex].id}`;
} else {
// a[i].href = '#';
a[i].removeAttribute('href');
}
}
// step 2) p태그 내 닉네임 수정
for (let i = 0; i < p.length; i++) {
//data.length는 8이라 p 인덱스에러 나니까 p.length로 수정
const dataIndex = startIndex + i;
if (data[dataIndex]) {
//데이터 있을 때 출력
p[i].innerText = data[dataIndex].nickname;
} else {
p[i].innerText = '';
}
}
// step 3) data 있을 때만 img 보이도록
for (let i = 0; i < starImg.length; i++) {
const dataIndex = startIndex + i + starCnt;
if (data[dataIndex]) {
starImg[i].style.display = 'block';
} else {
starImg[i].style.display = 'none';
}
}
// step 4) data 있을 때만 tooltip 보이도록
for (let i = 0; i < starImg.length; i++) {
const dataIndex = startIndex + i + starCnt;
if (data[dataIndex]) {
stars[i].setAttribute('data-bs-toggle', 'tooltip');
stars[i].setAttribute('data-bs-placement', 'top');
stars[i].setAttribute(
'data-bs-original-title',
'송편지별을 둘러보세요'
);
} else {
stars[i].removeAttribute('data-bs-toggle');
stars[i].removeAttribute('data-bs-placement');
stars[i].removeAttribute('data-bs-original-title');
}
}
});
} catch (err) {
console.error('Error', err);
}
}
}




페이징 : 화살표 버튼을 통한 편지 목록 페이징 구현

페이징에 따른 편지별 작성자 닉네임/편지 아이콘 변경
let curPage = 1;
function prevPage() {
if (curPage > 1) {
const letterImg = document.querySelectorAll('.letterImg');
const letterP = document.querySelectorAll('.letterP');
const letters = document.querySelectorAll('.letters');
let id = document.querySelector('#personId').value;
try {
axios({
method: 'GET',
url: `/letter/myLetter/${id}/prevPage?curPage=${curPage}`,
}).then((res) => {
curPage--;
const data = res.data.postData;
const designMap = {
1: '/img/letterIcons/px_acorn.png',
2: '/img/letterIcons/px_apple.png',
3: '/img/letterIcons/px_apple2.png',
4: '/img/letterIcons/px_coin.png',
5: '/img/letterIcons/px_food.png',
6: '/img/letterIcons/px_hedgehog.png',
7: '/img/letterIcons/px_lApple.png',
8: '/img/letterIcons/px_nuts.png',
9: '/img/letterIcons/px_panda.png',
10: '/img/letterIcons/px_pear.png',
11: '/img/letterIcons/px_persimmon.png',
12: '/img/letterIcons/px_pumpkin.png',
13: '/img/letterIcons/px_rabbit.png',
14: '/img/letterIcons/px_squirrel.png',
15: '/img/letterIcons/px_tree.png',
};
const startIndex = (curPage - 1) * letterCnt;
document.querySelector('#postNo5').value = startIndex;
// step 1) 각자 다른 이미지 path 가져오기
for (let i = 0; i < letterImg.length; i++) {
const dataIndex = i;
if (data[dataIndex]) {
const designNumber = data[dataIndex].postDesign;
const imagePath = designMap[designNumber];
if (imagePath) {
letterImg[dataIndex].src = imagePath;
letterImg[dataIndex].style.display = 'block';
} else {
letterImg[dataIndex].src = '';
}
} else {
letterImg[dataIndex].src = '';
letterImg[dataIndex].style.display = 'none';
}
}
// step 2) 각자 다른 이름 가져오기
for (let i = 0; i < letterP.length; i++) {
let dataIndex = i;
if (data[dataIndex]) {
letterP[i].innerText = data[dataIndex].postNickname;
} else {
letterP[i].innerText = '';
}
}
// step 3) 데이터 있을 때에만 편지 내용 보여주기
for (let i = 0; i < letters.length; i++) {
let dataIndex = i;
if (data[dataIndex]) {
letters[i].setAttribute('data-bs-target', '#letterModal');
letters[i].setAttribute('data-bs-toggle', 'modal');
letters[i].setAttribute('onclick', `showPost(${id}, ${i})`);
letters[i].style.cursor = 'pointer';
} else {
letters[i].removeAttribute('data-bs-target');
letters[i].removeAttribute('data-bs-toggle');
letters[i].removeAttribute('onclick');
letters[i].style.cursor = 'default';
}
}
});
} catch (err) {
console.error('Error', err);
}
}
}

/* 버블보블 애니메이션 */
@keyframes bounce {
100% {
top: -20px;
text-shadow: 0 1px 0 #ccc, 0 2px 0 #ccc, 0 3px 0 #ccc, 0 4px 0 #ccc,
0 5px 0 #ccc, 0 6px 0 transparent, 0 7px 0 transparent,
0 8px 0 transparent, 0 9px 0 transparent, 0 50px 25px rgba(0, 0, 0, 0.4);
}
}
메인페이지
메인페이지에서 개별 회원 닉네임과 링크를 페이징 시마다 변경해야 했고, 프론트단 코드와 더불어 백엔드쪽 코드도 수정해야했다.
백엔드 분께 여쭤봐서 도움을 받아 각각 nextPage, prevPage 컨트롤러를 작성하고, 라우터에도 연결해주었다.
nextPage: async (req, res) => {
const result = await User.findAll();
res.send({ data: result });
},
prevPage: async (req, res) => {
const result = await User.findAll();
res.send({ data: result });
},
router.get('/prevPage', controller.output.prevPage);
router.get('/nextPage', controller.output.nextPage);
=> 모든 사용자를 데이터베이스에서 가져와 프론트엔드로 보내주는 코드이다.
편지함 페이지
편지함 페이지 역시 페이징 시마다 아이콘, 작성자 닉네임이 변경되어야 했고, 컨트롤러와 라우터를 다음과 같이 작성했다.
nextPage: async (req, res) => {
let curPage = req.query.curPage;
const postData = await Post.findAll({
where: { letterNo: req.params.id },
attributes: ['postNickname', 'postDesign'],
offset: 5 * req.query.curPage,
limit: 5,
order: [['letterNo', 'ASC']],
});
res.send({ postData: postData });
},
prevPage: async (req, res) => {
let curPage = 1 | req.query.curPage;
const postData2 = await Post.findAll({
where: { letterNo: req.params.id },
attributes: ['postNickname', 'postDesign'],
offset: 5 * (req.query.curPage - 2),
limit: 5,
order: [['letterNo', 'ASC']],
});
res.send({ postData: postData2 });
},
=> id를 기준으로 데이터베이스에서 편지 목록을 찾고, 프론트엔드에서 처리해준 curPage를 기준으로 offset을 설정해, limit인 5개에 맞춰 보내주는 코드이다.
router.get('/letter/MyLetter/:id/nextPage', controllerPost.output.nextPage);
router.get('/letter/MyLetter/:id/prevPage', controllerPost.output.prevPage);
공모전까지 develop하던 단계에서, 자신의 편지함 링크를 복사해 친구에게 공유하면 좋을 것 같다는 의견이 있었고, 구글링을 통해 해당 기능 및 버튼을 추가했다.
const btnShare = document.querySelector('#btnShare');
async function copyUrl() {
try {
//개발자도구 오류 -> 문서에 포커스 주기
document.body.focus();
await navigator.clipboard.writeText(window.location.href);
alert('편지함 링크가 복사됐어요!');
} catch (error) {
console.error('복사 중 오류 발생:', error);
}
}
=> try 구문은 다른 분께서 넣어주신 부분인데, 검색에 따르면 겉보기에는 이상이 없지만, 없으면 호환성 및 UX적 문제가 일어날 수 있기에 문서에 포커스를 준다고 한다. MDN focus
사용자의 로그인 여부에 따라 보여지는 기능 버튼 내용이 달라야했고, 로그인 여부, 편지함 주인을 기준으로 3개의 경우를 나눠 다음 코드를 ejs에 추가했다.
<div class="menuDiv col-md-2">
<!-- 1. 로그인 했고 내 페이지일 때 -->
<% if (isMine && isLogin) { %>
<div class="myPageDiv">
<a href="/user/myPage/<%= lord.id%>">
<%if (profile.profileLocation !== 'null'){%>
<button id="btnMypage">
<img src="<%= profile.profileLocation%>" alt="" id="imgMypage" />
</button>
<p>마이페이지</p>
<%}else{%>
<button id="btnMypage">
<img src="/img/header/user.png" alt="" id="imgProfilePic" />
</button>
<%}%>
</a>
</div>
<div class="friendConfirmDiv">
<a href="/letter/friendConfirm">
<button id="btnFriendConfirm" >
<img src="/img/header/ring.png" alt="" id="imgFriendConfirm" />
</button>
<p>친구 신청</p>
</a>
</div>
<div class="friendsDiv">
<a href="/letter/friends/<%=lord.id%>">
<button id="btnFriends">
<img src="/img/header/friends.png" alt="" id="imgFriends" />
</button>
<p>친구 목록</p>
</a>
</div>
<div class="shareDiv">
<button id="btnShare" onclick="copyUrl()" >
<img src="/img/header/share.png" alt="" id="imgShare" />
</button>
<p>공유하기</p>
</div>
<% } %>
<!-- 2. 로그인 했고 남의 페이지일 때 -->
<% if(isLogin && !isMine ) { %>
<div class="profilePicDiv">
<button id="btnProfilePic" disabled>
<%if (profile.profileLocation !== 'null'){%>
<img src="<%= profile.profileLocation%>" alt="" id="imgMypage" />
<%}else{%>
<img src="/img/header/user.png" alt="" id="imgProfilePic" />
<%}%>
</button>
</div>
<div class="addFriendDiv">
<button id="btnAddFriend" onclick="addFriend()">
<%if( checkFriend || checkRequest){%>
<img src="/img/header/check.png" alt="" id="imgAddFriend" />
<%}else{%>
<img src="/img/header/add.png" alt="" id="imgAddFriend" />
<%}%>
</button>
<input type="text" id="lordid" value="<%= lord.id%>">
<p>친구 신청</p>
</div>
<div class="friendsDiv">
<a href="/letter/friends/<%=lord.id%>">
<button id="btnFriends" >
<img src="/img/header/friends.png" alt="" id="imgFriends" />
</button>
<p>친구 목록</p>
</div>
<div class="writeLetterDiv">
<a href="/letter/select/<%=lord.id%>">
<button id="btnWriteLetter">
<img src="/img/header/pen.png" alt="" id="imgWriteLetter" />
</button>
<p>편지 쓰기</p>
</a>
</div>
<%}%>
<!-- 3. 로그인 안 했고 남의 페이지일 때 -->
<% if (!isLogin && !isMine) { %>
<div class="profilePicDiv">
<button id="btnProfilePic" disabled>
<%if (profile.profileLocation !== 'null'){%>
<img src="<%= profile.profileLocation%>" alt="" id="imgMypage" />
<%}else{%>
<img src="/img/header/user.png" alt="" id="imgProfilePic" />
<%}%>
</button>
</div>
<div class="writeLetterDiv">
<a href="/letter/select/<%=lord.id%>">
<button id="btnWriteLetter">
<img src="/img/header/pen.png" alt="" id="imgWriteLetter" />
</button>
<p>편지 쓰기</p>
</a>
</div>
<% } %>
</div>
원래는 페이지에 three.js를 넣고 싶어 magica voxel로 obj를 만들어, 쿠키를 사용해 최초 접속 시에만 로딩 화면을 띄우는 코드를 작성했었다.
// 로딩 화면
const loading = document.querySelector('.loading');
// setTimeout(() => {
// loading.style.display = 'none';
// }, 3000);
// 쿠키 굽기
function setCookie(name, value, expireTime) {
let today = new Date();
// console.log('현재 시각: ', today.getHours()); //nn
today.setHours(today.getHours() + expireTime);
document.cookie =
name + '=' + escape(value) + ';expires=' + today.toGMTString();
}
function getCookie(name) {
let cookie = document.cookie;
// console.log(cookie);
if (document.cookie != '') {
//있으면
let cookieArr = cookie.split('; ');
// console.log(cookieArr);
for (let idx in cookieArr) {
let cookieName = cookieArr[idx].split('=');
if (cookieName[0] == 'bensCookie') {
return cookieName[1];
}
}
}
return;
}
let checkCookie = getCookie('bensCookie');
setCookie('bensCookie', 'end', 1);
if (checkCookie == 'end') {
loading.style.display = 'none';
} else {
setTimeout(() => {
loading.style.display = 'none';
}, 3000);
}
///////////////////////////////////////////////
// three.js 처리
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
/* Basic Setting*/
var myRenderer;
var myCamera;
var myScene;
var myLight1;
var myLight2;
//*renderer setting
myRenderer = new THREE.WebGL1Renderer();
let rd_w = window.innerWidth;
let rd_h = window.innerHeight;
myRenderer.setSize(rd_w, rd_h);
myRenderer.setViewport(0, 0, rd_w, rd_h);
loading.appendChild(myRenderer.domElement);
//*camera setting
myCamera = new THREE.PerspectiveCamera(45, rd_w / rd_h, 1, 500);
myCamera.position.set(0, 0, 12);
myCamera.up.set(0, 1, -10);
myCamera.lookAt(0, 0, 0);
//*scene setting
myScene = new THREE.Scene();
myLight1 = new THREE.DirectionalLight(0xffffff, 1.3);
myLight1.position.set(0, 20, 30);
myScene.add(myLight1);
myLight2 = new THREE.AmbientLight(0xffffff, 1.3);
myLight2.position.set(0, 20, -10);
myScene.add(myLight2);
myScene.add(myCamera);
myScene.background = new THREE.Color('#DDF7E3');
/*Obj load */
const ctrl = new OrbitControls(myCamera, myRenderer.domElement);
ctrl.update();
/*Mtl Load */
const mtlLoader = new MTLLoader();
mtlLoader.load('/obj/skewers2.mtl', function (materials) {
materials.preload();
objLoader(materials, 'skewers2');
});
mtlLoader.load('/obj/rabbit.mtl', function (materials) {
materials.preload();
objLoader(materials, 'rabbit');
});
let skewers = new THREE.Mesh();
let rabbit = new THREE.Mesh();
function objLoader(materials, modelName) {
const objLoader = new OBJLoader();
objLoader.setMaterials(materials);
objLoader.load(`/obj/${modelName}.obj`, function (loadedModel) {
switch (modelName) {
case 'skewers2':
skewers = loadedModel;
myScene.add(skewers);
break;
case 'rabbit':
rabbit = loadedModel;
myScene.add(rabbit);
break;
}
});
}
let step = 0;
// 함수 동작 관련
function animate() {
let rotateDirection = 1;
skewers.scale.x = rotateDirection * 2;
skewers.scale.y = rotateDirection * 2;
skewers.scale.z = rotateDirection * 2;
skewers.rotation.z = 0.5;
skewers.rotation.y += 0.07;
skewers.position.x = 0;
skewers.position.y = -0.7;
skewers.position.z = 4;
// rabbit
rabbit.position.set(-0.5, 0, -2);
step += 0.06;
rabbit.scale.x = rotateDirection * 2.2;
rabbit.scale.y = rotateDirection * 2.2;
rabbit.scale.z = rotateDirection * 2.2;
rabbit.position.y = 2.3 * Math.abs(Math.sin(step));
rabbit.position.z += step;
if (rabbit.position.z >= 4) {
rabbit.scale.x = rotateDirection * 3;
rabbit.scale.y = rotateDirection * 3;
rabbit.scale.z = rotateDirection * 3;
rabbit.position.y = -1;
rabbit.position.z = 4;
rabbit.rotation.y += 0.07;
skewers.scale.x = 0;
skewers.scale.y = 0;
skewers.rotation.y = 0;
}
requestAnimationFrame(animate);
ctrl.update();
myRenderer.render(myScene, myCamera);
// labelRenderer.render(myScene, myCamera);
}
//resize event
function onResize() {
myCamera.aspect = window.innerWidth / window.innerHeight;
myCamera.updateProjectionMatrix();
myRenderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', onResize);
animate();

그러나, 최종 단계에서 몇 기기와의 호환성 이슈로 인해 로딩 화면이 삭제되었고, 이 부분이 조금 아쉬웠다. ㅠㅠ
시간이 촉박해서 원인을 찾진 못했는데, 꼭 개선하고 싶다.
하지만 내가 좋아해서 아르바이트까지 했었던 브랜드인, BensCookie의 이름으로 심었던 쿠키는 기능만 바뀌어서 가이드 화면에 잘 심겨있다.ㅎㅎ
트러블 슈팅


현상
원인
해결 방법
깨달은 점

현상
→
원인
해결 방법
깨달은 점
메인 페이지에서, 화살표를 누르면 이전/다음 회원 목록으로 바뀌며,
작성자 이름과 회원 편지함 이동 링크가 각각 바뀌어야 하는데
작성자 이름은 바뀌었지만, 이동 링크가 바뀌지 않고 계속해 첫 페이지의 회원 편지함 링크와 동일한 에러 발생.
회원 편지함 이동 링크는 /letter/MyLetter/3 과 같이 MyLetter/ 뒤 회원별로 고유한 id 값이 붙는 형태였음
그러나 첫 페이지에서의 1~7의 id 값이 도저히 바뀌지 않아 다른 페이지의 회원 편지함을 클릭해도 첫 번째 페이지 내, 동일 위치의 회원 편지함으로 이동 되곤 했음.
⇒ 페이징 샘플 코드를 참조했다.
(1) 변수를 통해 offset, limit을 조정했다.
(2) 회원 편지함 링크로 이어주는 a 태그의 수만큼 반복문을 실행했다.
(3) 데이터 유무에 따른 링크 속성 설정
/letter/MyLetter/${data[dataIndex].id});프론트엔드와 백엔드가 같이 팀이 돼 프로젝트를 한 경험은 처음이었다.
더불어 DB와 연동된 동적인 서비스는 처음이었기에 긴장도 많이 하고 어렵게 느껴졌었다.
프론트엔드와 백엔드 역할을 나눈 만큼 내 역할에 대한 책임감과 부담감 역시 컸었다.
좋은 팀원분들을 만나서 많이 배워갈 수 있었던 과정이었고, 웹 사이트의 전체 동작 사이클을 경험해볼 수 있어 유익했다.
가장 어려웠던 것은 페이징! 샘플 코드와 팀원분의 도움을 받아 어찌어찌 구현하긴 했지만 처음 해보기도 하고 알고리즘 이해 과정이 어려워서 더욱 더 기억에 남는 과정이었다.
또한 three.js를 통해 로딩화면을 구현했었는데, 아이폰이나 맥의 호환 이슈로 인해 빼게 되어 상당히 아쉽다.
프론트엔드를 맡으며 와이어프레임도 디자인하고, 사용자 가이드 화면을 통해 UX도 개선해가며 UX/UI의 중요성을 더 깨달았다.. 수업을 듣기 잘했던 것 같다..
반응형 역시 5개의 breakpoint를 사용했는데, 반응형 자체를 프로젝트에 구현한 것도 처음이었지만, 필수적인 요소임을 깨달아 더 열심히 했다.
와카타임에 10시간이 찍히기도 하고, 새싹이 문 닫을 때까지 프로젝트를 하거나 휴일에도 모두모두 모여서 프로젝트를 했던 만큼 열심이었던 시간이라고 말할 수 있다. 그렇게 수여한 최우수상도 값졌다.
1차 프로젝트가 2주만에 끝났다는 것이 안 믿기고, 다음 프로젝트를 위해 정말 더 많이 공부해야겠다.