최근 처음으로 IntelliJ에서 AI Junie를 이용하게 되었는데, 대화할 때
Tools > AI Assistant > prompt Libray에 프롬프트를 입력해도
의도한 답변이 나오지 않는 현상이 있었다.

예를 들면,
한국어로 설명해달라고 했는데 영어로만 답변한다
레거시 스타일을 요청했는데 최신 문법을 사용한다
•코드 스타일 지침을 무시한다

Junie를 이용할 경우 guidelines.md에 적용해야 된다고 한다…

guidelines.md 경로 설정 위치

IntelliJ에서 다음 탭에서 설정을 확인해야 한다.
Settings > Tools > Junie > Project Settings > Guidelines Path
여기에 설정된 경로가 .junie/guidelines.md 일 경우

이 파일에 작성된 내용이 AI의 기본 행동 규칙이 된다.

guidelines.md 예시 (프롬프트 샘플)

아래는 실제로 사용하기 좋은 예시이다.
코드는 영어, 설명은 한국어, 실무 기준으로 작성했다.

# AI Code Generation Guidelines

## Language
- 코드 자체는 영어를 사용하되, 설명은 한국어로 한다.

## Code Style
- 유지보수를 고려한 명확한 코드로 작성한다.
- 불필요한 최신 문법이나 과한 추상화는 사용하지 않는다.
- 가독성을 최우선으로 한다.

## Backend Development Rules
- 예외 처리는 명확하게 분리한다.
- null 가능성은 항상 고려한다.
- 로그는 운영 환경을 고려하여 남긴다.

## When Explaining Code
- "왜 이렇게 작성했는지"를 함께 설명한다.
- 기존 레거시 코드와의 호환성을 고려한다.
- 대안이 있다면 장단점을 비교 설명한다.

## Refactoring
- 동작 변경 없는 리팩토링인지 명시한다.
- 성능 영향이 있다면 반드시 언급한다.

## Assumptions
- 프레임워크나 라이브러리 전제 조건이 있다면 먼저 명시한다.
- 불확실한 부분은 추측하지 말고 질문하거나 가정하였음을 표시한다

이렇게 작성해두면:
•매번 프롬프트를 반복 입력할 필요 없음
•팀 단위 코드 스타일 통제 가능
•AI 응답 품질이 눈에 띄게 안정됨

팀 단위로 관리하지 않을 경우 개인 별로 맞게 수정하여 사용하기 바랍니다.

ex) 개인 공부용일 경우 최신문법을 많이 사용해달라는 느낌으로
프롬프트를 수정하면 좋을 듯…? 합니다.

프롬프트 적용됬는지 확인

프롬프트 미적용 프로젝트에서 대화시도

프롬프트 적용 후 대화시도

실무에서 추천하는 사용 방식

•개인 프로젝트 → 개인 취향 기준 작성
•팀 프로젝트 → 프롬프트 문서를 공유하여 함께 관리
•레거시 유지보수 → “최신 문법 사용 금지” 명시 필수

하나의 서비스에서 여러 데이터베이스를 동시에 처리해야 하는 경우가 있다.

이때 단일 DataSource 기반의 @Transactional만으로는 여러 데이터베이스 간의 트랜잭션 일관성을 보장하기 어렵다.

예를 들어, 하나의 함수에서 A 데이터베이스 수정 → B 데이터베이스 수정 중 오류 발생

이런 상황에서 A와 B 데이터베이스를 모두 함께 롤백하려면 글로벌 트랜잭션(JTA) 이 필요하다.

JTA가 필요없는 예시:
•회원 정보 수정 → A DB만 사용
•인증 정보 수정 → B DB만 사용

이처럼 상황에 따라 특정 데이터베이스만 접근하는 구조라면, 트랜잭션 매니저를 동적으로 선택하는 방식을 참고하는 것이 더 적절할 수 있다.
(관련 내용은 아래 링크 참고)
https://thornapp.tistory.com/10

 

하나의 DB에서 트랜잭션 매니저를 동적으로 선택하는 방법 (Spring AOP)

배경 및 문제점운영 중인 시스템에서 다음과 같은 상황이 있었다.• 하나의 물리 DB• 특정 역할에 따른 DB 계정 분리• MyBatis + Spring Transaction 사용하지만 Spring의 기본 @Transactional 은→ 트랜잭션

thornapp.tistory.com

 

아키텍처 구성

• Spring Boot
• Atomikos (JTA Transaction Manager)
• Oracle XA DataSource
• MyBatis
• 다중 DB (Primary / Secondary / Third)

java 코드 예제
@Configuration
public class DataSourceConfig {

    /* =====================================================
     * XA DataSource 설정
     *
     * - AtomikosDataSourceBean은 XA 트랜잭션을 지원하는 DataSource
     * - 각 DataSource는 서로 다른 DB를 바라보지만
     * - JTA TransactionManager에 의해 하나의 글로벌 트랜잭션으로 묶인다
     * ===================================================== */

