수요일

🔍 검색 키워드: GitHub Actions permission denied 해결, GITHUB_TOKEN permissions 에러, GitHub Actions resource not accessible by integration, GitHub Actions write permission 설정, GitHub Actions contents read write

GitHub Actions 워크플로우를 처음 구성하거나 레포지토리 설정이 바뀐 뒤에 갑자기 이런 에러가 나는 경우가 있다.

Error: Resource not accessible by integration
Error: HttpError: 403 — The requested URL returned error: 403
remote: Permission to owner/repo.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/...': The requested URL returned error: 403

모두 GITHUB_TOKEN의 권한이 부족해서 발생하는 에러다.


증상

  • actions/checkout에서 push 실패
  • GitHub Pages 배포 단계에서 403 에러
  • PR에 코멘트 달기, 라벨 붙이기 등 GitHub API 호출 실패
  • Release 생성, 태그 푸시 등 쓰기 작업에서 403

원인

GitHub는 기본적으로 GITHUB_TOKEN의 권한을 read-only로 설정한다. 이는 2023년 이후 새로 만들어진 레포지토리의 기본 정책이고, 조직 설정에 따라 강제될 수도 있다.


해결방법

방법 1: 워크플로우 파일에 permissions 추가 (권장)

name: Deploy

on:
  push:
    branches: [main]

# ✅ 필요한 권한만 명시
permissions:
  contents: write      # 코드 push, 태그 생성
  pull-requests: write # PR 코멘트
  pages: write         # GitHub Pages 배포
  id-token: write      # OIDC 토큰

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

특정 job에만 적용하는 것도 가능하다:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read    # 읽기만 필요한 빌드 단계
    steps:
      - uses: actions/checkout@v4
      - run: npm run build

  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      contents: write   # 쓰기 필요한 배포 단계만
      pages: write
      id-token: write
    steps:
      - uses: actions/deploy-pages@v4

방법 2: 레포지토리 설정 변경

레포지토리 → Settings → Actions → General → Workflow permissions 에서 "Read and write permissions"로 변경. 단, 이 방법은 모든 워크플로우에 쓰기 권한을 부여하므로 보안상 권장하지 않는다.


자주 쓰는 permissions 조합

# GitHub Pages 배포
permissions:
  contents: read
  pages: write
  id-token: write

# 릴리즈 생성 + 태그 푸시
permissions:
  contents: write

# PR 자동 코멘트
permissions:
  pull-requests: write
  issues: write

# GitHub Packages / GHCR 푸시
permissions:
  contents: read
  packages: write

# Security 스캔 결과 업로드
permissions:
  security-events: write
  contents: read

사용 가능한 권한 종류

권한 설명
contents 레포 코드 읽기/쓰기, 태그, 릴리즈
pull-requests PR 생성, 코멘트, 라벨
packages GitHub Packages (GHCR)
pages GitHub Pages 배포
id-token OIDC JWT 토큰 (AWS, GCP 인증)
checks 체크 결과 생성/업데이트
security-events 코드 스캐닝 결과

Next.js → GitHub Pages 배포 완성 예시

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./out

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

점검 체크리스트

항목 확인
에러 메시지 403 또는 Resource not accessible인지 확인
워크플로우 permissions 블록 필요한 권한이 명시됐는지
레포 기본 설정 Settings → Actions → Workflow permissions 확인
조직 설정 조직 레벨에서 강제 제한되지 않는지
최소 권한 원칙 필요한 권한만 최소한으로 부여

정리

GITHUB_TOKEN permission denied는 거의 항상 워크플로우 파일에 permissions 블록을 추가하는 것으로 해결된다. 레포 설정을 "Read and write permissions"로 바꾸는 방법도 있지만, 모든 워크플로우에 넓은 권한을 주는 것이라 권장하지 않는다.

필요한 권한을 최소한으로 명시하는 습관을 들이면 보안도 챙기면서 에러도 예방할 수 있다.

🔍 검색 키워드: Spring Boot @Transactional 작동 안 함, @Transactional self-invocation 에러, Spring AOP 프록시 문제, @Transactional 같은 클래스 호출, Spring 트랜잭션 무시

Spring Boot로 개발하다가 @Transactional을 붙였는데도 롤백이 안 되거나 트랜잭션 자체가 적용이 안 되는 상황을 겪는다. 코드를 아무리 봐도 문제가 없어 보이는데 예외가 발생해도 DB에 그대로 저장된다.

이 문제의 80%는 self-invocation(자기 자신을 직접 호출)이 원인이다.


증상

  • @Transactional을 붙였는데 예외 발생 후에도 DB가 롤백되지 않음
  • 같은 클래스 안에서 @Transactional 메서드를 호출할 때만 이 현상 발생
  • 다른 빈(Bean)에서 호출하면 정상적으로 동작함
  • 로그에 트랜잭션 관련 에러는 없고 조용히 문제가 생김

원인: Spring AOP 프록시 메커니즘

