프로젝트/하우키키 : 고객 응대 챗봇

00. [졸업 프로젝트-스타트] Howkiki 프로젝트

yjzini 2024. 11. 26. 17:46

 

📌 프로젝트 소개

Howkiki생성형 AI를 활용하여 자연스러운 대화 흐름으로 주문 및 매장 관련 질문을 처리하는 '직원'의 역할을 하는 매장 고객 응대 챗봇 서비스


📌 기술 흐름

최종 목표 : 생성형 AI를 활용한 프롬프트 엔지니어링을 통해 가게에 대한 질문과 주문을 처리하는 챗봇과 백엔드 API 및 서버의 DB를 연결하여 챗봇에 실시간 정보 제공 및 작성이 가능하도록 한다. 

 

우선 핵심기능이자 데모 시현을 위한 기술 구현 흐름은 다음과 같다. 

AI 챗봇에서 사용자와의 대화를 통해 최종적으로 주문 내역을 받고, 이를 json형식의 requestBody로 포함해 POST 메소드 형식으로 백엔드 주문 생성 API를 호출한다. 

서버의 DB에 새로운 주문이 등록되고 프론트에서는 주문 조회 API를 통해 화면으로 생성된 주문을 보여준다. 

 

이 흐름의 기술 구현을 보임으로써, 자연스러운 대화로 매장 내 서비스 및 주문 처리를 담당하는  "매장 고객 응대 챗봇- 하우키키"의 기술 검증을 진행하려 한다. 


📌 백엔드 기술 구성

  • 프로그래밍 언어 : Java17
  • 프레임 워크: Spring Boot 3.4
  • 라이브러리: JPA/Hibernate, Lombok
  • 데이터베이스 : MySQL
  • AWS : EC2, RDS, S3, CodeDeploy
  • GitHub Actions

▶️백엔드 진행 단계 

  • 데모 시현을 위해 주문이 생성되고 조회하는 API를 구현해야한다. 

설계

1. ERD 설계

   일단 기술 검증을 위해 주문 테이블 위주로 설계 (초안)

전체 ERD
주문 테이블과 각 주문에 포함된 메뉴가 저장되는 주문 상세 테이블

2. API 명세서 작성

주문 메뉴와 수량을 받아 저장하는 주문 생성 기능, 

특정 Id의 가게의 모든 주문 내역을 보여주는 주문 목록 조회 기능, 

특정 Id의 주문의 상세 주문 내용을 보여주는 주문 상세 조회 기능으로 구성

 

구현

1. SpringBoot Project 생성

  • initializer 사용해 생성

 

 

  • gradle 프로젝트로 설정
  • java로 스프링부트 버전을 snapshot이 아닌 것 중 가장 높은 버전으로 설정(snapshot은 아직 공식 배포판 X)
  • 프로젝트 메타데이터 설정 (이름)
  • jar 파일로 java 17로 설정
  • 의존성 추가

 

 

 

 

 

 

  • application.yml 파일 생성 및 설정 
  • 깃으로 깃허브와 연동 
  • ! 중요 : application.yml 파일은 민감한 내용이 담겨있기에 .gitignore에 등록하여 깃허브에 올라가지 않도록 해야한다. 

2. 엔티티 클래스 작성 

  • 우선 global 패키지 하위에 모든 엔티티 클래스의 기본이 되는 baseEntity를 만든다.
    (생성시각과 수정시각이 자동으로 기록되도록 하는 클래스)
@MappedSuperclass
@NoArgsConstructor
@SuperBuilder @Getter
@EntityListeners(AuditingEntityListener.class) // JPA에서 엔티티의 생성 및 수정 시각을 자동으로 관리해주는 애노테이션
public class BaseEntity {

    @CreatedDate
    @Column(name = "created_at")
    @NotNull
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "modified_at")
    @Nullable
    private LocalDateTime modifiedAt;

    @PrePersist
    protected void onCreate() {
        if (modifiedAt == null) {
            modifiedAt = createdAt; // createdAt 값과 동일하게 초기화
        }
    }

}
  • (애플리케이션 실행 클래스에 @EnableJpaAuditing 애노테이션 등록)
  • 이후 store, menu, order 패키지로 나누어서 ERD에서 설계한 테이블들에 해당하는 엔티티 클래스를  baseEntity.class 를 상속받도록 하여 작성한다 .

여기까지 했다면 초기설정은 끝난 것!

 

