MySQL 아키텍처

1. MySQL 접속 클라이언트

MySQL에 접속할 수 있게 해주는 API로, MySQL Connector와 Shell이 있다.

 

2. MySQL 엔진

클라이언트 접속과 SQL 요청을 처리하는 두뇌 역할이다.

쿼리 파서, 전처리기, 옵티마이저, 실행 엔진 등으로 구성되어 있다. 

옵티마이저는 요청된 SQL문을 최적화해서 실행시키기 위해 실행 계획을 짜는 중요한 역할이다.

 

3. MySQL 스토리지 엔진

데이터를 실제로 디스크에 저장하거나, 디스크에 저장된 데이터를 읽어오는 역할을 한다.

옵티마이저가 작성한 실행 계획에 따라 스토리지 엔진을 적절히 호출해서 쿼리를 실행한다.

MySQL 엔진이 스토리지를 호출할 때 사용하는 API를 핸들러 API라고 한다.

핸들러 API를 직접 구현해서 나만의 스토리지 엔진을 추가할 수도 있다.

 

4. 운영체제, 하드웨어

실제 테이블의 데이터와 로그 데이터를 파일로 저장하는 운영체제 파일 시스템과 하드웨어 부분이다.

 

 

쿼리 실행 과정

쿼리 캐시

  • 사용자가 SQL 요청을 MySQL로 보내면 가장 먼저 쿼리 캐시를 만난다.
  • 쿼리 캐시는 쿼리 요청 결과를 캐싱하는 모듈이다.
  • 동일한 SQL 요청에 대한 결과를 빠르게 받을 수 있다.
  • 쿼리 캐시는 가지고 있는 데이터의 테이블에 변경이 발생한다면 쓸모없어진 캐싱 데이터를 삭제해야 한다.
캐싱 데이터가 삭제될 때마다 쿼리 캐시에 접근하는 쓰레드에 Lock이 걸리는데, 심각한 동시 처리 성능 저하를 유발한다.
따라서 MySQL 8.0부터 쿼리 캐시가 완전히 삭제되었다.

 

쿼리 파서

  • SQL 문장을 의미있는 단위의 토큰으로 쪼개서 트리(Parse Tree)로 만든다.
  • 이 과정에서 SQL 문법 오류를 체크한다.

 

전처리기

  • Parse Tree의 토큰을 하나씩 검사하면서 토큰이 유효한지, 토큰의 테이블명이나 컬럼이 실제 존재하는 값인지 체크한다.

 

옵티마이저

  • SQL 실행을 최적화해서 쿼리 실행 계획을 만든다.
  • 규칙 기반 최적화 : 옵티마이저에 내장된 우선순위에 따라 실행 계획 수립 (원칙적으로 어떤 방식이 더 빠른가에 초점)
  • 비용 기반 최적화 : SQL을 처리하는 다양한 방법을 마련해두고, 각 방법의 비용과 테이블 통계 정보를 통해 실행 계획 수립 (레코드 수, 데이터의 분포 상태 등을 반영해서 최적의 방법을 고르는 방식)
    • 규칙 기반은 빠르다는 장점, 비용 기반은 효율적이라는 장점을 가짐
    • 규칙 기반은 초기 MySQL에서 사용한 방식, 최근에는 대부분 비용 기반 방식 사용

 

쿼리 실행 엔진

  • 옵티마이저가 만든 실행 계획대로 스토리지 엔진을 호출해서 쿼리를 수행한다.

 

스토리지 엔진

  • 쿼리 실행 엔진이 요청하는대로 데이터를 디스크로 저장하고 읽는다.
  • 플러그인 형태로 제공되기 때문에 사용자는 원하는 스토리지 엔진을 선택해서 사용할 수 있다.
  • 다양한 스토리지 엔진과의 상호작용이 이루어지도록 중간 다리 역할을 해주는 게 핸들러 API이다.
  • ex. InnoDB, MyISAM
MySQL은 스토리지 엔진 말고도 검색어 파서, 사용자 인증 모듈 등도 플러그인 형태로 제공한다.

플러그인끼리 통신할 수 없고, 플러그인이 MySQL 서버의 변수나 함수를 직접 호출하는 것 때문에 캡슐화 위반한다는 단점이 있다.
따라서 MySQL 8.0부터는 이러한 플러그인 아키텍처의 단점을 보완한 컴포넌트 아키텍처를 제공한다.
컴포넌트 아키텍처가 도입되면서 MySQL 서버 내부를 모듈로 구성하고, 이 컴포넌트(모듈)끼리 인터페이스를 통해 통신한다.
=> 캡슐화, 의존성 관리, 통신이 체계화 가능해짐
더보기

기본 파서 (영어 기준)

'hello world' → ['hello', 'world']

 

사용자 정의 파서 (한국어 예시)

'나무위키는 누구나 편집할 수 있습니다'  → ['나무위키', '편집', '누구나', '수', '있습니다'] 등으로 쪼개도록 구현 가능

 

=> 검색 품질을 높이기 위해 파서를 커스터마이징 할 수 있는게 검색어 파서

 


사용자 인증 모듈은 MySQL에 로그인하려는 사용자의 인증 방식을 결정하는 기능

자체 로직을 통해 사용자 인증을 수행하고 싶을 때 인증 플러그인 사용 

 

mysql_native_password
→ 전통적인 MySQL 비밀번호 방식 (암호화된 패스워드 비교)

 

caching_sha2_password
→ MySQL 8.0의 기본 인증. 더 보안 강화됨.

 

커스텀 플러그인
→ 예를 들어 Google OAuth 또는 자체 개발한 API를 이용한 로그인 인증도 가능

 


InnoDB 스토리지 엔진

 

PK에 의한 클러스터링

  • 레코드를 PK순으로 정렬해서 저장하기 때문에 범위 검색이 매우 빠르다.
  • 하지만 클러스터링을 쓰기 때문에 쓰기 성능이 저하된다.
  • PK를 지정하지 않으면 내부적으로 PK 인덱스 자동 생성한다.
    • 사용자는 자동 생성된 PK에 접근할 수 없어서 데이터를 조회할 때 성능을 최적화하기 어렵다.
    • 따라서 PK는 직접 지정하는 것을 권장한다.

트랜잭션 지원

  • MVCC(Multi Version Concurrency Control) 지원
    • 다양한 버전을 동시에 관리하는 것을 의미한다. 
    • 버퍼풀 : 변경된 데이터를 디스크에 저장하기 전까지 잠깐 버퍼링 하는 공간
    • 언두 로그 : 변경되기 전의 데이터를 백업해두는 공간 -> 롤백 지원!

다른 트랜잭션이 유재석의 취미를 검색한다면?

DB에 설정된 트랜잭션 격리 수준에 따라 다르다.
격리 수준이란 여러 트랜잭션이 동시에 실행될 때, 서로의 작업이 얼마나 영향을 미치지 않도록 격리할 것인가를 결정하는 기준이다.
READ_UNCOMMITED : 아직 커밋되지 않은 최신 변경 상태인 '코딩' 반환
READ_COMMITED, REPEATABLE_READ, SERIALIZABLE : 커밋된 상태인 '독서' 반환

=> 레코드에 잠금을 걸지 않아도 레코드의 격리 수준에 맞게 읽기를 할 수 있는 것이 바로 MVCC 기술이다.
더보기

레코드 단위 잠금

DB에서 데이터를 변경할 때 동시성 문제를 고려해서 레코드에 대한 접근을 막는 것이다.

 

InnoDB는 인덱스 기반의 레코드 단위 잠금을 수행한다.

MVCC는 읽기 전용 트랜잭션의 동시성을 높이기 위한 기술이고, 쓰기(수정, 삭제)에는 두 트랜잭션이 동시에 같은 레코드를 수정하면 안되기 때문에 인덱스 기반 레코드 잠금이 필수이다.

 

인덱스 기반의 레코드 잠금을 사용하지 않으면 테이블 전체를 스캔해야 해서 불필요하게 많은 레코드에 잠금이 걸리고, 성능 저하와 데드락 위험이 증가한다.

 

