회사에서 합법적 토이프로젝트 하기

조은지
CJ 온스타일 기술 블로그
17 min readJan 29, 2024

기술블로그 사냥꾼의 보물찾기(개발편)

안녕하세요. CJ온스타일 플랫폼개발팀 회원파트 조은지입니다.

앞서 Eunji님이 작성하신 기술블로그 사냥꾼의 보물찾기에서 보셨다시피, 저희는 작년 ‘2023 DT 워크샵 DT:TIME’ 에서 ‘기술블로그 사냥꾼의 보물찾기’ 이벤트를 진행했습니다.

IT 부서에 있는 만큼, 일반적인 보물찾기처럼 종이로 상품을 찾아다니는 게 아닌 IT 요소를 결합해 우리만 할 수 있는 걸 해보자! 라는 마음으로 팀원을 모집했고..

팀즈 단톡방이 생겼어요

이렇게 기획에는 하늘님, 개발에는 Eunji님, 강원구님, 저 총 4명의 팀원이 모이게 되었습니다.

이번 글에서는 개발자의 입장에서 어떻게 보물찾기를 개발했는지 회고해보려 합니다.

저희는 3가지 목표에 중점을 두어 보물찾기를 개발했습니다.

  1. 효율적인 기술스택 선정
  2. 목적은 기술블로그 홍보
  3. 모든 온스타일 구성원이 즐길 수 있도록 만들기

첫 번째, 효율적인 기술스택 선정

보물찾기의 기획부터 디자인,개발 그리고 배포를 위해 주어진 시간은 약 4주였습니다.
하지만.. 회사에서 주어진 시간은 일주일에 한 번, 1시간 회의가 전부였기 때문에 사실상 보물찾기에 할애할 수 있는 시간은 주말밖에 없었습니다.

따라서 짧은 시간 동안 시스템을 구현하기 위해 효율적인 기술 스택을 선정하는 것이 중요했습니다.

그래서 빠르고 효율적인 개발을 위해 백엔드는 평소 업무에 사용하는 Java와 Spring, 프론트는 Vue.js를 사용했습니다.
개발 담당이였던 세명 모두 주로 백엔드를 개발하기 때문에 프론트는 프론트 프레임워크 중에서도 비교적 러닝커브가 낮은 Vue.js가 적합하다고 판단했습니다.

서버는 모든 임직원이 접근 할 수 있도록 아키텍처 팀으로부터 AWS EC2 스터디용 서버를 제공받았습니다. (감사합니다 🙇‍♂️🙇‍♂️)

DB는 AWS에서 제공하는 DynamoDB를 사용했습니다.
데이터가 간단한 구조로 이루어져 있고, 양은 많지 않지만 상품과 임직원의 상태를 조회하는 케이스가 많을 것이라고 판단해 관계형 DB보다는 NoSQL DB를 사용하는것이 적합하다고 생각했습니다.

Java 17 , Spring Boot 3.1.5, Vue 2

두 번째, 목적은 기술블로그 홍보

이번 보물찾기를 개발한 가장 큰 목적은 바로 ‘온스타일 기술블로그 홍보’ 였습니다.
따라서 보물찾기 결과 페이지만 필요한 것이 아니라 그 전 단계에 기술블로그퀴즈를 넣어 본 목적인 기술블로그를 홍보 할 수있도록 개발했습니다.

세 번째, 모든 온스타일 구성원이 즐길 수 있도록 만들기

보물찾기의 주 목적이 기술블로그 홍보이니만큼 이번 이벤트는 DT담당 구성원뿐만 아니라 모든 CJ온스타일 임직원이 즐길 수 있도록 개발하고 싶었습니니다.
따라서 저희는 1.어딘가에 저장된 임직원 정보와 이를 이용해 2.임직원 여부를 판단하는 페이지가 필요했습니다.
이 목표를 달성하기 위해 임직원 데이터를 불러오는 과정에서 이슈가 발생했는데요(..!) 이부분은 DB 구성에서 자세하게 다루도록 하겠습니다.

DB를 구성해 보자

앞서 말씀드린 세 번째 목표였던 모든 온스타일 구성원이 즐길 수 있게 만들 위해서는 ‘임직원 정보를 어떻게 불러올 것인가’ 가 제일 중요했습니다.
그래서 이미 데이터센터 내부 DB에 적재되어 있는 임직원 정보를 API로 전달받고, 그중에서도 사번 + 이름을 사용해 혹시나 실수로 사번을 잘 못 입력했을 때나 다른 사람에게 사번을 탈취당해 이용당했을때의 가능성을 줄이려고 했습니다.

