서론

현재 운영 중인 서비스는 같은 데이터베이스를 공유하는 5개의 프로젝트가 존재하는데, 모두 Spring or Spring boot + JPA를 사용하는 프로젝트들이다.

이 때문에, 한 프로젝트에서 도메인(Entity) 클래스에 변수를 추가한 경우에 다른 모든 프로젝트에서도 동일하게 추가 해주지 않으면 데이터베이스 조회 시 등 에러가 발생 할 수 밖에 없는데 이런 copy & paste 반복 작업을 개발자가 손수 하다보면 휴먼에러가 발생할 수 밖에 없음은 물론 같은 데이터베이스를 사용하는 Entity 임에도 불구하고 코드가 모두 다른 이상한 상황이 발생 할 수 밖에 없는 구조였다.

그렇기에 어떻게 할까 고민하던 중 멀티모듈 설계 이야기 with Spring, Gradle 이라는 글을 보고 필자의 서비스에도 단일 프로젝트 멀티모듈 구조로 가져갈 까 하다, 각각의 프로젝트마자 깃을 따로 관리하는게 서비스에는 맞다는 생각이 들어 멀티 프로젝트에 Nexus maven 저장소를 이용해서 도메인 클래스들을 분리 시키기로 했다.


본론

현재 시스템의 구조

이 처럼 여러 개의 프로젝트가 하나의 데이터베이스를 공유하는 구조로, 같은 데이터베이스 이므로 동일한 내용의 도메인 클래스가 각자 프로젝트에 각각 중복으로 존재하기 때문에 관리가 힘든 상황이다.

때문에 도메인 클래슬들만 모아놓은 새로운 프로젝트를 생성하고, 그 프로젝트를 jar파일로 만들어 라이브러리화 한 후, 내부 Maven 저장소인 Nexus에 업로드해서 다른 프로젝트들에서 dependency에 추가해서 시스템적으로 정합성을 유지하면서 사용 할 수 있게 하려고 한다.

간단하게 정리하면

  1. Nexus에 모듈화된 도메인 프로젝트 업로드
  2. 다른 프로젝트들에서 라이브러리화 된 도메인 프로젝트를 dependency 추가하여 사용

하는 것이 이번 글의 목표이다.

이 포스트에 Nexus를 설치하는 내용까지 담으려고 하니 내용이 너무 길어질 것 같아 nexus는 이미 설치되어 있다고 가정한다.


1. Nexus 설정

다른 분들의 글들을 보고 nexus를 무사히 설치하고 실행하면 아래와 같은 화면을 볼 수 있을 것이다.

그 후 Sign in 버튼을 클릭하면 초기 아이디와 비밀번호가 저장된 파일의 위치를 알려준다.


1
cat /설치경로/nexus3/admin.password


  • 설정 하다보면 아래처럼 익명 사용자도 접근하게 할 것인지 물어보는데 당연히도 수정 할 수 있으므로 적당히 선택해주면 된다.


2. Repository 생성

우선 모듈화한 도메인 프로젝트를 업로드 할 레파지토리부터 생성해야한다.



레파지토리 type은 group, hosted, proxy가 있지만, 우리는 위 처럼 maven2의 hosted로 생성한다.

  • 이때 hosted는 레파지토리 관리자가 직접 호스팅하는 레파지토리 type이고,
  • 다른 것들과의 차이점은 여기서 확인 해 볼 수 있다.



  • Version policy는

    • Release
    • Snapshot
    • Mixed

    3 가지가 존재하는데, 보통 Snapshot 레파지토리, Release 레파지토리를 따로 만들어 관리해야하지만, 필자는 Mixed로 지정하고 사용하였다.



  • Deployment policy는

    • Allow redeploy
      • 같은 버전으로 재배포 가능하고 덮어씀
    • Disable redeploy
      • 같은 버전으로 다시 배포 할 수 없음
    • Read-only
      • 아예 배포가 허용되지 않음
    • Deploy by Replication Only
      • 레플리카를 사용하는 경우 레플리카로만 배포되고 다른 배포는 차단됨
  • 자세한 내용은 여기서 확인 할 수 있다.



3. Repository access 유저 생성

Role 생성



필터란에 생성한 레파지토리명으로 검색해서 nx-repository-view-… 로 시작하는 권한들을 넣어주고, 해당 Role로 레파지토리에 업로드도 할 수 있게 하기 위해 nx-component-upload 권한도 넣어준다.

유저 생성



  • 각 항목들은 각자에 맞게 넣어주고 Role엔 조금 전에 생성한 Role을 넣어주고 생성을 완료하면 우리가 처음에 로그인한 어드민 계정과는 달리 우리가 생성한 repository만 접근 할 수 있는 것을 확인 할 수 있다.


4. 라이브러리 업로드

settings.xml 설정

nexus 레파지토리에 접근하기 위해서는 우선, maven 설정 파일인 settings.xml을 수정해야야 한다. 기본적으로는 ~/.m2/settings.xml 으로 설정되어 있으므로 이 곳에 생성해서 사용하거나, 이미 settings.xml을 사용하고 있다면 그 파일을 수정해주면 되는데.. 이도 저도 모르겠으면 아래 이미지 처럼 인텔리제이에서 확인하여 그 위치의 settings.xml을 수정해준다.


1
2
3
4
5
6
7
8
9
10
11
12
13
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">

<servers>
<server>
<id>junggu-demo-repository</id>
<username>junggu-demo-user</username>
<password>qweqwe</password>
</server>
</servers>
</settings>
  • id : 레파지토리 서버의 ID (자유롭게 지정)
  • username : 레파지토리 접근을 위해 생성했던 nexus access 계정 Id
  • password: 레파지토리 접근을 위해 생성했던 nexus access 계정 password

코드 작성

모듈화할 코드를 작성하면 되는데, 우리는 도메인 영역을 모듈화 할 것이므로 Entity 클래스를 생성해서 테스트 해본다.


POM.xml 설정

코드 작성을 완료했으면 이제 해당 프로젝트를 nexus repository에 업로드 하기 위해 pom.xml에 아래 처럼 설정을 추가해줘야한다.

