반응형

1. 문제


매일 00시 00분마다 작동되는 스케쥴러를 구현하기 위해서 다음과 같이 코드를 작성했습니다.

@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
@Transactional
public void surveyScheduling() {
    log.info("[{}] 스케쥴러 작동 설문조사 데이터 정리", LocalDateTime.now());

    LocalDate now = LocalDate.now();
    List<Survey> surveys = surveyRepository.findAllByStatusIsNot(SurveyStatus.REVERT);

    for (Survey survey : surveys) {
        if (isEnd(survey.getEndDate().toLocalDateTime(), now)) {
            survey.setStatus(SurveyStatus.REVERT);
        }
        if (isStart(survey.getStartDate().toLocalDateTime(), now)) {
            survey.setStatus(SurveyStatus.IN_PROGRESS);
        }
    }
}

private boolean isEnd(LocalDateTime endDate, LocalDate now) {
    return endDate.toLocalDate().isBefore(now);
}

private boolean isStart(LocalDateTime startDate, LocalDate now) {
    return startDate.toLocalDate().isEqual(now);
}

로컬에서 매일 00시 00분 정각에 작동되는 것을 테스트했습니다. (완전 무식하게 정각까지 안 자고 기다리고 있다가 확인했다. 😆)

 

그래서 정상적으로 작동되는 것을 확인하고, AWS EC2 서버에 변경된 코드를 적용해서 배포했습니다.

그리고 하루를 기다렸다가 스케쥴러가 작동 했는지 확인하려고 데이터베이스를 확인해 봤습니다.

음..? 저 빨간색 네모 안에 있는 status 값이 분명 WAIT에서 IN_PROGRESS로 변경되었어야 하는데... 왜 변하지 않았을까...?

 

스케쥴러가 작동 안 됐나 하면서 AWS 서버에 접근해서 로그 기록을 확인해 봤습니다. 분명 스케쥴러가 작동했다면 INFO 레벨의 로그가 찍혔을 것이기 때문에...

분명 INFO 레벨의 지정해 둔 로그가 찍혔다. 그럼 정상적으로 스케쥴러가 작동했다는 것을 의미한다. 근데 제가 의도한 시간이었던 2023년 8월 4일 00:00:00이 아닌 2023년 8월 3일 15:00:00에 동작을 하는 문제가 발견되었습니다.

 

 

2. 문제 분석


처음에 AWS EC2 인스턴스를 만들 때 Asia/Seoul로 선택해서 만들어서 당연히 동작될 줄 알았습니다. 그래서 일단 AWS 서버에 접근해서 date 명령어로 현재 서버의 시간을 한번 확인해 봤습니다.

현재 제 컴퓨터에서는 시간이 2023년 8월 4일 19:05인데, 서버에서는 2023년 8월 4일 10:04로 찍혔습니다. AWS 서버와 현재 제 로컬의 시간이 다르다는 것을 확인했습니다. 

 

UTC라는 세계 표준 시간을 사용해서 현재 한국의 시간에서 9시간을 빼면 AWS EC2의 서버 시간이 된다고 합니다. 허허허....

 

그렇게 생각을 하니깐 위에서 8월 3일 15:00에 작동한 게 이해가 됐습니다. 한국 시간으로는 8월 4일 00:00에서 9시간을 빼면 8월 3일 15:00가 나오기 때문이었습니다.

 

 

 

3. 해결 과정


(1) 서버 시간에서 그럼 9시간을 더해서 재설정해두면 되지 않을까?

되게 단순한 발상인데, 나름 그럴싸한 해결방법 같아 보입니다. 바로 실행에 옮기기 전에 항상 먼저 구글링을 시작합니다.

(터미널에서 뭐 잘못 건드렸다가 노트북 싹 다 날려버렸던 경험이 있어서.... 항상 제대로 알고 나서 실행하는 편...)

 

>> sudo date -s "2023-08-04 19:20:00"

위 명령어로 서버의 시간을 재설정할 수 있다고 한다. 근데 역시 위험한 느낌의 직감은 틀리지 않습니다.

