Skip to content

Commit 10554e4

Browse files
authored
(#27) SRP 분리, 관심사 분리, 트랜잭션 최적화 (#26)
- UserPersistenceService에서 ActivityLogOrchestrator 분리 - BadgeService에서 TierGradientProvider, BadgeFormatter, SvgBadgeRenderer 분리 - GitHub API 호출 시 트랜잭션 일시 중단으로 청크 트랜잭션 유지 시간 최소화
1 parent 72369be commit 10554e4

8 files changed

Lines changed: 392 additions & 324 deletions

File tree

src/main/java/com/gitranker/api/batch/strategy/FullActivityUpdateStrategy.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import lombok.RequiredArgsConstructor;
88
import lombok.extern.slf4j.Slf4j;
99
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.annotation.Propagation;
11+
import org.springframework.transaction.annotation.Transactional;
1012

1113
@Slf4j
1214
@Component
@@ -15,7 +17,12 @@ public class FullActivityUpdateStrategy implements ActivityUpdateStrategy {
1517

1618
private final GitHubActivityService activityService;
1719

20+
/**
21+
* GitHub API 호출은 트랜잭션 외부에서 실행하여 청크 트랜잭션 유지 시간을 최소화합니다.
22+
* Propagation.NOT_SUPPORTED: 현재 트랜잭션을 일시 중단하고 API 호출 후 재개합니다.
23+
*/
1824
@Override
25+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
1926
public ActivityStatistics update(User user, ActivityUpdateContext context) {
2027
GitHubAllActivitiesResponse fullResponse =
2128
activityService.fetchRawAllActivities(user.getUsername(), user.getGithubCreatedAt());

src/main/java/com/gitranker/api/batch/strategy/IncrementalActivityUpdateStrategy.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import lombok.RequiredArgsConstructor;
99
import lombok.extern.slf4j.Slf4j;
1010
import org.springframework.stereotype.Component;
11+
import org.springframework.transaction.annotation.Propagation;
12+
import org.springframework.transaction.annotation.Transactional;
1113

1214
@Slf4j
1315
@Component
@@ -16,7 +18,12 @@ public class IncrementalActivityUpdateStrategy implements ActivityUpdateStrategy
1618

1719
private final GitHubActivityService activityService;
1820

21+
/**
22+
* GitHub API 호출은 트랜잭션 외부에서 실행하여 청크 트랜잭션 유지 시간을 최소화합니다.
23+
* Propagation.NOT_SUPPORTED: 현재 트랜잭션을 일시 중단하고 API 호출 후 재개합니다.
24+
*/
1925
@Override
26+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
2027
public ActivityStatistics update(User user, ActivityUpdateContext context) {
2128
GitHubActivitySummary currentYearSummary = activityService.fetchActivityForYear(user.getUsername(), context.currentYear());
2229

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.gitranker.api.domain.badge;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
/**
6+
* 배지에 표시되는 숫자 및 텍스트 포맷팅을 담당하는 컴포넌트.
7+
*/
8+
@Component
9+
public class BadgeFormatter {
10+
11+
public String formatNumber(long number) {
12+
return String.format("%,d", number);
13+
}
14+
15+
public String formatCount(int count) {
16+
return String.format("%,d", count);
17+
}
18+
19+
public String formatDiff(int diff) {
20+
if (diff > 0) {
21+
return String.format("<tspan class='diff-plus' dy='-1'>+%d</tspan>", diff);
22+
}
23+
if (diff < 0) {
24+
return String.format("<tspan class='diff-minus' dy='-1'>-%d</tspan>", Math.abs(diff));
25+
}
26+
return "";
27+
}
28+
29+
public String formatTierName(String tierName) {
30+
return tierName.charAt(0) + tierName.substring(1).toLowerCase();
31+
}
32+
33+
public int calculateTierFontSize(String displayTierName) {
34+
if (displayTierName.length() > 9) {
35+
return 26;
36+
} else if (displayTierName.length() > 6) {
37+
return 30;
38+
}
39+
return 32;
40+
}
41+
}

src/main/java/com/gitranker/api/domain/badge/BadgeService.java

Lines changed: 21 additions & 257 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@
2222
@RequiredArgsConstructor
2323
public class BadgeService {
2424

25-
private static final String GITHUB_LOGO_PATH = "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.137 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z";
26-
private static final String FONT_IMPORT_CSS = "@import url('https://fonts.googleapis.com/css2?&family=Noto+Sans+KR:wght@400;500;700&family=Outfit:wght@400;500;700;900&display=swap');";
27-
2825
private final UserRepository userRepository;
2926
private final ActivityLogRepository activityLogRepository;
27+
private final SvgBadgeRenderer svgBadgeRenderer;
3028

3129
@Transactional(readOnly = true)
3230
public String generateBadge(String nodeId) {
@@ -41,266 +39,32 @@ public String generateBadge(String nodeId) {
4139
.with("target_username", user.getUsername())
4240
.info();
4341

44-
return createSvgContent(user, user.getTier(), activityLog);
42+
return svgBadgeRenderer.render(user, user.getTier(), activityLog);
4543
}
4644

4745
@Transactional(readOnly = true)
4846
public String generateBadgeByTier(Tier tier) {
49-
User user = User.builder().nodeId("preview").username(tier.toString()).build();
47+
User user = User.builder()
48+
.nodeId("preview")
49+
.username(tier.toString())
50+
.build();
5051
user.updateScore(Score.of(12345));
5152
user.updateRankInfo(RankInfo.of(1, 0.1, 12345));
5253

5354
ActivityLog activityLog = ActivityLog.builder()
54-
.user(user).commitCount(150).prCount(30).mergedPrCount(25).issueCount(10).reviewCount(45)
55-
.diffCommitCount(12).diffPrCount(0).diffMergedPrCount(0).diffIssueCount(2).diffReviewCount(8).build();
56-
return createSvgContent(user, tier, activityLog);
57-
}
58-
59-
private String createSvgContent(User user, Tier tier, ActivityLog activityLog) {
60-
String gradientDefs = getTierGradientDefs(tier);
61-
62-
String displayTierName = tier.name().charAt(0) + tier.name().substring(1).toLowerCase();
63-
64-
int tierFontSize = 32;
65-
if (displayTierName.length() > 9) {
66-
tierFontSize = 26;
67-
} else if (displayTierName.length() > 6) {
68-
tierFontSize = 30;
69-
}
70-
71-
return String.format("""
72-
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
73-
<svg width="350" height="170" viewBox="0 0 350 170" fill="none" role="img" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve">
74-
<defs>
75-
%s
76-
<clipPath id="card-clip">
77-
<rect x="0" y="0" width="350" height="170" rx="12" ry="12"/>
78-
</clipPath>
79-
80-
<linearGradient id="static-gloss" x1="0%%" y1="0%%" x2="0%%" y2="100%%">
81-
<stop offset="0%%" style="stop-color:#ffffff;stop-opacity:0.2" />
82-
<stop offset="100%%" style="stop-color:#ffffff;stop-opacity:0" />
83-
</linearGradient>
84-
85-
<linearGradient id="soft-shine-gradient" x1="0%%" y1="0%%" x2="100%%" y2="0%%">
86-
<stop offset="0%%" style="stop-color:#ffffff;stop-opacity:0" />
87-
<stop offset="50%%" style="stop-color:#ffffff;stop-opacity:0.4" />
88-
<stop offset="100%%" style="stop-color:#ffffff;stop-opacity:0" />
89-
</linearGradient>
90-
</defs>
91-
92-
<style type="text/css">
93-
<![CDATA[
94-
%s
95-
96-
@keyframes soft-pass {
97-
0%% { transform: translateX(-400px) skewX(-25deg); }
98-
50%% { transform: translateX(-400px) skewX(-35deg); }
99-
100%% { transform: translateX(500px) skewX(-35deg); }
100-
}
101-
102-
.shine-bar {
103-
animation: soft-pass 5s infinite ease-in-out;
104-
opacity: 0.20;
105-
}
106-
107-
text {
108-
fill: #ffffff;
109-
text-rendering: geometricPrecision;
110-
-webkit-font-smoothing: antialiased;
111-
}
112-
113-
.text-shadow { filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.5)); }
114-
.text-shadow-strong { filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.7)); }
115-
116-
.header {
117-
font-family: 'Noto Sans KR', sans-serif;
118-
font-weight: 700;
119-
font-size: 12px;
120-
letter-spacing: 0px;
121-
}
122-
123-
.username {
124-
font-family: 'Noto Sans KR', sans-serif;
125-
font-weight: 500;
126-
font-size: 13px;
127-
opacity: 0.95;
128-
}
129-
130-
.stat-label {
131-
font-family: 'Noto Sans KR', sans-serif;
132-
font-size: 11px;
133-
opacity: 0.85;
134-
letter-spacing: 0.5px;
135-
font-weight: 700;
136-
}
137-
138-
.stat-value {
139-
font-family: 'Noto Sans KR', sans-serif;
140-
font-weight: 700;
141-
font-size: 13px;
142-
}
143-
144-
.tier-text {
145-
font-family: 'Noto Sans KR', sans-serif;
146-
font-weight: 500;
147-
font-size: %dpx;
148-
letter-spacing: 0.5px;
149-
}
150-
151-
.score-text {
152-
font-family: 'Noto Sans KR', sans-serif;
153-
font-weight: 700;
154-
font-size: 22px;
155-
letter-spacing: 0px;
156-
}
157-
158-
.rank-text {
159-
font-family: 'Noto Sans KR', sans-serif;
160-
font-size: 11px;
161-
font-weight: 500;
162-
opacity: 0.9;
163-
}
164-
165-
.diff-plus { fill: #4ADE80; font-weight: 700; font-size: 11px; font-family: 'Noto Sans KR', sans-serif; }
166-
.diff-minus { fill: #FF6B6B; font-weight: 700; font-size: 11px; font-family: 'Noto Sans KR', sans-serif; }
167-
]]>
168-
</style>
169-
170-
<rect x="0" y="0" width="350" height="170" rx="12" ry="12" fill="url(#tierGradient)" shape-rendering="geometricPrecision" />
171-
172-
<g clip-path="url(#card-clip)">
173-
<path d="%s" fill="white" fill-opacity="0.08" transform="translate(200, -20) scale(9)" shape-rendering="geometricPrecision"/>
174-
175-
<rect x="0" y="0" width="350" height="85" fill="url(#static-gloss)" />
176-
<rect class="shine-bar" x="0" y="-30" width="200" height="230" fill="url(#soft-shine-gradient)" />
177-
</g>
178-
179-
<text x="20" y="28" class="base-text header text-shadow">Git Ranker</text>
180-
<text x="330" y="28" text-anchor="end" class="base-text username text-shadow">@%s</text>
181-
<line x1="20" y1="40" x2="330" y2="40" stroke="#ffffff" stroke-width="1" stroke-opacity="0.4" shape-rendering="crispEdges"/>
182-
183-
<g transform="translate(20, 85)">
184-
<text x="0" y="0" class="base-text tier-text text-shadow-strong">%s</text>
185-
<text x="0" y="30" class="mono-text score-text text-shadow">%s pts</text>
186-
<text x="0" y="52" class="base-text rank-text text-shadow">Top %.2f%% • Rank %s</text>
187-
</g>
188-
189-
<line x1="165" y1="55" x2="165" y2="155" stroke="#ffffff" stroke-width="1" stroke-opacity="0.3" shape-rendering="crispEdges"/>
190-
191-
<g transform="translate(180, 60)">
192-
<g transform="translate(0, 0)">
193-
<text x="0" y="0" class="base-text stat-label">Commits</text>
194-
<text x="0" y="18" class="mono-text stat-value text-shadow">%s %s</text>
195-
</g>
196-
<g transform="translate(85, 0)">
197-
<text x="0" y="0" class="base-text stat-label">Issues</text>
198-
<text x="0" y="18" class="mono-text stat-value text-shadow">%s %s</text>
199-
</g>
200-
<g transform="translate(0, 34)">
201-
<text x="0" y="0" class="base-text stat-label">PR Open</text>
202-
<text x="0" y="18" class="mono-text stat-value text-shadow">%s %s</text>
203-
</g>
204-
<g transform="translate(85, 34)">
205-
<text x="0" y="0" class="base-text stat-label">PR Merged</text>
206-
<text x="0" y="18" class="mono-text stat-value text-shadow">%s %s</text>
207-
</g>
208-
<g transform="translate(0, 68)">
209-
<text x="0" y="0" class="base-text stat-label">Reviews</text>
210-
<text x="0" y="18" class="mono-text stat-value text-shadow">%s %s</text>
211-
</g>
212-
</g>
213-
</svg>
214-
""",
215-
gradientDefs,
216-
FONT_IMPORT_CSS,
217-
tierFontSize,
218-
GITHUB_LOGO_PATH,
219-
user.getUsername(),
220-
displayTierName,
221-
formatNumber(user.getTotalScore()),
222-
user.getPercentile(),
223-
formatNumber(user.getRanking()),
224-
225-
formatCount(activityLog.getCommitCount()), formatDiff(activityLog.getDiffCommitCount()),
226-
formatCount(activityLog.getIssueCount()), formatDiff(activityLog.getDiffIssueCount()),
227-
formatCount(activityLog.getPrCount()), formatDiff(activityLog.getDiffPrCount()),
228-
formatCount(activityLog.getMergedPrCount()), formatDiff(activityLog.getDiffMergedPrCount()),
229-
formatCount(activityLog.getReviewCount()), formatDiff(activityLog.getDiffReviewCount())
230-
);
231-
}
232-
233-
private String formatNumber(long number) {
234-
return String.format("%,d", number);
235-
}
236-
237-
private String formatCount(int count) {
238-
return String.format("%,d", count);
239-
}
240-
241-
private String formatDiff(int diff) {
242-
if (diff > 0) return String.format("<tspan class='diff-plus' dy='-1'>+%d</tspan>", diff);
243-
if (diff < 0) return String.format("<tspan class='diff-minus' dy='-1'>-%d</tspan>", Math.abs(diff));
244-
return "";
245-
}
246-
247-
private String getTierGradientDefs(Tier tier) {
248-
String color1, color2, color3;
249-
250-
switch (tier) {
251-
case CHALLENGER -> {
252-
color1 = "#09203F";
253-
color2 = "#3B82F6";
254-
color3 = "#D4AF37";
255-
}
256-
case MASTER -> {
257-
color1 = "#2E1065";
258-
color2 = "#7C3AED";
259-
color3 = "#F472B6";
260-
}
261-
case DIAMOND -> {
262-
color1 = "#0C4A6E";
263-
color2 = "#0284C7";
264-
color3 = "#7DD3FC";
265-
}
266-
case EMERALD -> {
267-
color1 = "#064E3B";
268-
color2 = "#059669";
269-
color3 = "#34D399";
270-
}
271-
case PLATINUM -> {
272-
color1 = "#1E293B";
273-
color2 = "#0F766E";
274-
color3 = "#2DD4BF";
275-
}
276-
case GOLD -> {
277-
color1 = "#8E6310";
278-
color2 = "#C2971F";
279-
color3 = "#F4D03F";
280-
}
281-
case SILVER -> {
282-
color1 = "#111827";
283-
color2 = "#4B5563";
284-
color3 = "#9CA3AF";
285-
}
286-
case BRONZE -> {
287-
color1 = "#431407";
288-
color2 = "#92400E";
289-
color3 = "#D97706";
290-
}
291-
default -> {
292-
color1 = "#0F172A";
293-
color2 = "#334155";
294-
color3 = "#64748B";
295-
}
296-
}
297-
298-
return String.format("""
299-
<linearGradient id="tierGradient" x1="0%%" y1="0%%" x2="100%%" y2="100%%">
300-
<stop offset="0%%" style="stop-color:%s;stop-opacity:1" />
301-
<stop offset="50%%" style="stop-color:%s;stop-opacity:1" />
302-
<stop offset="100%%" style="stop-color:%s;stop-opacity:1" />
303-
</linearGradient>
304-
""", color1, color2, color3);
55+
.user(user)
56+
.commitCount(150)
57+
.prCount(30)
58+
.mergedPrCount(25)
59+
.issueCount(10)
60+
.reviewCount(45)
61+
.diffCommitCount(12)
62+
.diffPrCount(0)
63+
.diffMergedPrCount(0)
64+
.diffIssueCount(2)
65+
.diffReviewCount(8)
66+
.build();
67+
68+
return svgBadgeRenderer.render(user, tier, activityLog);
30569
}
306-
}
70+
}

0 commit comments

Comments
 (0)