Skip to main content
Summary

스프링을 위한 자바 문법만 먼저 보자

스프링을 배우기 전에 자바 문법을 전부 완벽히 외울 필요는 없습니다. 다만 스프링 예제에서 자주 보이는 문법은 알아야 합니다. 코드를 읽을 때 class, new, interface, List<Post>, Optional<Post>, stream() 같은 표현이 계속 나오기 때문입니다.

이번 장의 목표는 자바 문법 자체를 깊게 파는 것이 아닙니다. 앞으로 만들 게시판 API 코드에서 필요한 만큼만 빠르게 익히는 것입니다. 문법을 하나씩 보고, 마지막에는 게시판 서비스 코드에 연결해 보겠습니다.

스프링 학습에 필요한 자바 핵심 문법AI 생성 이미지

클래스는 설계도, 객체는 실제 물건이다

자바 코드는 대부분 클래스 안에 작성합니다. 클래스는 설계도와 비슷합니다. 예를 들어 게시글을 표현하려면 Post라는 설계도를 만들 수 있습니다.

public class Post {
    private Long id;
    private String title;
    private String content;

    public Post(Long id, String title, String content) {
        this.id = id;
        this.title = title;
        this.content = content;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }
}

여기서 Post는 클래스입니다. id, title, content는 게시글이 가진 값입니다. 이런 값을 필드라고 부릅니다.

클래스를 실제로 사용하려면 객체를 만들어야 합니다.

Post post = new Post(1L, "첫 번째 글", "스프링을 공부합니다.");

System.out.println(post.getTitle());

new Post(...)는 Post 설계도를 바탕으로 실제 게시글 객체를 만드는 코드입니다. getTitle()은 객체가 가진 제목을 꺼내는 메서드입니다.

정리하면 다음과 같습니다.

  • 클래스: 객체를 만들기 위한 설계도

  • 객체: 클래스로 만든 실제 대상

  • 필드: 객체가 가진 데이터

  • 메서드: 객체가 할 수 있는 동작

  • 생성자: 객체가 만들어질 때 처음 실행되는 특별한 코드

생성자는 클래스 이름과 같습니다. 일반 메서드처럼 보이지만 반환 타입을 쓰지 않는다는 점이 다릅니다.

public Post(Long id, String title, String content) {
    this.id = id;
    this.title = title;
    this.content = content;
}

this.id는 "이 객체의 id 필드"라는 뜻입니다. 오른쪽의 id는 생성자로 전달받은 값입니다. 즉, 전달받은 값을 객체 내부 필드에 넣는 코드입니다.

스프링 컴포넌트도 결국 클래스다

앞에서 만들었던 컨트롤러도 사실은 자바 클래스입니다.

@RestController
public class HelloController {

    @GetMapping("/")
    public String hello() {
        return "게시판 API 서버가 실행 중입니다.";
    }
}

처음 보면 @RestController, @GetMapping 때문에 특별해 보입니다. 하지만 기본 구조는 단순합니다.

public class HelloController {
    public String hello() {
        return "게시판 API 서버가 실행 중입니다.";
    }
}

여기에 스프링이 알아볼 수 있는 표시를 붙인 것이 애너테이션입니다. 애너테이션은 코드에 붙이는 메모나 라벨이라고 생각하면 됩니다.

  • @RestController: 이 클래스는 웹 요청을 처리하는 컨트롤러라는 표시

  • @GetMapping("/"): / 주소로 GET 요청이 오면 이 메서드를 실행하라는 표시

스프링 코드를 읽을 때는 먼저 자바 구조를 보세요.

  1. 어떤 클래스인가?

  2. 어떤 필드를 가지고 있는가?

  3. 어떤 생성자가 있는가?

  4. 어떤 메서드가 실행되는가?

  5. 그 위에 어떤 애너테이션이 붙어 있는가?

이 순서로 보면 훨씬 덜 복잡합니다. 애너테이션부터 해석하려고 하면 낯선 단어가 너무 많이 보입니다. 먼저 "이것도 자바 클래스구나"라고 생각하고, 그다음 스프링 표시를 하나씩 읽으면 됩니다.