3. 기능 API 작성 - 예) 주문 생성 API

  • 이제 본격적으로 api 코드를 작성해보자 
  • 여기선 대표적으로 주문 생성 기능을 구현하는 과정을 설명하려 한다.
  • 응답 및 요청시 엔티티를 감싸서 받아오는 dto와 엔티티 클래스등의 설명은 생략하고 핵심 로직이 포한된 controller, service, repository 를 중심으로 살펴보자.

OrderController : 입력 값을 받아 서비스 로직에 넘겨주고 결과 값을 응답으로 다시 반환해주는 로직

@RestController
@RequiredArgsConstructor
@RequestMapping("/stores/{storeId}/orders")
public class OrderController {

    private final OrderService orderService;

    /* 주문 생성 */
    @PostMapping
    public ApiResponse<OrderResponseDto> createOrder(@PathVariable(name = "storeId") Long storeId,
                                                     @RequestBody OrderRequestDto requestDto){
        OrderResponseDto responseDto = orderService.createNewOrder(storeId, requestDto);
        ApiResponse<OrderResponseDto> response = new ApiResponse<>(
                HttpStatus.CREATED.value(),
                "주문 생성 성공",
                responseDto
        );
        return response;

    }
}
  • @PostMapping : 해당 url의 POST 메소드 요청 매핑
  • @PathVariable(name = "storeId") Long storeId : 요청 url에서 storeId에 해당하는 값을 storeId 변수로 가져옴
  • @RequestBody OrderRequestDto requestDto : requestBody로 넘어온 주문 내용을 지정해둔 dto형식으로 받아옴
  • orderService.createNewOrder(storeId, requestDto) : 받아온 두 값을 파라미터로 하여 서비스 로직 호출
  • 이후 지정한 응답형식에 맞추어  응답

OrderService : 핵심 구현 로직을 포함

@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderDetailRepository orderDetailRepository;
    private final MenuRepository menuRepository;
    private final StoreRepository storeRepository;

    /* 주문 생성 */
    public OrderResponseDto createNewOrder(Long storeId, OrderRequestDto requestDto) {

        // 주문 요청 검증
        validateOrderRequest(requestDto);

        // requestDto에서 주문한 메뉴이름과 수량이 담긴 OrderDetails
        List<OrderDetailDto> orderDetails = requestDto.getOrderDetails();

        // 주문 총액 계산
        Long orderPrice = calculateOrderPrice(orderDetails);

        // Order 객체 생성
        Order order = Order.builder()
                .storeId(storeId)
                .tableNumber(requestDto.getTableNumber())
                .orderPrice(orderPrice)
                .status(OrderStatus.PENDING)  // 기본상태 : PENDING
                .build();
        // 주문 저장
        Order savedOrder = orderRepository.save(order);

        // *Order Detail 객체 생성
        List<OrderMenuListDetailDto> savedOrderDetail = createOrderDetail(savedOrder, orderDetails);

        // 응답 dto 생성 및 반환
        return OrderResponseDto.from(savedOrder, savedOrderDetail);

    }
...
  • requestDto.getOrderDetails() : 받아온 입력 값에서 주문한 내용 가져와 리스트로 저장 
  • calculateOrderPrice(orderDetails) : 주문한 모든 메뉴의 총액을 계산하는 메소드 호출
  • orderRepository.save(order) : 새로운 주문 객체를 생성하고 db에 저장
  • createOrderDetail(savedOrder, orderDetails) : 새로운 메뉴 상세 객체를 생성하고 저장하는 메소드 호출

OrderService 중 주문 생성시 사용되는 요청 검증 메서드와 총액 계산 메서드

...
    // 주문 요청 검증 메서드
    private void validateOrderRequest(OrderRequestDto orderRequestDto) {
        if (orderRequestDto.getOrderDetails() == null || orderRequestDto.getOrderDetails().isEmpty()) {
            throw new IllegalArgumentException("주문 항목이 비어있습니다.");
        }
    }

    // 주문 총액 계산 메서드
    private Long calculateOrderPrice(List<OrderDetailDto> orderList) {
        return orderList.stream()
                .mapToLong(detail -> {
                    Menu menu = menuRepository.findMenuByName(detail.getMenuName())
                            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 메뉴입니다: " + detail.getMenuName()));
                    return (menu.getCost() * detail.getQuantity());
                })
                .sum();
    }
