반응형

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

반응형
반응형

1. 개요

 이번 동계 올림픽 하이라이트를 보니 '쭝' 얘기가 많더라. 그러고보니 한창 배그에 빠져있을 때였나... 그날도 회사갔다가 친구들이랑 배그를 하려고 접속했더니 XING, MING 으로 시작하는 '쭝' 사람들과 필자의 아이디가 4인 스쿼드를 돌리고 있더라. 나쁜 X끼들.

 

 본론으로 넘어가겠다. 서비스 오픈 후 국가별 서비스 접근 내역을 확인했다. 우리나라가 대부분이나 타국(중국, 미국, 러시아, 체코 기타등등...)에서의 접근도 있었다.

 타국 사용자를 타겟으로 한 서비스가 아니었기에 신경을 쓰지 않았으나, 필자의 서버는 호스팅 서버에 묶여있었고, 트래픽에 따른 추가비용 결제가 걸려있는 호스팅 서버의 특성 상 불필요한 접근을 막아야 했다. 타국에서의 접근은 불순한 의도일 확률이 높기 때문에 보안을 위해서도 해외 IP를 차단해야했다는 생각이 들었다.

 해외 IP를 차단하는 방법에 대해 서치를해보니 GeoIP를 사용하는 방법이 많던데, 몇천건 이상으로는 유료라는 썰이 있어 KISA의 무료 오픈 API인 WHOIS를 채택하게 되었다.


2. WHOIS API

https://whois.kisa.or.kr/kor/openkey/keyCre.do

 

KISA 후이즈검색 whois.kisa.or.kr

한국인터넷진흥원 인터넷주소자원 검색(후이즈검색) 서비스 입니다.

xn--c79as89aj0e29b77z.xn--3e0b707e

 

 WHOIS API는 KISA에서 제공하는 무료 Open API로 IP에 대한 국가코드 값을 얻을 수 있다. OpenAPI 사용 안내를 보면 다음과 같이 IP주소/AS 번호에 대한 국가코드를 요청하는 API를 확인할 수 있다.

 무료라는 큰 장점이 있지만, API를 위해 서버 내에서 HTTP 통신을 한번 태워야한다는 단점이 있다.

 * 만약, 더 좋은 방법이 있다면 댓글로 공유부탁해요!

WHOIS API

 

WHOIS API 응답값


3. 키 발급

 API 사용을 위해서는 먼저 키를 발급받아야 한다. WHOIS API 사이트에 접속 후 전자우편을 통해 발급받는다.

키 발급

 필자의 경우 네이버 메일로 받았으며, 다음과 같이 메일에 키 값이 포함되어 온다. @_@. 준비는 끝났다. (벌써)

WHOIS API 인증 키 메일


4. 구현

 필자는 단순 API 테스트를 위해 JUnit으로 단순하게 로직을 구현하였으며, 응답받은 Json String 값을 객체로 변환하기 위해 ObjectMapper를 사용하였다.

@Test
	public void whoisAPI() {

		String ip = "[IP]";
		String apiKey = "[API KEY]";
		String apiUri = "http://whois.kisa.or.kr/openapi/ipascc.jsp";
		
		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);
		
		URI uri = null;
		ObjectMapper objectMapper = null;
		
		try {
			uri = new URIBuilder(httpGet.getURI())
					.addParameters(nameValuePairs)
					.build();
			
			httpGet.setURI(uri);
			
			CloseableHttpClient httpClient = HttpClientBuilder.create().build();
			CloseableHttpResponse response = httpClient.execute(httpGet);

			int statusCode = response.getStatusLine().getStatusCode();
			
			if(statusCode == HttpStatus.OK.value()) {
				String json = EntityUtils.toString(response.getEntity(), "UTF-8");
				objectMapper = new ObjectMapper();
				
				Map<String,Map<String,String>> map = objectMapper.readValue(json, Map.class);
				Map<String,String> whois = map.get("whois");
				
				System.out.println("response : "+ map.toString());
				System.out.println("contryCode :"+whois.get("countryCode"));
			}
		} catch (URISyntaxException e) {
			e.printStackTrace();
		} catch (ClientProtocolException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		} 
	}

 


5. 실행 결과

 실행 결과 key,value 형태로 IP 및 국가코드가 들어있는 걸 확인할 수 있다. 너무 간단하쥬?

실행결과


6. 마치며

 다음 포스팅에서는 인터셉터를 활용하여 해외 IP일 경우 페이지 접근을 차단시키는 로직을 구현해보도록 하겠다. HttpClient 또한 ConnectionPool 한 HttpClient로 커스텀하여 사용해보도록 하겠다.

 

아참, KISA 홈페이지를 둘러보다 우연히 발견한건데, 이 API가 개방된지 얼마 되지않았더라. 많이 쓰세요 여러분.

될놈될...헤헤...

 

반응형
반응형

