데이터를 완전히 삭제하지 않고, 삭제된 것처럼 표시만 해놓는 방법이다.

테이블에 deleted, is_deleted, deleted_at 같은 컬럼으로 삭제 여부를 관리한다.

 

Soft Delete 사용 목적

- 복구 가능

- 참조 무결성 유지

- 유저 탈퇴같은 상황에서 논리적 삭제

나의 경우는 이미 가입했던 이메일은 재가입이 불가능해야 하기 때문에 Soft Delete로 구현해서 중복 데이터를 처리해야 한다.

 

원래는 생성일, 수정일과 통일 시키기 위해 deleted_at을 사용했지만, 현재 요구사항에서 삭제된지 며칠 지난 사용자를 조회하지 않기 때문에 굳에 삭제 여부를 날짜로 관리할 필요가 없다.

그리고 DB에 데이터를 문자열을 날짜 형식으로 변환할 필요가 없고, null로 데이터를 조회하지 않아도 된다는 점에서 이점이 있다고 판단해서 삭제 여부를 boolean 타입으로 판단할 수 있는 is_deleted를 사용하게 되었다.

 

기존에 사용하던 Filter는 HTTP 요청 흐름 중에 포워드(foward), 에러 디스패치(error dispatch)가 일어나면 같은 요청이 여러 번 필터 체인을 탈 수 있다.

이런 상황에서 의도치 않게 필터가 두 번 이상 실행되는 것을 방지하기 위해 한 번만 실행하도록 보장해주는 것이 OncePerRequestFilter이다.

 

기본 Filter 인터페이스의 문제점

포워드 발생 상황

// 단순히 sout을 수행하는 일반 Filter
public class SimpleLoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        System.out.println("[SimpleLoggingFilter] 필터 실행!");
        
        chain.doFilter(request, response);
    }
}
@RestController
public class TestController {

    @GetMapping("/test")
    public String test(HttpServletRequest request) throws ServletException, IOException {
        System.out.println("[Controller] /test 요청 처리 중...");

        // 내부적으로 다른 URL로 forward 시도
        request.getRequestDispatcher("/forwarded").forward(request, response);
        return "처리 완료";
    }

    @GetMapping("/forwarded")
    public String forwarded() {
        System.out.println("[Controller] /forwarded 요청 처리 중...");
        return "포워딩된 페이지";
    }
}

 

1. Filter를 거쳐서 TestController의 test 메서드 실행

2. test 메서드 내부에서 /forwarded URL로 포워드

3. 서버 내부에서 다시 필터 체인을 실행

 

-> `[SimpleLoggingFilter] 필터 실행!` 이 두 번 출력됨

 

에러 디스패치 발생 상황

@RestController
public class TestController {

    @GetMapping("/error-test")
    public String errorTest() {
        System.out.println("[Controller] /error-test 요청 처리 중...");
        
        // 일부러 예외 발생시키기
        throw new RuntimeException("일부러 터뜨린 에러!");
    }

    @RequestMapping("/error")
    public String error() {
        System.out.println("[Controller] 기본 에러 페이지 처리 중...");
        return "에러가 발생했습니다.";
    }
}

 

1. errorTest 메서드에서 에러가 발생

2. 서블릿 컨테이너가 /error로 다시 디스패치

(/error는 스프링부트가 기본적으로 설정하는 에러 처리용 경로)

3. 서버 내부에서 다시 필터 체인을 실행

 

-> `[SimpleLoggingFilter] 필터 실행!` 이 두 번 출력됨

에러 처리용 경로는 설정파일(application.properties)에서 설정 가능
server.error.path=/my-error (/my-error로 에러 처리용 경로 변경)

ExceptionHandler와 에러 처리용 경로(ErrorController) 차이
*둘 다 구현한 경우 ExceptionHandler가 우선 적용되고, 해당하는 Handler가 없으면 ErrorController 적용.

 

OncePerRequestFilter 사용 예시

스프링 시큐리티에서 커스텀 인증 필터를 만들 때 많이 사용된다.

  • JWT 토큰 검증 필터
  • 사용자 인증 정보 세팅 필터
  • 요청 헤더 검사 필터
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("Authorization");
        
        if (token != null && validateToken(token)) {
            // 토큰이 유효하면 SecurityContext에 인증 정보 저장
            Authentication auth = createAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // 필수: 다음 필터로 넘기기
        filterChain.doFilter(request, response);
    }
}

 

'언어, 프레임워크 > Spring' 카테고리의 다른 글

동시성 제어(비관적 락, 낙관적 락, 분산 락)  (1) 2025.04.24
AOP, Interceptor  (1) 2025.04.23
Bean 생명주기  (1) 2025.04.18
cascade와 orphanRemoval 비교하기  (1) 2025.04.17
JPA 테이블 전략  (2) 2025.04.17

Bean 생명주기

1. Spring Container 생성

- ApplicationContext, BeanFactory와 같은 컨테이너 생성

- @Configuration, @ComponentScan 또는 XML 파일 설정을 통해 bean 정보를 읽는다.

 

2. Bean 인스턴스 생성

- 기본 생성자가 호출되어 객체가 생성되며, 싱글톤 bean은 앱이 시작할 때 미리 생성된다.

- 프로토타입 bean은 요청 시점에 생성된다.

 

3. 의존성 주입

- 생성된 bean에 의존하는 다른 bean이 있으면 의존성 주입

 

4. 초기화 콜백

- @PostConstruct 또는 InitializingBean의 afterPropertiesSet() 메서드 호출

- DB연결, 리소스 준비, 설정 작업 등의 준비 수행

 

5. Bean 사용

- 비즈니스 로직 수행

 

6. 소멸 전 콜백

