반응형

비동기 구현을 하려는 이유


프로젝트에서 서버에서 발생하는 5xx Exception을 모니터링하기 위해서 Slack WebHook을 사용해서 구현했습니다.

 

현재 로직은 5xx 예외가 발생 -> Slack 메시지 전송 -> 클라이언트에게 응답까지의 과정이 동기적으로 진행됩니다.

 

여기서 사용하고 있는 Slack 서버는 제가 제어할 수 없는 부분이기 때문에 만약 Slack 서버에 지연이 생겨 응답을 늦게 준다면 어떻게 될지 생각을 해봤습니다.

 

Slack 메시지 전송이 지연되어 정작 더 중요한 다음 과정인 "클라이언트에게 응답"이 제대로 수행되지 않는다면... 끔찍한 상황이 벌어질 수도 있다고 생각했습니다.

 

실제로 Slack 메시지를 보내는 push() 메서드에서 5초의 지연 시간을 주고 테스트 해보니 클라이언트에게 응답이 가는 시간도 그만큼 지연이 되는 것을 확인했습니다.

요청이 들어온 시간과 클라이언트에게 응답되는 시간 사이의 5초간의 지연 발생

 

 

그래서 Slack으로 메시지를 보내는 부분을 비동기로 처리하여 클라이언트에게 응답을 제공하는 부분에 지연이 생기지 않도록 구현하기로 했습니다.

 

 

 

🔎 Spring의 @Async


구현을 하기 전에 @Async를 조금 알아보면, 비동기 처리를 간단하게 할 수 있도록 Spring에서 제공해주는 어노테이션입니다.

 

사용 방법은 @Async를 사용하겠다고 @EnableAsyc를 붙여주고, 비동기로 처리할 메서드 위에 @Async를 붙여주면 됩니다.

 

@Async를 사용할 때는 주의 해야할 점이 2가지 있습니다.

  1. private 메서드에 붙여서는 안 됩니다. (컴파일 에러로 확인 가능)
  2. 같은 클래스 내부에서 스스로 호출(self-invocation)을 하면 안 된다. (컴파일 에러로 확인 불가)

 

1번은 private 메서드에 @Async를 붙여보면 IntelliJ에서 컴파일 에러를 확인해 볼 수 있습니다.

private 메서드에 @Async 붙였을 때

 

2번은 아래와 같이 컴파일로 확인이 되지 않습니다.

같은 클래스 내부에 있는 test() 메서드를 self invocation

 

이제 외부에서 MonitoringProvider의 push() 메서드를 호출 했을 때, 내부에 있는 test()가 당연히 비동기 처리 되겠지 하고 생각하고 놔두면 이제 디버깅 지옥을 맛볼 수 있습니다.

 

실제로 외부에서 push() 메서드를 여러 번 요청해서 비동기 처리가 되는지 확인해보면 다음과 같이 순차적으로 동기적으로 처리되는 것을 확인할 수 있습니다.

순차적으로 동기적으로 처리된 모습

 

자가 호출을 하면 동작하지 않는 이유는 Spring 컨테이너에 등록되어 있는 스프링 빈의 메서드가 호출되었을 때, @Async가 붙어있다면 AOP로 처리되어 스프링이 메서드 요청을 가로채서 다른 Thread Pool에서 실행시켜주는 구조이기 때문이라고 합니다.

 

그래서 @Async를 private에 붙이거나 내부 호출을 하게 되면 스프링이 가로채서 프록시 처리를 할 수 없기 때문에 2가지 주의점이 있습니다.

 

 

 

✨ 적용하기


이런 주의점을 확인하고, 기존 로직에 비동기 처리를 진행해줍니다.

 

먼저, @Async를 사용할 수 있도록 @EnableAsync를 붙여줍니다.

@EnableAsync
@SpringBootApplication
public class PraisePushApplication {
    public static void main(String[] args) {
        SpringApplication.run(PraisePushApplication.class, args);
    }
}

 

비동기 처리를 원했던 메시지를 보내는 push() 메서드에 @Async를 붙여줍니다.

public class SlackMonitoringProvider implements MonitoringProvider {

    @Value("${error.webhook.url}")
    private String webHookUrl;

    @Async
    @Override
    public void push(final Exception exception, final HttpServletRequest request) {
        SlackApi slackApi = new SlackApi(webHookUrl);
        slackApi.call(createSlackMessage(exception, request));
    }
    
    ... 생략 ...
}

 

요청 시간 로그를 설정하고, push 로직에서 5초의 지연을 두고 요청을 확인해 보면 메시지를 보내는 로직이 비동기적으로 처리되는 것을 확인할 수 있습니다.

 

Slack으로 메시지 보내는 로직이 비동기로 처리되어 클라이언트에게 응답되는 시간이 먼저 처리되는 것을 확인

 

 

반응형
반응형

🖥️ 사용한 기술 버전 확인


SpringBoot: 2.7.15

JackSon: 2.13.5

Lombok: 1.18.28

 

 

🤨 분석을 해보기로 한 이유


Controller에서 Json에서 DTO로 역직렬화를 할 때, @NoArgsConstructor + @Setter가 있으면 역직렬화가 되는지 알고 있었습니다.

JackSon의 내부 코드를 모두 까보지 않으면 모르지만, 개발하며 마주한 몇 가지 생각지 못한 케이스가 있어서 다뤄보려고 합니다.

 

추가적인 말이 없을 때까지는 아래의 HTTP 요청과 Controller를 사용했을 때의 결과입니다.

POST http://localhost:8080/test
Content-Type: application/json

{
  "testString": "테스트",
  "testInteger": 100
}
@PostMapping("/test")
public String test01(@RequestBody TestDTO dto) {
    System.out.println(">>> " + dto);

    return "ok";
}

 

 

1. @NoArgsConstructor


첫 번째는 비어있는 생성자만 있을 때입니다.

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}

toString()은 값이 제대로 바인딩되었는지 콘솔에서 확인하기 위한 용도입니다.

[결과]
>>> testString: null, testInteger=null

바인딩이 제대로 되지 않았습니다.

 

 

 

2. @NoArgsConstructor + @Getter


두 번째는 기본 생성자에 Getter를 붙여봅니다.

@Getter
@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: 테스트, testInteger=100

바인딩이 제대로 되었습니다.

 

 

 

3. @NoArgsConstructor + @Setter


세 번째는 Setter와 생성자의 조합입니다.

@Setter
@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: 테스트, testInteger=100

바인딩이 제대로 되었습니다.

 

여기서 중간으로 확인해야 할 것이 있습니다. 기본 생성자만 있을 때는 바인딩이 안 됐었는데, Getter나 Setter가 있으면 바인딩을 해줍니다. 한 가지 특별 케이스를 만들어봅시다.

 

 

 

4. @NoArgsConstructor + 커스텀 Getter


만약 Getter의 기능을 하는 다른 이름의 메서드를 만들고 싶어 졌다고 해봅시다.

"나는 get이라는 걸 안 쓰고 find를 써서 새롭게 만들어볼래." <-- 똑같은 건 싫어 싫어 개발자

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String findTestString() {
        return testString;
    }

    public Integer findTestInteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: null, testInteger=null

???... Getter와 똑같은 기능을 하는 메서드를 만들었는데 데이터가 정상적으로 바인딩되지 않았다. 그럼 여기서 또 궁금한 건 못 참는 피곤한 사람(나)들은 get이라는 이름으로 커스텀 메서드를 만들어봅니다. ㅎ

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String getTestString() {
        return testString;
    }

    public Integer getTestInteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: 테스트, testInteger=100

!!!? 바인딩이 되었습니다. 더 궁금한걸 못 참는 분들은 이미 해보시고 있겠지만, Setter도 동일합니다. bindTestInteger() 이런 식으로 Setter를 만들면 바인딩이 제대로 되지 않습니다.

 

그럼 여기서 얻어볼 수 있는 첫 번째 결론은 Getter 또는 Setter가 필요한데, 이름이 get 어쩌고, set 어쩌고여야 한다는 것입니다.

 

이 궁금점은 JackSon 홈페이지에 가보면 알 수 있습니다. 영어를 잘하는 분들은 이런 노가다짓은 안 하고 계시겠죠..? 흑 하지만... 재밌는 걸 어떡하죠. 🤥