인덱스를 사용하면 해당 레코드만 잠금을 하기 때문에 동시성이 향상된다.

 

InnoDB 버퍼풀

  • 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐싱해두는 공간이다.
  • 버퍼풀은 쓰기 지연 버퍼로도 사용된다.
  • SQL 요청 결과를 일정한 크기의 페이지 단위로 캐싱한다.
    • 페이지 교체 알고리즘으로 LRU 알고리즘을 사용하고 있다.
  • 더티 페이지를 모았다가 이벤트를 발생시켜 디스크에 반영하는데, 이것은 랜덤 IO를 줄이기 위함이다.
    • 더티 페이지는 DDL 명령어로 변경된 페이지다.
    • 랜덤 IO는 디스크 여기저기 흩어진 데이터를 읽고 쓰는 작업이다.

어댑티브 해시 인덱스

  • 인덱스 키와 페이지의 주소값 쌍으로 구성된 인덱스이다.
  • 사용자가 자주 요청하는 데이터에 대해서 InnoDB가 자동으로 만들어주는 인덱스이다.
  • 어댑티브 해시 인덱스를 통해 빠르게 데이터에 접근할 수 있어서 쿼리 역시 더 빠르게 처리가 가능하다.

 


MyISAM 스토리지 엔진

  • 클러스터링, 트랜잭션, 외래키 모두 지원하지 않는다.
  • 테이블 단위로 잠금을 걸기 때문에 동시 처리에 불리하다.
  • InnoDB의 버퍼풀과 비슷한 기능을 하는 키 캐시가 존재한다.
    • 버퍼풀과 다르게 키와 레코드 위치를 저장

기존에는 MyISAM을 스토리지 엔진으로 사용했지만 MySQL 5.5버전부터 InnoDB 스토리지 엔진이 기본 엔진으로 채택되었다.

시스템 테이블(메타데이터를 저장하는 내부 테이블)은 여전히 MyISAM을 사용했지만 MySQL 8.0부터 모든 테이블이 InnoDB 스토리지를 사용하게 되었다.

HttpServletResponse가 API 파라미터로 존재하는 이유

서버는 클라이언트로 응답할 때, return을 통해 View, String, ResponseEntity(JSON)를 전달하거나 HttpServletResponse를 사용해서 응답 데이터(Body, Headers, Cookies 등)를 설정하고 전달할 수 있다.

 

@PostMapping("/login")
public ResponseEntity<LoginResponseDto> login(
    @Valid @RequestBody LoginRequestDto requestDto, 
    HttpServletResponse response) {
    
    LoginResponseDto responseDto = userService.login(requestDto);

    // 로그인 성공하면 쿠키 설정
    Cookie cookie = new Cookie("userId", String.valueOf(responseDto.getId()));
    response.addCookie(cookie); // 응답 객체 활용

    return new ResponseEntity<>(responseDto, HttpStatus.OK);
}

 

이 코드를 보면, HttpServletResponse가 login 메서드의 파라미터로 전달되고 있다.

Response는 서버에서 클라이언트로 응답할 때 사용하는데 왜 파라미터로 전달되는 것일까?

 

이유는 HttpServletResponse가 단순히 데이터가 아니라, 클라이언트의 요청과 1:1로 연결된 응답 객체이기 때문이다.

Spring이 HttpServletResponse 객체를 자동으로 주입해주고, 개발자가 이 객체를 조작하면 그 결과가 클라이언트에게 응답으로 전달된다.

개발자가 설정한 값을 클라이언트에 반환하는 것도 Spring이 자동으로 해주기 때문에 따로 return할 필요가 없다.

 

@PostMapping("/hello")
public ResponseEntity<String> hello() {
    return ResponseEntity.ok("Hello, Client!");
}

 

이렇게 파라미터로 HttpServletResponse가 주어지지 않은 경우에도 Spring은 내부적으로 응답 객체를 전달하고 있다.

개발자가 따로 응답 객체인 HttpServletResponse를 조작하지 않아서 Spring이 알아서 처리하고 클라이언트로 함께 전달한다.

 

 

HttpServletResponse의 주요 기능

1. 응답 상태 코드 설정

response.setStatus(HttpServletResponse.SC_BAD_REQUEST); // 400 Bad Request

// Spring에서는 ResponseEntity, @ResponseStatus를 사용하는 게 더 간편

 

2. 응답 헤더 설정

response.setHeader("Cache-Control", "no-cache");
response.addHeader("Custom-Header", "value");
// Spring에서는 ResponseEntity로 대체 가능

return ResponseEntity.ok()
    .header("Cache-Control", "no-cache")
    .header("Custom-Header", "value")
    .body("Hello!");

 

3. 쿠키 추가

Cookie cookie = new Cookie("userId", "12345");
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);

 

4. 리디렉션

response.sendRedirect("/home");

 

5. 파일 다운로드

response.setContentType("application/pdf");
response.setHeader("Content-Disposition", "attachment; filename=\"example.pdf\"");
OutputStream out = response.getOutputStream();
byte[] fileData = Files.readAllBytes(Paths.get("example.pdf"));
out.write(fileData);
out.flush();

 

 


쿠키는 HttpServletRepsonse가 필요하지만, 세션은 HttpServletRequest만 필요한 이유

아래 코드는 동일한 메서드에서 세션을 적용한 예시이다.

쿠키를 사용하기 위해서 HttpServletResponse를 파라미터로 전달받아 쿠키를 설정해야 했는데,

세션은 HttpServletRequest를 전달받고 있다.

public ResponseEntity<LoginResponseDto> login
	(@Valid @RequestBody LoginRequestDto requestDto, 
       HttpServletRequest request){
        
        LoginResponseDto responseDto = userService.login(requestDto);

        HttpSession session = request.getSession(); // 세션 할당

        UserResponseDto loginUser = new UserResponseDto(responseDto.getId(), userService.findById(responseDto.getId()));
        session.setAttribute(LOGIN_USER, loginUser);

        return new ResponseEntity<>(responseDto, HttpStatus.OK);
    }

 

쿠키는 개발자가 쿠키 객체를 생성하고 addCookie()로 response 객체에 쿠키를 담아줘야 했다.

반면에 세션은 request로 전달받은 객체에서 getSession()으로 세션 정보만 꺼내서 서버에서 관리한다.

 

즉, 쿠키는 클라이언트에서 관리하고 세션은 서버에서 관리하기 때문에 이러한 차이가 있다고 볼 수 있다.

문제

Filter 내부에서 발생하는 에러를 커스텀 에러로 만들어서 아래와 같이 클래스를 생성했다.

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

 

@ExceptionHandler를 통해 해당 에러의 예외처리를 실행하도록 했다.

@ExceptionHandler(LoginFailedException.class)
public ResponseEntity<ErrorResponseDto> handleLoginFailedException(LoginFailedException e, HttpServletRequest request){
    ...
    return new ResponseEntity<>(responseDto,HttpStatus.BAD_REQUEST);
}

 

하지만 super(message)를 통해 서버에만 로그가 출력되고, 클라이언트로는 예외처리가 제대로 수행하지 않아 ServletContainer가 기본적으로 전달하는 500 서버 에러가 클라이언트로 전달됐다.

 

원인

 

@ExceptionHandler는 Spring MVC 컨트롤러의 예외처리만 잡아주기 때문에 그 이전에 발생하는 예외는 처리하지 않는다.

Filter는 사용자 요청과 DispatcherServlet 사이의 과정에서 수행되기 때문에 @ExceptionHandler로 예외처리할 수 없었다.

따라서 filter 클래스 안에서 예외처리를 진행해야 했다.

 

 

해결

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String requestURI = httpRequest.getRequestURI();

        log.info("로그인 필터 로직 실행");
        log.info("request URI = {}", requestURI);

        if (!isWhiteList(requestURI)) {
            HttpSession session = httpRequest.getSession(false);

            if (session == null || session.getAttribute(LOGIN_USER) == null) {
                // 여기서 발생하는 예외처리 필요
            }
        }

        chain.doFilter(request, response);
    }

 

