Skip to content

Commit 56e46ee

Browse files
Merge pull request #249 from DevKor-github/develop
#245 ~ #248 반영해 운영 서버 배포
2 parents f757258 + d3d4bcf commit 56e46ee

65 files changed

Lines changed: 2541 additions & 124 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ dependencies {
7878

7979
// FCM admin SDK 추가
8080
implementation 'com.google.firebase:firebase-admin:9.5.0'
81+
82+
// Redis
83+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
8184
}
8285

8386
tasks.named('test') {

deploy/docker-compose.dev.yml

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,10 @@ services:
1010
cpus: "0.25"
1111
mem_limit: "512m"
1212

13-
# 런타임에 생성한 RAM env 파일을 컨테이너로 그대로 주입
1413
env_file:
1514
- /run/saerok/env.dev
1615

1716
environment:
18-
# dev 오버레이에서 명시적으로 고정하고 싶은 값만 남김
1917
SPRING_PROFILES_ACTIVE: "dev"
2018
JAVA_OPTS: >-
2119
-Duser.timezone=Asia/Seoul
@@ -25,9 +23,13 @@ services:
2523
-XX:ReservedCodeCacheSize=48m
2624
-XX:+UseSerialGC
2725
-XX:+ExitOnOutOfMemoryError
26+
# Redis 연결 정보
27+
SPRING_DATA_REDIS_HOST: redis
28+
SPRING_DATA_REDIS_PORT: "6379"
2829

2930
depends_on:
3031
- postgres
32+
- redis
3133

3234
postgres:
3335
image: saerok-postgres:ko
@@ -38,16 +40,52 @@ services:
3840
# 로컬 개발/디버깅도 필요하면 열어두고, 진짜 막고 싶으면 나중에 빼면 됨
3941
ports:
4042
- "5432:5432"
41-
4243
environment:
4344
POSTGRES_DB: saerok
4445
POSTGRES_USER: ${DB_USERNAME}
4546
POSTGRES_PASSWORD: ${DB_PASSWORD}
4647
POSTGRES_INITDB_ARGS: "--locale=ko_KR.utf8 --encoding=UTF8"
47-
4848
volumes:
4949
- ./postgres-data:/var/lib/postgresql/data
5050

5151
# t2.micro 고려한 소박한 리소스
5252
cpus: "0.25"
5353
mem_limit: "256m"
54+
55+
redis:
56+
image: redis:7-alpine
57+
container_name: saerok-redis-dev
58+
restart: unless-stopped
59+
# 내부 전용: 필요 시에만 포트 노출
60+
# ports:
61+
# - "6379:6379"
62+
command:
63+
- "redis-server"
64+
# --- RDB 느슨한 주기, AOF 끔 ---
65+
- "--save"
66+
- "900"
67+
- "1" # 15분 동안 1건 이상 변경 시 스냅샷
68+
- "--save"
69+
- "300"
70+
- "10" # 5분 동안 10건 이상 변경 시 스냅샷
71+
- "--appendonly"
72+
- "no" # AOF 비활성화
73+
- "--rdbcompression"
74+
- "yes" # 스냅샷 압축(기본값이지만 명시)
75+
- "--stop-writes-on-bgsave-error"
76+
- "no" # dev: 백업 실패로 쓰기 중단 방지
77+
# --- 캐시 특성: 메모리 상한과 축출 정책 ---
78+
- "--maxmemory"
79+
- "128mb"
80+
- "--maxmemory-policy"
81+
- "allkeys-lru"
82+
volumes:
83+
- ./redis-data:/data # dump.rdb 저장 위치(단일 파일로 덮어씀)
84+
healthcheck:
85+
test: ["CMD", "redis-cli", "ping"]
86+
interval: 5s
87+
timeout: 3s
88+
retries: 20
89+
start_period: 5s
90+
cpus: "0.15"
91+
mem_limit: "192m"

deploy/docker-compose.override.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,41 @@ services:
1616
JAVA_OPTS: >-
1717
-Duser.timezone=Asia/Seoul
1818
-XX:MaxRAMPercentage=60
19+
# Redis 연결 (Spring Boot 표준 환경변수)
20+
SPRING_DATA_REDIS_HOST: redis
21+
SPRING_DATA_REDIS_PORT: "6379"
1922

2023
extra_hosts:
2124
- "host.docker.internal:host-gateway"
2225

2326
ports:
2427
- "8080:8080"
2528

29+
depends_on:
30+
- redis
31+
2632
healthcheck:
2733
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health >/dev/null || exit 1"]
2834
interval: 10s
2935
timeout: 3s
3036
retries: 10
3137
start_period: 20s
38+
39+
redis:
40+
image: redis:7-alpine
41+
container_name: saerok-redis
42+
restart: unless-stopped
43+
ports:
44+
- "6379:6379"
45+
command: ["redis-server", "--appendonly", "yes"]
46+
volumes:
47+
- redis-data:/data
48+
healthcheck:
49+
test: ["CMD", "redis-cli", "ping"]
50+
interval: 5s
51+
timeout: 3s
52+
retries: 20
53+
start_period: 5s
54+
55+
volumes:
56+
redis-data:

deploy/docker-compose.prod.yml

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ services:
99
ports:
1010
- "8080:8080"
1111

12-
# t3.small 자원 컷
12+
# t3.small 자원 컷 (기존 값 유지)
1313
cpus: "1.0"
1414
cpu_shares: 768
1515
mem_limit: "1100m"
@@ -31,9 +31,52 @@ services:
3131
-XX:ReservedCodeCacheSize=96m
3232
-XX:+UseG1GC
3333
-XX:+ExitOnOutOfMemoryError
34+
# Redis 연결 정보 (prod에서도 명시)
35+
SPRING_DATA_REDIS_HOST: redis
36+
SPRING_DATA_REDIS_PORT: "6379"
37+
38+
depends_on:
39+
- redis
3440

3541
logging:
3642
driver: json-file
3743
options:
3844
max-size: "10m"
3945
max-file: "5"
46+
47+
redis:
48+
image: redis:7-alpine
49+
container_name: saerok-redis-prod
50+
restart: unless-stopped
51+
# 외부 노출 금지(내부 네트워크만). 필요 시에만 열 것
52+
# ports:
53+
# - "6379:6379"
54+
command:
55+
- "redis-server"
56+
# 운영: 내구성 보장(AOF), 성능 타협은 everysec로
57+
- "--appendonly"
58+
- "yes"
59+
- "--appendfsync"
60+
- "everysec"
61+
- "--auto-aof-rewrite-percentage"
62+
- "100"
63+
- "--auto-aof-rewrite-min-size"
64+
- "64mb"
65+
# 캐시 특성: 메모리 상한과 축출 정책
66+
- "--maxmemory"
67+
- "256mb"
68+
- "--maxmemory-policy"
69+
- "allkeys-lru"
70+
volumes:
71+
- redis-data:/data
72+
healthcheck:
73+
test: ["CMD", "redis-cli", "ping"]
74+
interval: 5s
75+
timeout: 3s
76+
retries: 20
77+
start_period: 5s
78+
cpus: "0.4"
79+
mem_limit: "384m"
80+
81+
volumes:
82+
redis-data:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package org.devkor.apu.saerok_server.domain.ad.api;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.media.Content;
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.devkor.apu.saerok_server.domain.ad.api.dto.request.AdEventRequest;
11+
import org.devkor.apu.saerok_server.domain.ad.api.dto.response.AdEventStatusResponse;
12+
import org.devkor.apu.saerok_server.domain.ad.api.dto.response.GetAdSlotResponse;
13+
import org.devkor.apu.saerok_server.domain.ad.application.AdEventService;
14+
import org.devkor.apu.saerok_server.domain.ad.application.AdSelectionResult;
15+
import org.devkor.apu.saerok_server.domain.ad.application.AdSelectorService;
16+
import org.devkor.apu.saerok_server.domain.ad.core.entity.Ad;
17+
import org.devkor.apu.saerok_server.domain.ad.core.entity.AdEventType;
18+
import org.devkor.apu.saerok_server.global.shared.infra.ImageDomainService;
19+
import org.springframework.web.bind.annotation.GetMapping;
20+
import org.springframework.web.bind.annotation.PathVariable;
21+
import org.springframework.web.bind.annotation.PostMapping;
22+
import org.springframework.web.bind.annotation.RequestBody;
23+
import org.springframework.web.bind.annotation.RequestMapping;
24+
import org.springframework.web.bind.annotation.RestController;
25+
26+
@Tag(name = "Ad API", description = "클라이언트 광고 노출 및 이벤트 API")
27+
@RestController
28+
@RequiredArgsConstructor
29+
@RequestMapping("${api_prefix}/ad")
30+
public class AdController {
31+
32+
private final AdSelectorService adSelectorService;
33+
private final AdEventService adEventService;
34+
private final ImageDomainService imageDomainService;
35+
36+
@GetMapping("/slots/{slotName}")
37+
@Operation(
38+
summary = "슬롯 단위 광고 요청",
39+
description = """
40+
주어진 슬롯 이름에 대해 광고를 하나 선택해서 반환합니다.<br>
41+
- 슬롯의 fallbackRatio에 따라 FALLBACK 또는 AD를 응답합니다.<br>
42+
- AD인 경우 이미지 URL과 클릭 시 이동할 URL을 함께 내려줍니다.
43+
""",
44+
responses = {
45+
@ApiResponse(responseCode = "200", description = "응답 성공",
46+
content = @Content(schema = @Schema(implementation = GetAdSlotResponse.class))),
47+
@ApiResponse(responseCode = "404", description = "슬롯을 찾을 수 없음", content = @Content)
48+
}
49+
)
50+
public GetAdSlotResponse getSlotAd(
51+
@PathVariable String slotName
52+
) {
53+
AdSelectionResult result = adSelectorService.selectAdForSlot(slotName);
54+
55+
if (result.isFallback()) {
56+
return new GetAdSlotResponse("FALLBACK", result.ttlSeconds(), null);
57+
}
58+
59+
Ad ad = result.ad();
60+
String imageUrl = imageDomainService.toUploadImageUrl(ad.getObjectKey());
61+
62+
GetAdSlotResponse.AdPayload payload = new GetAdSlotResponse.AdPayload(
63+
ad.getId(),
64+
imageUrl,
65+
ad.getTargetUrl()
66+
);
67+
68+
return new GetAdSlotResponse("AD", result.ttlSeconds(), payload);
69+
}
70+
71+
@PostMapping("/event/impression")
72+
@Operation(
73+
summary = "광고 노출 이벤트 기록",
74+
description = """
75+
광고 이미지가 실제로 렌더링되었을 때 호출해 주세요.<br>
76+
deviceId는 서버에서 SHA-256 해싱 후 deviceHash로 저장합니다.<br>
77+
동일 기기에서 10초 내에 같은 슬롯의 같은 광고가 노출된 기록은 중복 기록되지 않습니다.
78+
""",
79+
responses = {
80+
@ApiResponse(responseCode = "200", description = "기록 성공",
81+
content = @Content(schema = @Schema(implementation = AdEventStatusResponse.class))),
82+
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
83+
@ApiResponse(responseCode = "404", description = "광고를 찾을 수 없음", content = @Content)
84+
}
85+
)
86+
public AdEventStatusResponse logImpression(
87+
@Valid @RequestBody AdEventRequest request
88+
) {
89+
adEventService.logEvent(
90+
AdEventType.IMPRESSION,
91+
request.adId(),
92+
request.slotName(),
93+
request.deviceId()
94+
);
95+
return new AdEventStatusResponse("ok");
96+
}
97+
98+
@PostMapping("/event/click")
99+
@Operation(
100+
summary = "광고 클릭 이벤트 기록",
101+
description = """
102+
광고 클릭 시 호출해 주세요.<br>
103+
deviceId는 서버에서 SHA-256 해싱 후 deviceHash로 저장합니다.<br>
104+
동일 기기에서 10초 내에 같은 슬롯의 같은 광고를 클릭한 기록은 중복 기록되지 않습니다.
105+
""",
106+
responses = {
107+
@ApiResponse(responseCode = "200", description = "기록 성공",
108+
content = @Content(schema = @Schema(implementation = AdEventStatusResponse.class))),
109+
@ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content),
110+
@ApiResponse(responseCode = "404", description = "광고를 찾을 수 없음", content = @Content)
111+
}
112+
)
113+
public AdEventStatusResponse logClick(
114+
@Valid @RequestBody AdEventRequest request
115+
) {
116+
adEventService.logEvent(
117+
AdEventType.CLICK,
118+
request.adId(),
119+
request.slotName(),
120+
request.deviceId()
121+
);
122+
return new AdEventStatusResponse("ok");
123+
}
124+
}

0 commit comments

Comments
 (0)