프로필

데브고래밥

@devgoraebap

스택오버플로우의 단골손님이였던 Claude를 채찍질하는 개발자

Album Art

0:00 0:00
방문자 정보

요즘 관심있는

61
0
#java #spring

Spring Boot 개발기록 #2 [claude 요약]

 Lombok, JPA 트랜잭션, 전역 예외 처리, Component Scan 등의 내용을 NestJS와 비교하며 설명하는 글이다.

1. @RequiredArgsConstructor - 생성자 자동 생성

NestJS와 비교

NestJS:

@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}
}

Spring (Lombok 없이):

@RestController
public class TodoController {
    private final TodoService todoService;

    // 생성자를 직접 작성해야 함!
    public TodoController(TodoService todoService) {
        this.todoService = todoService;
    }
}

Spring (Lombok 사용):

@RestController
@RequiredArgsConstructor  // ← final 필드의 생성자를 자동 생성!
public class TodoController {
    private final TodoService todoService;
}

→ NestJS의 constructor(private readonly ...)와 동일한 효과!


2. @Transactional(readOnly = true) - 클래스 레벨

개념

클래스 레벨에 선언하면 해당 클래스의 모든 메서드가 기본적으로 읽기 전용 트랜잭션으로 실행됩니다.

NestJS 비유 (Prisma)

// Spring의 @Transactional(readOnly = true)와 유사
await prisma.$transaction(async (tx) => {
  // 읽기 전용 모드 - 성능 최적화
  const todos = await tx.todo.findMany();
}, { isolationLevel: 'ReadCommitted' });

효과

  • 성능 최적화: Hibernate가 변경 감지(Dirty Checking)를 하지 않음
  • DB 최적화: 읽기 전용 힌트를 DB에 전달
  • 기본값 설정: 클래스 내 모든 메서드가 읽기 전용 트랜잭션

3. 메서드의 @Transactional - 쓰기 가능 트랜잭션

Spring 예시

@Service
@Transactional(readOnly = true)  // 클래스 기본값: 읽기 전용
public class TodoService {

    public List<Todo> findAll() {
        // 읽기 전용 트랜잭션 사용
    }

    @Transactional  // ← 메서드는 쓰기 가능으로 오버라이드!
    public TodoResponseDto create(TodoCreateDto dto) {
        // 쓰기 가능 트랜잭션
        todoRepository.save(todo);
    }
}

NestJS 비유

@Injectable()
export class TodoService {
  // 읽기만
  async findAll() {
    return this.prisma.todo.findMany();
  }

  // 쓰기 필요 - 트랜잭션 시작
  async create(dto: CreateTodoDto) {
    return this.prisma.$transaction(async (tx) => {
      return tx.todo.create({ data: dto });
    });
  }
}

차이점: Spring은 명시적으로 트랜잭션 경계를 지정, NestJS는 보통 자동 커밋


4. private final - 불변 필드 선언

NestJS와 비교

NestJS:

@Injectable()
export class TodoService {
  constructor(
    private readonly todoRepository: TodoRepository  // ← 이것과 동일!
  ) {}
}

Spring:

@Service
@RequiredArgsConstructor
public class TodoService {
    private final TodoRepository todoRepository;  // ← 동일한 의미!
}

공통점

  • private: 클래스 내부에서만 접근
  • final (Java) = readonly (TypeScript): 한 번 할당되면 변경 불가
  • 생성자 주입으로 할당됨

5. 의존성 자동 인식 - Component Scan

NestJS: 명시적 등록

// app.module.ts
@Module({
  controllers: [TodoController],  // ← 명시적 등록
  providers: [TodoService],        // ← 명시적 등록
})
export class AppModule {}

Spring Boot: 자동 스캔

// 별도 설정 불필요!
@RestController  // ← 자동 스캔
public class TodoController {
    private final TodoService todoService;
}

@Service  // ← 자동 스캔
public class TodoService {
    private final TodoRepository todoRepository;
}