위에 있는 명령어로 시간을 재설정하게 되면 EC2 타임존이 기본적으로 UTC로 되어 있어서 나머지 근본적인 지역에 대한 설정은 하나도 안 바뀌고 날짜만 한국 날짜로 바뀐 거여서 다른 서비스와 시간 동기를 맞출 때 시간이 다르다고 판단되어 의도하지 않은 오류가 발생할 수 있다고 합니다.

 

 

(2) AWS 서버의 타임존을 한국으로 바꿔보자.

첫 번째 방법처럼 날짜만 한국에 맞추는 것이 아니고, 근본적인 타임존을 한국으로 한번 설정해 봅시다.

>> sudo cat /etc/localtime

위 명령어로 한번 확인을 해보면 현재는 UTC0로 쓰여있습니다. 즉, 현재는 UTC 기준으로 작동된다는 것을 의미합니다.

이 부분을 이제 한국의 타임존을 쓸 수 있도록 수정해 봅시다.

 

>> sudo rm /etc/localtime
>> sudo ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime

기존에 있는 UTC 타임존 파일을 제거하고, 서울 기준의 타임존 파일을 새롭게 적용하는 명령어입니다.

 

위 명령어로 재설정을 하고, 다시 date 명령어로 서버의 시간을 확인해 봅시다.

이제 시간이 의도한 대로 한국 시간으로 설정이 되었습니다.

이제 데이터베이스의 startDate를 2023년 8월 5일로 바꾸고, 하루를 기다려보겠습니다.

 

이제 2023년 8월 5일 00:00이 되면 저 id 1번의 데이터에서 status 값이 WAIT에서 IN_PROGRESS가 되어야 합니다. 제발 돼라...

 

데이터베이스를 확인해보니 정상적으로 스케쥴러가 2023년 8월 5일 00:00에 동작을 한 거 같네요! 😆

이제 AWS 서버에도 INFO 로그가 찍혔는지도 한번 확인해보겠습니다.

 

음...동작은 해서 update 쿼리가 실행되어 WAIT 상태를 IN_PROGRESS로 변경시키기는 했지만 여전히 로그에 찍힌 시간을 보면 UTC 기준으로 15:00:00이라고 찍히는 것을 확인할 수 있습니다.

 

 

(3) 프로젝트 기본 TimeZone 변경하기 + 로그에 찍히는 시간도 변경

@SpringBootApplication
public class ServerApplication {

    public static void main(String[] args) {

        SpringApplication.run(ServerApplication.class, args);
    }

    @PostConstruct
    public void init() {
        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
        System.out.println(">>> 현재 시간은 " + LocalDateTime.now());
    }

}

@PostConstruct를 사용해서 Spring 프로젝트의 기본 TimeZone 세팅을 "Asia/Seoul"로 지정하도록 했습니다.

그리고 제대로 동작하는지 확인하기 위해서 LocalDateTime.now()를 사용해서 콘솔에 흔적을 남기도록 하고 한번 동작해 봤습니다.

빨간색을 보면 정상적으로 "Asia/Seoul"의 시간이 찍힌 것을 확인할 수 있었습니다. (현재 노트북 시간과 동일)

하지만 파란색 부분을 보면 로그로 찍히는 시간은 여전히 UTC 기준으로 찍히는 것을 볼 수 있습니다.

이 부분도 한번 바꿔봅시다! 간단하게 application.yml 파일을 설정해 주면 됩니다.

logging:
  pattern:
    dateformat: yyyy-MM-dd HH:mm:ss.SSSz,Asia/Seoul

Log에 찍히는 시간의 dateformat을 설정해 줄 수 있습니다.

이때 Timezone은 당연히 Asia/Seoul로 해주면 됩니다.

이제 다시 서버에 jar 파일을 올려서 실행시켜서 확인해 봅시다.

아주 완벽하게 LocalDateTime.now()도 현재 시간으로 뜨고, 로그에 찍히는 시간도 현재 시간으로 찍히는 것을 확인할 수 있습니다!

 

이제 아래와 같이 스케쥴러 코드를 살짝만 수정해서 10초가 될 때마다 작동되는 스케쥴러를 작동시켜 보겠습니다.