인터페이스는 약속이다

게시판에는 글을 저장하고 조회하는 기능이 필요합니다. 이때 바로 구현 클래스를 만들 수도 있지만, 스프링에서는 인터페이스를 자주 사용합니다.

인터페이스는 "이런 기능을 제공해야 한다"는 약속입니다. 실제 동작은 구현 클래스가 맡습니다.

import java.util.List;
import java.util.Optional;

public interface PostRepository {
    Post save(Post post);

    List<Post> findAll();

    Optional<Post> findById(Long id);
}

이 인터페이스는 세 가지 약속을 합니다.

  • save: 게시글을 저장한다.

  • findAll: 모든 게시글을 조회한다.

  • findById: id로 게시글 하나를 찾는다.

하지만 여기에는 실제 저장 코드가 없습니다. 실제 코드는 구현 클래스에서 작성합니다.

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class MemoryPostRepository implements PostRepository {

    private final List<Post> posts = new ArrayList<>();
    private long nextId = 1L;

    @Override
    public Post save(Post post) {
        Post savedPost = new Post(nextId++, post.getTitle(), post.getContent());
        posts.add(savedPost);
        return savedPost;
    }

    @Override
    public List<Post> findAll() {
        return new ArrayList<>(posts);
    }

    @Override
    public Optional<Post> findById(Long id) {
        return posts.stream()
                .filter(post -> post.getId().equals(id))
                .findFirst();
    }
}

implements PostRepository는 "이 클래스는 PostRepository의 약속을 지킨다"는 뜻입니다. @Override는 인터페이스에 있는 메서드를 구현했다는 표시입니다.

findAll()에서 new ArrayList<>(posts)를 반환한 것도 눈여겨볼 만합니다. 내부에서 관리하는 posts 리스트를 그대로 밖으로 내보내면, 다른 코드가 그 리스트를 직접 수정할 수 있습니다. 지금은 작은 예제라 큰 문제는 아니지만, 복사본을 돌려주면 저장소 내부 데이터가 함부로 바뀌는 일을 조금 더 줄일 수 있습니다.

인터페이스를 쓰는 이유는 느슨한 결합을 만들기 위해서입니다. 느슨한 결합은 콘센트와 플러그의 관계와 비슷합니다. 노트북은 벽 안의 전선 구조를 몰라도 됩니다. 정해진 모양의 콘센트에 꽂기만 하면 됩니다.

서비스 코드도 마찬가지입니다.

public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }
}

PostService는 MemoryPostRepository를 직접 알지 않습니다. 대신 PostRepository라는 약속만 압니다. 나중에 메모리 저장소를 데이터베이스 저장소로 바꾸더라도, PostService 코드는 크게 흔들리지 않습니다.

여기서 final은 한 번 넣은 값을 바꾸지 않겠다는 뜻입니다. 스프링에서는 의존성을 생성자로 주입받고 final 필드에 담는 코드를 자주 사용합니다. 다음 장들에서 이 형태를 계속 보게 됩니다.

제네릭은 상자에 붙이는 이름표다

다음 코드를 봅시다.

List<Post> posts = new ArrayList<>();

List는 여러 값을 순서대로 담는 컬렉션입니다. 컬렉션은 여러 데이터를 담는 자료구조입니다. 장바구니에 여러 상품을 담는 것과 비슷합니다.

List<Post>에서 <Post>는 이 리스트에 Post 객체를 담겠다는 뜻입니다. 이것을 제네릭이라고 합니다. 제네릭은 상자에 붙이는 이름표와 같습니다.

List<String> names = new ArrayList<>();
names.add("spring");
names.add("java");

List<Post> posts = new ArrayList<>();
posts.add(new Post(1L, "제목", "내용"));

List<String>에는 문자열을 담고, List<Post>에는 게시글을 담습니다. 이렇게 타입을 미리 정하면 잘못된 값을 넣는 실수를 줄일 수 있습니다.