- @PostDestroy 또는 DisposableBean의 destroy() 메서드 호출

- 파일 닫기, 데이터베이스 연결 해제 등 리소스 정리

 

7. 스프링 종료(Bean 소멸)

 


@Bean을 사용한 생명주기 콜백

설정 정보에 초기화 메서드와 종료 메서드를 지정하여 생명주기 콜백을 지원한다.

이 방법으로 외부 라이브러리에도 콜백 메서드를 사용할 수 있다.

 

public class MyBeanV2 { // 비즈니스 로직 실행 전후로 동작시킬 Bean 정의

    private String data;

    public MyBeanV2() {
        System.out.println("Bean 생성자 호출");
        System.out.println("data = " + data);
    }

    public void setData(String data) {
        this.data = data;
    }

    public String getData() {
        return data;
    }

    public void init() {
        System.out.println("MyBean 초기화 - init() 호출됨");
        System.out.println("data = " + data);
    }

    public void close() {
        System.out.println("MyBean 종료 - close() 호출됨");
        data = null;
    }

    public void doSomething() {
        System.out.println("MyBean 작업 중...");
    }

}
@Configuration  // 설정 클래스에서 bean 등록
public class AppConfigV2 { 
    // initMethod: 빈 생성 후 호출할 메서드명
    // destroyMethod: 컨테이너 종료 시 호출할 메서드명
    @Bean(initMethod = "init", destroyMethod = "close")
    public MyBeanV2 myBeanV2() {
        MyBeanV2 myBeanV2 = new MyBeanV2();
        // 의존관계 설정
        myBeanV2.setData("Example");
        return myBeanV2;
    }
}
public class InterfaceCallbackV2 {

    public static void main(String[] args) {
        // Spring 컨테이너 생성 및 설정 클래스 등록
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfigV2.class);

        // Bean 사용
        MyBeanV2 myBeanV2 = context.getBean(MyBeanV2.class);
        myBeanV2.doSomething();

        // Bean 소멸 전 필드
        System.out.println(myBeanV2.getData());

        // 컨테이너 종료 (destroy 호출)
        context.close();

        // Bean 소멸 후 필드
        System.out.println(myBeanV2.getData());
    }

}

 


@PostConstruct, @PreDestroy을 사용한 생명주기 콜백

@PostConstruct : bean 생성 및 의존성 주입 후 호출될 메서드 지정

@PreDestroy : bean이 소멸되기 직전 호출될 메서드 지정

 

외부 라이브러리 적용 불가

 

public class MyBeanV3 {

    private String data;

    public MyBeanV3() {
        System.out.println("Bean 생성자 호출");
        System.out.println("data = " + data);
    }

    public void setData(String data) {
        this.data = data;
    }

    public String getData() {
        return data;
    }

    // 초기화 메서드
    @PostConstruct
    public void init() {
        System.out.println("MyBean 초기화 - init() 호출됨");
        System.out.println("data = " + data);
    }

    // 소멸 메서드
    @PreDestroy
    public void destroy() throws Exception {
        System.out.println("MyBean 종료 - destroy() 호출됨");
        data = null;
    }

    public void doSomething() {
        System.out.println("MyBean 작업 중...");
    }
}

// 해당 클래스 외에는 동일

 


주요 Bean Scope

Bean의 생명주기 설정 (유지 기간, 재사용 여부 등)

 

1. Singleton(Default)

- Application 전체에서 공유되는 Bean

 

2. Prototype

- 매번 새로운 인스턴스가 필요한 경우

 

3. Request

- Web Application에서 요청별로 별도의 Bean이 필요한 경우

- 요청 데이터를 처리하는 객체

 

// 자동 등록
@Scope("singleton")
@Component // @Service 사용 가능
public class MemberServiceImpl implements MemberService { ... }

// 수동 등록
@Configuration
public class AppConfig {

    @Scope("singleton")
    @Bean
    public MemberService memberService() {
		    return new MemberServiceImpl();
    }
}

 


 

'언어, 프레임워크 > Spring' 카테고리의 다른 글

AOP, Interceptor  (1) 2025.04.23
OncePerRequestFilter 인터페이스  (0) 2025.04.21
cascade와 orphanRemoval 비교하기  (1) 2025.04.17
JPA 테이블 전략  (2) 2025.04.17
JPA 연관관계  (2) 2025.04.16

cascade = CascadeType.REMOVE을 설정하는 것과 orphanRemoval = true을 설정하는 건 어떤 차이가 있을까

cascade와 orphanRemoval 둘 다 영속성 관리를 위해 사용하는 옵션이다.

 

Cascade

- 부모가 하는 걸 자식이 따라가는 설정

- ex. 부모가 삭제되면 자식도 삭제, 부모가 persist되면 자식도 persist

- 즉, 부모와 자식의 생명주기를 함께 관리하는 개념이다.

 

orphanRemoval

- cascade + 고아객체 삭제 설정 추가

- 즉, 영속성에서 삭제에 대해서만 관리하는 개념이다.

 

고아객체란

부모의 리스트에서 자식을 지우면, 지워진 자식 데이터는 부모의 컬렉션(자식 리스트)에서 떨어져 나오게 된다.

떨어져 나온 자식은 부모가 사라지게 되는데, 이를 고아객체라고 지칭한다.

이때 orphanRemoval=true로 설정해주면 고아객체를 자동으로 삭제해준다.


cascade = CascadeType.REMOVE로 설정하면 부모가 삭제됐을 때 자식도 삭제된다.

  • 부모를 삭제하는 행위(em.remove(parent))가 일어날 때
  • 자식도 같이 삭제 요청이 들어간다.

 

