서론

오늘도 어김없이 영한님의 스프링 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


참고 사이트

댓글 공유

서론

평소와 다름없이 유튜브를 배회하던 중 재난급 서버 장애내고 개발자 인생 끝날뻔 한 썰이란 영상을 보게 되었는데… ‘나는 지금 어떻게 하고 있지..?’ 라는 생각이 문득 들어서 얼른 나도 백업을 수시로 해둬야겠다는 생각에 적용한 mysql backup 방법을 남겨둔다.


왜 mysqldump로 했어요??

mysqldump는 스토리지 엔진에 관계없이 논리 백업을 수행 할 수 있는 도구다.
무엇을 이용해서 backup을 해야할까 고민 하던 중 장애와 관련된 XtraBackup 적용기를 보게 되었는데
데이터 사이즈가 크지 않다면 mysqldump를 사용하는 것이 간단하며 복원 시에도 신경 써야 할 것이 적다는 내용을 보고 mysqldump로 backup을 진행하기로 결정했다.

그럼 어떻게 해요??

mysqldump엔 여러가지 옵션들이 있지만 그 중 필자는 아래 처럼 사용 했다.

1
2
mysqldump --single-transaction --databases [db명] --tables [테이블명] -h [db주소] -u [username] -p | gzip > [파일명].gz

mysqldump option

  • –single-transaction : lock 을 걸지 않고도 dump 파일의 정합성 보장하는데 InnoDB 테이블이 아닌 MyISAM or MEMORY 테이블인 경우에는 여전히 상태가 변경 될 수 있다.
    MySQL에선 큰 테이블을 덤프하려면 –quick 옵션과 결합하기를 권장한다.
  • –databases : dump 할 db명을 지정한다. 여러 개를 한번에 지정하는 것도 가능하다.
  • –tables : dump 할 table명을 지정한다. 마찬가지로 여러 개를 한번에 지정 할 수 있다.

그리고 필자는 테이블 별로 데이터를 gzip 형식으로 압축하기 위해 gzip 명령어를 사용했다.

그 결과

명령어를 실행하면 이 처럼 해당 경로에 .gz로 압축된 파일이 생성되고 이 파일의 압축을 해제하면 sql 파일이 된다.

예외 상황

mysqldump는 mysql client 유틸리티 이므로 당연히 mysql client가 있어야 실행 가능한데 아래 메시지는 그 mysql client가 설치되지 않았으니 설치 한 후 사용하라는 메시지이다.

1
2
3
4
5
6
The program 'mysqldump' can be found in the following packages:
* mysql-client-5.5
* mariadb-client-5.5
* mysql-client-5.6
* percona-xtradb-cluster-client-5.5
Try: sudo apt-get install <selected package>

이 처럼 메시지가 출력 될 경우 위 리스트 중 하나를 설치하면 정상적으로 mysqldump가 이용 가능해 질 것이다.


shell script로 작성하기

이제 이 명령어를 스케줄러에 등록하기 위해 우선 쉘 스크립트 파일로 작성해야 하는데 필자 같은 경우는 아래와 같이 작성해서 사용하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
HOST=host명
USER=user명
PASSWORD=password
DATABASE=db명
TABLES=(
table1
table2
table3
...
)

TABLES_STRING=''
for TABLE in "${TABLES[@]}"
do :
sudo mysqldump --single-transaction --databases ${DATABASE} --tables ${TABLE} --host ${HOST} --user ${USER} --password ${PASSWORD} | gzip > /home/backup/${TABLE}.sql.gz
done

이렇게 작성한 sh 파일을 bash 명령어로 실행하면 테이블 갯수만큼 비밀번호를 입력하면 백업 작업을 정상적으로 완료하게 된다… (정말?)


crontab을 자동 backup

이제 백업을 자동으로 실행하게 하기 위해서 리눅스에 존재하는 crontab을 이용하기로 한다.

crontab 등록 방법

리눅스 서버에 접속해서

1
crontab -l

명령어를 실행하면 아래 처럼 해당 유저로 서버에 등록된 스케줄들이 보이게 되는데 해당 유저로 등록된 서비스가 없다면 아래 처럼

1
2
user명@ip-127.0.0.1:~$ crontab -l
no crontab for user명

메시지가 출력되고 해당 유저로 등록된 서버스가 존재 할 경우엔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
user명@ip-127.0.0.1:~$ crontab -l
# Edit this file to introduce tasks to be run by cron.
#
# Each task to run has to be defined through a single line
# indicating with different fields when the task will be run
# and what command to run for the task
#
# To define the time you can provide concrete values for
# minute (m), hour (h), day of month (dom), month (mon),
# and day of week (dow) or use '*' in these fields (for 'any').#
# Notice that tasks will be started based on the cron's system
# daemon's notion of time and timezones.
#
# Output of the crontab jobs (including errors) is sent through
# email to the user the crontab file belongs to (unless redirected).
#
# For example, you can run a backup of all your user accounts
# at 5 a.m every week with:
# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/
#
# For more information see the manual pages of crontab(5) and cron(8)
#
# m h dom mon dow command
10 04 * * * bash 백업 실행 파일.sh