방법은 간단하게 if문 내부에서 예외가 발생하면 try-catch문으로 처리하는 것이다.

여기서 문제점이 있다면 doFilter의 반환 타입은 void라 아무것도 반환하지 않지만, 나는 클라이언트로 에러 메시지를 전달해야 하는 것이다.

만약 반환 타입을 ResponseEntity 등으로 반환한다면, 예외가 발생하지 않는 상황에서도 어떤 데이터를 반환해야 했다.

그래서 나는 HttpServletResponse를 사용해서 내부에 에러 메시지를 담고, return 하지 않아도 Spring이 자동으로 리턴해주도록 수정했다.

 

if (session == null || session.getAttribute(LOGIN_USER) == null) {
    // ObjectMapper를 통해 Dto를 HttpServletResponse에 담아서 JSON 형식으로 전달
    ErrorResponseDto responseDto = new ErrorResponseDto(Timestamp.valueOf(LocalDateTime.now()), UNAUTHORIZED.getStatus(), UNAUTHORIZED.getError(), UNAUTHORIZED.getCode(), UNAUTHORIZED.getMessage(), requestURI);
    ObjectMapper objectMapper = new ObjectMapper();
    String json = objectMapper.writeValueAsString(responseDto);
    
    httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
    httpResponse.setContentType("application/json");
    httpResponse.setCharacterEncoding("UTF-8");
    httpResponse.getWriter().write(json);
    
    return;
}
{
	"timestamp": "2025-03-26T14:26:45",
	"status": 400,
	"error": "BAD_REQUEST",
	"code": "C001",
	"message": "잘못된 입력값입니다",
	"path": "/api/login"
}

 

위와 같이 정해진 형식으로 통일해서 에러메시지를 전달하고 싶었기에 ErrorResponseDto를 사용했고, 이렇게 만들어진 객체를 JSON으로 변환해서 response에 담기 위해 ObjectMapper를 활용했다.

ObjectMapper는 객체를 JSON으로 변환해주는 클래스이다.

그리고 setStastus, setContentType, setCharacterEncoding으로 전달할 데이터의 상태코드, 타입, 인코딩 설정을 한 뒤 response에 JSON을 담아 응답하는 방식으로 이 문제를 해결했다.

 

결과

로그인 하지 않은 상태로 API 요청을 시도하면, 로그인 필터에서 예외를 발생시켜 이미지와 같이 정해진 형식의 에러메시지를 응답한다.

백준 2798번

 


브루트포스란 완전 탐색 알고리즘으로, 모든 경우의 수를 탐색해서 결과값을 도출하는 알고리즘이다.

모든 경우를 탐색하기 때문에 시간 복잡도가 효율적이지 않지만 코드가 직관적이라는 특징이 있다.

브루트 포스 알고리즘은 일반적으로 O(n), O(n²), O(n³) 등의 시간이 걸리며, 입력 크기가 커지면 성능이 급격히 나빠진다.

이 문제의 경우 3개의 카드를 고르는 상황이라 O(n³)의 시간이 소요된다.

 


1차 코드

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int count = scanner.nextInt();
        int maxNum = scanner.nextInt();
        int[] nums = new int[count];
        int maxSum = 0;

        for (int i = 0; i < count; i++) {
            int num = scanner.nextInt();
            nums[i] = num;
        }
        
        for (int i = 0; i < nums.length; i++) {
            for (int j = 0; j < nums.length; j++) {
                if(nums[i] == nums[j]){
                    continue;
                }
                for (int k = 0; k < nums.length; k++) {
                    if (nums[j] == nums[k] || nums[i] == nums[k]){
                        continue;
                    }
                    if(nums[i] + nums[j] + nums[k] > maxSum && nums[i] + nums[j] + nums[k] <= maxNum){
                        maxSum = nums[i] + nums[j] + nums[k];
                    }
                }
            }
        }
        System.out.println(maxSum);
    }
}

 

1. 반복문으로 count 만큼의 값을 입력받는다.

2. 3개의 중첩된 반복문으로 각 카드에 접근하여 총합은 계산한다.

2-1. 만약 카드가 중복인 경우 continue

2-2. 조건문으로 카드의 총합이 입력받은 maxNum보다 작거나 같은 값을 maxSum에 저장한다.

 

이 코드는 정상적으로 동작하지만, 다른 분이 짠 코드를 보다가 각 반복문의 초기값을 0이 아닌, 이전 반복문의 초기값+1로 설정한 것을 보고 따라서 개선해봤다.

예를 들어 첫 번째 반복문이 `i = 0`으로 시작한다면, 그 다음 중첩된 반복문은 `j = i + 1`로 지정하는 것이다.

이렇게 하면 각 카드가 중복될 일이 없기 때문에, 조건문을 넣지 않아도 된다.

 

2차 코드

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int count = scanner.nextInt();
        int maxNum = scanner.nextInt();
        int[] nums = new int[count];
        int maxSum = 0;

        for (int i = 0; i < count; i++) {
            int num = scanner.nextInt();
            nums[i] = num;
        }

        for (int i = 0; i < nums.length; i++) {
            for (int j = i + 1; j < nums.length; j++) {
                for (int k = j + 1; k < nums.length; k++) {
                    if(nums[i] + nums[j] + nums[k] > maxSum && nums[i] + nums[j] + nums[k] <= maxNum){
                        maxSum = nums[i] + nums[j] + nums[k];
                    }
                }
            }
        }
        System.out.println(maxSum);
    }
}

 

다중 중첩 반복문이지만 조건문만 제거해도 훨씬 가독성이 좋은 것 같다.

하지만 백준에 코드를 제출했을 때, 1차 코드와 비교해서 2차 코드의 성능이 유의미하게 올라가진 않았다.

 

3차 코드

import java.io.*;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

        String[] input = br.readLine().split(" ");
        int count = Integer.parseInt(input[0]);
        int maxNum = Integer.parseInt(input[1]);

        int[] nums = new int[count];
        int maxSum = 0;

        StringTokenizer st = new StringTokenizer(br.readLine());

        for (int i = 0; i < count; i++) {
            nums[i] = Integer.parseInt(st.nextToken());
        }

        for (int i = 0; i < nums.length; i++) {
            for (int j = i + 1; j < nums.length; j++) {
                for (int k = j + 1; k < nums.length; k++) {
                    if(nums[i] + nums[j] + nums[k] > maxSum && nums[i] + nums[j] + nums[k] <= maxNum){
                        maxSum = nums[i] + nums[j] + nums[k];
                    }
                }
            }
        }
        bw.write(maxSum+"");
        bw.flush();
        bw.close();
        br.close();
    }
}

 

스캐너 대신 버퍼를 사용해서 입출력을 해보았다.

코드를 작성할 때는 배열에 split한 값을 담고, 문자열을 정수로 변환하는 과정이 필요해서 번거롭지만,

제출해보면 성능은 개선되는 것을 볼 수 있다.

 

 

Scanner는 내부에서 정규 표현식으로 한 글자씩 입력을 분석하고 원하는 타입으로 변환 후 반환한다.

BufferedReader는 버퍼를 이용해서 한 줄 단위로 한꺼번에 읽어서 반환한다. (필요 시 개발자가 타입 변환)

=> 정규 표현식을 사용해서 값을 변환하지 않고, 한 번에 여러 글자를 읽어오는 BufferedReader가 훨씬 빠르다.

 

System.out.println()은 출력할 때마다 출력 스트림으로 데이터를 보낸다.

BufferedWriter는 버퍼를 이용해서, 버퍼가 꽉 차거나 flush()가 호출될 때 한꺼번에 데이터를 출력한다.

=> 자주 출력하는 상황에서는 BufferedWriter가 훨씬 빠르다.

SQL Mapper의 한계

- Spring JDBC Template

