레이블이 트러블슈팅인 게시물을 표시합니다. 모든 게시물 표시
레이블이 트러블슈팅인 게시물을 표시합니다. 모든 게시물 표시

월요일

PostgreSQL deadlock detected 해결 — 두 트랜잭션이 서로 락을 기다리는 교착상태 원인 해결

PostgreSQL deadlock detected 에러 해결 — 교착상태 원인과 방지 전략

핵심 답변: PostgreSQL deadlock detected는 두 트랜잭션이 서로 상대방이 보유한 락을 기다리는 교착상태에서 발생한다. PostgreSQL은 이를 자동으로 감지해 하나를 강제 롤백한다. 방지법은 ① 일관된 락 순서 유지, ② SELECT FOR UPDATE 사용, ③ 배치 단위 COMMIT, ④ 외래키 인덱스 추가, ⑤ 재시도 로직 구현이다.

운영 중인 서비스 로그에 갑자기 이런 에러가 쏟아진다.

ERROR: deadlock detected

DETAIL: Process 12345 waits for ShareLock on transaction 678;

blocked by process 67890.

Process 67890 waits for ShareLock on transaction 456;

blocked by process 12345.

HINT: See server log for query details.

교착상태(Deadlock)는 두 트랜잭션이 서로 상대방이 보유한 락을 기다리는 상황이다. 양쪽 모두 진행할 수 없는 상태가 되면 PostgreSQL이 감지해 하나를 강제 롤백하고 ERROR 40P01을 반환한다.
트랜잭션 A: 행 1 잠금 → 행 2 잠금 시도 (대기)

트랜잭션 B: 행 2 잠금 → 행 1 잠금 시도 (대기)

→ 영원히 풀리지 않음 → PostgreSQL이 하나를 강제 종료


원인 1: UPDATE 순서가 트랜잭션마다 다른 경우

가장 흔한 원인이다. 같은 테이블의 여러 행을 업데이트할 때 트랜잭션마다 접근 순서가 다르면 교착상태가 생긴다.

-- 트랜잭션 A (id=1 → id=2 순서)

BEGIN;

UPDATE orders SET status = 'processing' WHERE id = 1;

UPDATE orders SET status = 'processing' WHERE id = 2;

COMMIT;

-- 트랜잭션 B (id=2 → id=1 역순 — 충돌!)

BEGIN;

UPDATE orders SET status = 'cancelled' WHERE id = 2;

UPDATE orders SET status = 'cancelled' WHERE id = 1; -- deadlock

COMMIT;

해결: 모든 트랜잭션에서 동일한 순서(id 오름차순)로 접근
-- SQL에서 ORDER BY로 순서 보장

UPDATE orders SET status = 'processing'

WHERE id IN (1, 2)

ORDER BY id;

# 애플리케이션에서 정렬 후 처리

def update_orders(session, order_ids, status):

for order_id in sorted(order_ids): # 항상 오름차순

session.query(Order).filter(Order.id == order_id).update({"status": status})

session.commit()


원인 2: SELECT FOR UPDATE 없이 나중에 UPDATE

조회 후 수정하는 패턴에서 자주 발생한다. 조회 시 락을 잡지 않으면 다른 트랜잭션이 끼어든다.

-- ❌ 락 없이 조회 후 수정 — 위험

BEGIN;

SELECT balance FROM accounts WHERE id = 100;

-- 이 사이에 다른 트랜잭션이 같은 행을 수정 가능

UPDATE accounts SET balance = balance - 1000 WHERE id = 100;

COMMIT;

-- ✅ SELECT FOR UPDATE로 조회 시 즉시 락 획득

BEGIN;

SELECT balance FROM accounts WHERE id = 100 FOR UPDATE;

UPDATE accounts SET balance = balance - 1000 WHERE id = 100;

COMMIT;

// Spring Data JPA — 비관적 락 사용

@Repository

public interface AccountRepository extends JpaRepository<Account, Long> {

@Lock(LockModeType.PESSIMISTIC_WRITE)

@Query("SELECT a FROM Account a WHERE a.id = :id")

Optional<Account> findByIdForUpdate(@Param("id") Long id);

}

@Transactional

public void transfer(Long fromId, Long toId, BigDecimal amount) {

// 작은 id부터 락 획득 — 순서 일관성 보장

Long firstId = Math.min(fromId, toId);

Long secondId = Math.max(fromId, toId);

Account first = accountRepository.findByIdForUpdate(firstId).orElseThrow();

Account second = accountRepository.findByIdForUpdate(secondId).orElseThrow();

}


원인 3: 인덱스 없이 대량 UPDATE/DELETE

인덱스 없는 컬럼으로 넓은 범위를 업데이트하면 테이블 전체에 락이 걸린다.

-- ❌ 인덱스 없는 컬럼으로 대량 업데이트 — 테이블 전체 잠금

UPDATE orders SET processed = true WHERE created_at < '2026-01-01';

-- ✅ 배치로 나눠서 처리

DO $$

DECLARE

batch_size INT := 1000;

last_id BIGINT := 0;

BEGIN

LOOP

UPDATE orders SET processed = true

WHERE id IN (

SELECT id FROM orders

WHERE created_at < '2026-01-01'

AND processed = false

AND id > last_id

ORDER BY id

LIMIT batch_size

)

RETURNING max(id) INTO last_id;

EXIT WHEN NOT FOUND OR last_id IS NULL;

COMMIT;

PERFORM pg_sleep(0.01); -- 다른 트랜잭션에 기회 부여

END LOOP;

END $$;


원인 4: 외래키 인덱스 누락

PostgreSQL은 외래키 참조 시 부모 테이블에 ShareLock을 건다. 외래키 컬럼에 인덱스가 없으면 잠금 범위가 커진다.

-- 외래키 컬럼 인덱스 누락 여부 확인

SELECT

tc.table_name,

kcu.column_name,

(SELECT 1 FROM pg_indexes

WHERE tablename = tc.table_name

AND indexdef LIKE '%' || kcu.column_name || '%') AS has_index

FROM information_schema.table_constraints tc

JOIN information_schema.key_column_usage kcu

��N��B��6�FR7G��S�&&6�w&�V�C�6ccc�FF��s�'�g��&�&FW"�&F�W3�7��f��B�f֖Ǔ�����76S�f��B�6��S��V�#�4UB��6��F��V�WB�sW2s��6�FS������ȹΫN��B� ��Y��Zȉ��莸�B���ࠣ�7G&��s��FVF��6���B� ��9��Y���B����N�K�i�8��B�9ޫ��)�ɩC���7G&��s����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#���XN�����B��7Fw&U5���FVF��6�� �xȹ��Y��)��قث��������Y���BɘN�N���N� �Yθ�B�����N�K��B��ΫH�K�莸�B�8�9κ[����x�Yθ�B��N� � ����x^�x����ȹθ�N�Y���B� θ�B���ࠣƇ"7G��S�&&�&FW#����S�&�&FW"�F���6�ƖB6SSS��&v��3'�#ࠣƃ"7G��S�&f��B�6��S��VVӶ�&v��3'�'��FF��r�&�GF�ӣg��&�&FW"�&�GF�ӣ'�6�ƖB6SSS�6���#�3##"#�� ^�j����#ࠣ�7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#��7Fw&U5�FVF��6�� ��xnɹ˙����ࠣ�7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#���zι���h�ȉ�� Rȹ��7G&��s��Z��8�BɊN�hN� �ȉ���7G&��s�� �{�����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�"��٨�ٸBȉ�� ^���7G&��s�4T�T5Bd�"UDDS��7G&��s��ȹ�������7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�2�������x^����Nث���B�7G&��s� ˙������C��7G&��s���)���4��ԕC����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�B�ɛ����*B˺ι���y� ��9�ȹ��7G&��s��ێ��ȪC��7G&��s��9��K����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�R�ث��������Y������^�Y��7G&��s��z~�(���7G&��s����x����7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#�b����ȹθ�B���x���7G&��s�W���V�F��&6��fc��7G&��s���Z�وC��ࠣ�7G��S�&�&v��'��Ɩ�RֆV�v�C��s�6���#�3332#���Bn��x�[��x�*N��B�Y� ��8�9�� ��9��ق�^�[��x���Bȉ��莸�B���ࠣƇ"7G��S�&&�&FW#����S�&�&FW"�F���6�ƖB6SSS��&v��3'�#ࠣ�F�b7G��S�&&6�w&�V�C�6S�cFfC�&�&FW"��VgC�G�6�ƖB3s6S��&�&FW"�&F�W3�G��FF��s�G�����&v��#��f��B�6��S��VVӶ6���#�3&S�Ɩ�RֆV�v�C��r#�H� ������&Vc�&�GG3���GV�GV�6�R�&��w7�B�6��"7G��S�&6���#�3s6S�#䆖�&�56���V7F������W��W7FVB�y����[N�+�����F�c���F�c

