λ³Έ 글은 λ²¨λ¦¬λ°μ΄μ…˜ 였λ₯˜ λ©”μ‹œμ§€κ°€ μ‚¬μš©μž μ–Έμ–΄λ‘œ μ²˜λ¦¬λ˜μ§€ μ•Šμ€ 이유λ₯Ό μž‘μ„±ν•˜λ©΄μ„œ λ³΄μ™„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

λ‹€κ΅­μ–΄ λ©”μ‹œμ§€

μŠ€ν”„λ§μ΄λ‚˜ μŠ€ν”„λ§ λΆ€νŠΈμ—μ„œ λ‹€κ΅­μ–΄ λ©”μ‹œμ§€λ₯Ό μ μš©ν•˜κΈ° μœ„ν•΄μ„œλŠ” Propertiesλ₯Ό 기본으둜 μ‚¬μš©ν•΄μ•Όν•©λ‹ˆλ‹€. κ·ΈλŸ¬λ‚˜, messages-en.properties λ˜λŠ” messages-ko.properties와 같이 μ–Έμ–΄λ³„λ‘œ ν”„λ‘œνΌν‹° νŒŒμΌμ„ κ΅¬λΆ„ν•˜μ—¬ λ©”μ‹œμ§€λ₯Ό κ΄€λ¦¬ν•΄μ•Όλ§Œ ν•©λ‹ˆλ‹€. 이처럼 ν”„λ‘œνΌν‹° 파일둜 λ©”μ‹œμ§€λ₯Ό κ΄€λ¦¬ν•˜λ‹€λ³΄λ©΄ ν•΄λ‹Ή μ–Έμ–΄μ—μ„œ νŠΉμ • λ©”μ‹œμ§€λ₯Ό ν‚€λ₯Ό μ‚¬μš©ν–ˆλŠ”μ§€ νŒŒμ•…ν•˜λŠ”κ²Œ μƒλ‹Ήνžˆ μ–΄λ ΅μŠ΅λ‹ˆλ‹€. ν˜„μž¬ 쑰직처럼 νšŒμ‚¬ λ‚΄ ν”„λ‘œμ νŠΈλ₯Ό 진행할 λ•Œ λ©”μ‹œμ§€ 킀에 λŒ€ν•΄ μ •μ˜λœ λ¬Έμ„œκ°€ μ—†λŠ” κ²½μš°μ—λŠ” κ°œλ°œμžκ°€ λ©”μ‹œμ§€ μ½”λ“œλ₯Ό κ΄€λ¦¬ν•΄μ•Όν•˜λ―€λ‘œ 맀번 κ²€μƒ‰ν•΄μ„œ μ‚¬μš©ν•˜κ³  μžˆλŠ”μ§€ νŒŒμ•…ν•΄μ•Όλ§Œ ν•©λ‹ˆλ‹€.

λŒ€μ•ˆ 방식

ν”„λ‘œνΌν‹° 파일둜 λ‹€κ΅­μ–΄ λ©”μ‹œμ§€λ₯Ό κ΄€λ¦¬ν•˜λŠ” 것을 λ³΄μ™„ν•˜κΈ° μœ„ν•œ 방법은 λ‹€μ–‘ν•©λ‹ˆλ‹€.

YAML