@Scheduled(cron = "10 * * * * *", zone = "Asia/Seoul")
@Transactional
public void surveyScheduling() {
    log.info("[{}] 스케쥴러 작동 설문조사 데이터 정리", LocalDateTime.now());

    LocalDate now = LocalDate.now();
    List<Survey> surveys = surveyRepository.findAllByStatusIsNot(SurveyStatus.REVERT);

    for (Survey survey : surveys) {
        if (isEnd(survey.getEndDate().toLocalDateTime(), now)) {
            survey.setStatus(SurveyStatus.REVERT);
        }
        if (isStart(survey.getStartDate().toLocalDateTime(), now)) {
            survey.setStatus(SurveyStatus.IN_PROGRESS);
        }
    }
}

실행 결과를 확인해 보면 로그에 찍히는 시간과 작동할 때 LocalDateTime.now()로 찍은 시간이 동일한 것을 확인했습니다. 🙆🏻‍♂️

다시 코드를 원복해서 서버에 적용해둡시다!

 

반응형
반응형

1. 문제


Hibernate를 사용해서 간단하게 Customer 객체를 디비에 저장하는 save()와 모든 유저를 조회하는 findAll() 그리고, 단일 조회할 수 있는 findById()를 만들어서 테스트 중에 있었다.

 

@Import(CustomerRepository.class)
@DataJpaTest
class CustomerRepositoryTest {

    @Autowired
    private CustomerRepository customerRepository;

    @Test
    public void findById_test() {
        // Given
        Customer customer = Customer.builder()
                .name("코스")
                .tel("0101111")
                .build();
        customerRepository.save(customer);
        Long id = 1L;

        // When
        Customer customerPS = customerRepository.findById(id);

        // Then
        assertThat(customerPS.getName()).isEqualTo("코스");
    }

    @Test
    public void save_test() {
        // Given
        Customer customer1 = Customer.builder()
                .name("코스")
                .tel("0101111")
                .build();
        Customer customer2 = Customer.builder()
                .name("홍길동")
                .tel("0102222")
                .build();

        // When
        customerRepository.save(customer1);
        customerRepository.save(customer2);

        // Then
        Customer customerPS = customerRepository.findById(1L);
        System.out.println(customerPS);
        assertThat(customerPS.getName()).isEqualTo("코스");
    }

    @Test
    public void findAll() {
        // Given
        Customer customer1 = Customer.builder()
                .name("코스")
                .tel("0101111")
                .build();
        Customer customer2 = Customer.builder()
                .name("임꺽정")
                .tel("0101111")
                .build();
        customerRepository.save(customer1);
        customerRepository.save(customer2);

        // When
        List<Customer> result = customerRepository.findAll();

        // Then
        assertThat(result.size()).isEqualTo(2);
        assertThat(result.get(1).getName()).isEqualTo("임꺽정");
    }
}

테스트를 하나하나 실행시켰을 때는 통과가 나왔지만, 전체 테스트를 돌려보니 계속 실패가 나왔다.

내가 처음에 생각한 로직은 다음과 같다.

 

  1. 테스트 메서드가 하나씩 실행되고 끝날 때 Transaction이 종료되고, Rollback을 해준다고 알고 있다. 이건 @DataJpaTest에 @Transactional이 붙어있기 때문이다.
  2. 그럼 각 메소드가 종료될 때 Rollback이 되니깐 Id(PK)도 당연히 다시 1부터 시작된다고 생각해서 모든 테스트를 같이 돌려도 완벽히 격리된 테스트를 할 수 있을 거라고 생각했다.

 

 

 

2. 분석


궁금해서 @AfterEach와 @BeforeEach를 사용해서 각 테스트의 시작 전과 끝난 후에 findAll() 결과를 출력해 봤다.

@BeforeEach
public void beforeCheck() {
    System.out.println("===== Before Check =====");
    check();
    System.out.println("=======================");
}

@AfterEach
public void afterCheck() {
    System.out.println("===== After Check =====");
    check();
    System.out.println("=======================");
}

public void check() {
    List<Customer> result = customerRepository.findAll();
    if (result.isEmpty()) {
        System.out.println("비어있습니다.");
    } else {
        result.forEach(customer -> {
            System.out.println(">>> " + customer.getId() + " " + customer.getName() + " " + customer.getTel());
        });
    }
}
# findById 테스트

===== Before check =====
비어있습니다.
=======================

===== After check =====
>>> 1 코스 0101111
=======================


# findAll 테스트

===== Before check =====
비어있습니다.
=======================

