본문 바로가기
Development/Spring Boot

스프링(부트)에서 final이 아닌 필드(인스턴스 변수, 클래스 변수)가 있으면 안됩니다.

by Nahwasa 2024. 4. 4.

목차

     

      이 내용은 당연한 것 같으면서도 은근히 코드리뷰할 때나 얘기할 때 한번씩 보이는 것 같아서 글로 적어보게 되었다. 개인 생각을 적은것이니 다른 의견 혹은 틀린 부분이 있으면 알려주세요. 제목을 좀 더 구체적으로 적은 이 글의 결론은 다음과 같다.

     

     

    결론

      스프링 혹은 스프링부트로 만든 프로젝트에서 @Component, @Controller, @Service, @Repository 와 같이 스프링 컨테이너에 등록되는 클래스에 Bean 주입 이외 용도의 필드(인스턴스 변수, 클래스 변수)가 있으면 안됩니다. 혹시 들어가야 한다면 final이어야 합니다. final 이더라도 그게 객체라면 불변임이 보장되는게 좋습니다.

     

      물론 POJO로 된 도메인 계층 등 스프링 컨테이너에 안들어가는 애들은 상관없습니다. POJO라도 final로 가능한건 항상 붙여주는게 좋습니다.

     

     

    용어 정의

    • 필드 : 클래스에서 메소드 외부에 선언된 변수. 클래스 변수 + 인스턴스 변수
    • 클래스 변수 : static 키워드로 선언된 필드
    • 인스턴스 변수 : static이 안붙은 필드

     

     

    클래스 변수가 있는 경우

      이하와 같은 코드를 보자. Controller에 클래스 변수가 있고, 컨트롤러 호출 시 cnt가 1개 증가한 후 그 값을 리턴해서 출력해주고 있다.

    @RestController
    @RequestMapping("/cnt")
    public class NhsController {
    
        private static int cnt = 0;
    
        @GetMapping
        public ResponseEntity<String> increase() {
            cnt++;
            
            return ResponseEntity.ok("cnt : " + cnt);
        }
    }

     

     

      서로 다른 세션에서 해당 서버를 접근하더라도 서버는 하나이고, static으로 된 필드는 메소드 영역에 메모리가 잡히니 당연히 공유된다.

     

      자바 메모리 구조를 메소드 영역, 힙 영역, 스택 영역으로 생각해보자. 프로그램이 실행될 때 static 필드는 메소드 영역에 잡힐 것이고, 컨트롤러는 클래스이므로 인스턴스화 될 때 힙 영역에 잡힐 것이다. 그리고 increase()가 불릴 때 스태틱 영역에 할당되어 실행될 것이고, cnt는 메소드 영역에 존재하므로 서로 다른 세션이더라도 공유한다.

     

     

    static이 없는 인스턴스 변수라면?

      그럼 이번엔 아래의 코드를 봐보자. '1'과 동일한 코드인데, static이 빠진 상태이다. private static int cnt = 0

    @RestController
    @RequestMapping("/cnt")
    public class NhsController {
    
        private int cnt = 0;
    
        @GetMapping
        public ResponseEntity<String> increase() {
            cnt++;
    
            return ResponseEntity.ok("cnt : " + cnt);
        }
    }

     

     

      이럼 단순히 자바 기준으로만 생각해보면 아래와 같이 동작될 것 같다. 그럼 예상되는 출력값은 세션이 다르더라도 처음 호출시엔 전부 '1'이 떠야될꺼같다.

     

      근데 실제로 돌려보면 공유된다.

     

     

      이유는 스프링 컨테이너가 싱글턴으로 bean을 관리하기 때문이다. 한마디로 말하면 저 NhsController는 new가 한 번만 불린다. 즉, 메모리 그림을 그려보면 다음과 같이 그려지므로, static이 없더라도 인스턴스 변수인 cnt는 공유된다.

     

     

      물론 스프링 컨테이너에 포함될 클래스에 @Scope("prototype") 를 붙여주면 싱글턴으로 동작하지 않아서 매번 불릴 때 마다 new를 하므로 이 경우엔 공유되지 않는다. 근데 스프링으로 짜는 프로젝트에 저런걸 붙여서 만들 필요가 있다면 애초에 뭔가 잘못된 설계 혹은 잘못된 선택을 한게 아닌가 생각된다.

     

     

    공유되는게 뭐가 문제일까?

      기본적으로 API는 stateless를 지향해야 하므로, 일단 필드로 상태를 저장한다는 것 부터가 문제긴 하다. 근데 어쨌든 필요에 의해 공유 자원으로 둔거고, 현재 상황을 정확히 인지하고 있다면 뭐 상관은 없다. 예를들어 작은 사이즈로 만들어 로드밸런서 같은 것 없이 서버 한대만 두는 상태에서 그냥 심플하게 해당 컨트롤러의 API 호출 횟수 세는 용도 정도라면 괜찮을 것 같다. 어차피 모든 설계는 트레이드오프이고, 중요한건 명분과 팀의 합의다.

     

      근데 이하의 코드가 정상적으로 동작할거라고 생각한다면 문제가 좀 크다. 이유는 공유되는걸 보여줬으니 쉽게 알 수 있을꺼라 생각된다. A가 receive로 currentCode를 변경하고 -> B가 receive로 currentCode를 변경하고 -> A가 do-something을 부르면 B의 code를 받게된다.

    @RestController
    @RequestMapping("/")
    public class NhsController {
    
        private String currentCode;
    
        @GetMapping("/receive")
        public ResponseEntity<String> receiveCode(@RequestParam String code) {
            currentCode = code;
    
            return ResponseEntity.ok("currentCode : " + currentCode);
        }
    
        @GetMapping("/do-something")
        public ResponseEntity<String> doSomething() {
            currentCode += " end!";
    
            return ResponseEntity.ok("currentCode : " + currentCode);
        }
    }

     

      

      위는 문제됨이 너무 자명하다. 그럼 이건 어떨까? 필드에 code를 넣고 그냥 그대로 return한다.

    @RestController
    @RequestMapping("/")
    public class NhsController {
    
        private String currentCode;
    
        @GetMapping("/receive")
        public ResponseEntity<String> receiveCode(@RequestParam String code) {
            currentCode = code;
    
            return ResponseEntity.ok("currentCode : " + currentCode);
        }
    }

     

     

      이제 이런 코드가 언젠가 100% 오류가 나면서도, 뭐가 문젠지 찾을 수 없는 심연의 코드가 되는거다. 왜냐면 아무리 테스트 해봐도 에러가 안날꺼다. 테스트 해본다고 막 1000명 동시 접속을 가정하고 테스트해봐도 에러가 잘 안날꺼다. 근데 언젠가 반드시 에러가 날 수 밖에 없다. 동접자가 많을수록 확률은 올라가며, 언젠가 우연히 서로 완전히 동시에 접근한 2명이 있을 경우, currentCode = code; 후 바로 return하는 저 짧은 순간 사이에도 공유 자원인 currentCode가 변경될 수 있다.

     

    여기까지 밑줄친 부분을 확인했다. 그럼 이제 빨간색 부분도 확인해보자.

      스프링 혹은 스프링부트로 만든 프로젝트에서 @Component, @Controller, @Service, @Repository 와 같이 스프링 컨테이너에 등록되는 클래스에 Bean 주입 용도의 필드(인스턴스 변수, 클래스 변수)가 있으면 안됩니다.  혹시 들어가야 한다면 final이어야 합니다. final 이더라도 그게 객체라면 불변임이 보장되는게 좋습니다.

     

     

    필드가 꼭 들어가야 한다면 final이어야 한다.

      위에서 봤던 코드에서 final이 붙으면 어떻게 될까? private final String currentCode

    @RestController
    @RequestMapping("/")
    public class NhsController {
    
        private final String currentCode;
    
        @GetMapping("/receive")
        public ResponseEntity<String> receiveCode(@RequestParam String code) {
            currentCode = code;
    
            return ResponseEntity.ok("currentCode : " + currentCode);
        }
    }

     

     

      이 경우 당연히 에러가 난다. final은 선언 하면서 초기화 되거나, 생성자에서 넣어져야 한다. 저렇게 receiveCode()에서 변경할 수 없다. 그럼 어떻게 저 로직을 구현할까? 사실 내 경우 Controller 내에서 Bean 주입 목적 이외에 지금까지 final이 붙지 않은 필드가 필요했던 적이 한 번도 없다. 즉, 애초에 저런게 들어가야만 한다면 뭔가 잘못짠건 아닌지부터 생각해봐야 한다. API는 stateless를 지향해야 하고, 그럼 애초에 필드를 저런식으로 쓰는건 하지 말아야 한다. 위와 같은 단순한 경우라면 그냥 아래처럼 필드 빼버리고 짜면 된다.

    @RestController
    @RequestMapping("/")
    public class NhsController {
        
        @GetMapping("/receive")
        public ResponseEntity<String> receiveCode(@RequestParam String code) {
            return ResponseEntity.ok("currentCode : " + code);
        }
    }

     

     

      즉, 혹시라도 Bean에 필드가 들어가야 하는 상황이면 일단 final을 붙여보자. 그 상태로 에러나서 구현이 안되는 경우가 대부분일 것이다. 그럼 다른 방법을 생각해보자. 애초에 내가 이해하고 있는 코드 설계가 잘못된거다.

     

      final인 애도 정말 필요한건지 확인을 해야 한다. 보통은 필요없을거다. 목적으로 잡을만한건 상수값 표현 밖에 없는데, 상수도 보통은 별도 클래스나 다른 방법을 사용해 받아오지 Controller 같은 곳에 고정으로 박혀있는 경우는 잘 없는 것 같다.

     

     

    @Autowired 필드 주입, @Value를 지양해야 하는 하나의 이유

      위에서 얘기한게 @Autowired 필드 주입과 @Value를 지양해야 하는 이유 중 하나이기도 하다. 둘의 공통점은 final을 붙일 수 없다는 점이다. 아래 두개는 모두 에러가 난다(위에서 얘기햇듯 final은 변수 선언 시 초기화 되거나 생성자에서 초기화 되어야 한다).

    @Autowired
    private final NhsService nhsService;
    
    @Value("${server.port}")
    private final String port;

     

     

      @Autowired 필드 주입 대신 생성자 주입을 쓰면 final을 붙일 수 있다. 아래처럼 될꺼다.

    @RestController
    @RequestMapping("/")
    public class NhsController {
    
        private final NhsService nhsService;
    
        @Autowired
        public NhsController(NhsService nhsService) {
            this.nhsService = nhsService;
        }
    }

     

     

      이 때 생성자가 하나라면 생성자 주입 시 붙인 @Autowired는 생략해도 되므로 그냥 아래처럼 쓰면 된다. 혹시 생성자 주입을 하면서 final을 안쓴다면 장점을 많이 퇴색시키는거니 반드시 붙이자.

    @RestController
    @RequestMapping("/")
    public class NhsController {
    
        private final NhsService nhsService;
    
        public NhsController(NhsService nhsService) {
            this.nhsService = nhsService;
        }
    }

     

     

      @Value 대신 @ConstructorBinding을 쓰면 final을 붙일 수 있다. 이건 별도로 설정이 필요해서, 사용해보려면 구글링 해서 사용법을 확인해보자.

    private final String port;
    
    @ConstructorBinding
    public ServerPortProperties(String port) {
        this.port = port;
    }
    
    public String port() {
        return port;
    }

     

     

    final 이더라도 객체라면 불변임이 보장되는게 좋다 

      다음과 같이 객체가 들어간 final 변수에서 값이 변경될 일이 있을까? String은 내부 데이터를 바꿀 수 없는 불변이므로 변경될 일이 없다.

    private final String url = "nahwasa.com";

     

     

      그럼 이건 어떨까? list = new ArrayList<>(); 이런건 final이라 안되지만, list.add("dd"); 이런건 다 된다. 즉, list 주소값 변경에 대한 final이지 list 내부 데이터는 어차피 객체 내의 일이므로 불변이라고 할 수 없다.

    private final ArrayList<String> list;

     

     

      그럼 불변 List를 쓰면 어떨까? Collections.unmodifiableList() 로 생성하거나(단, 이 경우 원본 리스트 말고 새로 생성된 리스트만 불변), List.of로 생성하는 등의 방법으로 불변 리스트로 생성할 수 있다. 이럼 list 자체도 새로 만들 수 없고 내부 데이터도 변경 불가하므로 좀 더 알맞게 만들어졌다고 볼 수 있다.

    private final List<String> list;
    
    public NhsController(List<String> list) {
        this.list = Collections.unmodifiableList(list);
    }

     

    private final List<String> list = List.of("nahwasa.com", ":)");

     

     

      그럼 이런건 어떨까? list를 아무리 불변 리스트를 쓰더라도 결국 저 Position 객체가 불변이 아니면 또 의미가 없어진다. 그러니 이런 부분들을 이해하고 정확히 제어할 수 있어야 원치않은 에러를 줄일 수 있다.

    private final List<Position> list;

     

    댓글