To read Java objects from JSON with Jackson properly, it is important to know how Jackson maps the fields of a JSON object to the fields of a Java object, so I will explain how Jackson does that.By default Jackson maps the fields of a JSON object to fields in a Java object by matching the names of the JSON field to the getter and setter methods in the Java object. Jackson removes the "get" and "set" part of the names of the getter and setter methods, and converts the first character of the remaining name to lowercase. For instance, the JSON field named brand matches the Java getter and setter methods called getBrand() and setBrand(). The JSON field named engineNumber would match the getter and setter named getEngineNumber() and setEngineNumber().If you need to match JSON object fields to Java object fields in a different way, you need to either use a custom serializer and deserializer, or use some of the many Jackson Annotations.

Jackson에서 필드를 바인딩할 때 getter, setter 메서드에서 get, set 부분을 제거하고 첫 문자를 소문자로 변경하는 방식으로 해당 필드가 있는지 확인한다고 합니다. 즉, 해당 변수를 찾을 때 변수명을 확인하는 게 아니고 getter와 setter를 보고 해당 필드가 있는지 확인을 하고, 매칭한다는 것을 의미합니다.

 

 

 

5. Getter 테스트 (제일 재밌는 부분 😆)


Jackson에서는 getter, setter에서 get, set 부분을 제거하고, 첫 문자를 소문자로 변경하는 방식으로 해당 필드를 찾는다고 했습니다.

궁금한 건 못 참는 두 번째 테스트를 해봤습니다.

 

5.1 get + 소문자 시작

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public void gettestString() {
        return testString;
    }

    public void gettestInteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}

CamelCase로 작성하기 때문에 이렇게는 안 적겠지만, 궁금해서 테스트해 봤습니다.

get을 제외하면 testString이고, 여기서 첫 문자를 소문자로 바꾸면 testString 그대로 이기 때문에 바인딩이 될 것이라고 예상했습니다.

[결과]
>>> testString: null, testInteger=null

바인딩이 정상적으로 되지 않았습니다.

 

 

5.2 get + 대문자 시작 + 소문자(두 번째 단어) (1)

이번에는 첫 문자는 CamelCase대로 대문자로 시작하고, 두 번째 단어를 소문자로 변경해 봤습니다.

@NoArgsConstructor
public static class TestDTO {
    private String testString;
    private Integer testInteger;

    public String getTeststring() {
        return testString;
    }

    public Integer getTestinteger() {
        return testInteger;
    }

    public String toString() {
        return "testString: " + this.testString + ", testInteger=" + this.testInteger;
    }
}
[결과]
>>> testString: null, testInteger=null

역시나 바인딩이 되지 않았습니다. 위에 건 예상 못했는데, 이건 사실 예상을 했습니다.

JackSon에서 말하는 첫 문자를 소문자로 변경한다는 것이 어디까지를 말하는 것일지 궁금해서 더 테스트해 봤습니다.

 

 

5.3 get + 대문자 시작 + 소문자 시작(두 번째 단어) (2)

메서드 조건은 5.2와 동일하게 가져가고, 이번에는 변수 이름을 변경해 봤습니다.

@NoArgsConstructor
public static class TestDTO {
    private String teststring;
    private Integer testinteger;

    public String getTeststring() {
        return teststring;
    }

    public Integer getTestinteger() {
        return testinteger;
    }

    public String toString() {
        return "teststring: " + this.teststring + ", testinteger=" + this.testinteger;
    }
}
[결과]
>>> teststring: 테스트, testinteger=100

바인딩이 되었습니다. 음 이쯤 되면 조금 억지스러운 테스트를 한번 해보려고 합니다.

 

 

5.4 get + 대문자 여러 개 + 소문자

@NoArgsConstructor
public static class TestDTO {
    private String aBCdef;
    private Integer ghijk;

    public String getABCdef() {
        return aBCdef;
    }

    public Integer getGHIjk() {
        return ghijk;
    }

    public String toString() {
        return "aBCdef: " + this.aBCdef + ", ghijk=" + this.ghijk;
    }
}

저는 getABCdef()에서 get을 제외하면 ABCdef가 남고, 첫 문자를 소문자로 변경한다고 되어 있었습니다.

예상하는 건 "aBCdef라는 변수가 있으면 바인딩이 제대로 될 것이다."라는 가정을 하고 접근했습니다.

 

아래 있는 getGHIjk()는 get을 제외하면 GHIjk에서 처음으로 만나는 소문자 j 전까지를 한 문자로 본다고 생각하고, 적어봤습니다.

예상이 맞다면 ghijk라는 변수에 바인딩이 정상적으로 될 것입니다. 둘 중에 하나는... 맞겠지 🥺

 

[결과]
>>> aBCdef: null, ghijk=100

두두둥! 두 번째 예상이 맞은 거 같습니다. get을 제외하고, 그다음에 나오는 단어에서 첫 번째로 만나는 소문자 전까지의 대문자를 모두 소문자로 변경하는 것 같습니다.

 

 

5.5 진짜 정말 마지막 테스트

예상한 것이 맞는지 이해한 대로 마지막 테스트를 진행해 봅니다.

  1. getter에서 get을 제외합니다.
  2. 나머지 단어에서 첫 번째 소문자가 나오기 전까지의 대문자는 모두 소문자로 변경합니다.
@NoArgsConstructor
public static class TestDTO {
    private String abcdEFG;
    private Integer abcDEFG;

    public String getABcdEFG() {
        return abcdEFG;
    }

    public Integer getabcDEFG() {
        return abcDEFG;
    }

    public String toString() {
        return "abcdEFG: " + this.abcdEFG + ", abcDEFG=" + this.abcDEFG;
    }
}

첫 번째 변수를 제가 생각한 대로 변경해 보겠습니다. getABcdEFG에서 get을 제거하면 ABcdEFG가 됩니다.

여기서 처음으로 만나는 소문자(c) 전까지의 모든 대문자를 소문자로 변경하면 abcdEFG가 됩니다.

 

두 번째 변수를 제가 생각한 대로 변경해 보겠습니다. getabcDEFG에서 get을 제거하면 abcDEFG가 됩니다.

여기서 처음으로 만나는 소문자(a) 이전에는 대문자가 존재하지 않기 때문에 그대로 abcDEFG가 됩니다.

 

[결과]
>>> abcdEFG: 테스트, abcDEFG=100

 

정상적으로 바인딩이 되었습니다. 다음에는 @Builder, @AllArgsConstructor과 JackSon을 사용해 보고, 이번글에서 상세하게 정리해보지 못한 Setter 관련해서도 적어보려고 합니다. 시간이... 된다면? 

 

반응형
반응형

1. 문제


프론트엔드 팀과 협업을 위해서 API 서버를 개발하고, AWS LightSail을 사용해서 서버를 배포했습니다.

클라이언트 서버가 띄워지기 전까지는 로컬에서 작업을 해서 몰랐지만, 띄우고 나서 확인해 보니 통신에 문제를 겪었습니다.

클라이언트 서버는 https로 배포가 되어있고, 개발 서버는 http로 배포가 되어 있어 두 서버 사이의 통신에 문제가 발생했습니다.

 

 

 

2. 해결


(1) SSL 인증서가 필요하다!

첫 번째로 HTTPS로 배포를 하기 위해서는 CA의 인증을 받은 SSL 인증서가 필요합니다. 

이 인증서는 보통 비용을 지불하고, 받아서 사용합니다. 하지만 저희는 돈이 없기 때문에!

무료로 SSL 인증서를 발급 받을 수 있는 Let's Encrypt를 사용했습니다.

무료라는 특징도 있지만 3개월(90일)마다 인증서를 갱신해줘야 한다는 특징도 존재합니다.

 

 

 

(2) 도메인 구매하기

letsencrypt에서 SSL 인증서를 받기 위해서는 "이메일"과 "도메인"이 필요합니다.

그래서 이 도메인이라는 것을 구매해야하는데, 가비아라는 사이트에서 도메인을 구매했습니다.