일요일

BeanCurrentlyInCreationException 해결 — Spring Boot 순환 의존성 에러 완전 정복

🔍 검색 키워드: BeanCurrentlyInCreationException 해결, Spring Boot 순환 의존성, circular dependency 에러, Spring Boot 빈 생성 실패, allow-circular-references, Spring Boot 시작 실패

Spring Boot 프로젝트를 띄우다가 서버가 뜨지도 않고 이런 에러를 만난 적 있을 것이다.

The dependencies of some of the beans in the application context form a cycle:
userService -> orderService -> userService

혹은 더 직접적으로:

org.springframework.beans.factory.BeanCurrentlyInCreationException:
Error creating bean with name 'userService':
Requested bean is currently in creation: Is there an unresolvable circular reference?

서버 자체가 뜨지 않으니 당황스럽다. 이 글에서는 원인부터 실무에서 자주 쓰는 해결책까지 정리한다.

순환 의존성이란 뭔가

ServiceA가 ServiceB를 주입받고, ServiceB가 다시 ServiceA를 주입받는 상황이다. Spring이 A를 만들려면 B가 필요하고, B를 만들려면 A가 필요하다. 닭이 먼저냐 달걀이 먼저냐 — Spring은 이 상황에서 예외를 던진다.

@Service
public class UserService {
    private final OrderService orderService;

    public UserService(OrderService orderService) { // OrderService 주입
        this.orderService = orderService;
    }
}

@Service
public class OrderService {
    private final UserService userService;

    public OrderService(UserService userService) { // UserService 주입
        this.userService = userService;
    }
}

위처럼 생성자 주입(Constructor Injection)으로 서로를 참조하면 Spring Boot 2.6 이후부터는 기본적으로 예외가 발생한다.

왜 Spring Boot 2.6부터 더 자주 보이나

Spring Boot 2.6에서 기본 순환 의존성 감지가 강화됐다. 이전 버전에서는 필드 주입(@Autowired)의 경우 특별한 설정 없이도 넘어가는 경우가 있었는데, 2.6부터는 훨씬 엄격하게 잡아낸다. 기존에 잘 돌아가던 프로젝트를 Spring Boot 버전 올리다가 갑자기 이 에러를 만나는 이유다.

에러 발생 상황 체크리스트

상황가능성
Service 간 서로 참조★★★★★
Service → Repository → Service 체인★★★★☆
@Configuration 클래스 간 참조★★★☆☆
Spring Boot 버전 업그레이드 직후★★★★☆
이벤트 리스너와 서비스 간 참조★★★☆☆

해결 방법 1 — @Lazy 어노테이션 (빠른 임시 해결)

둘 중 하나의 의존성에 @Lazy를 붙인다. Spring이 실제로 해당 빈이 필요한 시점까지 초기화를 미룬다.

@Service
public class UserService {
    private final OrderService orderService;

    public UserService(@Lazy OrderService orderService) { // @Lazy 추가
        this.orderService = orderService;
    }
}

당장 급하게 해결해야 할 때 쓰는 방법이다. 하지만 근본적인 구조 문제를 가리는 것이라 장기적으로는 좋지 않다.

해결 방법 2 — 세터/필드 주입으로 전환

생성자 주입 대신 세터 주입(Setter Injection)으로 바꾸면 Spring이 빈을 먼저 만든 후 의존성을 주입하기 때문에 순환 참조 문제가 해소된다.

@Service
public class OrderService {
    private UserService userService;

    @Autowired
    public void setUserService(UserService userService) {
        this.userService = userService;
    }
}

해결 방법 3 — 구조 리팩토링 (가장 올바른 해결)

순환 의존성이 생겼다는 건 클래스의 책임이 잘못 분리됐다는 신호다. 보통 아래 패턴 중 하나로 해결된다.

공통 로직을 별도 서비스로 분리:

// UserService와 OrderService 모두 참조하던 공통 로직
@Service
public class UserOrderBridgeService {
    private final UserRepository userRepository;
    private final OrderRepository orderRepository;

    public void processUserOrder(Long userId, Long orderId) {
        // 공통 로직 처리
    }
}

@Service
public class UserService {
    private final UserRepository userRepository;
    private final UserOrderBridgeService bridgeService; // 공통 서비스만 참조
}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final UserOrderBridgeService bridgeService;
}

이벤트 기반으로 분리:

@Service
public class UserService {
    private final ApplicationEventPublisher eventPublisher;

    public void deleteUser(Long userId) {
        eventPublisher.publishEvent(new UserDeletedEvent(userId));
    }
}

@Component
public class OrderEventListener {
    private final OrderService orderService;

    @EventListener
    public void handleUserDeleted(UserDeletedEvent event) {
        orderService.cancelOrdersByUserId(event.getUserId());
    }
}

해결 방법 4 — application.properties 설정 (비추천)

spring.main.allow-circular-references=true

이 설정은 에러만 안 보이게 하는 것이다. 실제로는 Spring Boot가 내부적으로 의존성 순서를 휴리스틱하게 결정하게 되어, 예측하기 어려운 초기화 순서 문제가 생길 수 있다. 근본 원인을 분석할 시간이 없는 긴급 상황에서 임시로만 쓰자.

정리

  • BeanCurrentlyInCreationException은 두 빈이 서로 의존하는 구조에서 발생한다
  • Spring Boot 2.6+에서 기본적으로 더 엄격하게 감지한다
  • 빠른 해결은 @Lazy, 올바른 해결은 공통 로직 분리 또는 이벤트 기반 구조 변경
  • allow-circular-references=true는 임시방편일 뿐, 운영 환경에서는 근본 해결 필요

순환 의존성 에러는 코드 냄새(Code Smell)다. 에러를 꺼주는 것보다 왜 두 서비스가 서로를 참조해야 하는지를 먼저 물어보는 게 맞다.

금요일

Nginx 502 Bad Gateway 해결 — upstream 에러 원인별 완전 정리

🔍 검색 키워드: nginx 502 bad gateway 해결, nginx upstream 에러, nginx 리버스 프록시 502, nginx proxy_read_timeout, upstream sent too big header

Nginx62�는 리버스 프록시로 쓰다 보면 피할 수 없는 에러가 502다.

502 Bad Gateway
nginx/1.x.x

사용자한테 이게 보이는 순간 장애다. 빠르게 원인 찾고 고쳐야 하는데, 502는 원인이 한두 가지가 아니다. 이 글에서 발생 빈도 높은 원인과 해결책을 순서대로 정리한다.


1. 502 Bad Gateway는 뭔가