1. 개요

  • 토이 프로젝트 진행 중 관리자 권한에 따른 기능 분리가 필요하여 기존 Gradle 프로젝트에 SpringSecurity 인증을 끼얹어보았다.
  • 스프링 시큐리티 온라인 강의를 들은 적이 있다. 이해를 할때마다 고개를 끄덕거리며 만족해하던 내 자신이 기억났다. '생겨버렸다. 자신감'. 하지만 정작 기억해야할 건 전혀 기억하지 못하더라... 자신감은 잠시 버려두고 강의노트를 펼쳤다.

 


2. 환경

  • SpringBoot
  • Gradle
  • Mybatis

3. 스프링 시큐리티 인증 절차

  • 스프링 시큐리티를 사용하면 여러 인증 및 인가 필터를 거친다. 필터를 거칠때마다 이와 관련된 여러 인터페이스 구현체를 거치게 되는데, 이걸 우리의 서비스에 맞게 커스텀하면 된다. 그러려면? 내부적으로 어떤 클래스(절차)들을 거치는지 알아볼 필요가 있다. 
    SpringSecurity Form 인증 절차
     
  • UsernamePasswordAuthenticationFilter > AuthenticationManager > AuthenticationProvider > UserDetailsService > Repository(DB)
    1. UsernamePasswordAuthenticationFilter에서 요청값에 대한 Authentication 객체를 생성한다.
    2. 생성한 Authentication 객체를 AuthenticationManager에게 전달한다.
    3. AuthenticationManager는 실제 인증 처리를 하지 않고, 적절한 인증 처리 클래스에게 인증 처리를 위임하는, 말 그대로 매니저 역할을 하는 클래스이다. 폼 인증 요청에 대한 적절한 인증 처리 클래스인 AuthenticationProvider에게 Authentication 객체를 전달하여 인증 처리를 위임한다.
    4. AuthenticationProvider는 들어온 Authentication 객체의 username을 값을 추출하여 UserDetailsService의 loadUserByUsername 메서드 호출에 사용한다. 말 그대로 유저 ID에 대한 유저 정보를 조회한다.
    5. UserDetailsService는 username 값을 통해 DB에서 유저 상세 정보(아이디, 비밀번호, 권한 등)를  UserDetails 타입으로 조회한다.
    6. 조회한 UserDetilas 타입의 객체를 AuthenticationProvider에게 return한다.
    7. AuthenticationProvider는 DB에서 조회한 비밀번호와 요청으로 들어온 비밀번호를 체크한다. 만약 암호화 된 비밀번호라면 암호화 클래스를 사용해 체크한다.
    8. 비밀번호가 일치할 경우 인증 토큰에 들어갈 권한 객체인 authorities를 생성 후 원하는 권한을 넣어준다.
    9. AuthenticationProvider는 유저 정보 + authorities를 담은 Authentication 객체를 생성하여 AuthenticationManager에게 return한다.
    10. AuthenticationManager는 Authentication 객체를 UsernamePasswordAuthenticationFilter에게 return 한다.
    11. UsernamePasswordAuthenticationFilter는 Authentication 객체를 SecurityContext에 저장한다.

4. 구현

  • 필자가 정리한 위 인증 절차에서 커스텀해야할 녀석은 누구일까? 당연히 실질적인 인증처리를 하고있는 녀석들을 커스텀해야한다. AuthenticationProvider와 UserDetailsService가 핵심이다. 둘 다 인터페이스이므로 구현체를 생성하여 주입시켜주기만 하면 된다. SecurityConfig 부터 시작하자.

 

1) SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Autowired
	private AuthenticationSuccessHandler authenticationSuccessHandler;
	
	@Autowired
	private AuthenticationFailureHandler authenticationFailureHandler;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http
			.csrf().disable(); //일반 사용자에 대해 Session을 저장하지 않으므로 csrf을 disable 처리함.
		
		http
			.authorizeRequests()
			.antMatchers("/admin").permitAll()
			.antMatchers("/admin/**").hasRole("ADMIN")
			.antMatchers("/swagger-ui.html").hasRole("ADMIN")
			.antMatchers("/schedule/insert","/schedule/update","/schedule/delete").hasRole("ADMIN")
			.antMatchers("/comment/insert","/comment/update","/comment/delete").hasRole("ADMIN")
			.anyRequest().permitAll();
		
		http
			.formLogin()
			.loginPage("/admin")
			.loginProcessingUrl("/admin/login")
			.usernameParameter("username")
			.passwordParameter("password")
			.successHandler(authenticationSuccessHandler)
			.failureHandler(authenticationFailureHandler);
			
		http
			.logout()
			.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
			.logoutSuccessUrl("/admin")
			.invalidateHttpSession(true);
	
	}	
}

 - csrf().disable()

  •  먼저 csrf는 Cross Site Request Forgery의 약자로 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게하는 공격을 말한다.
  •  예를들어 은행 홈페이지에 특정 사용자에게 송금을 시키는 API(test.com/transMoney?money={송금할 금액}&to={송금받을 유저})가 있다고 하자.  A라는 공격자는 해당 홈페이지를 사용하는 유저들에게 다음과 같은 이미지 태그를 포함한 메일을 전송한다.
