Skip to content

Commit

Permalink
Merge branch 'feature/ISSUE-53' into staging
Browse files Browse the repository at this point in the history
  • Loading branch information
stopmin committed Jul 18, 2024
2 parents c09579f + abd1868 commit ac7f143
Show file tree
Hide file tree
Showing 12 changed files with 330 additions and 22 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/deploy-ecs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ env:
AWS_REGION: ap-northeast-2 # AWS region 설정
ECR_REPOSITORY: gyeongdan-spring # Amazon ECR repository 이름
ECS_SERVICE: GyeongdanSpring # Amazon ECS 서비스 이름
ECS_CLUSTER: Gyeongdan # Amazon ECS 클러스터 이름
ECS_CLUSTER: Gyeongdan-v2 # Amazon ECS 클러스터 이름
ECS_TASK_DEFINITION: tf-staging.json # Amazon ECS task definition 파일 경로
CONTAINER_NAME: Spring # 컨테이너 이름
PROGRESS_SLACK_CHANNEL: C07BRCDNBMF # Slack 채널 ID
Expand Down
72 changes: 57 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,67 @@

## 🔒 보안 및 인가

경단 Spring 서버는 다음과 같은 보안 및 인가 메커니즘을 사용합니다:
### 1. 클라이언트 보안 및 인가

- **[카카오톡 보안 인증](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api)**: 카카오톡에서 토큰을 받아 사용자를 인증하고, 우리 서버에서 유효성 검사 후 JWT 토큰 생성
- **인증 과정**: 카카오톡 토큰을 사용하여 사용자 인증 → 서버에서 실제 유저 확인 및 검증 → JWT 액세스 토큰 및 리프레시 토큰 생성
- **[카카오톡 보안 인증](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api)**: 카카오톡에서 토큰을 받아 사용자를 인증하고, 우리 서버에서 유효성 검사 후 JWT 토큰을 생성합니다.
- **인증 과정**:
1. 카카오톡 토큰을 사용하여 사용자 인증
2. 서버에서 실제 유저 확인 및 검증
3. JWT 액세스 토큰 및 리프레시 토큰 생성
- **역할 기반 접근 제어**: ADMIN, ROLE 등의 역할 확인
- **OAuth 2.0**: 권한 부여 프레임워크를 사용한 안전한 자원 접근

### 2. HTTPS 설정
<img src="https://github.com/user-attachments/assets/571c820e-bda7-467e-a8a3-1425bd9aa40c" alt="보안 다이어그램" width="500"/>

- **배포 구성**: Application Load Balancer를 활용한 배포
- **도메인 및 인증서 설정**:
- **Route 53을 통해 도메인 구매**: AWS Route 53을 통해 도메인을 구매하고 관리합니다.
- **ACM 인증서 적용**: AWS Certificate Manager(ACM)를 사용하여 SSL/TLS 인증서를 생성하고 관리합니다.
- **HTTPS 설정**:
- ACM에서 생성한 인증서를 Application Load Balancer(ELB)에 적용하여 HTTPS를 통해 안전하게 통신할 수 있도록 합니다.
- 클라이언트는 Route 53에서 설정된 도메인을 통해 Application Load Balancer와 통신합니다.
- 이를 통해 모든 트래픽은 HTTPS를 통해 암호화되어 전송됩니다.






## 🚀 배포

경단 Spring 서버는 GitHub Actions를 사용하여 자동화된 배포를 진행합니다. 백엔드는 Docker를 사용하여 컨테이너화된 이미지를 Amazon ECR에 올리고, Amazon ECS를 통해 Gyeongdan 클러스터에 배포합니다. 프론트엔드는 Vercel을 사용하여 배포합니다.
경단의 2개의 백엔드 서버와 1개의 NextJS 서버는 GitHub Actions를 사용하여 자동화된 배포를 진행합니다.