스프링에서 Repository나 Service 코드를 보면 이런 형태가 자주 나옵니다.

public List<Post> findAll() {
    return posts;
}

이 메서드는 "게시글 목록을 돌려준다"는 뜻입니다. 반환 타입이 List<Post>이므로, 이 메서드를 호출하는 쪽에서도 "아, 게시글 여러 개가 오겠구나"라고 읽을 수 있습니다.

Optional은 없을 수도 있는 값을 안전하게 다룬다

게시글 id로 글을 찾을 때, 항상 글이 존재한다고 보장할 수 없습니다. 사용자가 없는 id를 요청할 수도 있습니다. 이때 자바에서는 null을 사용할 수 있지만, null은 실수하기 쉽습니다.

Post post = null;
System.out.println(post.getTitle()); // 오류 발생

값이 없는데 메서드를 호출하면 오류가 납니다. 이런 상황을 줄이기 위해 Optional을 사용합니다.

Optional<Post> post = postRepository.findById(1L);

Optional<Post>는 "Post가 있을 수도 있고 없을 수도 있다"는 뜻입니다. 선물 상자를 열었을 때 물건이 들어 있을 수도 있고 비어 있을 수도 있다고 생각하면 됩니다.

값이 있을 때만 실행하려면 이렇게 씁니다.

postRepository.findById(1L)
        .ifPresent(foundPost -> System.out.println(foundPost.getTitle()));

값이 없으면 예외를 던질 수도 있습니다.

Post post = postRepository.findById(1L)
        .orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));

이 코드는 게시글을 찾으면 그 게시글을 post 변수에 담고, 찾지 못하면 IllegalArgumentException 예외를 던집니다. 지금은 예외 처리가 낯설어도 괜찮습니다. 중요한 것은 "없을 때 어떻게 할지"를 코드에 같이 적는다는 점입니다.

초보자가 자주 하는 실수는 Optional에서 바로 get()을 호출하는 것입니다.

Post post = postRepository.findById(1L).get();

값이 없으면 이 코드는 오류를 냅니다. 가능하면 orElseThrow, orElse, ifPresent처럼 값이 없을 때의 처리를 함께 작성하세요.

람다식은 짧게 쓰는 함수다

앞에서 이런 코드를 봤습니다.

.filter(post -> post.getId().equals(id))

post -> post.getId().equals(id)가 람다식입니다. 람다식은 이름 없는 작은 함수를 짧게 쓰는 문법이라고 생각하면 됩니다.

풀어서 생각하면 다음과 같습니다.

post를 하나 받아서
post.getId().equals(id)가 참인지 확인한다

왼쪽의 post는 입력값이고, 오른쪽의 post.getId().equals(id)는 그 입력값으로 계산한 결과입니다. 위 코드에서는 결과가 true이면 목록에 남기고, false이면 걸러냅니다.

람다식은 목록을 처리할 때 자주 등장합니다. 예를 들어 게시글 목록에서 제목만 꺼내고 싶다면 다음처럼 쓸 수 있습니다.

List<String> titles = posts.stream()
        .map(post -> post.getTitle())
        .toList();

stream()은 컬렉션을 흐르는 컨베이어 벨트처럼 다루게 해줍니다. map은 각 값을 다른 값으로 바꿉니다. toList()는 결과를 다시 리스트로 모읍니다. 이 책에서는 앞에서 준비한 것처럼 Java 17 이상을 기준으로 하므로 toList()를 사용할 수 있습니다.

과정은 이렇습니다.

  1. posts.stream(): 게시글 목록을 하나씩 흐르게 한다.

  2. map(post -> post.getTitle()): 각 게시글을 제목으로 바꾼다.

  3. toList(): 제목들을 리스트로 모은다.

람다식이 처음에는 낯설지만, 스프링 코드에서는 DTO 변환에 자주 쓰입니다.

DTO 변환에서 스트림을 사용해 보자

