Side Project

맥주 추천 서비스 애플리케이션

sanglog 2024. 10. 17. 20:57

배경 

2018년에 창업 스터디를 하면서 맥주를 추천하는 서비스를 개발했던 경험이 있었습니다. 당시에는 학생이었고 팀에 혼자 개발자였기 때문에 중구난방으로 개발을 진행했고 결과적으로 마지막에 웹앱을 개발하기는 했었지만 성능적적으로 봤을때 굉장히 미흡한 결과물이었습니다. 결국 스터디는 종료가 되고 세월이 흘렀지만 이제 다들 어느 정도 실무 경력도 생겼기 때문에 그때의 기능을 다시 구현을 마무리 하면서 유종의 미를 거두자는 생각으로 친구와 함께 다시 작업을 시작했습니다. 

 

서비스 사용 기능 구성

프로젝트 시작 관련 블로그 포스트 : https://sang-log.tistory.com/3

 

기본적으로 돈을 많이 사용하지 않고 서비스를 만들어 보고 싶었기 때문에 최대한 무료 플랜을 사용했고, 백엔드는 익숙한 spring boot로 그리고 애플리케이션으로 서비스를 배포하고 기때문에 flutter를 사용해서 프론트 개발을 진행했습니다. 

서버 : AWS EC2

DB: AWS aurora mysql ,  AWS Elastic search

AI기능 : Gemini vertex ai 

저장소 : AWS S3  (with cloud front)

프론트 : Flutter 

앱서버 : Spring Boot 

 

 

사용 기능 정리

 

AWS Aurora mysql 로컬 환경 구성

프리티어에서 ec2에서 aurora를 연결해서 사용하는건 문제가 없지만 public ipv4 주소 할당을 하면 비용이 발생하기 때문에 해당 설정을 제거하고(vpc 외부에서는 접근 불가능) 로컬환경에서는 ssh로 접근하는 방법을 선택했습니다. (관련 블로그 포스팅 링크)

 

spring boot의 로컬 환경에서 ssh 터널링을 구성하기위해서 jsch 라이브러리를 사용했으며 로컬 환경과 라이브 환경을 분리해서 환경 설정 파일이 사용될 수 있도록 로컬 환경에서는 application-local.yml 로 파일을 생성 했습니다.

 

이렇게 설정을 하게되면 추가적인 요금이 없이 로컬 환경과 서버환경의 db를 일치시켜서 개발을 할 수 있습니다.  

SSH 터널링 이란? 
특정 포트의 트래픽을 SSH 연결을 통해 전달하는 방법으로 안전하지 않은 네트워크 상에서 데이터를 안전하게 전송하는 기술. 
이를 통해서 원격 서버와의 연결이나 특정 네트워크 리소스에 안전하게 접근 할 수 있다. 

 

//gradle 설정
implementation 'com.github.mwiede:jsch:0.2.17'
// local yml 파일에서는 다음 내용 추가  
ssh:
  remote_jump_host: {ec2의 host 정보}
  ssh_port: 22
  user: ec2-user
  private_key: {pem key가 있는 경로}
  database_port: 3306
  remote_db_host: {aurora의 host 정보}

 

 

local profile을 사용하는 경우 ssh 터널링 연결 component생성

@Slf4j
@Profile("local")
@Component
@ConfigurationProperties(prefix = "ssh")
@Validated @Setter
public class SshTunnelingInitializer {
  private String remoteJumpHost;
  private String user;
  private int sshPort;
  private String privateKey;
  private int databasePort;
  private String remoteDbHost;

  private Session session;

  @PreDestroy
  public void closeSSH() {
    if (session.isConnected())
      session.disconnect();
  }