하지만 여기서 이슈가 발생했습니다..!

사내 정보보안팀에게 요청한 결과 보안 문제로 인해 AWS 서버에서 데이터센터 내부 DB는 조회할 수 없다는 것과 AWS 내부 DB를 사용해 사번과 이름을 함께 저장할 경우, 특정인을 유추할 수 있어 개인정보보호법에 저촉될 수 있다는 것이었습니다.

정보보안팀 담당자님과의 팀즈1

그래서 사번 + 이름이 아닌 사번 + 다른 정보로 저장했을 때도 문제가 발생하는지 보안 쪽에 근무하는 지인에게 수소문해봤지만

타 회사 보안담당자 지인과의 카톡

사번 + @가 되는 순간 특정인을 식별할 수 있기 때문에 DB에 저장했을 때 개인정보보호법에 위반될 것이라는 답변을 받았습니다 🥲🥲

승인을 위한 하늘님의 눈물..
정보보안팀 담당자님의 말씀

그래서 결국 사번만 AWS 내부 DB인 DynamoDB에 담게 되었고, 다행히 정보보안팀의 승인을 받게 되었습니다.

DB 세팅과 쿼리는 강원구님이 맡아주셨는데요,
AWS 공식문서와 이미 사내에서 DynamoDB(이하 DDB)를 사용하고 있는 팀 코드를 참고하며 세팅과 CRUD 개발을 진행했습니다.

이때 공식문서에 파티션 키와 기본 키에 대한 정의가 명확하게 되어있지 않아 파티션 키 == 기본 키로 판단하여 테이블을 설계했지만, 실제 데이터로 CRUD를 개발하며 파티션 키와 기본 키가 구분되어 있다는 것을 인지해 나중에 테이블 구조를 전체적으로 수정하는 일이 있었습니다.

3개의 테이블

DDB에 데이터를 넣는 과정에서 가장 아쉬웠던 부분은 Bulk Insert 방식을 찾지 못해 임직원 정보 1150건을 하나씩 insert로(😱) 넣은 것입니다.
하지만 지금 다시 생각해보니 이미 프로젝트와 DDB가 연동된 상태이니 insert 전용 메소드를 만들어 데이터를 읽어오고 바로 해당 메소드를 실행했으면 굳이 고생을 하지 않아도 되었을 것 같다는 생각이 듭니다. 😭

TreasureItem 테이블

DB를 설계하며 들었던 고민은 상품을 어떤 기준으로 구분할지 PK를 정하는 것이었습니다.

QR코드에 연결된 링크를 ‘보물찾기 URL/{itemCode}’ 로 만들어 itemCode를 이용해 상품을 구분하고자 했기 때문에 이 itemCode는 사용자가 유추 불가능해야 하고, 무작위로 대입하더라도 최대한 탈취될 가능성이 적은 값이어야 했습니다.

특히 개발자 중에서 브루트포스방식으로 대입해보는 사람이 분명히 있을 것이다(!) 라는 의견이 있어 저희는 더더욱 유추할 수 없는 값이 필요했습니다.

만약 itemCode가 뚫린다면.. 굉장히 곤란해질 것이 분명했기 때문입니다 🤫

그래서 고민 끝에 UUID(v4), 즉 ‘범용 고유 식별자’ 를 사용하기로 했습니다.

UUID는 32개의 16진수로 표현되는 무작위의 난수 값이며 무작위 하게 생성되기 때문에 예측할 수 없는 값입니다.
하지만.. 이론적으로는 고유한 값이라고 해도 중복 값이 만들어지는 것이 불가능하지는 않기 때문에 걱정이 있었습니다.

그래서 서칭을 해본 결과

설마 그사이에 하늘에서 떨어지는 운석에 맞은 사람이 나타나지는 않겠죠..?

중복된 UUID를 만들 확률은 매우 매우 희박했기 때문에 최종적으로 PK는 UUID를 사용하기로 결정했습니다.

본격적인 개발 시작!

DB 설계와 필요한 데이터까지 모두 준비되었으니 이제 개발을 시작할 준비가 되었습니다.
이번에는 보물 찾기의 주 페이지들에 대해 작성해보려 합니다.

  1. 사번 확인 페이지
  2. 퀴즈 페이지
  3. 당첨 결과 페이지

사번 확인 페이지

요랬던게
요렇게 됐습니다

처음 QR 코드를 찍으면 사번 확인 페이지로 이동해 임직원 여부를 확인합니다.
이때 페이지가 렌더링 되기 전 mounted 단계에서 uuid가 올바른 값인지 검증합니다. 검증된 uuid는 Vuex store에 전역변수로 세팅해주었습니다.