부모를 삭제한 게 아니라 부모가 가진 자식 필드에서 해당 자식을 지우는 경우(관계만 끊는 경우)에는 부모가 가진 자식 리스트에서만 지우고 DB에 있는 자식 테이블에는 그대로 남는다.

  • 메모리 상에서는 자식 리스트에서 빠지지만
  • DB에서는 삭제되지 않고 가비지 데이터로 남는다.

 

DB에서도 깔끔하게 지우기 위해서는 부모 자체를 삭제해 버리거나, 직접 자식 데이터를 remove() 함수로 지워줘야 한다.

  • 부모를 삭제해서 cascade로 자식까지 지우거나
  • em.remove(child) 직접 호출해서 자식만 지워야 한다.

 

orphanRemoval = true로 설정하면 부모가 가진 자식 리스트에서 해당 데이터를 지우기만 해도 JPA가 고아 객체가 된 걸 인지하고 자식 테이블에서도 지워준다.

  • 리스트에서 지우고
  • flush()가 실행되는 시점에 JPA가 자동으로 DB에서 delete 한다.

 

cascade = REMOVE는 부모 삭제할 때만 동작하고, orphanRemoval = true는 연관 끊겼을 때도 자식 삭제까지 해준다.

cascade = REMOVE는 부모가 죽을 때만 따라간다.

orphanRemoval = true는 부모가 버렸을 때도 자식이 죽는다.

 


단방향과 양방향에서의 고아객체 관리 방법

단방향에서는

부모 컬렉션에서 자식을 지우면 해당 자식은 바로 고아객체가 되어, orphanRemoval을 true로 설정하면 바로 삭제된다.

 

양방향에서는

부모의 컬렉션에서 자식을 삭제할 때, 자식.setParent(null)을 해줘야 한다.

부모 측에서는 자식을 지웠지만 자식 측에서는 아직 부모를 참조하고 있기 때문에, JPA가 해당 자식 데이터를 고아객체로 인식하지 못할 수도 있다.

'언어, 프레임워크 > Spring' 카테고리의 다른 글

OncePerRequestFilter 인터페이스  (0) 2025.04.21
Bean 생명주기  (1) 2025.04.18
JPA 테이블 전략  (2) 2025.04.17
JPA 연관관계  (2) 2025.04.16
JPA 성능 최적화 (fetch join, batch size)  (0) 2025.04.16

테이블 전략

엔티티 상속 구조를 데이터베이스 테이블에 매핑하는 방법

 

1. 단일 테이블 전략

- 하나의 테이블로 생성한다.

- DTYPE으로 어떤 하위 테이블의 데이터인지 구분한다.

- 데이터를 insert했을 때, 다른 엔티티의 공통되지 않은 속성은 null 값이 입력된다.

 

 

2. 조인 전략

- 공통된 데이터를 관리하는 테이블 외에, 그 외 속성을 관리하는 각각의 테이블을 생성한다.

- DB 조회 시 테이블을 조인하는 과정을 거친다.

- 데이터를 입력할 때는 insert 과정이 두 번 필요하다.(product, book 또는 product, coat)

 

 

3. 구현 클래스 전략

- 각각의 테이블로 따로 생성한다.

 


구현 방법

@Inheritance(strategy = InheritanceType.${전략})

  1. JOINED : 조인
  2. SINGLE_TABLE : 단일 테이블(Default)
  3. TABLE_PER_CLASS : 구현 클래스

@DiscriminatorColumn(name = "dtype")

  • dtype 컬럼을 생성한다(관례).
  • 이름 변경이 가능하다.
  • 기본 값 : DTYPE

@DiscriminatorValue("${값}")

  • dtype 값 지정
  • 기본 값 : 클래스 이름

 

1. 단일 테이블 전략

@Entity
@Table(name = "product")
@DiscriminatorColumn(name = "dtype") // dtype 컬럼 생성
public abstract class Product {

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

    private String name;

    private BigDecimal price;

    public Product() {
    }

    public Product(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

}
@Entity
@Table(name = "book")
@DiscriminatorValue(value = "B") // B라는 이름의 dtype 컬럼값 생성 (더 명시적인 이름 사용 필요)
public class Book extends Product {

    private String author;

    public Book() {
    }

    public Book(String author, String name, BigDecimal price) {
        super(name, price);
        this.author = author;
    }
}
@Entity
@Table(name = "coat")
@DiscriminatorValue(value = "C")
public class Coat extends Product {

    private Integer size;

}

해당하지 않는 컬럼은 null

 

2. 조인 전략

- @Inheritance 속성값 외에는 단일 테이블 전략과 코드 동일

- @DiscriminatorColumn을 생략하면 DTYPE이 자동 생성되지 않으므로, 부모 클래스의 기본키를 자식 클래스에서 외래키로 사용해서 조인한다.

2번의 insert문 & 조인해서 데이터 조회

 

 

3. 구현 클래스 전략

- 하위 클래스를 따로 분리하여 관리하기 때문에, 부모 클래스는 실제로 존재하지 않는다.

(부모 클래스는 추상 클래스여야 한다)

- 각 테이블이 별도의 ID 시퀀스를 관리한다.

(동일한 값을 가진 데이터가 여러 테이블에 존재할 수 있어서, GenerationType.IDENTITY로 설정 불가)

- @DiscriminatorColumn, @DiscriminatorValue를 사용할 필요 없음

 

부모 클래스를 통해 데이터를 조회하는 경우에는 아래 이미지처럼 union all을 사용해서 모든 테이블을 조회해야 한다.

-> 구현 클래스 전략은 사용 안 함!

Book book = new Book("wonuk", "spring-advanced", BigDecimal.TEN);
em.persist(book);

Product findProduct = em.find(Product.class, book.getId());

 


정리

JOINED