1
2
3
4
5
6
7
<distributionManagement>
<repository>
<id>junggu-demo-repository</id>
<name>junggu nexus repository~ name is free</name>
<url>http://localhost:8081/repository/junggu-nexus-demo/</url>
</repository>
</distributionManagement>
  • id : settings.xml에서 설정했던 id와 동일해야함.
  • name : 사람이 식별 할 수 있게 설정.
  • url : 생성한 repository의 url

  • <distributionManagement> 와 <repository>대한 자세한 설명은 이 쪽으로

배포하기

1
mvn clean compile deploy
  • 위 명령어로 deploy 하거나 혹은 Intellij에서 deploy를 실행시켜도 된다.


  • 무사히 deploy가 성공했다면 nexus에서 jar 파일이 정상적으로 업로드된 것을 확인 할 수 있고, 이 후엔 이 라이브러리화된 jar파일을 다른 프로젝트들에서 dependency에 추가해서 사용하면 된다.


업로드된 라이브러리 사용하기

업로드된 라이브러리를 사용하기 위해서 사용하고자 하는 프로젝트의 pom.xml에 아래와 같은 설정을 추가해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...


<!-- 모듈화되서 업로드된 도메인 프로젝트 -->
<dependency>
<groupId>com.junggu.ji</groupId>
<artifactId>demo</artifactId>
<version>0.0.1</version>
</dependency>

...


<!-- 라이브러리가 업로드된 Nexus Repository 정보 -->
<repositories>
<repository>
<id>junggu-demo-repository</id>
<name>junggu nexus repository</name>
<url>http://localhost:8081/repository/junggu-nexus-demo/</url>
</repository>
</repositories>
  • 우선 라이브러리가 업로드된 nexus repository의 정보를 입력하고
  • 우리가 업로드한 라이브러리 정보도 dependency에 추가해준다.
  • 이 때, dependency의 속성들이 헷갈린다면, nexus에서 업로드된 jar를 선택 할 경우 아래 이미지처럼 친절하게 알려주므로 복사해서 사용하면 된다.


Entity 호출하기


  • 위 이미지 처럼 dependency 추가 후 maven을 새로고침하면 보이는 것 처럼 추가도 잘 되고 코드에서도 사용 할 수 있는 것 처럼 보이는데 막상 코드에서 사용하려고 하면 아래 이미지에서 보는 것 처럼 정상적으로 호출되지 않는 것을 알 수 있다.


  • 결론부터 이야기하면 우리가 jar로 만든 프로젝트는 Spring boot 프로젝트를 그대로 jar로 패키징 한 것으로, 일반적인 jar 파일이 아닌 Spring boot가 실행 가능한 jar 형태로 패키징 되었기 때문에, jar에 포함된 클래스들을 호출 할 수가 없는 것이다.
    • 여기서 말하는 실행 가능한 jar는 META-INF에 저장된 MANIFEST.MF에 저장된 정보를 읽어서 실행시키기 때문에 우리가 일반적으로 호출하는 형태로는 사용 할 수 없다.



이 실행 가능한 jar를 일반적인 jar로 만들어서 repository에 배포하기 위해서 모듈화하는 프로젝트의 pom.xml에서 아래 설정을 제거 해주고 다른 dependency들에게 각각 version을 지정한 후 maven compiler plugin도 알맞게 추가해주면 되는데

이 때 우리가 삭제한 spring-boot-parent에 포함된 의존성들은 여기서 확인 할 수 있으므로, 필요한 것이나 각각 dependency들을 무슨 버전으로 지정해야 할 지 모르겠다면 여기서 확인 후 추가해주면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<plugins>

...


<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
<compilerArgument>-Xlint:all</compilerArgument>
</configuration>
</plugin>

...

<plugins>
  • 이제 verison을 올린 후 새로고침 해보면 이전과 다르게 BOOT-INF가 없어지고 바로 com. 으로 시작하는 자바 패키지가 노출되는 것을 확인 할 수 있다.


이제 테스트 코드를 작성해서 돌려보면 테스트가 성공 할 줄 알았으나.. 애석하게도 아래와 같이 Not a managed type 라는 에러를 내뿜으며 우리의 기대를 무참히 박살내는데…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Caused by: java.lang.IllegalArgumentException: Not a managed type: class com.junggu.ji.demo.domain.Junggu
at org.hibernate.metamodel.internal.MetamodelImpl.managedType(MetamodelImpl.java:582)
at org.hibernate.metamodel.internal.MetamodelImpl.managedType(MetamodelImpl.java:85)
at org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation.<init>(JpaMetamodelEntityInformation.java:75)
at org.springframework.data.jpa.repository.support.JpaEntityInformationSupport.getEntityInformation(JpaEntityInformationSupport.java:66)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getEntityInformation(JpaRepositoryFactory.java:233)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:182)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:165)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactory.getTargetRepository(JpaRepositoryFactory.java:76)
at org.springframework.data.repository.core.support.RepositoryFactorySupport.getRepository(RepositoryFactorySupport.java:325)
at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.lambda$afterPropertiesSet$5(RepositoryFactoryBeanSupport.java:323)
at org.springframework.data.util.Lazy.getNullable(Lazy.java:231)
at org.springframework.data.util.Lazy.get(Lazy.java:115)
at org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport.afterPropertiesSet(RepositoryFactoryBeanSupport.java:329)
at org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.afterPropertiesSet(JpaRepositoryFactoryBean.java:144)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1863)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1800)
... 85 more

이는 우리가 추가한 라이브러리의 Entithy가 component scan에 인식되지 않아서 Bean context에 등록되지 않았기 때문에 발생하는 에러이다.

왜냐면, spring boot는 기본적으로 @SpringBootApplication안에 있는 @EnableAutoConfiguration으로 스프링이 정의한 외부 의존성들과 @ComponentScan으로 해당 어노테이션 하위 패키지에 있는 클래스들을 탐색해서 Bean으로 등록하는데,

우리가 추가한 라이브러리는 둘 중 어느 위치에도 존재하지 않으므로 당연히 Bean에 등록 될 수없는 것이다.

라이브러리안에 있는 Entity Bean context 등록하기

여러 방법이 있겠지만, 필자는 SessionFactory Bean을 따로 만들어서 우리가 만든 Entity가 있는 라이브러리의 패키지까지 스캔 하도록 추가하는 방식을 사용하였다.