<img src="test.com/transMoney?money=100000&to=A />
  • 이 메일을 열어본 사용자는 src에 기입된 API가 호출되게 된다. 만약 해당 홈페이지에 로그인하여 세션이 브라우저에 남아있는 상태라면 공격자 A에게 100000원이 송금되는 것이다. 이런 공격을 csrf 공격이라고 한다.
  • http.csrf() 구문을 사용하면 클라이언트가 서버 통신 시 csrf 토큰 값을 함께 전달하고, 서버는 토큰에 대한 유효성을 체크하여 일치하지 않을 경우 요청을 차단하게 된다. 위 API를 호출한다 해도 csrf 토큰 값이 없으므로 서버에서는 401 에러가 발생할것이다. 하지만 내가 만든 서비스에서는 이러한 방식에 대한 필요성을 느끼지 못해 disable 처리를 하였다.

 

 - authorizeRequest() ~ anyRequest().permitAll()

  • 요청에 대한 인가를 설정한다. 현재 필자의 토이프로젝트 권한에 맞게 설정하였으므로 참고만 하고 넘어가면 된다.

 

 - formLogin()

  • formLogin 인증 방식을 사용한다는 의미이다. 

 

 - usernameParameter("username")와 passwordParameter("password")

  • 클라이언트가 전송한 폼 데이터 중 "username"과 "password"라는 name을 가진 값을 스프링 시큐리티에서 username, password로 사용한다. 즉, UsernamePasswordAuthenticationFilter 에서 Authentication 객체를 생성할때 각각의 변수 값을 사용한다.
<form action="/admin/login" id="form" method="post">
	<input type="password" name="password" id="adminPassword" />
    <input type="hidden" name="username" id="adminId" />
    <input type="submit" class="loginBtn" value="로그인">
</form>

 

 - loginPage()

  • 로그인할 페이지의 주소를 입력한다.

 

 - loginProcessingUrl()

  • 로그인을 처리할 Url을 입력한다.

 

 - successHandler()

  • 인증이 성공한 후 호출되는 핸들러 클래스이다.

  

 - failureHandler()

  • 인증이 실패한 후 호출되는 핸들러 클래스이다.

 

 - logout() ~ invalidateHttpSession()

  • /logout 을 호출하면 /admin 페이지로 이동하며 로그아웃이 되며, 이와 동시에 session이 무효화(invaildate)된다.

 

2) CustomAuthenticationSuccessHandler.java

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		
		logger.info("admin login Success !!");
		response.sendRedirect("/");
	}

}

 

  • AuthenticationSuccessHandler 인터페이스의 구현체이다.
  • 로그인이 성공할 경우 메인 페이지("/")로 리다이렉트 되도록 하였다.

 

3) CustomAuthenticationFailureHandler.java

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{

	private final Logger logger = LoggerFactory.getLogger(getClass());
	
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
		logger.error("admin login Failed !!");
		response.sendRedirect("/admin?auth=fail");
	}

}
  • AuthenticationFailureHandler 인터페이스의 구현체이다.
  • 로그인이 실패할 경우 쿼리 스트링을 포함하여 "/admin"로 이동시켰다.
  • 참고로 /admin 페이지는 관리자 로그인을 시도하는 페이지이며, 클라이언트단에서 auth값에 따른 알림 메시지를 출력하기 위해 쿼리스트링을 포함시켰다.

 

4) CustomAuthenticationProvider.java

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{

	@Autowired
	private UserDetailsService userDetailsService; //CustomUserDetails Class Autowired.
	
	@Autowired
	private PasswordEncoder passwordEncoder; //BCryptPasswordEncoder Class Autowired.
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		String username = authentication.getName();
		String password = (String)authentication.getCredentials();
		
		CustomUserDetails customUserDetails = (CustomUserDetails) userDetailsService.loadUserByUsername(username);
	
		if(!passwordEncoder.matches(password, customUserDetails.getPassword())) {
			throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
		}
				
		List<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
		
		return new UsernamePasswordAuthenticationToken(username,password,authorities);

	}

	
	@Override
	public boolean supports(Class<?> authentication) {
		return authentication.equals(UsernamePasswordAuthenticationToken.class);
	}

}
  • AuthenticationProvider 인터페이스의 구현체이다.
  • 요청에 대한 ID와 PW 값이 포함된 Authentication이라는 객체가 요청 파라미터 값으로 들어온다. 인증절차의 4번째 단계이다.
  • authentication 객체에서 username 값을 사용하여 userDetailsService.loadUserByUsername 메서드를 호출한다.
  • 참고로 userDetailsService 구현체 내에서는 username을 가진 admin 계정의 정보를 조회하는 기능을 한다.
  • 비밀번호가 일치하지 않을 경우 BadCredentialsException을, 일치할 경우 GrantedAuthority 리스트 내에 ROLE_ADMIN 권한을 추가시킨 후 UsernamePasswordAuthenticationToken 생성자를 호출해 return한다. 이 생성자를 호출할 경우 내부적으로 AbstractAuthenticationToken 객체를 생성하는데, 이 객체는 Authentication 객체의 구현체이다. 즉, 인증절차의 9번째 단계이다.
  • supprotes 메서드는 authenticate 메서드의 동작 여부를 결정한다. false를 return하면 동작하지 않는다. 위 코드에서는 Form 인증에 대한 authentication 객체. 즉 UsernamePasswordAuthenticationToken 자료형으로 요청이 들어올 경우에만 인증 절차를 진행한다.