===== After check =====
>>> 2 코스 0101111
>>> 3 임꺽정 0101111
=======================


# save 테스트

===== Before check =====
비어있습니다.
=======================

===== After Check =====
>>> 4 코스 0101111
>>> 5 홍길동 0102222
=======================

출력 결과를 보면 다음 테스트가 시작되기 전에 이전 데이터는 Rollback이 되는 게 맞다. 근데 보면 Id 값은 1로 초기화되지 않는 것을 확인해 볼 수 있다.

 

혹시나 현재 h2 데이터베이스를 사용하고 있어서 그런 건가 하고 MySQL로 연동해서 동일하게 실행해 보니 동일한 결과가 나왔습니다.

 

이후에 조금 궁금해져서 MySQL을 실행해서 직접 테이블을 하나 만들고, 트랜잭션을 만들어서 Insert 후 동일하게 Rollback을 해봤습니다.

# 테이블 생성
create table member (
	id bigint not null auto_increment,
    name varchar(20),
    primary key(id)
}

# 트랜잭션 시작
start transaction;

insert into member(name) values('오징어');
insert into member(name) values('김징어');

Rollback 전 Select

# 롤백 후 커밋
rollback;
commit;

Rollback 후 Select

# 다시 데이터 삽입
insert into member(name) values('이징어');

데이터 추가 삽입 후 Select

테스트 코드 상황과 동일하게 상황을 구성해서 직접 쿼리를 작성해봤는데, 롤백 후에도 Id가 1부터 시작되는 것이 아닌 3부터 시작돼서 저장되는 것을 확인해 볼 수 있었습니다.

 

 

 

3. 해결 방법


@AfterEach에서 각 테스트가 끝나면 Id의 시작점을 RESTART 해준다.

@AfterEach
public void check() {
    em.createNativeQuery("ALTER TABLE customer ALTER COLUMN id RESTART")
            .executeUpdate();
}

이 방법은 솔직히 좋은 방법은 아니라고 생각합니다. NativeQuery를 이렇게 사용해서 한다는 것은 특정 DB에 종속적인 테스트를 하고 있다는 것을 의미합니다.

 

사실 계속 방법을 찾다가 현재 작성한 테스트를 한 번에 실행했을 때 통과시켜보고 싶다는 생각에 찾아본 방법입니다.

 

전체 테스트 실행 결과

 

 

 

4. 느낀 점


원하는 결과는 얻었지만, 좋지 않은 테스트라는 것은 분명하기 때문에 더 좋은 방법을 찾아봐야 할 거 같습니다.

테스트를 할 때는 Id 값을 저렇게 Magic Number를 사용해서 하는 방법도 좋지 않은 것 같습니다.

테스트에 관해서는 더 찾아보고, 학습해야될 요소라고 생각됩니다. 단순히 궁금점이 조금 생겨서 기초부터 파고 들어봤던 거 같습니다.

반응형
반응형

1. 코드 구성


@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        MustacheViewResolver resolver = new MustacheViewResolver();
        resolver.setPrefix("classpath:/templates/");
        resolver.setSuffix(".html");
        registry.viewResolver(resolver);
    }
}
  • Mustache 템플릿 엔진을 사용해서 html 파일을 읽도록 설정을 해줬습니다.

 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
  <h1>로그인 페이지입니다.</h1>
</body>
</html>
  • login.html 페이지는 이렇게 구성했습니다.

 

@GetMapping("/login")
public String login() {
    return "login";
}
  • Controller 메서드는 다음과 같이 구성했습니다.

 

 

 

2. 문제 발생


localhost:8080/login 요청 시 랜더링 결과

  • 한글이 ?로 깨져서 나오는 것을 확인했습니다.

 

개발자 도구 확인

  • 개발자 도구에 들어가서 확인해보니 UTF-8로 되어있는 것이 아니라 ISO-8859-1로 되어있었습니다.

 

 

 

3. 문제 분석


  • 아마도 MustacheViewResolver에서 html 파일을 읽게하는 부분에서 charset, Content-Type을 따로 설정해주지 않은 것이 문제로 보였습니다.

 

 

 

 

4. 문제 해결