이렇게 사이트에서 원하는  도메인을 검색해보면 사용할 수 있는 도메인 목록이 나오게 됩니다.

이렇게 뜨면 원하는걸 골라서 선택한 후 신청하기를 진행하고, 결제를 하면 이제 제 도메인이 생깁니다.

위에는 20,000원짜리로 예시가 되어있는데 잘 찾아보면 500원짜리도 존재하고, 가격은 다양합니다.

자신이 적당하다고 생각되는 가격으로 도메인을 하나 구매해 주시면 됩니다.

이제 이렇게 도메인을 구매했으면 링크를 참고해서 미리 준비되어 있는 서버(AWS)의 Public IP를 DNS로 등록해줘야 합니다.

 

 

 

(3) My 가비아 > 도메인으로 접속

도메인을 구매하고, 조금 있으면 가비아 홈페이지의 오른쪽 상단에 My가비아에 들어가 보면 도메인을 관리할 수 있는 홈페이지로 갈 수 있습니다.

여기서 빨간색 박스에 있는 "도메인"을 클릭합니다.

들어가면 내가 구매한 도메인을 확인할 수 있고, "관리" 버튼을 클릭합니다.

쭉 내리다 보면 오른쪽 아래 DNS 레코드 설정으로 갈 수 있는 버튼이 있습니다. "설정"을 눌러줍니다.

내가 구매한 도메인을 확인하고, 체크하고 "DNS 설정"을 클릭합니다.

 

이제 "레코드 추가"를 누르고 호스트에 @와 www를 추가해줘야 합니다. 값/위치에는 AWS EC2 또는 다른 클라우드 서비스를 통해 띄운 서버의 IP를 입력해 줍니다.

 

오른쪽 수정 또는 확인 버튼을 눌러주고, "저장"을 눌러주면 DNS 서버에 내가 구매한 도메인과 내 서버의 IP가 연결이 완료되었습니다.

 

이렇게 등록하고 나서 http://poppleserver.store:8080 에 접속해 보면 http://{ubuntu ip}:8080에 접근했을 때와 같은 결과 페이지가 나오면 됩니다. 

DNS 서버의 쿼리가 퍼지기까지는 약간의 시간이 소요되고, 도메인 등록 업체에서는 12시간 정도를 얘기하고 있습니다. 하지만 실제로는 30분 정도 기다리면 사용하는데 문제가 없다고 합니다. 연결될 때까지 기다려 봅시다.

 

이렇게 연결이 되었으면 어떻게 확인해 볼까? 터미널을 켜고 nslookup 명령어로 확인해볼 수 있습니다.

nslookup 명령어 사용

NXDOMAIN 에러가 뜨면 DNS 서버에 제대로 등록될 때까지 조금만 여유를 가지고 기다려보시길 바랍니다!

그래도 해결이 안 된다면 방법을 찾아야 하겠지만, 제대로 된 IP에 등록하셨다면 될 겁니다!

 

 

 

(4) Certbot 설치

Ubuntu 서버로 접속한 후에 Certbot을 설치해줘야 합니다.

$ sudo apt-get update
$ sudo apt-get install certbot

저는 Ubuntu로 서버를 만들어서 apt-get을 사용했지만, 만약 Amazon Linux를 사용해서 서버를 띄우셨다면 yum을 사용해서 조금 복잡하게 설치를 했던 거 같습니다. (구글링 해보시면 좋을 거 같습니다!)

 

 

 

(5) 인증서 발급받기

$ sudo certbot certonly --standalone

standalone 옵션 말고 다른 옵션들도 있는데, 아래 링크에 첨부를 해뒀습니다.

저는 설정을 모두 끝마치고 서버를 다시 재시작할 것이기 때문에 standalone 옵션을 사용해서 발급했습니다.

 

위 명령어를 치면 이메일을 치라고 하고, 몇 가지 동의 사항을 받습니다. 본인의 이메일을 입력하고, yes를 입력해 주면 됩니다.

그러고 나서 마지막에 도메인을 입력하라고 합니다. 이때 저희가 구매한 도메인을 입력해 줍니다. (DNS 서버에 등록된 상태여야 합니다.)

 

입력하라고 하는 것을 모두 입력해 주면 LetsEncrypt에서 우리 도메인에 어떤 요청을 보내고, 제대로 연결된 도메인이 맞는지 확인하는 작업을 한 후 아래와 같은 문구가 뜹니다.

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/subbak2.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/subbak2.com/privkey.pem

 

💡 만약 계속해서 certification failed이 뜬다면 몇 가지를 확인해 보자!

(1) 우리 서버가 이미 80번 포트를 사용하고 있지는 않은가?
: LetsEncrypt는 80번 포트(http)로 GET 요청을 보내서 어떤 처리를 하고 나서 응답되는 결과가 기대한 결과여야 성공적으로 인증서를 발급해 준다. 하지만 80번 포트를 이미 누군가 쓰고 있으면 안 된다.

(2) 도메인이 DNS 서버에 잘 등록이 되었는가?
: LetsEncrypt가 이 도메인이 우리 서버와 잘 연결되었는지 확인하기 때문에 필수 조건이다. 이건 터미널에서도 확인이 가능하다. nslookup {도메인} 명령어를 사용해 보자.

 

 

 

(6) 인증을 통해 받은 파일로 Springboot 프로젝트로 가져갈 keystore 파일 생성하기

$ cd /etc/letsencrypt/live/subbak2.com

위 경로로 이동을 해봅시다. 위 경로는 인증 성공했을 때 나왔던 메시지의 경로입니다. 인증을 통해 만들어진 pem 파일이 있는 곳입니다.

 

저는 위 경로로 이동하려고 했을 때, live 파일에 대한 접근 권한이 없었습니다. 그래서 접근 권한을 풀어준 후 접근했습니다.

$ sudo chmod 777 live

이제 위 경로로 이동했다면 발급받은 pem 파일을 통해서 keystore 파일을 만들어 내야 합니다.

$ openssl pkcs12 -export -in fullchain.pem -inkey privkey.pem -out keystore.p12 -name tomcat -CAfile chain.pem -caname root

코드는 길지만 그냥 Certbot을 통해서 발급받은 fullchain.pem과 privkey.pem을 이용해서 지지고 볶고 해서 Springboot에서 사용할 암호화된 keystore.p12 파일을 만든다는 명령어이다.

 

위 명령어를 입력하면 비밀번호를 치라고 하는 게 나오는데, 이때 치는 비밀번호는 나중에 application.yml에 넣어줘야 하는 비밀번호입니다. 아무거나 입력해도 되는데 기억하고 있어야 합니다.

 

생성된 keystore.p12를 Spring 프로젝트의 /src/main/resources로 가져가야 합니다. 이때는 리눅스의 mv 명령어로 가져가봅니다.

keystore.p12의 파일 권한이 없다면 권한을 허용하고, 이동을 해준다.

$ sudo chmod 777 keystore.p12
$ mv keystore.p12 {/src/main/resoureces 경로}

 

 

 

(7) application.yml 설정

#SSL
server:
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-type: PKCS12
    key-store-password: {keystore.p12 파일 만들 때 입력한 비밀번호}
    key-alias: tomcat

security:
  require-ssl: true

classpath:는 모두 알겠지만 /src/main/resources 경로를 나타냅니다.

 

아래 있는 security 설정은 Spring Security 때문에 해둔 설정인데, 추가하지 않아도... 되는 거 같은데 더 알아봐야 할 거 같습니다.

 

 

 

(8) 포트 포워딩 설정

이제 https://poppleserver.store로 요청을 하면 Spring 내장 톰캣의 8080으로 연결되도록 하고 싶습니다.

이때는 443 포트(HTTPS)로 들어온 요청을 8080 포트(톰캣)로 포트 포워딩을 해줍니다.

그럼 이제 https://poppleserver.store로 요청을 보내면 8080 포트로 포트 포워딩이 되어 실행됩니다.

$ sudo iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-port 8080

 

 

 

(9) https://{구매한 도메인}에 들어가서 API 요청 보내서 확인

위에 주소창 왼쪽에 자물쇠 모양이 생기고, 정상적으로 요청이 간다면 성공입니다!

주소창 자물쇠 확인