...
  • validateOrderRequest() :주문 요청이 비어있는지 확인하는 메서드 

OrderService 중 주문 생성시 사용되는 주문 상세 생성 메서드

...
    // 주문 상세 생성
    private List<OrderMenuListDetailDto> createOrderDetail(Order order, List<OrderDetailDto> orderList) {
        List<OrderMenuListDetailDto> result = new ArrayList<>();  // 변환된 DTO를 저장할 리스트

        for (OrderDetailDto detail : orderList) {
            Menu menu = menuRepository.findMenuByName(detail.getMenuName())
                    .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 메뉴입니다: " + detail.getMenuName()));

            OrderDetail orderDetail = OrderDetail.builder()
                    .order(order)
                    .menu(menu)
                    .quantity(detail.getQuantity())
                    .totalPrice(menu.getCost() * detail.getQuantity())
                    .build();

            // 저장
            orderDetailRepository.save(orderDetail);

            // 변환된 DTO를 리스트에 추가
            result.add(OrderMenuListDetailDto.from(orderDetail));
        }

        return result;  // 변환된 DTO 리스트 반환
    }
...
  • 주문의 각 메뉴와 수량과 메뉴*수량의 가격을 포함하는 orderDetail 객체 생성 및 저장

OrderRepository

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o WHERE o.storeId = :storeId")
    List<Order> findOrderByStoreId(@Param("storeId") Long storeId);
    
}
  • JpaRepository를 상속받는 repository 를 생성하여 자바코드로 쉽게 MySQL문을 다루고 연동할 수 있도록 함
  • 간단한 findByID(), save()등의 메서드는 추가 작성없이 사용 가능하지만 이 경우 보다 명확히 작성하기 위해 따로 메서드를 만들고 쿼리문을 작성하여 구현함. 

 

주문 목록 조회 API와 주문 상세 조회 API도 비슷한 흐름으로 구현

(이때 조회 기능이므로 controller에서 @GetMapping이 사용된다는 점 유의!)

 

테스트

  • 각 기능 구현이 끝날 때마다 postman을 통해 테스트를 진행한다.

주문 생성 api 테스트 결과

  •  메서드 방식을 지정하고 메소드에 매핑한 url로 요청을 보내어 응답 상태및 응답 값 확인
  • POST 요청인 경우 requestBody로 필수인 요청 값을 넣어서 SEND
  • cf) 아직 가게나 메뉴 생성api 구현 전이므로 order과 orderDetail이 참조하고 있는 데이터는 미리 sql문을 작성해 더미 데이터로 넣어두어야 한다.

📣 배포

이제 로컬 밖에서도 사용할 수 있도록 배포해보자!

서버로는 AWS EC2를 그리고 데이터베이스로는 AWS의 RDS를 사용하여 배포할 것

 

1. EC2인스턴스 생성

    우선 AWS에서 EC2 인스턴스를 생성하고 로컬 컴퓨터에서 putty를 이용하여 서버에 접속한다.

AWS 에서 EC2를 검색하여 이동
인스턴스 시작 클릭

 

인스턴스 이름 설정, AMI 는 ubuntu 서버로 설정하고 무료로 사용할 수 있는 가장 높은 성능의 인스턴스 유형인 t4g.small로 설정

 

액세스 권한으로 사용될 키 페어를 생성하여 설정

 

SHH, HTTP, HTTPS 트래픽을 허용으로 설정하고, 무료로 사용가능한 최대크기로 스토리지 구성
인스턴스 시작

 

cf) 인스턴스 t4g.small은 2024년까지만 무료로 사용가능합니다!

 

2. 보안 그룹 설정

EC2를 생성하면서 만들어진 보안 그룹 클릭
인바운드 규칙 편집을 클릭하여 8080 port에 대해서 모든 접근 허용하도록 설정

 

3. EC2서버 접속

AWS EC2 인스턴스 콘솔창 또는 putty를 사용하여 생성된 ec2 서버에 접속 

 

4. RDS 데이터베이스 생성
   이후 AWS에서 RDS를 통해 MySQL데이터베이스를 생성한다. 이때 방금 생성한 EC2 인스턴스와 연결하여 생성한다.

  • AWS > RDS > 데이터베이스 생성 클릭