Nginx가 업스트림 서버(백엔드 앱, WAS, API 서버)에 요청을 넘겼는데 제대로 된 응답을 못 받았을 때 클라이언트에게 돌려주는 응답이다.

즉, Nginx 자체의 문제가 아니라 Nginx 뒤에 있는 서버의 문제다.


2. 원인별 분류 및 해결

원인 1: 업스트림 서버가 꺼져 있다 (가장 흔함)

에러 로그:

2026/06/26 10:00:00 [error] 12345#0: *1 connect() failed (111: Connection refused)
while connecting to upstream, upstream: "http://127.0.0.1:8080/api/health"

Connection refused가 보이면 백엔드 앱이 죽은 거다.

# 프로세스 확인
ps aux | grep java
ps aux | grep node
ps aux | grep gunicorn

# 포트 리스닝 확인
ss -tlnp | grep 8080

프로세스가 없으면 앱을 재시작한다. 재시작 후에도 바로 죽는다면 앱 자체의 에러 로그를 봐야 한다.

원인 2: 타임아웃

에러 로그:

upstream timed out (110: Operation timed out) while reading response header from upstream

백엔드가 살아있긴 한데 응답이 너무 느린 경우다. Nginx의 기본 타임아웃값(60초)을 넘기면 이 에러가 발생한다.

# nginx.conf 또는 서버 블록
location / {
    proxy_pass http://localhost:8080;

    proxy_connect_timeout 60s;   # 연결 타임아웃
    proxy_send_timeout    60s;   # 요청 전송 타임아웃
    proxy_read_timeout    120s;  # 응답 수신 타임아웃 (이걸 늘리면 됨)
}

배치 작업이나 파일 업로드처럼 처리 시간이 긴 요청은 proxy_read_timeout을 요청 성격에 맞게 올린다. 무작정 크게 올리는 건 좋지 않고, 실제 처리 시간 + 여유분 정도로 설정한다.

원인 3: 업스트림 헤더가 너무 크다

에러 로그:

upstream sent too big header while reading response header from upstream

백엔드 응답의 HTTP 헤더가 Nginx 버퍼 크기를 초과한 경우다. 쿠키가 많거나 커스텀 헤더를 대량으로 쓰는 앱에서 발생한다.

location / {
    proxy_pass http://localhost:8080;

    proxy_buffer_size          128k;
    proxy_buffers              4 256k;
    proxy_busy_buffers_size    256k;
}

헤더 크기를 줄이는 게 근본 해결이지만, 당장 급하면 버퍼를 올린다.

원인 4: Unix 소켓 권한 문제

PHP-FPM이나 Gunicorn을 소켓으로 연결할 때 발생한다.

connect() to unix:/var/run/php-fpm.sock failed (13: Permission denied)
# 소켓 파일 권한 확인
ls -la /var/run/php-fpm.sock

# 소켓 파일 소유권 변경
chown www-data:www-data /var/run/php-fpm.sock
chmod 660 /var/run/php-fpm.sock
; /etc/php-fpm.d/www.conf
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

3. 빠른 진단 순서

# 1. Nginx 에러 로그 확인 (제일 먼저)
tail -f /var/log/nginx/error.log

# 2. 업스트림 서버 상태 확인
curl -I http://127.0.0.1:8080/

# 3. 포트 리스닝 확인
ss -tlnp | grep 8080

# 4. Nginx 설정 문법 검사
nginx -t

# 5. Nginx 재시작 (설정 변경 후)
systemctl reload nginx

에러 로그에 Connection refused, timed out, Permission denied, too big header 중 뭐가 찍혀 있는지 먼저 보는 게 핵심이다.


4. 원인별 체크리스트

에러 로그 키워드 원인 해결 방법
Connection refused 업스트림 프로세스 다운 앱 재시작
timed out 응답 지연 proxy_read_timeout 증가
too big header 헤더 버퍼 부족 proxy_buffer_size 증가
Permission denied 소켓 권한 문제 소켓 파일 권한 수정
no live upstreams 업스트림 그룹 전체 다운 업스트림 서버 상태 확인

5. 실무 팁 — health check 설정

업스트림이 다운됐을 때 Nginx가 알아서 fallback하도록 설정할 수 있다.

upstream backend {
    server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:8081 backup;  # 메인이 죽으면 이걸로
}

max_fails는 해당 서버를 비활성화하기 전 실패 허용 횟수, fail_timeout은 비활성화 유지 시간이다.


정리

Nginx 502는 반드시 /var/log/nginx/error.log를 먼저 봐야 한다. 에러 메시지가 원인을 정확히 알려준다. Connection refused면 앱 프로세스 확인, timed out이면 타임아웃 설정, too big header면 버퍼 설정 — 이 세 가지가 전체 502의 대부분을 차지한다.

작성일: 2026-06-26

Redis WRONGTYPE Operation 해결 — 키 타입 충돌 완벽 정리

TypeScript cannot find module 해결 — ts(2307) 에러 완벽 정리

🔍 검색 키워드: typescript cannot find module, ts2307 에러 해결, tsconfig paths 설정, typescript module not found, cannot find module or its corresponding type declarations

TypeScript 프로젝트 하다 보면 한 번쯤은 꼭 만나는 에러다.

Cannot find module 'X' or its corresponding type declarations. ts(2307)

빌드도 안 되고, IDE에서도 빨간 줄 잔뜩 그어지면서 당황하게 된다. 이 에러는 발생 원인이 여러 가지라 원인을 제대로 모르면 해결책을 찾아 헤매다 시간만 버린다.


1. 증상

error TS2307: Cannot find module '../utils/logger' or its corresponding type declarations.
error TS2307: Cannot find module '@/components/Button' or its corresponding type declarations.
error TS2307: Cannot find module 'lodash' or its corresponding type declarations.

import 구문에서 빨간 줄이 생기고, tsc 실행 시 컴파일이 실패한다. VSCode 같은 IDE에서도 동일하게 에러가 표시된다.


2. 원인별 분류

크게 4가지 패턴으로 나뉜다.

패턴 1: @types 패키지 누락

서드파티 라이브러리를 설치했지만 TypeScript 타입 정의 패키지를 설치하지 않은 경우다. lodash, express, node 같은 라이브러리는 별도의 @types/xxx 패키지가 필요하다.

# 확인 방법
ls node_modules/@types/

# 해결
npm install --save-dev @types/lodash
npm install --save-dev @types/node
npm install --save-dev @types/express

패턴 2: tsconfig.json 경로 별칭(Path Alias) 미설정

@/, ~/ 같은 경로 별칭을 사용하는데 tsconfig에 paths 설정이 없는 경우다. Vite, Next.js, CRA 등에서 흔하게 발생한다.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "~/*": ["src/*"]
    }
  }
}
주의: Next.js는 tsconfig.json의 paths를 자동으로 인식하지만, Node.js 환경에서 직접 실행할 때는 tsconfig-paths 패키지가 추가로 필요하다.
npm install --save-dev tsconfig-paths

# 실행 시
ts-node -r tsconfig-paths/register src/index.ts

패턴 3: moduleResolution 설정 불일치

Node.js 16+ 혹은 ES Modules를 사용할 때 moduleResolution 설정이 맞지 않는 경우다.

// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

NodeNext 설정에서는 import 시 파일 확장자를 명시해야 한다.

// ❌ 에러
import { logger } from './utils/logger';

// ✅ 정상
import { logger } from './utils/logger.js';

컴파일된 결과가 .js이기 때문에 소스에서도 .js 확장자를 써야 한다. 처음엔 이상하게 느껴지지만 ESM 스펙이 이렇다.

패턴 4: node_modules 손상 또는 누락