LetsEncrypt는 무료이지만, 단점이라고 하면 90일마다 인증서를 재발급해줘야 하는 귀찮음이 있습니다. 이 부분도 찾아보니 자동화를 하는 방법이 있었는데, 필요해질 때 적용해 보면 될 거 같습니다.

 

 

 

반응형
반응형

캐시(cache)는 무엇일까?

캐시에 대한 정의를 먼저 알아보고 가야 할 것 같습니다. 캐시란 데이터나 값을 미리 복사해서 보관하는 임시 장소를 말합니다.

그럼 캐시를 왜 사용하는 걸까요?

 

 

 

캐시(cache)를 사용하는 이유?

같은 정보를 반복해서 읽어오는 경우가 있던가, 데이터에 접근하는데 시간이 오래 걸리는 데이터인 경우에 사용을 고려해볼 수 있습니다.

말 그대로 반복해서 가져오는 데이터를 복사해서 캐시에 저장해 두고, 호출될 때마다 캐시에 보관된 데이터를 빠르게 가져올 수 있습니다.

데이터에 접근하는 시간이 긴 경우도 해당 데이터를 미리 캐시에 복사해서 넣어두고, 호출하면 캐시에 보관된 데이터를 가져와서 사용합니다.

 

이렇게 해주면 I/O가 감소하고, 프로젝트의 성능을 올려줄 수 있습니다. 하지만 캐시를 사용할 때 고려해줘야 하는 점은 다음과 같습니다.

캐시가 있을 때, 없었을 때와 프로젝트의 기대 동작이 동일해야 합니다.

 

 

이제 코드 예시로 한번 살펴봅시다. 이번에 생각해본 시나리오는 온라인 서점에서 책(Book)의 정보를 조회할 때, 같은 책을 여러 번 조회하는 상황을 만들어볼 것입니다.

 

기대하는 결과는 최초에 요청된 Book만 Repository에 접근해서 데이터를 가져오고, 그 뒤에 들어온 요청의 경우에는 캐시에서 데이터를 가져와서 사용하는 것입니다.

 

 

 

필요한 기본 코드 작성

# Book.java

@NoArgsConstructor
@AllArgsConstructor(staticName = "of") // .of()로 Book 객체를 만들 수 있도록 해줍니다.
@Data
public class Book {
    private String title;
    private String author;
    private String publishedDate; // 0000-00-00 형태의 문자열로 입력받음
}

BookRepository에 제목(title), 저자(author), 출간일(publishedDate)를 받아서 저장합니다.

 

 

# BookRepository.java

@NoArgsConstructor
@Repository
public class BookRepository {
    private Map<String, Book> bookShelf = new HashMap<>();

    public void enrollBookInfo(String title, String author, String publishedDate) {
        bookShelf.put(title, Book.of(title, author, publishedDate));
    }

    public Book getBook(String title) {
        System.out.println(title + "을 찾기 위해서 데이터베이스에 접근....");
        if (bookShelf.keySet().contains(title)) {
            return bookShelf.get(title);
        }
        throw new RuntimeException("찾는 도서는 존재하지 않습니다.");
    }
}

enrollBookInfo()는 도서를 등록할 수 있는 메서드입니다. getBook()에서 찾고자하는 도서가 없다면 RuntimeException을 발생시키도록 설계해 봤습니다.

 

캐시는 Repository로 따로 들어오지 않기 때문에 만약 Service를 통해서 Repository에 접근했다면 "title을 찾기 위해서 데이터베이스에 접근...."이라는 메시지를 출력하도록 해놨습니다.

 

 

# BookService.java

@RequiredArgsConstructor
@Service
public class BookService {

    private final BookRepository bookRepository;

    @PostConstruct
    public void init() {
        // 도서 4개를 등록합니다.
        bookRepository.enrollBookInfo("자바는 정석이다", "김정석", "2022-12-12");
        bookRepository.enrollBookInfo("파이썬은 정석이다", "이정석", "2012-06-13");
        bookRepository.enrollBookInfo("자바스크립트는 정석이다", "박정석", "2020-02-14");
        bookRepository.enrollBookInfo("C는 정석이다", "정정석", "2015-01-11");
    }

    public void printBookInfo(String title) {
        try {
            Book book = bookRepository.getBook(title);
            System.out.println("도서명: " + book.getTitle() + " 저자: " + book.getAuthor() + " 출간일: " + book.getPublishedDate());
        } catch (RuntimeException exception) {
            System.out.println(exception.getMessage());
        }
    }
}

@PostConstruct 애노테이션을 이용하여 BookService의 생성자가 실행된 후에 바로 4개의 도서를 등록합니다. 이렇게 등록하면 BookRepository에 해당 책들이 등록됩니다.

 

그리고 조회에 사용할 때는 printBookInfo()를 이용하여 출력할 수 있도록 했습니다. 이때 Repository에서 발생시킨 RuntimeException에 대한 예외를 처리해 줍니다.

 

 

# MainApplication.java

@RequiredArgsConstructor
@SpringBootApplication
public class MainApplication {
    private final BookService bookService;

    public static void main(String[] args) {
         SpringApplication.run(MainApplication.class, args);
    }

    @EventListener(ApplicationReadyEvent.class) // <-- 모든 빈이 다 등록이 끝났을 때의 이벤트를 말함
    public void run() {
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
    }
}

BookService를 DI 받아서 "자바는 정석이다"라는 도서를 BookRepository에서 꺼내서 조회합니다. 해당 작업을 4번 연속으로 조회하는 작업입니다. 출력되는 결과를 한번 확인해 보겠습니다.

 

 

출력된 결과를 보면 "자바는 정석이다을 찾기 위해서 데이터베이스에 접근...."이라는 메시지가 4번 떴습니다. 그 말은 즉, "자바는 정석이다"라는 책을 4번 조회하기 위해서 BookRepository에 4번 접근했다는 것을 의미합니다.

 

그럼 이제 캐시를 사용해서 최초 접근에만 BookRepository에 접근하도록 설정해 봅시다. 만약 성공적으로 캐시가 작동했다면 기대하는 결과는 아래와 같습니다.

자바는 정석이다을 찾기 위해서 데이터베이스에 접근....
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12
도서명: 자바는 정석이다 저자: 김정석 출간일: 2022-12-12

 

 

 

Redis 설치하기

Redis Homepage에 들어가면 각 운영체제마다 설치할 수 있는 방법이 있습니다. 이 부분에 대해서는 다루지 않겠습니다.

저는 Mac M1을 사용하고 있어서 homebrew로 간단하게 설치했습니다.

 

 

 

Redis 실행시키기 (터미널)

# Redis 서버 실행하기

>> redis-server --loglevel verbose

 

 

이렇게 입력해 주면 로컬에서 Redis Server를 여는데 성공했습니다. --loglevel verbose는 debug와 비슷하게 로그 내역을 상세하게 출력해 줍니다. 예를 들면 서버 접속 이력, 캐시 등록 이력 이런 내용이 모두 출력됩니다. 원한다면 특정 로그 레벨만 출력되도록 해줄 수 있습니다.

 

 

# Redis Client로 접속하기

열어둔 서버에 Client로 접속해 보겠습니다.

>> redis-cli

 

 

클라이언트로 Redis 서버에 접속하니 Redis 서버 쪽에서 이력이 남는 것을 볼 수 있습니다.

 

 

# Redis에 등록된 캐시(Key) 데이터 확인하기

>> keys *

 

 

등록된 모든 Key를 출력해 볼 수 있는 명령어입니다. 결과는 (empty array)라고 나와야 정상입니다. 저희는 아직 캐시를 사용하기 위한 설정을 하지 않았습니다. 이제 다시 프로젝트로 넘어가서 Redis를 사용하기 위한 설정을 해볼 것입니다.

 

 

 

Redis 의존성 추가하기

# build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

dependencies에 해당 Redis 의존성을 추가해 줍니다. 아 참고로 저는 지금 스프링부트 2.5.2 버전에 자바는 11 버전을 사용하고 있습니다. 혹시나 스프링부트 버전이 많이 낮으신 분들은 Redis를 지원하는 버전인지 확인해 볼 필요가 있습니다. 대부분 2.5.2 이상은 쓰고 있을 것으로 판단됩니다.

 

 

 

