본문 바로가기

RECORD/회고

[회고] 프로젝트 마무리 : [채식이들] v.1.2.0 배포

* 전체를 읽는데, 예상 소요 시간 : 15 ~ 20분

글이 다소 길 수 있습니다. 정독 피드백 그리고 좋아요는 큰 힘입니다.😉
천천히 읽어주세요~!

안녕하세요 NOT-ERROR팀 백엔드 개발자이자 PM 강시혁(제임스)입니다.😎

22년 10월 12일부로 프로젝트를 마치게 되었습니다.
낫 에러팀은 [채식이들] 쇼핑몰을 성공적으로 배포했습니다. 주어진 기간에 비해 높은 목표 설정으로 인해서, 프로젝트 내내 걱정과 불안이 함께 했던 것 같습니다. 그럼에도 목표한 수준의 결과물을 완성했다는 것이 여전히 믿기지 않습니다.


저에겐 가장 의미 있던 프로젝트였습니다. 그 이유 중 (1) 첫 번째 [고객이 겪는 문제를 해결하기 위해 노력]했다는 점입니다. 특히 오늘 코드스테이츠 데모데이를 통해서 크게 느끼게 되었습니다. 데모데이는 다른 팀과 결과물을 공유하는 시간입니다. 약 60개의 프로젝트가 있었는데요. 대부분 기능 구현 목적의 프로젝트가 많았습니다. 물론 타 팀의 프로젝트를 통해 기능 구현과 완성도 면에선 배울 점도 있었습니다. (어떻게 이 짧은 기간 동안 그런 기능들을 구현했지 싶었던 프로젝트도 있었습니다..대단해요) 반면, 우리 팀은 위에서 언급했듯이 고객이 겪는 문제에 집중하고, 해결하는 서비스를 만들었습니다. 덕분에 우리가 왜 해야 하는지에 대해 끊임없이 고민하게 되었습니다. 또한 자료조사로 근거를 확보하다 보니, 논리도 자연스럽게 형성되었습니다. 어떻게 보면 단순 개발이 아닌, 문제를 해결하는 기획을 경험하고 배움을 얻을 수 있었습니다.

(2) 두 번째는 프로젝트 매니저를 맡음으로써, 프로젝트 운영 경험을 쌓을 수 있었습니다. (3) 세 번째 향상된 기술로 프로젝트를 진행했다는 점입니다.
위와 같은 이유들로 이번 프로젝트는 저에게 많은 배움과 의미를 남겨주었습니다. 이제 서론은 이만하고, 프로젝트를 소개하겠습니다.

# [낫 에러] 팀 블로그

https://not-error-064.tistory.com/

 

NOT-ERROR-064

 

not-error-064.tistory.com


(v.1.2.0) 채식이들의 결과물


1) 간단한 소개

'채식이들'은 채식을 하는 사람들이 식제품을 편하게 구매할 수 있도록 도와주는 서포터즈 쇼핑몰입니다.

채식 유형만 선택하세요.
상품은 '채식이들'이 찾아드릴게요 🥗

2) 웹 사이트 도메인

웹 애플리케이션은 현재 v.1.2.0의 수준이며, 큰 리소스 비용이 없는 한에서 23년 6월까지 서버를 오픈할 예정입니다.

http://chaesik2s.shop/

 

채식이들

chaesik2s.shop

결과 페이지를 빠르게 보고 싶다면, 아래 <더보기>를 클릭해주세요.
더보기

(1) 메인 페이지

(2) 회원가입 과정

(3) 마이페이지

(4) 상세 제품 페이지

(5) 장바구니 내역

(6) 주문 내역

 


서비스 소개


1) 개요

“채식이들” 쇼핑몰은 소비자들의 채식 유형에 해당하는 제품을 편하게 구매할 수 있도록 제공합니다.

2) 핵심 고객

  • 채식 유형에 따라 식제품을 구매하는 소비자
우리 서비스의 페르소나가 궁금하다면, <더보기>를 클릭해주세요.

3) 기획 배경

