Skip to main content
Summary

이번 장의 목표

지금까지 우리는 스프링을 조각별로 배웠습니다. 컨트롤러는 요청을 받고, 서비스는 비즈니스 흐름을 처리하며, 리포지토리는 데이터베이스와 연결되었습니다. DTO는 외부 요청과 응답 모양을 정리했고, 검증과 예외 처리는 실패 상황을 다루는 방법을 알려 주었습니다.

이번 장에서는 이 조각들을 하나의 작은 게시판 API로 합칩니다. 목표는 거창한 서비스를 만드는 것이 아닙니다. 스프링 애플리케이션이 실제 서비스 형태로 어떻게 연결되는지 끝까지 경험하는 것입니다.

우리가 완성할 기능은 다음과 같습니다.

영역기능
회원회원 생성, 회원 조회
게시글작성, 목록 조회, 단건 조회, 수정, 삭제
댓글댓글 작성, 게시글별 댓글 조회, 삭제
권한작성자만 게시글과 댓글을 수정하거나 삭제 가능

여기서 권한은 Spring Security까지 사용하지 않고 단순하게 처리합니다. 요청으로 전달된 memberId가 실제 작성자의 ID와 같은지 확인하는 방식입니다. 실무에서는 로그인과 인증 토큰을 사용하지만, 지금은 권한 체크의 위치와 흐름을 익히는 데 집중합니다.

미니 프로젝트로 완성하는 게시판 APIAI 생성 이미지

구현 순서 정하기

처음부터 모든 기능을 한 번에 만들려고 하면 코드가 쉽게 꼬입니다. 작은 기능을 완성하고 확인한 뒤 다음 기능을 붙이는 방식이 좋습니다.

추천 순서는 다음과 같습니다.

  1. 패키지 구조 만들기

  2. Member, Post, Comment Entity 작성

  3. Repository 인터페이스 작성

  4. 요청 DTO와 응답 DTO 작성

  5. Service에서 핵심 기능 구현

  6. Controller에서 API URL 연결

  7. 검증과 예외 처리 적용

  8. 테스트 작성

  9. 빌드 실행

이 순서는 식당을 여는 과정과 비슷합니다. 먼저 주방과 홀의 위치를 정하고, 재료를 준비한 뒤, 요리 순서를 만들고, 마지막에 손님이 주문할 수 있는 메뉴판을 내놓습니다. 스프링 프로젝트도 구조를 먼저 잡아야 이후 코드가 자연스럽게 놓입니다.

패키지 구조 잡기

프로젝트의 기본 패키지가 com.example.board라고 가정하겠습니다. 그 아래에 기능별 패키지를 둡니다.

com.example.board
├── member
│   ├── controller
│   ├── service
│   ├── repository
│   ├── domain
│   └── dto
├── post
│   ├── controller
│   ├── service
│   ├── repository
│   ├── domain
│   └── dto
├── comment
│   ├── controller
│   ├── service
│   ├── repository
│   ├── domain
│   └── dto
└── global
    └── exception

패키지는 파일을 담는 폴더이면서 역할을 나누는 약속입니다. 초보자일수록 이 약속을 지키는 것이 중요합니다. 코드가 많아져도 "게시글 서비스는 post.service에 있겠구나" 하고 바로 찾을 수 있기 때문입니다.

작은 프로젝트에서는 controller, service, repository처럼 계층별로 나누어도 됩니다. 하지만 게시판처럼 회원, 게시글, 댓글이 분명히 나뉘는 프로젝트에서는 기능별로 먼저 나누고 그 안에 계층을 두는 구조가 읽기 쉽습니다.

Entity로 데이터의 뼈대 만들기

Entity는 데이터베이스 테이블과 연결되는 자바 클래스입니다. 게시판의 핵심 Entity는 회원, 게시글, 댓글입니다.

먼저 회원입니다.

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 30)
    private String name;

    protected Member() {
    }

    public Member(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

JPA Entity에는 기본 생성자가 필요합니다. 그래서 protected Member()를 둡니다. 외부에서 아무 값 없이 회원을 마음대로 만들 필요는 없으므로 public이 아니라 protected로 열어 둔 것입니다.

게시글은 작성자인 회원을 참조합니다.

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member writer;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String content;

    protected Post() {
    }

    public Post(Member writer, String title, String content) {
        this.writer = writer;
        this.title = title;
        this.content = content;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }

    public boolean isWrittenBy(Long memberId) {
        return writer.getId().equals(memberId);
    }

    public Long getId() {
        return id;
    }

    public Member getWriter() {
        return writer;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }
}

