Skip to content

Commit b36101c

Browse files
authored
(#61) 국제화(i18n) 영어 지원 기반 구축
* feature: 국제화 메시지 인프라 추가 * refactor: 예외 응답을 메시지 키 기반으로 전환 * refactor: 검증 메시지를 국제화 키로 전환
1 parent 9774a97 commit b36101c

14 files changed

Lines changed: 189 additions & 43 deletions

src/main/java/com/gitranker/api/domain/ranking/RankingController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class RankingController {
2121

2222
@GetMapping
2323
public ApiResponse<RankingList> getRankings(
24-
@RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다") int page,
24+
@RequestParam(defaultValue = "0") @Min(value = 0, message = "{validation.ranking.page.min}") int page,
2525
@RequestParam(required = false) Tier tier
2626
) {
2727
RankingList response = rankingService.getRankingList(page, tier);

src/main/java/com/gitranker/api/domain/user/UserController.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
public class UserController {
2323

2424
private static final String USERNAME_PATTERN = "^(?=.{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$";
25-
private static final String USERNAME_MESSAGE = "GitHub 사용자명은 1-39자의 영문, 숫자, 하이픈만 허용됩니다";
25+
private static final String USERNAME_MESSAGE = "{validation.user.username.pattern}";
2626
private final UserQueryService userQueryService;
2727
private final UserRefreshService userRefreshService;
2828
private final UserDeletionService userDeletionService;
@@ -69,4 +69,3 @@ public ResponseEntity<Void> deleteMyAccount(
6969
}
7070
}
7171

72-

src/main/java/com/gitranker/api/domain/user/dto/RegisterUserRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
import jakarta.validation.constraints.NotBlank;
44

5-
public record RegisterUserRequest(@NotBlank(message = "GitHub username은 필수값입니다.") String username) {
5+
public record RegisterUserRequest(@NotBlank(message = "{validation.user.username.not-blank}") String username) {
66
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.gitranker.api.global.config;
2+
3+
import org.springframework.context.MessageSource;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
7+
import org.springframework.web.servlet.LocaleResolver;
8+
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
9+
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.List;
12+
import java.util.Locale;
13+
14+
@Configuration
15+
public class MessageConfig {
16+
17+
@Bean
18+
public MessageSource messageSource() {
19+
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
20+
messageSource.setBasenames("classpath:messages");
21+
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name());
22+
messageSource.setFallbackToSystemLocale(false);
23+
24+
return messageSource;
25+
}
26+
27+
@Bean
28+
public LocaleResolver localeResolver() {
29+
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
30+
localeResolver.setDefaultLocale(Locale.KOREAN);
31+
localeResolver.setSupportedLocales(List.of(Locale.KOREAN, Locale.ENGLISH));
32+
33+
return localeResolver;
34+
}
35+
}

src/main/java/com/gitranker/api/global/error/ErrorMessage.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.gitranker.api.global.error;
22

3+
import com.gitranker.api.global.i18n.MessageUtils;
34
import lombok.Getter;
45

56
@Getter
@@ -19,6 +20,10 @@ public ErrorMessage(ErrorType errorType) {
1920
}
2021

2122
public ErrorMessage(ErrorType errorType, Object data) {
22-
this(errorType.toString(), errorType.getMessage(), data);
23+
this(errorType.toString(), MessageUtils.getMessage(errorType.getMessageKey()), data);
24+
}
25+
26+
public ErrorMessage(ErrorType errorType, String message, Object data) {
27+
this(errorType.toString(), message, data);
2328
}
2429
}

src/main/java/com/gitranker/api/global/error/ErrorType.java

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,48 +7,48 @@
77
@Getter
88
public enum ErrorType {
99
/* GitHub 관련 에러 */
10-
GITHUB_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "계정을 찾을 수 없어요. 아이디를 다시 확인해주세요.", LogLevel.INFO),
11-
GITHUB_COLLECT_ACTIVITY_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "GitHub 활동 데이터를 가져오는데 실패했어요. 잠시 후 다시 시도해주세요.", LogLevel.WARN),
12-
GITHUB_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "GitHub 서버와 연결할 수 없어요. 잠시 후 다시 시도해주세요.", LogLevel.WARN),
13-
GITHUB_PARTIAL_ERROR(HttpStatus.MULTI_STATUS, "일부 데이터를 불러오지 못했어요. 랭킹이 정확하지 않을 수 있어요.", LogLevel.WARN),
14-
GITHUB_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "지금은 요청이 너무 많아요.", LogLevel.WARN),
15-
GITHUB_RATE_LIMIT_EXHAUSTED(HttpStatus.SERVICE_UNAVAILABLE, "일시적으로 서비스 이용이 어려워요. 잠시 후 다시 시도해주세요.", LogLevel.WARN),
10+
GITHUB_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "error.github.user-not-found", LogLevel.INFO),
11+
GITHUB_COLLECT_ACTIVITY_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "error.github.collect-activity-failed", LogLevel.WARN),
12+
GITHUB_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "error.github.api-error", LogLevel.WARN),
13+
GITHUB_PARTIAL_ERROR(HttpStatus.MULTI_STATUS, "error.github.partial-error", LogLevel.WARN),
14+
GITHUB_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "error.github.rate-limit-exceeded", LogLevel.WARN),
15+
GITHUB_RATE_LIMIT_EXHAUSTED(HttpStatus.SERVICE_UNAVAILABLE, "error.github.rate-limit-exhausted", LogLevel.WARN),
1616