await store.dispatch("fetchItemInfoAndSetUuid", uuid);

async fetchItemInfoAndSetUuid(ctx, uuid) {
const item = await getUuidInfo(uuid);
this.commit("setUuid", item);
}

setUuid(state, value) {
state.uuid = value.itemCode;
}

그리고, 지정된 보물찾기 이벤트 시간이 아닐 경우에는 페이지에 접속하더라도 더이상 진행할 수 없도록 접근불가 화면을 띄워주었습니다.

잠금화면을 제외한 앞으로 나올 퀴즈, 당첨 결과 페이지는 인증된 사용자만이 접근할 수 있기 때문에 검증된 임직원 정보를 Vuex store에 전역변수로 저장해주었습니다.

await store.dispatch("fetchAndSetEmployeeInfo", password);

async fetchAndSetEmployeeInfo(ctx, empNo) {
const employee = await getEmployeeInfo(empNo);
this.commit("setEmployeeInfo", employee);
}

setEmployeeInfo(state, value) {
state.empNo = value.empNo;
state.hasItem = !isEmpty(value.getTreasureCode) ? true : false;
}

퀴즈 페이지

사번 확인에 성공하면 앞서 말한 3가지 목표 중 두 번째 목표였던 ‘목적은 기술블로그 홍보’ 를 위해 만든 퀴즈 페이지로 넘어가게 됩니다.

편의성을 위해 한번 정답을 맞히면 다음에 다른 QR코드로 접속하더라도 퀴즈 페이지는 넘어갈 수 있도록 개발했습니다.

이 페이지도 인증된 사용자만 접근할 수 있기 때문에 컴포넌트가 마운트 되기 전, 즉 렌더링이 되기 전 전역변수에 저장된 uuid와 empNo를 확인하고 잠금화면으로 이동하게 했습니다.

beforeMount() {
if (isEmpty(this.uuid) && isEmpty(this.empNo)) {
this.$router.push("/lock");
}
},

당첨 결과 페이지

축 당첨!
꽝 이미지 by 하늘님🎨

아마도 가장 많은 분들이 보고싶어하셨던(!) 페이지인 당첨 결과 페이지는 그만큼 고민을 많이 한 페이지입니다.

특히 상품을 얼마나 주고, 한번 찾으면 끝인건지, 교환은 할 수 있는지, 찾는대로 상품을 가질 수 있는건지 등.. 에 대해 많이 고민한 것 같습니다.

그 결과 최대한 많은 구성원이 받을 수 있도록 한 사람이 가질 수 있는 상품의 개수는 최대 1개로 두되, 한번 찾으면 끝이 아니라 다른 상품을 찾았을 때 상품을 교환할 수 있도록 했습니다.

플로우 차트

현재 상품의 상태를 확인한 후, 만약 아직 아무도 받지 않은 상품이라면 접속한 사용자가 받은 상품이 있는지 확인했습니다.
이 때 받은 상품이 있다면 ‘포기하기/받기 버튼’을, 없다면 받기 버튼을 활성화했습니다.
이미 누군가가 받은 상품이라면 꽝 화면이 노출되도록 했습니다.

// empNo 유효성 체크
MemberDto member = commonService.getEmpNo(empNo);
Boolean alreadyReceived = Boolean.FALSE;
String beforeUUID = uuid;

if (member == null || member.getEmpNo() == null) {
throw new TreasureException("사번에 해당하는 임직원이 없습니다.");
}

log.info("[ResultService][checkResult][uuid : {}, empNo : {}] member : {}", uuid, empNo, member);
// 획득한 보물이 있음
if (member.getGetTreasureCode() != null && !"".equals(member.getGetTreasureCode())) {
if (member.getGetTreasureCode().equals(uuid)) {
log.info("획득한 보물과 지금 받은 보물이 같음");
} else {
// 현재 아이템 != 기존에 획득한 아이템
alreadyReceived = Boolean.TRUE;
beforeUUID = member.getGetTreasureCode();
ItemDto beforeItem = dynamoService.selectItemInfo(beforeUUID);
if (beforeItem == null || beforeItem.getItemCode() == null) {
throw new TreasureException("이전에 획득한 보물을 불러올 수 없습니다.");
}
log.info("[ResultService][checkResult][uuid : {}, empNo : {}] beforeItem : {}", uuid, empNo, beforeItem.toString());
beforeItemName = beforeItem.getItemName();
}
}

앞 페이지와 마찬가지로 임직원 인증 없이는 결과페이지를 렌더링 할 수 없으므로 create 단계에서 임직원 정보를 확인하고, 정보가 있다면 api로 상품 정보를 받아와 data가 컴포넌트 초기에 세팅될 수 있게끔 했습니다.