isWrittenBy()는 권한 체크를 위한 작은 메서드입니다. 서비스에서 post.getWriter().getId().equals(memberId)를 반복해서 쓰는 대신, 게시글에게 "이 회원이 네 작성자니?"라고 물어보는 형태입니다. 이렇게 하면 코드가 더 읽기 쉬워집니다.

댓글도 게시글과 작성자를 참조합니다.

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member writer;

    @Column(nullable = false, length = 500)
    private String content;

    protected Comment() {
    }

    public Comment(Post post, Member writer, String content) {
        this.post = post;
        this.writer = writer;
        this.content = content;
    }

    public boolean isWrittenBy(Long memberId) {
        return writer.getId().equals(memberId);
    }

    public Long getId() {
        return id;
    }

    public Post getPost() {
        return post;
    }

    public Member getWriter() {
        return writer;
    }

    public String getContent() {
        return content;
    }
}

처음에는 양방향 관계를 무리해서 만들 필요가 없습니다. Post에서 List<Comment>를 들고 있지 않아도, 댓글 Repository에서 postId로 댓글을 조회할 수 있습니다. 초보 단계에서는 단방향 관계로 시작하는 편이 이해하기 쉽습니다.

Repository는 짧게, Service는 분명하게

Spring Data JPA를 사용하면 Repository는 대부분 짧습니다.

public interface MemberRepository extends JpaRepository<Member, Long> {
}

public interface PostRepository extends JpaRepository<Post, Long> {
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
    List<Comment> findAllByPost_IdOrderByIdAsc(Long postId);
}

findAllByPost_IdOrderByIdAsc는 메서드 이름만으로 쿼리를 만드는 Spring Data JPA 기능입니다. Comment의 post가 가진 id 값으로 댓글을 찾고, 댓글 ID 오름차순으로 정렬한다는 뜻입니다. 이름이 조금 길어 보이지만, 초보자에게는 "무엇을 기준으로 찾는지"가 드러나는 장점이 있습니다.

이제 서비스에서 실제 흐름을 작성합니다. 서비스는 컨트롤러보다 더 중요한 판단을 담당합니다. "회원이 존재하는가?", "게시글이 존재하는가?", "작성자가 맞는가?" 같은 질문은 서비스에서 확인하는 것이 좋습니다.

먼저 게시글 서비스입니다.

@Service
@Transactional(readOnly = true)
public class PostService {

    private final PostRepository postRepository;
    private final MemberRepository memberRepository;

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

    @Transactional
    public Long create(PostCreateRequest request) {
        Member writer = memberRepository.findById(request.memberId())
                .orElseThrow(() -> new NotFoundException("회원을 찾을 수 없습니다."));

        Post post = new Post(writer, request.title(), request.content());
        return postRepository.save(post).getId();
    }

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

    public PostResponse findOne(Long postId) {
        return PostResponse.from(findPost(postId));
    }

    @Transactional
    public void update(Long postId, PostUpdateRequest request) {
        Post post = findPost(postId);
        checkWriter(post, request.memberId());
        post.update(request.title(), request.content());
    }

    @Transactional
    public void delete(Long postId, Long memberId) {
        Post post = findPost(postId);
        checkWriter(post, memberId);
        postRepository.delete(post);
    }

    private Post findPost(Long postId) {
        return postRepository.findById(postId)
                .orElseThrow(() -> new NotFoundException("게시글을 찾을 수 없습니다."));
    }

    private void checkWriter(Post post, Long memberId) {
        if (!post.isWrittenBy(memberId)) {
            throw new ForbiddenException("작성자만 처리할 수 있습니다.");
        }
    }
}

여기서 중요한 흐름은 세 가지입니다.

첫째, 없는 회원이나 게시글은 NotFoundException으로 처리합니다. 둘째, 작성자가 아닌 사용자의 요청은 ForbiddenException으로 막습니다. 셋째, 데이터 변경 기능에는 @Transactional을 붙입니다. 게시글 수정처럼 Entity의 값을 바꾸는 작업은 트랜잭션 안에서 처리되어야 데이터베이스에 안전하게 반영됩니다.

클래스 전체에는 @Transactional(readOnly = true)를 붙였습니다. 그래서 조회 메서드는 기본적으로 읽기 전용 트랜잭션으로 동작합니다. 생성, 수정, 삭제처럼 데이터를 바꾸는 메서드에만 다시 @Transactional을 붙여 쓰기 가능한 트랜잭션으로 바꿉니다.

댓글 서비스도 구조는 비슷합니다.

@Service
@Transactional(readOnly = true)
public class CommentService {