Spring의 @Transactional은 AOP 프록시로 동작한다. 외부에서 빈을 호출하면 프록시가 먼저 받아서 트랜잭션을 시작하고 실제 메서드를 호출한 뒤 트랜잭션을 종료한다.

외부 호출 → [Spring Proxy] → 트랜잭션 시작 → [실제 객체.method()] → 트랜잭션 종료
this.method() → [실제 객체.method()] (프록시 미통과, 트랜잭션 없음)
@Service
public class OrderService {

    public void createOrder(OrderDto dto) {
        // ... 주문 생성 로직
        this.sendNotification(dto); // ❌ self-invocation — 트랜잭션 적용 안 됨
    }

    @Transactional
    public void sendNotification(OrderDto dto) {
        // 이 메서드의 @Transactional은 위에서 직접 호출하면 무시됨
        notificationRepository.save(new Notification(dto));
    }
}

해결방법

방법 1: 별도 빈으로 분리 (가장 권장)

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void createOrder(OrderDto dto) {
        // ... 주문 생성 로직
        notificationService.sendNotification(dto); // ✅ 외부 빈 호출 → 프록시 통과
    }
}

@Service
public class NotificationService {

    @Transactional
    public void sendNotification(OrderDto dto) {
        notificationRepository.save(new Notification(dto));
    }
}

방법 2: Self-Injection

@Service
public class OrderService {

    @Autowired
    @Lazy  // 순환 참조 방지
    private OrderService self;

    public void createOrder(OrderDto dto) {
        self.sendNotification(dto); // ✅ 프록시를 통해 호출
    }

    @Transactional
    public void sendNotification(OrderDto dto) {
        notificationRepository.save(new Notification(dto));
    }
}

방법 3: AopContext.currentProxy() 사용

@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)  // 필수 설정
public class Application { ... }

@Service
public class OrderService {

    public void createOrder(OrderDto dto) {
        ((OrderService) AopContext.currentProxy()).sendNotification(dto);
    }

    @Transactional
    public void sendNotification(OrderDto dto) {
        notificationRepository.save(new Notification(dto));
    }
}

@Transactional의 다른 흔한 함정들

함정 1: private 메서드에 @Transactional

// ❌ private 메서드는 프록시 오버라이딩 불가 — @Transactional 무시됨
@Transactional
private void updateStock(Long productId, int quantity) { ... }

// ✅ public으로 변경
@Transactional
public void updateStock(Long productId, int quantity) { ... }

함정 2: Checked Exception은 기본 롤백 안 됨

// ❌ IOException(Checked Exception)은 기본 롤백 대상이 아님
@Transactional
public void processFile(String path) throws IOException {
    fileRepository.save(new FileRecord(path));
    throw new IOException("파일 처리 실패"); // 롤백 안 됨!
}

// ✅ rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void processFile(String path) throws IOException {
    fileRepository.save(new FileRecord(path));
    throw new IOException("파일 처리 실패"); // 롤백 됨
}

함정 3: try-catch로 예외를 삼켜버리기

// ❌ 예외를 catch하고 아무것도 안 하면 롤백 안 됨
@Transactional
public void saveData(Data data) {
    try {
        repository.save(data);
        externalApiCall();
    } catch (Exception e) {
        log.error("에러 발생", e); // 트랜잭션 매니저가 롤백 대상인지 모름
    }
}

// ✅ 롤백이 필요하면 명시적 처리
@Transactional
public void saveData(Data data) {
    try {
        repository.save(data);
        externalApiCall();
    } catch (Exception e) {
        log.error("에러 발생", e);
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    }
}

점검 체크리스트

항목 확인
self-invocation 여부 같은 클래스 내 직접 호출인지 확인
메서드 접근자 public 메서드인지 확인
예외 타입 Checked Exception이면 rollbackFor 설정
try-catch 위치 예외를 너무 일찍 삼키지 않는지
빈 분리 트랜잭션 경계를 별도 서비스로 분리했는지

정리

@Transactional이 작동 안 한다면 첫 번째로 확인할 것은 호출 방식이다. 같은 클래스 안에서 this.method()로 호출하면 Spring 프록시를 거치지 않아 트랜잭션이 적용되지 않는다.

가장 깔끔한 해결은 트랜잭션이 필요한 로직을 별도 서비스 빈으로 분리하는 것이다. Self-injection이나 AopContext.currentProxy() 방식은 동작은 하지만 코드가 복잡해지므로 리팩토링이 가능하다면 분리를 우선한다.

🔍 검색 키워드: Node.js ENOENT 해결, ENOENT no such file or directory, Node.js 파일 경로 에러, __dirname 경로 에러, fs.readFile ENOENT, Node.js path 모듈

Node.js 개발하다 보면 파일 읽기/쓰기 코드에서 자주 만나는 에러다. 로컬에서는 잘 되는데 서버에 올리면 갑자기 죽는 경우도 이 에러 때문인 경우가 많다.