1
2
3
4
5
6
7
8
@Bean
public LocalSessionFactoryBean entityManagerFactory(DataSource dataSource) {
LocalSessionFactoryBean localSessionFactory = new LocalSessionFactoryBean();
localSessionFactory.setDataSource(dataSource);
localSessionFactory
.setPackagesToScan("com.junggu.ji.usenexus", "com.junggu.ji.demo");
return localSessionFactory;
}

적당히 작성한 테스트 코드와 성공한 모습
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource("classpath:application-test.properties")
@Import(DatabaseConfig.class)
@DataJpaTest
public class NexusTest {

@Autowired
private JungguRepository jungguRepository;

private final String name = "지중구";
private final int age = 30;
private final Gender gender = Gender.MALE;

private Junggu junggu = null;

@BeforeEach
void setUp() {

junggu = Junggu.builder()
.name(name)
.age(age)
.gender(gender)
.build();
}

@Test
void save() {
Junggu when = jungguRepository.save(junggu);

assertThat(when.getName()).isEqualTo(junggu.getName());
assertThat(when.getAge()).isEqualTo(junggu.getAge());
assertThat(when.getGender()).isEqualTo(junggu.getGender());
}
}


결론

이렇게 우리는 여러 프로젝트에서 공통으로 사용되는 부분을 따로 빼서 모듈화 하는 방법을 알아보았다.

필자는 단순히 Entity 클래스의 관리를 편하게 하기 위해 이번 모듈화를 진행했지만, 레이어 별로 프레젠테이션 레이어, 서비스 레이어, 도메인 레이어등으로 구분하여 모듈을 구분 할 수도 있을 것 이다.

다만, 이렇게 멀티 프로젝트에 nexus를 이용해서 모듈화를 구현하는 경우, 모듈화한 프로젝트를 수정 했을 때 모듈화된 라이브러리를 사용하는 다른 프로젝트 모든 프로젝트에서도 손수 version을 올려줘야한다는 단점이 있기 때문에(물론 Deployment policy를 Allow redeploy로 설정했다면 상관없다.), 각자의 서비스에서 단일 프로젝트 멀티 모듈 구조가 맞을 지 아니면 필자처럼 멀티 프로젝트인 상태로 모듈화를 구성 할지는 잘 판단해서 구조화를 해야 할 것 이다.


P.S

여담으로.. 필자는 이번 모듈화를 진행하면서 DTO에 의존적인 Entity들에 대해 굉장히 많이 리팩토링을 진행 할 수 있었고, 레이어 별 분리에 대해 다시 한번 생각해보는 계기가 되어서 고생은 좀 많이 했지만, 개인적으로 굉장히 뜻 깊은 시간을 보낼 수 있었다.


참고 사이트

댓글 공유

서론

어쩌다 보니 연속해서 @RequestBody에 관한 글을 작성하게 되었는데… 이전에 말했던 것 처럼 @RequestBody는 Jackson 라이브러리를 사용하기 때문에 기본생성자를 이용해 object(DTO)를 바인딩한다. 그 때문에 다른 생성자에서 validtion등을 처리했다고 해도 당연히 처리되지 않는데 이 때 사용할 수 있는 @JsonCreator에 대해 알아본다.


본론

현재 상황



위 이미지들 처럼 Controller와 dto를 만들었다고 했을 때, /test 를 호출해보면 어떻게 될까?


@RequestBody를 사용 할 경우 Jackson 라이브러리를 사용해서 기본 생성자를 이용해 매핑시키기 때문에 “여기타고 생성이 될까요?” 라는 로그는 찍히지 않는다.

이 때, DTO에 처리 혹은 Assert 등을 이용한 validtion을 추가하고 싶다면 어떻게 될까?

생성자에 추가해서 처리하면 좋겠지만, 기본 생성자를 이용해서 생성되므로, controller 혹은 service 단에서 처리해야 한다.(물론 validtion의 경우엔 @Valid를 통해 어느정도 처리 가능하다.)

이런 경우, 기본 생성자가 아닌 다른 생성자를 이용해 dto를 바인딩 시키고 싶은 경우 사용 할 수 있는 annotation이 바로 @JsonCreator이다.

이제 다시 코드로 돌아와서, 이 @JsonCreator를 생성자에 선언 해놓고 다시 /test를 요청한다면


아까와 다르게 @Builder 패턴이 적용된 생성자를 통해 바인딩 되어서 “여기타고 생성이 될까요?” 라는 로그가 정상적으로 찍힌 걸 볼 수 있다.


결론

이제 우리는 @RequestBody를 사용할 때 기본 생성자만이 아닌 다른 생성자로도 DTO를 바인딩 할 수 있게 되었으므로, @Valid보다 더 정교한 validtion이나, String을 encoding 해야 할 경우에도 service나 controller가 아닌 생성자에서 처리 할 수 있게 되었으므로 테스트도 더 간편하게 할 수 있게 되었다.

번외로 보통 @JsonCreator는 인스턴스화 할 때 사용 할 생성자를 정의 할 때 사용 하는 annotation으로,
보통 아래 코드처럼 @JsonProperty와 같이 사용해서 엔티티와 완벽히 일치되지 않는 JSON을 역직렬화할 때 주로 사용하는 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
public class BeanWithCreator {
public int id;
public String name;

@JsonCreator
public BeanWithCreator(
@JsonProperty("id") int id,
@JsonProperty("theName") String name) {
this.id = id;
this.name = name;
}
}

개인적으로 이렇게 쓸 일이 몇 번이나 있을까 싶긴하지만…


전체 코드

참고 사이트

댓글 공유

서론

이전에 작성한 글 중에 @Requestbody Annotation 사용 시 기본 생성자 필요 라는
글을 올린 적이 있는데 글의 마지막에 보면

@Requestbody가 jackson 라이브러리를 이용해 매핑하기 때문이다.

JG.JI@Requestbody Annotation 사용 시 기본 생성자 필요

라는 결론으로 글을 마무리 하였는데, 김영한님의 강의에서 해당 내용과 관련해서 자세하게 알려주시는 부분이 있어, 해당 내용을 정리해본다.


본론

우선 Spring에서는 Controller에서 @RequestBody로 넘어오는 파라미터들에 대해서 내부적으로 ObjectMapper를 이용해 JSON 데이터를 우리가 만든 DTO 객체로 변환시켜주고,
@ResponseBody인 경우엔, DTO 객체를 JSON 데이터로 변환해서 클라이언트에 넘겨준다.