    private final CommentRepository commentRepository;
    private final PostRepository postRepository;
    private final MemberRepository memberRepository;

    public CommentService(
            CommentRepository commentRepository,
            PostRepository postRepository,
            MemberRepository memberRepository
    ) {
        this.commentRepository = commentRepository;
        this.postRepository = postRepository;
        this.memberRepository = memberRepository;
    }

    @Transactional
    public Long create(Long postId, CommentCreateRequest request) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new NotFoundException("게시글을 찾을 수 없습니다."));
        Member writer = memberRepository.findById(request.memberId())
                .orElseThrow(() -> new NotFoundException("회원을 찾을 수 없습니다."));

        Comment comment = new Comment(post, writer, request.content());
        return commentRepository.save(comment).getId();
    }

    public List<CommentResponse> findAllByPost(Long postId) {
        return commentRepository.findAllByPost_IdOrderByIdAsc(postId).stream()
                .map(CommentResponse::from)
                .toList();
    }

    @Transactional
    public void delete(Long commentId, Long memberId) {
        Comment comment = commentRepository.findById(commentId)
                .orElseThrow(() -> new NotFoundException("댓글을 찾을 수 없습니다."));

        if (!comment.isWrittenBy(memberId)) {
            throw new ForbiddenException("작성자만 처리할 수 있습니다.");
        }

        commentRepository.delete(comment);
    }
}

회원 서비스는 더 단순합니다.

@Service
@Transactional(readOnly = true)
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public Long create(MemberCreateRequest request) {
        Member member = new Member(request.name());
        return memberRepository.save(member).getId();
    }

    public MemberResponse findOne(Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new NotFoundException("회원을 찾을 수 없습니다."));
        return MemberResponse.from(member);
    }
}

이렇게 보면 Repository는 데이터 접근을 맡고, Service는 흐름과 판단을 맡는다는 차이가 조금 더 분명해집니다.

DTO에 검증 규칙 넣기

DTO는 외부와 내부 사이의 입구입니다. 사용자가 보낸 값이 비어 있거나 너무 길면 서비스까지 보내지 않는 것이 좋습니다.

게시글 요청 DTO는 다음처럼 만들 수 있습니다.

public record PostCreateRequest(
        @NotNull Long memberId,
        @NotBlank @Size(max = 100) String title,
        @NotBlank String content
) {
}

public record PostUpdateRequest(
        @NotNull Long memberId,
        @NotBlank @Size(max = 100) String title,
        @NotBlank String content
) {
}

댓글과 회원 요청 DTO도 같은 방식입니다.

public record CommentCreateRequest(
        @NotNull Long memberId,
        @NotBlank @Size(max = 500) String content
) {
}

public record MemberCreateRequest(
        @NotBlank @Size(max = 30) String name
) {
}

record는 값을 담는 DTO를 짧게 만들 수 있는 자바 문법입니다. 일반 클래스로 작성해도 됩니다. 중요한 것은 요청 DTO에 검증 규칙을 붙이고, 컨트롤러에서 @Valid를 사용한다는 점입니다.

응답 DTO는 필요한 값만 담습니다.

public record PostResponse(
        Long id,
        String title,
        String content,
        Long writerId,
        String writerName
) {
    public static PostResponse from(Post post) {
        return new PostResponse(
                post.getId(),
                post.getTitle(),
                post.getContent(),
                post.getWriter().getId(),
                post.getWriter().getName()
        );
    }
}
public record CommentResponse(
        Long id,
        Long postId,
        String content,
        Long writerId,
        String writerName
) {
    public static CommentResponse from(Comment comment) {
        return new CommentResponse(
                comment.getId(),
                comment.getPost().getId(),
                comment.getContent(),
                comment.getWriter().getId(),
                comment.getWriter().getName()
        );
    }
}
public record MemberResponse(
        Long id,
        String name
) {
    public static MemberResponse from(Member member) {
        return new MemberResponse(member.getId(), member.getName());
    }
}

Entity를 그대로 응답하지 않는 이유는 내부 구조를 API 바깥에 노출하지 않기 위해서입니다. Entity는 데이터베이스와 가까운 객체이고, DTO는 클라이언트와 가까운 객체입니다. 지금은 Entity가 단순하지만, 실제 프로젝트에서는 비밀번호, 내부 상태값, 연관 관계 같은 민감하거나 복잡한 정보가 함께 들어 있을 수 있습니다.

Controller에서 API 연결하기

컨트롤러는 URL과 서비스 메서드를 연결합니다. 먼저 게시글 컨트롤러입니다.