  public Integer buildSshConnection() {

    Integer forwardedPort = null;

    try {
	  log.info("start ssh tunneling..");
      JSch jSch = new JSch();
      jSch.addIdentity(privateKey);  // 개인키
      session = jSch.getSession(user, remoteJumpHost, sshPort);  // 세션 설정
      Properties config = new Properties();
      config.put("StrictHostKeyChecking", "no");
      session.setConfig(config);
      session.connect();  // ssh 연결

      // 로컬pc의 남는 포트 하나와 원격 접속한 pc의 db포트 연결
      log.info("start forwarding");
      forwardedPort = session.setPortForwardingL(0, remoteDbHost, databasePort);
      log.info("successfully connected to database");

    } catch (Exception e){
      log.error("fail to make ssh tunneling");
      this.closeSSH();
      e.printStackTrace();
      exit(1);
    }

    return forwardedPort;
  }
}

 

local profile을 사용하는 경우 ssh 터널링으로 연결된 포트 정보를 활용해서 datasource bean 생성 

@Slf4j
@Profile("local")
@Configuration
@RequiredArgsConstructor
public class SshDataSourceConfig {
  private final SshTunnelingInitializer initializer;

  @Bean("dataSource")
  @Primary
  public DataSource dataSource(DataSourceProperties properties) {
    Integer forwardedPort = initializer.buildSshConnection();  // ssh 연결 및 터널링 설정
    String url = properties.getUrl().replace("[forwardedPort]", Integer.toString(forwardedPort));
    log.info(url);
    return DataSourceBuilder.create()
        .url(url)
        .username(properties.getUsername())
        .password(properties.getPassword())
        .driverClassName(properties.getDriverClassName())
        .build();
  }
}

 

 

 

 

이미지 저장 및 Cloud front 적용 

Cloud front 관련 설명 글 :  https://sang-log.tistory.com/6#cloudfront

 

맥주 이미지 정보를 저장하고 사용자들에게 보여주기위해서는 S3과 AWS CF 를 적용해서 구현했습니다. 

S3는 public 접근을 제어하도록 설정하고 CF에서 OAC를 구성해서 cf를 통해서만 이미지에 접근 할 수 있도록 설정했습니다 

 

 

AWS 설정 가이드 링크