### ELB를 통한 무중단 배포
- **롤링 업데이트**: 롤링 업데이트는 애플리케이션의 새로운 버전을 순차적으로 배포하여 무중단 배포를 가능하게 합니다. 새로운 인스턴스를 생성하고, 기존 인스턴스와 교체하여 서비스 중단 없이 배포를 완료합니다.
- **동적 포트 매핑**: 동적 포트 매핑을 통해 새로 배포된 애플리케이션 인스턴스가 사용 중인 포트를 자동으로 할당받아 충돌 없이 동작할 수 있습니다. 이를 통해 새로운 인스턴스가 정상적으로 실행되고 있는지 확인할 수 있습니다.

**경단 무중단 배포 프로세스:**
1. **코드 푸시 및 빌드**: GitHub Actions를 통해 코드가 푸시되면 자동으로 빌드 및 테스트가 수행
2. **Docker 이미지 생성 및 배포**: 성공적으로 빌드된 애플리케이션은 Docker 이미지를 생성하고, Amazon ECR(Amazon Elastic Container Registry)에 푸시
3. **Amazon ECS 및 ELB 설정**:
- Amazon ECS(Amazon Elastic Container Service)를 사용하여 새로운 Docker 컨테이너 인스턴스를 시작
- ELB(Elastic Load Balancer)는 새로운 인스턴스의 상태를 모니터링하고, 헬스체크(health check)를 통해 인스턴스가 정상적으로 동작하는지 확인
4. **포트 매핑 및 트래픽 전환**:
- 동적 포트 매핑을 통해 새로운 인스턴스가 기존 인스턴스와 포트 충돌 없이 실행
- 새로운 인스턴스가 정상적으로 동작하면, ELB는 트래픽을 기존 인스턴스에서 새로운 인스턴스로 점진적으로 전환
5. **기존 인스턴스 종료**:
- 새로운 인스턴스가 안정적으로 서비스 중인 경우, 기존 인스턴스는 점진적으로 종료
- 이 과정은 서비스 중단 없이 완료

### 지속적 통합 및 배포(CI/CD)

- **GitHub Actions**: GitHub Actions 워크플로우를 통해 코드 변경 시마다 자동으로 빌드, 테스트, 배포 수행
- **Slack 알림**: 배포 시작 및 완료 시 Slack 채널에 알림을 보내어 팀원들에게 배포 상태를 실시간으로 공유
- **Docker**: 애플리케이션을 컨테이너로 패키징하여 일관된 환경에서 실행
- **Amazon ECR**: 생성된 이미지를 안전하게 저장하고 배포 시 사용합
- **Amazon ECS**: 여러 컨테이너를 관리하고 확장
- **Amazon ELB**: ELB를 통해 헬스체크를 통해 인스턴스의 상태를 모니터링하고, 무중단 배포
- **Route 53**: ELB와 함께 사용하여 트래픽을 라우팅
- **Vercel**: 프론트엔드 main 브랜치 코드 변경 시 자동으로 배포되어 최신 상태를 유지(프론트만 해당)

코드 변경 후 자동으로 배포가 이루어지며, 서비스 중단 없이 최신 버전의 애플리케이션을 사용자에게 제공



### 백엔드 배포 절차
Expand All @@ -61,22 +112,13 @@
- AWS RDS 콘솔에서 PostgreSQL 인스턴스를 생성하고 설정
- Spring Boot 애플리케이션에서 데이터베이스 연결 설정

### 지속적 통합 및 배포(CI/CD)
- GitHub Actions: 코드 푸시에 대한 자동 빌드 및 테스트
- Slack 알림: 배포 시작 및 완료 시 Slack 채널에 알림
- Docker: 컨테이너 이미지 생성 및 관리
- Amazon ECR: Docker 이미지 저장소
- Amazon ECS: 컨테이너 오케스트레이션 및 배포 자동화
- Vercel: 프론트엔드 자동 배포

## 🔧 서버 구성
경단 프로젝트는 클라이언트, Spring 서버, FastAPI 서버로 구성됩니다.