created() {
this.uuid = this.$store.state.uuid;
this.empNo = this.$store.state.empNo;
// console.log("uuid:" + this.uuid + " empNo:" + this.empNo);
if (this.uuid === "" || this.empNo === "") {
this.sweetAlert("error", "접근 금지", true);
} else {
this.init(this.uuid, this.empNo);
}
},

...

init(uuid, empNo) {
axios
.get(localBaseUrl + "/result/" + uuid + "?empNo=" + empNo)
.then((res) => {
if (res.status == "200") {
this.isPrize = res.data.isPrize;
this.alreadyReceived = res.data.alreadyReceived;
this.itemName = res.data.itemName;
if (this.alreadyReceived == true) {
this.beforeItemName = res.data.beforeItemName;
}
...생략

그런데 이렇게 결과 페이지까지 만들고 나니 왠지 모를 허전함이 느껴졌습니다.

초기 당첨 결과 화면

당첨이 된건지 만건지 멀리서 보면 잘 모를거같은 이 화면..

그래서 당첨이 되었을 때의 효과를 더 극대화 시키기 위해 ‘vue-confetti’ 라는 vue 컴포넌트를 사용해 팡파레 효과를 추가해주었습니다.

이때, 상품 수령 페이지를 벗어났을 때도 팡파레 효과가 유지되는 이슈가 있어 watch를 이용해 만약 페이지가 변경되었을 때, 현재 페이지와 변경된 페이지의 경로가 다르면 팡파레를 중단할 수 있도록 로직을 추가했습니다.

watch: {
$route(to, from) {
if (to.path != from.path) {
this.$confetti.stop();
}
},
},

Docker 배포

배포는 직접 파일을 올려서 실행시키자 vs docker를 사용하자 등 다양한 의견이 있었습니다.

그 중 저희는 자체 QA를 진행하며 코드가 자주 수정될 것으로 판단해 소스코드가 수정되더라도 이미지만 교체하면 되고, API 서버와 Front 서버처럼 여러 대의 서버를 띄울 수 있는 docker를 사용했습니다.
또, EC2 인스턴스를 사용할 예정이었기 때문에 Docker Hub 대신 AWS ECR를 사용했습니다.

출처: docker 공식 홈페이지

인스턴스 IP 접근 문제로 살짝 헤매긴 했지만, 아키텍처 팀 JeonYB님의 도움으로 무사히 배포를 마칠 수 있었습니다. (다시 한번 감사드립니다 🙇‍♂️🙇‍♂️)

배포까지 완료되었으니 이제 정말 다 끝난걸까요..!

QA도 놓칠 수 없어요

열심히 QA하기

마지막으로 최대한 결함이 없는 시스템을 만들기 위해 자체 QA를 진행했습니다. 테스트 케이스들을 적어 정리해보니 생각보다 체크할 부분이 많아 살짝 걱정했지만, 다행히 모두의 노력 끝에 QA까지 잘 마무리 되었습니다.

나머지 4개는 어디에 있던 상품이었을까요?

이렇게 보물찾기 개발을 완료하고, 이벤트까지 무사히 마무리되었습니다.

이벤트가 열리고 나서 몇 가지 이슈(서버 다운, 다른 사람이 찾은 보물 훔치기(feat. 보안성검토의 필요성))가 있었는데요, 미처 생각하지 못했던 부분이라 이번 기회로 배울 수 있어 다행이라고 느꼈고 앞으로 더 꼼꼼하게 개발해야겠다고 생각했습니다. 만약 다음에 보물찾기를 또 만들 기회가 있다면 이 부분들을 보완해서 더 좋은 시스템을 개발해보고 싶습니다.

꿀맛같은 회식과 방탈출✌️(feat. 하늘님, Eunji님, 강원구님)

마지막으로 입사 후 피곤하다는 핑계로 토이프로젝트를 하지 못했었는데 보물찾기 덕분에 토이프로젝트도 진행하고 함께한 하늘님, Eunji님, 강원구님과도 친해질 수(?) 있어 여러모로 잘 참여했다는 생각이 든 프로젝트였습니다.
또, 많은 분들이 관심을 가져주시고 참여해주어 굉장히 뿌듯하고 보람찼습니다.

앞으로도 온스타일에서 만들어 갈 다양한 개발에 대해 많은 관심 부탁드리며
이상 회사에서 합법적 토이프로젝트 하기 — 기술블로그 사냥꾼의 보물찾기(개발편)은 마치도록 하겠습니다.

감사합니다!

--

--