자바 record·Optional·애노테이션: 프론트 개발자가 자바 코드를 읽기 시작할 때 (문법편)
자바 record와 Optional, 애노테이션이 뭔지, Entity와 DTO를 나누는 이유를 TypeScript에 빗대 정리했다. 프론트 개발자가 스프링 코드를 읽기 시작할 때 필요한 문법만.
목차
프론트만 하다가 백엔드 코드를 직접 고쳐야 하는 작업이 들어왔다.
로직은 이해되는데 문법이 안 읽혔다. 뭘 하는 코드인지는 알겠는데 @ 붙은 줄들이 뭔지, 왜 파일이 다섯 개나 되는지, new를 안 쓰는데 객체가 어디서 오는지를 몰랐다.
자바 강의를 처음부터 듣기엔 급하고, 검색하면 나오는 건 실무 코드와 모양이 달랐다. 실제로 고쳐야 하는 코드에 나온 것만 골라서 정리했다. TypeScript를 아는 상태 전제.
먼저 — 왜 파일이 이렇게 많은가
기능 하나에 파일이 다섯 개쯤 생긴다. 처음엔 이게 제일 당황스럽다.
Controller 요청을 받는다. 검증하고 Service 를 부른다.
Service 실제 로직. 여기가 본체.
Repository DB 접근. SQL 이 여기 붙는다.
Entity DB 테이블 한 줄에 대응하는 객체.
DTO API 로 주고받는 모양.
프론트로 치면 route handler → 비즈니스 로직 → API 클라이언트를 파일로 강제 분리한 거다.
나누는 이유는 각 층이 아는 것이 다르기 때문이다.
- Controller는 HTTP를 안다. DB는 모른다.
- Service는 도메인 규칙을 안다. HTTP도 DB도 모른다.
- Repository는 DB만 안다.
그래서 "SQL을 어디서 고치지" 같은 질문의 답이 항상 정해져 있다.
Entity와 DTO를 왜 나누나
같아 보이는 객체가 두 개 있어서 헷갈린다.
| Entity | DTO | |
|---|---|---|
| 뭐냐 | DB 테이블 한 줄 | API 요청/응답 모양 |
| 바뀌는 이유 | 스키마가 바뀔 때 | API 계약이 바뀔 때 |
| 담기는 것 | 컬럼 전부 (비밀번호 해시 포함) | 밖에 보여줄 것만 |
Entity를 그대로 응답으로 내보내면 안 되는 이유가 이거다. 컬럼 하나 추가했을 뿐인데 API 응답이 같이 바뀌고, 내부 값이 밖으로 샌다.
record — DTO는 거의 다 이것
public record RegisterRequest(
String userId,
String email
) {}
TypeScript의 type + 값이 안 바뀌는 객체다. 이 한 줄이 생성자·getter·비교 함수를 다 만들어준다.
var req = new RegisterRequest("hong", "a@b.com");
req.userId(); // getter 는 get 접두사 없이 필드명 그대로
// req.userId = "x"; ← 불가능. 만들고 나면 안 바뀐다
값이 안 바뀌는 게 핵심이다. 요청 객체가 중간에 바뀌면 추적이 안 되니까 아예 막는다.
옛날 방식으로 같은 걸 하려면 40줄쯤 된다. 필드마다 getter/setter를 손으로 쓴다. 그 노가다에서 실수가 난다 — 복사해놓고 필드명 안 바꾸는 것 같은. record가 그래서 생겼다.
Optional — "없을 수도 있음"을 강제로 열게 함
Optional<UserEntity> found = repository.findById(seq);
TypeScript의 User | undefined인데, 그냥 못 쓰고 반드시 열어야 한다.
// 없으면 예외 (제일 많이 쓴다)
var user = repository.findById(seq)
.orElseThrow(() -> new ApiException("사용자가 없습니다"));
// 없으면 그냥 끝
if (found.isEmpty()) return;
var user = found.get();
TypeScript의 ?.는 안 열어도 통과하지만 Optional은 안 열면 컴파일이 안 된다. null을 깜빡하는 걸 문법으로 막는 장치다.
애노테이션 — 제일 헷갈렸던 것
@ 붙은 줄이 사방에 있는데, 이건 자바 문법이 아니라 프레임워크가 읽는 메모다.
@RestController // Spring: 이 클래스는 HTTP 요청을 받는다
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/{seq}") // Spring: GET /api/user/{seq} 를 이 메서드로
public UserResponse get(@PathVariable int seq) { ... }
}
핵심은 이거다 — 애노테이션 자체는 아무 일도 안 한다. 그냥 표시다. 프레임워크가 시작할 때 클래스들을 훑어보고, 그 표시를 발견하면 그때 뭔가를 한다.
TypeScript 데코레이터와 같은 개념인데 자바에선 훨씬 많이 쓴다.
| 애노테이션 | 누가 읽나 | 무슨 뜻 |
|---|---|---|
@RestController @Service | Spring | 이 클래스를 관리 대상으로 등록 |
@GetMapping @PostMapping | Spring | URL ↔ 메서드 연결 |
@Transactional | Spring | 이 메서드를 트랜잭션으로 감싼다 |
@NotBlank @Email | 검증기 | 요청 값 규칙 |
@Mapper | MyBatis | 이 인터페이스를 SQL과 연결 |
@Test @Mock | 테스트 도구 | 테스트 메서드 / 가짜 객체 |
그 외 짧은 것들
var — 타입 추론. TypeScript와 거의 같다.
final — 재할당 금지. const와 비슷한데 자바에선 필드에 많이 붙는다.
enum — 정해진 값 목록. 문자열 대신 쓰면 오타를 컴파일 때 잡아준다.
제네릭 <> — TypeScript와 같다.
지금은 안 배워도 되는 것
시간이 한정돼 있으니 미룰 것도 정해두는 게 낫다.
- 상속·추상클래스·다형성 — 실무 스프링 코드에선 생각보다 안 쓴다
- 스레드·동시성 — 프레임워크가 처리한다
- 자바 8 이전 문법 — 요즘 코드엔 잘 안 나온다
- Gradle 심화 — 빌드가 깨졌을 때만 보면 된다
남길 것
- 파일이 여러 개인 건 각 층이 아는 것이 다르기 때문이다. 그래서 "어디를 고치지"의 답이 정해져 있다.
- Entity와 DTO를 나누는 이유는 "바뀌는 이유가 다르기" 때문이다.
- 애노테이션은 문법이 아니라 메모다. 지워도 컴파일은 되고 동작만 바뀐다.
- 읽기 연습은 이미 고쳐본 파일로 한다. 뭘 하는 코드인지 아는 상태에서 문법만 붙이면 되니까 훨씬 빠르다. 모르는 코드로 시작하면 로직과 문법을 동시에 풀어야 한다.
다음 글에서 Spring과 MyBatis, 테스트 코드를 정리한다.