![스크린샷 2024-07-14 오전 3 19 12](https://github.com/user-attachments/assets/c7d8e970-7698-4405-860e-96da9065f85d)
<img width="847" alt="Screenshot 2024-07-18 at 5 17 12 PM" src="https://github.com/user-attachments/assets/613f1574-c794-4713-b2ce-2e4ff585404e">


- 클라이언트 (NextJS): 사용자의 요청을 Spring 서버에 전달하고, Spring 서버의 응답을 받아 처리합니다.
- Spring 서버: 클라이언트 요청을 처리하고, 인증 및 인가를 담당합니다. FastAPI 서버와 협력하여 예측, 추천 시스템, 기사 재생성 등의 작업을 수행합니다.
- FastAPI 서버: 스케줄링 작업을 수행하며, PostgreSQL에 있는 정보를 기반으로 외부 기사나 Google Custom Search Engine(CSE) 등을 호출하여 예측, 추천 시스템, 기사 재생성 작업을 수행합니다.
- FastAPI 서버: 주로 스케줄링된 작업을 수행하며, PostgreSQL에 있는 정보를 기반으로 외부 기사나 Google Custom Search Engine(CSE) 등을 호출하여 예측, 추천 시스템, 기사 재생성 작업을 수행합니다.


Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package gyeongdan.article.recommend.controller;

import gyeongdan.article.article.dto.ArticleAllResponse;
import gyeongdan.article.recommend.service.RecommendService;
import gyeongdan.util.CommonResponse;
import gyeongdan.util.JwtUtil;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/article")
@RequiredArgsConstructor
public class RecommendController {
private final JwtUtil jwtUtil;
private final RecommendService recommendService;

@GetMapping("/recommend")
public ResponseEntity<?> getUserTypeArticles(@RequestHeader @Nullable String accessToken) { // id : 기사id, access token : 유저의 접근 권한
Optional<Long> userId = Optional.empty();
if (accessToken != null && !accessToken.isEmpty()) {
userId = jwtUtil.getUserId(jwtUtil.resolveToken(accessToken));
}

// 추천 알고리즘
List<ArticleAllResponse> articles = recommendService.recommendArticleById(userId);

return ResponseEntity.ok(new CommonResponse<>(articles, "추천 기사 10개 가져오기 성공", true));
}
}
58 changes: 58 additions & 0 deletions src/main/java/gyeongdan/article/recommend/domain/Recommends.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package gyeongdan.article.recommend.domain;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.*;
import lombok.*;

import java.io.IOException;
import java.util.List;

@Entity
@Table(name = "recommends", schema = "gyeongdan")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Recommends {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long classificationId;

@Column(name = "recommend_article_ids", columnDefinition = "jsonb")
private String recommendArticleIdsJson;

@Transient
private List<Long> recommendArticleIds;

// 활용 메서드들
private static final ObjectMapper objectMapper = new ObjectMapper();

@PostLoad
private void onLoad() {
this.recommendArticleIds = convertJsonToList(this.recommendArticleIdsJson);
}

@PrePersist
@PreUpdate
private void onPersist() {
this.recommendArticleIdsJson = convertListToJson(this.recommendArticleIds);
}

private List<Long> convertJsonToList(String json) {
try {
return objectMapper.readValue(json, new TypeReference<List<Long>>() {});
} catch (IOException e) {
throw new RuntimeException("Failed to convert JSON to List<Long>", e);
}
}

private String convertListToJson(List<Long> list) {
try {
return objectMapper.writeValueAsString(list);
} catch (IOException e) {
throw new RuntimeException("Failed to convert List<Long> to JSON", e);
}
}
}
31 changes: 31 additions & 0 deletions src/main/java/gyeongdan/article/recommend/domain/UserType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package gyeongdan.article.recommend.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;


@Entity
@Table(name = "user_type", schema = "gyeongdan")
@Getter
@Setter
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
public class UserType {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;

private Long userTypeIssueFinder;
private Long userTypeLifestyleConsumer;
private Long userTypeEntertainer;
private Long userTypeTechSpecialist;
private Long userTypeProfessionals;
private Long userType;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package gyeongdan.article.recommend.repository;

import gyeongdan.article.recommend.domain.Recommends;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface RecommendJpaRepository extends JpaRepository<Recommends, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package gyeongdan.article.recommend.repository;

import gyeongdan.article.recommend.domain.UserType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface UserTypeJpaRepository extends JpaRepository<UserType, Long> {
Optional<UserType> findByuserId(Long user_id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package gyeongdan.article.recommend.service;

import gyeongdan.article.article.dto.ArticleAllResponse;
import gyeongdan.article.article.repository.ArticleJpaRepository;
import gyeongdan.article.recommend.domain.Recommends;
import gyeongdan.article.recommend.domain.UserType;
import gyeongdan.article.recommend.repository.RecommendJpaRepository;
import gyeongdan.article.recommend.repository.UserTypeJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class RecommendService {
private final RecommendJpaRepository recommendJpaRepository;
private final UserTypeJpaRepository userTypeJpaRepository;
private final ArticleJpaRepository articleJpaRepository;

public List<ArticleAllResponse> recommendArticleById(Optional<Long> userId) {
if (userId.isEmpty()) {
throw new IllegalArgumentException( "아직 유형검사를 하지 않은 유저입니다.");
}

// (1) 사용자 id에 해당하는 UserType을 가져옴
UserType userType = userTypeJpaRepository.findByuserId(userId.get())
.orElseThrow(() -> new IllegalArgumentException( "아직 유형검사를 하지 않은 유저입니다."));

// (2) UserType에서 가장 값이 높은 3개의 타입값을 추출하기
Map<String, Long> userTypeValues = new HashMap<>();
userTypeValues.put("ISSUE_FINDER", userType.getUserTypeIssueFinder());
userTypeValues.put("LIFESTYLE_CONSUMER", userType.getUserTypeLifestyleConsumer());
userTypeValues.put("ENTERTAINER", userType.getUserTypeEntertainer());
userTypeValues.put("TECH_SPECIALIST", userType.getUserTypeTechSpecialist());
userTypeValues.put("PROFESSIONALS", userType.getUserTypeProfessionals());

List<String> top3UserTypes = userTypeValues.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(3)
.map(Map.Entry::getKey)
.collect(Collectors.toList());

// (3) top3UserTypes를 기반으로 classification_id를 결정
int classificationId = determineClassificationId(top3UserTypes);

// (4) classification_id에 해당하는 추천 기사 ID들을 가져옴
Recommends recommends = recommendJpaRepository.findById((long) classificationId)
.orElseThrow(() -> new IllegalArgumentException("Invalid classification ID"));

// 추천 기사 ID들에 해당하는 ArticleAllResponse 리스트를 반환
return fetchArticlesByIds(recommends.getRecommendArticleIds());
}

private int determineClassificationId(List<String> top3UserTypes) {
// 데이터 매핑 (파이썬의 데이터 프레임에 해당)
Map<Integer, Map<String, Integer>> data = new HashMap<>();
data.put(1, Map.of("ISSUE_FINDER", 1, "LIFESTYLE_CONSUMER", 1, "ENTERTAINER", 1, "TECH_SPECIALIST", 0, "PROFESSIONALS", 0));
data.put(2, Map.of("ISSUE_FINDER", 1, "LIFESTYLE_CONSUMER", 1, "ENTERTAINER", 0, "TECH_SPECIALIST", 1, "PROFESSIONALS", 0));
data.put(3, Map.of("ISSUE_FINDER", 1, "LIFESTYLE_CONSUMER", 1, "ENTERTAINER", 0, "TECH_SPECIALIST", 0, "PROFESSIONALS", 1));
data.put(4, Map.of("ISSUE_FINDER", 1, "LIFESTYLE_CONSUMER", 0, "ENTERTAINER", 1, "TECH_SPECIALIST", 1, "PROFESSIONALS", 0));
data.put(5, Map.of("ISSUE_FINDER", 1, "LIFESTYLE_CONSUMER", 0, "ENTERTAINER", 1, "TECH_SPECIALIST", 0, "PROFESSIONALS", 1));
data.put(6, Map.of("ISSUE_FINDER", 1, "LIFESTYLE_CONSUMER", 0, "ENTERTAINER", 0, "TECH_SPECIALIST", 1, "PROFESSIONALS", 1));
data.put(7, Map.of("ISSUE_FINDER", 0, "LIFESTYLE_CONSUMER", 1, "ENTERTAINER", 1, "TECH_SPECIALIST", 1, "PROFESSIONALS", 0));
data.put(8, Map.of("ISSUE_FINDER", 0, "LIFESTYLE_CONSUMER", 1, "ENTERTAINER", 1, "TECH_SPECIALIST", 0, "PROFESSIONALS", 1));
data.put(9, Map.of("ISSUE_FINDER", 0, "LIFESTYLE_CONSUMER", 1, "ENTERTAINER", 0, "TECH_SPECIALIST", 1, "PROFESSIONALS", 1));
data.put(10, Map.of("ISSUE_FINDER", 0, "LIFESTYLE_CONSUMER", 0, "ENTERTAINER", 1, "TECH_SPECIALIST", 1, "PROFESSIONALS", 1));

// (3) top3UserTypes를 기반으로 classification_id를 결정
for (Map.Entry<Integer, Map<String, Integer>> entry : data.entrySet()) {
Map<String, Integer> userTypeMap = entry.getValue();
boolean matches = true;
for (String userType : top3UserTypes) {
if (userTypeMap.getOrDefault(userType, 0) != 1) {
matches = false;
break;
}
}
if (matches) {
return entry.getKey();
}
}

throw new IllegalArgumentException("User type 3개를 기반으로 classification_id를 결정할 수 없습니다!");
}

// (4) ArticleAllResponse 리스트를 반환하는 메서드
private List<ArticleAllResponse> fetchArticlesByIds(List<Long> articleIds) {
return articleIds.stream()
.map(articleJpaRepository::findById)
.filter(Optional::isPresent)
.map(Optional::get)
.map(article -> new ArticleAllResponse(
article.getId(),
article.getSimpleTitle(),
article.getSimpleContent(),
article.getViewCount(),
article.getCategory(),
Optional.ofNullable(article.getImageUrl()),
article.getPublishedAt()
))
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ public ResponseEntity<?> getShortForm(@RequestParam Long id) {
return ResponseEntity.ok(new CommonResponse<>(new ShortFormDetailResponse(shortForm), "숏폼 조회 성공", true));
}

// 숏폼 html 상세 조회
@GetMapping("/html")
public ResponseEntity<?> getShortFormHtml(@RequestParam Long id) {
ShortForm shortForm = shortFormService.getShortForm(id);
return ResponseEntity.ok(new CommonResponse<>(shortForm.getGraph_html(), "숏폼 조회 성공", true));

// 가장 최근 숏폼 3개 가져오기
@GetMapping("/recent")
public ResponseEntity<?> getRecentShortForms() {
List<ShortFormDetailResponse> shortForms = shortFormService.getRecentShortForms();
return ResponseEntity.ok(new CommonResponse<>(shortForms, "숏폼 조회 성공", true));
}
}
1 change: 0 additions & 1 deletion src/main/java/gyeongdan/shortform/domain/ShortForm.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@ public class ShortForm {
private Long id;
private String title;
private String content;
private String graph_html;
private Timestamp createdAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

import gyeongdan.shortform.domain.ShortForm;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ShortFormJpaRepository extends JpaRepository<ShortForm, Long> {

@Query(value = "SELECT * FROM gyeongdan.api_visualization sf WHERE sf.id != (SELECT MAX(sf2.id) FROM gyeongdan.api_visualization sf2) ORDER BY sf.created_at DESC LIMIT 3", nativeQuery = true)
List<ShortForm> findTop3AfterLatest();
}
Loading

0 comments on commit ac7f143

Please sign in to comment.