μ• ν”Œλ¦¬μΌ€μ΄μ…˜ ν”„λ‘œνΌν‹° νŒŒμΌμ„ μ•Όλ―ˆ(Yaml) 파일둜 λŒ€μ²΄ν•˜λŠ” κ²ƒμ²˜λŸΌ YAML νŒŒμΌμ„ μ‚¬μš©ν•΄μ„œ λ‹€κ΅­μ–΄ λ©”μ‹œμ§€λ₯Ό κ΄€λ¦¬ν•˜λŠ” 방법은 κΈ°μ–΅ν•˜κΈ° μœ„ν•œ κ°œλ°œλ…ΈνŠΈ:μŠ€ν”„λ§λΆ€νŠΈμ—μ„œ λ‹€κ΅­μ–΄ κΈ°λŠ₯ μ‚¬μš©ν•˜κΈ°λ₯Ό 톡해 확인할 수 μžˆμŠ΅λ‹ˆλ‹€. κ·ΈλŸ¬λ‚˜ 이 방식은 파일만 λŒ€μ²΄ν•  뿐 λ©”μ‹œμ§€λ₯Ό κ΄€λ¦¬ν•˜κΈ° μœ„ν•œ YAML νŒŒμΌμ€ μ–Έμ–΄λ³„λ‘œ λ§Œλ“€μ–΄μ•Όν•¨μœΌλ‘œ ν”„λ‘œνΌν‹°μ˜ λ¬Έμ œμ μ„ λ™μΌν•˜κ²Œ 가지고 μžˆμŠ΅λ‹ˆλ‹€.

ISO-8859-1 인코딩 ν˜•μ‹μœΌλ‘œ μ €μž₯λ˜λŠ” ν”„λ‘œνΌν‹°μ™€λŠ” λ‹€λ₯΄κ²Œ ν•œκΈ€μ΄ μœ λ‹ˆμ½”λ“œλ‘œ ν‘œμ‹œλ˜μ§€ μ•ŠλŠ”λ‹€λŠ” μž₯점은 μ‘΄μž¬ν•©λ‹ˆλ‹€.

XML

ν˜„μž¬ μ‘°μ§μ—μ„œλŠ” ν”„λ‘œμ νŠΈμ—μ„œ μ‚¬μš©ν•˜λŠ” λ‹€κ΅­μ–΄ λ©”μ‹œμ§€λ₯Ό XML 파일둜 κ΅¬μ„±ν•˜μ—¬ κ΄€λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€. λ‹€μŒμ€ λ‹€κ΅­μ–΄ λ©”μ‹œμ§€λ₯Ό κ΄€λ¦¬ν•˜κΈ° μœ„ν•œ XML 파일의 κ°„λ‹¨ν•œ μ˜ˆμ‹œμž…λ‹ˆλ‹€.

<?xml version="1.0" encoding="UTF-8"?>

<messages>
    <entry key="btn.signIn">
        <ko_KR><![CDATA[둜그인]]></ko_KR>
        <en_US><![CDATA[Login]]></en_US>
    </entry>
</messages>

μ»€μŠ€ν…€ λ¦¬μ†ŒμŠ€ λ²ˆλ“€

ν”„λ‘œνΌν‹° 파일 λŒ€μ‹ μ— XML둜 λ©”μ‹œμ§€ μ†ŒμŠ€λ₯Ό λ§Œλ“€κΈ° μœ„ν•΄μ„œλŠ” λ¦¬μ†ŒμŠ€ λ²ˆλ“€λΆ€ν„° λ§Œλ“€μ–΄μ•Όν•©λ‹ˆλ‹€. ResourceBundle 클래슀의 getBundle ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•΄μ„œ XML νŒŒμΌμ„ 읽어 λ¦¬μ†ŒμŠ€ λ²ˆλ“€λ‘œ λ³€ν™˜ν•  수 μžˆμŠ΅λ‹ˆλ‹€. λ¦¬μ†ŒμŠ€ λ²ˆλ“€λ‘œ λ©”μ‹œμ§€ μ†ŒμŠ€λ₯Ό λ§Œλ“œλŠ” κ΅¬μ‘°λŠ” XML 기반의 Resource Bundle, PropertyPlaceHolder μ‚¬μš©ν•˜κΈ°μ—μ„œ 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.

XmlResourceBundle