  • 장점
    1. 테이블 정규화
    2. 외래 키 참조 무결성
    3. 저장공간 효율
  • 단점
    1. 조회시 JOIN을 많이 사용한다.
    2. 데이터 저장시 INSERT SQL 이 2번 호출된다.
    3. SQL Query가 복잡하여 성능이 저하될 수 있다.

SINGLE_TABLE

  • 장점
    1. JOIN을 사용하지 않는다.
    2. 실행되는 SQL이 단순하다.
  • 단점
    1. 자식 Entity가 매핑한 컬럼은 모두 null을 허용한다.
    2. 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있다.
    3. 상황에 따라서 조회 성능이 오히려 느려질 수 있다.

TABLE_PER_CLASS

  • 테이블끼리 연관짓기 힘들다, 사용하지 않는것을 권장한다.
  • 장점
    1. 자식 클래스를 명확하게 구분해서 처리할 수 있다.
    2. not null 제약조건 사용이 가능하다.
  • 단점
    1. 여러 자식 테이블을 함께 조회할 때 성능이 느리다.
    2. 부모 객체 타입으로 조회할 때 모든 테이블을 조회해야 한다.

'언어, 프레임워크 > Spring' 카테고리의 다른 글

Bean 생명주기  (1) 2025.04.18
cascade와 orphanRemoval 비교하기  (1) 2025.04.17
JPA 연관관계  (2) 2025.04.16
JPA 성능 최적화 (fetch join, batch size)  (0) 2025.04.16
Converter, Formatter  (0) 2025.04.15

문제 설명

문자열로 구성된 리스트 strings와, 정수 n이 주어졌을 때, 각 문자열의 인덱스 n번째 글자를 기준으로 오름차순 정렬하려 합니다. 예를 들어 strings가 ["sun", "bed", "car"]이고 n이 1이면 각 단어의 인덱스 1의 문자 "u", "e", "a"로 strings를 정렬합니다.

제한 조건
  • strings는 길이 1 이상, 50이하인 배열입니다.
  • strings의 원소는 소문자 알파벳으로 이루어져 있습니다.
  • strings의 원소는 길이 1 이상, 100이하인 문자열입니다.
  • 모든 strings의 원소의 길이는 n보다 큽니다.
  • 인덱스 1의 문자가 같은 문자열이 여럿 일 경우, 사전순으로 앞선 문자열이 앞쪽에 위치합니다.

1차 코드

import java.util.Arrays;

class Solution {
    public String[] solution(String[] strings, int n) {
        Arrays.sort(strings, n, n+1);

        return strings;
    }
}

 

처음에는 Arrays.sort() 메서드가 각 배열 원소에 대해 특정 위치 인덱스를 기준으로 비교해서 정렬해주는 메서드인 줄 알고 위와 같이 코드를 작성했다.

하지만 결과값이 아래처럼 변화없이 출력되길래 찾아보니, 배열 자체의 인덱스를 설정하는 파라미터였다.

내 코드는 결국 n번째 원소 하나를 정렬하려고 시도하니까 아무 의미 없이 그대로 결과값이 반환되는 것이었다.

 


개선된 코드

특정 기준에 맞게 정렬하기 위해 Comparator 객체를 사용하게 되었다.

import java.util.Arrays;

class Solution {
    public String[] solution(String[] strings, int n) {
        Arrays.sort(strings, (s1, s2) -> {
            if(s1.charAt(n) == s2.charAt(n)){
                return s1.compareTo(s2);
            }else{ 
                return Character.compare(s1.charAt(n), s2.charAt(n));
            }
        });

        return strings;
    }
}

 

Arrays.sort 안에 파라미터로 Comparator를 람다식으로 표현했다.

Comparator는 두 인자를 전달받는데, 내부적으로 정렬을 사용해서 임의의 두 값을 비교하기 때문에 따로 인덱스를 지정해주지 않아도 최적의 원소들만 자동으로 비교해준다.

 

조건문을 보면 특정 위치의 문자가 동일한 경우, 해당 문자열 자체를 사전순으로 비교한다.

compareTo는 String 클래스가 인터페이스에 정의된 메서드를 구현한 것으로, 두 문자열을 사전 순으로 비교해준다.

 

만약 두 문자열의 해당 위치 문자가 다르다면, Character 클래스의 compare 메서드로 두 값을 비교한다.

두 문자를 마이너스 연산을 통해 쉽게 비교할 수도 있지만, 아스키 코드의 범위를 넘어가는 경우에는 스택 오버 플로우가 발생할 수 있어서 Character 클래스의 compare()를 사용하는 게 안정적으로 두 문자를 비교하는 방법이다.

 


 

성능 요약

메모리: 86 MB, 시간: 1.15 ms

1 : N  단방향

ex. 학교 - 학생, 팀 - 멤버

1에 해당하는 엔티티가 연관관계의 주인이 되어 FK를 관리해야하는 요구사항이 있다고 가정하자.

하지만 DB 입장에서는 N에 해당하는 테이블이 FK를 관리한다.

따라서 객체지향과 데이터베이스의 충돌로 인해, 1에 해당하는 엔티티가 N에 해당하는 테이블의 데이터를 수정하게 된다.

 

어차피 DB 측에서는 1 : N이어도 N : 1처럼 구조가 만들어지고 동작하기 때문에 기능적으로 큰 차이는 없지만,

요구사항에서 튜터를 먼저 생성하고 튜터의 회사를 관리할 것인지, 회사를 먼저 만들고 튜터를 집어넣어줄 것인지에 따라 1 : N을 사용할지, N : 1을 사용할지가 달라진다.

@OneToMany
@JoinColumn(name = "company_id") // 연관 관계 주인이 JoinColumn으로 관계를 정의
private List<Tutor> tutors = new ArrayList<>();
// Company를 통해 Tutor 테이블에 데이터 추가
company.getTutors().add(tutor);
em.persist(company);
만약 @JoinColumn을 생략하면?
JPA가 자체적으로 중간 테이블을 생성하여 관리한다.
불필요한 중간테이블을 생성하고 쿼리를 실행하지 않도록, @JoinColumn을 꼭 붙여줘야 한다.

반대로 둘 다 @JoinColumn을 사용하면?
에러가 나거나 연관관계가 꼬일 위험이 있으므로 반드시 한쪽만 주인으로 정하도록 한다.

 


1 : N  양방향

1 : N 단방향과 마찬가지로 N:1 양방향을 사용하는 것을 권장한다.

 

JPA는 양방향 연관관계가 있을 때 DB의 외래키를 주인 엔티티의 필드로 관리한다.

주인이 아닌 엔티티의 필드는 단순히 주인 엔티티가 가지고 있는 값을 매핑받을 뿐이지, DB에 접근해서 값을 생성하거나 수정할 수 없다.

따라서 주인이 아닌 엔티티를 통해 값을 생성하려고 하면, null로 처리한다.

@Entity
public class Member {
    @ManyToOne
    @JoinColumn(name = "team_id") // 주인: 여기서 team_id FK 컬럼이 생김
    private Team team;
}

@Entity
public class Team {
    @OneToMany(mappedBy = "team") // 단순히 매핑된 것
    private List<Member> members = new ArrayList<>();
}

 

N : 1 or 1 : N 관계에서는 N인 쪽이 FK를 가지고 있어야 하므로 N이 주인이 된다.

만약 요구사항에 의해 1인 쪽이 연관관계의 주인이 되어야 한다면, 아래와 같이 N인 쪽을 읽기 전용으로 만들고 1인 쪽을 @JoinColumn으로 주인으로 직접 설정해준다.

@Entity
public class Tutor {
    @ManyToOne
    @JoinColumn(name = "company_id", insertable = false, updatable = false) // 읽기 전용
    private Company company;