    /**
     * Primary XA DataSource
     * - 기본으로 사용될 DataSource
     * - @Primary로 지정하여 주입 시 우선 선택
     */
    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return new AtomikosDataSourceBean();
    }

    /**
     * Secondary XA DataSource
     */
    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return new AtomikosDataSourceBean();
    }

    /**
     * Third XA DataSource
     */
    @Bean(name = "thirdDataSource")
    @ConfigurationProperties("spring.datasource.third")
    public DataSource thirdDataSource() {
        return new AtomikosDataSourceBean();
    }

    /* =====================================================
     * JTA Transaction Manager 설정
     *
     * - 여러 XA DataSource를 하나의 글로벌 트랜잭션으로 관리
     * - @Transactional 하나로 다중 DB 트랜잭션을 제어 가능
     * - 내부적으로 2-Phase Commit(2PC) 수행
     * ===================================================== */

    @Bean
    public PlatformTransactionManager transactionManager() throws SystemException {

        // UserTransaction: 트랜잭션의 시작 / 커밋 / 롤백을 제어
        UserTransactionImp userTx = new UserTransactionImp();
        userTx.setTransactionTimeout(30);

        // Atomikos의 핵심 트랜잭션 매니저
        // 여러 XA Resource(DataSource)를 관리
        UserTransactionManager atomikosTxManager = new UserTransactionManager();
        atomikosTxManager.init();

        // Spring에서 사용하는 JTA 트랜잭션 매니저
        // @Transactional 이 이 매니저를 통해 글로벌 트랜잭션을 제어
        return new JtaTransactionManager(userTx, atomikosTxManager);
    }

    /* =====================================================
     * SqlSessionFactory 설정
     *
     * - 각 DataSource마다 별도의 SqlSessionFactory 생성
     * - 하지만 트랜잭션은 모두 JTA(TransactionManager) 하나로 관리됨
     * ===================================================== */

    @Primary
    @Bean(name = "primarySqlSessionFactory")
    public SqlSessionFactory primarySqlSessionFactory(
            @Qualifier("primaryDataSource") DataSource dataSource,
            ApplicationContext context) throws Exception {

        return createSqlSessionFactory(dataSource, context);
    }

    @Bean(name = "secondarySqlSessionFactory")
    public SqlSessionFactory secondarySqlSessionFactory(
            @Qualifier("secondaryDataSource") DataSource dataSource,
            ApplicationContext context) throws Exception {

        return createSqlSessionFactory(dataSource, context);
    }

    @Bean(name = "thirdSqlSessionFactory")
    public SqlSessionFactory thirdSqlSessionFactory(
            @Qualifier("thirdDataSource") DataSource dataSource,
            ApplicationContext context) throws Exception {

        return createSqlSessionFactory(dataSource, context);
    }

    /**
     * SqlSessionFactory 공통 생성 메서드
     *
     * - DataSource만 다르고 MyBatis 설정은 동일
     * - 유지보수를 위해 공통 메서드로 분리
     */
    private SqlSessionFactory createSqlSessionFactory(
            DataSource dataSource,
            ApplicationContext context) throws Exception {

        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setConfigLocation(
                context.getResource("classpath:mybatis/mybatis-config.xml"));
        factoryBean.setMapperLocations(
                context.getResources("classpath:mapper/**/*.xml"));
        return factoryBean.getObject();
    }

    /* =====================================================
     * SqlSessionTemplate 설정
     *
     * - MyBatis에서 실제 SQL 실행에 사용되는 객체
     * - 각각 다른 DB를 바라보지만
     * - 트랜잭션은 JTA에 의해 하나로 묶인다
     * ===================================================== */

    @Primary
    @Bean(name = "primarySqlSessionTemplate")
    public SqlSessionTemplate primarySqlSessionTemplate(
            @Qualifier("primarySqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }

    @Bean(name = "secondarySqlSessionTemplate")
    public SqlSessionTemplate secondarySqlSessionTemplate(
            @Qualifier("secondarySqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }

    @Bean(name = "thirdSqlSessionTemplate")
    public SqlSessionTemplate thirdSqlSessionTemplate(
            @Qualifier("thirdSqlSessionFactory") SqlSessionFactory factory) {
        return new SqlSessionTemplate(factory);
    }
}
DB 설정 yml 예제
spring:
  datasource:
    primary:
      unique-resource-name: primary-db
      xa-datasource-class-name: oracle.jdbc.xa.client.OracleXADataSource
      xa-properties:
        URL: jdbc:oracle:thin:@...
        user: primary_user
        password: primary_password
      min-pool-size: 1
      max-pool-size: 50
      borrow-connection-timeout: 30
      maintenance-interval: 60
      max-lifetime: 3600
      max-idleTime: 60

    secondary:
      unique-resource-name: secondary-db
      xa-datasource-class-name: oracle.jdbc.xa.client.OracleXADataSource
      xa-properties:
        URL: jdbc:oracle:thin:@...
        user: secondary_user
        password: secondary_password
      min-pool-size: 1
      max-pool-size: 50
      borrow-connection-timeout: 30
      maintenance-interval: 60
      max-lifetime: 3600
      max-idleTime: 60
      
    third:
      unique-resource-name: third-db
      xa-datasource-class-name: oracle.jdbc.xa.client.OracleXADataSource
      xa-properties:
        URL: jdbc:oracle:thin:@...
        user: third_user
        password: third_password
      min-pool-size: 1
      max-pool-size: 50
      borrow-connection-timeout: 30
      maintenance-interval: 60
      max-lifetime: 3600
      max-idleTime: 60

lifetime 등 설정값은 찾아보시고 본인 프로젝트에 맞게 변경하시기 바랍니다.

적용예시
@Transactional
public void process() {
    primaryMapper.insertA();
    secondaryMapper.insertB();
    thirdMapper.insertC();
    // 하나라도 실패하면 전체 롤백
}

 

주의사항

• unique-resource-name 중복 금지
• 커스텀한 로컬 트랜잭션과 혼용해서 사용 X
• 성능 오버헤드 고려
• XA 지원 DB만 가능

JTA방식 VS AOP기반 트랜잭션 동적 선택방식
구분 Atomikos + JTA AOP 기반 트랜잭션 선택
트랜잭션 범위 여러 DB를 하나의 글로벌 트랜잭션으로 묶음 DB별 로컬 트랜잭션
정합성 보장 ✅ 완전 보장 (2PC) ❌ 직접 처리 필요
실패 시 처리 자동 전체 롤백 보상 트랜잭션 필요
구현 난이도 높음 중~낮음
성능 상대적으로 느림 빠름
DB 제약 XA 지원 DB 필요 제약 거의 없음
추천 사용처 금융, 정산, 주문, 결제 조회 위주, 비핵심 데이터

웹 앱 배포 시 클라이언트 쪽 JS 코드를 난독화하면 소스의 가독성을 낮춰 리버스 엔지니어링을 어렵게 만들 수 있다.

이 글에서는 javascript-obfuscator를 사용해 JS를 난독화하는 방법, 설정 예제, 빌드 통합, 주의사항까지 정리한다.

1) 준비: 설치

프로젝트 루트에서 npm (또는 yarn)으로 설치

# npm
npm install --save-dev javascript-obfuscator

# yarn
yarn add --dev javascript-obfuscator

2) 간단 사용 예 (CLI)

단일 파일 난독화:

npx javascript-obfuscator src/app.js --output dist/app.obf.js

폴더 전체 난독화:

npx javascript-obfuscator src --output dist --compact true

3) 주요 옵션 (자주 쓰이는 것들)
옵션 설명 비고
--compact true/false 출력 결과에서 공백 및 줄바꿈을 제거하여 코드 길이를 줄임 기본값: true
--control-flow-flattening 제어 흐름을 평탄화하여 코드 분석을 어렵게 만듦 난독화 강도 ↑ / 성능 저하 가능
--dead-code-injection 실행되지 않는 코드를 삽입하여 분석을 방해 번들 크기 증가 가능
--debug-protection 브라우저 개발자 도구 디버깅을 방지하는 코드 삽입 디버깅 불편 주의
--string-array 문자열 리터럴을 배열로 분리하여 참조하도록 변환 권장 옵션
--string-array-encoding 문자열 배열을 인코딩하여 저장 (base64, rc4) 난독화 강도 ↑
--disable-console-output console.log 등 콘솔 출력 함수 비활성화 운영 환경에서 유용
--rotate-string-array 문자열 배열의 인덱스를 지속적으로 변경 분석 난이도 증가
--source-map 난독화된 코드에 대한 소스맵 생성 운영 배포 시 노출 주의
--threshold <0~1> 문자열 배열 변환 적용 비율 설정 0~1 사이 값