DTO는 Data Transfer Object의 줄임말입니다. 쉽게 말해, 외부에 데이터를 전달하기 위한 객체입니다. Entity나 내부 객체를 그대로 응답하지 않고, 필요한 값만 골라 담을 때 사용합니다.

게시글 응답용 DTO를 만들어 보겠습니다.

public class PostResponse {

    private Long id;
    private String title;

    public PostResponse(Long id, String title) {
        this.id = id;
        this.title = title;
    }

    public static PostResponse from(Post post) {
        return new PostResponse(post.getId(), post.getTitle());
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }
}

from 메서드는 Post를 받아 PostResponse로 바꿉니다. 이런 메서드를 만들어 두면 변환 코드가 한곳에 모여 읽기 쉬워집니다.

게시글 목록을 응답 DTO 목록으로 바꿀 때는 이렇게 쓸 수 있습니다.

public List<PostResponse> findAll() {
    return postRepository.findAll().stream()
            .map(post -> PostResponse.from(post))
            .toList();
}

조금 더 짧게 쓰면 다음과 같습니다.

public List<PostResponse> findAll() {
    return postRepository.findAll().stream()
            .map(PostResponse::from)
            .toList();
}

PostResponse::from은 post -> PostResponse.from(post)와 같은 의미입니다. 이것을 메서드 참조라고 부릅니다. 처음에는 람다식으로 읽고, 익숙해지면 메서드 참조도 함께 사용하면 됩니다.

게시판 서비스 코드로 묶어 보기

지금까지 본 문법을 하나의 서비스 코드에 모아 보겠습니다.

import java.util.List;

public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    public PostResponse create(String title, String content) {
        Post post = new Post(null, title, content);
        Post savedPost = postRepository.save(post);

        return PostResponse.from(savedPost);
    }

    public List<PostResponse> findAll() {
        return postRepository.findAll().stream()
                .map(PostResponse::from)
                .toList();
    }

    public PostResponse findById(Long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("게시글을 찾을 수 없습니다."));

        return PostResponse.from(post);
    }
}

이 코드 안에는 이번 장에서 다룬 문법이 모두 들어 있습니다.

  • PostService: 클래스

  • postRepository: 필드

  • PostService(PostRepository postRepository): 생성자

  • create, findAll, findById: 메서드

  • PostRepository: 인터페이스 타입

  • List<PostResponse>: 제네릭과 컬렉션

  • Optional의 orElseThrow: 없을 수도 있는 값 처리

  • stream, map, toList: 목록 변환

  • PostResponse::from: DTO 변환용 메서드 참조

아직 이 클래스에는 @Service 같은 스프링 애너테이션을 붙이지 않았습니다. 하지만 구조는 이미 스프링 서비스 코드와 거의 같습니다. 다음 장들에서는 여기에 스프링이 객체를 만들고 연결해 주는 방식을 더하게 됩니다.

이번 장에서 꼭 기억할 것

스프링 코드는 특별한 마법처럼 보이지만, 바탕은 자바입니다. 먼저 클래스와 메서드를 읽고, 그다음 애너테이션의 의미를 보면 됩니다.

핵심만 다시 정리하겠습니다.

  • 클래스는 설계도이고 객체는 실제로 만들어진 대상입니다.

  • 생성자는 객체가 처음 만들어질 때 필요한 값을 넣습니다.

  • 메서드는 객체가 수행하는 동작입니다.

  • 인터페이스는 구현을 바꿔도 사용하는 쪽 코드가 덜 흔들리게 해줍니다.

  • List<Post> 같은 제네릭은 어떤 타입의 값을 담는지 알려줍니다.

  • Optional<Post>는 값이 없을 수 있음을 코드에 드러냅니다.

  • 람다식과 스트림은 목록을 필터링하거나 변환할 때 자주 사용합니다.

이 정도 문법을 알고 있으면 앞으로 나올 컨트롤러, 서비스, 리포지토리 코드를 훨씬 편하게 읽을 수 있습니다.

책 목록

초보자를 위한 자바 스프링 사용법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기