어떤 부분에 캐시를 적용할지 찾고, 적용하기

우리가 캐시를 적용해야 하는 부분은 Repository에 접근하여 Book 정보를 가져오는 행위를 하는 곳에 캐시를 적용해야 합니다. 찾아보면 BookRepository의 getBook() 메서드에서 해당 역할을 담당하고 있습니다. 

 

그럼 BookRepository의 getBook() 메서드에 캐시를 적용해 보겠습니다.

 

# getBook() in BookRepository.java

@Cacheable("book")
public Book getBook(String title) {
    System.out.println(title + "을 찾기 위해서 데이터베이스에 접근....");
    if (bookShelf.keySet().contains(title)) {
        return bookShelf.get(title);
    }
    throw new RuntimeException("찾는 도서는 존재하지 않습니다.");
}

@Cacheable() 애노테이션으로 캐시를 적용시켰습니다. 캐시에서 사용할 key 이름은 "book"으로 설정했습니다.

 

 

# MainAppalication.java에 @EnableCaching 적용하기

@EnableCaching
@RequiredArgsConstructor
@SpringBootApplication
public class MainApplication {
    private final BookService bookService;

    public static void main(String[] args) {
         SpringApplication.run(MainApplication.class, args);
    }

    @EventListener(ApplicationReadyEvent.class) // <-- 모든 빈이 다 등록이 끝났을 때의 이벤트를 말함
    public void run() {
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
        bookService.printBookInfo("자바는 정석이다");
    }
}

캐시를 사용하기 위해서 붙여줘야 하는 애노테이션입니다. Configuration 빈에 붙여도 되지만, 지금은 따로 캐시에 관련된 Config 설정을 한 것이 없기 때문에 MainApplication에 붙여줬습니다. 이제 다시 프로그램을 돌려보겠습니다.

 

 

실행이 되긴 하는데, Serialize 할 수 없다고 뜹니다. 이건 Redis에 캐시 데이터를 저장할 때, 우리가 만든 Book 클래스를 Redis에서 기본설정되어 있는 Serialize 방법으로 직렬화하여 저장하게 됩니다. 이 말은 즉 캐시 데이터로 저장될 Book 클래스는 Serialize가 가능한 클래스가 되어야 한다는 것을 의미합니다. 코드를 수정해 보겠습니다.

 

# Book.java

@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
@Data
public class Book implements Serializable {
    private String title;
    private String author;
    private String publishedDate;
}

Serializable 인터페이스를 implements 받아서 serialize가 가능한 클래스로 만들어줬습니다. 이제 다시 MainApplication을 재시작해봅시다.

 

 

우리가 원하는 결과가 나왔습니다! 가장 처음에만 데이터베이스에 접근을 해서 데이터를 가져왔고, 그 뒤로는 데이터베이스가 아닌 캐시에 있는 데이터를 가져와서 출력했습니다.

 

이제 Redis Client로 가서 잘 등록되었는지 확인해 봅시다.

 

 

key가 잘 등록됐습니다. book이 key이고, (::)로 구분한 후 뒤에 나오는 부분이 "자바는 정석이다"라는 문자열을 인코딩하여 넣어둔 문자열 형태인 것 같습니다. 처음부터 영어로 잘 설정했으면 괜찮았을 거 같은데, 이 부분은 뒤쪽 리펙토링에서 한번 추가적으로 다뤄봅시다.

 

Redis를 사용해서 프로젝트에 캐시를 적용해 보는 작업은 끝났습니다. 하지만 몇 가지 더 코드를 리펙토링 해봅시다.

 

 

 

추가적인 리펙토링 작업

첫 번째로 캐시 데이터가 얼마나 저장되게 할 것인가를 설정해 봅시다. 이 부분은 성능과도 연관될 수 있는 부분이기 때문에 중요한 기능입니다. 캐시도 너무 많이 저장되고, 관리되지 않으면 서버에 부하가 걸립니다. 

 

두 번째로 직렬화 방법을 바꿔볼 것입니다. 지금은 Redis의 Default 설정을 사용하고 있지만 우리에게 더 익숙한 JackSon 라이브러리를 사용하여 Serialize 방법을 바꿔봅시다. 그럼 더 보기 좋게 출력될 것입니다.

 

 

캐시 데이터 보관 기간 설정하기

Redis에 대한 설정을 커스텀하기 위해서는 @Configuration 애노테이션을 갖는 설정 클래스를 하나 생성해야 합니다. 

 

# CustomRedisConfig.java

@EnableCaching
@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(5)); // <-- 5초 뒤에 캐시 데이터가 삭제됩니다.
    }
}

MainApplication에 등록했었던 @EnableCaching 애노테이션을 가져옵니다. 이제 @Configuration이 존재하기 때문에 여기에 붙여주면 됩니다.

 

Redis 커스텀 설정을 위해서 RedisCacheConfiguration을 Bean으로 등록합니다. 우리가 설정하려는 TTL(Time To Live, 데이터 유효기간)을 5초로 설정한 후 return 하여 해당 인스턴스가 스프링 빈으로 등록될 수 있도록 합니다.

 

이제 다시 MainApplication을 실행시킨 다음에 5초 후에 "자바는 정석이다"가 사라지는지 확인해 봅시다. 그전에 해야 할 것이 있습니다. 아까 테스트할 때 등록되었던 캐시 데이터를 먼저 비워줘야 합니다.

>> flushall

 

 

해당 명령어를 사용해서 모든 캐시 데이터를 삭제합니다. 그 후에 keys *로 삭제가 잘 되었는지 확인합니다. 그런 다음 MainApplciation을 한번 다시 돌려보겠습니다.

 

 

실행했을 때 처음에 keys *를 했을 때는 위처럼 데이터가 등록된 것을 볼 수 있고, 5초 후에 다시 keys *를 해보면 데이터가 사라진 것을 확인할 수 있습니다. 그래도 못 믿겠다면 Redis 서버에 찍힌 로그를 확인해 볼 수도 있습니다.

 

 

 

Serialize 방법 변경하기

이 부분도 Configuration에서 RedisCacheConfiguration의 설정을 변경해서 빈에 등록해 주면 됩니다. 앞서 사용했던 Bean에서 추가로 적용해 보겠습니다.

 

# CustomRedisConfig.java

import org.springframework.data.redis.serializer.RedisSerializationContext;

@EnableCaching
@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(5))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}

이렇게 . 연산자가 많아지고, 가독성이 떨어질 때는 static import를 이용해서 아래와 같이 정리해 볼 수 있습니다.

import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;

@EnableCaching
@Configuration
public class CustomRedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(100))
                .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }
}

이제 Redis의 Value 직렬화 방법을 Jackson2로 변경했습니다. 다시 MainApplication을 돌려봅시다. 캐시 TTL을 설정해서 Redis에 캐시 데이터가 남아있지 않지만 혹시 남아있는 분들은 꼭 다 삭제하고 재시작하시길 바랍니다.

 

Serialize가 적용된 것을 확인하기 위해서 TTL을 100초로 변경하고 해 보겠습니다. 5초면 등록되자마자 거의 바로 삭제되기 때문에 확인이 어렵습니다.

 

 

음... 직렬화가 되지 않았습니다. 여기서 문제점 한 가지가 있었습니다. 저희는 Configuration에서 직렬화 방법을 변경해 줬습니다. 그렇기 때문에 이전에 Book 클래스에 붙여줬던 Serializable 인터페이스의 implements는 지워줘야 합니다.

 

 

# Book.java

@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
@Data
public class Book {
    private String title;
    private String author;
    private String publishedDate;
}

추가적으로 JackSon을 사용할 때는 @NoArgsConstructor를 사용해서 비어있는 생성자를 꼭 만들어두어야 정상적으로 작동됩니다.

 

이제 다시 MainApplication을 재시작해봅시다.

 

 

한글은 인코딩 되어 저렇게 뜨고 있지만 title, author, publishedDate에 대한 객체가 Jackson을 이용하여 JSON 형태로 Serialize 된 것을 볼 수 있습니다.

 

 

 