위 처럼 cat 명령어를 실행 한 것 처럼 콘솔에 crontab의 내용이 보여지게 된다.
이렇게 저장된 스케줄러들을 확인난 후에

1
crontab -e

명령어를 통해 스케줄러를 등록하면 되는데 이 때 crontab -e 명령어를 실행시키면 crontab을 열 텍스트 에디터를 선택하라는 메시지가 출력되거나, 디폴트로 지정된 텍스트 에디터로 crontab이 열리게 된다.

어떤 식이든 crontab이 열리면

1
2
3
4
5
6
7
8
9
* * * * *  수행할 명령어
┬ ┬ ┬ ┬ ┬
│ │ │ │ │
│ │ │ │ │
│ │ │ │ └───────── 요일 (0 - 6) (0:일요일, 1:월요일, 2:화요일, …, 6:토요일)
│ │ │ └───────── 월 (1 - 12)
│ │ └───────── 일 (1 - 31)
│ └───────── 시 (0 - 23)
└───────── 분 (0 - 59)

위 형식에 맞춰 언제마다 실행 시킬 지 정한 후 우리가 만든 backup용 sh 파일을 실행하게 하면 된다.

필자는 매일 04시 10분에 실행 시키기 위해 아래와 같이 작성하여 등록했다.

1
10 04 * * * bash 백업 실행 파일.sh

이렇게 작성하고 테스트를 하기 위해 date 명령어로 서버 시간을 확인한 후 실행 시간을 현재 시간에서 2분 뒤로 변경하여 2분 뒤에 실행하게 하였는데…


왜 백업이 안되지…?

이미지에서 보이는 것 처럼 파일 사이즈가 20 바이트 밖에 안되길래 확인해보니 백업이 정상적으로 이루어지지 않은 것 이었다.

왜 이렇게 됐을까를 고민하던 중… 스케줄러에 등록하기 전 sh파일을 단독으로 실행 했을 때 table 갯수만큼 비밀번호를 입력했던 것이 떠올라서 그 부분이 문제일 것이라고 판단되어 찾아보니 공식 문서에서 아래와 같은 글을 확인 할 수 있었다.

Use the –password or -p option on the command line with no password value specified. In this case, the client program solicits the password interactively:

shell> mysql -u francis -p db_name
Enter password: ****
The * characters indicate where you enter your password. The password is not displayed as you enter it.

It is more secure to enter your password this way than to specify it on the command line because it is not visible to other users. However, this method of entering a password is suitable only for programs that you run interactively. If you want to invoke a client from a script that runs noninteractively, there is no opportunity to enter the password from the keyboard. On some systems, you may even find that the first line of your script is read and interpreted (incorrectly) as your password.

정리하면 —password 옵션은 클라이언트에 대화식으로 암호를 요청하고, 이 경우 비대화형으로 실행되는 스크립트에서 클라이언트를 호출 할 경우 암호를 입력 할 기회가 없으므로 일부 시스템에서 잘못 해석 될 수도 있다는 내용이었다.

그래서 어떻게 해야되요…

공식 문서를 보면 .my.cnf파일을 만들어서 거기에

1
2
[client]
password=password

아래와 같은 형식으로 지정하고 안전을 위해 파일의 엑서스 모드를 400 또는, 600으로 설정하라고 한다.

그 후 mysqldump에서 지정한 설정 파일을 읽어들이게 하기 위해

1
shell> mysql --defaults-file=/home/francis/mysql-opts

처럼 파일의 전체 경로를 지정하라고 설명하고 있다.

바뀐 sh파일 내용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
HOST=host명
USER=user명
DATABASE=db명
TABLES=(
table1
table2
table3
...
)

TABLES_STRING=''
for TABLE in "${TABLES[@]}"
do :
sudo mysqldump --single-transaction --databases ${DATABASE} --tables ${TABLE} --host ${HOST} --user ${USER} | gzip > /home/backup/${TABLE}.sql.gz
done

—password 옵션을 제외하고 password가 my.cnf 설정파일에 들어갔으므로 최종적인 sh 파일 내용은 위와 같이 된다.

결과

모든 난관을 극복하고 다시 crontab -e에 등록해서 스케줄을 실행한 결과 위와 같이 정상적으로 백업을 완료하여 파일 사이즈가 아까와 다르게 20 byte가 아닌 모습을 볼 수 있다.


