반응형

1. 개요

 JWT는 인증, 인가에 사용하는 Json 형식의 토큰이고, 인코딩되어있다 정도로만 알고있었으나 이번 멘토링 과제에 적용하기 위해 공부해보니 자세해서 정리된 글들이 많이 보였다. 열심히 구글링하여 이해한 내용들과 필자가 궁금한 점들을 정리해보았다.

 

 


2. JWT란?

 JWT는 Json Web Token의 약자로 '웹에서 사용되는 Json 형식의 토큰'이다. 토큰에는 사용자의 권한 및 기본 정보, 서명 알고리즘 등이 포함되어 있다. 개인정보는 저장하지 않는데, 이유는 정보성 데이터가 저장되는 Payload는 쉽게 조회할 수 있기 때문이다.

 JWT는 서버에 저장되지 않고 클라이언트에서 저장하기 때문에 서버의 메모리 부담을 덜 수 있다. 이처럼 서버에 상태값을 저장하지 않는 것을 무상태(Stateless)라 하며 JWT는 이 무상태 성질을 갖는다.

 

JWT 사용 프로세스는 다음과 같다.

 1. 로그인을 성공했을 때 JWT를 생성하여 클라이언트에게 응답해준다.

 2. 클라이언트는 요청마다 Authrization 헤더에 Bearer 타입으로 JWT 값을 넣어 서버로 보낸다.

 3. 서버는 JWT 값을 검증하여 요청 처리 여부를 결정한다.

 


3. JWT 구조

 JWT는 Header, Payload, Signature로 구성되며, 각 부분은 온점(.)으로 구분된다.

 Header에는 토큰의 유형과 서명 알고리즘, Payload에는 권한 및 기본 정보, Signature에는 Header와 Payload를 Base64로 인코딩 한 후 Header에 명시된 해시 함수를 적용하고, 개인키로 서명한 값(이를 전자서명이라고 한다)이 담겨있다. Signature를 통해 토큰에 대한 위변조 여부를 체크할 수 있다.

 

 아래 이미지는 JWT 에 대한 인코딩, 디코딩 데이터를 확인할 수 있는 jwt.io 라는 사이트에서 제공하는 예제를 캡쳐한 것이다. 빨간색 부분이 Header, 보라색 부분이 Payload, 하늘색 부분이 Signature에 해당한다. 

jwt.io

 

 


4. 토큰은 암호화 된거 아냐?

 앞서 Payload는 쉽게 조회할 수 있어 개인정보를 저장하지 않는다고 했었는데, 위 사진을 보면 '어딜봐서 쉽게 조회할 수 있다는거지? 딱 봐도 암호화 되어있는것 같은데?' 라고 생각할 수 있다. 하지만 Header와 Payload는 Base64 URL-safe Encode 형식으로 인코딩되어있을 뿐이고 Signature 만 암호화 되어있다.

 아래는 실제 Header와 Payload를 base64 디코딩 사이트에서 디코딩한 결과이며, 토큰 값은 인코딩 값이라는 것을 알 수 있다. (인/디코딩 사이트 https://www.convertstring.com/ko/EncodeDecode/Base64Decode)

구분 토큰 값 디코딩 값
Header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 {"alg":"HS256","typ":"JWT"}
Payload eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG5Eb2UiLCJpYXQiOjE1MTYyMzkwMjJ9 {"sub":"1234567890","name":"JohnDoe","iat":1516239022}

 

 


 

5. 토큰의 인가 정보를 바꾸면?

 지금은 Payload에 sub, name, iat와 같은 값들이 들어있으나 일반적으로 "role":"admin" 혹은  "role":"user"와 같은 권한 정보도 포함시킨다. 그런데 여기서 다음과 같은 궁금증이 생겼다. 권한정보를 임의로 바꾼다면 admin 권한도 갖을 수 있지 않을까?

 클라이언트가 응답 받은 JWT의 Payload 값을 디코딩한 후 role에 대한 value값을 user 에서 admin으로 바꾼 후 다시 인코딩하여 서버에게 전달하면 admin 권한을 갖는 것과 동일하게 처리될지 궁금했다. 어차피 서버에서도 인증 정보를 따로 저장하지 않기 때문이다.

결론은 그딴 상술은 통하지 않는다 였다.

 JWT의 세번째 항목인 Signature가 토큰의 위변조를 체크하기 때문이다. Signature는 Header와 Payload를 Base64로 인코딩 한 후 Header에 명시된 해시 함수를 적용한 값이 들어있다. 서버에서 토큰을  발급한 시점의 Header와 Payload 값에 대한 해싱 값이 Signature에 있으며, Header와 Payload 값이 위변조 됐다면 해싱 값이 일치하지 않아 위변조된 토큰임을 알아차릴 수 있다.

 

 


 

6. 클라이언트의 토큰 저장위치

JWT를 Access Token, Refresh Token으로 분리하고 refresh Token은 http Only secure 쿠키에, 액세스 토큰은 로컬변수에 저장하는 방식을 채택해야한다. 추가로 http only secure 옵션과 XSS 공격을 막는 필터를 추가해야한다. 여기서 액세스 토큰은 인가, 인증정보가 들어있는 토큰, 리프레시 토큰은 액세스 토큰을 재발급하기 위한 토큰이다.

 CSRF 공격을 막기 위해서는 쿠키에 액세스 토큰이 있어서는 안된다. 그러므로 쿠키에는 리프레시 토큰을 저장하고 액세스 토큰은 로컬변수에 저장해야한다. http only는 스크립트를 통한 쿠키 접근을 막는 옵션이고, secure는 네트워크 감청에 의한 쿠키 탈취를 막는 옵션이다. secure가 적용되어 있을 경우 https가 적용된 서버에 대해서만 통신이 가능하게 된다.

 이렇게 되면 해커가 CSRF 공격을 하더라도 쿠키에는 액세스 토큰이 없기 때문에 인증 불가 상태가 되어 요청이 차단되고, http only secure 쿠키 특성 상 리프레시 토큰 조회는 불가능하다. 액세스 토큰은 로컬 변수에 저장되어 있으나 XSS 공격을 막으므로 스크립트를 통한 접근도 불가능하다. (참고: https://pronist.dev/143)

 

 

 

 

 

반응형
반응형

1. 과제

 JWT 토큰을 사용하여 인증, 인가를 구현하라

 

2. 배운점

2.1. JWT

 JWT 토큰을 적용하여 인증 처리를 하였다. 토큰 적용 후 사용목적이나 한계점, 효과적인 토큰 사용 방법들에 대해 공부하며 JWT 토큰에 대한 개념을 확립할 수 있었다. 정리한 개념은 따로 포스팅하였다.

https://tlatmsrud.tistory.com/87

 

2.2. Interceptor에 대한 테스트 코드

 요청에 대한 권한 여부를 체크하는 방식으로 커스텀 어노테이션 방식을 채택했다.

클라이언트로부터 요청이 들어왔을 때 실행되는 컨트롤러 메서드에 커스텀 어노테이션(@CheckJwtToken)이 있을 경우 JWT 토큰을 검증하도록 하였으며 이를 위해 앞단에 Interceptor를 구현하였다.

 Interceptor의 테스트 코드 작성 시 preHandler메서드에 대한 테스트 작성이 어려웠다. HttpServletRequest, HttpServletResponse, Handler에 대한 파라미터를 테스트코드에서 어떻게 넘겨야할지 감이 안잡혔기 때문이다. 우여곡절 끝에 MockHttpServletRequest, MockHttpServletResponse, HandlerMethod로 구현하게 되었다.(멘토님 감사합니다.)

HandlerMethod로 구현한 이유
 디스패처 서블릿은 애플리케이션이 실행될 때 모든 컨트롤러의 메소드를 추출한 뒤 HandlerMethod 형태로 저장해두고, 실제 요청이 들어오면 요청 조건에 맞는 HandlerMethod를 참조하여 해당 메서드를 실행시킨다.
 실제 API에 대한 요청이 들어올 경우 Interceptor의 preHandler 메서드 파라미터인 handler에 HandlerMethod가 들어오기 때문에 HandlerMethod로 구현하였다.

 

HandlerMethod 생성자 파라미터는 beanObject, methodName, parameter로 구성되는데, 테스트 클래스에 MockHandler 클래스를 하나 만들어 아래과 같이 구현하고, beanObject는 테스트 클래스를, methodName에는 @CheckJwtToken가 포함된 메서드 명을 기재하였다. 이로써 Interceptor에 대한 테스트코드를 작성할 수 있었다. 아래는 MockHandler 클래스와 preHandler 메서드에 대한 테스트 코드이다.

class MockHandler{
	@CheckJwtToken
    public void handleRequest(){

    }
}

 

@Test
	void withInvalidTokenReturnFalse() throws Exception {
		MockHttpServletRequest request = new MockHttpServletRequest();
 		MockHttpServletResponse response = new MockHttpServletResponse();

		HandlerMethod handler = new HandlerMethod(new MockHandler(), "handleRequest");

		request.setMethod("GET");
		request.addHeader("Authorization", "Bearer "+INVALID_TOKEN);

		// 유효하지 않은 토큰일 경우 PreHandle 메서드에서 예외가 발생하며 최종적으로 false를 리턴함.
		boolean result = loginCheckInterceptor.preHandle(request, response, handler);

		assertThat(result).isFalse();
 }

 

2.3. ParameterizedTest

 테스트 메서드의 로직은 동일하나 특정 변수 값만 다른 테스트 케이스가 있다. 예를들어 유효하지 않은 토큰 검증에 대한 테스트 시 토큰의 요청 값으로 null, 빈 값, 쓰레기값이 들어갈 수 있는데, 이를 각각 테스트하기 위해 다음과 같이 3개 메서드로 구현할 수 있다.

void parseTokenWithBlankToken(){
	assertThatThrownBy(() -> authenticationService.parseToken(" "))
  		.isInstanceOf(InvalidTokenException.class);
}

void parseTokenWithNullToken(){
	assertThatThrownBy(() -> authenticationService.parseToken(null))
  		.isInstanceOf(InvalidTokenException.class);
}

void parseTokenWithInvalidToken(){
    // INVALID_TOKEN 은 실제 유효하지 않은 토큰을 담은 static 변수임.
	assertThatThrownBy(() -> authenticationService.parseToken(INVALID_TOKEN))
  		.isInstanceOf(InvalidTokenException.class);
}

위의 경우 테스트 로직은 동일하나 토큰 값이 달라서 각각의 메서드를 만들어 처리하였는데, 이처럼 특정 변수 값만 다르고 로직이 동일할 경우 ParameterizedTest를 통해 하나의 메서드로 테스트 가능하다.

@ParameterizedTest
    @ValueSource(strings = { INVALID_TOKEN, "", null})
    void parseTokenWithInvalidTokens(String token){
        assertThatThrownBy(() -> authenticationService.parseToken(token))
                .isInstanceOf(InvalidTokenException.class);
    }

 

3. 느낀점

 실무에서 JWT 토큰 적용을 검토했던 적이 있다. 적용을 목적으로 했던거라 개념적인 내용을 많이 건너뛰었던 것 같았는데 이번 기회에 JWT 토큰에 대한 기본 개념, 사용 이유, 보안상 이점, 한계점과 같은 것들을 공부하게 되어 좋았다. 

 테스트 코드는 Controller, Service, Repository 정도로만 구현을 하다가 이번에 처음으로 Interceptor를 구현하게 되었는데 처음 해보는거라 감이 아예 잡히지 않았었다. prehandle 메서드 검증을 위해 파라미터를 채우는 방법을 몰랐기 때문이었는데, 멘토님께서 도와주시어 해결할 수 있었다.

 이제 2주 남았다. 남은 2주도 미루지 않고 잘 해보자!

반응형
반응형

1. 과제

 TDD 기반의 회원 관리 REST API를 구현하라.

 

2. 배운점

2.1. Validation

 하나의 DTO에 대해 상황에 맞는 Validation을 체크해야하는 부분이 있었다. 예를들어 유저 최초 생성시에는 모든 필드에 대한 NotBlank를 적용하고, 수정시에는 몇몇 필드에 대한 NotBlank를 적용하는 것과 같은 부분이다.

 사내 프로젝트에 적용할때는 DTO Class를 inner Class로 관리하여 Create, Update Class를 생성하고 각각에 대해 Validation을 적용했었는데 찾아보니 Validation Group을 지정하는 방법이 있어 이를 적용해보았다.

 적용해본 결과 그 방법이 간단하지 않고, 결과적으로 하나의 DTO에 여러 기능들이 응집되어 있는 형태가 되었다. 만약 Validation이 더 추가된다면 거대한 DTO가 될 우려가 있다는 멘토님의 조언을 받을 수 있었다. Validation은 InnerClass가 좋은 방법인 것 같다.

 

2.2. 테스트 커버리지의 목적

 과제가 끝나면 테스트 커버리지를 체크한다. 커버리지를 100%로 맞추려하다보니 비지니스 로직이 없는 DTO도 테스트 클래스를 별도로 만들게 되었는데 이렇게 하다보면 결국 '기존 코드와 테스트코드를 1:1비율로 만들게 되겠는데?' 라는 우려가 들기 시작했다. 테스트 커버리지가 100%가 아니라는 건 뭔가 헛점이 있는 어플리케이션이라는 생각이 들었기 때문이다. 이에 대해 멘토님께서 좋은 참고자료를 주셨는데, 글을 읽어보니 필자처럼 100% 커버리지에 너무 신경쓰는 사람들에 대한 따끔한 조언의 포럼이었다.

 테스트 커버리지의 목적은 '테스트 되지 않는 부분이 있는지 확인'하기 위함이다. DTO의 경우 테스트가 필요 없는 부분이므로 굳이 이에대한 테스트코드를 작성할 필요가 없었다.

 

2.3. Fixture 관리

 테스트 코드 작성 중 중복되는 고정 데이터를 Fixture라고 한다. 예를들어 Update를 위한 객체가 이에 해당하는데 다음과 같이 Builder나 new 생성자를 통해 생성하게 된다.

UserData userData = UserData.builder()
                .name("앙김홍집")
                .email("hongzip@naver.com")
                .password("123123")
                .build();

 BDD 테스트의 경우 사용자의 여러 행동을 예측하여 테스트 코드를 작성하게 되는데 이럴 경우 하드코딩이 들어간 이 로직이 중복되게 되는데 이 경우 Enum을 통해 깔끔하게 관리할 수 있다.

 

public enum UserFixture {

    UPDATE_USER(1L, "앙김홍집","hongzip@naver.com","123123"),
    CREATE_USER(2L, "앙생성집","sangzip@naver.com","12345");

    private final Long id;
    private final String name;
    private final String email;
    private final String password;

    private UserFixture(Long id, String name, String email, String password){
        this.id = id;
        this.name = name;
        this.email = email;
        this.password = password;
    }

    public UserData getUserData(){
        return new UserData(id, name, email, password);
    }

}

 

위와같이 Enum을 만들어 놓고 중복되는 부분에서 호출만해주면 된다. 이를 통해 고정 데이터를 관리할 수 있고, 하드코딩으로 인한 문제도 막을 수 있다.

@Test
@DisplayName("UserNotFoundException 예외를 던진다")
void throwsUserNotFoundException() {
	UserData userData = UserFixture.UPDATE_USER.getUserData();

	assertThatThrownBy(() -> userService.updateUser(INVALID_ID, userData))
		.isInstanceOf(UserNotFoundException.class);
}

 

3. 느낀점

 코드를 짜면서 들었던 찝찝함, 궁금증들을 모두 풀수있어 좋았다. Validation의 경우도 실무에서 적용해보았으나 그 방식에 대해 '이렇게 하는게 과연 맞나?' 라는 찝찝함도 이번 기회를 통해 해소할 수 있었다. 또한 멘토분께서 던져주시는 용어들에 대해 알아가는 과정이 너무 좋았다.

  사실 테스트 코드를 작성하는 건 아직 익숙하지 않다. 먼저 테스트코드를 작성하고 리팩토링하고 로직을 적용하는 방식이 너무 생소하지만 계속 하다보면 이것도 몸에 배겠지? 예전 자바지기님의 TDD 강의를 잠깐 들은 적이 있는데 그분께서 하신 말씀이 생각난다. TDD는 운동처럼 계속 해야한다고, 하다보면 자연스럽게 하게된다고.

반응형
반응형

1. 과제

 지금까지 배운 내용을 토대로 '고양이 장난감 가게' 어플리케이션을 개발하라.

 

2. 배운점

2.1. 계층별 단위테스트 방법

 - 계층별 단위테스트 방법을 알게되었다. 일반적으로 각 계층은 하위 계층에 의존적이다. 클린 아키텍쳐로 구성한 고양이 장난감 가게는 Web,  Controllers, Use Cases, Entites 계층으로 구성되었으며, 계층별 단위테스트 방법을 익혔다.

출처 : http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

1) Web

 mockMvc 와 Mock Service(Use Cases)로 테스트하였다. mockMvc는 서블릿 컨테이너의 구동 없이 HTTP 서블릿 요청과 응답을 제공하는 유틸리티 클래스이고, Mock Service는 Controller가 의존하는 Service를 Mocking 한 객체이다. Web 어플리케이션의 REST API 호출 테스트가 목적이기에 실제 Service 로직을 확인할 필요가 없으므로 Mocking한다.

 

2) Controllers

 Controller와 Mock Service(Use Cases)로 테스트하였다. 위와 마찬가지로 Controller 테스트가 목적이기에 Service를 Mocking 하였다. Controller가 의존하는 Service에 대해서는 메서드 호출 여부만 확인하면 된다. 확인이 필요한 메서드는 verify 메서드를 통해 확인하였다.

 초기 테스트 코드에는 Controller가 호출하는 모든 Service 메서드에 대해 verify 검증을 하였으나, Mock Service의 메서드에 Stubbing을 하여 응답 지정하고, 테스트를 통해 이 값을 받는 행위 자체가 Service의 메서드를 호출한 것이므로 메서드 검증을 할 필요가 없었다. 해서 응답 값이 없거나, 확인이 어려운 메서드에 대해서만 verify로 검증하였다.

 