번외: Redis에서 한글 인코딩 방식 변경해 보기

Redis에서 한글을 보기 위해서는 다음과 같은 명령어로 Client를 실행하면 된다.

>> redis-cli --raw

 

 

결과를 보면 "자바는 정석이다"라는 문자열이 잘 나오는 것을 볼 수 있다. JSON 타입으로 직렬화된 데이터도 잘 확인이 된다. Good ~

반응형
반응형

application.properties로 사용하는 분들도 계시지만 방법은 동일하기 때문에 저는 application.yml 기준으로 혼자 복습하며 기록해보려고 합니다.

 

만약 A, B, C라는 클래스가 존재할 때, 이 3개의 클래스 내부에서 companyName이라는 값을 모두 같은 "Fast Company"라는 값을 사용한다고 가정을 해봅시다.

 

이때 만약 회사 이름이 변경되어서 "Slow Company"로 바뀌었다고 생각해 봅시다. 그럼 우리는 A, B, C 클래스로 모두 이동해서 "Fast Company"라는 값을 "Slow Company"로 모두 변경해줘야 합니다.

 

물론 클래스가 3개 밖에 없어서 간단하게 할 수 있지만, 만약 클래스가 1000개가 넘는다고 생각하면 어떨까요? 무려 1000번을 변경해줘야 합니다. 이때 이런 공통적인 값들을 properties이나 yml(yaml) 파일에서 설정 파일의 값만 바꿔주면 모든 클래스에 적용시켜 줄 수 있습니다.

 

이렇게 클래스에서 프로퍼티 값을 가져오는 방법에는 여러 가지가 존재하는데, 코드를 작성하며 한번 확인해 봅시다.

 

일단 먼저 application.yml에 우리가 사용할 설정 값을 입력해 둡니다.

# application.yml

company:
  name: "Fast Company"

 

 

1. @Value("${}")

제일 먼저 등장하는 것은 @Value 어노테이션입니다. SpEL(Spring Expression Langauge)로 내가 설정한 property 이름을 표현해 주면 해당 값을 가져올 수 있는 방법입니다.

 

A class에서는 @Value를 이용해서 application.yml의 company.name을 가져와볼 것입니다.

// A.java

@Component
public class A {

    @Value("${company.name}")
    private String companyName;

    public A() {

    }

    public void printCompanyName() {
        System.out.println("A class = " + companyName);
    }
}

클래스 A를 @Component를 이용해서 빈으로 등록합니다. 그리고 @Value를 이용해서 우리가 원하는 설정 값을 가져와서 companyName에 넣을 것이고, main 함수가 있는 클래스에서 printCompanyName()을 이용해서 값이 잘 입력되었는지 확인해 볼 것입니다.

 

main에서는 생성자 주입을 통해서 A, B, C 클래스 빈에 대해서 DI를 받을 것이고, 모든 생성자 주입이 끝난 후에 실행시켜 줄 것이기 때문에 @PostConstruct를 사용할 것입니다. 아래 코드에 해당 과정이 작성되어 있습니다.

// MainApplication.java

@SpringBootApplication
public class MainApplication {

    private final A a;
    private final B b;
    private final C c;
    
    public MainApplication(A a, B b, C c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public static void main(String[] args) {
         SpringApplication.run(MainApplication.class, args);
    }

    @PostConstruct
    public void init() {
        a.printCompanyName();
        b.printCompanyName();
        c.printCompanyName();
    }

}

코드를 실행시켜 보면 콘솔에 찍히는 값은 다음과 같습니다. 나머지 로그들은 모두 제외했습니다. 간단하게 출력 값만 적어봤습니다. 

A class = Fast Company
B class = null
C class = null

결과를 보면 A class에서 Fast Company라는 값을 제대로 가져온 것을 확인할 수 있습니다. B와 C는 아직 아무 설정도 안 해줬기 때문에 null이 나오는 것입니다. 

 

A class를 조금 더 멋스럽게 변경해 보겠습니다. 필드에 주입하는 것이 아닌 우리는 생성자를 사용해서 필드값을 초기화하는 방법을 더 많이 사용하기 때문에 이렇게 변경해보겠습니다.

// A.java

@Component
public class A {

    private String companyName;

    public A(@Value("${company.name}") String companyName) {
        this.companyName = companyName;
    }

    public void printCompanyName() {
        System.out.println("A class = " + companyName);
    }
}

매우 간단하게 해냈습니다. 필드 값 위에 입력하는 것이 아닌 생성자의 매개변수에 @Value를 붙여서 넣어주면 됩니다. 조금 멋있어졌군요.

 

 

 

2. Enviroment Bean (ApplicationContext)

두 번째 방법은 Enviroment 빈을 사용해서 프로퍼티 값을 가져오는 방법입니다. Spring Bean으로 등록되어 있는 Enviroment Bean을 주입받아서 getProperty()를 이용하여 해당 프로퍼티 값을 가져올 수 있는 방법입니다. 

 

B class는 이 방법을 사용해서 프로퍼티 값을 한번 가져와보겠습니다.

// B.java

@Component
public class B {

    private String companyName;

    private final Environment environment; // 생성자 주입을 통해서 DI

    public B(Environment environment) {
        this.environment = environment;
        companyName = this.environment.getProperty("company.name");
    }

    public void printCompanyName() {
        System.out.println("B class = " + companyName);
    }
}

 

org.springframework.core.env에 있는 Enviroment를 생성자를 통해서 의존성 주입을 받아옵니다. 그런 다음 Enviroment의 getProperty() 메서드를 이용하여 원하는 프로퍼티 값을 가져와서 companyName에 초기화해 줬습니다.

 

다시 main 메서드를 돌려서 출력 값을 확인해 봅니다.

A class = Fast Company
B class = Fast Company
C class = null

B class에도 값이 잘 가져와진 것을 볼 수 있습니다. 위에서 작성한 Enviroment를 사용하는 코드는 직접 ApplicationContext를 가져와서 사용해 볼 수도 있습니다. 코드를 한번 리펙토링 해보겠습니다.

// B.java

@Component
public class B {

    private String companyName;

    private final ApplicationContext applicationContext;

    public B(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
        companyName = this.applicationContext.getEnvironment().getProperty("company.name");
    }

    public void printCompanyName() {
        System.out.println("B class = " + companyName);
    }
}

main을 다시 재시작해보니 출력되는 값은 동일하게 성공했습니다. 하지만 뭔가 코드 가독성면에서는 별로 좋아 보이진 않는다는 생각이 드는 코드입니다.

 

 

 

3. Configuration Properties

다음 방법은 자바 클래스로 따로 분리해서 값을 가져올 수 있는 방법입니다. @Value를 사용했을 때와는 다른 점은 클래스로 값을 매핑해서 가져올 수 있기 때문에 Type Safe 합니다. 그리고 각 프로퍼티 값에 대한 Meta 데이터를 작성할 수 있습니다. 

 

사용하는 방법은 @ConfigurationProperties("") 애노테이션을 사용하여 작성할 수 있습니다. 해당 클래스를 Bean으로 등록해서 사용할 수 있게 하기 위해서는 @Configuration 애노테이션을 사용해야 합니다. 코드를 먼저 작성해 보겠습니다.

 

 

# @Configuration 애노테이션 사용

// CompanyProperties.java

@ConfigurationProperties("company")
@Configuration
public class CompanyProperties {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

@ConfigurationProperties("company")를 이용하여 나는 application.yml에서 company라는 프로퍼티 값을 가져와서 매핑한다는 것을 의미한다.

 

@Configuration을 붙여주는 이유는 CompanyProperties 클래스를 스프링 빈으로서 등록하기 위함이다. 그래야 다른 곳에서 DI 받아서 사용할 수 있기 때문이다.

 

private String name에 company.name을 가져와서 값을 초기화시키는 것이다. 이때 우리는 setter를 정의해 주면 된다. getter는 외부에서 해당 값을 사용하기 위해서는 필요하다.

 

이제 스프링 빈으로 등록된 CompanyProperties를 주입받아서 C class에서 사용해 볼 것이다.

// C.java

@Component
public class C {

    private CompanyProperties companyProperties;

    public C(CompanyProperties companyProperties) {
        this.companyProperties = companyProperties;
    }