* 필자의 경우 DB에서 계정에 대한 권한 정보를 조회하여 부여해주는 방식이 아닌, 비밀번호 일치 시 ADMIN 권한을 주도록 로직이 짜여져있다. 이 부분은 전자와 같이 동작하도록 로직을 수정이 필요함을 느꼈다. 만약 독자분들도 전자와 같이 구현하고자한다면 loadUserByUsername 메서드를 통해 권한 정보도 조회하도록 쿼리 및 CustomUserDetails 클래스 수정 후 "ROLE_ADMIN" 부분에 customUserDetails의 get 메서드로 권한 정보를 가져와서 넣어주면 될 것이다. 

 

5) CustomUserDetailsService

@Service
public class CustomUserDetailsService implements UserDetailsService{

	@Autowired
	private AdminRepository adminRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		UserDetails userDetails = adminRepository.getUserDetails(username);
		
		if(userDetails == null) {
			throw new UsernameNotFoundException("유효하지 않는 로그인 정보입니다.");
		}
		
		return userDetails;
	}

}
  • UserDetailsService 인터페이스의 구현체이다.
  • AdminRepository는 Mybatis에 대한 Mapper 인터페이스이다.
  • loadUserByUsername 메서드를 오버라이드한 후 AdminRepository 인터페이스를 사용해 admin 계정에 대한 id, pw 정보를 UserDetails 자료형으로 조회 후 리턴한다.

 

6) CustomUserDetails

@Component
public class CustomUserDetails implements UserDetails{

	private static final long serialVersionUID = 1L;

	private String username;
	private String password;
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// TODO Auto-generated method stub
		return null;
	}

	@Override
	public String getPassword() {
		return this.password;
	}

	@Override
	public String getUsername() {
		return this.username;
	}

	@Override
	public boolean isAccountNonExpired() {
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	public boolean isEnabled() {
		return false;
	}
}
  • UserDetails 인터페이스의 구현체이다.

 

 

7) AdminRepository

@Repository
public interface AdminRepository {

	public UserDetails getUserDetails(String username);
}


// AdminMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.cds.repository.AdminRepository">

    <select id = "getUserDetails" resultType = "CustomUserDetails">
    	SELECT
    		id AS username
    		,password
    	FROM
    		TB_ADMIN
    	WHERE
    		id = #{username}
    		AND use_yn = 'Y'
    </select>
</mapper>
  • AdminRepository 및 AdminMapper 쿼리이다.
  • returnType의 CustomUserDetails은 typeAlias를 사용해 네이밍을 간소화시켰다.

5. 테스트

1) 로그인 실패 시 

로그인 실패 시 로그
로그인 실패 시 리다이렉트

2) 로그인 성공 시

로그인 실패 시 로그

 

로그인 성공 시 리다이렉트


6. 마치며

  • 스프링 시큐리티 폼 인증 절차에 대해 개념을 다시 정리할 수 있었던 좋은 기회였다. 또한 내가 작성한 로직들을 포스팅을 통해 재확인해보니 수정할 곳도 몇몇 보였다. 한동안 이핑계 저핑계로 포스팅을 하지 않아 쌓여있는 내용이 엄청 많아졌다. 천천히 조금씩 정리해나가야겠다.
반응형
반응형

1. 개요

  • Connection을 생성한 상태에서 Controller, Service, Repository, Mapper 구조를 통해 DB 데이터를 Select 하자
  • Controller, Service, Repository 클래스에 대한 자세한 설명은 하지 않겠다.

2. 준비

  • postgreSQL DB의 Select 테이블
  • mybatis 연동 경험

3. Controller 생성

  • 먼저 클라이언트의 요청을 받을 Controller 클래스를 생성한다.
  • 필자는 각각 Controller, Service, Repository 모두 각각의 패키지를 만든 후 생성했다.
  • test-select로 오는 get 요청에 대해 testService 인터페이스 구현체 응답값을 리턴하도록 하였다.
@Controller
public class TestController {

	@Autowired
	private TestService testService;
	
	@GetMapping("/test-select")
	@ResponseBody
	public List<BoardDto.Info> testSelect(){
		
		return testService.testSelect();
	}
}

4. ServiceInterface 생성

  • 서비스 인터페이스를 생성한다.
public interface TestService {

	public List<BoardDto.Info> testSelect();
}

5. Service 구현체 생성

  • TestService Interface의 구현체 클래스를 생성한다.
  • TestRepository를 Autowired 한다.
@Service
public class TestServiceImpl implements TestService{

	@Autowired
	private TestRepository testRepository;
	
	@Override
	public List<Info> testSelect() {

		return testRepository.testSelect();
	}

}

