Trello Clone, 계획 공유 서비스
AWS 프리티어 만료 및 도메인 만료로 메뉴얼만 보실 수 있습니다. 메뉴얼에는 프로젝트 GIF와 기능 설명이 있습니다.
간단 메뉴얼 : https://pozafly.github.io/tripllo-manual/
메인 :
https://tripllo.tech
- 2020년 12월 7일 ~ 2021년 2월 15일
- 개인 프로젝트
Front-end
- Vue 2.6.11 + Vuex + Vue-router
- Scss
- Webpack
- Axios
- Dragula (https://bevacqua.github.io/dragula/)
- Sockjs-client
- Sentry
Back-end
- Java 8
- SpringBoot 2.1.9
- Spring Security + JWT
- Websocket
- Swagger 2
- Spring Mail
- Spring Cloud-AWS
- Mysql 8.0.22
- MyBatis
Deploy
- AWS-EC2 (Amazon Linux 2)
- AWS-RDS
- AWS-S3
- AWS-CloudFront
- AWS-Route53
- AWS-CodeDeploy
- Travis
- Nginx
- Let's Encrypt(SSL)
- 전체 흐름
- 계획 등록
- 사용자 초대
- 소셜 기능
핵심 기능 설명 펼치기
- Frontend
- Backend
-
카드 기능
-
Location(구글맵 API)
- 구글맵 API를 사용해서 card에서는 static 이미지를 불러오며 클릭 시, 구글맵 전체를 볼 수 있습니다.
- 구글맵 상세 페이지에서는 해당 Board에서 등록된 모든 location이 지도에 표시되는 클러스터 기능이 구현되어 있습니다. 📌 코드 확인
-
Attachment
-
Checklist
-
Comments
- 답글(대댓글)을 위한 group_num, dept 칼럼을 두어 답글을 표현합니다.
- 삭제 시 댓글에 답글이 없을 경우는 화면에서 사라지지만, 답글이 존재하는 경우 삭제된 메세지 입니다. 라고 표시됩니다. 📌 코드 확인
-
그 외 기능 - Description(메모), Labels(라벨링), dueDate(날짜 지정)
-
-
드래그 앤 드롭
- dragula 모듈을 사용해, List와 Card를 드래그해서 위치를 변화시킬 수 있습니다.
- 대상의 이전 DOM과 다음 DOM을 비교해서 pos(포지션) 값을 지정 후 UPDATE 합니다. 📌 코드 확인
-
BoardPage & CardModal 화면 연동
-
유저 검색
- 모달 창에서 초대하고 싶은 회원의 ID를 검색합니다. filter를 사용해 자신과 이미 초대된 사람은 목록에 뜨지 않습니다. 📌 코드 확인
-
실시간 messaging
-
초대 수락
- Board 조회 시, Data를 한 번에 조회하는 방식이었습니다.
- 무한 스크롤을 적용할 때 전체를 조회하는 것이 아니라 이어지는 일부분을 가져와야 했습니다.
- 커서 기반 페이지네이션을 읽고 MySQL의 limit와 offset을 사용해서 들고 오면, Table 전체를 조회 후 offset에 맞는 Data를 가져오게 되므로 성능상 문제가 생긴다는 사실을 알게 되었습니다.
기존SQL
<select id="readPersonalBoardList" parameterType="String" resultType="com.pozafly.tripllo.board.model.Board">
select
a.id,
a.title,
a.bg_color,
a.public_yn,
a.hashtag,
a.like_count,
a.created_at,
a.created_by,
EXISTS
(
select 1
from board_has_like
where board_id = a.id and user_id = #{userId}
) as own_like
from board a
where a.created_by = #{userId}
order by created_at desc
</select>
- 커서(기준)는 정렬하고 있는 대상인 created_at 이며
- 처음 조회 시 lastCreatedAt 변수에
firstCall
문자열을 주어, 14개의 데이터만 조회했습니다. - 이후 조회 시 lastCreatedAt 변수에
화면에 뿌려진 마지막 DOM의 createdAt
로 조회하면, 커서(기준)보다 작은 순서로 Data를 가져옵니다.
수정된 SQL
<select id="readPersonalBoardList" parameterType="Map" resultType="com.pozafly.tripllo.board.model.Board">
select
a.id,
a.title,
a.bg_color,
a.public_yn,
a.hashtag,
a.like_count,
a.created_at,
a.created_by,
EXISTS
(
select 1
from board_has_like
where board_id = a.id and user_id = #{userId}
) as own_like
from board a
where a.created_by = #{userId}
<choose>
<when test='"firstCall".equals(lastCreatedAt)'>
order by created_at desc
limit 14
</when>
<otherwise>
and created_at <![CDATA[ < ]]> #{lastCreatedAt}
order by created_at desc
limit 6
</otherwise>
</choose>
</select>
- Vue에서는
vue-infinite-loading
패키지를 설치하고,<infinite-loading>
를 이용해infiniteHandler
메서드를 호출하도록 했습니다.
Vue templete 코드
<infinite-loading @infinite="infiniteHandler" spinner="waveDots">
<div
slot="no-more"
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px;"
>
목록의 끝입니다 :)
</div>
</infinite-loading>
- script에서는 lastCreatedAt 변수에 담길 값을 저장합니다.
- 이때, vue-infinite-loading는
$state.loaded
와$state.complete
로 무한스크롤이 끝났는지 지속해야 하는지 판단합니다.
Vue script 코드
data() {
return {
...
lastCreatedAt: 'firstCall', // 초기 값.
}
}
...
async infiniteHandler($state) {
try {
const { data } = await readPersonalBoardAPI(this.lastCreatedAt);
if (data.data === null) {
this.isInfinity = false;
$state.complete(); // 데이터는 모두 소진되고 다시 가져올 필요가 없다는 것을 알려준다.
} else {
(...)
setTimeout(() => {
const boardItem = data.data;
// BoardItem의 마지막 값을 가져옴
const lastCreatedAt = boardItem[boardItem.length - 1].createdAt;
this.lastCreatedAt = lastCreatedAt;
$state.loaded(); // 계속 데이터가 남아있다는 것을 infinity에게 알려준다.
}, 1000);
}
} catch (error) {
console.log(error);
alert('Personal 보드를 가져오지 못했습니다.');
}
},
- 회원가입 페이지에서 input을 조작할 때, 동적으로 validation 체크와 button 활성화 기능을 넣고 싶었습니다.
- vue의 watch를 통한 데이터를 감지와 input 태그에 debounce를 걸어 약간의 딜레이를 주고자 했습니다.
- 하지만, vue data에 선언된 userData가 객체형태였고 객체의 요소 하나라도 변하면 메서드가 실행되는 문제가 발생했습니다.
기존 코드
data() {
return {
userData: {
id: '',
password: '',
email: '',
name: '',
response: '',
name: '',
},
},
}
...
watch: {
userData: {
id: function() {
() => {
_.debounce(function(e)) {
this.validUserId(e);
}
},
...
},
},
},
- 아래와 같이
- 객체 내부의 변수 1개만 감지 :
'객체.변수명': [some function]
- 객체 내부 요소가 하나라도 변화할 때 감지 :
handler(e)
,deep: true
- debounce는 즉시 실행 함수로 선언하는 것이 아니라, 함수 자체를 등록해줘야 한다는 것을 알게 되어 개선할 수 있었습니다.
개선된 코드
watch: {
userData: {
handler(e) {
...
e.id !== '' && e.password !== '' && e.email !== '' && e.name !== ''
? (this.btnDisabled = false)
: (this.btnDisabled = true);
},
deep: true,
},
'userData.id': _.debounce(function(e) {
this.validUserId(e);
}, 750),
'userData.password': _.debounce(function(e) {
this.validatePw(e);
}, 750),
(...)
},
- Vue는 SPA이므로 새로고침 했을 때, state에 jwt(token), user 정보 등의 데이터가 지워져 여러 오류를 발생시켰습니다.
- 이를 해결하기 위해서 브라우저 저장소(쿠키)를 이용 하여 문제를 해결했습니다.
- 하지만, 쿠키는 4kb밖에 되지 않고 서버에 계속해서 쿠키를 보내기 때문에 제외하고 webStorage를 사용하기로 했습니다.
localStorage
는 user와 token 정보를 저장합니다. 재접속 시 localStorage에 user 관련 정보가 있다면, 라우터 가드에서 main 페이지로 이동시킵니다. 로그인된 상태로 이용하게 하기 위함입니다. 📌 코드 확인- localStorage는 userInfo와 token을 저장하고 인코딩 합니다. 또한
sessionStorage
는 새롭게 api를 연동해야 하는 휘발성이 있는 객체들을 저장합니다. 📌 코드 확인 - 새로고침 시, state에서 webStorage에 저장된 Data를 가져오도록 했습니다.
state 코드
const state = {
token: getTokenFromLocalStorage() || '',
user: getUserFromLocalStorage() || '',
board: getSessionStorage('board') || {},
card: getSessionStorage('card') || {},
bgColor: getSessionStorage('bgColor') || '',
(...)
};
- axios interceptor에서, 로그인 후 받아온 JWT token을 header에 담아 백엔드로 보내 인증을 하고 싶었습니다.
interceptor.js
// request
instance.interceptors.request.use(
config => {
config.headers.Authorization = store.state.token;
return config;
},
error => {
return Promise.reject(error);
}
);
- SpringSecurity의
JwtTokenProvider
class에서 아래와 같이 token을 받고 있었습니다. - 이는 token 앞에 "TOKEN" 이라는 문자열을 가진 header를 읽는 코드입니다.
기존 코드
// Request의 Header에서 token 값을 가져옵니다. "TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("TOKEN");
}
- 사진과 같이 크롬 Network 탭의 Request Header를 확인해보면,
- token을,
Authorization
이라는 이름으로 보내고 있었기 때문에 JwtTokenProvider에서 이를 불러오지 못하고 있었습니다.
- 따라서 JwtTokenProvider class에서 request.getHeader("Authorization") 코드로 token을 받겠다고 명시해 주어 문제를 해결했습니다.
Dev 서버가 실행되지 않는 문제(PostCSS)
Uncaught Error: Module build failed (from ./node_modules/postcss-loader/src/index.js) :
Error: PostCSS received undefined instead of CSS string
...
- PostCSS는 자바스크립트로 CSS 변환을 해주는 도구이며, CSS 작성 경험을 향상 시켜주는 도구.
- npm을 업데이트했는데 node-sass, sass-loader 두 가지는 npm 버전을 많이 가린다고 알고 있었음.
- npm 설치가 안되는 에러 를 참고하여 node-module을 지우고 다시 설치로 해결.
JSON.parse 문제
- JSON.parse는 문자열을 json 형태로 만들어주는데, 이때, null 값이 들어가게 되면 파싱 오류를 뱉음.
- 따라서 공통 함수를 만들어 파싱 전 if문으로 null 체크를 해줌. 📌 코드 보기
vue-google-login 플러그인 문제
- 커뮤니티에 링크를 공유 후 다른 접속자들의 환경에서는 접속이 안 된다는 제보를 받음.
- test시 크롬은 동작하는데 사파리에서 구글 로그인을 사용하니 아무 동작을 하지 않음.
- 플러그인을 지우고, Google 공식 버전으로 직접 코딩 후 해결. 📌 코드 보기
모달 외부 클릭 시 닫히지 않는 문제
- 모달 외부 wrapper에 click 이벤트를 걸어, 모달 DOM을 제외한 곳을 click시 닫히도록 함. 📌 코드 보기
- v-click-outside 모듈 사용.
actions 로직파악 어려운 문제
- Vuex의 action안에서 다른 action을 부르기 때문에 actions가 굉장히 복잡했다. actions를 사용할 필요가 없는 것은 전부 컴포넌트 단으로 api 함수를 옮겼고, actions에 Mutation를 발생시키는 commit 메서드만 남기도록 수정함.
- actions.js가 415줄에서 73줄이 되었다. 📌 코드 보기
Vue 파일 내, Script 로드 문제
- 외부 API( ex) 소셜로그인, 구글맵) 를 사용할 때 index.html에 script 태그를 선언하게 되면 모든 페이지에서 스크립트가 로드된다.
- 성능 낭비이기 때문에, 하나의 vue 컴포넌트에서만 script를 load 하고 싶었다. 해당 vue 파일에서 script load가 필요한 상황.
vue-plugin-load-script
모듈을 다운받아 플러그인 화하여 사용했음. 📌 코드 보기
event 중첩 문제
-
프로젝트 내 input 수정 로직은 Enter를 누르거나, input에서 포커스를 벗어나면 UPDATE 되는 방식을 사용함.
-
input 태그에 @keyup.enter와 @blur를 사용하는데 keyup 이벤트가 발생하면 blur 이벤트까지 같이 일어나 api가 2번 요청되는 이슈가 있었음.
기존 코드
<input ... @keyup.enter="onSubmitTitle" @blur="onSubmitTitle" />
-
이때, 2개 모두 같은 method를 등록하는 것이 아니라 @keyup.enter 이벤트에는 blur 이벤트가 트리거 되는 이벤트를 따로 등록시켜주어 개선할 수 있었음.
개선된 코드
<input ... @keypress.enter="onKeyupEnter" @blur="onSubmitTitle" /> ... onKeyupEnter(event) { event.target.blur(); },
한글 문자열 입력 시 함수가 2번 실행되는 문제.
- keyup은 키보드에서 손을 떼었을 때 실행되며, keypress는 키보드를 눌렀을 때 실행됨.
- @keyup.enter 대신 @keypress.enter 으로 해결. 📌 코드 보기
MySQL 글자 수 제한으로 인해 input 입력값이 등록되지 않는 문제.
- input 속성으로 maxlength를 걸어주었음. 📌 commit 보기
display sticky 시, 다른 컴포넌트를 붙였을 때 ui가 틀어지는 문제
- 상위 태그의 height가 auto 일 경우, height 값에 따라서 sticky가 위치를 조정함.
- height를 100%로 주어 하위 컴포넌트들이 높이 값을 상속받게 하여 해결. 📌 commit 보기
무한 스크롤 시 한번 멈춰버리면 같은 페이지 내 다른 컴포넌트에서 동작하지 않는 문제
- 무한 스크롤이 $state.complete 코드를 만나면 다음 탭에서 동작하지 않음.
- infinite-loading 태그의 :identifier 속성을 선언해서, 탭이 바뀌면 infiniteId를 변화시켜주어 다른 컴포넌트에서도 재동작 하도록 수정. 📌 commit 보기
SpringSecurity와 Swagger 문제
- Security 적용 후 Swagger가 동작하지 않아,
WebSecurityConfig
class에 ignore 처리로 해결. 📌코드 보기
Java 타입 문제
-
소수점이 붙은 String 형("12.0")의 숫자가 long 형으로 바로 변환이 안 되어 Double 타입으로 변경 후 long 타입으로 변경.
long listId = (long)Double.parseDouble(String.valueOf(requestBody.get("listId")));
- requestBody.get("listId") : Object 형
- String.valueOf : String 형으로 변환
- Double.parseDouble : Double 형으로 파싱
- (long) : long 형으로 변환
@AuthenticationPrincipal 현재 접속한 userId 가져오기
- token으로 해당 User의 ID를 자동으로 받을 수 없을까 고민했음.
- 보안상으로 클라이언트가 직접 userId를 매개변수로 하여 api를 호출하면 다른 user의 정보가 변경될 수 있으므로.
- JwtTokenProvider에 있는 getUserPk() 메서드를 static화 하여 Contorller에서 끌어다 사용하기로 했음. (Controller에서 @RequestHeader(value = "Authorization")을 통해 token을 얻고 getUserPK() 메서드로 userId를 가져오는 방식) 📌 commit 보기
- 하지만, SpringSecurity에서 제공하는 @AuthenticationPrincipal을 통해 손쉽게 가져오는 방법을 사용. 📌 commit 보기
Java 배열 요소 삭제 문제
- 배열의 요소를 삭제 해야 했음. for문을 사용하고 싶지 않고 forEach로 배열을 순회하여 작업하고 싶었음.
- 하지만 오류 문도 없이 배열의 요소가 삭제되지 않았는데, 컬렉션에서 원소 삭제하기 를 참고하여
removeIf()
메서드 사용으로 문제를 해결. 📌코드 보기
Interceptor에서 request body 사용 문제
- 프로젝트에서 권한 문제는 큰 문제였으므로, SpringSecurity의 role을 이용하여 권한을 줄 수 있을지 고민.
- 하지만 role은 각기 다른 도메인에 부여할 수 없는 것. 도메인별 Interceptor를 만들어야겠다고 생각.
- Interceptor에서 권한을 체크하기 위해 Controller로 들어오는 @ReqeustBody를 끌어와야 했다. 그러려면 HttpServletRequestWrapper 객체를 상속받아 재구현해야 했다. 참고자료 : Interceptor에서 권한 관리하기, RequestBody의 내용을 로그로 남기고 싶다.
- ReadableRequestWrapper class 생성으로 해결. 📌 코드 보기
MyBatis selectKey 문제
- 테이블의 PK는 주로 auto_increment로 설정되어 레코드가 추가될 때마다 자동으로 1씩 올라가는 구조.
- insert 후, 이 PK 값을 사용해야 될 때가 있는데 MyBatis의 selectKey 태그를 이용해 PK값을 가져와서 사용. 📌코드 보기
Gson, JsonParser 라이브러리 호환 문제
- Gradlew 명령어를 사용하여, Gradle 6.6.1 -> Gradle 4.10.2 로 변경하여 해결.
Test ID 비밀번호 변경 문제
- Spring Scheduler를 사용하여 Test ID를 만들고, 7-23시 사이에 2시간 간격으로 Test ID의 모든 데이터가 재구성되도록 만들어 놓았음.
- 하지만 누군가 Test ID의 비밀번호를 바꾸는 바람에 접속할 수 없게 되었음.
- SpringSecurity에서 제공하는 passwordEncoder의 BCrypt 방식으로 비밀번호를 저장하고 login 시 복호화하여 login 하므로 쿼리문으로 비밀번호를 원상태로 돌리는 것은 불가능함.
- 미리 만들어둔 ApplicationRunner를 구현한 class가 있었기 때문에 다시 build 후 원상복구 시킨 뒤, 방어 로직을 추가함. 📌 코드 보기
EC2 access key 노출로 ssh 접속 후, 지속적 끊김 문제
- EC2 - amazon linux 2로 인스턴스를 만들고 SpringBoot와 연동하는 도중, Github에 secret key를 노출하는 사건이 발생.
- ssh 접속이 되어도 15분 안으로 끊어지는 이슈. secret key가 노출되었다고 aws로부터 여러 개의 이메일이 와있었음.
- Git reset HEAD 를 사용하여 commit을 삭제, aws에 알렸는데도 불구하고 ssh 접속이 끊기는 현상은 없어지지 않았음.
계정 삭제 후 다시 처음부터 세팅.
이 사건으로 secret key는 반드시 ec2 내에 옮겨두고 SpringBoot로 부터 build시 ec2 내 따로 생성해둔 environment(properties) 파일을 함께 묶어 build가 되도록 함.
linux 메모리 문제
- AWS free 유저이기 때문에 SpringBoot build 시 메모리 부족으로 build가 되지 않는 문제가 발생.
- swap 파일을 생성하여 설정해서 문제를 해결.
- 참고자료 : 리눅스 메모리 부족 문제 해결 방법, AWS(EC2) - swap 메모리 생성, aws공식 swap 메모리 사용법
Mixed Content 문제
- Mixed Content는 https, http 간 통신 규약이 매칭되지 않을 때 생기는 문제.
- Frontend는 AWS-CloudFront와 AWS-Certificate Manager를 사용해 SSL이 적용되어
https
url을 갖게 되었지만, Backend는http
url 이었으므로, Backend를 https url로 변경시켜주어야 했다. let's encrypt
로 무료 SSL 인증서를 발급받고 nginx의 Reverse Proxy를 사용하여 적용.- 참고자료 : nginx와 let's encrypt로 SSL 적용하기(+자동 갱신), nginx를 활용해 AWS EC2에 https 적용하기
Build 자동화 문제(Travis)
- SpringBoot의 Build 자동화로 Travis를 사용하는데 build 에러가 남.
- AWS-RDS MySQL datasource가 SpringBoot단의 properties 파일에 있고, github 소스에 올릴 때는 해당 properties가 올라가지 않기 때문이다. (ec2에 따로 지정해둠)
- local에서는 properties가 존재하기 때문에 문제없이 build 되었지만 Github과 연동된 Travis는 Datasource가 없다며 빌드에러를 낸 것.
h2
를 적용하기로 했다. 메모리 DB인 h2는 Datasource가 존재하지 않아도 에러를 내지 않기 때문에.- gradle에 따로 h2 라이브러리를 로드 받아 build 하여 문제를 해결함.
S3 File upload 시 local 파일 저장 권한 문제
- SpringBoot에서 S3로 파일을 올릴 때 반드시 local 어딘가에 File을 저장 후 올리고 나서 지우는 작업을 하는 구조.
- mac 환경에서는 SpringBoot 폴더 내 파일이 생겼다가 지워지는데, 배포 후 linux에는 permission 문제가 생겼다.
- 따라서 SpringBoot의 properties에 환경별 path를 지정하고, @Value를 통해 디렉토리를 지정함.
- 그리고 linux 환경에서 해당 디렉토리를 만들어 chmod로 권한을 부여해 해결.