@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        MustacheViewResolver resolver = new MustacheViewResolver();
        resolver.setPrefix("classpath:/templates/");
        resolver.setSuffix(".html");
        resolver.setCharset("UTF-8");
        resolver.setContentType("text/html;charset=utf-8");
        registry.viewResolver(resolver);
    }
}
  • charset과 ContentType에 대한 설정 코드를 추가해줬습니다.

 

한글 설정 완료

  • 개발자 도구에서도 Content-Type이 변경된 것을 확인해볼 수 있습니다.
반응형
반응형

크리티컬 한 오류는 아니지만 IntelliJ에서 작동이 안 되길래 찾아본 오류입니다.

 

🤔 문제발생

@ConfigurationProperties(prefix = "spring.thymeleaf")를 사용하여 사용자 정의 프로퍼티(spring.thymeleaf3)를 만드는 코드를 작성했습니다.

물론 main 메서드가 있는 클래스에서 해당 프로퍼티를 스캔할 수 있도록 @ConfigurationPropertiesScan도 붙여줬습니다.

/*  앞 코드 생략  */

@Getter
@RequiredArgsConstructor
@ConstructorBinding
@ConfigurationProperties(prefix = "spring.thymeleaf3")
public static class Thymeleaf3Properties {
    /**
     * Thymeleaf 3 Decoupled Logic 기능의 활성화
     */
    private final boolean decoupledLogic;
}

/*  뒤 코드 생략  */

 

그리고 나서 사용자 정의 프로퍼티에 대한 지원을 해주는 의존성인 configuration-processor도 추가해 줬습니다.

// build.gradle

dependencies {
    /* 생략 */
    
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}

 

위 의존성을 추가해 주고, gradle을 다시 build 했습니다. 그리고 나서 application.properties에 들어가서 사용자 정의한 "spring.thymeleaf3"가 자동완성되는지 확인했는데, 작동이 안 됐습니다.

 

 

 

🙆🏻‍♂️ 시도 & 해결

구글링에 들어갔습니다. 주요 검색 키워드는 configuration-processor is not working...

 

(1) Clean 하고 다시 Build를 해보자.

 

수도 없이 해봤지만 작동하지 않았습니다. IntelliJ 캐시 문제인가..?라는 생각이 들어서 캐시를 비우고, IDE를 재시작하고 여러 방법을 시도해 봤습니다.

 

이 방법도 근데 소용은 없었습니다. 그럼 캐시 문제도 아니고, 빌드의 문제도 아니라는 것입니다. 

 

 

 

(2) 사용자 정의 프로퍼티를 만들면 spring-configuration-metadata.json 파일이 생성된다는 글을 발견

어디에 생성되는지 찾아보니 아래와 같은 경로에 생성되었습니다.

build/classes/java/main/META-INF/spring-configuration-metadata.json

json 파일로 생성되고, 우리가 Java Doc으로 작성한 내용도 저장되며, 해당 프로퍼티에 대한 메타 데이터가 들어가 있는 파일이었습니다.

{
  "groups": [
    {
      "name": "spring.thymeleaf3",
      "type": "com.fastcampus.getinline.config.ThymeleafConfig$Thymeleaf3Properties",
      "sourceType": "com.fastcampus.getinline.config.ThymeleafConfig$Thymeleaf3Properties"
    }
  ],
  "properties": [
    {
      "name": "spring.thymeleaf3.decoupled-logic",
      "type": "java.lang.Boolean",
      "description": "Thymeleaf 3 Decoupled Logic 기능의 활성화",
      "sourceType": "com.fastcampus.getinline.config.ThymeleafConfig$Thymeleaf3Properties",
      "defaultValue": false
    }
  ],
  "hints": []
}

 

즉, Build는 정상적으로 되었고 메타 데이터도 정상적으로 들어왔다는 것이 확인되었습니다.

그럼 문제는 하나 IntelliJ에서 해당 메타 데이터를 인식하지 못한다는 점이었습니다.

 

 

 

(3) IntelliJ Project Setting 변경

그렇게 구글을 찾아서 돌아다니다가 글 하나를 발견했습니다. 

https://youtrack.jetbrains.com/issue/IDEA-191886

 

spring-configuration-metadata.json ignored in Gradle "build" directory : IDEA-191886