6. TestRepository 인터페이스 생성

  • 리포지토리 인터페이스를 생성한다.
  • 이 클래스는 인터페이스이기때문에 @Repository만 입력 시 구현체가 없으므로 Service 클래스에서 TestRepository를 주입에 실패했다는 에러가 발생한다. @Mapper 어노테이션은 이러한 인터페이스를 mybatis의 매퍼로 등록해주기 위해 사용된다. 즉 Mapper Bean이 되는것이다.
@Repository
@Mapper
public interface TestRepository {

	public List<Info> testSelect();
}

 

  • 하지만 이렇게 Mapper 어노테이션을 명시적으로 선언하게 되면 생성되는 모든 Repository에 다 넣어줘야한다. 이게 귀찮다면 DatabaseConfig 클래스에 @MapperScan("패키지 경로") 어노테이션을 선언해주자. 그럼 패키지 경로에 포함된 인터페이스에 대해 @Mapper 어노테이션을 선언한 효과를 얻을 수 있다.
@Configuration
@MapperScan("com.modu.repository")
public class DatabaseConfig {

	...
	
}

7. TestMapper 생성

  • Mapper.xml을 생성한다.
  • 필자의 경우 Dto 클래스를 static inner class 형식으로 사용하기 때문에 resultType에 $가 포함되어 있다. 만약, static inner class를 사용하지 않는다면 패키지 경로를 넣어주면 된다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.modu.repository.TestRepository">

	<select id="testSelect" resultType ="com.modu.dto.BoardDto$Info">
		SELECT
			board_seq
			,cat_id
			,title
			,content
			,writer
			,create_date
			,update_date
		FROM
			tbl_board
	</select>
</mapper>

 


8. 테스트

응답 값

 


 

9. 마치며

  • hikariCP와 mybatis, postgreSQL을 연동해보았는데 그저 머릿속에 있는 DB 통신에 대한 패턴을 별 생각없이 구현했다. DB 설정 클래스의 bean들은 각각 역할을 하는지, Mapper.xml 파일에 등록한 쿼리는 내부적으로 어떻게 생성되어 실제 쿼리가 처리되는지, Repository 인터페이스와 Mapper의 id가 어떻게 매핑되는지가 궁금해졌다. 다음 포스팅에서는 이런 설정 하나하나가 시스템적으로 어떻게 돌아가는지 알아봐야겠다.
반응형
반응형

1. 개요

 - SpringBoot 환경에서 hikariCP, mybatis, PostgreSQL DB를 연동해보자!


2. 환경

 - SpringBoot

 - Gradle

 - PostgreSQL

 - mybatis

 - JDK 1.8


3. hikariCP란?

  • hikari : '빛'의 일본어, CP : ConnectionPool, 뭐다? 빛처럼 빠른 Connection Pool !(?) 이라는 의도로 히카리라고 지었는지는 모르겠지만, JDBC ConnectionPool 중 하나이다. (실제로 tomcat ConnectionPool 보다 성능이 좋다)
  • ConnectionPool이란, 서버 시작 시 DB와의 Connection을 일정 개수 생성하여 Pool에 저장시켜 놓는 기술이다. 그 후 DB에 접근할 일이 생기면 Pool에서 놀고있는 Connection을 가져다 쓰고, 사용이 끝나면 다시 반환시켜 놓는다. ConnectionPool을 사용하지 않으면 트랜잭션 요청이 들어올때마다 Connection을 맺는 작업을 해야하는데, 접속이 몰리게 되면 서버에 부하를 가져올 수 있다. 그래서 일반적으로 ConnectionPool 방식을 많이 사용한다.
  • 결론은 hikariCP란 JDBC ConnectionPool이며, 스프링 부트 2 버전에서는 jdbc 의존성 주입 시 기본적으로 요녀석을 제공할 만큼 똑똑하고 빠른놈이란걸 알 수 있다.

 4. 의존성 주입

    •  build.gradle에 postgreSQL, jdbc, mybatis에 대한 의존성을 추가해주자.
//postgreSQL
runtimeOnly 'org.postgresql:postgresql'

//jdbc (hikari)
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

//mybatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4'

5. hikari 설정

  • hikari 설정은 크게 DB 접속 정보Connection Pool 설정으로 구성된다. application.properties 파일에 다음과 같이 추가하도록 하자.
# DB 접속정보
spring.datasource.hikari.driver-class-name=org.postgresql.Driver
spring.datasource.hikari.jdbc-url=jdbc:postgresql://[URL]:5432/[DB명]
spring.datasource.hikari.username=[접속 ID]
spring.datasource.hikari.password=[접속 PW]
spring.datasource.hikari.pool-name=[PoolName - 임의]

