[Spring] Lombok, Validation, Service 설계, MapStruct, CRUD 흐름

Raha·2026년 3월 21일

Spring

목록 보기
5/6

이전 글에서 Spring MVC 흐름, JPA, 영속성 컨텍스트까지 살펴봤다. 이번 글에서는 그 위에 실전 코드를 올린다.

다음 세 가지 질문을 중심으로 이야기를 풀어간다.

  • 반복적인 Getter/Setter/생성자 코드를 어떻게 없앨 수 있을까?
  • 잘못된 데이터가 Service까지 도달하기 전에 어디서 막아야 할까?
  • Entity와 DTO 간 변환 코드가 수십 군데 흩어지는 문제를 어떻게 해결할까?

1. Lombok: 반복 코드를 지우는 어노테이션

Java 클래스를 만들면 거의 자동으로 따라오는 코드들이 있다. Getter, Setter, 생성자, toString. 이 코드들은 비즈니스 로직과 직접적인 관련이 없다. 객체를 만들기 위한 준비 코드일 뿐이다.

Lombok은 그 준비 코드를 어노테이션 하나로 대신 써준다.

// Before Lombok
public class UserDto {
    private String name;
    private String email;

    public String getName() { return name; }
    public String getEmail() { return email; }
    public UserDto(String name, String email) { ... }
}
// After Lombok
@Getter
@RequiredArgsConstructor
public class UserDto {
    private final String name;
    private final String email;
}

@Setter를 지양하는 이유

Setter가 열려있으면 코드 어디서든 객체의 상태를 바꿀 수 있다.

User user = userRepository.findById(1L);
// ... 코드 100줄 ...
user.setStatus("DELETED"); // 누가? 왜? 여기서?

버그가 생겼을 때 "이 값이 어디서 바뀐 거지?"를 추적하는 게 매우 어려워진다. 실무에서는 "객체는 한 번 만들어지면 가능한 한 바뀌지 않아야 한다"는 불변성(Immutability) 원칙을 선호한다. Setter 대신 Builder를 사용하는 게 그 대안이다.

@Builder: Setter 없이 안전하게 객체 생성

생성자 방식의 문제는 명확하다. 파라미터 순서를 외워야 하고, 빠진 값이 있어도 컴파일 에러가 나지 않는다.

// 이게 뭔지 바로 알 수 있는가?
new User("홍길동", "hong@email.com", "010-1234-5678", "ACTIVE", 25);

Builder 패턴을 쓰면 필드 이름을 명시하며 값을 설정하기 때문에 훨씬 명확하다.

User user = User.builder()
    .name("홍길동")
    .email("hong@email.com")
    .phone("010-1234-5678")
    .status("ACTIVE")
    .age(25)
    .build();

@Builder는 클래스 또는 생성자에 붙일 수 있다. 두 방식의 차이는 "얼마나 자유롭게 만들게 할 것인가"에 있다.

대상추천 방식
DTO, Response 객체클래스에 @Builder — 모든 필드를 자유롭게 채워야 하는 경우
Entity, 도메인 객체생성자에 @Builder — 필수 값 강제, 생성자 안에 검증 로직 추가 가능

생성자에 @Builder를 붙이면 검증 로직도 함께 넣을 수 있다.

@Builder
public Product(String name, double price) {
    if (price < 0) throw new IllegalArgumentException("가격은 음수일 수 없습니다.");
    this.name = name;
    this.price = price;
}

객체가 만들어지는 순간부터 올바른 상태를 보장할 수 있다.

@Data는 쓰지 않는다

@Data@Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor를 한 번에 묶은 어노테이션이다. 편리하지만 실무에서는 기피한다.

이유는 단순하다. @Setter가 딸려오기 때문이다. 거기에 JPA 엔티티에 @ToString을 사용하면 양방향 연관관계에서 순환 참조가 발생해 StackOverflowError로 앱이 죽는다.

// 이렇게 하면 Order.toString() -> User.toString() -> Order.toString() ... 무한 반복
@Entity @Data public class Order { @ManyToOne private User user; }
@Entity @Data public class User  { @OneToMany private List<Order> orders; }