@RestController
@RequestMapping("/api/posts")
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    @PostMapping
    public ResponseEntity<Long> create(@Valid @RequestBody PostCreateRequest request) {
        Long postId = postService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(postId);
    }

    @GetMapping
    public ResponseEntity<List<PostResponse>> findAll() {
        return ResponseEntity.ok(postService.findAll());
    }

    @GetMapping("/{postId}")
    public ResponseEntity<PostResponse> findOne(@PathVariable Long postId) {
        return ResponseEntity.ok(postService.findOne(postId));
    }

    @PatchMapping("/{postId}")
    public ResponseEntity<Void> update(
            @PathVariable Long postId,
            @Valid @RequestBody PostUpdateRequest request
    ) {
        postService.update(postId, request);
        return ResponseEntity.noContent().build();
    }

    @DeleteMapping("/{postId}")
    public ResponseEntity<Void> delete(
            @PathVariable Long postId,
            @RequestParam Long memberId
    ) {
        postService.delete(postId, memberId);
        return ResponseEntity.noContent().build();
    }
}

댓글 컨트롤러는 게시글 아래의 댓글 목록과 댓글 생성을 담당합니다. 삭제는 댓글 자체의 ID로 처리합니다.

@RestController
public class CommentController {

    private final CommentService commentService;

    public CommentController(CommentService commentService) {
        this.commentService = commentService;
    }

    @PostMapping("/api/posts/{postId}/comments")
    public ResponseEntity<Long> create(
            @PathVariable Long postId,
            @Valid @RequestBody CommentCreateRequest request
    ) {
        Long commentId = commentService.create(postId, request);
        return ResponseEntity.status(HttpStatus.CREATED).body(commentId);
    }

    @GetMapping("/api/posts/{postId}/comments")
    public ResponseEntity<List<CommentResponse>> findAllByPost(@PathVariable Long postId) {
        return ResponseEntity.ok(commentService.findAllByPost(postId));
    }

    @DeleteMapping("/api/comments/{commentId}")
    public ResponseEntity<Void> delete(
            @PathVariable Long commentId,
            @RequestParam Long memberId
    ) {
        commentService.delete(commentId, memberId);
        return ResponseEntity.noContent().build();
    }
}

회원 컨트롤러는 회원 생성과 조회만 둡니다.

@RestController
@RequestMapping("/api/members")
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping
    public ResponseEntity<Long> create(@Valid @RequestBody MemberCreateRequest request) {
        Long memberId = memberService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(memberId);
    }

    @GetMapping("/{memberId}")
    public ResponseEntity<MemberResponse> findOne(@PathVariable Long memberId) {
        return ResponseEntity.ok(memberService.findOne(memberId));
    }
}

API 주소는 자원을 기준으로 정합니다. 게시글 목록은 /api/posts, 특정 게시글은 /api/posts/{postId}, 특정 게시글의 댓글은 /api/posts/{postId}/comments처럼 표현합니다.

최종 API는 다음처럼 정리할 수 있습니다.

기능메서드URL
회원 생성POST/api/members
회원 조회GET/api/members/{memberId}
게시글 작성POST/api/posts
게시글 목록 조회GET/api/posts
게시글 단건 조회GET/api/posts/{postId}
게시글 수정PATCH/api/posts/{postId}
게시글 삭제DELETE/api/posts/{postId}?memberId=1
댓글 작성POST/api/posts/{postId}/comments
댓글 목록 조회GET/api/posts/{postId}/comments
댓글 삭제DELETE/api/comments/{commentId}?memberId=1

예외 응답 통일하기

API를 사용하는 쪽은 오류 응답 모양이 일정해야 처리하기 쉽습니다. 그래서 전역 예외 처리 클래스를 둡니다.

먼저 우리가 직접 사용할 예외를 만듭니다.

public class NotFoundException extends RuntimeException {

    public NotFoundException(String message) {
        super(message);
    }
}
public class ForbiddenException extends RuntimeException {

    public ForbiddenException(String message) {
        super(message);
    }
}