    @Column(name = "company_id")
    private Long companyId; // 실제로 값을 세팅하는 필드
}
@Entity
public class Company {
    @OneToMany
    @JoinColumn(name = "company_id") // FK를 여기가 직접 관리
    private List<Tutor> tutors = new ArrayList<>();
}

1 : 1  양방향

1 : N 양방향과 마찬가지로 두 엔티티 중 하나를 주인 엔티티로 설정해야 한다.

주인이 아닌 엔티티는 읽기 전용으로 동작하게 된다.

1 : 1 관계는 서로가 하나만을 참조해야 진정한 일대일 관계지만, @OneToOne으로 명시를 하더라도 동작 자체는 @ManyToOne과 비슷하게 동작한다.

따라서 확실하게 일대일 관계를 보장하기 위해 unique=true를 설정해 주어야 한다.

따라서 일대일 연관관계를 다대일로 변경하고 싶으면 unique 설정만 지워주면 된다.

 

1:1 관계에서는 어떤 엔티티가 주인이 되어야 할까?

- 자주 조회하거나 수정되는 엔티티

- 생명주기 관리 기준 (ex. profile이 항상 user에 종속된다면 user가 주인)

- DB 설계에 따라 (이미 DB 설계가 어느 테이블에 줄지 결정되어 있다면 거기에 맞춰서 결정)

 

외래 키 위치의 장단점 정리

1. 주 테이블에 외래 키 위치
장점
- JPA로 객체 지향적 개발이 가능
- 주 테이블만 조회해도 대상 테이블 조회 가능
단점
- 대상 테이블에 값이 없다면 null 허용 (무결성 보장 X)
- 대상 테이블의 데이터를 참조하기 때문에, 삭제될 때 외래 키 값을 처리하는 관리 필요

2. 대상 테이블에 외래 키 위치
장점
- 데이터베이스 무결성 보장
- 주 테이블과 대상 테이블 연관관계가 변경되어도 테이블 구조 유지
단점
- 조회 성능 떨어짐
- 연관관계 매칭 설정 복잡
- 지연 로딩으로 설정해도 즉시 로딩된다. (외래키가 대상테이블에 있으면 추후 쿼리로 대상 테이블을 찾아내기 번거롭기 때문)

 


N : M  단방향

@Entity
@Table(name = "language")
public class Language {

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

    private String name;

}
@Entity
@Table(name = "tutor")
public class Tutor {

    ...

    @ManyToMany
    @JoinTable(  // 중간 테이블 생성
            name = "tutor_language", // 중간 테이블 이름 설정
            joinColumns = @JoinColumn(name = "tutor_id"),
            inverseJoinColumns = @JoinColumn(name = "language_id") // 중간테이블을 연결할 상대 엔티티 명시
    )
    private List<Language> languages = new ArrayList<>();

	...
}

 


N : M  양방향

@Entity
@Table(name = "language")
public class Language {

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

    private String name;

    @ManyToMany(mappedBy = "languages")
    private List<Tutor> tutors = new ArrayList<>();

}

 

N : M 매핑의 문제점

  • level, license와 같은 중간테이블에 추가적인 데이터가 필요하다.
  • 중간테이블이 내부적으로 생성되기 때문에 생각하지 못한 SQL Query가 실행된다.
    • 중간 테이블을 실제 엔티티로 만들어서 @ManyToOne, @OneToMany로 관리한다.
  • 두 테이블의 id를 복합키로 PK가 생성된다.
    • PK가 종속적이면 사이드 이펙트가 발생하므로, 비즈니스적으로 의미없는 Long값으로 PK를 설정하는 게 유연성에 좋다.   

N : M 매핑 - 중간테이블 생성

중간 테이블 엔티티를 직접 생성하여 N : M 매핑의 문제점을 해결한다.

@Entity
@Table(name = "tutor_language")
public class TutorLanguage {

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