패키지가 설치되어 있는데도 에러가 난다면 node_modules 자체가 손상됐을 가능성이 있다.

# 클린 재설치
rm -rf node_modules package-lock.json
npm install

3. 원인 진단 체크리스트

점검 항목 확인 방법 해결 방법
@types 패키지 설치 여부 ls node_modules/@types/ npm i -D @types/xxx
tsconfig paths 설정 tsconfig.json 확인 baseUrl + paths 추가
moduleResolution 설정 tsconfig.json 확인 NodeNext 또는 bundler
node_modules 상태 ls node_modules/[패키지명] 클린 재설치
상대 경로 확장자 import 구문 확인 .js 확장자 명시

4. --traceResolution 으로 정확히 추적하기

어디서 모듈을 찾는지 추적하고 싶다면 이 플래그를 쓴다.

tsc --traceResolution 2>&1 | grep -A 5 "Cannot find module"

TypeScript가 어떤 경로를 탐색했는지 전부 출력해준다. 설정 문제인지, 패키지 문제인지 바로 보인다.


5. 실제 사례 — Next.js에서 @/components 경로 에러

Cannot find module '@/components/Button' or its corresponding type declarations.

Next.js 프로젝트에서 흔히 겪는 케이스다.

// tsconfig.json — 이 설정이 없으면 에러 발생
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}

Next.js 13+는 프로젝트 생성 시 자동으로 이 설정을 넣어주지만, 수동으로 구성한 프로젝트나 마이그레이션한 경우 누락되기 쉽다.


6. Jest + TypeScript 환경

Jestl��서도 같은 에러가 발생할 수 있다. Jest는 tsconfig의 paths를 직접 읽지 않기 때문에 별도 설정이 필요하다.

npm install --save-dev babel-jest @babel/preset-typescript ts-jest
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

moduleNameMapper로 tsconfig의 paths와 동일하게 맞춰줘야 한다.


정리

TypeScript cannot find module 에러는 딱 떨어지는 단일 원인이 아니다. 발생 맥락을 보고 위 패턴 중 어디에 해당하는지 진단하는 게 먼저다. --traceResolution으로 탐색 경로를 직접 확인하면 대부분 30분 안에 해결된다.

작성일: 2026-06-26

.env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결

🔍 검색 키워드: process.env undefined, dotenv not working, .env 파일 적용 안됨, python-dotenv not loading, 환경변수 undefined, docker 환경변수 안됨, next.js env not working

상황

.env 파일을 분명히 만들었는데 코드에서 undefined가 찍힌다.

# .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecretkey
console.log(process.env.DATABASE_URL); // undefined ???

또는 Python에서:

import os
print(os.getenv("SECRET_KEY"))  # None ???

배포 환경에서만 안 된다거나, Docker 안에서만 안 된다거나, 로컬은 되는데 서버에선 안 된다거나. 패턴은 다양하지만 원인은 몇 가지로 좁혀진다.


Node.js — dotenv 관련

원인 1: dotenv를 아예 안 불렀거나 너무 늦게 불렀다

// 잘못된 예 — DB 모듈보다 나중에 dotenv 로드
const db = require('./database'); // 이미 process.env 읽음
require('dotenv').config();       // 너무 늦었다
// 올바른 예 — 진입점 파일 최상단에서 먼저
require('dotenv').config();

const db = require('./database');
const app = require('./app');

TypeScript / ES Modules:

import 'dotenv/config'; // 이 방법이 제일 깔끔하다

// 또는
import dotenv from 'dotenv';
dotenv.config();

import { createConnection } from './db';

원인 2: .env 파일 경로가 다르다

기본적으로 dotenvprocess.cwd() 기준으로 .env를 찾는다. 실행 위치가 프로젝트 루트가 아니면 못 찾는다.

import path from 'path';
import dotenv from 'dotenv';

dotenv.config({
  path: path.resolve(__dirname, '../.env'),
});

원인 3: .env 파일이 .gitignore에 있고 서버에 없다

로컬에서만 됐던 이유가 이거다. .env는 보통 .gitignore에 들어가 있어서 서버에 배포가 안 된다.

# GitHub Actions — 시크릿 주입
steps:
  - name: Create .env file
    run: |
      echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
      echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env

원인 4: 변수명에 공백이나 따옴표 문제

# 잘못된 .env
DATABASE_URL = postgresql://localhost/mydb   # = 주변 공백 금지

# 올바른 .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecret
APP_NAME="My App"  # 값에 공백이 필요하면 따옴표 사용

Python — python-dotenv

from dotenv import load_dotenv
import os

# .env 파일 로드 (현재 디렉토리 기준)
load_dotenv()

# 또는 경로 명시
load_dotenv(dotenv_path='/path/to/.env')

# 이미 설정된 환경변수를 덮어쓰려면
load_dotenv(override=True)

print(os.getenv("DATABASE_URL"))
💡 load_dotenv()는 이미 시스템에 설정된 환경변수는 덮어쓰지 않는다. 테스트 환경에서 .env 값으로 강제하려면 override=True를 써야 한다.
# Django settings.py
import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST', 'localhost'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

Next.js / React — 프레임워크별 규칙

Next.js는 dotenv를 직접 쓰지 않고 자체 env 로딩 시스템을 쓴다.

  • 서버에서만 쓰는 변수: DATABASE_URL=value
  • 클라이언트(브라우저)에서도 쓰는 변수: NEXT_PUBLIC_API_URL=value (반드시 NEXT_PUBLIC_ 접두사)
// 서버 컴포넌트 / API Route에서만 접근 가능
process.env.DATABASE_URL // ✅

// 클라이언트에서 접근하려면 NEXT_PUBLIC_ 접두사 필수
process.env.NEXT_PUBLIC_API_URL // ✅
process.env.API_URL // 클라이언트에서는 undefined ❌

환경 파일 우선순위:

.env.local > .env.development.local > .env.development > .env
⚠️ NEXT_PUBLIC_ 변수는 빌드 시 정적으로 교체된다. 빌드 후 값을 바꿔도 소용없다. 반드시 빌드 전에 설정해야 한다.

Docker / Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    # 방법 1: env_file 지정 (권장)
    env_file:
      - .env

    # 방법 2: 직접 명시
    environment:
      - DATABASE_URL=postgresql://db:5432/mydb
      - SECRET_KEY=${SECRET_KEY}  # 호스트 환경변수에서
# Docker run
docker run --env-file .env myapp
docker run -e DATABASE_URL=xxx -e SECRET_KEY=yyy myapp
⚠️ Dockerfile에서 COPY .env .를 하면 이미지에 시크릿이 박힌다. .dockerignore.env를 넣고, 런타임에 주입하는 방식을 써야 한다.
# .dockerignore
.env
.env.*

상황별 체크리스트

증상원인확인/해결
로컬만 됨, 서버 안 됨.env가 서버에 없음CI/CD 시크릿 주입 또는 서버에 .env 생성
dotenv.config() 했는데 undefinedimport 순서 문제진입점 최상단에서 dotenv 먼저 로드
특정 변수만 undefined변수명 오타, 공백.env 파일 문법 확인
Docker 안에서 undefinedenv_file 미설정env_file 또는 -e 옵션으로 주입
Next.js 클라이언트에서 undefinedNEXT_PUBLIC_ 접두사 누락접두사 추가 후 재빌드
Python에서 Noneload_dotenv() 미호출import 후 load_dotenv() 호출
기존 시스템 변수가 우선override 미설정load_dotenv(override=True)

디버깅 팁

// 로드된 환경변수 키 목록 확인
console.log('ENV keys:', Object.keys(process.env).filter(k => !k.startsWith('npm_')));