κ·ΈλŸ¬λ‚˜ μ•žμ„œ μ•Œμ•„λ³Έ λ‹€κ΅­μ–΄ λ©”μ‹œμ§€μ— λŒ€ν•œ XML νŒŒμΌμ€ ν”„λ‘œνΌν‹° ꡬ쑰λ₯Ό λ”°λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. κ·Έλž˜μ„œ Properties.loadFromXML ν•¨μˆ˜λ₯Ό 톡해 XML을 ν”„λ‘œνΌν‹° κΈ°μ€€μœΌλ‘œ 읽으면 μ•ˆλ©λ‹ˆλ‹€. λ‹€μŒμ²˜λŸΌ XML ꡬ성에 λ”°λΌμ„œ λ©”μ‹œμ§€ 정보λ₯Ό λ§Œλ“€μ–΄μ•Όν•©λ‹ˆλ‹€.

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;

public class XmlResourceBundle extends ResourceBundle {

    private Map<String, Map<String, String>> messages;
    private Locale i18n;

    public XmlResourceBundle(InputStream is, Locale i18n) throws IOException, ParserConfigurationException, SAXException {
        try (is) {
            this.i18n = i18n;
            messages = new HashMap<>();

            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            DocumentBuilder builder = factory.newDocumentBuilder();
            Document doc = builder.parse(is);
            doc.getDocumentElement().normalize();

            NodeList entries = doc.getElementsByTagName("entry");

            for (int i = 0; i < entries.getLength(); i++) {
                Element entry = (Element) entries.item(i);
                String key = entry.getAttribute("key");
                NodeList childNodes = entry.getChildNodes();
                for (int j = 0; j < childNodes.getLength(); j++) {
                    Node n = childNodes.item(j);
                    if (n.getNodeType() == Node.ELEMENT_NODE) {
                        String locale = n.getNodeName();
                        String message = n.getTextContent();

                        if (!messages.containsKey(locale)) {
                            messages.put(locale, new HashMap<>());
                        }

                        messages.get(locale).put(key, message);
                    }
                }
            }
        }
    }

    @Override
    protected Object handleGetObject(String key) {
        return messages.get(i18n).get(key);
    }

    @Override
    public Enumeration<String> getKeys() {
        Set<String> handleKeys = messages.keySet();
        return Collections.enumeration(handleKeys);
    }

    public void setLocale(Locale locale) {
        this.i18n = locale;
    }

    public Map<String, Map<String, String>> getMessages() {
        return messages;
    }

    public Map<String, String> getMessages(Locale locale) {
        return messages.get(locale.toString());
    }
}

기쑴의 handleGetObject ν•¨μˆ˜λŠ” ν‚€ νŒŒλΌλ―Έν„°λ§Œ 받도둝 λ˜μ–΄μžˆκΈ° λ•Œλ¬Έμ— λ©”μ‹œμ§€λ₯Ό κ°€μ Έμ˜¬ κ²½μš°μ— μ–Έμ–΄λ₯Ό 지정할 수 μ—†μœΌλ―€λ‘œ λ¦¬μ†ŒμŠ€ λ²ˆλ“€μ„ μƒμ„±ν•˜λŠ” μ‹œμ μ— μ–Έμ–΄λ₯Ό 지정할 수 있게 ν•˜μ˜€μŠ΅λ‹ˆλ‹€.

XmlResourceBundleLoader

XmlResourceBundleλ₯Ό λ‘œλ“œν•˜κΈ° μœ„ν•œ 클래슀λ₯Ό λ§Œλ“€κΈ° μœ„ν•΄μ„œ The Strings.xml Resource Bundle을 μ°Έκ³ ν•˜μ—¬ μ½”λ“œλ₯Ό μž‘μ„±ν•©λ‹ˆλ‹€.

import lombok.extern.slf4j.Slf4j;
import org.xml.sax.SAXException;

import javax.xml.parsers.ParserConfigurationException;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;

@Slf4j
public class XmlResourceBundleLoader extends ResourceBundle.Control {

    private static final List<String> formats = Collections.singletonList("xml");

    @Override
    public List<String> getFormats(String baseName) {
        return formats;
    }