- MyBatis

 

  1. SQL과 자바 간의 매핑이 필요하다.
  2. 필드가 추가되는 등의 객체 구조가 바뀌는 경우에 SQL문도 수정해야 한다.
  3. 패러다임 불일치 문제가 발생한다.
    • 부모 테이블과 자식 테이블에 각각 데이터를 삽입해야 한다. (DB는 상속 개념 X)
    • 두 테이블을 조인해서 데이터를 꺼내와야 한다.
    • => 데이터의 CRUD가 까다롭기 때문에, DB에 저장할 객체는 상속 관계를 사용하지 않게 된다.

 


JPA

  • 자바의 ORM 기술 표준 인터페이스이다.
  • 대표적인 구현체로 Hibernate를 사용한다.

ORM(Object-Relational Mapping) : 객체와 관계형 DB를 자동으로 Mapping하여 패러다임 불일치 문제 해결

 

JPA의 CRUD

// 저장
jpa.persist(tutor);
// 조회
Tutor tutor = jpa.find(Tutor.class, tutorId);
// 수정
tutor.setName("수정할 이름");
// 삭제
jpa.remove(tutor);

 

 

JPA의 패러다임 불일치 해결

jpa.persist(tutor);
// INSERT INTO person ..., INSERT INTO tutor ... 

Tutor tutor = jpa.find(Tutor.class, tutorId);
// SELECT * FROM tutor t JOIN company c ON t.company_id = c.id

tutor.setCompany(company);
jpa.persist(company);
// Collection처럼 setter로 데이터를 바로 수정 가능

Tutor tutor1 = jpa.find(Tutor.class, tutorId);
Tutor tutor2 = jpa.find(Tutor.class, tutorId);
// 동일한 인스턴스를 가져오기 때문에 동일성 비교 가능

 

이때 동일한 인스턴스를 가져오기 위해서는 두 객체가 동일한 트랜잭션 안에서 생성되어야 한다.

조회한 엔티티를 JPA가 내부적으로 캐싱해두고, 같은 트랜잭션 안에서 해당 엔티티를 조회하면 새로 쿼리를 날리지 않고 이미 조회된 인스턴스를 반환하기 때문이다. (-> 1차 캐시)

 

 

JPA의 성능

1차 캐시

DB에 데이터를 저장하기 전, 1차 캐시에 우선적으로 저장하기 때문에, 같은 데이터를 조회할 때 DB를 거치지 않고 조회 가능하다.

Tutor tutor1 = jpa.find(Tutor.class, tutorId); // 실행 결과 1차 캐시에 저장
Tutor tutor2 = jpa.find(Tutor.class, tutorId); // 캐시에서 조회

tutor1 == tutor2; // true

 

 

쓰기 지연

네트워크 통신을 저장소에 한 번에 모아서 요청할 수 있어서 비용이 감소된다.

`@GeneratedValue(strategy = GenerationType.IDENTITY)`를 엔티티 PK 필드에 적용하면 쓰기 지연을 무시한다.

// 트랜잭션 시작
transaction.begin();

jpa.persist(company);
jpa.persist(tutor1);
jpa.persist(tutor2);

// 트랜잭션 제출, JDBC BATCH SQL
transaction.commit();

 

배치 처리

`hibernate.jdbc.batch_size`을 application.properties에 설정해서 한 번에 몇 개의 SQL을 모아서 실행할지 지정할 수 있다.

// hibernate.jdbc.batch_size=10 일 때

for (int i = 1; i <= 25; i++) {
    Member member = new Member();
    member.setId((long) i);
    member.setName("User " + i);
    em.persist(member);  // 영속 상태 (쓰기 지연 저장소에 저장)
}

tx.commit(); // INSERT 10개 실행 -> INSERT 10개 실행 -> INSERT 5개 실행 -> 트랜잭션 종료

 

 

즉시 로딩 : 한 번에 조회

지연 로딩 : 필요할 때 조회

@Entity
public class Tutor {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
    private Company company;
}

Tutor tutor = tutorRepository.find(tutorId); // JOIN 사용하여 Tutor, Company 모두 조회
Company company = tutor.getCompany();
String companyName = company.getName();
@Entity
public class Tutor {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    private Company company;
}

Tutor tutor = tutorRepository.find(tutorId); // Tutor만 조회
Company company = tutor.getCompany(); // Company 조회 X
String companyName = company.getName(); // Company 조회

 

 


hibernate.dialect

SQL 표준을 지키지 않는 특정 데이터베이스의 고유한 기능(dialect, 방언)을 지정해서, 데이터베이스와 Hibernate가 상호작용할 때 사용 중인 데이터베이스에 맞게 SQL 구문을 자동으로 조정한다.

JPA가 생성하는 SQL과 JPQL(@Query 또는 em.createQuery())로 작성한 SQL문이 자동으로 조정된다.

sql-jpql

 


hibernate.hbm2ddl.auto

애플리케이션 실행 시점에 DB 테이블을 어떻게 생성/관리할지 결정한다.

ddl


영속성 컨텍스트

Entity 객체를 영속성 상태로 관리하는 캐시 역할을 하는 공간이다.

DB와 자동으로 동기화되며 같은 트랜잭션 안에서는 동일한 객체가 유지된다.

영속성 컨텍스트에 접근하기 위해서는 Entity Manager를 통해야 한다.

 

Entity의 생명주기

JPA에서 Entity란 데이터베이스의 테이블을 나타내는 클래스를 의미한다.

  1. 비영속(new/transient)
    • 영속성 컨텍스트가 모르는 새로운 상태
    • 데이터베이스와 전혀 연관이 없는 객체 (=자바 세상에만 데이터가 존재하는상태)
  2. 영속(managed)
    • 영속성 컨텍스트에 저장되고 관리되고 있는 상태
    • 1차 캐시에 저장
  3. 준영속(detached)
    • 영속성 컨텍스트에 저장되었다가 분리되어 더 이상 기억하지 않는 상태
  4. 삭제(removed)
    • 영속성 컨텍스트에 의해 삭제로 표시된 상태
    • 트랜잭션이 끝나면 데이터베이스에서 제거
public static void main(String[] args) {
    // EntityManagerFactory 생성
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("test");
    // EntityManager 생성
    EntityManager em = emf.createEntityManager();
    // Transaction 생성
    EntityTransaction transaction = em.getTransaction();
    // 트랜잭션 시작
    transaction.begin();

    try {
        Tutor tutor = new Tutor(1L, "wonuk", 100); // 비영속

        System.out.println("persist 전");
        em.persist(tutor); // EntityManager가 Tutor 객체 저장 -> 영속
        System.out.println("persist 후");

        // SQL 실행
        transaction.commit();
    } catch (Exception e) {
        // 실패 -> 롤백
        e.printStackTrace();
        transaction.rollback();
    } finally {
        // 엔티티 매니저 연결 종료
        em.close();
    }
    emf.close();
}
준영속 상태로 만드는 법

// 1. 특정 Entity만 준영속 상태로 변경
em.detach()

// 2. 영속성 컨텍스트 초기화
em.clear()

// 3. 영속성 컨텍스트 종료
em.close()

 


변경 감지(Dirty Checking)

영속성 컨텍스트가 엔티티의 초기 상태를 저장하고 트랜잭션 커밋 시점에 현재 상태와 비교해 변경 사항이 있는지 확인한다.

 

내부 동작

1. 값을 조회한 최초 시점의 상태를 1차 캐시와 snapshot에 저장

...

2. flush()가 수행될 때, JPA가 snapshot과 현재 엔티티 상태를 비교 (변경 감지)

3. 엔티티에 변경이 있으면 DB에 commit()

 

=> setter로 값을 변경하고 update 과정을 거치지 않아도 자동으로 변경된 부분을 DB에 커밋

flush()를 수행하지 않는 경우는 commit() 호출 시 자동으로 flush()가 실행되면서 변경 감지

 


필드 매핑

JPA로 관리되는 클래스인 Entity의 필드는 테이블의 컬럼과 매핑된다.

