금요일

PostgreSQL "FATAL: password authentication failed" 에러 완전 해결 가이드

🔍 검색 키워드: postgresql fatal password authentication failed, psql role does not exist, postgresql connection refused, pg_hba.conf, postgresql 비밀번호 에러

상황

PostgreSQL에 접속하려는데 이런 에러가 뜬다.

FATAL: password authentication failed for user "myapp"

또는

FATAL: role "myapp" does not exist

또는

psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed:
FATAL: Peer authentication failed for user "postgres"

전부 다르게 생겼지만 원인은 비슷한 경우가 많다. 하나씩 짚어보자.


원인 1: 비밀번호가 틀렸다 (가장 흔한 경우)

앱 설정 파일에 넣은 비밀번호와 실제 DB 비밀번호가 다른 거다. .env, application.yml, database.yml 등에서 비밀번호 오타나 환경별 혼용이 잦다.

확인 방법:

# postgres 유저로 직접 접속해서 비밀번호 재설정
sudo -u postgres psql

-- 현재 유저 목록 확인
\du

-- 비밀번호 재설정
ALTER USER myapp WITH PASSWORD 'newpassword';

원인 2: 유저 자체가 없다 (role does not exist)

DB는 있는데 유저를 만든 적이 없거나, 다른 환경에서 만든 유저가 이 환경에는 없는 경우다. 로컬에서 개발하다가 스테이징 DB로 붙으려 할 때 자주 발생한다.

해결:

-- postgres 슈퍼유저로 접속 후
CREATE USER myapp WITH PASSWORD 'yourpassword';

-- 데이터베이스 권한 부여
GRANT ALL PRIVILEGES ON DATABASE mydb TO myapp;

-- 스키마 권한도 줘야 하는 경우
GRANT ALL ON SCHEMA public TO myapp;

원인 3: pg_hba.conf 인증 방식 문제 (Peer auth failed)

Peer authentication failed 에러는 pg_hba.conf의 인증 방식 설정 문제다. 로컬 소켓 접속 시 OS 유저명과 DB 유저명이 같아야 하는 peer 방식으로 설정돼 있을 때 발생한다.

pg_hba.conf 위치 확인:

sudo -u postgres psql -c "SHOW hba_file;"
# 보통 /etc/postgresql/14/main/pg_hba.conf 또는 /var/lib/pgsql/data/pg_hba.conf

pg_hba.conf 수정:

sudo nano /etc/postgresql/14/main/pg_hba.conf

수정 전:

local   all   all   peer

수정 후 (비밀번호 인증으로 변경):

local   all   all   md5

또는 특정 유저만:

local   mydb   myapp   md5
host    mydb   myapp   127.0.0.1/32   md5

변경 후 재시작:

sudo systemctl restart postgresql

원인 4: Docker 환경에서 환경변수 미전달

Docker로 PostgreSQL 띄울 때 POSTGRES_PASSWORD 없이 컨테이너를 올리거나, 앱 컨테이너에 DB 접속 정보를 제대로 안 넘긴 경우다.

# docker-compose.yml 올바른 예시
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: mypassword
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    environment:
      DATABASE_URL: postgresql://myapp:mypassword@db:5432/mydb
    depends_on:
      db:
        condition: service_healthy
💡 depends_on은 컨테이너 시작 순서만 보장한다. PostgreSQL이 실제로 준비됐는지는 healthcheckcondition: service_healthy로 처리해야 한다.

원인 5: 접속 호스트/포트 오류

Connection refused는 PostgreSQL이 해당 주소에서 리슨하고 있지 않다는 뜻이다.

# PostgreSQL이 실제로 떠 있는지
sudo systemctl status postgresql

# 어느 포트에서 리슨 중인지
sudo ss -tlnp | grep 5432

# 외부 접속 허용 설정 확인
sudo grep listen_addresses /etc/postgresql/14/main/postgresql.conf

외부에서 접속하려면 postgresql.conf에서:

listen_addresses = '*'

그리고 pg_hba.conf에도 원격 접속 허용 라인 추가:

host    all   all   0.0.0.0/0   md5

상황별 체크리스트

증상확인할 것해결책
password authentication failed비밀번호 오타, 환경 혼용ALTER USER ... WITH PASSWORD
role does not exist유저 미생성CREATE USER + 권한 부여
Peer authentication failedpg_hba.conf 설정peer → md5 변경 후 재시작
Connection refusedPostgreSQL 미실행 또는 포트 불일치서비스 상태 확인, listen_addresses 설정
Docker에서만 발생컨테이너 간 네트워크, 환경변수db 호스트명 사용, healthcheck 추가