    @ManyToOne
    @JoinColumn(name = "tutor_id")
    private Tutor tutor;

    @ManyToOne
    @JoinColumn(name = "language_id")
    private Language language;

    private Integer level;

    private String license;

}
@Entity
@Table(name = "language")
public class Language {

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

    private String name;

    @OneToMany(mappedBy = "language")
    private List<TutorLanguage> tutorLanguages = new ArrayList<>();

}
@Entity
@Table(name = "tutor")
public class Tutor {

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

    private String name;

    @ManyToOne
    @JoinColumn(insertable = false, updatable = false)
    private Company company;

    @OneToOne
    @JoinColumn(name = "address_id", unique = true)
    private Address address;

    @OneToMany(mappedBy = "tutor")
    private List<TutorLanguage> tutorLanguages = new ArrayList<>();

    public Tutor() {
    }

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

'언어, 프레임워크 > Spring' 카테고리의 다른 글

cascade와 orphanRemoval 비교하기  (1) 2025.04.17
JPA 테이블 전략  (2) 2025.04.17
JPA 성능 최적화 (fetch join, batch size)  (0) 2025.04.16
Converter, Formatter  (0) 2025.04.15
EntityManager Method  (0) 2025.04.06

데이터 증폭이란?

팀과 멤버가 1 : N인 관계에서 DB를 조회할 때 이미지처럼 Team이 여러 번 함께 조회되는 건 어쩔 수 없다.

하지만 이미지처럼 전체적인 데이터가 필요한 게 아니라, Team Name만 필요한 상황이라면 어떨까?

 

fetch join을 사용하는 쿼리에서 Team Name을 조회하면 JPA는 자동으로 두 엔티티를 조인한 뒤에 Team Name을 조회하기 때문에 동일한 값도 여러 번 조회되는 문제가 발생한다.

이게 바로 데이터 증폭이다.

데이터 증폭이 발생하는 상황에서는 distinct 사용으로 동일한 값을 여러 번 출력하지 않도록 한다.

 

 

fetch join을 사용하는 이유

엔티티가 서로 연관 관계를 가질 때, 불필요한 상황에서 관련된 엔티티의 데이터를 모두 조회하면 성능이 좋지 않기 때문에 대부분의 상황에서 지연 로딩을 사용하게 된다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "requester_id", nullable = false)
private User requester;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "receiver_id", nullable = false)
private User receiver;

 

지연 로딩을 사용하는 엔티티가 큰 규모의 반복문 처리가 필요한 상황이 있다고 가정하자.

그럼 가져온 엔티티를 재사용하는 게 아니라, 지연 로딩으로 인해 반복문을 돌 때마다 DB에 접근하여 데이터를 조회하게 된다.

이 문제를 N+1 문제라고 한다.

처음 DB 조회한 횟수 1과 반복문을 돌 때마다 N번 DB 조회를 실행해서 N+1이다.

 

N+1 문제를 해결하기 위해 fetch join을 사용해서 연관된 엔티티의 데이터까지 한 번에 조회하는 방법이 사용된다.

하지만 fetch join의 문제는 처음에 말했다시피 데이터 증폭이 발생할 수 있고, 그로 인해서 페이징도 제대로 적용되지 않을 가능성이 크다.

 

 

fetch join 대신 배치 사이즈

사실상 배치 사이즈(@BatchSize)를 사용해서 N+1 문제를 해결하는 게 가장 간단한 방법이다.

@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 10) // 한 번에 10개씩 로딩
private Team team;

// 또는 application.properties에서 hibernate.jdbc.batch_size=10으로 설정

 

이렇게 10으로 값을 설정하면, 해당 데이터에 대해 10번의 DB 접근 요청이 발생해야 DB에 접근하여 쿼리를 실행한다.

배치 사이즈를 사용하면 데이터 증폭이 발생하지 않기 때문에 페이징도 적용이 가능하다.

해당 쿼리가 어느정도 발생해야 DB 요청을 실행하기 때문에 성능 최적화에 적절한 방법이다.

 

그렇다면 어떤 상황에서 fetch join 또는 배치 사이즈를 사용해야 할까?

데이터를 조회하는 과정에서 연관된 데이터가 많다면 배치 사이즈를 사용

적은 양의 데이터를 빠르게 조회해서 처리해야 하는 상황에서는 fetch join을 사용 

 

 

탈 JPA?

사실상 데이터 증폭, N+1 문제는 JPA(연관관계 설정, 지연 로딩)에 의해 발생하는 문제다.

이 문제를 방지하기 위해 아예 연관 관계를 두지 않는 DB 설계도 있다.

댓글과 게시글처럼 연관성이 짙은 경우를 제외하면 굳이 서로 연관 관계를 설정해서 동일한 생명주기를 가질 필요가 없기 때문이다.

(이런 상황에서는 nativeQuery를 사용해서 DB에서 데이터를 조회한다.)

 

대기업처럼 큰 데이터를 관리하는 상황에서는 이렇게 연관성이 큰 데이터를 다룰 일이 많지 않기 때문에 nativeQuery를 사용하는 경우가 대부분이고 JPA를 잘 사용하지 않지만, 지금의 내가 하는 프로젝트나 과제는 규모가 작으면서 각 엔티티가 서로 연관성을 가지기 때문에 JPA를 사용하는 게 적절하다.