이렇게해서 해소 될 궁금증이었으면 이 글을 읽으러 오지 않았을테니, 해당 내용에 대하여 코드 레벨에서 자세히 알아보기로 한다.

우선, Spring MVC의 동작 순서를 간략하게 서술하자면(구글에 검색하면 훌륭한 분들이 작성하신 훌륭한 글들이 많으므로)

  1. 클라이언트에서 요청이 넘어오면 FrontController 역할을 수행하는 DispatcherServlet에서 먼저 확인해서,
  2. 핸들러 매핑 리스트 중 해당 요청을 처리 할 수 있는 핸들러를 가져오고,
  3. 핸들러 어댑터 리스트 중에서 해당 핸들러를 처리 할 수 있는 핸들러 어댑터를 가져와서,
  4. 해당 어댑터에서 우리가 만든 컨트롤러에 호출해서 비즈니스 로직을 처리 한 후,
  5. 리턴되는 데이터에 맞게 뷰 리졸버나 메시지컨버터가 동작하고,
  6. 알맞게 클라이언트로 반환된다.

여기서, @RequestMapping에 대한 request는 RequestMappingHandlerAdapter에서 처리하는데
이 RequestMappingHandlerAdapter는
1. byte[]를 처리하기 위한 ByteArrayHttpMessageConverter
2. String을 처리하기 위한 StringHttpMessageConverter
3. json을 처리하기 위한 MappingJackson2HttpMessageConverter
등을 가지고 있다.

RequestMappingHandlerAdapter에 설정된 MessageConverter들

RequestMappingHandlerAdapter에 설정된 MessageConverter들

그리고 자신이 가지고 있는 ArgumentResolver들 중
@RequestBody형식으로 들어오는 파라미터를 처리하기 위해 RequestResponseBodyMethodProcessor 라는 ArgumentResolver를 호출한다.

RequestMappingHandlerAdapter에 설정된 ArgumentResolver들


이 RequestResponseBodyMethodProcessor의 readWithMessageConverters 메서드에서 메시지 컨버터 리스트를 loop 돌면서
대상 클래스 타입과 미디어 타입 등을 체크해서 알맞은 메시지 컨버터를 호출해서 사용하는데
이 때 호출되는 MessageConverter가 MappingJackson2HttpMessageConverter라는 MessageConverter이다.

요청을 처리 할 수 있는 MessageConverter 호출

  1. MessageConverter의 canRead() 메서드로 해당 요청을 처리 할 수 있는 컨버터 인지 확인.
  2. 처리 할 수 있으면 read() 메서드로 메시지에서 객체를 읽고 반환한다.

MappingJackson2HttpMessageConverter에서는 최종적으로 readJavaType 메서드가 호출되는데 이 때 objectMapper를 사용해서 json 데이터를 DTO 객체로 변환하는 것을 볼 수 있다.

ObjectMapper를 이용해서 JSON데이터를 변환하는 부분


결론

보이지 않는 곳에서 이렇게 많은 부분을 스프링에서 처리해주고 있었기 때문에 우리는 Controller에서 편하게 파라미터를 사용 할 수 있고, JSON 데이터도 바로 객체로 받을 수 있는 것이었다.
이런 내용을 알아도, 몰라도 결국 같은 방식으로 개발 하겠지만… 언젠가 도움이 되겠거니 생각하고 지금은 궁금증을 해결 했다는 것에 대해 만족한다.
이 글을 읽는 다른 분들도 필자와 같은 궁금증이 있었다면 조금이나마 해소가 됐길 바라며..


참고 사이트

댓글 공유

서론

오늘도 어김없이 영한님의 스프링 MVC 강의를 듣고 있었는데, html form 데이터를 body에 보낼 때는 post 방식 밖에 안된다는 말씀을 하시기에…
‘그럼 GET 방식도 안된다는건가?’ 라는 궁금증이 들어서 직접 확인해본 결과에 대해 작성한다.


본론

우선… 기본적으로 지금 필요한 GET방식과 POST방식의 차이점만을 다시 상기해보자면, GET방식은 데이터가 URL의 query string으로 들어간다는 것과,
POST방식은 데이터가 http message-body에 들어간다는 차이만 알아두면 될 것 같다. (다른 많은 차이가 있지만)

그렇기 때문에 우리가 html form 태그 에서 method attribute에 GET라고 작성한 후 데이터를 submit하면 데이터는 query string에 담겨 서버로 전송되고
POST라고 작성한 후 데이터를 submit하면 데이터는 message-body에 담겨서 서버로 전성되고 이 때 content-type은 application/x-www-form-urlencoded로 전송된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/request-param" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>

그럼 method는 GET이면서 content-type은 application/x-www-form-urlencoded 일 경우엔 어떻게 될까?

postman으로 아래처럼 실행해본 결과 보는 것과 같이 400 error가 응답되며 요청에 실패하였다.

어째서 이런 일이 발생하였나를 코드 레벨에서 확인해보니…

tomcat의 Request class에 있는 parseParameters()라는 메서드에서 넘어온 데이터를 parsing 하는데,
이 때 http method가 post인지 확인하고 post가 아니라면 message-body의 데이터를 파싱 할 수 없게 되어 있는 것을 확인했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
if( !getConnector().isParseBodyMethod(getMethod()) ) {
success = true;
return;
}

...


// 이 아래부턴 Connector.java

protected boolean isParseBodyMethod(String method) {
return parseBodyMethodsSet.contains(method);
}

...

if (null == parseBodyMethodsSet) {
setParseBodyMethods(getParseBodyMethods());
}

...

/**
* Set list of HTTP methods which should allow body parameter
* parsing. This defaults to <code>POST</code>.
*
* @param methods Comma separated list of HTTP method names
*/
public void setParseBodyMethods(String methods) {

HashSet<String> methodSet = new HashSet<>();

if (null != methods) {
methodSet.addAll(Arrays.asList(methods.split("\\s*,\\s*")));
}

if (methodSet.contains("TRACE")) {
throw new IllegalArgumentException(sm.getString("coyoteConnector.parseBodyMethodNoTrace"));
}

this.parseBodyMethods = methods;
this.parseBodyMethodsSet = methodSet;
setProperty("parseBodyMethods", methods);
}

...

/**
* @return the HTTP methods which will support body parameters parsing
*/
public String getParseBodyMethods() {
return this.parseBodyMethods;
}