필요한 어노테이션만 명시적으로 조합해서 쓰는 것이 훨씬 안전하다.

@Data            → 쓰지 않는다
@Setter          → 꼭 필요한 경우만
@Getter          → 자유롭게
@Builder         → 객체 생성에 적극 활용
@RequiredArgsConstructor → DI에 적극 활용

2. Validation: Controller 진입점의 방어막

검증에는 두 종류가 있다.

계층검증 종류
Controller형식 검증 — 이메일 형식이 맞는가? 값이 비어있지 않은가?
Service비즈니스 검증 — 이미 가입된 이메일인가? 재고가 충분한가?

형식이 잘못된 데이터는 Controller에서 바로 막는 게 맞다. Service까지 도달시킬 필요가 없다.

동작 원리

검증 규칙은 DTO 클래스의 필드에 선언한다. Controller에서는 @Valid 하나만 붙이면 된다.

// 규칙 선언 — DTO
public class UserRequest {
    @NotBlank(message = "이름은 필수입니다.")
    @Size(min = 2, max = 50)
    private String name;

    @NotBlank
    @Email(message = "이메일 형식이 아닙니다.")
    private String email;

    @Pattern(regexp = "^010-\\d{4}-\\d{4}$")
    private String phone;
}
// 실행 트리거 — Controller
@PostMapping
public ApiResponse<UserResponse> create(@Valid @RequestBody UserRequest request) {
    return ApiResponse.ok(userService.save(request)); // 검증은 이미 끝났다
}

역할이 깔끔하게 분리된다.

UserRequest → "나는 이런 형식이어야 해" (규칙 선언)
@Valid      → "이 규칙대로 검증해줘"   (실행 트리거)
Controller  → 비즈니스 로직에만 집중

@Valid 검증이 실패하면 Spring이 자동으로 MethodArgumentNotValidException을 발생시킨다. GlobalExceptionHandler가 이를 가로채 400 Bad Request 응답을 반환한다. Controller와 Service는 이 예외를 직접 처리할 필요가 없다.

주요 어노테이션

어노테이션설명
@NotBlanknull + 빈 문자열 + 공백 전부 막음. 문자열 필드에 가장 안전
@Size(min, max)문자열 길이, 컬렉션 크기 검증
@Min / @Max숫자 최솟값 / 최댓값 검증
@Positive양수만 허용
@Email이메일 형식 검증
@Pattern(regexp)정규식으로 형식 검증. 전화번호 등에 활용
@Future / @Past날짜가 미래 / 과거인지 검증

3. Service 계층 설계: get vs find, 순환 참조

Service 계층은 애플리케이션의 두뇌다. 비즈니스 규칙을 처리하고, 계층 간 데이터를 가공한다.

get vs find: 메서드 이름이 계약이다

메서드 이름은 해당 메서드의 "동작 계약"을 나타낸다.

get...  → "반드시 찾아드립니다. 없으면 예외를 던집니다."
find... → "찾아보겠습니다. 없을 수도 있으니 직접 처리하세요."
// get → 없으면 예외
public User getUserById(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new DomainException(NOT_FOUND_USER));
}

// find → Optional 반환, 호출한 쪽에서 처리
public Optional<User> findUserByEmail(String email) {
    return userRepository.findByEmail(email);
}

이메일 중복 확인은 find다. 없는 게 정상일 수 있기 때문이다. 로그인한 사용자 조회는 get이다. 반드시 존재해야 하기 때문이다.

순환 참조: 설계 문제의 신호

@Service
public class OrderService {
    private final UserService userService; // UserService 주입
}

@Service
public class UserService {
    private final OrderService orderService; // OrderService 주입
}

Spring이 앱 시작 시점에 Bean을 생성하지 못한다. 서로가 서로를 기다리기 때문이다.

OrderService 만들려면 → UserService 필요
UserService 만들려면  → OrderService 필요
OrderService 만들려면 → ...

