본문 바로가기
개발/KB IT's Your Life 7기

KB IT's Your Life 7기 - CRUD부터 Spring Security까지

by Lylica 2026. 6. 28.

 

지난 포스팅에서는 Servlet과 Spring Context, MVC 구조와 MySQL 연동까지 정리했었다.

 

이번주에는 CRUD 구현부터 시작해서 파일 처리, REST API, 실시간 통신, 그리고 인증/보안까지... 고봉밥으로 잔뜩 배웠다.

 

그래서 이번 주 포스팅도 아주 대략적인 요약본 형식으로 작성해보고자 한다.


영속/비즈니스 계층의 CRUD 구현

 

Spring MVC(Model View Controller)에서 CRUD를 구현할 때는, 계층 간 데이터를 어떻게 주고받는지를 주로 보면 된다.


그래서 구현할 때 DB에서 꺼낸 데이터는 VO(Value Object)로 만들고, 외부에 전달할 때는 DTO(Data Transfer Object)로 변환해서 갖다주는게 좋다.

 

VO는 DB 테이블 구조에 맞춘 객체니까 테이블이 조금 바뀐다고 생각하면, VO도 같이 바꿔줘야 한다.


반면 DTO는 클라이언트에게 전달할 구조라고 생각하면 된다. 필요한 필드만 담고, 위험한 정보는 빼놓으면 된다.

 

VO와 DTO는 왜 나누냐?

 

이 둘을 분리하는 이유는 뭐... DB 구조가 바뀌어도 클라이언트에게 제공되는 인터페이스는 유지해야 하고, 반대로 클라이언트 요구사항이 바뀌어도 DB 구조에 영향을 주지 않아야 하기 때문이다. 그래서 VO를 DTO로 바꾸는 과정이 필요하다.

 

변환 메서드는 DTO 클래스 안에 직접 구현하는 방식이 초보자에게 가장 단순한 방법이라고 한다.

public class BoardDTO {
    // VO → DTO 변환 (정적 팩토리 메서드)
    public static BoardDTO of(BoardVO vo) {
        return vo == null ? null : BoardDTO.builder()
                .no(vo.getNo())
                .title(vo.getTitle())
                .build();
    }

    // DTO → VO 변환
    public BoardVO toVo() {
        return BoardVO.builder()
                .no(this.no)
                .title(this.title)
                .build();
    }
}

 

of()는 VO를 받아서 DTO를 반환하는 정적 팩토리 메서드고,
toVo()는 DTO를 VO로 돌려보내는 인스턴스 메서드다.


두 방향의 변환을 모두 DTO 클래스 안에 두면, 변환 로직이 한 곳에 모여서 보기 나중에 보기 편한 방식이다.

하지만 프로젝트가 커지게 된다면 변환하는 것도 Mapper 클래스에 넘겨버리는게 좋은 방법이라고 하신다.

 

List 변환은 스트림으로

DB에서 목록을 조회하면 List<VO> 형태로 돌아온다. 이를 List<DTO>로 바꿀 때 스트림을 활용하면 편하다.

mapper.getList().stream()       // List<BoardVO>의 스트림
      .map(BoardDTO::of)        // 각 VO를 DTO로 변환
      .toList();                // List<BoardDTO>로 수집

 

map(BoardDTO::of)는 스트림의 각 요소에 of() 메서드를 적용하는 메서드 참조 방식이다. 파이썬에서 쓰던 map과 동일한 역할이라고 생각하면 된다.


map을 잘 쓰면 진짜 편하다는건 써본 사람만 안다. 이런 방식으로 반복문 없이 한 줄로 전체 리스트를 변환할 수 있다.

 

INSERT 후 PK 획득 — selectKey

게시글을 등록할 때 첨부파일도 함께 저장해야 한다면, 게시글의 PK가 필요할태지만, INSERT가 완료되기 전까지 PK를 알 수 없다는 문제가 있다.

 

그래서 이를 해결하기 위해서 MyBatis의 selectKey를 사용하게 된다.

<selectKey resultType="Long" keyProperty="no" keyColumn="no" order="AFTER">
    SELECT LAST_INSERT_ID()
</selectKey>

 