MySQL 클릭
템플릿과 자격 증명 설정 - 사용자 이름과 pw는 따로 기억해두기!
프리티어가 사용할 수 있는 인스턴스 유형 & 스토리지 구성으로 설정, 스토리지 자동 조절 체크 해제
과금되지 않도록 ec2 컴퓨팅 리소스에 연결(아까 생성한 ec2에 연결), 방금 설정한 보안그룹으로 설정
자동 백업 및 자동 업그레이드 설정 해제 (과금 요소)

 

5. 파라미터 그룹 생성

RDS > 파라미터 그룹 > 파라미커 그룹 생성 클릭

  • 원하는 이름으로 그룹 이름 지정
  • > 편집 > 아래와 같이 설정
  • Timezone: Asia/Seoul
  • Character set: utf8mb4 
  • Collation :utf8mb4_general_ci
  • Max Connections: 150
  • 변경사항 저장

RDS > 데이터베이스 > 4번에서 생성했던 데이터베이스 클릭 > 수정> 추가 구성 > DB 파라미터 그룹 설정

즉시 적용 > DB 인스턴스 수정 클릭

 

6. Mysql Workbench 연결

  로컬 컴퓨터의 MySQL Worckbench에서 사용할 수 있도록 connections 생성한다. 

 

7. Springboot 프로젝트 배포

  • application.yml 파일에서 db설정을 rds의 주소와 아이디및 비밀번호로 수정한다. 
  • 프로젝트를 빌드하고 아래 명령어를 통해 EC2로 만들어진 jar파일을 전송한다
scp -i {pem 파일 경로} {로컬 jsr 파일 경로} ubuntu@{Ec2 IPv4}:{jar 파일 복사할 경로}
  • putty에서 해당 서버로 접속하여 에플리케이션을 실행시켜 확인한다. 

🔥여기까지 했다면 서버 배포는 완료된 것!

 

Postman 테스트

EC2 인스턴스의 퍼블릭 IP 주소로 테스트를 진행한다. 

배포후 주문 생성 api 테스트


➕더 나아가기 (옵션)

사실 여기까지만 진행해도 외부에서 api에 접근하고 사용하는데 문제없다.
그러나 배포 과정에 귀찮음을 느꼈다면 아래의 내용을 참고해보길 바란다.

♾️ CI/CD 배포 자동화

  • 지금처럼 여러 개발자가 협업을 하면서 동시에 개발을 진행하는 경우, 서비스를 배포하고 운영 중 코드 수정 작업이 필요한 경우 CI/CD를 도입하여 빌드 부터 배포까지의 과정을 자동화할 수 있다. 
  • 또한 이를 통해 지속적인 모니터링이 가능해 문제를 빠르게 감지하고 해결할 수 있다. 
  • 여기에서는 여러 CI/CD tool 중 Github Actions를 사용하고,  AWS Code DeployS3를 이용할 것이다. 
  • 과정 흐름은 다음과 같다. 

출처 :EFUB 세미나 자료

1. GitHub에 코드 푸시하여 GitHub Actions 트리거
2. GitHub Actions를 통해 .zip파일 생성 및 S3에 업로드
3. AWS CodeDeploy 서비스에 배포를 요청
4. AWS CodeDeploy가 S3에서 .zip 파일 가져오기
5. AWS CodeDeploy가 EC2 인스턴스에 배포

 

실제로 도입해보자!

 

1. AWS에서 IAM,S3,CodeDeploy 설정

  • ec2 인스턴스에 태그 추가

원하는 이름의 태그 생성

  • IAM 역할 생성 : S3에 올린 파일에 접근할 수 있는 역할 생성
    • 사용자 - 실제 AWS의 기능과 자원을 이용하는 사람 혹은 애플리케이션
    • 역할 - 임시적인 자격 증명서로 리소스에 부여됨,  리소스가 가지는 권한을 정의

  • S3 파일 접근 권한을 EC2 인스턴스에 부여
    • EC2 > 인스턴스 > 해당 인스턴스 클릭 > 작업 > 보안 > IAM 역할 수정
  • EC2 서버에 다음의 명령어로 Codedeploy agent 설치 
