의존성 주입 (Dependency Injection)

 

소프트웨어 개발에서 코드의 유지보수성과 확장성을 높이기 위해 "의존성 주입(Dependency Injection, DI)"이라는 개념이 자주 사용된다. 특히 Java에서는 DI를 활용하면 객체 간 결합도를 낮추고 코드의 유연성을 극대화할 수 있다. DI를 활용하면 객체 간의 직접적인 의존성을 제거하고, 대신 외부에서 객체를 주입받도록 설계할 수 있다.

이 포스팅에서는 Java에서 의존성 주입을 어떻게 구현하는지, 그리고 DI를 활용했을 때 얻을 수 있는 장점에 대해 알아보겠다.


1. 의존성 문제와 기존 방식의 한계

기존 방식의 문제점

아래 예제는 EmailService 클래스를 활용하는 애플리케이션을 구현한 코드다.

public class EmailService {
    public void sendEmail(String message, String receiver) {
        System.out.println("Email sent to " + receiver + " with Message=" + message);
    }
}

 

이 EmailService 클래스를 사용하는 MyApplication 클래스를 구현해보자.

public class MyApplication {
    private EmailService email = new EmailService();
    
    public void processMessages(String msg, String rec) {
        this.email.sendEmail(msg, rec);
    }
}

 

그리고 클라이언트 코드도 작성해보자.

public class MyLegacyTest {
    public static void main(String[] args) {
        MyApplication app = new MyApplication();
        app.processMessages("Hi Pankaj", "pankaj@abc.com");
    }
}

이 코드에서 MyApplication 클래스는 EmailService 인스턴스를 직접 생성하고 있다. 이는 다음과 같은 문제를 초래한다.

  1. 하드코딩된 의존성 - EmailService에 강하게 결합되어 있어, 다른 메시지 서비스로 변경하려면 코드를 수정해야 한다.
  2. 유연성 부족 - SMS나 Facebook 메시지 같은 기능을 추가하려면 새로운 클래스를 만들어야 한다.
  3. 테스트 어려움 - EmailService가 직접 생성되므로, 단위 테스트 시 Mock 객체를 활용하기 어렵다.

이 문제를 해결하기 위해 의존성 주입을 적용해보자.


2. 의존성 주입 적용하기

서비스 인터페이스 정의

먼저, 메시지 전송 기능을 추상화하는 인터페이스를 만들자.

public interface MessageService {
    void sendMessage(String msg, String rec);
}

구체적인 서비스 구현

이제 MessageService를 구현하는 EmailServiceImpl과 SMSServiceImpl 클래스를 작성하자.

public class EmailServiceImpl implements MessageService {
    @Override
    public void sendMessage(String msg, String rec) {
        System.out.println("Email sent to " + rec + " with Message=" + msg);
    }
}
public class SMSServiceImpl implements MessageService {
    @Override
    public void sendMessage(String msg, String rec) {
        System.out.println("SMS sent to " + rec + " with Message=" + msg);
    }
}

서비스 소비자 (Consumer)

이제 서비스 인터페이스를 이용하는 MyDIApplication 클래스를 구현하자.

public interface Consumer {
    void processMessages(String msg, String rec);
}
public class MyDIApplication implements Consumer {
    private MessageService service;
    
    public MyDIApplication(MessageService svc) {
        this.service = svc;
    }
    
    @Override
    public void processMessages(String msg, String rec) {
        this.service.sendMessage(msg, rec);
    }
}

이제 MyDIApplication 클래스는 MessageService 인터페이스에 의존하므로, 특정 구현체(EmailServiceImpl, SMSServiceImpl)에 직접 의존하지 않는다.

인젝터 인터페이스

public interface MessageServiceInjector {
    Consumer getConsumer();
}

인젝터 클래스

이제 각 서비스별 인젝터 클래스를 작성하자.

public class EmailServiceInjector implements MessageServiceInjector {
    @Override
    public Consumer getConsumer() {
        return new MyDIApplication(new EmailServiceImpl());
    }
}
public class SMSServiceInjector implements MessageServiceInjector {
    @Override
    public Consumer getConsumer() {
        return new MyDIApplication(new SMSServiceImpl());
    }
}