@Entity
@Table(name = "board")
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // @Column을 사용하지 않아도 자동으로 매핑된다.
    private Integer view;

    // 객체 필드 이름과 DB 이름을 다르게 설정할 수 있다.
    @Column(name = "title")
    private String bigTitle;

    // DB에는 기본적으로 enum이 없다.
    @Enumerated(EnumType.STRING)
    private BoardType boardType;

    // VARCHAR()를 넘어서는 큰 용량의 문자열을 저장할 수 있다.
    @Column(columnDefinition = "longtext")
    private String contents;

    // 날짜 타입 DATE, TIME, TIMESTAMP를 사용할 수 있다.
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdDate;

    @Temporal(TemporalType.TIMESTAMP)
    private Date lastModifiedDate;
    
    @Transient
    private int count;

    public Board() {
    }
}

 

@Transient이 붙은 필드는 DB에 반영 X
EnumType.ORDINAL을 사용하면 Enum 값이 추가될 때 마다 순서가 바뀌기 때문에 실제로 사용하지 않는다.

 


연관관계 Mapping

단방향

@Entity
@Table(name = "tutor")
public class Tutor {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    
    // N:1 단방향 연관관계 설정
    @ManyToOne
    @JoinColumn(name = "company_id")
    private Company company;
		
    // 기본 생성자, getter/setter
}

양방향

 

@Entity
@Table(name = "company")
public class Company {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
	
    // 양방향 연관관계 설정(mappedBy)
    @OneToMany(mappedBy = "company")
    // null을 방지하기 위해 ArrayList로 초기화 한다.(관례)
    private List<Tutor> tutors = new ArrayList<>(); 
		
    // 기본 생성자, getter/setter
}

 

mappedBy는 두 엔티티 간의 관계에서 연관관계의 주인이 아닌 쪽에 선언한다.

연관관계의 주인 선정 기준

- 항상 FK가 있는 곳을 연관관계의 주인으로 지정한다.

- Company가 주인인 경우 Company를 수정할 때 Tutor를 Update하는 SQL이 실행된다.(SQL문 반복 실행 발생)


JPA와 Spring Data JPA

JPA

  • 자동으로 내부에서 EntityManagerFactory와 TransactionManager를 싱글톤으로 관리한다.
  • @PersistenceContext를 통해 자동으로 생성된 EntityManager를 주입받아 사용할 수 있다.
@Repository
public class TutorRepository {
    
    @PersistenceContext
    private EntityManager em;

    public void save(Tutor tutor) {
        em.persist(tutor);
    }

    public Tutor findById(Long id) {
        return em.find(Tutor.class, id);
    }

    public List<Tutor> findAll() {
        return em.createQuery("SELECT * FROM tutor", Tutor.class).getResultList();
    }

    public void delete(Tutor tutor) {
        em.remove(tutor);
    }
}

 

Spring Boot는 프록시(가짜 객체)를 싱글톤으로 등록해 요청마다 별도의 EntityManager 인스턴스를 제공하여,
각 요청은 독립적으로 EntityManager를 사용해 안전하게 데이터베이스 작업을 처리할 수 있게 된다.
따라서 EntityManager는 동시성 문제 방지를 위해 싱글톤으로 등록되지 않는다.


+ JPA는 지연 로딩 기능을 지원할 때도, 프록시 객체를 생성한다.
프록시 객체는 엔티티의 기본 생성자를 호출하여 빈 객체를 만들고 필드값을 채워넣는 방식으로 생성되기 때문에, 엔티티는 기본 생성자를 필수로 가져야 한다.

 

Spring Data JPA

public interface MemberRepository extends JpaRepository<Member, Long> {
		// JPA Query Methods
		public Member findById(Long id);
}

  1. JPA 추상화 Repository 제공
    • CrudRepository, JpaRepository 인터페이스를 제공한다.
    • SQL이나 EntityManager를 직접 호출하지 않아도 기본적인 CRUD 기능을 손쉽게 구현할 수 있다.
  2. JPA 구현체와 통합
    • 일반적으로 Hibernate를 통해 자동으로 SQL이 생성된다.
  3. QueryMethods
    • Method 이름만으로 SQL을 자동으로 생성한다.
    • @Query 를 사용하여 JPQL 또는 Native Query를 정의할 수 있다.
      • 복잡한 SQL을 직접 구현할 때 사용
  4. 트랜잭션 관리와 LazyLoading
    • 트랜잭션 기능을 Spring과 통합하여 제공한다.
    • 연관된 Entity를 필요할 때 로딩하는 지연로딩 기능을 지원한다.

 

SimpleJpaRepository

Spring Data JPA의 기본 Repository 구현체로 JpaRepository 인터페이스의 기본 메서드들을 실제로 수행하는 클래스이다.

내부적으로 EntityManager를 사용하여 JPA Entity를 DB에 CRUD 방식으로 저장하고 관리하는 기능을 제공한다.

public interface MemberRepository extends JpaRepository<Member, Long> {} 
// JpaRepository< entity타입, id타입 >
  • Spring이 실행되면서 JpaRepository 인터페이스를 상속받은 인터페이스가 있다면, 해당 인터페이스의 정보를 토대로 SimpleJpaRepository 를 생성하고 Bean으로 등록한다.
  • 인터페이스의 구현 클래스를 직접 만들지 않아도 JpaRepository 의 기능을 사용할 수 있다.
  • 개발자가 직접 SimpleJpaRepository를 사용하거나 참조할 필요는 없다.
  • 제네릭에 선언된 엔티티와 매핑되는 테이블의 SQL이 생성된다.

 

Query Methods

public interface MemberRepository extends JpaRepository<Member, Long> {
    // Query Methods
    Member findByNameAndAddress(String name, String address);
}

// 자동으로 생성되어 실제로 실행되는 SQL
SELECT * FROM member WHERE name = ? AND address = ?;
  1. find : Entity에 매핑된 테이블(member)을 조회한다.
  2. ByName : 조건은 member 테이블의 name 필드이다.
  3. AndAddress : 또다른 조건은 member 테이블의 address 필드이다.

https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html

 

JPA Query Methods :: Spring Data JPA

By default, Spring Data JPA uses position-based parameter binding, as described in all the preceding examples. This makes query methods a little error-prone when refactoring regarding the parameter position. To solve this issue, you can use @Param annotati

docs.spring.io

 

JPA Auditing

엔티티의 생성 및 수정 시간을 자동으로 관리해주는 기능이다.

@EnableJpaAuditing // JPA Auditing 기능을 활성화
@SpringBootApplication
public class SpringDataJpaApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringDataJpaApplication.class, args);
    }
}
@Getter
// 클래스를 상속받는 Entity에 공통 매핑 정보 제공
@MappedSuperclass 
// Entity를 DB에 적용하기 전, 커스텀 콜백 요청 (내부적으로 @PrePersist 사용)
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity{
    
    @CreatedDate // 생성 시점의 날짜 자동 기록
    @Column(updatable = false)
    @Temporal(TemporalType.TIMESTAMP) // 날짜 타입을 세부적으로 지정
    private LocalDateTime createdAt;

    @LastModifiedDate // 수정 시점의 날짜 자동 기록
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime modifiedAt;
}
@Entity
public class User extends BaseEntity{
    @Id
    private Long id;
    private String name;
}

 

SOLID : 객체 지향 설계의 5가지 기본 원칙

SRP(Single Responsibility Principle) 단일 책임 원칙

클래스는 한 가지 기능에 집중하고, 그 외의 기능을 담당하지 않는다.

실제로는 상황에 따라 책임의 크기가 달라진다.

 

OCP(Open Closed Principle) 개방 폐쇄 원칙

확장에는 열려있고, 수정에는 닫혀있어야 한다.

새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있도록 설계해야 한다.

 

LSP(Liskov Substitution Principle) 리스코프 치환 원칙

부모 클래스를 사용하는 곳에서 자식 클래스를 사용해도 프로그램의 동작에 문제가 없어야 한다.

Person person = new Developer();
// Developer는 Person의 행위를 모두 수행할 수 있기 때문에, Person에 저장할 수 있는 것이다.

 

ISP(Interface Segregation principle) 인터페이스 분리 원칙