    public void printCompanyName() {
        System.out.println("C class = " + companyProperties.getName());
    }
}

생성자 주입을 통해서 CompanyProperties를 주입받습니다. 그런 후 getName()을 통해서 해당 프로퍼티 값을 받아서 printCompanyName()에 넣어줍니다. 이제 main을 재시작해서 출력값을 확인해 보겠습니다.

A class = Fast Company
B class = Fast Company
C class = Fast Company

C class도 적절하게 값을 받아오는 것을 확인할 수 있습니다.

 

위에서 작성한 코드를 조금 리펙토링 해보겠습니다.

 

CompanyProperties에서 @Configuration을 떼어보겠습니다. 어 그럼 Bean으로 등록되지 않아서...주입을 받아오지 못하지 않나?라는 의문이 들어야 합니다.

 

그래서 MainApplication로 가서 @ConfigurationPropertiesScan 애노테이션을 붙여줍니다. @ConfigurationProperties가 붙은 클래스를 자동으로 Scan 하여 빈으로 등록해 줍니다.

 

 

# @Configuration을 빼고, @ConfigurationPropertiesScan을 사용

// CompanyProperties.java

@ConfigurationProperties("company")
public class CompanyProperties {

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
// MainApplication.java

@ConfigurationPropertiesScan
@SpringBootApplication
public class MainApplication {

    /* 코드 생략 */

}

이렇게 설정해 두고 다시 main을 재시작해보면 적절하게 잘 출력되는 것을 확인해 볼 수 있습니다.

 

하지만 여기서 조금 생각해 볼 것이 있습니다. CompanyProperties를 보면 setName()을 통해서 해당 값을 초기화합니다. 이렇게 되면 언제든 setter를 통해서 값을 변경할 수 있다는 것은 조금 위험한 내용인 것 같습니다. 필드를 final로 선언해서 생성자를 통해서 해당 값을 초기화할 수 있도록 코드를 리펙토링 해봅시다. 이때는 @ConstructorBinding 애노테이션을 사용합니다. 생성자를 통해 프로퍼티 값을 바인딩받을 수 있도록 해주는 애노테이션입니다.

 

 

# Setter를 빼고, @ConstructorBinding을 사용하여 생성자 바인딩

// CompanyProperties.java

@ConstructorBinding
@ConfigurationProperties("company")
public class CompanyProperties {

    private final String name;

    public CompanyProperties(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

main을 다시 재시작해봅니다. 그럼 값이 적절하게 출력되는 것을 볼 수 있습니다. 리펙토링 성공이네요. 이렇게 불변의 값으로 설정하고 싶다면 @ConstructorBinding을 사용해서 해당 값을 다루는 것이 좋습니다.

 

 

 

이제 코드를 완성했으니 우리가 생각했던 시나리오대로 "Fast Company"를 "Slow Company"로 바꿔서 모든 출력값이 정상적으로 변경되는지 확인해 봅시다.

# application.yml

company:
  name: "Slow Company"

콘솔 출력값

마지막은 캡처로 깔끔하게 확인해 봤습니다. 모든 출력값이 정상적으로 Slow Company로 변경되었습니다. 좋습니다~ 🙄

 

이번 정리 글에서는 많은 애노테이션이 새롭게 나와서 하나씩 정리해 보는 시간을 가지는 것이 좋을 거 같습니다. 프로젝트에서 이 내용을 써보게 되는 날이 왔으면 좋겠습니다.

반응형
반응형

🤔 문제 상황

Global Exception 처리에 대한 코드를 작성하는 과정에서 @RestControllerAdvice가 작동되지 않는 오류가 발생했습니다.

프로젝트 구조는 아래와 같이 구성했었습니다.

 

Book을 등록하는 간단한 프로그램입니다. 예상했던 시나리오는 BookController의 "/register"로 POST 요청을 보냅니다. 이때 BookService 로직에서 POST 요청으로 들어온 Book의 name과 같은 name의 Book이 이미 Repositroy에 존재한다면 BookException을 던져주도록 작성했습니다.

 

그 후 BookController에서 BookException이 발생했을 때, Global 하게 예외를 처리하기 위해서 BookExceptionHandler를 추가해 줬습니다. 

 

즉, 제가 생각한 flow는 POST 요청 ->  BookController -> BookService에서 BookException이 발생 -> BookExceptionHandler에서 해당 예외를 처리였습니다.

 

# BookController

package com.example.selftest.controller;

import com.example.selftest.dto.book.CreateBookDto;
import com.example.selftest.dto.book.ResponseBookDto;
import com.example.selftest.service.BookService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @PostMapping("/register")
    public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
        return ResponseBookDto.fromEntity(bookService.registerBook(dto));
    }
}

 

# BookService

package com.example.selftest.service;

import com.example.exception.BookException;
import com.example.selftest.dto.book.CreateBookDto;
import com.example.selftest.entity.Book;
import com.example.selftest.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static com.example.exception.ErrorCode.DUPLICATE_BOOK_NAME;

@RequiredArgsConstructor
@Service
public class BookService {
    private final BookRepository bookRepository;

    @Transactional
    public Book registerBook(CreateBookDto dto) {
        validateRegisterBook(dto);
        return bookRepository.save(dto.toEntity());
    }

    private void validateRegisterBook(CreateBookDto dto) {
        bookRepository.findByName(dto.getName()).ifPresent((book) -> {
            throw new BookException(DUPLICATE_BOOK_NAME);
        });
    }
}

 

# BookException

package com.example.exception;

import lombok.Getter;

@Getter
public class BookException extends RuntimeException {
    private ErrorCode errorCode;

    public BookException(ErrorCode errorCode) {
        super(errorCode.getErrorMessage());
        this.errorCode = errorCode;
    }
}

 

# BookExceptionHandler

package com.example.exception;

import com.example.selftest.dto.book.BookErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;

@RestControllerAdvice
public class BookExceptionHandler {
    @ExceptionHandler(BookException.class)
    public BookErrorResponse handlerException(BookException e, HttpServletRequest request) {
        System.out.println("에러가 발생!!!");
        return BookErrorResponse.builder()
                .errorCode(e.getErrorCode())
                .errorMessage(e.getMessage())
                .build();
    }
}

 

# BookErrorResponse

package com.example.selftest.dto.book;

import com.example.exception.ErrorCode;
import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BookErrorResponse {
    private ErrorCode errorCode;
    private String errorMessage;
}

 

🔎 문제 분석 및 시도

코드를 잘 작성한 것 같은데...뭐가 문제지...라는 생각으로 엄청난 구글링에 들어갔습니다. 주요 키워드는 "@RestControllerAdvice not working"였습니다.

 

1. 예외가 Service에서 던져져서 @RestControllerAdvice에서 못 찾는 것인가?

사실 이거도 바보같은 생각이었지만 2시간 내내 이리저리 돌아다녔기 때문에 지푸라기라도 잡는 심정으로 해봤다.

그럼 BookController에서 직접 예외를 던져봤다. POST요청할 때 보내는 값 중에서는 name(책이름), author(저자)가 있었고, 만약 request로 들어온 데이터의 name이 "자바의 정석"이라면 직접 controller에서 BookException을 던지도록 해봤다.

 

@PostMapping("/register")
public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
	if (dto.getName().equals("자바의 정석")) {
        throw new BookException(INTERNAL_SERVER_ERROR);
    }
    return ResponseBookDto.fromEntity(bookService.registerBook(dto));
}

이렇게 하고 POST 요청을 아래와 같이 보내봤다.

POST http://localhost:8080/register
Content-Type: application/json

{
  "name": "자바의 정석",
  "author": "남궁성"
}

예상했던 결과는 에러코드인 INTERNAL_SERVER_ERROR과 errorMessage인 "서버에 오류가 발생했습니다."가 BookErrorResponse에 저장되어 반환되는 것이었습니다. 즉, 정상적으로 ExceptionHandler가 작동했다면 아래와 같은 결과가 나왔을 것입니다.

{
  "errorCode": INTERNAL_SERVER_ERROR,
  "errorMessage": "서버에 오류가 발생했습니다."
}

하지만 실제 결과는 다음과 같이 나왔습니다.

{
  "timestamp": "2023-04-03T07:58:38.217+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/register"
}

음 ExceptionHandler가 작동하지 않았구나... 일단 위에서 작성했던 코드는 다시 원상복구 해놨습니다.

 

 

2. 내 프로젝트 빌드 버전에 문제가 있는 것일까?

저는 SpringBoot 2.5.2 버전을 사용했습니다. 몇몇 검색 결과에서는 @RestController가 4.3 버전 이후부터 지원이 된다는...그런 말들이 돌아다녔습니다. 물론 정확한 정보인지는 공식문서를 찾아보는 것이 정확하기 때문에 이곳저곳 찾아봤습니다. 제 구글링 능력 밖인지 아니면 영어 난독증에 걸려서인지 찾을 수 없었습니다.

 

@RestControllerAdvice가 아닌 @ExceptionHandler의 문제인지 궁금했습니다. 그래서 Controller 내부에서 메서드로 @ExceptionHandler로 처리를 해봤습니다.

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    @PostMapping("/register")
    public ResponseBookDto registerBook(@RequestBody CreateBookDto dto) {
        return ResponseBookDto.fromEntity(bookService.registerBook(dto));
    }