interface TodoRepository extends JpaRepository<Todo, Long> {}  // ← 자동 생성 + 등록

Spring Boot의 Component Scan 동작 원리

  1. @SpringBootApplication 어노테이션이 있는 메인 클래스를 찾음
  2. 해당 패키지 및 하위 패키지를 자동 스캔
  3. @Component, @Service, @RestController, @Repository 등을 찾아서 자동 등록
  4. 생성자를 보고 의존성을 자동으로 주입

메인 클래스 예시

@SpringBootApplication  // ← 이게 ComponentScan을 포함!
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

NestJS vs Spring Boot 비교

  NestJS Spring Boot
등록 @Module에 명시적 등록 자동 스캔 (어노테이션만 달면 됨)
DI Container Module 기반 ApplicationContext (전역)
스캔 범위 Module별로 제한 패키지 기반 전체 스캔

→ Spring은 더 "관례 중심(Convention over Configuration)"입니다!


추가 학습한 내용

Java Record

  • Java 16에서 정식 도입된 불변 데이터 클래스
  • DTO에 적합
  • Lombok @Builder와 함께 사용 가능
  • getter가 getTitle()이 아닌 title() 형태

Hibernate 타임스탬프 자동 생성

  • @CreationTimestamp: 생성 시간 자동 기록
  • @UpdateTimestamp: 수정 시간 자동 기록
  • Spring Data JPA의 @CreatedDate/@LastModifiedDate보다 간단 (설정 불필요)

Swagger 어노테이션 간소화

  • RequestBody에 대한 긴 Swagger 어노테이션은 대부분 불필요
  • Spring의 @RequestBody만으로도 Swagger가 자동 인식
  • DTO 레벨에서 @Schema 어노테이션으로 문서화하는 것이 재사용성 높음

6. Optional 언박싱 - orElseThrow() 패턴

문제 상황

JPA Repository의 findById()Optional<T>를 반환합니다.

Optional<Todo> todo = todoRepository.findById(id);
todo.update(dto);  // ❌ 컴파일 에러! Optional에는 update 메서드가 없음

해결 방법

방법 1: orElseThrow() - 추천

@Transactional
public void update(Long id, TodoUpdateDto dto) {
    Todo todo = todoRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("게시물을 찾을 수 없습니다."));

    todo.update(dto);
    // save() 불필요 - JPA Dirty Checking으로 자동 저장
}

방법 2: isEmpty() + get() - 비추천

Optional<Todo> optional = todoRepository.findById(id);
if (optional.isEmpty()) {
    throw new NotFoundException("게시물을 찾을 수 없습니다.");
}
Todo todo = optional.get();

NestJS와 비교

NestJS:

const todo = await this.todoRepository.findById(id);
if (!todo) {
  throw new NotFoundException('게시물을 찾을 수 없습니다.');
}
todo.update(dto);

Spring:

Todo todo = todoRepository.findById(id)
    .orElseThrow(() -> new NotFoundException("게시물을 찾을 수 없습니다."));
todo.update(dto);

JPA Dirty Checking

@Transactional 안에서 조회한 엔티티의 필드를 변경하면:

  • 트랜잭션 종료 시 Hibernate가 자동으로 변경 감지
  • 자동으로 UPDATE 쿼리 실행
  • repository.save() 호출 불필요!

7. 전역 예외 처리 - @RestControllerAdvice

NestJS vs Spring Boot 예외 처리

NestJS:

// exception.filter.ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception.getStatus();

    response.status(status).json({
      statusCode: status,
      message: exception.message,
      timestamp: new Date().toISOString(),
    });
  }
}

// main.ts
app.useGlobalFilters(new HttpExceptionFilter());

Spring Boot:

@RestControllerAdvice  // ← NestJS의 @Catch()와 유사
public class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFoundException(
        NotFoundException ex,
        WebRequest request
    ) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", LocalDateTime.now());
        body.put("status", HttpStatus.NOT_FOUND.value());
        body.put("error", ex.getMessage());
        body.put("path", request.getDescription(false).replace("uri=", ""));

        return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
    }
}

