[spring] final 키워드와 @RequiredArgsConstructor의 원리

2025. 10. 22. 22:20·공부일기../Spring

1. final 키워드의 의미와 중요성

1-1. 자바에서 final의 의미

자바에서 final 키워드는 "변경할 수 없다"는 의미를 가진다.

필드에 final을 붙이면 해당 필드는 한 번 초기화된 이후에는 다른 값으로 변경할 수 없다.

final 필드는 반드시 선언 시점이나 생성자에서 초기화되어야 한다.

public class Example {
    private final String name;  // 반드시 생성자에서 초기화해야 함
    
    public Example(String name) {
        this.name = name;  // 초기화
    }
    
    public void changeName(String newName) {
        this.name = newName;  // 컴파일 에러! final 필드는 변경 불가
    }
}

이 제약은 컴파일 타임에 강제된다.

final 필드를 변경하려는 시도가 있다면 코드가 아예 컴파일되지 않는다.

 

 

 


1-2. 의존성 주입에서 final을 사용하는 이유

의존성 주입 맥락에서 final을 사용하는 것은 매우 중요한 의미를 가진다.

 

첫째, 의존성이 변경되지 않는다는 것을 컴파일러 수준에서 보장한다. UserController가 UserService에 의존한다면, 이 의존 관계는 객체의 생명주기 동안 절대 변하지 않는다. 이 제약은 다른 사람의 코드를 읽을 경우에도 명확히 할수있다.

 

둘째, 멀티스레드 환경에서 안전성을 제공한다. final 필드는 JVM의 메모리 모델에 의해 취급된다.

Java Memory Model에 따르면, final 필드는 생성자가 완료된 후 다른 스레드에게 안전하게 보여진다. 추가적인 동기화 없이도 스레드 안전성이 보장된다.

 

셋째, 객체의 상태를 예측 가능하게 만든다. final 필드가 없는 객체는 언제든지 상태가 변경될 수 있어, 메서드 호출 시점에 따라 다른 동작을 할 수 있다. 하지만 final 필드로 구성된 불변 객체는 생성 시점의 상태를 계속 유지한다. 그렇기때문에 코드의 동작을 추론하기 쉽다.

 

 

 


1-3. final과 생성자 주입의 관계

final 필드는 생성자에서만 초기화할 수 있다. 이것이 바로 생성자 주입과 final이 찰떡으로 맞아떨어지는 이유이다!

필드 주입이나 세터 주입은 객체 생성 이후에 값을 주입하는 방식이므로 final을 사용할 수 없다.

오로지 생성자 주입만이 final 필드와 함께 사용될 수 있다.

// 생성자 주입 - final 사용 가능
public class UserController {
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;  // 생성자에서 초기화
    }
}

// 필드 주입 - final 사용 불가능
public class UserController {
    @Autowired
    private final UserService userService;  // 컴파일 에러!
    // 생성자에서 초기화하지 않았으므로 오류
}

스프링 공식 문서에서는 이러한 이유로 생성자 주입을 권장한다.

불변 객체를 만들 수 있고, 필수 의존성을 명확하게 표현할 수 있으며, 테스트하기 쉽기 때문이다.

 

 

 

 


2. Lombok의 @RequiredArgsConstructor

2-1. Lombok이란

Lombok은 자바의 보일러플레이트 코드를 줄여주는 라이브러리이다. 보일러플레이트 코드란 반복적으로 작성해야 하는 틀에 박힌 코드를 의미한다. 예를 들어 getter, setter, toString, equals, hashCode 같은 메서드들은 대부분의 클래스에서 비슷한 패턴으로 작성된다. Lombok은 어노테이션을 통해 이러한 코드들을 컴파일 타임에 자동으로 생성해준다.

 

Lombok은 애노테이션 프로세서(Annotation Processor)를 사용하여 동작한다. 컴파일 과정에서 소스 코드를 분석하고, 어노테이션에 따라 추가적인 코드를 생성한다. 이렇게 생성된 코드는 .class 파일에 포함되어 실제로 실행 가능하게 된다.

 

 

 


2-2. @RequiredArgsConstructor의 동작 원리

@RequiredArgsConstructor는 final 필드와 @NonNull 어노테이션이 붙은 필드를 매개변수로 받는 생성자를 자동으로 생성해준다.

 

실제 작성 코드 

@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;
    private final EmailService emailService;
    
    @GetMapping("/users")
    public List<User> getUsers() {
        return userService.getAllUsers();
    }
}

 

 

Lombok이 컴파일 타임에 생성하는 실제 코드

@RestController
public class UserController {
    private final UserService userService;
    private final EmailService emailService;
    
    // Lombok이 자동으로 생성한 생성자
    public UserController(UserService userService, EmailService emailService) {
        this.userService = userService;
        this.emailService = emailService;
    }
    