    @Override
    public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException {
        ResourceBundle resourceBundle = null;

        String bundleName = toBundleName(baseName, locale);
        String resourceName = toResourceName(bundleName, format);

        URL url = loader.getResource(resourceName);
        if (url == null) {
            return null;
        }

        URLConnection connection = url.openConnection();
        if (connection == null) {
            return null;
        }
        if (reload) {
            connection.setUseCaches(false);
        }
        InputStream stream = connection.getInputStream();
        if (stream == null) {
            return null;
        }
        try (BufferedInputStream bis = new BufferedInputStream(stream)) {
            if (locale == Locale.ROOT) {
                locale = Locale.getDefault();
            }
            resourceBundle = new XmlResourceBundle(bis, locale);
        } catch (SAXException | ParserConfigurationException e) {
            log.error(e.getMessage());
        }

        return resourceBundle;
    }
}

λ©”μ‹œμ§€ μ†ŒμŠ€

μ€€λΉ„λœ λ¦¬μ†ŒμŠ€ λ²ˆλ“€μ„ μ‚¬μš©ν•˜κΈ° μœ„ν•΄μ„œ λ©”μ‹œμ§€ μ†ŒμŠ€λ‘œ λ³€ν™˜ν•΄μ•Όν•©λ‹ˆλ‹€. μ‚¬μš©μž μ •μ˜ λ©”μ‹œμ§€ μ†ŒμŠ€λ₯Ό λ§Œλ“€κΈ° μœ„ν•΄μ„œλŠ” AbstractMessageSourceλ₯Ό μƒμ†ν•˜λ©΄ λ©λ‹ˆλ‹€.

package com.example.springboot.i18n;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.AbstractMessageSource;
import org.springframework.stereotype.Component;

import java.text.MessageFormat;
import java.util.*;

@Slf4j
@Component
public class CustomMessageSource extends AbstractMessageSource {

    private final Map<String, Map<String, MessageFormat>> formats = new HashMap<>();
    private Map<String, Map<String, String>> messages = new HashMap<>();