예: 제어 흐름 + 문자열 배열 + 소스맵

npx javascript-obfuscator src/app.js --output dist/app.obf.js \
  --control-flow-flattening true \
  --dead-code-injection true \
  --string-array true \
  --string-array-encoding base64 \
  --source-map true

4) package.json에 스크립트 등록
{
  "scripts": {
    "build:obf": "javascript-obfuscator dist/bundle.js --output dist/bundle.obf.js --compact true --control-flow-flattening true --source-map true"
  }
}

빌드 파이프라인에서 npm run build 후 npm run build:obf로 난독화 처리하면 편리하다.

5) 소스맵 & 디버깅

•난독화하면 디버깅이 거의 불가능하므로 별도 소스파일 생성을 권장(단, 배포 시 소스파일을 공개하지 않도록 주의)

6) 주의사항

1. 보안 대책이 아님
난독화는 코드 이해를 어렵게 할 뿐, 핵심 비밀(비밀번호, API 키 등)을 숨기는 수단이 아니다. 민감 정보는 절대 클라이언트에 두지 말 것.

2. 성능 영향
control-flow-flattening 등 옵션은 런타임 성능을 저하시킬 수 있으니 프로파일링 필요.

3. 호환성
일부 난독화 옵션은 특정 브라우저/환경에서 문제를 일으킬 수 있으니 주요 브라우저에서 테스트 필수.

배경 및 문제점

운영 중인 시스템에서 다음과 같은 상황이 있었다.
• 하나의 물리 DB
• 특정 역할에 따른 DB 계정 분리
• MyBatis + Spring Transaction 사용

하지만 Spring의 기본 @Transactional 은
트랜잭션 매니저를 컴파일 타임에 고정해야 한다는 한계가 있다.

따라서 @Transactional 어노테이션 사용 시 롤백이 안되는 상황이 발생함.

해결전략

1. 커스텀 어노테이션 정의
2. AOP에서 요청 정보로 국가 식별
3. 테넌트 코드 기반으로 트랜잭션 매니저 Bean 동적 조회
4. Spring PlatformTransactionManager 직접 제어

전체 구조

@TenantTransactional
        ↓
Aspect (Around)
        ↓
RequestVO 에서 tenantId 추출
        ↓
tenantId → TransactionManager 매핑
        ↓
commit / rollback 직접 제어