3) Use Cases(Service or Application)

 Service와 Mock Repository로 테스트하였다. Service 테스트가 목적이기에 의존하는 Repository를 Mocking하였다. 마찬가지로 응답값이 없거나, 확인이 어려운 메서드에 대해서만 verify로 검증하였다.

 

4) Entities

 Repository와 서버 방식의 h2 DB로 테스트하였다. JPA를 사용하는 경우 JPA에서 지원하는 CrudRepository 같은 클래스를 상속받아 가져다 쓰므로 사실 검증할 필요가 없다. 단, 직접 쿼리를 작성할 경우에는 검증이 필요하다.

 

3. 느낀점

 비록 매우 단순하지만 클린 아키텍쳐가 적용된 프로그램의 단위 테스트코드를 작성해봄으로써 테스트코드에 대한 메커니즘을 이해할 수 있었다.

 필자는 이번 과제 중 Repository에 대한 테스트가 계속 실패하는 이슈가 있었다. 도저히 이해가 되지 않아 필자가 모르는 JPA의 영속성 컨텍스트 이슈로 판단하였고, 멘토님께 도움을 요청하였다.

 원인은 단순 테스트 데이터를 Insert하는 부분이었으며, 인메모리가 아닌 실제 DB 서버 방식으로 변경 후 디버깅하며 DB 데이터를 확인한 결과 필자가 예상하지 못한 데이터가 들어있었다. 데이터가 이렇게 들어간 원인도 정말 너무 단순했다.

 멘토님의 피드백 중 Repository 테스트 방식에 대해 언급해주신 내용이 있다. DB를 인메모리 방식의 h2를 많이 사용하나 개발자가 생각한 동작과 다를 수 있다는 점에서 실제 DB 서버와 연결하여 사용하는 것을 권장한다는 것. 이 내용이 사실 와닿지 않았지만, 내가 한 짓을 통해 단박에 이해하게 되었다! 5주차도 열심히!

반응형
반응형

1. 과제

 테스트 코드를 작성하라.

2. 배운점

2.1. 테스트 코드

토이프로젝트에 TDD를 적용해볼까 하고 테스트코드에 대해 알아본 적이 있었다. 무작정 예제코드를 통해 공부를 시작했는데 개념과 방법론을 이해하지 못하니 어렵고 복잡하게만 느껴져 건너 뛴적이 있다.

 이번 주자에 테스트 코드를 작성하면서 개념과 방법론에 대해 다시 궁금해졌다. 사용하는 개념들을 정리하고 멘토님의 조언을 통해 TDD의 필요성을 조금은 이해하게 된것같다. 그중에서도 특히 이해가지 않던 Mock Object. 가짜객체에 대한 개념과 사용 이유를 확실히 이해하게 되었다. 이 내용은 현재 정리중이며 어느정도 정립이 되었을 때 블로그에 올리도록 하겠다.

 

2.1. given.willthrow에 대한 문제

 예외를 설정하는 stubbing 메서드인 given.willthrow를 호출했을 때 실제로 해당 예외가 발생하는 이슈가 있었다.

 원인은 동일한 상황에 대한 stubbing 케이스에 대해 given.willthrow 구문이 두번 실행될 경우 발생했다.

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
    ...
    
        @Nested
        @DisplayName("Task 상세조회 테스트")
        class TaskDetailTest{
            
            @BeforeEach
            void setUp(){
                ...
                
                   given(taskService.getTask(INVALID_ID)).willThrow(new TaskNotFoundException(INVALID_ID));
          }
 
            @Test
            @DisplayName("유효 ID 상세조회")
            void detailWithValidId() throws Exception {
                ...
            }
 
            @Test
            @DisplayName("유효하지 않는 ID 상세조회")
            void detailWithInvalidId() throws Exception {
 
                mockMvc.perform(get("/tasks/"+INVALID_ID))
                        .andExpect(status().isNotFound())
                        .andExpect(content().string("{\"message\":\"Task not found\"}"));
            }
        }
cs

TaskDetailTest에 대한 Junit 테스트를 실행할 경우 detailWithValidId, detailWithInvalidId 메서드에 대한 테스트를 각각 진행하게 되는데 @BeforEach 어노테이션으로 인해 한 메서드의 테스트가 시작할때마다 setUp() 메서드를 호출하게 됐고, 두번째 테스트 시 호출되는 setUp() 의 given절에서 TaskNotFoundException 이 발생하게 됐다.

다양한 테스트를 통해 실제 예외가 발생한 구간을 알아내었다.

 

1. setUp() 메서드 호출

2. given() 메서드를 통해 taskService.getTask에 대한 stubbing 객체 생성

3. willThrow를 통해 stubbing에 대한 가짜 예외 생성

4. detailWithValidId 테스트 코드 실행

5. setUp() 메서드 호출

6. given() 메서드를 통해 taskService.getTask에 대한 stubbing 객체 시 TaskNotFoundException  발생!!

 

즉, taskService.getTask에 대한 stubbing이 정해진 상태에서 다시 given() 을 통해 해당 taskService.getTask()에 대한 stubbing을 생성하려 하나 이미 TaskNotFoundException을 갖고 있기에 해당 값이 반환되고 있었다.

reset 메서드를 이용하여 taskService를 리셋시키거나, given.willthrow 구문이 한번만 실행될 수 있도록 setUp에서 detailWithInvalidId 로 이동시켜주면 된다.

 

3. 느낀점

테스트 코드를 처음 작성하며 느낀건,

첫번째, 테스트 코드는 객체 지향적이고 클린한 코드를 만들수 있도록 노력하게 해준다(?)

 단위 테스트는 객체, 메서드 단위이다. 결합도가 높거나 코드가 복잡할 경우 테스트의 범위도 커지기 때문에 다양하고 정밀한 테스트가 어려워진다. 때문에 테스트를 위해 객체지향적이고 클린한 코드를 지향하게 되는 느낌을 받았다.

 

두번째, 현업에서 TDD를 지양하는 이유

 작성한 모든 로직에 대한 테스트 코드를 작성하니 실제 로직을 작성하는 시간과 테스트 코드를 작성하는 시간이 비슷하게 들었다. 실제 서비스를 개발하는 현업에서 TDD가 포함된다면 그 공수는 배로 들지않을까.. 라는 생각과 함께 TDD를 지양하는 이유에 대해 확실히 느끼게 되었다.

 