sudo apt update
sudo apt install ruby-full
sudo apt install wget
cd /home/ubuntu
wget https://aws-codedeploy-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/latest/install
chmod +x ./install
sudo ./install auto > /tmp/logfile
sudo service codedeploy-agent status
  • EC2에 루트 디렉토리 생성 & 자바 버전 확인 & timeZone 설정
  • s3 버킷 생성
    • Github actions가 프로젝트를 build하고 zip파일 업로드할 s3 생성
  • CodeDeploy를 위한 IAM 역할 생성
    • 위에서 EC2에 S3 접근 권한 생성한 것처럼 이번엔 ec2가 아닌 CodeDeploy을 사용 사례로하여 생성
  • CodeDeploy 애플리케이션 생성

 

  • CodeDeploy 배포 그룹 생성

생성한 애플리케이션에서 배포 그룹 생성

  • Github actions를 위한 IAM 사용자 생성 
    • IAM > 액세스 관리 > 사용자 > 사용자 생성 
      • CodeDeploy와 S3에 접근 권한 부여 

  • IAM > 사용자 > 보안자격증명 > 액세스 키 만들기

지금이 아니면 액세스 키를 다시 확인할 수 없으니 꼭 다운받기!!!

 

2. Github Secrets 추가

: 깃허브 공개 코드로 올릴 수 없는 민감한 파일은 SECRETS 파일로 저장하여 사용한다. 

  • GitHub Secrets에 IAM 사용자 액세스 키 정보 추가 
  • GitHub Secrets에 application.yml 정보 추가

  • 코드 파일에 src > appspec.yml 파일 작성

출처 :EFUB 세미나 자료

 

3. 배포 스크립트 작성

  • > scripts 디렉토리에 stop.sh, start.sh 파일 작성

  • stop.sh
#!/usr/bin/env bash

PROJECT_ROOT="/home/ubuntu/howkiki"
JAR_FILE="$PROJECT_ROOT/ShareEat-webapp.jar"

DEPLOY_LOG="$PROJECT_ROOT/deploy.log"

TIME_NOW=$(date +%c)

# 현재 구동 중인 애플리케이션 PID 확인
CURRENT_PID=$(pgrep -f $JAR_FILE)

# 프로세스가 켜져 있으면 종료
if [ -z $CURRENT_PID ]; then
  echo "$TIME_NOW > 현재 실행 중인 애플리케이션이 없습니다." >> $DEPLOY_LOG
else
  echo "$TIME_NOW > 실행 중인 $CURRENT_PID 애플리케이션 종료" >> $DEPLOY_LOG
  kill -15 $CURRENT_PID
fi
  • start.sh
#!/usr/bin/env bash

PROJECT_ROOT="/home/ubuntu/howkiki"
JAR_FILE="$PROJECT_ROOT/howkiki-webapp.jar"

APP_LOG="$PROJECT_ROOT/application.log"
ERROR_LOG="$PROJECT_ROOT/error.log"
DEPLOY_LOG="$PROJECT_ROOT/deploy.log"

TIME_NOW=$(date +%c)

# build 파일 복사
echo "$TIME_NOW > $JAR_FILE 파일 복사" >> $DEPLOY_LOG
cp $PROJECT_ROOT/build/libs/*.jar $JAR_FILE

# jar 파일 실행
echo "$TIME_NOW > $JAR_FILE 파일 실행" >> $DEPLOY_LOG
nohup java -jar $JAR_FILE > $APP_LOG 2> $ERROR_LOG &

CURRENT_PID=$(pgrep -f $JAR_FILE)
echo "$TIME_NOW > 실행된 프로세스의 아이디는 $CURRENT_PID 입니다." >> $DEPLOY_LOG
  • 빌드 시 -plain.jar 파일은 생기지 않도록 build.gradle에 다음 코드 추가
jar {
    enabled = false
}

 

4. Github Actions Workflow 작성

  • .github > workflow > deploy.yml 파일 작성
  • Workflow를 트리거하는 이벤트를 지정하고 , 환경 변수 및 기본 권한, jobs를 지정
  • 작성 후 commit, push 시 Actions, CodeDeploy에서 실행 결과 확인 가능

 

5. 서버 접속하여 로그 확인 

: 스프링부트 프로젝트가 잘 실행되었다면 성공한것 

 

6. postman test로 확인!

성공!


📜앞으로의 계획

  • 방학 동안 ERD를 다시 점검하고, API 명세서를 작성하여 API 개발을 완성할 것
  • 데모 시연을 위해 필수 기능만 먼저 구현해야했기에 설계 부분에서 부족한 부분들이 있어 다시 꼼꼼히 보려고 한다. 
  • 이후 도커를 도입하고 가능하다면 테스트 코드도 작성하려 한다.