...


/**
* Comma-separated list of HTTP methods that will be parsed according
* to POST-style rules for application/x-www-form-urlencoded request bodies.
*/
protected String parseBodyMethods = "POST";

위에 정리한 것 처럼

  1. isParseBodyMethod()에서 지금 request의 method가 body를 파싱 할 수 있는 메서드 인지 확인하는데, 이 때 가능한 method들은 인스턴스 변수 parseBodyMethodsSet에 저장되어 있고,
  2. 인스턴스 변수 parseBodyMethodsSet는 setParseBodyMethods() 메서드에서 저장되고 있고~
  3. parseBodyMethodsSet가 null인 경우에
  4. setParseBodyMethods(getParseBodyMethods()) 메서드를 실행해서 이 때 method를 저장 시키는데,
  5. getParseBodyMethods() 메서드를 호출하면
  6. 인스턴스변수 parseBodyMethods를 return 하는데
  7. 이 때 이 인스턴스 변수 parseBodyMethods에는 이미 “POST”로 method가 지정되어 있기 때문에
  8. GET방식 일 경우에는 message-body를 파싱 할 수 있는 method가 아닌 것으로 처리 되므로~
  9. 파싱 할 수 없다는 에러 메시지를 출력하면서 프로세스가 끝이난다.

결론

생각해보면… content-type은 message-body이 어떤 형식의 데이터로 담겨 있는가를 알려주는 일종의 os의 파일 확장자같은 개념인데
당연히 get방식의 경우 message-body에 데이터가 담기는 것이 아니니 get방식으로 content-type만 application/x-www-form-urlencoded로 한다고 될 턱이 없는데
강의를 듣기만 했을 때는 왜 생각하지 못했는 지 모르겠다.. 머리가 나쁘면 몸이 고생이라더니 옛말에 틀린 말이 없다.


참고 사이트

댓글 공유

서론

우선 사과의 말씀부터 올린다. 제목은 ‘spring에서~’ 지만 정확히는 tomcat에서 ~ 로 보는 것이 맞다.

인프런에서 김영한 님의 Spring MVC 강의를 보던 도중 … ‘http request를 정확히 어떠한 방식으로 파싱하고 있을까?’라는 궁금증이 생겨서 강의를 듣다가 말고 삽질을 한 내용을 기록해둔다.


본론

우선, 처음으로 궁금증을 해결하기 위해 들어가본 곳은 http request 요청을 받기 위한 HttpServletRequest interface의 tomcat 구현체인 RequestFacade class부터 시작했다.

이 중 getHeader 메서드에 대해 찾아보면,

Returns the value of the specified request header as a String.
If the request did not include a header of the specified name, this method returns null.
If there are multiple headers with the same name, this method returns the first head in the request.
The header name is case insensitive.
You can use this method with any request header.

라고 설명되어 있고… 이 때 코드에서 request의 header를 살펴보면

이미 header에 정보들이 저장되어 있는 상태

이미지와 같이 이미 header에 대한 정보들이 각각 저정되어 있는 걸 볼 수 있다.

필자가 궁금했던 것은 ‘이 request에 header는 언제 저장되는가?’ 였으므로
막연하게 ‘여기서 더 타고 올라가면 찾아 낼 수 있겠구나..’ 라고 안일한(?) 생각을 하고 안으로 더 들어가보았다.

타고타고 가다보니 …
http 1.1 header에 의한 요청이므로 Http11Processor의 service 메서드에서

1
2
3
4
5
6
7
8
// Don't parse headers for HTTP/0.9
if (!http09 && !inputBuffer.parseHeaders()) {
// We've read part of the request, don't recycle it
// instead associate it with the socket
openSocket = true;
readComplete = false;
break;
}

처럼 작성되어 있는 코드를 발견했고, 여기에 있는 inputBuffer.parseHeaders() 을 보니
이름부터 여기서 http request에 대해 파싱을 할 것 같아 이 안으로 들어가보았다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
while (headerParsePos == HeaderParsePosition.HEADER_NAME) {

// Read new bytes if needed
if (byteBuffer.position() >= byteBuffer.limit()) {
if (!fill(false)) { // parse header
return HeaderParseStatus.NEED_MORE_DATA;
}
}

int pos = byteBuffer.position();
chr = byteBuffer.get();
if (chr == Constants.COLON) {
headerParsePos = HeaderParsePosition.HEADER_VALUE_START;
headerData.headerValue = headers.addValue(byteBuffer.array(), headerData.start,
pos - headerData.start);
pos = byteBuffer.position();
// Mark the current buffer position
headerData.start = pos;
headerData.realPos = pos;
headerData.lastSignificantChar = pos;
break;
} else if (!HttpParser.isToken(chr)) {
// Non-token characters are illegal in header names
// Parsing continues so the error can be reported in context
headerData.lastSignificantChar = pos;
byteBuffer.position(byteBuffer.position() - 1);
// skipLine() will handle the error
return skipLine();
}

// chr is next byte of header name. Convert to lowercase.
if ((chr >= Constants.A) && (chr <= Constants.Z)) {
byteBuffer.put(pos, (byte) (chr - Constants.LC_OFFSET));
}
}

들어가서보니 해당 코드처럼

현재 header data의 파싱된 위치가 key, value 형식의 데이터인 header의 name 부분 이라면 계속 반복하면서 버퍼에 저장하고 COLON(:) 을 만나게 되면,

1
2
headerData.headerValue = headers.addValue(byteBuffer.array(), headerData.start,
pos - headerData.start);

코드가 실행되면서 아까 봤던 request의 MimeHeaders 타입인 header에 name으로 저장되고

value에도 위와 같은 방식으로 byte 단위로 이동하고 저장되면서 header에 value에 저장된다.


결론

서블릿이 http 통신을 위해 귀찮은 것들을 해준다는 것은 알고 있었지만
실제로 어떻게 하고 지에 대해서는 신경을 안 쓰고 있었는데 이렇게 강의를 듣다 보니 궁금해서 한번 확인해보았다.

만일 이 작업을 서블릿에서 대신 안해주지 않고 개발자가 개발할 때마다 해줘야한다고 생각하면…
머리가 어지러워지는 것 같아 이만 글을 줄인다…


참고 사이트

댓글 공유

서론