    @GetMapping("/users")
    public List<User> getUsers() {
        return userService.getAllUsers();
    }
}

이 생성자는 스프링의 생성자 주입 방식과 호환된다.

스프링 4.3 이상에서는 단일 생성자에 대해 @Autowired를 생략할 수 있으므로, 별도의 어노테이션 없이도 의존성 주입이 정상적으로 동작한다.

 

 

 


2-3. @RequiredArgsConstructor를 사용해야 하는 이유

@RequiredArgsConstructor는 단순히 코드를 줄여주는 것 이상의 의미가 있다.

 

첫째, 의존성이 추가되거나 제거될 때 생성자를 수동으로 수정할 필요가 없다. 새로운 final 필드를 추가하면 Lombok이 자동으로 생성자에 해당 매개변수를 추가해준다. 이는 리팩토링 시 실수를 줄여준다.

 

둘째, 코드의 의도를 명확하게 표현한다. @RequiredArgsConstructor와 final을 함께 사용하면 "이 클래스는 생성 시점에 이러한 의존성들이 반드시 필요하며, 이후로는 변경되지 않는다"는 의도가 명확하게 드러난다.

 

셋째, 일관성 있는 코드 스타일을 유지할 수 있다. 팀 내의 모든 클래스가 같은 패턴으로 의존성을 주입받으면, 코드를 읽고 이해하기 쉬워진다. 어떤 클래스는 생성자를 직접 작성하고, 어떤 클래스는 필드 주입을 사용하는 것보다 훨씬 일관성 있다.

 

 

 


3. 전체 레이어 구조에서의 의존성 주입

3-1. 일반적인 웹 애플리케이션의 레이어 구조

스프링 MVC 기반의 웹 애플리케이션은 일반적으로 세 가지 계층으로 구성된다. 프레젠테이션 계층(Controller), 비즈니스 로직 계층(Service), 데이터 접근 계층(Repository)이다.

 

각 계층은 명확한 책임을 가지며, 상위 계층이 하위 계층을 의존하는 구조를 가진다.

 

Controller는 HTTP 요청을 받아서 적절한 Service를 호출하고, 그 결과를 HTTP 응답으로 변환하는 역할을 한다. Service는 실제 비즈니스 로직을 수행하며, 트랜잭션 관리도 이 계층에서 이루어진다. Repository는 데이터베이스와의 통신을 담당하며, CRUD 작업을 추상화한다.

 

 

 

 


3-2. 실제 코드 예시

Repository 계층

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String name);
}

Spring Data JPA를 사용하면 인터페이스만 정의해도 스프링이 자동으로 구현체를 생성해준다.

이 인터페이스는 스프링 빈으로 등록되어 다른 계층에서 주입받을 수 있다.

 

 

Service 계층

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    
    @Transactional(readOnly = true)
    public List<User> findAllUsers() {
        return userRepository.findAll();
    }
    
    @Transactional(readOnly = true)
    public User findUserById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }
    
    @Transactional
    public User createUser(UserCreateRequest request) {
        User user = User.builder()
            .email(request.getEmail())
            .name(request.getName())
            .build();
        return userRepository.save(user);
    }
}

@Transactional 어노테이션을 통해 트랜잭션 경계를 설정한다.

readOnly 옵션을 사용하면 읽기 전용 트랜잭션으로 최적화할 수 있다.

 

 

Controller 계층

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;
    
    @GetMapping
    public ResponseEntity<List<UserResponse>> getAllUsers() {
        List<User> users = userService.findAllUsers();
        List<UserResponse> responses = users.stream()
            .map(UserResponse::from)
            .collect(Collectors.toList());
        return ResponseEntity.ok(responses);
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
        User user = userService.findUserById(id);
        return ResponseEntity.ok(UserResponse.from(user));
    }
    
    @PostMapping
    public ResponseEntity<UserResponse> createUser(@RequestBody @Valid UserCreateRequest request) {
        User user = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(UserResponse.from(user));
    }
}

 

 

 


3-3. 의존성의 방향과 원칙

레이어 구조에서 중요한 원칙은 의존성의 방향이다.

상위 계층은 하위 계층을 의존하지만, 하위 계층은 상위 계층을 알아서는 안 된다.

 

Controller는 Service를 알고, Service는 Repository를 알지만, Repository는 Service를 알지 못하고, Service는 Controller를 알지 못한다.

 

단일 책임 원칙과 의존성 역전 원칙을 따르는 것이다. 각 계층은 자신의 책임에만 집중하고, 구체적인 구현이 아닌 추상화에 의존한다. 예를 들어 UserService는 구체적인 UserRepositoryImpl이 아닌 UserRepository 인터페이스에 의존한다.

 

이렇게 하면 Repository의 구현체를 JPA에서 MyBatis로 바꾸더라도 Service 계층의 코드는 전혀 수정할 필요가 없다.

 

 

 