예제 코드
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantTransactional {

    Class<? extends Throwable>[] rollbackFor() default {};
}

 

예제 코드2
@Slf4j
@Aspect
@Component
public class TenantTransactionAspect implements ApplicationContextAware {

    private static final String TX_MANAGER_PREFIX = "mybatisTransactionManager_";

    // 테넌트별 트랜잭션 매니저 캐시
    private final ConcurrentMap<String, PlatformTransactionManager> txManagerCache =
            new ConcurrentHashMap<>();

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    /**
     * 테넌트(Tenant) 기준으로 트랜잭션 매니저를 동적으로 선택하여
     * 트랜잭션을 제어하는 Around Aspect
     */
    @Around("@annotation(tenantTx)")
    public Object applyTenantTransaction(
            ProceedingJoinPoint pjp,
            TenantTransactional tenantTx
    ) throws Throwable {

        /*
         * [1] ProceedingJoinPoint(pjp)
         *
         * - AOP가 가로챈 실제 비즈니스 메서드에 대한 실행 정보
         * - 주요 기능
         *   - pjp.getArgs()  : 메서드 파라미터 조회
         *   - pjp.proceed() : 실제 메서드 실행
         */

        // [2] 메서드 인자 중 RequestVO 추출
        //     → 테넌트 식별자(tenantId)를 얻기 위함
        RequestVO requestVO = extractRequestVO(pjp.getArgs());

        // RequestVO가 없으면 테넌트 판단 불가
        // → 트랜잭션 개입 없이 원본 메서드 그대로 실행
        if (requestVO == null) {
            return pjp.proceed();
        }

        // [3] RequestVO에서 테넌트 ID 추출
        String tenantId = requestVO.getTenantId();

        // [4] 테넌트 ID 기반으로 트랜잭션 매니저 동적 조회
        // ex) mybatisTransactionManager_kr
        PlatformTransactionManager txManager =
                resolveTransactionManager(tenantId);

        // [5] 트랜잭션 정의 생성
        // - PROPAGATION_REQUIRED:
        //   기존 트랜잭션이 있으면 참여, 없으면 신규 생성
        DefaultTransactionDefinition definition =
                new DefaultTransactionDefinition();
        definition.setPropagationBehavior(
                TransactionDefinition.PROPAGATION_REQUIRED
        );

        // [6] 트랜잭션 시작
        TransactionStatus status =
                txManager.getTransaction(definition);

        try {
            // [7] 실제 비즈니스 메서드 실행
            Object result = pjp.proceed();

            // [8] 정상 종료 시 트랜잭션 커밋
            commit(txManager, status);

            return result;

        } catch (Throwable ex) {

            /*
             * [9] 예외 발생 시 롤백 여부 판단
             *
             * - @TenantTransactional.rollbackFor 에 명시된 예외면 롤백
             * - 지정되지 않은 경우 기본 정책 적용
             */
            if (shouldRollback(ex, tenantTx.rollbackFor())) {

                // [10] 롤백 수행
                rollback(txManager, status, ex);

            } else {

                // [11] 롤백 대상이 아닌 예외는 커밋 처리
                commit(txManager, status);
            }

            // [12] 예외는 반드시 다시 던져
            //      상위 레이어에서 처리하도록 함
            throw ex;
        }
    }

    private PlatformTransactionManager resolveTransactionManager(String tenantId) {

        String beanName = TX_MANAGER_PREFIX + tenantId.toLowerCase();

        return txManagerCache.computeIfAbsent(beanName, name -> {
            try {
                return applicationContext.getBean(
                        name,
                        PlatformTransactionManager.class
                );
            } catch (Exception e) {
                log.warn(
                        "Failed to resolve transaction manager. tenantId={}, beanName={}",
                        tenantId,
                        name,
                        e
                );
                throw new IllegalStateException(
                        JavaMessage.getMessage("util_define_server_error"),
                        e
                );
            }
        });
    }

    private void commit(
            PlatformTransactionManager txManager,
            TransactionStatus status
    ) {
        if (!status.isCompleted()) {
            try {
                txManager.commit(status);
            } catch (Exception e) {
                log.warn(
                        "Transaction commit failed. txManager={}",
                        txManager,
                        e
                );
                throw new IllegalStateException(
                        JavaMessage.getMessage("util_define_server_error"),
                        e
                );
            }
        }
    }

    private void rollback(
            PlatformTransactionManager txManager,
            TransactionStatus status,
            Throwable original
    ) {
        if (!status.isCompleted()) {
            try {
                txManager.rollback(status);
            } catch (Exception e) {
                e.addSuppressed(original);
                log.warn(
                        "Transaction rollback failed. txManager={}",
                        txManager,
                        e
                );
                throw new IllegalStateException(
                        JavaMessage.getMessage("util_define_server_error"),
                        e
                );
            }
        }
    }

    private boolean shouldRollback(
            Throwable ex,
            Class<? extends Throwable>[] rollbackFor
    ) {
        if (rollbackFor == null || rollbackFor.length == 0) {
            return ex instanceof Exception;
        }
        for (Class<? extends Throwable> type : rollbackFor) {
            if (type.isAssignableFrom(ex.getClass())) {
                return true;
            }
        }
        return false;
    }

    private RequestVO extractRequestVO(Object[] args) {
        if (args == null) {
            return null;
        }
        for (Object arg : args) {
            if (arg instanceof RequestVO) {
                return (RequestVO) arg;
            }
        }
        return null;
    }
}

 