이제 애플리케이션에서 이메일과 SMS를 전송하는 기능을 실행할 수 있다.

public class MyMessageDITest {
    public static void main(String[] args) {
        String msg = "Hi Pankaj";
        String email = "pankaj@abc.com";
        String phone = "4088888888";
        
        MessageServiceInjector injector = new EmailServiceInjector();
        Consumer app = injector.getConsumer();
        app.processMessages(msg, email);
        
        injector = new SMSServiceInjector();
        app = injector.getConsumer();
        app.processMessages(msg, phone);
    }
}

이제 MyDIApplication은 MessageService 인터페이스를 통해 서비스를 이용하며, 실제 서비스 객체는 인젝터에서 주입받는다. 덕분에 새로운 메시지 서비스를 추가할 때 MessageService를 구현한 클래스와 해당하는 인젝터 클래스만 추가하면 된다.

 

단위 테스트 코드

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;

public class MyDIApplicationJUnitTest {
    private MessageServiceInjector injector;

    @Before
    public void setUp() {
        injector = new MessageServiceInjector() {
            @Override
            public Consumer getConsumer() {
                return new MyDIApplication(new MessageService() {
                    @Override
                    public void sendMessage(String msg, String rec) {
                        System.out.println("Mock Message Service implementation");
                    }
                });
            }
        };
    }

    @Test
    public void test() {
        Consumer consumer = injector.getConsumer();
        consumer.processMessages("Hi Pankaj", "pankaj@abc.com");
    }

    @After
    public void tearDown() {
        injector = null;
    }
}

이제 JUnit 테스트에서 mock을 활용하여 DI가 적용된 애플리케이션을 쉽게 검증할 수 있다.


 

3. 의존성 주입의 장점과 단점

장점

  • 관심사의 분리: 서비스 객체를 직접 생성하지 않으므로, 애플리케이션 코드가 보다 깔끔해진다.
  • 유연성 증가: 새로운 메시지 서비스를 쉽게 추가할 수 있다.
  • 테스트 용이성: Mock 객체를 사용하여 단위 테스트가 가능해진다.

단점

  • 구현 복잡성 증가: 단순한 애플리케이션이라면 굳이 DI를 사용할 필요가 없다.
  • 런타임 오류 가능성: 컴파일 타임에 발견할 수 있는 오류가 런타임까지 지연될 수 있다.

4. 결론

의존성 주입은 애플리케이션의 확장성과 유지보수성을 높이는 강력한 디자인 패턴이다. Java에서 DI를 적용하면 객체 간 결합도를 낮추고, 인터페이스 기반으로 설계할 수 있어 테스트가 용이해진다. Spring Framework와 같은 DI 컨테이너를 사용하면 DI를 더욱 편리하게 적용할 수도 있다.

다만, 모든 프로젝트에 DI가 반드시 필요한 것은 아니다. 필요에 따라 적절한 디자인 패턴을 선택하는 것이 중요하다. 하지만, 복잡한 애플리케이션을 개발할 때 DI를 도입하면 코드의 유연성과 확장성이 크게 향상될 것이다.

Spring Boot에서는 초기화 과정에서 컴포넌트를 주입할 때, 어플리케이션에 대한 Key/Value 형태의 설정을 클래스 내 변수에 값을 넣어주는 @Value Annotation이 존재한다. 이러한 설정은 application.properties 또는 application.yml 과 같은 파일에서 다음과 같은 형식으로 관리할 수 있다.

예) application.properties

application.version = v1.0.2

예) application.yml

application
    version: v1.0.2

이러한 방식을 사용하여 아마존 서비스와 같이 다른 3rd party 서비스를 사용할 때 Access Key 또는 Secret Key 같은 설정을 유용하게 할 수 있다. 또한, Spring Boot는 Profile 별로 설정 파일을 분리하여 관리할 수 있다. 이와 같이 설정 파일에 정의한 값을 사용하기 위하여 Spring Boot에서는 @Value annotation 을 제공하고 있다.

