Spring Boot 다국어 서비스 구현하기

글로벌 서비스를 개발할 때 다국어 지원은 필수적인 기능입니다. HTTP의 Accept-Language 헤더를 활용하면 사용자의 언어 설정에 따라 자동으로 적절한 언어로 서비스를 제공할 수 있습니다. 이번 글에서는 HTTP 기초부터 Spring Boot에서 Accept-Language를 활용한 다국어 서비스 구현까지 알아보겠습니다.

 

이 글에서 사용한 예제 코드는 GitHub 레포지토리에 정리해 두었습니다.
자세한 내용은 글 하단의 링크를 참고해주세요.

HTTP 기초 이해하기

HTTP란?

HTTP(HyperText Transfer Protocol)는 웹에서 클라이언트와 서버 간의 통신을 위한 프로토콜입니다. 요청-응답(Request-Response) 모델을 기반으로 하며, 클라이언트가 서버에 요청을 보내면 서버가 응답을 반환하는 방식으로 작동합니다.

HTTP 헤더의 역할

HTTP 헤더는 요청과 응답에 대한 메타데이터를 전달합니다. 헤더는 다음과 같은 정보를 포함할 수 있습니다:

  • 컨텐츠 타입 및 인코딩 정보
  • 인증 및 권한 정보
  • 캐싱 정책
  • 클라이언트의 언어 선호도

Accept-Language 헤더 알아보기

Accept-Language란?

Accept-Language 헤더는 클라이언트가 선호하는 언어를 서버에 알려주는 HTTP 요청 헤더입니다. 브라우저는 사용자의 언어 설정을 기반으로 이 헤더를 자동으로 설정합니다.

Accept-Language 헤더 구조

Accept-Language: ko-KR,ko;q=0.9,en;q=0.8,en-US;q=0.7

이 헤더는 다음과 같이 해석됩니다:

  • ko-KR: 한국어(대한민국) - 우선순위 1.0 (기본값)
  • ko: 한국어(일반) - 우선순위 0.9
  • en: 영어(일반) - 우선순위 0.8
  • en-US: 영어(미국) - 우선순위 0.7

언어 태그 형식

언어 태그는 다음과 같은 형식을 따릅니다:

  • ko: 언어 코드만
  • ko-KR: 언어-국가 코드 -> en-US(미국 영어)와 en-GB(영국 영어), zh-CN(중국 간체)와 zh-TW(대만 번체) 등
  • zh-Hans-CN: 언어-문자체계-국가 코드

품질 값(q-value)

품질 값은 0.0부터 1.0까지의 값으로, 클라이언트의 언어 선호도를 나타냅니다:

  • 1.0: 가장 선호 (기본값, 생략 가능)
  • 0.8: 높은 선호도
  • 0.5: 보통 선호도
  • 0.0: 허용하지 않음

Spring Boot에서 다국어 서비스 구현하기

국제화 하는 두 가지의 방법

구분 messages.properties DB 방식
구현 복잡도 간단(스프링 기본 기능) 복잡(테이블 설계)
성능 빠름(메모리 로드) 느림(DB 조회 필요)
관리 편의성 파일 수정 후 재배포 해야하므로 개발자만 수정 가능 실시간 수정 가능
버전 관리 쉬움 어려움

 

1. 국제화 설정

@Configuration
class LocaleConfig {

    @Bean
    fun localeResolver(): LocaleResolver {
        val resolver = AcceptHeaderLocaleResolver().apply {
            // 🛡️ 보안 & 성능 - DB에 있는 언어만 처리
            setSupportedLocales(
                listOf(
                    Locale.ENGLISH,    // "en"
                    Locale.KOREAN,     // "ko"
                    Locale.JAPANESE,   // "ja"
                    Locale.CHINESE     // "zh"
                )
            )
            // 🎯 예측 가능한 기본값 - 매우 중요!
            setDefaultLocale(Locale.ENGLISH)
        }

        return resolver
    }
}

2. 메시지 파일 생성

src/main/resources/ 디렉토리에 언어별 메시지 파일을 생성합니다:

messages.properties (기본)

hello=안녕하세요
welcome=환영합니다
user.name=사용자 이름
user.email=이메일
button.save=저장
button.cancel=취소

messages_en.properties

hello=Hello
welcome=Welcome
user.name=User Name
user.email=Email
button.save=Save
button.cancel=Cancel