1717
/* Batch/API 안정성 관련 에러 */
18-
GITHUB_API_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "요청 시간이 너무 오래 걸려요. 잠시 후 다시 시도해주세요.", LogLevel.WARN),
19-
GITHUB_API_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "GitHub 연결 중 문제가 발생했어요.", LogLevel.WARN),
20-
GITHUB_API_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "GitHub 서버에 문제가 생긴 것 같아요.", LogLevel.ERROR),
18+
GITHUB_API_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "error.github.api-timeout", LogLevel.WARN),
19+
GITHUB_API_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "error.github.api-client-error", LogLevel.WARN),
20+
GITHUB_API_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "error.github.api-server-error", LogLevel.ERROR),
2121

2222
/* Batch 관련 에러 */
23-
BATCH_JOB_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "작업 처리에 실패했어요. 관리자에게 문의해주세요.", LogLevel.ERROR),
24-
BATCH_STEP_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "데이터 처리 중 문제가 발생했어요.", LogLevel.ERROR),
23+
BATCH_JOB_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "error.batch.job-failed", LogLevel.ERROR),
24+
BATCH_STEP_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "error.batch.step-failed", LogLevel.ERROR),
2525

2626
/* 공통 에러 */
27-
DEFAULT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.", LogLevel.ERROR),
28-
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청이에요. 입력 값을 확인해주세요.", LogLevel.INFO),
29-
UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "접근 권한이 없어요.", LogLevel.INFO),
30-
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "등록되지 않은 사용자에요. 먼저 등록해주세요.", LogLevel.INFO),
31-
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "요청하신 정보를 찾을 수 없어요.", LogLevel.INFO),
27+
DEFAULT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "error.common.default", LogLevel.ERROR),
28+
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "error.common.invalid-request", LogLevel.INFO),
29+
UNAUTHORIZED_ACCESS(HttpStatus.UNAUTHORIZED, "error.common.unauthorized-access", LogLevel.INFO),
30+
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "error.user.not-found", LogLevel.INFO),
31+
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "error.common.resource-not-found", LogLevel.INFO),
3232