현재 운영 중인 서비스가 어른들의 사정(?)으로 기존에 앱스토어에 올라와있던 어플의 팀을 이전해야 하는 경우가 생겼는데 이 때 팀 변경 후(정확히는 변경 후, Service Ids를 새로 생성한 후) 기존 애플 유저들의 계정이 로그인 되지 않고 가입 프로세스를 타게 되었는데, 이를 해결 하기 위해 이전 된 팀으로 기존 유저 정보를 마이그레이션 하는 방법을 정리 해둔다.


본론

1. 기존 팀 Key file로 client_secret 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
require 'jwt'

key_file = '기존 키파일명'
team_id = '기존 팀 아이디'
client_id = '클라이언트 아이디'
key_id = '기존 키 아이디'

ecdsa_key = OpenSSL::PKey::EC.new IO.read key_file

headers = {
'kid' => key_id
}

claims = {
'iss' => team_id,
'iat' => Time.now.to_i,
'exp' => Time.now.to_i + 86400*180,
'aud' => 'https://appleid.apple.com',
'sub' => client_id,
}

token = JWT.encode claims, ecdsa_key, 'ES256', headers

puts token
  1. 위 코드를 복사해서 ruby script 파일(.rb)로 만든다.
  2. 키 파일과 작성한 script 파일을 같은 디렉토리 내에 위치 시킨다.
  3. 터미널에서 .rb 스크립트를 실행 시킨다.
1
2
3
4
5
# ruby 스크립트와 키파일이 존재하는 디렉토리로 이동
cd /Users/user/Documents/apple_client

# ruby 스크립트 실행
ruby client_secret.rb
  1. 스크립트가 정상적으로 실행됐으면 client_secret이 생성됨
1
2
3
Ignoring ffi-1.15.0 because its extensions are not built. Try: gem pristine ffi --version 1.15.0
# 아래
eyJraWQiOiJHMzg2UlM3MlY3IiwiYWxnIjoiRVMyNTYifQ.eyJpc3MiXXXXXXXXXxxxTJSOVM1IiwiaWF0IjoxNjM1MzAwOTY0LCJleHAiOjE2NTA4NTI5NjQsImxxxxxxXXXBzOi8vYXBwbGVpZC5hcHBsZS5jb20iLCJzdWIiOiJjb20uampsZWUuV2VkUXVlZW4ifQ.Zw18dKVQxxxxxXXXXXXeSVMT8B_aTkVHULNPOal7W_n_KTba3WtYwE-fQh8Ru6GhmNdVbx2VD1TnPBjTZmKB6PaZ58w

2. access_token 생성

  1. request 작성
1
2
3
4
5
6
7
8
9
10
URL : https://appleid.apple.com/auth/token
HTTP Method : POST
Content-Type : x-www-form-urlencoded
Body :
{
grant_type : client_credentials
scope : user.migration
client_id : 클라이언트 아이디
client_secret : 위에서 생성한 client_secret
}
  1. 요청 후 결과 확인
  • 정상적으로 응답이 온다면 아래처럼 1시간 짜리 access_token이 발급된다.
  • 1 시간이 경과하면 재발급을 받아야 한다.

3. 기존 유저 provider Id transfer

  • 변경된 팀의 provider Id로 변경하기 전 단계
  • 여기서 응답받은 transfer_sub 으로 다시 변경 요청을 보내야 변경된 팀 provider id를 받을 수 있음
  1. request 작성
access_token 설정 token_type이 bearer였으므로, bearer token으로 설정
1
2
3
4
5
6
7
8
9
10
11
URL : https://appleid.apple.com/auth/usermigrationinfo
HTTP Method : POST
Content-Type : x-www-form-urlencoded
Authorization: Bearer {access_token}
Body :
{
sub : 기존 유저 Provider Id
target : 새로 만든 팀 ID
client_id : 클라이언트 아이디
client_secret : 위에서 생성한 client_secret
}
  1. 요청 후 결과 확인

4. 변경된 팀 Provider Id로 transfer

  1. requset 생성
    • 이 때 전송되는 client_secret은 새로 만든 팀의 키 파일로 생성된 client_secret이다.
    • 이 때 전송되는 client_secret은 새로 만든 팀의 키 파일로 생성된 client_secret이다.
    • 이 때 전송되는 client_secret은 새로 만든 팀의 키 파일로 생성된 client_secret이다.
    • client_secret을 만드는 방법은 1번과 같다.
    • bearer token은 기존에 만든 것을 사용하면 된다.
1
2
3
4
5
6
7
8
9
10
URL : https://appleid.apple.com/auth/usermigrationinfo
HTTP Method : POST
Content-Type : x-www-form-urlencoded
Authorization: Bearer {access_token}
Body :
{
transfer_sub : 이전 단계에서 transfer된 sub
client_id : 클라이언트 아이디
client_secret : 새로 만든 팀의 client_secret
}
  1. 요청 후 결과 확인
  • 000000.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.0000
  • 형식으로 생성되는데 이 때 맨 앞에 00000. 부분은 변경되지 않으므로
  • 이 부분을 확인

5. DB에 저장된 유저 provider Id 변경

  • 필자의 서비스는 provider ID로 유니크한 KEY를 만들어 사용하고 있었으므로 이 부분을 업데이트 해주었다.

참고 사이트

댓글 공유

서론

spring으로 된 프로젝트를 spring boot으로 전환 중… query method로 작성되어 있는 부분에서 발생한 문제에 대해 알아보고 이에 대한 해결 방법을 정리 해둔다.


본론

에러가 발생한 code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

public class XXXX {
...

private DDay dDay;

...
}

public interface Repository {
...

List<Category> findByDDayOrderByDDayStartDateAsc(DDay dday);

...
}