범용 인터페이스 하나가 아니라 세분화된 작은 인터페이스를 조합해서 사용해야 한다.

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다.

public class Person implements Runnable, Swinmmable {
	...
}

 

DIP(Dependency Inversion Principle) 의존관계 역전 원칙

구체적인 클래스에 의존하지 않고, 인터페이스나 추상 클래스에 의존하도록 설계해야 한다.

 

=> 인터페이스의 구현체가 추가, 수정, 삭제되어도, 해당 구현체의 존재를 모르기 때문에 영향을 받지 않는다.

 


 

OCP, DIP 원칙의 한계

public interface Shape {
    double calculateArea();
}

public class Circle implements Shape {
    public double calculateArea() { return /* 원의 넓이 계산 */; }
}

public class Square implements Shape {
    public double calculateArea() { return /* 사각형의 넓이 계산 */; }
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.calculateArea();
    }
}
public class Main {
    public static void main(String[]) {
	AreaCalculator areaCalculator = new AreaCalculator();
        
        Circle circle = new Circle();	// 구현체를 변경할 때마다 코드 수정 필요
        Square square = new Square();	// Circle -> Square
        
        areaCalculator.calculate(circle);
        areaCalculator.calculate(square);
    }
}

 

추상화, 다형성 구현을 위해 위와 같이 Shape 인터페이스를 구현하는 구현체 Circle, Square를 만들었다.

AreaCalculator는 이 설계 덕분에 Shape의 구현체가 추가, 변경되더라도 넓이를 계산하는 로직을 변경하지 않아도 된다.

하지만 Main 메서드를 보면 결국 어떤 구현체를 사용할지에 대해 알고 있어야 하고, 구현체를 변경할 때마다 코드를 수정해줘야 한다.

Spring은 이러한 다형성, 추상화에 대한 한계를 해결한다.

물론 Spring을 사용하더라도 실무에서는 추상화 과정에서 비용이 발생하기 때문에 기능을 확장할 가능성이 없다면 구현 클래스를 직접 사용하고, 추후 변경된다면 리팩토링을 진행하면 된다.

 


Spring Container의 역할

Spring은 new 키워드로 객체를 생성하지 않고, 어노테이션 또는 XML 설정으로 Bean으로 등록한다.

어노테이션의 경우 @Component(+@Controller, @Service, @Repository)를 적용하면 해당 클래스는 Bean으로 등록된다.

Bean은 Spring Container가 관리하는 객체라고 보면 된다.

Spring Container는 자동으로 의존성을 주입(DI)해서 각 객체들의 의존 관계를 만들어 준다.

 

Spring은 객체를 싱글톤으로 관리한다.

Spring을 사용하지 않는 기존의 싱글톤 패턴은 getInstance() 메서드를 통해 객체가 생성되어 있는지를 확인하고 생성되어 있다면 기존에 만들어진 인스턴스를 반환하는 식으로 코드를 작성해야 한다.

이런 방식으로 싱글톤 패턴을 구현하면 코드의 길이도 길어질 뿐만 아니라, 생성하고자 하는 인스턴스가 getInstance() 메서드를 구현하고 있는 클래스에 의존하게 되므로 OCP, DIP에 맞지 않는다.

public class MainApp {
    public static void main(String[] args) {
       // 첫 번째 싱글톤 인스턴스 요청, 구현클래스.getInstance();
        Singleton instance1 = SingletonImpl.getInstance();
        instance1.showMessage(); // 인스턴스 주소값 출력

        // 두 번째 싱글톤 인스턴스 요청, 구현클래스.getInstance();
        Singleton instance2 = SingletonImpl.getInstance();
        instance2.showMessage(); // 인스턴스 주소값 출력
        
        // 다른 구현체로 바꾸려면 DIP, OCP 위반
        Singleton instance3 = SingletonImplV2.getInstance();
        instance3.showMessage();
    }
}

 

 

스프링의 의존성 주입을 활용하면 OCP, DIP를 준수하면서 싱글톤의 장점을 유지할 수 있다.

스프링에서는 직접 싱글톤 패턴을 구현하지 않고, @Component, @Service, @Repository, @Bean을 사용해서 스프링 컨테이너(ApplicationContext)가 자동으로 해당 객체를 싱글톤으로 관리한다.


자동 bean 등록 vs 수동 bean 등록

자동 Bean 등록(@ComponentScan, @Component)

@Component가 있는 클래스의 앞글자를 소문자로 변경하여 bean 이름으로 등록한다.

 

수동 Bean 등록(@Configuration, @Bean)

@Configuration이 있는 클래스를 bean으로 등록하고 해당 클래스를 파싱해서, @Bean이 있는 메서드를 찾아서 Bean을 생성한다.

이때 해당 메서드의 이름으로 Bean 이름이 설정된다.

// 인터페이스
public interface TestService {
    void doSomething();
}

// 인터페이스 구현체
public class TestServiceImpl implements TestService {
    @Override
    public void doSomething() {
        System.out.println("Test Service 메서드 호출");
    }
}

// 수동으로 빈 등록
@Configuration
public class AppConfig {
    
    // TestService 타입의 Spring Bean 등록
    @Bean
    public TestService testService() {
        // TestServiceImpl을 Bean으로 등록
        return new TestServiceImpl();
    }
    
}

// Spring Bean으로 등록이 되었는지 확인
public class MainApp {
    public static void main(String[] args) {
        // Spring ApplicationContext 생성 및 설정 클래스(AppConfig) 등록
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

        // 등록된 TestService 빈 가져오기
        TestService service = context.getBean(TestService.class);

        // 빈 메서드 호출
        service.doSomething();
    }
}

같은 타입의 Bean이 충돌할 때 해결 방법

기본적으로 자동 bean과 자동 bean끼리 충돌하면, 오류가 발생한다.

자동 bean과 수동 bean이 충돌하면, 수동 bean이 자동 bean 등록을 오버라이딩 하기 때문에 우선권을 가진다.

하지만 스프링 부트에서는 자동 bean과 수동 bean의 충돌도 오류가 발생하게 된다.

1. @Autowired + 필드명

@Autowired는 타입으로 주입을 시도하고, 같은 타입의 bean이 여러 개라면 필드 이름/파라미터 이름으로 매칭한다.

public interface MyService { ... }

@Component
public class MyServiceImplV1 implements MyService { ... }

@Component
public class MyServiceImplV2 implements MyService { ... }

@Component
public class ConflictApp {

	// 필드명을 Bean 이름으로 설정
	@Autowired
	private MyService myServiceImplV2;
	...
}

 

2. Qualifier 사용

bean 등록 시, 추가 구분자를 붙여 준다.

생성자 주입 또는 세터 주입 시 사용 가능하다.

@Component
@Qualifier("firstService")
public class MyServiceImplV1 implements MyService { ... }

@Component
@Qualifier("secondService")
public class MyServiceImplV2 implements MyService { ... }

@Component
public class ConflictApp {

	private MyService myService;

	// 생성자 주입에 구분자 추가
	@Autowired
	public ConflictApp(@Qualifier("firstService") MyService myService) {
			this.myService = myService;
	}
	
	// setter 주입에 구분자 추가
	@Autowired
	public void setMyService(@Qualifier("firstService") MyService myService) {
			this.myService = myService;
	}
	...
}

 

3. @Primary 사용

@Primary로 지정된 Bean이 우선 순위를 가진다.

@Component
public class MyServiceImplV1 implements MyService { ... }

@Component
@Primary
public class MyServiceImplV2 implements MyService { ... }

@Component
public class ConflictApp {

		private MyService myService;

		@Autowired
		public ConflictApp(MyService myService) {
				this.myService = myService;
		}
	...
}

 

@Primary와 @Qualifier의 사용 예시

Oracle, Mysql을 모두 사용할 때, 각 상황에 맞는 메서드 주입하기

@Configuration
public class DataSourceConfig {

    @Bean
    @Primary  // 기본적으로 MySQL을 사용
    public DataSource mysqlDataSource() {
        return DataSourceBuilder.create()
                .url("jdbc:mysql://localhost:3306/mydb")
                .username("mysql_user")
                .password("mysql_password")
                .build();
    }