사용예시
@TenantTransactional(rollbackFor = { BusinessException.class })
public void processOrder(RequestVO requestVO) {
    // 비즈니스 로직
}

 

장점과 주의사항

장점
• 테넌트 추가 시 설정만 추가하면 됨
• 서비스 코드 오염 없음
• 하나의 DB에서 유저 분리 구조에 적합

주의사항
• 중첩 트랜잭션 / 다른 @Transactional 과 혼용 금지
• 반드시 Aspect 우선순위 관리
• RequestVO 누락 시 동작 정의 필요

최근 운영 환경에서 특정 조회 기능이 갑자기 DB 락이 걸린 것처럼 느려지는 현상이 발생했다.
증상은 다음과 같았다.

• 같은 쿼리를 로컬 PC에서 SQL Developer로 실행하면 수 ms 만에 응답
• 동일한 쿼리를 WAS(MyBatis)를 통해 실행하면 수 초~수십 초 이상 걸림
• 심한 경우 DB 전체 부하가 증가하면서 다른 쿼리까지 지연 발생

특히 이상한 점은, 예전에도 같은 쿼리 구조에서 바인드 변수를 썼는데
최근 쿼리가 복잡해진 뒤부터 현상이 심해졌다는 것이다.

결과부터 말하자면 잘못된 바인드 변수 스니핑에 의해 풀스캔 + 오버헤드가 발생하여 생긴 현상이었다.

아래부터 예시를 들어 설명하겠다.

바인드 변수(매개변수) 스니핑 이란

Oracle 옵티마이저는 첫 번째 바인드 변수 값을 기반으로 실행 계획을 세우고,
해당 계획을 캐싱해서 이후 동일한 SQL ID에 재사용한다.

SELECT *
FROM USERS
WHERE 
<choose>
 <when test='type == "A"'>
  USER_ID = #{value}
 </when>
 <when test='type == "B"'>
  USER_NAME = #{value}
 </when>

USER_ID에는 인덱스가 걸려있고, USER_NAME에는 인덱스가 안걸려있다고 가정함.

이렇게 같은 SQL에 있는 경우 ID는 하나로 유지되며 실행계획, 캐싱또한 공유함.

•첫 번째 실행에서 B 타입으로 실행하여 풀스캔
•이후 A타입으로 실행 -> 인덱스 타야되지만 바인드변수 스니핑 으로 인해 풀스캔
•결과적으로 불필요한 I/O가 폭증 → 성능 저하

왜 최근들어 심해졌는지

문제는 바인드 스니핑에도 있었지만, 쿼리가 점점 무거워진 것과 관련 있다.

여러개의 조인테이블 추가, 서브쿼리 추가되어 아래와 같은 문제가 발생하였다.

•인덱스를 쓰면 여전히 빠름 → 문제 없음
•하지만 바인드 스니핑 때문에 풀스캔 계획 고정 → 연결된 모든 테이블 다 조회함
•TEMP 공간 폭발, CPU 과다 사용, DB 전체 부하 발생 → 락 걸린 듯한 현상 체감

핵심 포인트

•예전엔 단일 테이블 풀스캔 → 부하가 크지 않았음
•쿼리가 복잡해질수록 풀스캔 1회가 가져오는 오버헤드가 기하급수적으로 증가
•바인드 스니핑 때문에 잘못된 계획이 고정되면 → DB 전체 성능 저하

해결방안 : 인덱스 힌트 적용
SELECT
 <when test='type == "A"'>
  /*+ INDEX(USERS A인덱스명)*/
 </when>
 <when test='type == "B"'>
  /*+ INDEX(USERS B인덱스명)*/
 </when>
 *
FROM USERS
WHERE 
<choose>
 <when test='type == "A"'>
  USER_ID = #{value}
 </when>
 <when test='type == "B"'>
  USER_NAME = #{value}
 </when>

•옵티마이저에게 인덱스를 강제 사용하게 유도
•단, 데이터 편중이 심한 경우 오히려 느려질 수도 있어 → 신중히 적용

여러가지 해결방안이 있지만 가장 간단하게 인덱스 힌트를 통해 해결하였다.

결론

바인드 변수 스니핑 문제는 예전부터 있었지만,
쿼리가 복잡해질수록 잘못된 실행 계획 하나가 시스템 전체를 마비시킬 수 있다.
WAS 환경에서 바인드 변수를 사용할 땐 항상 실행 계획 캐싱을 주의 깊게 살펴야 한다.




글로벌 서비스를 개발하다 보면, 사용자마다 접속 지역이 달라 시간이 서로 다르게 표시될 수 있습니다.

특히 다국어·다국가 관리자 시스템의 경우, 단순히 브라우저 로컬 시간대로 시간을 보여주는 것은 한계가 있습니다.

예를들어 시스템은 utc 기준이고 java의 Date타입을 그대로 클라이언트로 전송할 경우 클라이언트의 지역 시간대로 자동변환되어 사용자에게 혼란을 줄 수 있습니다.

혼란을 줄수 있는 상황 재현
1. Oracle → Java
•Oracle의 DATE 필드는 Java에서 java.util.Date 또는 LocalDateTime으로 받아 사용
•서버는 기본적으로 UTC 타임존에서 동작하도록 설정