3-4. 스프링의 빈 생성 순서

앞서 설명한 레이어 구조에서 스프링은 다음과 같은 순서로 빈을 생성한다.

 

첫 번째로 UserRepository가 생성된다. 이는 다른 빈에 의존하지 않으므로 가장 먼저 생성될 수 있다. Spring Data JPA가 인터페이스를 기반으로 프록시 객체를 생성하여 빈으로 등록한다.

 

두 번째로 UserService가 생성된다. UserService는 생성자를 통해 UserRepository를 필요로 한다. 스프링은 이미 생성된 UserRepository 빈을 찾아서 UserService의 생성자에 주입한다. 이때 @RequiredArgsConstructor가 생성한 생성자가 사용된다.

 

세 번째로 UserController가 생성된다. UserController는 생성자를 통해 UserService를 필요로 한다. 스프링은 이미 생성된 UserService 빈을 찾아서 UserController의 생성자에 주입한다.

 

이러한 순서는 의존성 그래프를 분석하여 자동으로 결정된다. 개발자가 명시적으로 순서를 지정할 필요가 없다.

 

 

 


4. 다양한 상황에서의 @RequiredArgsConstructor 활용

4-1. 여러 의존성을 가진 경우

하나의 서비스가 여러 리포지토리나 다른 서비스들을 의존하는 경우도 흔하다.

이런 경우에도 @RequiredArgsConstructor는 매우 유용하다.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final PaymentService paymentService;
    private final EmailService emailService;
    
    @Transactional
    public Order createOrder(OrderCreateRequest request) {
        User user = userRepository.findById(request.getUserId())
            .orElseThrow(() -> new UserNotFoundException(request.getUserId()));
        
        Product product = productRepository.findById(request.getProductId())
            .orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
        
        Order order = Order.builder()
            .user(user)
            .product(product)
            .quantity(request.getQuantity())
            .build();
        
        Order savedOrder = orderRepository.save(order);
        paymentService.processPayment(savedOrder);
        emailService.sendOrderConfirmation(user.getEmail(), savedOrder);
        
        return savedOrder;
    }
}

만약 생성자를 직접 작성했다면 다섯 개의 파라미터를 받는 생성자를 작성해야 하고, 의존성이 추가되거나 제거될 때마다 생성자를 수정해야 한다.

하지만 @RequiredArgsConstructor를 사용하면 final 필드만 추가하면 나머지는 자동으로 처리된다.

 

 

 


4-2. 선택적 의존성과 필수 의존성 구분

때로는 필수 의존성과 선택적 의존성을 구분해야 할 때가 있다. 이런 경우 final 키워드의 유무로 구분할 수 있다.

@Service
@RequiredArgsConstructor
public class NotificationService {
    private final UserRepository userRepository;  // 필수 의존성
    private final EmailService emailService;      // 필수 의존성
    
    private SmsService smsService;  // 선택적 의존성 (final 없음)
    
    public void sendNotification(Long userId, String message) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));
        
        // 이메일은 항상 전송
        emailService.send(user.getEmail(), message);
        
        // SMS는 설정되어 있을 때만 전송
        if (smsService != null && user.getPhoneNumber() != null) {
            smsService.send(user.getPhoneNumber(), message);
        }
    }
    
    @Autowired(required = false)
    public void setSmsService(SmsService smsService) {
        this.smsService = smsService;
    }
}

@RequiredArgsConstructor는 final 필드만을 대상으로 생성자를 만들기 때문에, userRepository와 emailService만 생성자 파라미터로 포함된다.

smsService는 선택적이므로 세터 주입을 사용하고, required = false 옵션으로 해당 빈이 없어도 애플리케이션이 시작되도록 한다.

 

 

 


4-3. 상수와 함께 사용하기

클래스에 상수와 의존성이 함께 있는 경우도 있다.

@RequiredArgsConstructor는 final 필드만을 대상으로 하므로, 초기화된 상수는 생성자에 포함되지 않는다.

@Service
@RequiredArgsConstructor
public class UserService {
    private static final int MAX_LOGIN_ATTEMPTS = 5;  // 상수는 생성자에 포함 안됨
    private static final Duration TOKEN_EXPIRATION = Duration.ofHours(24);
    
    private final UserRepository userRepository;  // 생성자에 포함됨
    private final TokenService tokenService;      // 생성자에 포함됨
    
    public void validateLoginAttempts(User user) {
        if (user.getLoginAttempts() >= MAX_LOGIN_ATTEMPTS) {
            throw new AccountLockedException();
        }
    }
}

Lombok은 이미 초기화된 final 필드는 생성자 파라미터에서 제외한다.