발생한 에러 전문

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Caused by: java.lang.IllegalArgumentException: Unable to locate Attribute  with the the given name [attribute명] on this ManagedType [class명]
at org.hibernate.metamodel.model.domain.internal.AbstractManagedType.checkNotNull(AbstractManagedType.java:148) ~[hibernate-core-5.4.32.Final.jar:5.4.32.Final]
at org.hibernate.metamodel.model.domain.internal.AbstractManagedType.getAttribute(AbstractManagedType.java:119) ~[hibernate-core-5.4.32.Final.jar:5.4.32.Final]
at org.hibernate.metamodel.model.domain.internal.AbstractManagedType.getAttribute(AbstractManagedType.java:44) ~[hibernate-core-5.4.32.Final.jar:5.4.32.Final]
at org.springframework.data.jpa.repository.query.QueryUtils.requiresOuterJoin(QueryUtils.java:697) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.QueryUtils.toExpressionRecursively(QueryUtils.java:638) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.QueryUtils.toExpressionRecursively(QueryUtils.java:617) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.QueryUtils.toExpressionRecursively(QueryUtils.java:613) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.JpaQueryCreator$PredicateBuilder.getTypedPath(JpaQueryCreator.java:385) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.JpaQueryCreator$PredicateBuilder.build(JpaQueryCreator.java:308) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.JpaQueryCreator.toPredicate(JpaQueryCreator.java:211) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.JpaQueryCreator.create(JpaQueryCreator.java:124) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.JpaQueryCreator.create(JpaQueryCreator.java:59) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.repository.query.parser.AbstractQueryCreator.createCriteria(AbstractQueryCreator.java:119) ~[spring-data-commons-2.5.2.jar:2.5.2]
at org.springframework.data.repository.query.parser.AbstractQueryCreator.createQuery(AbstractQueryCreator.java:95) ~[spring-data-commons-2.5.2.jar:2.5.2]
at org.springframework.data.repository.query.parser.AbstractQueryCreator.createQuery(AbstractQueryCreator.java:81) ~[spring-data-commons-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.PartTreeJpaQuery$QueryPreparer.<init>(PartTreeJpaQuery.java:217) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.PartTreeJpaQuery$CountQueryPreparer.<init>(PartTreeJpaQuery.java:348) ~[spring-data-jpa-2.5.2.jar:2.5.2]
at org.springframework.data.jpa.repository.query.PartTreeJpaQuery.<init>(PartTreeJpaQuery.java:91) ~[spring-data-jpa-2.5.2.jar:2.5.2]
... 117 common frames omitted


Process finished with exit code 0

왜 발생 했을까?

The inconsistency between the subject part and the OrderBy part is certainly a bug.

For the given case the correct behaviour seem to be the one from the OrderBy part which is adhering to the conventions laid out in the Java Bean Specification 1.0.1 Section 8.8:

There is no way to write the property name in the middle of a java method name if the property begins with a single lower case letter followed by an upper case letter.

Therefore renaming the property, or less invasive providing a @Query annotation are the ways to solve this problem on your side GitFlip


독자들의 빠른 문제 해결을 위해 결론부터 이야기 하자면 위의 글에서 보는 것 처럼 query method에서 OrderBy를 사용 할 때 “dDay” 같이 맨 앞 한 자리가 소문자 변수명을 사용 하는 경우 spring-data-jpa의 버그로 발생하는 에러인데 해결방법으로는 @Query annotation을 사용하거나 변수명을 변경하는 것을 권하고 있다.


결론

해결 방법

1
2
@Query("SELECT c FROM Entity명 c WHERE c.dDay =:dday ORDER BY dDayStartDate")
List<Category> findByDDayOrderByDDayStartDateAsc(DDay dday);

위에서 알려준 해결책 중 필자는 @Query Annotation을 사용하는 방법으로 문제를 수정하였는데 다른 이유가 있는 것은 아니고 현재 프로젝트에서 변수명을 변경하는 것이 더 코스트가 많이 발생하기 때문에 @Query를 사용해서 처리했다.

이 문제 때문에 꽤 많은 시간을 소비 하였는데 필자와 같은 무고한 피해자(?)가 발생하지 않기를 바라며 글을 마친다.


참고 사이트

댓글 공유

상황

spring으로 된 프로젝트를 spring boot으로 전환 중에 spring-security-taglibs의 버전을 3.x 에서 5.5.1로 업데이트 했는데 아래와 같은 에러가 발생하여 이에 관한 내용을 정리 해둔다.


발생한 에러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
org.apache.jasper.JasperException: /WEB-INF/views/layout/layout.jsp (line: [126], column: [0]) Attribute [ifAnyGranted] invalid for tag [authorize] according to TLD
at org.apache.jasper.compiler.DefaultErrorHandler.jspError(DefaultErrorHandler.java:41) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.ErrorDispatcher.dispatch(ErrorDispatcher.java:292) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.ErrorDispatcher.jspError(ErrorDispatcher.java:115) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Validator$ValidateVisitor.checkXmlAttributes(Validator.java:1287) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Validator$ValidateVisitor.visit(Validator.java:900) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$CustomTag.accept(Node.java:1558) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Nodes.visit(Node.java:2385) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Visitor.visitBody(Node.java:2437) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Visitor.visit(Node.java:2443) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Root.accept(Node.java:471) ~[tomcat-embed-jasper-9.0.48.jar!/:na]
at org.apache.jasper.compiler.Node$Nodes.visit(Node.java:2385) ~[tomcat-embed-jasper-9.0.48.jar!/:na]

...

왜 발생했을까?

This section describes all of the deprecated APIs within the spring-security-taglibs module. If you are not using the spring-security-taglibs module or have already completed this task, you can safely skip to spring-security-web.

Spring Security’s authorize JSP tag deprecated the properties ifAllGranted, ifAnyGranted, and ifNotGranted in favor of using expressions.

문서에 적혀 있는 것 처럼 spring-security-taglibs 4.x 버전 이상 부터는 ifAllGranted, ifAnyGranted, ifNotGranted 속성들을 지원하지 않기 때문에 3.x 버전에서 5 버전으로 업그레이드하면서 해당 속성들을 사용한 코드들이 있는 경우 발생하는 문제였다.

해결방법

1
2
3
4
5
6
7
8
9
10
11

<sec:authorize ifAllGranted="ROLE_ADMIN,ROLE_USER">
<p>Must have ROLE_ADMIN and ROLE_USER</p>
</sec:authorize>
<sec:authorize ifAnyGranted="ROLE_ADMIN,ROLE_USER">
<p>Must have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>
<sec:authorize ifNotGranted="ROLE_ADMIN,ROLE_USER">
<p>Must not have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>

위 처럼 되어 있을 경우

1
2
3
4
5
6
7
8
9
10
11
12


<sec:authorize access="hasRole('ROLE_ADMIN') and hasRole('ROLE_USER')">
<p>Must have ROLE_ADMIN and ROLE_USER</p>
</sec:authorize>
<sec:authorize access="hasAnyRole('ROLE_ADMIN','ROLE_USER')">
<p>Must have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>
<sec:authorize access="!hasAnyRole('ROLE_ADMIN','ROLE_USER')">
<p>Must not have ROLE_ADMIN or ROLE_USER</p>
</sec:authorize>

아래 처럼 치환해주면 정상적으로 작동하는 것을 확인 할 수 있을 것이다.


참고 사이트

댓글 공유

상황

평소와 같이 테스트 코드를 작성하고 실행을 했는데… 아래와 같이 에러가 발생하며 테스트가 진행되지 않았다.

발생한 에러


해결

위 이미지처럼 프로젝트 루트에 존재하는 .idea 폴더에 workspace.xml 파일을 열어서

1
2
3
<component name="PropertiesComponent">
<property name="dynamic.classpath" value="true" />
</component>

를 추가해주면 된다.

보통 이미 PropertiesComponent 태그는 존재 할 테니 그 아래 있는

1
<property name="dynamic.classpath" value="true" />

옵션만 정상적으로 넣어주면 된다.


보너스

dynamic.classpath 옵션이 뭔데 넣으면 되는 거에요?

이 질문에 대한 답은 역시 없는게 없는 스택오버 플로우에 이미 존재한다. What does the dynamic.classpath flag do? (IntelliJ project settings)

간단하게 정리하면 커맨드라인이나 파일을 통해 클래스 패스가 jvm에 전달되는 방식을 제어하는데 대부분의 os에선 최대 명령 줄 제한이 있어서 이 제한을 넘는 명령은 IDEA에서 실행 할 수 없기 때문에 명령이 32768 자 보다 길면 동적 클래스 패스로 전환 할 것을 제안하고 이 때 긴~ 클래스 패스는 파일에 기록된 다음, 응용 프로그램 관리자가 읽어서 시스템 클래스 로더를 통해 로드 된다는 것이다.

끝!


참고 사이트

댓글 공유

서론

어쩌다보니 학부생 시절 가장 싫어해 마지않던 안드로이드를 다시 공부하게 되었는데, 간단한 화면 하나 만들고도 실행이 안되서 삽질한 내용을 정리한다.


발생한 에러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
2021-05-03 22:06:26.170 18752-18752/com.jgji.memo E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.jgji.memo, PID: 18752
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.jgji.memo/com.jgji.memo.MainActivity}: android.view.InflateException: Binary XML file line #33 in com.jgji.memo:layout/activity_main: Binary XML file line #33 in com.jgji.memo:layout/activity_main: Error inflating class Button
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2066)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: android.view.InflateException: Binary XML file line #33 in com.jgji.memo:layout/activity_main: Binary XML file line #33 in com.jgji.memo:layout/activity_main: Error inflating class Button
Caused by: android.view.InflateException: Binary XML file line #33 in com.jgji.memo:layout/activity_main: Error inflating class Button
Caused by: java.lang.IllegalArgumentException: The style on this component requires your app theme to be Theme.AppCompat (or a descendant).
at com.google.android.material.internal.ThemeEnforcement.checkTheme(ThemeEnforcement.java:243)
at com.google.android.material.internal.ThemeEnforcement.checkAppCompatTheme(ThemeEnforcement.java:213)
at com.google.android.material.internal.ThemeEnforcement.checkCompatibleTheme(ThemeEnforcement.java:148)
at com.google.android.material.internal.ThemeEnforcement.obtainStyledAttributes(ThemeEnforcement.java:76)
at com.google.android.material.button.MaterialButton.<init>(MaterialButton.java:229)
at com.google.android.material.button.MaterialButton.<init>(MaterialButton.java:220)
at com.google.android.material.theme.MaterialComponentsViewInflater.createButton(MaterialComponentsViewInflater.java:43)
at androidx.appcompat.app.AppCompatViewInflater.createView(AppCompatViewInflater.java:123)
at androidx.appcompat.app.AppCompatDelegateImpl.createView(AppCompatDelegateImpl.java:1551)
at androidx.appcompat.app.AppCompatDelegateImpl.onCreateView(AppCompatDelegateImpl.java:1602)
at android.view.LayoutInflater.tryCreateView(LayoutInflater.java:1059)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:995)
at android.view.LayoutInflater.createViewFromTag(LayoutInflater.java:959)
at android.view.LayoutInflater.rInflate(LayoutInflater.java:1121)
at android.view.LayoutInflater.rInflateChildren(LayoutInflater.java:1082)
at android.view.LayoutInflater.inflate(LayoutInflater.java:680)
at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
at android.view.LayoutInflater.inflate(LayoutInflater.java:479)
at androidx.appcompat.app.AppCompatDelegateImpl.setContentView(AppCompatDelegateImpl.java:696)
at androidx.appcompat.app.AppCompatActivity.setContentView(AppCompatActivity.java:170)
at com.jgji.memo.MainActivity.onCreate(MainActivity.java:27)
at android.app.Activity.performCreate(Activity.java:8000)
at android.app.Activity.performCreate(Activity.java:7984)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3601)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:85)

해결

결론부터 바로 말하면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

...

import androidx.appcompat.app.AppCompatActivity;

...

@SuppressLint(value = "StaticFieldLeak")
public class MainActivity extends AppCompatActivity {

private MemoDatabase db;
private List<MemoEntity> memoList;
private RecyclerView recyclerView;
private MemoDAO memoDAO;

...

되어 있던 코드를 AppCompatActivity 말고 Activity를 상속 받도록 변경해서 해결 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...

import android.app.Activity;

...

@SuppressLint(value = "StaticFieldLeak")
public class MainActivity extends Activity {

private MemoDatabase db;
private List<MemoEntity> memoList;
private RecyclerView recyclerView;
private MemoDAO memoDAO;

...

여담

둘의 정확한 차이는 모르겠지만, AppCompatActivity가 이전 안드로이드버전까지 커버해주는 클래스이고, Activity는 최신버전만 지원한다고 하는데… 이 문제랑 무슨 상관이 있는지 필자의 좁은 식견으로는 알 수가 없었기에 문제 해결에 도움을 받은 스택오버플로우의 글을 링크하며 글을 마무리 한다. You need to use a Theme.AppCompat theme (or descendant) with this activity


참고 사이트

댓글 공유

Junggu Ji

author.bio


author.job