    @Autowired
    public CustomMessageSource() {
        try {
            load();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void load() {
        ResourceBundle resourceBundle = ResourceBundle.getBundle("messages", Locale.ROOT, new XmlResourceBundleLoader());

        XmlResourceBundle xmlResourceBundle = (XmlResourceBundle) resourceBundle;
        this.messages = xmlResourceBundle.getMessages();
    }

    // λ©”μ‹œμ§€ μ†ŒμŠ€μ—μ„œ μ§€μ›ν•˜λŠ” μ–Έμ–΄ λͺ©λ‘
    public List<String> getLocales() {
        return new ArrayList<>(messages.keySet());
    }

    @Override
    protected MessageFormat resolveCode(String code, Locale locale) {
        synchronized (formats) {
            // μ–Έμ–΄ 포맷이 없을 경우 λ©”μ‹œμ§€ 포맷을 μƒˆλ‘œ 생성
            if (!formats.containsKey(locale.toString())) {
                formats.put(locale.toString(), new HashMap<>());
            }

            Map<String, MessageFormat> map = formats.get(locale.toString());

            // μ–Έμ–΄ 포맷에 λ©”μ‹œμ§€ μ½”λ“œκ°€ μ—†μœΌλ©΄ λ©”μ‹œμ§€ 정보λ₯Ό 톡해 포맷을 μ €μž₯
            if (!map.containsKey(code)) {
                if (!messages.containsKey(locale.toString())) {
                    locale = Locale.getDefault();
                }
                Map<String, String> msgs = messages.get(locale.toString());
                map.put(code, new MessageFormat(msgs.getOrDefault(code, code), locale));
            }
            return map.get(code);
        }
    }

    public String getMessage(String code) {
        try {
            return getMessage(code, new Object[0], LocaleContextHolder.getLocale());
        } catch (NoSuchMessageException e) {
            return code;
        }
    }

    public String getMessage(String code, Object[] args) {
        try {
            return getMessage(code, args, LocaleContextHolder.getLocale());
        } catch (NoSuchMessageException e) {
            return code;
        }
    }

    public String getMessage(String code, Object[] args, String defaultMessage) {
        try {
            return getMessage(code, args, defaultMessage, LocaleContextHolder.getLocale());
        } catch (NoSuchMessageException e) {
            return code;
        }
    }

    public String getMessage(String code, Locale locale) {
        try {
            return getMessage(code, new Object[0], locale);
        } catch (NoSuchMessageException e) {
            return code;
        }
    }
}

λ©”μ‹œμ§€ μ†ŒμŠ€ κ°±μ‹ 

둜컬 ν™˜κ²½μ—μ„œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ κ°œλ°œν•˜λŠ” λ™μ•ˆμ—λŠ” λ©”μ‹œμ§€ μ†ŒμŠ€ 정보가 계속 λ³€κ²½λ˜μ–΄μ•Όν•˜λŠ” μš”κ΅¬μ‚¬ν•­μ΄ μžˆμŠ΅λ‹ˆλ‹€. μŠ€ν”„λ§ λΆ€νŠΈλ₯Ό μ‚¬μš©ν•˜κ³  μžˆλ‹€λ©΄ spring-boot-devtool을 μ‚¬μš©ν•΄μ„œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ΄ λ‹€μ‹œ μ‹€ν–‰ν•  수 있게 λ³€κ²½ν•  수 μžˆμŠ΅λ‹ˆλ‹€. κ·ΈλŸ¬λ‚˜, λ©”μ‹œμ§€ 정보가 λ³€κ²½λ˜λŠ” 것이 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μžμ²΄μ— νŠΉλ³„ν•œ 영ν–₯을 λ―ΈμΉ˜μ§€λŠ” μ•ŠμŠ΅λ‹ˆλ‹€. μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ λ‹€μ‹œ μ‹€ν–‰ν•˜μ§€ μ•Šκ³  λ³€κ²½λœ λ©”μ‹œμ§€ 정보λ₯Ό λ°˜μ˜ν•  수 μžˆλ„λ‘ ν•˜λŠ” 것이 μ’‹μŠ΅λ‹ˆλ‹€.

μš°λ¦¬κ°€ κ΅¬ν˜„ν•΄μ•Όν•  λ™μž‘μ€ ReloadableResourceBundleMessageSourceκ°€ λ³€κ²½λœ ν”„λ‘œνΌν‹° νŒŒμΌμ„ λ‹€μ‹œ λ‘œλ“œν•˜λŠ” 것과 λΉ„μŠ·ν•©λ‹ˆλ‹€.

@Repository
public class CustomMessageSource extends AbstractMessageSource {

    @Autowired
    public CustomMessageSource(Environment environment) {
        instance = this;
        try {
            load();
        } catch (Exception e) {
            e.printStackTrace();
        }

        if (!ArrayUtils.contains(environment.getActiveProfiles(), "production")) {
            reload();
        }
    }

    public void reload() {
        new Thread(() -> {
            try {
                File file = new ClassPathResource("messages.xml").getFile();
                long lastModified = -1;
                while (true) {
                    try {
                        if (lastModified < file.lastModified()) {
                            load();
                            log.info("Reload MessageSource - {}", System.currentTimeMillis());
                            lastModified = file.lastModified();
                        }
                    } catch (Exception e) {
                        log.error(e.getMessage());
                    }
                    Thread.sleep(5000);
                }

            } catch (Exception e) {
                log.error(e.getMessage());
                if (e instanceof InterruptedException) {
                    Thread.currentThread().interrupt();
                }
            }
        }).start();
    }
}

μœ„ 처럼 파일 μˆ˜μ •μΌμ„ λΉ„κ΅ν•˜μ§€ μ•Šμ•„λ„ μžλ°” 7의 WatchServiceλ₯Ό ν™œμš©ν•΄λ„ νŒŒμΌμ„ κ°μ‹œν•  수 μžˆμŠ΅λ‹ˆλ‹€.

μ°Έκ³