# 정보
- 일시 : 2023년 06.01 ~ 08.25
- 부서 : 현대백화점 기획조정본부 미래전략팀
- 인원 : 3명
- 진행 프로젝트 : ALL-FIT (얼핏)
- 프로젝트 요약 : 비대면 수선 플랫폼을 위한 MVP모델 기획/개발 및 "세탁특공대"와 협업을 통해 사내 50명 체험단을 선정하여 실서비스 운영 및 결과보고
- 역할 : 백엔드, 배포, 기획, 인터뷰, 운영
- 개발 사항 : 수선업자용, 일반유저용, 크리에이터용, 어드민용
# 시작하기 앞서
대기업에서 인턴경험을 해볼 수 있는 좋은 기회를 얻었다. 오는기회를 마다하는 성격은 아니다. 인턴생활을 결정해야할 무렵, KB국민은행 채용프로세스가 함께 진행중이었다. 그때는 경험삼아 넣었던 서류가 면접까지 갈 줄 몰랐다. 당연히 커트당할걸 예상했었기에, 고민하다가 현대백화점에서 인턴생활을 하기로 했다!
노비를 해도 대감집에서 하라는 말이 있듯이, 인턴을 해도 대감집에서 인턴을 하는 경험은 좋은 경험이 되는것이 분명하다. 사옥이 삼성역 나오자말자 있어서 걸을 필요도 없다. 건물도 매우 깔끔하고 쾌적하고 좋다. 밥이 매우매우 맛있다. 현대그린푸드에서 제공하는데 본사 밥은 정말 말도 안되게 맛있다. 엘베도 빠르다. 무엇보다 우리가 진행하는 프로젝트에 있어서 금전적인 지원을 아낌없이 해주셨다. 3인으로 구성된 우리팀은 미래전략실에 속해 있지만, 엄청난 자율성을 주셨다. 업무에 필요한 맥북(16인치M1)도 지급해주시고 넓은자리와 듀얼모니터도 지원해주셨다. 한달에 1주일씩은 3층에 위치한 사내카페에서 임직원 대상으로 무료행사도 진행해서 음료도 먹을 수 있었다. 중간중간 2층에 위치한 회의실 예약잡고 회의도 진행할 수 있었다.
대기업인 만큼 누릴 수 있는게 많았고 이외에도 많은 복지들이 존재했다. 이런걸 보면서 동기부여가 됐던 것 같다. 더욱 큰 회사에서 제공하는 정말 다양한 복지들을 누릴 수 있는 그러한 삶. 누가뭐라해도 나는 워라벨 중요시 여기는 MZ인거 같았다. 무튼간 이런 여러한 시설에 대해서 상당히 만족하면서 출근을 할 수 있었다. 강조하지만 밥이 정말 맛있다.(밖에서 사먹으면 15,000원은 족히 넘을 퀄리티의 밥들이 매일 제공된다.)
서론은 여기까지만 쓰고 이제부터 여기서 내가 무엇을 했으며 무엇을 깨달았는지에 대해서 천천히 나열해보겠다.
# 무엇을 했는가?
우리는 3명(FE,BE,디자인&PM)의 인턴으로 구성된 팀이었다. 목적은 수선을 비대면 플랫폼화 시키기 위한 MVP모델을 개발하는 것이었다. 이를 위해서 6월 재택으로 근무하던 시절에 기획을 이어나갔다. 이시기에 실제 비대면 수선 플랫폼을 진행하고 있는 "런드리고"와 "세탁특공대"를 직접 사용해보고 어떠한 점이 불편한지, 어떠한점이 좋은지에 대해서 직접 경험해보고 개선점을 앱에 담으려고 했다. 나는 "세탁특공대"를 이용했었는데, 사실 수선을 비대면으로 맡긴다는 것 자체에 매우 부정적인 생각이 가득했었다.
## 실제 경쟁서비스 업체를 이용해보다.
마침 슬랙스 기장수선을 할 필요가 있었고, 이를 위해서 기장 수선을 의뢰했다. 수선에 대한 요청사항을 위해서 내가 업로드한 이미지에 특정 부분을 클릭하면 O(빨간원)가 마킹된다. 그리고 하단에 설명을 적을 수 있었다. 집에 자가 없었기 때문에 내가 자르기를 원하는 만큼 접어서 수선을 의뢰했었다. 왜냐하면 cm로 길이를 표기한다고해도, 어디에서부터 어떻게 재는지에 따라서 오차가 발생할 여지가 있기 때문에 확실히 하고 싶어서 저렇게 했다.
세탁특공대는 사실 처음들어보는 기업이었고, 집앞에 두면 수거해간다고 하지만 처음에는 택배사가 와서 수거해가는 줄 알았다. 그래서 꽤 긴 배송기간이 소요될거라고 생각했는데, 쿠팡처럼 전문 배달인력이 있는듯했다. 주말에도 문제없이 23:00 ~ 넘어서부터 수거가 진행됐다.
어찌저찌 상품이 정상적으로 전달되고 난 후, 견적서가 도착하면 결제를 하면 수선이 진행된다. 이후 다시 집으로 배송해준다. 모든 과정이 카카오톡으로 알림이 전송됐기에 꽤 편리하고 유용하게 사용 할 수 있었다. 무엇보다, 수선된 상품의 세탁까지 함께하여 배송해주기 때문에 만족스러웠다.
어플 사용하기전에는 이런 서비스에 매우 부정적인 의견이었으나, 배송비 걱정만 없고 복잡한 수선이 아니라면 매우 편리하고 이용해볼만한 서비스라는 생각이 들었다. 대부분의 사람이 그렇겠지만, 개발을 할때 뭔가 내가 긍정적으로 생각하는 개발모델일수록 더욱 열심히 개발을 할 것이다. 난 이때 이 관점이 전환되면서 관심가지고 재밌게 개발을 시작해볼 수 있는 계기가 됐다.
## 얼핏 모델에는 어떤걸 어떻게 적용했을까?
### 핀(feat. 쿼리고민하기)
세탁특공대에서는 위처럼 마킹을하고 해당 부분에 대한 설명을 진행한다. 우리는 이부분을 조금 개선하기 위해서 옷에 핀을 찍고 해당 핀에 바로 설명을 적을 수 있도록 기획했다.
이러한 요구사항의 존재로 [주문(orders)]라는 엔티티를 먼저 만들고 [수선_이미지(mending_img)]라는 엔티티와 1:N관계를 만들어 줬다. 그다음에 [수선_이미지(mending_img)]에 [수선_정보핀[order_mending_info]와 1:N관계를 만들도록 했다. 후술하겠지만, 수선을 맡길때 크리에이터가 올린 사진을 레퍼런스참조하여 수선을 의뢰할 수 있는데 이러한 요구사항의 반영으로 [주문(orders)]엔티티에서 게시글을 참조하고 있는지(True/False)와 참조한다면 참조 게시글의 FK를 가지고 있도록 했다.
여태까지 진행했던 프로젝트에서 대부분 업로드시 ERD가 설계된다면 크게 벗어나거나 수정해야할 일이 생기지는 않았다. 왜냐하면 DB 스키마 자체도 그렇게 비대하지 않았기 때문이다. 그러나 이번 프로젝트에서는 초기 설계했던 ERD를 빈번하게 수정하는 일이 너무나도 많았다. 왜냐하면 짧은 시간동안의 개발이었기 때문에 기획이 탄탄하지 않았고, 개발하면서 기획과 요구사항이 계속해서 변화했다.
가장먼저 마주했던 수정사항은 썸네일 이미지였다. 사실 초기 기획단계에서 이걸 최상위 부모 엔티티인 [주문(orders)]엔티티에 칼럼을 만들어야하는가 였다. 그때 당시에는 그냥 mending_img중에서 첫번째가 됐든 랜덤이 됐든 하나만 보여주면 되겠지라는 생각에 따로 뺴놓지 않았다.
그러나 위처럼 주문정보만 불러와야하는 경우가 빈번했고, 그러한 작업에서 N개의 [수선_이미지mending_img]엔티티를 다시 불러오는 쿼리를 사용하는 것 자체가 너무 비효율적이라는 생각을 했다. 특히 나는 Spring-data-jpa를 사용했고 해당 로직에서는 LAZY전략을 사용해 FETCH시켰기 때문이다. 그렇기떄문에 이미지를 불러오기 위해서 N번의 쿼리가 또 나가게 된다. 주문이 10개이면서 각 주문마다 7장의 사진이 가득차있다면 아마 70개 쿼리호출이 일어날것이기 때문에 이러한 성능저하를 방지하고자 [주문(orders)]엔티티에 썸네일 이미지를 저장시키도록 분리했다.
이렇게 이번 개발을 하면서는 이전에 무차별적으로 JPA를 사용해서 연관관계를 불러오는 행위를 지양하고, 조금 더 효율적이게 불러올 수 있는 방법들에 대해서 고민했다. 옛날이었으면 JPA로 모든 값들 조회해서 코드로 처리해야했던 것들에 대해서 JPA의 @Query를 이용해서 직접 쿼리를 작성하기도 하면서 효율성을 높이기 위해서 고민했다. 이렇게 생각한 가장 큰 이유중 하나는 우리 서비스를 실제 50명의 테스트유저에게 테스팅 시켜야함과 동시에 약 200만원이 넘어가는 실제 수선프로세스가 진행되기 때문이다.
// 게시글(Post)를 위한 레포지토리
public interface PostRepository extends JpaRepository<Post,Long> {
// ...
@Query("SELECT DISTINCT p FROM Post p JOIN p.postImgs pi JOIN pi.mendingInfos mi ON mi.mendingType = :mendingType")
Page<Post> findAllByMendingType(Pageable pageable, MendingType mendingType);
@Query("SELECT DISTINCT p FROM Post p JOIN p.postImgs pi JOIN pi.mendingInfos mi ON mi.clothType = :clothType")
Page<Post> findByALLClothType(Pageable pageable, ClothType clothType);
@Query("SELECT DISTINCT p " +
"FROM Post p " +
"LEFT JOIN p.likes pl " +
"GROUP BY p.id " +
"ORDER BY count(pl) DESC ")
// ...
}
실제로 위처럼 수선 종류, 옷의 타입, 게시글의 좋아요 순에 따라서 게시글을 조회가 필요할때는 저렇게 쿼리 한방으로 조회가 가능하도록 했다. 게시글도 여러 엔티티들로 이루어져있기 때문에 일반적으로 쿼리를 작성했다면 N+1문제와 같이 쓸데 없이 많은 쿼리들이 나갈것이 분명했기 때문이다. 그렇다고 EAGER로 FETCH전략을 바꿀수도 없는게, 특정 조회기능에서만 다 불러와서 확인을 해야하기 때문이다. 특히 무차별적으로 EAGER를 남발하면 성능저하의 가장 큰 이유라고 들었기 때문이다.
핀정보를 효율적이게 불러오고 저장시키기 위해서 고민했던 첫 디딤발이, 프로젝트 전반적으로 JPA와 DB쿼리에 대해서 고민해보는 계기가 되기도 했으며, 실제 JPA가 어떤식으로 값을 불러오는지 1차캐시 개념에 대해서도 다시금 공부할 수 있었다.
### 중개 플랫폼 ( like 헤이딜러 )
세탁특공대나 런드리고는 플랫폼이라기보다는 자사 서비스를 제공하는 형태이다. 그렇기 때문에 수선을 요청하면 자사 담당자들이 검수하고 수선을 진행하는 형태이다. 우리는 조금 차별점을 두고 싶었고 실제 성공한 헤이딜러의 모델을 벤치마킹하려고 했다. 대부분의 사람들은 수선을 자주이용하지는 않을 것이다. 그렇기 때문에 수선에 대한 가격도 잘 모를 뿐더러 어떤게 합리적인 가격인지 모르는게 대부분이다. 그래서 우리는 수선전에 사진을 통해 상품에 대한 견적을 받아볼 수 있도록 앱을 기획하고 개발했다.
그리고 이 상태에서 수선요청하기 또는 수선거절하기를 통해서 실제 수선프로세스를 진행할지에 대한 여부를 판가름했다. 만일 수선을 요청한다면 해당업체와의 거래가 체결되고 상품이 수거대기 상태로 변경되며 집앞에 놓아두면 수거를 진행한다.
수선업자의 입장에서는 위처럼 주문을 조회하고 상품의 상세 이미지 및 유저가 작성한 핀정보를 토대로 예상가격, 예상기간, 전문가 의견을 작성하도록 했다. 견적이 작성된다면 맨 위에서 보는거와 같이 유저에게 FCM을 통한 알림이 통보된다. 예상기간을 산정할때는 택배 수거+배송의 단계의 보정값이 필요했다. 우리 MVP모델은 세탁특공대와 협업 결정이 늦게 나서 세특 API를 통한 공유가 불가능했고 배송일을 빡세게 관리할 수 없었다. 배송일이 유저에게 다가오는 가장 큰 부분이라는 점을 알았고 우리는 이에 대한 보정값을 주기로 했다. 빡세게 잡으면 늦으면 컴플레인이 생길게 분명했기 때문에 3일의 여유기간을 잡아 예상기간이 산정되도록 했다.
// .properties
# --- DeliveryExpectedCalibrationValue --- #
order.delivery.expectedCalibration = 3
견적이 최종 체결된다면, 예상 기간 + 배송보정일(3일)이 추가되어 예상 기간이 최종 산정된다. 그리고 이를 properties에서 일괄적으로 관리하도록 작성했다. 이렇게 되면 런타임에서 수정할 수는 없지만 , MVP모델에서는 별다른 문제가 되지 않을 거 같기 때문에 위처럼 적용했다.
해당과정을 개발하면서 도메인 지식의 중요성을 느꼈다. 세탁특공대 PO님과 미팅하는 시간을 가졌는데, 우리 앱에는 반영되지 않았던 중요한 두가지를 전달해주셨다.
- 공동현관 비밀번호 또는 공동현관을 출입하는 방법
- 실제 입고 후 실물 확인 후 최종 가격을 결정
처음에는 집주소를 입력할때 공동현관 비밀번호를 잊고 있었다. 근데 이게 수거-배송과정에서 가장 많은 문제가 발생하는 것이라고했다. 공동현관 출입이 불가하거나 비밀번호가 맞지 않아서 출입하지 못하는 경우가 빈번하다고 했다. 실제로 우리 서비스를 시작했을때에도 간혹가다가 외부인은 호출을 통해서만 들어갈 수 있는 환경에 계신분이 있었고 두번에 걸쳐 방문했으나 수거하지 못했었다. 공동현관 비밀번호를 추가하지 않았더라면 정말 극악무도했을 것이다.
우리는 앱의 견적서 사진상으로만 수선의 가격을 결정하도록 했었다. 그러나 실제 수선품목의 경우 제품의 질, 세탁상태, 관리상태 등등 수만가지의 이유로 하여금 가격 변동이 일어난다고 하셨다. 이전에 현대백화점 무역센터점을 방문해서 수선업자님을 인터뷰하는 시간을 가졌었는데 그때에도 명품수선의 경우는 천차만별이라고 말씀하신것이 머릿속을 스쳐지나갔다. 이후 부랴부랴 수선단계를 담당하는 상태에서 ESTIMATE_REAL_PRICE상태를 중간에 추가했고 수선업자분이 제품을 받아보고 실물 상태를 확인 후 최종가격을 입력하는 단계로 변경했다. 이 과정에서 수선 불가처리를 진행 할수도 있고 이런건 유선상으로 전문가분이 연락을 드린 후에 반송조치를 하도록했다.(실제 테스팅 기간중에 해당 케이스가 발생했다.)
4학년1학기에 소프트웨어공학수업을 수강하면서 수도없이 도메인이랑 단어를 들었고, 도메인 모델링이 뭔지 등등 소프트웨어 공학적인 요소를 책으로만 배웠었다. 고객의 요구사항을 왜 잘 정의해야하며 실제 프로젝트에서는 어떠한 제약사항들이 발생하고 이해관계자(StakeHolder)들이 얽혔을때는 어떤한 어려움이 생기는지에 대해서 말이다.
이번 얼핏(ALl-Fit)플랫폼을 기획하고 개발하면서 이러한 점들을 많이 느낄 수 있었다. 새로운것을 넣어보려는 MZ인턴들과 이를 상부에 보고해야하는 선임님, 테스팅기간동안 제휴를 맺은 세탁특공대, 프로젝트를 내려주신 상무님 등등 많은 이해관계자들이 추구하는 다양한 목적에 부합해야 했기 때문에 이를 잘 조율해나가는 과정또한 쉽지많은 않았다. 더불어 제한된 시간과 제한된 인력에서 빠르게 무엇인가를 결과물을 만들어야 했기때문에 책임감 없이는 수행할 수 없었던 작업들이었다. 이러한 과정을 중개플랫폼으로 기획하면서 더욱 많은 마찰들이 생겼었다.
여차저차 필수적으로 구현해야하는 것들 테스팅을 진행할때 가장 필요한것들에 대한 우선순위를 정하고 해당 기능들에 대해서 잘 적용할 수는 있었다. 나름 변화에 유연한 소프트웨어를 만들기 위해서 노력했고 하드코딩보다는 DTO같은것들을 최대한 정의하고 도메인에 로직을 정의하도록 노력했었던 결과라고 생각한다.
이파트에서 가장 크게 느낀건 개발자가 개발해야하는 분야에 대한 도메인지식(Domain-Knowledge)을 이해하고 소비자, 관리자의 입장에서 생각해보는 것이 매우 중요하다는 생각이 들었다. 결국 내가 '개발하기 편리한 소프트웨어'보다는 '과연 나라면 이렇게 서비스를 사용하면 편할까?'라는 물음에 대한 대답을 먼저 생각해야한다는 것이고 결국 이러한 발상의 전환은 추후 운영에 있을때에도 운영을 조금 더 쉽게 만들어주는 역할을 하는 것 같았다.
### 인플루언서 큐레이션을 통한 리폼 & 수선 활성화
게시글 요구사항
- 댓글 및 대댓글 기능
- 게시글 좋아요 기능
- 인기 게시글
- 작성자가 작성했던 게시글들 중 유사한 게시글
우리 어플리케이션이 점점 슈퍼앱이 되어가는 것 같다는 생각이 들었다. 수선주문외에도 인플루언서가 수선게시글을 작성하고 이에대해서 유저들이 댓글을 달 수 있는 게시판기능이 들어간다. 취지는 수선에 대한 인사이트를 넓혀주고 수선문화를 활성화 시키기 위함이다. 크게보면 유저들이 수선 스타일을 공유할 수 있는 하나의 플랫폼이 될수도 있다.( 무신사처럼 )
클라이언트에서 무한스크롤을 지원하기 때문에 페이지네이션을 지원해야한다. 여기에 카테고리별로 지원이 가능하도록 해야했기 때문에 이를 분리해야했다.
/*
썸네일 포스트를 10개로 지정해서 보여주는 페이지네이션 API
*/
@GetMapping("/posts")
public ResponseEntity<?> getPostsUsingPages(@RequestParam Integer page,
HttpServletRequest request,
@RequestParam(name = "filter",required = false) CategoryFilterType categoryFilterType,
@RequestParam(name = "category",required = false) String category){
PostPagingThumbnailResoponseDto postsThumbnail = postService.getPostsThumbnail(categoryFilterType, category, page);
log.info("[{}] 게시글의 {}페이지 리스트의 {}필터와 {}카테고리를 조회합니다.",request.getRequestURI(),page,categoryFilterType,category);
return ResponseEntity.ok()
.body(postsThumbnail);
}
// 카테고리필터타입(대주제) ENUM
@Getter
public enum CategoryFilterType
{
CLOTH, MENDING, STYLE,NULL,LIKE
}
위처럼 큰 필터 대주제로 옷,수선,스타일,좋아요순, 미지정을 구분시켰다. 그다음에 switch문으로 하여금 소분류에 맞는 결과를 가져오게 했다.
try {
/*
NULL처리를 3항연산자를 활용하여 NULL enum을 사용해 해결함
switch-case문은 null을 지원하지 않기 때문에
*/
switch (categoryFilterType != null ? categoryFilterType : CategoryFilterType.NULL) {
case NULL :
page = postRepository.findAll(paging); // 최신순으로 정렬합니다.
break;
case CLOTH:
page = postRepository.findByALLClothType(paging,ClothType.valueOf(category)); // ClothType에 맞는 것들을 선정합니다.
break;
case STYLE:
page = postRepository.findAllByStyleType(paging, StyleType.valueOf(category)); // StyleType에 맞는 것들을 선정합니다.
break;
case MENDING:
page = postRepository.findAllByMendingType(paging, MendingType.valueOf(category)); // MendingType에 맞는 것들을 선정합니다.
break;
case LIKE :
page = postRepository.findAllByLikeOrderByCountLike(PageRequest.of(curPage, pagingPerSize)); // 좋아요에 따라서 정렬합니다.
break;
default:
// 잘못된 요청일경우 그냥 기본 페이지 반환
throw new CategoryUnDefinitionException("카테고리에 대해서 잘못된 요청이 들어왔습니다.");
}
} catch (RuntimeException ex) {
throw new CategoryUnDefinitionException("카테고리 요청이 잘못 됐습니다.");
}
이때 개발 당시에도 고민했던 것 중 하나가 소분류에 대해서 string으로 처리할 것인지, enum으로 만들어 처리할 것인지였다.
@RequestParam(name = "category",required = false) String category
결과적으로는 String으로 처리했는데 그때 그당시의 근거는 그렇다면 새롭게 만들 category ENUM에 대해서는 CLOTH, MENDING, STYLE, LIKE, NULL에 대한 모든 값을 포함해야 하기 때문이었다.
지금시점에서 되돌아본다면 categoy ENUM을 새롭게 만들고 모든 종류를 담는것이 유지보수측면에서 훨씬 뛰어난것 같다. 회고글을 작성하는 시점에서 해단 category에 무슨 조건들이 검색되는지 알기 위해서는 서비스 로직에서 분기되는 모든 ENUM.valueOf()를 뜯어봐야지 알 수 있다. 내가 만약 이코드를 유지보수해야한다면 상당한 악취가 나는 코드임이 분명하다. 이번 프로젝트를 하면서 크게 느낀 것 하나는 하드코딩을 자제하고 ENUM이나 DTO처럼 클래스 만들어 관리해야 추후 수정시 다른 코드들을 건들일이 적어지고 후임자가 읽기에도 좋은코드일 것이라는 것을 절실히 느꼈다.
그다음으로는 게시글 좋아요기능을 추가했다. 게시글 좋아요를 추가하면서는 별다른 어려움은 없었다.
이후 댓글&대댓글 기능을 개발했다. 무한대댓글을 가능하게 할지, 에브리타임처럼 댓글과 대댓글기능만 가능하게 할지 고민했었다. 보통 댓글을 보면 무한대댓글보다는 댓글-답글 형식만 지원하기때문에 이방법을 채택하기로 했다. 그래서 처음에는 COMMENT엔티티와 RECOMMENT엔티티 각각을 만들고 1:N관계를 형성해주려고 했다. 그러나 코드를 작성하면서 똑같은 엔티티 두개를 만드는것 같다는 생각과 함께 비효율적이라는 생각이 문득 들었고, 추후 무한 대댓글을 구현해야할때를 대비해 무한대댓글이 가능하도록 설계해보려 했다. 어차피 대댓글이 안달리게 클라이언트단에서 막으면 큰 문제는 없을거 같기 때문이다.
그래서 찾은 방법은 컴포지트 패턴을 이용하는 방법이었다. 디자인패턴 강의를 수강할때 공부했던 것중 하나였다(블로그에도 정리했었다). 여튼 그래서 컴포지트 패턴으로 구성했다.
@Table(name = "COMMENT")
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long id;
/*
글쓴이
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="writer_id")
private User writer;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@Column(name = "content")
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;
/*
대댓글
*/
@OneToMany(mappedBy = "parent")
@Builder.Default
private List<Comment> recomments = new ArrayList<>();
/*
현재 댓글이 최상위 댓글인지 대댓글인지 판단하는 도메인 로직
만일 true를 리턴할때 RootComment이다.
*/
public boolean isRootComment() {
if (this.parent == null) return true;
return false;
}
}
코드도 간단했다. 또한 도메인 로직에 현재 노드가 루트인지를 판단하게 하여 답글인지 댓글인지 여부를 쉽게 가릴 수 있도록 했다.
마지막으로 인기 게시글과 유저가 작성했던 게시글을 구현하면서 조금 수정사항이 발생했다. 게시글에서 보여지는 인기게시글 또는 작성자가 작성한 비슷한 게시글을 나름의 추천형식과 비슷하게 보여주어야 했는데, 이럴경우에는 Before이미지와 After이미지가 존재해야 했다. 초기 게시글에서는 썸네일만 뽑았엇지, Before와 After를 따로 관리하지 않았기 때문에 게시글 이미지를 저장할때 Before와 After ENUM을 통해 태그(?)를 달아 디비에 저장했다. 이후 Before에서 첫번째 After에서 첫번째를 뽑아와서 DTO에 담아 Response를 해주는 방식으로 변경했다.
위 구현방식이 비효율적이라고 생각한다. 실제 현업에서는 어떻게 처리하는지 매우 궁금했다. 게시글을 DB에서 다 뒤져서 인기순으로 내림차순 정렬해서 위에서 5개를 뽑는 식으로 했다. 이것또한 N+1쿼리 문제를 방지하고자 따로 Qurey를 만들어 뽑아오도록 했다. 무엇인가를 찾기 위해서 DB를 다 뒤지는 행위가 과연 효율적인 행위일까? 근데 또 결국엔 완전탐색을 해야하니 그렇게 하지 않고서는 찾을 수 없을 것이다... 아무튼 우리 프로젝트에서는 큐레이션용으로 게시글을 어드민에서 30개 남짓 생성하니 별다른 문제가 없을 것이라 생각해서 마무리 지었다.
## 개발자로써
지금까지 우리가 원하는 서비스를 기획/개발하기 위해서 내가 겪었던 과정과 생각들을 정리해봤다. 이제는 개발자로써 내가 이번 프로젝트에서 적용해보고 싶었던 것들에 대해서 나열해보겠다.
### DDD를 반영하고 싶었다.
이전 캡스톤 프로젝트가 끝나고 내 코드들을 뒤돌아보면서 서비스계층로직에 모든 코드를 때려박고 있었다. 당연히 도메인(엔티티)는 DB를 TABLE을 코드로 나타내기 위한 하나의 수단에 불가한 역할을 했다. 그러니 비효율적이고 해당 엔티티의 역할이 불명확해졌다. 재사용성을 활용하기도 애매했다. 서비스로직에 구현하니 다른 서비스에서 해당 서비스의 로직을 재활용하기에도 애매했다. 그래서 DDD라는 것에 관심을 가졌고 책을 끝까지 읽지는 못했지만 참고해가면서 도메인에 나름의 역할을 부여했다. 예를 들어 아래와 같이 도메인을 정의하고
@Entity
@Table(name = "ORDERS")
public class Order extends BaseTimeEntity {
@Id
@Column(name="orders_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... 칼럼들 생략
public void addMending(Mending mending){}
private String extractTitleClothTypeAndCount()
public void changeStatePendingToPreparing() {}
public void changeOrderMemo(OrderMemoChangeRequestDto dto) {}
private boolean isStateInbound(OrderState state) {}
// ... 생략
}
이게 맞는지는 모르겠지만 도메인에 특정 도메인이 수행해야하는 로직들을 수행하도록했다. 그리고 서비스 로직에서는
myOrder.changeOrderMemo()
를 수행하도록 했다. 그렇게 함으로써 도메인이 무슨 기능을 수행해야하는지 알 수 있었으며 내부적으로 상태를 변경할때에 대한 제약사항을 코드안에 정의함으로써 불가능하다면 에러를 뱉도록 하던가 핸들링을 해줬다.
엄청나게 짜임새 있던 DDD는 아니었지만 도메인을 어떻게 활용하고 도메인 로직을 어떻게 작성해야하는지에 대해서 고민해볼 수 있었다.
실제 배송과 수거과 이루어지는 주문과정을 처리하는건 쉽지는 않았다. 물론 현업에 계신분들이 보면 비웃을정도로 이게 돌아가? 하겠지만(돌아가는 가고 테스팅도 끝내긴했다..ㅎㅎ). 견적서 작성, 수선업자가 수락/거절, 배송, 실가격 측정, 수선, 배송, 완료와 같은 상태를 관리해야했다.
가장 아쉬운건 위의 상태의 과정을 State-Pattern을 이용해서 보다 쉽게 관리하고 싶었지만, 요구사항이 게속해서 바뀌고 개발기간도 짧았기 때문에 해당 내용들을 공부하기 코드로 옮길 자신이 없었다. 그래서 상태들을 조금은 Hard코딩 스타일로 관리한게 이번 프로젝트에서 가장 아쉬운 부분이다.
### 에러핸들링
이번 프로젝트에서는 나름 에러핸들링도 빡세게 해보려고 노력했다. CustomException을 구현하고 에러시 에러를 처리하거나 관련된 Response를 하도록 했다. 각 도메인의 에러핸들러를 정의하고 아래와 같이 throw한 커스텀 에러를 잡도록했다. 이렇게 에러를 핸들링하다보니 어디서 어떤 에러가 났는지 내가보기가 너무 편리해서 좋았다. 조금은 귀찮지만은 반드시 수행해야하는 것이라는 생각이 들었다.
@RestControllerAdvice
public class OrderExceptionHandler {
/*
Order를 조회할 수 없을 시
*/
@ExceptionHandler(UnFindOrderException.class)
public ResponseEntity<?> handleUnFindOrderException(UnFindOrderException ex){
log.error("[OrderExceptionHandler] 해당 Order를 조회할 수 없습니다. \n : ", ex);
return ResponseEntity.status(ex.getCode())
.body(ErrorResponse.builder()
.status(ex.getCode())
.message(ex.getMessage())
.build());
}
}
물론 이렇게 @RestControllerAdvice를 활용해서 전역에서 잡는것이 좋은지는 모르겠지만, 이번 프로젝트를 진행하면서 별다른 문제는 없었던 것 같다.
아마 이렇게 핸들링헀을때 시큐리티를 담당하는 서블릿을 넘지 못하는 곳에서 발생하는 에러는 잡히지 않았다. 어찌보면 너무나도 당연한 것이었고 관련되서 공부해보고 AuthenticationEntryPoint를 상속하는 CustomAuthenticationEntryPoint를 상속하여 에러를 핸들링 하도록 헀다.
### JWT토큰과 ROLE에 따른 접근권한
캡스톤 프로젝트 복실이에서 JWT를 사용했던 경험을 살려 이번에도 JWT를 이용해서 로그인을 진행했다. 다만 카카오톡을 활용하지는 않고 우리 서비스상에서 정보들을 관리하도록 했다. 이전에 반쪽짜리 JWT라면 이번에는 90%짜리 JWT였다고 생각한다. 10%을 제외한 이유는 Redis를 사용하여 블랙리스트로 토큰 만료 여부를 가리지 않았기 때문에 10%을 제외했다.
스프링 시큐리티를 사용하였고 실제 유저 비밀번호가 DB에 저장될때도 암호화 했다. 그래서 아무리 어드민이라고 해도 디비만 보고 비밀번호 유추를 할 수 없도록 했다. 사용자 경험을 위해서 액세스토큰과 리프래쉬 토큰을 도입하여, 실제 액세스토큰이 만료되더라도 리프래쉬토큰으로 다시 토큰을 발급하여 사용할 수 있도록 했다. 당연하게도 리프래쉬 토큰은 유저DB에서 관리시키도록 했으며, 상황에 따라서 계속 업데이트 되도록 로직을 짰다.
이러한 과정을 이루기 위해서 SecurityConfig에서 커스텀Filter를 구성시켰다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.formLogin().disable()
.httpBasic().disable()
// .oauth2Login().disable() // 아마도 oauth2Login() 빈이 없는데 사용해서 오류나는듯
.csrf().disable()
.headers().frameOptions().disable()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안함
.and()
.authorizeRequests() // URL 별 권한 관리 옵션 설정 //
.antMatchers("생략").permitAll() // 스웨거 페이지에 대한 접근 권한 설정
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterAfter(customUsernamePasswordAuthenticationFilter(), LogoutFilter.class)
.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomUsernamePasswordAuthenticationFilter.class)
.build();
}
jwtAuthenticationProcessingFilter에서는 아래와 같이 URL요청에 따라 회원가입, 로그인을 담당할때는 해당 필터를 거치지 않도록 했고 그러한 요청이 아니라면 JWT을 검증하도록 했다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.debug(request.getRequestURI());
if (request.getRequestURI().startsWith(NO_CHECK_URL) || request.getRequestURI().startsWith(ADMIN_URL)
|| request.getRequestURI().startsWith(RESOURCE_STATIC_URL)) {
filterChain.doFilter(request, response);
return; // 유저 회원가입,로그인을 담당하는 맵핑일때는 여기를 거치지 않도록 한다.
}
}
로그인시에는 로그인 실패와 성공 핸들러를 각각정의하고 이에따라 리프래쉬 토큰과 알림전송을 위한 디바이스의 FCM을 등록하도록 했다.
## 운영단계를 경험해보면서
실제 운영단계를 경험해보는 것은 엄청나게 소중한 경험임이 틀림없다. 특히 아직 대학생인 나에게는 이러한 과정이 매우 특별한 기회였다. 우리끼리 코드를 치고 끝내는 것 까지는 많이 해봤지만 실사용자가 50명을 강제할 수 있는 프로젝트가 어디있을까! 그래서 이 처음이었던 소중한 기억들에서 느낀점을 하나씩 써보려고 한다.
### 사용자에게 알림이 날라오는것의 의미
배포날 아침 부랴부랴 반드시 진행되어야하는 USECASE에 대해서 빠르게 테스트해보았다. 이때 앱이 두개다 나왔기 때문에 배포날 아침에 시행하는 진짜 테스트였다. 우리 서비스는 크게 알림의 주체가 두가지 형태이다. 애초에 앱을 두개로 분리했었다.
- 유저 -> 수선업자
- 유저 <- 수선업자
그리고 각 단계가 진행됨에 따라서 각 디바이스에 알림으로 아래와 같이 요청이 가야했다.
근데 테스트를 하던 중 유저-> 수선업자의 알림은 수선업자에게 잘 도착하지만, 유저 <- 수선압자의 알림이 유저에게 도착하지 않는 이슈를 발견했다. 쿵쾅쿵쾅뛰는 심장을 부여잡고 에러를 천천히 읽었다. 401에러가 보였고 일단은 뭔가 싶어서 FCM이 잘 저장되는지, 잘업데이트 되는지 확인했다. 거기에는 별다른 문제가 없었고 파이어베이스 콘솔에서 유효한 FCM을 가지고 TEST전송을 해보았지만 역시나 도착하지 않았다. 그리고 FCM 401키워드를 가지고 폭풍검색을 하던중, 애플 개발자센터에 테스팅용 어플리케이션을 등록할때 두가지중 유저용 앱을 등록하는 것을 클라이언트 담당하는 팀원이 누락해버렸던 문제였다. 다행하게도 등록하는것만으로 쉽게 해결했으며 알림도 잘 날라갔다.
내가 이 이야기를 한 챕터로 뽑아 써내려가는 이유는 여기서 내 머릿속을 스쳤던 안일한 생각 때문이다. "에이~ 알림 안가면 필요할때 어플 들어가서 확인하면 되지" 라는 생각은 너무나도 안일하고 무책임했던 생각이었다. 얼핏(ALL-FIT)은 앱에서 수선체결이 이루어지고 배송이 시작되고 다시 유저에게 수선품목이 돌아오기까지의 과정은 앱에서 알림을 받아볼 수 없었다. 위에서 언급했던거와 같이 세특과 API연동이 이루어지지 않았고 그렇기 때문에 작업들이 실시간 동기화 되지 않았기 때문이다. 우리는 익일 15:00되면 수거결과나 수선결과등을 받아볼 수 있었는데 이를 특이사항 존재시 카카오톡 채널을 통해서 안내를 해드렸다.
테스팅이 끝나고 받았던 설문에서 가장 큰 불편사항 중 하나가 알림이 따로 전송되지 않았던 것이다. 수거가 출발했는지, 배송이 완료됐는지 등에 대한 상황을 유저가 계속해서 어플을 들어가서 확인하는 것은 너무나 이상적인 일이고, 실제로는 불가능하다. 그렇기 때문에 푸시알림이 필수적으로 전송되어야 하는 것이다.
아무튼 우리가 감당할 수 없었던 영역에서의 문제였기 때문에(당연히 되면 구현을 했을 것이다! 관련된 기능을 함수로 짰기 때문에 재활용하면 된다) 대응할 수 없다는 것이 너무나 아쉬웠지만, 그래도 앞선 우리영역에서의 알림마저 구현하지 못했더라면 아찔했겠다라는 생각이 머릿속을 다시한번 스쳤다.
의사들이 히포크라테스 선서를 하듯이 개발자들도 폰노이만 선서가 필요하다. 항상 유저의 입장에서 생각해보고 내가 유저였을때 이렇게 사용하면 과연 불편하지 않을까에 대해서 스스로 대답할 수 있어야 한다. 내가 만들어가는 서비스에서도 불편하면 유저는 그 서비스를 더 불편히 생각하고 결국엔 이용하지 않을 것이다. 성공하지 못하는 소프트웨어가 될 것이다.
### 심봉사가 되지 않기(Feat. 어드민 페이지)
졸업 프로젝트를 할쯤 멘토링을 받았었다. 그때 당시 멘토님께서 어드민페이지를 자꾸 강조하시면서 이게 없는 서비스는 운영할 수가 없다.라고 강조하셨다. 그당시에 우리는 운영까지 생각하지 않았기에 무심코 지나갔었다. 그러나 이번 프로젝트를 진행하면서 정말 뼈저리게 느꼈다. 아무리 잘만들어도 어드민페이지가 없으면 운영을 하지 못한다.
어드민 없는 서비스는 심봉사 서비스이다. 서비스가 어떻게 돌아가는지 운영자들은 절대 알지 못한다. 그리고 어드민은 직관적일수록 좋다. 결국 내가써야하니까 우리가 알아보기 쉬워야한다. 그리고 많은 자료들을 한번에 또는 필터링해서 보면 좋다고 느꼈다.
일단 나는 얼른 API서버 개발을 끝내고 꼭 필요한 어드민에 들어갈 요소들을 정리했다.
- 크리에이터가 작성한 게시글을 볼 수 있어야 한다.
- 유저 정보들을 볼 수 있어야 하며 유저들의 수선진행 과정 및 정보를 알야아 한다.
- 유저들이 주문한 요청에 대해서 조회(필터링)하고, 상태를 변경하며, 메모가 가능해야 한다.
그리고 위 요구사항에 맞게 계속해서 개발했고, 실질적으로 운영을 책임져야하는 PM팀원과 계속해서 협의하며 필요한 기능들을 추가해나갔다. 그리고 아래와 같이 개발했다. 이전에 타임리프를 몇번 써봤고 CSS는 자신 없었기에 부트스트랩만 적용해서 빠르게 디자인까지 끝내 개발했다.
실제로 주문명단을 가장 많이 조회하고 관리해야하기 때문에 각 상태에 따라서 필터링되어 보기 편하게 하도록 필터링 버튼을 만들었다. 또한 주문을 클릭시 상세 주문으로 이동하여 해당 주문에 대한 메모를 기록하게 해놓아 관리가 용이하도록 했으며, 여기서 상태를 변경시키도록 했다 또 관련된 정보들을 모두 조회가능했다.
그렇게 주문 상세 화면으로 들어오게 되면 아래와 같은 것들을 할 수 있다.
해당 주문에 대한 메모를 할 수 있다. 이것도 배포 막바지에 생각해낸 것인데, 특이케이스나 진행사항들을 메모해야할일이 있을거 같았고, 어드민에 빠르게 반영하여 추가했다. 실제로 수선취소나, 가격이 너무 높았던 케이스들 같은 경우에 메모를 매우 잘 활용했다. 귀찮아도 개발하면 결국 미래에 내가 편해지는 것 같다고 다시금 느꼈다.
이렇게 주문에 대한 정보를 요약해서 볼 수 있다. 그리고 상태를 강제로 변경시킬 수 있는데, 각 상태는 IN-BOUND와 OUT-BOUND를 나누었다. ( 변경할 상태를 선택해 주세요를 클릭하면 아래가 나온다)
나눈 가장 큰 이유는 OUT-BOUND는 우리의 손을 벗어난 단계이다. 즉 저 상태부터는 세특에서 직접 과정을 처리하기 때문이다. 그래서 OUT-BOUND에서 IN-BOUND로의 상태 변경이 불가능하게 로직으로 막아두었다. 그렇게 변경이 될경우 어떤 문제가 후에 초래될지 몰라서 막았다. 또한 확정 가격이 입력이 되고와 되고나서부터의 플로우로 변경을 하려면 실제 확정가격을 다시한번 적어야지 변경이 되도록 했다. 잘못변경하는 불상사를 막기위한( 마치 회원가입시 비밀번호 한번 더 입력하는 ) 나만의 조치였다.
그리고 이렇게 내가 찍은 이미지와 핀이 존재한다면 핀에 대한 설명과 타입 핀에 대한 좌표가 보이도록했다. JS안쓰고 어드민 페이지 만들어서 동적으로 핀을 이미지위에 올리지는 못했다. 그래도 이정도면 꽤 준수한 어드민이라고 생각했다. 보기 편해야 손도 간다고 어찌저찌 디자인도 포기하지 않고 빠르게 만들었고 운영단계에 접어들면서 가장 많이 보고있던 페이지들이라서 정이가고 뿌듯했다.
아무튼 우리가 열심히 만든 프로젝트가 심봉사가 되지 않게하기 위해 어드민페이지 기획하고, 디자인하고, 개발하고 반영했다. 다만 아쉬운점이 있다면 API서버와 어드민서버가 동일한 서버여서 아쉬웠다. 시간이 많다면 이런것도 분리해서 해보고싶다. 이렇게 또 새로운 기술들에 대해서 필요성을 느끼고 공부해나가게 되는 것 같다.
### 로그를 찍는 것
어드민외에도 개발자에게는 log만큼 중요한게 없을 것이다. logback을 사용해서 로그를 기록했다. logback쓰는거 처음이었는데 @Slf4j인터페이스의 구현체라서 이전에 찍었던 로그들을 별다르게 수정할 필요도 없었다. 로그는 INFO, WARN, ERROR만 기록되게 했다. DEBUG레벨을 써보니까 진짜 별의별개 다 찍혀서 이거 찍히면 용량이 너무 커질거 같아서 뺏다.
이걸 한 폴더에서 보면 가독성 너무 안좋게 계속 쌓이길래(날짜별로 생성되게 했다) 로그 단계별로 분리해서 찍히도록 했다.
사실 이렇게 로그를 기록해야하는 필요성이 있는 프로젝트가 처음이라 이렇게 하는방법이 맞는지도 잘 모르겠었다. 로그를 만드는방법만 나오지 로그전략을 어떻게 수립해야하는지는 구글뒤져봐도 명확히 찾기가 어려웠다. 그래서 내가 쓰고 내가 볼거니까 내가 필요한 로그들을 기록하게 했다.
일단 기본적으로 API요청이 오고 컨트롤러를 지날때마다 로그가 기록되도록했다. 그리고 일반적인 로직상 WARN또는 에러들에 대해서 ERROR로 로그가 기록되도록 했다. 근데 신기하게도 서버가 죽지 않았다. 에러도 터지는거 없었다. 신기했다..(아니면 기록되지 않은건가)!
### 로컬, 개발, 운영서버로 분리한다는 것
저번 캡스톤 프로젝트에서 로컬/개발/운영 서버로 분리해야하는 필요성을 느껴서 이번 프로젝트에 적용했다. dev, local, prod로 나누어 properties를 구성했다. 이런거 보면 스프링부트가 정말 편리하다. 이렇게만 함으로써 분리가 된다니..!
spring.profiles.active=dev
그리고 appliation.properties에 profiles를 어떤걸 선택할지만 바꿔준다면 상황에 따라서 휘리릭 변환이 가능해서 좋았다.
굉장히 이걸 많이 사용했다. 바꿔야할일이 정말 많았고 이렇게 분리해놓지 않았더라면 환경에 따라서 복붙했다고 생각하니 너무 끔찍하다.
# 마치며
- 다음 프로젝트에서는 회원번호UID를 특정 알고리즘에 의해서 생성하거나, 주문번호도 주문에 따라서 알아보기 쉽게 만드는 알고리즘을 채택하여 UID로 사용해보고싶다는 생각이 들었다.
- 개발하면서 계속해서 느낀건데, 필요에 의해서 해야지 필요하지 않는데도 대비해서 그걸 만드는데 시간을 쏟으면 정말 최악인 것 같다.
- 폰노이만 선서로 비유했던거와 같이 항상 유저의 입장에서 생각해보는 개발자가 되어야 겠다.
- 얼렁뚱땅 프로젝트를 만들기는 했는데 현업에서 보면 정말 웃기게 볼수도 있다. 그렇지만 나에게는 정말 좋은 경험이었고 이 모든것(백엔드 담당)을 혼자서 개발해보면서 시야가 더욱더 넓어진 기분이라 좋다. 무엇보다 서비스를 이용하는데 로직이 꼬여서 컴플레인이 생긴적은 없었다.
- 팀즈같은것도 써보면서, AWS서버 할당 받으려고 여러 선임님 책임님들에게 질문해가면서 구했었다. 이것또한 재밌는 경험이었다.
- 하면서 마주친 이슈나 에러들 정말 많았고 해결하는 과정들도 시간 많이 썼지만 온전히 다 기록하지는 못했다. 나름 3개는 어찌 기록했었다.
- 한게 더 많은데 글로 쓰다보면 끝도 없을거 같다 욕심없이 여기서 정리하겠다. 깃허브는 공개가 가능하면 소스코드 주소를 올려보도록 하겠다.
# 얼핏 찐 이용 후기 !
이렇게 수선까지 잘 맞추고 돌려주는 실제 서비스를 기획/개발하고 운영했다!
'•회고' 카테고리의 다른 글
[회고] 졸업 프로젝트를 성공리에 마무리하며(Feat. 인공지능 복지사 복실이) (0) | 2023.08.13 |
---|---|
[회고] 2022-2023 동계 인턴을 마무리하며 (0) | 2023.02.16 |
[회고] SW중심대학 공동 해커톤 그리고 수상 (0) | 2022.10.03 |
[회고] 알파프로젝트 - 추억을 담는 캡슐 (0) | 2022.10.03 |
[회고] 멋쟁이사자처럼 보조강사 (0) | 2022.10.03 |