세번째, 테스트를 코드로

 현업에서 어떠한 테스트를 한다라는 건 실제로 API를 호출해 보는 것이었다. 테스트는 로직이 개발되거나 수정됐을 때 진행했기에 코드가 수정이 될 때마다 실제 API를 호출했다. 하지만 이를 코드로 구현하니 테스트에 걸리는 시간을 많이 줄일 수 있고, 현업에서 TDD를 지양하는 이유인 '시간'이 이 상쇄될 수 있지 않을까라는 생각이 들었다. 테스트를 코드로 한다는 건 딱 들었을땐 당연한 소리같지만 실제 현업에서는 당연하지만은 않았기 때문이다. 테스트를 코드로 구현한다는 것에 다시금 많은 생각이 들었던 주차였다.

 

반응형
반응형

1. 과제

 객체지향적인 REST API를 스프링 부트로 구현하라.

 

2. 배운점

2.1. HTTP Status 를 개념있게 사용하자

 서버 오류가 발생했을 때는 500, 리소스가 없을때는 404, 성공했을 때는 200, 요청 값이 비정상적일때는 400. 개발을 하면서 내가 접했던, 그리고 아는 HTTP Status는 딱 이정도였다. 이번 과제를 진행하며 처음으로 201, 204 등의 코드를 접해보았으나 의미와 개념을 파악하지 않고 '메서드에 따라서 상태코드가 달라질 수 있구나' 라고만 생각하고 넘겼다.

알고보니 201은 요청성공 및 리소스 생성일때, 즉 Create한 요청을 보냈을 때의 상태코드이고, 204는 요청 성공했으나 응답값이 없을 때, Delete한 요청을 보냈을 때 사용 가능한 상태코드이다. 404도 클라이언트 자원(html, css, img 등)이 없을 때의 상태코드로 알고있었으나 클라이언트 자원 뿐 아니라 서버 리소스가 없을 때도 404 상태코드로 표현이 가능했다.

이를테면 id에 매핑된 정보를 수정요청할 때 서버에서 유효한 id 값인지 확인하게되는데 이 값이 없을 경우 상태코드를 404로 리턴해도 된다.

 100번 대는 정보응답, 200번 대는 성공, 300번 대는 리다이렉트 400번 대는 클라이언트 에러, 500번 대는 서버 에러로 백의자리 숫자에 따라 상태코드가 구분된다는 것도 처음 알게 되었다.

아래는 HTTP 상태코드에 대한 공식문서이다.

https://developer.mozilla.org/ko/docs/Web/HTTP/Status

 

2.2. 스프링 부트야 고맙다.

 1주차에서는 스프링 없이 Java로만 코드를 짜다보니 코드가 길어지고 시간도 많이들었다. 이번 주차에 스프링 프레임워크를 사용하니 훨씬 쉽고 깔끔한 코드를 구현할 수 있었다. 나쁘게만 보였던 스프링 부트가 조금은 착해보인다.

 

2.3. 공식문서 보는 습관을 들이자.

 람다식을 사용하니 멘토님께서 공식문서 주소를 주시며 한번 봐보라고 하셨다. 하지만 필자는 공식문서의 영어가 보이는 순간 자신감을 잃는다. 한글로 번역을 때리면 코드들도 번역이 되기에 블로그를 기웃기웃 거렸다.

 하지만 생각과 달리 공식문서에서 제공되는 예제 코드들은 정말 간단하고 이해하기 쉽게 되어있었다. 설명들은 번역기로 돌려가며 이해했고, 코드들은 쭉 코딩해보니 맥락이 이해되면서 예제코드도 이해되기 시작했다. 그리고 무엇보다 간지가 좀 나는것 같다. 공식문서를 읽는 개발자... 아무쪼록 공식문서를 보는 습관을 들여보겠다!

3. 느낀점

이번 과제는 1주차보다 빨리 끝났다. 이유는 스프링 문법을 사용했기 때문이다. 생각없이 사용했던 어노테이션과 스프링 문법들에 대해 생각의 여지를 갖게 해준 시간이었던 것 같다.

 HTTP Status 코드를 새롭게 이해하게 됐고, 공식문서를 보는 게 얼마나 중요한 것인지도 알게되었다. 뭔가 다음주차부터는 큰 고통받을 것 같아 두려운 마음이 있지만 멘토님이 있으니 걱정안한다. -ㅅ-

반응형
반응형

1. 과제

 객체지향적인 REST API를 구현하라

 

2. 배운점

2.1. '객체에게 묻기보단 시켜야 한다.'

 이번 주차에 가장 나에게 와닿았던 피드백이다. 객체가 갖고있는 정보에 대해서 해야할 일이 있을 때 단순히 그 정보를 받아온 후에 다른 객체에서 뭔가를 처리하는게 아니라 그 처리마저도 객체에게 시켜야한다는 의미였다.

처음엔 이 의미가 이해가 가지 않아 현재 내 코드를 '후임에게 업무를 맡기는 상황'에 적용해보았다.

 

 일반적으로 후임에게 업무를 맡길때  '~에 대한 A, B, C 업무를 모두 처리해주시고, 저한테 보고해주세요' 라고 시킨다. 그런데 내 코드는 '~에 대한 업무를 처리해주시되, C 업무는 저한테 주시면 제가 알아서 처리할게요' 였다. (ㅜㅠ)

객체의 멤버필드에 대한 비지니스 로직이 필요한 부분이 있었는데, 나는 해당 멤버필드를 get메서드로 가져온 후 다른 객체에서 로직을 처리하고 있었기 때문이다.

 

 만약 이 상황에서 업무 내용이 변경되거나 차질이 생긴다면 어떨까?

 전자의 경우 업무에 대한 모든 책임을 후임이 맡게된다. 후임은 작업을 하고 나는 보고받으면 된다.

 후자의 경우 A, B 업무에 대한 책임은 후임이, C 업무에 대한 책임은 내가 맡게 된다. 맡은 업무에 대해서 작업은 책임을 맡은 사람이 하며, A, B, C 모두 관련성 있는 업무이기 때문에 나와 후임 모두 사이드 이팩트를 확인하는 일을 추가적으로 해야한다. 결국 업무에 대한 응집도는 분산되어 낮아지고, 결합도는 나와 후임으로 증가하는 느낌이 들었다.

 

 '객체에게 묻기보단 시켜야 한다.' 라는 피드백이 객체지향적인 설계의 가장 기본을 지키세요 라는 의미가 아니었을까 라는 생각과 함께 자바 개발자로써의 부끄러움와 지금이라도 알게되어 다행이라는 안도감이 몰려왔다.

 

2.2. 관습을 지키자.

 코드를 짜면서 사실 언어의 관습에 대해 생각해본적이 없다. 언어별 코드 정렬 방식, 변수 네이밍 및 케이스 등.

그런데 관습을 지키지 않는다면 관습을 지키는 개발자들에게 혼란을 야기할 수 있다는 걸 알았다.

 

2.3. 변수명에 대한 고민

 변수명은 개발자가 이해하기 쉬운 이름으로 설정하자. 불필요한 축약이나 본인만 알 수 있는 네이밍을 피해야 하고, 상황에 따라 실제 사용하는 기술 명칭을 넣는 것도 방법이다.

 

배운점을 3개정도 썼지만 사실 이것말고도 아주 많다. 1번은 너무 강렬하게 다가와 꼭 정리를 하고 싶어 길게 썼다.

 

3. 느낀점

 처음 과제를 접했을 때 너무 간단하게 느껴져 하루 이틀이면 끝날것이라 생각했다.

 하지만 코드를 fork, commit, pr하는 과정부터 버벅거렸고, 코드 리뷰를 받다보니 어느새 일주일이 지나있었다. 멘토님께서 개선이 필요한 부분을 찾아주셨으나, 계속적으로 피드백이 온걸 보면 거의 지뢰찾기 게임을 하시지 않았나 싶다. 너무 감사했지만 한편으로는 조금 죄송스러웠다.

 이제 1주차인데 나의 잘못된 코딩 습관들을 고쳐야 하고, 언어에 대해 배워야할 것들이 아주 많다는 것을 느꼈다. 언어의 특성에 대해 너무 생각없이 개발해 온 지난 세월에 대해 안타까움도 느껴졌다. 이번 피드백을 머릿속에 새기고 내일부터 있을 2주차에 적용할 수 있도록 고민하며 개발할 것이다. 2주차도 열심히!

 

 

 

 

반응형
반응형

1. 개요

  • TDD 공부에 들어가기 전, 코드 리팩토링 연습을 위해 '객체지향 생활체조 9가지 원칙'을 공부하였다. 이론만 숙지하고 넘기기에는 너무 중요한 내용인것 같아 실습을 진행하였다.
  • 실습 프로그램은 초간단 홀짝게임이며, 각각의 원칙을 하나씩 적용하여 리팩토링 해나갔다.
  • 코드는 피드백을 받을 여건이 되지 않아 오로지 필자의 생각만으로 짜여져있다. 도둑놈 심보인걸 잘 알지만... 눈살이 찌뿌려지는 부분이 있다면 독자분들께서 댓글을 달아주셨으면 좋겠다. 피드백을 적극 수용할 자신이 있다.

1. 한 메서드에 오직 한 단계의 들여쓰기만 한다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.

3) 1차 코드

    public void holjjakVer1() {
        System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

        Scanner in = new Scanner(System.in);

        while(true) {

            int input = in.nextInt();

            Random random = new Random();
            int randomNumber = random.nextInt(9)+1;

            if(input > 0 && input <3) {

                int result = randomNumber%2;

                if(result == 0 && input == 2) {
                    System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
                }else if(result == 1 && input == 1) {
                    System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
                }else {
                    System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
                }

            }else {
                System.out.println("종료합니다.");
                break;
            }
        }
        in.close();
    }
  • 제 1 법칙을 생각하지 않고 평소처럼 프로그래밍 했다.

4) 리팩토링 코드

    public void holjjakVer2() {

        System.out.println("개같은 홀짝 게임!");
        System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

        Scanner in = new Scanner(System.in);
        int input = 0;

        while(inputCheck(input = in.nextInt())) {
            int randomNumber = makeRandomNumber();
            check(input, randomNumber);
        }

        in.close();
    }

    /**
     * @title 입력값 체크
     * @param input
     * @desc 사용자가 입력한 값을 체크한다. 
     * 1,2 일 경우 홀짝 게임을 계속 진행하고, 그 외일 경우 종료한다.
     */
    public boolean inputCheck(int input) {

        if(input == 1 || input == 2) {
            return true;
        }else {
            System.out.println("종료되었습니다.");
            return false;
        }
    }

    /**
     * @title 난수 생성
     * @param 
     * @dsec 1에서 10까지의 난수를 생성한다.
     */
    public int makeRandomNumber() {

        Random random = new Random();
        return random.nextInt(9)+1;
    }

    /**
     * @title 홀짝 체크
     * @param input, randomNumber
     * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
     */
    public void check(int input, int randomNumber) {

        int result = randomNumber%2;

        if(result == 0 && input == 2) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
        }else if(result == 1 && input == 1) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
        }else {
            System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
        }

    }
  1. 제 1법칙을 준수하고자 하니 자연스럽게 메서드가 분리되었다.
  2. 메서드를 분리하다 보니 자연스럽게 기능별로 분리하게 되었다.
  3. 결합도가 낮아지고, 응집도가 높아지니 코드 수정이 편해짐을 느꼈다.

5) 회고

몇줄 안되는 간단한 프로그램을 구현하고, 1 법칙을 적용시키는데에도 많은 시간이 걸렸다. 1법칙을 적용해본 결과, 메서드를 늘리는 것 자체가 가독성을 저해한다고 생각했는데 그 반대였다. 가독성은 좋아지고 메서드가 기능별로 분리되어 있어 기능에 집중하여 코드를 짤 수 있었다. 집중도도 높아지는 것을 느꼈다.

1법칙 적용 전에는 이렇게 간단한 코드로 모든 법칙을 적용할 수 있을까라는 의구심이 있었지만, 지금은 법칙을 적용시켜나갈때마다 어떻게 변할까 기대가 된다.

 


2. Else_Switch_키워드를_사용하지_않는다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.