messages_ja.properties

hello=こんにちは
welcome=ようこそ
user.name=ユーザー名
user.email=メール
button.save=保存
button.cancel=キャンセル

3. 컨트롤러 구현

@RestController
class HelloController(
    private val messageSource: MessageSource
) {

    @GetMapping("/hello", produces = ["text/plain;charset=UTF-8"])
    fun hello(locale: Locale): String {
        val locale = LocaleContextHolder.getLocale()
        val text = messageSource.getMessage("hello", null, locale)
        return text
    }

}

 

4. MessageSourceConfig

/**
 * DB에서 데이터를 가져오는 방법이 아니라면 필요
 */
@Configuration
class MessageSourceConfig {
    @Bean
    fun messageSource(): MessageSource =
        ReloadableResourceBundleMessageSource().apply {
            setBasenames("classpath:messages")
            setDefaultEncoding("UTF-8")
            setCacheSeconds(1) // 1초마다 파일 변경 확인
        }
}
  • messages.properties 파일을 서버 재시작 하지 않고 1초에 한번씩 파일 변경 확인
  • 명시적인 UTF-8 인코딩으로 한글 등 다국어 문자가 깨지지 않도록 보장

 

5. 엔티티 클래스 만들기

@Entity
@Table(name = "products")
class Product(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(nullable = false, length = 255)
    var sku: String,             // 비즈니스용 코드(선택)

    @Column(nullable = false, length = 255)
    var defaultTitle: String = "",

    @Column(columnDefinition = "TEXT")
    var defaultDescription: String? = null,
) {
    // JPA용 기본 생성자
    constructor() : this(null, "", "", null)

}

@Entity
@Table(
    name = "product_localization",
    uniqueConstraints = [UniqueConstraint(columnNames = ["product_id", "locale"])]
)
class ProductLocalization(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    var product: Product? = null,

    @Column(nullable = false, length = 10)
    var locale: String,    // "en", "ko", "ja", "en-US" 등

    @Column(nullable = false, length = 255)
    var title: String,

    @Column(columnDefinition = "TEXT")
    var description: String? = null,
) {
    // JPA용 기본 생성자
    constructor() : this(null, null, "", "", null)
}

6. DB 사용해서 다국어 처리하기

Locale 클래스 사용

@RestController
@RequestMapping("/api/products")
class ProductController(
    private val productReadService: ProductReadService,
) {

    @GetMapping("/{id}")
    fun getProduct(
        @PathVariable id: Long,
        locale: Locale, // Spring이 자동으로 http 헤더의 locale 설정을 주입해줌
    ): ResponseEntity<ProductDto> {

        val languageCode = locale.language.ifBlank { "en" }
        val dto = productReadService.get(productId = id, locale = languageCode)

        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_LANGUAGE, languageCode)
            .body(dto)
    }
}

6. ProductService

@Service
class ProductReadService(
    private val productRepository: ProductRepository,
    private val productLocRepo: ProductLocRepository,
) {
    fun get(productId: Long, locale: String): ProductDto {
        val product = productRepository.findById(productId).orElseThrow {
            ResponseStatusException(HttpStatus.NOT_FOUND, "product not found")
        }

        val loc = productLocRepo.findFirstByProduct_IdAndLocale(productId, locale)

        return if (loc != null) {
            ProductDto(product.id!!, loc.title, loc.description)
        } else {
            // 번역이 한 건도 없으면 기본값 사용
            ProductDto(product.id!!, product.defaultTitle, product.defaultDescription)
        }
    }
}

data class ProductDto(
    val id: Long,
    val title: String,
    val description: String?
)

7. 웹 설정

@Configuration
class WebConfig : WebMvcConfigurer {
    @Bean
    fun stringHttpMessageConverter() =
        StringHttpMessageConverter(StandardCharsets.UTF_8)
}

 

1. cURL을 사용한 테스트

지원되지 않는 경우나 헤더를 사용하지 않을 경우 기본 값인 영어로 출력되는 모습

 




해당 코드들을 참고 할 수 있는 레포지토리를 공유해드립니다.

 

https://github.com/HongYeseul/spring-boot-locale-demo

 

GitHub - HongYeseul/spring-boot-locale-demo

Contribute to HongYeseul/spring-boot-locale-demo development by creating an account on GitHub.

github.com