'언어, 프레임워크 > Spring' 카테고리의 다른 글

JPA 테이블 전략  (2) 2025.04.17
JPA 연관관계  (2) 2025.04.16
Converter, Formatter  (0) 2025.04.15
EntityManager Method  (0) 2025.04.06
영속성 컨텍스트에서의 insert 시점 실습  (0) 2025.04.06

[level 1] 숫자 문자열과 영단어 - 81301

문제 링크

구분

코딩테스트 연습 > 2021 카카오 채용연계형 인턴십

문제 설명

img1.png

네오와 프로도가 숫자놀이를 하고 있습니다. 네오가 프로도에게 숫자를 건넬 때 일부 자릿수를 영단어로 바꾼 카드를 건네주면 프로도는 원래 숫자를 찾는 게임입니다.

다음은 숫자의 일부 자릿수를 영단어로 바꾸는 예시입니다.

  • 1478 → "one4seveneight"
  • 234567 → "23four5six7"
  • 10203 → "1zerotwozero3"

이렇게 숫자의 일부 자릿수가 영단어로 바뀌어졌거나, 혹은 바뀌지 않고 그대로인 문자열 s가 매개변수로 주어집니다. s가 의미하는 원래 숫자를 return 하도록 solution 함수를 완성해주세요.

참고로 각 숫자에 대응되는 영단어는 다음 표와 같습니다.

숫자 영단어
0 zero
1 one
2 two
3 three
4 four
5 five
6 six
7 seven
8 eight
9 nine

제한사항
  • 1 ≤ s의 길이 ≤ 50
  • s가 "zero" 또는 "0"으로 시작하는 경우는 주어지지 않습니다.
  • return 값이 1 이상 2,000,000,000 이하의 정수가 되는 올바른 입력만 s로 주어집니다.

 


1차 시도

class Solution {
    public int solution(String s) {
        String answer = "";
        
        StringBuilder sb = new StringBuilder(answer);
        
        Map<String, Integer> numberMap = Map.of(
            "zero", 0, "one", 1, "two", 2, "three", 3, "four", 4, "five", 5, "six", 6, "seven", 7, "eight", 8, "nine", 9);
        
        for(int i = 0; i < s.length(); i++){
            // 숫자인지 문자인지 확인
            if(numberMap.containsValue(s.charAt(i) - '0')){ 
                // 숫자이면 바로 append
                sb.append(s.charAt(i) - '0');
            }else{
                // 문자이면 한 단어를 추출 -> map에서 value 찾아서 append
                // keyset과 문자열 비교
            }
        }
        
        answer = Integer.parseInt(answer);
        
        return answer;
    }
}

 

숫자 문자열을 key, 변환할 값을 value로 Map을 만들어서 반복문 내에서 숫자가 오면 바로 결과값에 append하고,

문자가 오면 한 단어를 추출해서 Map을 통해 append를 진행하려고 했다.

이렇게 하려면 else문 안에 반복문을 하나 더 만들고, 내부에서 keyset과 비교한 다음에 값을 집어 넣어야 한다.

그리고 two-three, four-five 등 첫 글자가 일치하는 숫자 문자열이 있기 때문에 상황에 따라 문자열의 2번째 자리까지도 비교하는 로직이 필요했다.

 

가독성 있으면서 깔끔한 코드를 만들고 싶어서 Map을 사용해본 건데 반복문 내부가 너무 지저분해지는 것 같아 이 방법은 폐지하기로 했다.

 


개선된 코드

class Solution {
    public int solution(String s) {
        
        String[] strArr = {"zero", "one", "two", "three","four", "five", "six", "seven", "eight", "nine"};
        
        for(int i = 0; i < strArr.length; i++){
            s = s.replaceAll(strArr[i], Integer.toString(i));
        }
        
        return Integer.valueOf(s);
    }
}

 

이 문제로 replaceAll 함수를 처음으로 사용해보았다.

replaceAll은 문자열에서 해당 단어가 몇 글자인지 비교할 필요없이, 전체 문자열에 대해 첫 번째 파라미터로 넣어준 값이 등장하면 두 번째 파라미터로 값을 바꿔준 뒤 새로운 문자열을 반환해준다.

 

바꾸고자 하는 숫자 문자열을 미리 배열에 만들어 두고, 반복문을 사용해서 각 문자열이 등장할 때마다 동일한 값을 정수로 표현하고 있는 인덱스 값으로 변환하도록 코드를 작성했다.

 


성능 요약

메모리: 83.1 MB, 시간: 3.13 ms

HttpMessageConverter가 적용되는 경우

HttpMessageConverter는 JSON, TEXT, XML 등의 데이터를 전달하는 요청과 응답 모두 사용된다.

HTTP 요청 : @RequestBody, HttpEntity<>, RequestEntity<>

HTTP 응답 : @ReponseBody, HttpEntity<>, ResponseEntity<>

 


Converter 적용 우선순위

Spring은 다양한 HttpMessageConverter를 제공하고, 요청된 메서드의 파라미터 타입과 MediaType을 체크해서 컨버터를 결정한다.

우선순위 1. ByteArrayHttpMessageConverter

- 대상 : byte[]

- MediaType(content-type) : */*

- 반환 : application/octet-stream

public byte[] byteArray(@RequestBody byte[] data){...}

 

우선순위 2. StringHttpMessageConverter

- 대상 : String

- MediaType : */*

- 반환 : text/plain

public String string(@RequestBody String data) {...}

 

우선순위 3. MappingJackson2HttpMessageConverter

- 대상 : Object, HashMap

- MediaType : application/json

- 반환 : application/json

public Data json(@RequestBody Data data) {...}

 