3) 기존 코드

    public void holjjakVer2() {

        System.out.println("안신나는 홀짝 게임!");
        System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

        Scanner in = new Scanner(System.in);
        int input = 0;

        while(inputCheck(input = in.nextInt())) {
            int randomNumber = makeRandomNumber();
            check(input, randomNumber);
        }

        in.close();
    }

    /**
     * @title 입력값 체크
     * @param input
     * @desc 사용자가 입력한 값을 체크한다. 
     * 1,2 일 경우 홀짝 게임을 계속 진행하고, 그 외일 경우 종료한다.
     */
    public boolean inputCheck(int input) {

        if(input == 1 || input == 2) {
            return true;
        }else {
            System.out.println("종료되었습니다.");
            return false;
        }
    }

    /**
     * @title 난수 생성
     * @param 
     * @dsec 1에서 10까지의 난수를 생성한다.
     */
    public int makeRandomNumber() {

        Random random = new Random();
        return random.nextInt(9)+1;
    }

    /**
     * @title 홀짝 체크
     * @param input, randomNumber
     * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
     */
    public void check(int input, int randomNumber) {

        int result = randomNumber%2;

        if(result == 0 && input == 2) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
        }else if(result == 1 && input == 1) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
        }else {
            System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
        }

    }

4) 리팩토링 코드

   public void holjjak() {

        System.out.println("안신나는 홀짝 게임!");
        System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

        Scanner in = new Scanner(System.in);
        int input = 0;

        while(inputCheck(input = in.nextInt())) {
            int randomNumber = makeRandomNumber();
            check(input, randomNumber);
        }

        in.close();
    }

    /**
     * @title 입력값 체크
     * @param input
     * @desc 사용자가 입력한 값을 체크한다. 
     * 1,2 일 경우 홀짝 게임을 계속 진행하고, 그 외일 경우 종료한다.
     */
    public boolean inputCheck(int input) {

        if(input == 1 || input == 2) {
            return true;
        }

        System.out.println("종료되었습니다.");
        return false;
    }

    /**
     * @title 난수 생성
     * @param 
     * @dsec 1에서 10까지의 난수를 생성한다.
     */
    public int makeRandomNumber() {

        Random random = new Random();
        return random.nextInt(9)+1;
    }

    /**
     * @title 홀짝 체크
     * @param input, randomNumber
     * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
     */
    public void check(int input, int randomNumber) {

        int result = randomNumber%2;

        if(result == 0 && input == 2) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
            return;
        }
        if(result == 1 && input == 1) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
            return;
        }

        System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
    }
  1. inputCheck 메서드의 else를 제거하였다. 제거하고 보니 애초에 else는 불필요했다.
  • before
    public boolean inputCheck(int input) {

        if(input == 1 || input == 2) {
            return true;
        }else {
            System.out.println("종료되었습니다.");
            return false;
        }
    }
  • after
    public boolean inputCheck(int input) {

        if(input == 1 || input == 2) {
            return true;
        }

        System.out.println("종료되었습니다.");
        return false;
    }
  1. check 메서드의 else if, else를 제거하고 return하는 형식으로 수정했다. 마찬가지로 return이 있으니 else가 필요없었다.
  2. else if 대신 if-return 구문을 사용하니 코드가 분리되는 느낌(?)을 받았다. 말로 설명하긴 힘든데 else if는 상위 if문과 엮여있는 느낌이랄까... 그리고 else if문은 else{ if(조건){ 로직 }} 형식의 메커니즘이었다. 내부적으로 불필요한 else문을 실행하고, 객체지향 생활체조 1법칙을 위반하는 것이기도 했기에 고민없이 if문으로 대체하였다. 가독성면에서도 훨씬 좋아보인다.
  • before
    public void check(int input, int randomNumber) {

        int result = randomNumber%2;

        if(result == 0 && input == 2) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
        }else if(result == 1 && input == 1) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
        }else {
            System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
        }

    }
  • after
    public void check(int input, int randomNumber) {

        int result = randomNumber%2;

        if(result == 0 && input == 2) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
            return;
        }
        if(result == 1 && input == 1) {
            System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
            return;
        }

        System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
    }

5) 회고

역시 제2 법칙도 적용할게 있었다. else 문과 else if문보다 if문으로 처리하는 것이 가독성과 코드 품질면에서 득이 있었다. 또한, 조건절이 들어가는 모든 코드에 별 생각없이 else if, else를 사용했던 이유를 생각해봤는데, 이유를 생각해보지 않았던게 이유였다. 이제 문법을 사용할 때 사용하는 이유를 먼저 생각해보는 습관을 가져야겠다.

 


3. 모든 원시값과 문자열을 포장(wrap)한다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.

3) 기존 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            int input = 0;

            while(inputCheck(input = in.nextInt())) {
                int randomNumber = makeRandomNumber();
                check(input, randomNumber);
            }

            in.close();
        }

        /**
         * @title 입력값 체크
         * @param input
         * @desc 사용자가 입력한 값을 체크한다. 
         * 1,2 일 경우 홀짝 게임을 계속 진행하고, 그 외일 경우 종료한다.
         */
        public boolean inputCheck(int input) {

            if(input == 1 || input == 2) {
                return true;
            }

            System.out.println("종료되었습니다.");
            return false;
        }

        /**
         * @title 난수 생성
         * @param 
         * @dsec 1에서 10까지의 난수를 생성한다.
         */
        public int makeRandomNumber() {

            Random random = new Random();
            return random.nextInt(9)+1;
        }

        /**
         * @title 홀짝 체크
         * @param input, randomNumber
         * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
         */
        public void check(int input, int randomNumber) {

            int result = randomNumber%2;

            if(result == 0 && input == 2) {
                System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
                return;
            }
            if(result == 1 && input == 1) {
                System.out.println("개수는 "+ randomNumber +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber +"! 틀렸습니다.");
        }
    }

4) 리팩토링 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");
            Scanner in = new Scanner(System.in);

            InputValue inputValue = null;

            while((inputValue = new InputValue(in.nextInt())).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                check(inputValue, randomNumber);
            }

            in.close();
        }

        /**
        * @title 홀짝 체크
        * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
        */
        public void check(InputValue inputValue, RandomNumber randomNumber) {

            if((!randomNumber.isHol() && !inputValue.isHol()) || (randomNumber.isHol() && inputValue.isHol())) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        }
    }

    /**
    * @title 사용자 입력 값 클래스
    * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
    */
    class InputValue{

        int inputValue;

        // 생성자
        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(this.inputValue == 1 || this.inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        // 홀 여부 체크
        public boolean isHol() {
            if(inputValue == 1) {
                return true; 
            }
            return false;
        }
    }

    /**
    * @title 랜덤 값 클래스
    * @desc 랜덤 값을 관리하는 Wrapper 클래스
    */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            this.randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return this.randomNumber;
        }

        // 홀 여부 체크
        public boolean isHol() {

            if(this.randomNumber % 2 == 1) {
                return true;
            }
            return false;
        }
    }ㅒ

1. 사용자의 입력 값을 관리하는 InputValue 클래스를 생성하였다. 입력 값에 대한 체크, 홀수 여부를 체크하는 메서드 또한 위 클래스로 이관하여 입력 값에 대한 모든 책임을 지도록 하였다.

2. 랜덤 값을 관리하는 RandomNumber 클래스를 생성하였다. 랜덤 값에 생성, 조회, 홀수 여부를 체크하는 메서드또한 위 클래스로 이관하여 랜덤 값에 대한 모든 책임을 지도록 하였다.

3. check 메서드의 홀 짝 체크 대한 if문 2개가 조건만 다르고 내용은 동일하여 하나의 조건절로 병합하였다. 조건절 또한 isHol 메서드를 사용하여 가독성을 높였다.

5) 회고

제 3법칙 적용 전 이 법칙을 왜 써야하는지 이해가 잘 되지 않아 관련 내용을 머릿속에 정리한 후 작업하였다.

1. 상태나 정보를 갖는 변수값은 비지니스적으로 의미있는 값이다. 이를 단순 변수로 처리하는것 보다 책임을 위임할 수 있는 클래스 형태로 관리하는 것이 좋다.
2. 원시타입을 포장함으로써 메서드의 시그니처가 명확해진다. 클래스로 포장한 타입의 메서드를 파라미터로 사용하게 되면 메소드를 설명하기 더욱 쉬워진다. 아래 두 메서드를 비교해보면 아래의 메서드 시그니처가 더욱 명확함을 알 수 있다. 출처 : https://limdingdong.tistory.com/9
public void evaluateCustomerCreditRate(int score)
public void evaluateCustomerCreditRate(CreditScore creditScore)

1번은 확실히 이해가 가지만 2번의 경우 확실히 이해가 가지 않는다. Wrapper 클래스로 관리하는게 시그니처를 명확하게한다? 그로인한 장점도 크게 와닿지 않는다. 이 내용은 계속 리팩토링을 해 나가며 이해하도록 노력해야겠다.

시그니처를 명확하게 한다는 뜻은 메서드 명과 파라미터가 명확해진다는 뜻. 여기서 말하는 명확의 대상은 파라미터를 의미한다. 원시 타입의 파라미터는 그 값에 대한 검증이 이루어지지 않았다. 위 코드를 예로 들어보면 evaluateCustomerCreditRate의 score는 어떤 값이든 들어올 수 있다. 마이너스든, 큰숫자든 말이다. 하지만 CreditScore WraaperClass는 특정 범위의 값을 가진 값으로 시그니처를 명확하게 할 수 있다. 예를들어 CreditScore의 score를 모든 int가 아닌 자연수로 한정하려면 score에 대한 WrapperClass를 아래와 같이 만들면 된다. 시그니처가 명확해지며 CreditScore 생성자만 확인한다면 된다.

class CreditScore{

    int score;

    public CreditScore(int score) {

        this.score = score;
    }

    public void validationScore(int score){
        if(score < 0) {
            throw new RuntimeException("신용점수가 0보다 작습니다.");
        }
    }
}

WrapperClass가 아닌 일반 변수로 관리한다면 검증 로직을 모두 서비스단에 적용해야하며, 관련 부분 모두 동일하게 적용해야한다. 만약 한곳이라도 누락시킨다면 버그가 발생할 수 있게 된다.

 


4. 한 줄에 점을 하나만 찍는다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.
  8. 객체지향 생활체조 9법칙의 4법칙 '한 줄에 점을 하나만 찍는다.'을 무조건 준수한다.

3) 기존 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");
            Scanner in = new Scanner(System.in);

            InputValue inputValue = null;

            while((inputValue = new InputValue(in.nextInt())).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                check(inputValue, randomNumber);
            }

            in.close();
        }

        /**
        * @title 홀짝 체크
        * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
        */
        public void check(InputValue inputValue, RandomNumber randomNumber) {

            if((!randomNumber.isHol() && !inputValue.isHol()) || (randomNumber.isHol() && inputValue.isHol())) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        }
    }

    /**
    * @title 사용자 입력 값 클래스
    * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
    */
    class InputValue{

        int inputValue;

        // 생성자
        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(this.inputValue == 1 || this.inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        // 홀 여부 체크
        public boolean isHol() {
            if(inputValue == 1) {
                return true; 
            }
            return false;
        }
    }

    /**
    * @title 랜덤 값 클래스
    * @desc 랜덤 값을 관리하는 Wrapper 클래스
    */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            this.randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return this.randomNumber;
        }

        // 홀 여부 체크
        public boolean isHol() {

            if(this.randomNumber % 2 == 1) {
                return true;
            }
            return false;
        }
    }

4) 리팩토링 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            InputValue inputValue = null;

            while((inputValue = new InputValue(in)).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                check(inputValue, randomNumber);
            }

            in.close();
        }

        /**
        * @title 홀짝 체크
        * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
        */
        public void check(InputValue inputValue, RandomNumber randomNumber) {
            int input = inputValue.getInputValue();

            if(input == randomNumber.getHoljjak()) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        }
    }

    /**
    * @title 사용자 입력 값 클래스
    * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
    */
    class InputValue{

        int inputValue;

        InputValue(Scanner in){
            this.inputValue = in.nextInt();
        }

        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(inputValue == 1 || inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        public int getInputValue() {
            return inputValue;
        }
    }

    /**
    * @title 랜덤 값 클래스
    * @desc 랜덤 값을 관리하는 Wrapper 클래스
    */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return randomNumber;
        }

        // 홀/짝 조회
        public int getHoljjak() {
            if(randomNumber % 2 == 1) {
                return 1;
            }
            return 2;
        }
    }

