지난 글에서 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) 을 정리한다.