// .env 경로 확인
const path = require('path');
console.log('Looking for .env at:', path.resolve(process.cwd(), '.env'));
# .env 파일에서 읽은 값만 확인 (시스템 변수 제외)
from dotenv import dotenv_values
config = dotenv_values(".env")
print(config)

마무리

환경변수 에러는 대부분 세 가지 중 하나다: .env 파일이 없거나, dotenv 로드를 너무 늦게 했거나, 프레임워크별 규칙을 무시했거나.

서버에 배포했을 때 갑자기 안 된다면 .env가 서버에 실제로 존재하는지부터 확인하고, Docker면 --env-file이나 env_file로 주입됐는지 확인한다. Next.js면 클라이언트에서 쓰는 변수에 NEXT_PUBLIC_ 붙이고 재빌드. 이 세 가지가 90%다.

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 패턴으로 설계 단계에서 트랜잭션 경계를 명확히 하는 게 장기적으로 낫다.

화요일

GitHub Actions OOM 에러 해결: JavaScript heap out of memory 완전 정복

TypeScript "Type is not assignable" 에러 완전 정복

🔍 검색 키워드: TypeScript 타입 에러, Type is not assignable to type, TS2322, TS2345, TypeScript 타입 오류 해결, typescript type error 해결

TypeScript 쓰다 보면 한 번쯤은 이 에러를 만난다.

Type 'string' is not assignable to type 'number'.  ts(2322)

처음엔 당황스럽지만, 패턴을 이해하면 금방 잡힌다. 이 글은 자주 나오는 케이스별로 원인과 해결을 정리했다.


에러가 생기는 이유

TypeScript는 정적 타입 언어다. 컴파일 타임에 타입을 검사하기 때문에, 선언된 타입과 실제 할당값이 다르면 에러를 낸다. Java의 컴파일 에러와 같은 맥락이다.


레벨 1 (초보자) — 기본 타입 불일치

증상

let count: number = "5";  // ❌ TS2322

원인

number 타입으로 선언된 변수에 string을 대입했다.

해결

// 방법 1: 타입에 맞는 값으로 수정
let count: number = 5;

// 방법 2: 타입 변환
let count: number = parseInt("5");

// 방법 3: 타입 선언 수정
let count: string = "5";

레벨 2 (중급자) — 함수 파라미터 타입 불일치

증상

function greet(name: string): string {
  return `Hello, ${name}`;
}
greet(123);  // ❌ TS2345

해결

// 방법 1: 올바른 타입으로 전달
greet("홍길동");

// 방법 2: Union 타입 사용
function greet(name: string | number): string {
  return `Hello, ${String(name)}`;
}

레벨 3 (중급자) — Object 타입 구조 불일치

증상

interface User {
  id: number;
  name: string;
  email: string;
}
const user: User = {
  id: 1,
  name: "홍길동"
  // email 누락 → ❌ TS2322
};

해결

// 방법 1: 빠진 프로퍼티 추가
const user: User = { id: 1, name: "홍길동", email: "hong@example.com" };

// 방법 2: 선택적 프로퍼티로 변경
interface User { id: number; name: string; email?: string; }

// 방법 3: Partial 사용
const partialUser: Partial<User> = { id: 1, name: "홍길동" };

레벨 4 (중급자) — 배열/제네릭 타입 불일치

증상

const ids: number[] = [1, 2, "3", 4];  // ❌ TS2322

function first<T>(arr: T[]): T { return arr[0]; }
const result: number = first(["a", "b"]);  // ❌ TS2322

해결

const ids: (number | string)[] = [1, 2, "3", 4];
const result: string = first(["a", "b"]);

레벨 5 (실무자) — null/undefined 처리

증상

function getUser(id: number): User | null {
  return id === 1 ? { id: 1, name: "홍길동", email: "hong@example.com" } : null;
}
const user: User = getUser(999);  // ❌ TS2322: 'User | null'

해결

// 방법 1: 타입에 null 포함
const user: User | null = getUser(999);

// 방법 2: null 체크 후 사용 (Type narrowing)
const maybeUser = getUser(999);
if (maybeUser !== null) {
  const user: User = maybeUser;
}

// 방법 3: Non-null assertion
const user: User = getUser(1)!;

// 방법 4: Nullish coalescing으로 기본값
const user: User = getUser(999) ?? { id: 0, name: "Guest", email: "" };

레벨 6 (고급자) — 타입 추론 문제

증상

const config = { mode: "development" };  // TypeScript가 string으로 추론
function setup(mode: "development" | "production") {}
setup(config.mode);  // ❌ TS2345

해결

// 방법 1: as const로 리터럴 타입 고정
const config = { mode: "development" } as const;

// 방법 2: 명시적 타입 선언
const config: { mode: "development" | "production" } = { mode: "development" };

// 방법 3: 타입 단언
setup(config.mode as "development" | "production");

상황별 체크리스트

상황확인 포인트
변수 할당 에러선언 타입과 값 타입 일치 여부
함수 인자 에러함수 시그니처와 전달값 타입 비교
객체 에러인터페이스 필수 프로퍼티 누락 여부
null 에러strictNullChecks 활성화 여부 확인
리터럴 타입 에러as const 또는 명시적 타입 선언 필요
any 남용any 대신 unknown + 타입 가드 사용

자주 하는 실수 — any로 도배

// ❌ TypeScript 쓰는 의미 없음
const data: any = fetchData();

// ✅ unknown 쓰고 타입 가드로 좁혀라
const data: unknown = fetchData();
if (typeof data === "string") {
  console.log(data.toUpperCase());
}

마무리

TypeScript 타입 에러는 대부분 세 가지다.

  1. 타입을 잘못 선언했거나
  2. 값이 여러 타입이 될 수 있는데 하나만 선언했거나
  3. null/undefined 처리를 안 했거나

에러 메시지를 읽으면 어느 쪽인지 대부분 나온다. ts(숫자) 에러코드로 TypeScript 공식 문서에서 정확한 설명도 찾을 수 있다.

월요일

MySQL 연결 에러 완전 정복: Too many connections, Connection refused, Access denied 해결법

Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

🔍 검색 키워드: CERTIFICATE_VERIFY_FAILED · Python SSL 에러 · ssl.SSLCertVerificationError · Python requests SSL 오류 · macOS Python SSL · Python urllib SSL 인증서 오류

Python으로 외부 API 호출하거나 크롤링하다 보면 이 에러 한 번쯤은 만난다.

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)

이거 처음 보면 당황스럽다. 에러 메시지만 봐서는 뭐가 문제인지 감도 안 온다. 원인부터 단계별 해결까지 정리한다.


왜 이 에러가 나는가

Python이 HTTPS 요청을 보낼 때 서버의 SSL 인증서를 검증한다. 이 과정에서 신뢰할 수 있는 루트 인증서(CA) 목록을 참조하는데, 여기에 서버 인증서 체인이 없으면 에러가 난다.

주요 원인은 크게 세 가지다.

  • Python 자체 CA 번들이 오래됨 — macOS에서 Python 3.6+ 공식 배포판은 시스템 인증서를 쓰지 않고 번들로 따로 관리하는데, 이게 업데이트 안 되면 발생한다.
  • 회사 네트워크의 프록시/방화벽 — 기업 환경에서 HTTPS 트래픽을 자체 인증서로 중간에서 가로채는(MITM) 경우, Python이 그 인증서를 모른다.
  • 자체 서명(self-signed) 인증서를 가진 서버 — 내부 개발 서버나 테스트 환경에서 자주 발생.

상황별 체크리스트

상황원인권장 해결책
macOS + Python 공식 설치번들 CA 미업데이트Install Certificates.command 실행
회사 내부 네트워크기업 프록시 인증서기업 CA 인증서 추가
내부 개발 서버self-signed 인증서verify=인증서경로 지정
Docker/CI 환경CA 번들 미포함certifi 패키지 + 환경변수 설정
갑자기 발생 (예전엔 됐는데)Python 또는 OS 업그레이드certifi 재설치