1. while 조건절에 4 법칙을 적용하기 위해 InputValue 클래스에 Scanner를 받는 생성자를 만들고 조건절을 수정하였다. 복잡해보이던 조건절이 한단계 단순해졌다.

before

    while((inputValue = new InputValue(in.nextInt())).inputCheck()) {

after

    while((inputValue = new InputValue(in)).inputCheck())  { // 수정
    ...

    InputValue(Scanner in){ // 추가
        this.inputValue = in.nextInt();
    }

2. 불필요한 this 구문을 제거하였다. 한 줄에 하나의 점만 찍는에 대한 목적과 거리가 먼 생성자의 this는 남겨두었다.

before

    if(this.inputValue == 1 || this.inputValue == 2) { 
    ... 

after

    if(inputValue == 1 || inputValue == 2) {
    ...

3. InputValue와 RandomNumber 클래스의 isHol 메서드를 제거하고 RandomNumber 클래스에 홀/짝에 대한 숫자 값을 조회하는 getHoljjak 메서드를 추가하였다. 두 곳에 홀 여부를 판단하는 메서드를 넣으니 홀 여부 판단에 대한 책임이 늘어났고, 홀짝 체크를 홀 여부로 판단할 필요가 없다고 느꼈기 때문이다. 이로써 조건절또한 훨씬 간단해졌다.

before

    // InputValue 클래스의 isHol
     public boolean isHol() {

        if(this.randomNumber % 2 == 1) {
            return true;
        }
        return false;
    }
    // RandomNumber 클래스의 isHol
    public boolean isHol() {
        if(inputValue == 1) {
            return true; 
        }
        return false;
    }
    // check 메서드
    public void check(InputValue inputValue, RandomNumber randomNumber) {

        if((!randomNumber.isHol() && !inputValue.isHol()) || (randomNumber.isHol() && inputValue.isHol())) {
            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
            return;
        }

        System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
    }

after

    // isHol 메서드를 제거하고 RandomNumber 클래스에 getHoljjak 메서드 생성
    public int getHoljjak() {
        if(randomNumber % 2 == 1) {
            return 1; //홀
        }
        return 2; //짝
    }

    public void check(InputValue inputValue, RandomNumber randomNumber) {
        int input = inputValue.getInputValue();

        if(input == randomNumber.getHoljjak()) { // 조건절 수정
            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
            return;
        }

        System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
    }

5) 회고

점이 많다는 건 단순 가독성 문제 뿐 아닌 높은 결합도, 책임의 분산, 불필요한 코드일 수 있음을 알려주는 좌표점과 같은 의미였다. 법칙을 적용해나갈수록 코드에게 미안함을 느낀다. 겔겔


5. 줄여쓰지 않는다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.
  8. 객체지향 생활체조 9법칙의 4법칙 '한 줄에 점을 하나만 찍는다.'을 무조건 준수한다.
  9. 객체지향 생활체조 9법칙의 5법칙 '줄여쓰지 않는다.을 무조건 준수한다.

3) 기존 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            InputValue inputValue = null;

            while((inputValue = new InputValue(in)).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                check(inputValue, randomNumber);
            }

            in.close();
        }

        /**
         * @title 홀짝 체크
         * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
         */
        public void check(InputValue inputValue, RandomNumber randomNumber) {
            int input = inputValue.getInputValue();

            if(input == randomNumber.getHoljjak()) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        }
    }

    /**
     * @title 사용자 입력 값 클래스
     * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
     */
    class InputValue{

        int inputValue;

        InputValue(Scanner in){
            inputValue = in.nextInt();
        }

        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(inputValue == 1 || inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        public int getInputValue() {
            return inputValue;
        }
    }

    /**
     * @title 랜덤 값 클래스
     * @desc 랜덤 값을 관리하는 Wrapper 클래스
     */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return randomNumber;
        }

        // 홀/짝 조회
        public int getHoljjak() {
            if(randomNumber % 2 == 1) {
                return 1;
            }
            return 2;
        }
    }

4) 리팩토링 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            InputValue inputValue = null;

            while((inputValue = new InputValue(in)).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                holjjakCheck(inputValue, randomNumber);
            }

            in.close();
        }

        /**
         * @title 홀짝 체크
         * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
         */
        public void holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
            int input = inputValue.getInputValue();

            if(input == randomNumber.getHoljjak()) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        }
    }

    /**
     * @title 사용자 입력 값 클래스
     * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
     */
    class InputValue{

        int inputValue;

        InputValue(Scanner in){
            inputValue = in.nextInt();
        }

        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(inputValue == 1 || inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        public int getInputValue() {
            return inputValue;
        }
    }

    /**
     * @title 랜덤 값 클래스
     * @desc 랜덤 값을 관리하는 Wrapper 클래스
     */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return randomNumber;
        }

        // 홀/짝 조회
        public int getHoljjak() {
            if(randomNumber % 2 == 1) {
                return 1;
            }
            return 2;
        }
    }

1. 변수나 메서드 명을 줄여쓰는 이유는 이름이 너무 길고 복잡하기 때문인데, 이 의미는 곧 그만큼 로직이 복잡하고 중요도가 높다는 반증일 수 있다. 이를 줄여쓴다면 이름으로써 전달하고자 하는 값의 의미를 파악하기 어려워진다. 불필요하게 긴 이름을 축약해야겠지만, 분명한 의미를 표현하지 못하는 이름은 목적에 맞게 명확한 이름을 정해주자. 위 코드에서 check 메서드가 정확히 무엇을 체크하는지 그 의미를 파악하기 어려워 holjjakCheck로 변경하였다.

before

    public void check(InputValue inputValue, RandomNumber randomNumber)

after

    public void holjjakCheck(InputValue inputValue, RandomNumber randomNumber)

5) 회고

변수나 메서드 명을 정할 때에는 목적에 맞게 명확한 이름을 정해주고, 코드에 긴 이름이 보인다면 곧장 로직만 보지 않고 이름의 의미를 먼저 파악하는 습관을 길러봐야겠다.

 


6. 모든 엔티티를 작게 유지한다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.
  8. 객체지향 생활체조 9법칙의 4법칙 '한 줄에 점을 하나만 찍는다.'을 무조건 준수한다.
  9. 객체지향 생활체조 9법칙의 5법칙 '줄여쓰지 않는다.'을 무조건 준수한다.
  10. 객체지향 생활체조 9법칙의 6법칙 '모든 엔티티를 작게 유지한다.'을 무조건 준수한다.

3) 회고

이 법칙의 '작게' 라는 기준이 모호하여 찾아본 결과, 책에서 부가적으로 설명하고 있으며 내용은 다음과 같다.

50줄 이상 되는 클래스와 10개 파일 이상 되는 패키지는 없어야한다.

긴 파일은 읽고, 이해하고, 재사용하기 어렵다는 의미이다. 홀짝은 너무 단순한 프로그램이라 그런지 최초 작성한 코드 또한 50줄이 채 되지 않았으며, 현재 3개의 클래스. 각 20줄 정도로 유지하고 있어 리팩토링하지 않았다. 이 법칙은 객체지향 생활체조 원칙을 잘 지켜라는 의미 또한 내포하고 있지 않나 싶다. 겔겔

 


7. 두개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.
  8. 객체지향 생활체조 9법칙의 4법칙 '한 줄에 점을 하나만 찍는다.'을 무조건 준수한다.
  9. 객체지향 생활체조 9법칙의 5법칙 '줄여쓰지 않는다.을 무조건 준수한다.
  10. 객체지향 생활체조 9법칙의 6법칙 '모든 엔티티를 작게 유지한다.'을 무조건 준수한다.
  11. 객체지향 생활체조 9법칙의 7법칙 '두개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.'을 무조건 준수한다.

3) 회고

'두개'는 클래스 분리를 강조하기 위함이다. 변수가 두 개 이상이라는 의미는 상태 정보가 두개 이상일 수 있고, 이는 곧 wrap 클래스로 분리(3원칙)할 여지가 있다는 의미이기도 하다.

3 원칙을 적용했을 때 자연스럽게 7 법칙도 적용이 되어 리팩토링을 하지 않았다. 복잡한 프로그램을 구현할 때 이 원칙을 지키는 것은 정말 어려울 것 같다.

 


8. 일급 컬렉션을 사용한다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.
  8. 객체지향 생활체조 9법칙의 4법칙 '한 줄에 점을 하나만 찍는다.'을 무조건 준수한다.
  9. 객체지향 생활체조 9법칙의 5법칙 '줄여쓰지 않는다.을 무조건 준수한다.
  10. 객체지향 생활체조 9법칙의 6법칙 '모든 엔티티를 작게 유지한다.'을 무조건 준수한다.
  11. 객체지향 생활체조 9법칙의 7법칙 '두개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.'을 무조건 준수한다.
  12. 객체지향 생활체조 9법칙의 8법칙 '일급 컬렉션을 사용한다.'을 무조건 준수한다.
  13. 홀짝 종료 시 사용자의 정답률을 표시한다. 형식은 다음과 같다 '정답률은 56%(게임 횟수/정답 횟수) 입니다.'

3) 기존 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            InputValue inputValue = null;

            while((inputValue = new InputValue(in)).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                holjjakCheck(inputValue, randomNumber);
            }

            in.close();
        }

        /**
         * @title 홀짝 체크
         * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
         */
        public void holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
            int input = inputValue.getInputValue();

            if(input == randomNumber.getHoljjak()) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        }
    }

    /**
     * @title 사용자 입력 값 클래스
     * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
     */
    class InputValue{

        int inputValue;

        InputValue(Scanner in){
            inputValue = in.nextInt();
        }

        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(inputValue == 1 || inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        public int getInputValue() {
            return inputValue;
        }
    }

    /**
     * @title 랜덤 값 클래스
     * @desc 랜덤 값을 관리하는 Wrapper 클래스
     */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return randomNumber;
        }

        // 홀/짝 조회
        public int getHoljjak() {
            if(randomNumber % 2 == 1) {
                return 1;
            }
            return 2;
        }
    }

4) 리팩토링 코드

    class Holjjak{

    public void holjjak() {

        System.out.println("안신나는 홀짝 게임!");
        System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

        Scanner in = new Scanner(System.in);
        InputValue inputValue = null;
        HoljjakScore score = new HoljjakScore();

        while((inputValue = new InputValue(in)).inputCheck()) {
            RandomNumber randomNumber = new RandomNumber();
            score.addResult(holjjakCheck(inputValue, randomNumber));
        }
        score.printScoreInfo();
        in.close();
    }

    /**
     * @title 홀짝 체크
     * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
     */
    public boolean holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
        int input = inputValue.getInputValue();

        if(input == randomNumber.getHoljjak()) {
            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
            return true;
        }

        System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        return false;
    }
}

/**
 * @title 사용자 입력 값 클래스
 * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
 */
class InputValue{

    int inputValue;

    InputValue(Scanner in){
        inputValue = in.nextInt();
    }

    InputValue(int inputValue){
        this.inputValue = inputValue;
    }

    // 입력값 체크
    public boolean inputCheck() {

        if(inputValue == 1 || inputValue == 2) {
            return true;
        }
        System.out.println("종료되었습니다.");
        return false;
    }

    public int getInputValue() {
        return inputValue;
    }
}

/**
 * @title 랜덤 값 클래스
 * @desc 랜덤 값을 관리하는 Wrapper 클래스
 */
class RandomNumber{

    int randomNumber;

    // 난수 생성 생성자
    RandomNumber(){

        Random random = new Random();
        randomNumber = random.nextInt(9)+1;
    }

    // 랜덤 값 조회
    public int getRandomNumber() {

        return randomNumber;
    }

    // 홀/짝 조회
    public int getHoljjak() {
        if(randomNumber % 2 == 1) {
            return 1;
        }
        return 2;
    }
}

/**
 * @title 홀짝 결과 클래스
 * @desc 홀짝 결과를 관리하는 일급 컬렉션
 *
 */
class HoljjakScore{

    List<Boolean> scoreBoard;

    HoljjakScore(){
        scoreBoard = new ArrayList<Boolean>();
    }

    // 홀짝 결과 추가
    public void addResult(boolean result){
        scoreBoard.add(result);
    }