order="AFTER"는 INSERT 실행 후 LAST_INSERT_ID()를 조회한다는 의미다.


조회된 PK는 keyProperty="no"로 지정한 VO의 필드에 자동으로 세팅된다.


이후 그 no 값을 이용해 첨부파일 테이블에 FK로 넣으면 된다.

 

Optional로 null을 명시적으로 처리

강사님이 말씀하시길 개발을 하다보면 null처리가 가장 실수하기 좋은 부분이라고 한다. 그래서 null 관리를 잘 하는게 좋은 개발자의 덕목이라고 하신다.

 

우리의 경우 단건 조회에서 데이터가 없을 때 null을 그냥 반환할 수는 있지만, 호출하는 쪽에서 매번 null 체크를 해는 불편한 점이 있다.


그래서 Optional을 사용해 "없을 수 있다"는 가능성을 타입으로 명시할 수 있다.

Optional.ofNullable(board)
        .orElseThrow(NoSuchElementException::new);

 

데이터가 없으면 NoSuchElementException을 던지고, 이후 예외 처리 흐름으로 넘어간다.


null을 직접 다루는 것보다 의도가 명확하고, 예외 처리를 강제할 수 있다는게 장점.


프레젠테이션(웹) 계층의 CRUD 구현과 테스트

 

비즈니스 계층을 준비했다면, 이를 웹 요청과 연결하는 프레젠테이션 계층을 구현하는 방법을 배우게 된다.


그 중 테스트 방법에 대해서 짚고 넘어가려고 한다.

 

MockMvc로 컨트롤러 테스트하기

우리가 코드를 짜면서 Controller를 테스트할려고 서버를 띄우지 않아도 되는 방법이 있다.


MockMvc로 DispatcherServlet을 모킹?해서 HTTP 요청/응답을 시뮬레이션하면 된다고 한다.

@Autowired
private WebApplicationContext ctx;
private MockMvc mockMvc;

@BeforeEach
public void setup() {
    this.mockMvc = MockMvcBuilders.webAppContextSetup(ctx).build();
}

 

@BeforeEach는 각 테스트 메서드 실행 전마다 MockMvc를 초기화해주는 역할이다.

 

MockMvc 자체는 상태를 별로 많이 갖고 있지 않아서, 한 번 생성하고 필드에 보관하는 방식이 일반적이라고 한다.


webAppContextSetup(ctx)는 실제 Spring 컨텍스트를 사용해서 MockMvc를 구성한다.

 

요청을 만들고 결과를 검증하는 방식은 다음과 같다.

// GET 요청 (파라미터 없음)
MockMvcRequestBuilders.get("/board/list")

// POST 요청 (파라미터 있음)
MockMvcRequestBuilders.post("/board/create")
        .param("title", "테스트 새글 제목")
        .param("content", "테스트 새글 내용")
        .param("writer", "user1")

 

실행 후 모델과 뷰를 확인하려면 getModelAndView()를 사용한다.

ModelAndView mv = mockMvc.perform(요청)
        .andReturn()
        .getModelAndView();

mv.getModelMap();   // Model에 담긴 데이터 확인
mv.getViewName();   // 렌더링될 View 이름 확인

 

대신, 알아둬야 할 점은 MockMvc는 JSP 렌더링까지는 검증하지는 않는다. 그냥 논리 View 이름까지만 검증한다고 생각하면 된다.

 

검증되는 시퀀스는 list.jsp 으로 Forward 되는지 까지만 확인한다고 보면 된다.

 


File Upload / Download / 트랜잭션

 

이제 단순히 파일을 저장하는 것 뿐만 아니라 여러 업로드의 흐름, 트랜잭션에 대한 이야기까지 한번에 배우게 된다.

 

그래서 그런지 배우는 내용도 굉장히 많은 메소드들로 점철되어 있지만, 핵심적인 부분만 짚고 넘어가려고 한다.

 

파일 업로드

`Part` 는 Servlet 표준 파일 업로드 객체(Servlet 3.X API)이고, `MultipartFile`은 Spring에서 파일 업로드를 더 편하게 다루기 위한 객체(Spring Bean)다.

 

파일은 multipart/form-data 인코딩으로 전송된다. Spring에서는 MultipartFile로 받는다.