static 변수에 대하여 @Value Annotation 사용

서론에서 설명한 것과 같이 Spring Boot의 설정 파일에 등록되어 있는 값을 주입된 컴포넌트에서 사용하기 위하여 @Value annotation를 사용한다. 클래스 인스턴스 내에서 @Value annotation 으로 decorate 된 변수를 어렵지 않게 사용할 수 있다. 하지만, static 변수 에서 다음과 같이 @Value annotation 을 사용한다면 잘못된 결과를 초래할 수 있다.

예) EncryptionUtil.java

@Component
public class EncryptionUtil {

    @Value("${enc.key}"})
    public static String key;

}

위와 같이 작성된 EncryptionUtil 클래스 내 정의되어 있는 static 변수 key를 사용하려고 EncryptionUtil.key 로 접근을 하게 된다면 항상 null 이 반환 될 것이다. 이는 static 변수에 대하여 @Value annotation 이 동작하지 않는다.

이를 해결하기 위해서는 static 이 아닌 setter 메소드를 추가하여 static 변수에 직접적으로 값을 넣을 수 있도록 하면 된다.

예) 수정된 EncryptionUtil.java

@Component
public class EncryptionUtil {

    public static String key;

    @Value("${enc.key}"})
    public void setKey(String value) {
        key = value;
    }

}

 

 

출처 

 

https://jkpark.me/springboot/java/2020/06/04/Spring-Boot-static-%EB%B3%80%EC%88%98%EC%97%90%EC%84%9C-@value-annontation-%EC%82%AC%EC%9A%A9.html

JPA의 FlushMode, Flush, 변경 감지 개념 및 동작 원리

1. JPA에서 영속성 컨텍스트란?

JPA를 쓰면 엔티티를 데이터베이스에서 가져와 객체로 다루게 된다. 이때 객체를 단순히 메모리에 올리는 게 아니라, 영속성 컨텍스트라는 곳에서 관리하게 된다. 쉽게 말해, JPA가 엔티티를 보관하고 변경 사항을 추적하는 1차 캐시 같은 개념이다.

엔티티를 조회하면 JPA는 먼저 영속성 컨텍스트에서 찾고, 없으면 데이터베이스에서 가져와서 저장한다. 덕분에 같은 트랜잭션 내에서는 동일한 엔티티를 여러 번 조회해도 쿼리가 추가로 실행되지 않는다.

2. Flush란?

영속성 컨텍스트에 있는 엔티티의 변경 사항은 곧바로 데이터베이스에 반영되지 않는다. 대신 쓰기 지연 저장소에 모아뒀다가 특정 시점에 한꺼번에 반영하는데, 이 작업을 flush라고 한다.

하지만 flush는 데이터베이스에 커밋을 하는 게 아니라, SQL을 생성해서 데이터베이스와 동기화하는 것일 뿐이다. 실제 데이터 반영은 트랜잭션이 커밋될 때 이루어진다.

Flush가 발생하는 조건

  1. EntityManager.flush() 메서드를 직접 호출할 때
  2. 트랜잭션이 커밋될 때 (commit 시 자동 실행)
  3. JPQL 또는 네이티브 쿼리를 실행하며, 해당 쿼리가 변경된 엔티티가 포함된 테이블을 조회할 때

3. FlushMode란?

FlushMode언제 flush를 실행할지 결정하는 설정이다.

FlushMode의 종류

  • FlushModeType.AUTO (기본값)
    • 트랜잭션이 커밋될 때 자동 flush
    • JPQL 실행 시 변경된 엔티티가 포함된 테이블을 조회하면 flush
  • FlushModeType.COMMIT
    • JPQL 실행 시 flush가 발생하지 않음
    • 오직 트랜잭션이 커밋될 때만 flush 실행

4. 변경 감지(Dirty Checking)란?

