diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e877fbbc8..a26def102a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -34,6 +34,7 @@ jobs: echo REDIS_PORT=${{ secrets.REDIS_PORT }} >> .env echo S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }} >> .env echo S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }} >> .env + echo CLOUD_FRONT_URL=${{ secrets.CLOUD_FRONT_URL }} >> .env echo SECRET_KEY_BASE=${{ secrets.SECRET_KEY_BASE }} >> .env echo SERVER_NAME=${{ secrets.SERVER_NAME }} >> .env echo SERVER_URL=${{ secrets.SERVER_URL }} >> .env diff --git a/back-gateway/src/main/java/com/gateway/backgateway/filter/AuthorizationHeaderFilter.java b/back-gateway/src/main/java/com/gateway/backgateway/filter/AuthorizationHeaderFilter.java index f1e02bd99a..e7c344ac8b 100644 --- a/back-gateway/src/main/java/com/gateway/backgateway/filter/AuthorizationHeaderFilter.java +++ b/back-gateway/src/main/java/com/gateway/backgateway/filter/AuthorizationHeaderFilter.java @@ -38,7 +38,6 @@ public GatewayFilter apply(Config config) { GatewayFilter filter = (exchange, chain) -> { String requiredRole = config.getRequiredRole(); ServerHttpRequest request = exchange.getRequest(); - log.info("요청한 uri : " + request.getURI()); if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) throw JwtTokenInvalidException.INSTANCE; @@ -46,6 +45,8 @@ public GatewayFilter apply(Config config) { String token = request.getHeaders() .getFirst(HttpHeaders.AUTHORIZATION).replace("Bearer ", ""); + log.info("Authorization Token : {}", token); + if (!validateToken(token)) { throw JwtTokenInvalidException.INSTANCE; } diff --git a/back-gateway/src/main/java/com/gateway/backgateway/filter/GlobalLoggingFilter.java b/back-gateway/src/main/java/com/gateway/backgateway/filter/GlobalLoggingFilter.java new file mode 100644 index 0000000000..edf3001d4d --- /dev/null +++ b/back-gateway/src/main/java/com/gateway/backgateway/filter/GlobalLoggingFilter.java @@ -0,0 +1,79 @@ +package com.gateway.backgateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpRequestDecorator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +@Slf4j +@Configuration +public class GlobalLoggingFilter { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Bean + @Order(-1) + public GlobalFilter preLoggingFilter() { + return (exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest(); + + log.info("Global Filter Start: request id -> {}", request.getId()); + log.info("Request: {} {}", request.getMethod(), request.getURI()); + + if (request.getHeaders().containsKey("Authorization")) { + log.info("Authorization: {}", request.getHeaders().get("Authorization")); + } + + if (request.getMethod().toString().equals("GET")) { + return chain.filter(exchange); + } + + return DataBufferUtils.join(request.getBody()) + .flatMap(dataBuffer -> { + byte[] bodyBytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bodyBytes); + DataBufferUtils.release(dataBuffer); + String bodyString = new String(bodyBytes, StandardCharsets.UTF_8); + + String jsonBody; + try { + Object json = objectMapper.readValue(bodyString, Object.class); + jsonBody = objectMapper.writeValueAsString(json); + } catch (Exception e) { + jsonBody = bodyString; + } + + log.info("Request Body: {}", jsonBody); + + ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(request) { + @Override + public Flux getBody() { + DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bodyBytes); + return Flux.just(buffer); + } + }; + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + }); + }; + } + + @Bean + @Order(Ordered.LOWEST_PRECEDENCE) + public GlobalFilter postLoggingFilter() { + return (exchange, chain) -> chain.filter(exchange).then(Mono.fromRunnable(() -> { + log.info("Response: {}", exchange.getResponse().getStatusCode()); + })); + } +} diff --git a/back-gateway/src/main/resources/application.yml b/back-gateway/src/main/resources/application.yml index 1574e51d12..0e9284314a 100644 --- a/back-gateway/src/main/resources/application.yml +++ b/back-gateway/src/main/resources/application.yml @@ -8,11 +8,19 @@ spring: data: 0 + jwt: secret: key: ${JWT_SECRET} + server: port: 8081 chatbot-url: ${CHATBOT_URL} +logging: + level: + root: INFO + com: + gateway: + backgateway: DEBUG \ No newline at end of file diff --git a/back/.env.example b/back/.env.example index fe7c82491a..259aefbf2e 100644 --- a/back/.env.example +++ b/back/.env.example @@ -1,6 +1,42 @@ # JWT Secret Key JWT_SECRET= +JWT_ACCESS_EXPIRATION_TIME= +JWT_REFRESH_EXPIRATION_TIME= # HMAC HMAC_SECRET= -HMAC_ALGORITHM= \ No newline at end of file +HMAC_ALGORITHM= + +#DeepL +DeepL_API_KEY= + +#MYSQL +DB_ENDPOINT= +DB_PORT= +DB_NAME= +MYSQL_USERNAME= +MYSQL_PASSWORD= + +#TEST +TEST_KEY= + +#Azure +Azure_API_KEY= + +##REDIS +REDIS_HOST= +REDIS_PORT= + +## S3 +S3_ACCESS_KEY= +S3_SECRET_KEY= +CLOUD_FRONT_URL= + +## Ruby On Rails Production Key +SECRET_KEY_BASE= + +## Nginx ENV +SERVER_NAME= + +## CHAT BOT ENV +CHATBOT_URL= diff --git a/back/README.md b/back/README.md new file mode 100644 index 0000000000..91925ccffe --- /dev/null +++ b/back/README.md @@ -0,0 +1,173 @@ +### 기능적 고려 사항 + +### 성능적 고려 사항 + +#### 캐싱 + +유저들이 자주 접근하는 데이터는 캐싱하는 것이 좋다. +예를 들어서, 공지사항 목록이나 Q&A 목록인 경우, 자주 바뀌지도 않을텐데 유저들이 해당 게시판에 접속할 때마다 매번 RDB에서 목록을 읽어오는 것은 비효율적이다. +따라서, Redis같은 곳에 캐싱해두고, 똑같은 요청이 들어오면 빠르게 Redis에서 뽑아가도록 구성했다. +캐싱 전략은 다음과 같다. + +캐싱 전략은 Look Aside와 Write Around 전략의 조합을 택하였다. +Read 전략은 Look Aside 전략을 활용했다. 우선적으로 Redis에서 읽어오고, cache miss가 발생했을 때만, RDS에서 읽어오고 다시 redis에 저장하는 방식이다. +Write 전략은 Write Around 전략을 활용했다. 데이터를 쓸 때, Redis에 저장하지 않고, 반드시 RDB에 저장하고 기존 캐시는 무효화하는 전략이다. + +실제 캐싱을 적용하기 전과 후의 시간을 측정해보았다. + +캐싱 전에는 메서드를 실행하는데 414ms가 걸렸다. + + + +하지만 캐싱 후에는 12ms로 단축되었다. + + + +그런데 캐싱된 데이터를 받으면, class 정보에 대한 field가 json에 추가되는 현상이 있어서 추가 보류를 하였다. + +
+ +#### 비동기 처리 + +모든 요청을 동기적으로 처리하는 것은 매우 비효율적이다. 특히, 이미지를 업로드하거나, 번역 API 같은 다른 API에 요청을 보내거나, 크롤링 등은 매우 오래걸리는 작업이다. 이 모든 작업을 동기적으로 처리하면, 성능에 매우 치명적일 것이다. 그래서, 해당 기능들을 사용하는 경우에는 Async를 통한 비동기 처리를 실시하였다. + +이 비동기처리를 위해서 Spring에서 ThreadExecutor를 이용한다. 그런데, 기본 Async Executor SimpleAsyncTaskExecutor인데, 이는 비동기 작업마다 새로운 스레드를 생성한다. 이로 인해 리소스 낭비, 성능 저하, 스케일링 문제 등이 발생할 수 있다. 왜냐하면 Thread Pool 방식 Executor가 아니라서 스레드 재사용을 하지 않기 때문이다. 그래서 실행시간이 짧은 많은 량의 Task를 처리할 때 불리하다. 리소스 측면에서는 각 비동기 작업마다 새로운 스레드를 생성하므로, 동시에 많은 비동기 작업이 요청되면 매번 많은 스레드가 생성된다. 따라서 CPU와 메모리 리소스의 사용량이 과도하게 증가하는 문제가 발생할 수 있다. 성능 저하 면에서는 스레드를 생성하고 소멸시키는 데는 많은 시간과 리소스가 소요된다. 각 작업마다 스레드를 생성하면 이런 오버헤드가 계속 발생하게 되고, 전체적인 시스템 성능에 영향을 끼칠 수 있다. 또한, SimpleAsyncTaskExecutor는 스레드 수에 대한 제한이 없다. 따라서 동시에 많은 요청이 들어올 경우 스레드 수가 무한정으로 제어할 수 없는 수준으로 증가할 수 있으며, 이는 곧 OutOfMemoryError 등의 문제를 일으킬 수 있다. + + + +그래서, 위 사진과 같이 Async의 Executor를 ThreadPool방식의 Executor로 설정했다. 이는, 사용할 스레드 풀에 속한 기본 스레드 수인 corepoolsize, corepoolsize가 가득 찬 상태에서 더이상 추가 처리가 불가능 할때, 대기하는 장소 크기인 queuecapacity, 스레드 풀이 확장될 수 있는 스레드의 상한선, 즉 스레드 수의 상한선인 maxpoolsize를 조정하여 ThreadPool방식의 Async Executor를 구성했다. + +#### Race Condition 해결 + +프로젝트에서 답변의 추천기능을 구현하면서 추천 / 추천 해제 기능을 만들었다. 문제가 있다면 추천수, 조회수와 같은 기능은 하나의 필드에 대해 수많은 트랜젝션 요청이 오가는 기능이기 때문에 이경우 자원에 접근하는 것에 대한 Race Condition, 동시성 문제가 발생하게 된다. 이러한 경우를 해결하기 위해서 Redis를 사용하였다. Redis는 싱글 스레드로 자원에 대한 Race Condition 문제를 해결할 수 있기 때문이다. Redis에 요청을 보낼 countTemplate를 선언하고 이를 통해서 Redis에 객체와 답변의 추천수를 저장하고 일정시간마다 이렇게 Cache에 저장된 내용을 DB에 반영시키도록 하였다. 이렇게 Redis에 저장된 변경 사항은 30초 간격으로 DB에 반영되는 방식으로 구현하였다. + +현재 방법도 동시성 문제를 해결하는 방법이지만 내가 추천한 답변의 추천수가 즉시 반영되어 보여지지는 않는다. 이는 데이터를 읽어올때는 캐시에서 찾는 방식이 아니기 때문이다. 추후에는 캐시에서 값을 찾도록 구현하고 Redis Lock과 Redis Trasaction을 사용한 관리나 RDS의 트랜잭션 단위를 조절하는 방법도 고려해봐야 한다. + +#### N+1 문제 + +여러 참조관계를 가진 테이블이 존재하고 비즈니스 로직에서 이들을 Join하여 호출하는 상황이 많이 있었으며 이때 N+1문제가 발생하였다. 이를 해결하기 위해 Fetch Join을 사용하거나 Projection 주입을 사용하는 경우의 Inner Join과 같이 여러 경우의 N+1의 해결 방법을 모색하여 적용하였다. 이로 인해 서버 비즈니스 로직의 성능향상이 이루어질 수 있었다. 이 과정에서 여러 시행 착오가 있었다. 기존의 방식은 Projection을 사용하여 DTO를 바탕으로 JPA 검색을 사용하는 방식이었는데 이 경우 Fetch Join이나 EntityGraph로는 해결이 불가능하다. @Projection을 사용한 해결 방안도 있으나 좀 더 고민해본 결과 Join의 목적에서 N+1문제를 야기할 필드값이 큰 문제가 없어 InnerJoin으로도 충분히 해결되는 범주였다는 것을 깨달았다. + +### 안정성 고려 사항 + +
+ +#### 사용자 트래픽 제한 + + + + + +실제로 위에 사진처럼 + + +#### 사용자 업로드 파일 용량 제한 + +
+ +### 보안적 고려 사항 + +
+ +#### 사용자 비밀번호 암호화 + +#### JWT를 통한 Authentication + +#### Token 탈취 상황 예방 + +#### AccessToken Ban + +#### HMAC + +#### 환경변수 적용 + +
+ +### 테스트 고려 사항 + +#### Test Container + +Test Containers는 통합 테스트에서 사용되는 자바 라이브러리로, Docker 컨테이너를 통해 데이터베이스, 메시징 큐, 웹 서버 등의 외부 리소스를 손쉽게 설정하고 관리할 수 있다. 이를 통해 개발자는 테스트 환경을 실제 운영 환경과 유사하게 구성할 수 있으며, 테스트의 신뢰성과 재현성을 높일 수 있다. 주요 특징으로는 코드 내에서 직접 Docker 컨테이너를 제어할 수 있는 API 제공, 다양한 프리셋과 유연한 구성 옵션, 테스트 실행 시 필요한 컨테이너를 자동으로 시작하고 종료하는 기능, 각종 데이터베이스(MySQL, PostgreSQL 등)와의 통합 지원, 그리고 쿠버네티스 환경에서도 사용 가능한 확장성 등이 있다. Test Containers는 JUnit과 같은 테스트 프레임워크와도 통합되어, 단위 테스트와 통합 테스트를 쉽고 일관성 있게 작성할 수 있다는 장점도 있다. + +우리가 실제 서비스하는 RDS를 가지고 테스트를 진행한다면, 매우 위험할 것이다. 따라서, Test Container를 이용하여 독립된 환경에서 안전하게 테스트를 진행하도록 하였다. + +#### Jacoco + +JaCoCo는 자바 애플리케이션의 코드 커버리지를 측정하는 데 사용되는 오픈 소스 라이브러리이다. 코드 커버리지는 테스트가 애플리케이션의 소스 코드에서 얼마나 많은 부분을 실행하는지를 나타내는 지표로, 소프트웨어 테스트의 품질을 평가하는 중요한 요소이다. JaCoCo는 개발자들이 테스트의 효과를 분석하고, 테스트 범위를 확장하여 보다 신뢰성 있는 소프트웨어를 개발할 수 있도록 돕는다. + +Jacoco를 이용하여 쉽게 테스트 코드 커버리지를 측정하고, 이를 html로 추출해서 팀원들이 쉽게 볼 수 있도록 구성하기 위해 택하였다. + +#### Apache Jmeter + +Apache JMeter는 자바 기반의 오픈 소스 소프트웨어로, 웹 애플리케이션 및 다양한 서비스의 성능 테스트와 부하 테스트를 수행하기 위해 사용된다. JMeter는 초기에는 웹 애플리케이션을 테스트하기 위해 개발되었으나, 현재는 데이터베이스, FTP 서버, 웹 서비스, JMS, LDAP, SMTP, POP3, IMAP 서버 등 다양한 프로토콜 또한 지원한다. + +프로젝트에서 동시성 문제 테스트와 같이 대규모 부하 테스트를 위해서 사용하게 되었다. + +#### JUnits + +JUnit은 자바 프로그래밍 언어를 위한 오픈 소스 단위 테스트 프레임워크로, 개발자가 자바 애플리케이션의 소프트웨어 테스트를 자동화하고 효율적으로 수행할 수 있게 합니다. JUnit은 테스트 주도 개발(TDD)을 촉진하며, 코드 변경 시 발생할 수 있는 버그를 조기에 발견하고 수정하는 데 유용하다. 주요 기능으로는 어노테이션을 사용한 간편한 테스트 정의, Test Suite을 통한 여러 테스트의 그룹화, 다양한 메서드를 통한 테스트 결과 검증, 테스트 실행 중 예외 상황 처리, 그리고 테스트 실행 결과를 시각적으로 보여주는 리포트 기능 등이 있다. JUnit은 모듈화된 코드 테스트를 장려하며, 지속적 통합(CI) 도구와의 통합을 통해 자동화된 빌드 프로세스에서도 중요한 역할을 한다. 직관적이고 사용하기 쉬운 API 덕분에 개발자들이 테스트 케이스를 작성하고 유지보수하는 데 걸리는 시간을 줄이는 데 도움이 된다. + +
+ +### 현실적 제한 및 개선 필요 사항 + +
+ +#### 검색 기능 개선 + +현재 게시물 및 공지사항 검색은 제목으로만 검색된다. 하지만, 이보다 제목 + 내용으로 검색이 가능한 것이 더 좋을 것이다. 이를 위해서 SQL Like 연산자로 내용까지 검색이 가능하긴 하나, Like 연산자는 선형 연산자이기 때문에 성능에 매우 치명적이다. 따라서, ElasticSearch를 이용해서 빠르게 검색하는 것이 더 좋을 것이다. ElasticSearch는 분산 검색 및 분석 엔진으로, 대규모 데이터에서 빠른 전체 텍스트 검색을 지원한다. 추후, ElasticSearch를 통해 우리 RDB에 저장된 글들을 역색인하여 거의 실시간에 가깝게 검색하도록 개선할 것이다. + +
+ +#### 인스턴스 성능의 한계 + + + +현재 우리서버는 Free Tier를 사용하여 견딜 수 있는 트래픽의 한계가 있는 상황이다. 이를 해결하기 위해서 수직적 확장 또는 수평적 확장을 고려할 수 있다. 현재 Free Tier 성능이 아닌 더 좋은 EC2 인스턴스를 사용하거나, 여러개의 EC2를 만들어 Load Balancing을 적용하여 Scale Out적인 확장을 할 수도 있다. 하지만, 현재는 비용적인 문제로 불가능한 상황이다. + +
+ +#### 무중단 배포 + +현재 우리 서비스는 Git Actions를 이용한 CI/CD를 적용하고 있다. 하지만, 현재 방식은 새 버전으로 서버가 배포될 때 반드시 1~2분정도 서버가 중단될 수 밖에 없다. 이는, 실제 서비스할 때 매우 치명적이다. 따라서, 무중단 배포가 반드시 필요하다. + +
+ + + +
+ +무중단 배포 방식으로는 Rolling, Canary, Blue-Green 배포 방식 등 여러가지 방법이 있지만, 만약 우리가 무중단 배포를 구축한다면 Blue-Green 배포 방식을 적용할 것이다. Blue-Green이란 두 개의 동일한 환경인 "블루"와 "그린"을 사용하여 새로운 버전의 소프트웨어를 배포하고 롤백하는 방식이다. 블루 그린 배포 방식은 가장 많이 쓰이는 무중단 배포 방식이며, 카나리에 비해서 구현도 간단하고, Rolling 방식보다 훨씬 안정적이다. 따라서, 추후 Blue-Green 무중단 배포 환경을 구축할 계획이다. + +
+ +#### 로드 밸런싱 + +서버를 단일로 구성하면, 많은 트래픽이 몰렸을 때 감당하기 힘들 것이다. 따라서, 서버를 여러개 만들고, Load Balacning하는게 좋은 선택지일 것이다. 하지만, 로드 밸런싱을 하기엔 비용적인 문제 때문에 환경 구축이 어렵다. 왜냐하면, 여러개의 EC2를 띄워야되기 때문이다. 그래서, 어쩔 수 없이 단일 서버로 운영하게 되었지만, 추후 사용자가 늘어나면 로드 밸런싱을 적용할 계획이다. + +
+ +#### 캐싱 + +이미지를 받아오는 S3도 캐싱을 무조건 해야한다. 아니면 S3 비용이 굉장히 많아질 것이고 응답시간이 길어진다. 일반적으로 AWS CloudFront같은 CDN 서버를 두는 경우가 많은데, 이 부분은 시간이 부족해서 하지 못했다. + +
+ +#### 모니터링 및 로깅 강화 + + + +우리 서비스의 전반적인 로깅을 하는 것도 중요하다. 현재 로깅은 Nginx 단에서 파일 저장으로 한번, API Gateway단에서 요청 url, body, Authorization으로 한번, 각 MSA 서버 단에서 한번 이루어진다. 하지만, 애플리케이션 단에서의 로그는 파일로 저장하고 있지 않아 우리가 직접 컨테이너에 접근해서 하나하나 찾아봐야한다. 또한, MSA 구조다 보니깐, 각각 컨테이너에 직접 접근해서 봐야한다는 문제점이 있다. 하지만, 이런식으로 진행하면 애플리케이션 로그를 검색하고 파악하는데 시간을 더 써서 서비스에 매우 치명적이다. 따라서, Kafka + ELK Stack을 통하여 비동기적으로 로그 데이터를 ELK Stack으로 전송하고, 이를 분석한 후, 대시보드 형태의 시각적인 데이터로 바꿔주는 시스템이 구축되어야 할 것이다. + + + +또한, 서비스를 운영할 때 모니터링 및 성능 관리는 매우 중요하다. 우리가 24시간 365일 컴퓨터 앞에 상주하여 서버 모니터링을 할 수 없기 때문이다. 따라서, Prometheus + Grafana나 Datadog 등을 이용하여 우리 서비스를 모니터링 하는 환경은 필수적이다. 이런 환경을 구축해두면 서버의 CPU나 메모리 사용량이 급증 했을 때, 우리 팀 Slack으로 결과를 전송하고 대시보드로 현황을 쉽게 현황을 파악할 수 있을 것이다. + +
+ +#### 컨테이너 오케스트라제이션 + + + +현재 우리 서비스는 MSA 구조에 가까운 형태여서 수많은 Container들이 돌아가고 있다. 돌아가는 Container만 세보더라도, Redis, Spring, Spring Cloud Gateway, Ruby On Rails, Nginx, Certbot, FastAPI 벌써 7가지가 있다. 그런데 여기에 앞서 언급한 Kafka, Grafana, Prometheus, DB Replication, ELK Stack 등 까지 적용하면 수많은 컨테이너가 돌아갈 것이다. 거기에 로드 밸린성까지 적용한다면 우리가 이 컨테이너들을 한번에 관리하는 것은 무리일 것이다. 그래서, EKS, Docker Swarm, Kubernetes같은 자동으로 컨테이너 장애 복구를 도와주는 컨테이너 오케스트라제이션이 필요하다. + + diff --git a/back/docker-compose.yml b/back/docker-compose.yml index 62aee99004..69fc6e0cc6 100644 --- a/back/docker-compose.yml +++ b/back/docker-compose.yml @@ -1,5 +1,4 @@ ## Test Version - version: '3' networks: backend_network: diff --git a/back/env.sh b/back/env.sh deleted file mode 100644 index 41cba56fb7..0000000000 --- a/back/env.sh +++ /dev/null @@ -1,6 +0,0 @@ -ENV_FILE=".env" - -if test -f "$ENV_FILE"; then - export "$(grep -v '^#' .env | xargs -d '\n')" -fi -echo "DONE" \ No newline at end of file diff --git a/back/nginx/test.conf.template b/back/nginx/test.conf.template deleted file mode 100644 index 4a87ac928f..0000000000 --- a/back/nginx/test.conf.template +++ /dev/null @@ -1,42 +0,0 @@ -upstream spring_gateway { - server spring_gateway:8081; -} - -server { - listen 80; - server_name ${SERVER_NAME}; - access_log off; - server_tokens off; - client_max_body_size 1G; - - location /.well-known/acme-challenge/ { - allow all; - root /var/www/certbot; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - server_name ${SERVER_NAME}; - access_log off; - server_tokens off; - client_max_body_size 1G; - - ssl_certificate /etc/letsencrypt/live/${SERVER_NAME}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/${SERVER_NAME}/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://spring_gateway; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Host $server_name; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/back/nginx/tool/encrypt.sh b/back/nginx/tool/encrypt.sh new file mode 100644 index 0000000000..c748e38e79 --- /dev/null +++ b/back/nginx/tool/encrypt.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +if ! [ -x "$(command -v docker-compose)" ]; then + echo 'Error: docker-compose is not installed.' >&2 + exit 1 +fi + +domains={SERVER_NAME} +rsa_key_size=4096 +data_path="./data/certbot" +email="" # Adding a valid address is strongly recommended +staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits + +if [ -d "$data_path" ]; then + read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision + if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then + exit + fi +fi + + +if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then + echo "### Downloading recommended TLS parameters ..." + mkdir -p "$data_path/conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" + echo +fi + +echo "### Creating dummy certificate for $domains ..." +path="/etc/letsencrypt/live/$domains" +mkdir -p "$data_path/conf/live/$domains" +sudo docker compose run --rm --entrypoint "\ + openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ + -keyout '$path/privkey.pem' \ + -out '$path/fullchain.pem' \ + -subj '/CN=localhost'" certbot +echo + + +echo "### Starting nginx ..." +sudo docker compose up --force-recreate -d nginx +echo + +echo "### Deleting dummy certificate for $domains ..." +sudo docker compose run --rm --entrypoint "\ + rm -Rf /etc/letsencrypt/live/$domains && \ + rm -Rf /etc/letsencrypt/archive/$domains && \ + rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot +echo + + +echo "### Requesting Let's Encrypt certificate for $domains ..." +#Join $domains to -d args +domain_args="" +for domain in "${domains[@]}"; do + domain_args="$domain_args -d $domain" +done + +# Select appropriate email arg +case "$email" in + "") email_arg="--register-unsafely-without-email" ;; + *) email_arg="--email $email" ;; +esac + +# Enable staging mode if needed +if [ $staging != "0" ]; then staging_arg="--staging"; fi + +sudo docker compose run --rm --entrypoint "\ + certbot certonly --webroot -w /var/www/certbot \ + $staging_arg \ + $email_arg \ + $domain_args \ + --rsa-key-size $rsa_key_size \ + --agree-tos \ + --force-renewal" certbot +echo + +echo "### Reloading nginx ..." +sudo docker compose exec nginx nginx -s reload \ No newline at end of file diff --git a/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java b/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java index f2113c861c..e3cd315cfd 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/controller/AnnouncementController.java @@ -2,7 +2,6 @@ import com.example.capstone.domain.announcement.dto.AnnouncementListResponse; import com.example.capstone.domain.announcement.dto.AnnouncementListWrapper; -import com.example.capstone.domain.announcement.dto.AnnouncementSearchListRequest; import com.example.capstone.domain.announcement.entity.Announcement; import com.example.capstone.domain.announcement.service.AnnouncementCallerService; import com.example.capstone.domain.announcement.service.AnnouncementSearchService; diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java index 7b597f1989..ba98207686 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCallerService.java @@ -15,7 +15,7 @@ public class AnnouncementCallerService { private final AnnouncementCrawlService announcementCrawlService; - @Scheduled(cron = "0 0 0 * * *") + @Scheduled(cron = "0 0 12,18 * * *") public void crawlingAnnouncement() { announcementCrawlService.crawlKookminAnnouncement(KOOKMIN_OFFICIAL); announcementCrawlService.crawlInternationlAnnouncement(INTERNATIONAL_ACADEMIC); diff --git a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java index 7abed22946..23279ae631 100644 --- a/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java +++ b/back/src/main/java/com/example/capstone/domain/announcement/service/AnnouncementCrawlService.java @@ -55,7 +55,7 @@ public void crawlKookminAnnouncement(AnnouncementUrl url) { String dateStr = announcement.select(".board_etc > span").first().text(); LocalDate noticeDate = LocalDate.parse(dateStr, formatter); - if (noticeDate.equals(currentDate) || noticeDate.isEqual(currentDate.minusDays(1))) { + if (noticeDate.equals(currentDate)) { String href = announcement.attr("href"); links.add(href); } @@ -251,7 +251,7 @@ public void crawlSoftwareAnnouncement(AnnouncementUrl url) { String dateStr = announcement.select(".date").text(); LocalDate noticeDate = LocalDate.parse(dateStr, formatter); - if (noticeDate.equals(currentDate) || noticeDate.isAfter(currentDate.minusDays(4))) { + if (noticeDate.equals(currentDate)) { String href = announcement.select(".subject > a").attr("href"); links.add(href.substring(1, href.length())); } diff --git a/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java b/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java index a14f0702d7..61f1be7925 100644 --- a/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java +++ b/back/src/main/java/com/example/capstone/domain/qna/service/ImageService.java @@ -36,6 +36,8 @@ public class ImageService { @Value("${s3.bucket.name}") private String bucketName; + @Value("${s3.cloud.front.url}") + private String cloudFront; private final QuestionImageRepository questionImageRepository; @@ -125,7 +127,7 @@ private String uploadImageToS3(MultipartFile image) throws IOException { is.close(); } - return amazonS3.getUrl(bucketName, s3FileName).toString(); + return "https://" + cloudFront + "/" + s3FileName; } public void deleteImageFromS3(String imageAddress) { diff --git a/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java b/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java index 7a6fa2e329..289601e0ac 100644 --- a/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java +++ b/back/src/main/java/com/example/capstone/global/config/AsyncConfig.java @@ -1,6 +1,5 @@ package com.example.capstone.global.config; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; diff --git a/back/src/main/resources/application.properties b/back/src/main/resources/application.properties index 0e40e12825..d81e905ce4 100644 --- a/back/src/main/resources/application.properties +++ b/back/src/main/resources/application.properties @@ -25,6 +25,7 @@ s3.secret.key=${S3_SECRET_KEY} s3.bucket.name=capstone-30-backend s3.region.static=ap-northeast-2 s3.stack.auto=false +s3.cloud.front.url=${CLOUD_FRONT_URL} spring.servlet.multipart.resolve-lazily=true spring.servlet.multipart.max-file-size=100MB diff --git a/docker-compose.yml b/docker-compose.yml index 111884f6b6..f9c47768d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,6 @@ ## Production Deploy Version version: '3' -networks: - backend_network: - ssl_network: services: redis: @@ -34,6 +31,7 @@ services: ports: - "8081:8081" networks: + - gateway_network - backend_network spring: @@ -87,7 +85,7 @@ services: - "ruby" command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" networks: - - backend_network + - gateway_network - ssl_network certbot: @@ -99,3 +97,11 @@ services: entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" networks: - ssl_network + +networks: + gateway_network: + driver: bridge + backend_network: + driver: bridge + ssl_network: + driver: bridge \ No newline at end of file