Node.js 접속 예시 (pg 라이브러리)

const { Pool } = require('pg');

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
  connectionTimeoutMillis: 5000,
  idleTimeoutMillis: 30000,
  max: 10,
});

pool.query('SELECT NOW()', (err, res) => {
  if (err) {
    console.error('DB 연결 실패:', err.message);
  } else {
    console.log('DB 연결 성공:', res.rows[0].now);
  }
});

Python (psycopg2, SQLAlchemy)

import psycopg2
from psycopg2 import OperationalError
import os

def create_connection():
    try:
        conn = psycopg2.connect(
            host=os.getenv("DB_HOST", "localhost"),
            port=int(os.getenv("DB_PORT", 5432)),
            database=os.getenv("DB_NAME"),
            user=os.getenv("DB_USER"),
            password=os.getenv("DB_PASSWORD"),
        )
        print("PostgreSQL 연결 성공")
        return conn
    except OperationalError as e:
        print(f"연결 실패: {e}")
        raise

from sqlalchemy import create_engine
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_recycle=3600)

마무리

PostgreSQL 접속 에러는 대부분 세 가지다: 비밀번호 틀림, 유저 없음, pg_hba.conf 설정 문제. 에러 메시지를 정확히 읽으면 원인이 나온다. FATAL: 뒤에 오는 텍스트가 전부다. Connection refused는 PostgreSQL 자체가 안 떠있거나 포트가 다른 거고, authentication failed는 자격증명 문제, role does not exist는 유저 생성을 안 한 거다.

Docker 환경이면 컨테이너 간 네트워크와 healthcheck까지 챙겨야 한다.

목요일

Python 메모리 누수 디버깅 완전 가이드

Spring Boot UnexpectedRollbackException 완전 해결 가이드

🔍 검색 키워드: Spring Boot UnexpectedRollbackException, 트랜잭션 롤백 에러, Spring @Transactional 에러, rollback-only 에러, Spring 트랜잭션 전파

이 에러, 왜 뜨는 건가

운영 중에 갑자기 이런 에러를 마주하는 경우가 있다.

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

겉으로 보면 "조용히 롤백됐다"는 건데, 왜 롤백됐는지 이유가 안 보인다. 내 코드엔 예외처리도 했고, try-catch도 했는데 왜?

원인: Spring 트랜잭션 전파(Propagation) 구조 이해

핵심 개념

Spring의 기본 트랜잭션 전파 방식은 REQUIRED다. 즉, 이미 트랜잭션이 있으면 그 트랜잭션에 참여(join)한다.

문제는 여기서 발생한다.

[외부 트랜잭션 시작]
  └── 내부 서비스 호출 (REQUIRED → 외부 트랜잭션에 참여)
        └── 내부에서 예외 발생 → 트랜잭션에 rollback-only 마킹
  내부 예외를 try-catch로 잡음
  외부 트랜잭션 커밋 시도
      → BOOM: UnexpectedRollbackException

내부 메서드에서 예외가 발생해서 rollback-only로 마킹됐는데, 외부에서 예외를 잡아버리면 Spring은 "아 괜찮은 거구나"하고 커밋을 시도한다. 하지만 이미 롤백 마킹이 됐기 때문에 UnexpectedRollbackException이 터진다.

레벨별 해결 방법

Level 1 — 기초 (원인 파악부터)

상황 재현 코드:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final PaymentService paymentService;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 주문 저장 로직...

        try {
            paymentService.processPayment(request.getPaymentInfo()); // 내부 트랜잭션 참여
        } catch (Exception e) {
            log.error("결제 실패: {}", e.getMessage()); // 예외 잡음
            // → 이 시점에 트랜잭션은 이미 rollback-only
        }

        // 커밋 시도 → UnexpectedRollbackException 발생!
    }
}

@Service
public class PaymentService {
    @Transactional // REQUIRED (기본값) → 외부 트랜잭션에 참여
    public void processPayment(PaymentInfo info) {
        // 내부 예외 발생
        throw new PaymentException("카드 한도 초과");
    }
}

트랜잭션 상태 디버깅:

