Skip to content

Commit 6e99c2a

Browse files
committed
feat: 로컬 개발 환경 Docker 전환 및 전체 버그 수정
1 parent 4f46f2a commit 6e99c2a

27 files changed

Lines changed: 430 additions & 56 deletions

File tree

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,8 @@ application-local.yml
4545
src/main/resources/keystore.p12
4646
src/test/resources/application-s3-secrets.properties
4747
spy.log
48-
htmlReport
48+
htmlReport
49+
50+
# 로컬 개발용 SSL 인증서 개인 키 제외
51+
nginx/certs/*.key
52+
nginx/certs/*.crt

compose.yaml

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
services:
2+
nginx:
3+
image: nginx:1.27
4+
container_name: pullit-local-nginx
5+
restart: unless-stopped
6+
ports:
7+
- "443:443"
8+
volumes:
9+
- ./nginx/conf.d:/etc/nginx/conf.d
10+
- ./nginx/certs:/etc/nginx/certs
11+
depends_on:
12+
- pullit-local-api
13+
networks:
14+
- pullit-local-network
15+
216
pullit-local-api:
317
build: .
418
container_name: pullit-local-api
519
restart: unless-stopped
6-
ports:
7-
- "8080:8080"
20+
ports: []
821
environment:
922
- SPRING_PROFILES_ACTIVE=local
1023
- DB_URL=jdbc:mariadb://mariadb:3306/mydatabase?createDatabaseIfNotExist=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
@@ -14,6 +27,14 @@ services:
1427
- RABBITMQ_HOST=rabbitmq
1528
- SPRING_RABBITMQ_USERNAME=guest
1629
- SPRING_RABBITMQ_PASSWORD=guest
30+
- APP_GEMINI_APIKEY=${APP_GEMINI_APIKEY}
31+
- AWS_REGION=${AWS_REGION}
32+
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
33+
- KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET}
34+
- KAKAO_REDIRECT_URI=${KAKAO_REDIRECT_URI}
35+
- KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY}
36+
- S3_ACCESS_KEY=${S3_ACCESS_KEY}
37+
- S3_SECRET_KEY=${S3_SECRET_KEY}
1738
depends_on:
1839
mariadb:
1940
condition: service_healthy
@@ -38,6 +59,14 @@ services:
3859
- RABBITMQ_HOST=rabbitmq
3960
- SPRING_RABBITMQ_USERNAME=guest
4061
- SPRING_RABBITMQ_PASSWORD=guest
62+
- APP_GEMINI_APIKEY=${APP_GEMINI_APIKEY}
63+
- AWS_REGION=${AWS_REGION}
64+
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
65+
- KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET}
66+
- KAKAO_REDIRECT_URI=${KAKAO_REDIRECT_URI}
67+
- KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY}
68+
- S3_ACCESS_KEY=${S3_ACCESS_KEY}
69+
- S3_SECRET_KEY=${S3_SECRET_KEY}
4170
depends_on:
4271
mariadb:
4372
condition: service_healthy
@@ -59,6 +88,11 @@ services:
5988
- "127.0.0.1:3306:3306"
6089
networks:
6190
- pullit-local-network
91+
healthcheck:
92+
test: ["CMD", "mariadb", "-u", "myuser", "-psecret", "-e", "USE mydatabase;"]
93+
interval: 10s
94+
timeout: 5s
95+
retries: 10
6296

6397
prometheus:
6498
container_name: prometheus
@@ -100,9 +134,8 @@ services:
100134
healthcheck:
101135
test: ["CMD", "redis-cli", "ping"]
102136
interval: 10s
103-
timeout: 10s
137+
timeout: 5s
104138
retries: 5
105-
start_period: 30s
106139

107140
rabbitmq:
108141
image: 'rabbitmq:3-management-alpine'
@@ -113,9 +146,9 @@ services:
113146
networks:
114147
- pullit-local-network
115148
healthcheck:
116-
test: ["CMD", "rabbitmq-diagnostics", "ping"]
149+
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
117150
interval: 10s
118-
timeout: 10s
151+
timeout: 5s
119152
retries: 5
120153

121154
networks:

nginx/certs/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# 로컬 개발 환경을 위한 SSL 인증서
2+
3+
이 디렉토리는 `https://localhost` 접속에 필요한 SSL 인증서(`localhost.crt`)와 개인 키(`localhost.key`)를 저장하는 곳입니다.
4+
5+
**주의**: 개인 키 파일(`*.key`)은 보안상의 이유로 Git 버전 관리에서 제외됩니다. 프로젝트를 처음 설정하는 경우, 아래의 방법으로 직접 인증서를 생성해야 합니다.
6+
7+
## 인증서 생성 방법 (mkcert 사용)
8+
9+
`mkcert`는 로컬 환경에서 신뢰할 수 있는 SSL 인증서를 손쉽게 만들어주는 도구입니다.
10+
11+
### 1. mkcert 설치
12+
13+
사용 중인 운영체제에 맞게 `mkcert`를 설치합니다.
14+
15+
- **Linux (Ubuntu/Debian)**:
16+
```bash
17+
sudo apt update
18+
sudo apt install libnss3-tools mkcert
19+
```
20+
- **macOS (Homebrew)**:
21+
```bash
22+
brew install mkcert
23+
brew install nss # Firefox 지원을 위해 필요
24+
```
25+
- **Windows (Chocolatey)**:
26+
```powershell
27+
choco install mkcert
28+
```
29+
30+
### 2. 로컬 인증 기관(CA) 설치
31+
32+
다음 명령어를 실행하여 내 컴퓨터에 로컬 CA를 생성하고 신뢰하도록 설정합니다. 이 작업은 컴퓨터 당 한 번만 수행하면 됩니다.
33+
34+
```bash
35+
mkcert -install
36+
```
37+
38+
### 3. 인증서 생성
39+
40+
프로젝트의 `nginx/certs` 디렉토리로 이동한 후, 다음 명령어를 실행하여 `localhost`에 대한 인증서와 개인 키를 생성합니다.
41+
42+
```bash
43+
# 터미널에서 `pullit/nginx/certs` 디렉토리로 이동
44+
mkcert localhost
45+
```
46+
47+
위 명령어를 실행하면 `localhost.pem` (인증서)과 `localhost-key.pem` (개인 키) 파일이 생성됩니다. Nginx 설정 파일(`default.conf`)이 `.crt``.key` 확장자를 사용하므로, 생성된 파일들의 이름을 아래와 같이 변경해주세요.
48+
49+
- `localhost.pem` -> `localhost.crt`
50+
- `localhost-key.pem` -> `localhost.key`
51+
52+
이제 `docker-compose up -d --build`을 실행하면 Nginx가 인증서를 정상적으로 로드하여 `https://localhost` 접속이 가능해집니다.

nginx/conf.d/default.conf

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
server {
2+
listen 443 ssl;
3+
server_name localhost;
4+
5+
ssl_certificate /etc/nginx/certs/localhost.crt;
6+
ssl_certificate_key /etc/nginx/certs/localhost.key;
7+
8+
# SSL Settings
9+
ssl_protocols TLSv1.2 TLSv1.3;
10+
ssl_ciphers HIGH:!aNULL:!MD5;
11+
12+
# SSE (Server-Sent Events)를 위한 특별 설정
13+
# 실시간 알림을 위해 Nginx가 응답을 버퍼링하지 않고 즉시 클라이언트에 전달하도록 함
14+
location /api/notifications/subscribe {
15+
proxy_pass http://pullit-local-api:8080;
16+
proxy_set_header Host $host;
17+
proxy_set_header X-Real-IP $remote_addr;
18+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
19+
proxy_set_header X-Forwarded-Proto $scheme;
20+
21+
# SSE를 위한 핵심 설정
22+
proxy_buffering off; # 응답 버퍼링 비활성화
23+
proxy_cache off; # 캐시 비활성화
24+
proxy_http_version 1.1; # HTTP/1.1 사용
25+
proxy_set_header Connection ''; # Connection 헤더를 비워서 keep-alive 연결 유지
26+
proxy_read_timeout 1h; # 타임아웃을 1시간으로 길게 설정
27+
keepalive_timeout 7200s; # Keep-alive 타임아웃 설정
28+
}
29+
30+
location / {
31+
proxy_pass http://pullit-local-api:8080;
32+
proxy_set_header Host $host;
33+
proxy_set_header X-Real-IP $remote_addr;
34+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
35+
proxy_set_header X-Forwarded-Proto $scheme;
36+
proxy_set_header Upgrade $http_upgrade;
37+
proxy_set_header Connection "upgrade";
38+
}
39+
}

src/main/java/kr/it/pullit/modules/learningsource/source/service/SourceService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ public List<Source> findByIdIn(List<Long> ids) {
148148
public void deleteSource(Long sourceId, Long memberId) {
149149
Source source = getOrElseThrow(sourceId, memberId);
150150
String filePath = source.getFilePath();
151+
152+
source.getQuestionSets().forEach(questionSet -> questionSet.getSources().remove(source));
153+
151154
sourceRepository.delete(source);
152155
deleteInS3(filePath);
153156
}

src/main/java/kr/it/pullit/modules/projection/learnstats/domain/LearnStats.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public int calculateLearningProgress() {
6464
if (this.totalQuestionCount == 0) {
6565
return 0;
6666
}
67-
return (int) (((double) this.totalSolvedQuestionCount / this.totalQuestionCount) * 100);
67+
return (int) (((double) this.totalCorrectQuestionCount / this.totalQuestionCount) * 100);
6868
}
6969

7070
public void onWeeklyReset() {

src/main/java/kr/it/pullit/modules/projection/outbox/domain/OutboxEvent.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public enum Status {
4949
private String eventType; // WEEKLY_RESET, QUESTION_SOLVED, ...
5050

5151
@Lob
52-
@Column(columnDefinition = "json", nullable = false)
52+
@Column(columnDefinition = "longtext", nullable = false)
5353
private String payload; // JSON 문자열
5454

5555
@Enumerated(EnumType.STRING)

src/main/java/kr/it/pullit/modules/questionset/client/GeminiClient.java

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
import com.fasterxml.jackson.databind.ObjectMapper;
44
import com.google.genai.Client;
5-
import com.google.genai.types.FinishReason;
5+
import com.google.genai.ResponseStream;
6+
import com.google.genai.types.FinishReason.Known;
67
import com.google.genai.types.GenerateContentResponse;
78
import com.google.genai.types.HttpOptions;
89
import java.io.IOException;
910
import java.time.Duration;
11+
import java.util.stream.Collectors;
12+
import java.util.stream.StreamSupport;
1013
import kr.it.pullit.modules.questionset.api.LlmClient;
1114
import kr.it.pullit.modules.questionset.client.dto.request.GeminiRequest;
1215
import kr.it.pullit.modules.questionset.client.dto.request.LlmGeneratedQuestionRequest;
@@ -42,27 +45,35 @@ public LlmGeneratedQuestionSetResponse getLlmGeneratedQuestionContent(
4245
GeminiRequest geminiRequest = GeminiRequest.from(request, configBuilder);
4346
logRequestDetails(geminiRequest, request);
4447

45-
GenerateContentResponse response = callGeminiApi(geminiRequest);
46-
validateResponse(response);
48+
ResponseStream<GenerateContentResponse> responseStream = callGeminiApiStream(geminiRequest);
49+
String aggregatedResponse = aggregateStreamResponse(responseStream);
4750

48-
return parseResponse(response);
51+
return parseResponse(aggregatedResponse);
4952
} catch (IOException e) {
5053
throw LlmResponseParseException.create(e);
5154
} catch (Exception e) {
5255
throw LlmException.withCause(e);
5356
}
5457
}
5558

56-
private GenerateContentResponse callGeminiApi(GeminiRequest geminiRequest) {
57-
return client.models.generateContent(
59+
private ResponseStream<GenerateContentResponse> callGeminiApiStream(GeminiRequest geminiRequest) {
60+
return client.models.generateContentStream(
5861
geminiRequest.model(), geminiRequest.content(), geminiRequest.config());
5962
}
6063

61-
private void validateResponse(GenerateContentResponse response) {
62-
if (response.finishReason().knownEnum() != FinishReason.Known.STOP) {
63-
throw LlmException.generationFailed(
64-
"AI 모델이 비정상적으로 응답 생성을 중단했습니다. (사유: " + response.finishReason() + ")");
65-
}
64+
private String aggregateStreamResponse(ResponseStream<GenerateContentResponse> responseStream) {
65+
return StreamSupport.stream(responseStream.spliterator(), false)
66+
.peek(
67+
response -> {
68+
if (response.finishReason().knownEnum() != Known.STOP
69+
&& response.finishReason().knownEnum() != Known.FINISH_REASON_UNSPECIFIED) {
70+
throw LlmException.generationFailed(
71+
"AI 모델이 비정상적으로 응답 생성을 중단했습니다. (사유: " + response.finishReason() + ")");
72+
}
73+
})
74+
.map(GenerateContentResponse::text)
75+
.filter(text -> text != null && !text.isEmpty())
76+
.collect(Collectors.joining());
6677
}
6778

6879
private void logRequestDetails(GeminiRequest geminiRequest, LlmGeneratedQuestionRequest request) {
@@ -84,9 +95,7 @@ private void logRequestDetails(GeminiRequest geminiRequest, LlmGeneratedQuestion
8495
: "");
8596
}
8697

87-
private LlmGeneratedQuestionSetResponse parseResponse(GenerateContentResponse response)
88-
throws IOException {
89-
String rawResponse = response.text();
98+
private LlmGeneratedQuestionSetResponse parseResponse(String rawResponse) throws IOException {
9099
try {
91100
return mapper.readValue(rawResponse, LlmGeneratedQuestionSetResponse.class);
92101
} catch (IOException e) {

src/main/java/kr/it/pullit/modules/questionset/repository/QuestionSetRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public interface QuestionSetRepository {
1111

1212
Optional<QuestionSet> findById(Long id);
1313

14+
Optional<QuestionSet> findByIdWithQuestions(Long id);
15+
1416
Optional<QuestionSet> findByIdAndMemberId(Long id, Long memberId);
1517

1618
Optional<QuestionSet> findWithQuestionsForFirstSolving(Long id, Long memberId);

src/main/java/kr/it/pullit/modules/questionset/repository/QuestionSetRepositoryImpl.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ public Optional<QuestionSet> findById(Long id) {
2222
return questionSetJpaRepository.findById(id);
2323
}
2424

25+
@Override
26+
public Optional<QuestionSet> findByIdWithQuestions(Long id) {
27+
return questionSetJpaRepository.findByIdWithQuestions(id);
28+
}
29+
2530
@Override
2631
public Optional<QuestionSet> findByIdAndMemberId(Long id, Long memberId) {
2732
return questionSetJpaRepository.findByIdAndMemberId(id, memberId);

0 commit comments

Comments
 (0)