Checked Exception vs Unchecked Exception

왜 Controller에서 throws 선언이 필요 없을까?

Java에는 두 가지 예외 타입이 있습니다:

  1. Checked Exception (Exception을 상속)

    • 컴파일러가 처리를 강제함
    • 메서드 시그니처에 throws 선언 필수
    • 예: IOException, SQLException, BadRequestException
  2. Unchecked Exception (RuntimeException을 상속)

    • 컴파일러가 처리를 강제하지 않음
    • throws 선언 불필요
    • 예: NullPointerException, IllegalArgumentException

커스텀 예외 설계:

// Unchecked Exception으로 만들기
public class NotFoundException extends RuntimeException {
    public NotFoundException(String message) {
        super(message);  // ← RuntimeException을 상속
    }
}

Checked Exception 예시:

// Service
public void update(Long id, TodoUpdateDto dto) throws BadRequestException {
    // BadRequestException은 Exception을 상속 (Checked)
}

// Controller - throws 선언 필수!
public void update(...) throws BadRequestException {
    todoService.update(id, dto);
}

Unchecked Exception 예시:

// Service
public void update(Long id, TodoUpdateDto dto) {
    throw new NotFoundException("...");  // RuntimeException 상속
}

// Controller - throws 선언 불필요!
public void update(...) {
    todoService.update(id, dto);  // ✅ 컴파일 에러 없음
}

GlobalExceptionHandler의 동작 원리

  1. Controller/Service에서 예외 발생
  2. Spring이 예외를 캐치
  3. @RestControllerAdvice가 붙은 클래스에서 @ExceptionHandler 메서드 찾기
  4. 매칭되는 예외 타입의 핸들러 실행
  5. 핸들러가 반환한 ResponseEntity를 클라이언트에 전송

장점:

  • Controller에 throws 불필요 (Unchecked Exception 사용 시)
  • 일관된 에러 응답 형식
  • 중복 코드 제거
  • 모든 예외를 한 곳에서 관리

실무 패턴

// 커스텀 예외들 (모두 RuntimeException 상속)
public class NotFoundException extends RuntimeException {}
public class UnauthorizedException extends RuntimeException {}
public class ForbiddenException extends RuntimeException {}

// GlobalExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<?> handleNotFound(...) { /* 404 */ }

    @ExceptionHandler(UnauthorizedException.class)
    public ResponseEntity<?> handleUnauthorized(...) { /* 401 */ }

    @ExceptionHandler(ForbiddenException.class)
    public ResponseEntity<?> handleForbidden(...) { /* 403 */ }
}

8. Path Variable vs Request Param

잘못된 사용

@PutMapping(":id")  // ❌ 잘못된 문법
public void update(@RequestParam Long id, ...) {
    // @RequestParam은 쿼리 파라미터용
    // PUT /api/todos?id=1 형태로 요청해야 함
}

올바른 사용

@PutMapping("/{id}")  // ✅ 중괄호 사용
public void update(@PathVariable Long id, ...) {
    // PUT /api/todos/1 형태로 요청
}

NestJS와 비교

NestJS:

@Put(':id')
update(@Param('id') id: number, @Body() dto: UpdateDto) {
  // PUT /api/todos/1
}

@Get()
search(@Query('keyword') keyword: string) {
  // GET /api/todos?keyword=spring
}

Spring Boot:

@PutMapping("/{id}")
public void update(@PathVariable Long id, @RequestBody UpdateDto dto) {
    // PUT /api/todos/1
}

@GetMapping
public List<Todo> search(@RequestParam String keyword) {
    // GET /api/todos?keyword=spring
}
  Path Variable Query Parameter
NestJS @Param('id') @Query('keyword')
Spring @PathVariable @RequestParam
URL 패턴 /todos/{id} /todos?id=1
용도 리소스 식별 필터링/검색