I am trying to get IDEA to recognize custom properties in my Spring Boot application. The required metadata file is generated by Gradle in "build/classes/java/main/META-INF/spring-configuration-metadata.json" but IDEA does not seem to recognise it. As soon

youtrack.jetbrains.com

제목부터가 "spring-configuration-metadata.json ignored in Gradle "build" directory"이어서 뭔가 내가 찾던 문제와 같은 문제를 겪고 있는 거 같았습니다.

The required metadata file is generated by Gradle in "build/classes/java/main/META-INF/spring-configuration-metadata.json" but IDEA does not seem to recognise it.

 

해당 글 중에서 제가 말했던 경로에 metadata.json이 만들어는 졌는데 IDEA가 그걸 인지하지 못한다는 질문이었습니다. 역시... 구글링은 영어로 하는 것이 정답인가... 영어를....

 

해당 질문 댓글 중에서 해결책 하나가 쓰여있는 거 같아서 해봤습니다.

 

IntelliJ에서 Project Structrue에 들어갑니다. 맥에서는 {Command + ;}를 누르면 들어가집니다. 위 Tool bar를 통해서도 들어갈 수 있습니다.

 

Modules -> main -> Paths를 눌러보면 아래와 같은 화면이 나옵니다.

 

Output path를 보면 지금 out/production/classes로 되어 있는데, 이것을 Metadata.json이 있던 경로로 변경해 줬습니다.

 

out/production/classes -> build/classes/java/main으로 변경해 줬습니다. 

 

그리고 나서 Gradle clean을 하고 다시 build를 해주고 나서 application.properties에서 사용자 정의한 "spring.thymeleaf3"이 자동완성이 되는지 해봤습니다.

 

우와...됐다... 이전에는 자동완성이 나오지 않았었는데, spring.만 쳐도 thymeleaf3가 나오는 것을 볼 수 있습니다.

 

 

 

🙆🏻‍♂️ 알게 된 점 & 느낀 점

  • 구글링을 한글로 막 검색하다 보니 자료도 많이 안 나왔던 거 같습니다.
  • 해당 문제가 뭐 때문에 발생하는지 판단을 먼저 해야 정확한 구글링이 될 수 있다는 것을 느꼈습니다.
  • 저는 해당 문제가 Spring configuration processor의 문제라고 생각하고, 그쪽으로 검색을 엄청했었는데, IntelliJ의 문제일 수도 있겠다는 생각으로 검색을 하니 방법을 찾을 수 있었다고 생각합니다.

 

 

 

📘 참고자료

https://docs.spring.io/spring-boot/docs/current/reference/html/configuration-metadata.html#appendix.configuration-metadata.annotation-processor.automatic-metadata-generation

 

Configuration Metadata

Configuration metadata files are located inside jars under META-INF/spring-configuration-metadata.json. They use a JSON format with items categorized under either “groups” or “properties” and additional values hints categorized under "hints", as sh

docs.spring.io

 

반응형
반응형

🤔 문제 발생

Request 요청을 위한 DTO로 사용하기 위한 클래스를 생성한 후 해당 객체를 ObjectMapper의 writeValueAsString() 메서드에 넣어 serialize 해서 json 데이터로 mvc에 content에 담아 POST 요청을 하는 테스트를 진행했습니다.

 

# PlaceRequest.java 코드

@AllArgsConstructor
public class PlaceRequest {
    private PlaceType placeType;
    private String placeName;
    private String address;
    private String phoneNumber;
    private Integer capacity;
    private String memo;

    public static PlaceRequest of(
            PlaceType placeType,
            String placeName,
            String address,
            String phoneNumber,
            Integer capacity,
            String memo
    ) {
        return new PlaceRequest(placeType, placeName, address, phoneNumber, capacity, memo);
    }
}

위 코드의 static 메서드인 of()로 만든 객체를 ObjectMapper.writeValueAsString()에 넣어 serialize를 진행했고, 아래와 같은 오류가 발생했습니다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class

 

 

 

🙆🏻‍♂️ 시도 / 해결

1. PlaceRequest 클래스에 빈 생성자가 존재하지 않으면 Serializer가 작동하지 않는다.

@NoArgsConstructor
@AllArgsConstructor
public class PlaceRequest {
    /* 코드생략 */
}