채식에는 식단에 따라 채식주의자 유형을 9가지로 나눕니다. 이중에는 '육류'를 섭취하는 유형도 있습니다. 하지만 한국의 대부분은 '채식'이라 하면, 단순히 '고기를 먹지 않는 것'이라고 생각합니다. 이러한 점은 현재 서비스 중인 쇼핑몰에서도 느낄 수 있었습니다. 우리 팀은 국내에서 서비스 중인 오아시스 마켓과 채식 한 끼를 대표적으로 분석했습니다. 그리고 아래와 같이 고객이 겪을 페인 포인트를 찾을 수 있었습니다.
따라서 우리 팀은 채식을 하는 소비자들이 본인의 채식 유형에 해당하는 제품을 편하게 구매할 수 있도록 제공하고자 했습니다.

# 고객이 겪을 페인 포인트

1. 대체품과 야채류 제품만 나열되어 있어서, 소비자들의 식단에 맞는 식품을 찾는데 비교적 긴 시간이 발생
2. 식품의 상세 정보를 보면서 영양 성분을 직접 찾아봐야 한다는 번거로움
9가지 채식 유형 

4) 핵심 기능

  • 회원이 설정한 채식 유형에 맞게 식제품 노출 기능
  • 장바구니, 주문 등 쇼핑몰 기능
비회원일 때와 회원일 때 보이는 상품 페이지가 다릅니다.
비회원일 때는 가장 많은 식품군을 섭취할 수 있는 "플렉시테리언"이 기본으로 설정된 상태입니다.

 


설계


짧은 기간임에도 많은 설계 명세서를 작성했습니다. 프로젝트를 진행하다 보니 프론트엔드와 백엔드 개발자들이 사용하는 용어와 생각하는 방식이 많이 다르다는 것을 느꼈습니다. 그래서 최대한 통일성을 갖추고, 서비스의 완성도를 높이기 위해 설계 과정에 긴 시간을 투자했습니다.
낫에러팀이 설계한 내용은 다음과 같습니다.

자세한 내용이 궁금한 분들은 아래 첨부된 노션 링크로 확인해주세요.


https://codestates.notion.site/39-Team-cbcc6beff32a4ba1bfac9e8a12cf41ad

 

39기-Team-채식이들-발표문서

🔎 개인 맞춤형 채식 쇼핑몰, 채식이들

codestates.notion.site

 


나의 역할


1) 나는 프로젝트를 위해 어떤 고민을 했는가

(1) 목표에 도달하기 위한 일정 관리
(2) 팀 시너지 향상을 위한 고민
(3) 효율적인 프로젝트 진행을 위해 불필요한 시간들을 줄일 수 있는 전략적 선택
(4) 동료 성장
(5) 채식 유형별 제품 조회 기능을 구현하기 위한 고민

2) 백엔드 개발자로서 기술 구현

(1) JWT 토큰을 활용한 REST API 방식의 인증 기능 구현
(2) 비회원, 회원에 따른 제품 조회 기능
(3) 클린 코드를 통한 가독성 좋은 코드 구현
(4) JUnit5, Mokito를 활용한 테스트 코드 작성
(5) 예외 코드 정의 및 핸들링

(1) JWT 토큰을 활용한 REST API 방식의 인증 기능 구현

더보기
토큰 발급 및 인증 방식 다이어그램
Security Config 설정 내용

기술에 대한 자세한 내용은 낫에러 팀 블로그에 포스팅된 글을 확인해주세요. 여기서는 간단하게만 설명하겠습니다.

위 내용은 Security config에 작성한 코드 내용입니다. /auth/login 엔드포인트로 로그인 요청 시 Jwt AuthenticationFilter를 통해서 Access 토큰을 발급하게 만들었습니다. 그리고 성공, 실패 시마다 Handler를 통해서 추가 조작도 진행합니다. 클라이언트에서 발급받은 JWT 토큰을 갖고 API 요청을 보내면, Jwt VerificationFilter를 통해서 검증 작업을 진행하고 있습니다.