변경 감지는 JPA가 영속성 컨텍스트 내 엔티티의 변경 사항을 자동으로 감지하고 반영하는 기능이다. 엔티티를 수정하면 JPA가 알아서 UPDATE 쿼리를 생성해준다.

변경 감지 동작 방식

  1. 엔티티 조회 (1차 캐시 또는 데이터베이스에서 불러옴)
  2. 엔티티 필드 변경 (entity.setXXX() 호출)
  3. 트랜잭션이 커밋되거나 flush가 실행될 때 1차캐시 엔티티와 스냅샷의 변경사항을 비교
  4. update 쿼리 생성 및 쓰기 지연 저장소에 저장 후 쿼리 실행

5. Flush, FlushMode, 변경 감지 동작 예제

// 1. PK로 엔티티 조회
Member member = MemberRepository.findById(id);
// 엔티티 필드 수정
member.setGrade(5);

// 2. PK가 아닌 컬럼으로 엔티티 조회
MemMember member = MemberRepository.findByPhoneNum(phoneNum);/ ㅇ 이후 쿼리 실행 로직 (생략)

실행되는 SQL 쿼리 순서

  1. SELECT (PK 기반 조회)
  2. UPDATE 실행
  3. SELECT (컬럼 기반 조회)

UPDATE 쿼리가 실행될까?

findByPhoneNum()은 PK가 아닌 컬럼을 기준으로 엔티티를 조회하는 메서드이므로, 1차 캐시가 아닌 데이터베이스 테이블을 직접 조회한다.

따라서 JPA는 **데이터베이스 일관성을 유지하기 위해 조회 전에 변경 사항을 flush**한다. flush 과정에서 변경 감지가 수행되어 UPDATE 쿼리가 생성되고, 쓰기 지연 저장소에 저장된 후 실행된다.

6. Flush가 발생하지 않는 경우

만약 findByPhoneNum()이 아니라 전혀 관련 없는 다른 테이블을 조회한다면, flush는 실행되지 않는다.

즉, flush는 영속성 컨텍스트에 보류 중인 변경이 포함된 테이블을 조회할 때만 발생한다. 다른 테이블을 조회할 경우, 변경 사항과 관련이 없으므로 flush가 실행되지 않는다.

7. 영속성 컨텍스트 - Flush - Commit 과정

이해하기 쉽게 그림으로 정리해보자.

 

이렇게 보면 감이 올 것이다. flush는 변경 사항을 데이터베이스에 밀어 넣지만, 트랜잭션이 커밋되기 전까지 확정되지 않는다.

8. 정리

  • 영속성 컨텍스트: JPA가 엔티티를 관리하는 메모리 공간 (1차 캐시 역할)
  • Flush: 변경 사항을 데이터베이스에 반영하는 과정 (트랜잭션 내에서만 적용됨)
  • Commit: 트랜잭션이 종료되며, 변경 사항이 데이터베이스에 확정됨
  • FlushMode: AUTO는 JPQL 실행 시 flush, COMMIT은 트랜잭션 커밋 시에만 flush
  • 변경 감지: JPA가 변경된 엔티티를 감지하여 UPDATE 쿼리를 생성함

이제 JPA의 flush가 언제, 왜 실행되는지 감이 좀 잡혔을 거다. 더 깊이 알고 싶다면 직접 실험해보는 게 최고다. JPA의 동작을 로그로 확인하면서 실행 순서를 보는 것도 추천한다!

'Spring' 카테고리의 다른 글

SpringBoot static 변수에 @value 사용  (0) 2025.01.17

https://www.acmicpc.net/problem/1911

 

 

 

음... 그냥 단순하게 정렬한 후 널빤지의 개수를 구하면 된다.

주의해야 할 건 널빤지의 길이는 정해져 있기 때문에 물웅덩이가 아닌 위치도 널빤지로 덮을 수 있다는 점.

 

만약 물웅덩이의 위치가 [1,5] [10,15] 이고, 널빤지의 길이가 20이라면 널빤지의 모든 물웅덩이를 커버 가능하다.

 