# CP Setting
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=30
spring.datasource.hikari.idle-timeout=60000
  • DB 접속정보와 ConnectionPool 셋팅 정보를 입력해주었다. CP Setting 정보는 다음과 같다.
  • connection-timeout : 클라이언트가 Pool에 Connection을 요청했을 때 기다리는 시간(ms)이다. 만약 최대 50개의 Connection을 생성해둔 상태에서 50개의 요청이 한번에 들어올 경우 51번째 클라이언트는 텅텅 비어있는 Pool에서 유휴 Connection을 기다릴 수밖에 없다. 그 참을성에 대한 시간이다. 30초동안은 기다린다는 뜻이며, 만약 30초가 지날 경우 ConnectionTimeoutException이 throw된다. 이 경우 connection-timeout 값을 늘려주거나, maximum-pool-size를 늘려줘야한다.
  • maximum-pool-size : Pool에 저장할수 있는 Connection의 최대 개수이다.
  • minimum-idle : Pool에서 저장시켜야 할 Connection의 최소 개수이다. 서버 최초 기동 시 Pool에 Connection을 생성하는데 minimum-idle을 설정할 경우 maximum-pool-size만큼 생성하지 않고 minimum-idle개수만큼 생성한다. 30개는 유휴상태로 유지시키는 것이다. 만약 DB 통신 중 에러가 발생하여 한개의 Connection이 폐기되어 유휴 커넥션이 29개가 되면 이 개수를 맞추기 위해 1개의 Connection을 생성하게 된다.
  • idle-timeout : minimum-idle 개수를 넘어가게 되면 Connection을 사용하고 Pool에 반환하는게 아닌 폐기시킨다. 앞서 말했듯이 30개의 Connection만 유휴상태로 유지시키기 때문이다. 그런데 요청이 계속 들어오면 오히려 폐기하는 것보다는 유휴상태로 유지시키는 것이 효율적인 상황이다. minimum-idle 개수를 넘어간 상황에서 Connection 추가 생성 후 해당 커넥션을 일정 시간 유휴상태로 유지시키는 설정이 idle-timeout이다. 이 설정을 넣지 않으면 바로 폐기가 될까?라고 생각할 수 있지만 그것도 아니다. default 값이 60000이기 때문에 1분동안은 유지되다가 더이상 사용되지 않을 경우 폐기된다.

6. DatabaseConfig 클래스 생성

  • 의존성 주입 및 application.properties에 CP 설정을 마친 상태에서 서버를 기동하면 다음과 같은 에러가 발생한다.
    Consider the following:
    	If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
    	If you have database settings to be loaded from a particular profile you may need to activate it (the profiles oauth,prod are currently active).
     
  • H2, HSQL db에 대한 classpath를 추가해달란 것인데, 이 오류가 뜨는 이유는 앞서 설정했던 설정값들이 현재 어플리케이션에 적용이 되지 않아 default DB인 H2, HSQL로 셋팅이 되고, 실제 application.properties에는 이에 대한 설정값이 없기 때문에 발생하는 에러이다. DB 설정하는 Configuration 클래스를 만들면 해결이 된다.

 

  • DatabaseConfig.Class
package com.modu.config;


import javax.sql.DataSource;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@Configuration
public class DatabaseConfig {

	@Bean
	@ConfigurationProperties(prefix = "spring.datasource.hikari")
	public HikariConfig hikariConfig() {
		return new HikariConfig();
	}
	
	@Bean
	public DataSource dataSource() {
		return new HikariDataSource(hikariConfig());
	}
	
	@Bean
	public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception{
		final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
		sessionFactory.setDataSource(dataSource);
		PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
		sessionFactory.setMapperLocations(resolver.getResources("mapper/*.xml")); 	//mapper 파일 로드
		sessionFactory.setConfigLocation(resolver.getResource("mybatis-config.xml"));//mybatis-config 로드
		return sessionFactory.getObject();
	}
	
	@Bean
	public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) throws Exception{
		final SqlSessionTemplate sqlSessionTemplate = new SqlSessionTemplate(sqlSessionFactory);
		return sqlSessionTemplate;
	}
}
  • ConfigurationProperties(prefix = "spring.datasource.hikari") 구문은 prefix로 시작하는 properties 값들을 추출하여 특정 객체에 바인딩 시키는 어노테이션이다. 즉, hikari 관련 설정들을 HikariConfig Bean 객체 생성 시 바인딩을 시키는 것이다.  바인딩 시 완화된 규칙이 적용되어 있어 jdbcUrl이라는 변수에 바인딩 시 jdbc-url, jdbc_url도 정상적으로 바인딩된다. 실제로 bean 객체를 Autowired 하여 debug 해보면 properties에 설정한 데이터들이 객체화되어 들어가고 있음을 알 수 있다. 나머지 값들을 HikariConfig Bean 생성 시 기본으로 주입된 값들이다.

hikariConfig

  • DataSource를 생성할 때 위에서 만든 객체를 HikariDataSource의 생성자로 주입하면 히카리 CP에 대한 Datasource 객체가 생성된다.
  • mybatis 설정파일 및 mapper 파일을 로드하는 설정에 맞게 resource 경로에 mybatis-config.xml 파일과 mapper 폴더를 생성해준다.
  • 서버를 기동하면 정상적으로 기동됨을 확인할 수 있다. 추가적으로 로그가 설정되어 있다면 debug 레벨에 다음과 같이 Connection이 생성되었다는 로그를 확인할 수 있다.

Connection 생성 완료

 

실제 DB에서 데이터를 조회해보는 것은 다음 게시글에 포스팅하도록 하겠다.