프로젝트에 인증 기능을 적용한 것은 이번이 처음입니다. 인증에 대해 학습할 때는 SSR(Server Side Rendering) 방식으로만 학습했었습니다. 따라서 Stateless(무상 태성)한 Rest API 통신 기반인 CSR(Client Side Rendering)에서의 인증 방식에 대해서는 어떻게 해야 하는지 고민이 많았습니다. 그러던 중 Jwt 토큰 인증 방식을 알게 되었습니다.

끝내 성공적으로 특정 API 동작시 인증이 필요하도록 구현했습니다. 추후에는 OAuth2와 Jwt 토큰을 활용하여 소셜 인증 방식도 구현할 예정입니다.

(2) 비회원, 회원에 따른 제품 조회 기능

더보기
'채식이들' 서비스의 핵심 기능인 채식 유형에 따른 제품 조회 기능을 구현했어야 했습니다. 여러 아이디어가 나오면서, 전략적으로 방법을 선택해야했습니다. 우리 팀은 먼저 기능의 가능성을 체크하기위해 가장 단순한 방법으로 구현하기로 결정했습니다. 그렇게 결정된 방법은 테이블을 형성하고 조건에 따른 조회 쿼리를 활용하는 것이었습니다.

분석한 결과 채식 유형들은 계층적인 관계를 나타낸다는 것을 알았습니다.
낫에러 팀의 백엔드 개발자 '황윤준' 님이 구현한 계층형 조회 Native Query

따라서 우리 팀은 선택한 채식 유형에 따라 계층적으로 조회되게 함으로써 문제를 해결했습니다.

제품 전체 조회 기능 비즈니스 로직
채식 유형과 계층관계인 유형까지 포함하여 전체 출력되는 결과 화면
* 아쉬웠던 점과 추후 계획
9가지의 채식 유형이 바뀌지 않을 정적 데이터임에도 DB에 테이블을 만들어 저장한 상태입니다. 사실 DB까지 아니어도 구현단에서 해결할 수 있는 문제라고 생각합니다. 즉, 현재는 불필요한 트래픽이 발생한다고 생각이 듭니다.
그럼에도 아직은 기술력의 한계가 있습니다. 디자인 패턴과 객체에 대해서 좀 더 연구를 할 필요가 있습니다.
추가로 현재는 보다시피 native query로 기능이 구현된 상태인데요. 가독성과 더 효율적이게 데이터를 출력하기 위해 Query DSL 기술을 도입할 예정입니다.

(3) 클린 코드를 통한 가독성 좋은 코드 구현

더보기
컨트롤러 로직
서비스 로직
도메인형 패키지 구조

가독성과 추후 확장을 위해 클린한 코드를 작성했습니다. 이때 저만의 몇 가지 규칙을 지키려고 노력했습니다.

  1. 메서드 안에 코드들은 마치 책을 읽듯이 읽혀야 한다.
  2. 메서드 안에 하위 기능은 최대 3~4개 이하로 구현한다.
  3. 공통되는 기능은 최대한 메서드로 빼낸다.
  4. 이름만으로도 무슨 기능인지 알 수 있어야 한다.
  5. 목적에 맞게 패키지를 분리하고 정리한다.
  6. Java docs 주석을 활용하여, 추가 설명 명시

(4) JUnit5, Mokito를 활용한 통합 및 슬라이스 테스트 코드 작성

코드는 깃헙에 있기 때문에 전부 공유하지는 않겠습니다.
더보기

# 제품 조회 슬라이스 테스트

/**
 * 슬라이스 테스트 작성 예시
 */
@ExtendWith(MockitoExtension.class)
class ProductServiceImplSliceTest {

    @Mock private ProductRepository productRepository;
    @Mock private ProductMapper mapper;

    @InjectMocks private ProductServiceImpl productService;

    @Test
    @DisplayName("제품 조회 성공- 데이터 존재")
    void findProduct_success_test() throws Exception {
        //when
        given(productRepository.findById(anyLong()))
                .willReturn(Optional.of(getProduct_1()));
        given(mapper.productToProductResponseDto(any(Product.class)))
                .willReturn(responseProductData());

        ProductResponseDto response = productService.findProduct(1L);

        //then
        assertEquals(response.getProductId(),getProduct_1().getProductId());
        assertEquals(response.getProductName(), getProduct_1().getProductName());
    }