    // 홀짝 결과 출력
    public void printScoreInfo() {
        int correctCnt = Collections.frequency(scoreBoard, true);
        int totalCnt = scoreBoard.size();
        double correctPercent = (double)correctCnt/(double)totalCnt*100;

        if(totalCnt != 0) {
            System.out.println("정답률은 "+String.format("%.1f",correctPercent)+"%("+correctCnt+","+totalCnt+")입니다.");
        }
    }
}

 

1. 요구사항 11, 12를 준수하기 위해 홀짝 결과 클래스(HoljjakScore.java)를 일급 컬렉션으로 구현하였다.

class HoljjakScore{

    List<Boolean> scoreBoard;

    HoljjakScore(){
        scoreBoard = new ArrayList<Boolean>();
    }

    // 홀짝 결과 추가
    public void addResult(boolean result){
        scoreBoard.add(result);
    }

    // 홀짝 결과 출력
    public void printScoreInfo() {
        int correctCnt = Collections.frequency(scoreBoard, true);
        int totalCnt = scoreBoard.size();
        double correctPercent = (double)correctCnt/(double)totalCnt*100;

        if(totalCnt != 0) {
            System.out.println("정답률은 "+String.format("%.1f",correctPercent)+"%("+correctCnt+","+totalCnt+")입니다.");
        }
    }
}

 

2. 홀짝 결과를 추가하는 HoljjakScore.addResult는 holjjakCheck 메서드 안에서 호출하려 했으나 메서드 파라미터가 추가되고, 결합도가 높아지기(홀짝 체크 + 결과 관리) 에 holjjakCheck 메서드에서 결과값을 return하고, 외부에서 HoljjakScore.addResult를 호출하는 방향으로 구현하였다. 홀짝 게임을 종료할 경우 printScoreInfo() 메서드를 호출하여 결과값을 출력하였다.

before

    public void holjjak() {

        ...

        while((inputValue = new InputValue(in)).inputCheck()) {
            RandomNumber randomNumber = new RandomNumber();
            holjjakCheck(inputValue, randomNumber);
        }

        ...
    }

    public void holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
        int input = inputValue.getInputValue();

        if(input == randomNumber.getHoljjak()) {
            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
            return;
        }

        System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
    }

after

    public void holjjak() {

        ...

        HoljjakScore score = new HoljjakScore();

        while((inputValue = new InputValue(in)).inputCheck()) {
            RandomNumber randomNumber = new RandomNumber();
            score.addResult(holjjakCheck(inputValue, randomNumber));
        }
        score.printScoreInfo();

        ...
    }

    public boolean holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
        int input = inputValue.getInputValue();

        if(input == randomNumber.getHoljjak()) {
            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
            return true;
        }

        System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
        return false;
    }

5) 회고

일급 컬렉션이란 컬렉션 외에 다른 멤버변수를 포함하지 않는 클래스이다. 여기서 컬렉션은 Collection, Map 인터페이스를 상속받는 클래스를 의미하며 대표적으로 List, HashMap, HashSet이 있다.

이번 리팩토링에서 일급 컬렉션을 사용하였기에 클래스 형태로 홀짝 결과를 관리할 수 있게 되었다. 일급 컬렉션을 사용하지 않았다면 로직이 어땠을까? 1. List는 holjjak 메서드 최상단에 생성, 2. while 문 안에서 List.add 메서드를 호출하여 결과 추가, 3. Holjjak 클래스 내에 결과 리스트를 받아 출력하는 메서드 생성. 와 같은 작업을 진행했을 것이다.

일급컬렉션을 사용함으로 얻을 수 있는 이점은 다음과 같다.

1. 상태와 행위를 관리할 수 있다.

결과에 대한 상태와 추가, 조회, 출력, 삭제, 검증과 같은 행위를 하나의 클래스에서 관리할 수 있다. 만약 결과 초기화, 결과 검증 등의 기능이 추가된다고 가정해보자. 일급 컬렉션을 사용하지 않는다면 관련 기능이 Holjjack 클래스의 메서드로 추가될 것이다. 만약 다른 클래스에서 결과 관련 기능이 필요하다면 해당 클래스에도 메서드가 추가될 것이다. 결과 관련 로직이 여러 클래스로 분산될 수 있다.

2. 컬렉션에 대한 이름을 지정할 수 있다.

일급 컬렉션을 사용하지 않는다면 이름은 변수 명으로 설정될 것이다. 이 변수명은 개발자에 따라, 클래스에 따라 달라질것이다. 이는 곧 검색이 어려워진다. 홀짝 결과에 대한 요구사항이 추가된다면 관련 코드들을 모두 뒤져야한다. 클래스 명으로 이름을 지정한다면 단순히 클래스 명으로 검색하면 되니 유지보수 요청에 빠르게 대처할 수 있을것이다.

3. 불변을 보장한다.

변수로 선언된 컬렉션은 데이터 추가, 삭제가 자유롭다. 일급 컬렉션을 사용한다면 클래스 메서드를 생성하지 않는 한 추가, 삭제가 불가능하기에 불변성을 보장할 수 있다.

4. 비지니스에 종속적인 자료구조를 생성할 수 있다.

생성되는 컬렉션을 비지니스에 종속되는 자료구조로 생성 가능하다. 즉, 단순히 자료구조를 생성하는게 아니라 비지니스 로직을 녹인 자료구조를 생성할 수 있다는 뜻이다.

중복되는 이름이 있으면 안되고, 5자리 이하의 영어이름만을 관리하는 자동차의 객체를 예시로 들어보자. 해당 조건들을 서비스 메서드에서 구현을 한다면 자동차 객체리스트가 들어간 모든 장소에서 해당 자동차 리스트에 대한 검증 코드가 들어가야 하는 문제점이 발생한다. 이러한 문제는 아래의 코드와 같이 일급 컬렉션 생성자에 비지니스 로직을 넣어 생성하면 해결이 가능하다.

    public class Cars {

        private List<Car> carList;

        public Cars(List<Car> carList) {
            validateCarName(carList); // 5자리 이하의 영어이름이 아닐 경우 Exception
            validateDuplicateName(carList); // 이름이 중복될 경우 Exception
            this.carList = carList; // 비지니스에 종속적인 자료구조 생성
        }    
}

이번 규칙을 적용하기 위해 요구사항 12번을 추가하였다. 기존 요구사항으로는 일급컬렉션을 생성할 여지가 없었고, 단어가 너무 생소하여 직접 경험해봐야겠다고 생각했기 때문이다.

현재 내가 맡은 모든 프로젝트의 컬렉션은 99퍼센트 이상이 변수로 선언되어 있다. 일급 컬렉션의 이점 모두가 너무 중요한데 이를 몰랐던 무지에 대한 부끄러움과 이 규칙을 이해했다는 감동을 동시에 느꼈다. 다음은 마지막 9 규칙을 적용할 차례이다. 마지막까지 집중력을 잃지말자!

 

 


9. getter/setter/property를 쓰지 않는다

1) 프로그램명

  • 홀짝 게임

2) 요구사항

  1. 사용자가 1(홀) 또는 2(짝)을 입력하여 홀짝을 맞춘다.
  2. 홀짝 게임에 사용되는 돌의 수는 1~10개 이며, 매번 달라진다.
  3. 홀짝 결과는 사용된 돌의 개수와 맞춤 여부이다.
  4. 사용자가 1(홀) 또는 2(짝)을 입력하지 않을 때까지 게임은 계속된다.
  5. 객체지향 생활체조 9법칙의 1법칙 '한 메서드에 오직 한 단계의 들여쓰기만 한다'을 무조건 준수한다.
  6. 객체지향 생활체조 9법칙의 2법칙 'Else_Switch_키워드를_사용하지_않는다'을 무조건 준수한다.
  7. 객체지향 생활체조 9법칙의 3법칙 '모든 원시값과 문자열을 포장(wrap)한다.'을 무조건 준수한다.
  8. 객체지향 생활체조 9법칙의 4법칙 '한 줄에 점을 하나만 찍는다.'을 무조건 준수한다.
  9. 객체지향 생활체조 9법칙의 5법칙 '줄여쓰지 않는다.을 무조건 준수한다.
  10. 객체지향 생활체조 9법칙의 6법칙 '모든 엔티티를 작게 유지한다.'을 무조건 준수한다.
  11. 객체지향 생활체조 9법칙의 7법칙 '두개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.'을 무조건 준수한다.
  12. 객체지향 생활체조 9법칙의 8법칙 '일급 컬렉션을 사용한다.'을 무조건 준수한다.
  13. 홀짝 종료 시 사용자의 정답률을 표시한다. 형식은 다음과 같다 '정답률은 56%(게임 횟수/정답 횟수) 입니다.'
  14. 객체지향 생활체조 9법칙의 9법칙 'getter/setter/property를 쓰지 않는다.'을 무조건 준수한다.

3) 기존 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            InputValue inputValue = null;
            HoljjakScore score = new HoljjakScore();

            while((inputValue = new InputValue(in)).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                score.addResult(holjjakCheck(inputValue, randomNumber));
            }
            score.printScoreInfo();
            in.close();
        }

        /**
        * @title 홀짝 체크
        * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
        */
        public boolean holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
            int input = inputValue.getInputValue();

            if(input == randomNumber.getHoljjak()) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return true;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
            return false;
        }
    }

    /**
    * @title 사용자 입력 값 클래스
    * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
    */
    class InputValue{

        int inputValue;

        InputValue(Scanner in){
            inputValue = in.nextInt();
        }

        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(inputValue == 1 || inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        public int getInputValue() {
            return inputValue;
        }
    }

    /**
    * @title 랜덤 값 클래스
    * @desc 랜덤 값을 관리하는 Wrapper 클래스
    */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            randomNumber = random.nextInt(9)+1;
        }

        // 랜덤 값 조회
        public int getRandomNumber() {

            return randomNumber;
        }

        // 홀/짝 조회
        public int getHoljjak() {
            if(randomNumber % 2 == 1) {
                return 1;
            }
            return 2;
        }
    }

    /**
    * @title 홀짝 결과 클래스
    * @desc 홀짝 결과를 관리하는 일급 컬렉션
    *
    */
    class HoljjakScore{

        List<Boolean> scoreBoard;

        HoljjakScore(){
            scoreBoard = new ArrayList<Boolean>();
        }

        // 홀짝 결과 추가
        public void addResult(boolean result){
            scoreBoard.add(result);
        }

        // 홀짝 결과 출력
        public void printScoreInfo() {
            int correctCnt = Collections.frequency(scoreBoard, true);
            int totalCnt = scoreBoard.size();
            double correctPercent = (double)correctCnt/(double)totalCnt*100;

            if(totalCnt != 0) {
                System.out.println("정답률은 "+String.format("%.1f",correctPercent)+"%("+correctCnt+","+totalCnt+")입니다.");
            }
        }
    }

4) 리팩토링 코드

    class Holjjak{

        public void holjjak() {

            System.out.println("안신나는 홀짝 게임!");
            System.out.println("1 : 홀, 2 : 짝, 그외 : 종료");

            Scanner in = new Scanner(System.in);
            InputValue inputValue = null;
            HoljjakScore score = new HoljjakScore();

            while((inputValue = new InputValue(in)).inputCheck()) {
                RandomNumber randomNumber = new RandomNumber();
                score.addResult(holjjakCheck(inputValue, randomNumber));
            }
            score.printScoreInfo();
            in.close();
        }

        /**
        * @title 홀짝 체크
        * @desc 난수에 대한 홀짝을 추출하고, 입력값과 비교하여 최종 홀짝 여부를 판단한다 
        */
        public boolean holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
            boolean inputValueIsHol = inputValue.isHol();
            boolean randomNumberIsHol = randomNumber.isHol();

            randomNumber.printRandomNumber();

            if(inputValueIsHol == randomNumberIsHol) {
                System.out.println("맞췄습니다.");
                return true;
            }
            System.out.println("틀렸습니다.");
            return false;
        }
    }

    /**
    * @title 사용자 입력 값 클래스
    * @desc 사용자가 입력한 값을 관리하는 Wrapper 클래스
    */
    class InputValue{

        int inputValue;

        InputValue(Scanner in){
            inputValue = in.nextInt();
        }

        InputValue(int inputValue){
            this.inputValue = inputValue;
        }

        // 입력값 체크
        public boolean inputCheck() {

            if(inputValue == 1 || inputValue == 2) {
                return true;
            }
            System.out.println("종료되었습니다.");
            return false;
        }

        public boolean isHol() {
            if(inputValue == 1) {
                return true;
            }
            return false;
        }
    }

    /**
    * @title 랜덤 값 클래스
    * @desc 랜덤 값을 관리하는 Wrapper 클래스
    */
    class RandomNumber{

        int randomNumber;

        // 난수 생성 생성자
        RandomNumber(){

            Random random = new Random();
            randomNumber = random.nextInt(9)+1;
        }

        // 홀 여부 확인
        public boolean isHol() {
            if(randomNumber % 2 == 1) {
                return true;
            }
            return false;
        }

        //
        public void printRandomNumber() {
            System.out.println("개수 : "+randomNumber);
        }
    }

    /**
    * @title 홀짝 결과 클래스
    * @desc 홀짝 결과를 관리하는 일급 컬렉션
    *
    */
    class HoljjakScore{

        List<Boolean> scoreBoard;

        HoljjakScore(){
            scoreBoard = new ArrayList<Boolean>();
        }

        // 홀짝 결과 추가
        public void addResult(boolean result){
            scoreBoard.add(result);
        }

        // 홀짝 결과 출력
        public void printScoreInfo() {
            int correctCnt = Collections.frequency(scoreBoard, true);
            int totalCnt = scoreBoard.size();
            double correctPercent = (double)correctCnt/(double)totalCnt*100;

            if(totalCnt != 0) {
                System.out.println("정답률은 "+String.format("%.1f",correctPercent)+"%("+correctCnt+"/"+totalCnt+")입니다.");
            }
        }
    }