P.S : 저는 그래도 안되는데요…?

필자의 경우에도 .my.cnf 파일을 생성하고 등록해줬음에도 불구하고 백업이 정상적으로 진행되지 않았는데 결국 찾은 해결 방법은 .my.cnf 파일을 따로 생성하지 않고, 아래와 같이 /etc/mysql/my.cnf 파일에 password를 추가하는 방법으로 해결 했다.

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
...

# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html

# This will be passed to all mysql clients
# It has been reported that passwords should be enclosed with ticks/quotes
# escpecially if they contain "#" chars...
# Remember to edit /etc/mysql/debian.cnf when changing the socket location.
[client]
port = 3306
socket = /var/run/mysqld/mysqld.sock
password = password # !!! 이 부분 !!!
# Here is entries for some specific programs
# The following values assume you have at least 32M ram

# This was formally known as [safe_mysqld]. Both versions are currently parsed.
[mysqld_safe]
socket = /var/run/mysqld/mysqld.sock
nice = 0

...

필자와 같은 경우도 아니라면… 행운을 빈다…


참고 사이트

댓글 공유

서론

기존에 jar파일을 추가해서 Junit4로 테스트하던 프로젝트를 마찬가지로 jar파일을 Junit5로 버전 업해서 사용하고 있었는데
‘굳이 이럴 필요없이 maven 프로젝트로 변경하면 되지 않나?’라는 생각에 maven 프로젝트로 변경 후 발생했던 에러를 해결한 방법을 작성해둔다…

상황

위에서 적은 것 처럼 기존에 Junit jar를 다운받아서 직접 경로를 지정하는 방식으로 junit4, 5를 둘 다 사용하고 있었는데 프로젝트를 maven으로 변경 후 의존성 충돌이 발생해 아래와 같은 에러가 발생하며 테스트가 진행되지 않았다.

발생한 에러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Internal Error occurred.
org.junit.platform.commons.JUnitException: TestEngine with ID 'junit-vintage' failed to discover tests
at org.junit.platform.launcher.core.EngineDiscoveryOrchestrator.discoverEngineRoot(EngineDiscoveryOrchestrator.java:111)
at org.junit.platform.launcher.core.EngineDiscoveryOrchestrator.discover(EngineDiscoveryOrchestrator.java:85)
at org.junit.platform.launcher.core.DefaultLauncher.discover(DefaultLauncher.java:92)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:75)
at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:71)
at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:220)
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:53)
Caused by: org.junit.platform.commons.JUnitException: Unsupported version of junit:junit: 3.8.1. Please upgrade to version 4.12 or later.
at org.junit.vintage.engine.JUnit4VersionCheck.checkSupported(JUnit4VersionCheck.java:49)
at org.junit.vintage.engine.JUnit4VersionCheck.checkSupported(JUnit4VersionCheck.java:35)
at org.junit.vintage.engine.VintageTestEngine.discover(VintageTestEngine.java:62)
at org.junit.platform.launcher.core.EngineDiscoveryOrchestrator.discoverEngineRoot(EngineDiscoveryOrchestrator.java:103)
... 7 more

Process finished with exit code -2

위 에러 로그처럼 ‘junit-vintage’를 아이디로 하는 테스트 엔진을 찾지 못했다는 에러가 발생해서 junit-vintage 의존성을 추가해주었는데..


그 후 삽질

1
2
3
4
5
6
7
<!-- https://mvnrepository.com/artifact/org.junit.vintage/junit-vintage-engine -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.7.1</version>
<scope>test</scope>
</dependency>

추가 해줘도 똑같은 에러만 반복 할 뿐이었다.

그래서 공식 문서를 살펴보니..

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

라고 떡하니 써 적혀있어서 위에 3 개의 의존성을 모두 추가한 후 리빌드 후 다시 테스트를 실행해봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- Only needed to run tests in a version of IntelliJ IDEA that bundles older versions -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<version>1.7.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.7.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.7.1</version>
<scope>test</scope>
</dependency>

하지만 결과는 마찬가지였고… .m2폴더에서 Junit 의존성을 다 삭제하고, Intellij project setting에서 Modules, Libraies에서 모두 삭제하고 이것저것 다 해봤지만 모두 실패로 돌아가서 결국 새 maven 프로젝트를 만들어서 하나씩 비교해보기로 했다.


해결