이부분만 생각해서 풀면 되는 간단한(?) 문제였다. 딱히 정렬말고 알고리즘을 쓸 필요도 없음...

 

 

물웅덩이는 최대 만개이다.

 

웅덩이의 위치값은 최소 0에서 최대 10억이다.

 

시간복잡도를 고려하면 범위를 for문으로 돌리면 안된다는 걸 알 수 있다.

 

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

public class Main {

    public static int answer = 0;
    public static int N,L;

    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

        StringTokenizer st = new StringTokenizer(br.readLine());
        N = Integer.parseInt(st.nextToken());
        L = Integer.parseInt(st.nextToken());
		
        // 물웅덩이 위치 저장
        int[] waters = new int[2*N];

        for (int i=0; i<2*N; i+=2) {
            st = new StringTokenizer(br.readLine());
            int start = Integer.parseInt(st.nextToken());
            int end = Integer.parseInt(st.nextToken());
            waters[i] = start;
            waters[i+1] = end;
        }
		
        // 정렬해도 상관 없다. 입력에서 물웅덩이 위치가 겹치는 범위는 없으므로
        Arrays.sort(waters);
		
        // 널빤지의 가장 마지막 위치를 기억한다.
        int lastIdx = 0;

        for (int i=0; i<2*N; i+=2) {

            int start = waters[i];
            
            이미 물웅덩이에 널빤지가 존재한다면,
            시작점을 널빤지의 가장 마지막 위치 + 1로 잡는다.
            if (lastIdx >= start) {
                start = lastIdx + 1;
            }
            int end = waters[i+1];
            int length = end - start;
            
            // 이미 널빤지가 대져있을 수도 있기 때문에, 이런 경우는 continue
            if (length <= 0) continue;

		
       		// 널빤지 개수
            int cnt = length/L;

			// 널빤지 대고 남는 부분
            int res = length%L;

			// 남는 부분이 있으면, 널빤지개수를 1 증가시키고 마지막 널빤지위치를 구한다.
            if (res != 0) {
                cnt++;
                lastIdx = waters[i+1]-1 + (L - res);
            }

			// 답에 카운트 더하기
            answer += cnt;
        }

        System.out.println(answer);
    }
}

 

 

 

https://www.acmicpc.net/source/80479844

 

문제를 읽어보면, 서로 바라 볼 수 있는 사람의 쌍의 수를 구해야 한다.

조건은 두 사람 사이에, 둘중 아무나보다 키가 큰 사람이 존재하면 안된다는 것이다. ( 키가 동일하면 상관없다 )
처음엔 막연하게 이중포문을 생각했지만 N이 최대 50만 이기 때문에.. 절대 안된다 ! 

 

어렵다.. 머리 좋아지고 싶다..! 결국 보게 된 풀이..

 

스택을 사용한다. 

 

 

문제풀이 아이디어를 정리하면 다음과 같다.

1. 스택 만드는데 내림차순 스택임 (이유는 아래에)

2. 내림차순 스택을 만드는 과정에서 분기처리 잘하자

 

 

여기서 중요한점은 스택 내에  '앞으로 쌍이 될 가능성이 없는 사람' 은 제거한다.

 

예를들어 스택에 [3 2 1] 이 쌓여있다고 하자. 네번째에 키가 4인 사람이 들어왔다.

[3 2 1 4]가 된다. 네번째 사람은 첫번째~세번째 사람과 모두 쌍이 될 수 있다. 
근데 반대로 첫번째 ~ 세번째 사람은 네번째사람에게 가로막혀 앞으로 나올 사람들과 쌍이 절대 될 수 없다.

따라서 스택에서 제거한다.

 

다시 예를들어 [5 5 5]가 쌓여있다고하자. 네번째에도 키가 5인 사람이 들어왔다.

 

그럼 네번째 사람은 첫번째~세번째 사람과 모두 쌍이 될 수 있다. 근데 이걸 스택으로 어떻게 구현할것인가 ?

스택에 5 5 5 5 이렇게 쌓아두면 for문으로 5가 안나올때 까지 돌릴건가 ? 그건 시간초과다.