1. get 대신 isHol 메서드를 사용했다. 입력값, 랜덤 값에 대한 상태 값을 getter로 조회하여 다른 클래스에서 홀, 짝 여부를 판단하는 것보다 객체 스스로 판단하도록 하였다. 또한 메서드 명을 isHol로 하여 객체에 메시지를 전달하는 형태로 구현하였다. getter보다 훨씬 가독성이 좋아보인다.

3번 규칙을 적용할 때 isHol을 넣었다가 삭제한 부분이 있다. 그 때 당시 회고에 다음과 같이 기록하였다.

InputValue와 RandomNumber 클래스의 isHol 메서드를 제거하고 RandomNumber 클래스에 홀/짝에 대한 숫자 값을 조회하는 getHoljjak 메서드를 추가하였다. 두 곳에 홀 여부를 판단하는 메서드를 넣으니 홀 여부 판단에 대한 책임이 늘어났고, 홀짝 체크를 홀 여부로 판단할 필요가 없다고 느꼈기 때문이다. 이로써 조건절또한 훨씬 간단해졌다.

isHol을 두 클래스에 넣어 홀 여부 판단에 책임이 늘어났다고 했는데 잘못 생각한 것같다. InputValue의 isHol은 입력 값에 대한 홀 여부 판단(1 = 홀, 2 = 짝), RandomNumber의 isHol은 랜덤 값에 대한 홀 여부 판단(랜덤값 % 2로 판단)으로 두 기능은 아예 다른 기능이기 때문이다.

randomNumber를 가져오는 getRandomNumber대신 printRandomNumber로 수정하였다. randomNumber를 조회하는 이유는 오로지 출력이었기 때문이다.

before

    class InputValue{

        ...

        public int getInputValue() {
            return inputValue;
        }
    }
    class RandomNumber{

        ...

        // 홀/짝 조회
        public int getHoljjak() {
            if(randomNumber % 2 == 1) {
                return 1;
            }
            return 2;
        }
    }


    class Holjjak{

        ...

        public boolean holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
            int input = inputValue.getInputValue();

            if(input == randomNumber.getHoljjak()) {
                System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 맞췄습니다.");
                return true;
            }

            System.out.println("개수는 "+ randomNumber.getRandomNumber() +"! 틀렸습니다.");
            return false;
        }
    }

after

    class InputValue{

        ...

        public boolean isHol() {
            if(inputValue == 1) {
                return true;
            }
            return false;
        }
    }


    class RandomNumber{

        ...

        // 홀 여부 확인
        public boolean isHol() {
            if(randomNumber % 2 == 1) {
                return true;
            }
            return false;
        }

        // 랜덤값 출력
        public void printRandomNumber() {
            System.out.println("개수 : "+randomNumber);
        }
    }

    class Holjjak{

        ...

        public boolean holjjakCheck(InputValue inputValue, RandomNumber randomNumber) {
            boolean inputValueIsHol = inputValue.isHol();
            boolean randomNumberIsHol = randomNumber.isHol();

            randomNumber.printRandomNumber();

            if(inputValueIsHol == randomNumberIsHol) {
                System.out.println("맞췄습니다.");
                return true;
            }
            System.out.println("틀렸습니다.");
            return false;
        }

        ...
    }

5) 회고

이로써 객체지향 생활체조 9규칙을 모두 적용하였다. 처음 코드와 비교했을 때 코드 라인 수로만 보면 훨씬 길어졌지만 기능, 가독성, 유지보수, 재활용성 등 모든 면에서 훨씬 유연한 코드가 아닌가 싶다.

과정을 통해 배우고 느낀점이 많지만 내 생각에 한정된 리팩토링 코드이기에 잘못된 부분, 수정할 부분이 분명 존재할 것이다. 나는 배우고싶다. 부족한 점을 채우고 싶고 더 나아가 후배 개발자에게 정확한 지식을 알려주고 싶다. 이를 위해서는 개인 공부도 물론 중요하지만 동료 개발자들과 코드를 공유하고 의논하며 피드백을 받는것 또한 너무 중요하고 소중한 것이라 생각한다.

현재 재직중인 회사는 이런 분위기가 아니다. 입사 당시에는 이런 문화가 나에게 있어 다행이라고 생각했지만 지금 생각해보니 불행중 불행 겔겔...

올해 초 멘토링 프로그램 참여를 계획중인데 만약 등록된다면 적극적으로 임하여 많은 피드백을 받을수 있도록 노력할것이다.

반응형
반응형

1. 개요

  • CSRF의 정의
  • Spring Boot에서의 CSRF Filter 처리 방식

 

2. CSRF란?

 사이트 간 요청 위조(Cross-site request forgery, CSRF)는 웹사이트 취약점 공격의 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 공격을 말한다. - 위키백과

 

 

  • 필자는 처음 CSRF라는 용어를 접할 때 정의는 이해가 갔으나 실제 어떤 원리를 이용해 '공격'이라는 행위를 하는지 전혀 이해가 가지 않았다... 나와 같은 사람들의 이해를 돕기 위해 CSRF 공격에 대한 예를 스토리 텔링 기법으로 들어보도록 하겠다. (기억에 잘 남는다고 함...)
안녕하세요. 저는 아무개 커뮤니티의 회원 "talmobil"이라고 합니다.
유머게시판에 게시글 하나를 썼는데 좋아요가 너무 없어서 자괴감이 들었습니다. 아니, 열받더라구요. 제 글이 정말 재밌는데, 사람들이 일부러 좋아요를 눌러주지 않는 것 같거든요. -ㅅ-
그러던 중 좋은 방법이 하나 떠올랐습니다. 다른 사람이 제 게시글을 보면 좋아요를 자동으로 누르게 하는거죠!
방법은 이래요.
커뮤니티 사이트에서 게시글에 좋아요를 누르니  "http://community.com/like?post=[게시글 id]"이더군요.
제 게시글 ID를 확인해보니 9566 이구요. 그래서 게시글 안에 이미지 태그를 하나 삽입하고 src 값에 "http://community.com/like?post=9566"을 넣어봤습니다. 그랬더니 사람들이 제 게시글을 조회하게 되면 이미지 태그의 URL인 "http://community.com/like?post=9566" 가 자동으로 호출되었습니다! @_@.. 자동으로 제 게시글의 좋아요 수가 올라갔어요. 후후.. 다른 커뮤니티 사이트에도 어그로 게시글을 올리고 마찬가지로 src 태그를 넣었더니 더 빨리 오르더군요!
사용자들은 아무것도 모르고 제가 의도한대로 요청을 하고있는겁니다! 이제는 좋아요가 아닌 다른걸 해봐야겠어요 흐ㅏㅎ하
  • 아무것도 모르는 사용자들은 공격자가 의도한 행위를 사이트간 위조 요청으로 하게 되었다. 이런 공격이 바로 CSRF 이다. 그렇다면 이러한 공격을 막는 방법이 있을까? 당연히 있다. 서버에서 요청에 대한 검증을 하는 것이다. 이를테면 토큰 값으로 말이다. 이것도 예를 들어보겠다.
안녕하세요. 아무개 커뮤니티 담당자 "ㅅㄱ"입니다.
제가 집에 우환이 생겼습니다. 웃음이 필요해서 유머 게시판을 쓱 둘러봤는데, 개노잼 글이 하나 있더군요. 뭐지 하고 넘어가려는데 이 게시글의 좋아요 수가 무려 1만이 넘어갔습니다. 그리고 다시 들어가보니 누르지도 않은 좋아요가 눌러져있더라구요. 이게 말이되나? 싶었습니다.
 서버 로그를 확인해보니 제가 좋아요를 누른 기록도 있었습니다. 뭔가 이상해서 게시글 내용을 살펴봤는데 이미지 태그의 src에 좋아요 처리를 하는 URL이 들어가 있었습니다. 해당 URL을 구글링 해보니 이미 다른 웹사이트 게시글에도 포함되어 있더군요. 말로만 듣던 CSRF 공격이었습니다.
 막을 방법은 클라이언트마다 토큰을 발급하는 겁니다. 서버는 토큰 값을 검증하고요. 프로세스는 다음과 같습니다.
1. 저희 커뮤니티를 접근하면 특정 토큰을 클라이언트에게 발급함과 동시에 저희 서버 세션 안에 넣습니다.
    > A 클라이언트에 대해 A 토큰을, B클라이언트에 대해 B 토큰을 이렇게 각각 발급하는 겁니다.
2. 클라이언트는 모든 API를 호출할 때 필수적으로 이 토큰 값을 헤더에 넣어 보냅니다.
3. 서버에서는 요청을 수행하기전 Filter 레벨에서 세션 안에 들어있는 토큰 값과 요청 토큰 값을 비교합니다.
4. 토큰 값이 불일치할 경우 비정상적인 요청으로 판단하고 Access Denied 시킵니다.

토큰 검증을 성공하려면 요청 시 CSRF Token 값을 헤더에 넣어줘야하는데, 공격자는 사용자마다 각각 발급된 토큰 값을 알 수 없기때문에 막힐겁니다.
추가적으로, 이러한 방식을 스프링 시큐리티에서 기본적으로 지원하고 있더라구요!
  •  이처럼 서버에서 토큰을 발급 및 검증하고 클라이언트에서는 발급받은 토큰을 요청 값에 포함시켜 보내는 방식으로 CSRF 공격을 막을 수 있다. 스프링 시큐리티 의존성을 추가하면 이와 같은 방식을 제공하는 CSRF Filter가 자동으로 추가된다. csrf().disable() 설정을 통해 해제도 가능하다. 그럼 Spring Security에서 제공하는 CSRF Filter는 요청을 어떻게 처리하는지 알아보자.

 

3. CSRF Filter

CsrfFilter.java

  • 빨간색 표시된 부분이 중요한 부분이다. 하나하나 설명해보겠다.

 

  3.1. tokenRepository.loadToken(request)

  • 요청 세션에서 CSRF Token 객체를 조회한다. key는 HttpSessionCsrfTokenRepository.CSRF_TOKEN 이다.

tokenRepository 구현체

 

  3.2. tokenRepository.generateToken(request)

  • CSRF Token이 없을 경우 DefaultCsrfToken 생성자를 통해 CSRF Token을 발급한다.
  • 생성자 마지막 파라미터로 createNewToken() 리턴 값을 넣고있는데, token 값으로 사용할 랜덤 값을 생성한다.

createNewToken 메서드
DefaultCsrfToken 생성자

  • 생성자 메서드 호출 후 CsrfToken 객체를 생성하면 다음과 같은 형태의 CSRF Token 객체가 생성된다.