2. Java → JavaScript
•서버는 클라이언트로 시간 정보를 timestamp (long) 형태로 전달 (date.getTime())
•예: 2025-05-29 12:00:00 UTC → 1748510400000

3. JavaScript 클라이언트 처리
const date = new Date(1748510400000);
console.log(date.toString());
•한국(UTC+9): Thu May 29 2025 21:00:00
•인도네시아(UTC+7): Thu May 29 2025 19:00:00

동일한 시간 정보라도, 클라이언트의 시간대 설정에 따라 자동으로 다르게 보입니다.
만약 한국시간으로 설정된 pc를 사용하여 한국사람이 인도네시아 서버를 관리하는 경우 시간이 자동으로 한국시간으로 노출되어 인도네시아에서 접속한 사람과의 혼란이 발생합니다.

문제가 되는 상황
예약 기능, 마감일 등지역에 따라 시각이 달라져 예약 시간 오류 발생
날짜 비교 로직타임존 차이로 하루 차이 등 오작동 발생
날짜 포맷 표시같은 데이터라도 사용자에 따라 시간 표시가 달라져 혼란 초래


이를 해결하기 위해 DB와 서버는 UTC 기준, 사용자 계정에 설정된 국가의 타임존으로 시간 변환, 클라이언트에서는 변환된 문자열을 그대로 표시하는 구조를 설명합니다.

1. DB는 UTC 기준으로 저장

•모든 DATE, TIMESTAMP는 UTC 기준으로 저장
•SYSTIMESTAMP AT TIME ZONE 'UTC' 등을 활용

2. Java 서버에서 시간대 변환

•각 사용자 계정에 맞는 타임존 정보 사용 (예: "Asia/Seoul", "Asia/Jakarta")
•시간 변환 후 String 형식으로 변환하여 반환

ZonedDateTime utcZoned = instant.atZone(ZoneId.of("UTC"));
ZonedDateTime localTime = utcZoned.withZoneSameInstant(ZoneId.of(userTimeZone));
String formatted = localTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

3. 클라이언트에서는 문자열 그대로 표시
<span>2025-05-29 21:00:00</span>

•프론트에서는 JS Date 객체로 다시 변환하지 않음
•시각적으로 오해 없이 “국가 기준 시간”으로 고정됨

주의할 점

•타임존 변경 시 서버에서 매번 ZonedDateTime 변환이 필요
•사용자별 타임존 설정이 누락되지 않도록 유효성 검증 필요
•서버에서 포맷된 문자열은 반드시 일관된 포맷으로 제공 (yyyy-MM-dd HH:mm:ss 등)


다국가, 다시간대 사용자를 지원하는 시스템에서는 단순한 시간 처리로는 문제를 피할 수 없습니다.
“UTC로 저장 → 사용자 기준 타임존으로 변환 → 문자열 반환” 전략을 적용하였지만 사실 다른 방법도 많습니다.

무엇보다 중요한 것은 ‘시간 처리 방식의 통일성’입니다.
여러 계층(클라이언트, 서버, DB)에서 중복되거나 서로 다른 방식으로 시간을 처리하게 되면, 유지보수가 어려워지고 오류 가능성도 커집니다.
하나의 일관된 기준을 정하고, 그에 따라 시간을 저장하고 변환하는 구조를 갖추는 것이 안정적인 시스템 운영의 핵심입니다.

JWT 정의

JWT(JSON Web Token)는 웹 표준에 따른 인증 토큰 방식으로, 주로 사용자 인증 및 정보 전달에 사용된다.

JWT는 서버가 클라이언트의 상태를 저장하지 않고도 인증 정보를 주고받을 수 있게 해주는 방법으로, 특히 RESTful API와 함께 많이 사용된다.

특징

•토큰 자체에 정보를 담고 있어, 서버에 세션을 저장할 필요 없음
•URL, HTTP 헤더 등에 넣기 적합한 짧은 문자열
•사용자 정보나 권한 등을 토큰 안에 포함할 수 있음
•비밀키를 통해 위변조 여부 검증 가능

주의사항

•중요한 정보는 Payload에 넣지말기 (Base64 인코딩일 뿐 암호화 아님)
•서명 검증은 항상 필수
•적절한 만료 시간 설정 필요
•HTTPS 환경에서 사용 권장

JWT 구조

JWT는 3개의 파트 Header, Payload, Signature로 구성되며 각 파트 별 역할은 아래와 같다.

구성 요소 설명 예시
Header 토큰의 타입의 서명에 사용된 해싱 알고리즘을 지정
일반적으로 HS256 또는 RS256 등을 사용함
{
  "alg": "HS256",
  "typ": "JWT"
}
Payload 실제 담고 싶은 정보를 포함하는 부분
{
  "sub": "user123",
  "name": "홍길동",
  "exp": 1718868300
}
Signature 토큰이 위변조되지 않았는지를 검증하기 위한 서명
Header와 Payload를 base64 인코딩 후 비밀 키를 이용해 서명
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

 
아래는 0.12.6 버전을 예시로 작성한 java 코드입니다.

의존성 추가 maven기준
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.12.6</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.12.6</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId> <!-- 또는 jjwt-gson 중 택1 -->
  <version>0.12.6</version>
  <scope>runtime</scope>
</dependency>

https://mvnrepository.com/ maven 관련 참고.

java 코드, 생성과 검증
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

public class JwtValidator {