    @Bean
    public DataSource oracleDataSource() {
        return DataSourceBuilder.create()
                .url("jdbc:oracle:thin:@localhost:1521:orcl")
                .username("oracle_user")
                .password("oracle_password")
                .build();
    }
}
@Service  // MySQL 사용
public class DatabaseService {

    private final DataSource dataSource;

    @Autowired
    public DatabaseService(DataSource dataSource) { // 기본적으로 MySQL 사용
        this.dataSource = dataSource;
    }

    public void connect() {
        System.out.println("사용하는 DB: " + dataSource);
    }
}
@Service  // Oracle 사용
public class OracleDatabaseService {

    private final DataSource dataSource;

    @Autowired
    public OracleDatabaseService(@Qualifier("oracleDataSource") DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void connect() {
        System.out.println("Oracle DB 사용: " + dataSource);
    }
}

 

@Qualifier가 @Primary보다 우선순위가 높기 때문에, 기본적으로 MySQL을 사용할 때는 @Primary로 명시하고,

Oracle을 사용하고 싶은 상황에만 @Qualifier를 통해 해당하는 코드를 주입해준다.

 

 

백준 1157번

 


 

1차 풀이 과정

1. Scanner로 문자열 입력

2. 각 문자와 개수를 저장할 Map 선언

3. Map에 대문자, 문자열로 변환한 값을 개수와 함께 저장

4. 최빈값 반환

5. 각 value값과 최빈값을 비교

5. 만약 이미 결과값으로 저장된 문자가 있다면 최빈 문자가 2개 이상인 걸로 판단하고 `?` 출력

package baekjoon;

import java.util.*;

public class B1157 {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String input = scanner.nextLine();

        Map<String, Integer> map = new HashMap<>();

        // toUpperCase
        // Map에 알파벳, 수량 입력
        for(int i = 0; i < input.length(); i++){
            // map.merge : key값이 존재하지 않으면 1, 존재하면 value+1 연산하여 put
            // charAt -> touppercase 가능
            map.merge(String.valueOf(input.charAt(i)).toUpperCase(), 1, Integer::sum);

        }

        //최대값 출력
        int max = Collections.max(map.values());

        //최빈 문자를 찾기 위해 entrySet 사용
        Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
        String result = "";

        // 최빈값과 entrySet의 value 비교
        for(Map.Entry<String, Integer> entry : entrySet){
            if(entry.getValue()==max){
                if(!result.isEmpty()){
                    result = "?";
                    break;
                }
                result = entry.getKey();
            }
        }

        System.out.println(result);
    }
}

 

entrySet은 각 map의 key, value를 하나로 묶어서 set에 저장하는 방식이다.

entrySet을 사용하면 key와 value를 순서대로 각각 접근할 수 있다.

 


개선된 코드

튜터님의 코드 리뷰를 통해 불필요한 과정을 제거하는 리팩토링을 진행했다.

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String input = scanner.nextLine().toUpperCase();

        // 문자, 개수를 map에 저장
        Map<Character, Integer> map = new HashMap<>();

        for (int i = 0; i < input.length(); i++) {
            // key값이 존재하지 않으면 1, 존재하면 value+1 put
            map.merge(input.charAt(i), 1, Integer::sum);

        }

        //최빈값 반환
        int max = Collections.max(map.values());

        Character result = null;

        // 최빈값과 entrySet의 value 비교
        for (Map.Entry<Character, Integer> entry : map.entrySet()) {
            if (entry.getValue() == max) {
                if (result != null) {
                    result = '?';
                    break;
                }
                result = entry.getKey();
            }
        }

        System.out.println(result);
    }
}

 


결과

EOF(End of File)

EOF란 데이터 소스로부터 더이상 읽을 데이터가 없음을 나타내는 용어이다.

 

데이터 소스란?

데이터를 제공하는 주체

파일을 읽을 때 -> 파일이 데이터 소스

네트워크에서 데이터를 수신할 때 -> 소켓이 데이터 소스

사용자 입력을 받을 때 -> 키보드(표준 입력)이 데이터 소스

데이터베이스에서 데이터를 조회할 때 -> DB가 데이터 소스

 

예를 들어 파일을 읽을 때, 파일의 끝이 나타나면 자동적으로 EOF가 발생하는 것.

 


 

EOF를 사용한 백준 문제

B10951

 

참고로 Scanner를 사용할 때와, Buffer를 사용할 때 EOF를 처리하는 방식이 다르다.

이 문제에서는 Buffer를 사용해서 문제를 해결했다.

원래 데이터 소스를 읽어들이는 과정에서 null인 경우를 EOF로 판단해서 입력을 종료해야 하지만, 

인텔리제이를 사용해서 해당 알고리즘 문제를 풀었기 때문에 따로 null을 전달할 수 없어서 isEmpty()를 추가했다.

그렇기 때문에 아래 코드는 ""가 입력된 경우 EOF로 판단하고 종료하는 코드이다.

package baekjoon;

import java.io.*;

public class B10952 {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

        String inputString;
        int sum;

        while ((inputString = br.readLine()) != null && !inputString.isEmpty()) {
            String[] inputStringArray = inputString.split(" ");

            sum = 0;

            for (int i = 0; i < inputStringArray.length; i++) {
                sum += Integer.parseInt(inputStringArray[i]);
            }

            bw.write(sum + "\n");
        }

        bw.flush();
        bw.close();
        br.close();
    }
}

 

 

B11718

 

이 문제 역시 EOF가 입력될 때까지 입력값을 출력하는 문제이다.

위의 문제와 다른 점이 있다면 줄바꿈을 허용한다는 것이다.

줄바꿈이 일어나도 계속 실행되어야 하기  때문에, IDE를 사용하면 이 프로그램을 종료하지 못한다..

그래서 백준에 바로 제출해서 결과를 봐야했다.

 

이번 문제는 Scanner를 사용해서 풀었다.

입력값을 추가 연산없이 그대로 출력하는 문제라 아주 간단하다!

스캐너는 EOF를 hasNext()를 사용해서 판단하기 때문에, 반복문의 조건식으로 사용해줬다.

package baekjoon;

import java.util.Scanner;

public class B11718 {
    public static void main(String[] args) {
        // 스캐너 사용
        Scanner scanner = new Scanner(System.in);

        while (scanner.hasNext()) {
            String string = scanner.nextLine();
            System.out.println(string);
        }

        scanner.close();
    }
}

 


 

지난 번에 백준으로 첫 문제를 풀었음에도, 이 문제를 다시 만나니까 멈칫하게 됐다.

사실 어렵지 않은 문제이니까 확실히 익혀서 언제든 활용할 수 있도록 하자.

RESTful API를 구현한 Spring 프로젝트 - Todo Project


Keep

이번 프로젝트에서 진행한 과정 중 다음 프로젝트에서도 유지했으면 하는 부분

 

1️⃣ 문서화 (Readme, ERD, API 명세서)

ERD, API를 사전에 작성하는 게 번거롭고 시간을 많이 잡아먹긴 하지만, 프로젝트를 진행할 때 참고해서 코드를 짜니까 진행이 빠르게 됐던 것 같다.

 

2️⃣ 프로젝트 기간 중의 트러블 슈팅 정리

메모장, 블로그를 이용해서 조금씩 트러블 슈팅을 정리해두니까 고민했던 부분을 까먹지 않고 정리할 수 있었다.

 

3️⃣ 주석 달기

기존에는 타인이 내 프로젝트를 이해하기 쉽도록 가독성을 위해 프로젝트를 완성하고 의무적으로 달았다면, 코드를 짜기 전에 주석으로 머리를 정리하면서 코딩을 하니까 훨씬 수월했던 것 같다.

 

4️⃣ 커밋 컨벤션 잘 지키기

커밋 기록을 찾아볼 때 가독성이 좋고 깔끔하다.

 

Problem

문제점 : 이번 프로젝트에서 발생한 문제점을 객관적으로 판단
해결 방안 : 해당 문제점을 해결할 수 있는 현실적인 방안 제시

 