@NoArgsConstructor 애노테이션을 붙여 비어있는 생성자를 만들어줬습니다. 그리고 다시 실행했을 때의 결과는 동일한 예외가 발생되었습니다.

 

JackSon에서 Deserialize(역직렬화, json -> Object)를 할 때는 기본 생성자로 객체를 생성한 후 필드 값을 찾아서 바인딩해준다고 합니다. 하지만 우리가 하려는 과정은 Deserialize가 아닌 Serialize였기 때문에 사실상 지금은 @NoArgsConstructor가 아무런 효과가 없었습니다.

 

 

2. serialize를 하는 과정에서 PlaceRequest 클래스의 필드에 접근하게 되는데, 이때 public 접근제한자 또는 getter를 통해서 접근하게 된다.

여기서 2가지 방법을 시도해볼 수 있었습니다.

 

(1) 필드를 모두 public으로 바꿔보자

@AllArgsConstructor
public class PlaceRequest {
    public PlaceType placeType;
    public String placeName;
    public String address;
    public String phoneNumber;
    public Integer capacity;
    public String memo;
    
    /* 코드 생략 */
}

테스트를 돌려보니 통과하는 것을 볼 수 있습니다.

 

 

(2) 필드는 그대로 private로 두고, Getter를 만들어보자

@Getter
@AllArgsConstructor
public class PlaceRequest {
    private PlaceType placeType;
    private String placeName;
    private String address;
    private String phoneNumber;
    private Integer capacity;
    private String memo;
}

이 방법도 동일하게 테스트가 통과되는 것을 볼 수 있습니다. 저는 private 필드를 사용하기 위해서 두 번째 방법을 사용해서 코드를 작성했습니다.

 

 

 

💡 알게된 점

JackSon을 사용해서 Serialize(Object -> json)할 때는 필드를 public으로 지정하거나 private으로 해두었다면 JackSon이 해당 필드에 접근할 수 있도록 Getter를 만들어주어야 합니다.

 

반대로 Deserialize(json -> Object)를 할 때는 비어있는 기본 생성자(NoArgsConstructor)로 객체를 생성하기 때문에 반드시 기본 생성자를 클래스 안에 만들어줘야 합니다.

 

사실 해결 방법에는 @JsonProperty를 필드에 붙여주는 방법도 찾았던 거 같은데, 이 부분은 나중에 따로 시간을 내서 알아보고자 합니다. 

반응형
반응형

문제 상황

프로필 이미지를 업로드한 후 <img> 태그의 src로 해당 경로 요청했을 때, 한글이 깨지는 현상이 발생.

 

연두.png라는 사진 파일을 프로필 이미지로 업로드했고, DB에도 한글로 잘 데이터가 들어가 있는 것을 확인했다. 프로필에서 사용할 수 있는 DTO를 model에 담아서 jsp에 전달해 주는 Controller 로직을 작성했다.

// UserController.java

@GetMapping("/user/{pageUserId}")
public String profile(@PathVariable int pageUserId, Model model, @AuthenticationPrincipal PrincipalUserDetails principal) {
    UserProfileDto dto = userService.UserProfile(pageUserId, principal.getUser().getId());
    model.addAttribute("dto", dto);
    return "user/profile";
}

dto에 담겨있는 데이터 중에서 필자는 dto.user.profileImageUrl을 JSP에서 사용하고자 한다. 

// profile.jsp
<img class="profile-image" src="/upload/${dto.user.profileImageUrl}" onerror="this.src='/images/person.jpeg'" id="userProfileImage" />

위처럼 <img>의 src 요청을 보냈더니 콘솔에서 오류가 발생했다. 오류 내용은 아래와 같다.

요청을 원했던 URL
http://localhost:8080/upload/{UUID}_연두.png

실제로 요청된 URL
http://localhost:8080/upload/{UUID}_%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE.png

찾아보니 실제로 요청된 URL은 "연두"라는 한글을 UTF-8 방식으로 인코딩한 후 요청을 보낸 것으로 확인했다. 그럼 내가 원하는 정보는 실제로 요청된 URL에서 "%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE" 이 부분을 다시 UTF-8로 디코딩해서 "연두"로 요청해야 하는 건가...?

 

 

해결 사항