part.transferTo(dest);

 

transferTo()는 업로드된 파일을 지정한 경로로 저장하는 메서드다.


저장 경로는 서버의 실제 파일 시스템 경로여야 하는게 포인트.

 

파일 다운로드


브라우저가 파일을 저장하도록 만들려면 Content-disposition 헤더를 설정해야 한다.

response.setHeader("Content-disposition",
        "attachment;filename=\"" + filename + "\"");
Files.copy(Paths.get(file.getPath()), bos);

 

attachment는 파일을 브라우저에서 열지 말고 저장하라는 지시,
filename은 사용자에게 보여질 저장 파일명이고,
Files.copy()는 서버의 파일을 응답 스트림으로 복사해서 전송하는 역할이다.

 

경로에 포함된 파일 번호는 @PathVariable로 추출한다.

// URL: /board/download/{no}
@PathVariable("no") Long no

트랜잭션 처리

역시 데이터를 다룰 때는 트랜잭션을 빼먹을 수 없다.

 

논리적으로 생각해볼때 게시글 등록과 첨부파일 저장은 하나의 작업 단위여야 한다. 게시글은 저장됐는데 첨부파일 저장에서 실패하면 데이터 정합성이 깨지니까 말이다. 그래서 트랜잭션을 사용하게 된다.

 

트랜잭션을 사용하기 위해서는 우선 RootConfig@EnableTransactionManagement를 선언해야 한다.

@Configuration
@EnableTransactionManagement
public class RootConfig { ... }

 

다음으로 트랜잭션이 필요한 Service 메서드에 @Transactional을 붙이기만 하면 된다.

@Transactional
public void register(BoardDTO dto) { ... }

 

중요한점은, 트랜잭션은 RuntimeException이 발생할 때만 자동으로 rollback되고, IOException은 rollback의 대상이 아닐 수 있다. 그래서 가능하면 런타임예외로 감싸줘서 롤백 대상으로 만들어줘야 한다.

 

트랜잭션으로 감싼 작업이 정상 완료되면 commit된다. 개발자가 직접 commit/rollback을 호출할 필요가 없다는 이야기. Java 회고에서 작성했던 그 내용을 마침내 배운 것이다.


RestController와 Swagger

이제 본격적으로 REST API를 구현해보는 과정을 배우게 된다.

 

REST API 프로젝트를 만든다고 기존 코드를 전부 재작성할 필요는 없고, 적절하게 레이어를 나눠서 구현했다면 Controller 쪽만 적당히 바꿔주면 된다. 

@RestController 의 의미

 

@Controller는 뷰 이름을 반환해서 JSP를 렌더링한다면,
@RestController는 객체를 그대로 JSON으로 직렬화해서 응답 본문에 담는다.

 

쉽게 말해서 컨트롤러 어노테이션과 @ResponseBody동시에 적용된 거라고 보면 된다.

@RestController
public class BoardApiController {

    @GetMapping("/api/board/{no}")
    public ResponseEntity<BoardDTO> get(@PathVariable Long no) {
        BoardDTO dto = boardService.get(no);
        return ResponseEntity.ok(dto);
    }
}

 

ResponseEntity는 HTTP 상태코드, 헤더, 바디를 직접 제어할 수 있는 응답 객체다.

ResponseEntity.ok().build();                    // 200, 바디 없음
ResponseEntity.ok(body);                        // 200 + 바디
ResponseEntity.ok().headers(headers).body(body); // 200 + 헤더 + 바디
ResponseEntity.status(404).body(body);          // 404 + 바디

 

빌더 패턴으로 체인하는 방식이라 상황에 따라 필요한 요소만 붙이면 된다.

 

REST API 예외 처리 — @RestControllerAdvice

@RestControllerAdvice@RestController에서 발생한 예외를 한 곳에서 처리한다.

@RestControllerAdvice
public class ApiExceptionAdvice {

    @ExceptionHandler({NoSuchElementException.class})
    public ResponseEntity<String> handleNotFound(NoSuchElementException e) {
        return ResponseEntity.status(404).body("데이터를 찾을 수 없습니다.");
    }
}

 

예외 종류별로 @ExceptionHandler를 등록하면, 각 Controller에서 개별적으로 예외를 처리하지 않아도 된다.

 