3333
/* 인증 관련 에러 */
34-
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "다시 로그인해주세요.", LogLevel.INFO),
35-
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "로그인이 만료되었어요. 다시 로그인해주세요.", LogLevel.INFO),
36-
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요해요. 로그인해주세요.", LogLevel.INFO),
37-
FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없어요. 본인의 정보만 수정할 수 있어요.", LogLevel.INFO),
38-
SESSION_EXPIRED(HttpStatus.UNAUTHORIZED, "세션이 만료되었습니다. 다시 로그인해 주세요.", LogLevel.INFO),
34+
INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "error.auth.invalid-refresh-token", LogLevel.INFO),
35+
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "error.auth.expired-refresh-token", LogLevel.INFO),
36+
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "error.auth.unauthorized", LogLevel.INFO),
37+
FORBIDDEN(HttpStatus.FORBIDDEN, "error.auth.forbidden", LogLevel.INFO),
38+
SESSION_EXPIRED(HttpStatus.UNAUTHORIZED, "error.auth.session-expired", LogLevel.INFO),
3939

4040
/* 사용자 관련 에러 */
41-
REFRESH_COOL_DOWN_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "데이터 갱신은 5분에 한 번만 가능해요.", LogLevel.INFO),
42-
ACTIVITY_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 사용자의 활동 로그를 찾을 수 없습니다.", LogLevel.INFO),
41+
REFRESH_COOL_DOWN_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "error.user.refresh-cooldown-exceeded", LogLevel.INFO),
42+
ACTIVITY_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "error.user.activity-log-not-found", LogLevel.INFO),
4343
;
4444

4545
private final HttpStatus status;
46-
private final String message;
46+
private final String messageKey;
4747
private final LogLevel logLevel;
4848

49-
ErrorType(HttpStatus status, String message, LogLevel logLevel) {
49+
ErrorType(HttpStatus status, String messageKey, LogLevel logLevel) {
5050
this.status = status;
51-
this.message = message;
51+
this.messageKey = messageKey;
5252
this.logLevel = logLevel;
5353
}
5454
}