따라서 스택에 연속된 동일값은 하나만 저장해야한다. 그럴려면 연속된 동일값 개수를 카운트해야한다. 따라서 스택안의 타입은 인트배열로,  인트배열은 [키, 연속된 동일값 개수] 로 이루어진다.

 

import java.io.*;
import java.util.Stack;

public class Main {

    static int N;
    static Stack<int[]> stack = new Stack<>();
    static long answer;

    public static void main(String[] args) throws IOException {
        // N이 50만이기 때문에 웬만해선 O(N)이나 O(logN) 으로 해결해야함 . 
        // stack

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

        N = Integer.parseInt(br.readLine());

        for (int i = 0; i < N; i++) {

            // h => 키
            int h = Integer.parseInt(br.readLine());
            // cnt => 연속된 동일값 개수
            int cnt = 1;

            // 자신의 키와 스택에 쌓인 사람들의 키를 비교한다.
            // 만약 자신의 키가 스택에 있는 사람의 키보다 크다면 스택에서 사람을 지운다. (자신의 키가 더 크면 기존 스택에 있던 것들은 무용지물. 앞으로 나올 값들과 쌍이 될 수 없기 때문에 )
            // 스택은 내림차순으로 유지된다.
            while (!stack.isEmpty() && h > stack.peek()[0]) {
                int[] pop = stack.pop();
                answer += pop[1];
            }

            // 자신의 키와 스택에 있는 사람의 키가 같을 때.
            // 스택의 값을 없애고, 스택의 cnt만큼 현재 cnt에 추가한다.
            if (!stack.isEmpty() && h == stack.peek()[0]) {
                int[] pop = stack.pop();

                // 기존 스택 값을 없애기 때문에 answer에 더해준다.
                answer += pop[1];
                cnt += pop[1];
            }

            // 스택안에 사람이 있을 때. 쌍이 되므로 1을 더해줌
            if (!stack.isEmpty()) {
                answer++;
            }

            stack.push(new int[]{h, cnt});
        }

        bw.write(answer + "\n");
        bw.flush();
    }
}

 

 

https://www.acmicpc.net/problem/1644

 

 

 

문제를 읽어보면, "소수의 연속합"으로 특정 자연수를 나타내야 한다.

예시에 나와있듯이 20은 7+13으로 나타낼 수 있지만 "연속된 소수" 들이 아니기 때문에 (7과 13 사이에는 11이 있다)

잘못된 경우이다.

 

그럼 문제를 어떻게 풀어야 할지 생각해보면

1. 소수 리스트를 구한다. 특정 값이 소수인가? 가 아닌 범위내의 모든 소수들의 값을 구하는 것이기 때문에

에라토스테네스의 체를 사용한다.  시간복잡도는  O(n * log(log n))

 

2. 소수 리스트를 구했으면, "연속된 소수" 로 나타내야 하기 때문에 투포인터로 풀이가 가능할 것 같다.

 

 

에라토스테네스의 체와 투포인터로 풀이한 코드는 아래와 같다.

 

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;

public class Main {
    static boolean[] prime;
    static ArrayList<Integer> primeList;

    public static void main(String[] args) throws IOException {


        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int N = Integer.parseInt(br.readLine());

        prime = new boolean[N+1];
        primeList = new ArrayList<>();

        // 에라토스테네스의 체
        for (int i = 2; i*i <= N; i++) {
            if (!prime[i]) {
                for (int j = i*i; j <= N; j += i) {
                    prime[j] = true;
                }
            }
        }

        for (int i = 2; i <= N; i++) {
            if (!prime[i]) {
                primeList.add(i);
            }
        }

        // 투포인터
        int start = 0, end = 0, sum = 0, cnt = 0;
        while (true) {
            if (sum >= N) sum -= primeList.get(start++);
            else if (end == primeList.size()) break;
            else sum += primeList.get(end++);

            if (sum == N) cnt++;
        }

        System.out.println(cnt);

    }
}

+ Recent posts