Swagger로 API 문서화

 

 

Swagger는 코드에 어노테이션을 붙이는 것만으로 API 문서를 자동 생성해주는 좋은 도구라고 생각하면 된다.

 

어노테이션 대상 역할
@ApiOperation 메서드 API 이름과 설명
@ApiResponses 메서드 응답 코드별 설명
@ApiParam 파라미터 파라미터 설명
@ApiModel DTO 클래스 클래스 설명
@ApiModelProperty DTO 필드 필드 설명

 

별도로 문서를 작성하지 않아도 되고, 코드가 바뀌면 문서도 자동으로 반영된다는 것이 Swagger의 핵심 장점이다.


RestTemplate

 

다음으로 이제 다른 곳의 API를 끌어와 우리 어플리케이션에 사용하는 방법을 배우게 된다. 

 

RestTemplate Spring에서 다른 서버의 REST API를 호출할 때 사용하는 클라이언트다.


동기 방식으로 처리되기 때문에 응답이 올 때까지 현재 스레드가 대기하지만, 귀찮은 작업들(HTTP 연결부터 Parsing, Stream 정리 등등...)을 직접 반복하지 않아도 되서 편하다.

RestTemplate restTemplate = new RestTemplate();

String url = UriComponentsBuilder.fromHttpUrl("https://api.example.com/data")
        .queryParam("key", "value")
        .toUriString();

WeatherDTO weather = restTemplate.getForObject(url, WeatherDTO.class);

 

getForObject()는 GET 요청을 보내고 응답을 지정한 타입으로 역직렬화하는 방법이다.


UriComponentsBuilder는 URL과 쿼리 파라미터를 안전하게 조립할 때 사용한다.

 

AOP (Aspect Oriented Programming)

AOP(Aspect Oriented Programming)핵심 비즈니스 로직에서 공통 관심사를 분리하는 방법론 이라고 한다.

 

쉽게 말해서 비즈니스 로직은 그대로 냅두고, 해야 할 일을 미리 찾아놓고 갖다 쓰자는 이야기다.


그래서 로깅, 트랜잭션, 보안 처리처럼 여러 곳에서 반복되는 코드를 한 곳에 모을 수 있다.

 

AOP에서 사용하는 용어들은 이런게 있다.

  • Target: 부가 기능이 적용될 실제 객체
  • Proxy: Target을 감싸고, Advice를 거쳐 호출하는 객체
  • JoinPoint: 부가 기능이 끼어들 수 있는 지점 (메서드 호출 시점)
  • Advice: 실제로 실행될 부가 기능 코드

우리는 이들 중 Advice가 실제로 실행할 내용을 담고 있으니 이 내용을 중점적으로 배우게 되었다.

 

 

Advice는 실행 시점에 따라 다음과 같이 나뉜다.

어노테이션 실행 시점
@Before 메서드 실행 전
@AfterReturning 메서드가 정상 완료된 후
@AfterThrowing 예외가 발생한 후
@After 성공/예외 구분 없이 메서드 종료 후
@Around 메서드 실행 전/후 모두 제어
@Before("execution(* org.scoula.board.service.BoardService*.*(..))")
public void logBefore() {
    log.info("메서드 실행 전 로그");
}

 

execution(* org.scoula.board.service.BoardService*.*(..))는 포인트컷 표현식이다.

 

어떤 패키지의 어떤 클래스, 어떤 메서드에 Advice를 적용할지 범위를 지정한다.

 

STOMP (Simple Text Oriented Messaging Protocol)

 

HTTP는 요청이 있어야 응답이 오기 때문에 실시간 통신에는 적합하지 않다. 하지만 요즘 세상에 실시간 통신을 못한다는건 말이 안되지.

 

WebSocket은 한 번 연결을 맺으면 클라이언트와 서버가 서로 언제든 데이터를 보낼 수 있게 만들어준다.


그리고 STOMP는 WebSocket 위에서 동작하는 메시지 프로토콜이다.

 

STOMP는 이메일 규격과 유사하게 COMMAND(SEND/SUBSCRIBE), Header, Body 구조를 가진다. 이를 통해 발행-구독(Pub/Sub) 모델을 손쉽게 구현할 수 있어, 특정 방(Topic)에 있는 사람들에게만 메시지를 보내는 채팅방 구현이 가능해진다!

 