Spring boot에서 S3 업로드 기능 예시 링크

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
            .bucket(bucket)
            .key(fileName)
            .metadata(metadata)
            .contentType(file.getContentType())
            .build();
        try {
            s3Client.putObject(putObjectRequest, RequestBody.fromByteBuffer(ByteBuffer.wrap(file.getBytes())));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

 

 

 

Schedule을 통한 배치 기능 

맥주의 평점정보를 저장하기 위해서는 일정 시간 동안 유저들의 평점 정보가 쌓이고나면 해당 정보들을 통해서 맥주들의 평균 평점을 갱신하는 작업이 필요했습니다. 배치를 구성하기 위해서 사용할 수 있는 다양한 방법이 있지만 실패해도 상관없는 간단한 로직이기 때문에 spring boot 에서  @Scheduled 기능을 활용하는 정도면 충분할것으로 생각했습니다. 

 

 

1. @EnableScheduling을 application 클래스에 설정. 

 

2. 수행하고자 하는 method 위에 @Scheduled 추가 (class는 component 여야함) 

- fixedRate : 일정 주기마다 메소드를 호출하는 것

- fixedDelay는 (작업 수행 시간을 포함하여) 작업을 마친 후부터 주기 타이머가 돌며 메소드를 수행

- initialDelay: 스케줄러에서 메소드 초기 지연시간을 설정

- cron: Cron 표현식을 사용하여 작업을 예약

 

 

3. single-thread 에서 동작하는걸 원하지 않는경우 TaskScheduler Bean 등록

 @Bean
  public TaskScheduler scheduledThreadPool(){
    val taskScheduler = new ThreadPoolTaskScheduler();
    taskScheduler.setPoolSize(2);
    taskScheduler.setThreadNamePrefix("custom-schedule");
    return taskScheduler;
  }

 

 

참고 블로그 링크1 , 링크2 ,  

 

 

AI 기능을 활용하는 방법 

Spring AI 에 대해서 정리한 내용 : https://sang-log.tistory.com/4

 

추천을 해주기 위해서는 많은 데이터를 가지고 모델을 학습시켜야하는데 사실상 맥주에 대해서 전문가 적인 지식을 가지고 있지도 않고, 

해외 리뷰 사이트에서 데이터를 크롤링 해서 가져온다고 해도 국내의 사용자들에게 적합한 추천을 해줄 수 있을까에 대한 의문이 있었습니다. 초반에 추천기능을 이용하는건 생성형 AI를 통해서 해당 유저가 평가한 맥주 정보와 그리고 선호한다고 답변한 개인성향 정보를 통해서 추천 받는 방식으로 개발했습니다.  

 

최초에는 GPT gpt를 사용하려고 했으나 api 사용이 유료여서 무료인 Gemini 활용 추가로 Spring Ai를 사용하기 위해서는 Gemini vertex ai 를 사용해야한다. 

 

 

AWS OpenSearch 사용 방법. 

OpenSearch는 Elastic Search 를 기반으로 해서 만들어진 제품
핵심 개념 역색인 링크
기본 환경 구성 참고 블로그 링크

 

맥주 검색기능이 들어가기 때문에 빠른 검색을 위해서 AWS Open Search를 사용했습니다. (open search는 인덱스 기반으로 검색으로 속도 빠름). 맥주 데이터는 굉장히 중요한 데이터이기 때문에 Elastic search만을 사용하지는 않고 RDB 테이블도 같이 사용했습니다. 

 

Spring boot에서 다양한 방법으로 Open Search를 사용하기 위해서 Open Search 에서 제공하는 의존성을 추가하고 환경설정을 해주면 해당 기능들을 간단하게 사용할 수 있었다. 다만 확실히 elastic search를 사용하는것보다는 관련 자료들이 많이 부족해서 처음에는 개발하는데 시간이 좀 소요되었다. 

 

 

// 의존성
implementation 'org.opensearch.client:opensearch-rest-client:2.13.0'
implementation 'org.opensearch.client:opensearch-java:2.6.0'

 

@Configuration
@RequiredArgsConstructor
public class OpenSearchConfiguration {
  private final OpenSearchProperties openSearchProperties;

  @Bean
  public OpenSearchClient openSearchClient(){

    System.setProperty("javax.net.ssl.trustStore", "/full/path/to/keystore");
    System.setProperty("javax.net.ssl.trustStorePassword", "password-to-keystore");

    final HttpHost host = new HttpHost(openSearchProperties.getHost(),443,"https");
    final BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
    credentialsProvider.setCredentials(new AuthScope(host), new UsernamePasswordCredentials(openSearchProperties.getUser(), openSearchProperties.getPassword()));

    //Initialize the client with SSL and TLS enabled
    final RestClient restClient = RestClient.builder(host).
        setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback() {
          @Override
          public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) {
            return httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
                .setMaxConnTotal(100)
                .setMaxConnPerRoute(20);
          }
        }).build();

    final OpenSearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
    final OpenSearchClient client = new OpenSearchClient(transport);

    return client;

  }
}

 

 

 

// 저장 
IndexRequest<BeerDocument> indexRequest =
        new IndexRequest.Builder<BeerDocument>().index(BERR_INDEX).id(id).document(beerDocument).build();
try {
  openSearchClient.index(indexRequest);
} catch (IOException e) {
  throw new RuntimeException(e);
}
// 삭제 
DeleteRequest deleteRequest = new DeleteRequest.Builder()
        .index(BERR_INDEX)
        .id(id)
        .build();
try {
  openSearchClient.delete(deleteRequest);
} catch (IOException e) {
  throw new RuntimeException(e);
}

//조회
BeerListDocument beerListDocument = new BeerListDocument();
SearchRequest searchRequest = new SearchRequest.Builder()
    .index(BERR_INDEX)
    .from(from)
    .size(size)
    .build();
try {
  SearchResponse<BeerDocument> response = openSearchClient.search(searchRequest, BeerDocument.class);
} catch (IOException e) {
  throw new RuntimeException(e);
}
    