근본적인 해결 사항은 아니기 때문에 설정을 건드는 부분은 구글링을 통해서 찾아보시기 바랍니다. 웬만한 키워드로는 다 검색해 본 거 같습니다. "jsp img src 한글 깨짐", "jsp url encoding setting"과 같은 키워드를 통해서 StackOverFlow도 찾아보고 했지만, 대부분 Tomcat 설정 파일을 건드는 방식을 설명했던 거 같아서 아직 시도해보지는 못했습니다. jstl을 이용한 태그도 대부분 사용해 봤지만 통하지 않았습니다.

 

저는 이렇게 생각을 전환해 봤습니다. "http://localhost:8080/upload/{UUID}_연두.png" 이렇게 요청이 들어가면 한글 부분은 인코딩 되어서 "http://localhost:8080/upload/{UUID}_%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE.png" 실제로는 이렇게 요청이 간다.

 

그럼 애초에 사진 파일을 저장할 때 UTF-8로 파일명을 인코딩해서 업로드를 진행하면 되지 않을까? 예를 들어 "연두.png"라는 파일을 업로드하고 싶다면 인코딩을 해서 "%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE.png"로 사진 파일을 저장한 후에 DB에도 해당 인코딩된 정보를 저장합니다.

 

그리고 "http://localhost:8080/upload/{UUID}_%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE.png" 에 요청을 해버리면 되지 않을까?라는 생각으로 코드를 작성해 봤습니다.

 

가장 먼저 프로필 사진을 저장하는 로직을 가지고 있는 Service 로직을 변경했습니다.

// UserService.java
@Transactional
public User profileImageUrlUpdate(int principalId, MultipartFile profileImageFile) {
    UUID uuid = UUID.randomUUID();
    String originalName = profileImageFile.getOriginalFilename();
    String imageFileName = uuid + "_" + originalName;
    
    ...
}

기존 로직은 위와 같습니다. 사진 파일명이 동일한 문제를 발생시키지 않기 위해서 UUID를 생성해 줬고, 실제 파일의 이름을 받아왔습니다. "연두.png"라면 originalName은 "연두.png"가 될 것입니다. 그러고 나서 imageFileName에 UUID와 originalName을 언더바(_)로 구분하여 저장했습니다.

 

최종적으로 폴더에 저장되는 이름은 {UUID}_연두.png 입니다. 여기서 originalName 밑에 UTF-8 방식으로 인코딩하는 코드를 더 추가해 줬습니다.

import java.net.URLEncoder;

@Transactional
public User profileImageUrlUpdate(int principalId, MultipartFile profileImageFile) {
    UUID uuid = UUID.randomUUID();
    String originalName = profileImageFile.getOriginalFilename();
    try {
        originalName = URLEncoder.encode(originalName, "UTF-8");
    } catch (UnsupportedEncodingException exception) {
        throw new CustomApiException(exception.getMessage());
    }
    String imageFileName = uuid + "_" + originalName;
    
    ...
}

URLEncoder를 사용해 "연두.png"를 "%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE.png"으로 인코딩하여 저장했습니다. 아래는 폴더에 업로드된 사진 파일입니다.

{UUID}_%E1%84%8B%E1%85%A7%E1%86%AB%E1%84%83%E1%85%AE.png 으로 저장된 사진 파일

이제 기존 JSP 코드 그대로 요청을 해봅시다.

프로필 사진이 변경된 모습

원하는 이미지 파일이 잘 가져와지는 것을 볼 수 있습니다. 원래는 JSP와 SpringBoot의 원리를 바탕으로 한 번 해결법을 찾아보려 했지만 로직을 변경해서도 해결할 수 있을 거 같다는 생각에 한 번 도전해 봤습니다. 

 

물론 효율적이고 좋은 방법은 아닐지 모르지만 많은 자료를 구글링 해보는 과정에서 조금이라도 배움을 얻었다고 생각합니다. 구글링 하면서 찾은 사이트인데 원하는 방식으로 직접 인코딩, 디코딩해볼 수 있는 사이트를 아래 달아두겠습니다. 사용하기 괜찮은 거 같습니다!

 

https://www.urlencoder.org/

 

URL Encode and Decode - Online

Encode to URL-encoded format or decode from it with various advanced options. Our site has an easy to use online tool to convert your data.

www.urlencoder.org

 

반응형

+ Recent posts