src/main/java/com/gitranker/api/global/error/GlobalExceptionHandler.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import jakarta.validation.ConstraintViolation;
1212
import jakarta.validation.ConstraintViolationException;
1313
import lombok.RequiredArgsConstructor;
14+
import org.springframework.context.MessageSource;
1415
import org.springframework.http.ResponseEntity;
16+
import org.springframework.context.i18n.LocaleContextHolder;
1517
import org.springframework.web.bind.annotation.ExceptionHandler;
1618
import org.springframework.web.bind.annotation.RestControllerAdvice;
1719
import org.springframework.web.servlet.resource.NoResourceFoundException;
@@ -22,16 +24,18 @@ public class GlobalExceptionHandler {
2224

2325
private final TimeUtils timeUtils;
2426
private final BusinessMetrics businessMetrics;
27+
private final MessageSource messageSource;
2528

2629
@ExceptionHandler(BusinessException.class)
2730
public ResponseEntity<ApiResponse<Object>> handleBusinessException(BusinessException e) {
2831
ErrorType errorType = e.getErrorType();
32+
String localizedMessage = resolveMessage(errorType);
2933

3034
LogContext ctx = LogContext.event(Event.ERROR_HANDLED)
3135
.with("error_code", errorType.name())
3236
.with("error_status", errorType.getStatus().value())
3337
.with("error_type", e.getClass().getSimpleName())
34-
.with("error_message", e.getMessage());
38+
.with("error_message", localizedMessage);
3539

3640
switch (errorType.getLogLevel()) {
3741
case ERROR -> ctx.error();
@@ -43,7 +47,7 @@ public ResponseEntity<ApiResponse<Object>> handleBusinessException(BusinessExcep
4347

4448
return ResponseEntity
4549
.status(errorType.getStatus())
46-
.body(ApiResponse.error(errorType, e.getData()));
50+
.body(ApiResponse.error(errorType, localizedMessage, e.getData()));
4751
}
4852

4953
@ExceptionHandler(ConstraintViolationException.class)
@@ -53,7 +57,7 @@ public ResponseEntity<ApiResponse<Object>> handleConstraintViolationException(Co
5357
String message = e.getConstraintViolations().stream()
5458
.map(ConstraintViolation::getMessage)
5559
.findFirst()
56-
.orElse("잘못된 요청입니다");
60+
.orElse(resolveMessage("error.common.bad-request"));
5761

5862
LogContext.event(Event.ERROR_HANDLED)
5963
.with("error_code", errorType.name())
@@ -66,7 +70,7 @@ public ResponseEntity<ApiResponse<Object>> handleConstraintViolationException(Co
6670

6771
return ResponseEntity
6872
.status(errorType.getStatus())
69-
.body(ApiResponse.error(errorType, message));
73+
.body(ApiResponse.error(errorType, message, null));
7074
}
7175

7276
@ExceptionHandler(NoResourceFoundException.class)
@@ -92,7 +96,7 @@ public ResponseEntity<ApiResponse<Object>> handleGitHubRateLimitException(GitHub
9296
ErrorType errorType = e.getErrorType();
9397

9498
String resetTimeStr = timeUtils.formatForDisplay(e.getResetAt().plusMinutes(1));
95-
String message = String.format("%s 이후에 다시 시도해주세요.", resetTimeStr);
99+
String message = resolveMessage("error.github.rate-limit-retry-after", resetTimeStr);
96100

97101
LogContext.event(Event.ERROR_HANDLED)
98102
.with("error_code", errorType.name())
@@ -105,7 +109,7 @@ public ResponseEntity<ApiResponse<Object>> handleGitHubRateLimitException(GitHub
105109

106110
return ResponseEntity
107111
.status(errorType.getStatus())
108-
.body(ApiResponse.error(errorType, message));
112+
.body(ApiResponse.error(errorType, message, null));
109113
}
110114

111115
@ExceptionHandler(Exception.class)
@@ -125,4 +129,12 @@ public ResponseEntity<ApiResponse<Object>> handleException(Exception e) {
125129
.status(errorType.getStatus())
126130
.body(ApiResponse.error(errorType));
127131
}
132+
133+
private String resolveMessage(ErrorType errorType) {
134+
return resolveMessage(errorType.getMessageKey());
135+
}
136+
137+
private String resolveMessage(String messageKey, Object... args) {
138+
return messageSource.getMessage(messageKey, args, messageKey, LocaleContextHolder.getLocale());
139+
}
128140
}

src/main/java/com/gitranker/api/global/error/exception/BusinessException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public BusinessException(ErrorType errorType) {
1313
}
1414

1515
public BusinessException(ErrorType errorType, Object data) {
16-
super(errorType.getMessage());
16+
super(errorType.getMessageKey());
1717
this.errorType = errorType;
1818
this.data = data;
1919
}

src/main/java/com/gitranker/api/global/error/exception/GitHubApiNonRetryableException.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ public class GitHubApiNonRetryableException extends RuntimeException {
88
private final ErrorType errorType;
99

1010
public GitHubApiNonRetryableException(ErrorType errorType) {
11-
super(errorType.getMessage());
11+
super(errorType.getMessageKey());
1212
this.errorType = errorType;
1313
}
1414

1515
public GitHubApiNonRetryableException(ErrorType errorType, String message) {
16-
super(errorType.getMessage() + ": " + message);
16+
super(errorType.getMessageKey() + ": " + message);
1717
this.errorType = errorType;
1818
}
1919
}

src/main/java/com/gitranker/api/global/error/exception/GitHubApiRetryableException.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ public class GitHubApiRetryableException extends RuntimeException {
88
private final ErrorType errorType;
99

1010
public GitHubApiRetryableException(ErrorType errorType) {
11-
super(errorType.getMessage());
11+
super(errorType.getMessageKey());
1212
this.errorType = errorType;
1313
}
1414

1515
public GitHubApiRetryableException(ErrorType errorType, String message) {
16-
super(errorType.getMessage() + ": " + message);
16+
super(errorType.getMessageKey() + ": " + message);
1717
this.errorType = errorType;
1818
}
1919

2020
public GitHubApiRetryableException(ErrorType errorType, Throwable cause) {
21-
super(errorType.getMessage(), cause);
21+
super(errorType.getMessageKey(), cause);
2222
this.errorType = errorType;
2323
}
2424
}

0 commit comments

Comments
 (0)