반응형
반응형

1. 개요

 - 회사 로컬 PC에 STS 최신버전 (4.12.1) 설치 후 Lombok 연동 시 에러 발생


2. 환경

 - STS 4.12.1

 - Lombok 1.18.20


3. 에러 상황

 - 개인 PC에 설치한 STS에서는 Lombok이 정상기동되었음.

 - git을 통해 clone하여 소스 로드함.


4. 조치사항

 - 구글링을 통해 다음과 같은 조치를 하였으나 안먹힘

 1) SpringToolSuite4.ini의 마지막줄에 -javaagent:lombok.jar 코드 추가 (x)

 2) SpringToolSuite4.ini의 마지막줄에 -vmargs -javaagent:lombok.jar 코드 추가 (x)

 3) SpringToolSuite4.ini의 -vmargs 구문의 제일 위에 -javaagent:lombok.jar 코드 추가 (x)

 4) SpringToolSuite4.ini의 마지막줄에 --illegal-access=warn --add-opens java.base/java.lang=ALL-UNNAMED 추가 (x)
 5) PC 재부팅, 프로젝트 clean (x)


5. JDK 16의 보안정책 강화

 - JDK 16부터 보안정책이 강화되어 최신의 STS에 Lombok 라이브러리 연동 시 에러가 발생한다고 함.

https://github.com/projectlombok/lombok/issues/2810

 

[BUG] Unhandled event loop exception in Eclipse · Issue #2810 · projectlombok/lombok

After updating Eclipse to use Java 16, building projects gives an error. Install Lombok 1.18.20 in Eclipse, either through the update site or the jar (I tried both). If you used the update site, yo...

github.com


6. 해결

 - 최신버전 멈춰! STS 4.9.0 버전으로 재설치

 - 스무스하게 동작. 역시 최신버전보단 검증된 버전으로..

 

 

반응형
반응형

1. 개요

 - Git에서 관리하던 Gradle 프로젝트를 Clone한 후 빌드 시 org.gradle.wrapper.GradleWrapperMain가 발생하였다.


2. 원인

 - org.gradle.wrapper.GradleWrapperMain 패키지 클래스는 gradle-wrapper.jar 파일 내의 패키지이다. 이 파일이 프로젝트 내에 없었고, 그로 인해 위 클래스를 찾지 못해 발생한 에러였다. 어디선가 누락이 된것이다. 

 - 기본적으로 gradle-wrapper.jar는 '프로젝트 루트 디렉토리/gradle/wrapper' 경로에 있는데, 에러가 발생했던 프로젝트에는 이 파일이 누락되어 있었다.

gradle-wrapper.jar


3. 누락은 어디서?

 - 필자의 경우 gitignore에 *.jar 가 있었고, 그로인해 누락되었다. gitignore의 default 코드에 작성되어있어 신경을 쓰지 못한 것이 화근이었다.

gitignore


4. 해결

 - gradle.wrapper.jar파일을 gradle/wrapper 경로에 넣어주면 된다. 필자의 경우 다른 프로젝트에 있는 jar파일을 가져왔지만, 아래 링크를 통해 다운받아도 무관하다.

https://mvnrepository.com/artifact/org.gradle/gradle-wrapper/5.2.1

 

 

 

반응형
반응형

1. 개요

 - Git webhook을 사용하여 배포 자동화 시스템을 구축해보자.


2. 준비

 - Jenkins

 - Git 레포지토리

 - 스프링 부트 / Gradle 프로젝트 (jar파일 배포)

 


3. webhook이란?

 - webhook이란 원격 저장소의 소스에 push, commit 등의 이벤트가 발생하면 Jenkins와 같은 CI 서버에 해당 이벤트를 전달하는 기능입니다. Jenkins에서 이 이벤트 정보를 받아 리빌드, 배포와 같은 작업을 연계하여 진행해보도록 하겠습니다.

 


4. Item 생성

 4.1. Freestyle project를 생성합니다. 

freestyle 프로젝트 생성

 

 4.2. 설명 및 GitHub project url을 입력합니다. 실제로 여기에 입력한 Git 주소가 연동에 사용되진 않습니다.

 

 4.3. 소스코드 관리로 Git을 체크한 후, 실제로 연동할 Git 주소를 입력합니다. 그럼 인증을 하라는 에러가 발생하며,  Git 계정 인증 시 'password 방식'이 지원을 하지 않으니 'Token authentication 방식'을 사용하라고 합니다. 그럼 토큰을 만들어야겠죠? 토큰은 깃허브 홈페이지에서 만들 수 있습니다.

Git 주소 연동

 

 4.4. 하던 작업을 잠깐 멈추고 '깃허브 로그인 / settings / developer settings / Personal access tokens' 탭에 들어가 Generate new token 버튼을 선택합니다.

token 생성

 

 4.5. 리포지토리에 대한 전체 접근 권한 및 hook 권한을 체크한 후, Generate Token 버튼을 눌러 토큰을 생성합니다.

  * repo, admin:repo_hook을 체크하면 됩니다.

  * Expiration을 No expiration으로 설정하면 토큰 만료 기간 없이 계속 사용 가능합니다.