그리고 전역 예외 처리 클래스에서 이 예외들을 HTTP 상태 코드로 바꿉니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(ForbiddenException.class)
    public ResponseEntity<ErrorResponse> handleForbidden(ForbiddenException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(new ErrorResponse("FORBIDDEN", e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(error -> error.getDefaultMessage())
                .orElse("잘못된 요청입니다.");

        return ResponseEntity.badRequest()
                .body(new ErrorResponse("VALIDATION_ERROR", message));
    }
}
public record ErrorResponse(String code, String message) {
}

이제 존재하지 않는 게시글은 404, 작성자가 아닌 사용자의 삭제 요청은 403, 잘못된 입력은 400으로 응답할 수 있습니다. 상태 코드는 클라이언트에게 "무슨 일이 일어났는지" 알려 주는 신호입니다.

테스트로 흐름 확인하기

기능을 만들었다면 최소한 핵심 흐름은 테스트해야 합니다. 처음부터 모든 경우를 테스트하려고 하면 부담이 큽니다. 다음 다섯 가지부터 시작하면 충분합니다.

  1. 회원 생성 성공

  2. 게시글 작성 성공

  3. 없는 회원으로 게시글 작성 시 실패

  4. 작성자가 게시글 수정 성공

  5. 작성자가 아닌 사용자의 게시글 삭제 요청 실패

서비스 테스트 예시는 다음과 같습니다.

@Test
void 작성자가_아니면_게시글을_삭제할_수_없다() {
    Member writer = memberRepository.save(new Member("작성자"));
    Member other = memberRepository.save(new Member("다른 사용자"));
    Post post = postRepository.save(new Post(writer, "제목", "내용"));

    assertThatThrownBy(() -> postService.delete(post.getId(), other.getId()))
            .isInstanceOf(ForbiddenException.class);
}

테스트 이름은 한국어로 작성해도 됩니다. 중요한 것은 이름만 읽어도 어떤 상황을 검증하는지 알 수 있어야 한다는 점입니다.

컨트롤러 테스트에서는 MockMvc로 상태 코드와 JSON 응답을 확인할 수 있습니다. 예를 들어 게시글 생성 요청을 보냈을 때 201이 내려오는지, 잘못된 요청을 보냈을 때 400이 내려오는지 확인할 수 있습니다. Repository 테스트에서는 @DataJpaTest로 Entity 저장과 조회가 잘 되는지 확인합니다. 테스트는 코드를 믿게 해 주는 안전망입니다.

빌드하고 실행하기

마지막으로 프로젝트가 정상적으로 빌드되는지 확인합니다.

./gradlew clean test
./gradlew bootJar
java -jar build/libs/board-0.0.1-SNAPSHOT.jar

clean test는 이전 빌드 결과를 지우고 테스트를 실행합니다. bootJar는 실행 가능한 JAR 파일을 만듭니다. java -jar는 IntelliJ 없이도 애플리케이션을 실행하는 명령입니다.

실행 후 다음 순서로 API를 직접 호출해 보세요.

  1. 회원을 만든다.

  2. 그 회원 ID로 게시글을 작성한다.

  3. 게시글 목록을 조회한다.

  4. 게시글을 수정한다.

  5. 댓글을 작성하고 게시글별 댓글 목록을 조회한다.

  6. 다른 회원 ID로 게시글 삭제를 시도해 403을 확인한다.

  7. 작성자 ID로 게시글을 삭제해 204를 확인한다.

이 흐름을 한 번 통과하면 지금까지 배운 대부분의 개념이 실제로 연결됩니다.

다음에 확장할 것들

이번 프로젝트는 학습용으로 일부러 단순하게 만들었습니다. 이제 다음 주제로 확장할 수 있습니다.

  • 로그인과 회원가입

  • Spring Security와 JWT 인증

  • 게시글 검색과 페이징

  • 댓글 수정 기능

  • 파일 업로드

  • 운영용 데이터베이스 연결

  • Docker를 이용한 배포

  • API 문서 자동화

  • 더 촘촘한 통합 테스트

가장 먼저 추천하는 확장은 로그인과 인증입니다. 지금은 요청에 memberId를 직접 넣었지만, 실제 서비스에서는 로그인한 사용자가 누구인지 서버가 안전하게 확인해야 합니다. 그다음에는 페이징과 검색을 붙여 보세요. 게시글이 10개일 때와 10만 개일 때의 API 설계는 달라집니다.

마무리

스프링을 배운다는 것은 애너테이션을 많이 외우는 일이 아닙니다. 요청이 들어오고, 컨트롤러가 받고, 서비스가 판단하고, 리포지토리가 데이터를 다루고, 다시 응답으로 나가는 흐름을 이해하는 일입니다.

이번 미니 프로젝트에서 우리는 그 흐름을 하나로 연결했습니다. 아직 부족한 부분은 있어도 괜찮습니다. 중요한 것은 이제 코드를 어디에 두어야 하는지, 오류가 나면 어느 계층을 봐야 하는지, 기능을 추가할 때 어떤 순서로 접근해야 하는지 감이 생겼다는 점입니다.

이 감각이 스프링 학습의 가장 중요한 출발점입니다.

책 목록

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

전체 목차 보기

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

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

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

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

참여하기