[백엔드 입문 #2] TypeScript와 비교하며 읽은 Java 문법
지난 글에서 API 요청 하나가 백엔드에서 처리되는 흐름을 따라갔다. 이번에는 그 코드를 읽으며 헷갈렸던 Java 문법을 TypeScript와 비교하며 정리한다. 새 예제는 가져오지 않고, 지난번에 본 피처 플래그 코드를 문법 관점에서 다시 읽었다.
① 파일 = 클래스 하나, package / import
package com.example.wms.usecase.featureflag; // 이 파일의 "주소"
import com.example.wms.domain.featureflag.FeatureFlag;
public class DeleteFeatureFlag { // 파일 이름도 반드시 DeleteFeatureFlag.java
| Java | TS |
|---|---|
package a.b.c; |
폴더 경로를 코드에 적는 것 |
import a.b.c.FeatureFlag; |
import { FeatureFlag } from '@/a/b/c/FeatureFlag' |
public class X 하나 = X.java 파일 하나 |
파일마다 export default가 하나 있는 느낌 |
가장 낯설었던 점: Java에서는 모든 코드가 반드시 클래스 안에 있어야 한다. TS처럼 파일 최상단에 const helper = () => {}를 쓸 수 없다.
② 타입은 변수 이름 앞에
Long userId = command.getUserId();
String code = "NEW_PICKING";
boolean exists = false;
const userId: number = command.getUserId();
| Java | TS | 비고 |
|---|---|---|
String |
string |
|
Long, Integer |
number |
대문자: null 가능 |
long, int |
number |
소문자: null 불가, 기본값 0 |
boolean |
boolean |
|
List<T> |
T[] |
|
Optional<T> |
T \| undefined |
|
LocalDateTime |
Date (dayjs) |
Long과 long의 차이가 중요하다. Entity의 id는 저장 전에는 비어 있을 수 있어서 Long을 쓴다.
③ 접근 제어자: public / private
public class FeatureFlag {
private Long id; // 클래스 밖에서 접근 불가
public void delete(Long userId) { ... } // 누구나 호출 가능
private void validateNotActive() { ... } // 클래스 안에서만 호출 가능
}
필드는 거의 항상 private이고, 값을 바꾸는 건 메서드로만 한다. Redux 상태를 직접 수정하지 않고 action으로만 바꾸게 하는 것과 같은 의도다.
④ 생성자와 this
public FeatureFlag(FeatureFlagBasicInformation basicInformation, String code, Long createdBy) {
this.basicInformation = basicInformation;
this.createdBy = createdBy;
this.registeredAt = LocalDateTime.now();
}
TS의 constructor와 같고, 이름이 클래스 이름과 같다는 점만 다르다. 사용할 때는 new FeatureFlag(info, "CODE", 1L). 여기서 1L은 Long 타입 숫자 1이다.
⑤ final과 static
private final FindFeatureFlag findFeatureFlag; // final → TS readonly
private static final String ERROR_CODE = "feature-flag/active"; // static final → 상수
final: 한 번 넣으면 못 바꾼다.static: 객체마다가 아니라 클래스에 하나만 존재한다.static final: 모듈 최상단의const ERROR_CODE = '...'와 같은 상수. 대문자 스네이크 케이스로 쓴다.
⑥ record: 데이터만 담는 클래스
public record RegisterFeatureFlagRequest(String identifierCode, String identifierName, String description) {}
이 한 줄이 생성자, 읽기 메서드, 비교(equals)를 모두 만든다.
type RegisterFeatureFlagRequest = Readonly<{
identifierCode: string;
identifierName: string;
description: string;
}>;
주의할 점: 값을 꺼낼 때 request.identifierCode()처럼 괄호를 붙인다. 반면 Lombok @Getter를 쓴 일반 클래스는 command.getCode()처럼 get이 붙는다.
⑦ interface / implements / extends
public interface ExistFeatureFlag {
boolean existFeatureFlag(String code); // 시그니처만 있음
}
public class FeatureFlagRepository implements ExistFeatureFlag, FindFeatureFlag { ... }
public class ActiveFeatureFlagException extends AppRuleException { ... }
| Java | 의미 |
|---|---|
interface |
이런 메서드가 있어야 한다는 약속 |
implements A, B |
그 약속을 지키겠다 (여러 개 가능) |
extends X |
부모 클래스를 물려받음 (하나만 가능) |
TS와의 큰 차이: Java는 이름으로 타입을 판단한다(명목적 타이핑). TS는 모양만 맞으면 통과하지만, Java는 implements ExistFeatureFlag라고 명시하지 않으면 메서드가 똑같이 생겨도 그 타입으로 인정하지 않는다.
⑧ 제네릭
public AppResponse<Void> delete(...)
public AppResponse<GetAllFeatureFlagResult> getAll()
Optional<FeatureFlag>
List<UserFeatureFlagRelation>
TS 제네릭과 같다. AppResponse<T>는 ApiResponse<T>, Void는 “data 없음”. 제네릭 안에는 대문자 타입만 넣을 수 있어서 void가 아니라 Void를 쓴다.
⑨ 람다와 메서드 참조
() -> new NotFoundFeatureFlagException(id) // TS: () => new NotFoundError(id)
findUserFeatureFlagRelation::findByFeatureFlag // TS: (flag) => repo.findByFeatureFlag(flag)
화살표가 =>가 아니라 ->다.
Optional과 함께 쓰는 패턴도 자주 나온다.
return findFeatureFlag.findFeatureFlag(id)
.orElseThrow(() -> new NotFoundFeatureFlagException(id));
const flag = find(id);
if (!flag) throw new NotFoundError(id);
return flag;
⑩ 어노테이션
@Entity, @Transactional, @NotNull, @Getter… 모두 “이 코드를 이렇게 다뤄줘”라는 표시이고, 실제 동작은 Spring·JPA·Lombok 같은 도구가 해준다. 코드만 봐서는 무슨 일이 일어나는지 안 보이는 경우가 많아서, 백엔드 입문이 어렵게 느껴지는 가장 큰 이유인 것 같다.
연습: Java 코드 읽어 보기
public class FeatureFlagStatusChecker {
private static final String ON = "ON";
private final FindFeatureFlag findFeatureFlag;
public FeatureFlagStatusChecker(FindFeatureFlag findFeatureFlag) {
this.findFeatureFlag = findFeatureFlag;
}
public boolean isOn(String code) {
Optional<FeatureFlag> flag = findFeatureFlag.findFeatureFlag(code);
return flag.map(f -> f.getDetailInformation().getStatus().name().equals(ON))
.orElse(false);
}
}
내가 읽은 내용: 피처 플래그를 조회해서, 없으면 false, 있으면 status를 꺼내 “ON”과 비교한다.
TS로 옮기면 이렇다.
class FeatureFlagStatusChecker {
private static readonly ON = 'ON';
constructor(private readonly findFeatureFlag: FindFeatureFlag) {}
isOn(code: string): boolean {
const flag = this.findFeatureFlag.findFeatureFlag(code);
return (flag?.getDetailInformation().getStatus().name() === FeatureFlagStatusChecker.ON) ?? false;
}
}
enum은 메서드를 가질 수 있다
getStatus()는 문자열이 아니라 enum을 돌려준다. .name()으로 "ON" 문자열로 바꾼 뒤 비교한 것이다.
public enum FeatureFlagStatus { ON, OFF; ... }
Java enum은 메서드도 가질 수 있어서, 실제 코드에서는 getStatus().isOn()처럼 썼다. 이쪽이 더 안전하다. 문자열 비교는 오타("On")가 나도 조용히 false가 되지만, 없는 메서드를 부르면 컴파일 에러가 난다.
함정: 문자열 비교에 == 를 쓰면 안 된다
Java의 ==는 같은 객체를 가리키는지를 비교한다. 내용은 보지 않는다.
String a = new String("ON");
String b = new String("ON");
a == b // false ← 다른 객체
a.equals(b) // true ← 내용이 같음
TS에서 객체를 비교할 때와 같은 원리다.
{ v: 'ON' } === { v: 'ON' } // false
TS에서 문자열은 원시값이라 ===로 비교하면 되지만, Java에서는 String도 객체다.
| 비교 대상 | 사용 |
|---|---|
int, long, boolean (소문자 원시 타입) |
== |
String, Long, 그 밖의 객체 |
.equals() |
| enum | ==도 괜찮음 (값마다 객체가 하나뿐) |
특히 Long이 위험하다. Long a = 100L, b = 100L;이면 a == b가 true인데, 1000L이면 false가 나온다. Java가 -128~127 범위만 캐싱하기 때문이다. id 비교에서 실제로 자주 나오는 버그라고 한다.
다음 글에서는 new를 한 번도 안 썼는데 객체가 들어오는 이유, Spring의 의존성 주입(DI) 을 정리한다.