    // 256비트(32바이트) 이상의 시크릿 키
    private static final String SECRET = "your-256-bit-secret-your-256-bit-secret";
    private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));

    public static void main(String[] args) {
        String jwt = generateToken("testUser");
        System.out.println("생성된 JWT: " + jwt);

        Claims claims = validateToken(jwt);
        if (claims != null) {
            System.out.println("검증 성공! Subject: " + claims.getSubject());
            System.out.println("만료 시간: " + claims.getExpiration());
        }
    }

    // JWT 생성
    public static String generateToken(String subject) {
        long now = System.currentTimeMillis();
        return Jwts.builder()
                .subject(subject)
                .issuedAt(new Date(now))
                .expiration(new Date(now + 1000 * 60 * 60 * 24)) // 하루 뒤 만료
                .signWith(KEY, Jwts.SIG.HS256)
                .compact();
    }

    // JWT 검증
    public static Claims validateToken(String token) {
        try {
            JwtParser parser = Jwts.parser()
                    .verifyWith(KEY)
                    .build();

            return parser.parseSignedClaims(token).getPayload();

        } catch (ExpiredJwtException e) {
            System.out.println("JWT 만료됨: " + e.getMessage());
        } catch (UnsupportedJwtException e) {
            System.out.println("지원되지 않는 JWT: " + e.getMessage());
        } catch (MalformedJwtException e) {
            System.out.println("잘못된 형식의 JWT: " + e.getMessage());
        } catch (SecurityException e) {
            System.out.println("서명 검증 실패: " + e.getMessage());
        } catch (Exception e) {
            System.out.println("JWT 검증 중 오류: " + e.getMessage());
        }
        return null;
    }
}


JWT 는 토큰이 발급자에 의해 만들어졌고, 중간에 변조되지 않았음을 보장한다, 하지만 JWT 만으로는 사용자 인증을 완전히 보장하지 않으므로 JWT를 발급받을때의 보안절차 + JWT 유효기간 관리를 하여야 한다.

Java에서 문자열을 이어붙이는 방법은 여러 가지가 있지만, 가장 흔히 쓰이는 방식은 다음 두 가지가 있다.
1. StringBuilder.append() 사용
2. 문자열 덧셈 연산자 + 사용

두 방식 모두 동일한 결과를 만들어내지만, 성능과 내부 처리 방식에서는 큰 차이가 있다. 차이점과 어떤 상황에서 어떤 방식이 더 효율적인지 정리해보려 한다.

문자열은 불변(Immutable)이다

Java의 String 클래스는 불변(immutable)이다.
즉, 한 번 생성된 문자열은 수정될 수 없고, 문자열을 더할 경우 항상 새로운 String 객체가 생성된다.

String str = "Hello";
str = str + " World"; // 새로운 String 객체가 생성됨

변수에 문자열을 덧붙이면 기존 문자열은 버려지고 새로운 객체가 만들어지기 때문에,
반복적으로 수행하면 성능 저하와 메모리 낭비가 발생됨

하지만, 컴파일 타임 상수일 경우는 예외
String str = "Hello" + " World"; // 컴파일 타임에 "Hello World"로 합쳐짐

컴파일러는 리터럴 상수끼리의 문자열 덧셈은 컴파일 시점에 미리 계산하여 단일 문자열 리터럴로 처리함.
이 경우에는 불필요한 객체 생성이 발생하지 않음

StringBuilder.append() 방식은 객체 새로 만들지 않음
StringBuilder sb = new StringBuilder();
sb.append("Hello, ");
sb.append("world!");
sb.append(" Have a nice day!");

내부적으로 가변 배열을 사용하여 객체를 새로 만들지 않고 문자열을 추가.

문자열 덧셈 vs StringBuilder.append() 비교표
코드 간결성✅ 매우 간단하고 직관적임❌ 다소 길고 복잡함
가독성✅ 좋음❌ 비교적 낮음
컴파일 타임 최적화✅ 상수끼리는 최적화됨❌ 해당 없음
성능 (반복문 내부)❌ 느림 (객체 계속 생성됨)✅ 빠름 (하나의 객체 사용)
메모리 효율❌ 낮음 (불필요한 객체 생성)✅ 높음 (재사용 가능)
불변성(Immutable)✅ String은 불변이라 안전함❌ StringBuilder는 가변 객체
멀티스레드 안전성✅ 불변이므로 안전함❌ thread-safe 아님 (필요시 StringBuffer)
주 사용 용도간단한 문자열 조합ok대량 문자열 처리, 반복문

JWT에 대해 사용해보게 되었는데 JWT에서 시그니처 부분을 암호화하기 위한 방식으로 HMAC 암호화를 사용하였습니다. 그래서 정의 및 예제코드를 작성하여 정리하였습니다.

HMAC이란
HMAC(Hash-based Message Authentication Code)은
메시지의 **무결성(Integrity)**과 **인증(Authentication)**을 보장하기 위한 암호화 방식

•무결성: 메시지가 중간에 변조되지 않았음을 확인
•인증: 메시지가 신뢰할 수 있는 주체로부터 왔음을 확인

동작원리
1. 비밀 키 (Secret Key)
2. 해시 알고리즘 (예: SHA-256)

이 두 가지를 조합하여 고정된 길이의 해시값(MAC)을 생성하여
수신자가 동일한 키로 다시 계산하여 데이터 변조 여부를 검증