그 결과… .iml 파일에 아래 처럼 의존성들이 남아있는 것을 확인했고…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<orderEntry type="module-library" scope="TEST">
<library name="JUnit5.4">
<CLASSES>
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter/5.4.2/junit-jupiter-5.4.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter-api/5.4.2/junit-jupiter-api-5.4.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/apiguardian/apiguardian-api/1.0.0/apiguardian-api-1.0.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/opentest4j/opentest4j/1.1.1/opentest4j-1.1.1.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/platform/junit-platform-commons/1.4.2/junit-platform-commons-1.4.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter-params/5.4.2/junit-jupiter-params-5.4.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter-engine/5.4.2/junit-jupiter-engine-5.4.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/platform/junit-platform-engine/1.4.2/junit-platform-engine-1.4.2.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</orderEntry>
<orderEntry type="library" scope="TEST" name="Maven: org.junit.jupiter:junit-jupiter-api:5.7.1" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.apiguardian:apiguardian-api:1.1.0" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.opentest4j:opentest4j:1.2.0" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.junit.platform:junit-platform-commons:1.7.1" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.junit.jupiter:junit-jupiter-engine:5.7.1" level="project" />
<orderEntry type="library" scope="TEST" name="Maven: org.junit.platform:junit-platform-engine:1.7.1" level="project" />

모든 의존성을 삭제 후 다시 Junit5만 의존성 추가하여 테스트를 실행하니…

드디어 성공적인 테스트를 완료 할 수 있었다.

여담

project setting에서 분명 모듈 관련된 설정을 모두 삭제 했는데 왜 .iml파일에 설정이 삭제되지 않고 남아있는진 모르겠지만, 필자와 같은 상황이 발생하는 다른 사람들에게 도움이 되길 바라며 글을 마무리한다.

댓글 공유

문제

https://www.acmicpc.net/problem/2485


코드

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
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

List<Integer> differences = new ArrayList<>();
List<Integer> list = new ArrayList<>();

int preValue = 0;

int n = Integer.parseInt(br.readLine());
while (n-- > 0) {
int input = Integer.parseInt(br.readLine());
list.add(input);

if (preValue != 0) {
differences.add(Math.abs(preValue - input));
}

preValue = input;
}

Collections.sort(list);

int gcd = Integer.MAX_VALUE;
for (int i = 1; i < differences.size(); i++) {
int temp = gcd(differences.get(i-1), differences.get(i));
gcd = temp > gcd ? gcd : temp;
}

int answer = 0;
int pre = list.get(0);
for (int i = 1; i < list.size(); i++) {
if (list.get(i) - pre != gcd) {
answer += ((list.get(i) - pre) / gcd) - 1;
}
pre = list.get(i);
}

System.out.println(answer);
}

private static int gcd(int a, int b) {
return b!=0 ? gcd(b, a%b) : a;
}

흐름

  1. 입력 받은 값들을 뺀 값의 차를 구한 값으로 약수를 구하기 위한 differences,
    입력 받은 값을 그대로 저장하는 list 변수 생성.
  2. 두 리스트 모두 n 만큼 돌면서 값을 저장하는데
  3. differences의 경우 첫 번쨰 루프에서 값을 저장 할 수 없으므로(2 번쨰 값부터 첫 번째 값과 뺄 수 있으므로) 이전 값이 있는 경우에만 리스트에 저장한다.
  4. 가로수들 사이에 끼워넣어야 되는 가로수의 개수를 구해야 하므로 입력 받은 가로수의 위치를 정렬한다.
  5. 가로수 간의 최소 간격을 구하기 위해 아까 저장한 가로수 간의 차이를 구한 리스트 differences를 돌면서 유클리드 호제법을 이용해 최대공약수를 구하고, 구한 최대 공약수 중 가장 작은 값을 저장한다.
  6. 가로수 간의 차이값에서 최대 공약수를 구하는 이유는,
  7. 문제가 가로수를 일정한 간격으로 최대한 적게 심기게 하기 위한 문제 이므로 두 수에서 공통되는 값들 중 가장 큰 수인 최대 공약수를 구하고,
  8. 그 최대 공약수들 중 가장 작은 값이어야 모두 일정한 간격으로 심을 수 있으므로 최대 공약수 중 가장 작을 값을 구한다.
  9. 이렇게 잘 구한 값으로 이제 몇 개의 가로수를 더 심어야 하는지 알아내야 하므로
  10. 아까 정렬시킨 가로수 리스트를 반복 하면서 이전 가로수 와의 간격이 최대 공약수 보다 큰 녀석들이 있으면
  11. 그 녀석들 사이에 가로수를 심어야 되므로,
  12. 구한 간격을 최대 공약수로 나눈 값에서 -1 한 값을 더한다.
  13. 이 때 -1을 하는 이유는 두 수의 간격이므로 하나는 이미 심어져 있으니 -1 을 한다.
  14. 그렇게 추가로 심어야 하는 가로수의 개수를 모두 구하면
  15. 끝.

결과


댓글 공유

Junggu Ji

author.bio


author.job