1️⃣ 기능별로 해당하는 브랜치를 생성해서 작업하지 않고 main에서 통으로 작업했다.

개인 과제라 큰 문제는 없었지만, 앞으로의 협업을 위해 좋은 개발 습관을 들여놓도록 하자.

그리고 커밋도 비즈니스 로직 단위로 잘 세분화에서 자주 하자.

나름 생각날 때마다 커밋을 남기긴 했지만, 다시 보니 많이 섞여있어서 회고를 진행하면서 코드를 리뷰하기에 복잡한 것 같다.

-> Git을 열심히 활용하자!

 

Try

다음 프로젝트를 위해 해야 할 노력 또는 시도해볼 것

 

1️⃣ Controller, Service 분리해서 사용하는 연습하기

2️⃣ StringBuilder 공부하기 (문자열 동적 적용)

 


관련 발행글 모음

2025.03.23 - [언어, 프레임워크/Spring] - [부트캠프 5주차] 새로 알게된 개념, 메서드 정리

2025.03.24 - [트러블 슈팅] - [Java/Spring] 406 Not Acceptable / 검증 어노테이션 에러 메시지 변경

2025.03.25 - [트러블 슈팅] - [Java/Spring] 분리된 테이블의 데이터 반환 트러블 슈팅 (+레이어 리팩토링)

2025.03.26 - [프로젝트/프로젝트 회고] - [Java/Spring] Spring MVC 패턴과 Layered Architecture

2025.03.19 - [언어, 프레임워크/Spring] - Spring MVC

 

Spring MVC

Spring MVC 사용 배경Servlet 사용비지니스 로직을 처리하는 코드와 화면을 그리는 View 코드가 함께 존재하는 문제JSP 사용View 에 해당하는 코드를 분리하였지만, 여전히 비지니스 로직을 JSP에 포함하

go-getter1kim.tistory.com

 

이전에 발행한 글에 정리해놨듯이,

M(Model) 레이어는 데이터와 비즈니스 로직을 수행하는 부분,

V(View)는 사용자에게 보여지는 프론트엔드 역할을 하는 부분,

C(Controller)는 Model과 View를 연결해주는 역할을 하는 부분이다.

 

그런데 단순한 MVC은 Model 부분에 너무 많은 역할을 할당하기 때문에 Model이 다시 Service와 Repository로 세분화된다.

여기서 등장하는 개념이 바로 Layered Architecture이다.

Service는 비즈니스 로직을 처리하고 Repository는 DB에 접근하여 데이터 관련 작업을 수행한다.

 

프로젝트를 진행하기 전에 아키텍처를 머리로만 이해했을 때는 간단하게 특정 역할을 하는 코드를 각각 해당하는 레이어에 두고, 혹시라도 엔티티 외부에서 데이터를 변경하면 안되니까 레이어를 이동할 때 DTO에 엔티티를 담아서 전달하자! 정도의 지식이었다.

이번 RESTful API 개발 프로젝트를 진행하면서 Spring에 대해서는 아주 얕게 배웠지만 아키텍처 구조에 대해서는 이해도가 많이 올라간 것 같다.

 


package com.example.todoproject.entity;
 
 import java.sql.Timestamp;
 
 public class Todo {
     private long todoId;
     private long userId;
     private String contents;
     private Timestamp createdDate;
     private Timestamp updatedDate;
 }
package com.example.todoproject.entity;
 
 public class User {
     private long userId;
     private String userName;
     private int password;
 }

 

강의를 듣고 실습을 할 때 자연스럽게 Entity 패키지를 생성하고, 패키지에 필요한 객체 클래스를 만들었다.

그냥 자바에서 클래스를 생성하듯 스프링 프레임워크에서도 그렇게 했던 것 같다.

하지만 DB에서 데이터를 가져와서 특정 엔티티에 저장해야하는 문제에 부딪혔을 때, Entity의 역할을 이해한 것 같다.

나는 ERD로 테이블을 먼저 설계하고 설계된 테이블을 그대로 엔티티에 적용했는데, 막상 테이블을 조인해서 데이터를 반환하는 과정에서 이 데이터를 저장할 적절한 엔티티가 없었던 것이다. (엔티티에는 없는 컬럼도 반환 데이터에 포함되어 있기 때문이다.)

그래서 엔티티에 해당 컬럼을 추가할까? 하고 고민하며 튜터님께 조언을 구했다.

이전에 트러블 슈팅에 작성했던 글처럼, 엔티티는 그저 객체가 아니라 데이터베이스 테이블을 자바 세상에 가져다 놓은 틀의 역할을 하고 있는 거였다.

그렇기 때문에 데이터베이스 테이블의 구조와 달라지면 엔티티가 본연의 역할을 하지 못하게 된다.

 

이 개념을 배우면서 엔티티 뿐만 아니라 DTO의 개념도 다시 공부할 수 있었던 것 같다.

DTO는 단순히 캡슐화하여 외부에서 데이터를 변경하는 걸 막기 위한 역할도 있지만, 엔티티에 담지 못하는 데이터 묶음을 온전히 담아낼 수 있다.

또한 내가 프로젝트를 진행하면서 수시로 DB가 바뀌듯이 실무에서는 더더욱 많은 테이블이 바뀔 가능성이 농후한데, DTO를 사용하면 각 API, 각 레이어마다 반환타입이나 파라미터를 변경해주지 않아도 된다.

레이어를 거칠 때마다 DTO에 담아서 전달하기 때문에 DTO의 내부 구조만 변경해주면 되는 것이다.

 

 

이미지처럼 프로젝트를 진행하는 동안 데이터 타입을 자주 바꾸었는데, updatedDate를 전달하는 API마다 타입을 변경하지 않고 DTO에서만 변경하면 됐다.

 


 

아직 간단한 프로젝트라서 Controller와 Service를 여러 개로 분리해서 사용해보진 못했지만, Repository에서는 두 개의 DB 테이블을 접근해야하는 덕분에 이를 적용해볼 수 있었다.

 

처음에는 하나의 TodoRepository 테이블에 모든 데이터 접근 코드를 때려넣었다면,

User, Todo 각각의 레포지토리 클래스를 생성하고 분리했다.

이를 통해 조금 가독성있는 코드를 만들면서도 각 클래스의 역할이 분명하기 때문에, 추후 규모가 큰 프로젝트를 진행한다면 잘 활용할 수 있을 것 같다.

 


 

깃허브에 따로 수정하거나 고민했던 부분은 안남아있지만, 프로젝트를 진행하면서 + 튜터님들이 진행하시는 세션을 들으면서 상태 코드를 반환하는 것에 대한 의미도 조금 더 이해할 수 있었다.

강의를 들을 때는 ResponseEntity로 DTO를 감싸고, 상태코드를 함께 날려보내는 것은 의무적으로, 형식적으로 한다고 생각했다.

사실 아직 프론트엔드와 협업을 해보진 않았기에 역할을 완벽히 경험한 것은 아니지만, 포스트맨으로 데이터를 요청했을 때 500 Internal Server Error만 반환하는 탓에 서버 콘솔을 찾아보고, log를 찍어보며 에러를 파악하려 애썼다.

만약 내가 프론트엔드 개발자라면 더욱 알 수 없기에 이 상태코드와 예외처리가 협업에 있어서는 중요한 역할을 하는구나 깨달았다.

 

이렇게 프로젝트를 진행하면서 기억에 남는 부분을 적어보았다.

사실 이번 프로젝트는 Spring을 처음으로 써보는 거라 아직 많이 미숙하기도 하고 오로지 Spring의 이해도를 조금 높여주는 역할일 뿐이지만, Layered Architecture와 MVC 패턴, 그리고 협업에서의 백엔드 개발자의 역할 등을 조금은 배울 수 있어서 좋은 경험이었다.

다음에 기회가 있다면 Controller, Service를 분리해서 적용해보는 경험도 할 수 있길 바라고, 만약 없다면 주말을 이용해 개인적으로 실습해보자.