token 권한 설정

 

 4.6. 생성된 토큰 값을 확인 후 복사합니다.

 * 참고로 해당 페이지를 넘어가게 되면 현재 발급된 토큰 키값을 다시 확인할 수 없으며, 분실 시 재발급 받아야합니다.

토큰 값 확인 및 복사

 

 4.7. Jenkins로 돌아가 Credentials / Add를 선택 후 Kind에는 Username with password, UserName에는 GitHub ID, Password에는 복사한 토큰 키를 입력합니다. 아래 ID와 Description은 식별자와 설명입니다. 임의로 입력 후 Add를 누릅니다. 에러가 사라졌다면 인증이 완료된 것입니다.

자격증명 등록

 

 4.8. Branch는 Master로 설정합니다.

 

 4.9. 빌드 유발은 GitHub hook trigger for GITScm polling을 선택합니다. 이 옵션이 있어야 webhook을 통해 Jenkins로 들어온 push 이벤트 정보를 인식하여 빌드를 유발할 수 있습니다. 실제 빌드는 다음 Build 탭에서 설정합니다.

빌드 유발

 

 4.10. 필자의 경우 Gradle SpringBoot 프로젝트이기때문에 Invoke Gradle Script를 선택 후 Tasks에 bootJar를 입력했습니다. build.gradle 파일에는 다음 코드를 입력하여 modu.jar라는 파일명으로 빌드되도록 설정한 상태입니다.

 * jar 파일 빌드가 완료되면 빌드 후 조치 부분에 빌드된 jar 파일을 실행시키도록 하여 스프링 부트에 내장되어 있는 tomcat 서버를 통해 서비스를 기동할 예정입니다.

build.gradle

 

Build 설정

 

 * Gradle Version에서는 빌드에 Gradle을 지정해줘야하는데 이는 Global Tool Configuration 탭에서 설정이 가능합니다.

 

 4.11. 빌드 성공 시 start.sh라는 스크립트를 실행하도록 합니다. 해당 스크립트안에는 현재 실행되고 있는 프로세스가 있다면 kill 후 재시작하는 코드가 작성되어 있습니다. Script에 해당하는 경로에 start.sh 파일을 적절히 커스터마이즈하여 생성해줍시다.

 

 * start.sh

#!/bin/bash

echo "PID Check..."
CURRENT_PID=$(ps -ef | grep java | grep modu | awk '{print $2}')

echo "Running PID: {$CURRENT_PID}"

if [ -z ${CURRENT_PID} ] ; then
        echo "Project is not running"
else
        echo "Kill Current PID"
        kill -9 $CURRENT_PID
        sleep 10
fi

echo "Deploy Project...."
nohup java -jar /var/lib/jenkins/workspace/practice-project/build/libs/modu.jar >> /home/ec2-user/practice-script/modu.log &

echo "Done"

~

 

 

 이걸로 간단한 형태의 Item 생성 및 설정이 끝났습니다. 이제 마스터 브랜치로 push를 하여 자동으로 빌드 및 jar파일을 실행시켜 배포가 되는지 확인해보도록 하겠습니다.


5. Git Push

 5.1. 소스코드 수정 후 GitHub Desktop 프로그램을 사용해 commit / push를 진행합니다.

push !


6. Jenkins 자동 빌드 및 배포 확인

 6.1. Push를 하면 자동으로 Jenkins 빌드가 시작됩니다. Build History에서 확인이 가능합니다.

자동 Build

 6.2. 해당 빌드 선택 후 Console Output을 확인하면 빌드 로그를 확인할 수 있습니다. 확인 결과, 빌드에 성공하여 BUILD SUCCESSFUL를 뿌리고 있습니다. 그에 따라 start.sh 스크립트를 실행하는 로그도 확인되고 있습니다. 스크립트도 정상적으로 실행되었으니 실제로 서비스가 올라갔는지 확인해보도록 하겠습니다.

 

Build Log

 

 6.3. public DNS로 조회하여 서비스 확인 결과, 정상적으로 jar파일이 실행되어 tomcat 서비스가 올라간 것을 확인할 수 있습니다.

서비스 정상 기동 확인


7. 마치며...

 Jenkins를 사용하여 정말 간단한 구조의 DevOps 환경을 구성해보았습니다. SpringBoot / Gradle 프로젝트, Git을 통한 소스관리, Jenkins를 통한 자동 빌드 및 배포, Swagger를 통한 REST API Interface를 제공하게 되었네요.. 뿌듯..

 아무래도 프리티어 서버를 통해 작업을 진행하다 보니 중간중간 메모리 부족으로 인한 문제도 발생하였습니다. 간혹가다 빌드 시 OutofMemory가 발생하거나, 서버 자체가 꺼져버리는 현상이었습니다. 그럴때마다 AWS EC2 인스턴스를 재시작했었는데, 현재는 Swap Memory를 0 > 2GB로 늘려주어 해결한 상태입니다. 관련 포스팅 다음에 진행하여 링크를 남겨놓도록 하겠습니다!

반응형

+ Recent posts