레벨별 해결 방법

초보자 — 일단 돌아가게 만들기 (비추천)

개발 중에 빠르게 확인만 할 때 쓰는 방법이다. 절대 프로덕션에 쓰면 안 된다.

import requests

# SSL 검증 비활성화 — 개발용으로만!
response = requests.get("https://example.com", verify=False)

중급자 — certifi로 CA 번들 업데이트

가장 권장하는 방법이다. certifi는 Mozilla가 관리하는 신뢰할 수 있는 CA 목록을 Python에서 쓸 수 있게 패키징한 라이브러리다.

pip install --upgrade certifi
import requests
import certifi

response = requests.get("https://example.com", verify=certifi.where())
print(response.status_code)

환경변수로 전역 적용 (추천)

# Linux/macOS
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
export REQUESTS_CA_BUNDLE=$(python -c "import certifi; print(certifi.where())")

macOS 전용 — Install Certificates.command 실행

# 해당 버전의 Install Certificates 스크립트 실행 (버전에 맞게 경로 수정)
open /Applications/Python\ 3.12/Install\ Certificates.command

고급자 — 기업 CA 인증서 추가

import requests

# 단일 파일
response = requests.get("https://internal.company.com", verify="/path/to/company-ca.crt")
import certifi

with open("/path/to/company-ca.crt", "r") as f:
    company_cert = f.read()

with open(certifi.where(), "a") as bundle:
    bundle.write("\n" + company_cert)

Docker/CI 환경

FROM python:3.12-slim

RUN pip install certifi

ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
# GitHub Actions
- name: Fix SSL certificates
  run: |
    pip install --upgrade certifi
    echo "SSL_CERT_FILE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV
    echo "REQUESTS_CA_BUNDLE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV

urllib / aiohttp 사용자 참고

import ssl, urllib.request, certifi

ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request("https://example.com")
with urllib.request.urlopen(req, context=ctx) as response:
    data = response.read()
import aiohttp, ssl, certifi

async def fetch(url):
    ssl_ctx = ssl.create_default_context(cafile=certifi.where())
    connector = aiohttp.TCPConnector(ssl=ssl_ctx)
    async with aiohttp.ClientSession(connector=connector) as session:
        async with session.get(url) as response:
            return await response.text()

절대 하면 안 되는 것

⚠️ 프로덕션에서 이런 코드 보이면 반드시 수정해야 한다.
# ❌ 절대 하지 말 것
import ssl
ssl._create_default_https_context = ssl._create_unverified_context

# ❌ 이것도 마찬가지
requests.get(url, verify=False)

# ❌ 환경변수로 전체 비활성화
# PYTHONHTTPSVERIFY=0

SSL 검증을 끄는 순간 중간자 공격(MITM)에 완전히 노출된다. 개발 편의를 위해 껐다가 프로덕션에 그대로 배포되는 사고가 실제로 발생한다.


정리

우선순위해결책상황
1순위Install Certificates.commandmacOS + 공식 Python 설치
2순위pip install --upgrade certifi + 환경변수대부분의 환경
3순위기업 CA 인증서 추가회사 내부 네트워크
4순위verify=False로컬 개발 일시적 확인 (프로덕션 절대 불가)
💡 실무에서 이 에러를 자주 만나는 패턴은 Python 버전 업그레이드 직후, 또는 새 팀원이 회사 환경 세팅할 때다. 팀 위키에 이 내용 정리해두면 반복 질문 많이 줄어든다.

Redis 연결 에러 완전 정복: ECONNREFUSED 127.0.0.1:6379 트러블슈팅

🔍 검색 키워드: redis connection refused, redis ECONNREFUSED 6379, redis 연결 안됨, ioredis 연결 에러, spring boot redis 연결 실패, node redis ECONNREFUSED, docker redis 연결 에러

Redis 붙이다가 처음 보는 에러 아니다. 누구나 한 번쯤은 밟는다.

Error: connect ECONNREFUSED 127.0.0.1:6379

이거 뜨면 일단 당황하지 말고 순서대로 확인하면 금방 해결된다. 원인은 대부분 세 가지 중 하나다.


에러가 뜨는 주요 상황

상황에러 메시지
Node.js (ioredis)[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379
Node.js (node-redis)Error: Redis connection to 127.0.0.1:6379 failed - connect ECONNREFUSED
Spring BootUnable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException
Python (redis-py)redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379.
Docker 컨테이너 간connect ECONNREFUSED 127.0.0.1:6379 (컨테이너 내부에서 호스트 Redis 접근 시도)

레벨 1: 입문자 — Redis가 켜져 있나요?

가장 흔한 원인. Redis 서버가 꺼져 있으면 당연히 붙을 수 없다.

Redis 실행 상태 확인

# 프로세스 확인
ps aux | grep redis-server

# 포트 리스닝 확인
ss -tlnp | grep 6379

# systemd 기반 (Ubuntu/CentOS)
sudo systemctl status redis

Redis 직접 연결 테스트

redis-cli ping
# 정상이면: PONG
# 실패하면: Could not connect to Redis at 127.0.0.1:6379: Connection refused

Redis 실행 방법

# systemd로 시작
sudo systemctl start redis
sudo systemctl enable redis   # 부팅 시 자동 시작

# 백그라운드 실행
redis-server --daemonize yes

레벨 2: 실무자 — 포트/바인딩/방화벽 확인

Redis는 켜져 있는데 연결이 안 되면 이쪽을 본다.

bind 설정 문제

/etc/redis/redis.conf 기본 설정:

# 기본값: 127.0.0.1만 허용 (로컬호스트 전용)
bind 127.0.0.1

# 외부 접속 허용하려면 (주의: 보안 설정 필수)
bind 0.0.0.0

# 변경 후 재시작
sudo systemctl restart redis

방화벽 확인

# UFW (Ubuntu)
sudo ufw status
sudo ufw allow 6379

# 외부에서 포트 테스트
nc -zv <서버IP> 6379

requirepass 설정 시 인증 필요

redis-cli -a yourpassword ping

# 또는 연결 후 AUTH
redis-cli
> AUTH yourpassword
> PING

레벨 3: 고급 — Docker, Kubernetes 환경

이게 제일 헷갈린다. 컨테이너 안에서 127.0.0.1:6379컨테이너 자신을 가리킨다.

Docker Compose로 Redis 연결

# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    environment:
      - REDIS_HOST=redis      # 127.0.0.1이 아니라 서비스명!
      - REDIS_PORT=6379
    depends_on:
      - redis

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
// Node.js - 잘못된 예
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// 올바른 예
const redis = new Redis({
  host: process.env.REDIS_HOST || 'redis',
  port: parseInt(process.env.REDIS_PORT || '6379'),
});

호스트 머신의 Redis에 접근하는 경우

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      - REDIS_HOST=host.docker.internal

언어별 연결 코드 및 에러 핸들링

Node.js — ioredis

const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  retryStrategy(times) {
    return Math.min(times * 50, 2000);
  },
  maxRetriesPerRequest: 3,
});

redis.on('error', (err) => console.error('Redis 연결 에러:', err.message));
redis.on('connect', () => console.log('Redis 연결 성공'));

Python — redis-py

import redis, os

r = redis.Redis(
    host=os.getenv('REDIS_HOST', '127.0.0.1'),
    port=int(os.getenv('REDIS_PORT', 6379)),
    password=os.getenv('REDIS_PASSWORD'),
    decode_responses=True,
    socket_connect_timeout=5,
    retry_on_timeout=True,
)