//Id로 조회
GetRequest getRequest = new GetRequest.Builder().index(BERR_INDEX).id(beerId)
        .build();
    try {
      GetResponse<BeerDocument> response = openSearchClient.get(getRequest, BeerDocument.class);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
//특정 필드 값으로 조회 (like 검색)
String wildcardQuery = "*" + beerName + "*";
SearchRequest searchRequest = new SearchRequest.Builder()
    .index(BERR_INDEX)
    .query(q -> q.wildcard(m ->
        m.field("beerName")
        .value(wildcardQuery)))
    .build();
try {
  SearchResponse<BeerDocument> response = openSearchClient.search(searchRequest, BeerDocument.class);
} catch (IOException e) {
  throw new RuntimeException(e);
}

 

 

Open Search Rest api reference : 링크

AWS open search 기본 호출 가이드 :  링크

sample git repository : 링크

기본 연동 참고 블로그  : 링크

 

 

Redis 상용 방법

redis도 aws에서 완전관리형으로 elasticache를 사용할수도 있었지만 일부분의 기능에서. 간단하게 캐시용도로 사용하는 상황이기 때문에 현재 시점에는 docker에서 redis 컨테이너를 하나 올려서 사용하는 방법으로 개발을 진행했습니다. 

docker pull redis //최신버전 redis 이미지 다운 
docker run --name local-redis -p 6379:6379 -d redis // local-redis라는 이름으로 6379 포트로 통신 가능하게 설정

 

로컬 환경에서는 redis 설정에 큰 어려움 없이 yml파일에 아래의 설정을 추가하면 사용 가능합니다. 

spring:
  data:
    redis:
      host: localhost
      port: 6379

 

 

EC2에서 컨테이너에서 올려서 사용하는경우는 애플리케이션 컨테이너와 redis 컨테이너가 잘 통신하게 하기위해서 설정이 필요하다. 

기본적으로 bridge 네트워크에 연결이 되어있어서 두 컨테이너가 통신이 가능해야하지만(docker는 같은 네트워크에 연결된 컨테이너들간에만 통신이 가능). 저 같은 경우는 통신상에 오류가 있어서 네트워크를 별도로 생성해서 연결했습니다.

 

// 네트워크 생성 후 연결 
docker network create my_network
docker network connect my_network local-redis
docker network connect my_network application


// 추가 명령어 
docker network ls // 생성된 network 확인 가능
docker inspect {container name} // 해당 컨테이너 정보확인 가능 (연결네트워크)
docker network inspect {network name}// 해당 네트워크 정보확인 가능 (연결컨테이너)

 

 

중요한점은 통신을할때는 컨테이너의 이름으로 서로 접근을 한다는 점이다(Docker는 컨테이너 이름을 내부 DNS로 등록). 따라서 yml에 localhost로 host를 설정하면 안되고 redis가 떠있는 컨테이너의 이름을 써줘야한다는 것이다. (localhost로 되려면 같은 컨테이너에 redis를 올려야하는 거다). 

 

spring:
  data:
    redis:
      host: local-redis
      port: 6379

 

 

 

ps.  redis 데이터를 확인할때 redis insight를 사용하고 있는데 가독성도 좋고 무료 프로그램이라서 사용하기 좋습니다! 

ec2에 올린 redis의 경우로 ssh 터널링으로 로컬 환경에서 확인할 수 있습니다. 

 

Flutter 공부 방법  

flutter에 대한 기초가 없어서 강의는 인프런을 통해서 들으면서 진행했습니다. 

다만 초반에는 프론트와 백엔드를 일정부분 같이 개발을 했었지만 후반부에는 백엔드 작업이 할게 많아져서 대부분의 작업은 동료가 하게되면서 해당 기술에 대한 능력 향상은 제대로 이뤄지지 않은 것 같아서 아쉽습니다. 

https://www.inflearn.com/course/플러터-앱개발-완성

 

Flutter 앱 개발 기초 강의 | DevStory - 인프런

DevStory | Android와 iOS 앱을 하나의 코드로 만들 수 있는 Flutter를 여러가지 앱을 만들며 배우는 수업입니다. 기초 문법과 이론부터 실습까지 비전공자 왕초보 분들도 따라오실 수 있도록 준비했습

www.inflearn.com