따라서 MAX_LOGIN_ATTEMPTS와 TOKEN_EXPIRATION은 생성자에 포함되지 않고, userRepository와 tokenService만 생성자 파라미터가 된다.

 

 

 


5. @RequiredArgsConstructor의 주의사항

5-1. final 키워드를 반드시 붙여야 한다

@RequiredArgsConstructor를 사용할 때 가장 흔한 실수는 final 키워드를 빼먹는 것이다.

@Service
@RequiredArgsConstructor
public class UserService {
    private UserRepository userRepository;  // final이 없음!
    
    public List<User> findAll() {
        return userRepository.findAll();  // NullPointerException 발생!
    }
}

이 경우 Lombok은 생성자를 생성하지 않는다.

final 필드가 없으므로 매개변수가 없는 기본 생성자가 사용되고, userRepository는 null로 남게 된다. 이는 런타임에 `NullPointerException`을 발생시킨다.

따라서 @RequiredArgsConstructor를 사용할 때는 반드시 의존성 필드에 final 키워드를 붙여야 한다.

 

 

 


5-2. 순환 참조 문제

@RequiredArgsConstructor를 사용하더라도 순환 참조 문제는 여전히 발생할 수 있다.

두 서비스가 서로를 의존하는 경우이다.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;
    
    public void createOrder(Order order) {
        paymentService.processPayment(order);
    }
}

@Service
@RequiredArgsConstructor
public class PaymentService {
    private final OrderService orderService;
    
    public void processPayment(Order order) {
        orderService.updateOrderStatus(order);
    }
}

이 코드는 컴파일은 되지만 실행되지 않는다. 스프링이 OrderService를 생성하려면 PaymentService가 필요하고, PaymentService를 생성하려면 OrderService가 필요한 순환 고리가 만들어진다. 애플리케이션 시작 시 `BeanCurrentlyInCreationException`이 발생한다.

 

@RequiredArgsConstructor의 문제가 아니라 설계의 문제이다. 순환 참조가 발견되면 설계를 재검토해야 한다.

 

 

 


5-3. Lombok 의존성 추가 필요

@RequiredArgsConstructor를 사용하려면 프로젝트에 Lombok 라이브러리를 추가해야 한다.

 

Gradle의 경우

dependencies {
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

 

Maven의 경우 

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

또한 IDE에 Lombok 플러그인을 설치해야 자동 생성된 코드를 IDE가 인식할 수 있다.

IntelliJ IDEA의 경우 기본적으로 Lombok을 지원하지만, Eclipse는 별도의 설정이 필요할 수 있다!

'공부일기.. > Spring' 카테고리의 다른 글

[spring] @Component vs @Configuration 차이점  (0) 2025.10.24
[spring] 스프링 컨테이너와 스프링 빈  (1) 2025.10.15
[spring] 언제! 리팩토링해야 할까? 실전 가이드-유지보수성 문제 (파트 3/3)  (0) 2025.10.05
[spring] 언제! 리팩토링해야 할까? 실전 가이드- 코드 품질 문제 (2/3)  (0) 2025.10.04
[spring] 언제! 리팩토링해야 할까? 실전 가이드- 설계원칙 위반 (1/3)  (0) 2025.10.03
'공부일기../Spring' 카테고리의 다른 글
  • [spring] @Component vs @Configuration 차이점
  • [spring] 스프링 컨테이너와 스프링 빈
  • [spring] 언제! 리팩토링해야 할까? 실전 가이드-유지보수성 문제 (파트 3/3)
  • [spring] 언제! 리팩토링해야 할까? 실전 가이드- 코드 품질 문제 (2/3)
s0-0mzzang
s0-0mzzang
공부한것을 기록합니다...
  • s0-0mzzang
    승민이의..개발일기..🐰
    s0-0mzzang
  • 전체
    오늘
    어제
    • 전체~ (108)
      • 마음가짐..! (10)
      • 공부일기.. (76)
        • weekly-log (6)
        • Spring (19)
        • Java (18)
        • DataBase (10)
        • git (2)
        • JPA (6)
        • kafka (1)
        • Backend Architecture (3)
        • Troubleshooting (삽질..ㅋ) (2)
        • Cloud (1)
        • Docker (2)
        • 알고리즘 (1)
        • 리액트 (2)
        • Infra (3)
      • 하루일기.. (22)
        • 그림일기 (8)
        • 생각일기 (14)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
  • 공지사항

  • 인기 글

  • 태그

    다짐
    BufferedReader
    MySQL
    리팩토링
    인프라 기초
    Paging
    ERD
    ADC 환경
    swagger
    JPA
    spring boot
    TDD
    자바
    항해플러스
    SpringBoot
    spring
    스프링부트
    React
    항해99
    StringTokenizer
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
s0-0mzzang
[spring] final 키워드와 @RequiredArgsConstructor의 원리
상단으로

티스토리툴바