이 외에도 컨버터가 존재하지만, 대부분 이 세가지로 데이터 변환을 처리한다.

 


내부 구조와 동작 원리

RequestMappingHandlerAdapter

- @RequestMapping을 처리하는 HandlerAdapter의 구현체

- ArgumentResolver 호출

- HTTP 요청을 컨트롤러 메서드에 매핑하고, 이 메서드를 호출하여 결과를 반환하는 역할 수행

 

ArgumentResolver

- HandlerMethodArgumentResolver (ArgumentResolver의 실제 인터페이스)

- 컨트롤러에게 필요한 다양한 파라미터 값을 생성한다.

- 따라서 HttpServletRequest, Model, HttpEntity, @RequestBody, @RequestParam 등에 자동 바인딩이 가능해진다.

- HttpMessageConverter를 호출해서 데이터를 적절한 타입으로 변환한다.

- 사용 가능한 파라미터 목록

 

ReturnValueHandler

- HandlerMethodReturnValueHandler (ReturnValueHandler의 실제 인터페이스)

- 컨트롤러가 반환하는 값을 HTTP 응답 본문에 적절히 담아 전송한다.

- HttpMessageConverter를 호출해서 데이터를 적절한 타입으로 변환한다.

 


Converter 구현

특정 엔티티 포맷의 객체로 값을 전달받고자, 일반적인 자료형이 아닌 특별한 타입으로 변환해주는 컨버터가 필요할 때는 직접 컨버터를 생성해야 한다.

@ModelAttribute를 사용하면 컨버터가 없어서 객체에 바로 저장할 수 있었는데 왜 컨버터를 생성해야 할까?
ModelAttribute는 ?name=채진&age=25 처럼 어떤 필드가 전달되는지 명확하기 때문에 바로 객체에 저장이 가능하다.

컨버터를 생성해서 사용해야 하는 경우는 아래처럼 특수한 형태로 데이터가 저장되어 바로 필드에 저장하기 어려운 상황이다.
?person=wonuk:1200
?code=dsafkjf@code (<- @앞까지의 데이터만 DB에 저장할 가치가 있는 경우)

 

 

1. 특정 상황에 맞는 컨버터를 생성

public class StringToPersonConverter implements Converter<String, Person> {
    // source = "wonuk:1200"
    @Override
    public Person convert(String source) {
        // ':' 를 구분자로 나누어 배열로 만든다.
        String[] parts = source.split(":");

        // 첫번째 배열은 이름이다. -> wonuk
        String name = parts[0];
        // 두번째 배열은 개월수이다. -> 1200
        int months = Integer.parseInt(parts[1]);

        // 개월수 나누기 12로 나이를 구하는 로직 (12개월 단위만 고려)
        int age = months / 12;

        return new Person(name, age);
    }
}

 

 

2. 컨버터 등록(WebMvcConfigurer의 메서드 오버라이딩)

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry){
        registry.addConverter(new StringToPersonConverter());
        registry.addConverter(new PersonToStringConverter());
    }
}

 

기존에는 생성한 컨버터를 직접 ConversionService 객체를 생성한 뒤에 집어넣어줘야 했지만, 스프링에서 제공하는 ConversionService인 WebMvcConfigurer의 addFormatter 메서드를 사용하면 간편하다.

 

그리고 원래는 컨버터 등록(생성), 컨버터 사용 모두 ConversionRegistry와 ConversionService를 직접 사용해야 해서 개발자가 내부 로직으로 직접 데이터를 변환하는 것과 큰 차이가 없다.

하지만 스프링의 경우는 파라미터로 값을 전달받을 때 알아서 등록된 컨버터를 사용하고 데이터를 바인딩해주기 때문에 컨버터를 사용할 때의 코드 작성은 필요 없다.

 


Formatter 구현

포맷터는 주로 사용자 지정 포맷을 적용해 데이터를 변환할 때 사용한다.

객체를 특정한 포맷에 맞춰서 문자로 출력하는 기능에 특화된 것이 formatter이다. (converter보다 조금 더 세부적인 기능)

 

1. 포맷터 생성

@Slf4j
public class PriceFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        log.info("text = {}, locale = {}", text, locale);

        // 변환 로직
        // NumberFormat이 제공하는 기능
        NumberFormat format = NumberFormat.getInstance(locale);

        // "10,000" -> 10000L
        return format.parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        log.info("object = {}, locale = {}", object, locale);

        // 10000L -> "10,000"
        return NumberFormat.getInstance

 

 

Locale

  • 지역 및 언어 정보를 나타내는 객체.
    • 언어코드 en, ko
    • 국가코드 US, KR

2. 포맷터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry){
        registry.addConverter(new StringToPersonConverter());
        registry.addConverter(new PersonToStringConverter());
        
        // Formatter 등록
        conversionService.addFormatter(new PriceFormatter());
    }
}

 


결론

스프링 덕에 간소화된 버전으로 컨버터와 포맷터를 등록하고 사용할 수 있지만, 되도록 기존에 스프링이 제공하는 컨버터와 포맷터를 활용하는 게 베스트

 

파라미터

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/arguments.html

 

Method Arguments :: Spring Framework

JDK 8’s java.util.Optional is supported as a method argument in combination with annotations that have a required attribute (for example, @RequestParam, @RequestHeader, and others) and is equivalent to required=false.

docs.spring.io

 

포맷터

https://docs.spring.io/spring-framework/reference/core/validation/format.html#format-CustomFormatAnnotations

 

Spring Field Formatting :: Spring Framework

As discussed in the previous section, core.convert is a general-purpose type conversion system. It provides a unified ConversionService API as well as a strongly typed Converter SPI for implementing conversion logic from one type to another. A Spring conta

docs.spring.io