    @Test
    @DisplayName("제품 조회 실패 - 조회된 제품이 없습니다.")
    void findExistProduct_fail_test() throws Exception {
        //given
        Product nullDataInDB = null;

        //when
        given(productRepository.findById(anyLong()))
                .willReturn(Optional.ofNullable(nullDataInDB));

        String result = "";
        try {
            productService.findExistProduct(1L);
        } catch (BusinessLogicException e) {
            result = e.getMessage();
        }

        //then
        assertTrue(result.equals("조회된 제품이 없습니다."));
    }
    .
    .
    //생략
}

# 통합 테스트

/**
 * 제품 전체 조회 기능의 모든 CASE 통합 테스트
 */
@Transactional
@SpringBootTest
@AutoConfigureMockMvc
public class GetSortedProductsTestOfProductController {

    @Autowired private MockMvc mockMvc;
    @Autowired private ProductRepository productRepository;

    static Product dataInDB_1;
    static Product dataInDB_2;
    static Product dataInDB_3;

    @BeforeEach
    void beforeEach() throws InterruptedException {
        Product data_1
                = Product.builder()
                .productName("카레라면")
                .price(10000)
                .stockQuantity(3)
                .thumbnailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
                .detailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
                .build();

        dataInDB_1 = productRepository.save(data_1);
        dataInDB_1.setSignDate(LocalDateTime.now());

        // 제일 최신 등록
        Product data_2
                = Product.builder()
                .productName("옥수수식빵")
                .price(2000)
                .stockQuantity(2)
                .thumbnailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
                .detailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
                .build();

        dataInDB_2 = productRepository.save(data_2);
        dataInDB_2.setSignDate(LocalDateTime.now().plusDays(3));

        // 제일 낮은 가격
        Product data_3
                = Product.builder()
                .productName("가지돈까스")
                .price(500)
                .stockQuantity(7)
                .thumbnailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
                .detailImage("AOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYAAOh-ky201T2iwWCIEQQOTQYxLJ90U01aMK7o8NrPzoCSYA")
                .build();

        dataInDB_3 = productRepository.save(data_3);
        dataInDB_3.setSignDate(LocalDateTime.now().plusDays(1));
    }

    @AfterEach()
    void afterEach() {
        productRepository.deleteAll();
    }