Error: ENOENT: no such file or directory, open '/app/config/settings.json'
    at Object.openSync (fs.js:476:3)
    at Object.readFileSync (fs.js:377:35)

ENOENT는 Error NO ENTry의 약자다. 운영체제가 해당 경로에서 파일을 찾을 수 없다는 뜻이다.


증상

  • fs.readFile, fs.readFileSync, fs.writeFile 등 파일 시스템 작업에서 에러
  • 로컬에서는 잘 동작하는데 서버/Docker에서는 에러 발생
  • require('./config') 또는 import 구문에서 모듈을 찾지 못하는 에러
  • 상대 경로로 파일을 읽는데 실행 위치에 따라 동작이 달라짐

원인

원인 1: 상대 경로 기준점 오해

Node.js에서 상대 경로는 프로세스를 실행한 디렉토리(CWD)를 기준으로 한다. 파일이 있는 디렉토리가 아니다.

# 프로젝트 구조
/project/
  src/
    utils/
      fileReader.js  ← 여기서 './config.json' 읽으려 함
  config.json

# 이렇게 실행하면
cd /project/src/utils
node fileReader.js
# './config.json'은 /project/src/utils/config.json을 찾음 → 없음 → ENOENT

원인 2: __dirname vs process.cwd() 혼용

// process.cwd() → 프로세스 실행 위치 (실행할 때마다 달라질 수 있음)
// __dirname   → 현재 파일이 있는 디렉토리 (항상 고정)

// ❌ CWD 기준 — 실행 위치에 따라 경로가 달라짐
fs.readFileSync('./config.json');

// ✅ __dirname 기준 — 항상 이 파일 기준으로 경로 계산
fs.readFileSync(path.join(__dirname, 'config.json'));

해결방법

방법 1: __dirname + path.join() 사용 (CJS)

const fs = require('fs');
const path = require('path');

// ✅ 항상 이 파일 위치 기준으로 경로 계산
const configPath = path.join(__dirname, 'config', 'settings.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));

// 상위 디렉토리 올라갈 때
const rootConfigPath = path.join(__dirname, '..', '..', 'config.json');

방법 2: ES Modules에서 __dirname 대체

// ESM에서는 __dirname이 없음 — 이렇게 구현
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync } from 'fs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const config = JSON.parse(
  readFileSync(join(__dirname, 'config.json'), 'utf-8')
);

방법 3: 파일 존재 여부 사전 확인 + 에러 처리

try {
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
  return config;
} catch (err) {
  if (err.code === 'ENOENT') {
    console.error(`파일을 찾을 수 없습니다: ${configPath}`);
    return defaultConfig;
  }
  throw err; // ENOENT가 아닌 다른 에러는 다시 던짐
}

방법 4: 파일 쓰기 전 디렉토리 자동 생성

function writeFileWithDir(filePath, data) {
  const dir = path.dirname(filePath);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
  fs.writeFileSync(filePath, data, 'utf-8');
}

writeFileWithDir(
  path.join(__dirname, 'output', 'reports', '2026-07-01.json'),
  JSON.stringify(data, null, 2)
);

Docker 환경에서의 ENOENT

# ✅ Dockerfile에서 필요한 디렉토리 미리 생성
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN mkdir -p /app/uploads /app/logs /app/tmp
EXPOSE 3000
CMD ["node", "server.js"]
// ✅ 앱 시작 시 디렉토리 보장
const dirs = ['uploads', 'logs', 'tmp'].map(d => path.join(__dirname, d));
dirs.forEach(dir => fs.mkdirSync(dir, { recursive: true }));

디버깅 방법

// 어느 경로를 보고 있는지 출력
console.log('CWD:', process.cwd());
console.log('__dirname:', __dirname);
console.log('찾는 경로:', path.join(__dirname, 'config.json'));
console.log('파일 존재 여부:', fs.existsSync(path.join(__dirname, 'config.json')));

점검 체크리스트

상황 확인 항목
상대 경로 쓸 때 path.join(__dirname, '...') 형식인지
ES Modules import.meta.url__dirname 재구현했는지
Docker 배포 필요한 디렉토리가 이미지 안에 있는지
파일 쓰기 전 상위 디렉토리 존재하는지 확인
에러 처리 err.code === 'ENOENT' 분기 처리했는지
디버깅 시 process.cwd()__dirname 출력해서 확인

정리

ENOENT 에러는 대부분 두 가지 중 하나다. 경로 기준점을 잘못 잡았거나, 파일이 실제로 없는 것이다.

코드 레벨에서는 path.join(__dirname, ...) 패턴을 습관으로 만들면 실행 위치에 무관하게 안정적으로 동작한다. 배포 환경 이슈라면 Docker 이미지에 필요한 디렉토리가 포함됐는지 확인하고, 앱 시작 시 자동으로 디렉토리를 생성하는 코드를 추가하는 게 가장 확실한 방법이다.