@Transactional
public void placeOrder(OrderRequest request) {
    log.info("트랜잭션 활성: {}", TransactionSynchronizationManager.isActualTransactionActive());
    log.info("롤백 마킹: {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());
}

Level 2 — 실무 해결책 (전파 방식 변경)

방법 1: REQUIRES_NEW로 별도 트랜잭션 분리

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    // ↑ 기존 트랜잭션과 완전히 분리된 새 트랜잭션 시작
    public void processPayment(PaymentInfo info) {
        // 이 트랜잭션이 롤백돼도 외부 트랜잭션에 영향 없음
        paymentRepository.save(/* ... */);
    }
}
⚠️ 주의: REQUIRES_NEW는 별도 DB 커넥션을 사용한다. 커넥션 풀 고갈 위험이 있으니 남발하면 안 된다.

방법 2: noRollbackFor 설정

@Transactional(noRollbackFor = PaymentException.class)
public void processPayment(PaymentInfo info) {
    // PaymentException이 발생해도 롤백하지 않음
}

방법 3: 예외를 잡지 말고 던지기

@Transactional
public void placeOrder(OrderRequest request) {
    try {
        paymentService.processPayment(request.getPaymentInfo());
    } catch (PaymentException e) {
        // 잡지 말고 그냥 던진다
        throw e; // 혹은 새 예외로 래핑
        // → 외부 호출자가 트랜잭션 롤백 처리
    }
}

Level 3 — 고급 (아키텍처 관점에서 설계)

이벤트 기반 분리 (트랜잭션 완료 후 처리)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public void placeOrder(OrderRequest request) {
        Order order = orderRepository.save(Order.of(request));

        // 트랜잭션 커밋 후 이벤트 발행
        eventPublisher.publishEvent(new OrderPlacedEvent(order.getId()));
    }
}

@Component
public class PaymentEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    // ↑ 외부 트랜잭션 커밋 후에 실행 → 트랜잭션 오염 없음
    public void handleOrderPlaced(OrderPlacedEvent event) {
        paymentService.processPayment(event.getOrderId());
    }
}

Facade 패턴으로 트랜잭션 경계 명확히

@Service
public class OrderFacade {
    // @Transactional 없음 → 트랜잭션 경계 없음

    public void placeOrder(OrderRequest request) {
        orderService.saveOrder(request);        // 각각 독립 트랜잭션
        paymentService.processPayment(request); // 각각 독립 트랜잭션
    }
}

@Service
public class OrderService {
    @Transactional // 이 메서드 범위만 트랜잭션
    public void saveOrder(OrderRequest request) { /* ... */ }
}

상황별 해결 체크리스트

상황권장 방법주의사항
내부 예외가 외부와 독립적으로 처리돼야 할 때REQUIRES_NEW커넥션 풀 사용량 증가
특정 예외는 롤백 안 해도 될 때noRollbackFor데이터 정합성 검토 필요
결제 같은 외부 I/O가 있을 때@TransactionalEventListenerAFTER_COMMIT 타이밍 주의
서비스 레이어 설계를 바꿀 수 있을 때Facade 패턴트랜잭션 경계 재설계 필요
빠르게 임시 수정이 필요할 때예외 재던지기호출부에서 처리 필요

자주 하는 실수

실수 1: try-catch에서 예외를 먹어버리기

// ❌ 잘못된 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    // 예외를 삼켜버림 → UnexpectedRollbackException 확정
}

// ✅ 올바른 코드
try {
    innerService.doSomething();
} catch (Exception e) {
    log.error("에러 발생", e);
    throw new BusinessException("처리 실패", e); // 반드시 다시 던지기
}

실수 2: Self-invocation (같은 클래스 내 메서드 호출)

@Service
public class MyService {
    @Transactional
    public void outer() {
        inner(); // 프록시를 거치지 않음 → @Transactional 무시됨
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
    // → REQUIRES_NEW가 적용되지 않아서 여전히 같은 트랜잭션 사용
}

Self-invocation í•´ê²°:

@Service
@RequiredArgsConstructor
public class MyService {
    private final ApplicationContext context;

    public void outer() {
        MyService proxy = context.getBean(MyService.class); // 프록시 직접 가져오기
        proxy.inner();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void inner() { /* ... */ }
}

정리

UnexpectedRollbackException은 트랜잭션 전파를 모르면 반드시 한 번은 만나는 에러다. 핵심은 하나다.

💡 내부 트랜잭션이 롤백 마킹되면, 같은 트랜잭션에 참여한 외부 트랜잭션도 롤백될 수밖에 없다.

해결은 ‫글 두 방향이다.

  • 트랜잭션을 분리한다 (REQUIRES_NEW, Facade 패턴, 이벤트 기반)
  • 예외를 제대로 다룬다 (먹지 말고 던지기, noRollbackFor)

실무에서는 REQUIRES_NEW를 무분별하게 쓰기보다 @TransactionalEventListener나 Facade 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.