try:
    r.ping()
    print("Redis 연결 성공")
except redis.exceptions.ConnectionError as e:
    print(f"Redis 연결 실패: {e}")

Spring Boot — application.yml

spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      timeout: 5000ms
      lettuce:
        pool:
          max-active: 10
          max-idle: 10
          min-idle: 2

원인별 체크리스트

체크 항목확인 방법조치
Redis 서버 실행 중?ps aux | grep redissystemctl start redis
포트 리스닝 중?ss -tlnp | grep 6379포트 충돌 확인
bind 설정 맞음?redis.conf 확인bind 0.0.0.0
방화벽 열려 있음?ufw status포트 허용
Docker 환경?컨테이너 여부 확인서비스명으로 host 변경
인증 필요?requirepass 설정 확인password 파라미터 추가
TLS 사용 중?Redis 6.0+ 설정 확인tls:// 스킴 및 인증서 설정
원격 서버?네트워크 경로 확인VPN, 보안그룹 확인

자주 하는 실수 TOP 3

1. Docker에서 localhost 씀
컨테이너 안에서 localhost는 컨테이너 자신이다. 다른 컨테이너의 Redis에 붙으려면 서비스명을 써야 한다.

2. 환경변수 안 쓰고 하드코딩
로컬에서 127.0.0.1로 하드코딩해두고 스테이징/프로덕션에 그대로 올리면 터진다. 처음부터 환경변수로 빼두자.

3. Redis 안 뜨고 앱부터 뜸
depends_on은 컨테이너 시작 순서만 보장하지 Redis 준비를 보장하지 않는다. healthcheck를 써야 한다.

redis:
  image: redis:7-alpine
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 10s
    timeout: 5s
    retries: 5

app:
  depends_on:
    redis:
      condition: service_healthy

마무리

Redis 연결 에러의 90%는 위 체크리스트로 해결된다. 나머지 10%는 TLS, 클러스터 모드, Sentinel 설정 같은 고급 주제인데 그건 따로 다루겠다.

에러 메시지를 봤을 때 "Redis가 켜져 있냐 → 주소/포트가 맞냐 → 네트워크가 열려 있냐" 이 순서로만 확인해도 대부분 잡힌다.

금요일

npm install이 안 된다고? 원인 파악 없이 --force 치지 마라

npm install 에러 npm ERR! code ERESOLVE node_modules 삭제 후 재설치 npm ci 차이 package-lock.json 충돌 peer dependency 에러

프로젝트 클론하고 npm install 한 번에 되면 그날은 운이 좋은 날이다. 실무에서 이게 한 번에 되는 경우가 얼마나 되냐면, 팀이 클수록, 프로젝트 오래될수록 확률이 줄어든다.

문제는 에러 메시지를 제대로 읽지 않고 npm install --force 또는 npm install --legacy-peer-deps를 무지성으로 치는 경우다. 이러면 당장은 되는 것처럼 보이지만 나중에 런타임에서 이상한 에러로 돌아온다. 진짜 실무자는 에러 메시지를 읽는다.

에러 메시지별 원인과 해결

ERESOLVE: peer dependency 충돌

npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR!
npm ERR! While resolving: myapp@1.0.0
npm ERR! Found: react@18.2.0
npm ERR!   react@"^18.2.0" from the root project
npm ERR!
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^17.0.0" from some-library@2.1.0

이 에러는 some-library가 React 17을 요구하는데 프로젝트에는 React 18이 깔려 있다는 뜻이다. npm 7 버전부터 peer dependency를 엄격하게 검사하기 시작해서 이 에러가 갑자기 늘었다.

선택지는 세 가지다.

1. 라이브러리를 업데이트한다 (가장 좋음)

npm install some-library@latest

해당 라이브러리가 최신 버전에서 React 18을 지원하면 끝난다. 이게 제일 먼저 시도해야 할 옵션이다.

2. --legacy-peer-deps 옵션 (차선책)

npm install --legacy-peer-deps

npm 6 시절 방식으로 peer dependency를 그냥 무시하고 설치한다. 대부분의 경우 문제없이 동작하지만, 진짜 비호환 라이브러리가 섞이면 런타임 에러가 난다. 팀 전체가 이 옵션을 공유한다면 .npmrc에 박아두는 게 낫다.

# .npmrc
legacy-peer-deps=true

3. --force는 마지막 수단

npm install --force

--force는 캐시도 무시하고 버전 충돌도 무시하고 그냥 밀어붙인다. 개발 의존성이나 급할 때 쓰는 거고, CI/CD에서 이걸 쓰고 있다면 뭔가 잘못된 거다.

ENOENT: node_modules 꼬임

npm ERR! code ENOENT
npm ERR! syscall open
npm ERR! path /project/node_modules/.package-lock.json
npm ERR! errno -2
npm ERR! enoent ENOENT: no such file or directory

node_modules가 중간에 망가진 경우다. 삭제하고 다시 설치하면 된다.

# node_modules 통째로 삭제
rm -rf node_modules package-lock.json

# 클린 설치
npm install

Windows라면:

Remove-Item -Recurse -Force node_modules
Remove-Item package-lock.json
npm install

그냥 node_modules만 지우고 재설치하면 되는데, package-lock.json도 같이 지우는 게 나을 때가 있다. package-lock.json이 실제 package.json과 어긋나 있는 경우에 이 에러가 나기도 해서다. 단, package-lock.json을 지우면 의존성 버전이 최신으로 바뀔 수 있으니 팀 공유 프로젝트에선 조심해야 한다.

EACCES: 권한 문제

npm ERR! code EACCES
npm ERR! syscall mkdir
npm ERR! path /usr/local/lib/node_modules
npm ERR! errno -13
npm ERR! Error: EACCES: permission denied

글로벌 설치할 때 자주 나온다. sudo npm install -g로 해결했다면 잠깐은 괜찮지만, 이 방식이 습관되면 나중에 권한 문제가 더 복잡해진다. 올바른 방법은 npm 기본 디렉토리를 사용자 홈으로 옮기는 거다.

# npm 글로벌 디렉토리를 홈 폴더로 변경
mkdir ~/.npm-global
npm config set prefix '~/.npm-global'

# PATH에 추가 (~/.zshrc 또는 ~/.bashrc)
export PATH=~/.npm-global/bin:$PATH

# 적용
source ~/.zshrc

ETIMEDOUT / ECONNRESET: 네트워크 이슈

npm ERR! code ETIMEDOUT
npm ERR! errno ETIMEDOUT
npm ERR! network request to https://registry.npmjs.org/lodash failed

회사 네트워크나 VPN 환경에서 자주 나온다. 몇 가지 확인 포인트:

# npm 레지스트리 확인
npm config get registry

# 회사 사설 레지스트리 쓰고 있다면
npm config set registry https://your-company-registry.com/

# 원복
npm config set registry https://registry.npmjs.org/

# 프록시 환경이면
npm config set proxy http://proxy.company.com:8080
npm config set https-proxy http://proxy.company.com:8080

타임아웃 에러는 단순히 npm 서버 응답이 느린 경우도 있어서 재시도 먼저 해본다. 그래도 계속 나오면 레지스트리 설정 확인.

npm install vs npm ci — 이 차이 모르는 사람 많다

CI/CD 파이프라인에서 npm install을 쓰고 있다면 npm ci로 바꿔라.

구분npm installnpm ci
package-lock.json 없으면새로 생성에러로 중단
버전 범위(^, ~)최신 버전으로 설치 가능lock 파일 버전 그대로
node_modules있으면 그대로 두고 업데이트무조건 지우고 재설치
용도개발 환경CI/CD, 배포