검증 시 동일한 키를 사용하여야 되므로 대칭키 방식이며 해시 함수가 충돌을 방지하여 안정성이 확보됨.

코드예제
import javax.annotation.PostConstruct;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import lombok.Synchronized;
import org.apache.commons.codec.binary.Base64;

public class SecurityUtil {

    // 대칭 키: 실제 운영에서는 안전한 난수 값으로 대체해야 합니다.
    private static final String SYMMETRIC_KEY = "symmetericKey";

    private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
    private static Mac mac;

    /**
     * 클래스 초기화 시 HMAC 해시 객체를 한 번만 생성하고 초기화합니다.
     */
    @PostConstruct
    public void init() throws Exception {
        mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
        mac.init(new SecretKeySpec(SYMMETRIC_KEY.getBytes("utf-8"), HMAC_SHA256_ALGORITHM));
    }

    /**
     * 메시지를 HMAC-SHA256 방식으로 해싱하고 Base64로 인코딩합니다.
     * 동기화로 멀티스레드 환경에서도 안전하게 동작합니다.
     */
    @Synchronized
    public static String encodeHMacSha256(final String message) {
        byte[] hash = mac.doFinal(message.getBytes());
        return Base64.encodeBase64String(hash);
    }
}

•@PostConstruct는 Bean 생성 후 호출되어 Mac 객체를 한 번만 초기화
•Mac 객체는 스레드에 안전하지 않기 때문에 @Synchronized로 doFinal() 호출을 보호합니다.
•실제 운영 환경에서는 SYMMETRIC_KEY를 고정된 문자열이 아니라 안전하게 관리되는 키 저장소 또는 환경변수로부터 주입받는 것이 좋음

Syncronized 사용이유
Mac 객체는 내부 상태를 갖고 있어서 doFinal()이 호출되면 상태가 변경된다.
여러 스레드가 동시에 doFinal()을 호출할 경우 충돌이 발생하거나 예외가 발생할 수 있으므로, 반드시 동기화 처리가 필요.


참고사항
https://docs.oracle.com/javase/8/docs/api/javax/crypto/Mac.html

Mac (Java Platform SE 8 )

Finishes the MAC operation. A call to this method resets this Mac object to the state it was in when previously initialized via a call to init(Key) or init(Key, AlgorithmParameterSpec). That is, the object is reset and available to generate another MAC fro

docs.oracle.com

java 8버전 기준으로 doFinal 에 대한 설명을 보면 같은 키에 대해서는 여러번 재사용이 가능하다고 명시되어 있어 재사용 하였고, doFinal 사용 시 내부 상태가 변경되기 때문에 동기화 처리를 추가하였음.

웹 애플리케이션을 개발하다 보면, 프로젝트 내에 있는 이미지 파일을 불러와 <input type="file"> 요소에 직접 넣고, 이를 서버에 전송하고 싶은 상황이 생깁니다.

예를 들어, 기본 이미지가 정해져 있고 사용자가 수정하지 않는 경우, 해당 이미지를 별도의 선택 없이 자동으로 서버에 전달하는 방식으로 활용할 수 있습니다.

전체흐름 요약
1. 프로젝트 내부 이미지 경로를 받아 Base64 데이터로 변환
2. Base64 데이터를 File 객체로 변환
3. File 객체를 input[type=“file”] 요소에 삽입
4. 폼 전송

1. 이미지 경로를 base64 데이터로 변환
imgToBase64: function(file, maxWidth, maxHeight) {
  let src = (typeof file === 'string') ? file : URL.createObjectURL(file);

  return new Promise((resolve, reject) => {
    let image = new Image();

    image.onload = function () {
      let canvas = document.createElement('canvas');
      canvas.width = image.naturalWidth > maxWidth ? maxWidth : image.naturalWidth;
      canvas.height = image.naturalWidth > maxHeight ? maxHeight : image.naturalHeight;

      canvas.getContext('2d').drawImage(image, 0, 0, image.width, image.height);
      let uri = canvas.toDataURL('image/png'); // base64 데이터 생성
      resolve(uri);
    };

    image.src = src;
  });
}

2. Base64 데이터를 File 객체로 변환
dataURLtoFile: function(dataurl, filename) {
  let arr = dataurl.split(','),
      mime = arr[0].match(/:(.*?);/)[1],
      bstr = atob(arr[1]),
      n = bstr.length,
      u8arr = new Uint8Array(n);

  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }

  return new File([u8arr], filename, { type: mime });
}

3. File 객체를 input[type=“file”] 요소에 삽입
function setImage(imgInfo) {
  let fullPath = imgInfo.defaultImg;
  let fileName = fullPath.split('\\').pop().split('/').pop();

  WebUtil.imgToBase64(fullPath, imgInfo.widthSize, imgInfo.heightSize).then(resultData => {
    let file = WebUtil.dataURLtoFile(resultData, fileName);
    let container = new DataTransfer(); // FileList 대체용
    container.items.add(file);
    
    // 파일 input에 주입
    $body.find('#fileSearchFormId')[0].files = container.files;
    $body.find('#fileSearchFormId').eq(0).trigger('change'); // 업로드 트리거
  });
}


위 방법대로 하면 사용자 인터랙션 없이도 html file 입력창에 기본 이미지파일을 등록가능하며 그 입력폼을 전송하여 서버에 파일을 전송가능!

+ Recent posts