대략적으로 서버 설정은 이렇게 하게 된다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");          // 구독 토픽 접두어
        config.setApplicationDestinationPrefixes("/app"); // 발행 접두어
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/chat-app")
                .setAllowedOrigins("*");
    }
}

 

클라이언트는 /app/hello로 메시지를 발행하면, 서버는 처리 후 /topic/greetings 구독자에게 전달한다.

const stompClient = new StompJs.Client({
    brokerURL: 'ws://localhost:8080/chat-app'
});

stompClient.onConnect = (frame) => {
    // 구독 등록
    stompClient.subscribe('/topic/greetings', (message) => {
        console.log(JSON.parse(message.body));
    });

    // 메시지 발행
    stompClient.publish({
        destination: '/app/hello',
        body: JSON.stringify({ name: 'user1' })
    });
};

stompClient.activate(); // 연결 시작

 

HTTP 요청/응답과 달리 연결을 유지하면서 양쪽에서 언제든 메시지를 보낼 수 있다는 것이 핵심.


Spring Security

 

이제 로그인과 같은 인증 절차 과정에 대해서 배우게 되었다.

 

Spring Security는 인증(Authentication)과 인가(Authorization)를 담당하는 프레임워크다.


필터 체인 방식으로 동작하기 때문에, 요청이 Controller에 도달하기 전에 보안 처리가 먼저 이루어진다.

 

SecurityFilterChain

로그인 요청이 들어왔을 때의 처리 흐름은 다음과 같다.

Browser (로그인 요청)
→ SecurityContextPersistenceFilter  // 기존 인증 정보 복원
→ UsernamePasswordAuthenticationFilter  // 아이디/비밀번호 추출
→ AuthenticationManager  // 인증 처리 위임
→ AuthenticationProvider  // 실제 인증 수행
→ UserDetailsService  // DB에서 사용자 조회
→ UserDetails 반환
→ SecurityContextHolder에 저장

 

아이고... 확실히 보안 인증 파트기 때문에 꽤나 스텝이 길다. 사실 이것도 굉장히 단축된 과정이다.

 

그래도 반드시 알아야 하는 과정이기 때문에 제대로 배우는게 중요하겠다.

 

그래도 나름 친절하게도 Spring Security가 대부분의 역할을 담당한다. 우리가 주로 신경 써야 하는 부분은 우리 서비스의 회원 정보를 Spring Security가 이해할 수 있는 형태로 전달해야 한다는 점이다. 이를 위해서 UserDetailsService에서 DB의 회원 정보를 조회하고, 그 결과를 UserDetails 형태로 반환해야 한다.

 

일반적인 로그인 구현에서는 개발자가 주로 UserDetailsService를 구현하고, 필요에 따라 UserDetails를 직접 만들거나 Spring Security가 제공하는 기본 구현을 활용한다.

 

이후 비밀번호 비교, 인증 성공 처리, SecurityContext 저장 같은 과정은 Spring Security가 담당한다.

 

즉, 개발자는 회원 조회 방식과 권한 정보를 연결하고, 나머지 인증 흐름은 Spring Security의 구조에 맡기는 방식이다.

 


마무리

 

Spring을 배우면서 벌써 이정도의 개념들을 짚고 넘어갔다.

 

각 주제 하나하나가 오랜 시간동안 배울만한 내용이라고 생각하지만, 그걸 단 며칠만에 돌파하고 있다.

 

그래서 솔직히 지금 머리가 터질것🤯 같다. 

 

최근 개인정보 유출사건들이 굉장히 잦다. 누구나 다 아는 통신사부터 시작해서, 이커머스 플랫폼, OTT 플랫폼, 정부 사업체까지... 그래서 보안에 대해서 경각심을 갖게 된다. 하지만 확실히 보안은 어렵다. 어플리케이션의 모든 절차를 다 기억하고 이해하고 있을 때 비로소 구멍을 찾을 수 있으니까 말이다. 그래서 지금 이 배우는 과정 하나하나가 미래를 위해 쌓는 과정이라고 생각하고 있다.