npm installpackage.json의 버전 범위 안에서 최신 버전을 가져올 수 있다. "react": "^18.0.0"이면 18.x 최신을 잡는다는 얘기다. CI에서 이걸 쓰면 빌드할 때마다 버전이 달라질 수 있다. npm cipackage-lock.json에 적힌 정확한 버전만 설치한다. 재현 가능한 빌드를 원한다면 CI에서 npm ci가 맞다.

자주 보는 실수 모음

package-lock.json을 .gitignore에 넣는 경우

가끔 이렇게 된 프로젝트가 있다. package-lock.json은 반드시 커밋해야 한다. 이게 없으면 팀원마다 설치되는 패키지 버전이 달라지고, "내 로컬에선 되는데 왜 CI가 터지냐"는 상황이 만들어진다.

npm 버전이 팀마다 다른 경우

프로젝트 루트에 .nvmrcpackage.jsonengines 필드로 버전 명시해두는 습관을 들이자.

{
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=9.0.0"
  }
}

yarn/pnpm 섞어쓰기

package-lock.json(npm), yarn.lock(yarn), pnpm-lock.yaml(pnpm)이 동시에 존재하는 프로젝트가 있다. 이 상태로 팀에서 사람마다 다른 패키지 매니저 쓰면 lock 파일 충돌 지옥이 된다. package.jsonpackageManager 필드로 통일해두자.

{
  "packageManager": "npm@10.2.0"
}

정리

npm 에러는 대부분 세 가지 중 하나다: peer dependency 충돌, node_modules 꼬임, 네트워크/권한 문제. 에러 메시지 첫 줄에 나오는 code를 읽으면 원인이 나온다. --force는 진짜 마지막 수단이고, CI에선 npm ci 써라.

Git Merge Conflict 완전 정복 — 겁먹지 말고 읽어봐라

🔍 검색 키워드: git merge conflict 해결, git 충돌 해결, merge conflict 뜨는 이유, rebase conflict, git pull 충돌, git 머지 에러

왜 merge conflict가 생기는가

두 사람이 같은 파일의 같은 줄을 각자 다르게 수정하면 Git은 어느 쪽을 선택해야 할지 모른다. 그래서 멈추고 사람한테 결정을 넘긴다. 그게 전부다. 무서운 게 없다.

레벨 1 — 기초: 충돌 마커 읽기

충돌이 생기면 Git은 파일 안에 이런 마커를 심어준다.

<<<<<<< HEAD
const greeting = "안녕하세요";  // 내 변경사항
=======
const greeting = "Hello";       // 상대방 변경사항
>>>>>>> feature/english-greeting
  • <<<<<<< HEAD ~ ======= : 현재 브랜치(내 것)
  • ======= ~ >>>>>>> : 병합 대상 브랜치(상대 것)

해결 방법은 단순하다. 둘 중 하나를 고르거나, 둘 다 합치거나, 완전히 새로 쓰거나. 마커 3개(<<<<<<<, =======, >>>>>>>)를 모두 제거하고 원하는 최종 코드만 남기면 된다.

# 충돌 파일 확인
git status

# 수동 편집 후
git add src/greeting.js
git commit

레벨 2 — 실무: 자주 마주치는 상황별 대처

상황 1: git pull 했더니 충돌 폭탄

git pull origin main
# CONFLICT (content): Merge conflict in src/api.js
# Automatic merge failed; fix conflicts and then commit the result.
단계명령어설명
1git status충돌 파일 목록 확인
2에디터에서 파일 열기마커 찾아서 수동 편집
3git add <파일>해결된 파일 스테이징
4git commit머지 커밋 생성 (메시지 자동 입력됨)

상황 2: merge 중 충돌, 그냥 포기하고 싶을 때

git merge --abort

--abort 하면 merge 시작 전 상태로 되돌아간다. 깔끔하게 포기할 수 있다.

상황 3: rebase 중 충돌

git rebase main
# CONFLICT (content): Merge conflict in src/user.js

rebase는 커밋 하나씩 재적용하기 때문에 충돌도 커밋 단위로 난다.

# 각 충돌 해결 후
git add src/user.js
git rebase --continue  # 다음 커밋으로 진행

# 포기할 때
git rebase --abort

상황 4: 특정 파일을 그냥 한 쪽으로 덮어쓰고 싶을 때

# 내 것(HEAD) 으로 덮어쓰기
git checkout --ours src/config.js

# 상대 브랜치 것으로 덮어쓰기
git checkout --theirs src/config.js

git add src/config.js

레벨 3 — 고급: 도구 활용 및 예방

VS Code에서 충돌 해결

VS Code는 충돌 파일에 시각적 버튼을 표시해준다.

  • Accept Current Change → HEAD 것 선택
  • Accept Incoming Change → 병합 대상 것 선택
  • Accept Both Changes → 둘 다 유지
  • Compare Changes → diff 보기

터미널보다 훨씬 빠르다. 파일이 많을 때 특히 유용하다.

git mergetool 사용

# VS Code를 mergetool로 설정
git config --global merge.tool vscode
git config --global mergetool.vscode.cmd 'code --wait $MERGED'
git mergetool

Python 프로젝트: requirements.txt 충돌

<<<<<<< HEAD
requests==2.28.0
=======
requests==2.31.0
>>>>>>> feature/update-deps

보통 높은 버전을 선택하되, 호환성 깨지는 메이저 버전 업이면 테스트 먼저.

Node.js: package-lock.json 충돌

package-lock.json 충돌은 직접 해결하려 하지 말고 이게 낫다.

# 충돌난 package-lock.json 삭제 후 재생성
git checkout --theirs package-lock.json
npm install

# 또는 아예 새로 생성
rm package-lock.json
npm install
git add package-lock.json

Java/Spring: application.yml 충돌

server:
  port: 8080         # <-- HEAD vs 8081 충돌 예시
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_db

환경별 설정이 섞이는 케이스. application-dev.yml, application-prod.yml로 분리하면 근본 해결된다.

Nginx 설정 파일 충돌

location /api {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Real-IP $remote_addr;  # incoming 것 추가
}

두 변경사항을 합치는 게 맞다. 헤더 추가된 버전에 로컬 주소를 넣거나, 환경변수로 빼거나.

충돌 자주 나는 상황 진단표

상황원인예방법
pull할 때마다 충돌브랜치가 너무 오래됨자주 rebase/merge
같은 파일 반복 충돌역할 분리 안 됨파일 소유권 명확히
package-lock.json 항상 충돌여러 명이 npm installCI에서만 lock 갱신
config 파일 충돌환경 설정 공유환경별 파일 분리
이진 파일(이미지 등) 충돌.gitattributes 없음이진 파일 전략 설정

.gitattributes로 이진 파일 전략 설정

# 이진 파일은 충돌 없이 무조건 덮어쓰기
*.png binary
*.jpg binary
*.pdf binary

# package-lock.json은 theirs 전략
package-lock.json merge=theirs

실수하기 쉬운 것들

1. 마커 제거 안 하고 커밋

# 커밋 전 마커 잔존 여부 확인
grep -r "<<<<<<< " src/

CI에 충돌 마커 검사 스텝 추가해두면 실수 방지된다.

2. rebase 후 force push

rebase 완료 후 원격에 올릴 때는 --force 대신 이걸 써라.

git push --force-with-lease origin feature/my-branch

남이 push한 게 있으면 실패해서 안전하다.

요약

  • 충돌 마커(<<<<<<<, =======, >>>>>>>) 3개 찾아서 제거하고 원하는 코드 남기면 끝
  • 포기하고 싶으면 git merge --abort 또는 git rebase --abort
  • VS Code mergetool 설정해두면 시간 절약
  • package-lock.json은 직접 고치지 말고 재생성
  • 충돌 자주 나면 브랜치 수명을 짧게 가져가는 게 근본 해결책