이는 단순히 기술적인 오류가 아니라 두 서비스의 책임이 명확히 분리되지 않았다는 설계 문제의 신호다. 해결책은 다른 도메인의 Service 대신 Repository를 직접 주입받는 것이다.

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserRepository userRepository; // UserService 대신 직접 주입

    public void create(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new DomainException(NOT_FOUND_USER));
        // ...
    }
}

4. MapStruct: 객체 변환 자동화

계층을 분리하면 Entity, DTO, Request, Response 등 다양한 객체를 사용하게 된다. 이때 객체 간 변환 코드가 반드시 발생한다.

수동으로 작성하면 두 가지 문제가 생긴다. 첫째, Entity에 필드가 추가될 때마다 변환 코드가 있는 모든 곳을 찾아 수정해야 한다. 둘째, 실수로 필드 매핑을 누락해도 컴파일 에러가 나지 않는다.

MapStruct는 인터페이스만 정의하면 컴파일 시점에 구현체를 자동 생성해준다.

기본 사용법

@Mapper(componentModel = "spring")
public interface UserMapper {
    UserResponse toResponse(User user);
    User toEntity(UserRequest request);
}

Service에서는 주입받아 한 줄로 사용한다.

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final UserMapper userMapper;

    public UserResponse getUser(Long id) {
        User user = getUserById(id);
        return userMapper.toResponse(user); // 한 줄로 끝
    }
}

필드 이름이 다를 때

Entity와 DTO의 필드명이 다를 경우 @Mapping으로 규칙을 지정한다.

@Mapper(componentModel = "spring")
public interface ProductMapper {
    @Mapping(target = "name", source = "productName") // productName -> name
    ProductResponse toResponse(Product product);
}

중첩 객체의 필드는 점(.)으로 경로를 지정한다.

@Mapping(target = "username", source = "user.name") // order.getUser().getName()
OrderResponse toResponse(Order order);

5. CRUD 전체 흐름: 요청부터 응답까지

지금까지 배운 모든 것을 사용자 등록(POST /api/users) 하나로 조립한다.

Client (JSON)
      |
      v
DispatcherServlet -> HandlerMapping -> HandlerAdapter
      |
      v
Controller
  - @RequestBody  : JSON -> UserRequest 변환
  - @Valid        : 형식 검증 (실패 시 400 Bad Request)
      |
      v
Service
  - findByEmail   : 이메일 중복 확인 (비즈니스 검증)
  - passwordEncoder: 비밀번호 암호화
  - userMapper    : DTO -> Entity
      |
      v
Repository
  - save()        : DB 저장
      |
      v
Service
  - userMapper    : Entity -> DTO
      |
      v
Controller
  - ApiResponse.ok : 201 Created 응답
      |
      v
Client (JSON)

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED) // 성공 시 201 반환
    public ApiResponse<UserResponse> create(@Valid @RequestBody UserRequest request) {
        return ApiResponse.ok(userService.save(request));
    }
}

Service

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserResponse save(UserRequest request) {
        // 비즈니스 검증
        if (userRepository.findByEmail(request.getEmail()).isPresent()) {
            throw new DomainException(DUPLICATE_EMAIL);
        }

        String encodedPassword = passwordEncoder.encode(request.getPassword());
        User user = userMapper.toEntity(request, encodedPassword);
        return userMapper.toResponse(userRepository.save(user));
    }
}

Repository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email); // Query Method로 자동 구현
}

JpaRepository를 상속받으면 save(), findById(), findAll() 등 기본 CRUD 메서드가 이미 내장되어 있다. 추가로 필요한 쿼리만 메서드 이름 규칙에 맞게 선언하면 된다.


마치며

오늘 다룬 내용을 한 줄씩 정리한다.

개념핵심
Lombok반복 코드를 어노테이션으로 자동 생성. @Data는 쓰지 않는다
@BuilderSetter 없이 필드 이름을 명시하며 안전하게 객체 생성
ValidationController 진입점에서 형식 검증. 규칙은 DTO 필드에 선언
Service 설계get/find 네이밍 계약, 순환 참조 방지로 계층 책임 분리
MapStructEntity ↔ DTO 변환을 컴파일 시점에 자동 생성
profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글