    @ExceptionHandler(BookException.class)
    public BookErrorResponse handlerException(BookException e, HttpServletRequest request) {
        System.out.println("에러가 발생!!!");
        return BookErrorResponse.builder()
                .errorCode(e.getErrorCode())
                .errorMessage(e.getMessage())
                .build();
    }
}

이렇게 작성하고 {name = "자바의 정석", author = "남궁성"}을 넣어서 POST 요청을 해봤습니다. 첫 번째 요청은 역시 성공적으로 되었고, 같은 요청으로 두 번째 요청을 해봤습니다.

 

결과는 다음과 같이 성공적으로 나왔습니다.

{
  "errorCode": "DUPLICATE_BOOK_NAME",
  "errorMessage": "중복되는 이름의 도서가 존재합니다."
}

역시 @ExceptionHandler가 잘못된 게 아니고 내가 잘못된 거구나 컴퓨터는 역시 거짓말을 하지 않는다. 다른 방법을 찾아보기로 했습니다.

 

 

3. @RestControllerAdvice도 IoC 컨테이너에 등록되는 Bean이 아니었던가?

코드를 다시 원상 복구한 후 @RestControllerAdvice를 한번 쭉 살펴봤습니다. (차분하게)

@RestControllerAdvice 어노테이션

음 @RestControllerAdvice는 @ControllerAdvice와 @ResponseBody의 기능을 모두 할 수 있는 어노테이션이구나... 그러고 나서 @ControllerAdvice로 들어가 봤습니다.

@ControllerAdvice 어노테이션

@ControllerAdvice는 @Component 어노테이션을 가지고 있어서 Spring Bean으로 등록되어 IoC 컨테이너에서 관리될 수 있도록 되어 있었습니다.

 

그럼 @RestControllerAdvice를 사용해서 작성했던 BookExceptionHandler는 스프링 Bean으로 등록이 될 것이고, DI를 받아도 오류가 나지 않아야 한다는 말인데... 문득 궁금했습니다.

 

ApplicationContext를 가져와서 Bean 목록에 존재하는지 확인하는 방법도 있었지만, 간단하게 테스트해보고 싶었기 때문에 다음과 같은 생각을 한번 해봤습니다.

 

Spring Application이 실행이 되면 Component Scan을 하면서 @Component가 붙어있는 클래스들을 IoC 컨테이너에 등록합니다. 정상적으로 등록이 되었다면 BookController에서 생성자 DI로 BookExceptionHandler를 주입받은 다음에 한번 확인해 보자라는 생각을 했습니다.

 

@RequiredArgsConstructor
@RestController
public class BookController {
    private final BookService bookService;

    private final BookExceptionHandler exceptionHandler;
    
    /* ... 생략 ... */
}

이렇게 하고 한번 Application을 실행시켜 봤습니다.

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 1 of constructor in com.example.selftest.controller.BookController required a bean of type 'com.example.exception.BookExceptionHandler' that could not be found.

BookController의 생성자 주입 과정에서 BookExceptionHandler를 찾을 수 없다는 문구를 날리면서 프로그램이 종료되었습니다. 이 말은 즉... BookExceptionHandler가 아예 Bean으로 조차 등록되지 않았다는 것을 의미합니다.

 

이때부터 거의 멘탈이 와사삭 내려앉았습니다. 아니 이게 뭐야...@RestControllerAdvice를 붙이면...Bean으로 등록이 되어야 하는데...

 

 

 

🙆🏻‍♂️ 문제해결

계속 붙잡고 싸움만 하다가 정말 어이없는 곳에서 문제를 해결했습니다. 사실 창피해서 기록하지 않으려고 하다가 컴포넌트 스캔에 대해서 다시 생각해 볼 수 있는 기회인 것 같아서 기록하기로 결심했습니다.

 

정확한 원인은 바로 프로젝트의 패키지 구조에 있었습니다.

눈치채셨을까요. 제 프로젝트의 시작할 때 초기 구조는 com.example.selftest 였습니다. selftest 패키지 내부에 service, entity, repositroy, controller 등의 패키지가 존재합니다. 

 

빨리 코드를 치려고 하다 보니깐 example에 새로운 패키지인 exception 패키지를 만들게 되었고, Spring의 컴포넌트 스캔은 com.example.selftest 부터 스캔되기 때문에 BookExceptionHandler는 아예 스캔되지도 않았던 것입니다.

 

정말 바보 같은 실수를 하게 된 것입니다. 해결 방안은 모두가 알고 있듯이 exception 패키지를 selftest 내부에 넣어주면 됩니다.

첫 번째 POST 요청 응답 결과
두 번째 POST 요청 응답 결과 (BookExceptionHandler 정상 작동)

이제 정상적으로 제가 생각했던 결과가 나오는 것을 볼 수 있습니다. 

 

 

 

👀 문제를 해결하면서 느낀 점

일단 가장 컸던 것은 컴포넌트 스캔에 대해서 좀 알게 되었던 거 같습니다. 우리가 간단하게만 사용했던 SpringBoot에서 어떠한 설정도 해주지 않았는데, 자동으로 Component Scan을 하는 것인지 궁금해서 찾아봤습니다.

 

정답은 main 메서드가 존재하는 클래스에 붙은 @SpringBootApplication이라는 어노테이션 안에 있었습니다.

@SpringBootApplication

 

@ComponentScan이라는 어노테이션이 있어서 우리가 별도로 설정파일을 구성해주지 않아도 자동으로 Component Scan을 하고, Bean으로 등록해 줬던 것입니다.

 

단순한 궁금점으로 만약 문제 상황과 같이 com.example 아래 exception과 selftest 패키지가 있으니, 컴포넌트 스캔의 시작 지점을 기본 설정값인 com.example.selftest가 아닌 com.example로 바꿔도 문제가 해결되지 않을까라는 생각으로 한번 시도해 봤습니다.

 

컴포넌트 스캔의 시작 지점을 바꿀 때는 main 메서드가 있는 클래스에 @ComponentScan() 어노테이션을 붙여주고, basePackages 옵션을 넣어주면 됩니다.

@EnableJpaAuditing
@ComponentScan(basePackages = {"com.example"})
@SpringBootApplication
public class SelfPracticeApplication {
    public static void main(String[] args) {
        SpringApplication.run(SelfPracticeApplication.class, args);
    }

}

이렇게 설정한 뒤 한번 실행시켜 봤습니다.

같은 데이터로 POST 요청을 2번 했을 때의 응답 결과

 

생각했던 대로 정상적으로 작동되는 것을 확인할 수 있었습니다. 

 

Spring에서 컴포넌트 스캔의 디폴트 값을 설정해 둔 이유가 있다고 생각되기 때문에 위와 같이 basePackage를 바꾸는 방법은 이 문제의 근본적인 해결방법은 아니라고 생각합니다. 

 

결론은 눈을 똑바로 뜨고, 정확히 클릭해서 정확하게 잘 코딩하자입니다. 

반응형

+ Recent posts