CsrfToken
token Random UUID
parameterName _csrf
headerName X-CSRF-TOKEN

 

  3.3. tokenRepository.saveToken()

  •  세션 내에 key = HttpSessionCsrfTokenRepository.CSRF_TOKEN, value = 생성한 CsrfToken 객체를 생성한다.

 

  3.4. request.setAttribute(CsrfToken.class.getName(), csrfToken)

  • HttpServletRequest 를 통해 csrfToken 값을 조회할 수 있도록 설정해주는 부분이다.

 

  3.5. requireCsrfProtectionMatcher.matches(request)

  • Csrf 검증 메서드를 체크한다. 기본적으로 GET, HEAD, TRACE, OPTIONS를 제외한 모든 메서드에 대해서는 CsrfToken을 검증한다. 만약 GET으로 요청이 들어왔다면 검증 없이 다음 Filter로 넘어간다.

 

  3.6. request.getHeader(csrfToken.getHeaderName()) , getParameter(csrfToken.getParameterName());

  • 요청 헤더에 X-CSRF-TOKEN 값이 있는지 확인하고, 없을 경우 요청 바디에서 _csrf 값이 있는지 확인한다.
  • 클라이언트는 요청 헤더의 X-CSRF-TOKEN 혹은 요청 바디의 _csrf 값 둘 중 하나로 CsrfToken 값을 보내면 된다.

 

  3.7. equalsConstantTime(csrfToken.getToken(), actualToken)

  • 세션에 저장(혹은 생성)한 토큰값과 클라이언트에서 보낸 토큰 값을 비교하여 일치할 경우 다음 필터를 호출하고 불일치할 경우 accessDeniedHandler 메서드를 호출하여 예외 처리한다.

 

4. 테스트

   4.1. CSRF 기능 활성화

  • 기본적인 Spring Security 설정 후 csrfFilter가 활성화 되도록 csrf().disable 메서드를 주석처리 한 후 post 메서드를 호출하였다. 그 결과 필자가 Custom한 AccessDeniedHandler 메서드가 호출되어 /denied 페이지로 이동되었다.
  • 예외가 발생한 이유는 요청 헤더 혹은 바디에 csrf 토큰이 없기 때문이다. 

SecurityConfig.java

 

post 메서드 호출

 

  4.2. 화면단에 CSRF 토큰 추가

  • form 태그 안에 아래와 같은 코드를 추가하거나,  스프링 시큐리티 taglib 추가 후 <s:csrfInput/> 태그를 추가하면 발급받은 _csrf 토큰을 자동으로 set 해준다.
  • API 호출 시 정상적으로 처리되는 것을 확인할 수 있다.

csrf 구문 추가
자동 추가된 csrf 값
정상 처리된 post 메서드

 

 

5. 회고

  • 스프링 시큐리티를 설정 시 갑자기 발생한 403 에러에 당황했던 적이 많다. 찾아보면  csrf().disable() 한줄을  추가 하면 됐고, 간단한 설정처럼 보였던 이 CSRF가 뭔지는 크게 궁금하지 않았다. 개념정도만 짚고 넘어갔다. 그런데 나중에 똑같은 에러에 똑같이 당황하게 됐고, 똑같이 구글링하고 개념만 쓱 보게 되더라. 그때도 머릿속에 개념이 잘 잡히지 않았는데, 프로세스 정리를 쭉 하니 확실하게 이해하게 된 것 같다.
반응형
반응형

1. 개요

 - Interceptor와 WHOIS OpenAPI를 사용하여 해외에서 접근하는 IP를 차단해보자.

 


2. 환경

 - SpringBoot

 - JDK 1.8

 


3. 구현

 - 핵심 로직은 크게 2가지이다. Client의 요청을 Controller 앞단에서 처리되게 할 Interceptor, WHOIS API 통신을 위한 CloseableHttpClient.

 

 1) IPCheckInterceptor.java

@Component
public class IPCheckInterceptor implements HandlerInterceptor, Constants {
	
	@Autowired
	private WSOpenAPIService WSService;

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
	
		String clientIp = request.getHeader("X-Forwarded-For");
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("WL-Proxy-Client-IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_CLIENT_IP");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getHeader("HTTP_X_FORWARDED_FOR");
	    }
	    if (ObjectUtils.isEmpty(clientIp) || "unknown".equalsIgnoreCase(clientIp)) {
	        clientIp = request.getRemoteAddr();
	    }
		
	    //로컬 테스트 시 주석 해제해주세요. 미국 IP입니다.
	    //clientIp = "54.211.120.28";
	    
	    if(!LOCAL_HOST.equals(clientIp)) {
	    	Map<String,String> clientInfo = WSService.getClientInfoByIPAddress(clientIp);
	    	
	    	if(clientInfo == null) {
	    		logger.error("IP에 대한 클라이언트 정보 조회에 실패하였습니다.");
	    		return false;
	    	}
	    	
	    	String country = clientInfo.get(WHO_IS_COUNTRY_CODE);

	    	if(!KOREA_COUNTRY_CODE.equals(country)) {
	    		logger.error("해외 IP가 감지되었습니다. 접근을 차단합니다. IP : {}, Country : {}", clientIp, country);
	    		return false;
	    	}
	    }
	    
	    return true;
	}
}

 - 'Client IP 추출 > WHOIS Service 메서드 실행 > 국적 코드 확인 > 접근 차단' 로직을 수행한다.

 

 - preHandler Method : Controller에 접근하기 이전에 수행되는 메서드이다. 메서드 내부에는 웹서버나 프록시를 거쳐 들어온 클라이언트의 IP를 request Header에서 추출하고, WHOIS API를 호출하는 로직이 포함되어있다.

 

 - !LOCAL_HOST.equals(clientIp) : LOCAL_HOST는 127.0.0.1 값에 대한 상수으로 인터페이스에 정의해두었다. 로컬 테스트 시 API를 태울 필요가 없어 추가하였다.

 

 - WSService.getClientInfoByIpAddress(String clientIp) : WHOIS API에 대한 서비스 클래스이다.

 

 - WHO_IS_COUNTRY_CODE, KOREA_COUNTRY_CODE : 각각 countryCode, KR 문자열에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

 

2) WSOpenAPIService.java

@Service
public class WSOpenAPIService implements Constants{
	
	@Value("${whois.api.key}")
	private String apiKey; 
	
	@Value("${whois.api.uri}")
	private String apiUri; 
	
	@Autowired
	private CloseableHttpClient closeableHttpClient;
	
	@Autowired
	private RequestConfig requestConfig;
	
	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@SuppressWarnings("unchecked")
	public Map<String,String> getClientInfoByIPAddress(String ip) {
		
		ObjectMapper objectMapper = null;

		try {
			List<NameValuePair> nameValuePairs= new ArrayList<NameValuePair>();
			
			nameValuePairs.add(new BasicNameValuePair("query",ip));
			nameValuePairs.add(new BasicNameValuePair("key",apiKey));
			nameValuePairs.add(new BasicNameValuePair("answer","json"));
			
			HttpGet httpGet = new HttpGet(apiUri);
			httpGet.setConfig(requestConfig);
			httpGet.addHeader("Content-type", "application/json");
			
			URI uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();

			httpGet.setURI(uri);
			
			CloseableHttpResponse response = closeableHttpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				logger.info("WHO IS API Response json : "+json);
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);

				return map.get(WHO_IS);
				
			}
			return null;
		} catch (ClientProtocolException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (URISyntaxException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		} catch (IOException e) {
			logger.error(e.getMessage());
			e.printStackTrace();
			return null;
		}
	}
	
}

 - ConnectionPoolHttpClient를 사용하여 WHOIS API 서버로 통신 및 응답 값을 추출하는 로직을 수행한다.

 - 통신에 성공할 경우 ObjectMapper를 사용하여 whois 값을 Map 형태로 변환한다.

 - WHO_IS : whois 값에 대한 상수 값이다. WHOIS API의 json Response 값에 대한 key값이다.

 

3) HttpClientConfig.java

@Configuration
public class HttpClientConfig {

	private static final int MAX_CONNECTION_PER_ROUTE = 20;
	private static final int MAX_CONNECTION_TOTAL = 200;
	private static final int CONNECTION_TIMEOUT = 10;
	private static final int SOCKET_TIMEOUT = 5;
	private static final int CONNECTION_REQUEST_TIMEOUT = 5;
	
	@Bean
	public CloseableHttpClient closeableHttpClient() {
		CloseableHttpClient closeableHttpClient = 
				HttpClients.custom().setConnectionManager(poolingHttpClientConnectionManager()).build();
		
		return closeableHttpClient;
		 
	}
	
	private PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() {
		PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
		connectionManager.setDefaultMaxPerRoute(MAX_CONNECTION_PER_ROUTE);
		connectionManager.setMaxTotal(MAX_CONNECTION_TOTAL);
		return connectionManager;
	}
	
	@Bean
	public RequestConfig requestConfig() {
		RequestConfig requestConfig = RequestConfig.custom()
                .setSocketTimeout(SOCKET_TIMEOUT * 1000)
                .setConnectTimeout(CONNECTION_TIMEOUT * 1000)
                .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT * 1000)
                .build();
		
		return requestConfig;
	}
}

 - ConnectionPool HttpClient 에 대한 config 클래스이다.

 - requestConfig, closeableHttpClient를 Bean으로 등록해 사용한다.

 - closeableHttpClient Bean 생성 시 커스텀한 PoolingHttpClientConnectionManager 객체를 주입시킨다.

 

 - 각 상수에 대한 설명은 다음과 같다.

상수 의미
MAX_CONNECTION_PER_ROUTE  CONNECTION 하나의 ROUTE에 연결 가능한 최대 CONNECTION 수
MAX_CONNECTION_TOTAL CONNECTION POOL에 저장될 수 있는 최대 CONNECTION 수
CONNECTION_TIMEOUT 커넥션 (3-HAND)을 맺는 시간에 대한 TIMEOUT
 SOCKET_TIMEOUT 커넥션을 맺은 후 응답을 받는 시간에 대한 TIMEOUT
CONNECTION_REQUEST_TIMEOUT CONNECTION POOL에서 CONNECTION을 꺼내오는 시간에 대한 TIMEOUT

 

4) WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Autowired
	private IPCheckInterceptor ipCheckInterceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {

		registry.addInterceptor(ipCheckInterceptor) .addPathPatterns("/**")
		.excludePathPatterns("/js/**", "/css/**", "/img/**","/assets/**");

	}

	
}

 - js, css, img, assets 과 같은 정적 페이지 요청을 제외한 모든 요청에 대해 ipCheckInterceptor를 적용시킨다.

 

 

5) IP V4 설정

 - 클라이언트로부터 추출되는 IP가 IPv6 형식이라면 IPv4 형식으로 변경해야한다. STS 환경이라면 'Run Configurations /  Spring Boot App / Arguments' 설정의 VM arguments 값에 '-Djava.net.preferIPv4Stack=true' 값을 넣어준다.

VM arguments 설정

 - 만약 외부 tomcat에서 운용된다면 catalina.sh 파일에 JVM 설정을 추가해준다.

catalina.sh 설정


4. 테스트

 - 로컬에서 테스트 시 IP가 127.0.0.1로 들어오기 때문에 IPCheckInterceptor에서 IP 값을 임의의 미국 IP로 변경한 후 테스트를 진행하였으며, 다음과 같이 API 응답 값을 얻고 접근을 차단하였다. 실제 클라이언트에서는 다음과 같이 빈 화면이 조회되게 된다.

INFO  2022-02-18 01:29:24[http-nio-8088-exec-1] [WSOpenAPIService:87] - WHO IS API Response json : {"whois":{"query":"54.211.120.28","queryType":"IPv4","registry":"ARIN","countryCode":"US"}}
ERROR 2022-02-18 01:29:24[http-nio-8088-exec-1] [IPCheckInterceptor:66] - 해외 IP가 감지되었습니다. 접근을 차단합니다. IP : 54.211.120.28, Country : US

요청이 차단된 화면

 


5. 마치며

 API 통신을 하여 국가코드를 조회하는 방식을 구현하고 나니, 동접자가 많아져 Connection이 모두 사용될 경우 서비스 속도가 느려질수 있겠다라는 생각이 들었고 DB 기반의 geoIp를 사용하는 이유도 이해가 갔다. 

 

 혹시 이 글을 보시는 분들 중 geoIp를 사용해보신 분이 있다면 댓글로 후기를 남겨주셨으면 좋겠다 ㅎㅎ;

(Help...)

반응형

+ Recent posts