    @Test
    @DisplayName("제품 전체 조회 성공 테스트 - 한 페이지당 40개 데이터 조회")
    void 제품_전체_조회_40개() throws Exception {

        mockMvc.perform(
                        get("/products/list")
                                .param("size","40"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.pageInfo.size").value(40));
    }

    // 출력 순서 : 2 -> 3 -> 1
    @Test
    @DisplayName("제품 전체 조회 성공 테스트 - param 에 아무 것도 없을 때(default)")
    void 제품_전체_조회_신제품순() throws Exception {

        mockMvc.perform(
                        get("/products/list"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.products[0].productId").value(dataInDB_2.getProductId()))
                .andExpect(jsonPath("$.products[1].productId").value(dataInDB_3.getProductId()))
                .andExpect(jsonPath("$.products[2].productId").value(dataInDB_1.getProductId()))
                .andExpect(jsonPath("$.pageInfo.totalElements").value(3))
                .andExpect(jsonPath("$.sortInfo.sort").value("signDate"))
                .andExpect(jsonPath("$.sortInfo.orderBy").value("desc"));
    }
    .
    .
    //생략
}
먼저 프로젝트에서 생성되는 도메인이 많아지면서, postman을 통한 테스트 방식은 비효율적이게 되었습니다. 따라서 저희는 테스트 코드를 꼭 작성해야겠다고 결정하게 되었습니다. 이때 슬라이스 테스트와 통합 테스트를 모두 진행했는데요. 의존성을 제거하거나, 독립적이게 테스트를 진행할 수 있는 경우는 슬라이스 테스트를 진행했습니다. 반면, DB에서부터 의도한 대로 프로세스가 진행되는지 테스트할 때는 통합 테스트를 선택하여 진행했습니다.

테스트 코드를 작성함으로써 빠른 시간 내로 기능을 검증할 수 있어서 좋았습니다. 하지만 테스트 코드를 작성하는 시간이 길어지면서, 일정에 문제가 발생하게 되었습니다. 뿐만 아니라 도메인이 확장됨에 따라 모든 테스트 코드를 수정해야 하는 일이 발생하기도 했습니다.
추후에는 정말 테스트가 필요한 비즈니스 로직이 무엇인지 캐치하고, 모든 의존성을 해지하여 순수 자바로만 테스트를 진행할 수 있도록 해야겠습니다.

(5) 예외 코드 정의 및 핸들링

더보기
사전에 예외 케이스들을 예측하여 문서화 진행
핸들링할 케이스

 

구현 결과

이번 낫에러의 백엔드 팀은 사전에 예상 예외들을 캐치했고, 이에 따른 예외 코드와 메시지를 정의했습니다. 그리고 프로젝트에서 핸들링 코드를 작성하여 우리 팀만에 규칙으로 에러를 다루었습니다.

이렇게 하게 된 이유는 예외와 에러에 대한 신속한 대처를 위함입니다. 또한 갑작스러운 종료를 방지하기 위함도 있습니다. 개발을 하다 보면 수많은 에러가 발생하는데, 간혹 프로그램이 갑작스럽게 종료되는 것을 발견했습니다. 그럴 때마다 에러의 원인을 찾는데 많은 시간이 발생하게 되었습니다. 대체로 어떠한 부분에서 문제가 있었는지 정확하게 알 수가 없었습니다. 저희는 이러한 시간을 어떻게 줄일 수 있을지에 대해 고민을 했습니다. 따라서 저희는 구현을 시작하기 전에 어떠한 예외 상황들이 있을지 미리 예상해보았고, 이를 문서화했습니다. 해당 과정을 통해서 기존에 접해보지 못했던 HTTP STATUS(401, 403, 406, 409, 415, 500, 501)에 대해서 더 자세하게 알게 되었습니다. 또한 기대했던 대로 예외와 에러에 대해 신속하게 대처할 수 있게 되었습니다.

 


추후 계획


현재 '채식이들'은 시멘틱 버저닝 방식을 통해 버전별로 운영하고 있습니다. 따라서 버전 계획에 맞게 기능을 추가할 예정입니다.
그전에 우리 팀은 리팩토링과 테스트 코드 작성을 최우선적으로 진행할 계획입니다. 기능 검증을 위해 빠르게 코드를 작성했다 보니 아직은 부족한 부분이 많습니다.

'채식이들' 버전 운영 계획이 궁금하다면, <더보기>를 클릭해주세요.

 


마지막으로


저는 협업 프로젝트를 위해 1년 넘게 기다렸습니다. 어떻게 보면 저에게도 처음이었던 대규모 프로젝트이었습니다. 그래서인지 처음 PM(프로젝트 매니저)을 맡았을 때는 걱정도 많았습니다. 더군다나 해당 프로젝트로부터 높은 성취를 추구했던 터라 목표를 높게 설정하게 되었습니다. 이러한 점들은 프로젝트를 진행하며, 발목을 잡기도 했습니다. PM과 협업 프로젝트가 처음임에도 불구하고, 욕심이 많았습니다.

팀원들에게 미안함과 고마움이 공존합니다. 끝내 우리 팀은 마감기한까지 계획한 대로 프로젝트를 완성하게 되었는데요.

모두가 잠도 줄여가며, 적극적으로 역할을 수행한 덕분에 이룰 수 있었습니다. 물론 각각의 스프린트 목표와 진행 내용들을 생각했을 때는 다사다난했습니다. 그럼에도 최종적으로는 '성공'적인 프로젝트였다고 결론을 지으며, 글의 끝을 맺겠습니다.

반응형